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