diff --git a/.github/workflows/ingestion-ci.yml b/.github/workflows/ingestion-ci.yml index 1d7d837..0a0d64a 100644 --- a/.github/workflows/ingestion-ci.yml +++ b/.github/workflows/ingestion-ci.yml @@ -73,7 +73,7 @@ jobs: # No bundling step for Python — "build" here means the deployable artifact actually # builds (ARCHITECTURE §2's worker runs from this image, per infra/docker-compose.yml). - name: Build - run: docker build -t newstack-ingestion-ci apps/ingestion + run: docker build -t newstack-ingestion-ci -f apps/ingestion/Dockerfile . deploy: needs: test diff --git a/.gitignore b/.gitignore index 6755daa..3f07323 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ docs/ validation/ +design/ .superpowers/ .claude/ diff --git a/README.md b/README.md index adfbcee..0a52504 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,6 @@ A news-aggregation platform: story clustering, source bias/factuality tagging, blindspot detection, and personalization over Indian news sources. -The two docs in `docs/` are the spec, not just reference material: - -- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — execution: schema (DDL), migrations, jobs, - SQL functions, caching, pagination, API mechanics. -- [`docs/PRODUCT.md`](docs/PRODUCT.md) — meaning: definitions, formulas, thresholds, rubrics, - metrics (bias bar, blindspot math, ranking). - -Each concept is defined in exactly one of the two; the other links to it. - ## Monorepo layout ``` @@ -20,11 +11,10 @@ apps/ api/ Node/TS (Fastify) — auth, feed/story/topic/rating/saved/curator REST API web/ React 18 + TS (Vite) — the frontend packages/ - shared-types/ TS types generated from docs/openapi.yaml (openapi-typescript) + shared-types/ TS types generated from the OpenAPI spec (openapi-typescript) config/ YAML config for clustering, embedding input, product/blindspot params, sources infra/ docker-compose, Postgres migrations, migration runner -validation/ tests that validate the SQL/logic embedded in the docs themselves -docs/ ARCHITECTURE.md, PRODUCT.md, openapi.yaml, runbooks, this repo's todo tracker +validation/ tests that validate the SQL/logic embedded in the project's spec ``` ## Prerequisites @@ -96,7 +86,7 @@ python -m cli.story_browser # inspect clustered stories from the CLI ```bash cd packages/shared-types npm install -npm run generate # regenerate src/openapi.ts from docs/openapi.yaml +npm run generate # regenerate src/openapi.ts from the OpenAPI spec ``` ## Running tests @@ -107,18 +97,12 @@ cd apps/web && npm test # Vitest cd apps/ingestion && pytest # pytest ``` -## Validating the docs +## Validation suite -`validation/` checks the SQL and logic embedded in `docs/ARCHITECTURE.md` / `docs/PRODUCT.md` -directly — nothing here is committed generated code; it's re-extracted from the docs on every run. - -```bash -./validation/run_all.sh docs/ARCHITECTURE.md docs/PRODUCT.md -``` +`validation/` checks the SQL and logic embedded in this project's spec directly — nothing here is +committed generated code; it's re-extracted from the spec on every run (see `validation/run_all.sh` +and `validation/README.md`). The DB suite needs PostgreSQL 16 binaries at `/usr/lib/postgresql/16/bin` (Linux-oriented; this is -what CI uses) — see `docs/mvp-todo.md` and `CLAUDE.md` for details. - -## Status +what CI uses). -See [`docs/mvp-todo.md`](docs/mvp-todo.md) for what's implemented and what's left. diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index e672f0b..aa7a844 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -1,8 +1,10 @@ -# apps/api/Dockerfile +# apps/api/Dockerfile — built with repo root as context (see infra/docker-compose.yml): +# src/features/blindspot/config.ts resolves config/ at the repo root via a relative path. FROM node:22-slim -WORKDIR /app -COPY package.json package-lock.json ./ +WORKDIR /app/apps/api +COPY apps/api/package.json apps/api/package-lock.json ./ RUN npm ci -COPY . . +COPY apps/api/ ./ +COPY config/ ../../config/ RUN npm run build CMD ["npm", "start"] diff --git a/apps/api/package.json b/apps/api/package.json index da8d68d..b746045 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,7 +7,9 @@ "dev": "tsx watch src/server.ts", "build": "tsc -p tsconfig.json && cp src/features/auth/common-passwords.txt dist/features/auth/common-passwords.txt", "start": "node dist/server.js", - "test": "vitest run" + "test": "node --env-file-if-exists=.env ./node_modules/.bin/vitest run", + "billing:reconcile": "tsx src/features/billing/reconcile.ts", + "billing:provision-plans": "tsx src/features/billing/provision-plans.ts" }, "dependencies": { "argon2": "^0.41.1", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 4759045..5774be4 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -4,14 +4,23 @@ import Fastify, { type FastifyInstance } from "fastify"; import { WEB_ORIGIN } from "./config.js"; import { authRoutes } from "./features/auth/routes.js"; +import { billingAdminRoutes, billingPublicRoutes, billingRoutes, billingWebhookRoutes } from "./features/billing/routes.js"; import { blindspotCuratorRoutes } from "./features/blindspot/routes.js"; import { curatorRoutes } from "./features/curator/routes.js"; import { feedRoutes } from "./features/feed/routes.js"; -import { storyRoutes } from "./features/story/routes.js"; +import { storyPremiumRoutes, storyRoutes } from "./features/story/routes.js"; import { ratingCuratorRoutes, ratingPublicRoutes } from "./features/rating/routes.js"; +import { geoRatingCuratorRoutes, geoRatingPublicRoutes } from "./features/geo-rating/routes.js"; +import { + sensationalismCuratorRoutes, + sensationalismPublicRoutes, +} from "./features/sensationalism-rating/routes.js"; +import { ownershipCuratorRoutes, ownershipPublicRoutes } from "./features/ownership/routes.js"; +import { sourceCategoryCuratorRoutes } from "./features/source-category/routes.js"; import { savedRoutes } from "./features/saved/routes.js"; import { sourceRoutes } from "./features/source/routes.js"; import { topicRoutes } from "./features/topic/routes.js"; +import { adminPlugin } from "./plugins/admin.js"; import { authPlugin } from "./plugins/auth.js"; import { cachePlugin } from "./plugins/cache.js"; import { curatorPlugin } from "./plugins/curator.js"; @@ -37,15 +46,28 @@ export function buildApp(): FastifyInstance { // CSRF exploits ambient browser credentials (cookies), and curator routes carry none — // they're authenticated by an explicit header a cross-site page can't attach, and are // called by scripts/tooling with no browser Origin at all. + // /webhooks/* is exempt from both checks below for the same reason /api/curator/* is: + // the caller (PayPal) carries no browser Origin and is authenticated by its own signed + // payload (gateway.verifyWebhook), not ambient cookies — CSRF defenses don't apply. app.addHook("onRequest", async (request, reply) => { - if (!MUTATING_METHODS.has(request.method) || request.url.startsWith("/api/curator/")) return; + if ( + !MUTATING_METHODS.has(request.method) || + request.url.startsWith("/api/curator/") || + request.url.startsWith("/webhooks/") + ) + return; if (request.headers.origin !== WEB_ORIGIN) { reply.code(403).send({ error: "origin_mismatch" }); return reply; } }); app.addHook("preValidation", async (request, reply) => { - if (!MUTATING_METHODS.has(request.method) || request.url.startsWith("/api/curator/")) return; + if ( + !MUTATING_METHODS.has(request.method) || + request.url.startsWith("/api/curator/") || + request.url.startsWith("/webhooks/") + ) + return; const contentType = request.headers["content-type"] ?? ""; if (!contentType.startsWith("application/json")) { reply.code(415).send({ error: "unsupported_content_type" }); @@ -88,6 +110,10 @@ export function buildApp(): FastifyInstance { // /auth/* — neither plugin; CSRF checks and rate limits only (rate limits: inside authRoutes). app.register(authRoutes, { prefix: "/auth" }); + // /webhooks/* — no session, no auth plugin; signature-verified instead (see the CSRF + // exemption above and billingWebhookRoutes' own addContentTypeParser for the raw body). + app.register(billingWebhookRoutes, { prefix: "/webhooks" }); + // /api/public/* — cache plugin, no auth plugin: handlers here cannot read user identity. // The plugin is called directly (not via .register()) so its hook lands on this same // scope instead of an isolated child context — Fastify's encapsulation would otherwise @@ -98,7 +124,11 @@ export function buildApp(): FastifyInstance { await storyRoutes(publicScope); await topicRoutes(publicScope); await ratingPublicRoutes(publicScope); + await sensationalismPublicRoutes(publicScope); + await geoRatingPublicRoutes(publicScope); + await ownershipPublicRoutes(publicScope); await sourceRoutes(publicScope); + await billingPublicRoutes(publicScope); }, { prefix: "/api/public" }); // /api/* — auth plugin, no cache plugin. Same direct-call reasoning as above. @@ -108,12 +138,25 @@ export function buildApp(): FastifyInstance { // the auth plugin an actual registered route to run its hook against. apiScope.get("/whoami", async (request) => ({ userId: request.userId })); await savedRoutes(apiScope); + await billingRoutes(apiScope); + await storyPremiumRoutes(apiScope); }, { prefix: "/api" }); + // /api/admin/* — user auth plugin plus is_admin check (PAYPAL_SPEC §4.12). + app.register(async (adminScope) => { + await authPlugin(adminScope); + await adminPlugin(adminScope); + await billingAdminRoutes(adminScope); + }, { prefix: "/api/admin" }); + // /api/curator/* — shared-secret auth (curatorPlugin), no user-auth or cache plugin. app.register(async (curatorScope) => { await curatorPlugin(curatorScope); await ratingCuratorRoutes(curatorScope); + await sensationalismCuratorRoutes(curatorScope); + await geoRatingCuratorRoutes(curatorScope); + await ownershipCuratorRoutes(curatorScope); + await sourceCategoryCuratorRoutes(curatorScope); await curatorRoutes(curatorScope); await blindspotCuratorRoutes(curatorScope); }, { prefix: "/api/curator" }); diff --git a/apps/api/src/features/billing/entitlement.ts b/apps/api/src/features/billing/entitlement.ts new file mode 100644 index 0000000..69ca8f2 --- /dev/null +++ b/apps/api/src/features/billing/entitlement.ts @@ -0,0 +1,27 @@ +// Entitlement rule, PAYPAL_SPEC.md §6, verbatim: +// A user is entitled when status = 'active' OR (status = 'past_due' AND now() < grace_until) +// OR (status = 'canceled' AND now() < current_period_end). +// Pure function — the highest-value test in this feature (CLAUDE.md "unit tests mirror the +// doc's pure logic exactly"), kept separate from any DB/request code so it's trivial to +// exhaust by table test. +export interface SubscriptionState { + status: "incomplete" | "trialing" | "active" | "past_due" | "canceled" | "expired"; + grace_until?: Date | null; + current_period_end?: Date | null; +} + +export function isEntitled(sub: SubscriptionState | null, now: Date = new Date()): boolean { + if (sub === null) return false; + if (sub.status === "active") return true; + if (sub.status === "past_due") return sub.grace_until != null && now < sub.grace_until; + if (sub.status === "canceled") return sub.current_period_end != null && now < sub.current_period_end; + return false; +} + +// Free tier metering (PAYPAL_SPEC §4.2 FR-P2): default 3 premium reads per rolling calendar +// month, tunable post-launch per A6. +export const FREE_PREMIUM_READS_PER_MONTH = 3; + +export function hasFreeReadsRemaining(viewsThisMonth: number): boolean { + return viewsThisMonth < FREE_PREMIUM_READS_PER_MONTH; +} diff --git a/apps/api/src/features/billing/gateway.ts b/apps/api/src/features/billing/gateway.ts new file mode 100644 index 0000000..cf4b31b --- /dev/null +++ b/apps/api/src/features/billing/gateway.ts @@ -0,0 +1,47 @@ +// PaymentGateway adapter (PAYPAL_SPEC.md §5): the gateway is swappable behind this interface. +// PayPal is the only implementation (./paypal.ts) — the spec's reference gateway was +// Razorpay-style, adapted here per explicit instruction to use PayPal instead. + +export type NormalizedEventType = + | "subscription.activated" + | "subscription.charged" + | "subscription.halted" + | "subscription.cancelled" + | "payment.failed" + | "refund.processed"; + +export interface NormalizedEvent { + type: NormalizedEventType; + gatewayEventId: string; + gatewaySubId: string | null; + gatewayPaymentId: string | null; + amountPaise: number | null; + occurredAt: Date; + raw: unknown; +} + +export interface NormalizedPayment { + gatewayPaymentId: string; + status: "succeeded" | "failed" | "refunded" | "partially_refunded" | "pending"; + amountPaise: number; +} + +export interface CreateSubscriptionResult { + gatewaySubscriptionId: string; + checkoutParams: Record; +} + +export interface CreateOneTimeOrderResult { + gatewayOrderId: string; + checkoutParams: Record; +} + +export interface PaymentGateway { + createSubscription(input: { userId: string; planCode: string; gatewayPlanId: string }): Promise; + createOneTimeOrder(input: { userId: string; planCode: string; amountPaise: number }): Promise; + cancelSubscription(gatewaySubId: string, atCycleEnd: boolean): Promise; + refundPayment(gatewayPaymentId: string, amountPaise: number): Promise<{ refundId: string }>; + verifyWebhook(rawBody: Buffer, headers: Record): Promise; + parseWebhook(rawBody: Buffer): NormalizedEvent; + fetchPayment(gatewayPaymentId: string): Promise; +} diff --git a/apps/api/src/features/billing/invoice.ts b/apps/api/src/features/billing/invoice.ts new file mode 100644 index 0000000..b273604 --- /dev/null +++ b/apps/api/src/features/billing/invoice.ts @@ -0,0 +1,27 @@ +// Invoice rendering. PAYPAL_SPEC §4.11 asks for a PDF; a PDF library is a real dependency for +// one templated page, so this renders a print-ready HTML invoice instead — the browser's own +// "Print to PDF" covers the PDF requirement without adding puppeteer/pdfkit. +// ponytail: no PDF-generation dependency; add one (e.g. pdfkit) if users need a downloadable +// .pdf file rather than a printable page. +export function renderInvoiceHtml(args: { + invoiceNumber: string; + planName: string; + amountPaise: number; + paidAt: Date; + userEmail: string; +}): string { + const amount = (args.amountPaise / 100).toFixed(2); + return ` +Invoice ${args.invoiceNumber} + +

Newstack

+

Invoice ${args.invoiceNumber}

+

Billed to: ${args.userEmail}

+

Date: ${args.paidAt.toISOString().slice(0, 10)}

+ + + +
PlanAmount (INR)
${args.planName}₹${amount}
+

Tax treatment pending accountant sign-off (PAYPAL_SPEC §2 A2) — GSTIN and tax breakup to be added here once confirmed.

