fix(api): close v2 mobile contract gaps

This commit is contained in:
zouantchaw
2026-03-17 22:37:45 +01:00
parent afcd896b47
commit 008dd7efb1
14 changed files with 1315 additions and 54 deletions

View File

@@ -2,7 +2,7 @@ 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';
import { createVerificationJob, getVerificationJob } from './verification-jobs.js';
function safeName(value) {
return `${value}`.replace(/[^a-zA-Z0-9._-]/g, '_');
@@ -40,6 +40,53 @@ async function createPreviewUrl(actorUid, fileUri) {
}
}
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({
@@ -163,6 +210,76 @@ export async function uploadStaffDocument({ actorUid, documentId, file, routeTyp
};
}
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({
@@ -236,6 +353,106 @@ export async function uploadCertificate({ actorUid, file, payload }) {
};
}
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(