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