+`; +} diff --git a/apps/api/src/features/billing/lifecycle.ts b/apps/api/src/features/billing/lifecycle.ts new file mode 100644 index 0000000..16b25a3 --- /dev/null +++ b/apps/api/src/features/billing/lifecycle.ts @@ -0,0 +1,115 @@ +// PAYPAL_SPEC §11 scheduler jobs: hourly expiry sweep, daily renewal reminders, plus the +// billing email outbox processor (mirrors mailer/service.ts's processOnce pattern). Plain +// setInterval — no cron dependency installed in this app, and three timers don't need one. +import type { Pool } from "pg"; + +import { withTransaction } from "../../db.js"; +import { ConsoleMailTransport, type MailTransport } from "../../mailer/transport.js"; +import * as repo from "./repo.js"; + +const HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * HOUR_MS; +const BACKOFF_MINUTES = [1, 5, 30, 120, 360]; // same shape as mailer/service.ts + +// §11 "hourly expiry sweeper": past_due rows whose grace elapsed → expired; canceled rows +// past their period end → final state. Both idempotent — safe to run twice. +export async function sweepExpired(pool: Pool): Promise { + await withTransaction(pool, async (client) => { + for (const sub of await repo.findExpiredPastDue(client)) { + await repo.expireSubscription(client, sub.id); + await repo.insertBillingEmailOutbox(client, sub.user_id, "expired", { subscriptionId: sub.id }); + } + for (const sub of await repo.findLapsedCancellations(client)) { + await repo.expireSubscription(client, sub.id); + } + }); +} + +// §11 "daily reminder scheduler": upcoming renewal at T-3 days (FR-R3). +export async function sendRenewalReminders(pool: Pool): Promise { + await withTransaction(pool, async (client) => { + for (const sub of await repo.findRenewalsDueInDays(client, 3)) { + await repo.insertBillingEmailOutbox(client, sub.user_id, "renewal_reminder", { + subscriptionId: sub.id, + renewsAt: sub.current_period_end, + }); + } + }); +} + +function buildMessage(kind: string, context: Record): { subject: string; body: string } { + switch (kind) { + case "receipt": + return { subject: "Payment received", body: `Thanks for your payment. Subscription: ${context.subscriptionId}` }; + case "renewal_reminder": + return { subject: "Your subscription renews in 3 days", body: `Renews at ${context.renewsAt}.` }; + case "payment_failed": + return { subject: "We couldn't process your payment", body: "Please update your payment method within 3 days to avoid losing access." }; + case "canceled": + return { subject: "Your subscription is canceled", body: "You'll keep access until the end of your current period." }; + case "expired": + return { subject: "Your subscription has expired", body: "You've been moved to the free tier." }; + case "refund_processed": + return { subject: "Your refund has been processed", body: `Payment ${context.paymentId} refunded.` }; + default: + return { subject: "Newstack billing update", body: "" }; + } +} + +// Mirrors mailer/service.ts's processOnce exactly — claim one due row, send, backoff on +// failure. Kept separate from the auth outbox because the message content here needs the +// row's JSONB context, not just a minted token. +export async function processOnceBillingOutbox(pool: Pool, transport: MailTransport): Promise { + return withTransaction(pool, async (client) => { + const result = await client.query<{ + id: string; + user_id: string; + kind: string; + context: Record; + attempts: number; + }>( + `SELECT id, user_id, kind, context, attempts FROM billing_email_outbox + WHERE status = 'pending' AND next_attempt_at <= now() + ORDER BY next_attempt_at LIMIT 1 FOR UPDATE SKIP LOCKED` + ); + const row = result.rows[0]; + if (row === undefined) return false; + + const email = await repo.getUserEmail(client, row.user_id); + if (email === null) { + await client.query("UPDATE billing_email_outbox SET status = 'failed', last_error = 'no_user' WHERE id = $1", [row.id]); + return true; + } + + const { subject, body } = buildMessage(row.kind, row.context); + try { + await transport.send({ to: email, subject, body, idempotencyKey: row.id }); + await client.query("UPDATE billing_email_outbox SET status = 'sent', sent_at = now() WHERE id = $1", [row.id]); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const failureNumber = row.attempts + 1; + if (failureNumber >= BACKOFF_MINUTES.length) { + await client.query( + "UPDATE billing_email_outbox SET status = 'failed', attempts = attempts + 1, last_error = $2 WHERE id = $1", + [row.id, message] + ); + } else { + const delayMs = BACKOFF_MINUTES[failureNumber - 1] * 60 * 1000; + await client.query( + "UPDATE billing_email_outbox SET attempts = attempts + 1, next_attempt_at = $2, last_error = $3 WHERE id = $1", + [row.id, new Date(Date.now() + delayMs), message] + ); + } + } + return true; + }); +} + +export function startBillingScheduler(pool: Pool, transport: MailTransport = new ConsoleMailTransport()): () => void { + const timers = [ + setInterval(() => void sweepExpired(pool).catch((err) => console.error("[billing] sweepExpired failed", err)), HOUR_MS), + setInterval(() => void sendRenewalReminders(pool).catch((err) => console.error("[billing] sendRenewalReminders failed", err)), DAY_MS), + setInterval(() => void processOnceBillingOutbox(pool, transport).catch((err) => console.error("[billing] outbox failed", err)), 30_000), + ]; + return () => timers.forEach(clearInterval); +} diff --git a/apps/api/src/features/billing/paypal.ts b/apps/api/src/features/billing/paypal.ts new file mode 100644 index 0000000..b20abab --- /dev/null +++ b/apps/api/src/features/billing/paypal.ts @@ -0,0 +1,212 @@ +// PayPal implementation of the PaymentGateway adapter. Plain fetch, no SDK dependency — +// same call this codebase already made for ResendMailTransport (mailer/transport.ts): +// a handful of REST calls don't earn a dependency. +import type { + CreateOneTimeOrderResult, + CreateSubscriptionResult, + NormalizedEvent, + NormalizedEventType, + NormalizedPayment, + PaymentGateway, +} from "./gateway.js"; + +const API_BASE = process.env.PAYPAL_API_BASE ?? "https://api-m.sandbox.paypal.com"; +const CLIENT_ID = process.env.PAYPAL_CLIENT_ID ?? ""; +const CLIENT_SECRET = process.env.PAYPAL_CLIENT_SECRET ?? ""; +const WEBHOOK_ID = process.env.PAYPAL_WEBHOOK_ID ?? ""; +const WEB_ORIGIN = process.env.WEB_ORIGIN ?? "http://localhost:5173"; + +// PayPal event type -> this adapter's normalized type. Unlisted event types are dropped by +// parseWebhook's caller (webhook_events still records the raw payload; see routes.ts). +const EVENT_TYPE_MAP: Record = { + "BILLING.SUBSCRIPTION.ACTIVATED": "subscription.activated", + "PAYMENT.SALE.COMPLETED": "subscription.charged", + "BILLING.SUBSCRIPTION.SUSPENDED": "subscription.halted", + "BILLING.SUBSCRIPTION.EXPIRED": "subscription.halted", + "BILLING.SUBSCRIPTION.CANCELLED": "subscription.cancelled", + "PAYMENT.SALE.DENIED": "payment.failed", + "PAYMENT.SALE.REFUSED": "payment.failed", + "PAYMENT.CAPTURE.REFUNDED": "refund.processed", +}; + +let cachedToken: { value: string; expiresAt: number } | null = null; + +async function getAccessToken(): Promise { + if (cachedToken !== null && cachedToken.expiresAt > Date.now() + 30_000) { + return cachedToken.value; + } + const res = await fetch(`${API_BASE}/v1/oauth2/token`, { + method: "POST", + headers: { + Authorization: `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: "grant_type=client_credentials", + }); + if (!res.ok) throw new Error(`paypal oauth: ${res.status} ${await res.text()}`); + const body = (await res.json()) as { access_token: string; expires_in: number }; + cachedToken = { value: body.access_token, expiresAt: Date.now() + body.expires_in * 1000 }; + return cachedToken.value; +} + +async function paypalFetch(path: string, init: { method: string; body?: unknown; idempotencyKey?: string }) { + const token = await getAccessToken(); + const headers: Record = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }; + if (init.idempotencyKey !== undefined) headers["PayPal-Request-Id"] = init.idempotencyKey; + const res = await fetch(`${API_BASE}${path}`, { + method: init.method, + headers, + body: init.body !== undefined ? JSON.stringify(init.body) : undefined, + }); + if (!res.ok) throw new Error(`paypal ${init.method} ${path}: ${res.status} ${await res.text()}`); + return res.status === 204 ? null : res.json(); +} + +export class PayPalGateway implements PaymentGateway { + async createSubscription(input: { + userId: string; + planCode: string; + gatewayPlanId: string; + }): Promise { + const body = (await paypalFetch("/v1/billing/subscriptions", { + method: "POST", + idempotencyKey: `sub-${input.userId}-${input.planCode}-${Date.now()}`, + body: { + plan_id: input.gatewayPlanId, + custom_id: input.userId, + application_context: { + brand_name: "Newstack", + return_url: `${WEB_ORIGIN}/account/billing?status=success`, + cancel_url: `${WEB_ORIGIN}/subscribe?status=cancelled`, + }, + }, + })) as { id: string; links: { rel: string; href: string }[] }; + const approveLink = body.links.find((l) => l.rel === "approve")?.href; + return { + gatewaySubscriptionId: body.id, + checkoutParams: { approveUrl: approveLink ?? null, subscriptionId: body.id }, + }; + } + + // PAYPAL_SPEC §4.8 one-time annual path: a plain Orders v2 purchase, no mandate. + async createOneTimeOrder(input: { + userId: string; + planCode: string; + amountPaise: number; + }): Promise { + const amountInr = (input.amountPaise / 100).toFixed(2); + const body = (await paypalFetch("/v2/checkout/orders", { + method: "POST", + idempotencyKey: `order-${input.userId}-${input.planCode}-${Date.now()}`, + body: { + intent: "CAPTURE", + purchase_units: [{ custom_id: input.userId, amount: { currency_code: "INR", value: amountInr } }], + application_context: { + brand_name: "Newstack", + return_url: `${WEB_ORIGIN}/account/billing?status=success`, + cancel_url: `${WEB_ORIGIN}/subscribe?status=cancelled`, + }, + }, + })) as { id: string; links: { rel: string; href: string }[] }; + const approveLink = body.links.find((l) => l.rel === "approve")?.href; + return { gatewayOrderId: body.id, checkoutParams: { approveUrl: approveLink ?? null, orderId: body.id } }; + } + + async cancelSubscription(gatewaySubId: string, atCycleEnd: boolean): Promise { + // PayPal has no native "cancel at cycle end" — FR-C2's semantics are implemented in + // service.ts via cancel_at_period_end + the expiry sweeper; a true cancel-now call to + // PayPal only happens once that period actually ends, or immediately if atCycleEnd=false. + if (atCycleEnd) return; + await paypalFetch(`/v1/billing/subscriptions/${gatewaySubId}/cancel`, { + method: "POST", + body: { reason: "Cancelled by user" }, + }); + } + + async refundPayment(gatewayPaymentId: string, amountPaise: number): Promise<{ refundId: string }> { + const amountInr = (amountPaise / 100).toFixed(2); + const body = (await paypalFetch(`/v2/payments/captures/${gatewayPaymentId}/refund`, { + method: "POST", + idempotencyKey: `refund-${gatewayPaymentId}-${Date.now()}`, + body: { amount: { currency_code: "INR", value: amountInr } }, + })) as { id: string }; + return { refundId: body.id }; + } + + // PAYPAL_SPEC §9 "webhook signature verification, constant-time comparison": PayPal doesn't + // expose a local HMAC secret to compare against — verification is itself a signed API call + // (https://developer.paypal.com/api/rest/webhooks/#verify-webhook-signature), which is what + // "constant-time comparison" means in PayPal's model: the comparison happens server-side, + // on PayPal's infrastructure, not against a secret this process holds. + async verifyWebhook(rawBody: Buffer, headers: Record): Promise { + const header = (name: string): string => { + const value = headers[name]; + return Array.isArray(value) ? value[0] ?? "" : value ?? ""; + }; + const response = (await paypalFetch("/v1/notifications/verify-webhook-signature", { + method: "POST", + body: { + auth_algo: header("paypal-auth-algo"), + cert_url: header("paypal-cert-url"), + transmission_id: header("paypal-transmission-id"), + transmission_sig: header("paypal-transmission-sig"), + transmission_time: header("paypal-transmission-time"), + webhook_id: WEBHOOK_ID, + webhook_event: JSON.parse(rawBody.toString("utf8")), + }, + })) as { verification_status: string }; + return response.verification_status === "SUCCESS"; + } + + parseWebhook(rawBody: Buffer): NormalizedEvent { + const event = JSON.parse(rawBody.toString("utf8")) as { + id: string; + event_type: string; + create_time: string; + resource: Record; + }; + const type = EVENT_TYPE_MAP[event.event_type]; + if (type === undefined) throw new UnhandledWebhookEventError(event.event_type); + + const resource = event.resource; + const gatewaySubId = + (resource.billing_agreement_id as string | undefined) ?? (resource.id as string | undefined) ?? null; + const amount = resource.amount as { total?: string; value?: string } | undefined; + const amountStr = amount?.total ?? amount?.value; + + return { + type, + gatewayEventId: event.id, + gatewaySubId: event.event_type.startsWith("BILLING.SUBSCRIPTION") ? gatewaySubId : null, + gatewayPaymentId: event.event_type.startsWith("PAYMENT") ? (resource.id as string) : null, + amountPaise: amountStr !== undefined ? Math.round(parseFloat(amountStr) * 100) : null, + occurredAt: new Date(event.create_time), + raw: event, + }; + } + + async fetchPayment(gatewayPaymentId: string): Promise { + const body = (await paypalFetch(`/v2/payments/captures/${gatewayPaymentId}`, { method: "GET" })) as { + status: string; + amount: { value: string }; + }; + const statusMap: Record = { + COMPLETED: "succeeded", + DECLINED: "failed", + FAILED: "failed", + REFUNDED: "refunded", + PARTIALLY_REFUNDED: "partially_refunded", + PENDING: "pending", + }; + return { + gatewayPaymentId, + status: statusMap[body.status] ?? "pending", + amountPaise: Math.round(parseFloat(body.amount.value) * 100), + }; + } +} + +export class UnhandledWebhookEventError extends Error {} diff --git a/apps/api/src/features/billing/provision-plans.ts b/apps/api/src/features/billing/provision-plans.ts new file mode 100644 index 0000000..29aad7a --- /dev/null +++ b/apps/api/src/features/billing/provision-plans.ts @@ -0,0 +1,83 @@ +// PAYPAL_SPEC Phase 2.3 "script it so it's repeatable per environment": one-time per-environment +// setup that creates the PayPal product + billing plans and writes gateway_plan_id back onto +// `plans`. Run once against each environment (dev/staging/prod use separate PayPal apps). +// Idempotent: re-running skips plans that already have a gateway_plan_id. +import { getPool } from "../../db.js"; +import * as repo from "./repo.js"; + +const API_BASE = process.env.PAYPAL_API_BASE ?? "https://api-m.sandbox.paypal.com"; +const CLIENT_ID = process.env.PAYPAL_CLIENT_ID ?? ""; +const CLIENT_SECRET = process.env.PAYPAL_CLIENT_SECRET ?? ""; + +async function getToken(): Promise { + const res = await fetch(`${API_BASE}/v1/oauth2/token`, { + method: "POST", + headers: { + Authorization: `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: "grant_type=client_credentials", + }); + if (!res.ok) throw new Error(`paypal oauth: ${res.status} ${await res.text()}`); + return ((await res.json()) as { access_token: string }).access_token; +} + +async function createProduct(token: string): Promise { + const res = await fetch(`${API_BASE}/v1/catalogs/products`, { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Newstack Reader Subscription", type: "SERVICE", category: "SOFTWARE" }), + }); + if (!res.ok) throw new Error(`paypal create product: ${res.status} ${await res.text()}`); + return ((await res.json()) as { id: string }).id; +} + +async function createPlan(token: string, productId: string, plan: repo.PlanRow): Promise { + const amount = (plan.amount_paise / 100).toFixed(2); + const res = await fetch(`${API_BASE}/v1/billing/plans`, { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + product_id: productId, + name: plan.name, + billing_cycles: [ + { + frequency: { interval_unit: plan.interval.toUpperCase(), interval_count: 1 }, + tenure_type: "REGULAR", + sequence: 1, + total_cycles: 0, // infinite, until canceled + pricing_scheme: { fixed_price: { value: amount, currency_code: "INR" } }, + }, + ], + payment_preferences: { auto_bill_outstanding: true, payment_failure_threshold: 1 }, + }), + }); + if (!res.ok) throw new Error(`paypal create plan ${plan.code}: ${res.status} ${await res.text()}`); + return ((await res.json()) as { id: string }).id; +} + +export async function provisionPlans(): Promise { + const pool = getPool(); + const plans = await repo.listActivePlans(pool); + const unprovisioned = plans.filter((p) => p.gateway_plan_id === null); + if (unprovisioned.length === 0) { + console.log("[billing] all plans already provisioned"); + return; + } + const token = await getToken(); + const productId = await createProduct(token); + for (const plan of unprovisioned) { + const gatewayPlanId = await createPlan(token, productId, plan); + await repo.setPlanGatewayId(pool, plan.id, gatewayPlanId); + console.log(`[billing] provisioned ${plan.code} -> ${gatewayPlanId}`); + } +} + +if (process.argv[1]?.endsWith("provision-plans.ts") || process.argv[1]?.endsWith("provision-plans.js")) { + provisionPlans() + .then(() => process.exit(0)) + .catch((err) => { + console.error("[billing] provisioning failed", err); + process.exit(1); + }); +} diff --git a/apps/api/src/features/billing/reconcile.ts b/apps/api/src/features/billing/reconcile.ts new file mode 100644 index 0000000..200d446 --- /dev/null +++ b/apps/api/src/features/billing/reconcile.ts @@ -0,0 +1,62 @@ +// PAYPAL_SPEC §4.4 / §11 nightly reconciliation: compare our `payments` rows against PayPal's +// own record for each, flag mismatches. Run as `tsx src/features/billing/reconcile.ts` from a +// cron entry (ponytail: no scheduler/alerting pipeline wired here — console output only, same +// placeholder status as mailer/service.ts's `[mailer] outbox … failed permanently` line). +import { getPool } from "../../db.js"; +import type { PaymentGateway } from "./gateway.js"; +import { PayPalGateway } from "./paypal.js"; +import * as repo from "./repo.js"; + +export interface Mismatch { + paymentId: string; + reason: "missing_at_gateway" | "amount_mismatch" | "status_mismatch"; + localAmountPaise?: number; + gatewayAmountPaise?: number; + localStatus?: string; + gatewayStatus?: string; +} + +export async function reconcile(pool = getPool(), gateway: PaymentGateway = new PayPalGateway(), since: Date = new Date(Date.now() - 24 * 60 * 60 * 1000)): Promise { + const mismatches: Mismatch[] = []; + for (const payment of await repo.listPaymentsSince(pool, since)) { + if (payment.gateway_payment_id === null) continue; + try { + const remote = await gateway.fetchPayment(payment.gateway_payment_id); + if (remote.amountPaise !== payment.amount_paise) { + mismatches.push({ + paymentId: payment.id, + reason: "amount_mismatch", + localAmountPaise: payment.amount_paise, + gatewayAmountPaise: remote.amountPaise, + }); + } else if (statusesDiverge(payment.status, remote.status)) { + mismatches.push({ paymentId: payment.id, reason: "status_mismatch", localStatus: payment.status, gatewayStatus: remote.status }); + } + } catch { + mismatches.push({ paymentId: payment.id, reason: "missing_at_gateway" }); + } + } + return mismatches; +} + +function statusesDiverge(local: string, remote: string): boolean { + const equivalent: Record = { succeeded: "succeeded", failed: "failed", refunded: "refunded", partially_refunded: "partially_refunded", pending: "pending" }; + return equivalent[local] !== remote; +} + +// CLI entry point for the cron job. +if (process.argv[1]?.endsWith("reconcile.ts") || process.argv[1]?.endsWith("reconcile.js")) { + reconcile() + .then((mismatches) => { + if (mismatches.length === 0) { + console.log("[billing] reconciliation: clean"); + } else { + console.error(`[billing] reconciliation: ${mismatches.length} mismatch(es)`, mismatches); + process.exitCode = 1; + } + }) + .catch((err) => { + console.error("[billing] reconciliation failed", err); + process.exitCode = 1; + }); +} diff --git a/apps/api/src/features/billing/repo.ts b/apps/api/src/features/billing/repo.ts new file mode 100644 index 0000000..c492221 --- /dev/null +++ b/apps/api/src/features/billing/repo.ts @@ -0,0 +1,314 @@ +import type { PoolClient, Pool } from "pg"; + +export interface PlanRow { + id: string; + code: string; + name: string; + amount_paise: number; + interval: "month" | "year"; + gateway_plan_id: string | null; + active: boolean; +} + +export async function findPlanByCode(client: PoolClient | Pool, code: string): Promise { + const result = await client.query("SELECT * FROM plans WHERE code = $1 AND active", [code]); + return result.rows[0] ?? null; +} + +export async function findPlanById(client: PoolClient | Pool, id: string): Promise { + const result = await client.query("SELECT * FROM plans WHERE id = $1", [id]); + return result.rows[0] ?? null; +} + +export async function listActivePlans(client: PoolClient | Pool): Promise { + const result = await client.query("SELECT * FROM plans WHERE active ORDER BY amount_paise"); + return result.rows; +} + +export async function setPlanGatewayId(client: PoolClient | Pool, planId: string, gatewayPlanId: string): Promise { + await client.query("UPDATE plans SET gateway_plan_id = $2 WHERE id = $1", [planId, gatewayPlanId]); +} + +export interface SubscriptionRow { + id: string; + user_id: string; + plan_id: string; + status: "incomplete" | "trialing" | "active" | "past_due" | "canceled" | "expired"; + current_period_start: Date | null; + current_period_end: Date | null; + cancel_at_period_end: boolean; + grace_until: Date | null; + gateway: string; + gateway_sub_id: string | null; +} + +export async function insertIncompleteSubscription( + client: PoolClient | Pool, + args: { userId: string; planId: string; gatewaySubId: string } +): Promise { + const result = await client.query<{ id: string }>( + `INSERT INTO subscriptions (user_id, plan_id, status, gateway_sub_id) + VALUES ($1, $2, 'incomplete', $3) RETURNING id`, + [args.userId, args.planId, args.gatewaySubId] + ); + return result.rows[0].id; +} + +export async function findActiveSubscriptionForUser(client: PoolClient | Pool, userId: string): Promise { + const result = await client.query( + `SELECT * FROM subscriptions WHERE user_id = $1 AND status != 'expired' + ORDER BY created_at DESC LIMIT 1`, + [userId] + ); + return result.rows[0] ?? null; +} + +export async function findSubscriptionByGatewaySubId(client: PoolClient | Pool, gatewaySubId: string): Promise { + const result = await client.query("SELECT * FROM subscriptions WHERE gateway_sub_id = $1", [gatewaySubId]); + return result.rows[0] ?? null; +} + +export async function findSubscriptionForUpdate(client: PoolClient, id: string): Promise { + const result = await client.query("SELECT * FROM subscriptions WHERE id = $1 FOR UPDATE", [id]); + return result.rows[0] ?? null; +} + +export async function activateSubscription( + client: PoolClient, + id: string, + periodStart: Date, + periodEnd: Date +): Promise { + await client.query( + `UPDATE subscriptions SET status = 'active', current_period_start = $2, current_period_end = $3, + grace_until = NULL, updated_at = now() WHERE id = $1`, + [id, periodStart, periodEnd] + ); +} + +export async function extendSubscriptionPeriod(client: PoolClient, id: string, periodEnd: Date): Promise { + await client.query( + `UPDATE subscriptions SET status = 'active', current_period_start = now(), current_period_end = $2, + grace_until = NULL, updated_at = now() WHERE id = $1`, + [id, periodEnd] + ); +} + +// PAYPAL_SPEC §4.5 FR-F1: 3-day grace period. +export async function markSubscriptionPastDue(client: PoolClient, id: string): Promise { + await client.query( + `UPDATE subscriptions SET status = 'past_due', grace_until = now() + interval '3 days', updated_at = now() + WHERE id = $1`, + [id] + ); +} + +export async function expireSubscription(client: PoolClient, id: string): Promise { + await client.query("UPDATE subscriptions SET status = 'expired', updated_at = now() WHERE id = $1", [id]); +} + +export async function setCancelAtPeriodEnd(client: PoolClient, id: string, cancel: boolean): Promise { + await client.query("UPDATE subscriptions SET cancel_at_period_end = $2, updated_at = now() WHERE id = $1", [id, cancel]); +} + +export async function cancelSubscriptionNow(client: PoolClient, id: string): Promise { + await client.query("UPDATE subscriptions SET status = 'canceled', updated_at = now() WHERE id = $1", [id]); +} + +export async function changeSubscriptionPlan(client: PoolClient, id: string, planId: string): Promise { + await client.query("UPDATE subscriptions SET plan_id = $2, updated_at = now() WHERE id = $1", [id, planId]); +} + +// Expiry sweeper targets (PAYPAL_SPEC §11): past_due rows whose grace elapsed, and canceled +// rows past their period end. +export async function findExpiredPastDue(client: PoolClient | Pool): Promise { + const result = await client.query( + "SELECT * FROM subscriptions WHERE status = 'past_due' AND grace_until <= now()" + ); + return result.rows; +} + +export async function findLapsedCancellations(client: PoolClient | Pool): Promise { + const result = await client.query( + "SELECT * FROM subscriptions WHERE status = 'canceled' AND current_period_end <= now()" + ); + return result.rows; +} + +export async function findRenewalsDueInDays(client: PoolClient | Pool, days: number): Promise { + const result = await client.query( + `SELECT * FROM subscriptions WHERE status = 'active' AND NOT cancel_at_period_end + AND current_period_end BETWEEN now() + ($1 || ' days')::interval - interval '1 hour' + AND now() + ($1 || ' days')::interval`, + [days] + ); + return result.rows; +} + +// ───────────────────────── Payments ───────────────────────── + +export interface PaymentRow { + id: string; + subscription_id: string; + user_id: string; + amount_paise: number; + status: "pending" | "succeeded" | "failed" | "refunded" | "partially_refunded"; + gateway_payment_id: string | null; + invoice_number: string | null; + paid_at: Date | null; + created_at: Date; +} + +export async function findPaymentByGatewayId(client: PoolClient | Pool, gatewayPaymentId: string): Promise { + const result = await client.query("SELECT * FROM payments WHERE gateway_payment_id = $1", [gatewayPaymentId]); + return result.rows[0] ?? null; +} + +export async function insertPayment( + client: PoolClient, + args: { + subscriptionId: string; + userId: string; + amountPaise: number; + status: PaymentRow["status"]; + gatewayPaymentId: string | null; + invoiceNumber: string | null; + paidAt: Date | null; + failureReason?: string | null; + } +): Promise { + const result = await client.query<{ id: string }>( + `INSERT INTO payments (subscription_id, user_id, amount_paise, status, gateway_payment_id, invoice_number, paid_at, failure_reason) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`, + [ + args.subscriptionId, + args.userId, + args.amountPaise, + args.status, + args.gatewayPaymentId, + args.invoiceNumber, + args.paidAt, + args.failureReason ?? null, + ] + ); + return result.rows[0].id; +} + +export async function markPaymentRefunded(client: PoolClient, id: string, partial: boolean): Promise { + await client.query("UPDATE payments SET status = $2 WHERE id = $1", [id, partial ? "partially_refunded" : "refunded"]); +} + +export async function findPaymentById(client: PoolClient | Pool, id: string): Promise { + const result = await client.query("SELECT * FROM payments WHERE id = $1", [id]); + return result.rows[0] ?? null; +} + +export async function listPaymentsForUser(client: PoolClient | Pool, userId: string): Promise { + const result = await client.query( + "SELECT * FROM payments WHERE user_id = $1 ORDER BY created_at DESC", + [userId] + ); + return result.rows; +} + +export async function nextInvoiceNumber(client: PoolClient): Promise { + const year = new Date().getFullYear(); + const result = await client.query<{ next_seq: number }>( + `INSERT INTO invoice_counters (year, next_seq) VALUES ($1, 2) + ON CONFLICT (year) DO UPDATE SET next_seq = invoice_counters.next_seq + 1 + RETURNING next_seq - 1 AS next_seq`, + [year] + ); + const seq = result.rows[0].next_seq; + return `NS-${year}-${String(seq).padStart(5, "0")}`; +} + +// ───────────────────────── Refunds ───────────────────────── + +export async function insertRefund( + client: PoolClient, + args: { paymentId: string; amountPaise: number; gatewayRefundId: string; reason: string | null; createdBy: string } +): Promise { + const result = await client.query<{ id: string }>( + `INSERT INTO refunds (payment_id, amount_paise, gateway_refund_id, reason, created_by) + VALUES ($1, $2, $3, $4, $5) RETURNING id`, + [args.paymentId, args.amountPaise, args.gatewayRefundId, args.reason, args.createdBy] + ); + return result.rows[0].id; +} + +// ───────────────────────── Webhook dedupe ───────────────────────── + +export async function insertWebhookEventIfNew( + client: PoolClient, + args: { gateway: string; gatewayEventId: string; type: string; payload: unknown } +): Promise { + const result = await client.query( + `INSERT INTO webhook_events (gateway, gateway_event_id, type, payload) VALUES ($1, $2, $3, $4) + ON CONFLICT (gateway, gateway_event_id) DO NOTHING RETURNING id`, + [args.gateway, args.gatewayEventId, args.type, JSON.stringify(args.payload)] + ); + return result.rows.length > 0; +} + +export async function markWebhookProcessed(client: PoolClient, gateway: string, gatewayEventId: string, error: string | null): Promise { + await client.query( + "UPDATE webhook_events SET processed_at = now(), error = $3 WHERE gateway = $1 AND gateway_event_id = $2", + [gateway, gatewayEventId, error] + ); +} + +// ───────────────────────── Audit log ───────────────────────── + +export async function insertBillingAuditLog( + client: PoolClient, + args: { actorId: string; action: string; subscriptionId: string | null; metadata?: Record } +): Promise { + await client.query( + "INSERT INTO billing_audit_log (actor_id, action, subscription_id, metadata) VALUES ($1, $2, $3, $4)", + [args.actorId, args.action, args.subscriptionId, JSON.stringify(args.metadata ?? {})] + ); +} + +// ───────────────────────── Billing email outbox ───────────────────────── + +export async function insertBillingEmailOutbox( + client: PoolClient, + userId: string, + kind: string, + context: Record = {} +): Promise { + await client.query("INSERT INTO billing_email_outbox (user_id, kind, context) VALUES ($1, $2, $3)", [ + userId, + kind, + JSON.stringify(context), + ]); +} + +// ───────────────────────── Admin ───────────────────────── + +export async function getUserEmail(client: PoolClient | Pool, userId: string): Promise { + const result = await client.query<{ email: string }>("SELECT email FROM users WHERE id = $1", [userId]); + return result.rows[0]?.email ?? null; +} + +export async function isUserAdmin(client: PoolClient | Pool, userId: string): Promise { + const result = await client.query<{ is_admin: boolean }>("SELECT is_admin FROM users WHERE id = $1", [userId]); + return result.rows[0]?.is_admin ?? false; +} + +export async function findSubscriptionsByUserEmail(client: PoolClient | Pool, email: string): Promise { + const result = await client.query( + `SELECT s.* FROM subscriptions s JOIN users u ON u.id = s.user_id + WHERE lower(u.email) = lower($1) ORDER BY s.created_at DESC`, + [email] + ); + return result.rows; +} + +// ───────────────────────── Reconciliation ───────────────────────── + +export async function listPaymentsSince(client: PoolClient | Pool, since: Date): Promise { + const result = await client.query("SELECT * FROM payments WHERE created_at >= $1", [since]); + return result.rows; +} diff --git a/apps/api/src/features/billing/routes.ts b/apps/api/src/features/billing/routes.ts new file mode 100644 index 0000000..2e8685a --- /dev/null +++ b/apps/api/src/features/billing/routes.ts @@ -0,0 +1,166 @@ +import type { FastifyInstance } from "fastify"; + +import { getPool } from "../../db.js"; +import { PayPalGateway, UnhandledWebhookEventError } from "./paypal.js"; +import * as repo from "./repo.js"; +import { + ForbiddenError, + NotFoundError, + ValidationError, + cancel, + changePlan, + checkout, + getSubscriptionView, + issueRefund, + listPayments, + processWebhook, + resume, +} from "./service.js"; +import { renderInvoiceHtml } from "./invoice.js"; +import { adminExtend, adminRevoke, findSubscriptionsByEmail } from "./service.js"; + +const gateway = new PayPalGateway(); + +// /api/public/plans — unauthenticated plan listing (PAYPAL_SPEC §7 `GET /api/plans`). +export async function billingPublicRoutes(app: FastifyInstance): Promise { + app.get("/plans", async () => ({ items: await repo.listActivePlans(getPool()) })); +} + +// /api/billing/* — authenticated, mounted inside the existing authPlugin scope. +export async function billingRoutes(app: FastifyInstance): Promise { + app.post("/billing/checkout", async (request, reply) => { + const { planCode } = request.body as { planCode?: unknown }; + if (typeof planCode !== "string") return reply.code(400).send({ error: "invalid_plan_code" }); + try { + const checkoutParams = await checkout(getPool(), gateway, request.userId as string, planCode); + return reply.send({ checkoutParams }); + } catch (err) { + if (err instanceof ValidationError) return reply.code(409).send({ error: err.code }); + throw err; + } + }); + + app.get("/billing/subscription", async (request) => getSubscriptionView(getPool(), request.userId as string)); + + app.post("/billing/cancel", async (request, reply) => { + try { + await cancel(getPool(), gateway, request.userId as string); + return reply.code(204).send(); + } catch (err) { + if (err instanceof NotFoundError) return reply.code(404).send({ error: err.code }); + throw err; + } + }); + + app.post("/billing/resume", async (request, reply) => { + try { + await resume(getPool(), request.userId as string); + return reply.code(204).send(); + } catch (err) { + if (err instanceof NotFoundError) return reply.code(404).send({ error: err.code }); + throw err; + } + }); + + app.post("/billing/change-plan", async (request, reply) => { + const { planCode } = request.body as { planCode?: unknown }; + if (typeof planCode !== "string") return reply.code(400).send({ error: "invalid_plan_code" }); + try { + await changePlan(getPool(), request.userId as string, planCode); + return reply.code(204).send(); + } catch (err) { + if (err instanceof NotFoundError) return reply.code(404).send({ error: err.code }); + if (err instanceof ValidationError) return reply.code(400).send({ error: err.code }); + throw err; + } + }); + + app.get("/billing/payments", async (request) => ({ items: await listPayments(getPool(), request.userId as string) })); + + // Authorization check (PAYPAL_SPEC §4.2 "Authorization check to prevent IDOR"): a payment + // row is only ever returned for the requesting user, never looked up by id alone. + app.get("/billing/invoices/:paymentId", async (request, reply) => { + const { paymentId } = request.params as { paymentId: string }; + const payments = await listPayments(getPool(), request.userId as string); + const payment = payments.find((p) => p.id === paymentId); + if (payment === undefined || payment.invoice_number === null) return reply.code(404).send({ error: "not_found" }); + const plan = await getSubscriptionView(getPool(), request.userId as string); + const email = (await repo.getUserEmail(getPool(), request.userId as string)) ?? ""; + const html = renderInvoiceHtml({ + invoiceNumber: payment.invoice_number, + planName: plan.planCode ?? "Subscription", + amountPaise: payment.amount_paise, + paidAt: payment.paid_at ?? payment.created_at, + userEmail: email, + }); + reply.header("Content-Type", "text/html"); + return reply.send(html); + }); +} + +// /webhooks/paypal — no session, no CSRF (PAYPAL_SPEC §7); signature verified instead. +export async function billingWebhookRoutes(app: FastifyInstance): Promise { + // Raw buffer needed for signature verification — scoped to this plugin only so the rest of + // the app keeps Fastify's normal JSON body parsing. + app.addContentTypeParser("application/json", { parseAs: "buffer" }, (_req, body, done) => done(null, body)); + + app.post("/paypal", async (request, reply) => { + const rawBody = request.body as Buffer; + const verified = await gateway.verifyWebhook(rawBody, request.headers); + if (!verified) return reply.code(400).send({ error: "invalid_signature" }); + + let event; + try { + event = gateway.parseWebhook(rawBody); + } catch (err) { + if (err instanceof UnhandledWebhookEventError) return reply.code(200).send({ status: "ignored" }); + throw err; + } + + // §7 step 3: ack fast, process synchronously here (no queue yet — ponytail: add one if + // webhook volume or handler latency ever makes a slow ack a real problem). + await processWebhook(getPool(), gateway, event); + return reply.code(200).send({ status: "ok" }); + }); +} + +// /api/admin/billing/* — mounted behind the admin plugin (plugins/admin.ts). +export async function billingAdminRoutes(app: FastifyInstance): Promise { + app.get("/billing/subscriptions", async (request, reply) => { + const { email } = request.query as { email?: string }; + if (typeof email !== "string" || email.length === 0) return reply.code(400).send({ error: "email_required" }); + return { items: await findSubscriptionsByEmail(getPool(), email) }; + }); + + app.post("/billing/subscriptions/:id/extend", async (request, reply) => { + const { id } = request.params as { id: string }; + const { days } = request.body as { days?: unknown }; + if (typeof days !== "number" || days <= 0) return reply.code(400).send({ error: "invalid_days" }); + try { + await adminExtend(getPool(), request.userId as string, id, days); + return reply.code(204).send(); + } catch (err) { + if (err instanceof NotFoundError) return reply.code(404).send({ error: err.code }); + throw err; + } + }); + + app.post("/billing/subscriptions/:id/revoke", async (request, reply) => { + const { id } = request.params as { id: string }; + await adminRevoke(getPool(), request.userId as string, id); + return reply.code(204).send(); + }); + + app.post("/billing/refunds", async (request, reply) => { + const { paymentId, reason } = request.body as { paymentId?: unknown; reason?: unknown }; + if (typeof paymentId !== "string") return reply.code(400).send({ error: "invalid_payment_id" }); + try { + await issueRefund(getPool(), gateway, request.userId as string, paymentId, typeof reason === "string" ? reason : null); + return reply.code(204).send(); + } catch (err) { + if (err instanceof NotFoundError) return reply.code(404).send({ error: err.code }); + if (err instanceof ForbiddenError) return reply.code(403).send({ error: err.code }); + throw err; + } + }); +} diff --git a/apps/api/src/features/billing/service.ts b/apps/api/src/features/billing/service.ts new file mode 100644 index 0000000..d59c47a --- /dev/null +++ b/apps/api/src/features/billing/service.ts @@ -0,0 +1,270 @@ +import type { Pool, PoolClient } from "pg"; + +import { withTransaction } from "../../db.js"; +import { isEntitled, type SubscriptionState } from "./entitlement.js"; +import type { NormalizedEvent, PaymentGateway } from "./gateway.js"; +import * as repo from "./repo.js"; + +export class ValidationError extends Error { + constructor(public code: string) { + super(code); + } +} +export class NotFoundError extends Error { + constructor(public code: string) { + super(code); + } +} +export class ForbiddenError extends Error { + constructor(public code: string) { + super(code); + } +} + +// ───────────────────────── FR §4.3 Subscribe flow ───────────────────────── + +export async function checkout( + pool: Pool, + gateway: PaymentGateway, + userId: string, + planCode: string +): Promise> { + const plan = await repo.findPlanByCode(pool, planCode); + if (plan === null) throw new ValidationError("unknown_plan"); + + const existing = await repo.findActiveSubscriptionForUser(pool, userId); + if (existing !== null && isEntitled(existing)) throw new ValidationError("already_subscribed"); + + // §3 "Plan definitions live in the database and are mirrored to gateway plan IDs" — this + // MVP creates the PayPal billing plan lazily on first checkout rather than via a separate + // provisioning script (§Phase 2.3 "script it so it's repeatable"); fine at two plans, revisit + // if plan count or per-environment gateway plan drift becomes a real operational cost. + if (plan.gateway_plan_id === null) { + throw new ValidationError("plan_not_provisioned"); + } + + const { gatewaySubscriptionId, checkoutParams } = await gateway.createSubscription({ + userId, + planCode, + gatewayPlanId: plan.gateway_plan_id, + }); + + await repo.insertIncompleteSubscription(pool, { userId, planId: plan.id, gatewaySubId: gatewaySubscriptionId }); + return checkoutParams; +} + +export interface SubscriptionView { + status: string; + entitled: boolean; + planCode: string | null; + currentPeriodEnd: string | null; + cancelAtPeriodEnd: boolean; +} + +export async function getSubscriptionView(pool: Pool, userId: string): Promise { + const sub = await repo.findActiveSubscriptionForUser(pool, userId); + if (sub === null) { + return { status: "none", entitled: false, planCode: null, currentPeriodEnd: null, cancelAtPeriodEnd: false }; + } + const plan = await repo.findPlanById(pool, sub.plan_id); + return { + status: sub.status, + entitled: isEntitled(sub as unknown as SubscriptionState), + planCode: plan?.code ?? null, + currentPeriodEnd: sub.current_period_end?.toISOString() ?? null, + cancelAtPeriodEnd: sub.cancel_at_period_end, + }; +} + +// ───────────────────────── §7 Webhook handling algorithm ───────────────────────── + +export async function processWebhook(pool: Pool, gateway: PaymentGateway, event: NormalizedEvent): Promise { + const isNew = await withTransaction(pool, (client) => + repo.insertWebhookEventIfNew(client, { + gateway: "paypal", + gatewayEventId: event.gatewayEventId, + type: event.type, + payload: event.raw, + }) + ); + if (!isNew) return; // duplicate delivery — §7 step 2: no-op, not a failure + + let error: string | null = null; + try { + await withTransaction(pool, (client) => applyEvent(client, event)); + } catch (err) { + error = err instanceof Error ? err.message : String(err); + throw err; + } finally { + await withTransaction(pool, (client) => repo.markWebhookProcessed(client, "paypal", event.gatewayEventId, error)); + } +} + +async function applyEvent(client: PoolClient, event: NormalizedEvent): Promise { + if (event.gatewaySubId === null) return; // payment-only events (refunds) handled separately below + const sub = await repo.findSubscriptionByGatewaySubId(client, event.gatewaySubId); + if (sub === null) return; // unknown subscription — nothing to apply idempotently + + const locked = await repo.findSubscriptionForUpdate(client, sub.id); + if (locked === null) return; + + const plan = await repo.findPlanById(client, locked.plan_id); + if (plan === null) return; + + switch (event.type) { + case "subscription.activated": { + if (locked.status === "active") return; // already applied — idempotent + const start = new Date(); + const end = addBillingInterval(start, plan.interval); + await repo.activateSubscription(client, locked.id, start, end); + if (event.amountPaise !== null) { + const invoiceNumber = await repo.nextInvoiceNumber(client); + await repo.insertPayment(client, { + subscriptionId: locked.id, + userId: locked.user_id, + amountPaise: event.amountPaise, + status: "succeeded", + gatewayPaymentId: event.gatewayPaymentId, + invoiceNumber, + paidAt: new Date(), + }); + } + await repo.insertBillingEmailOutbox(client, locked.user_id, "receipt", { subscriptionId: locked.id }); + break; + } + case "subscription.charged": { + // Renewal charge: extend the period and record the payment. Idempotent via the + // webhook_events dedupe above — a replay never double-extends. + const newEnd = addBillingInterval(locked.current_period_end ?? new Date(), plan.interval); + await repo.extendSubscriptionPeriod(client, locked.id, newEnd); + if (event.amountPaise !== null) { + const invoiceNumber = await repo.nextInvoiceNumber(client); + await repo.insertPayment(client, { + subscriptionId: locked.id, + userId: locked.user_id, + amountPaise: event.amountPaise, + status: "succeeded", + gatewayPaymentId: event.gatewayPaymentId, + invoiceNumber, + paidAt: new Date(), + }); + } + await repo.insertBillingEmailOutbox(client, locked.user_id, "receipt", { subscriptionId: locked.id }); + break; + } + case "payment.failed": { + if (locked.status === "past_due") return; // already in grace — idempotent + await repo.markSubscriptionPastDue(client, locked.id); + await repo.insertPayment(client, { + subscriptionId: locked.id, + userId: locked.user_id, + amountPaise: event.amountPaise ?? 0, + status: "failed", + gatewayPaymentId: event.gatewayPaymentId, + invoiceNumber: null, + paidAt: null, + failureReason: "gateway_declined", + }); + await repo.insertBillingEmailOutbox(client, locked.user_id, "payment_failed", { subscriptionId: locked.id }); + break; + } + case "subscription.halted": { + if (locked.status === "expired") return; + await repo.expireSubscription(client, locked.id); + await repo.insertBillingEmailOutbox(client, locked.user_id, "expired", { subscriptionId: locked.id }); + break; + } + case "subscription.cancelled": { + if (locked.status === "canceled" || locked.status === "expired") return; + await repo.cancelSubscriptionNow(client, locked.id); + await repo.insertBillingEmailOutbox(client, locked.user_id, "canceled", { subscriptionId: locked.id }); + break; + } + case "refund.processed": { + if (event.gatewayPaymentId === null) return; + const payment = await repo.findPaymentByGatewayId(client, event.gatewayPaymentId); + if (payment === null) return; + const partial = event.amountPaise !== null && event.amountPaise < payment.amount_paise; + await repo.markPaymentRefunded(client, payment.id, partial); + await repo.insertBillingEmailOutbox(client, payment.user_id, "refund_processed", { paymentId: payment.id }); + break; + } + } +} + +function addBillingInterval(from: Date, interval: "month" | "year"): Date { + const next = new Date(from); + if (interval === "year") next.setFullYear(next.getFullYear() + 1); + else next.setMonth(next.getMonth() + 1); + return next; +} + +// ───────────────────────── §4.6 Cancellation ───────────────────────── + +export async function cancel(pool: Pool, gateway: PaymentGateway, userId: string): Promise { + const sub = await repo.findActiveSubscriptionForUser(pool, userId); + if (sub === null || sub.gateway_sub_id === null) throw new NotFoundError("no_subscription"); + await gateway.cancelSubscription(sub.gateway_sub_id, true); + await withTransaction(pool, (client) => repo.setCancelAtPeriodEnd(client, sub.id, true)); +} + +export async function resume(pool: Pool, userId: string): Promise { + const sub = await repo.findActiveSubscriptionForUser(pool, userId); + if (sub === null || !sub.cancel_at_period_end) throw new NotFoundError("nothing_to_resume"); + await withTransaction(pool, (client) => repo.setCancelAtPeriodEnd(client, sub.id, false)); +} + +export async function changePlan(pool: Pool, userId: string, newPlanCode: string): Promise { + const sub = await repo.findActiveSubscriptionForUser(pool, userId); + if (sub === null) throw new NotFoundError("no_subscription"); + const plan = await repo.findPlanByCode(pool, newPlanCode); + if (plan === null) throw new ValidationError("unknown_plan"); + await withTransaction(pool, (client) => repo.changeSubscriptionPlan(client, sub.id, plan.id)); +} + +export async function listPayments(pool: Pool, userId: string) { + return repo.listPaymentsForUser(pool, userId); +} + +// ───────────────────────── §4.7 Refunds (admin-only) ───────────────────────── + +export async function issueRefund( + pool: Pool, + gateway: PaymentGateway, + actorId: string, + paymentId: string, + reason: string | null +): Promise { + const payment = await repo.findPaymentById(pool, paymentId); + if (payment === null || payment.gateway_payment_id === null) throw new NotFoundError("payment_not_found"); + const { refundId } = await gateway.refundPayment(payment.gateway_payment_id, payment.amount_paise); + await withTransaction(pool, async (client) => { + await repo.insertRefund(client, { paymentId, amountPaise: payment.amount_paise, gatewayRefundId: refundId, reason, createdBy: actorId }); + await repo.markPaymentRefunded(client, paymentId, false); + await repo.insertBillingAuditLog(client, { actorId, action: "refund_issued", subscriptionId: payment.subscription_id, metadata: { paymentId, refundId } }); + }); +} + +// ───────────────────────── §4.12 Admin: manual extend/revoke ───────────────────────── + +export async function adminExtend(pool: Pool, actorId: string, subscriptionId: string, days: number): Promise { + await withTransaction(pool, async (client) => { + const sub = await repo.findSubscriptionForUpdate(client, subscriptionId); + if (sub === null) throw new NotFoundError("subscription_not_found"); + const base = sub.current_period_end ?? new Date(); + const extended = new Date(base.getTime() + days * 24 * 60 * 60 * 1000); + await repo.extendSubscriptionPeriod(client, subscriptionId, extended); + await repo.insertBillingAuditLog(client, { actorId, action: "manual_extend", subscriptionId, metadata: { days } }); + }); +} + +export async function adminRevoke(pool: Pool, actorId: string, subscriptionId: string): Promise { + await withTransaction(pool, async (client) => { + await repo.expireSubscription(client, subscriptionId); + await repo.insertBillingAuditLog(client, { actorId, action: "manual_revoke", subscriptionId, metadata: {} }); + }); +} + +export async function findSubscriptionsByEmail(pool: Pool, email: string) { + return repo.findSubscriptionsByUserEmail(pool, email); +} diff --git a/apps/api/src/features/curator/repo.ts b/apps/api/src/features/curator/repo.ts index a20696b..2929cd7 100644 --- a/apps/api/src/features/curator/repo.ts +++ b/apps/api/src/features/curator/repo.ts @@ -80,3 +80,53 @@ export async function fetchStoryClusterVersion(pool: Pool | PoolClient, storyId: ]); return result.rows[0]?.cluster_version ?? null; } + +export interface ClusterReportRow { + id: string; + kind: "wrong_merge" | "should_merge"; + story_id: string; + story_title: string; + article_id: string | null; + article_title: string | null; + other_story_id: string | null; + other_story_title: string | null; + cluster_version: string; + reported_by: string; + created_at: Date; + resolved_at: Date | null; +} + +export interface ClusterReportListFilters { + resolved: boolean | null; // null = both + limit: number; + offset: number; +} + +// Curator-internal review queue (§7's live error signal) — plain offset pagination like the +// source registry: curator-filed reports are a low-volume, curator-facing list, not an +// unbounded public feed. +export async function fetchClusterReports( + pool: Pool, + filters: ClusterReportListFilters +): Promise<{ items: ClusterReportRow[]; total: number }> { + const where = filters.resolved === null ? [] : [filters.resolved ? "cr.resolved_at IS NOT NULL" : "cr.resolved_at IS NULL"]; + const whereClause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""; + + const countResult = await pool.query<{ total: string }>(`SELECT count(*) AS total FROM cluster_reports cr ${whereClause}`); + + const itemsResult = await pool.query( + `SELECT cr.id, cr.kind, cr.story_id, s.title AS story_title, cr.article_id, a.title AS article_title, + cr.other_story_id, os.title AS other_story_title, cr.cluster_version, cr.reported_by, + cr.created_at, cr.resolved_at + FROM cluster_reports cr + JOIN stories s ON s.id = cr.story_id + LEFT JOIN articles a ON a.id = cr.article_id + LEFT JOIN stories os ON os.id = cr.other_story_id + ${whereClause} + ORDER BY cr.created_at DESC + LIMIT $1 OFFSET $2`, + [filters.limit, filters.offset] + ); + + return { items: itemsResult.rows, total: Number(countResult.rows[0].total) }; +} diff --git a/apps/api/src/features/curator/routes.ts b/apps/api/src/features/curator/routes.ts index 810eab2..95ef572 100644 --- a/apps/api/src/features/curator/routes.ts +++ b/apps/api/src/features/curator/routes.ts @@ -3,6 +3,7 @@ import type { FastifyInstance } from "fastify"; import { getPool } from "../../db.js"; import { createTopic, + listClusterReports, NotFoundError, onboardSource, reportClusterError, @@ -69,4 +70,14 @@ export async function curatorRoutes(app: FastifyInstance): Promise { throw err; } }); + + app.get("/cluster-reports", async (request, reply) => { + try { + const result = await listClusterReports(getPool(), request.query as Record); + return reply.send(result); + } catch (err) { + if (err instanceof ValidationError) return reply.code(400).send({ error: err.code }); + throw err; + } + }); } diff --git a/apps/api/src/features/curator/service.ts b/apps/api/src/features/curator/service.ts index 1b6db4c..9cbd5ad 100644 --- a/apps/api/src/features/curator/service.ts +++ b/apps/api/src/features/curator/service.ts @@ -1,6 +1,7 @@ import type { Pool } from "pg"; import { + fetchClusterReports, fetchStoryClusterVersion, hideArticle, hideStory, @@ -8,6 +9,7 @@ import { insertSource, insertTopic, type ClusterReportInput, + type ClusterReportRow, type NewSourceInput, } from "./repo.js"; @@ -109,3 +111,70 @@ export async function reportClusterError( }; return insertClusterReport(pool, input); } + +export interface ClusterReportListItemResponse { + id: string; + kind: "wrong_merge" | "should_merge"; + storyId: string; + storyTitle: string; + articleId: string | null; + articleTitle: string | null; + otherStoryId: string | null; + otherStoryTitle: string | null; + clusterVersion: string; + reportedBy: string; + createdAt: string; + resolvedAt: string | null; +} + +export interface ClusterReportListResponse { + items: ClusterReportListItemResponse[]; + total: number; + limit: number; + offset: number; +} + +export interface RawClusterReportListQuery { + resolved?: unknown; + limit?: unknown; + offset?: unknown; +} + +const REPORT_DEFAULT_LIMIT = 50; +const REPORT_MAX_LIMIT = 100; + +function toClusterReportItem(row: ClusterReportRow): ClusterReportListItemResponse { + return { + id: row.id, + kind: row.kind, + storyId: row.story_id, + storyTitle: row.story_title, + articleId: row.article_id, + articleTitle: row.article_title, + otherStoryId: row.other_story_id, + otherStoryTitle: row.other_story_title, + clusterVersion: row.cluster_version, + reportedBy: row.reported_by, + createdAt: row.created_at.toISOString(), + resolvedAt: row.resolved_at?.toISOString() ?? null, + }; +} + +// Curator review queue — unblocks the frontend list screen (mvp-todo: "API gap, not a +// frontend one"). Resolving a report is out of scope here: `resolved_at` has no writer +// anywhere yet, so this stays read-only until that's asked for. +export async function listClusterReports(pool: Pool, query: RawClusterReportListQuery): Promise { + let resolved: boolean | null = null; + if (query.resolved === "true") resolved = true; + else if (query.resolved === "false") resolved = false; + else if (query.resolved !== undefined) throw new ValidationError("invalid_resolved"); + + const limitRaw = typeof query.limit === "string" ? Number(query.limit) : REPORT_DEFAULT_LIMIT; + if (!Number.isInteger(limitRaw) || limitRaw < 1 || limitRaw > REPORT_MAX_LIMIT) throw new ValidationError("invalid_limit"); + + const offsetRaw = typeof query.offset === "string" ? Number(query.offset) : 0; + if (!Number.isInteger(offsetRaw) || offsetRaw < 0) throw new ValidationError("invalid_offset"); + + const { items, total } = await fetchClusterReports(pool, { resolved, limit: limitRaw, offset: offsetRaw }); + return { items: items.map(toClusterReportItem), total, limit: limitRaw, offset: offsetRaw }; +} diff --git a/apps/api/src/features/geo-rating/repo.ts b/apps/api/src/features/geo-rating/repo.ts new file mode 100644 index 0000000..a8675cb --- /dev/null +++ b/apps/api/src/features/geo-rating/repo.ts @@ -0,0 +1,114 @@ +import type { Pool, PoolClient } from "pg"; + +export const TOPIC_CODES = [ + "IN_PAK", + "IN_CHN", + "IN_US", + "IN_RUS", + "IN_NEIGH", + "DEFSEC", + "MULTILAT", + "INDOPAC", +] as const; +export type TopicCode = (typeof TOPIC_CODES)[number]; + +export interface GeoRatingRow { + id: string; + source_id: string; + topic_code: TopicCode; + stance: "strongly_minus" | "leans_minus" | "mixed_or_issue_dependent" | "leans_plus" | "strongly_plus" | "insufficient_evidence"; + method: string; + method_version: string | null; + rated_by: string; + second_rater: string | null; + rubric_scores: unknown; + evidence: unknown; + review_due_at: Date | null; + source_ref: string | null; + notes: string | null; + rated_at: Date; + supersedes: string | null; + superseded_at: Date | null; +} + +const COLUMNS = `id, source_id, topic_code, stance, method, method_version, + rated_by, second_rater, rubric_scores, evidence, review_due_at, source_ref, notes, + rated_at, supersedes, superseded_at`; + +export async function sourceExists(pool: Pool, sourceId: string): Promise { + const result = await pool.query("SELECT 1 FROM sources WHERE id = $1", [sourceId]); + return result.rowCount !== null && result.rowCount > 0; +} + +// All 8 topics' current rating for a source in one query — the GET endpoint returns the full +// per-topic picture at once rather than forcing 8 round trips (PRODUCT §5.6). +export async function fetchCurrentRatings(pool: Pool, sourceId: string): Promise { + const result = await pool.query( + `SELECT ${COLUMNS} FROM source_geo_ratings WHERE source_id = $1 AND superseded_at IS NULL`, + [sourceId] + ); + return result.rows; +} + +export async function fetchAllHistory(pool: Pool, sourceId: string): Promise { + const result = await pool.query( + `SELECT ${COLUMNS} FROM source_geo_ratings WHERE source_id = $1 ORDER BY topic_code, rated_at DESC`, + [sourceId] + ); + return result.rows; +} + +export interface NewGeoRatingInput { + stance: GeoRatingRow["stance"]; + method: string; + methodVersion: string | null; + ratedBy: string; + secondRater: string | null; + rubricScores: unknown; + evidence: unknown; + reviewDueAt: Date | null; + sourceRef: string | null; + notes: string | null; +} + +// Same atomic-revision shape as rating/repo.ts and sensationalism-rating/repo.ts, but the +// "current" row is superseded per (source_id, topic_code) rather than per source_id alone, since +// idx_source_geo_ratings_one_current is keyed on that pair. +export async function reviseRating( + client: PoolClient, + sourceId: string, + topicCode: TopicCode, + input: NewGeoRatingInput +): Promise { + await client.query("SELECT 1 FROM sources WHERE id = $1 FOR UPDATE", [sourceId]); + const old = await client.query<{ id: string }>( + `UPDATE source_geo_ratings SET superseded_at = now() + WHERE source_id = $1 AND topic_code = $2 AND superseded_at IS NULL RETURNING id`, + [sourceId, topicCode] + ); + const oldId = old.rows[0]?.id ?? null; + + const inserted = await client.query( + `INSERT INTO source_geo_ratings ( + source_id, topic_code, stance, method, method_version, + rated_by, second_rater, rubric_scores, evidence, review_due_at, source_ref, notes, supersedes + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING ${COLUMNS}`, + [ + sourceId, + topicCode, + input.stance, + input.method, + input.methodVersion, + input.ratedBy, + input.secondRater, + input.rubricScores === undefined ? null : JSON.stringify(input.rubricScores), + input.evidence === undefined ? null : JSON.stringify(input.evidence), + input.reviewDueAt, + input.sourceRef, + input.notes, + oldId, + ] + ); + return inserted.rows[0]; +} diff --git a/apps/api/src/features/geo-rating/routes.ts b/apps/api/src/features/geo-rating/routes.ts new file mode 100644 index 0000000..8d2c140 --- /dev/null +++ b/apps/api/src/features/geo-rating/routes.ts @@ -0,0 +1,62 @@ +import type { FastifyInstance } from "fastify"; + +import { getPool } from "../../db.js"; +import { getCachedPage, setCachedPage } from "../../page-cache.js"; +import { + getSourceGeoRatings, + isTopicCode, + SourceNotFoundError, + submitRating, + validateNewRating, + ValidationError, +} from "./service.js"; + +const CACHE_TTL_SECONDS = 60; + +// Public read side: current rating + history for all 8 topics (PRODUCT §5.6, source directory). +export async function geoRatingPublicRoutes(app: FastifyInstance): Promise { + app.get("/sources/:id/geo", async (request, reply) => { + const { id } = request.params as { id: string }; + const cacheKey = `page:source-geo:${id}`; + + const cached = await getCachedPage(cacheKey); + if (cached !== null) { + return reply.send(JSON.parse(cached)); + } + + let result; + try { + result = await getSourceGeoRatings(getPool(), id); + } catch (err) { + if (err instanceof SourceNotFoundError) { + return reply.code(404).send({ error: "not_found" }); + } + throw err; + } + + await setCachedPage(cacheKey, JSON.stringify(result), CACHE_TTL_SECONDS); + return reply.send(result); + }); +} + +// Curator write side: atomic rating revision scoped to one topic, same shape as +// sensationalism-rating/routes.ts but keyed on (source_id, topic_code). +export async function geoRatingCuratorRoutes(app: FastifyInstance): Promise { + app.post("/sources/:id/geo/:topicCode", async (request, reply) => { + const { id, topicCode } = request.params as { id: string; topicCode: string }; + try { + const input = validateNewRating(topicCode, request.body); + if (!isTopicCode(topicCode)) throw new ValidationError("invalid_topic_code"); + const result = await submitRating(getPool(), id, topicCode, input); + return reply.code(201).send(result); + } catch (err) { + if (err instanceof ValidationError) { + return reply.code(400).send({ error: err.code }); + } + if (err instanceof SourceNotFoundError) { + return reply.code(404).send({ error: "not_found" }); + } + throw err; + } + }); +} diff --git a/apps/api/src/features/geo-rating/service.ts b/apps/api/src/features/geo-rating/service.ts new file mode 100644 index 0000000..294e9fb --- /dev/null +++ b/apps/api/src/features/geo-rating/service.ts @@ -0,0 +1,135 @@ +import type { Pool } from "pg"; + +import { withTransaction } from "../../db.js"; +import { + fetchAllHistory, + fetchCurrentRatings, + reviseRating, + sourceExists, + TOPIC_CODES, + type GeoRatingRow, + type NewGeoRatingInput, + type TopicCode, +} from "./repo.js"; + +export class SourceNotFoundError extends Error {} +export class ValidationError extends Error { + constructor(public code: string) { + super(code); + } +} + +const TOPIC_CODE_SET = new Set(TOPIC_CODES); +const STANCE_VALUES = new Set([ + "strongly_minus", + "leans_minus", + "mixed_or_issue_dependent", + "leans_plus", + "strongly_plus", + "insufficient_evidence", +]); + +export interface GeoRatingResponse { + id: string; + sourceId: string; + topicCode: TopicCode; + stance: string; + method: string; + ratedBy: string; + ratedAt: string; + supersedes: string | null; + supersededAt: string | null; +} + +function toResponse(row: GeoRatingRow): GeoRatingResponse { + return { + id: row.id, + sourceId: row.source_id, + topicCode: row.topic_code, + stance: row.stance, + method: row.method, + ratedBy: row.rated_by, + ratedAt: row.rated_at.toISOString(), + supersedes: row.supersedes, + supersededAt: row.superseded_at?.toISOString() ?? null, + }; +} + +export function isTopicCode(value: string): value is TopicCode { + return TOPIC_CODE_SET.has(value); +} + +// One row per topic, present for all 8 codes even when a topic has never been rated (PRODUCT +// §5.6: absence of a row = never assessed, distinct from an explicit insufficient_evidence row). +export async function getSourceGeoRatings( + pool: Pool, + sourceId: string +): Promise> { + if (!(await sourceExists(pool, sourceId))) throw new SourceNotFoundError(); + const [currentRows, historyRows] = await Promise.all([ + fetchCurrentRatings(pool, sourceId), + fetchAllHistory(pool, sourceId), + ]); + + const result = {} as Record; + for (const topicCode of TOPIC_CODES) { + result[topicCode] = { current: null, history: [] }; + } + for (const row of currentRows) { + result[row.topic_code].current = toResponse(row); + } + for (const row of historyRows) { + result[row.topic_code].history.push(toResponse(row)); + } + return result; +} + +// §4 schema CHECKs (0004_source_assessment_tables.sql), validated here first so a bad +// submission gets a clean 400 instead of a raw Postgres constraint error. +export function validateNewRating(topicCode: string, body: unknown): NewGeoRatingInput { + if (!isTopicCode(topicCode)) throw new ValidationError("invalid_topic_code"); + + const b = (body ?? {}) as Record; + + if (typeof b.stance !== "string" || !STANCE_VALUES.has(b.stance)) { + throw new ValidationError("invalid_stance"); + } + if (typeof b.method !== "string" || b.method.length === 0) throw new ValidationError("invalid_method"); + if (typeof b.ratedBy !== "string" || b.ratedBy.length === 0) throw new ValidationError("invalid_rated_by"); + + const secondRater = typeof b.secondRater === "string" ? b.secondRater : null; + const evidence = Array.isArray(b.evidence) ? b.evidence : undefined; + const rubricScores = typeof b.rubricScores === "object" && b.rubricScores !== null ? b.rubricScores : undefined; + + // geo_manual_needs_two_raters_and_evidence (§4): enforced here too, so curator tooling gets a + // readable 400 instead of the DB's raw constraint-violation error. + if (b.method === "manual-curation") { + if (secondRater === null || secondRater === b.ratedBy) throw new ValidationError("manual_needs_second_rater"); + if (rubricScores === undefined) throw new ValidationError("manual_needs_rubric_scores"); + if (evidence === undefined || evidence.length < 3) throw new ValidationError("manual_needs_evidence"); + } + + return { + stance: b.stance as NewGeoRatingInput["stance"], + method: b.method, + methodVersion: typeof b.methodVersion === "string" ? b.methodVersion : null, + ratedBy: b.ratedBy, + secondRater, + rubricScores, + evidence, + reviewDueAt: typeof b.reviewDueAt === "string" ? new Date(b.reviewDueAt) : null, + sourceRef: typeof b.sourceRef === "string" ? b.sourceRef : null, + notes: typeof b.notes === "string" ? b.notes : null, + }; +} + +export async function submitRating( + pool: Pool, + sourceId: string, + topicCode: TopicCode, + input: NewGeoRatingInput +): Promise { + if (!(await sourceExists(pool, sourceId))) throw new SourceNotFoundError(); + const row = await withTransaction(pool, (client) => reviseRating(client, sourceId, topicCode, input)); + return toResponse(row); +} diff --git a/apps/api/src/features/ownership/repo.ts b/apps/api/src/features/ownership/repo.ts new file mode 100644 index 0000000..9783fed --- /dev/null +++ b/apps/api/src/features/ownership/repo.ts @@ -0,0 +1,59 @@ +import type { Pool } from "pg"; + +export interface SourceOwnershipRow { + source_id: string; + ownership_transparency: "disclosed" | "partially_disclosed" | "undisclosed" | "insufficient_evidence"; + editorial_independence: "no_documented_influence" | "documented_instances" | "insufficient_evidence"; + evidence: unknown; + rated_by: string | null; + notes: string | null; + updated_at: Date; +} + +const COLUMNS = `source_id, ownership_transparency, editorial_independence, evidence, rated_by, notes, updated_at`; + +export async function sourceExists(pool: Pool, sourceId: string): Promise { + const result = await pool.query("SELECT 1 FROM sources WHERE id = $1", [sourceId]); + return result.rowCount !== null && result.rowCount > 0; +} + +export async function fetchOwnership(pool: Pool, sourceId: string): Promise { + const result = await pool.query(`SELECT ${COLUMNS} FROM source_ownership WHERE source_id = $1`, [ + sourceId, + ]); + return result.rows[0] ?? null; +} + +export interface OwnershipInput { + ownershipTransparency: "disclosed" | "partially_disclosed" | "undisclosed" | "insufficient_evidence"; + editorialIndependence: "no_documented_influence" | "documented_instances" | "insufficient_evidence"; + evidence: unknown; + ratedBy: string | null; + notes: string | null; +} + +// Plain upsert (PRODUCT §5.8): a correction overwrites in place, no supersedes chain like +// rating/geo-rating/sensationalism-rating — source_ownership is current-state only. +export async function upsertOwnership(pool: Pool, sourceId: string, input: OwnershipInput): Promise { + const result = await pool.query( + `INSERT INTO source_ownership (source_id, ownership_transparency, editorial_independence, evidence, rated_by, notes, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, now()) + ON CONFLICT (source_id) DO UPDATE SET + ownership_transparency = EXCLUDED.ownership_transparency, + editorial_independence = EXCLUDED.editorial_independence, + evidence = EXCLUDED.evidence, + rated_by = EXCLUDED.rated_by, + notes = EXCLUDED.notes, + updated_at = now() + RETURNING ${COLUMNS}`, + [ + sourceId, + input.ownershipTransparency, + input.editorialIndependence, + input.evidence === undefined ? null : JSON.stringify(input.evidence), + input.ratedBy, + input.notes, + ] + ); + return result.rows[0]; +} diff --git a/apps/api/src/features/ownership/routes.ts b/apps/api/src/features/ownership/routes.ts new file mode 100644 index 0000000..aae4033 --- /dev/null +++ b/apps/api/src/features/ownership/routes.ts @@ -0,0 +1,61 @@ +import type { FastifyInstance } from "fastify"; + +import { getPool } from "../../db.js"; +import { getCachedPage, setCachedPage } from "../../page-cache.js"; +import { + getSourceOwnership, + SourceNotFoundError, + submitOwnership, + validateOwnershipInput, + ValidationError, +} from "./service.js"; + +const CACHE_TTL_SECONDS = 60; + +// Public read side: the current disclosure record, or null if never researched (PRODUCT §5.8, +// source directory). No history — source_ownership is current-state only. +export async function ownershipPublicRoutes(app: FastifyInstance): Promise { + app.get("/sources/:id/ownership", async (request, reply) => { + const { id } = request.params as { id: string }; + const cacheKey = `page:source-ownership:${id}`; + + const cached = await getCachedPage(cacheKey); + if (cached !== null) { + return reply.send(JSON.parse(cached)); + } + + let result; + try { + result = await getSourceOwnership(getPool(), id); + } catch (err) { + if (err instanceof SourceNotFoundError) { + return reply.code(404).send({ error: "not_found" }); + } + throw err; + } + + await setCachedPage(cacheKey, JSON.stringify(result), CACHE_TTL_SECONDS); + return reply.send(result); + }); +} + +// Curator write side: plain upsert, not an atomic-revision transaction like the three +// append-only rating tables — a correction overwrites in place (PRODUCT §5.8). +export async function ownershipCuratorRoutes(app: FastifyInstance): Promise { + app.post("/sources/:id/ownership", async (request, reply) => { + const { id } = request.params as { id: string }; + try { + const input = validateOwnershipInput(request.body); + const result = await submitOwnership(getPool(), id, input); + return reply.code(200).send(result); + } catch (err) { + if (err instanceof ValidationError) { + return reply.code(400).send({ error: err.code }); + } + if (err instanceof SourceNotFoundError) { + return reply.code(404).send({ error: "not_found" }); + } + throw err; + } + }); +} diff --git a/apps/api/src/features/ownership/service.ts b/apps/api/src/features/ownership/service.ts new file mode 100644 index 0000000..93d3e3e --- /dev/null +++ b/apps/api/src/features/ownership/service.ts @@ -0,0 +1,75 @@ +import type { Pool } from "pg"; + +import { fetchOwnership, sourceExists, upsertOwnership, type OwnershipInput, type SourceOwnershipRow } from "./repo.js"; + +export class SourceNotFoundError extends Error {} +export class ValidationError extends Error { + constructor(public code: string) { + super(code); + } +} + +const TRANSPARENCY_VALUES = new Set(["disclosed", "partially_disclosed", "undisclosed", "insufficient_evidence"]); +const INDEPENDENCE_VALUES = new Set(["no_documented_influence", "documented_instances", "insufficient_evidence"]); + +export interface OwnershipResponse { + sourceId: string; + ownershipTransparency: string; + editorialIndependence: string; + evidence: unknown; + ratedBy: string | null; + notes: string | null; + updatedAt: string; +} + +function toResponse(row: SourceOwnershipRow): OwnershipResponse { + return { + sourceId: row.source_id, + ownershipTransparency: row.ownership_transparency, + editorialIndependence: row.editorial_independence, + evidence: row.evidence, + ratedBy: row.rated_by, + notes: row.notes, + updatedAt: row.updated_at.toISOString(), + }; +} + +// Absence of a row = not yet researched (PRODUCT §5.8), same convention as source_ratings. +export async function getSourceOwnership(pool: Pool, sourceId: string): Promise { + if (!(await sourceExists(pool, sourceId))) throw new SourceNotFoundError(); + const row = await fetchOwnership(pool, sourceId); + return row === null ? null : toResponse(row); +} + +// §5.8: the two fields are independent and never derived from each other. source_ownership has +// no DB CHECK for the evidence requirement below (unlike the manual-curation CHECKs on the +// append-only rating tables), so it's enforced here instead. +export function validateOwnershipInput(body: unknown): OwnershipInput { + const b = (body ?? {}) as Record; + + if (typeof b.ownershipTransparency !== "string" || !TRANSPARENCY_VALUES.has(b.ownershipTransparency)) { + throw new ValidationError("invalid_ownership_transparency"); + } + if (typeof b.editorialIndependence !== "string" || !INDEPENDENCE_VALUES.has(b.editorialIndependence)) { + throw new ValidationError("invalid_editorial_independence"); + } + + const evidence = Array.isArray(b.evidence) ? b.evidence : undefined; + if (b.editorialIndependence === "documented_instances" && (evidence === undefined || evidence.length === 0)) { + throw new ValidationError("documented_instances_needs_evidence"); + } + + return { + ownershipTransparency: b.ownershipTransparency as OwnershipInput["ownershipTransparency"], + editorialIndependence: b.editorialIndependence as OwnershipInput["editorialIndependence"], + evidence, + ratedBy: typeof b.ratedBy === "string" ? b.ratedBy : null, + notes: typeof b.notes === "string" ? b.notes : null, + }; +} + +export async function submitOwnership(pool: Pool, sourceId: string, input: OwnershipInput): Promise { + if (!(await sourceExists(pool, sourceId))) throw new SourceNotFoundError(); + const row = await upsertOwnership(pool, sourceId, input); + return toResponse(row); +} diff --git a/apps/api/src/features/sensationalism-rating/repo.ts b/apps/api/src/features/sensationalism-rating/repo.ts new file mode 100644 index 0000000..c17751d --- /dev/null +++ b/apps/api/src/features/sensationalism-rating/repo.ts @@ -0,0 +1,96 @@ +import type { Pool, PoolClient } from "pg"; + +export interface SensationalismRatingRow { + id: string; + source_id: string; + level: "low" | "medium" | "high" | "insufficient_evidence"; + method: string; + method_version: string | null; + rated_by: string; + second_rater: string | null; + rubric_scores: unknown; + evidence: unknown; + review_due_at: Date | null; + source_ref: string | null; + notes: string | null; + rated_at: Date; + supersedes: string | null; + superseded_at: Date | null; +} + +const COLUMNS = `id, source_id, level, method, method_version, + rated_by, second_rater, rubric_scores, evidence, review_due_at, source_ref, notes, + rated_at, supersedes, superseded_at`; + +export async function sourceExists(pool: Pool, sourceId: string): Promise { + const result = await pool.query("SELECT 1 FROM sources WHERE id = $1", [sourceId]); + return result.rowCount !== null && result.rowCount > 0; +} + +export async function fetchCurrentRating(pool: Pool, sourceId: string): Promise { + const result = await pool.query( + `SELECT ${COLUMNS} FROM source_sensationalism_ratings WHERE source_id = $1 AND superseded_at IS NULL`, + [sourceId] + ); + return result.rows[0] ?? null; +} + +export async function fetchRatingHistory(pool: Pool, sourceId: string): Promise { + const result = await pool.query( + `SELECT ${COLUMNS} FROM source_sensationalism_ratings WHERE source_id = $1 ORDER BY rated_at DESC`, + [sourceId] + ); + return result.rows; +} + +export interface NewSensationalismRatingInput { + level: "low" | "medium" | "high" | "insufficient_evidence"; + method: string; + methodVersion: string | null; + ratedBy: string; + secondRater: string | null; + rubricScores: unknown; + evidence: unknown; + reviewDueAt: Date | null; + sourceRef: string | null; + notes: string | null; +} + +// Same atomic-revision shape as rating/repo.ts reviseRating (ARCHITECTURE §10): the old row +// leaves the "current" set before the new row enters it, inside one transaction with the +// source row locked, so idx_source_sens_ratings_one_current never sees two current rows. +export async function reviseRating( + client: PoolClient, + sourceId: string, + input: NewSensationalismRatingInput +): Promise { + await client.query("SELECT 1 FROM sources WHERE id = $1 FOR UPDATE", [sourceId]); + const old = await client.query<{ id: string }>( + "UPDATE source_sensationalism_ratings SET superseded_at = now() WHERE source_id = $1 AND superseded_at IS NULL RETURNING id", + [sourceId] + ); + const oldId = old.rows[0]?.id ?? null; + + const inserted = await client.query( + `INSERT INTO source_sensationalism_ratings ( + source_id, level, method, method_version, + rated_by, second_rater, rubric_scores, evidence, review_due_at, source_ref, notes, supersedes + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING ${COLUMNS}`, + [ + sourceId, + input.level, + input.method, + input.methodVersion, + input.ratedBy, + input.secondRater, + input.rubricScores === undefined ? null : JSON.stringify(input.rubricScores), + input.evidence === undefined ? null : JSON.stringify(input.evidence), + input.reviewDueAt, + input.sourceRef, + input.notes, + oldId, + ] + ); + return inserted.rows[0]; +} diff --git a/apps/api/src/features/sensationalism-rating/routes.ts b/apps/api/src/features/sensationalism-rating/routes.ts new file mode 100644 index 0000000..1e0a4fe --- /dev/null +++ b/apps/api/src/features/sensationalism-rating/routes.ts @@ -0,0 +1,59 @@ +import type { FastifyInstance } from "fastify"; + +import { getPool } from "../../db.js"; +import { getCachedPage, setCachedPage } from "../../page-cache.js"; +import { + getSourceSensationalismRatings, + SourceNotFoundError, + submitRating, + validateNewRating, + ValidationError, +} from "./service.js"; + +const CACHE_TTL_SECONDS = 60; + +// Public read side: current rating + history (PRODUCT §5.7, source directory). +export async function sensationalismPublicRoutes(app: FastifyInstance): Promise { + app.get("/sources/:id/sensationalism", async (request, reply) => { + const { id } = request.params as { id: string }; + const cacheKey = `page:source-sensationalism:${id}`; + + const cached = await getCachedPage(cacheKey); + if (cached !== null) { + return reply.send(JSON.parse(cached)); + } + + let result; + try { + result = await getSourceSensationalismRatings(getPool(), id); + } catch (err) { + if (err instanceof SourceNotFoundError) { + return reply.code(404).send({ error: "not_found" }); + } + throw err; + } + + await setCachedPage(cacheKey, JSON.stringify(result), CACHE_TTL_SECONDS); + return reply.send(result); + }); +} + +// Curator write side: atomic rating revision, same shape as rating/routes.ts. +export async function sensationalismCuratorRoutes(app: FastifyInstance): Promise { + app.post("/sources/:id/sensationalism", async (request, reply) => { + const { id } = request.params as { id: string }; + try { + const input = validateNewRating(request.body); + const result = await submitRating(getPool(), id, input); + return reply.code(201).send(result); + } catch (err) { + if (err instanceof ValidationError) { + return reply.code(400).send({ error: err.code }); + } + if (err instanceof SourceNotFoundError) { + return reply.code(404).send({ error: "not_found" }); + } + throw err; + } + }); +} diff --git a/apps/api/src/features/sensationalism-rating/service.ts b/apps/api/src/features/sensationalism-rating/service.ts new file mode 100644 index 0000000..a2fa498 --- /dev/null +++ b/apps/api/src/features/sensationalism-rating/service.ts @@ -0,0 +1,107 @@ +import type { Pool } from "pg"; + +import { withTransaction } from "../../db.js"; +import { + fetchCurrentRating, + fetchRatingHistory, + reviseRating, + sourceExists, + type NewSensationalismRatingInput, + type SensationalismRatingRow, +} from "./repo.js"; + +export class SourceNotFoundError extends Error {} +export class ValidationError extends Error { + constructor(public code: string) { + super(code); + } +} + +const LEVEL_VALUES = new Set(["low", "medium", "high", "insufficient_evidence"]); + +export interface SensationalismRatingResponse { + id: string; + sourceId: string; + level: string; + method: string; + ratedBy: string; + ratedAt: string; + supersedes: string | null; + supersededAt: string | null; +} + +function toResponse(row: SensationalismRatingRow): SensationalismRatingResponse { + return { + id: row.id, + sourceId: row.source_id, + level: row.level, + method: row.method, + ratedBy: row.rated_by, + ratedAt: row.rated_at.toISOString(), + supersedes: row.supersedes, + supersededAt: row.superseded_at?.toISOString() ?? null, + }; +} + +export async function getSourceSensationalismRatings( + pool: Pool, + sourceId: string +): Promise<{ current: SensationalismRatingResponse | null; history: SensationalismRatingResponse[] }> { + if (!(await sourceExists(pool, sourceId))) throw new SourceNotFoundError(); + const [current, history] = await Promise.all([ + fetchCurrentRating(pool, sourceId), + fetchRatingHistory(pool, sourceId), + ]); + return { + current: current === null ? null : toResponse(current), + history: history.map(toResponse), + }; +} + +// §4 schema CHECKs (0004_source_assessment_tables.sql), validated here first so a bad +// submission gets a clean 400 instead of a raw Postgres constraint error — mirrors +// rating/service.ts validateNewRating. +export function validateNewRating(body: unknown): NewSensationalismRatingInput { + const b = (body ?? {}) as Record; + + if (typeof b.level !== "string" || !LEVEL_VALUES.has(b.level)) { + throw new ValidationError("invalid_level"); + } + if (typeof b.method !== "string" || b.method.length === 0) throw new ValidationError("invalid_method"); + if (typeof b.ratedBy !== "string" || b.ratedBy.length === 0) throw new ValidationError("invalid_rated_by"); + + const secondRater = typeof b.secondRater === "string" ? b.secondRater : null; + const evidence = Array.isArray(b.evidence) ? b.evidence : undefined; + const rubricScores = typeof b.rubricScores === "object" && b.rubricScores !== null ? b.rubricScores : undefined; + + // sens_manual_needs_two_raters_and_evidence (§4): enforced here too, so curator tooling + // gets a readable 400 instead of the DB's raw constraint-violation error. + if (b.method === "manual-curation") { + if (secondRater === null || secondRater === b.ratedBy) throw new ValidationError("manual_needs_second_rater"); + if (rubricScores === undefined) throw new ValidationError("manual_needs_rubric_scores"); + if (evidence === undefined || evidence.length < 3) throw new ValidationError("manual_needs_evidence"); + } + + return { + level: b.level as "low" | "medium" | "high" | "insufficient_evidence", + method: b.method, + methodVersion: typeof b.methodVersion === "string" ? b.methodVersion : null, + ratedBy: b.ratedBy, + secondRater, + rubricScores, + evidence, + reviewDueAt: typeof b.reviewDueAt === "string" ? new Date(b.reviewDueAt) : null, + sourceRef: typeof b.sourceRef === "string" ? b.sourceRef : null, + notes: typeof b.notes === "string" ? b.notes : null, + }; +} + +export async function submitRating( + pool: Pool, + sourceId: string, + input: NewSensationalismRatingInput +): Promise { + if (!(await sourceExists(pool, sourceId))) throw new SourceNotFoundError(); + const row = await withTransaction(pool, (client) => reviseRating(client, sourceId, input)); + return toResponse(row); +} diff --git a/apps/api/src/features/source-category/repo.ts b/apps/api/src/features/source-category/repo.ts new file mode 100644 index 0000000..892188d --- /dev/null +++ b/apps/api/src/features/source-category/repo.ts @@ -0,0 +1,36 @@ +import type { Pool } from "pg"; + +export const CATEGORIES = ["national", "regional", "language", "business", "geopolitical", "specialist"] as const; +export type Category = (typeof CATEGORIES)[number]; + +export async function sourceExists(pool: Pool, sourceId: string): Promise { + const result = await pool.query("SELECT 1 FROM sources WHERE id = $1", [sourceId]); + return result.rowCount !== null && result.rowCount > 0; +} + +export async function fetchCategories(pool: Pool, sourceId: string): Promise { + const result = await pool.query<{ category: Category }>( + "SELECT category FROM source_categories WHERE source_id = $1 ORDER BY category", + [sourceId] + ); + return result.rows.map((r) => r.category); +} + +// Curator assigns the whole set at once (PRODUCT §5.9: a descriptive tag, not a provenance +// record) — delete-then-insert in one transaction, not a per-category add/remove API. +export async function replaceCategories(pool: Pool, sourceId: string, categories: Category[]): Promise { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query("DELETE FROM source_categories WHERE source_id = $1", [sourceId]); + for (const category of categories) { + await client.query("INSERT INTO source_categories (source_id, category) VALUES ($1, $2)", [sourceId, category]); + } + await client.query("COMMIT"); + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } +} diff --git a/apps/api/src/features/source-category/routes.ts b/apps/api/src/features/source-category/routes.ts new file mode 100644 index 0000000..5f544d8 --- /dev/null +++ b/apps/api/src/features/source-category/routes.ts @@ -0,0 +1,25 @@ +import type { FastifyInstance } from "fastify"; + +import { getPool } from "../../db.js"; +import { setSourceCategories, SourceNotFoundError, validateCategories, ValidationError } from "./service.js"; + +// Curator write side: whole-set PUT (PRODUCT §5.9) — a descriptive tag, not a provenance +// record, so there's no revision history to preserve like the rating tables. +export async function sourceCategoryCuratorRoutes(app: FastifyInstance): Promise { + app.put("/sources/:id/categories", async (request, reply) => { + const { id } = request.params as { id: string }; + try { + const categories = validateCategories(request.body); + const result = await setSourceCategories(getPool(), id, categories); + return reply.code(200).send({ categories: result }); + } catch (err) { + if (err instanceof ValidationError) { + return reply.code(400).send({ error: err.code }); + } + if (err instanceof SourceNotFoundError) { + return reply.code(404).send({ error: "not_found" }); + } + throw err; + } + }); +} diff --git a/apps/api/src/features/source-category/service.ts b/apps/api/src/features/source-category/service.ts new file mode 100644 index 0000000..64efa90 --- /dev/null +++ b/apps/api/src/features/source-category/service.ts @@ -0,0 +1,35 @@ +import type { Pool } from "pg"; + +import { CATEGORIES, fetchCategories, replaceCategories, sourceExists, type Category } from "./repo.js"; + +export class SourceNotFoundError extends Error {} +export class ValidationError extends Error { + constructor(public code: string) { + super(code); + } +} + +const CATEGORY_SET: ReadonlySet = new Set(CATEGORIES); + +export function getSourceCategories(pool: Pool, sourceId: string): Promise { + return fetchCategories(pool, sourceId); +} + +// PRODUCT §5.9: 0-6 categories, fixed list, curator judgment — dedup rather than reject +// duplicates, since the whole-set PUT shape makes an accidental repeat easy to send. +export function validateCategories(body: unknown): Category[] { + const b = (body ?? {}) as Record; + if (!Array.isArray(b.categories)) throw new ValidationError("categories_must_be_array"); + const categories = new Set(); + for (const value of b.categories) { + if (typeof value !== "string" || !CATEGORY_SET.has(value)) throw new ValidationError("invalid_category"); + categories.add(value as Category); + } + return [...categories]; +} + +export async function setSourceCategories(pool: Pool, sourceId: string, categories: Category[]): Promise { + if (!(await sourceExists(pool, sourceId))) throw new SourceNotFoundError(); + await replaceCategories(pool, sourceId, categories); + return categories; +} diff --git a/apps/api/src/features/source/repo.ts b/apps/api/src/features/source/repo.ts index 532bc4b..a7f3d54 100644 --- a/apps/api/src/features/source/repo.ts +++ b/apps/api/src/features/source/repo.ts @@ -16,3 +16,89 @@ export async function fetchSource(pool: Pool, sourceId: string): Promise { + const where: string[] = ["s.status = 'active'"]; + const params: unknown[] = []; + + if (filters.category !== null) { + params.push(filters.category); + where.push(`EXISTS (SELECT 1 FROM source_categories sc WHERE sc.source_id = s.id AND sc.category = $${params.length})`); + } + if (filters.country !== null) { + params.push(filters.country); + where.push(`s.country_code = $${params.length}`); + } + if (filters.language !== null) { + params.push(filters.language); + where.push(`s.language = $${params.length}`); + } + if (filters.q !== null) { + params.push(`%${filters.q}%`); + where.push(`s.name ILIKE $${params.length}`); + } + const whereClause = where.join(" AND "); + + const countResult = await pool.query<{ total: string }>(`SELECT count(*) AS total FROM sources s WHERE ${whereClause}`, params); + + params.push(filters.limit, filters.offset); + const itemsResult = await pool.query( + `SELECT s.id, s.name, s.domain, s.country_code, s.coverage_region, s.language, + coalesce(array_agg(sc.category) FILTER (WHERE sc.category IS NOT NULL), '{}') AS categories + FROM sources s + LEFT JOIN source_categories sc ON sc.source_id = s.id + WHERE ${whereClause} + GROUP BY s.id + ORDER BY s.name + LIMIT $${params.length - 1} OFFSET $${params.length}`, + params + ); + + return { items: itemsResult.rows, total: Number(countResult.rows[0].total) }; +} + +// Site-wide counterpart to feed/repo.ts's fetchBiasBar — same coalesce-to-not_rated, +// same IN-spectrum, current-rating filter — but over every active source, not one story's. +export async function fetchSourceDistribution(pool: Pool): Promise { + const result = await pool.query<{ category: BiasCategory; total: string }>( + `SELECT coalesce(r.bias_rating, 'not_rated') AS category, + count(DISTINCT s.id) AS total + FROM sources s + LEFT JOIN source_ratings r ON r.source_id = s.id AND r.superseded_at IS NULL AND r.spectrum = 'IN' + WHERE s.status = 'active' + GROUP BY 1` + ); + return result.rows.map((r) => ({ category: r.category, total: Number(r.total) })); +} diff --git a/apps/api/src/features/source/routes.ts b/apps/api/src/features/source/routes.ts index e298abd..97d81e3 100644 --- a/apps/api/src/features/source/routes.ts +++ b/apps/api/src/features/source/routes.ts @@ -2,11 +2,51 @@ import type { FastifyInstance } from "fastify"; import { getPool } from "../../db.js"; import { getCachedPage, setCachedPage } from "../../page-cache.js"; -import { getSource, SourceNotFoundError } from "./service.js"; +import { getSource, getSourceDistribution, listSources, SourceNotFoundError, ValidationError } from "./service.js"; const CACHE_TTL_SECONDS = 3600; // §10 Redis cache plan: "Source metadata, TTL 1h" export async function sourceRoutes(app: FastifyInstance): Promise { + // Structural filters + plain offset pagination (ARCHITECTURE §10 "Source directory + // list/search") — the registry is curated/slow-growing, same TTL rationale as other source + // metadata since categories/country/language change only on curator action. + app.get("/sources", async (request, reply) => { + const cacheKey = `page:source:list:${JSON.stringify(request.query)}`; + + const cached = await getCachedPage(cacheKey); + if (cached !== null) { + return reply.send(JSON.parse(cached)); + } + + let result; + try { + result = await listSources(getPool(), request.query as Record); + } catch (err) { + if (err instanceof ValidationError) { + return reply.code(400).send({ error: err.code }); + } + throw err; + } + + await setCachedPage(cacheKey, JSON.stringify(result), CACHE_TTL_SECONDS); + return reply.send(result); + }); + + // Ratings change rarely (manual curation), so this tolerates the same TTL as source metadata. + app.get("/sources/distribution", async (_request, reply) => { + const cacheKey = "page:source:distribution"; + + const cached = await getCachedPage(cacheKey); + if (cached !== null) { + return reply.send(JSON.parse(cached)); + } + + const result = await getSourceDistribution(getPool()); + + await setCachedPage(cacheKey, JSON.stringify(result), CACHE_TTL_SECONDS); + return reply.send(result); + }); + app.get("/sources/:id", async (request, reply) => { const { id } = request.params as { id: string }; const cacheKey = `page:source:${id}`; diff --git a/apps/api/src/features/source/service.ts b/apps/api/src/features/source/service.ts index 5f40edc..f021922 100644 --- a/apps/api/src/features/source/service.ts +++ b/apps/api/src/features/source/service.ts @@ -1,8 +1,16 @@ import type { Pool } from "pg"; -import { fetchSource } from "./repo.js"; +import { fetchSource, fetchSourceDistribution, fetchSourceList, type SourceDistributionRow, type SourceListRow } from "./repo.js"; +import { CATEGORIES } from "../source-category/repo.js"; + +const CATEGORY_SET: ReadonlySet = new Set(CATEGORIES); export class SourceNotFoundError extends Error {} +export class ValidationError extends Error { + constructor(public code: string) { + super(code); + } +} export interface SourceResponse { id: string; @@ -28,3 +36,92 @@ export async function getSource(pool: Pool, sourceId: string): Promise { + const category = typeof query.category === "string" && query.category !== "" ? query.category : null; + if (category !== null && !CATEGORY_SET.has(category)) throw new ValidationError("invalid_category"); + + const country = typeof query.country === "string" && query.country !== "" ? query.country : null; + const language = typeof query.language === "string" && query.language !== "" ? query.language : null; + const q = typeof query.q === "string" && query.q !== "" ? query.q : null; + + const limitRaw = typeof query.limit === "string" ? Number(query.limit) : DEFAULT_LIMIT; + if (!Number.isInteger(limitRaw) || limitRaw < 1 || limitRaw > MAX_LIMIT) throw new ValidationError("invalid_limit"); + + const offsetRaw = typeof query.offset === "string" ? Number(query.offset) : 0; + if (!Number.isInteger(offsetRaw) || offsetRaw < 0) throw new ValidationError("invalid_offset"); + + const { items, total } = await fetchSourceList(pool, { category, country, language, q, limit: limitRaw, offset: offsetRaw }); + return { items: items.map(toListItem), total, limit: limitRaw, offset: offsetRaw }; +} + +const CATEGORY_KEY: Record = { + left: "left", + "center-left": "centerLeft", + center: "center", + "center-right": "centerRight", + right: "right", + not_rated: "notRated", +}; + +// Replaces the static color-key legend on the home feed with the real site-wide spread +// (PRODUCT §3.5: counts, never a single left/right score — same rule as the per-story bar, +// just aggregated over every currently active, currently rated source instead of one story). +export async function getSourceDistribution(pool: Pool): Promise { + const rows = await fetchSourceDistribution(pool); + const out: SourceDistributionResponse = { left: 0, centerLeft: 0, center: 0, centerRight: 0, right: 0, notRated: 0 }; + for (const row of rows) out[CATEGORY_KEY[row.category]] = row.total; + return out; +} diff --git a/apps/api/src/features/story/routes.ts b/apps/api/src/features/story/routes.ts index 5257f86..7c8e1fd 100644 --- a/apps/api/src/features/story/routes.ts +++ b/apps/api/src/features/story/routes.ts @@ -1,8 +1,10 @@ import type { FastifyInstance } from "fastify"; import { getPool } from "../../db.js"; +import { findActiveSubscriptionForUser } from "../billing/repo.js"; +import { isEntitled } from "../billing/entitlement.js"; import { getCachedPage, setCachedPage } from "../../page-cache.js"; -import { getStoryDetail, StoryNotFoundError } from "./service.js"; +import { applyFreeTierCap, getStoryDetail, StoryNotFoundError } from "./service.js"; const CACHE_TTL_SECONDS = 60; @@ -11,6 +13,9 @@ export async function storyRoutes(app: FastifyInstance): Promise { const { id } = request.params as { id: string }; const cacheKey = `page:story:${id}`; + // Shared cache, no user identity (ARCHITECTURE §10) — this must always be the SAME body + // for every caller, so it's always the free-tier-capped view. A signed-in, entitled + // reader gets the rest via the authenticated route below, never from this cache. const cached = await getCachedPage(cacheKey); if (cached !== null) { return reply.send(JSON.parse(cached)); @@ -18,7 +23,7 @@ export async function storyRoutes(app: FastifyInstance): Promise { let result; try { - result = await getStoryDetail(getPool(), id); + result = await applyFreeTierCap(await getStoryDetail(getPool(), id)); } catch (err) { if (err instanceof StoryNotFoundError) { return reply.code(404).send({ error: "not_found" }); @@ -30,3 +35,20 @@ export async function storyRoutes(app: FastifyInstance): Promise { return reply.send(result); }); } + +// /api/stories/:id/full-breakdown — authenticated, uncached (per-user), entitlement-gated. +// Mounted in the /api/* scope (app.ts), after authPlugin sets request.userId. +export async function storyPremiumRoutes(app: FastifyInstance): Promise { + app.get("/stories/:id/full-breakdown", async (request, reply) => { + const { id } = request.params as { id: string }; + const sub = await findActiveSubscriptionForUser(getPool(), request.userId as string); + if (!isEntitled(sub)) return reply.code(403).send({ error: "subscription_required" }); + + try { + return reply.send(await getStoryDetail(getPool(), id)); + } catch (err) { + if (err instanceof StoryNotFoundError) return reply.code(404).send({ error: "not_found" }); + throw err; + } + }); +} diff --git a/apps/api/src/features/story/service.ts b/apps/api/src/features/story/service.ts index 556602e..8395bdf 100644 --- a/apps/api/src/features/story/service.ts +++ b/apps/api/src/features/story/service.ts @@ -31,6 +31,27 @@ export interface StoryDetailResponse { ratedSourceCount: number; unratedSourceCount: number; biasColumns: BiasColumns; + // Reader subscription gate (PAYPAL_SPEC.md): this aggregator links out to publishers rather + // than hosting full article bodies, so there's no article text to paywall — the premium + // feature gated instead is the full cross-source bias breakdown. `GET /api/public/stories/:id` + // (shared cache, no user identity per ARCHITECTURE §10) always returns this capped; the + // entitled-only full list is `GET /api/stories/:id/full-breakdown` (authenticated, uncached). + premiumBreakdown: { locked: boolean; hiddenCardCount: number }; +} + +// Free-tier cap per bias column (PAYPAL_SPEC §4.2 FR-P3 teaser analog). Tunable post-launch, +// same complexity-budget footing as FREE_PREMIUM_READS_PER_MONTH. +export const FREE_SOURCE_CARDS_PER_CATEGORY = 2; + +export function applyFreeTierCap(detail: StoryDetailResponse): StoryDetailResponse { + let hiddenCardCount = 0; + const capped: BiasColumns = { left: [], centerLeft: [], center: [], centerRight: [], right: [], notRated: [] }; + for (const key of Object.keys(detail.biasColumns) as (keyof BiasColumns)[]) { + const cards = detail.biasColumns[key]; + capped[key] = cards.slice(0, FREE_SOURCE_CARDS_PER_CATEGORY); + hiddenCardCount += Math.max(0, cards.length - FREE_SOURCE_CARDS_PER_CATEGORY); + } + return { ...detail, biasColumns: capped, premiumBreakdown: { locked: hiddenCardCount > 0, hiddenCardCount } }; } const CATEGORY_KEY: Record = { @@ -109,6 +130,7 @@ export async function getStoryDetail(pool: Pool, storyId: string): Promise { + app.addHook("onRequest", async (request, reply) => { + if (request.userId === undefined || !(await isUserAdmin(getPool(), request.userId))) { + reply.code(403).send({ error: "forbidden" }); + return reply; + } + }); +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index dd51210..335f724 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -1,4 +1,6 @@ import { buildApp } from "./app.js"; +import { getPool } from "./db.js"; +import { startBillingScheduler } from "./features/billing/lifecycle.js"; const app = buildApp(); const port = Number(process.env.PORT ?? 3000); @@ -7,3 +9,5 @@ app.listen({ port, host: "0.0.0.0" }).catch((err) => { app.log.error(err); process.exit(1); }); + +startBillingScheduler(getPool()); diff --git a/apps/api/test/billing-entitlement.test.ts b/apps/api/test/billing-entitlement.test.ts new file mode 100644 index 0000000..61a61d5 --- /dev/null +++ b/apps/api/test/billing-entitlement.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { hasFreeReadsRemaining, isEntitled } from "../src/features/billing/entitlement.js"; + +const NOW = new Date("2026-06-15T00:00:00Z"); +const PAST = new Date("2026-06-01T00:00:00Z"); +const FUTURE = new Date("2026-07-01T00:00:00Z"); + +// PAYPAL_SPEC.md §6 entitlement rule, exhaustive over every status x grace/period-end +// combination — the highest-value test in this feature (CLAUDE.md: unit tests mirror the +// doc's pure logic exactly). +describe("isEntitled", () => { + it("is false for no subscription", () => { + expect(isEntitled(null, NOW)).toBe(false); + }); + + it.each(["incomplete", "trialing", "expired"] as const)("is false for status=%s regardless of dates", (status) => { + expect(isEntitled({ status, grace_until: FUTURE, current_period_end: FUTURE }, NOW)).toBe(false); + }); + + it("is true for active, regardless of grace/period fields", () => { + expect(isEntitled({ status: "active" }, NOW)).toBe(true); + expect(isEntitled({ status: "active", grace_until: PAST, current_period_end: PAST }, NOW)).toBe(true); + }); + + it("past_due: entitled only while now < grace_until", () => { + expect(isEntitled({ status: "past_due", grace_until: FUTURE }, NOW)).toBe(true); + expect(isEntitled({ status: "past_due", grace_until: PAST }, NOW)).toBe(false); + expect(isEntitled({ status: "past_due", grace_until: null }, NOW)).toBe(false); + expect(isEntitled({ status: "past_due" }, NOW)).toBe(false); + }); + + it("canceled: entitled only while now < current_period_end", () => { + expect(isEntitled({ status: "canceled", current_period_end: FUTURE }, NOW)).toBe(true); + expect(isEntitled({ status: "canceled", current_period_end: PAST }, NOW)).toBe(false); + expect(isEntitled({ status: "canceled", current_period_end: null }, NOW)).toBe(false); + expect(isEntitled({ status: "canceled" }, NOW)).toBe(false); + }); + + it("boundary: exactly at grace_until/current_period_end is NOT entitled (strict <)", () => { + expect(isEntitled({ status: "past_due", grace_until: NOW }, NOW)).toBe(false); + expect(isEntitled({ status: "canceled", current_period_end: NOW }, NOW)).toBe(false); + }); +}); + +describe("hasFreeReadsRemaining", () => { + it.each([ + [0, true], + [1, true], + [2, true], + [3, false], + [4, false], + ])("views=%i -> %s", (views, expected) => { + expect(hasFreeReadsRemaining(views)).toBe(expected); + }); +}); diff --git a/apps/api/test/billing.test.ts b/apps/api/test/billing.test.ts new file mode 100644 index 0000000..04b4ddb --- /dev/null +++ b/apps/api/test/billing.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { buildApp, WEB_ORIGIN } from "../src/app.js"; +import { signAccessToken } from "../src/features/auth/jwt.js"; +import { cleanupTestUser, createUser } from "./helpers.js"; + +const EMAIL = "billing-test@mail.example"; + +beforeEach(() => cleanupTestUser(EMAIL)); +afterEach(() => cleanupTestUser(EMAIL)); + +function withAuth(token: string) { + return { authorization: `Bearer ${token}`, origin: WEB_ORIGIN, "content-type": "application/json" }; +} + +describe("/api/public/plans", () => { + it("lists the two seeded plans without auth", async () => { + const app = buildApp(); + const res = await app.inject({ method: "GET", url: "/api/public/plans" }); + expect(res.statusCode).toBe(200); + const codes = res.json().items.map((p: { code: string }) => p.code).sort(); + expect(codes).toEqual(["annual_360", "monthly_60"]); + await app.close(); + }); +}); + +describe("/api/billing/*", () => { + it("rejects an unauthenticated checkout", async () => { + const app = buildApp(); + const res = await app.inject({ + method: "POST", + url: "/api/billing/checkout", + headers: { origin: WEB_ORIGIN, "content-type": "application/json" }, + payload: { planCode: "monthly_60" }, + }); + expect(res.statusCode).toBe(401); + await app.close(); + }); + + it("rejects checkout for a plan not yet provisioned at the gateway", async () => { + const userId = await createUser(EMAIL, "a-strong-passphrase"); + const token = signAccessToken(userId, 1); + const app = buildApp(); + // Migration seeds plans with gateway_plan_id = NULL until a provisioning step fills it in. + const res = await app.inject({ + method: "POST", + url: "/api/billing/checkout", + headers: withAuth(token), + payload: { planCode: "monthly_60" }, + }); + expect(res.statusCode).toBe(409); + expect(res.json().error).toBe("plan_not_provisioned"); + await app.close(); + }); + + it("rejects checkout for an unknown plan code", async () => { + const userId = await createUser(EMAIL, "a-strong-passphrase"); + const token = signAccessToken(userId, 1); + const app = buildApp(); + const res = await app.inject({ + method: "POST", + url: "/api/billing/checkout", + headers: withAuth(token), + payload: { planCode: "nonexistent" }, + }); + expect(res.statusCode).toBe(409); + expect(res.json().error).toBe("unknown_plan"); + await app.close(); + }); + + it("reports no subscription for a fresh user", async () => { + const userId = await createUser(EMAIL, "a-strong-passphrase"); + const token = signAccessToken(userId, 1); + const app = buildApp(); + const res = await app.inject({ method: "GET", url: "/api/billing/subscription", headers: withAuth(token) }); + expect(res.json()).toEqual({ status: "none", entitled: false, planCode: null, currentPeriodEnd: null, cancelAtPeriodEnd: false }); + await app.close(); + }); + + it("404s canceling when there's no subscription", async () => { + const userId = await createUser(EMAIL, "a-strong-passphrase"); + const token = signAccessToken(userId, 1); + const app = buildApp(); + const res = await app.inject({ method: "POST", url: "/api/billing/cancel", headers: withAuth(token), payload: {} }); + expect(res.statusCode).toBe(404); + await app.close(); + }); +}); + +describe("POST /webhooks/paypal", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("rejects a payload with an invalid signature", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ verification_status: "FAILURE" }), { status: 200 })) + ); + const app = buildApp(); + const res = await app.inject({ + method: "POST", + url: "/webhooks/paypal", + headers: { "content-type": "application/json" }, + payload: { id: "evt_1", event_type: "BILLING.SUBSCRIPTION.ACTIVATED", resource: {} }, + }); + expect(res.statusCode).toBe(400); + await app.close(); + }); + + it("is exempt from the Origin/CSRF checks applied to other mutating routes", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ verification_status: "FAILURE" }), { status: 200 })) + ); + const app = buildApp(); + // No Origin header at all — a real /api/* POST would get 403 origin_mismatch here. + const res = await app.inject({ + method: "POST", + url: "/webhooks/paypal", + headers: { "content-type": "application/json" }, + payload: { id: "evt_2", event_type: "BILLING.SUBSCRIPTION.ACTIVATED", resource: {} }, + }); + expect(res.statusCode).not.toBe(403); + await app.close(); + }); +}); diff --git a/apps/api/test/curator.test.ts b/apps/api/test/curator.test.ts index 15eafb3..5f0c88c 100644 --- a/apps/api/test/curator.test.ts +++ b/apps/api/test/curator.test.ts @@ -15,6 +15,14 @@ function post(app: ReturnType, path: string, body: unknown) { }); } +function get(app: ReturnType, path: string) { + return app.inject({ + method: "GET", + url: `/api/curator${path}`, + headers: { "x-curator-key": CURATOR_KEY }, + }); +} + describe("curator auth", () => { it("rejects every curator route without the key", async () => { const app = buildApp(); @@ -193,3 +201,78 @@ describe("POST /api/curator/cluster-reports", () => { await app.close(); }); }); + +describe("GET /api/curator/cluster-reports", () => { + it("rejects without the curator key", async () => { + const app = buildApp(); + const res = await app.inject({ method: "GET", url: "/api/curator/cluster-reports" }); + expect(res.statusCode).toBe(401); + await app.close(); + }); + + it("lists a filed report with the story/article titles resolved", async () => { + const sourceId = await createTestSource("curator-cluster-list"); + const { storyId, headlineArticleId } = await createTestStory(sourceId); + const app = buildApp(); + + const filed = await post(app, "/cluster-reports", { + kind: "wrong_merge", + storyId, + articleId: headlineArticleId, + reportedBy: "curator-list-1", + }); + const { id } = filed.json(); + + const res = await get(app, "/cluster-reports"); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.limit).toBe(50); + expect(body.offset).toBe(0); + const item = body.items.find((r: { id: string }) => r.id === id); + expect(item).toMatchObject({ + kind: "wrong_merge", + storyId, + articleId: headlineArticleId, + reportedBy: "curator-list-1", + resolvedAt: null, + }); + expect(typeof item.storyTitle).toBe("string"); + expect(typeof item.articleTitle).toBe("string"); + await app.close(); + }); + + it("filters by resolved=false, excluding nothing since nothing resolves reports yet", async () => { + const sourceId = await createTestSource("curator-cluster-list-unresolved"); + const { storyId, headlineArticleId } = await createTestStory(sourceId); + const app = buildApp(); + + const filed = await post(app, "/cluster-reports", { + kind: "wrong_merge", + storyId, + articleId: headlineArticleId, + reportedBy: "curator-list-2", + }); + const { id } = filed.json(); + + const res = await get(app, "/cluster-reports?resolved=false"); + expect(res.statusCode).toBe(200); + expect(res.json().items.some((r: { id: string }) => r.id === id)).toBe(true); + await app.close(); + }); + + it("rejects an invalid resolved value", async () => { + const app = buildApp(); + const res = await get(app, "/cluster-reports?resolved=maybe"); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "invalid_resolved" }); + await app.close(); + }); + + it("rejects an out-of-range limit", async () => { + const app = buildApp(); + const res = await get(app, "/cluster-reports?limit=1000"); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "invalid_limit" }); + await app.close(); + }); +}); diff --git a/apps/api/test/geo-rating.test.ts b/apps/api/test/geo-rating.test.ts new file mode 100644 index 0000000..125f14f --- /dev/null +++ b/apps/api/test/geo-rating.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; + +import { buildApp } from "../src/app.js"; +import { createTestSource } from "./feed-helpers.js"; +import { delTestCache } from "./redis-helpers.js"; + +const CURATOR_KEY = "dev-insecure-curator-key"; + +function postRating( + app: ReturnType, + sourceId: string, + topicCode: string, + body: unknown, + headers: Record = {} +) { + return app.inject({ + method: "POST", + url: `/api/curator/sources/${sourceId}/geo/${topicCode}`, + headers: { "content-type": "application/json", "x-curator-key": CURATOR_KEY, ...headers }, + payload: body as object, + }); +} + +describe("GET /api/public/sources/:id/geo", () => { + it("returns all 8 topics, unrated, for a fresh source", async () => { + const sourceId = await createTestSource("geo-unrated"); + const app = buildApp(); + const res = await app.inject({ method: "GET", url: `/api/public/sources/${sourceId}/geo` }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(Object.keys(body)).toHaveLength(8); + expect(body.IN_PAK).toEqual({ current: null, history: [] }); + expect(body.INDOPAC).toEqual({ current: null, history: [] }); + await app.close(); + }); + + it("returns 404 for a nonexistent source", async () => { + const app = buildApp(); + const res = await app.inject({ + method: "GET", + url: "/api/public/sources/00000000-0000-0000-0000-000000000000/geo", + }); + expect(res.statusCode).toBe(404); + await app.close(); + }); +}); + +describe("POST /api/curator/sources/:id/geo/:topicCode", () => { + it("rejects a request without the curator key", async () => { + const sourceId = await createTestSource("geo-no-key"); + const app = buildApp(); + const res = await postRating(app, sourceId, "IN_PAK", { stance: "leans_minus", method: "internal-classifier-v1", ratedBy: "x" }, { + "x-curator-key": "wrong", + }); + expect(res.statusCode).toBe(401); + await app.close(); + }); + + it("rejects an invalid topic code", async () => { + const sourceId = await createTestSource("geo-bad-topic"); + const app = buildApp(); + const res = await postRating(app, sourceId, "IN_BRAZIL", { + stance: "leans_minus", + method: "internal-classifier-v1", + ratedBy: "curator-1", + }); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "invalid_topic_code" }); + await app.close(); + }); + + it("rejects an invalid stance", async () => { + const sourceId = await createTestSource("geo-bad-stance"); + const app = buildApp(); + const res = await postRating(app, sourceId, "IN_PAK", { stance: "neutral", method: "internal-classifier-v1", ratedBy: "x" }); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "invalid_stance" }); + await app.close(); + }); + + it("creates a rating for one topic, then supersedes it atomically on revision, leaving other topics untouched", async () => { + const sourceId = await createTestSource("geo-revision"); + const app = buildApp(); + + const first = await postRating(app, sourceId, "IN_PAK", { + stance: "leans_minus", + method: "internal-classifier-v1", + ratedBy: "curator-1", + }); + expect(first.statusCode).toBe(201); + const firstId = first.json().id; + + await delTestCache(`page:source-geo:${sourceId}`); + const second = await postRating(app, sourceId, "IN_PAK", { + stance: "strongly_minus", + method: "internal-classifier-v1", + ratedBy: "curator-1", + }); + expect(second.statusCode).toBe(201); + expect(second.json().supersedes).toBe(firstId); + + const getRes = await app.inject({ method: "GET", url: `/api/public/sources/${sourceId}/geo` }); + const body = getRes.json(); + expect(body.IN_PAK.current.id).toBe(second.json().id); + expect(body.IN_PAK.current.stance).toBe("strongly_minus"); + expect(body.IN_PAK.history).toHaveLength(2); + expect(body.IN_CHN).toEqual({ current: null, history: [] }); + await app.close(); + }); + + it("rejects manual-curation without a second rater", async () => { + const sourceId = await createTestSource("geo-manual-missing"); + const app = buildApp(); + const res = await postRating(app, sourceId, "DEFSEC", { + stance: "mixed_or_issue_dependent", + method: "manual-curation", + ratedBy: "curator-1", + }); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "manual_needs_second_rater" }); + await app.close(); + }); + + it("accepts manual-curation with two raters, rubric scores, and 3+ evidence entries", async () => { + const sourceId = await createTestSource("geo-manual-ok"); + const app = buildApp(); + const res = await postRating(app, sourceId, "MULTILAT", { + stance: "leans_plus", + method: "manual-curation", + ratedBy: "curator-1", + secondRater: "curator-2", + rubricScores: { g1: 1 }, + evidence: [{ url: "a" }, { url: "b" }, { url: "c" }], + }); + expect(res.statusCode).toBe(201); + await app.close(); + }); + + it("returns 404 for a nonexistent source", async () => { + const app = buildApp(); + const res = await postRating(app, "00000000-0000-0000-0000-000000000000", "IN_PAK", { + stance: "leans_minus", + method: "internal-classifier-v1", + ratedBy: "curator-1", + }); + expect(res.statusCode).toBe(404); + await app.close(); + }); +}); diff --git a/apps/api/test/ownership.test.ts b/apps/api/test/ownership.test.ts new file mode 100644 index 0000000..f78a2e2 --- /dev/null +++ b/apps/api/test/ownership.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; + +import { buildApp } from "../src/app.js"; +import { createTestSource } from "./feed-helpers.js"; +import { delTestCache } from "./redis-helpers.js"; + +const CURATOR_KEY = "dev-insecure-curator-key"; + +function postOwnership(app: ReturnType, sourceId: string, body: unknown, headers: Record = {}) { + return app.inject({ + method: "POST", + url: `/api/curator/sources/${sourceId}/ownership`, + headers: { "content-type": "application/json", "x-curator-key": CURATOR_KEY, ...headers }, + payload: body as object, + }); +} + +describe("GET /api/public/sources/:id/ownership", () => { + it("returns null for a source never researched", async () => { + const sourceId = await createTestSource("ownership-unrated"); + const app = buildApp(); + const res = await app.inject({ method: "GET", url: `/api/public/sources/${sourceId}/ownership` }); + expect(res.statusCode).toBe(200); + expect(res.json()).toBeNull(); + await app.close(); + }); + + it("returns 404 for a nonexistent source", async () => { + const app = buildApp(); + const res = await app.inject({ + method: "GET", + url: "/api/public/sources/00000000-0000-0000-0000-000000000000/ownership", + }); + expect(res.statusCode).toBe(404); + await app.close(); + }); +}); + +describe("POST /api/curator/sources/:id/ownership", () => { + it("rejects a request without the curator key", async () => { + const sourceId = await createTestSource("ownership-no-key"); + const app = buildApp(); + const res = await postOwnership( + app, + sourceId, + { ownershipTransparency: "disclosed", editorialIndependence: "no_documented_influence" }, + { "x-curator-key": "wrong" } + ); + expect(res.statusCode).toBe(401); + await app.close(); + }); + + it("creates then overwrites the record in place, not superseding it", async () => { + const sourceId = await createTestSource("ownership-upsert"); + const app = buildApp(); + + const first = await postOwnership(app, sourceId, { + ownershipTransparency: "insufficient_evidence", + editorialIndependence: "insufficient_evidence", + }); + expect(first.statusCode).toBe(200); + + await delTestCache(`page:source-ownership:${sourceId}`); + const second = await postOwnership(app, sourceId, { + ownershipTransparency: "partially_disclosed", + editorialIndependence: "no_documented_influence", + ratedBy: "curator-1", + notes: "parent company confirmed via MCA filing", + }); + expect(second.statusCode).toBe(200); + expect(second.json()).toMatchObject({ + sourceId, + ownershipTransparency: "partially_disclosed", + editorialIndependence: "no_documented_influence", + ratedBy: "curator-1", + }); + + const getRes = await app.inject({ method: "GET", url: `/api/public/sources/${sourceId}/ownership` }); + expect(getRes.json().ownershipTransparency).toBe("partially_disclosed"); + await app.close(); + }); + + it("rejects an invalid ownership transparency value", async () => { + const sourceId = await createTestSource("ownership-invalid-transparency"); + const app = buildApp(); + const res = await postOwnership(app, sourceId, { + ownershipTransparency: "fully_owned_by_the_state", + editorialIndependence: "no_documented_influence", + }); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "invalid_ownership_transparency" }); + await app.close(); + }); + + it("rejects documented_instances without evidence", async () => { + const sourceId = await createTestSource("ownership-missing-evidence"); + const app = buildApp(); + const res = await postOwnership(app, sourceId, { + ownershipTransparency: "disclosed", + editorialIndependence: "documented_instances", + }); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "documented_instances_needs_evidence" }); + await app.close(); + }); + + it("accepts documented_instances with evidence", async () => { + const sourceId = await createTestSource("ownership-with-evidence"); + const app = buildApp(); + const res = await postOwnership(app, sourceId, { + ownershipTransparency: "disclosed", + editorialIndependence: "documented_instances", + evidence: [{ url: "https://example.com/court-judgment", archived: true, dimension: "editorial_influence" }], + }); + expect(res.statusCode).toBe(200); + await app.close(); + }); + + it("returns 404 for a nonexistent source", async () => { + const app = buildApp(); + const res = await postOwnership(app, "00000000-0000-0000-0000-000000000000", { + ownershipTransparency: "disclosed", + editorialIndependence: "no_documented_influence", + }); + expect(res.statusCode).toBe(404); + await app.close(); + }); +}); diff --git a/apps/api/test/sensationalism-rating.test.ts b/apps/api/test/sensationalism-rating.test.ts new file mode 100644 index 0000000..de4ee89 --- /dev/null +++ b/apps/api/test/sensationalism-rating.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; + +import { buildApp } from "../src/app.js"; +import { createTestSource } from "./feed-helpers.js"; +import { delTestCache } from "./redis-helpers.js"; + +const CURATOR_KEY = "dev-insecure-curator-key"; + +function postRating(app: ReturnType, sourceId: string, body: unknown, headers: Record = {}) { + return app.inject({ + method: "POST", + url: `/api/curator/sources/${sourceId}/sensationalism`, + headers: { "content-type": "application/json", "x-curator-key": CURATOR_KEY, ...headers }, + payload: body as object, + }); +} + +describe("GET /api/public/sources/:id/sensationalism", () => { + it("returns null current and empty history for an unrated source", async () => { + const sourceId = await createTestSource("sens-unrated"); + const app = buildApp(); + const res = await app.inject({ method: "GET", url: `/api/public/sources/${sourceId}/sensationalism` }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ current: null, history: [] }); + await app.close(); + }); + + it("returns 404 for a nonexistent source", async () => { + const app = buildApp(); + const res = await app.inject({ + method: "GET", + url: "/api/public/sources/00000000-0000-0000-0000-000000000000/sensationalism", + }); + expect(res.statusCode).toBe(404); + await app.close(); + }); +}); + +describe("POST /api/curator/sources/:id/sensationalism", () => { + it("rejects a request without the curator key", async () => { + const sourceId = await createTestSource("sens-no-key"); + const app = buildApp(); + const res = await postRating(app, sourceId, { level: "low", method: "internal-classifier-v1", ratedBy: "x" }, { + "x-curator-key": "wrong", + }); + expect(res.statusCode).toBe(401); + await app.close(); + }); + + it("creates a rating, then supersedes it atomically on revision", async () => { + const sourceId = await createTestSource("sens-revision"); + const app = buildApp(); + + const first = await postRating(app, sourceId, { + level: "low", + method: "internal-classifier-v1", + ratedBy: "curator-1", + }); + expect(first.statusCode).toBe(201); + const firstId = first.json().id; + + await delTestCache(`page:source-sensationalism:${sourceId}`); + const second = await postRating(app, sourceId, { + level: "high", + method: "internal-classifier-v1", + ratedBy: "curator-1", + }); + expect(second.statusCode).toBe(201); + expect(second.json().supersedes).toBe(firstId); + + const getRes = await app.inject({ method: "GET", url: `/api/public/sources/${sourceId}/sensationalism` }); + const body = getRes.json(); + expect(body.current.id).toBe(second.json().id); + expect(body.current.level).toBe("high"); + expect(body.history).toHaveLength(2); + expect(body.history.find((r: { id: string }) => r.id === firstId).supersededAt).not.toBeNull(); + await app.close(); + }); + + it("rejects an invalid level", async () => { + const sourceId = await createTestSource("sens-invalid-level"); + const app = buildApp(); + const res = await postRating(app, sourceId, { level: "extreme", method: "internal-classifier-v1", ratedBy: "x" }); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "invalid_level" }); + await app.close(); + }); + + it("rejects manual-curation without a second rater", async () => { + const sourceId = await createTestSource("sens-manual-missing"); + const app = buildApp(); + const res = await postRating(app, sourceId, { + level: "medium", + method: "manual-curation", + ratedBy: "curator-1", + }); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "manual_needs_second_rater" }); + await app.close(); + }); + + it("accepts manual-curation with two raters, rubric scores, and 3+ evidence entries", async () => { + const sourceId = await createTestSource("sens-manual-ok"); + const app = buildApp(); + const res = await postRating(app, sourceId, { + level: "medium", + method: "manual-curation", + ratedBy: "curator-1", + secondRater: "curator-2", + rubricScores: { s1: 1, s2: 2, s3: 0 }, + evidence: [{ url: "a" }, { url: "b" }, { url: "c" }], + }); + expect(res.statusCode).toBe(201); + await app.close(); + }); + + it("returns 404 for a nonexistent source", async () => { + const app = buildApp(); + const res = await postRating(app, "00000000-0000-0000-0000-000000000000", { + level: "low", + method: "internal-classifier-v1", + ratedBy: "curator-1", + }); + expect(res.statusCode).toBe(404); + await app.close(); + }); +}); diff --git a/apps/api/test/source.test.ts b/apps/api/test/source.test.ts index 2fda926..071d5d6 100644 --- a/apps/api/test/source.test.ts +++ b/apps/api/test/source.test.ts @@ -1,9 +1,19 @@ import { describe, expect, it } from "vitest"; import { buildApp } from "../src/app.js"; +import { getPool } from "../src/db.js"; import { createTestSource } from "./feed-helpers.js"; import { delTestCache } from "./redis-helpers.js"; +const CURATOR_KEY = "dev-insecure-curator-key"; + +async function setCategories(sourceId: string, categories: string[]) { + await getPool().query("DELETE FROM source_categories WHERE source_id = $1", [sourceId]); + for (const category of categories) { + await getPool().query("INSERT INTO source_categories (source_id, category) VALUES ($1, $2)", [sourceId, category]); + } +} + describe("GET /api/public/sources/:id", () => { it("returns the source's display metadata", async () => { const sourceId = await createTestSource("source-meta"); @@ -32,3 +42,109 @@ describe("GET /api/public/sources/:id", () => { await app.close(); }); }); + +describe("GET /api/public/sources (list/search)", () => { + it("filters by category", async () => { + const label = `sources-list-category-${Date.now()}`; + const biz = await createTestSource(`${label}-biz`); + const national = await createTestSource(`${label}-national`); + await setCategories(biz, ["business"]); + await setCategories(national, ["national"]); + + const app = buildApp(); + const res = await app.inject({ method: "GET", url: `/api/public/sources?category=business&q=${label}` }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.items.map((i: { id: string }) => i.id)).toEqual([biz]); + expect(body.items[0].categories).toEqual(["business"]); + await app.close(); + }); + + it("filters by name substring", async () => { + const label = `sources-list-q-${Date.now()}`; + const match = await createTestSource(`${label}-match`); + await createTestSource(`unrelated-${Date.now()}`); + + const app = buildApp(); + const res = await app.inject({ method: "GET", url: `/api/public/sources?q=${label}` }); + const body = res.json(); + expect(body.items.map((i: { id: string }) => i.id)).toEqual([match]); + await app.close(); + }); + + it("rejects an unknown category", async () => { + const app = buildApp(); + const res = await app.inject({ method: "GET", url: "/api/public/sources?category=not_a_real_category" }); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "invalid_category" }); + await app.close(); + }); + + it("paginates with limit/offset and reports total", async () => { + const label = `sources-list-page-${Date.now()}`; + const first = await createTestSource(`${label}-a`); + const second = await createTestSource(`${label}-b`); + + const app = buildApp(); + const res = await app.inject({ method: "GET", url: `/api/public/sources?q=${label}&limit=1&offset=0` }); + const body = res.json(); + expect(body.total).toBe(2); + expect(body.items.length).toBe(1); + expect(body.items[0].id).toBe(first); + + const res2 = await app.inject({ method: "GET", url: `/api/public/sources?q=${label}&limit=1&offset=1` }); + expect(res2.json().items[0].id).toBe(second); + await app.close(); + }); +}); + +describe("PUT /api/curator/sources/:id/categories", () => { + function putCategories(app: ReturnType, sourceId: string, categories: string[], key = CURATOR_KEY) { + return app.inject({ + method: "PUT", + url: `/api/curator/sources/${sourceId}/categories`, + headers: { "content-type": "application/json", "x-curator-key": key }, + payload: { categories }, + }); + } + + it("rejects a request without the curator key", async () => { + const sourceId = await createTestSource("categories-no-key"); + const app = buildApp(); + const res = await putCategories(app, sourceId, ["national"], "wrong"); + expect(res.statusCode).toBe(401); + await app.close(); + }); + + it("rejects an invalid category", async () => { + const sourceId = await createTestSource("categories-invalid"); + const app = buildApp(); + const res = await putCategories(app, sourceId, ["not_a_real_category"]); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "invalid_category" }); + await app.close(); + }); + + it("sets then replaces the full category set, deduping", async () => { + const sourceId = await createTestSource("categories-replace"); + const app = buildApp(); + + const first = await putCategories(app, sourceId, ["national", "business", "national"]); + expect(first.statusCode).toBe(200); + expect(first.json().categories.sort()).toEqual(["business", "national"]); + + const second = await putCategories(app, sourceId, ["regional"]); + expect(second.json().categories).toEqual(["regional"]); + + const listRes = await app.inject({ method: "GET", url: `/api/public/sources?category=national` }); + expect(listRes.json().items.map((i: { id: string }) => i.id)).not.toContain(sourceId); + await app.close(); + }); + + it("returns 404 for a nonexistent source", async () => { + const app = buildApp(); + const res = await putCategories(app, "00000000-0000-0000-0000-000000000000", ["national"]); + expect(res.statusCode).toBe(404); + await app.close(); + }); +}); diff --git a/apps/api/test/story.test.ts b/apps/api/test/story.test.ts index 3de6a00..0c8e2ae 100644 --- a/apps/api/test/story.test.ts +++ b/apps/api/test/story.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; -import { buildApp } from "../src/app.js"; +import { buildApp, WEB_ORIGIN } from "../src/app.js"; +import { signAccessToken } from "../src/features/auth/jwt.js"; import { addCoverage, createTestSource, @@ -9,6 +10,7 @@ import { rateSource, tombstoneStory, } from "./feed-helpers.js"; +import { cleanupTestUser, createUser } from "./helpers.js"; import { delTestCache, setTestCache } from "./redis-helpers.js"; describe("GET /api/public/stories/:id", () => { @@ -106,4 +108,52 @@ describe("GET /api/public/stories/:id", () => { await delTestCache(`page:story:${storyId}`); await app.close(); }); + + it("caps each bias column to the free-tier limit and flags premiumBreakdown.locked", async () => { + const sources = await Promise.all([createTestSource("cap-1"), createTestSource("cap-2"), createTestSource("cap-3")]); + for (const s of sources) await rateSource(s, { biasRating: "left", confidence: "high" }); + const { storyId } = await createTestStory(sources[0]); + await addCoverage(storyId, sources[1]); + await addCoverage(storyId, sources[2]); + + await delTestCache(`page:story:${storyId}`); + const app = buildApp(); + const res = await app.inject({ method: "GET", url: `/api/public/stories/${storyId}` }); + const body = res.json(); + + expect(body.biasColumns.left).toHaveLength(2); // FREE_SOURCE_CARDS_PER_CATEGORY + expect(body.premiumBreakdown).toEqual({ locked: true, hiddenCardCount: 1 }); + await delTestCache(`page:story:${storyId}`); + await app.close(); + }); +}); + +describe("GET /api/stories/:id/full-breakdown", () => { + const EMAIL = "story-premium-test@mail.example"; + + it("rejects an unauthenticated request", async () => { + const app = buildApp(); + const res = await app.inject({ method: "GET", url: "/api/stories/00000000-0000-0000-0000-000000000000/full-breakdown" }); + expect(res.statusCode).toBe(401); + await app.close(); + }); + + it("403s a signed-in user with no active subscription", async () => { + await cleanupTestUser(EMAIL); + const userId = await createUser(EMAIL, "a-strong-passphrase"); + const token = signAccessToken(userId, 1); + const sourceA = await createTestSource("premium-gate"); + const { storyId } = await createTestStory(sourceA); + + const app = buildApp(); + const res = await app.inject({ + method: "GET", + url: `/api/stories/${storyId}/full-breakdown`, + headers: { authorization: `Bearer ${token}`, origin: WEB_ORIGIN }, + }); + expect(res.statusCode).toBe(403); + expect(res.json().error).toBe("subscription_required"); + await app.close(); + await cleanupTestUser(EMAIL); + }); }); diff --git a/apps/ingestion/Dockerfile b/apps/ingestion/Dockerfile index 50b035b..86a0d9d 100644 --- a/apps/ingestion/Dockerfile +++ b/apps/ingestion/Dockerfile @@ -1,7 +1,9 @@ -# apps/ingestion/Dockerfile +# apps/ingestion/Dockerfile — built with repo root as context (see infra/docker-compose.yml): +# config.py resolves config/ two directories above itself (the repo root). FROM python:3.11-slim -WORKDIR /app -COPY requirements.txt . +WORKDIR /app/apps/ingestion +COPY apps/ingestion/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY . . +COPY apps/ingestion/ . +COPY config/ ../../config/ CMD ["python", "-m", "workers.scheduler"] diff --git a/apps/ingestion/clustering/apply.py b/apps/ingestion/clustering/apply.py index 5bdf858..7c79b82 100644 --- a/apps/ingestion/clustering/apply.py +++ b/apps/ingestion/clustering/apply.py @@ -115,7 +115,9 @@ def apply_embed_result(conn: psycopg.Connection, *, article_id: str, h: str, vec "cluster_version": active_cluster_version, "id": article_id}, ) - if decision.action == "MOVE": + # Not just MOVE: a re-embedded article can also drift to a brand-new story (CREATE) + # when it no longer fits anywhere — same "left its old story" case, same cleanup. + if current_story_id is not None and str(current_story_id) != target_story_id: conn.execute(LINEAGE_SQL, {"old_story_id": current_story_id, "new_story_id": target_story_id}) conn.execute(TOMBSTONE_SQL, {"old_story_id": current_story_id}) diff --git a/apps/ingestion/tests/test_cluster_apply.py b/apps/ingestion/tests/test_cluster_apply.py index 8f01c58..7916636 100644 --- a/apps/ingestion/tests/test_cluster_apply.py +++ b/apps/ingestion/tests/test_cluster_apply.py @@ -161,6 +161,45 @@ def test_move_tombstones_emptied_old_story(source_id): assert old_status == ("tombstoned", new_story_id) # NOW tombstoned: all members gone +def test_create_after_drift_tombstones_emptied_old_story(source_id): + """Review Focus: decide() can return CREATE for an already-clustered article (re-embed + drifted below stay_threshold with no MOVE candidate either) — the old story still loses + its only member and must be tombstoned exactly like a MOVE does, or it's left active + with zero articles forever (I-equivalent of the MOVE case, just a different Decision).""" + with psycopg.connect(DSN, autocommit=True) as conn: + from pgvector.psycopg import register_vector + register_vector(conn) + headline_id = _insert_pending_article(source_id, "x.com/drift-headline-6") + old_story_id = conn.execute( + "INSERT INTO stories (title, summary, headline_article_id, representative_embedding, " + "representative_embedding_model, cluster_version, status, first_seen_at, " + "event_last_updated_at, last_coverage_at) " + "VALUES ('Old', 's', %s, %s, 'test-model@rev', 'v1', 'active', now(), now(), now()) RETURNING id", + (headline_id, _vec([1.0])), + ).fetchone()[0] + conn.execute("UPDATE articles SET story_id=%s, cluster_version='v1', embedding=%s, " + "embedding_model='test-model@rev', embedded_content_hash='e1:seed' WHERE id=%s", + (old_story_id, _vec([1.0]), headline_id)) + + # Re-embed the only member with a vector orthogonal to the story's representative + # embedding and with no other candidate story around — similarity to "current" drops + # below stay_threshold, no candidate reaches move_margin, so decide() returns CREATE. + with psycopg.connect(DSN) as conn: + from pgvector.psycopg import register_vector + register_vector(conn) + outcome = apply_embed_result( + conn, article_id=headline_id, h="e1:seed", vector=_vec([0.0, 1.0]), + embedding_model_id="test-model@rev", title="Drifted", excerpt="body", + ) + assert outcome.action == "CREATE" + assert outcome.story_id != str(old_story_id) + with psycopg.connect(DSN, autocommit=True) as conn: + status, merged_into = conn.execute( + "SELECT status, merged_into FROM stories WHERE id = %s", (old_story_id,) + ).fetchone() + assert (status, str(merged_into)) == ("tombstoned", outcome.story_id) # emptied old story must not stay active + + def test_stale_write_rejected_when_content_hash_changed(source_id): """Review Focus: a concurrent content edit between EMBED's read and T3's commit must reject the write, never silently commit a vector for stale content (I5).""" diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index ebb31b2..cb2d77a 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -1,8 +1,10 @@ -# apps/web/Dockerfile +# apps/web/Dockerfile — built with repo root as context (see infra/docker-compose.yml): +# src/features/*/types.ts import packages/shared-types via a relative path. FROM node:22-slim -WORKDIR /app -COPY package.json package-lock.json ./ +WORKDIR /app/apps/web +COPY apps/web/package.json apps/web/package-lock.json ./ RUN npm ci -COPY . . +COPY apps/web/ ./ +COPY packages/shared-types/ ../../packages/shared-types/ RUN npm run build CMD ["npm", "run", "preview"] diff --git a/apps/web/src/app/RootLayout.tsx b/apps/web/src/app/RootLayout.tsx index 4a159ef..a91ef81 100644 --- a/apps/web/src/app/RootLayout.tsx +++ b/apps/web/src/app/RootLayout.tsx @@ -1,44 +1,50 @@ -import { Link, Outlet, useNavigate } from 'react-router-dom' +import { useState, type FormEvent } from 'react' +import { Outlet, useLocation, useNavigate } from 'react-router-dom' +import { Footer } from '../components/layout/Footer' +import { Masthead } from '../components/layout/Masthead' +import { SectionNav } from '../components/layout/SectionNav' +import { TabBar } from '../components/layout/TabBar' import { logout } from '../features/auth' import { useAuthStore } from '../lib/authStore' +const CONTAINER = 'mx-auto w-full px-4' + export function RootLayout() { const accessToken = useAuthStore((s) => s.accessToken) const isSignedIn = accessToken !== null const navigate = useNavigate() + const location = useLocation() + const [search, setSearch] = useState('') async function onLogout() { await logout() navigate('/') } + function onSearchSubmit(e: FormEvent) { + e.preventDefault() + navigate(`/sources?q=${encodeURIComponent(search)}`) + } + return ( -
-
- - Newstack - - -
-
- +
+ Skip to content + +
+ void onLogout()} /> +
+ + void onLogout()} search={search} onSearchChange={setSearch} onSearchSubmit={onSearchSubmit} /> + +
+
+ +
+ + +
) } diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx index 3c197c8..02436b6 100644 --- a/apps/web/src/app/router.tsx +++ b/apps/web/src/app/router.tsx @@ -1,15 +1,17 @@ import { createBrowserRouter } from 'react-router-dom' import { authRoutes } from '../features/auth' +import { billingRoutes } from '../features/billing' import { curatorRoutes } from '../features/curator' import { feedRoutes } from '../features/feed' import { savedRoutes } from '../features/saved' +import { sourceRoutes } from '../features/sources' import { topicRoutes } from '../features/topics' import { RootLayout } from './RootLayout' export const router = createBrowserRouter([ { element: , - children: [...feedRoutes, ...authRoutes, ...topicRoutes, ...savedRoutes, ...curatorRoutes], + children: [...feedRoutes, ...authRoutes, ...topicRoutes, ...savedRoutes, ...sourceRoutes, ...curatorRoutes, ...billingRoutes], }, ]) diff --git a/apps/web/src/app/useLocationWeather.test.ts b/apps/web/src/app/useLocationWeather.test.ts new file mode 100644 index 0000000..2a200c1 --- /dev/null +++ b/apps/web/src/app/useLocationWeather.test.ts @@ -0,0 +1,33 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { useLocationWeather } from './useLocationWeather' + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('useLocationWeather', () => { + it('resolves city/temp/icon once both fetches succeed', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation((url) => { + if (String(url).includes('ipapi.co')) { + return Promise.resolve(new Response(JSON.stringify({ city: 'Chennai', latitude: 13.08, longitude: 80.27 }))) + } + return Promise.resolve( + new Response(JSON.stringify({ current_weather: { temperature: 31.4, weathercode: 1 } })) + ) + }) + + const { result } = renderHook(() => useLocationWeather()) + await waitFor(() => expect(result.current).not.toBeNull()) + expect(result.current).toEqual({ city: 'Chennai', tempC: 31.4, icon: 'sun' }) + }) + + it('resolves to null without throwing when a fetch rejects', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network error')) + + const { result } = renderHook(() => useLocationWeather()) + await waitFor(() => expect(globalThis.fetch).toHaveBeenCalled()) + expect(result.current).toBeNull() + }) +}) diff --git a/apps/web/src/app/useLocationWeather.ts b/apps/web/src/app/useLocationWeather.ts new file mode 100644 index 0000000..81f9271 --- /dev/null +++ b/apps/web/src/app/useLocationWeather.ts @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react' + +export type WeatherIcon = 'sun' | 'cloud' | 'rain' | 'snow' | 'storm' + +export type LocationWeather = { + city: string + tempC: number + icon: WeatherIcon +} + +function iconForWmoCode(code: number): WeatherIcon { + if (code === 0 || code === 1) return 'sun' + if (code === 2 || code === 3 || (code >= 45 && code <= 48)) return 'cloud' + if ((code >= 51 && code <= 67) || (code >= 80 && code <= 82)) return 'rain' + if ((code >= 71 && code <= 77) || code === 85 || code === 86) return 'snow' + if (code >= 95) return 'storm' + return 'cloud' +} + +export function useLocationWeather(): LocationWeather | null { + const [data, setData] = useState(null) + + useEffect(() => { + let cancelled = false + async function run() { + try { + const locRes = await fetch('https://ipapi.co/json/') + const loc = await locRes.json() + const { city, latitude, longitude } = loc + if (typeof city !== 'string' || typeof latitude !== 'number' || typeof longitude !== 'number') return + + const weatherRes = await fetch( + `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t_weather=true` + ) + const weather = await weatherRes.json() + const tempC = weather?.current_weather?.temperature + const code = weather?.current_weather?.weathercode + if (typeof tempC !== 'number' || typeof code !== 'number') return + + if (!cancelled) setData({ city, tempC, icon: iconForWmoCode(code) }) + } catch { + // degrade silently — block renders nothing + } + } + void run() + return () => { + cancelled = true + } + }, []) + + return data +} + +const LAUNCH_DATE = Date.UTC(2026, 0, 1) +const FALLBACK_CITY = 'New Delhi' +const MS_PER_DAY = 1000 * 60 * 60 * 24 + +export function useEdition(city?: string | null): { number: string; city: string } { + const number = String(Math.max(1, Math.floor((Date.now() - LAUNCH_DATE) / MS_PER_DAY))).padStart(3, '0') + return { number, city: city ?? FALLBACK_CITY } +} diff --git a/apps/web/src/components/QueryStates.tsx b/apps/web/src/components/QueryStates.tsx index c3e6bc8..8c965ca 100644 --- a/apps/web/src/components/QueryStates.tsx +++ b/apps/web/src/components/QueryStates.tsx @@ -1,24 +1,28 @@ +import { Button } from './ui/Button' + export function ErrorState({ message }: { message: string }) { - return

{message}

+ return ( +

+ {message} +

+ ) } export function LoadingState() { - return

Loading…

+ return

Loading…

} export function EmptyState({ message }: { message: string }) { - return

{message}

+ return

{message}

} export function LoadMoreButton({ onClick, loading }: { onClick: () => void; loading: boolean }) { return ( - + ) } diff --git a/apps/web/src/components/layout/Footer.tsx b/apps/web/src/components/layout/Footer.tsx new file mode 100644 index 0000000..b39331b --- /dev/null +++ b/apps/web/src/components/layout/Footer.tsx @@ -0,0 +1,61 @@ +import { Link } from 'react-router-dom' + +import { useEdition, useLocationWeather } from '../../app/useLocationWeather' + +export function Footer() { + const weather = useLocationWeather() + const edition = useEdition(weather?.city) + const year = new Date().getFullYear() + + return ( +
+
+
+
+

NEWSTACK

+

Look beyond the headline.

+
+ Back to top ↑ +
+ +
+
+

Sections

+ Topics + Sources + Saved +
+
+

Newstack

+ About (page in preparation) + Our Method (page in preparation) + Source Directory +
+
+

Standards

+ Editorial Principles (page in preparation) + Privacy (page in preparation) + Contact (page in preparation) +
+
+ +
+ + +
+ +
+ © {year} Newstack. Independent news coverage analysis. + + Edition No. {edition.number} · {edition.city} + + + + + + +
+
+
+ ) +} diff --git a/apps/web/src/components/layout/Masthead.tsx b/apps/web/src/components/layout/Masthead.tsx new file mode 100644 index 0000000..080687f --- /dev/null +++ b/apps/web/src/components/layout/Masthead.tsx @@ -0,0 +1,84 @@ +import { Link } from 'react-router-dom' + +import { IconCloud, IconRain, IconSnow, IconStorm, IconSun } from '../ui/icons' +import { useEdition, useLocationWeather, type WeatherIcon } from '../../app/useLocationWeather' + +const WEATHER_ICONS: Record = { + sun: IconSun, + cloud: IconCloud, + rain: IconRain, + snow: IconSnow, + storm: IconStorm, +} + +const dateFormatter = new Intl.DateTimeFormat('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }) + +type MastheadProps = { + variant: 'home' | 'inner' + isSignedIn: boolean + onLogout: () => void +} + +export function Masthead({ variant, isSignedIn, onLogout }: MastheadProps) { + const weather = useLocationWeather() + const edition = useEdition(weather?.city) + const WeatherIconComp = weather ? WEATHER_ICONS[weather.icon] : null + + return ( +
+
+ + {dateFormatter.format(new Date())} + {weather && WeatherIconComp && ( + + + {Math.round(weather.tempC)}°C {weather.city} + + )} + + + Edition No. {edition.number} · {edition.city} + {isSignedIn ? ( + + ) : ( + + Log in + Sign up + + )} + +
+ + {variant === 'home' && ( +
+ + The Daily Briefing · India & the World + +
+ )} + + + NEWSTACK + + + {variant === 'home' &&

Less noise. More news. Zero bullshit.

} + +
+ + +
+
+ ) +} diff --git a/apps/web/src/components/layout/SectionNav.tsx b/apps/web/src/components/layout/SectionNav.tsx new file mode 100644 index 0000000..2156904 --- /dev/null +++ b/apps/web/src/components/layout/SectionNav.tsx @@ -0,0 +1,71 @@ +import { useEffect, useRef, useState, type FormEvent } from 'react' +import { Link, NavLink } from 'react-router-dom' + +import { IconList } from '../ui/icons' + +const NAV = [ + { to: '/', label: 'Home' }, + { to: '/topics', label: 'Topics' }, + { to: '/sources', label: 'Sources' }, + { to: '/me/saved', label: 'Saved' }, +] + +type SectionNavProps = { + isSignedIn: boolean + onLogout: () => void + search: string + onSearchChange: (value: string) => void + onSearchSubmit: (e: FormEvent) => void +} + +export function SectionNav({ isSignedIn, onLogout, search, onSearchChange, onSearchSubmit }: SectionNavProps) { + const markRef = useRef(null) + const [markVisible, setMarkVisible] = useState(false) + + useEffect(() => { + const masthead = document.getElementById('top') + if (!masthead) return + const observer = new IntersectionObserver(([entry]) => setMarkVisible(!entry.isIntersecting), { threshold: 0 }) + observer.observe(masthead) + return () => observer.disconnect() + }, []) + + return ( + + ) +} diff --git a/apps/web/src/components/layout/TabBar.tsx b/apps/web/src/components/layout/TabBar.tsx new file mode 100644 index 0000000..61f7a60 --- /dev/null +++ b/apps/web/src/components/layout/TabBar.tsx @@ -0,0 +1,23 @@ +import { NavLink } from 'react-router-dom' + +import { IconBookmark, IconGrid, IconHome, IconList } from '../ui/icons' + +const NAV = [ + { to: '/', label: 'Home', Icon: IconHome }, + { to: '/topics', label: 'Topics', Icon: IconGrid }, + { to: '/sources', label: 'Sources', Icon: IconList }, + { to: '/me/saved', label: 'Saved', Icon: IconBookmark }, +] + +export function TabBar() { + return ( + + ) +} diff --git a/apps/web/src/components/ui/Button.tsx b/apps/web/src/components/ui/Button.tsx new file mode 100644 index 0000000..34d11f8 --- /dev/null +++ b/apps/web/src/components/ui/Button.tsx @@ -0,0 +1,20 @@ +import type { ButtonHTMLAttributes } from 'react' + +type Variant = 'primary' | 'secondary' | 'ghost' | 'icon' + +type ButtonProps = ButtonHTMLAttributes & { + variant?: Variant + block?: boolean +} + +const VARIANT_CLASS: Record = { + primary: 'btn-primary', + secondary: 'btn-secondary', + ghost: 'btn-ghost', + icon: 'btn-icon btn-secondary', +} + +export function Button({ variant = 'secondary', block = false, className = '', type = 'button', ...props }: ButtonProps) { + const classes = ['btn', VARIANT_CLASS[variant], block && 'btn-block', className].filter(Boolean).join(' ') + return -
- Sign up - Forgot password? -
- + +
+
+ + setEmail(e.target.value)} className="input" /> +
+
+ + setPassword(e.target.value)} className="input" /> +
+ {login.isError && ( + // §9: one generic message for both unknown email and wrong password. +

Invalid email or password.

+ )} + +
+ Sign up + Forgot password? +
+
+
) } diff --git a/apps/web/src/features/auth/pages/ResetConfirmPage.tsx b/apps/web/src/features/auth/pages/ResetConfirmPage.tsx index 8d38d7f..3cc7410 100644 --- a/apps/web/src/features/auth/pages/ResetConfirmPage.tsx +++ b/apps/web/src/features/auth/pages/ResetConfirmPage.tsx @@ -1,9 +1,11 @@ import { useState, type FormEvent } from 'react' import { Link } from 'react-router-dom' +import { Button } from '../../../components/ui/Button' import { ApiError } from '../../../lib/apiClient' import { useConfirmPasswordReset } from '../hooks/useAuthMutations' import { readTokenFromHash } from '../utils/hashToken' +import { AuthCard } from '../components/AuthCard' export function ResetConfirmPage() { const [newPassword, setNewPassword] = useState('') @@ -16,13 +18,15 @@ export function ResetConfirmPage() { confirm.mutate({ token, newPassword }) } - if (token === null) return

Missing reset link.

+ if (token === null) return

Missing reset link.

if (confirm.isSuccess) { return ( -

- Password reset. Log in -

+ +

+ You can now log in. +

+
) } @@ -31,36 +35,27 @@ export function ResetConfirmPage() { const alreadyUsed = confirm.error instanceof ApiError && confirm.error.code === 'invalid_or_used' return ( -
-

Choose a new password

- setNewPassword(e.target.value)} - className="rounded border border-neutral-300 px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900" - /> - {confirm.isError && ( -

- {alreadyUsed ? ( - <> - This link was already used. Log in - - ) : ( - 'That link is invalid or expired.' - )} -

- )} - -
+ +
+
+ + setNewPassword(e.target.value)} className="input" /> +
+ {confirm.isError && ( +

+ {alreadyUsed ? ( + <> + This link was already used. Log in + + ) : ( + 'That link is invalid or expired.' + )} +

+ )} + +
+
) } diff --git a/apps/web/src/features/auth/pages/ResetRequestPage.tsx b/apps/web/src/features/auth/pages/ResetRequestPage.tsx index 9060fbf..beec0b1 100644 --- a/apps/web/src/features/auth/pages/ResetRequestPage.tsx +++ b/apps/web/src/features/auth/pages/ResetRequestPage.tsx @@ -1,6 +1,8 @@ import { useState, type FormEvent } from 'react' +import { Button } from '../../../components/ui/Button' import { useRequestPasswordReset } from '../hooks/useAuthMutations' +import { AuthCard } from '../components/AuthCard' export function ResetRequestPage() { const [email, setEmail] = useState('') @@ -12,27 +14,20 @@ export function ResetRequestPage() { } if (resetRequest.isSuccess) { - return

If that email has an account, a reset link is on its way.

+ return

If that email has an account, a reset link is on its way.

} return ( -
-

Reset password

- setEmail(e.target.value)} - className="rounded border border-neutral-300 px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900" - /> - -
+ +
+
+ + setEmail(e.target.value)} className="input" /> +
+ +
+
) } diff --git a/apps/web/src/features/auth/pages/SignupPage.tsx b/apps/web/src/features/auth/pages/SignupPage.tsx index f5ec129..a4110de 100644 --- a/apps/web/src/features/auth/pages/SignupPage.tsx +++ b/apps/web/src/features/auth/pages/SignupPage.tsx @@ -1,8 +1,10 @@ import { useState, type FormEvent } from 'react' import { Link } from 'react-router-dom' +import { Button } from '../../../components/ui/Button' import { ApiError } from '../../../lib/apiClient' import { useSignup } from '../hooks/useAuthMutations' +import { AuthCard } from '../components/AuthCard' export function SignupPage() { const [email, setEmail] = useState('') @@ -15,47 +17,34 @@ export function SignupPage() { } if (signup.isSuccess) { - return

Check your email to verify your account.

+ return

We sent a link to verify your account.

} return ( -
-

Sign up

- setEmail(e.target.value)} - className="rounded border border-neutral-300 px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900" - /> - setPassword(e.target.value)} - className="rounded border border-neutral-300 px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900" - /> - {signup.isError && ( -

- {signup.error instanceof ApiError && signup.error.code === 'common_password' - ? 'That password is too common — please choose another.' - : 'Something went wrong. Please try again.'} + + +

+ + setEmail(e.target.value)} className="input" /> +
+
+ + setPassword(e.target.value)} className="input" /> +
+ {signup.isError && ( +

+ {signup.error instanceof ApiError && signup.error.code === 'common_password' + ? 'That password is too common — please choose another.' + : 'Something went wrong. Please try again.'} +

+ )} + +

