fix(api): close v2 mobile contract gaps
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user