feat(subs): subscribe/unsubscribe/list service + routes

This commit is contained in:
2026-05-21 00:17:16 +02:00
parent 28321c6f84
commit f378c0fdb0
3 changed files with 128 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
import { Router } from 'express';
import type { Db } from '../db/client.js';
import { subscribe, unsubscribe, listSubscriptions } from '../services/subscriptions.js';
export function subscriptionsRouter(db: Db): Router {
const r = Router();
r.post('/lessons/:id/subscribe', async (req, res, next) => {
try {
const result = await subscribe(db, req.user!.id, Number(req.params.id));
res.status(result.created ? 201 : 200).json({ ok: true });
} catch (e) { next(e); }
});
r.delete('/lessons/:id/subscribe', async (req, res, next) => {
try {
await unsubscribe(db, req.user!.id, Number(req.params.id));
res.status(204).end();
} catch (e) { next(e); }
});
r.get('/me/subscriptions', async (req, res, next) => {
try {
res.json(await listSubscriptions(db, req.user!.id));
} catch (e) { next(e); }
});
return r;
}

View File

@@ -0,0 +1,50 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { makeTestDb, createUserDirect, createLessonOwnedBy } from '../tests/dbHelper.js';
import { subscribe, unsubscribe, listSubscriptions } from './subscriptions.js';
let env: ReturnType<typeof makeTestDb>;
beforeEach(() => { env = makeTestDb(); });
describe('subscriptions', () => {
it('subscribes a user to a shared lesson', async () => {
const o = await createUserDirect(env.db, { email: 'o@example.com' });
const u = await createUserDirect(env.db, { email: 'u@example.com' });
const l = await createLessonOwnedBy(env.db, o.id, { name: 'L', visibility: 'shared' });
const r = await subscribe(env.db, u.id, l.id);
expect(r.created).toBe(true);
const list = await listSubscriptions(env.db, u.id);
expect(list.find((s) => s.lessonId === l.id)).toBeTruthy();
});
it('refuses to subscribe to a private lesson', async () => {
const o = await createUserDirect(env.db, { email: 'o@example.com' });
const u = await createUserDirect(env.db, { email: 'u@example.com' });
const l = await createLessonOwnedBy(env.db, o.id, { name: 'L', visibility: 'private' });
await expect(subscribe(env.db, u.id, l.id)).rejects.toThrow(/private|shared|forbidden/i);
});
it('idempotent: second subscribe returns created=false', async () => {
const o = await createUserDirect(env.db, { email: 'o@example.com' });
const u = await createUserDirect(env.db, { email: 'u@example.com' });
const l = await createLessonOwnedBy(env.db, o.id, { name: 'L', visibility: 'shared' });
await subscribe(env.db, u.id, l.id);
const r = await subscribe(env.db, u.id, l.id);
expect(r.created).toBe(false);
});
it('cannot subscribe to your own lesson', async () => {
const u = await createUserDirect(env.db, { email: 'u@example.com' });
const l = await createLessonOwnedBy(env.db, u.id, { name: 'L', visibility: 'shared' });
await expect(subscribe(env.db, u.id, l.id)).rejects.toThrow(/own/i);
});
it('unsubscribe removes the row', async () => {
const o = await createUserDirect(env.db, { email: 'o@example.com' });
const u = await createUserDirect(env.db, { email: 'u@example.com' });
const l = await createLessonOwnedBy(env.db, o.id, { name: 'L', visibility: 'shared' });
await subscribe(env.db, u.id, l.id);
await unsubscribe(env.db, u.id, l.id);
const list = await listSubscriptions(env.db, u.id);
expect(list).toHaveLength(0);
});
});

View File

@@ -0,0 +1,49 @@
import { and, eq } from 'drizzle-orm';
import type { Db } from '../db/client.js';
import { lessons, lessonSubscriptions, users } from '../db/schema.js';
import { ApiError } from '../lib/errors.js';
import type { SubscriptionEntry } from '@flashcard/shared';
export async function subscribe(db: Db, userId: number, lessonId: number): Promise<{ created: boolean }> {
const l = db.select().from(lessons).where(eq(lessons.id, lessonId)).get();
if (!l) throw ApiError.notFound('Lesson');
if (l.ownerId === userId) {
throw new ApiError(409, 'CANNOT_SUBSCRIBE_OWN', 'Cannot subscribe to your own lesson');
}
if (l.visibility !== 'shared') {
throw new ApiError(403, 'FORBIDDEN_LESSON', 'Lesson is not shared');
}
const existing = db.select().from(lessonSubscriptions).where(and(
eq(lessonSubscriptions.userId, userId),
eq(lessonSubscriptions.lessonId, lessonId),
)).get();
if (existing) return { created: false };
db.insert(lessonSubscriptions).values({ userId, lessonId }).run();
return { created: true };
}
export async function unsubscribe(db: Db, userId: number, lessonId: number): Promise<void> {
db.delete(lessonSubscriptions).where(and(
eq(lessonSubscriptions.userId, userId),
eq(lessonSubscriptions.lessonId, lessonId),
)).run();
}
export async function listSubscriptions(db: Db, userId: number): Promise<SubscriptionEntry[]> {
const rows = db.select({
lessonId: lessonSubscriptions.lessonId,
subscribedAt: lessonSubscriptions.createdAt,
name: lessons.name,
ownerDisplayName: users.displayName,
}).from(lessonSubscriptions)
.innerJoin(lessons, eq(lessons.id, lessonSubscriptions.lessonId))
.leftJoin(users, eq(users.id, lessons.ownerId))
.where(eq(lessonSubscriptions.userId, userId))
.all();
return rows.map((r) => ({
lessonId: r.lessonId,
name: r.name,
ownerDisplayName: r.ownerDisplayName ?? '—',
subscribedAt: r.subscribedAt,
}));
}