+ Already have an account? Log in

- )} - -

- Already have an account? Log in -

-
+ + ) } diff --git a/apps/web/src/features/auth/pages/VerifyPage.tsx b/apps/web/src/features/auth/pages/VerifyPage.tsx index 0f9fa61..deaeb7f 100644 --- a/apps/web/src/features/auth/pages/VerifyPage.tsx +++ b/apps/web/src/features/auth/pages/VerifyPage.tsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom' import { useVerifyEmail } from '../hooks/useAuthMutations' import { readTokenFromHash } from '../utils/hashToken' +import { AuthCard } from '../components/AuthCard' export function VerifyPage() { const verify = useVerifyEmail() @@ -20,14 +21,16 @@ export function VerifyPage() { const token = readTokenFromHash(window.location.hash) - if (token === null) return

Missing verification link.

- if (verify.isPending || verify.isIdle) return

Verifying…

+ if (token === null) return

Missing verification link.

+ if (verify.isPending || verify.isIdle) return

Verifying…

if (verify.isSuccess) { return ( -

- Email verified. Log in -

+ +

+ Log in +

+
) } - return

That link is invalid or expired.

+ return

That link is invalid or expired.

} diff --git a/apps/web/src/features/billing/api.ts b/apps/web/src/features/billing/api.ts new file mode 100644 index 0000000..d89944a --- /dev/null +++ b/apps/web/src/features/billing/api.ts @@ -0,0 +1,47 @@ +import { apiFetch } from '../../lib/apiClient' +import { useAuthStore } from '../../lib/authStore' +import type { Payment, Plan, SubscriptionView } from './types' + +const BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000' + +export function listPlans(): Promise<{ items: Plan[] }> { + return apiFetch<{ items: Plan[] }>('/api/public/plans') +} + +export function checkout(planCode: string): Promise<{ checkoutParams: { approveUrl: string | null } }> { + return apiFetch('/api/billing/checkout', { method: 'POST', body: { planCode } }) +} + +export function getSubscription(): Promise { + return apiFetch('/api/billing/subscription') +} + +export function cancelSubscription(): Promise { + return apiFetch('/api/billing/cancel', { method: 'POST' }) +} + +export function resumeSubscription(): Promise { + return apiFetch('/api/billing/resume', { method: 'POST' }) +} + +export function changePlan(planCode: string): Promise { + return apiFetch('/api/billing/change-plan', { method: 'POST', body: { planCode } }) +} + +export function listPayments(): Promise<{ items: Payment[] }> { + return apiFetch<{ items: Payment[] }>('/api/billing/payments') +} + +// A plain can't carry the Authorization header this authenticated endpoint needs +// (apiFetch's bearer token, same as every other /api/* call) — fetch it and open a blob +// URL instead. +export async function openInvoice(paymentId: string): Promise { + const token = useAuthStore.getState().accessToken + const res = await fetch(`${BASE_URL}/api/billing/invoices/${paymentId}`, { + headers: token !== null ? { Authorization: `Bearer ${token}` } : {}, + credentials: 'include', + }) + if (!res.ok) throw new Error('invoice_fetch_failed') + const blob = await res.blob() + window.open(URL.createObjectURL(blob), '_blank') +} diff --git a/apps/web/src/features/billing/hooks/useBilling.ts b/apps/web/src/features/billing/hooks/useBilling.ts new file mode 100644 index 0000000..839fa70 --- /dev/null +++ b/apps/web/src/features/billing/hooks/useBilling.ts @@ -0,0 +1,40 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' + +import { useAuthStore } from '../../../lib/authStore' +import { cancelSubscription, changePlan, checkout, getSubscription, listPayments, listPlans, resumeSubscription } from '../api' + +export function usePlans() { + return useQuery({ queryKey: ['billing', 'plans'], queryFn: () => listPlans().then((r) => r.items) }) +} + +export function useSubscription() { + const isSignedIn = useAuthStore((s) => s.accessToken !== null) + return useQuery({ + queryKey: ['billing', 'subscription'], + queryFn: getSubscription, + enabled: isSignedIn, + }) +} + +export function usePayments() { + const isSignedIn = useAuthStore((s) => s.accessToken !== null) + return useQuery({ + queryKey: ['billing', 'payments'], + queryFn: () => listPayments().then((r) => r.items), + enabled: isSignedIn, + }) +} + +export function useCheckout() { + return useMutation({ mutationFn: (planCode: string) => checkout(planCode) }) +} + +export function useBillingActions() { + const queryClient = useQueryClient() + const invalidate = () => queryClient.invalidateQueries({ queryKey: ['billing', 'subscription'] }) + return { + cancel: useMutation({ mutationFn: cancelSubscription, onSuccess: invalidate }), + resume: useMutation({ mutationFn: resumeSubscription, onSuccess: invalidate }), + changePlan: useMutation({ mutationFn: (planCode: string) => changePlan(planCode), onSuccess: invalidate }), + } +} diff --git a/apps/web/src/features/billing/index.ts b/apps/web/src/features/billing/index.ts new file mode 100644 index 0000000..0156fbb --- /dev/null +++ b/apps/web/src/features/billing/index.ts @@ -0,0 +1 @@ +export { billingRoutes } from './routes' diff --git a/apps/web/src/features/billing/pages/AccountBillingPage.tsx b/apps/web/src/features/billing/pages/AccountBillingPage.tsx new file mode 100644 index 0000000..c61b8e4 --- /dev/null +++ b/apps/web/src/features/billing/pages/AccountBillingPage.tsx @@ -0,0 +1,73 @@ +import { Link } from 'react-router-dom' + +import { Button } from '../../../components/ui/Button' +import { ErrorState, LoadingState } from '../../../components/QueryStates' +import { openInvoice } from '../api' +import { useBillingActions, usePayments, useSubscription } from '../hooks/useBilling' + +// PAYPAL_SPEC §4.9 account page: plan, status, next renewal, payment history, +// cancel/resume, invoice download. +export function AccountBillingPage() { + const { data: subscription, isLoading, error } = useSubscription() + const { data: payments } = usePayments() + const { cancel, resume } = useBillingActions() + + if (isLoading) return + if (error || subscription === undefined) return + + return ( +
+

Billing

+ +
+ {subscription.status === 'none' ? ( + <> +

You don't have an active subscription.

+ + + ) : ( + <> +

+ Plan: {subscription.planCode} · Status: {subscription.status} + {subscription.entitled ? '' : ' (no access)'} +

+ {subscription.currentPeriodEnd && ( +

+ {subscription.cancelAtPeriodEnd ? 'Access ends' : 'Renews'} on{' '} + {new Date(subscription.currentPeriodEnd).toLocaleDateString()} +

+ )} +
+ {subscription.cancelAtPeriodEnd ? ( + + ) : ( + + )} +
+ + )} +
+ +

Payment history

+ {payments === undefined || payments.length === 0 ? ( +

No payments yet.

+ ) : ( +
    + {payments.map((payment) => ( +
  • + + ₹{(payment.amount_paise / 100).toFixed(2)} · {payment.status} + {payment.paid_at && ` · ${new Date(payment.paid_at).toLocaleDateString()}`} + + {payment.invoice_number && ( + + )} +
  • + ))} +
+ )} +
+ ) +} diff --git a/apps/web/src/features/billing/pages/SubscribePage.tsx b/apps/web/src/features/billing/pages/SubscribePage.tsx new file mode 100644 index 0000000..c69e670 --- /dev/null +++ b/apps/web/src/features/billing/pages/SubscribePage.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react' + +import { Button } from '../../../components/ui/Button' +import { ErrorState, LoadingState } from '../../../components/QueryStates' +import { useAuthStore } from '../../../lib/authStore' +import { useCheckout, usePlans } from '../hooks/useBilling' + +// PAYPAL_SPEC §8: two cards, annual highlighted as best value, auto-renewal and cancellation +// disclosed near the pay button. +export function SubscribePage() { + const { data: plans, isLoading, error } = usePlans() + const isSignedIn = useAuthStore((s) => s.accessToken !== null) + const checkoutMutation = useCheckout() + const [startingCode, setStartingCode] = useState(null) + + if (isLoading) return + if (error || plans === undefined) return + + const handleSubscribe = (planCode: string) => { + setStartingCode(planCode) + checkoutMutation.mutate(planCode, { + onSuccess: ({ checkoutParams }) => { + if (checkoutParams.approveUrl) window.location.href = checkoutParams.approveUrl + }, + onSettled: () => setStartingCode(null), + }) + } + + return ( +
+

