mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
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>
This commit is contained in:
parent
e77134bd8b
commit
86a01426e8
4 changed files with 317 additions and 1 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
56
services/cards-server/src/routes/subscriptions.ts
Normal file
56
services/cards-server/src/routes/subscriptions.ts
Normal file
|
|
@ -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=<semver> required, e.g. ?from=1.0.0');
|
||||
}
|
||||
const diff = await service.diffSince(c.req.param('slug'), from);
|
||||
return c.json(diff);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
253
services/cards-server/src/services/subscriptions.ts
Normal file
253
services/cards-server/src/services/subscriptions.ts
Normal file
|
|
@ -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<string, string>;
|
||||
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<VersionPayload> {
|
||||
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<string, string>,
|
||||
ord: c.ord,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Smart-merge diff: tell the client what changed since `fromSemver`. */
|
||||
async diffSince(deckSlug: string, fromSemver: string): Promise<DiffPayload> {
|
||||
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<number, string>();
|
||||
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<string, string>,
|
||||
ord: c.ord,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
added.push({
|
||||
contentHash: c.contentHash,
|
||||
type: c.type,
|
||||
fields: c.fields as Record<string, string>,
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue