/opt/canhelp/apps/api/src/routes
Edit: /opt/canhelp/apps/api/src/routes/portfolio.ts (6658B)
import { Hono } from 'hono'
import { z } from 'zod'
import { eq, and, asc, sql } from 'drizzle-orm'
import fs from 'fs'
import path from 'path'
import { db } from '../db.js'
import { portfolioItems, users, plans } from '@canhelp/db'
import { requireAuth, type AuthVariables } from '../middleware/auth.js'
const IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp']
const VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/quicktime']
const MAX_SIZE = (Number(process.env.MAX_FILE_SIZE_MB) || 5) * 1024 * 1024
const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR || './uploads')
const MAX_PORTFOLIO_ITEMS = 100
const app = new Hono<{ Variables: AuthVariables }>()
// ─── GET /portfolio/my ────────────────────────────────────────────────────
app.get('/my', requireAuth, async (c) => {
const user = c.get('user')
const items = await db
.select()
.from(portfolioItems)
.where(eq(portfolioItems.specialistId, user.id))
.orderBy(asc(portfolioItems.order), asc(portfolioItems.createdAt))
return c.json(items)
})
// ─── POST /portfolio (multipart: file + optional title/description) ────────
app.post('/', requireAuth, async (c) => {
const user = c.get('user')
// Load plan to get canUploadVideo (portfolio item count is hard-capped per user)
let canUploadVideo = false
const [dbUser] = await db
.select({ planId: users.planId })
.from(users)
.where(eq(users.id, user.id))
if (dbUser?.planId) {
const [plan] = await db
.select({ canUploadVideo: plans.canUploadVideo })
.from(plans)
.where(eq(plans.id, dbUser.planId))
if (plan) {
canUploadVideo = !!plan.canUploadVideo
}
}
// Check item count limit (hardcoded cap of 100 photos per user)
const [{ count }] = await db
.select({ count: sql
`cast(count(*) as int)` })
.from(portfolioItems)
.where(eq(portfolioItems.specialistId, user.id))
if (count >= MAX_PORTFOLIO_ITEMS) {
return c.json({ error: `Maximum ${MAX_PORTFOLIO_ITEMS} portfolio items allowed`, code: 'PORTFOLIO_LIMIT' }, 400)
}
const formData = await c.req.formData()
const file = formData.get('file') as File | null
if (!file) return c.json({ error: 'No file provided' }, 400)
const isVideo = VIDEO_TYPES.includes(file.type)
const isImage = IMAGE_TYPES.includes(file.type)
if (!isImage && !isVideo) {
return c.json({ error: 'Only JPEG, PNG, WebP images and MP4/WebM videos are allowed' }, 400)
}
if (isVideo && !canUploadVideo) {
return c.json({ error: 'Video uploads require a Pro or Ultimate plan', code: 'PLAN_UPGRADE_REQUIRED' }, 403)
}
if (file.size > MAX_SIZE) {
return c.json({ error: `File too large (max ${MAX_SIZE / 1024 / 1024}MB)` }, 400)
}
const title = (formData.get('title') as string | null)?.trim().slice(0, 200) || null
const description = (formData.get('description') as string | null)?.trim().slice(0, 1000) || null
const cardId = (formData.get('cardId') as string | null) || null
const ext = file.name.split('.').pop()?.toLowerCase() || 'jpg'
const filename = `portfolio_${crypto.randomUUID()}.${ext}`
const dest = path.join(UPLOAD_DIR, filename)
fs.writeFileSync(dest, Buffer.from(await file.arrayBuffer()))
const imageUrl = `${process.env.API_URL || 'http://localhost:4000'}/api/uploads/${filename}`
const [item] = await db
.insert(portfolioItems)
.values({
specialistId: user.id,
cardId,
imageUrl,
title,
description,
order: count,
})
.returning()
return c.json(item, 201)
})
// ─── PATCH /portfolio/:id ─────────────────────────────────────────────────
const patchSchema = z.object({
title: z.string().max(200).optional().nullable(),
description: z.string().max(1000).optional().nullable(),
order: z.number().int().min(0).optional(),
})
app.patch('/:id', requireAuth, async (c) => {
const user = c.get('user')
const id = c.req.param('id')
const [item] = await db.select().from(portfolioItems).where(eq(portfolioItems.id, id))
if (!item) return c.json({ error: 'Not found' }, 404)
if (item.specialistId !== user.id && user.role !== 'admin') {
return c.json({ error: 'Forbidden' }, 403)
}
const body = await c.req.json()
const parsed = patchSchema.safeParse(body)
if (!parsed.success) return c.json({ error: 'Invalid data' }, 400)
const updates: any = { updatedAt: new Date() }
if ('title' in parsed.data) updates.title = parsed.data.title
if ('description' in parsed.data) updates.description = parsed.data.description
if ('order' in parsed.data) updates.order = parsed.data.order
const [updated] = await db
.update(portfolioItems)
.set(updates)
.where(eq(portfolioItems.id, id))
.returning()
return c.json(updated)
})
// ─── DELETE /portfolio/:id ────────────────────────────────────────────────
app.delete('/:id', requireAuth, async (c) => {
const user = c.get('user')
const id = c.req.param('id')
const [item] = await db.select().from(portfolioItems).where(eq(portfolioItems.id, id))
if (!item) return c.json({ error: 'Not found' }, 404)
if (item.specialistId !== user.id && user.role !== 'admin') {
return c.json({ error: 'Forbidden' }, 403)
}
// Try to delete the file from disk
try {
const filename = item.imageUrl.split('/').pop()
if (filename) {
const filePath = path.join(UPLOAD_DIR, filename)
if (fs.existsSync(filePath)) fs.unlinkSync(filePath)
}
} catch { /* ignore file deletion errors */ }
await db.delete(portfolioItems).where(eq(portfolioItems.id, id))
return c.json({ ok: true })
})
// ─── GET /portfolio/user/:userId (public) ──────────────────────────────────
app.get('/user/:userId', async (c) => {
const userId = c.req.param('userId')
const items = await db
.select({
id: portfolioItems.id,
cardId: portfolioItems.cardId,
imageUrl: portfolioItems.imageUrl,
title: portfolioItems.title,
description: portfolioItems.description,
order: portfolioItems.order,
createdAt: portfolioItems.createdAt,
})
.from(portfolioItems)
.where(eq(portfolioItems.specialistId, userId))
.orderBy(asc(portfolioItems.order), asc(portfolioItems.createdAt))
return c.json(items)
})
export default app