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