Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ model User {
onboarding OnboardingProgress?
consentRecords ConsentRecord[]
wallet Wallet?
avatar Avatar?

@@map("users")
}
Expand Down Expand Up @@ -554,3 +555,46 @@ model WalletProvisioningJob {
@@index([status, leasedUntil])
@@map("wallet_provisioning_jobs")
}

model Avatar {
id String @id @default(uuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
storageKey String
originalName String?
contentType String // declared MIME from upload intent
detectedMime String? // MIME after server-side sniffing (null before finalize)
originalBytes Int @default(0)
status String @default("PENDING") // PENDING, PROCESSING, ACTIVE, FAILED
scanResult String? // clean, rejected, error
scanReason String?
width Int?
height Int?
variantCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
finalizedAt DateTime?
replacedAt DateTime?
replacedById String?
replacedBy Avatar? @relation("ReplacedAvatar", fields: [replacedById], references: [id])
replacements Avatar? @relation("ReplacedAvatar")
variants AvatarVariant[]

@@index([userId, status])
@@map("avatars")
}

model AvatarVariant {
id String @id @default(uuid())
avatarId String
avatar Avatar @relation(fields: [avatarId], references: [id], onDelete: Cascade)
label String // original, thumb, medium
storageKey String
bytes Int @default(0)
width Int?
height Int?
createdAt DateTime @default(now())

@@index([avatarId])
@@map("avatar_variants")
}
250 changes: 250 additions & 0 deletions src/controllers/avatar.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
import { Request, Response } from 'express'
import { z } from 'zod'
import { AvatarService, AvatarValidationError } from '../services/avatar.service'
import { InMemoryStorageProvider } from '../services/storage/in-memory-storage'
import { AVATAR_MAX_BYTES } from '../types/avatar.types'

// Singleton storage — swap via DI or env-based factory in production
const storageProvider = new InMemoryStorageProvider()
const avatarService = new AvatarService(storageProvider)

// ── Zod schemas ───────────────────────────────────────────────────

const uploadIntentSchema = z
.object({
contentType: z.string().min(1, 'contentType is required'),
originalName: z.string().max(200).optional(),
sizeBytes: z.number().int().positive().max(AVATAR_MAX_BYTES).optional(),
})
.strict()

const finalizeSchema = z
.object({
uploadKey: z.string().min(1, 'uploadKey is required'),
sha256: z
.string()
.regex(/^[0-9a-f]{64}$/i, 'Invalid SHA-256 hex string')
.optional(),
})
.strict()

// ── Controller ────────────────────────────────────────────────────

export class AvatarController {
/**
* @openapi
* /v1/users/me/avatar/upload-intent:
* post:
* summary: Create a short-lived upload intent for an avatar image
* tags: [Avatars]
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [contentType]
* properties:
* contentType:
* type: string
* example: image/jpeg
* originalName:
* type: string
* sizeBytes:
* type: integer
* responses:
* 201:
* description: Upload intent created
* 400:
* description: Validation failed
* 401:
* description: Unauthorized
*/
async createUploadIntent(req: Request, res: Response): Promise<void> {
try {
const userId = req.user?.id
if (!userId) {
res.status(401).json({ error: 'Unauthorized' })
return
}

const validation = uploadIntentSchema.safeParse(req.body)
if (!validation.success) {
res.status(400).json({
error: 'Validation failed',
details: validation.error.format(),
})
return
}

const intent = await avatarService.createUploadIntent(
userId,
validation.data.contentType,
validation.data.originalName,
validation.data.sizeBytes,
)

res.status(201).json({ data: intent })
} catch (error) {
if (error instanceof AvatarValidationError) {
res.status(error.statusCode).json({ error: error.message })
return
}
console.error('Upload intent error:', error)
res.status(500).json({ error: 'Internal server error' })
}
}

/**
* @openapi
* /v1/users/me/avatar/finalize:
* post:
* summary: Finalize an uploaded avatar (validate, produce variants, promote to active)
* tags: [Avatars]
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [uploadKey]
* properties:
* uploadKey:
* type: string
* sha256:
* type: string
* responses:
* 200:
* description: Avatar finalized
* 400:
* description: Validation or ownership error
* 401:
* description: Unauthorized
* 403:
* description: Upload belongs to another user
* 404:
* description: Upload not found
* 409:
* description: Avatar already finalized
* 422:
* description: File validation failed
*/
async finalize(req: Request, res: Response): Promise<void> {
try {
const userId = req.user?.id
if (!userId) {
res.status(401).json({ error: 'Unauthorized' })
return
}

const validation = finalizeSchema.safeParse(req.body)
if (!validation.success) {
res.status(400).json({
error: 'Validation failed',
details: validation.error.format(),
})
return
}

const result = await avatarService.finalize(
userId,
validation.data.uploadKey,
validation.data.sha256,
)

res.status(200).json({ data: result })
} catch (error) {
if (error instanceof AvatarValidationError) {
res.status(error.statusCode).json({ error: error.message })
return
}
console.error('Avatar finalize error:', error)
res.status(500).json({ error: 'Internal server error' })
}
}

/**
* @openapi
* /v1/users/me/avatar:
* delete:
* summary: Delete the current avatar
* tags: [Avatars]
* security:
* - bearerAuth: []
* responses:
* 204:
* description: Avatar deleted
* 401:
* description: Unauthorized
* 404:
* description: No active avatar
*/
async deleteAvatar(req: Request, res: Response): Promise<void> {
try {
const userId = req.user?.id
if (!userId) {
res.status(401).json({ error: 'Unauthorized' })
return
}

await avatarService.deleteAvatar(userId)
res.status(204).send()
} catch (error) {
if (error instanceof AvatarValidationError) {
res.status(error.statusCode).json({ error: error.message })
return
}
console.error('Avatar delete error:', error)
res.status(500).json({ error: 'Internal server error' })
}
}

/**
* @openapi
* /v1/users/me/avatar:
* get:
* summary: Get the current avatar with variant URLs
* tags: [Avatars]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Current avatar
* 401:
* description: Unauthorized
*/
async getCurrentAvatar(req: Request, res: Response): Promise<void> {
try {
const userId = req.user?.id
if (!userId) {
res.status(401).json({ error: 'Unauthorized' })
return
}

const avatar = await avatarService.getCurrentAvatar(userId)

if (!avatar) {
res.status(200).json({ data: null })
return
}

res.status(200).json({ data: avatar })
} catch (error) {
console.error('Get avatar error:', error)
res.status(500).json({ error: 'Internal server error' })
}
}

/** Expose the service for testing */
static get service(): AvatarService {
return avatarService
}

static get storage(): InMemoryStorageProvider {
return storageProvider
}
}
51 changes: 51 additions & 0 deletions src/routes/v1/avatar.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Router } from 'express'
import { AvatarController } from '../../controllers/avatar.controller'
import { authenticate } from '../../middleware/auth.middleware'

