Files
Krow-workspace/backend/core-api/src/services/mobile-upload.js
2026-03-17 22:37:45 +01:00

478 lines
13 KiB
JavaScript

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, getVerificationJob } 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,
};
}
}
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({
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 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({
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 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(
`
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,
};
}