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