261 lines
6.9 KiB
JavaScript
261 lines
6.9 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 } 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,
|
|
};
|
|
}
|
|
}
|
|
|
|
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 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 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,
|
|
};
|
|
}
|