/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/auto-match.ts (7406B)
import { Hono } from 'hono' import { eq, and, desc, sql, avg, count, inArray } from 'drizzle-orm' import { db } from '../db.js' import { tasks, users, plans, offers, specialistCards, reviews, } from '@canhelp/db' import { requireAuth, type AuthVariables } from '../middleware/auth.js' import { withPlan } from '../middleware/plan.js' import { getHelperCapability } from '../lib/helper-capability.js' const app = new Hono<{ Variables: AuthVariables }>() // POST /auto-match/:taskId — find best specialist(s) for a task (Ultimate customers) app.post('/:taskId', requireAuth, withPlan, async (c) => { const user = c.get('user') if (await getHelperCapability(user.id)) { return c.json({ error: 'Customers only' }, 403) } const plan = c.get('plan' as never) as { hasAutoMatch: boolean } | null if (!plan?.hasAutoMatch) { return c.json({ error: 'Auto-match requires Ultimate plan', code: 'PLAN_UPGRADE_REQUIRED' }, 403) } const taskId = c.req.param('taskId') const [task] = await db.select().from(tasks).where(eq(tasks.id, taskId)) if (!task) return c.json({ error: 'Task not found' }, 404) if (task.customerId !== user.id) return c.json({ error: 'Forbidden' }, 403) if (task.status !== 'open') return c.json({ error: 'Task is not open' }, 400) // Find matching specialists const matches = await findBestSpecialists(task) return c.json({ taskId: task.id, matches, total: matches.length, }) }) // GET /auto-match/:taskId — get previously computed matches app.get('/:taskId', requireAuth, withPlan, async (c) => { const user = c.get('user') const plan = c.get('plan' as never) as { hasAutoMatch: boolean } | null if (!plan?.hasAutoMatch) { return c.json({ error: 'Auto-match requires Ultimate plan', code: 'PLAN_UPGRADE_REQUIRED' }, 403) } const taskId = c.req.param('taskId') const [task] = await db.select().from(tasks).where(eq(tasks.id, taskId)) if (!task) return c.json({ error: 'Task not found' }, 404) if (task.customerId !== user.id) return c.json({ error: 'Forbidden' }, 403) const matches = await findBestSpecialists(task) return c.json({ taskId: task.id, matches, total: matches.length }) }) export default app // ─── Scoring Algorithm ───────────────────────────────────────────────────── interface SpecialistMatch { specialistId: string name: string firstName: string | null lastName: string | null image: string | null score: number reasons: string[] rating: number | null reviewCount: number planTier: string hasVerifiedBadge: boolean alreadyOffered: boolean } async function findBestSpecialists(task: { id: string category: string | null location: string | null budget: string | null }): Promise { if (!task.category) return [] // Step 1: Find specialists with matching cards const matchingCards = await db .select({ specialistId: specialistCards.specialistId, categories: specialistCards.categories, locations: specialistCards.locations, }) .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))] // Step 2: Load specialist data + plan info const specialists = await db .select({ id: users.id, name: users.name, firstName: users.firstName, lastName: users.lastName, image: users.image, planId: users.planId, createdAt: users.createdAt, lastSeenAt: users.lastSeenAt, }) .from(users) .where(and(inArray(users.id, specialistIds), eq(users.isActive, true))) if (!specialists.length) return [] // Step 3: Load plans for all specialists const planIds = [...new Set(specialists.map((s) => s.planId).filter(Boolean))] as string[] const planRows = planIds.length > 0 ? await db.select().from(plans).where(inArray(plans.id, planIds)) : [] const planMap = Object.fromEntries(planRows.map((p) => [p.id, p])) // Step 4: Load ratings const ratingResults = await Promise.all( specialists.map(async (s) => { const [result] = await db .select({ avg: avg(reviews.rating), total: count() }) .from(reviews) .where(eq(reviews.targetId, s.id)) return { id: s.id, avg: result?.avg ? Number(result.avg) : null, total: result?.total ?? 0 } }), ) const ratingMap = Object.fromEntries(ratingResults.map((r) => [r.id, r])) // Step 5: Check who already offered on this task const existingOffers = await db .select({ specialistId: offers.specialistId }) .from(offers) .where(eq(offers.taskId, task.id)) const offeredSet = new Set(existingOffers.map((o) => o.specialistId)) // Step 6: Build location match map const locationMatchMap = new Map() if (task.location) { for (const card of matchingCards) { if (card.locations?.includes(task.location)) { locationMatchMap.set(card.specialistId, true) } } } // Step 7: Score each specialist const scored: SpecialistMatch[] = specialists.map((s) => { const rating = ratingMap[s.id] const plan = s.planId ? planMap[s.planId] : null const reasons: string[] = [] let score = 0 // Rating score (0-30 points) if (rating?.avg) { score += Math.min(30, rating.avg * 6) if (rating.avg >= 4.5) reasons.push('high_rating') } // Review count (0-15 points) if (rating?.total) { score += Math.min(15, rating.total * 1.5) if (rating.total >= 10) reasons.push('many_reviews') } // Plan tier (0-20 points) const tier = plan?.tier ?? 'free' if (tier === 'ultimate') { score += 20 reasons.push('ultimate_specialist') } else if (tier === 'pro') { score += 10 reasons.push('pro_specialist') } // Search boost from plan (0-10 points) score += Math.min(10, plan?.searchBoost ?? 0) // Location match (10 points) if (task.location && locationMatchMap.get(s.id)) { score += 10 reasons.push('location_match') } // Recent activity (0-10 points) if (s.lastSeenAt) { const hoursSinceActive = (Date.now() - s.lastSeenAt.getTime()) / (1000 * 60 * 60) if (hoursSinceActive < 1) { score += 10; reasons.push('recently_active') } else if (hoursSinceActive < 24) { score += 5 } } // Already offered — bonus (5 points, shows interest) const alreadyOffered = offeredSet.has(s.id) if (alreadyOffered) { score += 5 reasons.push('already_offered') } // Verified badge (5 points) const hasVerifiedBadge = !!plan?.hasVerifiedBadge if (hasVerifiedBadge) { score += 5 reasons.push('verified') } return { specialistId: s.id, name: s.name, firstName: s.firstName, lastName: s.lastName, image: s.image, score, reasons, rating: rating?.avg ?? null, reviewCount: rating?.total ?? 0, planTier: tier, hasVerifiedBadge, alreadyOffered, } }) // Sort by score descending, return top 10 scored.sort((a, b) => b.score - a.score) return scored.slice(0, 10) }