/opt/canhelp/apps/api/src/lib
Edit: /opt/canhelp/apps/api/src/lib/helper-capability.ts (4207B)
import { and, eq } from 'drizzle-orm'
import { specialistCards, users } from '@canhelp/db'
import { db } from '../db.js'
type HelperCapabilityCacheEntry = {
value: boolean
expiresAt: number
}
const helperCapabilityCache = new Map
()
const helperCapabilityCacheTtlMs = Number(process.env.HELPER_CAPABILITY_CACHE_TTL_MS ?? 30000)
export type LegacyMarketRole = 'customer' | 'specialist'
export type PlanAudience = 'requester' | 'helper'
export function isAdminRole(role: string | null | undefined): boolean {
return role === 'admin'
}
export function toNormalizedRole(role: string | null | undefined): 'user' | 'admin' {
return role === 'admin' ? 'admin' : 'user'
}
export function toLegacyMarketRole(isHelperReady: boolean): LegacyMarketRole {
return isHelperReady ? 'specialist' : 'customer'
}
/**
* The role exposed to API clients (web/mobile). Unlike the raw DB role
* (`user` | `admin`), this collapses helper capability into a market role so the
* frontend can branch on `'specialist' | 'customer' | 'admin'` consistently.
*/
export function toClientRole(
rawRole: string | null | undefined,
isHelperReady: boolean,
): 'admin' | 'specialist' | 'customer' {
if (isAdminRole(rawRole)) return 'admin'
return isHelperReady ? 'specialist' : 'customer'
}
export function toLegacyMarketRoleFromInput(role: string | null | undefined): LegacyMarketRole {
return role === 'specialist' || role === 'helper' ? 'specialist' : 'customer'
}
export function mapAudienceToLegacyPlanRole(audience: PlanAudience): LegacyMarketRole {
return audience === 'helper' ? 'specialist' : 'customer'
}
function getCachedHelperCapability(userId: string): boolean | null {
const cached = helperCapabilityCache.get(userId)
if (!cached) return null
if (cached.expiresAt <= Date.now()) {
helperCapabilityCache.delete(userId)
return null
}
return cached.value
}
function setCachedHelperCapability(userId: string, value: boolean) {
const ttl = Number.isFinite(helperCapabilityCacheTtlMs) ? helperCapabilityCacheTtlMs : 30000
helperCapabilityCache.set(userId, {
value,
expiresAt: Date.now() + Math.max(1000, ttl),
})
}
export function clearHelperCapabilityCache(userId?: string) {
if (userId) {
helperCapabilityCache.delete(userId)
return
}
helperCapabilityCache.clear()
}
export async function getHelperCapability(
userId: string,
options?: { bypassCache?: boolean },
): Promise {
if (!options?.bypassCache) {
const cached = getCachedHelperCapability(userId)
if (cached !== null) return cached
}
const [userRows, cards] = await Promise.all([
db
.select({
id: users.id,
bio: users.bio,
specialistCategories: users.specialistCategories,
specialistLocations: users.specialistLocations,
})
.from(users)
.where(eq(users.id, userId))
.limit(1),
db
.select({
categories: specialistCards.categories,
locations: specialistCards.locations,
})
.from(specialistCards)
.where(and(eq(specialistCards.specialistId, userId), eq(specialistCards.isActive, true))),
])
const [user] = userRows
if (!user) {
setCachedHelperCapability(userId, false)
return false
}
const hasBio = !!user.bio?.trim()
const cardCategories = cards.flatMap((c) => c.categories ?? [])
const cardLocations = cards.flatMap((c) => c.locations ?? [])
const allCategories = new Set([...(user.specialistCategories ?? []), ...cardCategories])
const allLocations = new Set([...(user.specialistLocations ?? []), ...cardLocations])
const hasActiveCards = cards.length > 0
const isHelperReady = hasBio && hasActiveCards && allCategories.size > 0 && allLocations.size > 0
setCachedHelperCapability(userId, isHelperReady)
return isHelperReady
}
export async function enrichUserWithHelperCapability(user: T) {
const isHelperReady = await getHelperCapability(user.id)
const legacyMarketRole = toLegacyMarketRole(isHelperReady)
return {
...user,
role: toClientRole(user.role, isHelperReady),
isHelperReady,
legacyMarketRole,
}
}