Saturday, November 15, 2025

Certificate Management Polish: Duplicates, Pagination, and UX Improvements

Paul (Founder)
Development
Developer polishing production software with attention to detail and quality improvements

Production usage reveals edge cases invisible during development. Certificate duplicate tracking through API created confusion when multiple certificates shared identical property, type, and expiry dates. Pagination broke navigating backwards through large certificate lists due to Alpine.js DOM key reuse. Certificate viewing required extra clicks through modals before accessing PDFs. These polish problems didn't prevent functionality yet degraded user experience sufficiently warranting fixes.

Week 46 refined certificate management addressing production-discovered edge cases: implementing rake tasks detecting and cleaning duplicate certificates, fixing Alpine.js pagination rendering bugs, replacing modal workflows with direct document links, enhancing API with certificate deletion endpoints, and unifying pagination patterns across certificate types. This article explores the duplicate detection algorithm, pagination key stability fixes, UX improvements eliminating unnecessary clicks, and database query optimization preventing N+1 problems.

What Your Team Will Notice

Certificate lists now sort by descending expiry date showing newest certificates first, matching natural expectation that recent certificates appear at top. Previously ascending sort showed oldest certificates first requiring scrolling to find current documents—unintuitive for staff primarily interested in unexpired certificates.

Viewing certificates requires one click instead of two: document links open PDFs directly in new browser windows rather than triggering modals showing document lists. For certificates with single documents (the common case), this eliminates unnecessary interaction. Multi-document certificates open first document automatically whilst providing access to additional documents through property detail pages.

Pagination works reliably across all certificate types. Previously EPC certificates used simple Previous/Next buttons whilst Gas Safety, EICR, and HMO License certificates showed numbered pagination creating inconsistent interfaces. Now all certificate types display identical numbered pagination: ← 1 ... 10 [11] 12 ... 25 → with highlighted current page and ellipsis for skipped ranges.

Navigating pagination backwards no longer causes rendering glitches. Previously clicking earlier page numbers sometimes showed two highlighted pages simultaneously or missing page numbers due to Alpine.js reusing DOM elements incorrectly. Fixed key generation ensures each page button maintains stable identity regardless of navigation direction.

Rake tasks provide database maintenance capabilities: rake certificates:find_duplicates identifies certificate groups sharing property, type, and expiry date; rake certificates:cleanup_duplicates removes duplicates automatically keeping newest certificates whilst deleting older duplicates. These tools handle data quality problems emerging through API usage or import processes.

Under the Bonnet: Duplicate Detection

The duplicate detection algorithm groups certificates by key fields:

# lib/tasks/find_duplicate_certificates.rake
namespace :certificates do
  desc 'Find duplicate certificates (same property, type, and expiry date)'
  task find_duplicates: :environment do
    puts 'Searching for duplicate certificates...'
    puts

    # Group certificates by property_id, certificate_type_id, and expiry_date
    # Count groups and filter those with multiple certificates
    duplicates = Certificate
                  .select('property_id, certificate_type_id, expiry_date, COUNT(*) as count')
                  .group('property_id, certificate_type_id, expiry_date')
                  .having('COUNT(*) > 1')
                  .order('COUNT(*) DESC')

    if duplicates.any?
      puts "Found #{duplicates.count} duplicate certificate groups:"
      puts

      total_duplicates = 0

      duplicates.each do |dup|
        # Find all certificates in this duplicate group
        certs = Certificate.where(
          property_id: dup.property_id,
          certificate_type_id: dup.certificate_type_id,
          expiry_date: dup.expiry_date
        ).order(:id)

        property = Property.find(dup.property_id)
        cert_type = CertificateType.find(dup.certificate_type_id)

        puts "Property: #{property.reference} (#{property.display_address})"
        puts "Type: #{cert_type.name}"
        puts "Expiry: #{dup.expiry_date}"
        puts "Count: #{dup.count} certificates"

        certs.each do |cert|
          puts "  - ID: #{cert.id}, Created: #{cert.created_at}, Documents: #{cert.documents.count}"
        end

        puts

        total_duplicates += (dup.count - 1) # Subtract 1 to count extras
      end

      puts "=" * 60
      puts "Total duplicate groups: #{duplicates.count}"
      puts "Total extra certificates: #{total_duplicates}"
      puts
      puts "Run 'rake certificates:cleanup_duplicates' to remove them"
    else
      puts 'No duplicate certificates found ✓'
    end
  end
end

This task identifies duplicates systematically using SQL grouping and aggregation, providing detailed reporting before any destructive operations occur.

Automatic Duplicate Cleanup

The cleanup task removes duplicates safely:

# lib/tasks/cleanup_duplicate_certificates.rake
namespace :certificates do
  desc 'Cleanup duplicate certificates (keeps newest, deletes older)'
  task cleanup_duplicates: :environment do
    puts 'Cleaning up duplicate certificates...'
    puts 'Strategy: Keep certificate with highest ID (newest), delete others'
    puts

    deleted_count = 0
    error_count = 0

    # Find duplicate groups
    duplicates = Certificate
                  .select('property_id, certificate_type_id, expiry_date')
                  .group('property_id, certificate_type_id, expiry_date')
                  .having('COUNT(*) > 1')

    duplicates.each do |dup|
      # Find all certificates in group, ordered by ID (oldest first)
      certs = Certificate.where(
        property_id: dup.property_id,
        certificate_type_id: dup.certificate_type_id,
        expiry_date: dup.expiry_date
      ).order(:id)

      # Keep last (newest) certificate, delete others
      certificates_to_delete = certs[0...-1]

      certificates_to_delete.each do |cert|
        begin
          property = cert.property
          cert_type = cert.certificate_type

          puts "Deleting duplicate: ID #{cert.id}"
          puts "  Property: #{property.reference}"
          puts "  Type: #{cert_type.name}"
          puts "  Expiry: #{cert.expiry_date}"
          puts "  Keeping: ID #{certs.last.id}"

          cert.destroy!
          deleted_count += 1
          puts "  ✓ Deleted"
        rescue StandardError => e
          puts "  ✗ Error: #{e.message}"
          error_count += 1
        end

        puts
      end
    end

    puts "=" * 60
    puts "Deleted #{deleted_count} duplicate certificates"
    puts "Errors: #{error_count}" if error_count > 0
    puts
    puts "Remaining duplicates: #{Certificate.select('property_id, certificate_type_id, expiry_date').group('property_id, certificate_type_id, expiry_date').having('COUNT(*) > 1').count.count}"
  end
end

This cleanup preserves newest certificates (highest ID indicating most recent creation) whilst removing older duplicates, preventing accidental data loss whilst addressing duplicate problems.

Pagination Fixes: Stable Keys for Alpine.js

The pagination bug stemmed from unstable key generation:

<!-- BEFORE (buggy): -->
<template x-for="item in pageNumbers" :key="item">
  <!-- Problem: Multiple ellipsis elements have same key "..."
       Alpine.js reuses DOM elements incorrectly -->
  <button>{{ item }}</button>
</template>

<!-- AFTER (fixed): -->
<template x-for="(item, index) in pageNumbers" :key="item === '...' ? `ellipsis-${index}` : `page-${item}`">
  <!-- Solution: Unique stable keys for all elements
       Ellipsis: "ellipsis-0", "ellipsis-1"
       Pages: "page-1", "page-11", "page-25" -->
  <button>{{ item }}</button>
</template>

The fix ensures each pagination element receives stable unique key preventing DOM reuse problems:

// app/views/certificates/index.html.erb (Alpine.js component)
{
  // ... existing properties ...

  get pageNumbers() {
    const pages = []
    const current = this.currentPage
    const total = this.totalPages

    // Always show first page
    pages.push(1)

    if (current > 3) {
      pages.push('...') // Ellipsis before current range
    }

    // Show pages around current
    for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) {
      pages.push(i)
    }

    if (current < total - 2) {
      pages.push('...') // Ellipsis after current range
    }

    // Always show last page (if more than 1 page)
    if (total > 1) {
      pages.push(total)
    }

    return pages
  },

  goToPage(page) {
    if (page >= 1 && page <= this.totalPages) {
      this.currentPage = page
      this.loadCertificates()
    }
  },

  nextPage() {
    if (this.currentPage < this.totalPages) {
      this.currentPage++
      this.loadCertificates()
    }
  },

  prevPage() {
    if (this.currentPage > 1) {
      this.currentPage--
      this.loadCertificates()
    }
  }
}

This pagination logic generates consistent number ranges with ellipsis, whilst stable keys prevent rendering glitches during navigation.

Unified Pagination Across Certificate Types

EPC certificates previously used different pagination:

<!-- BEFORE (EPC-specific simple pagination): -->
<div class="pagination-simple">
  <button x-show="currentPage > 1" @click="prevPage">Previous</button>
  <span>Page {{ currentPage }} of {{ totalPages }}</span>
  <button x-show="currentPage < totalPages" @click="nextPage">Next</button>
</div>

