diff --git a/packages/backend/src/routes/subscriptions.ts b/packages/backend/src/routes/subscriptions.ts new file mode 100644 index 0000000..41547c5 --- /dev/null +++ b/packages/backend/src/routes/subscriptions.ts @@ -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; +} diff --git a/packages/backend/src/services/subscriptions.test.ts b/packages/backend/src/services/subscriptions.test.ts new file mode 100644 index 0000000..7a4950c --- /dev/null +++ b/packages/backend/src/services/subscriptions.test.ts @@ -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; +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); + }); +}); diff --git a/packages/backend/src/services/subscriptions.ts b/packages/backend/src/services/subscriptions.ts new file mode 100644 index 0000000..81d3c81 --- /dev/null +++ b/packages/backend/src/services/subscriptions.ts @@ -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 { + db.delete(lessonSubscriptions).where(and( + eq(lessonSubscriptions.userId, userId), + eq(lessonSubscriptions.lessonId, lessonId), + )).run(); +} + +export async function listSubscriptions(db: Db, userId: number): Promise { + 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, + })); +}