/opt/canhelp/apps/api/src/routes
NameSizeModeActions
admin.ts950570644editdlrm
auto-match.ts74060644editdlrm
auto-response.ts81110644editdlrm
availability.ts72940644editdlrm
categories.ts18670644editdlrm
chat.ts93770644editdlrm
dashboard.ts51420644editdlrm
favorites.ts44410644editdlrm
fomo.ts68440644editdlrm
google.ts48720644editdlrm
health.ts12140644editdlrm
i18n.ts1670860644editdlrm
locations.ts17810644editdlrm
notifications.ts28620644editdlrm
offers.ts100470644editdlrm
payments.ts211670644editdlrm
plans.ts13520644editdlrm
portfolio.ts66580644editdlrm
price-list.ts144170644editdlrm
reports.ts20070644editdlrm
reviews.ts27830644editdlrm
skill-suggestions.ts10350644editdlrm
specialist-cards.ts186410644editdlrm
stats.ts10930644editdlrm
statuses.ts37310644editdlrm
support.ts84520644editdlrm
tasks.ts300180644editdlrm
uploads.ts15260644editdlrm
users.ts371570644editdlrm
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