const router: Router = Router()
const avatarController = new AvatarController()

// All avatar routes require authentication
router.use(authenticate)

/**
* @route POST /api/v1/users/me/avatar/upload-intent
* @desc Create a short-lived upload intent for an avatar image
* @access Private
*/
router.post(
'/upload-intent',
avatarController.createUploadIntent.bind(avatarController),
)

/**
* @route POST /api/v1/users/me/avatar/finalize
* @desc Finalize an uploaded avatar (validate, produce variants, promote)
* @access Private
*/
router.post(
'/finalize',
avatarController.finalize.bind(avatarController),
)

/**
* @route GET /api/v1/users/me/avatar
* @desc Get the current avatar with variant URLs
* @access Private
*/
router.get(
'/',
avatarController.getCurrentAvatar.bind(avatarController),
)

/**
* @route DELETE /api/v1/users/me/avatar
* @desc Delete the current avatar and all variants
* @access Private
*/
router.delete(
'/',
avatarController.deleteAvatar.bind(avatarController),
)

export default router
3 changes: 3 additions & 0 deletions src/routes/v1/users.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { PreferenceController } from '../../controllers/preference.controller'
import { ProfileController } from '../../controllers/profile.controller'
import { authenticate, optionalAuthenticate } from '../../middleware/auth.middleware'
import { validateProfileUpdate, validatePasswordChange, validateWalletAddress } from '../../middleware/validation.middleware'
import avatarRoutes from './avatar.routes'

const router: express.Router = Router()
const userController = new UserController()
Expand All @@ -30,4 +31,6 @@ router.patch('/password', authenticate, validatePasswordChange, userController.c

router.patch('/wallet', authenticate, validateWalletAddress, userController.updateWalletAddress.bind(userController))

router.use('/me/avatar', avatarRoutes)

export default router
Loading
Loading