diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index 97d70b1b0..158cbd7b5 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -269,23 +269,12 @@ db.version(1).stores({ 'id, startDate, kind, type, sourceModule, sourceId, parentBlockId, [sourceModule+sourceId], [type+startDate], [kind+startDate], [parentBlockId+recurrenceDate]', timeBlockTags: 'id, blockId, tagId, [blockId+tagId]', - // ─── News (appId: 'news') ─── - // `newsArticles` is the user's personal reading list (saved articles - // from the curated pool plus user-pasted URLs). `newsCategories` is - // user-defined folders for the reading list. `newsPreferences` is a - // singleton row holding selected topics, blocklist, language and the - // learned topic/source weights. `newsReactions` records per-article - // feedback (interested / not_interested / source_blocked / hidden) - // and is what the feed engine uses to suppress already-rated items. - // `newsCachedFeed` is a local mirror of the latest curated pool from - // the server — capped to ~200 entries for offline reading. It is - // intentionally NOT in module.config.ts and therefore not synced. - newsArticles: - 'id, type, isArchived, isRead, isFavorite, categoryId, originalUrl, sourceCuratedId, [type+isArchived], [categoryId+createdAt]', - newsCategories: 'id, sortOrder', - newsPreferences: 'id', - newsReactions: 'id, articleId, reaction, sourceSlug, topic, [reaction+createdAt]', - newsCachedFeed: 'id, topic, sourceSlug, language, publishedAt, [topic+publishedAt]', + // ─── News tables intentionally NOT in v1 ─── + // Originally added here, but that violates Dexie's "never edit a + // published version" rule. Existing browsers stuck at db.version(3) + // would never trigger an upgrade for v1 changes, so the news tables + // would only appear on a fresh-cleared IndexedDB. Moved into + // db.version(4) below — see comment there for rationale + indexes. // ─── Shared: Global Tags (appId: 'tags') ─── globalTags: 'id, name, groupId', @@ -329,6 +318,38 @@ db.version(3).stores({ whoMessages: 'id, gameId, sender, createdAt, [gameId+createdAt]', }); +// Schema version 4 — adds the News module (curated public feed + saved +// reading list + per-user preferences/reactions + a local cache of the +// server pool). Additive only; no v1/v2/v3 tables touched. +// +// `newsArticles` is the user's personal reading list (saved from the +// curated pool or pasted URLs). `newsCategories` are user-defined +// folders. `newsPreferences` is a singleton row keyed on 'singleton' +// holding selected topics, blocklist, languages and the learned topic +// + source weights. `newsReactions` records per-article feedback +// (interested / not_interested / source_blocked / hidden) and is what +// the feed engine uses to suppress already-rated items. `newsCachedFeed` +// is a local mirror of the server's curated pool, capped to ~400 rows +// for offline reading — intentionally NOT in module.config.ts and +// therefore not synced. +// +// Index strategy: +// - newsArticles indexes type/categoryId/sourceCuratedId for the +// reading-list filter strip and the saveFromCurated() dedupe lookup +// ([type+isArchived] for the unread/archive tab queries) +// - newsReactions indexes [reaction+createdAt] so the feed engine can +// range-scan "what did the user dismiss" without loading every row +// - newsCachedFeed indexes [topic+publishedAt] so the topic-filter +// pass in rankFeed() can do a single index walk instead of N scans +db.version(4).stores({ + newsArticles: + 'id, type, isArchived, isRead, isFavorite, categoryId, originalUrl, sourceCuratedId, [type+isArchived], [categoryId+createdAt]', + newsCategories: 'id, sortOrder', + newsPreferences: 'id', + newsReactions: 'id, articleId, reaction, sourceSlug, topic, [reaction+createdAt]', + newsCachedFeed: 'id, topic, sourceSlug, language, publishedAt, [topic+publishedAt]', +}); + // ─── Sync Routing ────────────────────────────────────────── // SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, // toSyncName() and fromSyncName() are now derived from per-module diff --git a/apps/mana/apps/web/src/lib/modules/news/ListView.svelte b/apps/mana/apps/web/src/lib/modules/news/ListView.svelte index d86ef08df..23d995cd9 100644 --- a/apps/mana/apps/web/src/lib/modules/news/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/news/ListView.svelte @@ -32,9 +32,10 @@ // We accept ViewProps for protocol compatibility but the workbench // view doesn't navigate within itself — every "open" jumps to the - // dedicated /news routes. - let _props: ViewProps = $props(); - void _props; + // dedicated /news routes. Empty destructure satisfies the $props() + // declaration without referencing the props object (which would + // trigger Svelte's "captured initial value" warning). + const {}: ViewProps = $props(); const prefs$ = usePreferences(); const pool$ = useCachedFeed(); diff --git a/apps/mana/apps/web/src/lib/modules/news/api.ts b/apps/mana/apps/web/src/lib/modules/news/api.ts index a2f77b7e0..ff5f4132d 100644 --- a/apps/mana/apps/web/src/lib/modules/news/api.ts +++ b/apps/mana/apps/web/src/lib/modules/news/api.ts @@ -13,13 +13,22 @@ * leak the SSR-side internal Docker hostname (`http://mana-api:3060`) to * the browser and trip CSP / DNS. * - * Auth is the unified Mana JWT, picked up by the same fetch wrapper the - * rest of the app uses (cookie + Authorization header set by SvelteKit - * `fetch` via the auth-provider middleware). + * Auth is the unified Mana JWT pulled from `authStore.getAccessToken()` + * and attached as a `Authorization: Bearer …` header. The credentials/ + * cookie path does NOT work — the apps/api authMiddleware only reads + * the Authorization header. Initially we passed `credentials: 'include'` + * thinking the cookie alone was enough, which made every browser-side + * fetch return 401 because mana-api never sees a token. */ +import { authStore } from '$lib/stores/auth.svelte'; import { getManaApiUrl } from '$lib/api/config'; +async function authHeader(): Promise> { + const token = await authStore.getAccessToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + export interface FeedArticleDto { id: string; originalUrl: string; @@ -61,7 +70,9 @@ export async function fetchFeed( if (query.offset != null) params.set('offset', String(query.offset)); const url = `${getManaApiUrl()}/api/v1/news/feed${params.toString() ? `?${params}` : ''}`; - const response = await fetchImpl(url, { credentials: 'include' }); + const response = await fetchImpl(url, { + headers: await authHeader(), + }); if (!response.ok) { throw new Error(`fetchFeed failed: ${response.status}`); } @@ -92,8 +103,10 @@ export async function extractFromUrl( ): Promise { const response = await fetchImpl(`${getManaApiUrl()}/api/v1/news/extract/save`, { method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(await authHeader()), + }, body: JSON.stringify({ url }), }); if (!response.ok) {