feat(api): add staff order detail and compliance eligibility

This commit is contained in:
zouantchaw
2026-03-19 20:17:48 +01:00
parent 4d74fa52ab
commit d2bcb9f3ba
18 changed files with 1051 additions and 42 deletions

View File

@@ -1,7 +1,7 @@
import { z } from 'zod';
export const createVerificationSchema = z.object({
type: z.enum(['attire', 'government_id', 'certification']),
type: z.enum(['attire', 'government_id', 'certification', 'tax_form']),
subjectType: z.string().min(1).max(80).optional(),
subjectId: z.string().min(1).max(120).optional(),
fileUri: z.string().startsWith('gs://', 'fileUri must start with gs://'),

View File

@@ -87,6 +87,70 @@ async function resolveVerificationBackedUpload({
};
}
async function bindVerificationToStaffDocument(client, {
verificationId,
tenantId,
staffId,
document,
routeType,
}) {
await client.query(
`
UPDATE verification_jobs
SET staff_id = $2,
document_id = $3,
subject_type = $4,
subject_id = $5,
metadata = COALESCE(metadata, '{}'::jsonb) || $6::jsonb,
updated_at = NOW()
WHERE id = $1
`,
[
verificationId,
staffId,
document.id,
routeType === 'attire' ? 'attire_item' : 'staff_document',
document.id,
JSON.stringify({
routeType,
documentType: document.document_type,
boundFromFinalize: true,
}),
]
);
}
async function bindVerificationToCertificate(client, {
verificationId,
staffId,
certificateType,
certificateName,
certificateIssuer,
}) {
await client.query(
`
UPDATE verification_jobs
SET staff_id = $2,
subject_type = 'certificate',
subject_id = $3,
metadata = COALESCE(metadata, '{}'::jsonb) || $4::jsonb,
updated_at = NOW()
WHERE id = $1
`,
[
verificationId,
staffId,
certificateType,
JSON.stringify({
certificateType,
name: certificateName || null,
issuer: certificateIssuer || null,
boundFromFinalize: true,
}),
]
);
}
export async function uploadProfilePhoto({ actorUid, file }) {
const context = await requireStaffContext(actorUid);
const uploaded = await uploadActorFile({
@@ -166,6 +230,14 @@ export async function uploadStaffDocument({ actorUid, documentId, file, routeTyp
});
await withTransaction(async (client) => {
await bindVerificationToStaffDocument(client, {
verificationId: finalized.verification.verificationId,
tenantId: context.tenant.tenantId,
staffId: context.staff.staffId,
document,
routeType,
});
await client.query(
`
INSERT INTO staff_documents (
@@ -363,6 +435,14 @@ export async function finalizeCertificateUpload({ actorUid, payload }) {
});
const certificateResult = await withTransaction(async (client) => {
await bindVerificationToCertificate(client, {
verificationId: finalized.verification.verificationId,
staffId: context.staff.staffId,
certificateType: payload.certificateType,
certificateName: payload.name,
certificateIssuer: payload.issuer,
});
const existing = await client.query(
`
SELECT id

View File

@@ -225,6 +225,78 @@ async function appendVerificationEvent(client, {
);
}
function normalizeArtifactStatus(status) {
switch (`${status || ''}`.toUpperCase()) {
case VerificationStatus.AUTO_PASS:
case VerificationStatus.APPROVED:
return 'VERIFIED';
case VerificationStatus.AUTO_FAIL:
case VerificationStatus.REJECTED:
return 'REJECTED';
case VerificationStatus.PENDING:
case VerificationStatus.PROCESSING:
case VerificationStatus.NEEDS_REVIEW:
case VerificationStatus.ERROR:
default:
return 'PENDING';
}
}
function looksLikeUuid(value) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(`${value || ''}`);
}
async function syncVerificationSubjectStatus(client, job) {
const subjectType = job.subject_type || job.subjectType || null;
const subjectId = job.subject_id || job.subjectId || null;
const tenantId = job.tenant_id || job.tenantId || null;
const staffId = job.staff_id || job.staffId || null;
const verificationId = job.id || job.verificationId || null;
if (!subjectType || !subjectId || !tenantId || !staffId || !verificationId) {
return;
}
const nextStatus = normalizeArtifactStatus(job.status);
const metadataPatch = JSON.stringify({
verificationStatus: job.status,
verificationJobId: verificationId,
syncedFromVerification: true,
});
const subjectIdIsUuid = looksLikeUuid(subjectId);
if (subjectType === 'staff_document' || subjectType === 'attire_item' || (subjectType === 'worker' && subjectIdIsUuid)) {
await client.query(
`
UPDATE staff_documents
SET status = $4,
metadata = COALESCE(metadata, '{}'::jsonb) || $5::jsonb,
updated_at = NOW()
WHERE tenant_id = $1
AND staff_id = $2
AND document_id::text = $3
`,
[tenantId, staffId, subjectId, nextStatus, metadataPatch]
);
return;
}
if (subjectType === 'certificate' || (subjectType === 'worker' && !subjectIdIsUuid)) {
await client.query(
`
UPDATE certificates
SET status = $4,
metadata = COALESCE(metadata, '{}'::jsonb) || $5::jsonb,
updated_at = NOW()
WHERE tenant_id = $1
AND staff_id = $2
AND certificate_type = $3
`,
[tenantId, staffId, subjectId, nextStatus, metadataPatch]
);
}
}
async function runAttireChecks(job) {
if (process.env.VERIFICATION_ATTIRE_AUTOPASS === 'true') {
return {
@@ -324,6 +396,13 @@ function getProviderConfig(type) {
token: process.env.VERIFICATION_GOV_ID_PROVIDER_TOKEN,
};
}
if (type === 'tax_form') {
return {
name: 'tax-form-provider',
url: process.env.VERIFICATION_TAX_FORM_PROVIDER_URL,
token: process.env.VERIFICATION_TAX_FORM_PROVIDER_TOKEN,
};
}
return {
name: 'certification-provider',
url: process.env.VERIFICATION_CERT_PROVIDER_URL,
@@ -458,7 +537,7 @@ async function processVerificationJob(verificationId) {
: await runThirdPartyChecks(startedJob, startedJob.type);
await withTransaction(async (client) => {
await client.query(
const updated = await client.query(
`
UPDATE verification_jobs
SET status = $2,
@@ -469,6 +548,7 @@ async function processVerificationJob(verificationId) {
provider_reference = $7,
updated_at = NOW()
WHERE id = $1
RETURNING *
`,
[
verificationId,
@@ -481,6 +561,8 @@ async function processVerificationJob(verificationId) {
]
);
await syncVerificationSubjectStatus(client, updated.rows[0]);
await appendVerificationEvent(client, {
verificationJobId: verificationId,
fromStatus: VerificationStatus.PROCESSING,
@@ -494,7 +576,7 @@ async function processVerificationJob(verificationId) {
});
} catch (error) {
await withTransaction(async (client) => {
await client.query(
const updated = await client.query(
`
UPDATE verification_jobs
SET status = $2,
@@ -503,6 +585,7 @@ async function processVerificationJob(verificationId) {
provider_reference = $4,
updated_at = NOW()
WHERE id = $1
RETURNING *
`,
[
verificationId,
@@ -512,6 +595,8 @@ async function processVerificationJob(verificationId) {
]
);
await syncVerificationSubjectStatus(client, updated.rows[0]);
await appendVerificationEvent(client, {
verificationJobId: verificationId,
fromStatus: VerificationStatus.PROCESSING,
@@ -703,17 +788,20 @@ export async function reviewVerificationJob(verificationId, actorUid, review) {
reasonCode: review.reasonCode || 'MANUAL_REVIEW',
};
await client.query(
const updatedResult = await client.query(
`
UPDATE verification_jobs
SET status = $2,
review = $3::jsonb,
updated_at = NOW()
WHERE id = $1
RETURNING *
`,
[verificationId, review.decision, JSON.stringify(reviewPayload)]
);
await syncVerificationSubjectStatus(client, updatedResult.rows[0]);
await client.query(
`
INSERT INTO verification_reviews (
@@ -800,7 +888,7 @@ export async function retryVerificationJob(verificationId, actorUid) {
});
}
await client.query(
const updatedResult = await client.query(
`
UPDATE verification_jobs
SET status = $2,
@@ -812,10 +900,13 @@ export async function retryVerificationJob(verificationId, actorUid) {
review = '{}'::jsonb,
updated_at = NOW()
WHERE id = $1
RETURNING *
`,
[verificationId, VerificationStatus.PENDING]
);
await syncVerificationSubjectStatus(client, updatedResult.rows[0]);
await appendVerificationEvent(client, {
verificationJobId: verificationId,
fromStatus: job.status,

View File

@@ -349,6 +349,28 @@ test('POST /core/verifications creates async job and GET returns status', async
assert.ok(['NEEDS_REVIEW', 'AUTO_PASS', 'AUTO_FAIL', 'ERROR'].includes(status.body.status));
});
test('POST /core/verifications accepts tax_form verification jobs', async () => {
const app = createApp();
const created = await request(app)
.post('/core/verifications')
.set('Authorization', 'Bearer test-token')
.send({
type: 'tax_form',
subjectType: 'worker',
subjectId: 'document-tax-i9',
fileUri: 'gs://krow-workforce-dev-private/uploads/test-user/i9.pdf',
rules: { formType: 'I-9' },
});
assert.equal(created.status, 202);
assert.equal(created.body.type, 'tax_form');
const status = await waitForMachineStatus(app, created.body.verificationId);
assert.equal(status.status, 200);
assert.equal(status.body.type, 'tax_form');
assert.ok(['NEEDS_REVIEW', 'AUTO_PASS', 'AUTO_FAIL', 'ERROR'].includes(status.body.status));
});
test('POST /core/verifications rejects file paths not owned by actor', async () => {
const app = createApp();
const res = await request(app)