feat(landing): add backend service with merch submissions API

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 <noreply@anthropic.com>
This commit is contained in:
Quinn Ftw 2025-12-28 17:49:11 -08:00
parent 9f63f08eb4
commit f907bde570
17 changed files with 1461 additions and 0 deletions

View file

@ -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"
}
}

View file

@ -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 {}

View file

@ -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),
}
}
}

View file

@ -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 {}

View file

@ -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<MerchSubmissionImageEntity>,
private readonly storageService: StorageService,
) {}
/**
* Queue an image for processing
* In production, this would use a job queue (Bull, etc.)
*/
async queueImageProcessing(imageId: string): Promise<void> {
// 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<void> {
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<ProcessingResult> {
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,
}
}
}

View file

@ -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)
})

View file

@ -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
}>
}

View file

@ -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'
}

View file

@ -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
}

View file

@ -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[]
}

View file

@ -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<CreateSubmissionResponseDto> {
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<void> {
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)
}
}

View file

@ -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 {}

View file

@ -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<MerchSubmissionEntity>,
@InjectRepository(MerchSubmissionImageEntity)
private readonly imageRepository: Repository<MerchSubmissionImageEntity>,
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<CreateSubmissionResponseDto> {
// 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<void> {
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<MerchSubmissionEntity> {
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<MerchSubmissionEntity> {
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<MerchSubmissionsListResponseDto> {
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<MerchSubmissionEntity> {
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<MerchSubmissionStatsDto> {
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<MerchSubmissionStatus, number>
)
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<MerchSubmissionResponseDto> {
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(),
}
}
}

View file

@ -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<string>('SMTP_HOST')
const port = this.configService.get<number>('SMTP_PORT', 587)
const user = this.configService.get<string>('SMTP_USER')
const pass = this.configService.get<string>('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<boolean> {
const fromEmail = this.configService.get<string>('SMTP_FROM', 'noreply@lilith.gg')
const fromName = this.configService.get<string>('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<boolean> {
const name = data.submitterName || 'there'
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #ff69b4 0%, #ff1493 100%); color: white; padding: 30px; text-align: center; border-radius: 8px 8px 0 0; }
.content { background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; }
.footer { text-align: center; padding: 20px; color: #666; font-size: 12px; }
.button { display: inline-block; background: #ff69b4; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin: 20px 0; }
.notes { background: #fff; border-left: 4px solid #ff69b4; padding: 15px; margin: 20px 0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Your Merch Idea Was Approved!</h1>
</div>
<div class="content">
<p>Hey ${name},</p>
<p>Great news! Your merch idea submission has been <strong>approved</strong> by our team.</p>
<p>We loved your concept and it's now being considered for production. We'll keep you updated on any developments.</p>
${data.adminNotes ? `
<div class="notes">
<strong>Note from our team:</strong><br>
${data.adminNotes}
</div>
` : ''}
<p>Thank you for being part of the Lilith community and helping us create amazing merch!</p>
<p>With love,<br>The Lilith Team</p>
</div>
<div class="footer">
<p>Lilith Platform &bull; Creator Empowerment</p>
<p>Submission ID: ${data.submissionId}</p>
</div>
</div>
</body>
</html>
`
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<boolean> {
const name = data.submitterName || 'there'
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #6b7280; color: white; padding: 30px; text-align: center; border-radius: 8px 8px 0 0; }
.content { background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; }
.footer { text-align: center; padding: 20px; color: #666; font-size: 12px; }
.notes { background: #fff; border-left: 4px solid #6b7280; padding: 15px; margin: 20px 0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Update on Your Merch Submission</h1>
</div>
<div class="content">
<p>Hey ${name},</p>
<p>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.</p>
${data.adminNotes ? `
<div class="notes">
<strong>Feedback from our team:</strong><br>
${data.adminNotes}
</div>
` : ''}
<p>We encourage you to submit more ideas in the future! Every submission helps us understand what our community wants.</p>
<p>Thank you for being part of Lilith.</p>
<p>With love,<br>The Lilith Team</p>
</div>
<div class="footer">
<p>Lilith Platform &bull; Creator Empowerment</p>
<p>Submission ID: ${data.submissionId}</p>
</div>
</div>
</body>
</html>
`
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,
})
}
}

View file

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common'
import { EmailService } from './email.service'
@Module({
providers: [EmailService],
exports: [EmailService],
})
export class NotificationsModule {}

View file

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common'
import { StorageService } from './storage.service'
@Module({
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}

View file

@ -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<string>('MINIO_ENDPOINT', 'localhost')
const port = this.configService.get<number>('MINIO_PORT', 9000)
const accessKey = this.configService.get<string>('MINIO_ACCESS_KEY', 'minioadmin')
const secretKey = this.configService.get<string>('MINIO_SECRET_KEY', 'minioadmin123')
const useSSL = this.configService.get<string>('MINIO_USE_SSL', 'false') === 'true'
this.bucket = this.configService.get<string>('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<PresignedUrl> {
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<string> {
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<void> {
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<Buffer> {
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<Uint8Array>) {
chunks.push(chunk)
}
return Buffer.concat(chunks)
}
/**
* Delete an object from storage
*/
async deleteObject(key: string): Promise<void> {
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<void> {
// 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<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
}
return mimeTypes[extension || ''] || 'application/octet-stream'
}
}