/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/tasks.ts (30018B)
import { Hono } from 'hono' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' import { eq, and, ilike, or, desc, count, gte, lte, sql, min, max, inArray, isNull } from 'drizzle-orm' import { db } from '../db.js' import { tasks, users, locations, offers, specialistCards, plans, reviews, categories } from '@canhelp/db' import { requireAuth, type AuthVariables } from '../middleware/auth.js' import { translateTaskAllLocales } from '../translate.js' import { emailNewTask, emailTaskUpdated } from '../lib/email.js' import { notifyTaskInvite, notifyTaskUpdated } from '../lib/notif.js' import { postTaskToChannels, isNewTaskPostEnabled } from '../lib/telegram.js' import { abbrevName } from '../lib/nameUtils.js' import { verifyTurnstile } from '../lib/turnstile.js' import { checkOrderLimit, incrementOrders } from '../lib/daily-limits.js' import { recordTaskView, getTaskStats } from '../lib/fomo.js' import { triggerAutoResponses } from './auto-response.js' import { getHelperCapability, isAdminRole } from '../lib/helper-capability.js' const app = new Hono<{ Variables: AuthVariables }>() function shouldBypassTaskCaptcha(c: any): boolean { const origin = c.req.header('Origin') ?? '' const userAgent = (c.req.header('User-Agent') ?? '').toLowerCase() // Keep CAPTCHA for browser-based create-task flows. if (origin) return false // Native mobile clients usually call API without Origin. return ( userAgent.includes('dart') || userAgent.includes('flutter') || userAgent.includes('cfnetwork') || userAgent.includes('okhttp') ) } /** Returns [slug] for a leaf category, or [slug, ...childSlugs] for a parent */ async function expandCategorySlugs(slug: string): Promise { const parent = await db .select({ id: categories.id }) .from(categories) .where(eq(categories.slug, slug)) .limit(1) if (!parent.length) return [slug] const children = await db .select({ slug: categories.slug }) .from(categories) .where(eq(categories.parentId, parent[0].id)) return children.length > 0 ? [slug, ...children.map(c => c.slug)] : [slug] } /** Returns [slug] for a leaf, or [slug, ...childSlugs] for a parent location */ async function expandLocationSlugs(slug: string): Promise { const parent = await db .select({ id: locations.id }) .from(locations) .where(eq(locations.slug, slug)) .limit(1) if (!parent.length) return [slug] const children = await db .select({ slug: locations.slug }) .from(locations) .where(eq(locations.parentId, parent[0].id)) if (!children.length) return [slug] return [slug, ...children.map((c) => c.slug)] } const createTaskSchema = z.object({ title: z.string().min(5).max(200), description: z.string().min(20), budget: z.number().positive().optional(), budgetNegotiable: z.boolean().default(false), category: z.string().optional(), location: z.string().optional(), district: z.string().optional(), street: z.string().optional(), houseNumber: z.string().optional(), timeSlot: z.string().optional(), confidentialNote: z.string().optional(), images: z.array(z.string().url()).optional(), deadline: z.string().datetime().optional(), expiresAt: z.string().datetime().optional(), status: z.enum(['draft', 'open']).default('open'), locale: z.enum(['el', 'en', 'uk', 'ru']).default('el'), captchaToken: z.string().optional(), inviteSpecialistIds: z.array(z.string().min(1).max(64)).max(10).optional(), }) // GET /tasks/budget-range — min and max budget of open tasks (respects q/category/location filters) app.get('/budget-range', async (c) => { const q = c.req.query('q') const category = c.req.query('category') const location = c.req.query('location') const conditions = [eq(tasks.status, 'open'), sql`${tasks.budget} IS NOT NULL`] if (q) conditions.push(or(ilike(tasks.title, `%${q}%`), ilike(tasks.description, `%${q}%`))!) if (category) { const catSlugs = await expandCategorySlugs(category) conditions.push(catSlugs.length > 1 ? inArray(tasks.category, catSlugs) : eq(tasks.category, catSlugs[0])) } if (location) { const slugs = await expandLocationSlugs(location) const locFilter = slugs.length > 1 ? inArray(tasks.location, slugs) : eq(tasks.location, slugs[0]) conditions.push(or(locFilter, isNull(tasks.location))!) } const [result] = await db .select({ min: min(sql`CAST(${tasks.budget} AS numeric)`), max: max(sql`CAST(${tasks.budget} AS numeric)`), }) .from(tasks) .where(and(...conditions)) return c.json({ min: result?.min ? Math.floor(Number(result.min)) : 0, max: result?.max ? Math.ceil(Number(result.max)) : 0, }) }) // GET /tasks — list open tasks (public), supports ?q= &category= &location= &budgetMin= &budgetMax= app.get('/', async (c) => { const page = Number(c.req.query('page') || 1) const limit = Math.min(Number(c.req.query('limit') || 20), 100) const offset = (page - 1) * limit const q = c.req.query('q') const category = c.req.query('category') const location = c.req.query('location') const budgetMin = c.req.query('budgetMin') const budgetMax = c.req.query('budgetMax') const conditions = [eq(tasks.status, 'open')] if (q) conditions.push(or( ilike(tasks.title, `%${q}%`), ilike(tasks.description, `%${q}%`), ilike(tasks.titleEl, `%${q}%`), ilike(tasks.titleEn, `%${q}%`), ilike(tasks.titleRu, `%${q}%`), ilike(tasks.titleUk, `%${q}%`), )!) if (category) { const catSlugs = await expandCategorySlugs(category) conditions.push(catSlugs.length > 1 ? inArray(tasks.category, catSlugs) : eq(tasks.category, catSlugs[0])) } if (location) { const slugs = await expandLocationSlugs(location) const locFilter = slugs.length > 1 ? inArray(tasks.location, slugs) : eq(tasks.location, slugs[0]) conditions.push(or(locFilter, isNull(tasks.location))!) } if (budgetMin) conditions.push(gte(sql`CAST(${tasks.budget} AS numeric)`, Number(budgetMin))) if (budgetMax) conditions.push(lte(sql`CAST(${tasks.budget} AS numeric)`, Number(budgetMax))) const where = and(...conditions) const [rows, [{ total }]] = await Promise.all([ db .select({ task: tasks, customer: { id: users.id, name: users.name, firstName: users.firstName, lastName: users.lastName, image: users.image, rating: sql`(SELECT ROUND(AVG(r.rating)::numeric, 1) FROM reviews r WHERE r.target_id = ${users.id})`, }, }) .from(tasks) .leftJoin(users, eq(tasks.customerId, users.id)) .where(where) .orderBy(desc(tasks.createdAt)) .limit(limit) .offset(offset), db.select({ total: count() }).from(tasks).where(where), ]) return c.json({ data: rows, total, page, limit }) }) // GET /tasks/search app.get('/search', async (c) => { const q = c.req.query('q') || '' const category = c.req.query('category') const location = c.req.query('location') const page = Number(c.req.query('page') || 1) const limit = Math.min(Number(c.req.query('limit') || 20), 100) const offset = (page - 1) * limit const conditions = [eq(tasks.status, 'open')] if (q) conditions.push(or( ilike(tasks.title, `%${q}%`), ilike(tasks.description, `%${q}%`), ilike(tasks.titleEl, `%${q}%`), ilike(tasks.titleEn, `%${q}%`), ilike(tasks.titleRu, `%${q}%`), ilike(tasks.titleUk, `%${q}%`), )!) if (category) { const catSlugs = await expandCategorySlugs(category) conditions.push(catSlugs.length > 1 ? inArray(tasks.category, catSlugs) : eq(tasks.category, catSlugs[0])) } if (location) { const slugs = await expandLocationSlugs(location) const locFilter = slugs.length > 1 ? inArray(tasks.location, slugs) : eq(tasks.location, slugs[0]) conditions.push(or(locFilter, isNull(tasks.location))!) } const rows = await db .select() .from(tasks) .where(and(...conditions)) .orderBy(desc(tasks.createdAt)) .limit(limit) .offset(offset) const [{ total }] = await db .select({ total: count() }) .from(tasks) .where(and(...conditions)) return c.json({ data: rows, total, page, limit }) }) // GET /tasks/counts-by-category — count of open tasks per category slug (public) // Optional query params: locations (comma-separated slugs), categories (comma-separated slugs) app.get('/counts-by-category', async (c) => { const conditions = [eq(tasks.status, 'open'), sql`${tasks.category} IS NOT NULL`] const locationsParam = c.req.query('locations') if (locationsParam) { const locationSlugs = locationsParam.split(',') const allExpanded: string[] = [] for (const slug of locationSlugs) { const expanded = await expandLocationSlugs(slug.trim()) allExpanded.push(...expanded) } const unique = [...new Set(allExpanded)] if (unique.length > 0) conditions.push(inArray(tasks.location, unique)) } const categoriesParam = c.req.query('categories') if (categoriesParam) { const cats = categoriesParam.split(',').map((s) => s.trim()).filter(Boolean) if (cats.length > 0) conditions.push(inArray(tasks.category, cats)) } const rows = await db .select({ category: tasks.category, cnt: count() }) .from(tasks) .where(and(...conditions)) .groupBy(tasks.category) const result: Record = {} for (const row of rows) { if (row.category) result[row.category] = Number(row.cnt) } return c.json(result) }) // GET /tasks/for-me — open tasks matching specialist's categories/locations app.get('/for-me', requireAuth, async (c) => { const user = c.get('user') const isHelperReady = isAdminRole(user.role) ? true : await getHelperCapability(user.id) if (!isHelperReady) { return c.json({ error: 'Only specialists can use this endpoint' }, 403) } // Get specialist's active cards const cards = await db .select({ categories: specialistCards.categories, locations: specialistCards.locations }) .from(specialistCards) .where(and(eq(specialistCards.specialistId, user.id), eq(specialistCards.isActive, true))) if (!cards.length) { return c.json({ noCards: true, data: [], total: 0, page: 1, limit: 20 }) } // Aggregate categories and locations across all active cards const myCategories = [...new Set(cards.flatMap((c) => c.categories ?? []))] const myLocations = [...new Set(cards.flatMap((c) => c.locations ?? []))] const page = Number(c.req.query('page') || 1) const limit = Math.min(Number(c.req.query('limit') || 20), 100) const offset = (page - 1) * limit const q = c.req.query('q') const categoryFilter = c.req.query('category') const locationFilter = c.req.query('location') const budgetMin = c.req.query('budgetMin') const budgetMax = c.req.query('budgetMax') const conditions = [eq(tasks.status, 'open')] // By default filter to specialist's own categories/locations (unless overridden) if (categoryFilter) { const catSlugs = await expandCategorySlugs(categoryFilter) const catFilter = catSlugs.length > 1 ? inArray(tasks.category, catSlugs) : eq(tasks.category, catSlugs[0]) // Also include uncategorized tasks (visible to all specialists) conditions.push(or(catFilter, isNull(tasks.category))!) } else if (myCategories.length > 0) { // Also include tasks with no category (they're visible to all specialists) conditions.push(or(inArray(tasks.category, myCategories), isNull(tasks.category))!) } if (locationFilter) { const slugs = await expandLocationSlugs(locationFilter) const locFilter = slugs.length > 1 ? inArray(tasks.location, slugs) : eq(tasks.location, slugs[0]) conditions.push(or(locFilter, isNull(tasks.location))!) } else if (myLocations.length > 0) { // Expand all specialist locations const allSlugs: string[] = [] for (const loc of myLocations) { const expanded = await expandLocationSlugs(loc) allSlugs.push(...expanded) } const unique = [...new Set(allSlugs)] // Also include tasks with no location (remote/nationwide tasks visible to all) if (unique.length > 0) conditions.push(or(inArray(tasks.location, unique), isNull(tasks.location))!) } if (q) conditions.push(or( ilike(tasks.title, `%${q}%`), ilike(tasks.description, `%${q}%`), ilike(tasks.titleEl, `%${q}%`), ilike(tasks.titleEn, `%${q}%`), ilike(tasks.titleRu, `%${q}%`), ilike(tasks.titleUk, `%${q}%`), )!) if (budgetMin) conditions.push(gte(sql`CAST(${tasks.budget} AS numeric)`, Number(budgetMin))) if (budgetMax) conditions.push(lte(sql`CAST(${tasks.budget} AS numeric)`, Number(budgetMax))) const where = and(...conditions) const [rows, [{ total }]] = await Promise.all([ db .select({ task: tasks, customer: { id: users.id, name: users.name, firstName: users.firstName, lastName: users.lastName, image: users.image, rating: sql`(SELECT ROUND(AVG(r.rating)::numeric, 1) FROM reviews r WHERE r.target_id = ${users.id})`, }, }) .from(tasks) .leftJoin(users, eq(tasks.customerId, users.id)) .where(where) .orderBy(desc(tasks.createdAt)) .limit(limit) .offset(offset), db.select({ total: count() }).from(tasks).where(where), ]) // Check which tasks the specialist has already offered on const taskIds = rows.map((r) => r.task.id) const existingOffers = taskIds.length ? await db .select({ taskId: offers.taskId }) .from(offers) .where(and(eq(offers.specialistId, user.id), inArray(offers.taskId, taskIds))) : [] const offeredSet = new Set(existingOffers.map((o) => o.taskId)) const data = rows.map((r) => ({ ...r, alreadyOffered: offeredSet.has(r.task.id) })) return c.json({ data, total: Number(total), page, limit }) }) // GET /tasks/my — customer's own tasks with offer counts app.get('/my', requireAuth, async (c) => { const user = c.get('user') const rows = await db .select() .from(tasks) .where(eq(tasks.customerId, user.id)) .orderBy(desc(tasks.createdAt)) if (rows.length === 0) return c.json([]) const taskIds = rows.map((r) => r.id) const offerCounts = await db .select({ taskId: offers.taskId, total: count(), declined: count(sql`CASE WHEN ${offers.status} = 'declined' THEN 1 END`), }) .from(offers) .where(inArray(offers.taskId, taskIds)) .groupBy(offers.taskId) const countMap: Record = {} for (const row of offerCounts) { if (row.taskId) countMap[row.taskId] = { total: Number(row.total), declined: Number(row.declined) } } return c.json(rows.map((t) => ({ ...t, offerCount: countMap[t.id]?.total ?? 0, declinedCount: countMap[t.id]?.declined ?? 0, }))) }) // GET /tasks/:id app.get('/:id', async (c) => { const [row] = await db .select({ task: tasks, customer: { id: users.id, name: users.name, firstName: users.firstName, lastName: users.lastName, image: users.image, rating: sql`(SELECT ROUND(AVG(r.rating)::numeric, 1) FROM reviews r WHERE r.target_id = ${users.id})`, }, }) .from(tasks) .leftJoin(users, eq(tasks.customerId, users.id)) .where(eq(tasks.id, c.req.param('id'))) if (!row) return c.json({ error: 'Not found' }, 404) const requestingUserId = c.var.user?.id ?? null const isOwner = requestingUserId === row.task.customerId // Check if requesting user is the accepted specialist for this task let isAcceptedSpecialist = false if (requestingUserId && !isOwner) { const [acceptedOffer] = await db .select({ specialistId: offers.specialistId }) .from(offers) .where( and( eq(offers.taskId, row.task.id), eq(offers.specialistId, requestingUserId), eq(offers.status, 'accepted'), ), ) .limit(1) isAcceptedSpecialist = !!acceptedOffer } // Strip confidential fields for everyone except owner and accepted specialist const canSeeConfidential = isOwner || isAcceptedSpecialist const taskData = canSeeConfidential ? row.task : { ...row.task, confidentialNote: null, street: null, houseNumber: null } return c.json({ ...row, task: taskData }) }) // ─── Helper: send email notifications to matching specialists ───────────── async function notifySpecialistsAboutNewTask( task: { id: string; title: string; category: string | null; customerId: string }, customer: { id: string; firstName?: string; lastName?: string; name?: string }, ) { if (!task.category) return // Determine customer's plan tier for dispatch filtering let customerTier = 'free' const [custUser] = await db.select({ planId: users.planId }).from(users).where(eq(users.id, customer.id)) if (custUser?.planId) { const [custPlan] = await db.select({ tier: plans.tier }).from(plans).where(eq(plans.id, custUser.planId)) customerTier = custPlan?.tier ?? 'free' } // Find active specialist cards whose categories contain this task's category const matchingCards = await db .select({ specialistId: specialistCards.specialistId, }) .from(specialistCards) .where( and( eq(specialistCards.isActive, true), sql`${task.category} = ANY(${specialistCards.categories})`, ), ) if (!matchingCards.length) return const specialistIds = [...new Set(matchingCards.map((c) => c.specialistId))] .filter((id) => id !== task.customerId) // don't notify the task author if (!specialistIds.length) return // Build dispatch condition based on customer tier: // - Free customer → specialist must have receiveInstantDispatchFree // - Pro customer → specialist must have receiveInstantDispatchPro // - Ultimate customer → specialist must have notifyNewTasks (standard notification) const dispatchCondition = customerTier === 'free' ? eq(plans.receiveInstantDispatchFree, true) : customerTier === 'pro' ? eq(plans.receiveInstantDispatchPro, true) : eq(plans.notifyNewTasks, true) // Ultimate: standard notification // Load specialists who have notifyNewTasks enabled AND whose plan allows dispatch const specialists = await db .select({ id: users.id, email: users.email, firstName: users.firstName, lastName: users.lastName, locale: users.locale }) .from(users) .leftJoin(plans, eq(users.planId, plans.id)) .where( and( inArray(users.id, specialistIds), eq(users.notifyNewTasks, true), dispatchCondition, ), ) const customerName = abbrevName(customer.firstName, customer.lastName, customer.name) for (const specialist of specialists) { const specialistName = abbrevName(specialist.firstName, specialist.lastName, specialist.email) const specLocale = (specialist.locale as 'el' | 'en' | 'ru' | 'uk') || 'el' // Create in-app notification notifyTaskInvite(specialist.id, task.title, task.id).catch(() => {}) emailNewTask({ to: specialist.email, specialistName, taskTitle: task.title, taskId: task.id, customerName, locale: specLocale, }).catch((e) => console.error(`[notify] email to ${specialist.email} failed:`, e)) } } // POST /tasks app.post('/', requireAuth, zValidator('json', createTaskSchema), async (c) => { const user = c.get('user') const { locale, captchaToken, inviteSpecialistIds, ...body } = c.req.valid('json') const skipCaptcha = shouldBypassTaskCaptcha(c) const ok = skipCaptcha || await verifyTurnstile(captchaToken, c.req.header('CF-Connecting-IP')) if (!ok) return c.json({ error: 'CAPTCHA verification failed. Please try again.' }, 400) // Check daily order limit const orderCheck = await checkOrderLimit(user.id) if (!orderCheck.allowed) { return c.json({ error: 'Daily task creation limit reached', code: 'DAILY_LIMIT_REACHED', limit: orderCheck.limit, current: orderCheck.current, }, 429) } const [task] = await db .insert(tasks) .values({ ...body, budget: body.budget?.toString(), deadline: body.deadline ? new Date(body.deadline) : null, expiresAt: body.expiresAt ? new Date(body.expiresAt) : null, customerId: user.id, originalLocale: locale, titleEl: locale === 'el' ? body.title : null, titleEn: locale === 'en' ? body.title : null, titleRu: locale === 'ru' ? body.title : null, titleUk: locale === 'uk' ? body.title : null, descriptionEl: locale === 'el' ? body.description : null, descriptionEn: locale === 'en' ? body.description : null, descriptionRu: locale === 'ru' ? body.description : null, descriptionUk: locale === 'uk' ? body.description : null, }) .returning() // Translate to the other locales in the background translateTaskAllLocales(body.title, body.description, locale).then(async (translations) => { const patch: { titleEl?: string; titleEn?: string; titleRu?: string; titleUk?: string descriptionEl?: string; descriptionEn?: string; descriptionRu?: string; descriptionUk?: string } = {} if (translations.el && locale !== 'el') { patch.titleEl = translations.el.title; patch.descriptionEl = translations.el.description } if (translations.en && locale !== 'en') { patch.titleEn = translations.en.title; patch.descriptionEn = translations.en.description } if (translations.ru && locale !== 'ru') { patch.titleRu = translations.ru.title; patch.descriptionRu = translations.ru.description } if (translations.uk && locale !== 'uk') { patch.titleUk = translations.uk.title; patch.descriptionUk = translations.uk.description } if (Object.keys(patch).length > 0) { await db.update(tasks).set(patch).where(eq(tasks.id, task.id)) .catch((e) => console.error('[translate] DB update failed:', e)) } // Post to Telegram channels after translations are ready (so all locales have proper text) if (task.status === 'open') { const enabled = await isNewTaskPostEnabled().catch(() => false) if (enabled) { const siteUrl = process.env.WEB_URL || process.env.NEXT_PUBLIC_APP_URL || 'https://canhelp.gr' const taskWithTranslations = { ...task, ...patch } await postTaskToChannels(taskWithTranslations, siteUrl).catch(() => {}) } } }).catch((e) => console.error('[translate] background job failed:', e)) // Notify specialists about new task (background, non-blocking) if (task.status === 'open' && task.category) { notifySpecialistsAboutNewTask(task, user).catch((e) => console.error('[notify] background job failed:', e), ) } // Notify specifically invited specialists (background, non-blocking) if (task.status === 'open' && inviteSpecialistIds && inviteSpecialistIds.length > 0) { const inviteIds = inviteSpecialistIds.filter((id) => id !== user.id) Promise.all( inviteIds.map((specialistId) => notifyTaskInvite(specialistId, task.title, task.id).catch((e) => console.error('[invite] notify failed for', specialistId, e), ), ), ).catch(() => {}) } // Trigger auto-responses from Ultimate specialists (background, non-blocking) if (task.status === 'open') { triggerAutoResponses({ id: task.id, title: task.title, category: task.category, location: task.location, budget: task.budget, customerId: task.customerId, }).catch((e) => console.error('[auto-response] trigger failed:', e)) } // Increment daily order counter incrementOrders(user.id).catch(() => {}) return c.json(task, 201) }) // PATCH /tasks/:id app.patch( '/:id', requireAuth, zValidator( 'json', z.object({ title: z.string().min(5).max(200).optional(), description: z.string().min(20).optional(), budget: z.number().positive().optional(), budgetNegotiable: z.boolean().optional(), category: z.string().optional(), location: z.string().optional(), district: z.string().optional(), street: z.string().optional(), houseNumber: z.string().optional(), timeSlot: z.string().optional(), confidentialNote: z.string().optional(), images: z.array(z.string().url()).optional(), deadline: z.string().datetime().optional(), expiresAt: z.string().datetime().optional(), status: z.enum(['draft', 'open']).optional(), }), ), async (c) => { const user = c.get('user') const [task] = await db.select().from(tasks).where(eq(tasks.id, c.req.param('id'))) if (!task) return c.json({ error: 'Not found' }, 404) if (task.customerId !== user.id) return c.json({ error: 'Forbidden' }, 403) if (!['draft', 'open'].includes(task.status)) { return c.json({ error: 'Task cannot be edited in its current status' }, 400) } const body = c.req.valid('json') const titleChanged = body.title !== undefined && body.title !== task.title const descChanged = body.description !== undefined && body.description !== task.description const retranslate = titleChanged || descChanged const [updated] = await db .update(tasks) .set({ ...body, budget: body.budget?.toString(), deadline: body.deadline ? new Date(body.deadline) : undefined, expiresAt: body.expiresAt ? new Date(body.expiresAt) : undefined, updatedAt: new Date(), // Clear translated fields when source text changes so stale translations are removed ...(retranslate ? { titleEl: null, titleEn: null, titleRu: null, titleUk: null, descriptionEl: null, descriptionEn: null, descriptionRu: null, descriptionUk: null, } : {}), }) .where(eq(tasks.id, task.id)) .returning() // Re-translate all locales in background if title or description changed if (retranslate) { const newTitle = body.title ?? task.title const newDesc = body.description ?? task.description const fromLocale = task.originalLocale ?? 'el' translateTaskAllLocales(newTitle, newDesc, fromLocale).then(async (translations) => { const patch: Record = {} for (const lang of ['el', 'en', 'ru', 'uk'] as const) { const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1) if (translations[lang]) { patch[`title${cap(lang)}`] = translations[lang].title patch[`description${cap(lang)}`] = translations[lang].description } } if (Object.keys(patch).length > 0) { await db.update(tasks).set(patch as any).where(eq(tasks.id, task.id)) .catch((e) => console.error('[translate] PATCH DB update failed:', e)) } }).catch((e) => console.error('[translate] PATCH background job failed:', e)) } // Notify all specialists with pending offers about the update (fire-and-forget) if (updated.status === 'open') { db.select({ specialistId: offers.specialistId }) .from(offers) .where(and(eq(offers.taskId, task.id), eq(offers.status, 'pending'))) .then(async (pendingOffers) => { for (const po of pendingOffers) { notifyTaskUpdated(po.specialistId, updated.title, task.id).catch(() => {}) const [sp] = await db.select().from(users).where(eq(users.id, po.specialistId)) if (sp?.email) { emailTaskUpdated({ to: sp.email, specialistName: sp.firstName || sp.name || 'Ειδικέ', taskTitle: updated.title, taskId: task.id, locale: (sp.locale as 'el' | 'en' | 'ru') || 'el', }).catch(() => {}) } } }) .catch(() => {}) } return c.json(updated) }, ) // PATCH /tasks/:id/complete app.patch('/:id/complete', requireAuth, async (c) => { const user = c.get('user') const [task] = await db.select().from(tasks).where(eq(tasks.id, c.req.param('id'))) if (!task) return c.json({ error: 'Not found' }, 404) if (task.customerId !== user.id) return c.json({ error: 'Forbidden' }, 403) if (task.status !== 'in_progress') return c.json({ error: 'Task is not in progress' }, 400) const [updated] = await db .update(tasks) .set({ status: 'completed', updatedAt: new Date() }) .where(eq(tasks.id, task.id)) .returning() return c.json(updated) }) // PATCH /tasks/:id/cancel app.patch('/:id/cancel', requireAuth, async (c) => { const user = c.get('user') const [task] = await db.select().from(tasks).where(eq(tasks.id, c.req.param('id'))) if (!task) return c.json({ error: 'Not found' }, 404) if (task.customerId !== user.id) return c.json({ error: 'Forbidden' }, 403) if (!['open', 'in_progress'].includes(task.status)) { return c.json({ error: 'Cannot cancel task in current status' }, 400) } const [updated] = await db .update(tasks) .set({ status: 'cancelled', updatedAt: new Date() }) .where(eq(tasks.id, task.id)) .returning() return c.json(updated) }) // POST /tasks/:id/view — record that current user viewed this task app.post('/:id/view', requireAuth, async (c) => { const user = c.get('user') const taskId = c.req.param('id') const [task] = await db.select({ id: tasks.id }).from(tasks).where(eq(tasks.id, taskId)) if (!task) return c.json({ error: 'Not found' }, 404) const stats = await recordTaskView(taskId, user.id) return c.json(stats) }) // GET /tasks/:id/stats — task statistics for the customer app.get('/:id/stats', requireAuth, async (c) => { const taskId = c.req.param('id') const stats = await getTaskStats(taskId) return c.json(stats) }) export default app