<!-- AFTER (unified numbered pagination): -->
<nav class="pagination">
  <button @click="prevPage" :disabled="currentPage === 1">←</button>

  <template x-for="(item, index) in pageNumbers" :key="item === '...' ? `ellipsis-${index}` : `page-${item}`">
    <button
      x-show="item !== '...'"
      @click="goToPage(item)"
      :class="{ 'active': item === currentPage }"
      x-text="item">
    </button>
    <span x-show="item === '...'" class="ellipsis">...</span>
  </template>

  <button @click="nextPage" :disabled="currentPage === totalPages">→</button>
</nav>

This unification provides consistent interface across all certificate types improving usability through familiar patterns.

UX Improvements: Direct Document Access

Certificate viewing streamlined removing unnecessary modals:

<!-- BEFORE (two-step process): -->
<a href="#" @click="openCertificateModal(certificate.id)">View</a>
<!-- Opens modal showing document list, then click document link -->

<!-- AFTER (direct access): -->
<% if certificate.documents.any? %>
  <a href="<%= certificate.first_document_url %>"
     target="_blank"
     class="btn-view-certificate">
    View Document
  </a>
<% else %>
  <span class="no-documents">No documents</span>
<% end %>

<% # For multi-document certificates, additional documents accessible on property page %>

The model provides convenient document URL access:

# app/models/certificate.rb
class Certificate < ApplicationRecord
  belongs_to :property
  belongs_to :certificate_type
  has_many_attached :documents

  # ... existing code ...

  def first_document_url
    return nil unless documents.attached?

    Rails.application.routes.url_helpers.rails_blob_url(
      documents.first,
      only_path: false
    )
  end

  def first_document_path
    return nil unless documents.attached?

    Rails.application.routes.url_helpers.rails_blob_path(
      documents.first,
      only_path: true
    )
  end
end

This direct access eliminates unnecessary interaction whilst maintaining access to all documents through property detail pages when needed.

API Enhancements

The certificates API gained deletion capability:

# config/routes.rb
namespace :api do
  namespace :v1 do
    resources :properties, param: :reference, only: [:index, :show] do
      resources :certificates, only: [:index, :create, :destroy]
    end
  end
end

# app/controllers/api/v1/certificates_controller.rb
class Api::V1::CertificatesController < Api::V1::BaseController
  before_action :set_property
  before_action :set_certificate, only: [:destroy]

  def index
    # Fix N+1 query with eager loading
    @certificates = @property.certificates
                             .includes(:certificate_type, documents_attachments: :blob)
                             .order(expiry_date: :desc)

    render json: @certificates.as_json(
      include: {
        certificate_type: { only: [:id, :name] },
        documents: { only: [:id, :filename] }
      },
      methods: [:first_document_url]
    )
  end

  def create
    @certificate = @property.certificates.build(certificate_params)

    if @certificate.save
      render json: @certificate, status: :created
    else
      render json: { errors: @certificate.errors.full_messages },
             status: :unprocessable_entity
    end
  end

  def destroy
    if @certificate.destroy
      render json: { message: 'Certificate deleted successfully' }, status: :ok
    else
      render json: { errors: @certificate.errors.full_messages },
             status: :unprocessable_entity
    end
  end

  private

  def set_property
    @property = Property.find_by!(reference: params[:property_reference])
  end

  def set_certificate
    @certificate = @property.certificates.find(params[:id])
  end

  def certificate_params
    params.require(:certificate).permit(
      :certificate_type_id,
      :issue_date,
      :expiry_date,
      :notes,
      documents: []
    )
  end
end

This API provides complete CRUD operations whilst preventing N+1 queries through eager loading, improving performance when listing certificates.

What's Next

Certificate management could extend further: implementing expiry notifications sending reminders before certificates lapse, adding certificate renewal workflows tracking renewal progress, providing regulatory compliance dashboards showing missing required certificates, and enabling landlord portal access showing certificate status to property owners.

Pagination improvements might include: saving page preferences per user, infinite scroll as alternative to numbered pagination, keyboard navigation (arrow keys for next/previous), and URL parameter persistence maintaining page state through browser back/forward.

Duplicate prevention could be proactive: validating uniqueness during creation preventing duplicates at entry, providing merge functionality combining duplicate certificate documents, and implementing deduplication during API imports automatically handling duplicate submissions.

By addressing production-discovered edge cases through duplicate detection tools, pagination fixes, and UX improvements, LetAdmin demonstrates that polish and attention to detail matter—small improvements in usability, reliability, and data quality compound into significant value for daily users managing thousands of certificates across hundreds of properties.