/opt/canhelp/apps/api/src/routes
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