Files & Uploads — the FileUpload storage subsystem
The whole file subsystem reduces to one idea: Every uploaded file is one
FileUploadrow whoseuripoints at an object in S3. The binary lives in the bucket; the row is the catalogue entry. Other entities never embed bytes — they store theFileUpload._id(or query byrefId) and resolve the row on read.
Source: BE src/modules/upload + library zync-nest-library/upload (UploadService, BucketService, ApUploadUrlScalar) · Admin src/modules/fileUpload
Naming note. This is the file upload subsystem (the
uploadBE module, GraphQL typeFileUpload). It is not a fixed-asset register — that is the separate Assets domain (../domains/assets/fixed-assets-depreciation.md), which merely consumes this subsystem to attachimages: [FileUpload!].
1. Purpose & scope
Responsible for:
- Accepting binary file uploads over GraphQL (multipart
Uploadscalar), streaming them to object storage (S3-compatible bucket), and recording oneFileUploaddocument per stored object. - Serving files back as absolute URLs via the
ApUploadUrlcustom scalar (prefixes the stored bucket key with the public CDN/bucket base URL on serialize). - Deleting both the catalogue row and the underlying object.
- Optional image resizing into
sm/md/lgvariants (viasharp) — exposed by the library but not wired through the zerpuploadFilemutation (nosizespassed; see §9).
Explicitly does NOT:
- Generate pre-signed/time-limited URLs. Objects are uploaded with ACL
public-readand served by a plain public URL. There is no per-request signing (§4, §9). - Enforce file-type or per-file size limits at the application layer beyond the global multipart guard (
maxFiles: 25,maxFileSize: 100 MB) set inmain.ts(§4). - Own the consumer relationships — each domain (KYC, orders, assets, recruitment, training, company) decides how it links to
FileUploadrows (§6, §8).
2. Data model
2.1 FileUpload — the file catalogue (collection file_uploads)
One row per stored object. Soft-deletable; tenant/branch scoped via BaseSchema (companyId, branchId). The binary itself is not in Mongo — only the uri key.
// upload/upload.schema.ts
@ApSchema({ collection: "file_uploads", timestamps: true })
export class FileUpload extends BaseSchema {
@Prop({ unique: true }) ref: string; // generated document number (unique)
@Prop() uri: string; // bucket key — "<bucket>/<baseKey>/<uuid>.<ext>"
@Prop() name: string; // original filename (file.filename)
@Prop() cover: boolean; // flag: is this the cover/primary image?
@Prop() type: string; // MIME type (file.mimetype) e.g. "image/png"
@Prop() module: string; // origin module tag: "upload" | "kyc" | "order" | ...
}
FileUploadSchema.plugin(SoftDelete, { deletedAt: true });| field | type | required | description |
|---|---|---|---|
_id |
ObjectId | auto | primary key — the value other entities store to reference a file |
ref |
string | no | generated unique document number (BaseSchema convention) |
uri |
string | no | stored bucket key, e.g. zerp-bucket/development/3f2a…b1.png. Served through ApUploadUrl (§4.3) |
name |
string | no | original upload filename, for display/download |
cover |
boolean | no | marks the primary/cover image among a set |
type |
string | no | MIME type captured from the multipart part |
module |
string | no | which feature created it (upload, kyc, order, …) — a tag, not a FK |
refId |
string | string[] | no | DTO-only back-pointer to the owning entity. See note below. |
companyId, branchId |
ObjectId | inherited | tenant/branch scope from BaseSchema |
createdBy |
ObjectId | set by service | the uploading user (contextSvc.user._id) |
createdAt, updatedAt |
number | inherited | timestamps (timestamps: true) |
deletedAt |
Date | plugin | soft-delete marker (mongoose-delete); excluded from normal reads |
refIdis on the DTO, not persisted as a schema@Prop.FileUpload(the GraphQLObjectTypeinupload/upload.dto.ts) declaresrefId: string | string[](scalarApStringOrArray), and the repository'sbuildQuery()casts it toObjectId(s)for$infiltering — yet the persistedFileUploadclass (upload.schema.ts) has norefIdprop. In practice consumers that usefindByRefId()(e.g. recruitment attachments) pass the owner's_idasrefIdat upload time via theIFileUpload.refIdfield; it is stored on the spread document but is untyped. TreatrefIdas a soft convention, not a guaranteed indexed column. The robust linking pattern is the owner storingFileUpload._id(KYC, company, order all do this — §6).
There are no enums on this schema. module/type are free-text strings.
2.2 GraphQL shape (upload/upload.dto.ts → schema.gql)
type FileUpload {
_id: String
ref: String
refId: ApStringOrArray
uri: ApUploadUrl # ← custom scalar: serializes the bucket key to an absolute URL
name: String
cover: Boolean
type: String
module: String
companyId: String branchId: String
createdBy: String createdAt: Float updatedAt: Float
canDelete: Boolean
}
scalar Upload # graphql-upload-ts multipart scalar (request side)
scalar ApUploadUrl # zerp custom scalar (response side, §4.3)
input FileUploadInput { file: Upload files: [Upload!] }
input FileUploadPageInput { skip: Float! take: Float! keyword: String }
type FileUploadPageResult { totalRecords: Float! data: [FileUpload!]! }3. API surface
| Operation | Type | Input | Returns | Auth |
|---|---|---|---|---|
uploadFile |
Mutation | refId: String!, file: FileUploadInput! |
[FileUpload!]! |
@ApGqlAuthorize() (class-level) |
deleteFile |
Mutation | _id: String! |
Boolean! |
class-level auth |
fileUploadPage |
Query | page: FileUploadPageInput! |
FileUploadPageResult! |
class-level auth |
Resolver: upload/upload.resolver.ts — FileUploadResolver extends ApBaseResolver<FileUpload>, guarded by @ApGqlAuthorize() and audited via @AuditMeta({ module:'upload', collection:'file_uploads' }) (snapshots on CREATE and DELETE → audit trail).
@Mutation((returns) => [FileUpload], { name: "uploadFile" })
public async upload(@GqlCurrentUser() user, @Args("refId") refId, @Args("file") file: FileUploadInput) {
const fl = await file.file; // resolve the multipart promise
if (fl) return this.fileUploadSvc.upload({ // → §4.1
files: [fl], type: "stream", module: "upload", refId,
});
throw new BadRequestException("No file found in the request");
}The
uploadFilemutation accepts only a singlefile(it readsfile.file, ignoresfile.files). The pluralfiles: [Upload!]exists on the input but the resolver does not iterate it. Multi-file uploads in zerp go through domain-specific mutations (uploadSalesReceipt,uploadPurchaseReceipt— §6.2) that pass arrays straight tofileUploadSvc.upload({ files }).
REST: FileUploadController (@Controller("api/file-uploads")) exists but is empty — no routes. All file traffic is GraphQL. Serving of the bytes is the bucket's own HTTP endpoint, not a NestJS route.
4. Upload & storage mechanics
The flow has three layers: zerp module (FileUploadService) → library (UploadService) → bucket (BucketService, the actual S3 client).
4.1 FileUploadService.upload() — zerp orchestration (upload/upload.service.ts)
public async upload(upload: IFileUpload): Promise<FileUpload[]> {
const uploads = await this.uploadSvc.saveFiles(upload.files, upload.sizes, upload.type); // → S3
const savedFiles: FileUpload[] = [];
for await (const up of uploads) {
savedFiles.push(await this.fileUploadRepo.create({
...upload, ...up, // up = { name, type, uri } from S3
createdBy: this.contextSvc?.user?._id,
}));
}
return savedFiles; // one FileUpload row per stored object
}IFileUpload (upload/upload.interface.ts): { files, sizes?, type: "stream"|"base64", module, refId? }. Storage happens first, then the row is written with the returned uri. (No transaction wraps the two; an orphaned object is possible if the Mongo write fails after the S3 put — §9.)
4.2 UploadService.saveFiles() — library, MIME + resize (zync-nest-library/upload/upload.service.ts)
async saveFiles(files, sizes?, type = "base64"): Promise<IUploadResult[]> {
for await (let file of files) {
const mapped = await this._mapFile(file, type); // stream: await { createReadStream, filename, mimetype }
const uri = await this._saveFile(mapped, type);// → bucketSvc.uploadStream | uploadBase64
const fl = { name: file.filename, type: file.mimetype, uri };
if (sizes?.length) { // OPTIONAL image variants (not used by zerp uploadFile)
// resize via sharp to SIZE_MAP[size] and store each → fl.smUri / fl.mdUri / fl.lgUri
}
}
}
const SIZE_MAP = { sm: 433, md: 640, lg: 800 }; // longest-edge px, sharp fit:"inside"type: "stream"(what zerp uses): awaits thegraphql-uploadpromise, takescreateReadStream(), pipes the stream to S3.type: "base64": decodes a data-URI string to a Buffer first.- Resize uses
sharp(...).resize({ height, width, fit: inside, withoutEnlargement: true })and stores each variant under a size-named sub-dir (433/,640/,800/). Only triggered whensizesis passed — which the zerpuploadFilepath never does.
4.3 BucketService — the S3 client (zync-nest-library/upload/bucket/bucket.service.ts)
S3-compatible storage via aws-sdk v2. Config comes from the bucket config namespace (bucket.config.ts), populated from env:
new AWS.S3({
endpoint: config.endpoint, // aws_s3_endpoint
sslEnabled: false, // ← plain HTTP to the endpoint
s3ForcePathStyle: true, // path-style (endpoint/bucket/key) — DigitalOcean/MinIO style
credentials: { accessKeyId, secretAccessKey },
region: config.region,
params: { ACL: config.acl, Bucket: config.bucket }, // ACL = "public-read"
});| env var | maps to | role |
|---|---|---|
aws_s3_endpoint |
endpoint |
S3-compatible endpoint host |
aws_access_key_id |
accessKeyId |
credentials |
aws_secret_access_key |
accessSecrete |
credentials |
aws_s3_region |
region |
region |
aws_bucket |
bucket |
bucket name |
aws_base_key |
baseKey |
path prefix prepended to every key (e.g. development) |
aws_s3_url |
— | public base URL used by ApUploadUrl serialize (§4.4) |
(acl) |
"public-read" |
hard-coded — objects are world-readable |
Upload (_processUpload) — the only writer:
const response = await this.s3.upload({
Body: upload.file,
Key: dir ? `${baseKey}/${dir}/${upload.filename}` : `${baseKey}/${upload.filename}`,
ContentType: upload.filetype,
}).promise();
return `${response.Bucket}/${response.Key}`; // stored as FileUpload.uri- Filename is rewritten to a UUID before storage (
uuidFilenameTransform(filename)→<uuidv4><ext>) unlessdisableTransformNameis set. Original name is preserved separately inFileUpload.name. This prevents collisions and path traversal. - Stored
uri="<bucket>/<baseKey>/<uuid>.<ext>"(path-style, includes the bucket name).
Delete (deleteFile(uri)) — keys off the last path segment of the stored uri and rebuilds ${baseKey}/${lastSegment}, then s3.deleteObject. (Note: it does not await the .promise(), and ignores dir sub-folders — size variants and any dir-nested objects are not removed; §9.)
4.4 Serving — ApUploadUrlScalar (zync-nest-library/upload/upload.scalar.ts)
The uri field is typed ApUploadUrl. On serialize (response), the stored bucket key is turned into an absolute URL; values already absolute are passed through:
serialize(value) {
return value?.includes("http") ? value : `${process.env.aws_s3_url}/${value}`;
}So a stored uri of zerp-bucket/development/abc.png is returned to the client as <aws_s3_url>/zerp-bucket/development/abc.png. No signing, no expiry — the URL is permanent and public (matches the public-read ACL). This is the entire "serving" mechanism; the browser fetches the object directly from the bucket/CDN.
4.5 Request-side multipart limits (src/main.ts)
app.use(graphqlUploadExpress({ maxFiles: 25, maxFileSize: 100 * 1024 * 1024, maxFieldSize: 100 * 1024 * 1024 }));Global guard via graphql-upload-ts: max 25 files / 100 MB per file / 100 MB per field per request. There is no per-MIME-type allow-list in code — any file type is accepted (type is recorded, not validated). See architecture for where this sits in bootstrap.
5. Permissions
FileUploadResolveris class-decorated@ApGqlAuthorize()→ a valid JWT is required for all three operations (see auth). There is no dedicated permission module/action gate (@ApGqlAuthorize()is used without a module/action arg, so it's authentication-only, not RBAC).- Tenant scope is enforced implicitly: every row carries
companyId/branchIdfromBaseSchema, and reads go through the repository which scopes by tenant context (see permissions-access and multi-tenancy). - Consumer mutations (
uploadSalesReceipt,updateKycDoc, etc.) carry their own module's@AuditMetaand whatever authorization their resolver declares — file access rides on the owning domain's permissions.
6. How other entities reference files
There are three linking conventions in zerp. All store IDs, never bytes.
6.1 Single-file FK (<thing>Id → resolve to FileUpload)
The owning document stores a FileUpload._id; a @ResolveField hydrates it on read.
- Company —
logoId,letterHeadHeaderId,letterHeadFooterId(company/company.resolver.ts):logo()→fileUploadSvc.findById(args.logoId). - KYC —
idFrontId,idBackId,selfieId(kyc/kyc.resolver.ts). TheupdateKycDocmutation uploads each provided image withmodule: "kyc", stores the returned_id, and rolls back (deletes) the uploaded files if the KYC create fails — the closest thing to transactional upload in the codebase. See../domains/crm/kyc.md.
// kyc.resolver.ts — upload-then-link, with compensating delete on failure
const idFront = kyc.idFront ? (await this.fileUploadSvc.upload({ files:[kyc.idFront], type:"stream", module:"kyc" }))[0] : null;
if (idFront?._id) (kyc as any).idFrontId = idFront._id;
return this.KYCSvc.create({ ...kyc }).catch(err => { if (idFront) this.fileUploadSvc.delete(idFront._id) /* ...others */ });6.2 Array of FKs (receiptIds: [String] → receipts: [FileUpload])
The owner stores an array of FileUpload._id; a resolve-field maps each to a row.
- Orders / Sales / Purchase / Finance transactions / Assets all expose
receipts: [FileUpload!](orders/transactions) orimages: [FileUpload!](assets). - Upload entry points:
uploadSalesReceipt/uploadPurchaseReceipt(OrderReceiptInput { salesId, files: [Upload] }) →fileUploadSvc.upload({ files, type:"stream", module:"order" })→ storereceiptIds = files.map(f => f._id)on the order.
// purchase.resolver.ts → uploadPurchaseReceipt
const files = await this.fileUploadSvc.upload({ files: receipt.files, type: "stream", module: "order" });
await this.purchaseSvc.update(receipt.salesId, { receiptIds: files.map(f => f._id) });
// resolve-field: receipts() → Promise.all(args.receiptIds.map(r => fileUploadSvc.findById(r)))Consumers: ../domains/inventory/purchases.md, ../domains/inventory/sales.md, ../domains/sales-pos/order-flows.md, ../domains/finance/transaction.md, ../domains/assets/fixed-assets-depreciation.md.
6.3 Reverse lookup by refId (findByRefId)
The owner stores nothing; files carry the owner's id as refId, and a resolve-field queries fileUploadSvc.findByRefId(owner._id).
- Recruitment job applicant —
attachments: [FileUpload!]resolves viathis.fileUploadSvc.findByRefId(applicant._id)(recruitment/job-applicant/job-applicant.resolver.ts). See../domains/recruitment/recruitment.md.
6.4 Server-generated files (no client upload)
- HR training certificate —
TrainingCertificateService.issuePdfCertificate()renders a PDF withpdfkitinto a Buffer, then callsuploadSvc.upload.uploadStream({ file: pdfBuffer, ... })directly on the bucket (bypassingFileUploadService/FileUploadrows) and stores the returned uri string inTrainingCertificate.certificateUrl. So a training cert URL is a raw bucket key string, not aFileUploadreference. See../domains/hr/training.md.
7. Admin UI
Admin module src/modules/fileUpload is a thin, headless wrapper (no page of its own — it's a shared context other feature screens consume).
context.tsx—UploadFileContextProviderexposinguseUploadFileState()with:uploadFile(refId, file)→useUploadFileQuery().uploadFile({ variables: { refId, file: { files: file } } })deleteFile(id)→deleteFile({ variables: { id } })loading,filesstate. (Follows the zync-nextjs context-owns-state rule: components calluseUploadFileState(), never the gql directly.)
gql/query.ts—UPLOAD_FILE(uploadFile(refId, file)) andDELETE_FILE(deleteFile(_id)) mutations;FileUploadFragmentselects_id ref key refId uri name cover type module createdBy createdAt updatedAt canDelete.model.ts—IFileUpload,IFileUploadInput { file?, files? },IFileUploadQuery.
Known bug in admin context:
uploadFile()readsres?.data?.updateAsset— a leftover from an asset screen — instead ofres?.data?.uploadFile. As written it will not surface the returned rows for generic use; feature screens that need the result generally call the mutation through their own module. Flag when porting (§9).
Image rendering uses the absolute uri returned by ApUploadUrl directly in <img>/avatar components — no client-side signing.
8. Dependencies & integrations
zync-nest-library→ApUploadModuleprovidesUploadService+ApUploadUrlScalar; under it,BucketModuleprovidesBucketService(the S3 client).FileUploadModuleimportsApUploadModuleandAuthModule, registers theFileUploadMongoose model, and exportsFileUploadServiceso every consumer module can inject it (upload/upload.module.ts).- External service: S3-compatible object storage (
aws-sdkv2, path-style, plain HTTP, public-read ACL). Sanity-checked by the repo-roots3-test.js(s3.putObjectsmoke test using the same env vars). sharp— image resize (variant generation; library-side, optional).graphql-upload-ts— multipartUploadscalar +graphqlUploadExpressmiddleware.pdfkit— server-side PDF generation for training certificates (§6.4).- Consumers (inject
FileUploadService): company, KYC, recruitment/job-applicant, inventory order/sales/purchase, inventory item, finance transaction/payment, assets, workflow event/task, user. - Audit:
uploadFile/deleteFilesnapshot to the audit trail. - No cron/jobs and no orphan-GC process exist for the bucket.
9. Gotchas & project-specific rules
- Public URLs, no signing/expiry. Objects are
public-read;ApUploadUrljust prefixesaws_s3_url. Anyone with the URL can fetch the file forever. There is no access check on serve and no pre-signed-URL path. If a private/expiring scheme is needed it must be added. sslEnabled: falseon the S3 client — traffic to the endpoint is plain HTTP. (Public reads viaaws_s3_urlmay still be HTTPS depending on that URL.)- Upload is not transactional with the row write.
saveFiles()(S3 put) runs beforerepo.create(). A failed Mongo write leaves an orphaned object in the bucket (no GC). KYC is the only flow with a compensating delete on the consuming document's failure (§6.1). deleteFileis fire-and-forget and incomplete.BucketService.deleteFiledoes notawaitthedeleteObject().promise(), derives the key from the last path segment only, and ignoresdirsub-folders — so resized variants (433/…, etc.) and any nested objects are never deleted.FileUploadService.deleteswallows errors (console.error) and still soft-deletes the row.refIdis not a persisted schema prop (§2.1).findByRefIdrelies on it being spread onto the document at upload time. The reliable link patterns are owner-stores-_id(§6.1/§6.2).- Single-vs-multi mismatch.
uploadFileignores thefilesarray and only handles one file; multi-file uploads use the domain receipt mutations. - No MIME/type allow-list — only the global size/count guard (
maxFiles 25,100 MB) applies. - Training certs bypass the catalogue — they store a raw bucket-key string on
TrainingCertificate.certificateUrl, not aFileUpload._id(§6.4). - Admin context bug —
uploadFile()readsres.data.updateAssetinstead ofres.data.uploadFile(§7). - Filename is UUID-rewritten in storage; the human name lives only in
FileUpload.name. Don't rely on the bucket key to recover the original filename.