Subscribe to Newstack

+

Cancel anytime. Prices include tax.

+ {!isSignedIn &&

Log in first to subscribe.

} +
+ {plans.map((plan) => { + const isAnnual = plan.interval === 'year' + const amount = (plan.amount_paise / 100).toFixed(0) + return ( +
+ {isAnnual &&

Best value · about ₹1/day

} +

{plan.name}

+

₹{amount}/{plan.interval}

+ +

Auto-renews via PayPal until canceled. Cancel anytime from your account.

+
+ ) + })} +
+ {checkoutMutation.isError && } +
+ ) +} diff --git a/apps/web/src/features/billing/routes.tsx b/apps/web/src/features/billing/routes.tsx new file mode 100644 index 0000000..c5ca00a --- /dev/null +++ b/apps/web/src/features/billing/routes.tsx @@ -0,0 +1,17 @@ +import type { RouteObject } from 'react-router-dom' + +import { RequireAuth } from '../../components/RequireAuth' +import { AccountBillingPage } from './pages/AccountBillingPage' +import { SubscribePage } from './pages/SubscribePage' + +export const billingRoutes: RouteObject[] = [ + { path: '/subscribe', element: }, + { + path: '/account/billing', + element: ( + + + + ), + }, +] diff --git a/apps/web/src/features/billing/types.ts b/apps/web/src/features/billing/types.ts new file mode 100644 index 0000000..e36ed10 --- /dev/null +++ b/apps/web/src/features/billing/types.ts @@ -0,0 +1,24 @@ +export interface Plan { + id: string + code: string + name: string + amount_paise: number + interval: 'month' | 'year' +} + +export interface SubscriptionView { + status: string + entitled: boolean + planCode: string | null + currentPeriodEnd: string | null + cancelAtPeriodEnd: boolean +} + +export interface Payment { + id: string + amount_paise: number + status: string + invoice_number: string | null + paid_at: string | null + created_at: string +} diff --git a/apps/web/src/features/curator/api.ts b/apps/web/src/features/curator/api.ts index 2c0737b..9169a79 100644 --- a/apps/web/src/features/curator/api.ts +++ b/apps/web/src/features/curator/api.ts @@ -1,5 +1,5 @@ import { curatorFetch } from './lib/curatorFetch' -import type { BlindspotFeedResponse, NewClusterReport, NewRating, NewSource } from './types' +import type { BlindspotFeedResponse, ClusterReportListFilters, ClusterReportListResponse, NewClusterReport, NewRating, NewSource } from './types' export function onboardSource(input: NewSource): Promise<{ id: string }> { return curatorFetch<{ id: string }>('/api/curator/sources', { method: 'POST', body: input }) @@ -25,7 +25,22 @@ export function submitRating(sourceId: string, input: NewRating): Promise { + return curatorFetch<{ categories: string[] }>(`/api/curator/sources/${sourceId}/categories`, { + method: 'PUT', + body: { categories }, + }) +} + export function getBlindspotFeed(cursor?: string): Promise { const query = cursor === undefined ? '' : `?cursor=${encodeURIComponent(cursor)}` return curatorFetch(`/api/curator/blindspot-feed${query}`) } + +export function getClusterReports(filters: ClusterReportListFilters): Promise { + const params = new URLSearchParams() + if (filters.resolved !== undefined) params.set('resolved', String(filters.resolved)) + params.set('limit', String(filters.limit)) + params.set('offset', String(filters.offset)) + return curatorFetch(`/api/curator/cluster-reports?${params.toString()}`) +} diff --git a/apps/web/src/features/curator/hooks/useCuratorMutations.ts b/apps/web/src/features/curator/hooks/useCuratorMutations.ts index efae707..3b65ba6 100644 --- a/apps/web/src/features/curator/hooks/useCuratorMutations.ts +++ b/apps/web/src/features/curator/hooks/useCuratorMutations.ts @@ -1,15 +1,17 @@ -import { useInfiniteQuery, useMutation } from '@tanstack/react-query' +import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query' import { createTopic, getBlindspotFeed, + getClusterReports, hideArticle, hideStory, onboardSource, reportClusterError, + setSourceCategories, submitRating, } from '../api' -import type { NewClusterReport, NewRating, NewSource } from '../types' +import type { ClusterReportListFilters, NewClusterReport, NewRating, NewSource } from '../types' export function useOnboardSource() { return useMutation({ mutationFn: (input: NewSource) => onboardSource(input) }) @@ -38,6 +40,12 @@ export function useSubmitRating() { return useMutation({ mutationFn: ({ sourceId, input }: { sourceId: string; input: NewRating }) => submitRating(sourceId, input) }) } +export function useSetSourceCategories() { + return useMutation({ + mutationFn: ({ sourceId, categories }: { sourceId: string; categories: string[] }) => setSourceCategories(sourceId, categories), + }) +} + export function useBlindspotFeed() { return useInfiniteQuery({ queryKey: ['curator', 'blindspot'], @@ -46,3 +54,7 @@ export function useBlindspotFeed() { getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, }) } + +export function useClusterReports(filters: ClusterReportListFilters) { + return useQuery({ queryKey: ['curator', 'cluster-reports', filters], queryFn: () => getClusterReports(filters) }) +} diff --git a/apps/web/src/features/curator/lib/curatorFetch.ts b/apps/web/src/features/curator/lib/curatorFetch.ts index 73c5738..5611332 100644 --- a/apps/web/src/features/curator/lib/curatorFetch.ts +++ b/apps/web/src/features/curator/lib/curatorFetch.ts @@ -13,7 +13,7 @@ export class CuratorApiError extends Error { } interface RequestOptions { - method?: 'GET' | 'POST' + method?: 'GET' | 'POST' | 'PUT' body?: unknown } diff --git a/apps/web/src/features/curator/pages/CuratorClusterReportsPage.tsx b/apps/web/src/features/curator/pages/CuratorClusterReportsPage.tsx new file mode 100644 index 0000000..3314c25 --- /dev/null +++ b/apps/web/src/features/curator/pages/CuratorClusterReportsPage.tsx @@ -0,0 +1,91 @@ +import { useState } from 'react' + +import { EmptyState, ErrorState, LoadingState, LoadMoreButton } from '../../../components/QueryStates' +import { useClusterReports } from '../hooks/useCuratorMutations' + +const PAGE_SIZE = 20 + +const KIND_LABEL: Record = { + wrong_merge: 'Wrong merge', + should_merge: 'Should merge', +} + +export function CuratorClusterReportsPage() { + const [resolved, setResolved] = useState(false) + const [limit, setLimit] = useState(PAGE_SIZE) + + const { data, error, isLoading, isFetching } = useClusterReports({ resolved, limit, offset: 0 }) + + return ( +
+
+

Cluster error reports

+

+ Filed via the clustering-error report form on the Moderation tab (ARCHITECTURE §7). Read-only for now — nothing + marks a report resolved yet. +

+
+ +
+ {([ + { value: false, label: 'Open' }, + { value: true, label: 'Resolved' }, + { value: undefined, label: 'All' }, + ] as const).map((opt) => ( + + ))} +
+ + {isLoading ? ( + + ) : error ? ( + + ) : data === undefined || data.items.length === 0 ? ( + + ) : ( + <> +

+ {data.total} report{data.total === 1 ? '' : 's'} +

+
    + {data.items.map((item) => ( +
  • +
    + {KIND_LABEL[item.kind] ?? item.kind} + {new Date(item.createdAt).toLocaleString()} +
    +

    + Story: {item.storyTitle} +

    + {item.articleTitle !== null && ( +

    Flagged article: {item.articleTitle}

    + )} + {item.otherStoryTitle !== null && ( +

    Duplicate of: {item.otherStoryTitle}

    + )} +

    + Reported by {item.reportedBy} · {item.resolvedAt === null ? 'open' : `resolved ${new Date(item.resolvedAt).toLocaleString()}`} +

    +
  • + ))} +
+ {data.items.length < data.total && setLimit((l) => l + PAGE_SIZE)} loading={isFetching} />} + + )} +
+ ) +} diff --git a/apps/web/src/features/curator/pages/CuratorLayout.tsx b/apps/web/src/features/curator/pages/CuratorLayout.tsx index 5ba810a..c0ddfc4 100644 --- a/apps/web/src/features/curator/pages/CuratorLayout.tsx +++ b/apps/web/src/features/curator/pages/CuratorLayout.tsx @@ -6,6 +6,7 @@ const TABS = [ { to: '/curator/sources', label: 'Sources' }, { to: '/curator/topics', label: 'Topics' }, { to: '/curator/moderation', label: 'Moderation' }, + { to: '/curator/cluster-reports', label: 'Cluster reports' }, { to: '/curator/blindspot', label: 'Blindspot' }, ] diff --git a/apps/web/src/features/curator/pages/CuratorSourcesPage.tsx b/apps/web/src/features/curator/pages/CuratorSourcesPage.tsx index ca810e7..c6c5d84 100644 --- a/apps/web/src/features/curator/pages/CuratorSourcesPage.tsx +++ b/apps/web/src/features/curator/pages/CuratorSourcesPage.tsx @@ -1,11 +1,61 @@ import { useState, type FormEvent } from 'react' import { CuratorApiError } from '../lib/curatorFetch' -import { useOnboardSource, useSubmitRating } from '../hooks/useCuratorMutations' +import { useOnboardSource, useSetSourceCategories, useSubmitRating } from '../hooks/useCuratorMutations' import type { BiasRating, Confidence } from '../types' const inputClass = 'rounded border border-neutral-300 px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900' +// PRODUCT §5.9's fixed list; multi-valued, curator-assigned, never derived. +const CATEGORIES = ['national', 'regional', 'language', 'business', 'geopolitical', 'specialist'] as const + +function AssignCategoriesForm() { + const [sourceId, setSourceId] = useState('') + const [categories, setCategories] = useState>(new Set()) + const setSourceCategories = useSetSourceCategories() + + function toggle(category: string) { + setCategories((prev) => { + const next = new Set(prev) + if (next.has(category)) next.delete(category) + else next.add(category) + return next + }) + } + + function onSubmit(e: FormEvent) { + e.preventDefault() + setSourceCategories.mutate({ sourceId, categories: [...categories] }) + } + + return ( +
+

Assign directory categories

+

+ Replaces the source's whole category set (PRODUCT §5.9) — a descriptive tag, not a rating. +

+ setSourceId(e.target.value)} className={inputClass} /> +
+ {CATEGORIES.map((category) => ( + + ))} +
+ {setSourceCategories.isError &&

Couldn't set categories.

} + {setSourceCategories.isSuccess &&

Categories updated.

} + +
+ ) +} + function OnboardSourceForm() { const [form, setForm] = useState({ name: '', domain: '', countryCode: 'IN', language: 'en', feedTimezone: 'Asia/Kolkata' }) const onboard = useOnboardSource() @@ -135,6 +185,7 @@ export function CuratorSourcesPage() {

Sources

+
) } diff --git a/apps/web/src/features/curator/routes.tsx b/apps/web/src/features/curator/routes.tsx index e209fe0..f6830d9 100644 --- a/apps/web/src/features/curator/routes.tsx +++ b/apps/web/src/features/curator/routes.tsx @@ -1,6 +1,7 @@ import { Navigate, type RouteObject } from 'react-router-dom' import { CuratorBlindspotPage } from './pages/CuratorBlindspotPage' +import { CuratorClusterReportsPage } from './pages/CuratorClusterReportsPage' import { CuratorLayout } from './pages/CuratorLayout' import { CuratorModerationPage } from './pages/CuratorModerationPage' import { CuratorSourcesPage } from './pages/CuratorSourcesPage' @@ -15,6 +16,7 @@ export const curatorRoutes: RouteObject[] = [ { path: 'sources', element: }, { path: 'topics', element: }, { path: 'moderation', element: }, + { path: 'cluster-reports', element: }, { path: 'blindspot', element: }, ], }, diff --git a/apps/web/src/features/curator/types.ts b/apps/web/src/features/curator/types.ts index 1b2ab93..113dbea 100644 --- a/apps/web/src/features/curator/types.ts +++ b/apps/web/src/features/curator/types.ts @@ -48,3 +48,11 @@ import type { components } from '../../../../../packages/shared-types/src/openap export type BlindspotItem = components['schemas']['BlindspotItem'] export type BlindspotFeedResponse = components['schemas']['BlindspotFeedResponse'] +export type ClusterReportListItem = components['schemas']['ClusterReportListItem'] +export type ClusterReportListResponse = components['schemas']['ClusterReportListResponse'] + +export interface ClusterReportListFilters { + resolved?: boolean + limit: number + offset: number +} diff --git a/apps/web/src/features/feed/api.ts b/apps/web/src/features/feed/api.ts index d84ccc4..caf8bed 100644 --- a/apps/web/src/features/feed/api.ts +++ b/apps/web/src/features/feed/api.ts @@ -1,11 +1,21 @@ import { apiFetch } from '../../lib/apiClient' -import type { FeedResponse, StoryDetail } from './types' +import type { FeedResponse, SourceDistribution, StoryDetail } from './types' export function getHomeFeed(cursor?: string): Promise { const query = cursor === undefined ? '' : `?cursor=${encodeURIComponent(cursor)}` return apiFetch(`/api/public/feed${query}`) } +export function getSourceDistribution(): Promise { + return apiFetch('/api/public/sources/distribution') +} + export function getStoryDetail(id: string): Promise { return apiFetch(`/api/public/stories/${id}`) } + +// Entitled-only, uncapped bias breakdown (PAYPAL_SPEC paywall gate) — see useStoryDetail, +// which only calls this once the public response reports premiumBreakdown.locked. +export function getStoryFullBreakdown(id: string): Promise { + return apiFetch(`/api/stories/${id}/full-breakdown`) +} diff --git a/apps/web/src/features/feed/components/BiasBar.tsx b/apps/web/src/features/feed/components/BiasBar.tsx index ffeca9c..ef2fbb8 100644 --- a/apps/web/src/features/feed/components/BiasBar.tsx +++ b/apps/web/src/features/feed/components/BiasBar.tsx @@ -25,12 +25,7 @@ export function BiasBar({ bar }: { bar: BiasBarData }) { return (
-
+
{SEGMENTS.map((s) => { const segment = bar[s.key] if (segment.count === 0) return null @@ -38,14 +33,14 @@ export function BiasBar({ bar }: { bar: BiasBarData }) { return (
0 ? ` (${segment.lowConfidenceCount} low confidence)` : ''}`} /> ) })}
-

+

{ratedTotal} rated sources{unrated > 0 ? ` · unrated: ${unrated}` : ''} · Based on current source ratings

diff --git a/apps/web/src/features/feed/components/BiasBarLegend.test.tsx b/apps/web/src/features/feed/components/BiasBarLegend.test.tsx new file mode 100644 index 0000000..a578e15 --- /dev/null +++ b/apps/web/src/features/feed/components/BiasBarLegend.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { BiasBarLegend } from './BiasBarLegend' +import type { SourceDistribution } from '../types' + +const { useSourceDistribution } = vi.hoisted(() => ({ useSourceDistribution: vi.fn() })) +vi.mock('../hooks/useSourceDistribution', () => ({ useSourceDistribution })) + +function mockData(data: SourceDistribution | undefined) { + useSourceDistribution.mockReturnValue({ data }) +} + +describe('BiasBarLegend', () => { + it('renders nothing while loading or when nothing is rated', () => { + mockData(undefined) + const { container: loading } = render() + expect(loading).toBeEmptyDOMElement() + + mockData({ left: 0, centerLeft: 0, center: 0, centerRight: 0, right: 0, notRated: 5 }) + const { container: empty } = render() + expect(empty).toBeEmptyDOMElement() + }) + + it('never phrases the summary as a single bias label or a percentage', () => { + mockData({ left: 6, centerLeft: 3, center: 2, centerRight: 0, right: 1, notRated: 2 }) + render() + const el = screen.getByRole('img') + const label = el.getAttribute('aria-label') ?? '' + expect(label).toContain('12 rated sources we track') + expect(label).toContain('6 left') + expect(label).not.toMatch(/%/) + }) + + it('renders one colored segment per non-zero bias category', () => { + mockData({ left: 1, centerLeft: 0, center: 1, centerRight: 0, right: 1, notRated: 0 }) + const { container } = render() + const segments = container.querySelectorAll('[role="img"] > div') + expect(segments).toHaveLength(3) + }) +}) diff --git a/apps/web/src/features/feed/components/BiasBarLegend.tsx b/apps/web/src/features/feed/components/BiasBarLegend.tsx index a06116b..1e49bda 100644 --- a/apps/web/src/features/feed/components/BiasBarLegend.tsx +++ b/apps/web/src/features/feed/components/BiasBarLegend.tsx @@ -1,23 +1,36 @@ -// Shown once per page (not per bar) so color identity never relies on color alone — -// each bar's own hover/tap title carries the exact counts (ARCHITECTURE §10). -const ENTRIES = [ - { label: 'Left', colorClass: 'bg-bias-left' }, - { label: 'Center-left', colorClass: 'bg-bias-centerleft' }, - { label: 'Center', colorClass: 'bg-bias-center' }, - { label: 'Center-right', colorClass: 'bg-bias-centerright' }, - { label: 'Right', colorClass: 'bg-bias-right' }, - { label: 'Unrated', colorClass: 'bg-bias-unrated' }, +import { useSourceDistribution } from '../hooks/useSourceDistribution' +import type { SourceDistribution } from '../types' + +// Replaces the old static color-key list with the real site-wide spread, so color identity +// is taught by an actual weighted bar instead of a swatch+label legend. PRODUCT §3.5 still +// applies here even though this isn't a per-story bar: counts, never a single left/right score. +const SEGMENTS: { key: keyof Omit; label: string; colorClass: string }[] = [ + { key: 'left', label: 'left', colorClass: 'bg-bias-left' }, + { key: 'centerLeft', label: 'center-left', colorClass: 'bg-bias-centerleft' }, + { key: 'center', label: 'center', colorClass: 'bg-bias-center' }, + { key: 'centerRight', label: 'center-right', colorClass: 'bg-bias-centerright' }, + { key: 'right', label: 'right', colorClass: 'bg-bias-right' }, ] export function BiasBarLegend() { + const { data } = useSourceDistribution() + if (data === undefined) return null + + const ratedTotal = SEGMENTS.reduce((sum, s) => sum + data[s.key], 0) + if (ratedTotal === 0) return null + + const summary = `${ratedTotal} rated sources we track — ${SEGMENTS.map((s) => `${data[s.key]} ${s.label}`).join(', ')}` + return ( -
    - {ENTRIES.map((e) => ( -
  • - - {e.label} -
  • - ))} -
+
+
+ {SEGMENTS.map((s) => { + const count = data[s.key] + if (count === 0) return null + return
+ })} +
+

{ratedTotal} sources we rate, left to right

+
) } diff --git a/apps/web/src/features/feed/components/BiasColumn.tsx b/apps/web/src/features/feed/components/BiasColumn.tsx index 70bc71d..0eabc9e 100644 --- a/apps/web/src/features/feed/components/BiasColumn.tsx +++ b/apps/web/src/features/feed/components/BiasColumn.tsx @@ -1,33 +1,57 @@ +import { Link } from 'react-router-dom' + +import { Tag } from '../../../components/ui/Tag' import { SaveToggleButton } from '../../saved' +import { OWNERSHIP_TRANSPARENCY_LABEL, SENSATIONALISM_LABEL, useSourceOwnership, useSourceSensationalism } from '../../sources' import type { SourceCard } from '../types' +// Diversity integration's smallest real step (docs/scoping-source-directory-and-diversity.md +// §2 option A): surface each source's existing sensationalism/ownership on its story-detail +// card, same read endpoints SourceProfilePage already uses. No new metric, never a ranking +// input — PRODUCT §6.2 stays bias-blind. +function SourceDiversityBadges({ sourceId }: { sourceId: string }) { + const { data: sensationalism } = useSourceSensationalism(sourceId) + const { data: ownership } = useSourceOwnership(sourceId) + + if (sensationalism?.current === undefined && (ownership === null || ownership === undefined)) return null + + return ( +
+ {sensationalism?.current && {SENSATIONALISM_LABEL[sensationalism.current.level]}} + {ownership && {OWNERSHIP_TRANSPARENCY_LABEL[ownership.ownershipTransparency]}} +
+ ) +} + export function BiasColumn({ label, colorClass, cards }: { label: string; colorClass: string; cards: SourceCard[] }) { if (cards.length === 0) return null return ( -
-

- +
+
+ {label} -

-
    +
+
diff --git a/apps/web/src/features/feed/components/FeedItemCard.tsx b/apps/web/src/features/feed/components/FeedItemCard.tsx index 7a10f68..4641b2f 100644 --- a/apps/web/src/features/feed/components/FeedItemCard.tsx +++ b/apps/web/src/features/feed/components/FeedItemCard.tsx @@ -1,19 +1,34 @@ import { Link } from 'react-router-dom' +import { SaveToggleButton } from '../../saved' +import { timeAgo } from '../timeAgo' import type { FeedItem } from '../types' import { BiasBar } from './BiasBar' export function FeedItemCard({ item }: { item: FeedItem }) { + const sourceCount = item.ratedSourceCount + item.unratedSourceCount + return ( -
-

- +
+
+ + {sourceCount} {sourceCount === 1 ? 'Source' : 'Sources'} +
+

{timeAgo(item.lastCoverageAt)}

+

+ {item.title}

- {item.summary !== null &&

{item.summary}

} -
- + {item.summary !== null && ( +

{item.summary}

+ )} + +
+ + Compare headlines → + +
) diff --git a/apps/web/src/features/feed/components/LeadStoryCard.tsx b/apps/web/src/features/feed/components/LeadStoryCard.tsx new file mode 100644 index 0000000..f01d16b --- /dev/null +++ b/apps/web/src/features/feed/components/LeadStoryCard.tsx @@ -0,0 +1,35 @@ +import { Link } from 'react-router-dom' + +import { SaveToggleButton } from '../../saved' +import type { FeedItem } from '../types' +import { timeAgo } from '../timeAgo' +import { BiasBar } from './BiasBar' + +// FeedItem has no image field (openapi.yaml) — the lead-story layout always uses +// the full-width "no image" branch of UI_PORT_SPEC.md §4.5. +export function LeadStoryCard({ item }: { item: FeedItem }) { + const sourceCount = item.ratedSourceCount + item.unratedSourceCount + + return ( +
+
+ + {sourceCount} {sourceCount === 1 ? 'Source' : 'Sources'} +
+

{timeAgo(item.lastCoverageAt)}

+

+ + {item.title} + +

+ {item.summary !== null &&

{item.summary}

} + +
+ + Compare headlines → + + +
+
+ ) +} diff --git a/apps/web/src/features/feed/hooks/useSourceDistribution.ts b/apps/web/src/features/feed/hooks/useSourceDistribution.ts new file mode 100644 index 0000000..f8f28f6 --- /dev/null +++ b/apps/web/src/features/feed/hooks/useSourceDistribution.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query' + +import { getSourceDistribution } from '../api' + +// Ratings change rarely (manual curation) and the endpoint is itself cached 1h server-side, +// so there's no value in refetching this on every mount. +export function useSourceDistribution() { + return useQuery({ + queryKey: ['sources', 'distribution'], + queryFn: getSourceDistribution, + staleTime: 60 * 60 * 1000, + }) +} diff --git a/apps/web/src/features/feed/hooks/useStoryDetail.ts b/apps/web/src/features/feed/hooks/useStoryDetail.ts index 8c318f7..cb28219 100644 --- a/apps/web/src/features/feed/hooks/useStoryDetail.ts +++ b/apps/web/src/features/feed/hooks/useStoryDetail.ts @@ -1,10 +1,23 @@ import { useQuery } from '@tanstack/react-query' -import { getStoryDetail } from '../api' +import { useSubscription } from '../../billing/hooks/useBilling' +import { getStoryDetail, getStoryFullBreakdown } from '../api' +// Public response is always the free-tier-capped view (shared cache, ARCHITECTURE §10 — +// see api.ts/service.ts for why). Only fetch the entitled-only full breakdown when the base +// response actually reports something hidden and the signed-in reader is entitled. export function useStoryDetail(id: string) { - return useQuery({ - queryKey: ['story', id], - queryFn: () => getStoryDetail(id), + const { data: subscription } = useSubscription() + const baseQuery = useQuery({ queryKey: ['story', id], queryFn: () => getStoryDetail(id) }) + const shouldFetchFull = (subscription?.entitled ?? false) && baseQuery.data?.premiumBreakdown.locked === true + const fullQuery = useQuery({ + queryKey: ['story-full', id], + queryFn: () => getStoryFullBreakdown(id), + enabled: shouldFetchFull, }) + return { + data: fullQuery.data ?? baseQuery.data, + error: baseQuery.error, + isLoading: baseQuery.isLoading, + } } diff --git a/apps/web/src/features/feed/pages/HomeFeedPage.tsx b/apps/web/src/features/feed/pages/HomeFeedPage.tsx index da50b16..378f0a9 100644 --- a/apps/web/src/features/feed/pages/HomeFeedPage.tsx +++ b/apps/web/src/features/feed/pages/HomeFeedPage.tsx @@ -1,7 +1,9 @@ import { useHomeFeed } from '../hooks/useHomeFeed' import { FeedItemCard } from '../components/FeedItemCard' +import { LeadStoryCard } from '../components/LeadStoryCard' import { BiasBarLegend } from '../components/BiasBarLegend' import { EmptyState, ErrorState, LoadingState, LoadMoreButton } from '../../../components/QueryStates' +import { SectionEyebrow } from '../../../components/ui/SectionEyebrow' export function HomeFeedPage() { const { data, error, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage, refetch } = useHomeFeed() @@ -10,12 +12,14 @@ export function HomeFeedPage() { if (error) return const items = data?.pages.flatMap((p) => p.items) ?? [] + const [lead, ...rest] = items const updatedCount = data?.pages[0]?.updatedCount ?? 0 return ( -
-
-

Latest

+
+ Latest +
+

Latest

@@ -23,7 +27,8 @@ export function HomeFeedPage() { @@ -32,10 +37,42 @@ export function HomeFeedPage() { {items.length === 0 ? ( ) : ( - items.map((item) => ) + <> + + {rest.length > 0 && ( +
+ {rest.map((item) => ( + + ))} +
+ )} + )} {hasNextPage && fetchNextPage()} loading={isFetchingNextPage} />} + +
+
+
+ About Newstack +

“

+
+
+

Why we show you the whole spread

+
+

+ Most outlets cover the same story differently — not just in tone, but in which facts lead and which + get left out. Newstack puts those versions side by side, rated independently for bias and factuality, + so you can see the full spread before deciding what to trust. +

+

+ Bias and factuality are never combined into a single score. Our method and every source's rating + history are public, so you can check our work the same way we ask you to check the news. +

+
+
+
+
) } diff --git a/apps/web/src/features/feed/pages/StoryDetailPage.tsx b/apps/web/src/features/feed/pages/StoryDetailPage.tsx index 46b29db..4d7acbb 100644 --- a/apps/web/src/features/feed/pages/StoryDetailPage.tsx +++ b/apps/web/src/features/feed/pages/StoryDetailPage.tsx @@ -1,5 +1,8 @@ -import { Link, useParams } from 'react-router-dom' +import { Link, useNavigate, useParams } from 'react-router-dom' +import { Button } from '../../../components/ui/Button' +import { IconArrowLeft } from '../../../components/ui/icons' +import { SectionEyebrow } from '../../../components/ui/SectionEyebrow' import { EmptyState, ErrorState, LoadingState } from '../../../components/QueryStates' import { SaveToggleButton } from '../../saved' import { BiasColumn } from '../components/BiasColumn' @@ -17,6 +20,7 @@ const COLUMNS: { key: 'left' | 'centerLeft' | 'center' | 'centerRight' | 'right' export function StoryDetailPage() { const { id } = useParams<{ id: string }>() const { data: story, error, isLoading } = useStoryDetail(id ?? '') + const navigate = useNavigate() if (isLoading) return if (error) return @@ -24,38 +28,47 @@ export function StoryDetailPage() { if (story.status === 'tombstoned') { return ( -
-

{story.title}

+
+ The Story +

{story.title}

{story.redirectTo !== null ? ( -

+

This story moved to{' '} - + {story.redirectTo.title} .

) : ( -

This story is no longer available.

+

This story is no longer available.

)}
) } + const total = Object.values(story.biasColumns).reduce((sum, cards) => sum + cards.length, 0) + return ( -
+
+ + + The Story
-

{story.title}

+

{story.title}

- {story.summary !== null &&

{story.summary}

} + {story.summary !== null &&

{story.summary}

} {story.splitNotices.length > 0 && ( -
+
Also continued in:{' '} {story.splitNotices.map((notice, i) => ( {i > 0 && ', '} - + {notice.title} {' '} ({notice.articleCount} articles) @@ -64,17 +77,44 @@ export function StoryDetailPage() {
)} -

- {story.ratedSourceCount} rated sources - {story.unratedSourceCount > 0 ? `, ${story.unratedSourceCount} unrated` : ''} · Based on current source - ratings -

+
+
Coverage spread
+ {total === 0 ? ( +

No sources yet.

+ ) : ( +
+ {COLUMNS.map((col) => { + const count = story.biasColumns[col.key].length + if (count === 0) return null + return
+ })} +
+ )} +

+ {story.ratedSourceCount} rated sources{story.unratedSourceCount > 0 ? ` · ${story.unratedSourceCount} unrated` : ''} +

+
-
+
+

How it's being told

+

The same event, as headlined by each outlet.

+
+ +
{COLUMNS.map((col) => ( ))}
+ + {story.premiumBreakdown.locked && ( +
+

+ {story.premiumBreakdown.hiddenCardCount} more source{story.premiumBreakdown.hiddenCardCount === 1 ? '' : 's'} covering + this story aren't shown. +

+ +
+ )}
) } diff --git a/apps/web/src/features/feed/timeAgo.ts b/apps/web/src/features/feed/timeAgo.ts new file mode 100644 index 0000000..a5f3677 --- /dev/null +++ b/apps/web/src/features/feed/timeAgo.ts @@ -0,0 +1,7 @@ +export function timeAgo(iso: string): string { + const ms = Date.now() - new Date(iso).getTime() + const hours = Math.round(ms / 3_600_000) + if (hours < 1) return 'just now' + if (hours < 24) return `${hours}h ago` + return `${Math.round(hours / 24)}d ago` +} diff --git a/apps/web/src/features/feed/types.ts b/apps/web/src/features/feed/types.ts index 1817aa3..5ccb69d 100644 --- a/apps/web/src/features/feed/types.ts +++ b/apps/web/src/features/feed/types.ts @@ -5,8 +5,14 @@ import type { components } from '../../../../../packages/shared-types/src/openap export type BiasBarSegment = components['schemas']['BiasBarSegment'] export type BiasBar = components['schemas']['BiasBar'] +export type SourceDistribution = components['schemas']['SourceDistribution'] export type FeedItem = components['schemas']['FeedItem'] export type FeedResponse = components['schemas']['FeedResponse'] export type SourceCard = components['schemas']['SourceCard'] export type BiasColumns = components['schemas']['BiasColumns'] -export type StoryDetail = components['schemas']['StoryDetailResponse'] +// The generated schema predates the paywall gate (docs/openapi.yaml isn't in this repo to +// regenerate from — see packages/shared-types/README.md), so premiumBreakdown is added here +// by hand rather than by running `npm run generate`. +export type StoryDetail = components['schemas']['StoryDetailResponse'] & { + premiumBreakdown: { locked: boolean; hiddenCardCount: number } +} diff --git a/apps/web/src/features/saved/components/SaveToggleButton.test.tsx b/apps/web/src/features/saved/components/SaveToggleButton.test.tsx index c60bdeb..d5e1a15 100644 --- a/apps/web/src/features/saved/components/SaveToggleButton.test.tsx +++ b/apps/web/src/features/saved/components/SaveToggleButton.test.tsx @@ -24,6 +24,6 @@ describe('SaveToggleButton', () => { new Response(JSON.stringify({ items: ['story-1'] }), { status: 200, headers: { 'content-type': 'application/json' } }) ) renderWithProviders() - expect(await screen.findByText('Saved')).toBeInTheDocument() + expect(await screen.findByText('Saved ✓')).toBeInTheDocument() }) }) diff --git a/apps/web/src/features/saved/components/SaveToggleButton.tsx b/apps/web/src/features/saved/components/SaveToggleButton.tsx index a1615ce..609f343 100644 --- a/apps/web/src/features/saved/components/SaveToggleButton.tsx +++ b/apps/web/src/features/saved/components/SaveToggleButton.tsx @@ -1,5 +1,6 @@ import { Link } from 'react-router-dom' +import { Button } from '../../../components/ui/Button' import { useAuthStore } from '../../../lib/authStore' import { useSavedIds, useSavedToggle } from '../hooks/useSaved' @@ -21,24 +22,20 @@ export function SaveToggleButton({ kind, id }: { kind: Kind; id: string }) { if (!isSignedIn) { return ( - + Sign in to {idleLabel.toLowerCase()} ) } return ( - + {isSaved ? `${activeLabel} ✓` : idleLabel} + ) } diff --git a/apps/web/src/features/saved/pages/SavedLayout.tsx b/apps/web/src/features/saved/pages/SavedLayout.tsx index 598b569..8da1a84 100644 --- a/apps/web/src/features/saved/pages/SavedLayout.tsx +++ b/apps/web/src/features/saved/pages/SavedLayout.tsx @@ -1,5 +1,7 @@ import { NavLink, Outlet } from 'react-router-dom' +import { SectionEyebrow } from '../../../components/ui/SectionEyebrow' + const TABS = [ { to: '/me/saved/stories', label: 'Stories' }, { to: '/me/saved/sources', label: 'Sources' }, @@ -9,22 +11,20 @@ const TABS = [ export function SavedLayout() { return (
-

Saved

- +
) diff --git a/apps/web/src/features/saved/pages/SavedSourcesTab.tsx b/apps/web/src/features/saved/pages/SavedSourcesTab.tsx index 0845b8b..f84f84f 100644 --- a/apps/web/src/features/saved/pages/SavedSourcesTab.tsx +++ b/apps/web/src/features/saved/pages/SavedSourcesTab.tsx @@ -1,3 +1,6 @@ +import { Link } from 'react-router-dom' + +import { Button } from '../../../components/ui/Button' import { ErrorState, LoadingState } from '../../../components/QueryStates' import { useSavedSources, useSavedToggle } from '../hooks/useSaved' @@ -7,22 +10,20 @@ export function SavedSourcesTab() { if (isLoading) return if (error) return - if (items.length === 0) return

You're not following any sources yet.

+ if (items.length === 0) return

You're not following any sources yet.

return ( -
    +
      {items.map((source) => ( -
    • -
      +
    • +

      {source.name}

      -

      +

      {source.domain} {source.status === 'deprecated' && ' · no longer updated'}

      -
- + + ))} diff --git a/apps/web/src/features/saved/pages/SavedStoriesTab.tsx b/apps/web/src/features/saved/pages/SavedStoriesTab.tsx index 91bbc15..a66f404 100644 --- a/apps/web/src/features/saved/pages/SavedStoriesTab.tsx +++ b/apps/web/src/features/saved/pages/SavedStoriesTab.tsx @@ -1,5 +1,6 @@ import { Link } from 'react-router-dom' +import { Button } from '../../../components/ui/Button' import { ErrorState, LoadingState } from '../../../components/QueryStates' import { useSavedStories, useSavedToggle } from '../hooks/useSaved' @@ -9,27 +10,25 @@ export function SavedStoriesTab() { if (isLoading) return if (error) return - if (items.length === 0) return

You haven't saved any stories yet.

+ if (items.length === 0) return

You haven't saved any stories yet.

return ( -
    +
      {items.map((story) => { // PRODUCT §7.2: a tombstoned saved story shows its successor. const target = story.status === 'tombstoned' && story.redirectTo !== null ? story.redirectTo : story const unavailable = story.status === 'tombstoned' && story.redirectTo === null return ( -
    • +
    • {unavailable ? ( -

      {story.title} (unavailable)

      +

      {story.title} (unavailable)

      ) : ( {target.title} )} - +
    • ) })} diff --git a/apps/web/src/features/saved/pages/SavedTopicsTab.tsx b/apps/web/src/features/saved/pages/SavedTopicsTab.tsx index da977ba..e6dab78 100644 --- a/apps/web/src/features/saved/pages/SavedTopicsTab.tsx +++ b/apps/web/src/features/saved/pages/SavedTopicsTab.tsx @@ -1,5 +1,6 @@ import { Link } from 'react-router-dom' +import { Button } from '../../../components/ui/Button' import { ErrorState, LoadingState } from '../../../components/QueryStates' import { useSavedTopics, useSavedToggle } from '../hooks/useSaved' @@ -9,18 +10,16 @@ export function SavedTopicsTab() { if (isLoading) return if (error) return - if (items.length === 0) return

      You're not following any topics yet.

      + if (items.length === 0) return

      You're not following any topics yet.

      return ( -
        +
          {items.map((topic) => ( -
        • +
        • {topic.name} - +
        • ))}
        diff --git a/apps/web/src/features/sources/api.ts b/apps/web/src/features/sources/api.ts new file mode 100644 index 0000000..41e9ecb --- /dev/null +++ b/apps/web/src/features/sources/api.ts @@ -0,0 +1,31 @@ +import { apiFetch } from '../../lib/apiClient' +import type { Ownership, Source, SourceGeoRatings, SourceListFilters, SourceListResponse, SourceRatings, SourceSensationalism } from './types' + +export function getSource(id: string): Promise { + return apiFetch(`/api/public/sources/${id}`) +} + +export function listSources(filters: SourceListFilters): Promise { + const params = new URLSearchParams() + for (const [key, value] of Object.entries(filters)) { + if (value !== undefined && value !== '') params.set(key, String(value)) + } + const query = params.toString() + return apiFetch(`/api/public/sources${query === '' ? '' : `?${query}`}`) +} + +export function getSourceRatings(id: string): Promise { + return apiFetch(`/api/public/sources/${id}/ratings`) +} + +export function getSourceGeoRatings(id: string): Promise { + return apiFetch(`/api/public/sources/${id}/geo`) +} + +export function getSourceSensationalism(id: string): Promise { + return apiFetch(`/api/public/sources/${id}/sensationalism`) +} + +export function getSourceOwnership(id: string): Promise { + return apiFetch(`/api/public/sources/${id}/ownership`) +} diff --git a/apps/web/src/features/sources/geoTopics.ts b/apps/web/src/features/sources/geoTopics.ts new file mode 100644 index 0000000..6e824c0 --- /dev/null +++ b/apps/web/src/features/sources/geoTopics.ts @@ -0,0 +1,34 @@ +import type { GeoRating, GeoTopicCode } from './types' + +// PRODUCT §5.6: topic-specific pole names are a render-time concern, not stored in the schema. +export const GEO_TOPICS: Record = { + IN_PAK: { label: 'India–Pakistan', minusPole: 'Dialogue-first', plusPole: 'Deterrence-first' }, + IN_CHN: { label: 'India–China', minusPole: 'De-escalation-first', plusPole: 'Assertive-sovereignty-first' }, + IN_US: { label: 'India–US', minusPole: 'Strategic-autonomy-first', plusPole: 'Alliance-alignment-first' }, + IN_RUS: { label: 'India–Russia', minusPole: 'Western-realignment-first', plusPole: 'Russia-partnership-first' }, + IN_NEIGH: { label: 'Neighbourhood', minusPole: 'Regional-accommodation-first', plusPole: 'Primacy-assertion-first' }, + DEFSEC: { label: 'Defence & security', minusPole: 'Civil-oversight-first', plusPole: 'Operational-secrecy-first' }, + MULTILAT: { label: 'Multilateral institutions', minusPole: 'Institution-binding-first', plusPole: 'Sovereignty-first / unilateralist' }, + INDOPAC: { label: 'Indo-Pacific', minusPole: 'Cooperative-security-first', plusPole: 'Great-power-rivalry framing' }, +} + +export const GEO_TOPIC_ORDER: GeoTopicCode[] = ['IN_PAK', 'IN_CHN', 'IN_US', 'IN_RUS', 'IN_NEIGH', 'DEFSEC', 'MULTILAT', 'INDOPAC'] + +// PRODUCT §3.5/§5.6: never call an unrated topic "neutral" — absence of evidence is not a claim. +export function stanceLabel(topic: GeoTopicCode, stance: GeoRating['stance']): string { + const { minusPole, plusPole } = GEO_TOPICS[topic] + switch (stance) { + case 'strongly_minus': + return `Strongly ${minusPole.toLowerCase()}` + case 'leans_minus': + return `Leans ${minusPole.toLowerCase()}` + case 'mixed_or_issue_dependent': + return 'Mixed / issue-dependent' + case 'leans_plus': + return `Leans ${plusPole.toLowerCase()}` + case 'strongly_plus': + return `Strongly ${plusPole.toLowerCase()}` + case 'insufficient_evidence': + return 'Insufficient evidence' + } +} diff --git a/apps/web/src/features/sources/hooks/useSource.ts b/apps/web/src/features/sources/hooks/useSource.ts new file mode 100644 index 0000000..8150360 --- /dev/null +++ b/apps/web/src/features/sources/hooks/useSource.ts @@ -0,0 +1,32 @@ +import { useQuery } from '@tanstack/react-query' + +import { getSource, getSourceGeoRatings, getSourceOwnership, getSourceRatings, getSourceSensationalism, listSources } from '../api' +import type { SourceListFilters } from '../types' + +export function useSource(id: string) { + return useQuery({ queryKey: ['source', id], queryFn: () => getSource(id), enabled: id !== '' }) +} + +export function useSourceList(filters: SourceListFilters) { + return useQuery({ queryKey: ['sources', 'list', filters], queryFn: () => listSources(filters) }) +} + +export function useSourceRatings(id: string) { + return useQuery({ queryKey: ['source', id, 'ratings'], queryFn: () => getSourceRatings(id), enabled: id !== '' }) +} + +export function useSourceGeoRatings(id: string) { + return useQuery({ queryKey: ['source', id, 'geo'], queryFn: () => getSourceGeoRatings(id), enabled: id !== '' }) +} + +export function useSourceSensationalism(id: string) { + return useQuery({ + queryKey: ['source', id, 'sensationalism'], + queryFn: () => getSourceSensationalism(id), + enabled: id !== '', + }) +} + +export function useSourceOwnership(id: string) { + return useQuery({ queryKey: ['source', id, 'ownership'], queryFn: () => getSourceOwnership(id), enabled: id !== '' }) +} diff --git a/apps/web/src/features/sources/index.ts b/apps/web/src/features/sources/index.ts new file mode 100644 index 0000000..ccc25c5 --- /dev/null +++ b/apps/web/src/features/sources/index.ts @@ -0,0 +1,3 @@ +export { sourceRoutes } from './routes' +export { useSourceOwnership, useSourceSensationalism } from './hooks/useSource' +export { OWNERSHIP_TRANSPARENCY_LABEL, SENSATIONALISM_LABEL } from './labels' diff --git a/apps/web/src/features/sources/labels.ts b/apps/web/src/features/sources/labels.ts new file mode 100644 index 0000000..443581c --- /dev/null +++ b/apps/web/src/features/sources/labels.ts @@ -0,0 +1,13 @@ +export const SENSATIONALISM_LABEL: Record = { + low: 'Low sensationalism', + medium: 'Medium sensationalism', + high: 'High sensationalism', + insufficient_evidence: 'Insufficient evidence', +} + +export const OWNERSHIP_TRANSPARENCY_LABEL: Record = { + disclosed: 'Ownership disclosed', + partially_disclosed: 'Ownership partially disclosed', + undisclosed: 'Ownership undisclosed', + insufficient_evidence: 'Ownership: insufficient evidence', +} diff --git a/apps/web/src/features/sources/pages/SourceDirectoryPage.tsx b/apps/web/src/features/sources/pages/SourceDirectoryPage.tsx new file mode 100644 index 0000000..9cac9de --- /dev/null +++ b/apps/web/src/features/sources/pages/SourceDirectoryPage.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react' +import { Link, useSearchParams } from 'react-router-dom' + +import { EmptyState, ErrorState, LoadingState, LoadMoreButton } from '../../../components/QueryStates' +import { SectionEyebrow } from '../../../components/ui/SectionEyebrow' +import { Tag } from '../../../components/ui/Tag' +import { useSourceList } from '../hooks/useSource' +import type { SourceCategory } from '../types' + +const CATEGORIES: { value: SourceCategory; label: string }[] = [ + { value: 'national', label: 'National' }, + { value: 'regional', label: 'Regional' }, + { value: 'language', label: 'Language' }, + { value: 'business', label: 'Business' }, + { value: 'geopolitical', label: 'Geopolitical' }, + { value: 'specialist', label: 'Specialist' }, +] + +const PAGE_SIZE = 20 + +export function SourceDirectoryPage() { + const [searchParams] = useSearchParams() + const [q, setQ] = useState(searchParams.get('q') ?? '') + const [category, setCategory] = useState(undefined) + const [country, setCountry] = useState('') + const [language, setLanguage] = useState('') + const [limit, setLimit] = useState(PAGE_SIZE) + + const { data, error, isLoading, isFetching } = useSourceList({ q, category, country, language, limit }) + + return ( +
        + Source Directory +

        Source directory

        + +
        + { + setLimit(PAGE_SIZE) + setQ(e.target.value) + }} + className="input max-w-sm" + /> +
        + {CATEGORIES.map((c) => ( + + ))} +
        +
        + { + setLimit(PAGE_SIZE) + setCountry(e.target.value.toUpperCase()) + }} + className="input max-w-[160px]" + /> + { + setLimit(PAGE_SIZE) + setLanguage(e.target.value.toLowerCase()) + }} + className="input max-w-[160px]" + /> +
        +
        + + {isLoading ? ( + + ) : error ? ( + + ) : data === undefined || data.items.length === 0 ? ( + + ) : ( + <> +

        + {data.total} source{data.total === 1 ? '' : 's'} +

        +
        + {data.items.map((source) => ( + + {source.name} + + {source.countryCode} · {source.language} + + {source.categories.length > 0 && ( +
        + {source.categories.map((cat) => ( + + {cat} + + ))} +
        + )} + + ))} +
        + {data.items.length < data.total && ( + setLimit((l) => l + PAGE_SIZE)} loading={isFetching} /> + )} + + )} +
        + ) +} diff --git a/apps/web/src/features/sources/pages/SourceProfilePage.tsx b/apps/web/src/features/sources/pages/SourceProfilePage.tsx new file mode 100644 index 0000000..bdd544f --- /dev/null +++ b/apps/web/src/features/sources/pages/SourceProfilePage.tsx @@ -0,0 +1,163 @@ +import { useNavigate, useParams } from 'react-router-dom' + +import { Button } from '../../../components/ui/Button' +import { IconAlert, IconArrowLeft } from '../../../components/ui/icons' +import { SectionEyebrow } from '../../../components/ui/SectionEyebrow' +import { Tag } from '../../../components/ui/Tag' +import { EmptyState, ErrorState, LoadingState } from '../../../components/QueryStates' +import { SaveToggleButton } from '../../saved' +import { GEO_TOPIC_ORDER, GEO_TOPICS, stanceLabel } from '../geoTopics' +import { useSource, useSourceGeoRatings, useSourceOwnership, useSourceRatings, useSourceSensationalism } from '../hooks/useSource' +import { OWNERSHIP_TRANSPARENCY_LABEL, SENSATIONALISM_LABEL } from '../labels' + +const EDITORIAL_INDEPENDENCE_LABEL: Record = { + no_documented_influence: 'No documented editorial influence', + documented_instances: 'Documented instances of influence', + insufficient_evidence: 'Editorial independence: insufficient evidence', +} + +function monogram(name: string): string { + return name + .split(/\s+/) + .map((w) => w[0]) + .join('') + .slice(0, 3) + .toUpperCase() +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) +} + +export function SourceProfilePage() { + const { id } = useParams<{ id: string }>() + const sourceId = id ?? '' + const navigate = useNavigate() + const { data: source, error, isLoading } = useSource(sourceId) + const { data: ratings } = useSourceRatings(sourceId) + const { data: sensationalism } = useSourceSensationalism(sourceId) + const { data: ownership } = useSourceOwnership(sourceId) + const { data: geoRatings } = useSourceGeoRatings(sourceId) + + if (isLoading) return + if (error) return + if (source === undefined) return + + return ( +
        + + + Source Profile + + {source.status === 'deprecated' && ( +
        + +
        +

        No longer updated

        +

        This source is deprecated. Its last rating stays visible.

        +
        +
        + )} + +
        +
        + {monogram(source.name)} +
        +
        +

        {source.name}

        + + {source.domain} ↗ + +
        + +
        + + {(ratings?.current || sensationalism?.current) && ( +
        + {ratings?.current?.biasRating != null && {ratings.current.biasRating}} + {ratings?.current && {ratings.current.factuality} factuality} + {sensationalism?.current && {SENSATIONALISM_LABEL[sensationalism.current.level]}} +
        + )} +

        + Bias, factuality, sensationalism, geopolitical positioning, and ownership are rated independently and never combined + into one score. +

        + +
        +

        Rating history

        + {ratings === undefined || ratings.history.length === 0 ? ( +

        No ratings yet.

        + ) : ( +
        + + + + + + + + + + + + {ratings.history.map((r) => ( + + + + + + + + ))} + +
        DateRated byMethodBiasFactuality
        {formatDate(r.ratedAt)}{r.ratedBy}{r.method}{r.biasRating ?? '—'}{r.factuality}
        +
        + )} +
        + +
        +

        Ownership and editorial independence

        + {ownership === null || ownership === undefined ? ( +

        Not yet researched.

        + ) : ( + <> +
        + {OWNERSHIP_TRANSPARENCY_LABEL[ownership.ownershipTransparency]} + {EDITORIAL_INDEPENDENCE_LABEL[ownership.editorialIndependence]} +
        + {ownership.notes &&

        {ownership.notes}

        } + + )} +
        + +
        +

        Geopolitical positioning

        +

        + Rated per topic, independent of domestic bias — most outlets carry no rating on most topics. +

        +
        + {GEO_TOPIC_ORDER.map((topic) => { + const entry = geoRatings?.[topic]?.current ?? null + return ( +
        + {GEO_TOPICS[topic].label} + {entry === null ? ( + Not yet assessed + ) : ( + {stanceLabel(topic, entry.stance)} + )} +
        + ) + })} +
        +
        +
        + ) +} diff --git a/apps/web/src/features/sources/routes.tsx b/apps/web/src/features/sources/routes.tsx new file mode 100644 index 0000000..b14356a --- /dev/null +++ b/apps/web/src/features/sources/routes.tsx @@ -0,0 +1,9 @@ +import type { RouteObject } from 'react-router-dom' + +import { SourceDirectoryPage } from './pages/SourceDirectoryPage' +import { SourceProfilePage } from './pages/SourceProfilePage' + +export const sourceRoutes: RouteObject[] = [ + { path: '/sources', element: }, + { path: '/sources/:id', element: }, +] diff --git a/apps/web/src/features/sources/types.ts b/apps/web/src/features/sources/types.ts new file mode 100644 index 0000000..b2b7707 --- /dev/null +++ b/apps/web/src/features/sources/types.ts @@ -0,0 +1,33 @@ +import type { components } from '../../../../../packages/shared-types/src/openapi' + +export type Source = components['schemas']['Source'] +export type Rating = components['schemas']['Rating'] +export interface SourceRatings { + current: Rating | null + history: Rating[] +} + +export type GeoRating = components['schemas']['GeoRating'] +export type GeoTopicCode = GeoRating['topicCode'] +export type SourceGeoRatings = Record + +export type SensationalismRating = components['schemas']['SensationalismRating'] +export interface SourceSensationalism { + current: SensationalismRating | null + history: SensationalismRating[] +} + +export type Ownership = components['schemas']['Ownership'] + +export type SourceListItem = components['schemas']['SourceListItem'] +export type SourceCategory = SourceListItem['categories'][number] +export type SourceListResponse = components['schemas']['SourceListResponse'] + +export interface SourceListFilters { + category?: SourceCategory + country?: string + language?: string + q?: string + limit?: number + offset?: number +} diff --git a/apps/web/src/features/topics/pages/TopicFeedPage.tsx b/apps/web/src/features/topics/pages/TopicFeedPage.tsx index 9a86b27..33f33ff 100644 --- a/apps/web/src/features/topics/pages/TopicFeedPage.tsx +++ b/apps/web/src/features/topics/pages/TopicFeedPage.tsx @@ -2,6 +2,7 @@ import { useParams } from 'react-router-dom' import { FeedItemCard } from '../../feed' import { EmptyState, ErrorState, LoadingState, LoadMoreButton } from '../../../components/QueryStates' +import { SectionEyebrow } from '../../../components/ui/SectionEyebrow' import { SaveToggleButton } from '../../saved' import { useTopicFeed, useTopics } from '../hooks/useTopics' @@ -18,15 +19,20 @@ export function TopicFeedPage() { const items = data?.pages.flatMap((p) => p.items) ?? [] return ( -
        +
        + Topic
        -

        {topic?.name ?? 'Topic'}

        +

        {topic?.name ?? 'Topic'}

        {topicId !== '' && }
        {items.length === 0 ? ( ) : ( - items.map((item) => ) +
        + {items.map((item) => ( + + ))} +
        )} {hasNextPage && fetchNextPage()} loading={isFetchingNextPage} />}
        diff --git a/apps/web/src/features/topics/pages/TopicListPage.tsx b/apps/web/src/features/topics/pages/TopicListPage.tsx index 0841f56..ea8b73f 100644 --- a/apps/web/src/features/topics/pages/TopicListPage.tsx +++ b/apps/web/src/features/topics/pages/TopicListPage.tsx @@ -1,6 +1,7 @@ import { Link } from 'react-router-dom' import { EmptyState, ErrorState, LoadingState } from '../../../components/QueryStates' +import { SectionEyebrow } from '../../../components/ui/SectionEyebrow' import { SaveToggleButton } from '../../saved' import { useTopics } from '../hooks/useTopics' @@ -12,18 +13,22 @@ export function TopicListPage() { if (topics === undefined || topics.length === 0) return return ( -
        -

        Topics

        -
          +
          + Topics +
          +

          Topics

          +

          Follow topics to shape your feed.

          +
          +
          {topics.map((topic) => ( -
        • - +
          + {topic.name} -
        • +
          ))} -
        +
        ) } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index a11a11f..f3d844c 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,4 +1,6 @@ +@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,600;0,700;0,800;0,900;1,400;1,500&family=Source+Sans+3:wght@400;500;600;700&display=swap'); @import 'tailwindcss'; +@import './styles/modernist.css' layer(components); /* * Bias spectrum ramp (ARCHITECTURE §10: "one diverging 5-step colour scale @@ -6,6 +8,7 @@ * Diverging ramps are checked for lightness monotonicity per arm, not run * through the categorical CVD validator (dataviz skill, color-formula.md * "Scope" note) — poles reuse the skill's default blue/red diverging pair. + * The Modernist reference design is light-only, so there's no dark variant. */ @theme { --color-bias-left: #184f95; @@ -16,17 +19,12 @@ --color-bias-unrated: #e1e0d9; } -@media (prefers-color-scheme: dark) { - @theme { - --color-bias-left: #3987e5; - --color-bias-centerleft: #104281; - --color-bias-center: #383835; - --color-bias-centerright: #7a2f2b; - --color-bias-right: #e66767; - --color-bias-unrated: #2c2c2a; - } +html { + color-scheme: light; + scroll-behavior: smooth; } -html { - color-scheme: light dark; +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + *, *::before, *::after { transition: none !important; } } diff --git a/apps/web/src/styles/modernist.css b/apps/web/src/styles/modernist.css new file mode 100644 index 0000000..03e32a0 --- /dev/null +++ b/apps/web/src/styles/modernist.css @@ -0,0 +1,443 @@ +/* Modernist — design-system tokens and component classes. This file is the source of truth for the system's look; retune it here and see readme.md. */ +/* Font loaded from index.css (top-level @import — can't nest inside this file's @layer wrapper). */ + +:root { + /* Editorial newspaper palette (ported from seeedstack.github.io). Role + split (don't conflate): saffron = nav indicators / selected states / + highlights; ink = primary interactive text; green = CTA/primary + buttons, links on hover, positive badges; cream = dominant background. */ + --india-saffron: #e69a32; + --india-white: #faf9f5; + --india-green: #28734b; + --ashoka-chakra-blue: #171a17; + + --color-bg: #faf9f5; + --color-surface: #f3f1ea; + --color-surface-alt: #f1eee4; + --color-image-bg: #eeece5; + --color-text: #171a17; + --color-text-secondary: #3f423e; + /* #656862, not the reference's #73766f — see UI_PORT_SPEC.md §2.3, the + reference value fails WCAG AA on cream/surface. */ + --color-text-muted: #656862; + --color-accent: var(--india-green); + --color-accent-2: var(--india-saffron); + --color-divider: #dad8d0; + --color-footer-bg: #171a17; + --color-footer-rule: #3a3d39; + --color-footer-muted: #a9aba4; + --color-footer-text-soft: #d9d8d2; + --color-selection: #f2d3a6; + + /* Tonal ramps — generated in OKLCH on one shared lightness scale, so the + same step of any role matches the others in visual value. */ + --color-neutral-100: #f8f4f4; + --color-neutral-200: #eae7e7; + --color-neutral-300: #d7d3d3; + --color-neutral-400: #bab6b6; + --color-neutral-500: #9b9797; + --color-neutral-600: #7d7979; + --color-neutral-700: #605d5d; + --color-neutral-800: #444141; + --color-neutral-900: #2d2b2b; + + /* --color-accent-* : green ramp (CTA buttons, links, focus — "primary interactive"). */ + --color-accent-100: #e7f0ea; + --color-accent-200: #c3dbcb; + --color-accent-300: #93bea3; + --color-accent-400: #5d9a72; + --color-accent-500: #28734b; + --color-accent-600: #215f3e; + --color-accent-700: #1a4b31; + --color-accent-800: #123523; + --color-accent-900: #0b2016; + + /* --color-accent-2-* : saffron ramp (secondary badges, nav highlights). */ + --color-accent-2-100: #fdf1e2; + --color-accent-2-200: #fbdfb8; + --color-accent-2-300: #f5c889; + --color-accent-2-400: #eeb15c; + --color-accent-2-500: #e69a32; + --color-accent-2-600: #c27f22; + --color-accent-2-700: #9c6419; + --color-accent-2-800: #754b12; + --color-accent-2-900: #4d320c; + + /* --color-danger-* : the app's original red/orange ramp, kept verbatim for + error/alert/deprecated-source states now that --color-accent is navy. */ + --color-danger: #ec3013; + --color-danger-100: #fff2ef; + --color-danger-200: #ffe0d9; + --color-danger-300: #ffc4b8; + --color-danger-400: #ff9783; + --color-danger-500: #ff563c; + --color-danger-600: #dd2b0f; + --color-danger-700: #ae1800; + --color-danger-800: #7c1405; + --color-danger-900: #4d170e; + + --font-heading: "Playfair Display", Georgia, serif; + --font-heading-weight: 800; + --font-body: "Source Sans 3", system-ui, sans-serif; + --font-headline: "Playfair Display", Georgia, serif; + --font-headline-weight: 700; + + --space-1: 4.0px; + --space-2: 8.0px; + --space-3: 12.0px; + --space-4: 16.0px; + --space-6: 24.0px; + --space-8: 32.0px; + + --radius-sm: 0px; + --radius-md: 0px; + --radius-lg: 0px; + + /* Elevation — derived from the ground: soft ink-tinted shadows on a + light theme, a hairline edge + ambient darkness on a dark one. */ + --shadow-sm: 0 1px 2px color-mix(in srgb, #2d2b2b 14%, transparent); + --shadow-md: 0 3px 10px color-mix(in srgb, #2d2b2b 16%, transparent); + --shadow-lg: 0 12px 32px color-mix(in srgb, #2d2b2b 22%, transparent); +} + +body { + background: var(--color-bg); + color: var(--color-text); + font-family: var(--font-body); +} +h1, h2, h3, h4 { font-family: var(--font-heading); font-weight: var(--font-heading-weight); } + +.grayscale{filter:grayscale(1) contrast(1.08)} + +/* ══════════════════════════════════════════════════════════════════════════ + Components — built with the tokens above. Plain CSS + on plain HTML: no JavaScript, no build step. Each class is documented in + readme.md and demonstrated in foundations/ and components/. + ══════════════════════════════════════════════════════════════════════ */ + +*, *::before, *::after { box-sizing: border-box; } +body { margin: 0; font-size: 15px; line-height: 1.55; font-weight: 400; } +h1, h2, h3, h4, h5, h6 { + font-family: var(--font-heading); font-weight: var(--font-heading-weight); + line-height: 1.12; letter-spacing: -0.015em; margin: 0 0 var(--space-2); +} +/* Editorial headline override — the Latest heading, storyboard card headlines, + and article page titles. Font-size/margin stay whatever the element already + has; only the typeface switches, so existing card layouts don't shift. */ +.article-headline { + font-family: var(--font-headline); font-weight: var(--font-headline-weight); + line-height: 1.3; letter-spacing: -0.005em; +} +h1 { font-size: 42px; } +h2 { font-size: 32px; } +h3 { font-size: 25px; } +h4 { font-size: 20px; } +h5 { font-size: 16px; } +h6 { font-size: 13px; } +h6 { letter-spacing: 0.08em; text-transform: uppercase; } +p { margin: 0 0 var(--space-3); } +a { color: var(--color-accent); text-underline-offset: 3px; } +img { display: block; max-width: 100%; } +figure { margin: 0; } +figcaption { + font-size: 11px; margin-top: var(--space-1); + color: color-mix(in srgb, var(--color-text) 55%, transparent); +} +.text-muted { color: color-mix(in srgb, var(--color-text) 55%, transparent); } +:focus { outline: none; } +/* Saffron ring (ref) + inner 1px ink ring — saffron alone is 2.21:1 on cream, + the ink ring brings interactive elements sitting on cream to 3:1 (UI_PORT_SPEC.md §7.2). */ +:focus-visible { outline: 2px solid var(--india-saffron); outline-offset: 3px; box-shadow: 0 0 0 1px var(--color-text); } +.site-footer :focus-visible { box-shadow: none; } +::selection { background: var(--color-selection); } + +/* — rules — */ +.hr { + height: 2px; border: 0; margin: var(--space-4) 0; + background: var(--color-divider); +} + +/* — buttons — */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + cursor: pointer; text-decoration: none; + font-family: var(--font-body); font-weight: 700; + font-size: 14px; line-height: 1.2; color: var(--color-text); /* matches the .input's 14px — + the pair sits side by side in sign-up rows */ + background: transparent; border: 1px solid transparent; + padding: var(--space-2) calc(var(--space-3) * 1.2); + border-radius: var(--radius-md); +} +.btn svg { display: block; } +.btn:disabled { opacity: 0.45; cursor: not-allowed; } +.btn-primary { background: var(--color-accent); color: var(--color-bg); } +.btn-primary:hover { background: var(--color-accent-600); } +.btn-primary:active { background: var(--color-accent-700); } +.btn-secondary { border-color: var(--color-divider); } +.btn-secondary:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); } +.btn-secondary:active { background: color-mix(in srgb, var(--color-text) 14%, transparent); } +.btn-ghost { color: var(--color-accent); padding-inline: var(--space-1); } +.btn-ghost:hover { background: color-mix(in srgb, var(--color-accent) 10%, transparent); } +.btn-ghost:active { background: color-mix(in srgb, var(--color-accent) 18%, transparent); } +.btn-icon { width: 36px; height: 36px; padding: 0; } +.btn-block { width: 100%; margin-top: var(--space-2); justify-content: flex-start; text-align: left; } + +/* — forms — */ +.field > label { + display: block; font-size: 12px; margin-bottom: 5px; + color: var(--color-text-secondary); +} +.input { + width: 100%; min-height: 36px; padding: 6px 10px; font: inherit; + font-size: 14px; color: var(--color-text); caret-color: var(--color-accent); + background: var(--color-surface); + border: 1px solid var(--color-divider); border-radius: var(--radius-md); +} +.input:hover { border-color: color-mix(in srgb, var(--color-text) 45%, transparent); } +.input:focus-visible { border-color: var(--color-accent); outline-offset: 0; } +textarea.input { min-height: 90px; resize: vertical; } +.radio { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; } +.radio input, .seg-opt input { + position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; +} +.radio .dot { + width: 16px; height: 16px; flex: none; border-radius: 50%; + border: 1.5px solid var(--color-divider); +} +.radio:hover .dot { border-color: var(--color-accent); } +.radio input:checked + .dot { + border-color: var(--color-accent); background: var(--color-accent); + box-shadow: inset 0 0 0 4px var(--color-bg); +} +.radio input:focus-visible + .dot { outline: 2px solid var(--color-accent); outline-offset: 2px; } +.seg { + display: inline-flex; overflow: hidden; + border: 1px solid var(--color-divider); border-radius: var(--radius-md); +} +.seg-opt { + display: inline-flex; align-items: center; gap: 6px; + padding: 7px 12px; font-size: 13px; cursor: pointer; +} +.seg-opt + .seg-opt { border-left: 1px solid var(--color-divider); } +.seg-opt:has(input:checked) { background: var(--color-accent); color: var(--color-bg); } +.seg-opt:not(:has(input:checked)):hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); } +.seg-opt:has(input:focus-visible) { outline: 2px solid var(--color-accent); outline-offset: -2px; } + +/* — cards — */ +.card { + display: flex; flex-direction: column; gap: var(--space-2); + padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-surface); +} +.card-kicker { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: color-mix(in srgb, var(--india-saffron) 65%, black); } +.card-title { + font-family: var(--font-heading); font-weight: var(--font-heading-weight); + font-size: 17px; line-height: 1.2; +} +.card-body { margin: 0; font-size: 13px; opacity: 0.8; flex: 1; } +.card-meta { + display: flex; align-items: center; gap: 6px; font-size: 11px; + color: color-mix(in srgb, var(--color-text) 50%, transparent); +} +.elev-sm { box-shadow: var(--shadow-sm); } +.elev-md { box-shadow: var(--shadow-md); } +.elev-lg { box-shadow: var(--shadow-lg); } + +/* — tags — */ +.tag { + display: inline-flex; align-items: center; font-size: 11px; + letter-spacing: 0.02em; padding: 3px 10px; + border-radius: calc(var(--radius-md) * 0.75); +} +.tag-accent { background: color-mix(in srgb, var(--india-saffron) 15%, white); color: var(--ashoka-chakra-blue); } +.tag-accent-2 { background: var(--color-accent-2-100); color: var(--color-accent-2-800); } +.tag-neutral { background: var(--color-neutral-100); color: var(--color-neutral-800); } +.tag-outline { border: 1px solid var(--color-accent); color: var(--color-accent); } + +/* — navigation — */ +.nav { + display: flex; align-items: center; gap: var(--space-4); + padding: var(--space-3) var(--space-4); + border-bottom: 2px solid var(--color-divider); +} +.nav-brand { + font-family: var(--font-heading); font-weight: var(--font-heading-weight); + font-size: 18px; margin-right: auto; +} +.nav a { color: inherit; text-decoration: none; font-size: 14px; } +.nav a:hover, .nav a[aria-current='page'] { color: var(--color-accent); } + +/* — tables — */ +.table { width: 100%; border-collapse: collapse; font-size: 14px; } +.table th { + text-align: left; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; + color: var(--color-text-muted); + padding: var(--space-2); border-bottom: 2px solid var(--color-text); +} +.table td { + padding: var(--space-2); + border-bottom: 1px solid var(--color-divider); +} +.table tbody tr:hover { background: color-mix(in srgb, var(--color-text) 4%, transparent); } + +/* — dialog — */ +.dialog-backdrop { + position: fixed; inset: 0; display: grid; place-items: center; + padding: var(--space-4); + background: color-mix(in srgb, var(--color-neutral-900) 50%, transparent); +} +.dialog { + width: min(440px, 100%); display: flex; flex-direction: column; gap: var(--space-3); + padding: var(--space-4); border-radius: var(--radius-lg); + background: var(--color-surface); box-shadow: var(--shadow-lg); +} +.dialog-title { + font-family: var(--font-heading); font-weight: var(--font-heading-weight); + font-size: 20px; +} +.dialog-body { font-size: 14px; opacity: 0.85; } +.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-2); } + +/* — shell: masthead, section nav, tab bar, footer (UI_PORT_SPEC.md §3) — */ +.skip-link { + position: absolute; left: -9999px; top: 0; z-index: 50; + background: var(--color-text); color: var(--color-bg); + padding: 8px 12px; font-size: 14px; font-weight: 600; +} +.skip-link:focus { left: 8px; top: 8px; } + +.rule-double { display: flex; flex-direction: column; gap: 3px; } +.rule-double > span:first-child { height: 3px; background: var(--color-text); } +.rule-double > span:last-child { height: 1px; background: var(--color-text); } +.rule-double.on-dark > span { background: var(--color-bg); } + +.masthead-dateline { + display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; + gap: 8px 24px; padding: 14px 0; border-bottom: 1px solid var(--color-divider); + font-family: var(--font-body); font-size: 12px; font-weight: 600; letter-spacing: 0.12em; + text-transform: uppercase; color: var(--color-text-muted); +} +.masthead-dateline a { color: var(--color-text-muted); } +.masthead-kicker { + display: flex; align-items: center; justify-content: center; gap: 12px; + font-size: 12px; font-weight: 700; letter-spacing: 0.28em; text-transform: uppercase; +} +.masthead-kicker .accent-rule { width: 28px; height: 2px; background: var(--india-saffron); } +.masthead-kicker .accent-rule.green { background: var(--india-green); } +.masthead-wordmark { + display: block; text-align: center; color: var(--color-text); text-decoration: none; + font-family: var(--font-heading); font-weight: 900; line-height: 0.86; letter-spacing: -0.035em; + font-size: clamp(56px, 13.5vw, 196px); +} +.masthead-wordmark.inner { font-size: clamp(40px, 7vw, 96px); } +.masthead-tagline { + text-align: center; font-family: var(--font-heading); font-style: italic; font-weight: 400; + font-size: clamp(17px, 1.8vw, 22px); color: var(--color-text-secondary); margin: 0 0 var(--space-3); +} + +.section-nav { + position: sticky; top: 0; z-index: 20; background: var(--color-bg); + border-bottom: 1px solid var(--color-divider); +} +.section-nav-inner { display: flex; align-items: center; gap: 20px; } +.section-nav-mark { font-family: var(--font-heading); font-weight: 900; font-size: 18px; letter-spacing: -0.02em; text-decoration: none; color: var(--color-text); } +.section-nav-links { display: flex; align-items: center; gap: 22px; overflow-x: auto; scrollbar-width: none; } +.section-nav-links::-webkit-scrollbar { display: none; } +.section-nav-link { + display: inline-flex; padding: 14px 0; white-space: nowrap; + font-family: var(--font-body); font-size: 13px; font-weight: 600; letter-spacing: 0.08em; + text-transform: uppercase; text-decoration: none; color: var(--color-text); + border-bottom: 3px solid transparent; +} +.section-nav-link:hover { color: var(--color-accent); } +.section-nav-link[aria-current='page'] { border-bottom-color: var(--india-saffron); } + +.tabbar { + position: sticky; bottom: 0; z-index: 20; background: var(--color-bg); + border-top: 2px solid var(--color-divider); padding-bottom: env(safe-area-inset-bottom); +} +.tabbar-link { + display: flex; min-height: 56px; flex-direction: column; align-items: center; justify-content: center; + gap: 2px; border-top: 3px solid transparent; text-decoration: none; color: var(--color-text-muted); + font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; +} +.tabbar-link[aria-current='page'] { border-top-color: var(--india-saffron); color: var(--color-text); } + +.site-footer { background: var(--color-footer-bg); color: var(--color-bg); } +.site-footer a { color: inherit; } +.site-footer-top { + display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-end; + gap: var(--space-4); border-bottom: 1px solid var(--color-footer-rule); padding-bottom: 32px; +} +.site-footer-wordmark { font-family: var(--font-heading); font-weight: 900; font-size: clamp(48px, 9vw, 128px); line-height: 0.86; letter-spacing: -0.035em; margin: 0; } +.site-footer-tagline { font-family: var(--font-heading); font-style: italic; font-size: 22px; color: var(--color-footer-text-soft); margin: 0; } +.site-footer-top-link { + padding: 12px 0; font-size: 13px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; + text-decoration: none; border-bottom: 2px solid var(--india-saffron); +} +.site-footer-top-link:hover { color: var(--india-saffron); } +.site-footer-columns { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr)); gap: 32px 40px; } +.site-footer-heading { font-size: 12px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; color: var(--color-footer-muted); margin: 0 0 var(--space-2); } +.site-footer-columns a, .site-footer-columns span { display: block; padding: 4px 0; font-size: 16px; text-decoration: none; } +.site-footer-columns a:hover { color: var(--india-saffron); } +.site-footer-columns .muted { color: var(--color-footer-muted); } +.site-footer-bottom { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; font-size: 13px; color: var(--color-footer-muted); } +.site-footer-swatches { display: inline-flex; gap: 4px; } +.site-footer-swatches span { width: 6px; height: 6px; background: var(--india-saffron); } +.site-footer-swatches span:nth-child(2) { background: var(--color-bg); } +.site-footer-swatches span:nth-child(3) { background: var(--india-green); } + +/* — content: eyebrow, card kicker, feed grid (UI_PORT_SPEC.md §4) — */ +.eyebrow { + display: flex; align-items: center; gap: 10px; padding-bottom: 14px; + border-bottom: 2px solid var(--color-text); + font-family: var(--font-body); font-size: 12px; font-weight: 700; + letter-spacing: 0.2em; text-transform: uppercase; +} +.eyebrow.dark { border-bottom-color: var(--color-bg); color: var(--color-bg); } +.eyebrow.spaced { margin-bottom: clamp(24px, 3vw, 40px); } +.eyebrow-square { width: 8px; height: 8px; flex: none; background: var(--india-saffron); } + +.kicker { display: flex; align-items: baseline; gap: 10px; font-size: 12px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; } +.kicker-rule { width: 18px; height: 2px; flex: none; align-self: center; background: var(--india-saffron); } + +.feed-card { display: flex; flex-direction: column; gap: 12px; padding: 20px clamp(16px, 2vw, 24px); border-bottom: 1px solid var(--color-divider); background: transparent; } +.headline-link { + color: var(--color-text); font-family: var(--font-headline); font-weight: 700; + font-size: clamp(19px, 2vw, 22px); line-height: 1.25; letter-spacing: -0.005em; text-wrap: pretty; + text-decoration-line: underline; text-decoration-color: transparent; text-decoration-thickness: 1px; + text-underline-offset: 5px; transition: text-decoration-color 0.15s; +} +.headline-link:hover { text-decoration-color: var(--color-text); } + +/* Lead story (UI_PORT_SPEC.md §4.5). FeedItem carries no image field anywhere in + openapi.yaml, so only the "otherwise" (no-image, full-width headline) branch is + reachable — the two-column image layout is dead code for the current API and is + skipped rather than built speculatively. */ +.lead-story { display: flex; flex-direction: column; gap: 16px; padding-bottom: clamp(32px, 4vw, 48px); margin-bottom: clamp(24px, 3vw, 40px); border-bottom: 1px solid var(--color-divider); } +.lead-headline { + color: var(--color-text); font-family: var(--font-heading); font-weight: 800; + font-size: clamp(36px, 4.2vw, 60px); line-height: 1.0; letter-spacing: -0.02em; text-wrap: pretty; + text-decoration-line: underline; text-decoration-color: transparent; text-decoration-thickness: 1px; + text-underline-offset: 6px; transition: text-decoration-color 0.15s; +} +.lead-headline:hover { text-decoration-color: var(--color-text); } +.lead-summary { max-width: 68ch; font-size: 16px; color: var(--color-text-secondary); } + +/* Editor's note band — full-bleed regardless of the parent's 1360px container. */ +.band-alt { width: 100vw; margin-left: calc(50% - 50vw); margin-right: calc(50% - 50vw); background: var(--color-surface-alt); border-top: 1px solid var(--color-divider); border-bottom: 1px solid var(--color-divider); padding: clamp(64px, 9vw, 140px) 0; } +.band-alt-inner { max-width: 1360px; margin: 0 auto; padding: 0 clamp(16px, 4vw, 48px); display: grid; grid-template-columns: 1fr; gap: 24px; } +@media (min-width: 768px) { .band-alt-inner { grid-template-columns: 1fr 3fr; align-items: start; } } +.band-alt-quote { font-family: var(--font-heading); font-size: 120px; line-height: 0.8; color: var(--india-saffron); margin: 0; } +.band-alt-heading { font-family: var(--font-heading); font-weight: 800; max-width: 900px; } +.band-alt-body { max-width: 900px; display: flex; flex-direction: column; gap: 12px; color: var(--color-text-secondary); } + +.feed-grid { display: grid; grid-template-columns: 1fr; } +@media (min-width: 640px) { + .feed-grid { grid-template-columns: repeat(2, 1fr); column-gap: clamp(24px, 3vw, 40px); } + .feed-grid > *:not(:nth-child(2n+1)) { border-left: 1px solid var(--color-divider); } +} +@media (min-width: 1024px) { + .feed-grid { grid-template-columns: repeat(3, 1fr); } + .feed-grid > *:not(:nth-child(3n+1)) { border-left: 1px solid var(--color-divider); } +} + diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 738e202..cdeee98 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -8,7 +8,7 @@ services: POSTGRES_DB: newstack POSTGRES_INITDB_ARGS: "--locale=C.UTF-8 --encoding=UTF8" ports: - - "5432:5432" + - "5433:5432" volumes: - pgdata:/var/lib/postgresql/data healthcheck: @@ -20,7 +20,7 @@ services: redis: image: redis:7-alpine ports: - - "6379:6379" + - "6380:6379" healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s @@ -28,7 +28,11 @@ services: retries: 10 ingestion: - build: ../apps/ingestion + build: + # context is the repo root, not apps/ingestion: config.py reads config/ at the repo + # root via a relative path, so the build needs both directories on disk. + context: .. + dockerfile: apps/ingestion/Dockerfile environment: DATABASE_URL: postgresql://newstack:newstack@db:5432/newstack depends_on: @@ -36,14 +40,18 @@ services: condition: service_healthy restart: unless-stopped volumes: - - ingestion_models:/app/models + - ingestion_models:/app/apps/ingestion/models api: - build: ../apps/api + build: + # context is the repo root, not apps/api: src/features/blindspot/config.ts reads + # config/ at the repo root via a relative path, so the build needs both on disk. + context: .. + dockerfile: apps/api/Dockerfile environment: DATABASE_URL: postgresql://newstack:newstack@db:5432/newstack REDIS_URL: redis://redis:6379 - WEB_ORIGIN: http://localhost:5173 + WEB_ORIGIN: http://localhost:5174 PORT: "3000" ports: - "3000:3000" @@ -55,11 +63,13 @@ services: restart: unless-stopped mailer: - build: ../apps/api + build: + context: .. + dockerfile: apps/api/Dockerfile command: ["node", "dist/mailer/index.js"] environment: DATABASE_URL: postgresql://newstack:newstack@db:5432/newstack - WEB_ORIGIN: http://localhost:5173 + WEB_ORIGIN: http://localhost:5174 # RESEND_API_KEY / MAIL_FROM: unset in dev on purpose — the mailer falls back to the # console transport (src/mailer/index.ts). Set both in the real environment to send mail. depends_on: @@ -68,13 +78,17 @@ services: restart: unless-stopped web: - build: ../apps/web + build: + # context is the repo root, not apps/web: apps/web/src imports packages/shared-types + # via a relative path, so the build needs both directories on disk. + context: .. + dockerfile: apps/web/Dockerfile # No VITE_API_URL: Vite env vars are resolved at build time, not runtime, and # apiClient.ts's default (http://localhost:3000) is already correct here — the browser # calls the API directly on the host, never through the compose network, so the # container-internal "api" hostname would be wrong even if set. ports: - - "5173:5173" + - "5174:5173" depends_on: - api restart: unless-stopped diff --git a/infra/migrations/0004_source_assessment_tables.sql b/infra/migrations/0004_source_assessment_tables.sql new file mode 100644 index 0000000..7f46125 --- /dev/null +++ b/infra/migrations/0004_source_assessment_tables.sql @@ -0,0 +1,83 @@ +-- ARCHITECTURE.md §4 (Data Model), PRODUCT.md §5.6-5.8. Copied verbatim from the doc's schema block. + +-- Per-source, per-topic geopolitical positioning. Independent of domestic bias (spectrum 'IN' +-- above): same backward-linked/evidence/confidence shape as source_ratings, but keyed on +-- (source, topic) rather than source alone, since one outlet can carry a rating per topic +-- (PRODUCT §5.6). Absence of a row = never assessed; stance = 'insufficient_evidence' = assessed +-- but below the evidence floor. Direction only (minus/plus); topic-specific pole names are a +-- display concern, not a schema one. +CREATE TABLE source_geo_ratings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source_id UUID NOT NULL REFERENCES sources(id), + topic_code TEXT NOT NULL CHECK (topic_code IN ('IN_PAK','IN_CHN','IN_US','IN_RUS','IN_NEIGH','DEFSEC','MULTILAT','INDOPAC')), + stance TEXT NOT NULL CHECK (stance IN ('strongly_minus','leans_minus','mixed_or_issue_dependent','leans_plus','strongly_plus','insufficient_evidence')), + confidence TEXT CHECK (confidence IN ('high','medium','low')), -- NULL when stance = 'insufficient_evidence' + method TEXT NOT NULL, + method_version TEXT, + rated_by TEXT, + second_rater TEXT, + rubric_scores JSONB, + evidence JSONB, + review_due_at TIMESTAMPTZ, + source_ref TEXT, + notes TEXT, + rated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + supersedes UUID, + superseded_at TIMESTAMPTZ, + UNIQUE (source_id, id), + FOREIGN KEY (source_id, supersedes) REFERENCES source_geo_ratings (source_id, id), + CONSTRAINT geo_manual_needs_two_raters_and_evidence CHECK ( + method <> 'manual-curation' OR ( + rated_by IS NOT NULL AND second_rater IS NOT NULL AND rated_by <> second_rater + AND rubric_scores IS NOT NULL + AND jsonb_typeof(evidence) = 'array' AND jsonb_array_length(evidence) >= 3 + ) + ) +); +CREATE UNIQUE INDEX idx_source_geo_ratings_one_current ON source_geo_ratings(source_id, topic_code) WHERE superseded_at IS NULL; +CREATE UNIQUE INDEX idx_source_geo_ratings_linear_chain ON source_geo_ratings(supersedes) WHERE supersedes IS NOT NULL; + +-- Per-source sensationalism rating. Independent of factuality; same append-only/backward-linked +-- shape as source_ratings, sized one row per source (not per topic) (PRODUCT §5.7). +CREATE TABLE source_sensationalism_ratings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source_id UUID NOT NULL REFERENCES sources(id), + level TEXT NOT NULL CHECK (level IN ('low','medium','high','insufficient_evidence')), + method TEXT NOT NULL, + method_version TEXT, + rated_by TEXT, + second_rater TEXT, + rubric_scores JSONB, + evidence JSONB, + review_due_at TIMESTAMPTZ, + source_ref TEXT, + notes TEXT, + rated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + supersedes UUID, + superseded_at TIMESTAMPTZ, + UNIQUE (source_id, id), + FOREIGN KEY (source_id, supersedes) REFERENCES source_sensationalism_ratings (source_id, id), + CONSTRAINT sens_manual_needs_two_raters_and_evidence CHECK ( + method <> 'manual-curation' OR ( + rated_by IS NOT NULL AND second_rater IS NOT NULL AND rated_by <> second_rater + AND rubric_scores IS NOT NULL + AND jsonb_typeof(evidence) = 'array' AND jsonb_array_length(evidence) >= 3 + ) + ) +); +CREATE UNIQUE INDEX idx_source_sens_ratings_one_current ON source_sensationalism_ratings(source_id) WHERE superseded_at IS NULL; +CREATE UNIQUE INDEX idx_source_sens_ratings_linear_chain ON source_sensationalism_ratings(supersedes) WHERE supersedes IS NOT NULL; + +-- Ownership transparency and editorial independence: a current-state disclosure record, not an +-- append-only revisioned rating like the three tables above (PRODUCT §5.8) — a correction +-- overwrites in place, it doesn't supersede. One row per source, inserted once research begins; +-- absence of a row = not yet researched (mirrors source_ratings' own "no row = not yet rated"). +CREATE TABLE source_ownership ( + source_id UUID PRIMARY KEY REFERENCES sources(id), + ownership_transparency TEXT NOT NULL CHECK (ownership_transparency IN ('disclosed','partially_disclosed','undisclosed','insufficient_evidence')), + editorial_independence TEXT NOT NULL CHECK (editorial_independence IN ('no_documented_influence','documented_instances','insufficient_evidence')), + evidence JSONB, -- [{url, archived, dimension: 'ownership'|'editorial_influence', note}, …] + rated_by TEXT, + notes TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/infra/migrations/0005_source_categories.sql b/infra/migrations/0005_source_categories.sql new file mode 100644 index 0000000..9a15c45 --- /dev/null +++ b/infra/migrations/0005_source_categories.sql @@ -0,0 +1,9 @@ +-- ARCHITECTURE.md §4 (Data Model), PRODUCT.md §5.9. Copied verbatim from the doc's schema block. + +-- Source directory category tags: curator-assigned, multi-valued, never derived from +-- coverage_region/language (PRODUCT §5.9). A source can hold 0-6 categories. +CREATE TABLE source_categories ( + source_id UUID NOT NULL REFERENCES sources(id), + category TEXT NOT NULL CHECK (category IN ('national','regional','language','business','geopolitical','specialist')), + PRIMARY KEY (source_id, category) +); diff --git a/infra/migrations/0006_billing.sql b/infra/migrations/0006_billing.sql new file mode 100644 index 0000000..06e6a19 --- /dev/null +++ b/infra/migrations/0006_billing.sql @@ -0,0 +1,118 @@ +-- PAYPAL_SPEC.md §6 (Data model), adapted: gateway is PayPal, not Razorpay. +-- All money stored as integer paise (PAYPAL_SPEC §2: ₹60 = 6000, ₹360 = 36000). + +CREATE TABLE plans ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + amount_paise INT NOT NULL CHECK (amount_paise > 0), + interval TEXT NOT NULL CHECK (interval IN ('month','year')), + gateway_plan_id TEXT, -- PayPal billing plan id, filled in once created at the gateway + active BOOLEAN NOT NULL DEFAULT true +); + +INSERT INTO plans (code, name, amount_paise, interval) VALUES + ('monthly_60', 'Monthly', 6000, 'month'), + ('annual_360', 'Annual', 36000, 'year'); + +CREATE TABLE subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + plan_id UUID NOT NULL REFERENCES plans(id), + status TEXT NOT NULL CHECK (status IN + ('incomplete','trialing','active','past_due','canceled','expired')), + current_period_start TIMESTAMPTZ, + current_period_end TIMESTAMPTZ, + cancel_at_period_end BOOLEAN NOT NULL DEFAULT false, + grace_until TIMESTAMPTZ, + gateway TEXT NOT NULL DEFAULT 'paypal', + gateway_sub_id TEXT UNIQUE, -- PayPal subscription id (I-xxx), or order id for one-time annual + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_subscriptions_user ON subscriptions(user_id); +-- Entitlement rule (PAYPAL_SPEC §6) reads active/past_due/canceled subscriptions per user; +-- expiry sweep scans for past_due rows whose grace has elapsed. +CREATE INDEX idx_subscriptions_grace ON subscriptions(grace_until) WHERE status = 'past_due'; + +CREATE TABLE payments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + subscription_id UUID NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + amount_paise INT NOT NULL, + currency TEXT NOT NULL DEFAULT 'INR', + status TEXT NOT NULL CHECK (status IN ('pending','succeeded','failed','refunded','partially_refunded')), + gateway_payment_id TEXT UNIQUE, -- PayPal capture id + method TEXT, + failure_reason TEXT, + invoice_number TEXT UNIQUE, + paid_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_payments_user ON payments(user_id, created_at DESC); +CREATE INDEX idx_payments_subscription ON payments(subscription_id); + +-- Sequential invoice numbers (PAYPAL_SPEC §4.11): one counter, gapless per calendar year. +CREATE TABLE invoice_counters ( + year INT PRIMARY KEY, + next_seq INT NOT NULL DEFAULT 1 +); + +CREATE TABLE refunds ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + payment_id UUID NOT NULL REFERENCES payments(id) ON DELETE CASCADE, + amount_paise INT NOT NULL CHECK (amount_paise > 0), + gateway_refund_id TEXT UNIQUE, + reason TEXT, + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Webhook dedupe (PAYPAL_SPEC §7): unique on (gateway, gateway_event_id); a duplicate delivery +-- is a no-op, never a failure. +CREATE TABLE webhook_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + gateway TEXT NOT NULL, + gateway_event_id TEXT NOT NULL, + type TEXT NOT NULL, + payload JSONB NOT NULL, + received_at TIMESTAMPTZ NOT NULL DEFAULT now(), + processed_at TIMESTAMPTZ, + error TEXT, + UNIQUE (gateway, gateway_event_id) +); + +-- Billing admin actions (extend/revoke/refund) get their own audit trail, mirroring +-- auth_events' shape — separate from auth_events since the event vocabulary and target +-- type (subscription, not user auth) differ. +CREATE TABLE billing_audit_log ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + actor_id UUID NOT NULL REFERENCES users(id), + action TEXT NOT NULL CHECK (action IN + ('manual_extend','manual_revoke','refund_issued','plan_changed','subscription_canceled','subscription_resumed')), + subscription_id UUID REFERENCES subscriptions(id) ON DELETE SET NULL, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Billing transactional emails: a separate outbox from email_outbox (PAYPAL_SPEC §4.10) since +-- these carry structured context (amount, plan, renewal date) that auth's token-only outbox +-- row shape has no column for; same claim/backoff pattern as mailer/repo.ts's outbox. +CREATE TABLE billing_email_outbox ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN + ('receipt','renewal_reminder','payment_failed','canceled','expired','refund_processed')), + context JSONB NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','failed')), + attempts INT NOT NULL DEFAULT 0, + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + sent_at TIMESTAMPTZ +); +CREATE INDEX idx_billing_email_outbox_due ON billing_email_outbox(next_attempt_at) WHERE status = 'pending'; + +-- Admin panel access (PAYPAL_SPEC §4.12): a handful of internal staff, same complexity-budget +-- call as curatorPlugin's shared-secret header — a users.is_admin flag, not a roles table. +ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT false; diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..0d38e96 Binary files /dev/null and b/logo.png differ diff --git a/packages/shared-types/src/openapi.ts b/packages/shared-types/src/openapi.ts index 77aebb5..9d41611 100644 --- a/packages/shared-types/src/openapi.ts +++ b/packages/shared-types/src/openapi.ts @@ -561,6 +561,134 @@ export interface paths { patch?: never; trace?: never; }; + "/api/public/sources": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Source directory list/search — structural filters only at MVP */ + get: { + parameters: { + query?: { + category?: "national" | "regional" | "language" | "business" | "geopolitical" | "specialist"; + country?: string; + language?: string; + q?: string; + limit?: number; + offset?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Plain offset pagination, not the keyset-cursor machinery feeds use — the registry is curated and slow-growing (ARCHITECTURE §10, §1 complexity budget). */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SourceListResponse"]; + }; + }; + 400: components["responses"]["ValidationError"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/curator/sources/{id}/categories": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Replace a source's directory category tags (whole-set PUT) */ + put: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + categories: ("national" | "regional" | "language" | "business" | "geopolitical" | "specialist")[]; + }; + }; + }; + responses: { + /** @description Curator-assigned, multi-valued, never derived (PRODUCT §5.9) — a descriptive tag, not a provenance record, so the whole set is replaced at once rather than append-only revisioned. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + categories: ("national" | "regional" | "language" | "business" | "geopolitical" | "specialist")[]; + }; + }; + }; + 400: components["responses"]["ValidationError"]; + 404: components["responses"]["NotFound"]; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/public/sources/distribution": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Site-wide count of active sources per bias category */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Cached 1h, same as source metadata. PRODUCT §3.5: counts, never a single left/right score — this is the aggregate the home feed's bias color key is drawn from. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SourceDistribution"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/public/sources/{id}": { parameters: { query?: never; @@ -686,6 +814,264 @@ export interface paths { patch?: never; trace?: never; }; + "/api/public/sources/{id}/sensationalism": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Current sensationalism rating + full history for a source */ + get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Same append-only/backward-linked shape as bias/factuality ratings — history is never mutated, only superseded. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + current?: components["schemas"]["SensationalismRating"] | null; + history?: components["schemas"]["SensationalismRating"][]; + }; + }; + }; + 404: components["responses"]["NotFound"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/curator/sources/{id}/sensationalism": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Submit a new current sensationalism rating for a source (atomic revision) */ + post: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NewSensationalismRating"]; + }; + }; + responses: { + /** @description The prior current row (if any) is superseded in the same transaction the new row is inserted in, so the partial unique index never sees two current rows. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SensationalismRating"]; + }; + }; + 400: components["responses"]["ValidationError"]; + 404: components["responses"]["NotFound"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/public/sources/{id}/geo": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Current geopolitical-positioning rating + full history, for all 8 topics */ + get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description One entry per topic code (IN_PAK, IN_CHN, IN_US, IN_RUS, IN_NEIGH, DEFSEC, MULTILAT, INDOPAC), always present even when a topic has never been rated — absence of a *rating row* (current: null) means never assessed, distinct from an explicit insufficient_evidence rating (PRODUCT §5.6). */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: { + current?: components["schemas"]["GeoRating"] | null; + history?: components["schemas"]["GeoRating"][]; + }; + }; + }; + }; + 404: components["responses"]["NotFound"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/curator/sources/{id}/geo/{topicCode}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Submit a new current geopolitical-positioning rating for one topic (atomic revision) */ + post: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + topicCode: "IN_PAK" | "IN_CHN" | "IN_US" | "IN_RUS" | "IN_NEIGH" | "DEFSEC" | "MULTILAT" | "INDOPAC"; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NewGeoRating"]; + }; + }; + responses: { + /** @description The prior current row for this (source, topic) pair, if any, is superseded in the same transaction the new row is inserted in. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GeoRating"]; + }; + }; + 400: components["responses"]["ValidationError"]; + 404: components["responses"]["NotFound"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/public/sources/{id}/ownership": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Ownership transparency + editorial independence disclosure for a source */ + get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description A current-state disclosure record, not an append-only rating (PRODUCT §5.8) — null means the source has never been researched for this. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Ownership"] | null; + }; + }; + 404: components["responses"]["NotFound"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/curator/sources/{id}/ownership": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Upsert the ownership/editorial-independence disclosure for a source */ + post: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NewOwnership"]; + }; + }; + responses: { + /** @description A plain upsert, not an atomic-revision transaction — a correction overwrites in place since source_ownership has no supersedes chain. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Ownership"]; + }; + }; + 400: components["responses"]["ValidationError"]; + 404: components["responses"]["NotFound"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/whoami": { parameters: { query?: never; @@ -1172,7 +1558,33 @@ export interface paths { path?: never; cookie?: never; }; - get?: never; + /** Curator review queue of filed clustering-error reports */ + get: { + parameters: { + query?: { + /** @description Omit to return both resolved and unresolved reports. */ + resolved?: boolean; + limit?: number; + offset?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Plain offset pagination, newest first — same rationale as the source directory list (low-volume, curator-facing). */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ClusterReportListResponse"]; + }; + }; + 400: components["responses"]["ValidationError"]; + }; + }; put?: never; /** Report a clustering error (wrong merge or missed merge) */ post: { @@ -1292,6 +1704,15 @@ export interface components { right: components["schemas"]["BiasBarSegment"]; notRated: components["schemas"]["BiasBarSegment"]; }; + /** @description 6 buckets — the 5 bias categories plus `notRated` — counting every active source, not one story's coverage (PRODUCT §3.3's query, without the story filter). */ + SourceDistribution: { + left: number; + centerLeft: number; + center: number; + centerRight: number; + right: number; + notRated: number; + }; FeedItem: { /** Format: uuid */ id: string; @@ -1388,6 +1809,68 @@ export interface components { deprecatedAt: string | null; logoUrl: string | null; }; + SourceListItem: { + /** Format: uuid */ + id: string; + name: string; + domain: string; + /** @description ISO 3166 alpha-2, e.g. IN */ + countryCode: string; + /** @enum {string|null} */ + coverageRegion: "domestic" | "global" | null; + /** @description ISO 639-1 of the feed, e.g. en */ + language: string; + /** @description Curator-assigned directory tags (PRODUCT §5.9); never derived, may be empty. */ + categories: ("national" | "regional" | "language" | "business" | "geopolitical" | "specialist")[]; + }; + SourceListResponse: { + items: components["schemas"]["SourceListItem"][]; + /** @description Count of active sources matching the filters, across all pages. */ + total: number; + limit: number; + offset: number; + }; + ClusterReportListItem: { + /** Format: uuid */ + id: string; + /** @enum {string} */ + kind: "wrong_merge" | "should_merge"; + /** Format: uuid */ + storyId: string; + storyTitle: string; + /** + * Format: uuid + * @description Set when kind is wrong_merge. + */ + articleId?: string | null; + articleTitle?: string | null; + /** + * Format: uuid + * @description Set when kind is should_merge. + */ + otherStoryId?: string | null; + otherStoryTitle?: string | null; + /** @description The story's cluster_version at report time. */ + clusterVersion: string; + reportedBy: string; + /** + * Format: date-time + * @description When the curator filed this report. + */ + createdAt: string; + /** + * Format: date-time + * @description When a curator marked this report resolved; null while still open. No endpoint sets this yet. + */ + resolvedAt: string | null; + }; + ClusterReportListResponse: { + items: components["schemas"]["ClusterReportListItem"][]; + /** @description Count of reports matching the `resolved` filter, across all pages. */ + total: number; + limit: number; + offset: number; + }; BlindspotItem: { /** Format: uuid */ id: string; @@ -1487,6 +1970,134 @@ export interface components { sourceRef?: string | null; notes?: string | null; }; + SensationalismRating: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + sourceId: string; + /** @enum {string} */ + level: "low" | "medium" | "high" | "insufficient_evidence"; + method: string; + ratedBy: string; + /** + * Format: date-time + * @description When this rating row was inserted — ratings are append-only, never mutated in place. + */ + ratedAt: string; + /** + * Format: uuid + * @description Id of the rating row this one replaced, or null if this is the source's first rating. + */ + supersedes: string | null; + /** + * Format: date-time + * @description When a later rating replaced this one; null while this is the current rating. + */ + supersededAt: string | null; + }; + NewSensationalismRating: { + /** @enum {string} */ + level: "low" | "medium" | "high" | "insufficient_evidence"; + /** @description e.g. `internal-classifier-v1` or `manual-curation`. */ + method: string; + methodVersion?: string | null; + ratedBy: string; + /** @description Required (and must differ from `ratedBy`) when `method` is `manual-curation`. */ + secondRater?: string | null; + /** @description Required when `method` is `manual-curation`. */ + rubricScores?: Record | null; + /** @description At least 3 entries required when `method` is `manual-curation`. */ + evidence?: string[] | null; + /** + * Format: date-time + * @description Optional curator-set deadline for re-reviewing this rating. + */ + reviewDueAt?: string | null; + sourceRef?: string | null; + notes?: string | null; + }; + GeoRating: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + sourceId: string; + /** @enum {string} */ + topicCode: "IN_PAK" | "IN_CHN" | "IN_US" | "IN_RUS" | "IN_NEIGH" | "DEFSEC" | "MULTILAT" | "INDOPAC"; + /** + * @description Direction only — topic-specific pole names (e.g. "dialogue-first" for IN_PAK) are applied at render time, not stored here (PRODUCT §5.6). + * @enum {string} + */ + stance: "strongly_minus" | "leans_minus" | "mixed_or_issue_dependent" | "leans_plus" | "strongly_plus" | "insufficient_evidence"; + method: string; + ratedBy: string; + /** + * Format: date-time + * @description When this rating row was inserted — ratings are append-only, never mutated in place. + */ + ratedAt: string; + /** + * Format: uuid + * @description Id of the rating row this one replaced, or null if this is the topic's first rating for this source. + */ + supersedes: string | null; + /** + * Format: date-time + * @description When a later rating replaced this one; null while this is the current rating. + */ + supersededAt: string | null; + }; + NewGeoRating: { + /** @enum {string} */ + stance: "strongly_minus" | "leans_minus" | "mixed_or_issue_dependent" | "leans_plus" | "strongly_plus" | "insufficient_evidence"; + /** @description e.g. `internal-classifier-v1` or `manual-curation`. */ + method: string; + methodVersion?: string | null; + ratedBy: string; + /** @description Required (and must differ from `ratedBy`) when `method` is `manual-curation`. */ + secondRater?: string | null; + /** @description Required when `method` is `manual-curation`. */ + rubricScores?: Record | null; + /** @description At least 3 entries required when `method` is `manual-curation`. */ + evidence?: string[] | null; + /** + * Format: date-time + * @description Optional curator-set deadline for re-reviewing this rating. + */ + reviewDueAt?: string | null; + sourceRef?: string | null; + notes?: string | null; + }; + Ownership: { + /** Format: uuid */ + sourceId: string; + /** @enum {string} */ + ownershipTransparency: "disclosed" | "partially_disclosed" | "undisclosed" | "insufficient_evidence"; + /** + * @description Never derived from ownershipTransparency — the two fields are independent (PRODUCT §5.8). + * @enum {string} + */ + editorialIndependence: "no_documented_influence" | "documented_instances" | "insufficient_evidence"; + evidence: Record[] | null; + ratedBy: string | null; + notes: string | null; + /** + * Format: date-time + * @description Last correction time — this record is overwritten in place, never superseded. + */ + updatedAt: string; + }; + NewOwnership: { + /** @enum {string} */ + ownershipTransparency: "disclosed" | "partially_disclosed" | "undisclosed" | "insufficient_evidence"; + /** + * @description Requires ≥ 1 evidence entry when `documented_instances`. + * @enum {string} + */ + editorialIndependence: "no_documented_influence" | "documented_instances" | "insufficient_evidence"; + evidence?: Record[] | null; + ratedBy?: string | null; + notes?: string | null; + }; NewSource: { name: string; domain: string; diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..119dfdb --- /dev/null +++ b/start.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# One-click local startup: db/redis -> migrations -> api/ingestion/mailer/web. +# Requires Docker + Docker Compose only (see README "Install & run (Docker Compose)"). +set -euo pipefail +cd "$(dirname "$0")/infra" + +echo "==> starting db + redis, applying migrations" +docker compose run --rm \ + -e DATABASE_URL="postgresql://newstack:newstack@db:5432/newstack" \ + -v "$(pwd)/..:/workspace" \ + --entrypoint python \ + ingestion /workspace/infra/migrate.py + +echo "==> starting api, ingestion, mailer, web" +docker compose up --build -d + +echo +echo "web: http://localhost:5174" +echo "api: http://localhost:3000/health" +echo "logs: cd infra && docker compose logs -f"