feat(wordeck): L-1f Phase C — public-feed-Endpoint + DSE für lokale Speicherung
Some checks are pending
CI / validate (push) Waiting to run
Some checks are pending
CI / validate (push) Waiting to run
apps/api:
- neue Route GET /api/v1/public/feed?cursor=<opaque>&limit=20
- Plattform-Konvention aus LOCAL_FIRST_LOGIN_OPTIONAL.md §3 D
- Shape: { items: [{ id, type:'deck', slug, title, excerpt, author,
publishedAt, stats }], nextCursor: <opaque> | null }
- Quelle: marketplace.public_decks (is_takedown=false), sortiert
nach createdAt desc — Aggregatoren wie hub.mana.how/timeline
konsumieren genau diese Shape
apps/web/datenschutz:
- neue Sektion 7a „Lokale Daten + anonyme Nutzung" mit Hinweisen auf
IndexedDB-DB-Name manasync_wordeck, anonyme Geräte-ID anon:<ulid>,
Sign-In-Lift mit AES-GCM-256-Vault-Verschlüsselung, Browser-Reset-
Risiko bei anonymer Nutzung
- alter sql-wasm-Hinweis raus (Wordeck nutzt jetzt event-sync, kein
sql-wasm mehr)
Verbleibend für Live-Smoke (Till manuell):
- wordeck.com lädt → /decks ohne Login (anonymous-Mode)
- Deck anlegen → IndexedDB manasync_wordeck zeigt Event
- Login → Re-Tagging-Sweep + Push zu sync2.mana.how
- /api/v1/public/feed Smoke (oder über hub.mana.how)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e0605b2f2e
commit
bebd182540
3 changed files with 150 additions and 2 deletions
|
|
@ -16,6 +16,7 @@ import { decksGenerateRouter } from './routes/decks-generate.ts';
|
|||
import { authorsRouter as marketplaceAuthorsRouter } from './routes/marketplace/authors.ts';
|
||||
import { marketplaceDecksRouter } from './routes/marketplace/decks.ts';
|
||||
import { exploreRouter as marketplaceExploreRouter } from './routes/marketplace/explore.ts';
|
||||
import { publicFeedRouter } from './routes/public-feed.ts';
|
||||
import { engagementRouter as marketplaceEngagementRouter } from './routes/marketplace/engagement.ts';
|
||||
import { subscriptionsRouter as marketplaceSubscriptionsRouter } from './routes/marketplace/subscriptions.ts';
|
||||
import { forkRouter as marketplaceForkRouter } from './routes/marketplace/fork.ts';
|
||||
|
|
@ -90,6 +91,10 @@ app.route('/api/v1/marketplace/me', marketplaceMeRouter());
|
|||
app.route('/api/v1/marketplace/authors', marketplaceAuthorsRouter());
|
||||
app.route('/api/v1/marketplace/decks', marketplaceDecksRouter());
|
||||
|
||||
// Plattform-Public-Feed-Konvention (LOCAL_FIRST_LOGIN_OPTIONAL.md §3 D).
|
||||
// hub.mana.how/timeline aggregiert über /api/v1/public/feed aller Apps.
|
||||
app.route('/api/v1/public', publicFeedRouter());
|
||||
|
||||
app.get('/', (c) =>
|
||||
c.json({
|
||||
app: 'wordeck',
|
||||
|
|
|
|||
118
apps/api/src/routes/public-feed.ts
Normal file
118
apps/api/src/routes/public-feed.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/**
|
||||
* Public-Feed-Endpoint nach Plattform-Konvention
|
||||
* (siehe `mana/docs/playbooks/LOCAL_FIRST_LOGIN_OPTIONAL.md` Konvention D).
|
||||
*
|
||||
* Shape:
|
||||
* GET /api/v1/public/feed?cursor=<opaque>&limit=20
|
||||
* → { items: [{ id, type, slug, title, excerpt, author, publishedAt, ... }],
|
||||
* nextCursor: <opaque> | null }
|
||||
*
|
||||
* Wordecks Public-Items sind die published Marketplace-Decks. Wir wrappen
|
||||
* den Marketplace-Browse mit dem Konventions-Output. Aggregator-Apps wie
|
||||
* `hub.mana.how/timeline` konsumieren genau diese Shape.
|
||||
*
|
||||
* Keine Auth — pure public read.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { authors, publicDecks } from '../db/schema/index.ts';
|
||||
import { count, eq, and, desc, sql } from 'drizzle-orm';
|
||||
import { getDb, type CardsDb } from '../db/connection.ts';
|
||||
|
||||
interface FeedItem {
|
||||
id: string;
|
||||
type: 'deck';
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt: string | null;
|
||||
author: {
|
||||
slug: string;
|
||||
display_name: string;
|
||||
};
|
||||
publishedAt: string;
|
||||
stats: {
|
||||
card_count: number;
|
||||
star_count: number;
|
||||
};
|
||||
}
|
||||
|
||||
function encodeCursor(offset: number): string {
|
||||
return Buffer.from(JSON.stringify({ offset })).toString('base64url');
|
||||
}
|
||||
|
||||
function decodeCursor(cursor: string): number {
|
||||
try {
|
||||
const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf-8'));
|
||||
return typeof parsed.offset === 'number' ? parsed.offset : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPage(
|
||||
db: CardsDb,
|
||||
offset: number,
|
||||
limit: number,
|
||||
): Promise<{ items: FeedItem[]; total: number }> {
|
||||
const starCount = sql<number>`(SELECT count(*)::int FROM marketplace.deck_stars s WHERE s.deck_id = ${publicDecks.id})`;
|
||||
const cardCountExpr = sql<number>`COALESCE((SELECT v.card_count FROM marketplace.deck_versions v WHERE v.id = ${publicDecks.latestVersionId}), 0)`;
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: publicDecks.id,
|
||||
slug: publicDecks.slug,
|
||||
title: publicDecks.title,
|
||||
description: publicDecks.description,
|
||||
createdAt: publicDecks.createdAt,
|
||||
cardCount: cardCountExpr,
|
||||
starCount,
|
||||
ownerSlug: authors.slug,
|
||||
ownerDisplayName: authors.displayName,
|
||||
})
|
||||
.from(publicDecks)
|
||||
.innerJoin(authors, eq(authors.userId, publicDecks.ownerUserId))
|
||||
.where(and(eq(publicDecks.isTakedown, false)))
|
||||
.orderBy(desc(publicDecks.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const totalRow = await db
|
||||
.select({ value: count() })
|
||||
.from(publicDecks)
|
||||
.where(and(eq(publicDecks.isTakedown, false)));
|
||||
|
||||
return {
|
||||
items: rows.map(
|
||||
(r): FeedItem => ({
|
||||
id: r.id,
|
||||
type: 'deck',
|
||||
slug: r.slug,
|
||||
title: r.title,
|
||||
excerpt: r.description,
|
||||
author: { slug: r.ownerSlug, display_name: r.ownerDisplayName },
|
||||
publishedAt: r.createdAt.toISOString(),
|
||||
stats: { card_count: Number(r.cardCount), star_count: Number(r.starCount) },
|
||||
}),
|
||||
),
|
||||
total: totalRow[0]?.value ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function publicFeedRouter() {
|
||||
const r = new Hono();
|
||||
r.get('/feed', async (c) => {
|
||||
const url = new URL(c.req.url);
|
||||
const limitRaw = url.searchParams.get('limit');
|
||||
const cursor = url.searchParams.get('cursor');
|
||||
const limit = Math.min(Math.max(1, parseInt(limitRaw ?? '20', 10) || 20), 100);
|
||||
const offset = cursor ? decodeCursor(cursor) : 0;
|
||||
|
||||
const { items, total } = await loadPage(getDb(), offset, limit);
|
||||
const nextOffset = offset + items.length;
|
||||
const nextCursor = nextOffset < total ? encodeCursor(nextOffset) : null;
|
||||
|
||||
return c.json({ items, nextCursor });
|
||||
});
|
||||
return r;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue