feat(subs): subscribe/unsubscribe/list service + routes
This commit is contained in:
29
packages/backend/src/routes/subscriptions.ts
Normal file
29
packages/backend/src/routes/subscriptions.ts
Normal 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;
|
||||||
|
}
|
||||||
50
packages/backend/src/services/subscriptions.test.ts
Normal file
50
packages/backend/src/services/subscriptions.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
49
packages/backend/src/services/subscriptions.ts
Normal file
49
packages/backend/src/services/subscriptions.ts
Normal 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user