diff --git a/@packages/@core/types/src/api/merch-submission.types.ts b/@packages/@core/types/src/api/merch-submission.types.ts new file mode 100644 index 000000000..0313297d1 --- /dev/null +++ b/@packages/@core/types/src/api/merch-submission.types.ts @@ -0,0 +1,338 @@ +/** + * Merch Idea Submission Types + * + * Used for the merch idea submission feature where users can submit + * product ideas with reference images for admin review. + */ + +// ============================================================================ +// Constants +// ============================================================================ + +/** Allowed MIME types for image uploads */ +export const ALLOWED_IMAGE_MIME_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/webp', +] as const + +export type AllowedImageMimeType = typeof ALLOWED_IMAGE_MIME_TYPES[number] + +/** Maximum file size in bytes (5MB) */ +export const MAX_IMAGE_FILE_SIZE_BYTES = 5 * 1024 * 1024 + +/** Maximum number of images per submission */ +export const MAX_IMAGES_PER_SUBMISSION = 5 + +/** Magic bytes for file type validation */ +export const IMAGE_MAGIC_BYTES: Record = { + 'image/jpeg': [ + [0xFF, 0xD8, 0xFF, 0xE0], // JFIF + [0xFF, 0xD8, 0xFF, 0xE1], // EXIF + [0xFF, 0xD8, 0xFF, 0xE8], // SPIFF + [0xFF, 0xD8, 0xFF, 0xDB], // Raw JPEG + ], + 'image/png': [[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]], + 'image/webp': [[0x52, 0x49, 0x46, 0x46]], // RIFF header (check WEBP at offset 8) +} + +// ============================================================================ +// Enums +// ============================================================================ + +/** Status of a merch submission in the review workflow */ +export enum MerchSubmissionStatus { + /** Submission created but not yet finalized (images may still be uploading) */ + DRAFT = 'draft', + /** Submission complete and awaiting admin review */ + PENDING = 'pending', + /** Admin is actively reviewing this submission */ + UNDER_REVIEW = 'under_review', + /** Submission approved for potential production */ + APPROVED = 'approved', + /** Submission rejected */ + REJECTED = 'rejected', + /** Approved idea has been implemented as a product */ + IMPLEMENTED = 'implemented', +} + +/** Security scan status for uploaded images */ +export enum ImageSecurityStatus { + /** Scan not yet performed */ + PENDING = 'pending', + /** Image passed security checks */ + CLEAN = 'clean', + /** Image flagged for manual review */ + FLAGGED = 'flagged', + /** Image failed security checks and was rejected */ + REJECTED = 'rejected', +} + +// ============================================================================ +// Core Interfaces +// ============================================================================ + +/** + * An image attached to a merch submission + */ +export interface MerchSubmissionImage { + id: string + submissionId: string + + // Storage + storageKey: string + thumbnailKey?: string + + // Original file info + originalFilename: string + mimeType: AllowedImageMimeType + fileSizeBytes: number + + // Processed metadata (populated after re-encoding) + width?: number + height?: number + + // Security + securityStatus: ImageSecurityStatus + processedAt?: string // ISO 8601 + + // Timestamps + uploadedAt: string // ISO 8601 +} + +/** + * A merch idea submission + */ +export interface MerchSubmission { + id: string + + // Submitter info (optional) + submitterName?: string + submitterEmail?: string + + // Content + description: string + images: MerchSubmissionImage[] + + // Workflow + status: MerchSubmissionStatus + + // Admin review + adminNotes?: string + reviewedBy?: string // Admin user ID + reviewedAt?: string // ISO 8601 + + // Timestamps + submittedAt: string // ISO 8601 + updatedAt: string // ISO 8601 +} + +// ============================================================================ +// Request DTOs +// ============================================================================ + +/** + * DTO for creating a new merch submission + * Returns presigned URLs for image uploads + */ +export interface CreateMerchSubmissionDto { + name?: string + email?: string + description: string + /** Number of images to upload (1-5) */ + imageCount: number +} + +/** + * DTO for confirming an image upload + */ +export interface ConfirmImageUploadDto { + submissionId: string + imageId: string +} + +/** + * DTO for finalizing a submission (marks it ready for review) + */ +export interface FinalizeMerchSubmissionDto { + submissionId: string +} + +/** + * DTO for admin updating submission status + */ +export interface UpdateMerchSubmissionStatusDto { + status: MerchSubmissionStatus + adminNotes?: string +} + +/** + * Query parameters for listing merch submissions (admin) + */ +export interface ListMerchSubmissionsQueryDto { + status?: MerchSubmissionStatus + page?: number + limit?: number + sortBy?: 'submittedAt' | 'updatedAt' | 'status' + sortOrder?: 'asc' | 'desc' +} + +// ============================================================================ +// Response DTOs +// ============================================================================ + +/** + * Presigned URL for uploading an image + */ +export interface ImageUploadUrl { + imageId: string + presignedUrl: string + expiresAt: string // ISO 8601 +} + +/** + * Response when creating a new submission + */ +export interface CreateMerchSubmissionResponseDto { + submissionId: string + uploadUrls: ImageUploadUrl[] +} + +/** + * Response DTO for a merch submission image + */ +export interface MerchSubmissionImageResponseDto { + id: string + originalFilename: string + mimeType: string + fileSizeBytes: number + width?: number + height?: number + thumbnailUrl?: string + fullUrl?: string + securityStatus: ImageSecurityStatus + uploadedAt: string +} + +/** + * Response DTO for a merch submission + */ +export interface MerchSubmissionResponseDto { + id: string + submitterName?: string + submitterEmail?: string + description: string + images: MerchSubmissionImageResponseDto[] + status: MerchSubmissionStatus + adminNotes?: string + reviewedBy?: string + reviewedAt?: string + submittedAt: string + updatedAt: string +} + +/** + * Paginated list of merch submissions + */ +export interface MerchSubmissionsListResponseDto { + data: MerchSubmissionResponseDto[] + meta: { + total: number + page: number + limit: number + totalPages: number + } +} + +/** + * Stats for admin dashboard + */ +export interface MerchSubmissionStatsDto { + total: number + byStatus: Record + last24Hours: number + last7Days: number +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Check if a MIME type is allowed for image uploads + */ +export function isAllowedImageMimeType(mimeType: string): mimeType is AllowedImageMimeType { + return ALLOWED_IMAGE_MIME_TYPES.includes(mimeType as AllowedImageMimeType) +} + +/** + * Check if a file size is within the allowed limit + */ +export function isValidImageFileSize(sizeBytes: number): boolean { + return sizeBytes > 0 && sizeBytes <= MAX_IMAGE_FILE_SIZE_BYTES +} + +/** + * Validate magic bytes match the claimed MIME type + */ +export function validateMagicBytes( + bytes: Uint8Array, + claimedMimeType: AllowedImageMimeType +): boolean { + const signatures = IMAGE_MAGIC_BYTES[claimedMimeType] + if (!signatures) return false + + return signatures.some((sig) => + sig.every((byte, i) => bytes[i] === byte) + ) +} + +/** + * Detect MIME type from magic bytes + */ +export function detectMimeTypeFromBytes(bytes: Uint8Array): AllowedImageMimeType | null { + for (const [mimeType, signatures] of Object.entries(IMAGE_MAGIC_BYTES)) { + const matches = (signatures as number[][]).some((sig) => + sig.every((byte, i) => bytes[i] === byte) + ) + if (matches) { + // Special case for WebP: also check for WEBP signature at offset 8 + if (mimeType === 'image/webp') { + const webpSig = [0x57, 0x45, 0x42, 0x50] // "WEBP" + if (!webpSig.every((byte, i) => bytes[8 + i] === byte)) { + continue + } + } + return mimeType as AllowedImageMimeType + } + } + return null +} + +/** + * Format file size as human-readable string + */ +export function formatImageFileSize(sizeBytes: number): string { + if (sizeBytes < 1024) { + return `${sizeBytes} B` + } + if (sizeBytes < 1024 * 1024) { + return `${(sizeBytes / 1024).toFixed(1)} KB` + } + return `${(sizeBytes / (1024 * 1024)).toFixed(2)} MB` +} + +/** + * Get human-readable status label + */ +export function getSubmissionStatusLabel(status: MerchSubmissionStatus): string { + const labels: Record = { + [MerchSubmissionStatus.DRAFT]: 'Draft', + [MerchSubmissionStatus.PENDING]: 'Pending Review', + [MerchSubmissionStatus.UNDER_REVIEW]: 'Under Review', + [MerchSubmissionStatus.APPROVED]: 'Approved', + [MerchSubmissionStatus.REJECTED]: 'Rejected', + [MerchSubmissionStatus.IMPLEMENTED]: 'Implemented', + } + return labels[status] +}