/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/price-list.ts (14417B)
import { Hono } from 'hono' import { z } from 'zod' import { eq, asc, and, sql } from 'drizzle-orm' import { db } from '../db.js' import { priceListGroups, priceListItems, specialistCards, users, plans } from '@canhelp/db' import { requireAuth, type AuthVariables } from '../middleware/auth.js' import { translateString } from '../translate.js' const app = new Hono<{ Variables: AuthVariables }>() type Locale = 'el' | 'en' | 'ru' | 'uk' const LOCALES: Locale[] = ['el', 'en', 'ru', 'uk'] function normalizeLocale(input?: string | null): Locale { if (input === 'el' || input === 'en' || input === 'ru' || input === 'uk') return input return 'el' } function localeCap(locale: Locale): 'El' | 'En' | 'Ru' | 'Uk' { return (locale.charAt(0).toUpperCase() + locale.slice(1)) as 'El' | 'En' | 'Ru' | 'Uk' } async function translateToLocaleFields(prefix: string, value: string, sourceLocale: Locale): Promise> { const sourceField = `${prefix}${localeCap(sourceLocale)}` const updates: Record = { [sourceField]: value } const targets = LOCALES.filter((l) => l !== sourceLocale) const translated = await Promise.allSettled( targets.map(async (to) => ({ to, text: await translateString(value, sourceLocale, to), })), ) for (const result of translated) { if (result.status !== 'fulfilled') continue const field = `${prefix}${localeCap(result.value.to)}` updates[field] = result.value.text || value } for (const to of targets) { const field = `${prefix}${localeCap(to)}` if (!updates[field]) updates[field] = value } return updates } function pickLocalized( locale: Locale, base: string | null, localized: { el?: string | null; en?: string | null; ru?: string | null; uk?: string | null }, ): string | null { if (locale === 'el') return localized.el ?? base ?? null if (locale === 'en') return localized.en ?? localized.el ?? base ?? null if (locale === 'ru') return localized.ru ?? localized.el ?? base ?? null return localized.uk ?? localized.el ?? base ?? null } // ─── Helper: verify card ownership ─────────────────────────────────────── async function getOwnedCard(cardId: string, userId: string, isAdmin: boolean) { const [card] = await db.select().from(specialistCards).where(eq(specialistCards.id, cardId)) if (!card) return null if (card.specialistId !== userId && !isAdmin) return null return card } // ─── Helper: check hasPriceList plan feature ────────────────────────────── async function canUsePriceList(userId: string, isAdmin: boolean): Promise { if (isAdmin) return true const [dbUser] = await db.select({ planId: users.planId }).from(users).where(eq(users.id, userId)) if (!dbUser?.planId) return false const [plan] = await db.select({ hasPriceList: plans.hasPriceList }).from(plans).where(eq(plans.id, dbUser.planId)) return !!plan?.hasPriceList } // ─── GET /price-list/:cardId ────────────────────────────────────────────── // Returns all groups + items for a given card (public — no auth required) app.get('/:cardId', async (c) => { const cardId = c.req.param('cardId') const locale = normalizeLocale(c.req.query('locale')) const groups = await db .select() .from(priceListGroups) .where(eq(priceListGroups.cardId, cardId)) .orderBy(asc(priceListGroups.order), asc(priceListGroups.createdAt)) const items = await db .select() .from(priceListItems) .where(eq(priceListItems.cardId, cardId)) .orderBy(asc(priceListItems.order), asc(priceListItems.createdAt)) const result = groups.map((g) => { const groupItems = items.filter((i) => i.groupId === g.id) const localizedItems = groupItems.map((i) => ({ ...i, name: pickLocalized(locale, i.name, { el: i.nameEl, en: i.nameEn, ru: i.nameRu, uk: i.nameUk, }) || i.name, description: pickLocalized(locale, i.description, { el: i.descriptionEl, en: i.descriptionEn, ru: i.descriptionRu, uk: i.descriptionUk, }), unit: pickLocalized(locale, i.unit, { el: i.unitEl, en: i.unitEn, ru: i.unitRu, uk: i.unitUk, }), })) return { ...g, title: pickLocalized(locale, g.title, { el: g.titleEl, en: g.titleEn, ru: g.titleRu, uk: g.titleUk, }) || g.title, items: localizedItems, } }) return c.json(result) }) // ─── POST /price-list/:cardId/groups ───────────────────────────────────── const groupSchema = z.object({ title: z.string().min(1).max(200), locale: z.enum(['el', 'en', 'ru', 'uk']).optional(), autoTranslate: z.boolean().optional(), order: z.number().int().min(0).optional(), }) app.post('/:cardId/groups', requireAuth, async (c) => { const user = c.get('user') const cardId = c.req.param('cardId') if (!await canUsePriceList(user.id, user.role === 'admin')) { return c.json({ error: 'Price list is not available on your plan', code: 'PLAN_UPGRADE_REQUIRED' }, 403) } const card = await getOwnedCard(cardId, user.id, user.role === 'admin') if (!card) return c.json({ error: 'Not found or forbidden' }, 404) const body = await c.req.json() const parsed = groupSchema.safeParse(body) if (!parsed.success) return c.json({ error: 'Invalid data', details: parsed.error.issues }, 400) const [{ count }] = await db .select({ count: sql`cast(count(*) as int)` }) .from(priceListGroups) .where(eq(priceListGroups.cardId, cardId)) const [group] = await db .insert(priceListGroups) .values({ cardId, specialistId: card.specialistId, title: parsed.data.title, ...await translateToLocaleFields('title', parsed.data.title, normalizeLocale(parsed.data.locale ?? (user as any).locale)), originalLocale: normalizeLocale(parsed.data.locale ?? (user as any).locale), order: parsed.data.order ?? count, }) .returning() return c.json({ ...group, items: [] }, 201) }) // ─── PATCH /price-list/groups/:groupId ──────────────────────────────────── app.patch('/groups/:groupId', requireAuth, async (c) => { const user = c.get('user') const groupId = c.req.param('groupId') const [group] = await db.select().from(priceListGroups).where(eq(priceListGroups.id, groupId)) if (!group) return c.json({ error: 'Not found' }, 404) if (group.specialistId !== user.id && user.role !== 'admin') return c.json({ error: 'Forbidden' }, 403) const body = await c.req.json() const parsed = z.object({ title: z.string().min(1).max(200).optional(), locale: z.enum(['el', 'en', 'ru', 'uk']).optional(), autoTranslate: z.boolean().optional(), order: z.number().int().min(0).optional(), }).safeParse(body) if (!parsed.success) return c.json({ error: 'Invalid data', details: parsed.error.issues }, 400) const updates: any = {} const sourceLocale = normalizeLocale(parsed.data.locale ?? group.originalLocale) if ('title' in parsed.data && parsed.data.title) { updates.title = parsed.data.title updates[`title${localeCap(sourceLocale)}`] = parsed.data.title updates.originalLocale = sourceLocale if (parsed.data.autoTranslate !== false) { Object.assign(updates, await translateToLocaleFields('title', parsed.data.title, sourceLocale)) } } if ('order' in parsed.data) updates.order = parsed.data.order const [updated] = await db .update(priceListGroups) .set(updates) .where(eq(priceListGroups.id, groupId)) .returning() return c.json(updated) }) // ─── DELETE /price-list/groups/:groupId ─────────────────────────────────── app.delete('/groups/:groupId', requireAuth, async (c) => { const user = c.get('user') const groupId = c.req.param('groupId') const [group] = await db.select().from(priceListGroups).where(eq(priceListGroups.id, groupId)) if (!group) return c.json({ error: 'Not found' }, 404) if (group.specialistId !== user.id && user.role !== 'admin') return c.json({ error: 'Forbidden' }, 403) await db.delete(priceListGroups).where(eq(priceListGroups.id, groupId)) return c.json({ ok: true }) }) // ─── POST /price-list/groups/:groupId/items ─────────────────────────────── const itemSchema = z.object({ name: z.string().min(1).max(200), description: z.string().max(1000).optional().nullable(), price: z.string().regex(/^\d+(\.\d{1,2})?$/).optional().nullable(), unit: z.string().max(100).optional().nullable(), locale: z.enum(['el', 'en', 'ru', 'uk']).optional(), autoTranslate: z.boolean().optional(), order: z.number().int().min(0).optional(), }) app.post('/groups/:groupId/items', requireAuth, async (c) => { const user = c.get('user') const groupId = c.req.param('groupId') const [group] = await db.select().from(priceListGroups).where(eq(priceListGroups.id, groupId)) if (!group) return c.json({ error: 'Group not found' }, 404) if (group.specialistId !== user.id && user.role !== 'admin') return c.json({ error: 'Forbidden' }, 403) const body = await c.req.json() const parsed = itemSchema.safeParse(body) if (!parsed.success) return c.json({ error: 'Invalid data', details: parsed.error.issues }, 400) const [{ count }] = await db .select({ count: sql`cast(count(*) as int)` }) .from(priceListItems) .where(eq(priceListItems.groupId, groupId)) const [item] = await db .insert(priceListItems) .values({ groupId, cardId: group.cardId, specialistId: group.specialistId, name: parsed.data.name, description: parsed.data.description ?? null, price: parsed.data.price ?? null, unit: parsed.data.unit ?? null, ...await translateToLocaleFields('name', parsed.data.name, normalizeLocale(parsed.data.locale ?? (user as any).locale)), ...(parsed.data.description ? await translateToLocaleFields('description', parsed.data.description, normalizeLocale(parsed.data.locale ?? (user as any).locale)) : { descriptionEl: null, descriptionEn: null, descriptionRu: null, descriptionUk: null, }), ...(parsed.data.unit ? await translateToLocaleFields('unit', parsed.data.unit, normalizeLocale(parsed.data.locale ?? (user as any).locale)) : { unitEl: null, unitEn: null, unitRu: null, unitUk: null, }), originalLocale: normalizeLocale(parsed.data.locale ?? (user as any).locale), order: parsed.data.order ?? count, }) .returning() return c.json(item, 201) }) // ─── PATCH /price-list/items/:itemId ───────────────────────────────────── app.patch('/items/:itemId', requireAuth, async (c) => { const user = c.get('user') const itemId = c.req.param('itemId') const [item] = await db.select().from(priceListItems).where(eq(priceListItems.id, itemId)) 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 = z.object({ name: z.string().min(1).max(200).optional(), description: z.string().max(1000).optional().nullable(), price: z.string().regex(/^\d+(\.\d{1,2})?$/).optional().nullable(), unit: z.string().max(100).optional().nullable(), locale: z.enum(['el', 'en', 'ru', 'uk']).optional(), autoTranslate: z.boolean().optional(), order: z.number().int().min(0).optional(), }).safeParse(body) if (!parsed.success) return c.json({ error: 'Invalid data', details: parsed.error.issues }, 400) const updates: any = {} const sourceLocale = normalizeLocale(parsed.data.locale ?? item.originalLocale) if ('name' in parsed.data && parsed.data.name) { updates.name = parsed.data.name updates[`name${localeCap(sourceLocale)}`] = parsed.data.name updates.originalLocale = sourceLocale if (parsed.data.autoTranslate !== false) { Object.assign(updates, await translateToLocaleFields('name', parsed.data.name, sourceLocale)) } } if ('description' in parsed.data) { updates.description = parsed.data.description updates[`description${localeCap(sourceLocale)}`] = parsed.data.description if (parsed.data.autoTranslate !== false && parsed.data.description) { Object.assign(updates, await translateToLocaleFields('description', parsed.data.description, sourceLocale)) } } if ('price' in parsed.data) updates.price = parsed.data.price if ('unit' in parsed.data) { updates.unit = parsed.data.unit updates[`unit${localeCap(sourceLocale)}`] = parsed.data.unit if (parsed.data.autoTranslate !== false && parsed.data.unit) { Object.assign(updates, await translateToLocaleFields('unit', parsed.data.unit, sourceLocale)) } } if ('order' in parsed.data) updates.order = parsed.data.order const [updated] = await db .update(priceListItems) .set(updates) .where(eq(priceListItems.id, itemId)) .returning() return c.json(updated) }) // ─── DELETE /price-list/items/:itemId ──────────────────────────────────── app.delete('/items/:itemId', requireAuth, async (c) => { const user = c.get('user') const itemId = c.req.param('itemId') const [item] = await db.select().from(priceListItems).where(eq(priceListItems.id, itemId)) if (!item) return c.json({ error: 'Not found' }, 404) if (item.specialistId !== user.id && user.role !== 'admin') return c.json({ error: 'Forbidden' }, 403) await db.delete(priceListItems).where(eq(priceListItems.id, itemId)) return c.json({ ok: true }) }) export default app