feat(api): add staff order detail and compliance eligibility
This commit is contained in:
@@ -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://'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user