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 } 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, }; } } 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 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 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, }; }