Next.js dashboard for viewing user-specific news and analytics from PostgreSQL. Each user signs in with Google, stores their own NewsAPI key, and enqueues search requests that are executed by the companion NEWS_API_ETL_Project.
- Next.js 16 (App Router,
--webpack,output: "standalone") - React 19 + TypeScript 5.8
- Auth.js / NextAuth 5 (Google OAuth, JWT sessions)
- PostgreSQL via
pg(singletonPool) - Node
node:testfor unit tests - Per-user NewsAPI key encryption with AES-256-GCM (
node:crypto)
- Google sign-in with
app_usersupsert on first login - Per-user NewsAPI key stored encrypted in
users_keys(AES-256-GCM, onlylast4exposed) - Protected pages:
/dashboard— search-request form, request list, paginated news with filters/analytics— totals, top keywords, top sources, request stats, AI aggregates/searches/[id]— request details with statistics, AI Briefing, and request-scoped news/profile— account info + NewsAPI key management
- Protected API routes:
GET /api/news— list user's news withq,keyword,author,language,page,limitGET /api/analytics— aggregates for the current user (includesaiblock)POST /api/searches— enqueue asearch_requestsrow (requires saved NewsAPI key)GET /api/searches/:id— status, per-request stats, andai_reportfor the current userGET/POST/DELETE /api/profile/news-api-key— manage the encrypted NewsAPI key
- Auto-refresh on the dashboard while any request is
queuedorrunning(3s polling) - In-memory per-user-and-IP rate limit on
POST /api/searches(20 req / 60s) - Same-origin check on mutating requests (
POST/DELETE) - Security headers added by
proxy.ts(X-Content-Type-Options,X-Frame-Options,Referrer-Policy,Permissions-Policy,Cross-Origin-Opener-Policy) - Unified API response shape:
{ ok, data | error, requestId }(request id is read fromx-request-idheader or generated)
NEWS_API_ETL_Projectowns ingestion: it pollssearch_requests, calls NewsAPI with the user's decrypted key, runs filtering/scoring, writesarticles,user_news, andrequest_stats. It also generates AI briefings and writes them torequest_ai_reports.NEWS_API_WEBowns: Google auth, user provisioning (app_users), encrypted key storage (users_keys), the dashboard / analytics / profile / search-details UI, and enqueueing rows intosearch_requests.- This app does not call NewsAPI, does not call any LLM, and does not run ETL workers. AI briefings are read-only here.
- Both projects share
NEWS_API_KEY_ENCRYPTION_SECRETso the ETL worker can decrypt keys written here.
Each completed search request can have an AI-generated briefing written by the ETL worker into request_ai_reports. The frontend only reads and renders these reports — it never calls an LLM, never stores AI provider keys, and never regenerates summaries on page load.
The briefing is exposed by GET /api/searches/:id as ai_report: AIReport | null and rendered by <AIBriefingCard /> on /searches/[id]. It contains:
summary,key_points,highlightssentiment_label(positive/neutral_positive/neutral/neutral_negative/negative) andsentiment_scoresentiment_distribution(positive / neutral / negative percent)main_topics,data_quality_warningsmodel_provider,model_name,news_count,created_atstatus(success|failed|pending) witherror_texton failure
UI states: queued / running (request not finished), success + ai_report === null (missing), ai_report.status === "failed" (failure with reason), ai_report.status === "success" (full briefing). All optional fields are normalized to empty arrays before render so the page never crashes on partial data.
Authorization: AI reports are always fetched through search_requests.user_id. The SQL joins request_ai_reports via LEFT JOIN ... ON air.search_request_id = sr.id and the WHERE clause requires sr.user_id = $current_user. A user cannot fetch another user's ai_report by guessing a search-request id.
Analytics: GET /api/analytics returns an ai block with total_reports, failed_reports, avg_sentiment_score, sentiment counts by label, and top_topics aggregated from main_topics JSONB arrays.
npm ci
cp .env.example .env.local
# fill in DATABASE_URL, AUTH_SECRET, AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRET,
# and NEWS_API_KEY_ENCRYPTION_SECRET in .env.local
npm run devOpen http://localhost:3000 — unauthenticated users are redirected to /login.
# PostgreSQL
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/db_news
DB_CONNECTION_TIMEOUT_MS=5000
DB_STATEMENT_TIMEOUT_MS=10000
DB_IDLE_TIMEOUT_MS=10000
# Auth.js / Google OAuth
AUTH_SECRET=replace_me_with_a_long_random_secret
AUTH_GOOGLE_ID=replace_me
AUTH_GOOGLE_SECRET=replace_me
# Set to true when running behind a reverse proxy (e.g. Vercel, nginx)
AUTH_TRUST_HOST=false
# Encryption key for users_keys.encrypted_key.
# MUST match NEWS_API_ETL_Project so the worker can decrypt user-supplied NewsAPI keys.
# Falls back to AUTH_SECRET if unset, but a dedicated value is recommended.
NEWS_API_KEY_ENCRYPTION_SECRET=replace_me_with_32+_random_charsnpm run dev # next dev --webpack
npm run build # next build --webpack (standalone output)
npm run start # next start
npm run lint # eslint . --max-warnings=0
npm run typecheck # tsc --noEmit
npm run test # tsc -p tsconfig.test.json && node --test dist-tests/tests/**/*.test.js
npm run format # prettier --check .
npm run format:write
npm run check # lint + typecheck + test + buildTests use node:test and live in tests/ (currently validation.test.ts covering input parsers and the rate limiter).
The data tables (app_users, articles, search_requests, user_news, request_stats, users_keys) are shared with NEWS_API_ETL_Project, which is the source of truth for the schema. This repo does not ship a migration file — apply schema from the ETL project, then point both apps at the same DATABASE_URL.
Tables this app reads/writes:
| Table | Access from this app |
|---|---|
app_users |
upsert on Google sign-in, read |
users_keys |
insert/update/delete/read (AES-256-GCM, service = news_api) |
search_requests |
insert (status queued), read |
user_news |
read |
articles |
read (joined via user_news) |
request_stats |
read (joined per request) |
request_ai_reports |
read (joined per request, scoped by search_requests.user_id) |
ETL workers should write/update everything else. This app only enqueues search_requests and reads dashboard data.
GET /api/news?q=ai&keyword=AI&author=Ivan%20Petrov&language=ru&page=1&limit=20
GET /api/analytics
POST /api/searches
GET /api/searches/123
GET /api/profile/news-api-key
POST /api/profile/news-api-key
DELETE /api/profile/news-api-key
POST /api/searches body:
{
"keyword": "ai",
"language": "ru",
"limitCount": 20,
"pageSize": 50
}Constraints: keyword ≤ 120 chars, language is ISO 639-1 (defaults to ru), limitCount ∈ [1, 500], pageSize ∈ [1, 100]. Returns 403 NEWS_API_KEY_REQUIRED if the user has not saved a NewsAPI key, and 429 RATE_LIMITED (with details.retryAfterSeconds) past 20 requests per minute per user+IP.
POST /api/profile/news-api-key body:
{ "apiKey": "<your NewsAPI key, 20–256 chars>" }Success response:
{
"ok": true,
"data": { "...": "..." },
"requestId": "..."
}Error response:
{
"ok": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "..."
},
"requestId": "..."
}Common error codes: UNAUTHORIZED, FORBIDDEN, FORBIDDEN_ORIGIN, NEWS_API_KEY_REQUIRED, VALIDATION_ERROR, INVALID_JSON, RATE_LIMITED, NOT_FOUND, INTERNAL_ERROR.
app/
(protected)/ # dashboard, analytics, profile, searches/[id] (server components)
api/ # auth, news, analytics, searches, profile/news-api-key
login/ # Google sign-in page
auth.ts # NextAuth config + signIn/jwt/session callbacks
proxy.ts # security headers (matcher excludes _next assets)
lib/
db.ts # singleton pg Pool
users.ts # app_users upsert + session → appUserId resolution
user-news-api-key.ts# AES-256-GCM encrypt/decrypt for users_keys
news.ts # paginated user_news query + filter options (incl. searchRequestId)
analytics.ts # per-user aggregates (incl. AI block)
searches.ts # create / list / get search_requests (joins request_ai_reports)
ai-report.ts # AIReport type + sentiment helpers
api-auth.ts # requireAppUserIdFromAuthRequest / requireCurrentAppUserId
api-response.ts # jsonOk / jsonError / handleApiError / requestId
app-error.ts # AppError class
validation.ts # parseRequiredText / parseLanguage / parseStrictPositiveInteger / ...
news-filters.ts # parse filters from URL or searchParams
rate-limit.ts # in-memory token bucket
request-security.ts # ensureSameOrigin + getClientIp
components/
auth/ # GoogleSignInButton, SignOutButton
dashboard/ # search form, news table, filter form, status tracker, auto-refresh
searches/ # AIBriefingCard
analytics/ # AIAnalyticsSection
profile/ # NewsApiKeyForm
tests/ # node:test specs (validation, rate-limit, ai-report)
docker build -t news-web-dashboard .
docker run --rm -p 3000:3000 --env-file .env.local news-web-dashboardThe image is built with output: "standalone" and runs as the non-root nextjs user. A HEALTHCHECK polls http://127.0.0.1:3000/.