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:
parent
9f63f08eb4
commit
f907bde570
17 changed files with 1461 additions and 0 deletions
50
features/landing/backend/package.json
Normal file
50
features/landing/backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
47
features/landing/backend/src/app.module.ts
Normal file
47
features/landing/backend/src/app.module.ts
Normal 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 {}
|
||||
26
features/landing/backend/src/health.controller.ts
Normal file
26
features/landing/backend/src/health.controller.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
51
features/landing/backend/src/main.ts
Normal file
51
features/landing/backend/src/main.ts
Normal 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)
|
||||
})
|
||||
|
|
@ -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
|
||||
}>
|
||||
}
|
||||
|
|
@ -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'
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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[]
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
220
features/landing/backend/src/notifications/email.service.ts
Normal file
220
features/landing/backend/src/notifications/email.service.ts
Normal 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 • 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 • 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common'
|
||||
import { EmailService } from './email.service'
|
||||
|
||||
@Module({
|
||||
providers: [EmailService],
|
||||
exports: [EmailService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
8
features/landing/backend/src/storage/storage.module.ts
Normal file
8
features/landing/backend/src/storage/storage.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common'
|
||||
import { StorageService } from './storage.service'
|
||||
|
||||
@Module({
|
||||
providers: [StorageService],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
155
features/landing/backend/src/storage/storage.service.ts
Normal file
155
features/landing/backend/src/storage/storage.service.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue