Routes (additiv unter /api/v1/marketplace/*): - POST/GET /authors/me — eigenes Author-Profil anlegen/updaten/lesen - GET /authors/:slug — public Profile-Lookup (banned-reason gestrippt) - POST /decks — Deck-Init (Slug-Validation + Pflicht-Author-Profil + CHECK auf paid + Pro-License) - POST /decks/:slug/publish — Versions-Snapshot mit per-Karte cardContentHash aus @cards/domain, per-Version-Hash, AI-Mod-Stub-Log, atomarer latest_version_id-Bump in Drizzle-Transaction - PATCH /decks/:slug — Metadaten-Update (Owner-Only) - GET /decks/:slug — Public-Detail mit optional-auth-Middleware Geport aus cards-decommission-base:services/cards-server/, mit Greenfield-Anpassungen: - Hashing über @cards/domain.cardContentHash (gemeinsame SoT zwischen privatem cards.cards und marketplace.deck_cards), per- Version-Hash als SHA-256 über sortierte Karten-Hashes mit Ord-Prefix - AI-Moderation als R2-Stub (pass+rationale+model='stub'), echte mana-llm-Anbindung in späterer Welle - Auth-Middleware-Shape an Greenfield (userId/tier/authMode in c.get(...) statt user-Object), optional-auth als Schwester für anonymen Public-Read - Hono-typing: outer Marketplace-Decks-Router ist Partial<AuthVars> weil Public-GET kein JWT braucht; Auth-Subroute ist strict Lese-Referenz: - 3331 LOC altes cards-server-Code (routes, services, middleware, lib) unter docs/marketplace/archive/code/ archiviert. Read-only, nicht im Build-Path. Verifikation: - 16 neue Vitest-Tests (Slug + Version-Hash), 72 gesamt grün - type-check 0 errors - E2E-Smoke gegen lokale cards-api: Cardecky-Author + Deck r2-stoische-ethik mit 3 Karten v1.0.0 (basic + basic + cloze), per-Karten-Hashes geschrieben, ai_moderation_log-Row da, semver-409 + paid-422-Errors verifiziert. Smoke-Daten danach aufgeräumt. Verbleibend für R3+: Discovery (explore + search), Engagement (stars/ subscribe/fork), Smart-Merge mit FSRS-State-Erhalt; danach R4 PRs + Card-Discussions, R5 Frontend-Routes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
266 lines
8.7 KiB
TypeScript
266 lines
8.7 KiB
TypeScript
/**
|
|
* 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 {
|
|
deckPurchases,
|
|
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 non-refunded purchase before the user can
|
|
// subscribe (= pull the cards). The author themselves can
|
|
// always subscribe to their own paid deck for testing.
|
|
if (deck.priceCredits > 0 && deck.ownerUserId !== userId) {
|
|
const purchase = await this.db.query.deckPurchases.findFirst({
|
|
where: and(eq(deckPurchases.buyerUserId, userId), eq(deckPurchases.deckId, deck.id)),
|
|
});
|
|
if (!purchase || purchase.refundedAt) {
|
|
throw new ForbiddenError('Paid deck — purchase required before subscribing');
|
|
}
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
}
|