feat(api): complete unified v2 mobile surface
This commit is contained in:
260
backend/core-api/src/services/mobile-upload.js
Normal file
260
backend/core-api/src/services/mobile-upload.js
Normal file
@@ -0,0 +1,260 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user