/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/specialist-cards.ts (18641B)
import { Hono } from 'hono' import { z } from 'zod' import { and, eq, asc, sql } from 'drizzle-orm' import { db } from '../db.js' import { categorySuggestions, specialistCards, portfolioItems, users, plans } from '@canhelp/db' import { requireAuth, type AuthVariables } from '../middleware/auth.js' import { generateSpecialistDescription, translateString } from '../translate.js' import { notifyAdmin } from '../lib/telegram.js' const app = new Hono<{ Variables: AuthVariables }>() const DEFAULT_MAX_CARDS = 1 // Returns null = unlimited, number = hard limit // Fetches planId directly from DB since session user may not include it async function getMaxCards(user: any): Promise { if (user.role === 'admin') return null // admins always unlimited const [dbUser] = await db.select({ planId: users.planId }).from(users).where(eq(users.id, user.id)) const planId = dbUser?.planId if (!planId) return DEFAULT_MAX_CARDS const [plan] = await db.select().from(plans).where(eq(plans.id, planId)) if (!plan) return DEFAULT_MAX_CARDS return plan.maxCards ?? null // null in plan means unlimited } const DEFAULT_MAX_SKILLS = 5 async function getMaxSkills(user: any): Promise { if (user.role === 'admin') return null const [dbUser] = await db.select({ planId: users.planId }).from(users).where(eq(users.id, user.id)) const planId = dbUser?.planId if (!planId) return DEFAULT_MAX_SKILLS const [plan] = await db.select().from(plans).where(eq(plans.id, planId)) if (!plan) return DEFAULT_MAX_SKILLS return plan.maxSkills ?? null } // ─── GET /specialist-cards/my ───────────────────────────────────────────── app.get('/my', requireAuth, async (c) => { const user = c.get('user') const locale = normalizeLocale(c.req.query('locale')) const cards = await db .select() .from(specialistCards) .where(eq(specialistCards.specialistId, user.id)) .orderBy(asc(specialistCards.order), asc(specialistCards.createdAt)) // Attach portfolio items per card const allPortfolio = await db .select() .from(portfolioItems) .where(eq(portfolioItems.specialistId, user.id)) .orderBy(asc(portfolioItems.order), asc(portfolioItems.createdAt)) const maxCards = await getMaxCards(user) const result = await Promise.all(cards.map(async (card) => ({ ...await localizeCardPublicFields(card, locale), portfolio: allPortfolio.filter((p) => p.cardId === card.id), }))) return c.json({ cards: result, maxCards }) }) // ─── POST /specialist-cards ─────────────────────────────────────────────── // Builds the four locale description fields, copying `description` into the // field that matches `originalLocale` when that field wasn't explicitly provided. function buildDescriptionFields( data: { description?: string | null; descriptionEl?: string | null; descriptionEn?: string | null; descriptionRu?: string | null; descriptionUk?: string | null }, originalLocale: 'el' | 'en' | 'ru' | 'uk', ) { const fallback = data.description ?? null return { descriptionEl: data.descriptionEl ?? (originalLocale === 'el' ? fallback : null), descriptionEn: data.descriptionEn ?? (originalLocale === 'en' ? fallback : null), descriptionRu: data.descriptionRu ?? (originalLocale === 'ru' ? fallback : null), descriptionUk: data.descriptionUk ?? (originalLocale === 'uk' ? fallback : null), } } function normalizeLocale(input?: string | null): 'el' | 'en' | 'ru' | 'uk' { if (input === 'el' || input === 'en' || input === 'ru' || input === 'uk') return input return 'el' } function pickStoredDescriptionByLocale( card: { description: string | null descriptionEl: string | null descriptionEn: string | null descriptionRu: string | null descriptionUk: string | null }, locale: 'el' | 'en' | 'ru' | 'uk', ): string | null { if (locale === 'el') return card.descriptionEl ?? null if (locale === 'en') return card.descriptionEn ?? null if (locale === 'ru') return card.descriptionRu ?? null return card.descriptionUk ?? null } async function localizeCardPublicFields( card: any, locale: 'el' | 'en' | 'ru' | 'uk', ) { const sourceLocale = normalizeLocale(card.originalLocale) const storedDescription = pickStoredDescriptionByLocale(card, locale) const fallbackDescription = card.description ?? storedDescription ?? null const localTitlePromise = locale === sourceLocale || !card.title ? Promise.resolve(card.title) : translateString(card.title, sourceLocale, locale).then((t) => t || card.title).catch(() => card.title) const localDescriptionPromise = storedDescription != null ? Promise.resolve(storedDescription) : !card.description || locale === sourceLocale ? Promise.resolve(fallbackDescription) : translateString(card.description, sourceLocale, locale) .then((t) => t || fallbackDescription) .catch(() => fallbackDescription) const localSkillsPromise = locale === sourceLocale || !Array.isArray(card.skills) || card.skills.length === 0 ? Promise.resolve(card.skills) : Promise.all( card.skills.map((skill: string) => translateString(skill, sourceLocale, locale).then((t) => t || skill).catch(() => skill), ), ) const [title, description, skills] = await Promise.all([ localTitlePromise, localDescriptionPromise, localSkillsPromise, ]) return { ...card, title, description, skills, } } const createSchema = z.object({ title: z.string().min(1).max(200), description: z.string().max(2000).optional().nullable(), descriptionEl: z.string().max(2000).optional().nullable(), descriptionEn: z.string().max(2000).optional().nullable(), descriptionRu: z.string().max(2000).optional().nullable(), descriptionUk: z.string().max(2000).optional().nullable(), descriptionLocale: z.enum(['el', 'en', 'ru', 'uk']).optional(), skills: z.array(z.string().max(100)).max(20).optional().default([]), categories: z.array(z.string()).max(30).optional().default([]), locations: z.array(z.string()).max(50).optional().default([]), }) const generateDescriptionSchema = z.object({ categories: z.array(z.string().trim().min(1).max(100)).min(1).max(30), locale: z.enum(['el', 'en', 'ru', 'uk']), }) function limitDescriptionWords(description: string, maxWords = 500): string { let wordCount = 0 let result = '' for (const part of description.split(/(\s+)/)) { if (/^\s+$/.test(part)) { result += part continue } if (wordCount >= maxWords) break result += part wordCount++ } return result.trim() } app.post('/generate-description', requireAuth, async (c) => { const parsed = generateDescriptionSchema.safeParse(await c.req.json()) if (!parsed.success) return c.json({ error: 'Invalid data', details: parsed.error.issues }, 400) const description = await generateSpecialistDescription(parsed.data.categories, parsed.data.locale) if (!description) return c.json({ error: 'Description generation is unavailable' }, 503) return c.json({ description: limitDescriptionWords(description) }) }) app.post('/', requireAuth, async (c) => { const user = c.get('user') const maxCards = await getMaxCards(user) const [{ count }] = await db .select({ count: sql`cast(count(*) as int)` }) .from(specialistCards) .where(eq(specialistCards.specialistId, user.id)) if (maxCards !== null && count >= maxCards) { return c.json({ error: `Your plan allows maximum ${maxCards} specialist card(s)`, code: 'LIMIT_REACHED', maxCards }, 403) } const body = await c.req.json() const parsed = createSchema.safeParse(body) if (!parsed.success) return c.json({ error: 'Invalid data', details: parsed.error.issues }, 400) // Check maxSkills plan limit const maxSkills = await getMaxSkills(user) if (maxSkills !== null && parsed.data.skills.length > maxSkills) { return c.json({ error: `Your plan allows maximum ${maxSkills} skill(s) per card`, code: 'SKILLS_LIMIT', maxSkills }, 403) } const [card] = await db .insert(specialistCards) .values({ specialistId: user.id, title: parsed.data.title, description: parsed.data.description ?? null, publicationStatus: 'inactive', isActive: false, // Use explicit locale if provided, otherwise fall back to the user's profile locale ...buildDescriptionFields(parsed.data, (parsed.data.descriptionLocale ?? (user as any).locale ?? 'el') as 'el' | 'en' | 'ru' | 'uk'), originalLocale: (parsed.data.descriptionLocale ?? (user as any).locale ?? 'el') as string, skills: parsed.data.skills, categories: parsed.data.categories, locations: parsed.data.locations, order: count, }) .returning() return c.json({ ...card, portfolio: [] }, 201) }) // ─── PATCH /specialist-cards/:id ────────────────────────────────────────── const updateSchema = z.object({ title: z.string().min(1).max(200).optional(), description: z.string().max(2000).optional().nullable(), descriptionEl: z.string().max(2000).optional().nullable(), descriptionEn: z.string().max(2000).optional().nullable(), descriptionRu: z.string().max(2000).optional().nullable(), descriptionUk: z.string().max(2000).optional().nullable(), descriptionLocale: z.enum(['el', 'en', 'ru', 'uk']).optional(), autoTranslate: z.boolean().optional(), publicationStatus: z.enum(['pending', 'active', 'inactive']).optional(), skills: z.array(z.string().max(100)).max(20).optional(), categories: z.array(z.string()).max(30).optional(), locations: z.array(z.string()).max(50).optional(), isActive: z.boolean().optional(), 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 [card] = await db.select().from(specialistCards).where(eq(specialistCards.id, id)) if (!card) return c.json({ error: 'Not found' }, 404) if (card.specialistId !== user.id && user.role !== 'admin') return c.json({ error: 'Forbidden' }, 403) const body = await c.req.json() const parsed = updateSchema.safeParse(body) if (!parsed.success) return c.json({ error: 'Invalid data', details: parsed.error.issues }, 400) // Check maxSkills plan limit on update if (parsed.data.skills) { const maxSkills = await getMaxSkills(user) if (maxSkills !== null && parsed.data.skills.length > maxSkills) { return c.json({ error: `Your plan allows maximum ${maxSkills} skill(s) per card`, code: 'SKILLS_LIMIT', maxSkills }, 403) } } 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 ('descriptionEl' in parsed.data) updates.descriptionEl = parsed.data.descriptionEl if ('descriptionEn' in parsed.data) updates.descriptionEn = parsed.data.descriptionEn if ('descriptionRu' in parsed.data) updates.descriptionRu = parsed.data.descriptionRu if ('descriptionUk' in parsed.data) updates.descriptionUk = parsed.data.descriptionUk if ('publicationStatus' in parsed.data) { updates.publicationStatus = parsed.data.publicationStatus updates.isActive = parsed.data.publicationStatus === 'active' } if ('skills' in parsed.data) updates.skills = parsed.data.skills if ('categories' in parsed.data) updates.categories = parsed.data.categories if ('locations' in parsed.data) updates.locations = parsed.data.locations if ('isActive' in parsed.data) { updates.isActive = parsed.data.isActive if (!('publicationStatus' in parsed.data)) { updates.publicationStatus = parsed.data.isActive ? 'active' : 'inactive' } } if ('order' in parsed.data) updates.order = parsed.data.order if (parsed.data.descriptionLocale) { updates.originalLocale = parsed.data.descriptionLocale if ('description' in parsed.data) { const cap = parsed.data.descriptionLocale.charAt(0).toUpperCase() + parsed.data.descriptionLocale.slice(1) updates[`description${cap}`] = parsed.data.description } if ('title' in parsed.data) { const cap = parsed.data.descriptionLocale.charAt(0).toUpperCase() + parsed.data.descriptionLocale.slice(1) updates[`title${cap}`] = parsed.data.title if (parsed.data.autoTranslate) { const targets = (['el', 'en', 'ru', 'uk'] as const).filter((locale) => locale !== parsed.data.descriptionLocale) const results = await Promise.all( targets.map(async (to) => ({ to, text: await translateString(parsed.data.title!, parsed.data.descriptionLocale!, to) })), ) for (const result of results) { const targetCap = result.to.charAt(0).toUpperCase() + result.to.slice(1) updates[`title${targetCap}`] = result.text || parsed.data.title } } } } const [updated] = await db .update(specialistCards) .set(updates) .where(eq(specialistCards.id, id)) .returning() const nextPublicationStatus = updates.publicationStatus ?? card.publicationStatus ?? (card.isActive ? 'active' : 'inactive') if (card.publicationStatus !== 'pending' && nextPublicationStatus === 'pending') { notifyAdmin( `📝 Карточка отправлена на проверку\n👤 ${(user as any).firstName || user.name || user.email}\n📧 ${user.email}\n🏷 ${updated.title}\n🆔 ${updated.id}`, ).catch((err) => { console.error('[specialist-cards] telegram notify ERROR:', err) }) } // Background auto-translation of description to other locales const srcLocale = parsed.data.descriptionLocale if (parsed.data.autoTranslate && srcLocale) { const descKey = `description${srcLocale.charAt(0).toUpperCase()}${srcLocale.slice(1)}` as keyof typeof updates const srcText = updates[descKey] as string | null | undefined if (srcText) { const targets = (['el', 'en', 'ru', 'uk'] as const).filter((l) => l !== srcLocale) Promise.allSettled( targets.map((to) => translateString(srcText, srcLocale, to).then((t) => ({ to, t }))), ).then(async (results) => { const translationUpdates: Record = {} for (const r of results) { if (r.status === 'fulfilled' && r.value.t) { const cap = r.value.to.charAt(0).toUpperCase() + r.value.to.slice(1) translationUpdates[`description${cap}`] = r.value.t } } if (Object.keys(translationUpdates).length > 0) { await db.update(specialistCards) .set({ ...translationUpdates, updatedAt: new Date() }) .where(eq(specialistCards.id, id)) } }).catch((e) => console.error('[card-translate]', e)) } } return c.json(updated) }) const categorySuggestionSchema = z.object({ name: z.string().trim().min(2).max(100), locale: z.enum(['el', 'en', 'ru', 'uk']), }) app.post('/:id/category-suggestions', requireAuth, async (c) => { const user = c.get('user') const cardId = c.req.param('id') const [card] = await db.select().from(specialistCards).where(eq(specialistCards.id, cardId)) if (!card) return c.json({ error: 'Not found' }, 404) if (card.specialistId !== user.id && user.role !== 'admin') return c.json({ error: 'Forbidden' }, 403) const parsed = categorySuggestionSchema.safeParse(await c.req.json()) if (!parsed.success) return c.json({ error: 'Invalid data', details: parsed.error.issues }, 400) const [existing] = await db.select().from(categorySuggestions).where(and( eq(categorySuggestions.cardId, cardId), eq(categorySuggestions.name, parsed.data.name), eq(categorySuggestions.status, 'pending'), )) if (existing) return c.json(existing) const [suggestion] = await db.insert(categorySuggestions).values({ cardId, specialistId: card.specialistId, name: parsed.data.name, locale: parsed.data.locale, }).returning() notifyAdmin( `🏷 Новая заявка на категорию\nКарточка: ${card.title}\nКатегория: ${suggestion.name}\n🆔 ${card.id}`, ).catch((err) => console.error('[specialist-cards] category suggestion notify ERROR:', err)) return c.json(suggestion, 201) }) // ─── DELETE /specialist-cards/:id ───────────────────────────────────────── app.delete('/:id', requireAuth, async (c) => { const user = c.get('user') const id = c.req.param('id') const [card] = await db.select().from(specialistCards).where(eq(specialistCards.id, id)) if (!card) return c.json({ error: 'Not found' }, 404) if (card.specialistId !== user.id && user.role !== 'admin') return c.json({ error: 'Forbidden' }, 403) await db.delete(specialistCards).where(eq(specialistCards.id, id)) return c.json({ ok: true }) }) // ─── GET /specialist-cards/user/:userId (public) ────────────────────────── app.get('/user/:userId', async (c) => { const userId = c.req.param('userId') const locale = normalizeLocale(c.req.query('locale')) const cards = await db .select() .from(specialistCards) .where(eq(specialistCards.specialistId, userId)) .orderBy(asc(specialistCards.order), asc(specialistCards.createdAt)) const activeCards = cards.filter((c) => (c.publicationStatus ?? (c.isActive ? 'active' : 'inactive')) === 'active') const allPortfolio = 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)) const result = await Promise.all( activeCards.map(async (card) => { const localized = await localizeCardPublicFields(card, locale) return { ...localized, portfolio: allPortfolio.filter((p) => p.cardId === card.id), } }), ) return c.json(result) }) export default app