/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/offers.ts (10047B)
import { Hono } from 'hono' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' import { eq, and, desc, sql } from 'drizzle-orm' import { db } from '../db.js' import { offers, tasks, users, notifications, plans } from '@canhelp/db' import { requireAuth, type AuthVariables } from '../middleware/auth.js' import { emailNewOffer, emailOfferAccepted, emailOfferDeclined, emailOfferOtherAccepted } from '../lib/email.js' import { notifyNewOffer, notifyOfferAccepted, notifyOfferDeclined, notifyOfferOtherAccepted } from '../lib/notif.js' import { abbrevName } from '../lib/nameUtils.js' import { checkOrderLimit, incrementOrders } from '../lib/daily-limits.js' import { recordMissedOrders } from '../lib/fomo.js' import { getHelperCapability, isAdminRole } from '../lib/helper-capability.js' const app = new Hono<{ Variables: AuthVariables }>() // GET /offers?taskId=... app.get('/', requireAuth, async (c) => { const taskId = c.req.query('taskId') if (!taskId) return c.json({ error: 'taskId is required' }, 400) const [task] = await db.select().from(tasks).where(eq(tasks.id, taskId)) if (!task) return c.json({ error: 'Task not found' }, 404) const user = c.get('user') if (task.customerId !== user.id) return c.json({ error: 'Forbidden' }, 403) const rows = await db .select({ offer: offers, specialist: { id: users.id, name: users.name, firstName: users.firstName, lastName: users.lastName, image: users.image, }, planTier: sql`COALESCE(${plans.tier}, 'free')`.as('plan_tier'), highlightedReviews: sql`COALESCE(${plans.highlightedReviews}, false)`.as('highlighted_reviews'), }) .from(offers) .leftJoin(users, eq(offers.specialistId, users.id)) .leftJoin(plans, eq(users.planId, plans.id)) .where(eq(offers.taskId, taskId)) .orderBy(sql`COALESCE(${plans.searchBoost}, 0) DESC`, desc(offers.createdAt)) return c.json(rows) }) // GET /offers/my — specialist's own offers app.get('/my', requireAuth, async (c) => { const user = c.get('user') const rows = await db .select({ offer: offers, task: { id: tasks.id, title: tasks.title, status: tasks.status, budget: tasks.budget, }, }) .from(offers) .leftJoin(tasks, eq(offers.taskId, tasks.id)) .where(eq(offers.specialistId, user.id)) .orderBy(desc(offers.createdAt)) return c.json(rows) }) // POST /offers app.post( '/', requireAuth, zValidator( 'json', z.object({ taskId: z.string().uuid(), price: z.number().positive(), message: z.string().max(2000).optional(), }), ), async (c) => { const user = c.get('user') const isHelperReady = isAdminRole(user.role) ? true : await getHelperCapability(user.id) if (!isHelperReady) { return c.json({ error: 'Complete your specialist profile (bio, service cards, categories, and locations) before sending offers', code: 'HELPER_PROFILE_INCOMPLETE', }, 403) } const body = c.req.valid('json') const [task] = await db.select().from(tasks).where(eq(tasks.id, body.taskId)) if (!task) return c.json({ error: 'Task not found' }, 404) if (task.status !== 'open') return c.json({ error: 'Task is not open for offers' }, 400) if (task.customerId === user.id) return c.json({ error: 'Cannot offer on your own task' }, 400) // Check daily offer limit const orderCheck = await checkOrderLimit(user.id) if (!orderCheck.allowed) { return c.json({ error: 'Daily offer limit reached', code: 'DAILY_LIMIT_REACHED', limit: orderCheck.limit, current: orderCheck.current, }, 429) } // Check duplicate const [existing] = await db .select() .from(offers) .where(and(eq(offers.taskId, body.taskId), eq(offers.specialistId, user.id))) if (existing) return c.json({ error: 'You already submitted an offer for this task' }, 409) const [offer] = await db .insert(offers) .values({ taskId: body.taskId, specialistId: user.id, price: body.price.toString(), message: body.message, }) .returning() // Remove invite notification for this specialist (they accepted by submitting an offer) await db .delete(notifications) .where( and( eq(notifications.recipientId, user.id), eq(notifications.type, 'task_invite' as any), eq(notifications.referenceId, body.taskId), ), ) .catch(() => {}) // Notify task owner await notifyNewOffer(task.customerId, task.title, task.id) // Email task owner const [customer] = await db.select().from(users).where(eq(users.id, task.customerId)) if (customer?.email) { emailNewOffer({ to: customer.email, customerName: customer.firstName || customer.name || 'Χρήστη', taskTitle: task.title, taskId: task.id, specialistName: abbrevName((user as any).firstName, (user as any).lastName, user.name), price: body.price, locale: (customer.locale as 'el' | 'en' | 'ru') || 'el', }).catch(() => {}) } // Increment daily order counter incrementOrders(user.id).catch(() => {}) return c.json(offer, 201) }, ) // PATCH /offers/:id/accept app.patch('/:id/accept', requireAuth, async (c) => { const user = c.get('user') const [offer] = await db.select().from(offers).where(eq(offers.id, c.req.param('id'))) if (!offer) return c.json({ error: 'Not found' }, 404) const [task] = await db.select().from(tasks).where(eq(tasks.id, offer.taskId)) if (task.customerId !== user.id) return c.json({ error: 'Forbidden' }, 403) if (offer.status !== 'pending') return c.json({ error: 'Offer is not pending' }, 400) const [updated] = await db .update(offers) .set({ status: 'accepted', updatedAt: new Date() }) .where(eq(offers.id, offer.id)) .returning() // Move task to in_progress await db.update(tasks).set({ status: 'in_progress', updatedAt: new Date() }).where(eq(tasks.id, task.id)) // Mark all other pending offers for this task as other_accepted and notify those specialists const otherOffers = await db .select({ id: offers.id, specialistId: offers.specialistId }) .from(offers) .where( and( eq(offers.taskId, task.id), eq(offers.status, 'pending'), ), ) if (otherOffers.length > 0) { await db .update(offers) .set({ status: 'other_accepted', updatedAt: new Date() }) .where( and( eq(offers.taskId, task.id), eq(offers.status, 'pending'), ), ) // Notify each rejected specialist (fire-and-forget) for (const other of otherOffers) { notifyOfferOtherAccepted(other.specialistId, task.title, task.id).catch(() => {}) // Email rejected specialists db.select().from(users).where(eq(users.id, other.specialistId)).then(([sp]) => { if (sp?.email) { emailOfferOtherAccepted({ to: sp.email, specialistName: sp.firstName || sp.name || 'Ειδικέ', taskTitle: task.title, taskId: task.id, locale: (sp.locale as 'el' | 'en' | 'ru') || 'el', }).catch(() => {}) } }).catch(() => {}) } } // Record missed orders for FOMO tracking (fire-and-forget) recordMissedOrders(task.id, offer.specialistId).catch(() => {}) // Notify accepted specialist await notifyOfferAccepted(offer.specialistId, task.title, task.id) // Email accepted specialist const [specialist] = await db.select().from(users).where(eq(users.id, offer.specialistId)) if (specialist?.email) { emailOfferAccepted({ to: specialist.email, specialistName: specialist.firstName || specialist.name || 'Ειδικέ', taskTitle: task.title, taskId: task.id, price: Number(offer.price), locale: (specialist.locale as 'el' | 'en' | 'ru') || 'el', }).catch(() => {}) } return c.json(updated) }) // PATCH /offers/:id/decline app.patch('/:id/decline', requireAuth, async (c) => { const user = c.get('user') const [offer] = await db.select().from(offers).where(eq(offers.id, c.req.param('id'))) if (!offer) return c.json({ error: 'Not found' }, 404) const [task] = await db.select().from(tasks).where(eq(tasks.id, offer.taskId)) if (task.customerId !== user.id) return c.json({ error: 'Forbidden' }, 403) const [updated] = await db .update(offers) .set({ status: 'declined', updatedAt: new Date() }) .where(eq(offers.id, offer.id)) .returning() await notifyOfferDeclined(offer.specialistId, task.title, task.id) // Email declined specialist const [specialist] = await db.select().from(users).where(eq(users.id, offer.specialistId)) if (specialist?.email) { emailOfferDeclined({ to: specialist.email, specialistName: specialist.firstName || specialist.name || 'Ειδικέ', taskTitle: task.title, taskId: task.id, locale: (specialist.locale as 'el' | 'en' | 'ru') || 'el', }).catch(() => {}) } return c.json(updated) }) // PATCH /offers/:id/counter app.patch( '/:id/counter', requireAuth, zValidator('json', z.object({ counterPrice: z.number().positive() })), async (c) => { const user = c.get('user') const [offer] = await db.select().from(offers).where(eq(offers.id, c.req.param('id'))) if (!offer) return c.json({ error: 'Not found' }, 404) const [task] = await db.select().from(tasks).where(eq(tasks.id, offer.taskId)) if (task.customerId !== user.id) return c.json({ error: 'Forbidden' }, 403) const body = c.req.valid('json') const [updated] = await db .update(offers) .set({ counterPrice: body.counterPrice.toString(), updatedAt: new Date() }) .where(eq(offers.id, offer.id)) .returning() return c.json(updated) }, ) export default app