Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api-gateway/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { progressRouter } from './routes/progress';
import { quizRouter } from './routes/quiz';
import { leaderboardRouter } from './routes/leaderboard';
import { analyticsRouter } from './routes/analytics';
import { subscriptionRouter } from './routes/subscription';

export function createApp(): Express {
const app = express();
Expand Down Expand Up @@ -54,6 +55,7 @@ export function createApp(): Express {
app.use('/api/v1/quiz', quizRouter);
app.use('/api/v1/leaderboard', leaderboardRouter);
app.use('/api/v1/analytics', analyticsRouter);
app.use('/api/v1/subscription', subscriptionRouter);

// Static frontend
const resolveAppPath = (...segments: string[]) => {
Expand Down
4 changes: 4 additions & 0 deletions api-gateway/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ const Env = z.object({
FRONTEND_PATH: z.string().default('frontend'),
COURSE_DATA_PATH: z.string().default('data/courses-full.json'),

// Stripe — optional; omit entirely to run without payment processing
STRIPE_SECRET_KEY: z.string().startsWith('sk_').optional(),
STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_').optional(),

LINODE_DB_HOST: z.string().optional(),
LINODE_DB_PORT: z.coerce.number().optional(),
LINODE_DB_USER: z.string().optional(),
Expand Down
2 changes: 2 additions & 0 deletions api-gateway/src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ export function decodeAccessToken(token: string): Express.UserPayload {
sub?: string;
email?: string;
role?: string;
tier?: string;
};
const userId = decoded.userId || decoded.sub;
if (!userId) throw new UnauthorizedError('Invalid token payload');
return {
userId,
email: decoded.email ?? '',
role: decoded.role ?? 'student',
tier: (decoded.tier as 'free' | 'premium' | 'enterprise') ?? 'free',
};
}

Expand Down
27 changes: 27 additions & 0 deletions api-gateway/src/middleware/requireTier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Request, Response, NextFunction } from 'express';
import { ForbiddenError, UnauthorizedError } from '../utils/errors';
import { TIER_ORDER, Tier } from '../services/courseStore';

/**
* Middleware that enforces a minimum subscription tier.
* Must be placed after requireAuth (req.user must be populated).
*
* On failure returns 403 with code TIER_REQUIRED so the frontend
* can distinguish a permissions error from an auth error and surface
* an upgrade CTA to the user.
*/
export function requireTier(minimum: Tier) {
return (req: Request, _res: Response, next: NextFunction): void => {
if (!req.user) throw new UnauthorizedError();
const userTier = (req.user.tier ?? 'free') as Tier;
if (TIER_ORDER[userTier] < TIER_ORDER[minimum]) {
const err = new ForbiddenError(
`This content requires a ${minimum} subscription. ` +
`Your current plan is ${userTier}.`,
);
(err as { code: string }).code = 'TIER_REQUIRED';
throw err;
}
next();
};
}
16 changes: 15 additions & 1 deletion api-gateway/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ authRouter.post(
userId: user.id,
email: user.email,
role: user.role,
tier: user.tier,
});
const refreshToken = signRefreshToken(user.id);

Expand Down Expand Up @@ -74,6 +75,7 @@ authRouter.post(
userId: user.id,
email: user.email,
role: user.role,
tier: user.tier,
});
const refreshToken = signRefreshToken(user.id);
res.json({ user, accessToken, refreshToken });
Expand All @@ -94,10 +96,12 @@ authRouter.post(
}
const user = await findUserById(payload.userId);
if (!user) throw new UnauthorizedError('User not found');
// Re-read tier from DB so the refreshed token always carries the current tier
const accessToken = signAccessToken({
userId: user.id,
email: user.email,
role: user.role,
tier: user.tier,
});
res.json({ accessToken });
}),
Expand All @@ -108,7 +112,17 @@ authRouter.get(
requireAuth,
asyncHandler(async (req, res) => {
const u = req.user!;
res.json({ id: u.userId, email: u.email, role: u.role });
// Return the latest user state from the store (not just what's in the token)
const user = await findUserById(u.userId);
if (!user) throw new UnauthorizedError('User not found');
res.json({
id: user.id,
email: user.email,
role: user.role,
tier: user.tier,
subscriptionStatus: user.subscriptionStatus,
currentPeriodEnd: user.currentPeriodEnd,
});
}),
);

