managarten/services/cards-server/src/db/schema/engagement.ts
Till JS a7b62ea8ae feat(cards-server): Phase α — service skeleton + 16-table schema
Lays the foundation for the Cards marketplace + community backend per
apps/cards/docs/MARKETPLACE_PLAN.md. Phase α scope: skeleton, schema,
JWT auth wiring, health endpoint. Routes follow in Phase β.

Stack: Hono + Bun + Drizzle + Postgres + jose-JWKS — mirrors the
mana-credits service template.

Schema: pgSchema('cards') inside mana_platform, 16 tables across six
groups in src/db/schema/:
  - authors.ts: authors, author_follows
  - decks.ts: decks, deck_versions, deck_cards (with cards_card_type
    enum mirroring @mana/cards-core; per-card content_hash for
    smart-merge; CHECK constraint that paid decks must use
    Cards-Pro-Only-1.0 license)
  - tags.ts: tag_definitions (hierarchical), deck_tags
  - engagement.ts: deck_stars, deck_subscriptions, deck_forks
  - discussions.ts: deck_pull_requests (with diff jsonb +
    pr_status enum), card_discussions (bound to card_content_hash
    so threads survive version bumps)
  - moderation.ts: deck_reports (with category/status enums),
    ai_moderation_log
  - credits.ts: deck_purchases (snapshot price + author/mana split),
    author_payouts

Phase λ's co_learn_sessions intentionally not yet here.

Service plumbing:
  - src/index.ts: Hono entry on :3072, /health unauth, /v1 stub
  - src/config.ts: env loader with author-payout BPS knobs
    (defaults 80/20 standard, 90/10 verified-mana) and
    community-verified thresholds
  - src/middleware/jwt-auth.ts + service-auth.ts: JWKS validation
    + X-Service-Key check (mirrors mana-credits)
  - src/lib/errors.ts: HttpError + named subclasses
  - drizzle.config.ts pointing at mana_platform with schemaFilter:cards
  - drizzle/0000_*.sql committed so other devs / prod migration path
    has a reproducible starting point

Validated: tsc --noEmit clean, drizzle-kit generate produces
233-line SQL with all 16 tables + 5 enums + indexes.

Next (Phase α.4): Dockerfile + docker-compose + cloudflare tunnel
route cards-api.mana.how → :3072.

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

66 lines
2.2 KiB
TypeScript

/**
* Engagement primitives — Stars (bookmarks), Subscriptions (live
* updates), Forks (own copy with lineage).
*
* All keyed by `user_id` text — a plain reference to auth.users.id
* with no cross-DB FK.
*/
import { boolean, index, timestamp, uniqueIndex, uuid, text } from 'drizzle-orm/pg-core';
import { cardsSchema } from './_schema';
import { publicDecks, publicDeckVersions } from './decks';
export const deckStars = cardsSchema.table(
'deck_stars',
{
userId: text('user_id').notNull(),
deckId: uuid('deck_id')
.notNull()
.references(() => publicDecks.id, { onDelete: 'cascade' }),
starredAt: timestamp('starred_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
pk: uniqueIndex('deck_stars_pk').on(t.userId, t.deckId),
deckIdx: index('deck_stars_deck_idx').on(t.deckId),
})
);
export const deckSubscriptions = cardsSchema.table(
'deck_subscriptions',
{
userId: text('user_id').notNull(),
deckId: uuid('deck_id')
.notNull()
.references(() => publicDecks.id, { onDelete: 'cascade' }),
// Latest version the user has pulled. Smart-merge compares this to
// the deck's `latest_version_id` to compute the diff.
currentVersionId: uuid('current_version_id').references(() => publicDeckVersions.id, {
onDelete: 'set null',
}),
subscribedAt: timestamp('subscribed_at', { withTimezone: true }).defaultNow().notNull(),
notifyUpdates: boolean('notify_updates').notNull().default(true),
},
(t) => ({
pk: uniqueIndex('deck_subscriptions_pk').on(t.userId, t.deckId),
deckIdx: index('deck_subscriptions_deck_idx').on(t.deckId),
userIdx: index('deck_subscriptions_user_idx').on(t.userId),
})
);
export const deckForks = cardsSchema.table(
'deck_forks',
{
userId: text('user_id').notNull(),
sourceDeckId: uuid('source_deck_id')
.notNull()
.references(() => publicDecks.id, { onDelete: 'cascade' }),
sourceVersionId: uuid('source_version_id')
.notNull()
.references(() => publicDeckVersions.id, { onDelete: 'cascade' }),
forkedAt: timestamp('forked_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
pk: uniqueIndex('deck_forks_pk').on(t.userId, t.sourceDeckId, t.sourceVersionId),
sourceIdx: index('deck_forks_source_idx').on(t.sourceDeckId),
})
);