Accounts Phase 3: save articles, account history, device import
- API (auth-required): GET/POST/DELETE /api/saved (+/api/saved/ids), GET/POST /api/history, POST /api/import — all FK-safe (skip ids that no longer exist). queries.saved/saved_ids/history reuse the feed article shape. - Frontend: reactive savedIds store (SvelteSet) + optimistic toggleSave; a Save control on cards for signed-in users; a "Saved" view (You sheet) with its own empty state; newly-seen items mirror to account history (cross-device); and a one-time import folds this device's anonymous history into the account on first sign-in. Anonymous browsing unchanged. 115 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+14
-6
@@ -5,12 +5,20 @@ export async function getJSON(url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function postJSON(url, body) {
|
export async function postJSON(url, body) {
|
||||||
const r = await fetch(url, {
|
return sendJSON('POST', url, body);
|
||||||
method: 'POST',
|
}
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body ?? {}),
|
export async function delJSON(url) {
|
||||||
credentials: 'same-origin', // send/receive the session cookie
|
return sendJSON('DELETE', url);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
async function sendJSON(method, url, body) {
|
||||||
|
const opts = { method, credentials: 'same-origin' };
|
||||||
|
if (body !== undefined) {
|
||||||
|
opts.headers = { 'Content-Type': 'application/json' };
|
||||||
|
opts.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
const r = await fetch(url, opts);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
let msg = r.statusText;
|
let msg = r.statusText;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// Shared, reactive auth state (Svelte 5 runes in a module). Components read
|
// Shared, reactive auth + saved-articles state (Svelte 5 runes in a module).
|
||||||
// `auth.user` and it stays reactive across the app.
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
import { getJSON, postJSON } from './api.js';
|
import { getJSON, postJSON, delJSON } from './api.js';
|
||||||
|
|
||||||
export const auth = $state({ user: null, ready: false });
|
export const auth = $state({ user: null, ready: false });
|
||||||
|
export const savedIds = new SvelteSet(); // reactive set of saved article ids
|
||||||
|
|
||||||
export async function refresh() {
|
export async function refresh() {
|
||||||
try {
|
try {
|
||||||
@@ -12,6 +13,34 @@ export async function refresh() {
|
|||||||
} finally {
|
} finally {
|
||||||
auth.ready = true;
|
auth.ready = true;
|
||||||
}
|
}
|
||||||
|
if (auth.user) await loadSaved();
|
||||||
|
else savedIds.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSaved() {
|
||||||
|
try {
|
||||||
|
const ids = await getJSON('/api/saved/ids');
|
||||||
|
savedIds.clear();
|
||||||
|
for (const id of ids) savedIds.add(id);
|
||||||
|
} catch {
|
||||||
|
/* not signed in / transient — leave as-is */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistic toggle; reverts on failure.
|
||||||
|
export async function toggleSave(id) {
|
||||||
|
if (!auth.user) return false;
|
||||||
|
const willSave = !savedIds.has(id);
|
||||||
|
if (willSave) savedIds.add(id);
|
||||||
|
else savedIds.delete(id);
|
||||||
|
try {
|
||||||
|
if (willSave) await postJSON(`/api/saved/${id}`, {});
|
||||||
|
else await delJSON(`/api/saved/${id}`);
|
||||||
|
} catch {
|
||||||
|
if (willSave) savedIds.delete(id);
|
||||||
|
else savedIds.add(id);
|
||||||
|
}
|
||||||
|
return savedIds.has(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request a magic link. Reply is intentionally identical for any address.
|
// Request a magic link. Reply is intentionally identical for any address.
|
||||||
@@ -19,10 +48,10 @@ export function startEmail(email) {
|
|||||||
return postJSON('/api/auth/email/start', { email });
|
return postJSON('/api/auth/email/start', { email });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exchange a magic-link token for a session (cookie set server-side).
|
|
||||||
export async function verifyToken(token) {
|
export async function verifyToken(token) {
|
||||||
const res = await postJSON('/api/auth/email/verify', { token });
|
const res = await postJSON('/api/auth/email/verify', { token });
|
||||||
auth.user = res.user;
|
auth.user = res.user;
|
||||||
|
await loadSaved();
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,5 +60,6 @@ export async function logout() {
|
|||||||
await postJSON('/api/auth/logout', {});
|
await postJSON('/api/auth/logout', {});
|
||||||
} finally {
|
} finally {
|
||||||
auth.user = null;
|
auth.user = null;
|
||||||
|
savedIds.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { auth, savedIds, toggleSave } from '$lib/auth.svelte.js';
|
||||||
|
|
||||||
let { article, onaction, onreplace, ontag, onimageerror, hero = false } = $props();
|
let { article, onaction, onreplace, ontag, onimageerror, hero = false } = $props();
|
||||||
|
let isSaved = $derived(savedIds.has(article.id));
|
||||||
|
|
||||||
const humanize = (s) => (s || '').replace(/-/g, ' ');
|
const humanize = (s) => (s || '').replace(/-/g, ' ');
|
||||||
// Grouping tags are the doorways; cap at 3 so cards stay calm. Fall back to
|
// Grouping tags are the doorways; cap at 3 so cards stay calm. Fall back to
|
||||||
@@ -83,6 +86,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
{#if auth.user}
|
||||||
|
<button class="save" class:on={isSaved} onclick={() => toggleSave(article.id)}
|
||||||
|
aria-pressed={isSaved} title={isSaved ? 'Saved' : 'Save for later'}>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" width="13" height="13">
|
||||||
|
<path d="M6 3h12v18l-6-4-6 4z" fill={isSaved ? 'currentColor' : 'none'}
|
||||||
|
stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
{isSaved ? 'Saved' : 'Save'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{#if onreplace}
|
{#if onreplace}
|
||||||
<button class="replace" onclick={() => onreplace(article)}>
|
<button class="replace" onclick={() => onreplace(article)}>
|
||||||
{article.paywalled ? 'Find one I can read' : 'Replace'}
|
{article.paywalled ? 'Find one I can read' : 'Replace'}
|
||||||
@@ -163,10 +176,16 @@
|
|||||||
}
|
}
|
||||||
.actions button:hover { color: var(--accent-deep); border-bottom-color: var(--accent); }
|
.actions button:hover { color: var(--accent-deep); border-bottom-color: var(--accent); }
|
||||||
.actions .replace { color: var(--accent-deep); border-bottom-color: var(--accent-soft); }
|
.actions .replace { color: var(--accent-deep); border-bottom-color: var(--accent-soft); }
|
||||||
|
.actions .save {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
color: var(--accent-deep); border-bottom-color: var(--accent-soft);
|
||||||
|
}
|
||||||
|
.actions .save.on { color: var(--accent); }
|
||||||
|
/* Save + Replace stay visible; the muter actions reveal on hover. */
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
.actions button:not(.replace) { opacity: 0; transition: opacity 0.16s ease; }
|
.actions button:not(.replace):not(.save) { opacity: 0; transition: opacity 0.16s ease; }
|
||||||
article:hover .actions button:not(.replace),
|
article:hover .actions button:not(.replace):not(.save),
|
||||||
article:focus-within .actions button:not(.replace) { opacity: 1; }
|
article:focus-within .actions button:not(.replace):not(.save) { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Typographic editorial tile (every non-hero card) ---- */
|
/* ---- Typographic editorial tile (every non-hero card) ---- */
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { getJSON } from '$lib/api.js';
|
import { getJSON, postJSON } from '$lib/api.js';
|
||||||
import * as P from '$lib/prefs.js';
|
import * as P from '$lib/prefs.js';
|
||||||
import Header from '$lib/components/Header.svelte';
|
import Header from '$lib/components/Header.svelte';
|
||||||
import BottomNav from '$lib/components/BottomNav.svelte';
|
import BottomNav from '$lib/components/BottomNav.svelte';
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
import ArticleCard from '$lib/components/ArticleCard.svelte';
|
import ArticleCard from '$lib/components/ArticleCard.svelte';
|
||||||
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
|
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
|
||||||
import SignIn from '$lib/components/SignIn.svelte';
|
import SignIn from '$lib/components/SignIn.svelte';
|
||||||
import { auth, refresh as refreshAuth, logout as authLogout } from '$lib/auth.svelte.js';
|
import { auth, savedIds, refresh as refreshAuth, logout as authLogout } from '$lib/auth.svelte.js';
|
||||||
|
|
||||||
let moods = $state([]);
|
let moods = $state([]);
|
||||||
let topics = $state([]);
|
let topics = $state([]);
|
||||||
@@ -32,6 +32,19 @@
|
|||||||
await authLogout();
|
await authLogout();
|
||||||
showYou = false;
|
showYou = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On first sign-in (per account, per device), fold this device's anonymous
|
||||||
|
// history + saved into the account so nothing's lost.
|
||||||
|
$effect(() => {
|
||||||
|
const u = auth.user;
|
||||||
|
if (!u || typeof window === 'undefined') return;
|
||||||
|
const key = 'goodnews:imported:' + u.id;
|
||||||
|
if (localStorage.getItem(key)) return;
|
||||||
|
const seen = [...new Set([...seenIds, ...history.map((a) => a.id)])];
|
||||||
|
postJSON('/api/import', { seen, saved: [] })
|
||||||
|
.then(() => localStorage.setItem(key, '1'))
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
@@ -53,16 +66,20 @@
|
|||||||
}
|
}
|
||||||
function remember(items) {
|
function remember(items) {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
const freshIds = [];
|
||||||
for (const a of items || []) {
|
for (const a of items || []) {
|
||||||
if (a && !seenIds.has(a.id)) {
|
if (a && !seenIds.has(a.id)) {
|
||||||
seenIds.add(a.id);
|
seenIds.add(a.id);
|
||||||
history.unshift(a);
|
history.unshift(a);
|
||||||
|
freshIds.push(a.id);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (changed) {
|
if (changed) {
|
||||||
if (history.length > HISTORY_CAP) history = history.slice(0, HISTORY_CAP);
|
if (history.length > HISTORY_CAP) history = history.slice(0, HISTORY_CAP);
|
||||||
persistSession();
|
persistSession();
|
||||||
|
// Mirror newly-seen items into the account history (cross-device), best-effort.
|
||||||
|
if (auth.user && freshIds.length) postJSON('/api/history', { ids: freshIds }).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function clearSession() {
|
function clearSession() {
|
||||||
@@ -88,16 +105,20 @@
|
|||||||
let viewLabel = $derived(
|
let viewLabel = $derived(
|
||||||
selected === 'today'
|
selected === 'today'
|
||||||
? 'Highlights from Today'
|
? 'Highlights from Today'
|
||||||
: currentTag
|
: selected === 'saved'
|
||||||
? humanize(currentTag)
|
? 'Saved'
|
||||||
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
|
: currentTag
|
||||||
|
? humanize(currentTag)
|
||||||
|
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
|
||||||
);
|
);
|
||||||
let viewSubtitle = $derived(
|
let viewSubtitle = $derived(
|
||||||
selected === 'today'
|
selected === 'today'
|
||||||
? (brief?.brief_date ?? '')
|
? (brief?.brief_date ?? '')
|
||||||
: currentTag
|
: selected === 'saved'
|
||||||
? (tagFamily?.description ?? '')
|
? 'Articles you saved to read later'
|
||||||
: (currentMood?.description ?? currentTopic?.description ?? '')
|
: currentTag
|
||||||
|
? (tagFamily?.description ?? '')
|
||||||
|
: (currentMood?.description ?? currentTopic?.description ?? '')
|
||||||
);
|
);
|
||||||
let activeTab = $derived(showYou ? 'you' : selected === 'today' ? 'today' : 'browse');
|
let activeTab = $derived(showYou ? 'you' : selected === 'today' ? 'today' : 'browse');
|
||||||
|
|
||||||
@@ -156,6 +177,9 @@
|
|||||||
try {
|
try {
|
||||||
if (key === 'today') {
|
if (key === 'today') {
|
||||||
await loadToday(fresh);
|
await loadToday(fresh);
|
||||||
|
} else if (key === 'saved') {
|
||||||
|
feed = (await getJSON('/api/saved')).items;
|
||||||
|
remember(feed);
|
||||||
} else if (key.startsWith('tag:')) {
|
} else if (key.startsWith('tag:')) {
|
||||||
const tag = key.slice(4);
|
const tag = key.slice(4);
|
||||||
const q = P.param(userPrefs);
|
const q = P.param(userPrefs);
|
||||||
@@ -302,6 +326,9 @@
|
|||||||
<div class="phead"><h2>You</h2><button class="close" onclick={() => (showYou = false)}>done</button></div>
|
<div class="phead"><h2>You</h2><button class="close" onclick={() => (showYou = false)}>done</button></div>
|
||||||
{#if auth.user}
|
{#if auth.user}
|
||||||
<div class="acctline">Signed in as <strong>{auth.user.email}</strong></div>
|
<div class="acctline">Signed in as <strong>{auth.user.email}</strong></div>
|
||||||
|
<button class="yourow" onclick={() => { showYou = false; select('saved'); }}>
|
||||||
|
<span>Saved</span>{#if savedIds.size}<span class="dot">{savedIds.size}</span>{/if}
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="yourow" onclick={() => { showYou = false; showBoundaries = true; }}>
|
<button class="yourow" onclick={() => { showYou = false; showBoundaries = true; }}>
|
||||||
<span>Your boundaries</span>{#if filtersOn}<span class="dot">on</span>{/if}
|
<span>Your boundaries</span>{#if filtersOn}<span class="dot">on</span>{/if}
|
||||||
@@ -355,7 +382,11 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="muted center pad">Nothing here right now — try another, or ease a boundary.</p>
|
{#if selected === 'saved'}
|
||||||
|
<p class="muted center pad">Nothing saved yet — tap <strong>Save</strong> on any story to keep it here.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="muted center pad">Nothing here right now — try another, or ease a boundary.</p>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/key}
|
{/key}
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,13 @@ def _current_user(conn: sqlite3.Connection, request: Request) -> sqlite3.Row | N
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _require_user(conn: sqlite3.Connection, request: Request) -> sqlite3.Row:
|
||||||
|
user = _current_user(conn, request)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Sign in to do that.")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
def _send_link_safe(email: str, link: str) -> None:
|
def _send_link_safe(email: str, link: str) -> None:
|
||||||
"""Send the magic link, swallowing failures (runs off the request path)."""
|
"""Send the magic link, swallowing failures (runs off the request path)."""
|
||||||
try:
|
try:
|
||||||
@@ -284,6 +291,15 @@ class SessionOut(BaseModel):
|
|||||||
token: str # for non-browser (app) clients; the web SPA uses the cookie
|
token: str # for non-browser (app) clients; the web SPA uses the cookie
|
||||||
|
|
||||||
|
|
||||||
|
class IdsBody(BaseModel):
|
||||||
|
ids: list[int] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ImportBody(BaseModel):
|
||||||
|
seen: list[int] = []
|
||||||
|
saved: list[int] = []
|
||||||
|
|
||||||
|
|
||||||
# --- App --------------------------------------------------------------------
|
# --- App --------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -420,6 +436,86 @@ def create_app() -> FastAPI:
|
|||||||
ok.delete_cookie(OAUTH_COOKIE, path="/")
|
ok.delete_cookie(OAUTH_COOKIE, path="/")
|
||||||
return ok
|
return ok
|
||||||
|
|
||||||
|
# --- Saved articles, history, and one-time import (all require sign-in) ---
|
||||||
|
|
||||||
|
@app.get("/api/saved", response_model=FeedResponse)
|
||||||
|
def saved_list(request: Request) -> FeedResponse:
|
||||||
|
with get_conn() as conn:
|
||||||
|
user = _require_user(conn, request)
|
||||||
|
rows = queries.saved(conn, user["id"])
|
||||||
|
items = [Article.from_row(r) for r in rows]
|
||||||
|
return FeedResponse(topic=None, flavor=None, count=len(items), items=items)
|
||||||
|
|
||||||
|
@app.get("/api/saved/ids")
|
||||||
|
def saved_id_list(request: Request) -> list[int]:
|
||||||
|
with get_conn() as conn:
|
||||||
|
user = _require_user(conn, request)
|
||||||
|
return queries.saved_ids(conn, user["id"])
|
||||||
|
|
||||||
|
@app.post("/api/saved/{article_id}")
|
||||||
|
def save_article(article_id: int, request: Request) -> dict:
|
||||||
|
with get_conn() as conn:
|
||||||
|
user = _require_user(conn, request)
|
||||||
|
if not conn.execute("SELECT 1 FROM articles WHERE id = ?", (article_id,)).fetchone():
|
||||||
|
raise HTTPException(status_code=404, detail="No such article.")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO saved_articles (user_id, article_id) VALUES (?, ?)",
|
||||||
|
(user["id"], article_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"saved": True}
|
||||||
|
|
||||||
|
@app.delete("/api/saved/{article_id}")
|
||||||
|
def unsave_article(article_id: int, request: Request) -> dict:
|
||||||
|
with get_conn() as conn:
|
||||||
|
user = _require_user(conn, request)
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM saved_articles WHERE user_id = ? AND article_id = ?",
|
||||||
|
(user["id"], article_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"saved": False}
|
||||||
|
|
||||||
|
@app.get("/api/history", response_model=FeedResponse)
|
||||||
|
def history_list(request: Request) -> FeedResponse:
|
||||||
|
with get_conn() as conn:
|
||||||
|
user = _require_user(conn, request)
|
||||||
|
rows = queries.history(conn, user["id"])
|
||||||
|
items = [Article.from_row(r) for r in rows]
|
||||||
|
return FeedResponse(topic=None, flavor=None, count=len(items), items=items)
|
||||||
|
|
||||||
|
@app.post("/api/history")
|
||||||
|
def record_history(body: IdsBody, request: Request) -> dict:
|
||||||
|
with get_conn() as conn:
|
||||||
|
user = _require_user(conn, request)
|
||||||
|
for aid in queries.existing_article_ids(conn, body.ids):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO user_history (user_id, article_id, event) "
|
||||||
|
"VALUES (?, ?, 'seen')",
|
||||||
|
(user["id"], aid),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.post("/api/import")
|
||||||
|
def import_local(body: ImportBody, request: Request) -> dict:
|
||||||
|
"""Fold this device's anonymous history/saved into the account (one-time)."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
user = _require_user(conn, request)
|
||||||
|
for aid in queries.existing_article_ids(conn, body.seen):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO user_history (user_id, article_id, event) "
|
||||||
|
"VALUES (?, ?, 'seen')",
|
||||||
|
(user["id"], aid),
|
||||||
|
)
|
||||||
|
for aid in queries.existing_article_ids(conn, body.saved):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO saved_articles (user_id, article_id) VALUES (?, ?)",
|
||||||
|
(user["id"], aid),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
@app.get("/api/categories", response_model=CategoriesResponse)
|
@app.get("/api/categories", response_model=CategoriesResponse)
|
||||||
def categories() -> CategoriesResponse:
|
def categories() -> CategoriesResponse:
|
||||||
return CategoriesResponse(
|
return CategoriesResponse(
|
||||||
|
|||||||
@@ -153,6 +153,60 @@ def brief(conn: sqlite3.Connection, brief_date: str | None = None, limit: int =
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def saved(conn: sqlite3.Connection, user_id: int, limit: int = 200) -> list[dict]:
|
||||||
|
"""Articles a user has saved, newest first (same shape as the feed)."""
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {_ARTICLE_COLUMNS}
|
||||||
|
FROM saved_articles sv
|
||||||
|
JOIN articles a ON a.id = sv.article_id
|
||||||
|
JOIN sources src ON src.id = a.source_id
|
||||||
|
LEFT JOIN article_scores s ON s.article_id = a.id
|
||||||
|
WHERE sv.user_id = ?
|
||||||
|
ORDER BY sv.saved_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(user_id, limit),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def saved_ids(conn: sqlite3.Connection, user_id: int) -> list[int]:
|
||||||
|
return [r[0] for r in conn.execute(
|
||||||
|
"SELECT article_id FROM saved_articles WHERE user_id = ?", (user_id,)
|
||||||
|
)]
|
||||||
|
|
||||||
|
|
||||||
|
def history(conn: sqlite3.Connection, user_id: int, limit: int = 200) -> list[dict]:
|
||||||
|
"""Articles in a user's account history, most-recent first."""
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {_ARTICLE_COLUMNS}, MAX(h.at) AS seen_at
|
||||||
|
FROM user_history h
|
||||||
|
JOIN articles a ON a.id = h.article_id
|
||||||
|
JOIN sources src ON src.id = a.source_id
|
||||||
|
LEFT JOIN article_scores s ON s.article_id = a.id
|
||||||
|
WHERE h.user_id = ?
|
||||||
|
GROUP BY a.id
|
||||||
|
ORDER BY seen_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(user_id, limit),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def existing_article_ids(conn: sqlite3.Connection, ids: list[int]) -> list[int]:
|
||||||
|
"""Filter to ids that still exist (FK-safe inserts for save/history/import)."""
|
||||||
|
clean = list({int(i) for i in ids})[:1000]
|
||||||
|
if not clean:
|
||||||
|
return []
|
||||||
|
placeholders = ",".join("?" * len(clean))
|
||||||
|
return [r[0] for r in conn.execute(
|
||||||
|
f"SELECT id FROM articles WHERE id IN ({placeholders})", clean
|
||||||
|
)]
|
||||||
|
|
||||||
|
|
||||||
def tag_counts(conn: sqlite3.Connection, accepted_only: bool = True) -> dict:
|
def tag_counts(conn: sqlite3.Connection, accepted_only: bool = True) -> dict:
|
||||||
"""How many shown (accepted, non-duplicate) articles carry each grouping tag."""
|
"""How many shown (accepted, non-duplicate) articles carry each grouping tag."""
|
||||||
where = "WHERE a.duplicate_of IS NULL" + (" AND s.accepted = 1" if accepted_only else "")
|
where = "WHERE a.duplicate_of IS NULL" + (" AND s.accepted = 1" if accepted_only else "")
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path, monkeypatch):
|
||||||
|
db = tmp_path / "t.sqlite3"
|
||||||
|
monkeypatch.setenv("GOODNEWS_DB", str(db))
|
||||||
|
monkeypatch.setenv("GOODNEWS_PUBLIC_BASE_URL", "http://testserver")
|
||||||
|
import importlib
|
||||||
|
import goodnews.api as api
|
||||||
|
importlib.reload(api)
|
||||||
|
from goodnews.db import connect, init_db
|
||||||
|
c = connect(str(db)); init_db(c)
|
||||||
|
c.execute("INSERT INTO sources (id,name,feed_url,trust_score) VALUES (1,'S','http://s/f',5)")
|
||||||
|
for aid in (1, 2, 3):
|
||||||
|
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash) "
|
||||||
|
"VALUES (?,1,?,?,?)", (aid, f"http://s/{aid}", f"t{aid}", f"h{aid}"))
|
||||||
|
c.commit(); c.close()
|
||||||
|
return api.create_app(), api
|
||||||
|
|
||||||
|
|
||||||
|
def _signed_in(app, api):
|
||||||
|
"""A TestClient with an authenticated session cookie."""
|
||||||
|
tc = TestClient(app)
|
||||||
|
sent = {}
|
||||||
|
import goodnews.email_send as es
|
||||||
|
orig = es.send_magic_link
|
||||||
|
es.send_magic_link = lambda to, link: sent.update(link=link)
|
||||||
|
try:
|
||||||
|
tc.post("/api/auth/email/start", json={"email": "a@b.com"})
|
||||||
|
token = sent["link"].split("token=")[1]
|
||||||
|
tc.post("/api/auth/email/verify", json={"token": token})
|
||||||
|
finally:
|
||||||
|
es.send_magic_link = orig
|
||||||
|
return tc
|
||||||
|
|
||||||
|
|
||||||
|
def test_saved_requires_auth(client):
|
||||||
|
app, _ = client
|
||||||
|
assert TestClient(app).get("/api/saved").status_code == 401
|
||||||
|
assert TestClient(app).post("/api/saved/1").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_unsave_and_list(client):
|
||||||
|
app, api = client
|
||||||
|
tc = _signed_in(app, api)
|
||||||
|
assert tc.get("/api/saved/ids").json() == []
|
||||||
|
assert tc.post("/api/saved/1").json() == {"saved": True}
|
||||||
|
assert tc.post("/api/saved/2").json() == {"saved": True}
|
||||||
|
assert tc.post("/api/saved/1").json() == {"saved": True} # idempotent
|
||||||
|
assert sorted(tc.get("/api/saved/ids").json()) == [1, 2]
|
||||||
|
body = tc.get("/api/saved").json()
|
||||||
|
assert body["count"] == 2 and {a["id"] for a in body["items"]} == {1, 2}
|
||||||
|
assert tc.post("/api/saved/999").status_code == 404 # unknown article
|
||||||
|
tc.delete("/api/saved/1")
|
||||||
|
assert tc.get("/api/saved/ids").json() == [2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_and_import(client):
|
||||||
|
app, api = client
|
||||||
|
tc = _signed_in(app, api)
|
||||||
|
tc.post("/api/history", json={"ids": [1, 2, 2, 999]}) # 999 ignored (FK-safe)
|
||||||
|
hist = tc.get("/api/history").json()
|
||||||
|
assert {a["id"] for a in hist["items"]} == {1, 2}
|
||||||
|
# import folds device-local seen + saved (unknown ids dropped)
|
||||||
|
tc.post("/api/import", json={"seen": [3], "saved": [1, 999]})
|
||||||
|
assert {a["id"] for a in tc.get("/api/history").json()["items"]} == {1, 2, 3}
|
||||||
|
assert tc.get("/api/saved/ids").json() == [1]
|
||||||
Reference in New Issue
Block a user