Expand Down
57 changes: 52 additions & 5 deletions api-gateway/src/routes/courses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,23 @@ import { Router } from 'express';
import { z } from 'zod';
import { validate } from '../middleware/validate';
import { asyncHandler } from '../utils/asyncHandler';
import { NotFoundError } from '../utils/errors';
import { loadCourses, summarizeCourse } from '../services/courseStore';
import { ForbiddenError, NotFoundError } from '../utils/errors';
import {
loadCourses,
summarizeCourse,
filterCourseForTier,
tierSatisfies,
Tier,
} from '../services/courseStore';

export const coursesRouter = Router();

const CourseIdParam = z.object({ id: z.string().min(1).max(80) });

// GET /api/v1/courses
// Public (no auth required). Returns summaries for all published courses.
// The `tierRequired` field signals which plan is needed so the frontend
// can render lock/upgrade badges without fetching full course detail.
coursesRouter.get(
'/',
asyncHandler(async (_req, res) => {
Expand All @@ -17,25 +27,62 @@ coursesRouter.get(
}),
);

// GET /api/v1/courses/:id/lessons/:lessonId
// Returns a single lesson's content. Premium lessons are gated: unauthenticated
// or free-tier users receive 403 TIER_REQUIRED instead of the content.
coursesRouter.get(
'/:id/lessons/:lessonId',
validate(CourseIdParam.extend({ lessonId: z.string().min(1).max(120) }), 'params'),
asyncHandler(async (req, res) => {
const { id, lessonId } = req.params as { id: string; lessonId: string };
const course = loadCourses().find((c) => c.id === id);
if (!course) throw new NotFoundError(`Course '${id}' not found`);
const lesson = course.sections?.flatMap((section) => section.lessons || []).find((lesson) => lesson.id === lessonId);

const lesson = course.sections
?.flatMap((section) => section.lessons || [])
.find((l) => l.id === lessonId);
if (!lesson) throw new NotFoundError(`Lesson '${lessonId}' not found for course '${id}'`);
res.json({ id: lesson.id, content: lesson.content ?? '', title: lesson.title, type: lesson.type, duration: lesson.duration });

// Find the parent section to check its isPremium flag
const parentSection = course.sections?.find((s) =>
s.lessons.some((l) => l.id === lessonId),
);

const userTier = (req.user?.tier ?? 'free') as Tier;
const courseLocked = !tierSatisfies(userTier, course.tierRequired);
const sectionLocked = parentSection?.isPremium && !tierSatisfies(userTier, 'premium');
const lessonLocked = lesson.isPremium && !tierSatisfies(userTier, 'premium');

if (courseLocked || sectionLocked || lessonLocked) {
const requiredTier = lesson.isPremium || parentSection?.isPremium ? 'premium' : course.tierRequired;
const err = new ForbiddenError(
`This lesson requires a ${requiredTier} subscription.`,
);
(err as { code: string }).code = 'TIER_REQUIRED';
throw err;
}

res.json({
id: lesson.id,
content: lesson.content ?? '',
title: lesson.title,
type: lesson.type,
duration: lesson.duration,
});
}),
);

// GET /api/v1/courses/:id
// Returns a full course. Sections/lessons the authenticated user cannot access
// are returned with `locked: true` and their content/questions stripped.
// Unauthenticated users are treated as 'free' tier.
coursesRouter.get(
'/:id',
validate(CourseIdParam, 'params'),
asyncHandler(async (req, res) => {
const course = loadCourses().find((c) => c.id === req.params.id);
if (!course) throw new NotFoundError(`Course '${req.params.id}' not found`);
res.json(course);
const userTier = (req.user?.tier ?? 'free') as Tier;
res.json(filterCourseForTier(course, userTier));
}),
);
164 changes: 164 additions & 0 deletions api-gateway/src/routes/subscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { Router } from 'express';
import { z } from 'zod';
import { validate } from '../middleware/validate';
import { asyncHandler } from '../utils/asyncHandler';
import { requireAuth } from '../middleware/auth';
import { requireRole } from '../middleware/auth';
import { BadRequestError, NotFoundError } from '../utils/errors';
import { findUserById, updateUserTier, SubscriptionTier, SubscriptionStatus } from '../services/userRepo';
import { getPool } from '../db/pool';
import { logger } from '../utils/logger';

export const subscriptionRouter = Router();

// All subscription routes require authentication
subscriptionRouter.use(requireAuth);

// GET /api/v1/subscription
// Returns the calling user's current subscription state.
subscriptionRouter.get(
'/',
asyncHandler(async (req, res) => {
const user = await findUserById(req.user!.userId);
if (!user) throw new NotFoundError('User not found');
res.json({
tier: user.tier,
subscriptionStatus: user.subscriptionStatus,
stripeCustomerId: user.stripeCustomerId,
currentPeriodEnd: user.currentPeriodEnd,
});
}),
);

// GET /api/v1/subscription/history
// Returns the audit log of subscription events for the calling user.
subscriptionRouter.get(
'/history',
asyncHandler(async (req, res) => {
const pool = getPool();
if (!pool) {
return res.json({ events: [] });
}
const { rows } = await pool.query(
`SELECT event_type, old_tier, new_tier, old_status, new_status, metadata, created_at
FROM subscription_events
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 50`,
[req.user!.userId],
);
res.json({ events: rows });
}),
);

const AdminTierBody = z.object({
userId: z.string().uuid('userId must be a valid UUID'),
tier: z.enum(['free', 'premium', 'enterprise']),
subscriptionStatus: z.enum(['active', 'trialing', 'past_due', 'canceled', 'unpaid']).default('active'),
currentPeriodEnd: z.string().datetime().optional(),
});

// POST /api/v1/subscription/admin/set-tier
// Admin-only: manually set a user's subscription tier.
// In production this is driven by Stripe webhook events; this endpoint
// exists for testing, manual overrides, and internal tooling.
subscriptionRouter.post(
'/admin/set-tier',
requireRole('admin'),
validate(AdminTierBody),
asyncHandler(async (req, res) => {
const { userId, tier, subscriptionStatus, currentPeriodEnd } = req.body as z.infer<typeof AdminTierBody>;

const existing = await findUserById(userId);
if (!existing) throw new NotFoundError(`User '${userId}' not found`);

const updated = await updateUserTier({
userId,
tier: tier as SubscriptionTier,
subscriptionStatus: subscriptionStatus as SubscriptionStatus,
currentPeriodEnd: currentPeriodEnd ? new Date(currentPeriodEnd) : undefined,
});

// Persist audit event if a database is available
const pool = getPool();
if (pool) {
try {
await pool.query(
`SELECT record_tier_change($1, $2, $3, $4, $5, $6, $7)`,
[
userId,
'admin_override',
existing.tier,
tier,
existing.subscriptionStatus,
subscriptionStatus,
JSON.stringify({ adminId: req.user!.userId, requestId: req.requestId }),
],
);
} catch (err) {
logger.warn('Failed to record tier change event', { error: (err as Error).message });
}
}

logger.info('Admin tier change', {
targetUserId: userId,
oldTier: existing.tier,
newTier: tier,
adminId: req.user!.userId,
requestId: req.requestId,
});

res.json({ success: true, user: updated });
}),
);

const CancelBody = z.object({
reason: z.string().max(500).optional(),
});

// POST /api/v1/subscription/cancel
// User cancels their own subscription. Sets status to 'canceled'; access
// continues until currentPeriodEnd (grace period). In production this
// would also call Stripe's API to cancel the billing subscription.
subscriptionRouter.post(
'/cancel',
validate(CancelBody),
asyncHandler(async (req, res) => {
const userId = req.user!.userId;
const existing = await findUserById(userId);
if (!existing) throw new NotFoundError('User not found');

if (existing.tier === 'free') {
throw new BadRequestError('No active paid subscription to cancel');
}

const updated = await updateUserTier({
userId,
tier: existing.tier,
subscriptionStatus: 'canceled',
});

const pool = getPool();
if (pool) {
try {
await pool.query(
`SELECT record_tier_change($1, $2, $3, $4, $5, $6, $7)`,
[
userId,
'user_cancel',
existing.tier,
existing.tier,
existing.subscriptionStatus,
'canceled',
req.body.reason ? JSON.stringify({ reason: req.body.reason }) : null,
],
);
} catch (err) {
logger.warn('Failed to record cancellation event', { error: (err as Error).message });
}
}

logger.info('Subscription canceled', { userId, tier: existing.tier, requestId: req.requestId });
res.json({ success: true, user: updated });
}),
);
Loading
Loading