managarten/services/cards-server/src/routes/subscriptions.ts
Till JS 86a01426e8 feat(cards-server): Phase δ.1 — subscriptions + version reads + smart-merge diff
Server-side plumbing for Phase δ. Frontend hookup follows in δ.2.

  - services/subscriptions.ts: subscribe/unsubscribe (idempotent
    upsert on (user, deck), stamps the latest_version_id at
    subscribe-time so the client knows what it pulled). listForUser
    returns each sub with `updateAvailable: currentVersion !== latest`
    so the client can render an update indicator without a second
    round-trip. Refuses paid decks with 403 — that path comes back
    in Phase ζ once the credits Marketplace lands.
  - versionWithCards: deterministic ord-ordered card payload for a
    specific version. Read-public so anonymous browsers can preview
    a deck's content.
  - diffSince: smart-merge payload between any two versions. Splits
    the latest cards into added/changed/unchanged + lists removed
    by content_hash. The 'changed' bucket is heuristic (ord-position
    pair where one was removed and one was added) — solid enough
    until Phase ε's pull-request pipeline gives us real card
    lineage.
  - routes/subscriptions.ts mounts: GET /v1/me/subscriptions,
    POST/DELETE /v1/decks/:slug/subscribe (auth required),
    GET /v1/decks/:slug/versions/:semver (public),
    GET /v1/decks/:slug/diff?from=<semver> (public).

cards-web layout fix:
  - Marketplace surface (/explore, /u/, /d/) was previously gated
    behind the AuthGate — anonymous browsers got pushed to /login
    via client-side navigate. PUBLIC_PATHS extended so those routes
    SSR + render unauthed.

Validated: tsc clean on cards-server, svelte-check 0/0 on cards-web.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:33:58 +02:00

56 lines
1.9 KiB
TypeScript

import { Hono } from 'hono';
import type { AuthUser } from '../middleware/jwt-auth';
import type { SubscriptionService } from '../services/subscriptions';
import { BadRequestError, UnauthorizedError } from '../lib/errors';
function requireUser(user: AuthUser | undefined): AuthUser {
if (!user || !user.userId) throw new UnauthorizedError();
return user;
}
export function createSubscriptionRoutes(service: SubscriptionService) {
const router = new Hono<{ Variables: { user?: AuthUser } }>();
// User-scoped routes -----------------------------------------------------
router.get('/me/subscriptions', async (c) => {
const user = requireUser(c.get('user'));
const list = await service.listForUser(user.userId);
return c.json(list);
});
router.post('/decks/:slug/subscribe', async (c) => {
const user = requireUser(c.get('user'));
const result = await service.subscribe(user.userId, c.req.param('slug'));
return c.json(result, 201);
});
router.delete('/decks/:slug/subscribe', async (c) => {
const user = requireUser(c.get('user'));
await service.unsubscribe(user.userId, c.req.param('slug'));
return c.json({ ok: true });
});
// Public read routes -----------------------------------------------------
router.get('/decks/:slug/versions/:semver', async (c) => {
const semver = c.req.param('semver');
if (!/^\d+\.\d+\.\d+$/.test(semver)) {
throw new BadRequestError('semver must look like 1.0.0');
}
const payload = await service.versionWithCards(c.req.param('slug'), semver);
return c.json(payload);
});
router.get('/decks/:slug/diff', async (c) => {
const url = new URL(c.req.url);
const from = url.searchParams.get('from');
if (!from || !/^\d+\.\d+\.\d+$/.test(from)) {
throw new BadRequestError('?from=<semver> required, e.g. ?from=1.0.0');
}
const diff = await service.diffSince(c.req.param('slug'), from);
return c.json(diff);
});
return router;
}