diff --git a/apps/cards/apps/web/src/routes/+layout.svelte b/apps/cards/apps/web/src/routes/+layout.svelte index ac2034d5e..7d6d19cf0 100644 --- a/apps/cards/apps/web/src/routes/+layout.svelte +++ b/apps/cards/apps/web/src/routes/+layout.svelte @@ -14,7 +14,10 @@ // Auth/marketing pages render outside the gate so first-time visitors // can actually reach them. Everything else is gated. - const PUBLIC_PATHS = ['/login', '/register', '/forgot-password']; + // Public marketplace surface — anyone can browse decks/profiles/explore + // without signing in. AuthGate kicks in once the user opens their own + // decks/learn pages. + const PUBLIC_PATHS = ['/login', '/register', '/forgot-password', '/explore', '/u/', '/d/']; const isPublic = $derived(PUBLIC_PATHS.some((p) => page.url.pathname.startsWith(p))); function handleAuthReady() { diff --git a/services/cards-server/src/index.ts b/services/cards-server/src/index.ts index 904424384..72bd9f908 100644 --- a/services/cards-server/src/index.ts +++ b/services/cards-server/src/index.ts @@ -20,10 +20,12 @@ import { AuthorService } from './services/authors'; import { DeckService } from './services/decks'; import { ExploreService } from './services/explore'; import { EngagementService } from './services/engagement'; +import { SubscriptionService } from './services/subscriptions'; import { createAuthorRoutes } from './routes/authors'; import { createDeckRoutes } from './routes/decks'; import { createExploreRoutes } from './routes/explore'; import { createEngagementRoutes } from './routes/engagement'; +import { createSubscriptionRoutes } from './routes/subscriptions'; // ─── Bootstrap ────────────────────────────────────────────── @@ -34,6 +36,7 @@ const authorService = new AuthorService(db); const deckService = new DeckService(db, config.manaLlmUrl); const exploreService = new ExploreService(db); const engagementService = new EngagementService(db); +const subscriptionService = new SubscriptionService(db); // ─── App ──────────────────────────────────────────────────── @@ -73,6 +76,7 @@ v1.use('/*', optionalAuth(config.manaAuthUrl)); // via requireUser() helpers when needed. v1.route('/', createExploreRoutes(exploreService)); v1.route('/', createEngagementRoutes(engagementService)); +v1.route('/', createSubscriptionRoutes(subscriptionService)); v1.route('/authors', createAuthorRoutes(authorService)); v1.route('/decks', createDeckRoutes(authorService, deckService)); diff --git a/services/cards-server/src/routes/subscriptions.ts b/services/cards-server/src/routes/subscriptions.ts new file mode 100644 index 000000000..a80257fa1 --- /dev/null +++ b/services/cards-server/src/routes/subscriptions.ts @@ -0,0 +1,56 @@ +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= required, e.g. ?from=1.0.0'); + } + const diff = await service.diffSince(c.req.param('slug'), from); + return c.json(diff); + }); + + return router; +} diff --git a/services/cards-server/src/services/subscriptions.ts b/services/cards-server/src/services/subscriptions.ts new file mode 100644 index 000000000..45e74b386 --- /dev/null +++ b/services/cards-server/src/services/subscriptions.ts @@ -0,0 +1,253 @@ +/** + * Subscriptions + version reads for Phase δ. + * + * `subscribe` records the user's intent and stamps the version they + * pulled at — so the client can compute a per-card diff against + * whatever the deck's `latest_version_id` is now. We don't push the + * cards back: that's the client's job (it owns the local Dexie). + * + * `versionWithCards` returns a version's cards in stable `ord` order + * so the client can replay them deterministically into its own DB. + * + * `diffSince` computes the smart-merge payload server-side: based on + * per-card `content_hash`, classify each card in the latest version + * as `unchanged | changed | added`, and list the hashes the latest + * version no longer has (`removed`). Saves the client from holding + * both versions at once. + */ + +import { and, asc, eq } from 'drizzle-orm'; +import type { Database } from '../db/connection'; +import { deckSubscriptions, publicDeckCards, publicDeckVersions, publicDecks } from '../db/schema'; +import { ConflictError, ForbiddenError, NotFoundError } from '../lib/errors'; + +export interface VersionPayload { + id: string; + semver: string; + contentHash: string; + publishedAt: Date; + changelog: string | null; + cards: VersionCardPayload[]; +} + +export interface VersionCardPayload { + contentHash: string; + type: string; + fields: Record; + ord: number; +} + +export interface DiffPayload { + from: string; + to: string; + added: VersionCardPayload[]; + changed: { previous: { contentHash: string }; next: VersionCardPayload }[]; + unchanged: { contentHash: string; ord: number }[]; + removed: { contentHash: string }[]; +} + +export class SubscriptionService { + constructor(private readonly db: Database) {} + + async subscribe(userId: string, deckSlug: string) { + const deck = await this.db.query.publicDecks.findFirst({ + where: eq(publicDecks.slug, deckSlug), + }); + if (!deck) throw new NotFoundError('Deck not found'); + if (deck.isTakedown) throw new ForbiddenError('Deck under takedown'); + if (!deck.latestVersionId) throw new ConflictError('Deck has no published version yet'); + // Paid decks need a purchase first — Phase ζ. For now: refuse. + if (deck.priceCredits > 0) { + throw new ForbiddenError('Paid decks require a purchase before subscribing (Phase ζ)'); + } + + await this.db + .insert(deckSubscriptions) + .values({ + userId, + deckId: deck.id, + currentVersionId: deck.latestVersionId, + }) + .onConflictDoUpdate({ + target: [deckSubscriptions.userId, deckSubscriptions.deckId], + set: { currentVersionId: deck.latestVersionId }, + }); + + return { deckSlug, latestVersionId: deck.latestVersionId }; + } + + async unsubscribe(userId: string, deckSlug: string) { + const deck = await this.db.query.publicDecks.findFirst({ + where: eq(publicDecks.slug, deckSlug), + }); + if (!deck) throw new NotFoundError('Deck not found'); + await this.db + .delete(deckSubscriptions) + .where(and(eq(deckSubscriptions.userId, userId), eq(deckSubscriptions.deckId, deck.id))); + } + + async listForUser(userId: string) { + const rows = await this.db + .select({ + deckSlug: publicDecks.slug, + deckTitle: publicDecks.title, + deckDescription: publicDecks.description, + deckLatestVersionId: publicDecks.latestVersionId, + subscribedAt: deckSubscriptions.subscribedAt, + notifyUpdates: deckSubscriptions.notifyUpdates, + currentVersionId: deckSubscriptions.currentVersionId, + }) + .from(deckSubscriptions) + .innerJoin(publicDecks, eq(publicDecks.id, deckSubscriptions.deckId)) + .where(eq(deckSubscriptions.userId, userId)) + .orderBy(deckSubscriptions.subscribedAt); + + return rows.map((r) => ({ + deckSlug: r.deckSlug, + deckTitle: r.deckTitle, + deckDescription: r.deckDescription, + subscribedAt: r.subscribedAt, + notifyUpdates: r.notifyUpdates, + currentVersionId: r.currentVersionId, + latestVersionId: r.deckLatestVersionId, + updateAvailable: + r.deckLatestVersionId !== null && r.currentVersionId !== r.deckLatestVersionId, + })); + } + + async versionWithCards(deckSlug: string, semver: string): Promise { + const deck = await this.db.query.publicDecks.findFirst({ + where: eq(publicDecks.slug, deckSlug), + }); + if (!deck) throw new NotFoundError('Deck not found'); + const version = await this.db.query.publicDeckVersions.findFirst({ + where: and(eq(publicDeckVersions.deckId, deck.id), eq(publicDeckVersions.semver, semver)), + }); + if (!version) throw new NotFoundError(`Version ${semver} not found`); + + const cards = await this.db + .select() + .from(publicDeckCards) + .where(eq(publicDeckCards.versionId, version.id)) + .orderBy(asc(publicDeckCards.ord)); + + return { + id: version.id, + semver: version.semver, + contentHash: version.contentHash, + publishedAt: version.publishedAt, + changelog: version.changelog, + cards: cards.map((c) => ({ + contentHash: c.contentHash, + type: c.type, + fields: c.fields as Record, + ord: c.ord, + })), + }; + } + + /** Smart-merge diff: tell the client what changed since `fromSemver`. */ + async diffSince(deckSlug: string, fromSemver: string): Promise { + const deck = await this.db.query.publicDecks.findFirst({ + where: eq(publicDecks.slug, deckSlug), + }); + if (!deck) throw new NotFoundError('Deck not found'); + if (!deck.latestVersionId) throw new NotFoundError('Deck has no published version'); + + const latestVersion = await this.db.query.publicDeckVersions.findFirst({ + where: eq(publicDeckVersions.id, deck.latestVersionId), + }); + if (!latestVersion) throw new NotFoundError('Latest version row missing'); + + const fromVersion = await this.db.query.publicDeckVersions.findFirst({ + where: and(eq(publicDeckVersions.deckId, deck.id), eq(publicDeckVersions.semver, fromSemver)), + }); + if (!fromVersion) throw new NotFoundError(`Version ${fromSemver} not found`); + + // Empty diff if already on latest. + if (fromVersion.id === latestVersion.id) { + return { + from: fromSemver, + to: latestVersion.semver, + added: [], + changed: [], + unchanged: [], + removed: [], + }; + } + + const [fromCards, toCards] = await Promise.all([ + this.db + .select({ contentHash: publicDeckCards.contentHash, ord: publicDeckCards.ord }) + .from(publicDeckCards) + .where(eq(publicDeckCards.versionId, fromVersion.id)), + this.db + .select() + .from(publicDeckCards) + .where(eq(publicDeckCards.versionId, latestVersion.id)) + .orderBy(asc(publicDeckCards.ord)), + ]); + + const fromHashes = new Set(fromCards.map((c) => c.contentHash)); + const toHashes = new Set(toCards.map((c) => c.contentHash)); + + // Cards that are still here verbatim. + const unchanged: { contentHash: string; ord: number }[] = []; + // Brand-new cards (hash not in `from`). + const added: VersionCardPayload[] = []; + // Cards in `from` that vanished completely. + const removed: { contentHash: string }[] = fromCards + .filter((c) => !toHashes.has(c.contentHash)) + .map((c) => ({ contentHash: c.contentHash })); + + // `changed` is hard to detect without a stable card-id across + // versions. We approximate by treating ord-position pairs that + // neither match nor are in the unchanged set: an "added" at the + // same ord as a "removed" → changed. Phase ε's pull-request + // pipeline gives us a real card-lineage; until then this + // heuristic is good enough. + const changed: { previous: { contentHash: string }; next: VersionCardPayload }[] = []; + const removedByOrd = new Map(); + for (const c of fromCards) { + if (!toHashes.has(c.contentHash)) removedByOrd.set(c.ord, c.contentHash); + } + + for (const c of toCards) { + if (fromHashes.has(c.contentHash)) { + unchanged.push({ contentHash: c.contentHash, ord: c.ord }); + } else if (removedByOrd.has(c.ord)) { + const prevHash = removedByOrd.get(c.ord)!; + removedByOrd.delete(c.ord); + changed.push({ + previous: { contentHash: prevHash }, + next: { + contentHash: c.contentHash, + type: c.type, + fields: c.fields as Record, + ord: c.ord, + }, + }); + } else { + added.push({ + contentHash: c.contentHash, + type: c.type, + fields: c.fields as Record, + ord: c.ord, + }); + } + } + + // Anything left in removedByOrd is a real removal (not paired up + // with a `changed`). + const trueRemoved = removed.filter((r) => [...removedByOrd.values()].includes(r.contentHash)); + + return { + from: fromSemver, + to: latestVersion.semver, + added, + changed, + unchanged, + removed: trueRemoved, + }; + } +}