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