import { AppError } from '../lib/errors.js'; import { requireStaffContext } from './actor-context.js'; import { generateReadSignedUrl, uploadToGcs } from './storage.js'; import { query, withTransaction } from './db.js'; import { createVerificationJob, getVerificationJob } from './verification-jobs.js'; function safeName(value) { return `${value}`.replace(/[^a-zA-Z0-9._-]/g, '_'); } function uploadBucket() { return process.env.PRIVATE_BUCKET || 'krow-workforce-dev-private'; } async function uploadActorFile({ actorUid, file, category }) { const bucket = uploadBucket(); const objectPath = `uploads/${actorUid}/${category}/${Date.now()}_${safeName(file.originalname)}`; const fileUri = `gs://${bucket}/${objectPath}`; await uploadToGcs({ bucket, objectPath, contentType: file.mimetype, buffer: file.buffer, }); return { bucket, objectPath, fileUri }; } async function createPreviewUrl(actorUid, fileUri) { try { return await generateReadSignedUrl({ fileUri, actorUid, expiresInSeconds: 900, }); } catch { return { signedUrl: null, expiresAt: null, }; } } function normalizeDocumentStatusFromVerification(status) { switch (`${status || ''}`.toUpperCase()) { case 'AUTO_PASS': case 'APPROVED': return 'VERIFIED'; case 'AUTO_FAIL': case 'REJECTED': return 'REJECTED'; default: return 'PENDING'; } } async function resolveVerificationBackedUpload({ actorUid, verificationId, subjectId, allowedTypes, }) { if (!verificationId) { throw new AppError('VALIDATION_ERROR', 'verificationId is required for finalized upload submission', 400); } const verification = await getVerificationJob(verificationId, actorUid); if (subjectId && verification.subjectId && verification.subjectId !== subjectId) { throw new AppError('VALIDATION_ERROR', 'verificationId does not belong to the requested subject', 400, { verificationId, subjectId, verificationSubjectId: verification.subjectId, }); } if (allowedTypes && allowedTypes.length > 0 && !allowedTypes.includes(verification.type)) { throw new AppError('VALIDATION_ERROR', 'verificationId type does not match the requested upload', 400, { verificationId, verificationType: verification.type, allowedTypes, }); } return { verification, fileUri: verification.fileUri, status: normalizeDocumentStatusFromVerification(verification.status), }; } export async function uploadProfilePhoto({ actorUid, file }) { const context = await requireStaffContext(actorUid); const uploaded = await uploadActorFile({ actorUid, file, category: 'profile-photo', }); await withTransaction(async (client) => { await client.query( ` UPDATE staffs SET metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, updated_at = NOW() WHERE id = $1 `, [context.staff.staffId, JSON.stringify({ profilePhotoUri: uploaded.fileUri })] ); }); const preview = await createPreviewUrl(actorUid, uploaded.fileUri); return { staffId: context.staff.staffId, fileUri: uploaded.fileUri, signedUrl: preview.signedUrl, expiresAt: preview.expiresAt, }; } async function requireDocument(tenantId, documentId, allowedTypes) { const result = await query( ` SELECT id, document_type, name FROM documents WHERE tenant_id = $1 AND id = $2 AND document_type = ANY($3::text[]) `, [tenantId, documentId, allowedTypes] ); if (result.rowCount === 0) { throw new AppError('NOT_FOUND', 'Document not found for requested upload type', 404, { documentId, allowedTypes, }); } return result.rows[0]; } export async function uploadStaffDocument({ actorUid, documentId, file, routeType }) { const context = await requireStaffContext(actorUid); const document = await requireDocument( context.tenant.tenantId, documentId, routeType === 'attire' ? ['ATTIRE'] : ['DOCUMENT', 'GOVERNMENT_ID', 'TAX_FORM'] ); const uploaded = await uploadActorFile({ actorUid, file, category: routeType, }); const verification = await createVerificationJob({ actorUid, payload: { type: routeType === 'attire' ? 'attire' : 'government_id', subjectType: routeType === 'attire' ? 'attire_item' : 'staff_document', subjectId: documentId, fileUri: uploaded.fileUri, metadata: { routeType, documentType: document.document_type, }, rules: { expectedDocumentName: document.name, }, }, }); await withTransaction(async (client) => { await client.query( ` INSERT INTO staff_documents ( tenant_id, staff_id, document_id, file_uri, status, verification_job_id, metadata ) VALUES ($1, $2, $3, $4, 'PENDING', $5, $6::jsonb) ON CONFLICT (staff_id, document_id) DO UPDATE SET file_uri = EXCLUDED.file_uri, status = 'PENDING', verification_job_id = EXCLUDED.verification_job_id, metadata = COALESCE(staff_documents.metadata, '{}'::jsonb) || EXCLUDED.metadata, updated_at = NOW() `, [ context.tenant.tenantId, context.staff.staffId, document.id, uploaded.fileUri, verification.verificationId, JSON.stringify({ verificationStatus: verification.status, routeType, }), ] ); }); const preview = await createPreviewUrl(actorUid, uploaded.fileUri); return { documentId: document.id, documentType: document.document_type, fileUri: uploaded.fileUri, signedUrl: preview.signedUrl, expiresAt: preview.expiresAt, verification, }; } export async function finalizeStaffDocumentUpload({ actorUid, documentId, routeType, verificationId, }) { const context = await requireStaffContext(actorUid); const document = await requireDocument( context.tenant.tenantId, documentId, routeType === 'attire' ? ['ATTIRE'] : ['DOCUMENT', 'GOVERNMENT_ID', 'TAX_FORM'] ); const finalized = await resolveVerificationBackedUpload({ actorUid, verificationId, subjectId: documentId, allowedTypes: routeType === 'attire' ? ['attire'] : ['government_id', 'document', 'tax_form'], }); await withTransaction(async (client) => { await client.query( ` INSERT INTO staff_documents ( tenant_id, staff_id, document_id, file_uri, status, verification_job_id, metadata ) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb) ON CONFLICT (staff_id, document_id) DO UPDATE SET file_uri = EXCLUDED.file_uri, status = EXCLUDED.status, verification_job_id = EXCLUDED.verification_job_id, metadata = COALESCE(staff_documents.metadata, '{}'::jsonb) || EXCLUDED.metadata, updated_at = NOW() `, [ context.tenant.tenantId, context.staff.staffId, document.id, finalized.fileUri, finalized.status, finalized.verification.verificationId, JSON.stringify({ verificationStatus: finalized.verification.status, routeType, finalizedFromVerification: true, }), ] ); }); const preview = await createPreviewUrl(actorUid, finalized.fileUri); return { documentId: document.id, documentType: document.document_type, fileUri: finalized.fileUri, signedUrl: preview.signedUrl, expiresAt: preview.expiresAt, verification: finalized.verification, status: finalized.status, }; } export async function uploadCertificate({ actorUid, file, payload }) { const context = await requireStaffContext(actorUid); const uploaded = await uploadActorFile({ actorUid, file, category: 'certificate', }); const verification = await createVerificationJob({ actorUid, payload: { type: 'certification', subjectType: 'certificate', subjectId: payload.certificateType, fileUri: uploaded.fileUri, metadata: { certificateType: payload.certificateType, name: payload.name, issuer: payload.issuer || null, certificateNumber: payload.certificateNumber || null, }, rules: { certificateType: payload.certificateType, name: payload.name, }, }, }); const certificateResult = await withTransaction(async (client) => { return client.query( ` INSERT INTO certificates ( tenant_id, staff_id, certificate_type, certificate_number, issued_at, expires_at, status, file_uri, verification_job_id, metadata ) VALUES ($1, $2, $3, $4, NOW(), $5, 'PENDING', $6, $7, $8::jsonb) RETURNING id `, [ context.tenant.tenantId, context.staff.staffId, payload.certificateType, payload.certificateNumber || null, payload.expiresAt || null, uploaded.fileUri, verification.verificationId, JSON.stringify({ name: payload.name, issuer: payload.issuer || null, verificationStatus: verification.status, }), ] ); }); const preview = await createPreviewUrl(actorUid, uploaded.fileUri); return { certificateId: certificateResult.rows[0].id, certificateType: payload.certificateType, fileUri: uploaded.fileUri, signedUrl: preview.signedUrl, expiresAt: preview.expiresAt, verification, }; } export async function finalizeCertificateUpload({ actorUid, payload }) { const context = await requireStaffContext(actorUid); const finalized = await resolveVerificationBackedUpload({ actorUid, verificationId: payload.verificationId, subjectId: payload.certificateType, allowedTypes: ['certification'], }); const certificateResult = await withTransaction(async (client) => { const existing = await client.query( ` SELECT id FROM certificates WHERE tenant_id = $1 AND staff_id = $2 AND certificate_type = $3 ORDER BY created_at DESC LIMIT 1 FOR UPDATE `, [context.tenant.tenantId, context.staff.staffId, payload.certificateType] ); const metadata = JSON.stringify({ name: payload.name, issuer: payload.issuer || null, verificationStatus: finalized.verification.status, finalizedFromVerification: true, }); if (existing.rowCount > 0) { return client.query( ` UPDATE certificates SET certificate_number = $2, expires_at = $3, status = $4, file_uri = $5, verification_job_id = $6, metadata = COALESCE(metadata, '{}'::jsonb) || $7::jsonb, updated_at = NOW() WHERE id = $1 RETURNING id `, [ existing.rows[0].id, payload.certificateNumber || null, payload.expiresAt || null, finalized.status, finalized.fileUri, finalized.verification.verificationId, metadata, ] ); } return client.query( ` INSERT INTO certificates ( tenant_id, staff_id, certificate_type, certificate_number, issued_at, expires_at, status, file_uri, verification_job_id, metadata ) VALUES ($1, $2, $3, $4, NOW(), $5, $6, $7, $8, $9::jsonb) RETURNING id `, [ context.tenant.tenantId, context.staff.staffId, payload.certificateType, payload.certificateNumber || null, payload.expiresAt || null, finalized.status, finalized.fileUri, finalized.verification.verificationId, metadata, ] ); }); const preview = await createPreviewUrl(actorUid, finalized.fileUri); return { certificateId: certificateResult.rows[0].id, certificateType: payload.certificateType, fileUri: finalized.fileUri, signedUrl: preview.signedUrl, expiresAt: preview.expiresAt, verification: finalized.verification, status: finalized.status, }; } export async function deleteCertificate({ actorUid, certificateType }) { const context = await requireStaffContext(actorUid); const result = await query( ` DELETE FROM certificates WHERE tenant_id = $1 AND staff_id = $2 AND certificate_type = $3 RETURNING id `, [context.tenant.tenantId, context.staff.staffId, certificateType] ); if (result.rowCount === 0) { throw new AppError('NOT_FOUND', 'Certificate not found for current staff user', 404, { certificateType, }); } return { certificateId: result.rows[0].id, deleted: true, }; }