import db from '../config/database'; import { logger } from '../utils/logger'; import { Certificate, CertificateCreateInput, CertificateUpdateInput, CertificateQueryOptions, CertificateType, CertificateStatus, } from '../models/Certificate'; export interface CertificateDatabaseRecord { id: string; tenantId: string; shopId: string; taskId?: string; traceId: string; businessType: 'TOC' | 'TOB'; certificateName: string; certificateType: CertificateType; certificateNo: string; productId?: string; productName?: string; issuer: string; issueDate: Date; expiryDate: Date; status: CertificateStatus; fileUrl?: string; fileHash?: string; remarks?: string; createdAt: Date; updatedAt: Date; } export interface CertificateBackupData { version: string; exportedAt: Date; tenantId: string; totalRecords: number; certificates: CertificateDatabaseRecord[]; } export interface CertificateMigrationResult { success: boolean; totalMigrated: number; failedRecords: string[]; errors: string[]; traceId: string; } export interface CertificateStatistics { totalCertificates: number; byStatus: Record; byType: Record; expiringWithin30Days: number; expired: number; byPlatform: Record; } export interface BulkImportResult { success: number; failed: number; errors: Array<{ row: number; error: string }>; } export class CertificateDatabaseService { private static readonly TABLE_NAME = 'cf_certificate'; private static readonly BACKUP_TABLE = 'cf_certificate_backup'; private static readonly VERSION = '1.0.0'; static async initTable(): Promise { const hasTable = await db.schema.hasTable(this.TABLE_NAME); if (!hasTable) { logger.info(`[CertificateDatabaseService] Creating ${this.TABLE_NAME} table...`); await db.schema.createTable(this.TABLE_NAME, (table) => { table.string('id', 36).primary(); table.string('tenant_id', 64).notNullable().index(); table.string('shop_id', 64).notNullable().index(); table.string('task_id', 36); table.string('trace_id', 64).notNullable(); table.enum('business_type', ['TOC', 'TOB']).notNullable(); table.string('certificate_name', 255).notNullable(); table.enum('certificate_type', [ 'CE', 'FCC', 'ROHS', 'ISO9001', 'ISO14001', 'FDA', 'UL', 'CCC', 'EXPORT_LICENSE', 'ORIGIN', 'OTHER' ]).notNullable(); table.string('certificate_no', 128).notNullable(); table.string('product_id', 64); table.string('product_name', 255); table.string('issuer', 255).notNullable(); table.date('issue_date').notNullable(); table.date('expiry_date').notNullable().index(); table.enum('status', [ 'PENDING_REVIEW', 'APPROVED', 'REJECTED', 'EXPIRED', 'REVOKED' ]).defaultTo('PENDING_REVIEW').notNullable(); table.string('file_url', 512); table.string('file_hash', 128); table.text('remarks'); table.timestamp('created_at').defaultTo(db.fn.now()); table.timestamp('updated_at').defaultTo(db.fn.now()); table.unique(['tenant_id', 'certificate_no'], 'uk_cert_no'); table.index(['certificate_type'], 'idx_cert_type'); table.index(['status'], 'idx_cert_status'); table.index(['product_id'], 'idx_cert_product'); }); logger.info(`[CertificateDatabaseService] Table ${this.TABLE_NAME} created`); } const hasBackupTable = await db.schema.hasTable(this.BACKUP_TABLE); if (!hasBackupTable) { await db.schema.createTable(this.BACKUP_TABLE, (table) => { table.string('id', 36).primary(); table.string('tenant_id', 64).notNullable().index(); table.json('backup_data').notNullable(); table.string('version', 32).notNullable(); table.timestamp('created_at').defaultTo(db.fn.now()); }); logger.info(`[CertificateDatabaseService] Backup table ${this.BACKUP_TABLE} created`); } } static async insert(record: CertificateDatabaseRecord): Promise { const id = record.id || this.generateId(); await db(this.TABLE_NAME).insert({ id, tenant_id: record.tenantId, shop_id: record.shopId, task_id: record.taskId || null, trace_id: record.traceId, business_type: record.businessType, certificate_name: record.certificateName, certificate_type: record.certificateType, certificate_no: record.certificateNo, product_id: record.productId || null, product_name: record.productName || null, issuer: record.issuer, issue_date: record.issueDate, expiry_date: record.expiryDate, status: record.status, file_url: record.fileUrl || null, file_hash: record.fileHash || null, remarks: record.remarks || null, }); logger.info(`[CertificateDatabaseService] Certificate inserted: id=${id}, tenantId=${record.tenantId}, traceId=${record.traceId}`); return id; } static async update( tenantId: string, id: string, updates: Partial, traceId: string ): Promise { const updateData: Record = { updated_at: new Date(), }; if (updates.certificateName) updateData.certificate_name = updates.certificateName; if (updates.certificateType) updateData.certificate_type = updates.certificateType; if (updates.certificateNo) updateData.certificate_no = updates.certificateNo; if (updates.productId !== undefined) updateData.product_id = updates.productId || null; if (updates.productName !== undefined) updateData.product_name = updates.productName || null; if (updates.issuer) updateData.issuer = updates.issuer; if (updates.issueDate) updateData.issue_date = updates.issueDate; if (updates.expiryDate) updateData.expiry_date = updates.expiryDate; if (updates.status) updateData.status = updates.status; if (updates.fileUrl !== undefined) updateData.file_url = updates.fileUrl || null; if (updates.fileHash !== undefined) updateData.file_hash = updates.fileHash || null; if (updates.remarks !== undefined) updateData.remarks = updates.remarks || null; const result = await db(this.TABLE_NAME) .where({ id, tenant_id: tenantId }) .update(updateData); logger.info(`[CertificateDatabaseService] Certificate updated: id=${id}, tenantId=${tenantId}, traceId=${traceId}`); return result > 0; } static async delete(tenantId: string, id: string, traceId: string): Promise { const result = await db(this.TABLE_NAME) .where({ id, tenant_id: tenantId }) .delete(); logger.info(`[CertificateDatabaseService] Certificate deleted: id=${id}, tenantId=${tenantId}, traceId=${traceId}`); return result > 0; } static async getById(tenantId: string, id: string): Promise { const row = await db(this.TABLE_NAME) .where({ id, tenant_id: tenantId }) .first(); return row ? this.mapRowToRecord(row) : null; } static async query(options: CertificateQueryOptions): Promise { let query = db(this.TABLE_NAME).where('tenant_id', options.tenantId); if (options.shopId) { query = query.andWhere('shop_id', options.shopId); } if (options.certificateType) { query = query.andWhere('certificate_type', options.certificateType); } if (options.status) { query = query.andWhere('status', options.status); } if (options.productId) { query = query.andWhere('product_id', options.productId); } if (options.searchKeyword) { query = query.andWhere(function() { this.where('certificate_name', 'like', `%${options.searchKeyword}%`) .orWhere('certificate_no', 'like', `%${options.searchKeyword}%`) .orWhere('product_name', 'like', `%${options.searchKeyword}%`); }); } if (options.expiryDateFrom) { query = query.andWhere('expiry_date', '>=', options.expiryDateFrom); } if (options.expiryDateTo) { query = query.andWhere('expiry_date', '<=', options.expiryDateTo); } const page = options.page || 1; const pageSize = options.pageSize || 20; const offset = (page - 1) * pageSize; query = query.orderBy('created_at', 'desc').limit(pageSize).offset(offset); const rows = await query; return rows.map(this.mapRowToRecord); } static async count(options: CertificateQueryOptions): Promise { let query = db(this.TABLE_NAME).where('tenant_id', options.tenantId); if (options.shopId) { query = query.andWhere('shop_id', options.shopId); } if (options.certificateType) { query = query.andWhere('certificate_type', options.certificateType); } if (options.status) { query = query.andWhere('status', options.status); } if (options.productId) { query = query.andWhere('product_id', options.productId); } const result = await query.count('* as count').first(); return Number(result?.count || 0); } static async getStatistics(tenantId: string, shopId?: string): Promise { let query = db(this.TABLE_NAME).where('tenant_id', tenantId); if (shopId) { query = query.andWhere('shop_id', shopId); } const records = await query; const stats: CertificateStatistics = { totalCertificates: records.length, byStatus: { PENDING_REVIEW: 0, APPROVED: 0, REJECTED: 0, EXPIRED: 0, REVOKED: 0, }, byType: { CE: 0, FCC: 0, ROHS: 0, ISO9001: 0, ISO14001: 0, FDA: 0, UL: 0, CCC: 0, EXPORT_LICENSE: 0, ORIGIN: 0, OTHER: 0, }, expiringWithin30Days: 0, expired: 0, byPlatform: {}, }; const now = new Date(); const thirtyDaysLater = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); for (const record of records) { stats.byStatus[record.status as CertificateStatus]++; stats.byType[record.certificate_type as CertificateType]++; const expiryDate = new Date(record.expiry_date); if (expiryDate < now) { stats.expired++; } else if (expiryDate <= thirtyDaysLater) { stats.expiringWithin30Days++; } } return stats; } static async backup(tenantId: string, traceId: string): Promise { const records = await db(this.TABLE_NAME).where('tenant_id', tenantId); const backupData: CertificateBackupData = { version: this.VERSION, exportedAt: new Date(), tenantId, totalRecords: records.length, certificates: records.map(this.mapRowToRecord), }; const backupId = this.generateBackupId(); await db(this.BACKUP_TABLE).insert({ id: backupId, tenant_id: tenantId, backup_data: JSON.stringify(backupData), version: this.VERSION, }); logger.info(`[CertificateDatabaseService] Backup created: backupId=${backupId}, tenantId=${tenantId}, records=${records.length}, traceId=${traceId}`); return backupId; } static async restore( tenantId: string, backupId: string, traceId: string ): Promise { const result: CertificateMigrationResult = { success: false, totalMigrated: 0, failedRecords: [], errors: [], traceId, }; try { const backup = await db(this.BACKUP_TABLE) .where({ id: backupId, tenant_id: tenantId }) .first(); if (!backup) { result.errors.push('Backup not found'); return result; } const backupData: CertificateBackupData = JSON.parse(backup.backup_data); for (const cert of backupData.certificates) { try { const existing = await this.getById(tenantId, cert.id); if (existing) { await this.update(tenantId, cert.id, cert, traceId); } else { await this.insert(cert); } result.totalMigrated++; } catch (error: any) { result.failedRecords.push(cert.id); result.errors.push(`Failed to restore certificate ${cert.id}: ${error.message}`); } } result.success = result.failedRecords.length === 0; logger.info(`[CertificateDatabaseService] Restore completed: backupId=${backupId}, migrated=${result.totalMigrated}, traceId=${traceId}`); } catch (error: any) { result.errors.push(`Restore failed: ${error.message}`); } return result; } static async exportToJson(tenantId: string, traceId: string): Promise { const records = await db(this.TABLE_NAME).where('tenant_id', tenantId); const exportData: CertificateBackupData = { version: this.VERSION, exportedAt: new Date(), tenantId, totalRecords: records.length, certificates: records.map(this.mapRowToRecord), }; logger.info(`[CertificateDatabaseService] Export completed: tenantId=${tenantId}, records=${records.length}, traceId=${traceId}`); return exportData; } static async bulkImport( tenantId: string, certificates: CertificateDatabaseRecord[], traceId: string ): Promise { const result: BulkImportResult = { success: 0, failed: 0, errors: [], }; for (let i = 0; i < certificates.length; i++) { const cert = certificates[i]; try { cert.tenantId = tenantId; await this.insert(cert); result.success++; } catch (error: any) { result.failed++; result.errors.push({ row: i + 1, error: error.message }); } } logger.info(`[CertificateDatabaseService] Bulk import completed: success=${result.success}, failed=${result.failed}, traceId=${traceId}`); return result; } static async getExpiringCertificates( tenantId: string, days: number, traceId: string ): Promise { const now = new Date(); const futureDate = new Date(now.getTime() + days * 24 * 60 * 60 * 1000); const records = await db(this.TABLE_NAME) .where('tenant_id', tenantId) .where('status', 'APPROVED') .where('expiry_date', '>=', now) .where('expiry_date', '<=', futureDate) .orderBy('expiry_date', 'asc'); logger.info(`[CertificateDatabaseService] Found ${records.length} expiring certificates within ${days} days, traceId=${traceId}`); return records.map(this.mapRowToRecord); } static async updateExpiredStatus(tenantId: string, traceId: string): Promise { const now = new Date(); const result = await db(this.TABLE_NAME) .where('tenant_id', tenantId) .where('status', 'APPROVED') .where('expiry_date', '<', now) .update({ status: 'EXPIRED', updated_at: now }); logger.info(`[CertificateDatabaseService] Updated ${result} certificates to EXPIRED status, tenantId=${tenantId}, traceId=${traceId}`); return result; } static async getBackups(tenantId: string): Promise> { const backups = await db(this.BACKUP_TABLE) .where('tenant_id', tenantId) .orderBy('created_at', 'desc') .select('id', 'version', 'created_at'); return backups.map(b => ({ id: b.id, version: b.version, createdAt: b.created_at, })); } static async deleteBackup(tenantId: string, backupId: string, traceId: string): Promise { const result = await db(this.BACKUP_TABLE) .where({ id: backupId, tenant_id: tenantId }) .delete(); logger.info(`[CertificateDatabaseService] Backup deleted: backupId=${backupId}, tenantId=${tenantId}, traceId=${traceId}`); return result > 0; } private static generateId(): string { return `cert_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } private static generateBackupId(): string { return `backup_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } private static mapRowToRecord(row: Record): CertificateDatabaseRecord { return { id: row.id, tenantId: row.tenant_id, shopId: row.shop_id, taskId: row.task_id, traceId: row.trace_id, businessType: row.business_type, certificateName: row.certificate_name, certificateType: row.certificate_type, certificateNo: row.certificate_no, productId: row.product_id, productName: row.product_name, issuer: row.issuer, issueDate: row.issue_date, expiryDate: row.expiry_date, status: row.status, fileUrl: row.file_url, fileHash: row.file_hash, remarks: row.remarks, createdAt: row.created_at, updatedAt: row.updated_at, }; } }