Add OTP-first signup and product catalog PDF uploads.

Signup verifies phone before password; existing Meshkee accounts keep their password. Products can attach an optional PDF catalog (max 2MB) via Pars Pack media.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-27 08:57:08 +03:30
co-authored by Cursor
parent 4a9e10e614
commit 1a49576752
11 changed files with 699 additions and 43 deletions
+66 -1
View File
@@ -26,6 +26,11 @@ const IMAGE_MIME_TYPES = new Set([
const VIDEO_MIME_TYPES = new Set(['video/mp4', 'video/webm']);
const PDF_MIME_TYPES = new Set(['application/pdf']);
/** Product catalog PDFs are capped lower than general media uploads. */
const PDF_MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024;
@Injectable()
export class MediaService {
private readonly maxFileSizeBytes: number;
@@ -375,6 +380,14 @@ export class MediaService {
throw new BadRequestException('Uploaded file is empty');
}
const isPdf =
PDF_MIME_TYPES.has(file.mimetype) ||
file.originalname.toLowerCase().endsWith('.pdf');
if (isPdf) {
return this.uploadPdf(businessId, file, uploadedBy);
}
if (file.size > this.maxFileSizeBytes) {
throw new BadRequestException(
`File ${file.originalname} exceeds the maximum allowed size`,
@@ -409,7 +422,7 @@ export class MediaService {
}
throw new BadRequestException(
`Unsupported file type: ${file.mimetype || 'unknown'}. Use JPEG, PNG, WebP, or GIF.`,
`Unsupported file type: ${file.mimetype || 'unknown'}. Use JPEG, PNG, WebP, GIF, or PDF.`,
);
}
@@ -443,6 +456,54 @@ export class MediaService {
return this.serialize(created);
}
private async uploadPdf(
businessId: bigint,
file: Express.Multer.File,
uploadedBy: bigint,
) {
if (file.size > PDF_MAX_FILE_SIZE_BYTES) {
throw new BadRequestException(
`PDF ${file.originalname} exceeds the maximum allowed size of 2MB`,
);
}
const header = file.buffer.subarray(0, 5).toString('utf8');
if (!header.startsWith('%PDF-')) {
throw new BadRequestException(
`File ${file.originalname} is not a valid PDF`,
);
}
const fileName = `${randomUUID()}.pdf`;
const storageKey = businessMediaKey(businessId, fileName);
const contentType = 'application/pdf';
const stored = await this.storage.upload({
key: storageKey,
body: file.buffer,
contentType,
});
const created = await this.prisma.media.create({
data: {
businessId,
uploadedBy,
mediaType: MediaType.document,
storageDisk: stored.storageDisk,
storagePath: stored.storagePath,
publicUrl: stored.publicUrl,
fileName,
originalFileName: file.originalname,
mimeType: contentType,
fileSizeBytes: BigInt(file.size),
width: null,
height: null,
},
});
return this.serialize(created);
}
private contentTypeFromFormat(format?: string) {
switch (format) {
case 'jpeg':
@@ -469,6 +530,8 @@ export class MediaService {
return '.webp';
case 'image/gif':
return '.gif';
case 'application/pdf':
return '.pdf';
default:
return '.jpg';
}
@@ -493,6 +556,8 @@ export class MediaService {
return '.mp4';
case 'video/webm':
return '.webm';
case 'application/pdf':
return '.pdf';
default:
return '';
}