From bf544b3e5bce01d627aa1864010f4d92f20ad78e Mon Sep 17 00:00:00 2001 From: Anton Date: Wed, 4 Mar 2026 14:15:09 +0300 Subject: [PATCH] feat: add subscription middleware Made-with: Cursor --- .gitmodules | 3 ++ samreshu_docs | 1 + src/app.ts | 2 ++ src/plugins/subscription.ts | 71 +++++++++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+) create mode 100644 .gitmodules create mode 160000 samreshu_docs create mode 100644 src/plugins/subscription.ts diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..1d5585f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "samreshu_docs"] + path = samreshu_docs + url = https://git.vakanaut.ru/admin/samreshu_docs.git diff --git a/samreshu_docs b/samreshu_docs new file mode 160000 index 0000000..99cd8ae --- /dev/null +++ b/samreshu_docs @@ -0,0 +1 @@ +Subproject commit 99cd8ae727fd5f3ada3564087710aacc4b9ed535 diff --git a/src/app.ts b/src/app.ts index 7c63570..f83faac 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,6 +5,7 @@ import redisPlugin from './plugins/redis.js'; import securityPlugin from './plugins/security.js'; import rateLimitPlugin from './plugins/rateLimit.js'; import authPlugin from './plugins/auth.js'; +import subscriptionPlugin from './plugins/subscription.js'; import { authRoutes } from './routes/auth.js'; import { env } from './config/env.js'; import { randomUUID } from 'node:crypto'; @@ -72,6 +73,7 @@ export async function buildApp(): Promise { await app.register(securityPlugin); await app.register(rateLimitPlugin); await app.register(authPlugin); + await app.register(subscriptionPlugin); await app.register(authRoutes, { prefix: '/auth' }); app.get('/health', async () => ({ status: 'ok', timestamp: new Date().toISOString() })); diff --git a/src/plugins/subscription.ts b/src/plugins/subscription.ts new file mode 100644 index 0000000..003d337 --- /dev/null +++ b/src/plugins/subscription.ts @@ -0,0 +1,71 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import fp from 'fastify-plugin'; +import { eq } from 'drizzle-orm'; +import { subscriptions } from '../db/schema/subscriptions.js'; +import { forbidden } from '../utils/errors.js'; + +export type SubscriptionInfo = { + plan: 'free' | 'pro'; + status: 'active' | 'trialing' | 'cancelled' | 'expired'; + isPro: boolean; + expiresAt: Date | null; +}; + +declare module 'fastify' { + interface FastifyRequest { + subscription?: SubscriptionInfo | null; + } + interface FastifyInstance { + withSubscription: (req: FastifyRequest, reply: FastifyReply) => Promise; + requirePro: (req: FastifyRequest, reply: FastifyReply) => Promise; + } +} + +async function loadSubscription(db: FastifyInstance['db'], userId: string): Promise { + const [sub] = await db + .select() + .from(subscriptions) + .where(eq(subscriptions.userId, userId)) + .limit(1); + + if (!sub) return null; + + const now = new Date(); + const isExpired = sub.expiresAt && sub.expiresAt < now; + const isPro = + sub.plan === 'pro' && + (sub.status === 'active' || sub.status === 'trialing') && + !isExpired; + + return { + plan: sub.plan as 'free' | 'pro', + status: sub.status as SubscriptionInfo['status'], + isPro, + expiresAt: sub.expiresAt, + }; +} + +export async function requirePro(req: FastifyRequest, _reply: FastifyReply): Promise { + const sub = req.subscription; + + if (!sub?.isPro) { + throw forbidden('Pro subscription required'); + } +} + +const subscriptionPlugin = async (app: FastifyInstance) => { + app.decorateRequest('subscription', undefined); + + app.decorate('withSubscription', async (req: FastifyRequest, _reply: FastifyReply) => { + if (!req.user?.id) return; + const sub = await loadSubscription(app.db, req.user.id); + req.subscription = sub; + }); + + app.decorate('requirePro', requirePro); +}; + +export default fp(subscriptionPlugin, { + name: 'subscription', + dependencies: ['database', 'auth'], +});