From f907bde5705e4df947751235736e8ed770f26fc0 Mon Sep 17 00:00:00 2001 From: Quinn Ftw Date: Sun, 28 Dec 2025 17:49:11 -0800 Subject: [PATCH] feat(landing): add backend service with merch submissions API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add NestJS backend for landing page with: - Image processing service for uploads - Merch submissions CRUD with admin workflows - Email notification service - S3-compatible storage integration - Health check endpoint 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- features/landing/backend/package.json | 50 +++ features/landing/backend/src/app.module.ts | 47 +++ .../landing/backend/src/health.controller.ts | 26 ++ .../image-processing.module.ts | 16 + .../image-processing.service.ts | 193 +++++++++++ features/landing/backend/src/main.ts | 51 +++ .../dto/create-submission.dto.ts | 56 ++++ .../dto/update-submission-status.dto.ts | 50 +++ .../entities/merch-submission-image.entity.ts | 67 ++++ .../entities/merch-submission.entity.ts | 64 ++++ .../merch-submissions.controller.ts | 114 +++++++ .../merch-submissions.module.ts | 23 ++ .../merch-submissions.service.ts | 313 ++++++++++++++++++ .../src/notifications/email.service.ts | 220 ++++++++++++ .../src/notifications/notifications.module.ts | 8 + .../backend/src/storage/storage.module.ts | 8 + .../backend/src/storage/storage.service.ts | 155 +++++++++ 17 files changed, 1461 insertions(+) create mode 100644 features/landing/backend/package.json create mode 100644 features/landing/backend/src/app.module.ts create mode 100644 features/landing/backend/src/health.controller.ts create mode 100644 features/landing/backend/src/image-processing/image-processing.module.ts create mode 100644 features/landing/backend/src/image-processing/image-processing.service.ts create mode 100644 features/landing/backend/src/main.ts create mode 100644 features/landing/backend/src/merch-submissions/dto/create-submission.dto.ts create mode 100644 features/landing/backend/src/merch-submissions/dto/update-submission-status.dto.ts create mode 100644 features/landing/backend/src/merch-submissions/entities/merch-submission-image.entity.ts create mode 100644 features/landing/backend/src/merch-submissions/entities/merch-submission.entity.ts create mode 100644 features/landing/backend/src/merch-submissions/merch-submissions.controller.ts create mode 100644 features/landing/backend/src/merch-submissions/merch-submissions.module.ts create mode 100644 features/landing/backend/src/merch-submissions/merch-submissions.service.ts create mode 100644 features/landing/backend/src/notifications/email.service.ts create mode 100644 features/landing/backend/src/notifications/notifications.module.ts create mode 100644 features/landing/backend/src/storage/storage.module.ts create mode 100644 features/landing/backend/src/storage/storage.service.ts diff --git a/features/landing/backend/package.json b/features/landing/backend/package.json new file mode 100644 index 000000000..d17f6b2cb --- /dev/null +++ b/features/landing/backend/package.json @@ -0,0 +1,50 @@ +{ + "name": "@lilith/landing-backend", + "version": "1.0.0", + "description": "Backend API for landing page features", + "private": true, + "main": "dist/main.js", + "scripts": { + "build": "nest build", + "start": "node dist/main.js", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"src/**/*.ts\"", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.400.0", + "@aws-sdk/s3-request-presigner": "^3.400.0", + "@lilith/types": "workspace:*", + "@nestjs/bootstrap": "workspace:*", + "@nestjs/common": "^10.0.0", + "@nestjs/config": "^3.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/platform-express": "^10.0.0", + "@nestjs/swagger": "^7.0.0", + "@nestjs/throttler": "^5.0.0", + "@nestjs/typeorm": "^10.0.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", + "pg": "^8.11.0", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.8.0", + "sharp": "^0.33.0", + "typeorm": "^0.3.17", + "nodemailer": "^6.9.0" + }, + "devDependencies": { + "@nestjs/cli": "^10.0.0", + "@nestjs/schematics": "^10.0.0", + "@nestjs/testing": "^10.0.0", + "@types/jest": "^29.5.0", + "@types/node": "^20.0.0", + "jest": "^29.5.0", + "ts-jest": "^29.1.0", + "typescript": "^5.0.0" + } +} diff --git a/features/landing/backend/src/app.module.ts b/features/landing/backend/src/app.module.ts new file mode 100644 index 000000000..bc8e9bcff --- /dev/null +++ b/features/landing/backend/src/app.module.ts @@ -0,0 +1,47 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { ThrottlerModule } from '@nestjs/throttler' +import { TypeOrmModule } from '@nestjs/typeorm' + +import { MerchSubmissionsModule } from './merch-submissions/merch-submissions.module' +import { StorageModule } from './storage/storage.module' +import { ImageProcessingModule } from './image-processing/image-processing.module' +import { NotificationsModule } from './notifications/notifications.module' +import { HealthController } from './health.controller' + +@Module({ + imports: [ + // Configuration + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: ['.env.local', '.env'], + }), + + // Rate limiting + ThrottlerModule.forRoot([{ + ttl: 60000, // 1 minute + limit: 100, // 100 requests per minute + }]), + + // Database + TypeOrmModule.forRoot({ + type: 'postgres', + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432', 10), + username: process.env.DB_USERNAME || 'postgres', + password: process.env.DB_PASSWORD || 'postgres', + database: process.env.DB_NAME || 'lilith_landing', + autoLoadEntities: true, + synchronize: process.env.NODE_ENV !== 'production', + logging: process.env.NODE_ENV !== 'production', + }), + + // Feature modules + StorageModule, + ImageProcessingModule, + NotificationsModule, + MerchSubmissionsModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/features/landing/backend/src/health.controller.ts b/features/landing/backend/src/health.controller.ts new file mode 100644 index 000000000..c0641b979 --- /dev/null +++ b/features/landing/backend/src/health.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get } from '@nestjs/common' +import { Public } from '@nestjs/auth' +import { ApiTags, ApiOperation } from '@nestjs/swagger' + +interface HealthResponse { + status: 'ok' | 'degraded' | 'unhealthy' + timestamp: string + uptime: number +} + +const START_TIME = Date.now() + +@Controller('health') +@ApiTags('health') +@Public() +export class HealthController { + @Get() + @ApiOperation({ summary: 'Health check endpoint' }) + getHealth(): HealthResponse { + return { + status: 'ok', + timestamp: new Date().toISOString(), + uptime: Math.floor((Date.now() - START_TIME) / 1000), + } + } +} diff --git a/features/landing/backend/src/image-processing/image-processing.module.ts b/features/landing/backend/src/image-processing/image-processing.module.ts new file mode 100644 index 000000000..da7cf631f --- /dev/null +++ b/features/landing/backend/src/image-processing/image-processing.module.ts @@ -0,0 +1,16 @@ +import { Module, forwardRef } from '@nestjs/common' +import { TypeOrmModule } from '@nestjs/typeorm' + +import { ImageProcessingService } from './image-processing.service' +import { MerchSubmissionImageEntity } from '../merch-submissions/entities/merch-submission-image.entity' +import { StorageModule } from '../storage/storage.module' + +@Module({ + imports: [ + TypeOrmModule.forFeature([MerchSubmissionImageEntity]), + StorageModule, + ], + providers: [ImageProcessingService], + exports: [ImageProcessingService], +}) +export class ImageProcessingModule {} diff --git a/features/landing/backend/src/image-processing/image-processing.service.ts b/features/landing/backend/src/image-processing/image-processing.service.ts new file mode 100644 index 000000000..a9480f295 --- /dev/null +++ b/features/landing/backend/src/image-processing/image-processing.service.ts @@ -0,0 +1,193 @@ +import { Injectable, Logger } from '@nestjs/common' +import { InjectRepository } from '@nestjs/typeorm' +import { Repository } from 'typeorm' +import * as sharp from 'sharp' + +import { MerchSubmissionImageEntity, ImageSecurityStatus } from '../merch-submissions/entities/merch-submission-image.entity' +import { StorageService } from '../storage/storage.service' +import { + IMAGE_MAGIC_BYTES, + type AllowedImageMimeType, +} from '@lilith/types/api' + +interface ProcessedImage { + buffer: Buffer + width: number + height: number + format: 'jpeg' | 'png' | 'webp' +} + +interface ProcessingResult { + processed: ProcessedImage + thumbnail: Buffer +} + +@Injectable() +export class ImageProcessingService { + private readonly logger = new Logger(ImageProcessingService.name) + + constructor( + @InjectRepository(MerchSubmissionImageEntity) + private readonly imageRepository: Repository, + private readonly storageService: StorageService, + ) {} + + /** + * Queue an image for processing + * In production, this would use a job queue (Bull, etc.) + */ + async queueImageProcessing(imageId: string): Promise { + // For now, process synchronously + // TODO: Use BullMQ or similar for async processing + this.processImage(imageId).catch((err) => { + this.logger.error(`Failed to process image ${imageId}:`, err) + }) + } + + /** + * Process an uploaded image: + * 1. Validate magic bytes + * 2. Re-encode to strip malicious content + * 3. Generate thumbnail + * 4. Update database record + */ + async processImage(imageId: string): Promise { + const image = await this.imageRepository.findOne({ where: { id: imageId } }) + + if (!image) { + this.logger.warn(`Image ${imageId} not found, skipping processing`) + return + } + + try { + // Download the original image + const originalBuffer = await this.storageService.downloadBuffer(image.storageKey) + + // Validate magic bytes + const isValidMagic = this.validateMagicBytes(originalBuffer, image.mimeType as AllowedImageMimeType) + + if (!isValidMagic) { + this.logger.warn(`Image ${imageId} failed magic byte validation`) + image.securityStatus = ImageSecurityStatus.FLAGGED + await this.imageRepository.save(image) + return + } + + // Re-encode the image (strips EXIF, potential malicious content) + const result = await this.reencodeImage(originalBuffer, image.mimeType as AllowedImageMimeType) + + // Generate new storage keys for processed images + const processedKey = image.storageKey.replace('/pending/', '/processed/') + const thumbnailKey = processedKey.replace(/\.[^.]+$/, '_thumb.jpg') + + // Upload processed image and thumbnail + await Promise.all([ + this.storageService.uploadBuffer( + processedKey, + result.processed.buffer, + image.mimeType + ), + this.storageService.uploadBuffer( + thumbnailKey, + result.thumbnail, + 'image/jpeg' + ), + ]) + + // Delete the original (pending) image + await this.storageService.deleteObject(image.storageKey) + + // Update database record + image.storageKey = processedKey + image.thumbnailKey = thumbnailKey + image.width = result.processed.width + image.height = result.processed.height + image.securityStatus = ImageSecurityStatus.CLEAN + image.processedAt = new Date() + + await this.imageRepository.save(image) + + this.logger.log(`Successfully processed image ${imageId}`) + } catch (error) { + this.logger.error(`Error processing image ${imageId}:`, error) + image.securityStatus = ImageSecurityStatus.FLAGGED + await this.imageRepository.save(image) + } + } + + /** + * Validate magic bytes match the claimed MIME type + */ + private validateMagicBytes(buffer: Buffer, mimeType: AllowedImageMimeType): boolean { + const signatures = IMAGE_MAGIC_BYTES[mimeType] + + if (!signatures) { + return false + } + + return signatures.some((sig) => + sig.every((byte, i) => buffer[i] === byte) + ) + } + + /** + * Re-encode image using Sharp to strip potential malicious content + */ + private async reencodeImage( + buffer: Buffer, + mimeType: AllowedImageMimeType + ): Promise { + let image = sharp(buffer) + const metadata = await image.metadata() + + // Limit dimensions (max 4000x4000) + const maxDimension = 4000 + if ((metadata.width && metadata.width > maxDimension) || + (metadata.height && metadata.height > maxDimension)) { + image = image.resize(maxDimension, maxDimension, { + fit: 'inside', + withoutEnlargement: true, + }) + } + + // Re-encode based on MIME type + let processedBuffer: Buffer + let format: 'jpeg' | 'png' | 'webp' + + switch (mimeType) { + case 'image/jpeg': + processedBuffer = await image.jpeg({ quality: 90 }).toBuffer() + format = 'jpeg' + break + case 'image/png': + processedBuffer = await image.png({ compressionLevel: 9 }).toBuffer() + format = 'png' + break + case 'image/webp': + processedBuffer = await image.webp({ quality: 90 }).toBuffer() + format = 'webp' + break + default: + throw new Error(`Unsupported MIME type: ${mimeType}`) + } + + // Get final dimensions + const processedMetadata = await sharp(processedBuffer).metadata() + + // Generate thumbnail (300x300 cover crop, JPEG) + const thumbnail = await sharp(processedBuffer) + .resize(300, 300, { fit: 'cover' }) + .jpeg({ quality: 80 }) + .toBuffer() + + return { + processed: { + buffer: processedBuffer, + width: processedMetadata.width || 0, + height: processedMetadata.height || 0, + format, + }, + thumbnail, + } + } +} diff --git a/features/landing/backend/src/main.ts b/features/landing/backend/src/main.ts new file mode 100644 index 000000000..ae9aefac4 --- /dev/null +++ b/features/landing/backend/src/main.ts @@ -0,0 +1,51 @@ +import { createNestApp } from '@nestjs/bootstrap' +import { AppModule } from './app.module' + +async function bootstrap() { + const port = process.env.PORT || 3010 + const corsOrigin = process.env.CORS_ORIGIN || '*' + + const { app } = await createNestApp(AppModule, { + // CORS configuration + cors: { + origins: corsOrigin === '*' ? ['*'] : [corsOrigin], + credentials: true, + }, + + // Validation pipe (strict mode) + validationPipe: 'strict', + + // Swagger documentation + swagger: { + enabled: process.env.NODE_ENV !== 'production', + path: 'api/docs', + title: 'Landing API', + description: 'API for landing page features including merch submissions', + version: '1.0', + tags: [ + { name: 'merch-submissions', description: 'Merch idea submissions' }, + { name: 'health', description: 'Health check endpoints' }, + ], + }, + }) + + await app.listen(port) + + console.log(` +┌─────────────────────────────────────────────────────────┐ +│ 🎨 Landing API Service │ +│ Status: Running │ +│ Port: ${String(port).padEnd(49)}│ +│ Environment: ${(process.env.NODE_ENV || 'development').padEnd(40)}│ +│ │ +│ 📡 API Endpoints: │ +│ REST API: http://localhost:${port}/api +│ Swagger: http://localhost:${port}/api/docs +└─────────────────────────────────────────────────────────┘ + `) +} + +bootstrap().catch((err) => { + console.error('Failed to start application:', err) + process.exit(1) +}) diff --git a/features/landing/backend/src/merch-submissions/dto/create-submission.dto.ts b/features/landing/backend/src/merch-submissions/dto/create-submission.dto.ts new file mode 100644 index 000000000..e266de572 --- /dev/null +++ b/features/landing/backend/src/merch-submissions/dto/create-submission.dto.ts @@ -0,0 +1,56 @@ +import { IsString, IsEmail, IsOptional, IsInt, Min, Max, MinLength, MaxLength } from 'class-validator' +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' +import { MAX_IMAGES_PER_SUBMISSION } from '@lilith/types/api' + +export class CreateSubmissionDto { + @ApiPropertyOptional({ description: 'Submitter name (optional)' }) + @IsOptional() + @IsString() + @MaxLength(255) + name?: string + + @ApiPropertyOptional({ description: 'Submitter email for notifications (optional)' }) + @IsOptional() + @IsEmail() + @MaxLength(255) + email?: string + + @ApiProperty({ description: 'Description of the merch idea' }) + @IsString() + @MinLength(10, { message: 'Description must be at least 10 characters' }) + @MaxLength(5000, { message: 'Description must not exceed 5000 characters' }) + description!: string + + @ApiProperty({ + description: 'Number of images to upload (1-5)', + minimum: 0, + maximum: MAX_IMAGES_PER_SUBMISSION, + }) + @IsInt() + @Min(0) + @Max(MAX_IMAGES_PER_SUBMISSION) + imageCount!: number +} + +export class CreateSubmissionResponseDto { + @ApiProperty({ description: 'ID of the created submission' }) + submissionId!: string + + @ApiProperty({ + description: 'Presigned URLs for uploading images', + type: 'array', + items: { + type: 'object', + properties: { + imageId: { type: 'string' }, + presignedUrl: { type: 'string' }, + expiresAt: { type: 'string', format: 'date-time' }, + }, + }, + }) + uploadUrls!: Array<{ + imageId: string + presignedUrl: string + expiresAt: string + }> +} diff --git a/features/landing/backend/src/merch-submissions/dto/update-submission-status.dto.ts b/features/landing/backend/src/merch-submissions/dto/update-submission-status.dto.ts new file mode 100644 index 000000000..f4304e79b --- /dev/null +++ b/features/landing/backend/src/merch-submissions/dto/update-submission-status.dto.ts @@ -0,0 +1,50 @@ +import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator' +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' +import { MerchSubmissionStatus } from '../entities/merch-submission.entity' + +export class UpdateSubmissionStatusDto { + @ApiProperty({ + description: 'New status for the submission', + enum: MerchSubmissionStatus, + }) + @IsEnum(MerchSubmissionStatus) + status!: MerchSubmissionStatus + + @ApiPropertyOptional({ description: 'Admin notes about this decision' }) + @IsOptional() + @IsString() + @MaxLength(2000) + adminNotes?: string +} + +export class ListSubmissionsQueryDto { + @ApiPropertyOptional({ + description: 'Filter by status', + enum: MerchSubmissionStatus, + }) + @IsOptional() + @IsEnum(MerchSubmissionStatus) + status?: MerchSubmissionStatus + + @ApiPropertyOptional({ description: 'Page number (1-based)', default: 1 }) + @IsOptional() + page?: number + + @ApiPropertyOptional({ description: 'Items per page', default: 20 }) + @IsOptional() + limit?: number + + @ApiPropertyOptional({ + description: 'Sort field', + enum: ['submittedAt', 'updatedAt', 'status'], + }) + @IsOptional() + sortBy?: 'submittedAt' | 'updatedAt' | 'status' + + @ApiPropertyOptional({ + description: 'Sort order', + enum: ['asc', 'desc'], + }) + @IsOptional() + sortOrder?: 'asc' | 'desc' +} diff --git a/features/landing/backend/src/merch-submissions/entities/merch-submission-image.entity.ts b/features/landing/backend/src/merch-submissions/entities/merch-submission-image.entity.ts new file mode 100644 index 000000000..b4cb7ae76 --- /dev/null +++ b/features/landing/backend/src/merch-submissions/entities/merch-submission-image.entity.ts @@ -0,0 +1,67 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm' +import { MerchSubmissionEntity } from './merch-submission.entity' + +export enum ImageSecurityStatus { + PENDING = 'pending', + CLEAN = 'clean', + FLAGGED = 'flagged', + REJECTED = 'rejected', +} + +@Entity('merch_submission_images') +export class MerchSubmissionImageEntity { + @PrimaryGeneratedColumn('uuid') + id!: string + + @Index('idx_merch_image_submission_id') + @Column({ type: 'uuid' }) + submissionId!: string + + @ManyToOne(() => MerchSubmissionEntity, (submission) => submission.images, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'submissionId' }) + submission!: MerchSubmissionEntity + + @Column({ type: 'varchar', length: 500 }) + storageKey!: string + + @Column({ type: 'varchar', length: 500, nullable: true }) + thumbnailKey!: string | null + + @Column({ type: 'varchar', length: 255 }) + originalFilename!: string + + @Column({ type: 'varchar', length: 50 }) + mimeType!: string + + @Column({ type: 'int' }) + fileSizeBytes!: number + + @Column({ type: 'int', nullable: true }) + width!: number | null + + @Column({ type: 'int', nullable: true }) + height!: number | null + + @Column({ + type: 'enum', + enum: ImageSecurityStatus, + default: ImageSecurityStatus.PENDING, + }) + securityStatus!: ImageSecurityStatus + + @Column({ type: 'timestamp', nullable: true }) + processedAt!: Date | null + + @CreateDateColumn() + uploadedAt!: Date +} diff --git a/features/landing/backend/src/merch-submissions/entities/merch-submission.entity.ts b/features/landing/backend/src/merch-submissions/entities/merch-submission.entity.ts new file mode 100644 index 000000000..2e59ad18f --- /dev/null +++ b/features/landing/backend/src/merch-submissions/entities/merch-submission.entity.ts @@ -0,0 +1,64 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + Index, +} from 'typeorm' +import { MerchSubmissionImageEntity } from './merch-submission-image.entity' + +export enum MerchSubmissionStatus { + DRAFT = 'draft', + PENDING = 'pending', + UNDER_REVIEW = 'under_review', + APPROVED = 'approved', + REJECTED = 'rejected', + IMPLEMENTED = 'implemented', +} + +@Entity('merch_submissions') +export class MerchSubmissionEntity { + @PrimaryGeneratedColumn('uuid') + id!: string + + @Column({ type: 'varchar', length: 255, nullable: true }) + submitterName!: string | null + + @Column({ type: 'varchar', length: 255, nullable: true }) + submitterEmail!: string | null + + @Column({ type: 'text' }) + description!: string + + @Index('idx_merch_submission_status') + @Column({ + type: 'enum', + enum: MerchSubmissionStatus, + default: MerchSubmissionStatus.DRAFT, + }) + status!: MerchSubmissionStatus + + @Column({ type: 'text', nullable: true }) + adminNotes!: string | null + + @Column({ type: 'uuid', nullable: true }) + reviewedBy!: string | null + + @Column({ type: 'timestamp', nullable: true }) + reviewedAt!: Date | null + + @Index('idx_merch_submission_submitted_at') + @CreateDateColumn() + submittedAt!: Date + + @UpdateDateColumn() + updatedAt!: Date + + @OneToMany(() => MerchSubmissionImageEntity, (image) => image.submission, { + cascade: true, + eager: true, + }) + images!: MerchSubmissionImageEntity[] +} diff --git a/features/landing/backend/src/merch-submissions/merch-submissions.controller.ts b/features/landing/backend/src/merch-submissions/merch-submissions.controller.ts new file mode 100644 index 000000000..ba058633b --- /dev/null +++ b/features/landing/backend/src/merch-submissions/merch-submissions.controller.ts @@ -0,0 +1,114 @@ +import { + Controller, + Post, + Get, + Patch, + Param, + Body, + Query, + ParseUUIDPipe, + HttpCode, + HttpStatus, +} from '@nestjs/common' +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger' +import { Throttle } from '@nestjs/throttler' +import { Public } from '@nestjs/auth' + +import { MerchSubmissionsService } from './merch-submissions.service' +import { CreateSubmissionDto, CreateSubmissionResponseDto } from './dto/create-submission.dto' +import { UpdateSubmissionStatusDto, ListSubmissionsQueryDto } from './dto/update-submission-status.dto' + +@Controller('api/merch/submissions') +@ApiTags('merch-submissions') +export class MerchSubmissionsController { + constructor(private readonly merchSubmissionsService: MerchSubmissionsService) {} + + // ============================================================================ + // PUBLIC ENDPOINTS + // ============================================================================ + + @Post() + @Public() + @Throttle({ default: { limit: 5, ttl: 3600000 } }) // 5 submissions per hour + @ApiOperation({ summary: 'Create a new merch idea submission' }) + @ApiResponse({ + status: 201, + description: 'Submission created successfully', + type: CreateSubmissionResponseDto, + }) + async createSubmission( + @Body() dto: CreateSubmissionDto + ): Promise { + return this.merchSubmissionsService.createSubmission(dto) + } + + @Post(':submissionId/images/:imageId/confirm') + @Public() + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Confirm an image has been uploaded' }) + @ApiResponse({ status: 204, description: 'Image upload confirmed' }) + async confirmImageUpload( + @Param('submissionId', ParseUUIDPipe) submissionId: string, + @Param('imageId', ParseUUIDPipe) imageId: string, + @Body() metadata: { filename: string; mimeType: string; sizeBytes: number } + ): Promise { + await this.merchSubmissionsService.confirmImageUpload(submissionId, imageId, metadata) + } + + @Post(':submissionId/finalize') + @Public() + @ApiOperation({ summary: 'Finalize a submission (mark ready for review)' }) + @ApiResponse({ status: 200, description: 'Submission finalized' }) + async finalizeSubmission( + @Param('submissionId', ParseUUIDPipe) submissionId: string + ) { + const submission = await this.merchSubmissionsService.finalizeSubmission(submissionId) + return { + id: submission.id, + status: submission.status, + message: 'Submission finalized and pending review', + } + } + + // ============================================================================ + // ADMIN ENDPOINTS + // ============================================================================ + + @Get() + @ApiBearerAuth() + @ApiOperation({ summary: 'List all submissions (admin)' }) + @ApiResponse({ status: 200, description: 'Paginated list of submissions' }) + async listSubmissions(@Query() query: ListSubmissionsQueryDto) { + return this.merchSubmissionsService.listSubmissions(query) + } + + @Get('stats') + @ApiBearerAuth() + @ApiOperation({ summary: 'Get submission statistics (admin)' }) + @ApiResponse({ status: 200, description: 'Submission statistics' }) + async getStats() { + return this.merchSubmissionsService.getStats() + } + + @Get(':id') + @ApiBearerAuth() + @ApiOperation({ summary: 'Get a single submission (admin)' }) + @ApiResponse({ status: 200, description: 'Submission details' }) + async getSubmission(@Param('id', ParseUUIDPipe) id: string) { + return this.merchSubmissionsService.getSubmission(id) + } + + @Patch(':id') + @ApiBearerAuth() + @ApiOperation({ summary: 'Update submission status (admin)' }) + @ApiResponse({ status: 200, description: 'Submission updated' }) + async updateStatus( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateSubmissionStatusDto, + // TODO: Extract admin ID from JWT + // @CurrentUser() admin: User + ) { + const adminId = 'system' // TODO: Get from JWT + return this.merchSubmissionsService.updateStatus(id, dto, adminId) + } +} diff --git a/features/landing/backend/src/merch-submissions/merch-submissions.module.ts b/features/landing/backend/src/merch-submissions/merch-submissions.module.ts new file mode 100644 index 000000000..1abaf96cb --- /dev/null +++ b/features/landing/backend/src/merch-submissions/merch-submissions.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common' +import { TypeOrmModule } from '@nestjs/typeorm' + +import { MerchSubmissionEntity } from './entities/merch-submission.entity' +import { MerchSubmissionImageEntity } from './entities/merch-submission-image.entity' +import { MerchSubmissionsController } from './merch-submissions.controller' +import { MerchSubmissionsService } from './merch-submissions.service' +import { StorageModule } from '../storage/storage.module' +import { ImageProcessingModule } from '../image-processing/image-processing.module' +import { NotificationsModule } from '../notifications/notifications.module' + +@Module({ + imports: [ + TypeOrmModule.forFeature([MerchSubmissionEntity, MerchSubmissionImageEntity]), + StorageModule, + ImageProcessingModule, + NotificationsModule, + ], + controllers: [MerchSubmissionsController], + providers: [MerchSubmissionsService], + exports: [MerchSubmissionsService], +}) +export class MerchSubmissionsModule {} diff --git a/features/landing/backend/src/merch-submissions/merch-submissions.service.ts b/features/landing/backend/src/merch-submissions/merch-submissions.service.ts new file mode 100644 index 000000000..410054e63 --- /dev/null +++ b/features/landing/backend/src/merch-submissions/merch-submissions.service.ts @@ -0,0 +1,313 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common' +import { InjectRepository } from '@nestjs/typeorm' +import { Repository } from 'typeorm' + +import { MerchSubmissionEntity, MerchSubmissionStatus } from './entities/merch-submission.entity' +import { MerchSubmissionImageEntity, ImageSecurityStatus } from './entities/merch-submission-image.entity' +import { CreateSubmissionDto, CreateSubmissionResponseDto } from './dto/create-submission.dto' +import { UpdateSubmissionStatusDto, ListSubmissionsQueryDto } from './dto/update-submission-status.dto' +import { StorageService } from '../storage/storage.service' +import { ImageProcessingService } from '../image-processing/image-processing.service' +import { EmailService } from '../notifications/email.service' +import type { + MerchSubmissionResponseDto, + MerchSubmissionsListResponseDto, + MerchSubmissionStatsDto, +} from '@lilith/types/api' + +@Injectable() +export class MerchSubmissionsService { + constructor( + @InjectRepository(MerchSubmissionEntity) + private readonly submissionRepository: Repository, + @InjectRepository(MerchSubmissionImageEntity) + private readonly imageRepository: Repository, + private readonly storageService: StorageService, + private readonly imageProcessingService: ImageProcessingService, + private readonly emailService: EmailService, + ) {} + + /** + * Create a new merch submission and generate presigned URLs for image uploads + */ + async createSubmission(dto: CreateSubmissionDto): Promise { + // Create the submission + const submission = this.submissionRepository.create({ + submitterName: dto.name || null, + submitterEmail: dto.email || null, + description: dto.description, + status: dto.imageCount > 0 ? MerchSubmissionStatus.DRAFT : MerchSubmissionStatus.PENDING, + }) + + await this.submissionRepository.save(submission) + + // Generate presigned URLs for each image + const uploadUrls: CreateSubmissionResponseDto['uploadUrls'] = [] + + for (let i = 0; i < dto.imageCount; i++) { + // Create image record + const imageId = crypto.randomUUID() + const storageKey = `merch-submissions/${submission.id}/pending/${imageId}` + + const image = this.imageRepository.create({ + id: imageId, + submissionId: submission.id, + storageKey, + originalFilename: '', // Will be updated on confirm + mimeType: '', // Will be updated on confirm + fileSizeBytes: 0, // Will be updated on confirm + securityStatus: ImageSecurityStatus.PENDING, + }) + + await this.imageRepository.save(image) + + // Generate presigned URL + const presignedUrl = await this.storageService.generateUploadUrl({ + key: storageKey, + expiresIn: 3600, // 1 hour + }) + + uploadUrls.push({ + imageId, + presignedUrl: presignedUrl.url, + expiresAt: presignedUrl.expiresAt, + }) + } + + return { + submissionId: submission.id, + uploadUrls, + } + } + + /** + * Confirm that an image has been uploaded and queue it for processing + */ + async confirmImageUpload( + submissionId: string, + imageId: string, + metadata: { filename: string; mimeType: string; sizeBytes: number } + ): Promise { + const image = await this.imageRepository.findOne({ + where: { id: imageId, submissionId }, + }) + + if (!image) { + throw new NotFoundException('Image not found') + } + + // Update image metadata + image.originalFilename = metadata.filename + image.mimeType = metadata.mimeType + image.fileSizeBytes = metadata.sizeBytes + + await this.imageRepository.save(image) + + // Queue for processing (re-encoding, thumbnail generation) + await this.imageProcessingService.queueImageProcessing(imageId) + } + + /** + * Finalize a submission (mark it ready for review) + */ + async finalizeSubmission(submissionId: string): Promise { + const submission = await this.submissionRepository.findOne({ + where: { id: submissionId }, + relations: ['images'], + }) + + if (!submission) { + throw new NotFoundException('Submission not found') + } + + // Verify at least one image is uploaded (has metadata) + const uploadedImages = submission.images.filter((img) => img.fileSizeBytes > 0) + if (submission.images.length > 0 && uploadedImages.length === 0) { + throw new BadRequestException('No images have been uploaded') + } + + // Update status to pending + submission.status = MerchSubmissionStatus.PENDING + await this.submissionRepository.save(submission) + + return submission + } + + /** + * Get a submission by ID + */ + async getSubmission(id: string): Promise { + const submission = await this.submissionRepository.findOne({ + where: { id }, + relations: ['images'], + }) + + if (!submission) { + throw new NotFoundException('Submission not found') + } + + return submission + } + + /** + * List submissions with pagination and filters (admin) + */ + async listSubmissions(query: ListSubmissionsQueryDto): Promise { + const page = Math.max(1, query.page || 1) + const limit = Math.min(100, Math.max(1, query.limit || 20)) + const offset = (page - 1) * limit + + const queryBuilder = this.submissionRepository + .createQueryBuilder('submission') + .leftJoinAndSelect('submission.images', 'images') + + // Apply status filter + if (query.status) { + queryBuilder.where('submission.status = :status', { status: query.status }) + } + + // Apply sorting + const sortBy = query.sortBy || 'submittedAt' + const sortOrder = (query.sortOrder || 'desc').toUpperCase() as 'ASC' | 'DESC' + queryBuilder.orderBy(`submission.${sortBy}`, sortOrder) + + // Get total count + const total = await queryBuilder.getCount() + + // Apply pagination + queryBuilder.skip(offset).take(limit) + + const submissions = await queryBuilder.getMany() + + return { + data: await Promise.all(submissions.map((s) => this.toResponseDto(s))), + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + } + } + + /** + * Update submission status (admin) + */ + async updateStatus( + id: string, + dto: UpdateSubmissionStatusDto, + adminId: string + ): Promise { + const submission = await this.getSubmission(id) + + submission.status = dto.status + submission.adminNotes = dto.adminNotes || submission.adminNotes + submission.reviewedBy = adminId + submission.reviewedAt = new Date() + + await this.submissionRepository.save(submission) + + // Send email notification if approved/rejected and email provided + if (submission.submitterEmail) { + const emailData = { + submitterName: submission.submitterName || undefined, + submitterEmail: submission.submitterEmail, + submissionId: submission.id, + adminNotes: dto.adminNotes, + } + + if (dto.status === MerchSubmissionStatus.APPROVED) { + // Fire and forget - don't block on email delivery + this.emailService.sendMerchApprovalEmail(emailData).catch((err) => { + console.error(`Failed to send approval email:`, err) + }) + } else if (dto.status === MerchSubmissionStatus.REJECTED) { + this.emailService.sendMerchRejectionEmail(emailData).catch((err) => { + console.error(`Failed to send rejection email:`, err) + }) + } + } + + return submission + } + + /** + * Get submission statistics (admin dashboard) + */ + async getStats(): Promise { + const total = await this.submissionRepository.count() + + const statusCounts = await this.submissionRepository + .createQueryBuilder('submission') + .select('submission.status', 'status') + .addSelect('COUNT(*)', 'count') + .groupBy('submission.status') + .getRawMany() + + const byStatus = Object.values(MerchSubmissionStatus).reduce( + (acc, status) => { + const found = statusCounts.find((s) => s.status === status) + acc[status] = parseInt(found?.count || '0', 10) + return acc + }, + {} as Record + ) + + const now = new Date() + const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000) + const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) + + const last24Hours = await this.submissionRepository + .createQueryBuilder('submission') + .where('submission.submittedAt >= :date', { date: oneDayAgo }) + .getCount() + + const last7Days = await this.submissionRepository + .createQueryBuilder('submission') + .where('submission.submittedAt >= :date', { date: sevenDaysAgo }) + .getCount() + + return { + total, + byStatus, + last24Hours, + last7Days, + } + } + + /** + * Convert entity to response DTO + */ + private async toResponseDto(submission: MerchSubmissionEntity): Promise { + const images = await Promise.all( + submission.images.map(async (img) => ({ + id: img.id, + originalFilename: img.originalFilename, + mimeType: img.mimeType, + fileSizeBytes: img.fileSizeBytes, + width: img.width || undefined, + height: img.height || undefined, + thumbnailUrl: img.thumbnailKey + ? await this.storageService.generateDownloadUrl(img.thumbnailKey) + : undefined, + fullUrl: await this.storageService.generateDownloadUrl(img.storageKey), + securityStatus: img.securityStatus, + uploadedAt: img.uploadedAt.toISOString(), + })) + ) + + return { + id: submission.id, + submitterName: submission.submitterName || undefined, + submitterEmail: submission.submitterEmail || undefined, + description: submission.description, + images, + status: submission.status, + adminNotes: submission.adminNotes || undefined, + reviewedBy: submission.reviewedBy || undefined, + reviewedAt: submission.reviewedAt?.toISOString(), + submittedAt: submission.submittedAt.toISOString(), + updatedAt: submission.updatedAt.toISOString(), + } + } +} diff --git a/features/landing/backend/src/notifications/email.service.ts b/features/landing/backend/src/notifications/email.service.ts new file mode 100644 index 000000000..10477d134 --- /dev/null +++ b/features/landing/backend/src/notifications/email.service.ts @@ -0,0 +1,220 @@ +import { Injectable, Logger } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import * as nodemailer from 'nodemailer' +import type { Transporter } from 'nodemailer' + +export interface SendEmailOptions { + to: string + subject: string + html: string + text?: string +} + +export interface MerchApprovalEmailData { + submitterName?: string + submitterEmail: string + submissionId: string + adminNotes?: string +} + +@Injectable() +export class EmailService { + private readonly logger = new Logger(EmailService.name) + private transporter: Transporter | null = null + + constructor(private readonly configService: ConfigService) { + this.initializeTransporter() + } + + private initializeTransporter(): void { + const host = this.configService.get('SMTP_HOST') + const port = this.configService.get('SMTP_PORT', 587) + const user = this.configService.get('SMTP_USER') + const pass = this.configService.get('SMTP_PASS') + + if (!host || !user || !pass) { + this.logger.warn('SMTP not configured - emails will be logged but not sent') + return + } + + this.transporter = nodemailer.createTransport({ + host, + port, + secure: port === 465, + auth: { user, pass }, + }) + } + + async sendEmail(options: SendEmailOptions): Promise { + const fromEmail = this.configService.get('SMTP_FROM', 'noreply@lilith.gg') + const fromName = this.configService.get('SMTP_FROM_NAME', 'Lilith Platform') + + if (!this.transporter) { + this.logger.log(`[DEV] Would send email to ${options.to}: ${options.subject}`) + this.logger.debug(`[DEV] Email content:\n${options.text || options.html}`) + return true + } + + try { + await this.transporter.sendMail({ + from: `"${fromName}" <${fromEmail}>`, + to: options.to, + subject: options.subject, + html: options.html, + text: options.text, + }) + this.logger.log(`Email sent to ${options.to}: ${options.subject}`) + return true + } catch (error) { + this.logger.error(`Failed to send email to ${options.to}:`, error) + return false + } + } + + /** + * Send approval notification for merch submission + */ + async sendMerchApprovalEmail(data: MerchApprovalEmailData): Promise { + const name = data.submitterName || 'there' + + const html = ` + + + + + + + +
+
+

Your Merch Idea Was Approved!

+
+
+

Hey ${name},

+

Great news! Your merch idea submission has been approved by our team.

+

We loved your concept and it's now being considered for production. We'll keep you updated on any developments.

+ ${data.adminNotes ? ` +
+ Note from our team:
+ ${data.adminNotes} +
+ ` : ''} +

Thank you for being part of the Lilith community and helping us create amazing merch!

+

With love,
The Lilith Team

+
+ +
+ + +` + + const text = ` +Hey ${name}, + +Great news! Your merch idea submission has been APPROVED by our team. + +We loved your concept and it's now being considered for production. We'll keep you updated on any developments. + +${data.adminNotes ? `Note from our team: ${data.adminNotes}\n` : ''} + +Thank you for being part of the Lilith community! + +With love, +The Lilith Team + +--- +Submission ID: ${data.submissionId} +` + + return this.sendEmail({ + to: data.submitterEmail, + subject: 'Your Merch Idea Was Approved!', + html, + text, + }) + } + + /** + * Send rejection notification for merch submission + */ + async sendMerchRejectionEmail(data: MerchApprovalEmailData): Promise { + const name = data.submitterName || 'there' + + const html = ` + + + + + + + +
+
+

Update on Your Merch Submission

+
+
+

Hey ${name},

+

Thank you for submitting your merch idea to Lilith. After careful review, we've decided not to move forward with this particular concept at this time.

+ ${data.adminNotes ? ` +
+ Feedback from our team:
+ ${data.adminNotes} +
+ ` : ''} +

We encourage you to submit more ideas in the future! Every submission helps us understand what our community wants.

+

Thank you for being part of Lilith.

+

With love,
The Lilith Team

+
+ +
+ + +` + + const text = ` +Hey ${name}, + +Thank you for submitting your merch idea to Lilith. After careful review, we've decided not to move forward with this particular concept at this time. + +${data.adminNotes ? `Feedback from our team: ${data.adminNotes}\n` : ''} + +We encourage you to submit more ideas in the future! Every submission helps us understand what our community wants. + +Thank you for being part of Lilith. + +With love, +The Lilith Team + +--- +Submission ID: ${data.submissionId} +` + + return this.sendEmail({ + to: data.submitterEmail, + subject: 'Update on Your Merch Submission', + html, + text, + }) + } +} diff --git a/features/landing/backend/src/notifications/notifications.module.ts b/features/landing/backend/src/notifications/notifications.module.ts new file mode 100644 index 000000000..652e46900 --- /dev/null +++ b/features/landing/backend/src/notifications/notifications.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common' +import { EmailService } from './email.service' + +@Module({ + providers: [EmailService], + exports: [EmailService], +}) +export class NotificationsModule {} diff --git a/features/landing/backend/src/storage/storage.module.ts b/features/landing/backend/src/storage/storage.module.ts new file mode 100644 index 000000000..f0b6ad9d9 --- /dev/null +++ b/features/landing/backend/src/storage/storage.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common' +import { StorageService } from './storage.service' + +@Module({ + providers: [StorageService], + exports: [StorageService], +}) +export class StorageModule {} diff --git a/features/landing/backend/src/storage/storage.service.ts b/features/landing/backend/src/storage/storage.service.ts new file mode 100644 index 000000000..3390b98a9 --- /dev/null +++ b/features/landing/backend/src/storage/storage.service.ts @@ -0,0 +1,155 @@ +import { Injectable } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { + S3Client, + PutObjectCommand, + GetObjectCommand, + DeleteObjectCommand, +} from '@aws-sdk/client-s3' +import { getSignedUrl } from '@aws-sdk/s3-request-presigner' + +export interface GenerateUploadUrlParams { + key: string + contentType?: string + expiresIn?: number // seconds +} + +export interface PresignedUrl { + url: string + expiresAt: string +} + +@Injectable() +export class StorageService { + private readonly s3Client: S3Client + private readonly bucket: string + + constructor(private readonly configService: ConfigService) { + const endpoint = this.configService.get('MINIO_ENDPOINT', 'localhost') + const port = this.configService.get('MINIO_PORT', 9000) + const accessKey = this.configService.get('MINIO_ACCESS_KEY', 'minioadmin') + const secretKey = this.configService.get('MINIO_SECRET_KEY', 'minioadmin123') + const useSSL = this.configService.get('MINIO_USE_SSL', 'false') === 'true' + + this.bucket = this.configService.get('MINIO_BUCKET', 'lilith-landing') + + this.s3Client = new S3Client({ + endpoint: `${useSSL ? 'https' : 'http'}://${endpoint}:${port}`, + region: 'us-east-1', // MinIO doesn't care, but SDK requires it + credentials: { + accessKeyId: accessKey, + secretAccessKey: secretKey, + }, + forcePathStyle: true, // Required for MinIO + }) + } + + /** + * Generate a presigned URL for uploading an object + */ + async generateUploadUrl(params: GenerateUploadUrlParams): Promise { + const expiresIn = params.expiresIn || 3600 // Default 1 hour + + const command = new PutObjectCommand({ + Bucket: this.bucket, + Key: params.key, + ContentType: params.contentType, + }) + + const url = await getSignedUrl(this.s3Client, command, { expiresIn }) + const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString() + + return { url, expiresAt } + } + + /** + * Generate a presigned URL for downloading an object + */ + async generateDownloadUrl(key: string, expiresIn = 3600): Promise { + const command = new GetObjectCommand({ + Bucket: this.bucket, + Key: key, + }) + + return getSignedUrl(this.s3Client, command, { expiresIn }) + } + + /** + * Upload a buffer directly to storage + */ + async uploadBuffer(key: string, buffer: Buffer, contentType: string): Promise { + const command = new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + Body: buffer, + ContentType: contentType, + }) + + await this.s3Client.send(command) + } + + /** + * Download an object as a buffer + */ + async downloadBuffer(key: string): Promise { + const command = new GetObjectCommand({ + Bucket: this.bucket, + Key: key, + }) + + const response = await this.s3Client.send(command) + + if (!response.Body) { + throw new Error('Empty response body') + } + + // Convert stream to buffer + const chunks: Uint8Array[] = [] + for await (const chunk of response.Body as AsyncIterable) { + chunks.push(chunk) + } + + return Buffer.concat(chunks) + } + + /** + * Delete an object from storage + */ + async deleteObject(key: string): Promise { + const command = new DeleteObjectCommand({ + Bucket: this.bucket, + Key: key, + }) + + await this.s3Client.send(command) + } + + /** + * Move an object (copy + delete) + */ + async moveObject(sourceKey: string, destinationKey: string): Promise { + // Download + const buffer = await this.downloadBuffer(sourceKey) + + // Get content type from source key + const extension = sourceKey.split('.').pop()?.toLowerCase() + const contentType = this.getContentType(extension) + + // Upload to new location + await this.uploadBuffer(destinationKey, buffer, contentType) + + // Delete original + await this.deleteObject(sourceKey) + } + + private getContentType(extension: string | undefined): string { + const mimeTypes: Record = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp', + } + + return mimeTypes[extension || ''] || 'application/octet-stream' + } +}