Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project

Aggie is a web application for tracking groups around real-time events (elections, disasters) by aggregating items ("reports") from social media and feed sources (Twitter, Facebook, TikTok, Instagram, TruthSocial, Mastodon, Telegram, Junkipedia, RSS, IODA, Cloudflare). Reports can be triaged as relevant/irrelevant and grouped into "incidents"/"groups" for follow-up. Roles: admin, manager, monitor, viewer.

Node `^22.14.0` (use `fnm install` then `fnm use`; pinned in `.nvmrc`). MongoDB `>= 7.0.0`. Mongoose is pinned to `^5.9.16` — schemas use callback-style APIs and `useCreateIndex`. Do not assume Mongoose 6+/7+ idioms.

## Commands

- `npm run dev` — runs frontend (`react-scripts` on `:8000`) and backend (nodemon on `:3000`) split-pane via stmux. Use `npm run dev:frontend` / `npm run dev:backend` to run them in separate shells.
- `npm run build` — production React build (`CI=false`, so warnings don't fail the build).
- `npm start` — production: runs `node app.js` with `ENVIRONMENT=production`. Expects the React build to already exist; in production the API process serves `/build`.
- `node install.js` — runs automatically as `postinstall`. Ensures Report indexes and creates an `admin` user from `ADMIN_EMAIL` / `ADMIN_PASSWORD` if none exists.
- No test runner is configured (`npm test` is not wired up).
- Dev URL: `https://localhost:8000` (the backend defaults to HTTPS if `backend/config/key.pem` and `cert.pem` exist, otherwise HTTP). The frontend dev server proxies `/api`, `/login`, `/logout`, `/session`, `/socket` to `http://127.0.0.1:3000` via `src/setupProxy.js`.
- Branching: feature branches off `develop` (the staging/main branch — production is built from it). Don't push directly to `develop`.

## Required env (`.env`, copied from `.env.example`)

`DATABASE_URL`, `DATABASE_NAME`, `ADMIN_EMAIL`, `ADMIN_USERNAME`, `ADMIN_PASSWORD`, `ADMIN_PARTY` (dev-only auth bypass), `SECRET`, `JWT_SESSION`, WebAuthn (`RP_ID`, `RP_NAME`, `ORIGIN`, `APP_BASE_PATH`, `MFA_REQUIRE_FOR_ENROLLED`), `ENCRYPTION_KEY` (AES-256), `API_REQUEST_TIMEOUT`, `API_FETCH_INTERVAL`, `SOCKET_FRONTEND_PORT` (default `37778`), `PUBLIC_URL`. Ask a maintainer for the shared dev `.env` and DB connection string.

## Architecture

The repo is **one Node project containing two largely separate apps** that share Mongoose models.

### Multi-process backend

`app.js` forks two child processes via `backend/process-manager.js`:

- **API** (`backend/api.js`, process title `aggie-api`) — Express + Passport on port `3000`, serves REST under `/api/*`, hosts socket.io. In production it also serves the built React app from `/build`.
- **FETCH** (`backend/fetching.js`, process title `aggie-fetching`) — runs the `downstream` library to poll all sources.

`process-manager.js` re-spawns crashed children automatically and routes events between them via `child-process.js` + `event-proxy.js`. When you see `childProcess.setupEventProxy({ emitter, subclass, emitterModule })` in `backend/api.js`, it's hooking a Mongoose schema event in the *fetching* process and forwarding it to a listener in the *api* process. **Mongoose model events fire in whichever process saved the document; cross-process notification only works if a proxy is registered.**

### Fetching pipeline

`backend/fetching/` is built around a single shared `Downstream` instance (`downstream.js`):

1. `sourceToChannel.js` reads `Source` documents from Mongo, maps each `source.media` (`twitter`, `mastodon`, `telegramBot`, `telegramUser`, `junkipedia`, `rss`, `ioda`, `cloudflare`, ...) to a Channel class in `channels/`, and registers it with `downstream`. The map between `Source._id` and Channel ID lives in-memory in `sourceChannelJoin`.
2. Hooks in `fetching/hooks/` run on every fetched item: `postToReport` → `saveToDatabase` → (optionally) `tagReportsAI`, `findImages`. Hooks are registered with `downstream.use(...)` in `backend/fetching.js`.
3. Listeners in `fetching/listeners/` react to settings/source changes pushed from the API process so channels can be enabled/disabled/created/deleted at runtime without restarting.
4. `config.get().fetching` is the master on/off switch — channels are registered regardless, but `downstream.start` filters on `channel.enabled && fetching`.

When adding a new source type: add a Channel class in `channels/`, wire it into the `switch (media)` in `sourceToChannel.createChannel`, add UI in `src/pages/Settings/source/`, and update `backend/api/controllers/sourceController.js`.

### API layer

- Routes: `backend/api/routes/apiRoutes.js` is the aggregator mounted at `/api` (after `auth.authenticate()`). Each resource has a `*Routes.js` + matching `controllers/*Controller.js`.
- Auth: `backend/api/authentication.js` (passport-local + passport-jwt + WebAuthn via `@simplewebauthn/server`). Auth routes are mounted *before* `/api` so login/logout don't require a token. `ADMIN_PARTY=true` short-circuits auth in development.
- Sockets: `backend/api/socket-handler.js` + `backend/api/sockets/` push live updates (new reports, source state, tag changes) to the frontend over socket.io. Mongoose schema event listeners are deferred 500ms after startup so cross-process proxies bind first.
- Models: `backend/models/` (Mongoose schemas). `report.js`, `source.js`, `group.js`, `user.js`, `credentials.js`, `tag.js`, plus auth-session models. Reports use full-text indexing — `install.js` calls `Report.ensureIndexes`.

### Frontend (`src/`)

React 17 SPA built with **`react-scripts` 5** (CRA). This locks us to React 17, TypeScript 4.5, Tailwind 3, headless-ui 1.7, and TanStack Query v4. **Don't use newer-version idioms** — when looking up docs, use the version-specific docs linked in `FRONTEND.md`.

Folder convention: **folders define scope; place files as close as possible to where they're used**. A hook used only in `pages/Reports/` belongs in `pages/Reports/`, not in the global `hooks/`.

Key directories:
- `src/api/<resource>/index.ts` — axios calls; `types.ts` — response/request types.
- `src/pages/` — file structure mirrors the router (see `AppRouter.tsx`).
- `src/components/` — only for components used in multiple pages.
- `src/objectTypes.d.ts`, `src/helpers.tsx` — **legacy**. New types/helpers go in scoped locations.

There is no shared API schema — frontend types are hand-written to match what the backend controllers return. Read the controller before changing a request.

When refactoring legacy code: keep the old file alongside (rename to `*_old` or `*_untyped`) rather than deleting, until it can be cleaned up during downtime.

### Real-time data flow

Frontend uses TanStack Query for REST and a socket.io connection (proxied through `/socket` in dev) for push updates. The socket connection is authenticated via the same session cookie (`passport.socketio`). When you change a Mongoose model that has socket listeners (Report, Source, Tag, Group), confirm both the in-process listener (`api.js`) and the cross-process proxy from fetching are still wired.

## Conventions worth knowing

- HTTPS by default on the backend if certs are present; otherwise HTTP. To run without certs locally, just don't create `backend/config/{key,cert}.pem`.
- Production deployments use PM2 (`npx pm2`); see `SCRIPTS.md` for the full Ubuntu setup runbook.
- The frontend build path is `/build` (served by the API in production), not `/dist`.
- `node_modules/downstream` is a local fork — don't assume the npm registry version matches.
50 changes: 50 additions & 0 deletions docs/claude-plans/incidents-list-scroll-retention.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Retain Scroll Position on Incidents List

## Context

Today, when a user is browsing the incidents list at `/incidents`, clicks into an incident detail at `/incidents/:id`, and hits the "Go Back" button, they are dropped at the top of the list and have to re-scroll to find where they were. Filters and page number already persist (they live in URL search params), so the only thing being lost is scroll position. The list page even *actively* re-scrolls `#main_view` to the top every time `searchParams` changes, which fires on remount.

We want a small UX fix: returning to the list from a detail view should land the user back where they were. Filter and page changes should still scroll to top (today's behavior). Persistence is in-memory only — a hard reload or new tab starts fresh.

## Approach

Use react-router-dom v6's `useNavigationType()` to distinguish a back-navigation (`POP`) from a fresh navigation (`PUSH`/`REPLACE`), and stash the last-known scroll offset of the `#main_view` container in a module-level variable.

- On `POP` mounts, suppress the existing scroll-to-top and restore the saved offset once list data has rendered.
- On any other mount or on `searchParams` change, keep the current scroll-to-top behavior.
- On unmount (or on item click — unmount is enough), capture `#main_view.scrollTop` into the module variable.

No new dependencies. No sessionStorage. No changes to `IncidentListItem` or the detail page.

## Files to modify

- [src/pages/incidents/index.tsx](../../src/pages/incidents/index.tsx) — only file touched.

## Implementation sketch

In `src/pages/incidents/index.tsx`:

1. Add `useNavigationType` to the `react-router-dom` import.
2. Above the component, add a module-scoped `let savedScrollTop: number | null = null;`.
3. Inside `Incidents`:
- `const navigationType = useNavigationType();`
- Modify the existing `useEffect` at `src/pages/incidents/index.tsx:34-42`: skip the `scrollTo({ top: 0 })` when `navigationType === "POP"` (let the restore effect handle it). Keep `document.title` and `refetch()` behavior.
- Add a new effect with deps `[data, navigationType]` that runs once when `data` is present and `navigationType === "POP"` and `savedScrollTop != null`: call `document.getElementById("main_view")?.scrollTo({ top: savedScrollTop })` (no `behavior: "smooth"` — instant feels right for restoration), then null out `savedScrollTop` so it isn't re-applied if `data` later refetches.
- Add a mount effect whose cleanup reads `document.getElementById("main_view")?.scrollTop` into `savedScrollTop`. This captures the position right before unmount (i.e., when navigating into a detail).

## Why this works with the existing stack

- The list query key is the static `["groups"]` (`src/pages/incidents/index.tsx:27`), so on back-nav TanStack Query serves the cached results immediately and the list height is reconstructed before the restore effect fires. No layout-jump race.
- Filter/page changes call `setParams` which is a `PUSH`, so `navigationType` becomes `PUSH` and the existing scroll-to-top still fires.
- Detail's "Go Back" already uses `navigate(-1)` (`src/pages/incidents/Incident/index.tsx:202`), which produces a `POP` — exactly what triggers restore.
- Browser back from anywhere else into `/incidents` is also `POP`; restoring to the last-saved offset is still the right behavior because that offset is from the user's last visit.

## Verification

1. `npm run dev` and open `https://localhost:8000/incidents`.
2. Scroll partway down the incidents list, click into an incident, click "Go Back" — list should be at the same scroll position (not the top).
3. From the detail page, use the browser back button — same restore behavior.
4. On the list, change a filter or page in `IncidentsFilters` — should still scroll to top.
5. Hit refresh on the list page — should load at the top (no persistence across reload, as designed).
6. Navigate to incidents from the sidebar / a bookmark on a fresh tab — should load at the top.
7. With a long list, confirm there is no visible flash of "top of list" before the restore lands (cached data should be available synchronously on POP).
33 changes: 27 additions & 6 deletions src/pages/incidents/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { useEffect } from "react";
import { useEffect, useLayoutEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { useQueryParams } from "../../hooks/useQueryParams";
import _ from "lodash";

import { getGroups } from "../../api/groups";
import type { Group, GroupQueryState, Groups } from "../../api/groups/types";

import { Link } from "react-router-dom";
import { Link, useNavigationType } from "react-router-dom";
import IncidentsFilters from "./IncidentsFilters";
import IncidentListItem from "./IncidentListItem";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
Expand All @@ -18,10 +18,13 @@ import { SocketEvent, useSocketSubscribe } from "../../hooks/WebsocketProvider";
import { updateByIds } from "../../utils/immutable";
import { useUpdateQueryData } from "../../hooks/useUpdateQueryData";

let savedScrollTop: number | null = null;

const Incidents = () => {
const { searchParams, getAllParams, getParam, setParams, clearAllParams } =
useQueryParams<GroupQueryState>();
const queryData = useUpdateQueryData();
const navigationType = useNavigationType();

const { data, refetch, isLoading, isFetching } = useQuery(
["groups"],
Expand All @@ -35,12 +38,30 @@ const Incidents = () => {
document.title = "Incidents - Aggie";
// refetch on filter change
refetch();
document.getElementById("main_view")?.scrollTo({
top: 0,
behavior: "smooth",
});
if (navigationType !== "POP") {
document.getElementById("main_view")?.scrollTo({
top: 0,
behavior: "smooth",
});
}
}, [searchParams]);

useEffect(() => {
const main = document.getElementById("main_view");
if (!main) return;
const onScroll = () => {
savedScrollTop = main.scrollTop;
};
main.addEventListener("scroll", onScroll, { passive: true });
return () => main.removeEventListener("scroll", onScroll);
}, []);

useLayoutEffect(() => {
if (navigationType === "POP" && savedScrollTop != null && data?.total) {
document.getElementById("main_view")?.scrollTo({ top: savedScrollTop });
}
}, [data, navigationType]);

interface GroupUpdateEvent extends SocketEvent {
data: {
ids: string[];
Expand Down