/opt/canhelp/apps/api/src/middleware
NameSizeModeActions
auth.ts13530644editdlrm
plan.ts28900644editdlrm
Edit: /opt/canhelp/apps/api/src/middleware/plan.ts (2890B)
import { createMiddleware } from 'hono/factory' import { eq } from 'drizzle-orm' import { db } from '../db.js' import { plans, users } from '@canhelp/db' import { redis } from '../redis.js' import type { AuthVariables } from './auth.js' import { getHelperCapability } from '../lib/helper-capability.js' export type PlanRow = typeof plans.$inferSelect export type PlanVariables = AuthVariables & { plan: PlanRow | null } const PLAN_CACHE_TTL = 3600 // 1 hour /** Fetch plan by id, with Redis cache */ async function fetchPlan(planId: string): Promise { const cacheKey = `plan:${planId}` const cached = await redis.get(cacheKey).catch(() => null) if (cached) { try { return JSON.parse(cached) as PlanRow } catch {} } const [plan] = await db.select().from(plans).where(eq(plans.id, planId)) if (plan) { await redis.set(cacheKey, JSON.stringify(plan), 'EX', PLAN_CACHE_TTL).catch(() => {}) } return plan ?? null } /** Invalidate cached plan (call after admin updates a plan) */ export async function invalidatePlanCache(planId: string) { await redis.del(`plan:${planId}`).catch(() => {}) } /** * Middleware: loads user's plan into c.var.plan. * If planExpiresAt has passed, downgrades user to the free plan for their role. * Must be used AFTER requireAuth. */ export const withPlan = createMiddleware<{ Variables: PlanVariables }>( async (c, next) => { const user = c.var.user as any let planId = user.planId as string | null | undefined // Check plan expiration if (planId && user.planExpiresAt) { const expiresAt = new Date(user.planExpiresAt) if (expiresAt < new Date()) { // Plan expired — downgrade to free const isHelperReady = await getHelperCapability(user.id) const freePlanId = isHelperReady ? 'specialist_free' : 'customer_free' await db .update(users) .set({ planId: freePlanId, planExpiresAt: null, updatedAt: new Date() }) .where(eq(users.id, user.id)) .catch(() => {}) planId = freePlanId } } if (planId) { const plan = await fetchPlan(planId) c.set('plan', plan) } else { c.set('plan', null) } await next() }, ) /** * Factory: returns middleware that blocks if the plan doesn't have a specific boolean feature. * Usage: app.post('/favorites', requireAuth, withPlan, requirePlanFeature('hasFavorites'), ...) */ export function requirePlanFeature(feature: keyof PlanRow) { return createMiddleware<{ Variables: PlanVariables }>(async (c, next) => { const plan = c.var.plan if (!plan || !plan[feature]) { return c.json( { error: 'Your plan does not include this feature', code: 'PLAN_FEATURE_REQUIRED', feature, upgrade: true, }, 403, ) } await next() }) }