diff --git a/.env.example b/.env.example index 5996d0a..a71fa8a 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,9 @@ DISCORD_ROLE_ID_ASSOCIATE=11 DISCORD_ROLE_ID_ALUMNI=12 # Optional. Suppressed anyway while SYNC_MODE=dry-run. DISCORD_OPS_WEBHOOK_URL= +# Optional. Structure damage alerts. Falls back to DISCORD_OPS_WEBHOOK_URL when +# unset; when NEITHER is set, nothing is alerted and /admin/structures says so. +DISCORD_STRUCTURE_WEBHOOK_URL= # --- Wanderer (fake) --------------------------------------------------------- WANDERER_BASE_URL=https://wanderer.example diff --git a/docs/ops.md b/docs/ops.md index 5a6ffdf..eb9dff8 100644 --- a/docs/ops.md +++ b/docs/ops.md @@ -112,12 +112,17 @@ renders — this table is a copy for readers, not a source. | `location` | `2,17,32,47 * * * *` | housekeeping | | `membership-recheck` | `0 4 * * 0` | on-demand | | `access-lists` | `25 * * * *` | on-demand | +| `structures` | `35 * * * *` | on-demand | +| `structure-events` | `3,13,23,33,43,53 * * * *` | on-demand | | `token-health` | `0 3 * * *` | housekeeping | | `purge` | `30 3 * * *` | housekeeping | `location` is offset off the :00/:05/:10/:15 minutes on purpose: there is no access-token cache, so it quadruples per-character SSO refreshes and would -otherwise race the contacts job for the same rows. +otherwise race the contacts job for the same rows. `structures` (:35) and +`structure-events` (:3/:13/:23/.../:53) land on minutes none of the above +already claim, for the same reason: two jobs sharing a minute race for the +same holder's token. The 90-minute freshness threshold used by `/api/health/sync` is a constant in `src/core/health.ts`, compared with `<=`. If the most frequent job here ever @@ -243,6 +248,11 @@ forever, so it is unbounded too — roughly an order of magnitude slower than `audit_log`, and worth its own retention policy eventually. Noted here, not fixed; it does not change this decision. +`structure_event` is unbounded for the same reason `audit_log` is: it is an +append-only record of fact — every structure notification this app has ever +seen, one row per `notification_id` — and `purge.ts` deliberately leaves it +alone. Same shelf life, same trigger to revisit it. + ### Revisit when — not before `audit_log` is the fastest-growing table with no retention policy — the `purge` @@ -362,6 +372,7 @@ character already on the ACL. | `DISCORD_GUILD_ID` | yes | the guild whose roles are managed | | `DISCORD_ROLE_ID_MEMBER` / `_ASSOCIATE` / `_ALUMNI` | yes | the three managed role ids (distinct) | | `DISCORD_OPS_WEBHOOK_URL` | no | ops alerts (final retry failures, config errors) | +| `DISCORD_STRUCTURE_WEBHOOK_URL` | no (falls back to `DISCORD_OPS_WEBHOOK_URL`) | structure notifications (see below). With neither set, nothing is alerted — events are still recorded, as `seeded` — and `/admin/structures` says so | | `WANDERER_BASE_URL` / `WANDERER_API_KEY` | yes | Wanderer instance + the **ACL's own** API key (the map API key returns 401 on `/api/acls/*`) | | `WANDERER_ACL_ID` | yes | the managed ACL — dedicated to authGD, reconciled destructively | | `STANDINGS_LABEL` | no (default `authgd`) | in-game contact label the app OWNS — see the warning below | @@ -429,6 +440,39 @@ URL is built, so at that moment there is no "the character" whose existing scopes could be carried forward. `/admin/access-lists` detects the loss and asks for a re-grant rather than failing silently. +### The structure scopes are opt-in + +`esi-corporations.read_structures.v1` (the roster read) and +`esi-characters.read_notifications.v1` (structure notifications) are both +deliberately **absent** from `EVE_SSO_SCOPES`, for the same reason the +access-list scope is: putting either there would flip every existing character +to `needs_reauth` on the next token-health run, for a feature only one +character needs. + +An admin grants both by visiting `/auth/eve/link?grant=structures`, the same +mechanism as `grant=access-lists`, and the grant is equally **not sticky** — any +ordinary re-authentication drops it, and `/admin/structures` detects the loss +and asks for a re-grant rather than re-authenticating silently into a monitor +that has quietly stopped reading anything. + +Granting the scope is necessary but not sufficient. Two **in-game corporation +roles** gate what the holder character can actually see, and this app has no +way to grant either of them — they are assigned in-game, by someone who already +holds them, to the character this app designates as the structure holder: + +- **Station_Manager** (or higher) is what ESI's structure-list endpoint itself + requires. Without it the roster read comes back forbidden and + `/admin/structures` reports it as such (`no-corp-roles`, in + `src/app/admin/structures/view.ts`), even though the scope grant succeeded. +- **Director or CEO** is what EVE requires before it will deliver structure + notifications to a character **at all** — this is CCP's own delivery rule, + not something this app enforces or can bypass. A holder below that rank sees + an empty notification stream forever, with no error to point at: the read + succeeds, it is simply never sent anything to read. + +Designate a holder who already holds both roles, or have someone who does grant +them to the designated character before relying on this page. + ## SYNC_MODE — the dry-run safety guard `SYNC_MODE` is **required and has no default**. Every other arrangement has a diff --git a/docs/plans/2026-08-24-structure-monitor.md b/docs/plans/2026-08-24-structure-monitor.md new file mode 100644 index 0000000..be780a5 --- /dev/null +++ b/docs/plans/2026-08-24-structure-monitor.md @@ -0,0 +1,3451 @@ +# Structure Damage Monitor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Monitor the corp's own Upwell structures and post a Discord alert when one takes damage. + +**Architecture:** One designated character ("the holder") grants two opt-in ESI scopes. An hourly `structures` job keeps a roster; a ten-minute `structure-events` job polls that character's notifications, records the four damage types keyed by ESI's own `notification_id`, and posts each newly-recorded one to a Discord webhook. Delivery is at-least-once via a `pending` → `sent` status on each event row. An admin page at `/admin/structures` renders the roster and states what is wrong when it is. + +**Tech Stack:** Next.js 16 App Router, Drizzle ORM + Postgres, pg-boss, zod 4, vitest, Playwright. + +**Spec:** `docs/specs/2026-08-24-structure-monitor-design.md` + +## Global Constraints + +- Node `>=24`. zod is v4 — `z.object({...}).strict()`, and `{ error: "..." }` not `{ message: "..." }`. +- **No new dependencies.** In particular no YAML library; Task 2 hand-rolls the parser. +- **Migrations are generated, never hand-written.** Run `npm run db:generate` after a schema edit. Never edit a migration already applied. +- **Enqueue, don't execute.** The web tier writes an outbox row via `enqueueSync` and returns. No server action, page, or route calls ESI. +- **`src/core/` is pure.** No imports from `@/db`, `@/services`, `@/lib`, no I/O. Types only. +- **Every state change writes an audit row inside the same transaction** as the change. +- **Never claim a command passed without running it.** Cite output. +- Run `npm run format:check` in every task, not only at the end. +- Unit tests run against real Postgres (port 5433, provisioned by the test helpers). e2e runs `workers: 1`, `retries: 0` — do not raise either. +- The four notification types, spelled exactly: `StructureUnderAttack`, `StructureLostShields`, `StructureLostArmor`, `StructureDestroyed`. + +--- + +### Task 1: Schema, enums, and migration + +**Files:** + +- Modify: `src/db/schema.ts` +- Modify: `src/db/tables.ts:14-38` +- Create: `drizzle/00NN_*.sql` (generated) +- Test: `tests/structure-schema.test.ts` + +**Interfaces:** + +- Consumes: nothing. +- Produces: `structureHolder`, `structureReadState`, `structure`, `structureEvent` table objects; `structureReadStatusEnum` / `StructureReadStatus`; `structureAlertStatusEnum` / `StructureAlertStatus`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/structure-schema.test.ts`: + +```ts +import { describe, expect, it, beforeAll, afterAll, beforeEach } from "vitest"; +import { sql } from "drizzle-orm"; +import { setupTestDb, truncateAll } from "./helpers/db"; +import { testConfig } from "./helpers/config"; +import { seedAccount, seedCharacter } from "./helpers/seed"; +import { MANAGED_TABLE_NAMES } from "@/db/tables"; + +let ctx: Awaited>; +beforeAll(async () => { + ctx = await setupTestDb(); +}); +afterAll(async () => { + await ctx.cleanup(); +}); +beforeEach(async () => { + await truncateAll(ctx.db); +}); + +describe("structure monitor schema", () => { + it("registers all four tables in MANAGED_TABLES", () => { + for (const t of [ + "structure_holder", + "structure_read_state", + "structure", + "structure_event", + ]) { + expect(MANAGED_TABLE_NAMES).toContain(t); + } + }); + + it("pins structure_holder to a single row", async () => { + const account = await seedAccount(ctx.db); + await seedCharacter(ctx.db, testConfig(), { id: 90000001, accountId: account.id }); + await ctx.db.execute( + sql`insert into structure_holder (id, character_id, corporation_id, designated_by) values (1, 90000001, 5, 'system')`, + ); + await expect( + ctx.db.execute( + sql`insert into structure_holder (id, character_id, corporation_id, designated_by) values (2, 90000001, 5, 'system')`, + ), + ).rejects.toThrow(); + }); + + it("keys structure_read_state by (kind, corporation_id)", async () => { + await ctx.db.execute( + sql`insert into structure_read_state (kind, corporation_id, last_attempt_at, read_status) values ('roster', 98000001, now(), 'ok')`, + ); + await ctx.db.execute( + sql`insert into structure_read_state (kind, corporation_id, last_attempt_at, read_status) values ('roster', 98000002, now(), 'ok')`, + ); + await expect( + ctx.db.execute( + sql`insert into structure_read_state (kind, corporation_id, last_attempt_at, read_status) values ('roster', 98000001, now(), 'ok')`, + ), + ).rejects.toThrow(); + }); + + it("carries all four alert statuses", async () => { + const res = await ctx.db.execute( + sql`select unnest(enum_range(null::structure_alert_status))::text as v`, + ); + const values = res.rows.map((r) => (r as { v: string }).v); + expect(values.sort()).toEqual(["abandoned", "pending", "seeded", "sent"]); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npx vitest run tests/structure-schema.test.ts` +Expected: FAIL — `relation "structure_holder" does not exist`. + +- [ ] **Step 3: Add the enums to `src/db/schema.ts`** + +Place beside the other `pgEnum` declarations near the top (after `accessListEntryKindEnum`): + +```ts +export const structureReadStatusEnum = pgEnum("structure_read_status", [ + "ok", + "forbidden", + "failed", +]); +export type StructureReadStatus = (typeof structureReadStatusEnum.enumValues)[number]; + +/** + * Four distinct states, not shades of one. + * + * `seeded` — recorded without alerting: this holder had never polled, or no + * webhook is configured so there is no recipient. + * `pending` — recorded and owed an alert. + * `sent` — posted successfully. + * `abandoned` — was pending when the holder was replaced, and will never be + * posted. + * + * `abandoned` is not a reuse of `seeded` because the two answer different + * questions: "deliberately not alerted" versus "owed an alert with no valid + * recipient". Collapsing them makes it impossible to tell from the table + * whether a holder swap swallowed a live attack. + */ +export const structureAlertStatusEnum = pgEnum("structure_alert_status", [ + "seeded", + "pending", + "sent", + "abandoned", +]); +export type StructureAlertStatus = (typeof structureAlertStatusEnum.enumValues)[number]; +``` + +- [ ] **Step 4: Add the four tables to `src/db/schema.ts`** + +Append after the access-list tables: + +```ts +/** + * The designated structure holder. Singleton, like `access_list_holder`. + * + * `corporationId` is PINNED at designation rather than read live off + * `character.corporationId`, which the membership job overwrites every thirty + * minutes (src/jobs/membership.ts:125). Following it live means a holder who + * changes corp silently re-rosters against the new corp and stamps + * `missingSince` on every previous structure — indistinguishable from a mass + * destruction event, arriving during the exact incident this tool exists for. + * + * `seededAt` null means this holder has never completed a poll: the events job + * records without alerting until it is stamped. `designateHolder` writes it + * null, so replacing the holder re-seeds. + */ +export const structureHolder = pgTable( + "structure_holder", + { + id: integer("id").primaryKey(), + characterId: bigint("character_id", { mode: "number" }) + .notNull() + .references(() => character.id, { onDelete: "cascade" }), + corporationId: bigint("corporation_id", { mode: "number" }).notNull(), + designatedAt: timestamp("designated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + designatedBy: text("designated_by").notNull(), // account uuid or "system" + seededAt: timestamp("seeded_at", { withTimezone: true }), + }, + (t) => [check("structure_holder_singleton_ck", sql`${t.id} = 1`)], +); + +/** + * Read health, one row per (kind, corporation). Two timestamps for the reason + * `access_list_snapshot` gives: `observedAt` is the last SUCCESSFUL read and is + * null until there is one; `lastAttemptAt` + `readStatus` + `detail` describe + * the most recent attempt either way. + * + * Keyed by corporation because the row describes a read against one specific + * corp. Without it, replacing the holder leaves the previous corp's freshness + * and 403 state in place and the page calls the new monitor healthy on the + * strength of a read against a corp it no longer watches. + */ +export const structureReadState = pgTable( + "structure_read_state", + { + kind: text("kind").notNull(), // 'roster' | 'events' + corporationId: bigint("corporation_id", { mode: "number" }).notNull(), + observedAt: timestamp("observed_at", { withTimezone: true }), + lastAttemptAt: timestamp("last_attempt_at", { withTimezone: true }).notNull(), + readStatus: structureReadStatusEnum("read_status").notNull(), + detail: text("detail"), + }, + (t) => [primaryKey({ columns: [t.kind, t.corporationId] })], +); + +/** + * The roster. `state` is stored verbatim as text, not a pgEnum: a state string + * CCP adds next patch must not be able to fail a read of a field nothing + * branches on for correctness. + * + * `typeName` is denormalized because there is no type-id name cache to use — + * `universe_name`'s kind enum has no `type` value and `resolveEntityNames` + * deliberately drops inventory types (src/services/entity-names.ts:76-80). + * + * A structure that stops appearing gets `missingSince` stamped, never deleted: + * never remove on unknown state. From the roster's side a destroyed Astrahus + * and a 403 are identical; only the event stream tells them apart. + */ +export const structure = pgTable("structure", { + structureId: bigint("structure_id", { mode: "number" }).primaryKey(), + corporationId: bigint("corporation_id", { mode: "number" }).notNull(), + typeId: bigint("type_id", { mode: "number" }).notNull(), + typeName: text("type_name"), + systemId: bigint("system_id", { mode: "number" }).notNull(), + name: text("name"), + state: text("state").notNull(), + stateTimerStart: timestamp("state_timer_start", { withTimezone: true }), + stateTimerEnd: timestamp("state_timer_end", { withTimezone: true }), + fuelExpires: timestamp("fuel_expires", { withTimezone: true }), + observedAt: timestamp("observed_at", { withTimezone: true }).notNull(), + missingSince: timestamp("missing_since", { withTimezone: true }), +}); + +/** + * One row per structure notification ever seen. ESI's own `notification_id` is + * the primary key, which is what makes "seen" idempotent across runs. + * + * `corporationId` is stamped at insert from the holder's PINNED corp, not + * parsed from the body. It is what the sender filters on, so a row recorded + * under a previous holder can never be posted under a new one. + * + * `details` holds ONLY the parsed subset actually rendered. The notifications + * endpoint returns every notification type for the character — war decs, mail, + * kill rights, corp applications — and this job persists none of them. + */ +export const structureEvent = pgTable( + "structure_event", + { + notificationId: bigint("notification_id", { mode: "number" }).primaryKey(), + type: text("type").notNull(), + sentAt: timestamp("sent_at", { withTimezone: true }).notNull(), + structureId: bigint("structure_id", { mode: "number" }), + corporationId: bigint("corporation_id", { mode: "number" }).notNull(), + alertStatus: structureAlertStatusEnum("alert_status").notNull(), + details: jsonb("details").$type>(), + }, + // Serves the sender's hot path: pending rows for the pinned corp, oldest + // first. Without it that is a full scan of a table that only grows. + (t) => [ + index("structure_event_pending_idx").on(t.corporationId, t.alertStatus, t.sentAt), + ], +); +``` + +Add `primaryKey` to the `drizzle-orm/pg-core` import list at the top of the file. + +- [ ] **Step 5: Register the tables** + +In `src/db/tables.ts`, add to `MANAGED_TABLES` after `"esi_entity_name"`: + +```ts + "structure_holder", + "structure_read_state", + "structure", + "structure_event", +``` + +- [ ] **Step 6: Generate and apply the migration** + +```bash +npm run db:generate +npm run db:migrate +``` + +Read the generated SQL before continuing. It must contain two `CREATE TYPE` +statements and four `CREATE TABLE`s, and must not `ALTER` any existing table. + +- [ ] **Step 7: Run the tests** + +Run: `npx vitest run tests/structure-schema.test.ts tests/seed-dev.test.ts` +Expected: PASS. `tests/seed-dev.test.ts` asserts `MANAGED_TABLES` equals the +database's table list in both directions, so it fails if step 5 was missed. + +- [ ] **Step 8: Format and commit** + +```bash +npm run format:check +git add src/db/schema.ts src/db/tables.ts drizzle tests/structure-schema.test.ts +git commit -m "feat(structures): schema for the structure damage monitor" +``` + +--- + +### Task 2: Pure notification parsing and formatting + +**Files:** + +- Create: `src/core/structure-event.ts` +- Test: `tests/structure-event.test.ts` + +**Interfaces:** + +- Consumes: nothing (pure module, types only). +- Produces: + - `STRUCTURE_EVENT_TYPES: readonly string[]` + - `isStructureEventType(type: string): boolean` + - `parseNotificationBody(text: string): Record` + - `type ParsedStructureEvent = { structureId: number | null; details: Record }` + - `extractStructureEvent(text: string): ParsedStructureEvent` + - `formatStructureAlert(input: StructureAlertInput): string` + - `compareRosterRows(a: RosterSortable, b: RosterSortable): number` + +**Critical detail:** EVE notification bodies are **not** flat `key: value`. They +contain block sequences and YAML anchors: + +```yaml +allianceName: Northern Coalition. +armorPercentage: 100.0 +corpName: Ceptaerin +hullPercentage: 100.0 +shieldPercentage: 94.98 +solarsystemID: 30004268 +structureID: &id001 1029209158734 +structureShowInfoData: + - showinfo + - 35832 + - *id001 +structureTypeID: 35832 +``` + +The parser must skip list-item lines, strip a leading `&anchor` from a scalar, +and resolve `*alias` against the anchors it has seen. A parser that ignores +anchors reads `structureID` as the string `"&id001 1029209158734"` and every +alert loses its structure. + +- [ ] **Step 1: Write the failing test** + +Create `tests/structure-event.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { + compareRosterRows, + extractStructureEvent, + formatStructureAlert, + isStructureEventType, + parseNotificationBody, + STRUCTURE_EVENT_TYPES, +} from "@/core/structure-event"; + +const UNDER_ATTACK = `allianceID: 99005338 +allianceName: Northern Coalition. +armorPercentage: 100.0 +charID: 96068617 +corpName: Ceptaerin +hullPercentage: 100.0 +shieldPercentage: 94.98 +solarsystemID: 30004268 +structureID: &id001 1029209158734 +structureShowInfoData: +- showinfo +- 35832 +- *id001 +structureTypeID: 35832`; + +const LOST_SHIELDS = `solarsystemID: 30004268 +structureID: &id001 1029209158734 +structureShowInfoData: +- showinfo +- 35832 +- *id001 +structureTypeID: 35832 +timeLeft: 892668963753 +vulnerableTime: 9000000000`; + +describe("STRUCTURE_EVENT_TYPES", () => { + it("is exactly the four damage types", () => { + expect([...STRUCTURE_EVENT_TYPES].sort()).toEqual([ + "StructureDestroyed", + "StructureLostArmor", + "StructureLostShields", + "StructureUnderAttack", + ]); + }); + + it("rejects non-damage structure notifications", () => { + expect(isStructureEventType("StructureFuelAlert")).toBe(false); + expect(isStructureEventType("StructureUnderAttack")).toBe(true); + }); +}); + +describe("parseNotificationBody", () => { + it("strips a YAML anchor from a scalar", () => { + expect(parseNotificationBody(UNDER_ATTACK).structureID).toBe("1029209158734"); + }); + + it("skips block sequence items", () => { + expect(parseNotificationBody(UNDER_ATTACK)).not.toHaveProperty("showinfo"); + expect(parseNotificationBody(UNDER_ATTACK).structureShowInfoData).toBeUndefined(); + }); + + it("resolves an alias to its anchor's value", () => { + const parsed = parseNotificationBody("a: &x 42\nb: *x"); + expect(parsed.b).toBe("42"); + }); + + it("returns an empty object for junk rather than throwing", () => { + expect(parseNotificationBody("!!! not yaml at all")).toEqual({}); + }); +}); + +describe("extractStructureEvent", () => { + it("pulls the structure id and the damage percentages", () => { + const e = extractStructureEvent(UNDER_ATTACK); + expect(e.structureId).toBe(1029209158734); + expect(e.details.shieldPercentage).toBe(94.98); + expect(e.details.corpName).toBe("Ceptaerin"); + expect(e.details.allianceName).toBe("Northern Coalition."); + }); + + it("returns a null structure id when the body will not parse", () => { + const e = extractStructureEvent("garbage"); + expect(e.structureId).toBeNull(); + expect(e.details).toEqual({}); + }); + + it("keeps timeLeft for a reinforcement notification", () => { + expect(extractStructureEvent(LOST_SHIELDS).details.timeLeft).toBe(892668963753); + }); +}); + +describe("formatStructureAlert", () => { + it("names the structure, the system and the attacker", () => { + const line = formatStructureAlert({ + type: "StructureUnderAttack", + structureName: "Home Fortizar", + typeName: "Fortizar", + systemName: "Jita", + details: { corpName: "Ceptaerin", allianceName: "Northern Coalition." }, + }); + expect(line).toContain("under attack"); + expect(line).toContain("Home Fortizar"); + expect(line).toContain("Jita"); + expect(line).toContain("Northern Coalition."); + }); + + it("falls back to the type name when the structure has no name", () => { + const line = formatStructureAlert({ + type: "StructureDestroyed", + structureName: null, + typeName: "Astrahus", + systemName: "Jita", + details: {}, + }); + expect(line).toContain("Astrahus"); + expect(line).toContain("destroyed"); + }); + + it("never exceeds the webhook clamp", () => { + const line = formatStructureAlert({ + type: "StructureUnderAttack", + structureName: "x".repeat(5000), + typeName: "Fortizar", + systemName: "Jita", + details: {}, + }); + expect(line.length).toBeLessThanOrEqual(1900); + }); +}); + +describe("compareRosterRows", () => { + it("sorts reinforced above vulnerable above healthy", () => { + const rows = [ + { state: "shield_vulnerable", name: "b" }, + { state: "online", name: "a" }, + { state: "hull_reinforce", name: "c" }, + { state: "armor_reinforce", name: "d" }, + ]; + expect([...rows].sort(compareRosterRows).map((r) => r.state)).toEqual([ + "hull_reinforce", + "armor_reinforce", + "shield_vulnerable", + "online", + ]); + }); + + it("breaks ties by name so the order is stable across runs", () => { + const rows = [ + { state: "online", name: "zeta" }, + { state: "online", name: "alpha" }, + ]; + expect([...rows].sort(compareRosterRows).map((r) => r.name)).toEqual([ + "alpha", + "zeta", + ]); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npx vitest run tests/structure-event.test.ts` +Expected: FAIL — cannot resolve `@/core/structure-event`. + +- [ ] **Step 3: Write the implementation** + +Create `src/core/structure-event.ts`: + +```ts +/** + * Pure parsing, formatting and ordering for structure damage notifications. + * No I/O, no imports from services or db — this module is unit-tested on + * literal notification bodies. + */ + +/** + * The four damage types. Fuel, low-power, anchoring and ownership-transfer + * notifications exist and are deliberately not here: this feature alerts on + * damage. Adding one later is a one-line change to this array. + */ +export const STRUCTURE_EVENT_TYPES = [ + "StructureUnderAttack", + "StructureLostShields", + "StructureLostArmor", + "StructureDestroyed", +] as const; + +export type StructureEventType = (typeof STRUCTURE_EVENT_TYPES)[number]; + +export function isStructureEventType(type: string): type is StructureEventType { + return (STRUCTURE_EVENT_TYPES as readonly string[]).includes(type); +} + +const SCALAR_LINE = /^([A-Za-z_][A-Za-z0-9_]*):[ \t]*(.*)$/; +const ANCHOR = /^&(\S+)[ \t]+(.*)$/; +const ALIAS = /^\*(\S+)$/; + +/** + * A tolerant reader for EVE notification bodies. + * + * The bodies are YAML, but a narrow dialect: top-level scalars plus block + * sequences, with anchors used to avoid repeating a structure id. Rather than + * take a YAML dependency for that, this reads the scalars and ignores + * everything else. + * + * Three behaviours are load-bearing: + * - block sequence items (`- showinfo`) are skipped, not parsed as keys + * - `structureID: &id001 102920` yields "102920", not "&id001 102920" + * - `b: *id001` resolves to whatever `&id001` was bound to + * + * Never throws. An unparseable body yields `{}`, and the caller records the + * event without a structure name rather than dropping the alert. + */ +export function parseNotificationBody(text: string): Record { + const out: Record = {}; + const anchors: Record = {}; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trimEnd(); + // Block sequence item, or a continuation of one. Not a key. + if (/^[ \t]*-/.test(line)) continue; + const m = SCALAR_LINE.exec(line); + if (!m) continue; + const [, key, rawValue] = m; + const value = rawValue.trim(); + // A key with an empty value opens a nested block (e.g. structureShowInfoData). + // Nothing this feature reads is nested, so drop it rather than record "". + if (value === "") continue; + const anchored = ANCHOR.exec(value); + if (anchored) { + const [, name, actual] = anchored; + anchors[name] = actual.trim(); + out[key] = actual.trim(); + continue; + } + const alias = ALIAS.exec(value); + if (alias) { + const resolved = anchors[alias[1]]; + if (resolved !== undefined) out[key] = resolved; + continue; + } + out[key] = value; + } + return out; +} + +/** The body keys worth persisting. Everything else is dropped on the floor. */ +const KEPT_KEYS = [ + "corpName", + "allianceName", + "charID", + "shieldPercentage", + "armorPercentage", + "hullPercentage", + "timeLeft", + "solarsystemID", + "structureTypeID", + "ownerCorpName", + "isAbandoned", +] as const; + +export type ParsedStructureEvent = { + structureId: number | null; + details: Record; +}; + +function asNumberIfNumeric(value: string): string | number { + if (value === "") return value; + const n = Number(value); + return Number.isFinite(n) ? n : value; +} + +/** + * The parsed subset this feature persists and renders. Anything not in + * KEPT_KEYS never reaches Postgres — the notifications endpoint returns every + * notification type for the character, including personal ones. + */ +export function extractStructureEvent(text: string): ParsedStructureEvent { + const body = parseNotificationBody(text); + const rawId = body.structureID; + const parsedId = rawId === undefined ? Number.NaN : Number(rawId); + const details: Record = {}; + for (const key of KEPT_KEYS) { + const value = body[key]; + if (value !== undefined) details[key] = asNumberIfNumeric(value); + } + return { + structureId: Number.isSafeInteger(parsedId) && parsedId > 0 ? parsedId : null, + details, + }; +} + +const VERB: Record = { + StructureUnderAttack: "is under attack", + StructureLostShields: "lost shields", + StructureLostArmor: "lost armor", + StructureDestroyed: "was destroyed", +}; + +export type StructureAlertInput = { + type: string; + structureName: string | null; + typeName: string | null; + systemName: string | null; + details: Record; +}; + +/** + * One plain-text line per alert. + * + * Clamped to 1900 characters here as well as in the webhook poster. The poster + * clamps to protect Discord; this clamps so the string a test asserts on is the + * string that gets sent, rather than one silently truncated a layer later. + */ +export function formatStructureAlert(input: StructureAlertInput): string { + const subject = input.structureName ?? input.typeName ?? "A structure"; + const verb = VERB[input.type] ?? input.type; + const where = input.systemName ? ` in ${input.systemName}` : ""; + const attacker = + input.details.allianceName ?? input.details.corpName ?? null; + const by = attacker ? ` — ${attacker}` : ""; + return `${subject}${where} ${verb}${by}`.slice(0, 1900); +} + +/** + * Most alarming first. Hull reinforce is the last timer before the structure + * dies, so it outranks armor; both outrank a vulnerability window that nobody + * has shot yet. + */ +const STATE_RANK: Record = { + hull_reinforce: 0, + armor_reinforce: 1, + hull_vulnerable: 2, + armor_vulnerable: 3, + shield_vulnerable: 4, +}; + +export type RosterSortable = { state: string; name: string | null }; + +export function compareRosterRows(a: RosterSortable, b: RosterSortable): number { + const ra = STATE_RANK[a.state] ?? 90; + const rb = STATE_RANK[b.state] ?? 90; + if (ra !== rb) return ra - rb; + // Ties break by name so the table does not reshuffle between renders on + // rows the state cannot distinguish. + return (a.name ?? "").localeCompare(b.name ?? ""); +} +``` + +- [ ] **Step 4: Run the tests** + +Run: `npx vitest run tests/structure-event.test.ts` +Expected: PASS. + +- [ ] **Step 5: Format and commit** + +```bash +npm run format:check +git add src/core/structure-event.ts tests/structure-event.test.ts +git commit -m "feat(structures): pure notification parsing and alert formatting" +``` + +--- + +### Task 3: Extract the existing page walk into a shared helper + +This task is a **pure refactor with no behaviour change**. `getAllContacts` +already reads `x-pages`, fails closed on a missing or non-integer header, and +loops pages 2..N (`src/lib/esi/client.ts:320-358`), covered by +`tests/esi-client.test.ts:109-163`. Task 4 needs the same walk for a different +endpoint, so it is extracted first, on its own, where a reviewer can reject the +refactor without rejecting the feature. + +**Files:** + +- Modify: `src/lib/esi/client.ts:320-358` +- Test: `tests/esi-client.test.ts` (extend) + +**Interfaces:** + +- Consumes: the existing `request` and `safeParse` closures inside `createEsiClient`. +- Produces: an internal `fetchAllPages(path: (page: number) => string, schema: z.ZodType, accessToken: string, opts?: { base?: string; compatibilityDate?: boolean }): Promise`. + +- [ ] **Step 1: Record the behaviour this refactor must preserve** + +This task writes no new test. Its proof is that the existing contacts +pagination coverage (`tests/esi-client.test.ts:109-163`) stays green across the +extraction — that is what "behaviour-preserving" means here, and a new test +would only assert the new helper's shape rather than the old behaviour. + +The fail-closed test for the roster endpoint belongs to Task 4, which is where +`getCorporationStructures` comes into existence. Do not write it here: `npm test` +is a CI gate on every commit, so a knowingly-red suite at this commit would make +any later bisect through this range meaningless. + +- [ ] **Step 2: Run the existing pagination tests to record the baseline** + +Run: `npx vitest run tests/esi-client.test.ts -t "pages"` +Expected: PASS. Note the count; it must be identical after step 3. + +- [ ] **Step 3: Extract the helper** + +Inside `createEsiClient`, add before `getAllContacts`: + +```ts + /** + * Reads every page of a paginated ESI collection. + * + * Fails closed on a missing or non-integer `x-pages`: an unknown page count + * means an unknown result set, and both callers feed a diff that REMOVES + * (contacts deletes; the structure roster stamps missingSince). Never guess + * — spec: never remove on unknown state. + * + * Extracted from getAllContacts, whose behaviour it preserves exactly. + */ + async function fetchAllPages( + pathFor: (page: number) => string, + schema: z.ZodType, + accessToken: string, + opts: { base?: string; compatibilityDate?: boolean } = {}, + ): Promise { + const first = await request(pathFor(1), { accessToken, ...opts }); + const pagesHeader = first.headers.get("x-pages"); + const pages = Number(pagesHeader); + if (pagesHeader === null || !Number.isInteger(pages) || pages < 1) { + throw new EsiError( + `ESI GET ${pathFor(1)}: missing or invalid X-Pages header (${pagesHeader})`, + 0, + "transient", + ); + } + const out = safeParse( + schema, + await first.json(), + "GET", + pathFor(1), + first.status, + ).slice(); + for (let page = 2; page <= pages; page++) { + const res = await request(pathFor(page), { accessToken, ...opts }); + out.push(...safeParse(schema, await res.json(), "GET", pathFor(page), res.status)); + } + return out; + } +``` + +Then rewrite `getAllContacts`'s body to use it, keeping its public signature, +its doc comment, and its final `.map(...)` shape unchanged: + +```ts + /** Reads ALL pages; any page failure rejects the whole call. */ + async function getAllContacts( + characterId: number, + accessToken: string, + ): Promise { + const raw = await fetchAllPages( + (page) => `/characters/${characterId}/contacts/?page=${page}`, + contactsSchema, + accessToken, + ); + return raw.map((c) => ({ + contactId: c.contact_id, + // ...unchanged: copy the existing mapping verbatim + })); + } +``` + +- [ ] **Step 4: Prove the refactor changed nothing** + +Run: `npx vitest run tests/esi-client.test.ts -t "pages"` +Expected: PASS, same count as step 2. If any contacts pagination assertion +changed, the extraction was not behaviour-preserving — revert and redo. + +- [ ] **Step 5: Format and commit** + +```bash +npm run format:check +git add src/lib/esi/client.ts +git commit -m "refactor(esi): extract the paged-collection walk from getAllContacts" +``` + +--- + +### Task 4: ESI scopes and the two new reads + +**Files:** + +- Modify: `src/lib/esi/client.ts` +- Test: `tests/esi-client.test.ts` (extend) + +**Interfaces:** + +- Consumes: `fetchAllPages` from Task 3. +- Produces: + - `STRUCTURES_SCOPE = "esi-corporations.read_structures.v1"` + - `NOTIFICATIONS_SCOPE = "esi-characters.read_notifications.v1"` + - `type EsiCorporationStructure = { structureId: number; typeId: number; systemId: number; name: string | null; state: string; stateTimerStart: Date | null; stateTimerEnd: Date | null; fuelExpires: Date | null }` + - `type EsiNotification = { notificationId: number; type: string; timestamp: Date; text: string }` + - `getCorporationStructures(corporationId: number, accessToken: string): Promise` + - `getCharacterNotifications(characterId: number, accessToken: string): Promise` + - `type StructuresEsi = Pick` + - `type StructureEventsEsi = Pick` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/esi-client.test.ts`: + +```ts +describe("paged reads fail closed", () => { + it("rejects a corporation structures read with no X-Pages header", async () => { + server.use( + http.get(`${ROOT}/corporations/98000001/structures/`, () => + HttpResponse.json([], { headers: {} }), + ), + ); + const esi = createEsiClient(); + await expect(esi.getCorporationStructures(98000001, "tok")).rejects.toThrow( + /X-Pages/i, + ); + }); +}); + +describe("getCorporationStructures", () => { + it("reads every page and maps timestamps to Date", async () => { + server.use( + http.get(`${ROOT}/corporations/98000001/structures/`, ({ request: req }) => { + const page = new URL(req.url).searchParams.get("page"); + const body = + page === "1" + ? [ + { + structure_id: 1029209158734, + type_id: 35832, + system_id: 30004268, + name: "Home Fortizar", + state: "armor_reinforce", + state_timer_end: "2026-08-25T12:00:00Z", + fuel_expires: "2026-09-01T00:00:00Z", + }, + ] + : [ + { + structure_id: 2, + type_id: 35832, + system_id: 30004268, + state: "shield_vulnerable", + }, + ]; + return HttpResponse.json(body, { headers: { "x-pages": "2" } }); + }), + ); + const esi = createEsiClient(); + const rows = await esi.getCorporationStructures(98000001, "tok"); + expect(rows).toHaveLength(2); + expect(rows[0].name).toBe("Home Fortizar"); + expect(rows[0].stateTimerEnd).toBeInstanceOf(Date); + expect(rows[1].name).toBeNull(); + expect(rows[1].fuelExpires).toBeNull(); + }); +}); + +describe("getCharacterNotifications", () => { + it("returns id, type, timestamp and raw text", async () => { + server.use( + http.get(`${ROOT}/characters/90000001/notifications/`, () => + HttpResponse.json([ + { + notification_id: 123456, + type: "StructureUnderAttack", + sender_id: 98000001, + sender_type: "corporation", + timestamp: "2026-08-24T10:00:00Z", + text: "structureID: &id001 1029209158734", + }, + ]), + ), + ); + const esi = createEsiClient(); + const rows = await esi.getCharacterNotifications(90000001, "tok"); + expect(rows).toHaveLength(1); + expect(rows[0].notificationId).toBe(123456); + expect(rows[0].type).toBe("StructureUnderAttack"); + expect(rows[0].timestamp).toBeInstanceOf(Date); + expect(rows[0].text).toContain("structureID"); + }); + + it("tolerates a notification with no text body", async () => { + server.use( + http.get(`${ROOT}/characters/90000001/notifications/`, () => + HttpResponse.json([ + { + notification_id: 7, + type: "StructureDestroyed", + sender_id: 1, + sender_type: "corporation", + timestamp: "2026-08-24T10:00:00Z", + }, + ]), + ), + ); + const esi = createEsiClient(); + expect((await esi.getCharacterNotifications(90000001, "tok"))[0].text).toBe(""); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npx vitest run tests/esi-client.test.ts -t "Structures"` +Expected: FAIL — `esi.getCorporationStructures is not a function`. + +- [ ] **Step 3: Add the scope constants** + +Beside `ACCESS_LISTS_SCOPE` in `src/lib/esi/client.ts`: + +```ts +/** + * Deliberately NOT in EVE_SSO_SCOPES, for the reason ACCESS_LISTS_SCOPE gives: + * adding either there would flip every character to needs_reauth at the next + * token-health run, for a feature only one character needs. + * + * Note the corporations scope is NOT `esi-universe.read_structures.v1`, which + * IS already in EVE_SSO_SCOPES and resolves a single structure's NAME for the + * location job. The two differ by one word and grant different things. + * + * Both also require an in-game corp role that no scope can grant: + * Station_Manager for the roster, and Director or CEO for corp structure + * notifications to be delivered to the character at all. + */ +export const STRUCTURES_SCOPE = "esi-corporations.read_structures.v1"; +export const NOTIFICATIONS_SCOPE = "esi-characters.read_notifications.v1"; +``` + +- [ ] **Step 4: Add the schemas, mappers and methods** + +Beside the other zod schemas: + +```ts +const corporationStructuresSchema = z.array( + z.object({ + structure_id: z.number(), + type_id: z.number().int(), + system_id: z.number().int(), + name: z.string().optional(), + state: z.string(), + state_timer_start: z.string().optional(), + state_timer_end: z.string().optional(), + fuel_expires: z.string().optional(), + }), +); + +const notificationsSchema = z.array( + z.object({ + notification_id: z.number(), + type: z.string(), + timestamp: z.string(), + // Absent on some notification types. Never fail a read over a missing body: + // the event still happened and still deserves an alert. + text: z.string().optional(), + }), +); +``` + +Exported types, beside `EsiAccessList`: + +```ts +export type EsiCorporationStructure = { + structureId: number; + typeId: number; + systemId: number; + name: string | null; + state: string; + stateTimerStart: Date | null; + stateTimerEnd: Date | null; + fuelExpires: Date | null; +}; + +export type EsiNotification = { + notificationId: number; + type: string; + timestamp: Date; + text: string; +}; +``` + +Methods inside `createEsiClient`, beside `getAccessLists`: + +```ts + function optionalDate(value: string | undefined): Date | null { + if (!value) return null; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? null : d; + } + + /** + * Every structure the corporation owns. Paginated, and read through + * fetchAllPages so a missing X-Pages fails closed rather than truncating — + * the roster's missingSince stamping is a diff that removes. + * + * A 403 here means the character lacks the Station_Manager corp role. It + * classifies `permanent` (core/errors.ts: 403 is needs_reauth only when the + * body names a scope/token/authorization problem, and the role error does + * not), which is what lets the caller tell it apart from a token fault. + * Nothing is swallowed here; the caller classifies. + */ + async function getCorporationStructures( + corporationId: number, + accessToken: string, + ): Promise { + const raw = await fetchAllPages( + (page) => `/corporations/${corporationId}/structures/?page=${page}`, + corporationStructuresSchema, + accessToken, + { base: ESI_ROOT, compatibilityDate: true }, + ); + return raw.map((s) => ({ + structureId: s.structure_id, + typeId: s.type_id, + systemId: s.system_id, + name: s.name ?? null, + state: s.state, + stateTimerStart: optionalDate(s.state_timer_start), + stateTimerEnd: optionalDate(s.state_timer_end), + fuelExpires: optionalDate(s.fuel_expires), + })); + } + + /** + * The character's notifications — ALL types, not only structure ones. The + * caller filters; this client does not, because filtering here would hide + * from the test suite what the endpoint actually returns. + * + * Not paginated: ESI returns a single page of the most recent ~50 from the + * last 90 days. + */ + async function getCharacterNotifications( + characterId: number, + accessToken: string, + ): Promise { + const path = `/characters/${characterId}/notifications/`; + const res = await request(path, { + accessToken, + base: ESI_ROOT, + compatibilityDate: true, + }); + const raw = safeParse(notificationsSchema, await res.json(), "GET", path, res.status); + return raw.map((n) => ({ + notificationId: n.notification_id, + type: n.type, + timestamp: new Date(n.timestamp), + text: n.text ?? "", + })); + } +``` + +Add both to the object `createEsiClient` returns, then add the narrowed types +beside `AccessListsEsi`: + +```ts +/** The roster job's narrow view: reads only, no writes reachable. */ +export type StructuresEsi = Pick< + EsiClient, + "getCorporationStructures" | "getUniverseNames" +>; +/** The events job's narrow view. */ +export type StructureEventsEsi = Pick; +``` + +- [ ] **Step 5: Run the tests** + +Run: `npx vitest run tests/esi-client.test.ts` +Expected: PASS, including the fail-closed test written in Task 3 step 1. + +- [ ] **Step 6: Format and commit** + +```bash +npm run format:check +git add src/lib/esi/client.ts tests/esi-client.test.ts +git commit -m "feat(esi): corporation structures and character notifications reads" +``` + +--- + +### Task 5: Config and webhook resolution + +**Files:** + +- Modify: `src/config.ts` +- Modify: `src/lib/ops-webhook.ts` +- Modify: `.env.example` +- Test: `tests/config.test.ts` (extend), `tests/ops-webhook.test.ts` (extend or create) + +**Interfaces:** + +- Consumes: nothing. +- Produces: + - `cfg.discord.structureWebhookUrl: string | undefined` + - `resolveStructureWebhookUrl(cfg: Config): string | undefined` + - `postStructureWebhook(cfg: Config, content: string, fetchImpl?: typeof fetch): Promise` + - `postOpsWebhookOrThrow(cfg, content, fetchImpl?)` gains an internal url parameter but keeps its exported signature. + +**Why this task is separate:** `postOpsWebhookOrThrow` returns early and +*successfully* when no URL is configured (`src/lib/ops-webhook.ts:47-48`). +That is correct for its existing callers and wrong for this feature, where a +successful no-op would mark an owed alert `sent`. The resolution has to be +readable *before* a post is attempted, by both the job and the page. + +- [ ] **Step 1: Write the failing test** + +Create `tests/structure-webhook.test.ts`: + +```ts +import { describe, expect, it, vi } from "vitest"; +import { postStructureWebhook, resolveStructureWebhookUrl } from "@/lib/ops-webhook"; +import { testConfig } from "./helpers/config"; + +function cfgWith(over: { structure?: string; ops?: string }) { + const base = testConfig(); + return { + ...base, + syncMode: "live" as const, + discord: { + ...base.discord, + structureWebhookUrl: over.structure, + opsWebhookUrl: over.ops, + }, + }; +} + +describe("resolveStructureWebhookUrl", () => { + it("prefers the structure webhook", () => { + expect( + resolveStructureWebhookUrl( + cfgWith({ structure: "https://s.example", ops: "https://o.example" }), + ), + ).toBe("https://s.example"); + }); + + it("falls back to the ops webhook", () => { + expect(resolveStructureWebhookUrl(cfgWith({ ops: "https://o.example" }))).toBe( + "https://o.example", + ); + }); + + it("is undefined when neither is set", () => { + expect(resolveStructureWebhookUrl(cfgWith({}))).toBeUndefined(); + }); +}); + +describe("postStructureWebhook", () => { + it("throws when no webhook is configured, rather than silently succeeding", async () => { + const fetchImpl = vi.fn(); + await expect( + postStructureWebhook(cfgWith({}), "boom", fetchImpl as unknown as typeof fetch), + ).rejects.toThrow(/not configured/i); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("posts to the resolved url", async () => { + const fetchImpl = vi.fn(async () => new Response(null, { status: 204 })); + await postStructureWebhook( + cfgWith({ structure: "https://s.example" }), + "hello", + fetchImpl as unknown as typeof fetch, + ); + expect(fetchImpl.mock.calls[0][0]).toBe("https://s.example"); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npx vitest run tests/structure-webhook.test.ts` +Expected: FAIL — `resolveStructureWebhookUrl` is not exported. + +- [ ] **Step 3: Add the env var** + +In `src/config.ts`, beside `DISCORD_OPS_WEBHOOK_URL` in `envSchema`: + +```ts + DISCORD_STRUCTURE_WEBHOOK_URL: z.string().url().optional().or(z.literal("")), +``` + +and in the `discord` block of the returned config, beside `opsWebhookUrl`: + +```ts + structureWebhookUrl: e.DISCORD_STRUCTURE_WEBHOOK_URL || undefined, +``` + +In `.env.example`, beside `DISCORD_OPS_WEBHOOK_URL=`: + +``` +# Optional. Structure damage alerts. Falls back to DISCORD_OPS_WEBHOOK_URL when +# unset; when NEITHER is set, nothing is alerted and /admin/structures says so. +DISCORD_STRUCTURE_WEBHOOK_URL= +``` + +- [ ] **Step 4: Give the poster an explicit url and add the structure variants** + +In `src/lib/ops-webhook.ts`, change `postOpsWebhookOrThrow` to take the url from +a parameter with the existing config read as its default, so no existing caller +changes: + +```ts +export async function postOpsWebhookOrThrow( + cfg: Config, + content: string, + fetchImpl: typeof fetch = fetch, + url: string | undefined = cfg.discord.opsWebhookUrl, +): Promise { + if (!url) return; + if (isDryRun(cfg)) { + logSuppressedWrite("ops-webhook", content.slice(0, 200)); + return; + } + await postOpsWebhookUrl(url, content, fetchImpl); +} +``` + +Then append: + +```ts +/** + * Where a structure alert goes: the dedicated webhook, else the ops one. + * + * Exposed rather than resolved inside the poster because both the job and the + * page need to know the answer BEFORE anything is posted. A post's return + * value cannot distinguish "delivered" from "nowhere to deliver" — + * postOpsWebhookOrThrow returns successfully when no url is set — so a job that + * inferred delivery from it would mark every owed alert `sent` on a deployment + * with no webhook configured at all. + */ +export function resolveStructureWebhookUrl(cfg: Config): string | undefined { + return cfg.discord.structureWebhookUrl ?? cfg.discord.opsWebhookUrl; +} + +/** + * Posts a structure alert, THROWING when there is no webhook configured. + * + * The throw is the point: unlike the ops alerts, a dropped structure alert is + * the failure this whole feature exists to prevent. Callers must have checked + * resolveStructureWebhookUrl first and recorded the event as `seeded` if it + * returned undefined; reaching here with no url is a bug, not a configuration. + */ +export async function postStructureWebhook( + cfg: Config, + content: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const url = resolveStructureWebhookUrl(cfg); + if (!url) throw new OpsWebhookError("structure webhook not configured"); + await postOpsWebhookOrThrow(cfg, content, fetchImpl, url); +} +``` + +- [ ] **Step 5: Run the tests** + +Run: `npx vitest run tests/structure-webhook.test.ts tests/config.test.ts tests/sync-mode.test.ts` +Expected: PASS. `tests/sync-mode.test.ts` covers the existing dry-run +suppression and must be unaffected. + +- [ ] **Step 6: Format and commit** + +```bash +npm run format:check +git add src/config.ts src/lib/ops-webhook.ts .env.example tests/structure-webhook.test.ts +git commit -m "feat(structures): dedicated alert webhook with explicit resolution" +``` + +--- + +### Task 6: The service layer + +**Files:** + +- Create: `src/services/structures.ts` +- Test: `tests/structure-service.test.ts` + +**Interfaces:** + +- Consumes: Task 1's tables, `logAudit` from `@/services/audit`. +- Produces: + - `STRUCTURE_HOLDER_ROW_ID = 1` + - `type StructureHolder = { characterId: number; corporationId: number; designatedAt: Date; designatedBy: string; seededAt: Date | null }` + - `getStructureHolder(dbx: Dbx): Promise` + - `designateStructureHolder(db: Db, characterId: number, corporationId: number, actor: string): Promise<{ abandonedAlerts: number }>` + - `stillStructureHolder(tx: Dbx, characterId: number): Promise` + - `markSeeded(dbx: Dbx, at: Date): Promise` + - `recordReadState(dbx: Dbx, input: { kind: "roster" | "events"; corporationId: number; status: StructureReadStatus; detail?: string | null; observed: boolean; at: Date }): Promise` + - `getReadStates(dbx: Dbx, corporationId: number): Promise>` + - `getRoster(dbx: Dbx, corporationId: number): Promise` + - `getRecentEvents(dbx: Dbx, corporationId: number, limit: number): Promise` + - `findGrantableCharacter(dbx: Dbx): Promise<{ characterId: number; name: string; corporationId: number | null } | null>` — the first admin-owned character whose PERSISTED `scopes` carry both structure scopes. Reads `character.scopes`, never `cfg.eveSso.scopes`: config says what we ask for, the column says what was granted. + - `toHolderView(dbx: Dbx, holder: StructureHolder): Promise` — joins `character` to fill `name`, `scopes`, `tokenStatus` and `currentCorporationId`. **Declare `HolderView` here**, in `src/services/structures.ts`; Task 10's `view.ts` imports it. It describes a service read's return shape, and declaring it in `view.ts` would make this task depend on a later one: + +```ts +export type HolderView = { + characterId: number; + name: string; + scopes: string[]; + tokenStatus: "valid" | "invalid" | "needs_reauth" | "missing"; + /** The corp PINNED at designation. */ + corporationId: number; + /** What character.corporationId says right now — null when never resolved. */ + currentCorporationId: number | null; +}; +``` + +- [ ] **Step 1: Write the failing test** + +Create `tests/structure-service.test.ts`: + +```ts +import { describe, expect, it, beforeAll, afterAll, beforeEach } from "vitest"; +import { eq } from "drizzle-orm"; +import { setupTestDb, truncateAll } from "./helpers/db"; +import { testConfig } from "./helpers/config"; +import { seedAccount, seedCharacter } from "./helpers/seed"; +import { auditLog, structureEvent } from "@/db/schema"; +import { + designateStructureHolder, + getStructureHolder, + markSeeded, + stillStructureHolder, +} from "@/services/structures"; + +let ctx: Awaited>; +beforeAll(async () => { + ctx = await setupTestDb(); +}); +afterAll(async () => { + await ctx.cleanup(); +}); +beforeEach(async () => { + await truncateAll(ctx.db); +}); + +describe("designateStructureHolder", () => { + it("pins the corporation and audits the designation", async () => { + const account = await seedAccount(ctx.db); + await seedCharacter(ctx.db, testConfig(), { id: 90000001, accountId: account.id }); + await designateStructureHolder(ctx.db, 90000001, 98000001, account.id); + + const holder = await getStructureHolder(ctx.db); + expect(holder).toMatchObject({ characterId: 90000001, corporationId: 98000001 }); + expect(holder?.seededAt).toBeNull(); + + const rows = await ctx.db.select().from(auditLog); + expect(rows).toHaveLength(1); + expect(rows[0].action).toBe("structure.holder_designated"); + expect(rows[0].details).toMatchObject({ + characterId: 90000001, + corporationId: 98000001, + }); + }); + + it("retires pending alerts when the holder is replaced, and says how many", async () => { + const account = await seedAccount(ctx.db); + await seedCharacter(ctx.db, testConfig(), { id: 90000001, accountId: account.id }); + await seedCharacter(ctx.db, testConfig(), { id: 90000002, accountId: account.id }); + await designateStructureHolder(ctx.db, 90000001, 98000001, account.id); + await ctx.db.insert(structureEvent).values([ + { + notificationId: 1, + type: "StructureUnderAttack", + sentAt: new Date(), + corporationId: 98000001, + alertStatus: "pending", + }, + { + notificationId: 2, + type: "StructureLostArmor", + sentAt: new Date(), + corporationId: 98000001, + alertStatus: "sent", + }, + ]); + + const result = await designateStructureHolder( + ctx.db, + 90000002, + 98000002, + account.id, + ); + expect(result.abandonedAlerts).toBe(1); + + const [one] = await ctx.db + .select() + .from(structureEvent) + .where(eq(structureEvent.notificationId, 1)); + expect(one.alertStatus).toBe("abandoned"); + const [two] = await ctx.db + .select() + .from(structureEvent) + .where(eq(structureEvent.notificationId, 2)); + expect(two.alertStatus).toBe("sent"); + + const rows = await ctx.db.select().from(auditLog); + const replaced = rows.find((r) => r.action === "structure.holder_replaced"); + expect(replaced?.details).toMatchObject({ + previousCharacterId: 90000001, + characterId: 90000002, + abandonedAlerts: 1, + }); + }); + + it("resets seededAt so a new holder re-seeds", async () => { + const account = await seedAccount(ctx.db); + await seedCharacter(ctx.db, testConfig(), { id: 90000001, accountId: account.id }); + await seedCharacter(ctx.db, testConfig(), { id: 90000002, accountId: account.id }); + await designateStructureHolder(ctx.db, 90000001, 98000001, account.id); + await markSeeded(ctx.db, new Date()); + expect((await getStructureHolder(ctx.db))?.seededAt).toBeInstanceOf(Date); + await designateStructureHolder(ctx.db, 90000002, 98000002, account.id); + expect((await getStructureHolder(ctx.db))?.seededAt).toBeNull(); + }); +}); + +describe("stillStructureHolder", () => { + it("is false once another character has been designated", async () => { + const account = await seedAccount(ctx.db); + await seedCharacter(ctx.db, testConfig(), { id: 90000001, accountId: account.id }); + await seedCharacter(ctx.db, testConfig(), { id: 90000002, accountId: account.id }); + await designateStructureHolder(ctx.db, 90000001, 98000001, account.id); + expect(await stillStructureHolder(ctx.db, 90000001)).toBe(true); + await designateStructureHolder(ctx.db, 90000002, 98000002, account.id); + expect(await stillStructureHolder(ctx.db, 90000001)).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npx vitest run tests/structure-service.test.ts` +Expected: FAIL — cannot resolve `@/services/structures`. + +- [ ] **Step 3: Write the service** + +Create `src/services/structures.ts`: + +```ts +import { and, desc, eq, inArray } from "drizzle-orm"; +import type { Db, Dbx } from "@/db"; +import { + structure, + structureEvent, + structureHolder, + structureReadState, + type StructureReadStatus, +} from "@/db/schema"; +import { logAudit } from "@/services/audit"; + +/** + * The holder table is a singleton enforced by `CHECK (id = 1)`. One constant so + * every read and write spells the key the same way; a literal `1` scattered + * across call sites is how a second row eventually appears. + */ +export const STRUCTURE_HOLDER_ROW_ID = 1; + +export type StructureHolder = { + characterId: number; + corporationId: number; + designatedAt: Date; + designatedBy: string; + seededAt: Date | null; +}; + +export async function getStructureHolder(dbx: Dbx): Promise { + const [row] = await dbx + .select({ + characterId: structureHolder.characterId, + corporationId: structureHolder.corporationId, + designatedAt: structureHolder.designatedAt, + designatedBy: structureHolder.designatedBy, + seededAt: structureHolder.seededAt, + }) + .from(structureHolder) + .where(eq(structureHolder.id, STRUCTURE_HOLDER_ROW_ID)); + return row ?? null; +} + +/** + * Points the monitor at a character and PINS the corporation, in one + * transaction so the audit row, the designation and the retired alerts cannot + * disagree. + * + * Three things happen together and must not be separable: + * 1. the designation is written, with `seededAt` reset to null so the new + * holder re-seeds rather than replaying a 90-day backlog; + * 2. every `pending` alert is retired to `abandoned` — those were owed to a + * holder that no longer exists, and posting them under the new one would + * alert about a corp this monitor no longer watches; + * 3. the audit row records how many were retired, which is the only number + * that says whether a holder swap swallowed a live attack. + */ +export async function designateStructureHolder( + db: Db, + characterId: number, + corporationId: number, + actor: string, +): Promise<{ abandonedAlerts: number }> { + return db.transaction(async (tx) => { + const previous = await getStructureHolder(tx); + const designatedAt = new Date(); + await tx + .insert(structureHolder) + .values({ + id: STRUCTURE_HOLDER_ROW_ID, + characterId, + corporationId, + designatedAt, + designatedBy: actor, + seededAt: null, + }) + .onConflictDoUpdate({ + target: structureHolder.id, + set: { characterId, corporationId, designatedAt, designatedBy: actor, seededAt: null }, + }); + + const retired = previous + ? await tx + .update(structureEvent) + .set({ alertStatus: "abandoned" }) + .where(eq(structureEvent.alertStatus, "pending")) + .returning({ id: structureEvent.notificationId }) + : []; + + await logAudit(tx, { + actor, + action: previous ? "structure.holder_replaced" : "structure.holder_designated", + target: String(characterId), + details: previous + ? { + previousCharacterId: previous.characterId, + characterId, + corporationId, + abandonedAlerts: retired.length, + } + : { characterId, corporationId }, + }); + return { abandonedAlerts: retired.length }; + }); +} + +/** + * Whether `characterId` is STILL the designated holder, read inside the + * caller's transaction. A job that read the holder minutes ago must not write + * another character's data under this designation; every write CASes on this. + */ +export async function stillStructureHolder( + tx: Dbx, + characterId: number, +): Promise { + const holder = await getStructureHolder(tx); + return holder?.characterId === characterId; +} + +/** Stamps the first completed poll, which is what switches seeding off. */ +export async function markSeeded(dbx: Dbx, at: Date): Promise { + await dbx + .update(structureHolder) + .set({ seededAt: at }) + .where(eq(structureHolder.id, STRUCTURE_HOLDER_ROW_ID)); +} + +/** + * Records one read attempt. `observedAt` advances ONLY on success, so the page + * can say how stale a roster is without either lying about freshness or + * discarding the failure that made it stale. + */ +export async function recordReadState( + dbx: Dbx, + input: { + kind: "roster" | "events"; + corporationId: number; + status: StructureReadStatus; + detail?: string | null; + observed: boolean; + at: Date; + }, +): Promise { + const set: Record = { + lastAttemptAt: input.at, + readStatus: input.status, + detail: input.detail ?? null, + }; + if (input.observed) set.observedAt = input.at; + await dbx + .insert(structureReadState) + .values({ + kind: input.kind, + corporationId: input.corporationId, + observedAt: input.observed ? input.at : null, + lastAttemptAt: input.at, + readStatus: input.status, + detail: input.detail ?? null, + }) + .onConflictDoUpdate({ + target: [structureReadState.kind, structureReadState.corporationId], + set, + }); +} + +export type ReadStateRow = { + observedAt: Date | null; + lastAttemptAt: Date; + readStatus: StructureReadStatus; + detail: string | null; +}; + +export async function getReadStates( + dbx: Dbx, + corporationId: number, +): Promise> { + const rows = await dbx + .select() + .from(structureReadState) + .where(eq(structureReadState.corporationId, corporationId)); + const out: Record = {}; + for (const r of rows) { + out[r.kind] = { + observedAt: r.observedAt, + lastAttemptAt: r.lastAttemptAt, + readStatus: r.readStatus, + detail: r.detail, + }; + } + return out; +} + +export type RosterRow = { + structureId: number; + name: string | null; + typeName: string | null; + systemId: number; + state: string; + stateTimerEnd: Date | null; + fuelExpires: Date | null; + observedAt: Date; + missingSince: Date | null; +}; + +export async function getRoster( + dbx: Dbx, + corporationId: number, +): Promise { + return dbx + .select({ + structureId: structure.structureId, + name: structure.name, + typeName: structure.typeName, + systemId: structure.systemId, + state: structure.state, + stateTimerEnd: structure.stateTimerEnd, + fuelExpires: structure.fuelExpires, + observedAt: structure.observedAt, + missingSince: structure.missingSince, + }) + .from(structure) + .where(eq(structure.corporationId, corporationId)); +} + +export type EventRow = { + notificationId: number; + type: string; + sentAt: Date; + structureId: number | null; + details: Record | null; +}; + +export async function getRecentEvents( + dbx: Dbx, + corporationId: number, + limit: number, +): Promise { + return dbx + .select({ + notificationId: structureEvent.notificationId, + type: structureEvent.type, + sentAt: structureEvent.sentAt, + structureId: structureEvent.structureId, + details: structureEvent.details, + }) + .from(structureEvent) + .where(eq(structureEvent.corporationId, corporationId)) + .orderBy(desc(structureEvent.sentAt)) + .limit(limit); +} +``` + +- [ ] **Step 4: Run the tests** + +Run: `npx vitest run tests/structure-service.test.ts` +Expected: PASS. + +- [ ] **Step 5: Format and commit** + +```bash +npm run format:check +git add src/services/structures.ts tests/structure-service.test.ts +git commit -m "feat(structures): holder designation, read state and roster reads" +``` + +--- + +### Task 7: Audit vocabulary and the opt-in grant + +Small, but it gates Task 11's page: the designate action cannot audit until the +namespace is registered, and the page's re-grant link cannot work until the +route knows the grant name. + +**Files:** + +- Modify: `src/services/audit.ts` (`NAMESPACE_TARGET_KIND`, `DETAIL_CHARACTER_KEYS`) +- Modify: `src/app/admin/audit/summarize.ts` +- Modify: `src/app/auth/eve/link/route.ts` +- Test: `tests/audit-summarize.test.ts` (extend), `tests/auth-routes.test.ts` (extend) + +**Interfaces:** + +- Consumes: `STRUCTURES_SCOPE`, `NOTIFICATIONS_SCOPE` from Task 4. +- Produces: `?grant=structures` on `/auth/eve/link`; audit rendering for the two `structure.*` actions. + +**Note:** the symbol is `NAMESPACE_TARGET_KIND` (`src/services/audit.ts:214`). +The spec calls it `TARGET_KIND_BY_NAMESPACE`; the spec is wrong on the name. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/auth-routes.test.ts`: + +```ts +it("adds both structure scopes for grant=structures, and nothing else", async () => { + const res = await GET(linkRequest("/auth/eve/link?grant=structures")); + const scope = new URL(res.headers.get("location")!).searchParams.get("scope")!; + expect(scope).toContain("esi-corporations.read_structures.v1"); + expect(scope).toContain("esi-characters.read_notifications.v1"); + expect(scope).not.toContain("esi-access.read_lists.v1"); +}); + +it("ignores an unknown grant value", async () => { + const res = await GET(linkRequest("/auth/eve/link?grant=esi-corporations.read_blueprints.v1")); + const scope = new URL(res.headers.get("location")!).searchParams.get("scope")!; + expect(scope).not.toContain("blueprints"); +}); + +// A prototype-chain key must take the same path as an unknown key, not throw. +// Assert on the resulting scope SET, not merely that nothing threw: a +// no-throw assertion would still pass if the route silently began granting +// something. +it("treats a prototype-chain grant key as unknown", async () => { + for (const grant of ["toString", "constructor", "__proto__"]) { + const res = await GET(linkRequest(`/auth/eve/link?grant=${encodeURIComponent(grant)}`)); + expect(res.status).toBe(307); + const scope = new URL(res.headers.get("location")!).searchParams.get("scope")!; + expect(scope.split(" ")).not.toContain(ACCESS_LISTS_SCOPE); + } +}); +``` + +Use whatever request helper the existing tests in that file already use for +`/auth/eve/link`; do not invent a new one. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npx vitest run tests/auth-routes.test.ts -t "structure"` +Expected: FAIL — the scope is absent from the authorize URL. + +- [ ] **Step 3: Extend the link route** + +In `src/app/auth/eve/link/route.ts`, replace the single-grant expression with a +lookup table, keeping the existing comment's argument intact: + +```ts +import { + ACCESS_LISTS_SCOPE, + NOTIFICATIONS_SCOPE, + STRUCTURES_SCOPE, +} from "@/lib/esi/client"; + +// Opt-in only: none of these are in EVE_SSO_SCOPES, because adding one there +// would flip every character to needs_reauth at the next token-health run. +// Exact literals keyed by an allowed grant name, never a free-form scope +// parameter — the query string is attacker-controllable and must not be able +// to widen what we ask EVE for. +const GRANTS: Record = { + "access-lists": [ACCESS_LISTS_SCOPE], + structures: [STRUCTURES_SCOPE, NOTIFICATIONS_SCOPE], +}; + +// `Object.hasOwn`, NOT a bare index and NOT `in`. `GRANTS` is a plain object +// literal, so it inherits from `Object.prototype`: a bare `GRANTS[grant]` +// returns an inherited member for `grant=toString`, `constructor` or +// `__proto__`, which `?? []` does not catch and the spread then throws — +// an unhandled 500 on a crafted query string. `in` walks the prototype chain +// too and would not fix it. Same guard `isJobType` uses (src/core/schedules.ts:40). +const grant = req.nextUrl.searchParams.get("grant") ?? ""; +const extraScopes = Object.hasOwn(GRANTS, grant) ? [...GRANTS[grant]] : []; +``` + +- [ ] **Step 4: Register the audit vocabulary** + +In `src/services/audit.ts`, add to `NAMESPACE_TARGET_KIND`: + +```ts + "structure.": "character", +``` + +and to `DETAIL_CHARACTER_KEYS`: + +```ts + "structure.holder_designated": ["characterId"], + "structure.holder_replaced": ["characterId", "previousCharacterId"], +``` + +In `src/app/admin/audit/summarize.ts`, add entries beside the `access_list.*` +ones, following the existing `Part` combinator style: + +```ts + "structure.holder_designated": (d) => [characterRef(d, "characterId")], + "structure.holder_replaced": (d) => [ + transition(characterRef(d, "previousCharacterId"), characterRef(d, "characterId")), + labelled("abandoned alerts", scalar(d.abandonedAlerts)), + ], +``` + +Match the exact combinator signatures already in that file — read them before +writing this, and adapt if they differ. + +- [ ] **Step 5: Run the tests** + +Run: `npx vitest run tests/auth-routes.test.ts tests/audit-summarize.test.ts tests/audit.test.ts` +Expected: PASS. + +- [ ] **Step 6: Format and commit** + +```bash +npm run format:check +git add src/app/auth/eve/link/route.ts src/services/audit.ts src/app/admin/audit/summarize.ts tests/auth-routes.test.ts +git commit -m "feat(structures): opt-in scope grant and audit vocabulary" +``` + +--- + +### Task 8: The roster job + +**Files:** + +- Create: `src/jobs/structures.ts` +- Modify: `src/core/schedules.ts` (`JOB_CRON`, `JOB_GROUP`) +- Modify: `src/worker/queues.ts` (`QUEUES`, `JOB_QUEUES`) +- Modify: `src/worker/handlers.ts` +- Modify: `src/services/sync-status.ts` (`KNOWN_ORDER`) +- Test: `tests/structure-roster-job.test.ts`, plus expectations in `tests/schedules.test.ts`, `tests/worker-queues.test.ts`, `tests/dispatcher.test.ts` + +**Interfaces:** + +- Consumes: `StructuresEsi` (Task 4), the service (Task 6), `compareRosterRows` (Task 2). +- Produces: `runStructuresJob(deps: { db: Db; cfg: Config; esi: StructuresEsi; fetchImpl?: typeof fetch }): Promise`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/structure-roster-job.test.ts`. Follow +`tests/access-lists-job.test.ts` exactly for setup: real Postgres via +`setupTestDb`, `truncateAll` per test, `testConfig()`, `seedAccount` / +`seedCharacter`, a hand-rolled `StructuresEsi` fake, and `okToken` / +`deadToken` / `flakyToken` `fetchImpl` stubs. Copy those helpers from that file +rather than importing them. + +```ts +const CORP = 98000001; +const HOLDER = 90000001; + +function fakeEsi(opts: { + structures?: EsiCorporationStructure[]; + error?: Error; +}): StructuresEsi { + return { + getCorporationStructures: async () => { + if (opts.error) throw opts.error; + return opts.structures ?? []; + }, + getUniverseNames: async (ids: number[]) => + ids.map((id) => ({ id, name: `name-${id}`, category: "inventory_type" })), + }; +} + +function struct(id: number, over: Partial = {}) { + return { + structureId: id, + typeId: 35832, + systemId: 30004268, + name: `S${id}`, + state: "shield_vulnerable", + stateTimerStart: null, + stateTimerEnd: null, + fuelExpires: null, + ...over, + }; +} + +/** + * Designates a holder pinned to CORP, with both scopes granted and a live + * token, and optionally moves the character's CURRENT corp elsewhere so the + * corp-changed branch can be exercised. + */ +async function designate(opts: { currentCorp?: number; scopes?: string[] } = {}) { + const account = await seedAccount(ctx.db); + await seedCharacter(ctx.db, testConfig(), { + id: HOLDER, + accountId: account.id, + corporationId: opts.currentCorp ?? CORP, + scopes: opts.scopes ?? [STRUCTURES_SCOPE, NOTIFICATIONS_SCOPE], + tokenStatus: "valid", + // The helper encrypts this with the test key itself — never pass a + // pre-encrypted blob (tests/helpers/seed.ts:33-50). + refreshToken: "refresh", + }); + await designateStructureHolder(ctx.db, HOLDER, CORP, account.id); + return account; +} + +function run(esi: StructuresEsi, fetchImpl = okToken) { + return runStructuresJob({ db: ctx.db, cfg: testConfig(), esi, fetchImpl }); +} + +describe("runStructuresJob", () => { + it("returns ok with noHolder when nothing is designated", async () => { + const res = await run(fakeEsi({})); + expect(res.status).toBe("ok"); + expect(res.counts?.noHolder).toBe(1); + }); + + it("does not call ESI when the holder lacks the scope", async () => { + await designate({ scopes: [] }); + let called = false; + const esi: StructuresEsi = { + getCorporationStructures: async () => { + called = true; + return []; + }, + getUniverseNames: async () => [], + }; + const res = await run(esi); + expect(called).toBe(false); + expect(res.counts?.scopeMissing).toBe(1); + }); + + it("refuses to read when the holder has left the pinned corporation", async () => { + await designate({ currentCorp: 98000002 }); + const res = await run(fakeEsi({ structures: [struct(1)] })); + expect(res.counts?.corpChanged).toBe(1); + const states = await getReadStates(ctx.db, CORP); + expect(states.roster.readStatus).toBe("failed"); + expect(states.roster.detail).toBe("corp-changed"); + expect(await getRoster(ctx.db, CORP)).toHaveLength(0); + }); + + it("records forbidden and mutates no roster rows on a corp-roles 403", async () => { + await designate(); + await run(fakeEsi({ structures: [struct(1)] })); // one good read first + const res = await run( + fakeEsi({ + error: new EsiError( + "Character does not have required role(s)", + 403, + "permanent", + ), + }), + ); + expect(res.status).toBe("partial"); + const states = await getReadStates(ctx.db, CORP); + expect(states.roster.readStatus).toBe("forbidden"); + // the last GOOD read's timestamp survives the failure + expect(states.roster.observedAt).toBeInstanceOf(Date); + const rows = await getRoster(ctx.db, CORP); + expect(rows).toHaveLength(1); + expect(rows[0].missingSince).toBeNull(); + }); + + it("pins a corp-roles 403 as permanent, not needs_reauth", () => { + // Load-bearing on CCP's error PROSE: classifyEsiError maps 403 to + // needs_reauth only when the body names a scope/token/authorization + // problem. If CCP reworded this, `forbidden` would start reading as a + // token fault and send admins round the re-auth loop forever. + expect( + classifyEsiError(403, { error: "Character does not have required role(s)" }), + ).toBe("permanent"); + expect(classifyEsiError(403, { error: "invalid token" })).toBe("needs_reauth"); + }); + + it("stamps missingSince rather than deleting a structure that stopped appearing", async () => { + await designate(); + await run(fakeEsi({ structures: [struct(1), struct(2)] })); + await run(fakeEsi({ structures: [struct(1)] })); + const rows = await getRoster(ctx.db, CORP); + expect(rows).toHaveLength(2); + expect(rows.find((r) => r.structureId === 2)?.missingSince).toBeInstanceOf(Date); + expect(rows.find((r) => r.structureId === 1)?.missingSince).toBeNull(); + }); + + it("clears missingSince when a structure reappears", async () => { + await designate(); + await run(fakeEsi({ structures: [struct(1), struct(2)] })); + await run(fakeEsi({ structures: [struct(1)] })); + await run(fakeEsi({ structures: [struct(1), struct(2)] })); + const rows = await getRoster(ctx.db, CORP); + expect(rows.find((r) => r.structureId === 2)?.missingSince).toBeNull(); + }); + + it("keeps a good type name when the name lookup fails", async () => { + await designate(); + await run(fakeEsi({ structures: [struct(1)] })); + const esi: StructuresEsi = { + getCorporationStructures: async () => [struct(1)], + getUniverseNames: async () => { + throw new Error("names down"); + }, + }; + await run(esi); + expect((await getRoster(ctx.db, CORP))[0].typeName).toBe("name-35832"); + }); +}); +``` + +`okToken` / `deadToken` / `flakyToken`, `seedAccount`, `seedCharacter`, +`setupTestDb` and `truncateAll` all come from the same places +`tests/access-lists-job.test.ts` gets them — copy those imports from that file. +If `seedCharacter` does not accept `corporationId` / `scopes` / `tokenStatus` / +`refreshTokenEnc`, extend the helper rather than hand-rolling an insert here. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npx vitest run tests/structure-roster-job.test.ts` +Expected: FAIL — cannot resolve `@/jobs/structures`. + +- [ ] **Step 3: Register the job** + +`src/core/schedules.ts` — add to `JOB_CRON`, with the slot argument: + +```ts + // :35 is free — :00/:30 membership, :05 contacts, :10 wanderer, + // :15 discord-roles, :25 access-lists, :02,17,32,47 location. The roster + // endpoint caches for an hour, so a faster tick would re-read the same page. + structures: "35 * * * *", +``` + +and to `JOB_GROUP`: + +```ts + structures: "on-demand", +``` + +`src/worker/queues.ts` — add `structures: "structures",` to `QUEUES` and +`QUEUES.structures,` to `JOB_QUEUES`. + +`src/worker/handlers.ts` — add the schema, the deps type and the handler: + +```ts +const structuresSchema = z.object({ jobType: z.literal(QUEUES.structures) }).strict(); +``` + +Widen `JobDeps["esi"]` with `& StructuresEsi`, and add: + +```ts + [QUEUES.structures]: async (data) => { + structuresSchema.parse(data); + await runStructuresJob(deps); + }, +``` + +`src/services/sync-status.ts` — add `"structures"` to `KNOWN_ORDER` after +`"access-lists"`. + +- [ ] **Step 4: Write the job** + +Create `src/jobs/structures.ts`: + +```ts +import { and, eq, inArray, isNull, not } from "drizzle-orm"; +import type { Config } from "@/config"; +import type { Db } from "@/db"; +import { character, structure } from "@/db/schema"; +import { EsiError } from "@/lib/esi/client"; +import type { StructuresEsi } from "@/lib/esi/client"; +import { STRUCTURES_SCOPE } from "@/lib/esi/client"; +import { + getStructureHolder, + recordReadState, + stillStructureHolder, +} from "@/services/structures"; +import { runJob, type JobResult } from "@/services/sync-run"; +import { getFreshAccessToken } from "@/services/tokens"; + +type Counts = { + structures: number; + missing: number; + noHolder: number; + scopeMissing: number; + corpChanged: number; + skipped: number; + forbidden: number; +}; + +/** + * Refreshes the roster of structures the pinned corporation owns. + * + * Staged exactly like the access-lists job: no holder is a normal `ok`, the + * scope is checked against the PERSISTED grant before any network call, and + * every write CASes on the holder still being the holder. + */ +export async function runStructuresJob(deps: { + db: Db; + cfg: Config; + esi: StructuresEsi; + fetchImpl?: typeof fetch; +}): Promise { + const { db, cfg, esi } = deps; + return runJob(db, "structures", async () => { + const counts: Counts = { + structures: 0, + missing: 0, + noHolder: 0, + scopeMissing: 0, + corpChanged: 0, + skipped: 0, + forbidden: 0, + }; + + // 1. No holder. An unconfigured optional feature must not paint + // /admin/sync red — the monitor page explains the missing designation. + const holder = await getStructureHolder(db); + if (!holder) { + counts.noHolder = 1; + return { status: "ok", counts }; + } + + const [row] = await db + .select({ + id: character.id, + corporationId: character.corporationId, + refreshTokenEnc: character.refreshTokenEnc, + tokenStatus: character.tokenStatus, + scopes: character.scopes, + }) + .from(character) + .where(eq(character.id, holder.characterId)); + if (!row) { + // The holder FK cascades, so a missing character row means the + // designation was deleted concurrently. Same state as no holder. + counts.noHolder = 1; + return { status: "ok", counts }; + } + + // 2. Scope, from the PERSISTED grant and before any ESI call: calling + // anyway would spend a refresh-token rotation to earn a certain 403. + if (!row.scopes.includes(STRUCTURES_SCOPE)) { + counts.scopeMissing = 1; + return { status: "ok", counts }; + } + + // 3. The corporation is PINNED. If the holder has moved, reading their new + // corp's structures under this designation would stamp missingSince on + // every structure of the old one — a fabricated mass-destruction event. + // Refuse, and let the page ask for a re-designation. + if (row.corporationId !== holder.corporationId) { + counts.corpChanged = 1; + await recordReadState(db, { + kind: "roster", + corporationId: holder.corporationId, + status: "failed", + detail: "corp-changed", + observed: false, + at: new Date(), + }); + return { status: "partial", errorSummary: "holder left the pinned corp", counts }; + } + + // 4. Token. getFreshAccessToken has FOUR outcomes and performs its own + // invalidation CAS internally, so this job must not repeat it. + const token = await getFreshAccessToken( + db, + cfg, + { + id: row.id, + refreshTokenEnc: row.refreshTokenEnc, + tokenStatus: row.tokenStatus, + }, + deps.fetchImpl, + ); + if (!token.ok) { + if (token.reason === "dry_run") { + counts.skipped = 1; + return { status: "ok", counts }; + } + if (token.reason === "transient") { + return { + status: "failed", + errorSummary: `token refresh failed: ${token.detail ?? "transient"}`, + counts, + retry: true, + }; + } + return { status: "failed", errorSummary: `holder token ${token.reason}`, counts }; + } + + const at = new Date(); + let rows; + try { + rows = await esi.getCorporationStructures(holder.corporationId, token.accessToken); + } catch (err) { + // A 403 here is the Station_Manager role missing in game — a normal + // state this app cannot fix, not a token fault. It classifies + // `permanent` because the ESI body names a role, not a scope or token. + const forbidden = err instanceof EsiError && err.status === 403; + const transient = err instanceof EsiError ? err.kind === "transient" : true; + counts.forbidden = forbidden ? 1 : 0; + await recordReadState(db, { + kind: "roster", + corporationId: holder.corporationId, + status: forbidden ? "forbidden" : "failed", + detail: forbidden ? "station-manager-role" : "read failed", + observed: false, + at, + }); + if (forbidden) { + // Never retry a permission the app cannot obtain; the hourly tick is + // enough to notice the role being granted. + return { status: "partial", errorSummary: "roster read forbidden", counts }; + } + return { + status: "failed", + errorSummary: "roster read failed", + counts, + retry: transient || undefined, + }; + } + + // Resolve type names once per run. Best-effort: a name failure must not + // fail the roster, since nothing branches on it. + const typeIds = [...new Set(rows.map((r) => r.typeId))]; + let typeNames = new Map(); + try { + const named = await esi.getUniverseNames(typeIds); + typeNames = new Map(named.map((n) => [n.id, n.name])); + } catch { + // leave typeNames empty; rows keep whatever name they already had + } + + await db.transaction(async (tx) => { + if (!(await stillStructureHolder(tx, holder.characterId))) return; + const seen = rows.map((r) => r.structureId); + for (const r of rows) { + const values = { + structureId: r.structureId, + corporationId: holder.corporationId, + typeId: r.typeId, + typeName: typeNames.get(r.typeId) ?? null, + systemId: r.systemId, + name: r.name, + state: r.state, + stateTimerStart: r.stateTimerStart, + stateTimerEnd: r.stateTimerEnd, + fuelExpires: r.fuelExpires, + observedAt: at, + missingSince: null, + }; + await tx + .insert(structure) + .values(values) + .onConflictDoUpdate({ + target: structure.structureId, + // typeName only overwrites when this run resolved one, so a failed + // name lookup does not blank a name that was already good. + set: { + ...values, + typeName: typeNames.get(r.typeId) ?? undefined, + }, + }); + } + counts.structures = rows.length; + + // Absent from the response: stamp, never delete. Only rows that are not + // already stamped, so missingSince records when it FIRST went missing. + const missing = await tx + .update(structure) + .set({ missingSince: at }) + .where( + and( + eq(structure.corporationId, holder.corporationId), + isNull(structure.missingSince), + seen.length > 0 ? not(inArray(structure.structureId, seen)) : undefined, + ), + ) + .returning({ id: structure.structureId }); + counts.missing = missing.length; + + await recordReadState(tx, { + kind: "roster", + corporationId: holder.corporationId, + status: "ok", + detail: null, + observed: true, + at, + }); + }); + + return { status: "ok", counts }; + }); +} +``` + +- [ ] **Step 5: Update the registry expectations** + +`tests/worker-queues.test.ts:40-47` holds a literal per-queue list, and +`tests/schedules.test.ts:113-118` and `tests/dispatcher.test.ts:128-132` assert +set equality against `JOB_CRON`. Add `structures` to each. + +- [ ] **Step 6: Run the tests** + +Run: `npx vitest run tests/structure-roster-job.test.ts tests/schedules.test.ts tests/worker-queues.test.ts tests/dispatcher.test.ts tests/sync-status.test.ts` +Expected: PASS. + +- [ ] **Step 7: Format and commit** + +```bash +npm run format:check +git add src/jobs/structures.ts src/core/schedules.ts src/worker/queues.ts src/worker/handlers.ts src/services/sync-status.ts tests/ +git commit -m "feat(structures): hourly roster job" +``` + +--- + +### Task 9: The events job + +**Files:** + +- Create: `src/jobs/structure-events.ts` +- Modify: `src/core/schedules.ts`, `src/worker/queues.ts`, `src/worker/handlers.ts`, `src/services/sync-status.ts` +- Test: `tests/structure-events-job.test.ts`, plus the same three registry test files + +**Interfaces:** + +- Consumes: `StructureEventsEsi` (Task 4), the service (Task 6), `extractStructureEvent` / `formatStructureAlert` / `isStructureEventType` (Task 2), `postStructureWebhook` / `resolveStructureWebhookUrl` (Task 5). +- Produces: `runStructureEventsJob(deps: { db: Db; cfg: Config; esi: StructureEventsEsi; fetchImpl?: typeof fetch }): Promise`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/structure-events-job.test.ts`, same harness as Task 8: + +```ts +describe("runStructureEventsJob", () => { + it("seeds silently on the first poll and sends nothing", async () => { + const posts: string[] = []; + const res = await run({ notifications: [attack(1), attack(2)], posts }); + expect(res.status).toBe("ok"); + expect(posts).toHaveLength(0); + const rows = await ctx.db.select().from(structureEvent); + expect(rows.map((r) => r.alertStatus)).toEqual(["seeded", "seeded"]); + expect((await getStructureHolder(ctx.db))?.seededAt).toBeInstanceOf(Date); + }); + + it("alerts only on events new since the seed", async () => { + const posts: string[] = []; + await run({ notifications: [attack(1)], posts }); + await run({ notifications: [attack(1), attack(2)], posts }); + expect(posts).toHaveLength(1); + expect(posts[0]).toContain("under attack"); + }); + + it("ignores non-damage notification types entirely", async () => { + await run({ notifications: [seedOne()] }); + await run({ + notifications: [{ ...attack(9), type: "StructureFuelAlert" }, mailNotification()], + }); + const rows = await ctx.db.select().from(structureEvent); + expect(rows.map((r) => r.notificationId)).not.toContain(9); + expect(rows).toHaveLength(1); + }); + + it("records as seeded, never pending, when no webhook is configured", async () => { + const cfg = { ...testConfig(), discord: { ...testConfig().discord, opsWebhookUrl: undefined, structureWebhookUrl: undefined } }; + await run({ notifications: [attack(1)], cfg }); // seeds + await run({ notifications: [attack(1), attack(2)], cfg }); + const rows = await ctx.db.select().from(structureEvent); + expect(rows.map((r) => r.alertStatus).sort()).toEqual(["seeded", "seeded"]); + expect(rows.some((r) => r.alertStatus === "sent")).toBe(false); + }); + + it("leaves a row pending and retries it next run when the post fails", async () => { + await run({ notifications: [attack(1)] }); // seed + const res = await run({ notifications: [attack(1), attack(2)], postFails: true }); + expect(res.status).toBe("partial"); + let [row] = await ctx.db + .select() + .from(structureEvent) + .where(eq(structureEvent.notificationId, 2)); + expect(row.alertStatus).toBe("pending"); + + const posts: string[] = []; + await run({ notifications: [attack(1), attack(2)], posts }); + expect(posts).toHaveLength(1); + [row] = await ctx.db + .select() + .from(structureEvent) + .where(eq(structureEvent.notificationId, 2)); + expect(row.alertStatus).toBe("sent"); + }); + + it("never posts a pending row belonging to another corporation", async () => { + await run({ notifications: [attack(1)] }); // seed, corp 98000001 + await ctx.db.insert(structureEvent).values({ + notificationId: 500, + type: "StructureUnderAttack", + sentAt: new Date(), + corporationId: 98000999, + alertStatus: "pending", + }); + const posts: string[] = []; + await run({ notifications: [attack(1), attack(2)], posts }); + expect(posts).toHaveLength(1); // event 2 only, never 500 + }); + + it("skips entirely in dry-run without touching the table", async () => { + const cfg = { ...testConfig(), syncMode: "dry-run" as const }; + const res = await run({ notifications: [attack(1)], cfg }); + expect(res.counts?.skipped).toBe(1); + expect(await ctx.db.select().from(structureEvent)).toHaveLength(0); + }); + + it("records an event whose body will not parse, and still alerts", async () => { + await run({ notifications: [attack(1)] }); + const posts: string[] = []; + await run({ + notifications: [attack(1), { ...attack(2), text: "!!! unparseable" }], + posts, + }); + expect(posts).toHaveLength(1); + const [row] = await ctx.db + .select() + .from(structureEvent) + .where(eq(structureEvent.notificationId, 2)); + expect(row.structureId).toBeNull(); + expect(row.alertStatus).toBe("sent"); + }); +}); +``` + +Write `run`, `attack(id)`, `seedOne()` and `mailNotification()` as local helpers +in this file. `run` builds a `StructureEventsEsi` fake, a `postStructureWebhook` +capture (inject via `fetchImpl`, matching how `tests/sync-mode.test.ts` stubs a +webhook post), designates a holder pinned to corp `98000001`, and calls +`runStructureEventsJob`. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npx vitest run tests/structure-events-job.test.ts` +Expected: FAIL — cannot resolve `@/jobs/structure-events`. + +- [ ] **Step 3: Register the job** + +`src/core/schedules.ts`: + +```ts + // Ten minutes matches the notifications endpoint's 600 s cache exactly — + // polling faster returns the same cached page. Offset off :00/:05/:10/:15/ + // :25/:30/:35 and location's :02,17,32,47. formatCadence renders evenly + // spaced comma minutes, so the admin page shows "every 10 minutes" rather + // than the raw cron. + "structure-events": "3,13,23,33,43,53 * * * *", +``` + +```ts + "structure-events": "on-demand", +``` + +`src/worker/queues.ts`: `structureEvents: "structure-events",` in `QUEUES`, and +`QUEUES.structureEvents,` in `JOB_QUEUES`. + +`src/worker/handlers.ts`: the strict schema, `& StructureEventsEsi` on +`JobDeps["esi"]`, and the handler entry. + +`src/services/sync-status.ts`: `"structure-events"` in `KNOWN_ORDER`. + +- [ ] **Step 4: Write the job** + +Create `src/jobs/structure-events.ts`: + +```ts +import { and, asc, eq } from "drizzle-orm"; +import type { Config } from "@/config"; +import type { Db } from "@/db"; +import { character, structure, structureEvent } from "@/db/schema"; +import { + extractStructureEvent, + formatStructureAlert, + isStructureEventType, +} from "@/core/structure-event"; +import { EsiError, NOTIFICATIONS_SCOPE } from "@/lib/esi/client"; +import type { StructureEventsEsi } from "@/lib/esi/client"; +import { postStructureWebhook, resolveStructureWebhookUrl } from "@/lib/ops-webhook"; +import { + getStructureHolder, + markSeeded, + recordReadState, + stillStructureHolder, +} from "@/services/structures"; +import { runJob, type JobResult } from "@/services/sync-run"; +import { getFreshAccessToken } from "@/services/tokens"; + +type Counts = { + fetched: number; + recorded: number; + alerted: number; + failedPosts: number; + noHolder: number; + scopeMissing: number; + skipped: number; + seeded: number; + unconfigured: number; +}; + +/** + * Polls the holder's notifications for structure damage and posts each newly + * recorded one to Discord. + * + * The delivery contract is at-least-once. Rows are inserted `pending` and + * flipped to `sent` only after a post succeeds, so a crash between the two + * re-sends on the next tick; a duplicate Discord post is preferred to a lost + * one. A failed post returns "partial", not "failed" — the ten-minute tick is + * the retry, and pg-boss's retry budget is for a run that accomplished nothing. + */ +export async function runStructureEventsJob(deps: { + db: Db; + cfg: Config; + esi: StructureEventsEsi; + fetchImpl?: typeof fetch; +}): Promise { + const { db, cfg, esi } = deps; + return runJob(db, "structure-events", async () => { + const counts: Counts = { + fetched: 0, + recorded: 0, + alerted: 0, + failedPosts: 0, + noHolder: 0, + scopeMissing: 0, + skipped: 0, + seeded: 0, + unconfigured: 0, + }; + + const holder = await getStructureHolder(db); + if (!holder) { + counts.noHolder = 1; + return { status: "ok", counts }; + } + + const [row] = await db + .select({ + id: character.id, + refreshTokenEnc: character.refreshTokenEnc, + tokenStatus: character.tokenStatus, + scopes: character.scopes, + }) + .from(character) + .where(eq(character.id, holder.characterId)); + if (!row) { + counts.noHolder = 1; + return { status: "ok", counts }; + } + + if (!row.scopes.includes(NOTIFICATIONS_SCOPE)) { + counts.scopeMissing = 1; + return { status: "ok", counts }; + } + + // The token branch comes BEFORE any insert or post. In dry-run + // getFreshAccessToken returns `dry_run` without a network call, so this + // job never reaches the sender — which is what stops a dry-run worker from + // consuming real pending alerts against a production database. + const token = await getFreshAccessToken( + db, + cfg, + { + id: row.id, + refreshTokenEnc: row.refreshTokenEnc, + tokenStatus: row.tokenStatus, + }, + deps.fetchImpl, + ); + if (!token.ok) { + if (token.reason === "dry_run") { + counts.skipped = 1; + return { status: "ok", counts }; + } + if (token.reason === "transient") { + return { + status: "failed", + errorSummary: `token refresh failed: ${token.detail ?? "transient"}`, + counts, + retry: true, + }; + } + return { status: "failed", errorSummary: `holder token ${token.reason}`, counts }; + } + + const at = new Date(); + let notifications; + try { + notifications = await esi.getCharacterNotifications(row.id, token.accessToken); + } catch (err) { + // A 403 here is the Director/CEO role missing in game: corp structure + // notifications are not delivered to the character at all. + const forbidden = err instanceof EsiError && err.status === 403; + const transient = err instanceof EsiError ? err.kind === "transient" : true; + await recordReadState(db, { + kind: "events", + corporationId: holder.corporationId, + status: forbidden ? "forbidden" : "failed", + detail: forbidden ? "director-role" : "read failed", + observed: false, + at, + }); + if (forbidden) { + return { status: "partial", errorSummary: "notifications forbidden", counts }; + } + return { + status: "failed", + errorSummary: "notifications read failed", + counts, + retry: transient || undefined, + }; + } + counts.fetched = notifications.length; + + // Resolve the recipient BEFORE inserting. postStructureWebhook cannot tell + // "delivered" from "nowhere to deliver" once it has returned, so a row + // inserted `pending` on a deployment with no webhook would be marked + // `sent` by a post that never happened. + const hasWebhook = resolveStructureWebhookUrl(cfg) !== undefined; + if (!hasWebhook) counts.unconfigured = 1; + const seeding = holder.seededAt === null; + + // Only the four damage types are persisted. The endpoint returns every + // notification the character has — mail, war decs, kill rights, corp + // applications — and none of those reach Postgres. + const damage = notifications.filter((n) => isStructureEventType(n.type)); + + await db.transaction(async (tx) => { + if (!(await stillStructureHolder(tx, holder.characterId))) return; + for (const n of damage) { + const parsed = extractStructureEvent(n.text); + const inserted = await tx + .insert(structureEvent) + .values({ + notificationId: n.notificationId, + type: n.type, + sentAt: n.timestamp, + structureId: parsed.structureId, + corporationId: holder.corporationId, + alertStatus: seeding || !hasWebhook ? "seeded" : "pending", + details: parsed.details, + }) + .onConflictDoNothing() + .returning({ id: structureEvent.notificationId }); + if (inserted.length > 0) counts.recorded += 1; + } + if (seeding) { + counts.seeded = counts.recorded; + await markSeeded(tx, at); + } + await recordReadState(tx, { + kind: "events", + corporationId: holder.corporationId, + status: "ok", + detail: null, + observed: true, + at, + }); + }); + + if (seeding || !hasWebhook) return { status: "ok", counts }; + + // Every pending row for the PINNED corp, oldest first. This picks up + // leftovers from a previous run's failed posts, and excludes anything + // recorded under a previous holder (those were retired to `abandoned`). + const pending = await db + .select({ + notificationId: structureEvent.notificationId, + type: structureEvent.type, + structureId: structureEvent.structureId, + details: structureEvent.details, + }) + .from(structureEvent) + .where( + and( + eq(structureEvent.corporationId, holder.corporationId), + eq(structureEvent.alertStatus, "pending"), + ), + ) + .orderBy(asc(structureEvent.sentAt)); + + for (const event of pending) { + const [known] = event.structureId + ? await db + .select({ name: structure.name, typeName: structure.typeName }) + .from(structure) + .where(eq(structure.structureId, event.structureId)) + : []; + const content = formatStructureAlert({ + type: event.type, + structureName: known?.name ?? null, + typeName: known?.typeName ?? null, + systemName: null, + details: event.details ?? {}, + }); + try { + await postStructureWebhook(cfg, content, deps.fetchImpl); + await db + .update(structureEvent) + .set({ alertStatus: "sent" }) + .where(eq(structureEvent.notificationId, event.notificationId)); + counts.alerted += 1; + } catch { + // Leave the row pending. The ten-minute tick is the retry; burning + // pg-boss's retry budget on a Discord blip would dead-letter a job + // that read ESI successfully. + counts.failedPosts += 1; + } + } + + if (counts.failedPosts > 0) { + return { status: "partial", errorSummary: "some alerts failed to post", counts }; + } + return { status: "ok", counts }; + }); +} +``` + +- [ ] **Step 5: Update the registry expectations** + +Add `structure-events` to the literal lists in `tests/worker-queues.test.ts`, +`tests/schedules.test.ts` and `tests/dispatcher.test.ts`. + +- [ ] **Step 6: Run the tests** + +Run: `npx vitest run tests/structure-events-job.test.ts tests/schedules.test.ts tests/worker-queues.test.ts tests/dispatcher.test.ts` +Expected: PASS. + +- [ ] **Step 7: Format and commit** + +```bash +npm run format:check +git add src/jobs/structure-events.ts src/core/schedules.ts src/worker/queues.ts src/worker/handlers.ts src/services/sync-status.ts tests/ +git commit -m "feat(structures): ten-minute damage alert job" +``` + +--- + +### Task 10: The page's pure view logic + +**Files:** + +- Create: `src/app/admin/structures/view.ts` +- Test: `tests/structure-view.test.ts` + +**Interfaces:** + +- Consumes: `RosterRow`, `ReadStateRow` (Task 6). +- Produces: `monitorState`, `monitorSentence`, `monitorRemedy`, `showsRoster`, `rowTone`, `doneNotice`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/structure-view.test.ts`, covering every arm: + +```ts +import { describe, expect, it } from "vitest"; +import { monitorRemedy, monitorSentence, monitorState } from "@/app/admin/structures/view"; + +const base = { + grantable: null, + holder: null, + readStates: {}, + rosterCount: 0, + webhookConfigured: true, +}; + +describe("monitorState", () => { + it("asks for a grant when nobody has one", () => { + expect(monitorState(base)).toBe("grant-needed"); + }); + + it("asks for a designation when a character has the scopes but is not the holder", () => { + expect(monitorState({ ...base, grantable: { characterId: 1, name: "A" } })).toBe( + "designate-needed", + ); + }); + + it("puts the dropped scope BEFORE the token fault", () => { + // the plain re-auth link is what DROPS the scope, so offering it first + // sends an admin round a loop that cannot terminate + const state = monitorState({ + ...base, + holder: { + characterId: 1, + name: "A", + scopes: [], + tokenStatus: "needs_reauth", + corporationId: 5, + currentCorporationId: 5, + }, + }); + expect(state).toBe("scope-dropped"); + }); + + it("reports corp-changed when the holder has left the pinned corp", () => { + expect( + monitorState({ + ...base, + holder: { + characterId: 1, + name: "A", + // Real scope constants, not placeholders: the cascade checks scopes + // BEFORE the corp comparison, so a holder carrying fake scope strings + // returns "scope-dropped" and this arm is never reached. A test that + // cannot reach the state it names proves nothing. + scopes: [STRUCTURES_SCOPE, NOTIFICATIONS_SCOPE], + tokenStatus: "valid", + corporationId: 5, + currentCorporationId: 6, + }, + }), + ).toBe("corp-changed"); + }); + + it("names which read is forbidden", () => { + const state = monitorState({ + ...base, + holder: healthyHolder(), + readStates: { events: { readStatus: "forbidden" } }, + }); + expect(state).toBe("no-corp-roles"); + expect(monitorSentence(state, { forbidden: ["events"] })).toContain("notifications"); + }); + + it("says alerts are unconfigured rather than claiming they go to Discord", () => { + expect( + monitorState({ + ...base, + holder: healthyHolder(), + rosterCount: 3, + webhookConfigured: false, + }), + ).toBe("alerts-unconfigured"); + expect( + monitorState({ ...base, holder: healthyHolder(), rosterCount: 3 }), + ).toBe("normal"); + }); + + it("offers no remedy for states an admin cannot fix from this app", () => { + expect(monitorRemedy("no-corp-roles")).toBeNull(); + expect(monitorRemedy("alerts-unconfigured")).toBeNull(); + expect(monitorRemedy("grant-needed")).toMatchObject({ + href: "/auth/eve/link?grant=structures", + }); + }); + + it("uses the re-grant link for a dropped scope and the bare link for a token fault", () => { + expect(monitorRemedy("scope-dropped")?.href).toBe("/auth/eve/link?grant=structures"); + expect(monitorRemedy("holder-needs-reauth")?.href).toBe("/auth/eve/link"); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npx vitest run tests/structure-view.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write `view.ts`** + +Create `src/app/admin/structures/view.ts`: + +```ts +import type { StructureReadStatus } from "@/db/schema"; +import type { HolderView } from "@/services/structures"; +import { NOTIFICATIONS_SCOPE, STRUCTURES_SCOPE } from "@/lib/esi/client"; + +export type MonitorState = + | "grant-needed" + | "designate-needed" + | "scope-dropped" + | "holder-needs-reauth" + | "holder-no-token" + | "corp-changed" + | "no-corp-roles" + | "roster-empty" + | "alerts-unconfigured" + | "normal"; + +export const GRANT_HREF = "/auth/eve/link?grant=structures"; +const REAUTH_HREF = "/auth/eve/link"; + +// HolderView is declared in @/services/structures (Task 6) and imported above: +// it describes that service read's return shape, and re-declaring it here +// would give the two files a copy each to drift apart. + +export type MonitorInput = { + grantable: { characterId: number; name: string } | null; + holder: HolderView | null; + readStates: Partial>; + rosterCount: number; + webhookConfigured: boolean; +}; + +/** + * A priority cascade, most blocking first. Total over its input: every arm + * returns, so a new field cannot leave the page with no sentence to print. + * + * Scope BEFORE token, deliberately. A dropped grant and a stale token both + * want an EVE round trip, but they want DIFFERENT ones: the bare re-auth link + * is what drops the opt-in scope in the first place, so offering it to a + * scope-dropped holder sends an admin round a loop that cannot terminate. + * + * corp-changed is derived HERE, live, rather than read from + * structure_read_state.detail — the page must say so the moment affiliation + * updates, not up to an hour later when the roster job next ticks. + */ +export function monitorState(input: MonitorInput): MonitorState { + const { holder } = input; + if (!holder) return input.grantable ? "designate-needed" : "grant-needed"; + const hasScopes = + holder.scopes.includes(STRUCTURES_SCOPE) && + holder.scopes.includes(NOTIFICATIONS_SCOPE); + if (!hasScopes) return "scope-dropped"; + if (holder.tokenStatus === "needs_reauth") return "holder-needs-reauth"; + if (holder.tokenStatus === "missing" || holder.tokenStatus === "invalid") { + return "holder-no-token"; + } + if ( + holder.currentCorporationId !== null && + holder.currentCorporationId !== holder.corporationId + ) { + return "corp-changed"; + } + if (forbiddenReads(input).length > 0) return "no-corp-roles"; + if (input.rosterCount === 0) return "roster-empty"; + if (!input.webhookConfigured) return "alerts-unconfigured"; + return "normal"; +} + +/** Which of the two reads the corp refused. Both can be forbidden at once. */ +export function forbiddenReads(input: MonitorInput): ("roster" | "events")[] { + const out: ("roster" | "events")[] = []; + if (input.readStates.roster?.readStatus === "forbidden") out.push("roster"); + if (input.readStates.events?.readStatus === "forbidden") out.push("events"); + return out; +} + +const READ_LABEL: Record<"roster" | "events", string> = { + roster: "structure list", + events: "notifications", +}; + +export function monitorSentence( + state: MonitorState, + ctx: { name?: string; count?: number; forbidden?: ("roster" | "events")[] }, +): string { + const who = ctx.name ?? "The holder"; + switch (state) { + case "grant-needed": + return "No character has granted structure access."; + case "designate-needed": + return `${who} granted structure access but is not the holder.`; + case "scope-dropped": + return `${who} is the holder but no longer grants structure access.`; + case "holder-needs-reauth": + return `${who} needs to sign in to EVE again.`; + case "holder-no-token": + return `${who} has no usable EVE token.`; + case "corp-changed": + return `${who} has left the corporation this roster belongs to.`; + case "no-corp-roles": + return `The corporation refused the ${(ctx.forbidden ?? []) + .map((k) => READ_LABEL[k]) + .join(" and ")} read.`; + case "roster-empty": + return "Nothing read yet."; + case "alerts-unconfigured": + return `${ctx.count ?? 0} structures. No Discord webhook is set, so nothing is alerted.`; + case "normal": + return `${ctx.count ?? 0} structures. Alerts go to Discord.`; + } +} + +export type Remedy = { href: string; label: string }; + +/** + * Total exhaustive switch, no `default` arm: adding a MonitorState without + * deciding its remedy must be a compile error, not a silent null. + * + * Three states return null because there is nothing this app can offer. The + * corp-role grants and the webhook secret are both outside it — a button that + * cannot fix the problem is worse than a sentence that explains it. + */ +export function monitorRemedy(state: MonitorState): Remedy | null { + switch (state) { + case "grant-needed": + return { href: GRANT_HREF, label: "Grant structure access" }; + case "scope-dropped": + return { href: GRANT_HREF, label: "Re-grant structure access" }; + case "holder-needs-reauth": + case "holder-no-token": + return { href: REAUTH_HREF, label: "Re-authenticate" }; + case "designate-needed": + case "corp-changed": + case "no-corp-roles": + case "roster-empty": + case "alerts-unconfigured": + case "normal": + return null; + } +} + +/** The roster is worth rendering in every state that has one. */ +export function showsRoster(state: MonitorState): boolean { + return ( + state === "normal" || + state === "alerts-unconfigured" || + state === "no-corp-roles" || + state === "corp-changed" + ); +} + +/** + * PRODUCT.md principle 4 reserves alarm colour for what a user can and should + * fix. access-lists/view.ts:220-227 refuses `bad` on that basis; a structure in + * hull or armor reinforce is precisely the exception it carves room for — a + * fight you can still show up to. + */ +export function rowTone(state: string): "bad" | "warn" | "neutral" { + if (state === "hull_reinforce" || state === "armor_reinforce") return "bad"; + if (state.endsWith("_vulnerable")) return "warn"; + return "neutral"; +} +``` + +`doneNotice` / `doneStamp` are the `?done=…&at=…` redirect-marker helpers — +copy them from `src/app/admin/access-lists/view.ts:285-322`, changing only the +marker names to `holder` and `check`. + +- [ ] **Step 4: Run the tests** + +Run: `npx vitest run tests/structure-view.test.ts` +Expected: PASS. + +- [ ] **Step 5: Format and commit** + +```bash +npm run format:check +git add src/app/admin/structures/view.ts tests/structure-view.test.ts +git commit -m "feat(structures): monitor state cascade" +``` + +--- + +### Task 11: The page, actions and nav + +**Files:** + +- Create: `src/app/admin/structures/page.tsx`, `src/app/admin/structures/actions.ts` +- Modify: `src/app/_components/nav-items.ts` +- Test: `tests/admin-structure-actions-validation.test.ts`, `tests/nav-items.test.ts` (extend) + +**Interfaces:** + +- Consumes: everything from Tasks 6 and 10, `enqueueSync` from `@/services/outbox`. +- Produces: `designateStructureHolderAction(formData: FormData): Promise`, `checkNowAction(): Promise`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/admin-structure-actions-validation.test.ts`, modelled on +`tests/admin-access-lists-actions-validation.test.ts`: + +```ts +it("rejects a non-numeric character id", async () => { + const fd = new FormData(); + fd.set("characterId", "12abc"); + await expect(designateStructureHolderAction(fd)).rejects.toThrow("invalid_id"); +}); + +it("rejects a negative character id", async () => { + const fd = new FormData(); + fd.set("characterId", "-1"); + await expect(designateStructureHolderAction(fd)).rejects.toThrow("invalid_id"); +}); + +it("rejects a missing character id", async () => { + await expect(designateStructureHolderAction(new FormData())).rejects.toThrow( + "invalid_id", + ); +}); +``` + +Extend `tests/nav-items.test.ts`: + +```ts +it("offers Structures to admins and nobody else", () => { + expect(navFor({ isAdmin: true, tier: "alumni" }).map((i) => i.label)).toContain( + "Structures", + ); + expect(navFor({ isAdmin: false, tier: "member" }).map((i) => i.label)).not.toContain( + "Structures", + ); +}); +``` + +Match the existing `navFor` call signature in that file. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npx vitest run tests/admin-structure-actions-validation.test.ts tests/nav-items.test.ts` +Expected: FAIL. + +- [ ] **Step 3: Write `actions.ts`** + +Copy `src/app/admin/access-lists/actions.ts`'s `idSchema` / `parseId` verbatim +(including their comments — the reasoning about `FormDataEntryValue | null` and +the `error` codes applies identically), then: + +```ts +/** + * Both actions gate themselves with `requireAdminAction`. The admin layout's + * guard does not protect server actions and does not re-run on soft + * navigation, so "the page checked already" is not a check. + * + * Neither calls ESI. This page reads Postgres and enqueues; the worker + * performs every read. + */ +export async function designateStructureHolderAction(formData: FormData): Promise { + const { accountId: actor } = await requireAdminAction(); + const characterId = parseId(formData.get("characterId")); + const corporationId = parseId(formData.get("corporationId")); + await designateStructureHolder(getDb(), characterId, corporationId, actor); + revalidatePath("/admin/structures"); + redirect(`/admin/structures?done=holder&at=${Date.now()}`); +} + +/** Asking for a read changes no state, so this writes no audit row. */ +export async function checkNowAction(): Promise { + await requireAdminAction(); + const db = getDb(); + await enqueueSync(db, { kind: "job", jobType: "structures" }); + await enqueueSync(db, { kind: "job", jobType: "structure-events" }); + revalidatePath("/admin/structures"); + redirect(`/admin/structures?done=check&at=${Date.now()}`); +} +``` + +`corporationId` comes from a hidden input the page renders from the candidate +character's current `character.corporationId`, so the pin records the corp the +admin was actually looking at. + +- [ ] **Step 4: Write `page.tsx`** + +Create `src/app/admin/structures/page.tsx`. The load-and-derive half is fixed; +the markup follows the access-list page's structure. + +```tsx +import type { Metadata } from "next"; +import { getConfig } from "@/config"; +import { getDb } from "@/db"; +import { compareRosterRows } from "@/core/structure-event"; +import { requireAdminPage } from "@/lib/admin-guard"; +import { resolveStructureWebhookUrl } from "@/lib/ops-webhook"; +import { + getReadStates, + getRecentEvents, + getRoster, + getStructureHolder, +} from "@/services/structures"; +import { lookupCachedNames } from "@/services/universe-names"; +import { + forbiddenReads, + monitorRemedy, + monitorSentence, + monitorState, + rowTone, + showsRoster, +} from "./view"; + +/** + * This page reads Postgres and enqueues; the worker performs every read. A + * live ESI fetch on render would burn a refresh-token rotation per page load. + */ +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { title: "Structures" }; + +const RECENT_EVENT_LIMIT = 20; + +export default async function StructuresPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + // The layout guarded, and that is not enough: layouts do not re-run on soft + // navigation and never see server actions. + await requireAdminPage(); + const db = getDb(); + const cfg = getConfig(); + + const holder = await getStructureHolder(db); + const corporationId = holder?.corporationId ?? null; + const [roster, readStates, events] = await Promise.all([ + corporationId ? getRoster(db, corporationId) : Promise.resolve([]), + corporationId ? getReadStates(db, corporationId) : Promise.resolve({}), + corporationId + ? getRecentEvents(db, corporationId, RECENT_EVENT_LIMIT) + : Promise.resolve([]), + ]); + + // ONE batched, cache-only name read for every system the two tables print. + const systemNames = await lookupCachedNames(db, [ + ...new Set(roster.map((r) => r.systemId)), + ]); + + const input = { + grantable: await findGrantableCharacter(db), + holder: holder ? await toHolderView(db, holder) : null, + readStates, + rosterCount: roster.length, + webhookConfigured: resolveStructureWebhookUrl(cfg) !== undefined, + }; + const state = monitorState(input); + const rows = [...roster].sort(compareRosterRows); + + return ( +
+

Structures

+

{monitorSentence(state, { + name: input.holder?.name, + count: roster.length, + forbidden: forbiddenReads(input), + })}

+ {/* remedy button, designate select, Check now — Check now is the ONE + primary (gold) action on this view */} + {showsRoster(state) && ( + /* wide table inside its own focusable, labelled, overflow-x region */ + + )} + +
+ ); +} +``` + +`findGrantableCharacter` and `toHolderView` are small local helpers: the first +picks an admin-owned character whose persisted `scopes` carry both structure +scopes; the second joins `character` to fill `name`, `scopes`, `tokenStatus` +and `currentCorporationId`. Put both in `src/services/structures.ts` beside the +other reads rather than in the page, so `view.ts` stays testable without a +React import. + +Design constraints, all from DESIGN.md — a reviewer will check these: + +- no zebra striping; hairline row rules; `--hull` header; mono uppercase labels +- status `ok` is neutral `--ink-dim` — **do not** restore the green +- alarm is `--signal-bad` **border and text, never filled** +- gold is rationed to one primary action per view — that is `Check now` +- hit targets: 36px standalone (`.btn`), 28px in-row (`.btn--micro`) +- the wide roster table scrolls inside its own focusable, labelled region +- `
`, and `prefers-reduced-motion` honoured globally + +- [ ] **Step 5: Add the nav entry** + +In `src/app/_components/nav-items.ts`, add `Structures` after `Access lists`, +admin-only, and update the rule table in the module docblock — the list in that +comment is the specification, not decoration. + +- [ ] **Step 6: Run the tests** + +Run: `npx vitest run tests/admin-structure-actions-validation.test.ts tests/nav-items.test.ts && npm run typecheck` +Expected: PASS, and typecheck clean. + +- [ ] **Step 7: Format and commit** + +```bash +npm run format:check +git add src/app/admin/structures src/app/_components/nav-items.ts tests/ +git commit -m "feat(structures): admin monitor page" +``` + +--- + +### Task 12: e2e, docs, and the full gate + +**Files:** + +- Create: `e2e/structures.spec.ts` +- Modify: `docs/ops.md` +- Test: the whole suite + +**Coverage boundary — read this before writing the spec file.** Playwright runs +`SYNC_MODE: "dry-run"` (`playwright.config.ts:27-73`), and dry-run makes +`getFreshAccessToken` return before any network call, so **no e2e test can reach +an ESI fetch**. e2e covers the state cascade, designation, and rendering from +rows seeded directly into Postgres. Every alerting behaviour is already proven +in Tasks 8 and 9. Do not write an e2e test that expects a job to fetch anything. + +- [ ] **Step 1: Write the e2e spec** + +Create `e2e/structures.spec.ts` using `seedMember(db, { isAdmin: true })` and +`sessionCookieFor` from `e2e/helpers.ts`: + +```ts +test("an admin with no holder is asked to grant", async ({ page, context }) => { + // seed admin, add cookie, goto /admin/structures + await expect(page.getByRole("main")).toContainText("No character has granted"); +}); + +test("a seeded roster renders most-alarming-first", async ({ page, context }) => { + // insert a holder + three structure rows directly, one hull_reinforce + const rows = page.locator(".log--dense > tbody > tr"); + await expect(rows.first()).toContainText("hull"); +}); + +test("Structures appears in the admin nav and not for a plain member", async ({ + page, + context, +}) => { + // two seeded sessions, two assertions +}); +``` + +- [ ] **Step 2: Run the e2e spec** + +Run: `npx playwright test e2e/structures.spec.ts` +Expected: PASS. Never run two e2e suites at once in the same worktree — they +share one database and truncate each other. + +- [ ] **Step 3: Document the operational surface** + +In `docs/ops.md`: + +- add `DISCORD_STRUCTURE_WEBHOOK_URL` to the secret table (`:348-371`) as + `no (falls back to DISCORD_OPS_WEBHOOK_URL)` +- add both new jobs to the job-schedule table (`:106-116`) and update the free-slot + note at `:118-120` +- add a `### The structure scopes are opt-in` subsection modelled on the + access-list one at `:415-430`, naming both scopes, the `?grant=structures` + link, and — the part the access-list section has no equivalent for — the two + **in-game corp roles** nobody can grant from this app: Station_Manager for the + roster, Director or CEO for notification delivery +- add `structure_event` to the unbounded-tables note at `:240-255`, beside + `audit_log` and `sync_run`: append-only record of fact, deliberately not purged +- state that with no webhook configured, events are recorded `seeded` and + nothing is alerted, and `/admin/structures` says so + +- [ ] **Step 4: Run the full gate** + +```bash +npm run typecheck +npm run lint +npm run format:check +npm test +npm run build +npx playwright test +``` + +All six must pass. `npm run build` and `npm run typecheck` are CI gates in +their own right (`.github/workflows/ci.yml`), not implied by the tests. + +- [ ] **Step 5: Commit** + +```bash +git add e2e/structures.spec.ts docs/ops.md +git commit -m "test(structures): e2e coverage and operational docs" +``` + +--- + +## Verification checklist + +Before calling this done: + +- [ ] `npm test` — cite the file and test counts; compare the file count against + `main` (99 before this feature), since a load failure silently drops tests +- [ ] `npm run typecheck`, `npm run lint`, `npm run format:check`, `npm run build` +- [ ] `npx playwright test` +- [ ] `git diff main --stat` reviewed for scope creep +- [ ] The generated migration `ALTER`s no existing table +- [ ] No `console.log`, no `TODO`, no placeholder left behind diff --git a/docs/specs/2026-08-24-structure-monitor-design.md b/docs/specs/2026-08-24-structure-monitor-design.md new file mode 100644 index 0000000..e2f753c --- /dev/null +++ b/docs/specs/2026-08-24-structure-monitor-design.md @@ -0,0 +1,533 @@ +# Structure damage monitor — design + +Status: implemented +Date: 2026-08-24 + +Monitor the corp's own structures and post a Discord alert when one takes +damage. Modelled closely on the access-list monitor +(`docs/specs/2026-08-09-access-list-monitor-design.md`), which established the +designated-holder pattern this feature reuses. + +## Outcome + +One character grants two opt-in ESI scopes. An hourly job keeps a roster of the +corp's structures; a ten-minute job polls that character's notifications for the +four damage types and posts each new one to Discord. An admin page at +`/admin/structures` states what is true and offers the remedy when it is not. + +## Scope sources + +Two scopes, both deliberately absent from `EVE_SSO_SCOPES`: + +- `esi-corporations.read_structures.v1` — the roster. Requires the + **Station_Manager** corp role in game. +- `esi-characters.read_notifications.v1` — the damage events. Corp structure + notifications are only _delivered_ to a **Director or CEO**. + +`esi-universe.read_structures.v1` is already in `EVE_SSO_SCOPES` +(`docs/ops.md:21`) and is a different scope — it resolves a structure's name for +the location job. Confusing the two produces a feature that authorizes cleanly +and returns nothing. + +Both are granted together by `/auth/eve/link?grant=structures`, which maps to +that exact literal pair. The link route refuses free-form scope params +(`src/app/auth/eve/link/route.ts:18-22`); this preserves that. + +The grant is not sticky — any ordinary re-auth link drops it, because EVE's +character picker runs after the authorize URL is built. The page detects the loss +and asks for a re-grant, exactly as `/admin/access-lists` does. + +## Data model + +Four tables, two enums, one generated migration (`npm run db:generate` — never +hand-written). All four go into `MANAGED_TABLES` (`src/db/tables.ts`); +`tests/seed-dev.test.ts:135-149` asserts set equality in both directions, so an +omission fails a test rather than rotting. + +### `structure_holder` + +Singleton, copied from `accessListHolder` (`src/db/schema.ts:280-294`): +`id integer PRIMARY KEY` pinned by +`structure_holder_singleton_ck CHECK (id = 1)`, +`character_id bigint NOT NULL REFERENCES character(id) ON DELETE CASCADE`, +`designated_at`, and `designated_by text NOT NULL` (account uuid or `"system"`, +carried over verbatim from the precedent at `src/db/schema.ts:291`). Two columns +access-lists does not have: + +- `corporation_id bigint NOT NULL` — **pinned at designation time**, not read + live. `character.corporationId` is overwritten from affiliation every thirty + minutes (`src/jobs/membership.ts:125`). Following it live means a holder who + changes corp silently re-rosters against the new corp and stamps + `missing_since` on every previous structure — rendered identically to + "destroyed", arriving during the exact incident this tool exists for. Pinning + turns that into a loud `corp-changed` state instead. +- `seeded_at timestamptz` — null means this holder has never completed a poll. + The events job seeds silently when null and alerts when not. `designateHolder` + writes it null, so replacing the holder re-seeds: a new holder is often a + different corp whose whole 90-day backlog would otherwise read as new and fire + at once. + +Re-seeding is necessary but **not sufficient** to make holder replacement safe +across a corp change. `seeded_at` only governs how *newly discovered* events +are recorded; it says nothing about events already sitting at `pending` from +the previous holder's corp, which the sender would otherwise pick up and post +under the new holder. When the new holder is in a DIFFERENT corp, +`designateHolder` retires every `pending` row — see `abandoned` below. When +the new holder is in the SAME corp, nothing is retired: those `pending` rows +are still for the corp being watched and are still owed their alert, so +abandoning them would silently drop a live attack. + +Not generalised into a shared `service_character(role, character_id)` table +alongside `access_list_holder`. That is the obvious dedupe and it would mean +migrating a table already in production — out of scope here, recorded as +follow-up. + +### `structure_read_state` + +Composite primary key `(kind, corporation_id)` — `kind text` is `'roster'` or +`'events'`. Then `observed_at`, `last_attempt_at`, `read_status`, `detail`. + +Two timestamps, not one, for the reason `accessListSnapshot` gives +(`src/db/schema.ts:316-326`): `observed_at` is the last _successful_ read and is +null until there is one; `last_attempt_at` + `read_status` + `detail` describe +the most recent attempt either way. Collapsing them forces a choice between lying +about freshness and discarding the failure. + +Keyed by kind rather than duplicated as columns on the holder because the two +reads fail independently — notifications can 403 on Director while the roster +reads fine on Station_Manager, and the page should say which. + +Keyed **also** by corporation because the row describes a read against one +specific corp. Without it, replacing the holder leaves the previous corp's +freshness and 403 state in place, and the page reports the new holder's monitor +as healthy on the strength of a read that happened against a corp it no longer +watches. The page reads the row for the currently pinned corp; rows for other +corps are inert history. + +This table exists because `sync_run` cannot serve it: `counts` is +`Record` and `error_summary` is free text +(`src/db/schema.ts:218-231`). A page deriving `no-corp-roles` by pattern-matching +a job's count keys or error prose is exactly the drift the snapshot split +prevents. + +### `structure` + +Roster. `structure_id bigint PRIMARY KEY`, `corporation_id`, `type_id`, +`type_name`, `system_id`, `name`, `state text`, `state_timer_start`, +`state_timer_end`, `fuel_expires`, `observed_at`, `missing_since`. + +`state` is `text`, not a pgEnum, for the reason `accessListEntry.access` is +(`src/db/schema.ts:344-355`): a state string CCP adds next patch must not fail +the read. + +`type_name` is denormalized at read time. There is no type-id name cache to use: +`universe_name`'s kind enum is `["system","station","structure"]` +(`src/db/schema.ts:239-243`) and `resolveEntityNames` deliberately drops +inventory types from `getUniverseNames` results, because an unmodelled category +would fail the whole insert (`src/services/entity-names.ts:76-80`). +Denormalizing touches neither cache. + +A structure that stops appearing gets `missing_since` stamped, never deleted — +"never remove on unknown state" (`src/jobs/access-lists.ts:277-283`). From the +roster's side a destroyed Astrahus and a 403 are identical; only the event stream +distinguishes them. + +### `structure_event` + +`notification_id bigint PRIMARY KEY` — ESI's own id, which is what makes "seen" +idempotent. `type text` verbatim, `sent_at timestamptz` (ESI's timestamp), +`structure_id bigint` nullable, `corporation_id bigint NOT NULL`, `alert_status`, +`details jsonb`. + +`corporation_id` is stamped at insert from the holder's **pinned** corp, not +parsed from the body. It is what the sender filters on: pending rows are selected +for the currently pinned corp only, so a row recorded under a previous holder can +never be posted under a new one. Without it the seed-silently rule protects only +newly discovered events and leaves the already-`pending` ones to fire under a +holder that never saw them. + +`structure_id` is nullable because the notification body is YAML and a parse can +fail; a failed parse still records the event as seen and still alerts, without a +structure name. + +`details` holds only the parsed subset actually rendered — attacker corp, +alliance, character, damage percentages, timer end. Everything else is dropped. +`/characters/{id}/notifications/` returns **every** notification type for that +character: war decs, mail, kill rights, corp applications, insurance. The job +filters to the four structure types and persists nothing else, so no personal +notification reaches Postgres. Same posture the `character.location*` docblock +takes about member location. + +No retention policy, and none is expected: `docs/ops.md:240-255` records that +append-only records of fact are deliberately exempt from `purge.ts`, with the gap +documented in ops.md instead. `structure_event` follows `audit_log`. + +### Enums + +```ts +export const structureReadStatusEnum = pgEnum("structure_read_status", [ + "ok", + "forbidden", + "failed", +]); +export type StructureReadStatus = (typeof structureReadStatusEnum.enumValues)[number]; + +export const structureAlertStatusEnum = pgEnum("structure_alert_status", [ + "seeded", + "pending", + "sent", + "abandoned", +]); +export type StructureAlertStatus = (typeof structureAlertStatusEnum.enumValues)[number]; +``` + +The four values are distinct states, not shades of one: + +- `seeded` — recorded without alerting, because this holder had never polled + (or because no webhook was configured; see Alerting). +- `pending` — recorded and owed an alert. +- `sent` — posted successfully. +- `abandoned` — was `pending` when the holder was replaced with one in a + DIFFERENT corp, and will never be posted. Written by `designateHolder` in + the same transaction as the new designation. A same-corp replacement writes + none of these: those rows are still owed to the corp being watched. + +`abandoned` is a fourth value rather than a reuse of `seeded` because the two +answer different questions. `seeded` means "deliberately not alerted, by the +first-run rule"; `abandoned` means "owed an alert that no longer has a valid +recipient." Collapsing them would make it impossible to tell, from the table, +whether a holder swap silently swallowed a live attack. + +`structure_read_status` is not a reuse of `accessListReadStatusEnum` — its +`not_visible` means something else, and coupling two features' enums makes the +next CCP change a two-feature migration. + +## Jobs + +Two job types, both `on-demand` in `JOB_GROUP` (both reachable from the page's +own Check now, which is what that group means — `src/core/schedules.ts:48-74`): + +```ts +"structures": "35 * * * *", +"structure-events": "3,13,23,33,43,53 * * * *", +``` + +Slots chosen off the busy minutes for the reason recorded at +`src/core/schedules.ts:18-24`: :00/:05/:10/:15/:25/:30 and :02,17,32,47 are +taken. `formatCadence` supports evenly-spaced comma minutes +(`src/core/schedules.ts:175-203`), so the admin page renders the ten-minute +cadence correctly rather than falling back to the raw cron. + +Ten minutes matches the notifications endpoint's 600s cache exactly; polling +faster returns the same cached page. The roster endpoint caches for an hour. + +Both jobs share the access-lists staging (`src/jobs/access-lists.ts:36-132`): + +1. No holder → `{status:"ok", noHolder:1}`. An unconfigured optional feature must + never paint `/admin/sync` red. +2. Persisted-scope check against `character.scopes` **before** any network call. +3. `getFreshAccessToken`, four-way branch. +4. `stillHolder(tx, characterId)` compare-and-swap before every write, so a + holder swapped mid-flight cannot have another character's read written under + their name. + +### `structures` + +Reads the **pinned** `structure_holder.corporation_id`. If the holder's current +`character.corporationId` differs, the job writes `read_status = 'failed'` with +`detail = 'corp-changed'` and mutates no roster rows. + +Otherwise fetches `/corporations/{id}/structures/` and, in one transaction, +upserts each row and stamps `missing_since` on any structure absent from the +response. A 403 sets `read_status = 'forbidden'`, returns `status: "partial"`, +and mutates no roster rows. + +### `structure-events` + +Fetches notifications, filters to `StructureUnderAttack`, +`StructureLostShields`, `StructureLostArmor`, `StructureDestroyed`, and parses +each YAML body. + +**Resolve the webhook URL before the insert, not at post time.** If neither +`structureWebhookUrl` nor `opsWebhookUrl` is set, there is no recipient, and an +event recorded as `pending` would be marked `sent` by a post that never +happened — `postOpsWebhookOrThrow` returns early and successfully when no URL is +configured (`src/lib/ops-webhook.ts:47-48`). So the insert status is: + +``` +alert_status = + no webhook configured -> 'seeded' + holder.seeded_at === null -> 'seeded' + otherwise -> 'pending' +``` + +Recording as `seeded` when unconfigured is deliberate: it keeps the table honest +(nothing is owed an alert that can never be delivered), it keeps the pending set +from growing without bound, and configuring a webhook later starts alerting on +genuinely new events rather than replaying the backlog. The page says so via the +`alerts-unconfigured` state rather than claiming alerts go to Discord. + +In one transaction: insert each event `ON CONFLICT DO NOTHING` with that status +and with `corporation_id` stamped from the holder's pinned corp, and if seeding, +stamp `seeded_at`. + +After commit, select all `pending` rows **for the currently pinned corp** — which +naturally includes leftovers from a previous run's failed sends, and naturally +excludes anything recorded under a previous holder — post each, flip to `sent`. A +failed post leaves the row `pending`; the run returns `"partial"`, not +`"failed"`, because the ten-minute tick is the retry and pg-boss's retry budget +is for a run that accomplished nothing (`src/services/sync-run.ts:36-47`). + +Posting happens after commit, so a crash between the two re-sends next tick. +At-least-once: a duplicate Discord post is possible and preferred to a lost one. + +**No age cutoff.** If the worker is down for a long weekend, the first run back +alerts on every structure notification from that window at once. Correct per the +seed-silently rule, and loud. The mitigation, if it ever bites, is a cutoff +constant in `src/core/structure-event.ts`. + +### Pagination + +`/corporations/{id}/structures/` is paginated. The ESI client already knows how +to do this: `getAllContacts` (`src/lib/esi/client.ts:320-358`) reads `x-pages` +off the first response, rejects a missing or non-integer header as a `transient` +`EsiError` rather than guessing, and loops pages 2..N. It is covered directly by +`tests/esi-client.test.ts:109-163`. + +The fail-closed behaviour is the valuable part and the comment says why: "an +unknown page count means an unknown contact set, and the downstream diff +deletes. Never guess (spec: never remove on unknown state)." That reasoning +transfers exactly — this roster's `missing_since` stamping is also a +diff-that-removes, so a silently truncated page set would mark real structures +missing. + +So this feature **generalises the existing loop** rather than inventing one: +extract the page walk from `getAllContacts` into a shared helper, keeping its +header validation and error class verbatim, and have both callers use it. +`getAllContacts` keeps its current observable behaviour, pinned by the tests +above. + +### Pure core + +`src/core/structure-event.ts` — no I/O, unit-tested: a tolerant flat +`key: value` YAML reader returning a partial record, the Discord line formatter, +and the roster sort. No YAML dependency; the bodies are flat enough that a small +parser beats adding one, and a parse failure returns nulls rather than throwing, +so a body CCP reshapes costs a structure name, not the alert. + +### Error classification + +`classifyEsiError` maps 403 to `needs_reauth` only when the body matches +`/scope|token|authorization/i`, otherwise `permanent` +(`src/core/errors.ts:22-32`). A corp-roles 403 reads "Character does not have +required role(s)" — no match — so it classifies `permanent`, which is what keeps +`forbidden` distinct from a token fault. This is load-bearing on CCP's error +prose and gets a test pinning it. + +### Dry run + +`getFreshAccessToken` returns `dry_run` **before any network call** +(`src/services/tokens.ts:74-87`), because EVE SSO rotates the refresh token on +use. Both jobs therefore exit at the token stage in dry-run and never reach the +fetch or the post. The post step must stay after the token branch, never before, +or a dry-run worker would consume real pending alerts. + +## Alerting + +New optional `DISCORD_STRUCTURE_WEBHOOK_URL`, declared exactly as +`DISCORD_OPS_WEBHOOK_URL` is (`src/config.ts:79`, normalized at `:174`): + +```ts +DISCORD_STRUCTURE_WEBHOOK_URL: z.string().url().optional().or(z.literal("")), +``` + +`postOpsWebhookOrThrow` currently reads `cfg.discord.opsWebhookUrl` internally, +so it grows an explicit url parameter; `postOpsWebhook(cfg, content)` keeps its +signature and delegates. New `postStructureWebhook(cfg, content)` resolves +`cfg.discord.structureWebhookUrl ?? cfg.discord.opsWebhookUrl`. The dry-run guard +and the 1900-character clamp stay where they are. + +**Both may be absent**, and that case is load-bearing rather than degenerate. +`postOpsWebhookOrThrow` returns early and *successfully* when no URL is +configured (`src/lib/ops-webhook.ts:47-48`) — correct for its existing callers, +which have nothing at stake in a missing ops channel, and wrong for this one, +where a successful no-op would flip an owed alert to `sent`. + +So the resolution is exposed rather than buried: a +`resolveStructureWebhookUrl(cfg): string | undefined` that both the job and the +page read. The job branches on it *before* inserting (see `structure-events`); +the page renders `alerts-unconfigured` from it. Neither infers delivery from a +post's return value, because that value cannot distinguish "delivered" from +"nowhere to deliver." + +Nothing asserts `.env.example` matches the schema, so the var is added by hand in +four places: `src/config.ts`, `.env.example`, the `docs/ops.md` secret table +(`:348-371`) plus the first-deploy secrets block (`:14-28`), and +`playwright.config.ts` if a page ever reads it. + +## Page + +`/admin/structures`, admin-only, nav entry declared in +`src/app/_components/nav-items.ts` (covered by `tests/nav-items.test.ts`). + +`page.tsx` guards itself with `requireAdminPage()` even though the layout guarded +— layouts do not re-run on soft navigation and never see server actions. Each +action re-guards with `requireAdminAction()` and parses its input with zod, not a +cast. + +### State cascade + +Priority order, in `view.ts`, mirroring `monitorState`: + +| state | sentence | remedy | +| --------------------- | ---------------------------------------------------------- | ----------------------------------- | +| `grant-needed` | No character has granted structure access. | `/auth/eve/link?grant=structures` | +| `designate-needed` | _Name_ granted structure access but is not the holder. | Designate | +| `scope-dropped` | _Name_ is the holder but no longer grants structure access. | Re-grant link | +| `holder-needs-reauth` | token fault | bare `/auth/eve/link` | +| `holder-no-token` | token fault | bare `/auth/eve/link` | +| `corp-changed` | _Name_ has left the corp the roster belongs to. | Re-designate | +| `no-corp-roles` | The corp refused the _roster_ / _notifications_ read. | Text only | +| `roster-empty` | Nothing read yet. | Check now | +| `alerts-unconfigured` | _N_ structures. No Discord webhook is set, so nothing is alerted. | Text only | +| `normal` | _N_ structures. Alerts go to Discord. | — | + +`alerts-unconfigured` sits directly above `normal` and is the only difference +between them: the monitor is working, the roster is real, and nothing will be +sent. It exists because `normal`'s sentence is otherwise a lie whenever neither +webhook is configured — and since an unconfigured webhook post returns +successfully (`src/lib/ops-webhook.ts:47-48`), nothing else on the page or in the +job would ever reveal it. + +The scope check precedes the token check for the reason recorded at +`src/app/admin/access-lists/view.ts:40-54`: the plain re-auth link is what drops +the scope, so offering it first sends an admin round a loop that cannot +terminate. + +`no-corp-roles` names which read is forbidden. It is not a token fault — the fix +is an in-game role grant nobody can make from this app, and conflating the two +sends an admin round the re-auth loop forever. + +`corp-changed` is derived **live on the page**, by comparing +`structure_holder.corporation_id` to the holder's current +`character.corporationId` — not read from `structure_read_state.detail`. The page +must say so the moment affiliation updates, rather than waiting up to an hour for +the roster job's next tick to record it. The job writes +`read_status = 'failed'`, `detail = 'corp-changed'` independently, so the two +never contradict: the page states the condition, the read state records that a +run declined to act on it. + +### Rendering + +Roster table: name, system, type, state, timer, fuel. Read-only, sorted most +alarming first (reinforced above vulnerable above healthy). Below it, the last +~20 events with timestamp, structure, what happened, and the attacker corp or +alliance from the notification body. + +`Check now` enqueues both job types via `enqueueSync` and writes no audit row — +asking for a read changes no state. No live ESI call on render, ever: the web +tier writes an outbox row and the worker performs every read. + +DESIGN.md constraints that apply: no zebra striping; status `ok` is neutral +`--ink-dim` ("do not restore the green"); alarm is `--signal-bad` **border and +text, never filled**; gold rationed to one primary action per view; R1's two hit +grades (36px standalone, 28px in-row); R4 parity — no information exists only in +the AT channel. + +Reinforced structures use the `bad` tone. +`src/app/admin/access-lists/view.ts:220-227` refuses that tone because PRODUCT.md +principle 4 reserves alarm colour for things the user can and should fix. A +structure in hull reinforce is precisely that — a fight you can still show up to. +This is the case the principle carves room for, not an exception to it. + +## Audit + +Two actions only, matching the access-list namespace shape, with entries in +`TARGET_KIND_BY_NAMESPACE`, `DETAIL_CHARACTER_KEYS`, and +`src/app/admin/audit/summarize.ts`: + +- `structure.holder_designated` — target characterId, details + `{characterId, corporationId}` +- `structure.holder_replaced` — target characterId, details + `{previousCharacterId, characterId, corporationId, abandonedAlerts}` + +`abandonedAlerts` is the count of `pending` rows retired to `abandoned` by that +replacement. It belongs in the audit row because it is the one number that says +whether a holder swap swallowed a live alert, and the swap is an admin action +nobody would otherwise connect to a missing Discord post. + +Events are observations, not state changes; access-lists sets the precedent that +observations surface on the page rather than in the audit log. Read failures are +likewise not audited — `structure_read_state` holds that, which is why it earns +its place. + +## Registries + +Mechanical, but each omission fails somewhere different: + +| Registry | File | Failure mode if omitted | +| ----------------------- | --------------------------------------------------------------- | ----------------------------------- | +| `JOB_CRON` | `src/core/schedules.ts:10-26` | `JobType` never includes it | +| `JOB_GROUP` | `src/core/schedules.ts:76-86` | compile error | +| `QUEUES` + `JOB_QUEUES` | `src/worker/queues.ts` | boot throws on missing cron; a `JOB_CRON` key with no `QUEUES` entry also fails `tests/dispatcher.test.ts:128-132`, since `RERUNNABLE` (`src/worker/dispatcher.ts:22-24`) is derived from `QUEUES` and asserted equal to `JOB_CRON`'s keys | +| zod schema + handler | `src/worker/handlers.ts` | job never runs | +| `KNOWN_ORDER` | `src/services/sync-status.ts:8-18` | sorts to the end of the sync page | +| `MANAGED_TABLES` | `src/db/tables.ts` | `tests/seed-dev.test.ts` fails | +| per-queue literals | `tests/schedules.test.ts:113-118`, `tests/worker-queues.test.ts:40-47` | fail | +| ops.md job table | `docs/ops.md:106-116` | drifts silently | + +## Testing + +Following the existing eight-file shape: + +- `tests/structure-event.test.ts` — the YAML reader against real bodies for all + four types; a malformed body yields nulls rather than throwing +- `tests/structure-view.test.ts` — every branch of the cascade, including both + `forbidden` variants, `corp-changed`, and `alerts-unconfigured` +- `tests/structure-job.test.ts` — seeding sends nothing; second run alerts only + on new; a failed post leaves `pending` and the next run re-sends; `stillHolder` + CAS rejects a mid-flight holder swap; 403 sets `forbidden` and mutates no + roster rows; a corp-roles 403 body classifies `permanent`; **with no webhook + configured, new events land as `seeded` and no row is ever marked `sent`**; + **replacing the holder with one in a different corp retires pending rows to + `abandoned` and the next run posts none of them**; **replacing the holder + with one in the SAME corp retires nothing, and those rows still post**; + **pending rows for a non-pinned corp are never + selected** +- `tests/esi-client.test.ts` — extend the existing pagination coverage + (`:109-163`) to the extracted shared helper, proving `getAllContacts` keeps its + behaviour and the roster read fails closed on a missing `x-pages` too +- `tests/structure-service.test.ts`, `tests/structure-schema.test.ts`, + `tests/structure-reads.test.ts`, + `tests/admin-structure-actions-validation.test.ts` +- `e2e/structures.spec.ts` — designate, check now, roster renders from seeded + rows + +ESI is mocked by hand-rolling the narrowed `StructuresEsi` / +`StructureEventsEsi` `Pick<>` types, as `tests/access-lists-job.test.ts` does; +msw is only used for tests that exercise the real client. + +**Coverage boundary:** e2e runs `SYNC_MODE: "dry-run"` +(`playwright.config.ts:27-73`), and dry-run cannot obtain a token, so Playwright +can only cover the state cascade, designation, and rendering from seeded rows. +Every alerting behaviour is proven in `tests/` against real Postgres. + +## Rollout + +The migration is additive: four tables, two enums, nothing rewritten, no +backfill, no index on an existing table (so no write-blocking `CREATE INDEX` +under the single-transaction migration batch — `docs/ops.md:180-244`). + +Deploying without setting the webhook or designating a holder is inert: both jobs +return `{status:"ok", noHolder:1}` and the page shows `grant-needed`. The code +can ship before anyone grants a scope in game. + +Migration numbering collides if another branch also generates `0013_*`; nothing +in-repo prevents that. Regenerate rather than hand-edit if it happens. + +## Out of scope + +Fuel alerts, low-power and anchoring notifications, structure timers as a +calendar, per-structure muting, alerting for any corp but the holder's own, and +the `service_character` generalisation that would dedupe `structure_holder` +against `access_list_holder`. diff --git a/drizzle/0013_bumpy_kid_colt.sql b/drizzle/0013_bumpy_kid_colt.sql new file mode 100644 index 0000000..e129830 --- /dev/null +++ b/drizzle/0013_bumpy_kid_colt.sql @@ -0,0 +1,49 @@ +CREATE TYPE "public"."structure_alert_status" AS ENUM('seeded', 'pending', 'sent', 'abandoned');--> statement-breakpoint +CREATE TYPE "public"."structure_read_status" AS ENUM('ok', 'forbidden', 'failed');--> statement-breakpoint +CREATE TABLE "structure" ( + "structure_id" bigint PRIMARY KEY NOT NULL, + "corporation_id" bigint NOT NULL, + "type_id" bigint NOT NULL, + "type_name" text, + "system_id" bigint NOT NULL, + "name" text, + "state" text NOT NULL, + "state_timer_start" timestamp with time zone, + "state_timer_end" timestamp with time zone, + "fuel_expires" timestamp with time zone, + "observed_at" timestamp with time zone NOT NULL, + "missing_since" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "structure_event" ( + "notification_id" bigint PRIMARY KEY NOT NULL, + "type" text NOT NULL, + "sent_at" timestamp with time zone NOT NULL, + "structure_id" bigint, + "corporation_id" bigint NOT NULL, + "alert_status" "structure_alert_status" NOT NULL, + "details" jsonb +); +--> statement-breakpoint +CREATE TABLE "structure_holder" ( + "id" integer PRIMARY KEY NOT NULL, + "character_id" bigint NOT NULL, + "corporation_id" bigint NOT NULL, + "designated_at" timestamp with time zone DEFAULT now() NOT NULL, + "designated_by" text NOT NULL, + "seeded_at" timestamp with time zone, + CONSTRAINT "structure_holder_singleton_ck" CHECK ("structure_holder"."id" = 1) +); +--> statement-breakpoint +CREATE TABLE "structure_read_state" ( + "kind" text NOT NULL, + "corporation_id" bigint NOT NULL, + "observed_at" timestamp with time zone, + "last_attempt_at" timestamp with time zone NOT NULL, + "read_status" "structure_read_status" NOT NULL, + "detail" text, + CONSTRAINT "structure_read_state_kind_corporation_id_pk" PRIMARY KEY("kind","corporation_id") +); +--> statement-breakpoint +ALTER TABLE "structure_holder" ADD CONSTRAINT "structure_holder_character_id_character_id_fk" FOREIGN KEY ("character_id") REFERENCES "public"."character"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "structure_event_pending_idx" ON "structure_event" USING btree ("corporation_id","alert_status","sent_at"); \ No newline at end of file diff --git a/drizzle/meta/0013_snapshot.json b/drizzle/meta/0013_snapshot.json new file mode 100644 index 0000000..19b8c1b --- /dev/null +++ b/drizzle/meta/0013_snapshot.json @@ -0,0 +1,2100 @@ +{ + "id": "43d34dcb-3649-4c98-8791-03035be35796", + "prevId": "30a41a53-336e-40af-86aa-e32b3116da3b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.access_list_catalog": { + "name": "access_list_catalog", + "schema": "", + "columns": { + "access_list_id": { + "name": "access_list_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "discovered_at": { + "name": "discovered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "observed_by_character_id": { + "name": "observed_by_character_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.access_list_entry": { + "name": "access_list_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "access_list_id": { + "name": "access_list_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "access_list_entry_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "access": { + "name": "access", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "access_list_entry_uq": { + "name": "access_list_entry_uq", + "nullsNotDistinct": false, + "columns": [ + "access_list_id", + "kind", + "entity_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.access_list_holder": { + "name": "access_list_holder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "designated_at": { + "name": "designated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "designated_by": { + "name": "designated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "access_list_holder_character_id_character_id_fk": { + "name": "access_list_holder_character_id_character_id_fk", + "tableFrom": "access_list_holder", + "tableTo": "character", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "access_list_holder_singleton_ck": { + "name": "access_list_holder_singleton_ck", + "value": "\"access_list_holder\".\"id\" = 1" + } + }, + "isRLSEnabled": false + }, + "public.access_list_snapshot": { + "name": "access_list_snapshot", + "schema": "", + "columns": { + "access_list_id": { + "name": "access_list_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "read_status": { + "name": "read_status", + "type": "access_list_read_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "observed_by_character_id": { + "name": "observed_by_character_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allow_everyone": { + "name": "allow_everyone", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.access_list_watch": { + "name": "access_list_watch", + "schema": "", + "columns": { + "access_list_id": { + "name": "access_list_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'alumni'" + }, + "tier_changed_at": { + "name": "tier_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "tier_changed_by": { + "name": "tier_changed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tier_locked": { + "name": "tier_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "account_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_note": { + "name": "status_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "main_character_id": { + "name": "main_character_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor": { + "name": "actor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "audit_log_at_idx": { + "name": "audit_log_at_idx", + "columns": [ + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_target_id_idx": { + "name": "audit_log_action_target_id_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_pattern_idx": { + "name": "audit_log_action_pattern_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_pattern_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bootstrap_admin_grant": { + "name": "bootstrap_admin_grant", + "schema": "", + "columns": { + "character_id": { + "name": "character_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "owner_hash": { + "name": "owner_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bootstrap_admin_grant_account_id_account_id_fk": { + "name": "bootstrap_admin_grant_account_id_account_id_fk", + "tableFrom": "bootstrap_admin_grant", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character": { + "name": "character", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "corporation_id": { + "name": "corporation_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "alliance_id": { + "name": "alliance_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "affiliation_checked_at": { + "name": "affiliation_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "affiliation_invalid": { + "name": "affiliation_invalid", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "owner_hash": { + "name": "owner_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_enc": { + "name": "refresh_token_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "token_status": { + "name": "token_status", + "type": "token_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'missing'" + }, + "location_system_id": { + "name": "location_system_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "location_station_id": { + "name": "location_station_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "location_structure_id": { + "name": "location_structure_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "location_online": { + "name": "location_online", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "location_checked_at": { + "name": "location_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "character_account_id_account_id_fk": { + "name": "character_account_id_account_id_fk", + "tableFrom": "character", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "character_id_account_uq": { + "name": "character_id_account_uq", + "nullsNotDistinct": false, + "columns": [ + "id", + "account_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact_sync_state": { + "name": "contact_sync_state", + "schema": "", + "columns": { + "character_id": { + "name": "character_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_result": { + "name": "last_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_detail": { + "name": "last_detail", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "contact_sync_state_character_id_character_id_fk": { + "name": "contact_sync_state_character_id_character_id_fk", + "tableFrom": "contact_sync_state", + "tableTo": "character", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord_link": { + "name": "discord_link", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "discord_user_id": { + "name": "discord_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linked_at": { + "name": "linked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "discord_link_account_id_account_id_fk": { + "name": "discord_link_account_id_account_id_fk", + "tableFrom": "discord_link", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "discord_link_discord_user_id_unique": { + "name": "discord_link_discord_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "discord_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.esi_entity_name": { + "name": "esi_entity_name", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "esi_entity_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.loot_item": { + "name": "loot_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pool_id": { + "name": "pool_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type_id": { + "name": "type_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "qty": { + "name": "qty", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "unit_price": { + "name": "unit_price", + "type": "numeric(20, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_value": { + "name": "total_value", + "type": "numeric(20, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "price_source": { + "name": "price_source", + "type": "loot_price_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "loot_item_pool_id_loot_pool_id_fk": { + "name": "loot_item_pool_id_loot_pool_id_fk", + "tableFrom": "loot_item", + "tableTo": "loot_pool", + "columnsFrom": [ + "pool_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "loot_item_qty_ck": { + "name": "loot_item_qty_ck", + "value": "\"loot_item\".\"qty\" > 0" + }, + "loot_item_price_ck": { + "name": "loot_item_price_ck", + "value": "\"loot_item\".\"unit_price\" >= 0 AND \"loot_item\".\"total_value\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.loot_pool": { + "name": "loot_pool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "operation_id": { + "name": "operation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "raw_paste": { + "name": "raw_paste", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "valuation_source": { + "name": "valuation_source", + "type": "loot_valuation_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "pricing_mode": { + "name": "pricing_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "station_id": { + "name": "station_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "region_id": { + "name": "region_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "total_value": { + "name": "total_value", + "type": "numeric(20, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "appraised_at": { + "name": "appraised_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "loot_pool_operation_id_payout_operation_id_fk": { + "name": "loot_pool_operation_id_payout_operation_id_fk", + "tableFrom": "loot_pool", + "tableTo": "payout_operation", + "columnsFrom": [ + "operation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "loot_pool_total_ck": { + "name": "loot_pool_total_ck", + "value": "\"loot_pool\".\"total_value\" >= 0" + }, + "loot_pool_flat_note_ck": { + "name": "loot_pool_flat_note_ck", + "value": "\"loot_pool\".\"valuation_source\" <> 'flat' OR (\"loot_pool\".\"notes\" IS NOT NULL AND \"loot_pool\".\"notes\" <> '')" + }, + "loot_pool_appraised_fields_ck": { + "name": "loot_pool_appraised_fields_ck", + "value": "\"loot_pool\".\"valuation_source\" <> 'appraised' OR (\"loot_pool\".\"pricing_mode\" IS NOT NULL AND (\"loot_pool\".\"station_id\" IS NOT NULL) <> (\"loot_pool\".\"region_id\" IS NOT NULL))" + } + }, + "isRLSEnabled": false + }, + "public.oauth_transaction": { + "name": "oauth_transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "intent": { + "name": "intent", + "type": "oauth_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_transaction_state_hash_unique": { + "name": "oauth_transaction_state_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "state_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox": { + "name": "outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_undispatched_idx": { + "name": "outbox_undispatched_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"outbox\".\"dispatched_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payout_operation": { + "name": "payout_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "battle_report_url": { + "name": "battle_report_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "corp_share_pct": { + "name": "corp_share_pct", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "status": { + "name": "status", + "type": "payout_operation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "payout_operation_created_by_account_id_fk": { + "name": "payout_operation_created_by_account_id_fk", + "tableFrom": "payout_operation", + "tableTo": "account", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "payout_operation_corp_pct_ck": { + "name": "payout_operation_corp_pct_ck", + "value": "\"payout_operation\".\"corp_share_pct\" >= 0 AND \"payout_operation\".\"corp_share_pct\" <= 100" + } + }, + "isRLSEnabled": false + }, + "public.payout_participant": { + "name": "payout_participant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "operation_id": { + "name": "operation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "recipient_character_id": { + "name": "recipient_character_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_characters": { + "name": "source_characters", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "shares": { + "name": "shares", + "type": "numeric(6, 2)", + "primaryKey": false, + "notNull": true, + "default": "'1'" + }, + "excluded": { + "name": "excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "amount": { + "name": "amount", + "type": "numeric(20, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "paid_amount": { + "name": "paid_amount", + "type": "numeric(20, 2)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "payout_participant_operation_id_payout_operation_id_fk": { + "name": "payout_participant_operation_id_payout_operation_id_fk", + "tableFrom": "payout_participant", + "tableTo": "payout_operation", + "columnsFrom": [ + "operation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payout_participant_account_id_account_id_fk": { + "name": "payout_participant_account_id_account_id_fk", + "tableFrom": "payout_participant", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "payout_participant_recipient_character_id_character_id_fk": { + "name": "payout_participant_recipient_character_id_character_id_fk", + "tableFrom": "payout_participant", + "tableTo": "character", + "columnsFrom": [ + "recipient_character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "payout_participant_shares_ck": { + "name": "payout_participant_shares_ck", + "value": "\"payout_participant\".\"shares\" > 0" + }, + "payout_participant_amount_ck": { + "name": "payout_participant_amount_ck", + "value": "\"payout_participant\".\"amount\" >= 0" + }, + "payout_participant_paid_amount_ck": { + "name": "payout_participant_paid_amount_ck", + "value": "\"payout_participant\".\"paid_amount\" IS NULL OR \"payout_participant\".\"paid_amount\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.payout_payment": { + "name": "payout_payment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "participant_id": { + "name": "participant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "payout_payment_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(20, 2)", + "primaryKey": false, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor": { + "name": "actor", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "payout_payment_participant_id_payout_participant_id_fk": { + "name": "payout_payment_participant_id_payout_participant_id_fk", + "tableFrom": "payout_payment", + "tableTo": "payout_participant", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payout_payment_actor_account_id_fk": { + "name": "payout_payment_actor_account_id_fk", + "tableFrom": "payout_payment", + "tableTo": "account", + "columnsFrom": [ + "actor" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_expires_at_idx": { + "name": "session_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_account_id_account_id_fk": { + "name": "session_account_id_account_id_fk", + "tableFrom": "session", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.structure": { + "name": "structure", + "schema": "", + "columns": { + "structure_id": { + "name": "structure_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "corporation_id": { + "name": "corporation_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "type_id": { + "name": "type_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "type_name": { + "name": "type_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_id": { + "name": "system_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_timer_start": { + "name": "state_timer_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "state_timer_end": { + "name": "state_timer_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "fuel_expires": { + "name": "fuel_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "missing_since": { + "name": "missing_since", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.structure_event": { + "name": "structure_event", + "schema": "", + "columns": { + "notification_id": { + "name": "notification_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "structure_id": { + "name": "structure_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "corporation_id": { + "name": "corporation_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "alert_status": { + "name": "alert_status", + "type": "structure_alert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "structure_event_pending_idx": { + "name": "structure_event_pending_idx", + "columns": [ + { + "expression": "corporation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "alert_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sent_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.structure_holder": { + "name": "structure_holder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "corporation_id": { + "name": "corporation_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "designated_at": { + "name": "designated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "designated_by": { + "name": "designated_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seeded_at": { + "name": "seeded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "structure_holder_character_id_character_id_fk": { + "name": "structure_holder_character_id_character_id_fk", + "tableFrom": "structure_holder", + "tableTo": "character", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "structure_holder_singleton_ck": { + "name": "structure_holder_singleton_ck", + "value": "\"structure_holder\".\"id\" = 1" + } + }, + "isRLSEnabled": false + }, + "public.structure_read_state": { + "name": "structure_read_state", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "corporation_id": { + "name": "corporation_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "read_status": { + "name": "read_status", + "type": "structure_read_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "structure_read_state_kind_corporation_id_pk": { + "name": "structure_read_state_kind_corporation_id_pk", + "columns": [ + "kind", + "corporation_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_run": { + "name": "sync_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "job_type": { + "name": "job_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "sync_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "error_summary": { + "name": "error_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "counts": { + "name": "counts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sync_run_job_type_id_idx": { + "name": "sync_run_job_type_id_idx", + "columns": [ + { + "expression": "job_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.universe_name": { + "name": "universe_name", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "universe_name_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanderer_acl_observation": { + "name": "wanderer_acl_observation", + "schema": "", + "columns": { + "character_id": { + "name": "character_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.access_list_entry_kind": { + "name": "access_list_entry_kind", + "schema": "public", + "values": [ + "character", + "corporation", + "alliance" + ] + }, + "public.access_list_read_status": { + "name": "access_list_read_status", + "schema": "public", + "values": [ + "ok", + "not_visible", + "failed" + ] + }, + "public.account_status": { + "name": "account_status", + "schema": "public", + "values": [ + "active", + "cryo" + ] + }, + "public.esi_entity_kind": { + "name": "esi_entity_kind", + "schema": "public", + "values": [ + "character", + "corporation", + "alliance" + ] + }, + "public.loot_price_source": { + "name": "loot_price_source", + "schema": "public", + "values": [ + "triff", + "manual", + "unresolved" + ] + }, + "public.loot_valuation_source": { + "name": "loot_valuation_source", + "schema": "public", + "values": [ + "appraised", + "flat" + ] + }, + "public.oauth_intent": { + "name": "oauth_intent", + "schema": "public", + "values": [ + "login", + "link-character", + "link-discord" + ] + }, + "public.payout_operation_status": { + "name": "payout_operation_status", + "schema": "public", + "values": [ + "draft", + "finalized" + ] + }, + "public.payout_payment_kind": { + "name": "payout_payment_kind", + "schema": "public", + "values": [ + "paid", + "reverted" + ] + }, + "public.structure_alert_status": { + "name": "structure_alert_status", + "schema": "public", + "values": [ + "seeded", + "pending", + "sent", + "abandoned" + ] + }, + "public.structure_read_status": { + "name": "structure_read_status", + "schema": "public", + "values": [ + "ok", + "forbidden", + "failed" + ] + }, + "public.sync_run_status": { + "name": "sync_run_status", + "schema": "public", + "values": [ + "ok", + "partial", + "failed" + ] + }, + "public.tier": { + "name": "tier", + "schema": "public", + "values": [ + "member", + "associate", + "alumni", + "pending" + ] + }, + "public.token_status": { + "name": "token_status", + "schema": "public", + "values": [ + "valid", + "invalid", + "needs_reauth", + "missing" + ] + }, + "public.universe_name_kind": { + "name": "universe_name_kind", + "schema": "public", + "values": [ + "system", + "station", + "structure" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index a0fd091..5b8e65b 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1786316312652, "tag": "0012_old_richard_fisk", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1787548177354, + "tag": "0013_bumpy_kid_colt", + "breakpoints": true } ] } \ No newline at end of file diff --git a/e2e/audit.spec.ts b/e2e/audit.spec.ts index 040986f..8f2e347 100644 --- a/e2e/audit.spec.ts +++ b/e2e/audit.spec.ts @@ -115,6 +115,7 @@ const EXPECTED_ACTION_NAMESPACES = [ "discord.", "payout.", "status.", + "structure.", "sync.", "tier.", "token.", diff --git a/e2e/shell.spec.ts b/e2e/shell.spec.ts index 4c0ded5..e0cdb3f 100644 --- a/e2e/shell.spec.ts +++ b/e2e/shell.spec.ts @@ -38,6 +38,7 @@ test("aria-current lands on the right tab on every shell route", async ({ ["/admin/audit", "Audit log", "page"], ["/admin/sync", "Sync", "page"], ["/admin/access-lists", "Access lists", "page"], + ["/admin/structures", "Structures", "page"], ["/payouts", "Operations", "page"], // `/payouts/new` sits under the Operations tab without being it, so the tab // is current-within-the-set rather than the page you are on. Asserting the @@ -107,6 +108,7 @@ test("nav membership follows the viewer, not the section", async ({ page, contex "Audit log", "Sync", "Access lists", + "Structures", ]); } @@ -127,6 +129,7 @@ test("nav membership follows the viewer, not the section", async ({ page, contex "Audit log", "Sync", "Access lists", + "Structures", ]); await expect( adminNav.getByRole("link", { name: "Operations", exact: true }), diff --git a/e2e/structures.spec.ts b/e2e/structures.spec.ts new file mode 100644 index 0000000..2263184 --- /dev/null +++ b/e2e/structures.spec.ts @@ -0,0 +1,171 @@ +/** + * Coverage boundary: Playwright runs with `SYNC_MODE: "dry-run"` + * (playwright.config.ts), and dry-run makes `getFreshAccessToken` return + * before any network call — refreshing during a dry run would rotate and + * destroy a real refresh token. So no e2e test here can reach an ESI fetch; + * neither job can populate a roster or send an alert under this harness. That + * behaviour is already proven in tests/ against real Postgres (Tasks 8 and 9). + * What this file covers instead: the state cascade's sentences, designation + * via the server action, and the roster/events tables rendering from rows + * seeded directly into Postgres — plus the nav entry's visibility. + */ +import { expect, test } from "@playwright/test"; +import { eq } from "drizzle-orm"; +import { character, structure, structureHolder } from "../src/db/schema"; +import { NOTIFICATIONS_SCOPE, STRUCTURES_SCOPE } from "../src/lib/esi/client"; +import { resetDb, seedMember, sessionCookieFor, testDb } from "./helpers"; + +const { db, pool } = testDb(); + +test.afterAll(() => pool.end()); +test.beforeEach(() => resetDb(db)); + +const CORP = 98_000_321; + +test("an admin with no holder is asked to grant", async ({ page, context }) => { + const admin = await seedMember(db, { name: "Vela Kaine", isAdmin: true }); + await context.addCookies([await sessionCookieFor(db, admin.id)]); + await page.goto("/admin/structures"); + + await expect(page.getByRole("main")).toContainText("No character has granted"); +}); + +test("a seeded roster renders most-alarming-first", async ({ page, context }) => { + const admin = await seedMember(db, { name: "Vela Kaine", isAdmin: true }); + await context.addCookies([await sessionCookieFor(db, admin.id)]); + const holderCharacterId = admin.mainCharacterId!; + await db + .update(character) + .set({ + scopes: [STRUCTURES_SCOPE, NOTIFICATIONS_SCOPE], + tokenStatus: "valid", + corporationId: CORP, + }) + .where(eq(character.id, holderCharacterId)); + await db.insert(structureHolder).values({ + id: 1, + characterId: holderCharacterId, + corporationId: CORP, + designatedBy: "e2e", + }); + const now = new Date(); + await db.insert(structure).values([ + { + structureId: 1001, + corporationId: CORP, + typeId: 1, + typeName: "Astrahus", + systemId: 30_000_142, + name: "Alpha Keep", + state: "shield_vulnerable", + observedAt: now, + }, + { + structureId: 1002, + corporationId: CORP, + typeId: 2, + typeName: "Fortizar", + systemId: 30_000_142, + name: "Bravo Keep", + state: "hull_reinforce", + observedAt: now, + }, + { + structureId: 1003, + corporationId: CORP, + typeId: 3, + typeName: "Athanor", + systemId: 30_000_142, + name: "Charlie Refinery", + state: "anchoring", + observedAt: now, + }, + ]); + await page.goto("/admin/structures"); + + const rows = page + .getByRole("region", { name: "Structure roster" }) + .locator("tbody > tr"); + await expect(rows).toHaveCount(3); + await expect(rows.first()).toContainText("hull"); +}); + +test("designating a holder through the server action clears the ask", async ({ + page, + context, +}) => { + const admin = await seedMember(db, { name: "Vela Kaine", isAdmin: true }); + await context.addCookies([await sessionCookieFor(db, admin.id)]); + const holderCharacterId = admin.mainCharacterId!; + await db + .update(character) + .set({ + scopes: [STRUCTURES_SCOPE, NOTIFICATIONS_SCOPE], + tokenStatus: "valid", + corporationId: CORP, + }) + .where(eq(character.id, holderCharacterId)); + + await page.goto("/admin/structures"); + await expect(page.getByRole("main")).toContainText( + "granted structure access but is not", + ); + await page.getByRole("button", { name: "Designate as holder" }).click(); + + await expect(page.getByRole("main")).not.toContainText("is not the holder"); +}); + +test("corp-changed shows the designate form and never a second primary action", async ({ + page, + context, +}) => { + const admin = await seedMember(db, { name: "Vela Kaine", isAdmin: true }); + await context.addCookies([await sessionCookieFor(db, admin.id)]); + const holderCharacterId = admin.mainCharacterId!; + await db + .update(character) + .set({ + scopes: [STRUCTURES_SCOPE, NOTIFICATIONS_SCOPE], + tokenStatus: "valid", + corporationId: CORP, + }) + .where(eq(character.id, holderCharacterId)); + await db.insert(structureHolder).values({ + id: 1, + characterId: holderCharacterId, + corporationId: CORP, + designatedBy: "e2e", + }); + // The character leaves the pinned corp after designation — this is what + // produces corp-changed, distinct from the holder never existing. + await db + .update(character) + .set({ corporationId: CORP + 1 }) + .where(eq(character.id, holderCharacterId)); + + await page.goto("/admin/structures"); + + await expect(page.getByRole("button", { name: "Designate as holder" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Check now" })).toHaveCount(0); +}); + +test("Structures appears in the admin nav and not for a plain member", async ({ + page, + context, +}) => { + const admin = await seedMember(db, { name: "Vela Kaine", isAdmin: true }); + await context.addCookies([await sessionCookieFor(db, admin.id)]); + await page.goto("/admin/structures"); + await expect( + page.locator(".shell__nav").getByRole("link", { name: "Structures" }), + ).toBeVisible(); + + await resetDb(db); + const member = await seedMember(db, { name: "Rane Solette", tier: "member" }); + await context.clearCookies(); + await context.addCookies([await sessionCookieFor(db, member.id)]); + await page.goto("/account"); + await expect( + page.locator(".shell__nav").getByRole("link", { name: "Structures" }), + ).toHaveCount(0); +}); diff --git a/e2e/sync.spec.ts b/e2e/sync.spec.ts index fd42566..26bd739 100644 --- a/e2e/sync.spec.ts +++ b/e2e/sync.spec.ts @@ -394,10 +394,23 @@ test("the strip's four groups are four named lists, not one flat one with the la const memberFacing = page.getByRole("list", { name: "Member-facing" }); await expect(memberFacing.getByRole("listitem")).toHaveCount(1); - // on-demand: membership-recheck and access-lists, each reachable from a - // dedicated control other than the fan-out. + // on-demand: membership-recheck, access-lists, structures, and + // structure-events, each reachable from a dedicated control other than the + // fan-out. const onDemand = page.getByRole("list", { name: "On-demand" }); - await expect(onDemand.getByRole("listitem")).toHaveCount(2); + await expect(onDemand.getByRole("listitem")).toHaveCount(4); + // Each job named explicitly, not just counted — `exact` matters here since + // "structures" is a substring of "structure-events". + for (const jobType of [ + "membership-recheck", + "access-lists", + "structures", + "structure-events", + ]) { + await expect( + onDemand.getByRole("heading", { name: jobType, exact: true }), + ).toBeVisible(); + } // housekeeping: token-health, purge. Still a `role="list"` of 2 items once // opened — `getByRole` reads the accessibility tree, and Chromium excludes diff --git a/src/app/_components/nav-items.ts b/src/app/_components/nav-items.ts index bd04a2e..41ba68a 100644 --- a/src/app/_components/nav-items.ts +++ b/src/app/_components/nav-items.ts @@ -22,6 +22,7 @@ import type { NavItem } from "./ui"; * Audit log — iff isAdmin * Sync — iff isAdmin * Access lists — iff isAdmin + * Structures — iff isAdmin * * `isAdmin` and `tier` are orthogonal columns (db/schema.ts) — an admin is not * necessarily a payouts reader (default tier is `alumni`), and a payouts @@ -30,13 +31,13 @@ import type { NavItem } from "./ui"; * can render Operations unconditionally just because the viewer is an admin. * * Order is fixed and identical everywhere: Your account, Operations, Members, - * Audit log, Sync, Access lists — broadest access first. A member-only reader, - * a payouts reader, and an admin all see a strict prefix (in membership, not - * merely in count) of the same six-item list, in the same order, rather than - * six per-surface orderings that happened to agree by convention. This is also - * why the admin bar's order changes here: "Your account" moves from last to - * first. That is a consequence of there being one order, not a separate - * decision about the admin bar. + * Audit log, Sync, Access lists, Structures — broadest access first. A + * member-only reader, a payouts reader, and an admin all see a strict prefix + * (in membership, not merely in count) of the same seven-item list, in the + * same order, rather than seven per-surface orderings that happened to agree + * by convention. This is also why the admin bar's order changes here: "Your + * account" moves from last to first. That is a consequence of there being one + * order, not a separate decision about the admin bar. * * The label strings live here exactly once, which is the point rather than a * side effect. Two routes carrying two names for one destination fails WCAG @@ -89,6 +90,7 @@ const MEMBERS: NavItem = { href: "/admin/accounts", label: "Members" }; const AUDIT: NavItem = { href: "/admin/audit", label: "Audit log" }; const SYNC: NavItem = { href: "/admin/sync", label: "Sync" }; const ACCESS_LISTS: NavItem = { href: "/admin/access-lists", label: "Access lists" }; +const STRUCTURES: NavItem = { href: "/admin/structures", label: "Structures" }; /** So `AdminNav` can attach the pending badge to the Members item without * re-typing its route string a second time. */ @@ -118,7 +120,7 @@ export function navFor({ canReadPayouts, isAdmin }: Reach): NavItem[] { return [ ACCOUNT, ...(canReadPayouts ? [PAYOUTS] : []), - ...(isAdmin ? [MEMBERS, AUDIT, SYNC, ACCESS_LISTS] : []), + ...(isAdmin ? [MEMBERS, AUDIT, SYNC, ACCESS_LISTS, STRUCTURES] : []), ]; } diff --git a/src/app/admin/audit/summarize.ts b/src/app/admin/audit/summarize.ts index 798c1e6..6986ff4 100644 --- a/src/app/admin/audit/summarize.ts +++ b/src/app/admin/audit/summarize.ts @@ -352,6 +352,20 @@ const PARTS: Record = { ], "access_list.watch_added": [accessListRef("name", "accessListId")], "access_list.watch_removed": [accessListRef("name", "accessListId")], + // `corporationId` rides along on both writes (services/structures.ts) but + // there is only ever one alliance's worth of corps this app tracks, so it + // adds nothing a scanning admin doesn't already know; silenced rather than + // left to surface as a `+1 more`. + "structure.holder_designated": [ + characterRef("character", "characterId"), + silent("corporationId"), + ], + "structure.holder_replaced": [ + characterRef("character", "characterId"), + characterRef("was", "previousCharacterId"), + labelled("abandoned alerts", "abandonedAlerts"), + silent("corporationId"), + ], "discord.unlinked": [scalar("reason")], // Both writers stamp `partial` on every row (jobs/discord-roles.ts:143 and // :369), so it has to be declared here or every single role-change row diff --git a/src/app/admin/structures/actions.ts b/src/app/admin/structures/actions.ts new file mode 100644 index 0000000..c70dbe5 --- /dev/null +++ b/src/app/admin/structures/actions.ts @@ -0,0 +1,80 @@ +"use server"; + +import { z } from "zod"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { getDb } from "@/db"; +import { requireAdminAction } from "@/lib/admin-guard"; +import { enqueueSync } from "@/services/outbox"; +import { + designateStructureHolder, + getCharacterCorporationId, +} from "@/services/structures"; + +/** + * Both actions gate themselves with `requireAdminAction`. The admin layout's + * guard does not protect server actions and does not re-run on soft + * navigation, so "the page checked already" is not a check. + * + * Neither calls ESI. This page reads Postgres and enqueues; the worker + * performs every read. + */ + +/** An id that will become a bigint column and an audit target, parsed with + * zod rather than cast — a server action takes whatever the wire sends. + * Copied verbatim from `admin/access-lists/actions.ts`: the input is a + * `FormDataEntryValue | null`, never bare `string`. */ +const idSchema = z.preprocess( + (value) => Number(value), + // The `error` on the type gate is not redundant with the refine's, and both + // are read: `parseId` below throws the code it takes off the rejected issue, + // so a path left without one would surface zod's own generated wording as + // the thrown message. `Number()` runs first and maps a non-numeric spelling + // to `NaN`, which `z.number()` rejects at the gate — the refine never runs — + // so the most likely bad input ("12abc") rejects through the gate while a + // well-formed-but-out-of-range one ("-1") rejects through the refine. Both + // spell it the same way, so the caller gets `invalid_id` either way. + z + .number({ error: "invalid_id" }) + .refine((n) => Number.isSafeInteger(n) && n > 0, { error: "invalid_id" }), +); + +/** Unreachable from the rendered page, so a bad value throws rather than + * earning notice copy — the same posture `syncJobAction` takes on `jobType`. + * The code comes off the rejected issue rather than being restated here, so + * `invalid_id` has one spelling (the schema's) rather than two that can + * drift; same shape as `admin/accounts/actions.ts`'s `assertValid`. */ +function parseId(value: FormDataEntryValue | null): number { + const parsed = idSchema.safeParse(value); + if (!parsed.success) throw new Error(parsed.error.issues[0]?.message ?? "invalid_id"); + return parsed.data; +} + +/** + * `corporationId` is read server-side from the character's current + * `character.corporationId` rather than taken off the form: a hidden input is + * client-controlled, and the pin has to reflect what the database actually + * says, not whatever value a request happened to carry. + */ +export async function designateStructureHolderAction(formData: FormData): Promise { + const { accountId: actor } = await requireAdminAction(); + const characterId = parseId(formData.get("characterId")); + const db = getDb(); + const corporationId = await getCharacterCorporationId(db, characterId); + if (corporationId === null) throw new Error("invalid_id"); + await designateStructureHolder(db, characterId, corporationId, actor); + revalidatePath("/admin/structures"); + redirect(`/admin/structures?done=holder&at=${Date.now()}`); +} + +/** Asking for a read changes no state, so this writes no audit row. */ +export async function checkNowAction(): Promise { + await requireAdminAction(); + const db = getDb(); + await db.transaction(async (tx) => { + await enqueueSync(tx, { kind: "job", jobType: "structures" }); + await enqueueSync(tx, { kind: "job", jobType: "structure-events" }); + }); + revalidatePath("/admin/structures"); + redirect(`/admin/structures?done=check&at=${Date.now()}`); +} diff --git a/src/app/admin/structures/page.tsx b/src/app/admin/structures/page.tsx new file mode 100644 index 0000000..66a60bd --- /dev/null +++ b/src/app/admin/structures/page.tsx @@ -0,0 +1,262 @@ +import type { Metadata } from "next"; +import { getConfig } from "@/config"; +import { getDb } from "@/db"; +import { compareRosterRows, formatStructureAlert } from "@/core/structure-event"; +import { requireAdminPage } from "@/lib/admin-guard"; +import { resolveStructureWebhookUrl } from "@/lib/ops-webhook"; +import { + findGrantableCharacter, + getReadStates, + getRecentEvents, + getRoster, + getStructureHolder, + toHolderView, + type RosterRow, +} from "@/services/structures"; +import { lookupCachedNames } from "@/services/universe-names"; +import { RuleHead, Scroller, Status } from "@/app/_components/ui"; +import { ConfirmNotice } from "@/app/_components/confirm-notice"; +import { Submit } from "@/app/_components/submit"; +import { RelativeTime } from "@/app/_components/relative-time"; +import { formatAgo } from "@/app/_components/format-ago"; +import { checkNowAction, designateStructureHolderAction } from "./actions"; +import { + doneNotice, + forbiddenReads, + monitorRemedy, + monitorSentence, + monitorState, + rowTone, + showsRoster, + type MonitorInput, +} from "./view"; + +/** + * This page reads Postgres and enqueues; the worker performs every read. A + * live ESI fetch on render would burn a refresh-token rotation per page load. + */ +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { title: "Structures" }; + +const RECENT_EVENT_LIMIT = 20; + +export default async function StructuresPage({ + searchParams, +}: { + searchParams: Promise<{ done?: string; at?: string }>; +}) { + // Its own guard, not the layout's: a layout does not re-run on soft + // navigation and never sees a server action. + await requireAdminPage(); + const { done, at } = await searchParams; + const db = getDb(); + const cfg = getConfig(); + + const holder = await getStructureHolder(db); + // The pinned corp, read off the raw holder row rather than off `holderView` + // below — the roster and event reads must not go dark just because the + // holder's character row happens to be missing. + const corporationId = holder?.corporationId ?? null; + const grantable = await findGrantableCharacter(db); + + // `toHolderView` THROWS when the holder's character row is missing: + // `unlinkCharacter` deletes a character row and `structure_holder`'s FK + // cascades, so the row `getStructureHolder` just read can be gone by the + // time this join runs. Caught here and treated as "no holder" — the page + // renders whatever `monitorState` gives a null holder (`grant-needed` or + // `designate-needed`) rather than surfacing the throw as a 500. + const holderView = holder ? await toHolderView(db, holder).catch(() => null) : null; + + const [roster, readStates, events] = await Promise.all([ + corporationId ? getRoster(db, corporationId) : Promise.resolve([]), + corporationId ? getReadStates(db, corporationId) : Promise.resolve({}), + corporationId + ? getRecentEvents(db, corporationId, RECENT_EVENT_LIMIT) + : Promise.resolve([]), + ]); + + // ONE batched, cache-only name read for every system the two tables print. + const systemNames = await lookupCachedNames(db, [ + ...new Set(roster.map((r) => r.systemId)), + ]); + + const input: MonitorInput = { + grantable, + holder: holderView, + readStates, + rosterCount: roster.length, + webhookConfigured: resolveStructureWebhookUrl(cfg) !== undefined, + }; + const state = monitorState(input); + const remedy = monitorRemedy(state); + const rows = [...roster].sort(compareRosterRows); + const notice = doneNotice(done, at); + const now = Date.now(); + + // One lookup from structureId to its roster row, for the recent-events list + // below: `structure` rows are never deleted (a vanished structure gets + // `missingSince` stamped instead), so every event's structure is still in + // `roster` even once it is gone. + const structureIndex = new Map(roster.map((r) => [r.structureId, r])); + + return ( +
+
+

Structures

+

+ {monitorSentence(state, { + name: input.holder?.name, + count: roster.length, + forbidden: forbiddenReads(input), + })} +

+
+ + + +
+ {/* Gold is rationed to one primary action per view. A link remedy + (grant/re-grant/re-authenticate) is that action when one exists; + "Designate as holder" takes its place when a holder is needed and + an admin-owned character can already fill it; "Check now" takes it + the rest of the time, once there is a holder and nothing else to + fix first. */} + {remedy && ( + + {remedy.label} + + )} + {!remedy && + (state === "designate-needed" || state === "corp-changed") && + grantable !== null && + grantable.corporationId !== null && ( +
+ + + Designate as holder + +
+ )} + {!remedy && state !== "designate-needed" && state !== "corp-changed" && ( +
+ + Check now + +
+ )} +
+ + {showsRoster(state) && ( + <> + Structures ({rows.length}) + + + + + + + + + + + + + {rows.map((row) => ( + + ))} + +
StructureSystemStateTimer endsFuel expires
+
+ + )} + + {events.length > 0 && ( + <> + Recent notifications + + + + + + + + + + {events.map((e) => { + const s = + e.structureId !== null + ? structureIndex.get(e.structureId) + : undefined; + const line = formatStructureAlert({ + type: e.type, + structureName: s?.name ?? null, + typeName: s?.typeName ?? null, + systemName: s ? (systemNames.get(s.systemId) ?? null) : null, + details: e.details ?? {}, + }); + const sentIso = e.sentAt.toISOString(); + return ( + + + + + ); + })} + +
NotificationSent
{line} + +
+
+ + )} +
+ ); +} + +/** + * One roster row. `rowTone` is the only place that decides a row's alarm + * colour (view.ts, per PRODUCT.md principle 4): reinforced timers get `bad`, + * a vulnerability window gets `warn`, anything else is `neutral` — this + * component only renders whatever it returns, never a colour of its own. + */ +function StructureRow({ + row, + systemName, + now, +}: { + row: RosterRow; + systemName: string | undefined; + now: number; +}) { + const timerIso = row.stateTimerEnd?.toISOString(); + const fuelIso = row.fuelExpires?.toISOString(); + return ( + + {row.name ?? row.typeName ?? `#${row.structureId}`} + {systemName ?? `#${row.systemId}`} + + {row.state.replaceAll("_", " ")} + + + {timerIso ? ( + + ) : ( + "—" + )} + + + {fuelIso ? : "—"} + + + ); +} diff --git a/src/app/admin/structures/view.ts b/src/app/admin/structures/view.ts new file mode 100644 index 0000000..55d018b --- /dev/null +++ b/src/app/admin/structures/view.ts @@ -0,0 +1,188 @@ +import type { StructureReadStatus } from "@/db/schema"; +import type { HolderView } from "@/services/structures"; +import { NOTIFICATIONS_SCOPE, STRUCTURES_SCOPE } from "@/lib/esi/client"; + +export type MonitorState = + | "grant-needed" + | "designate-needed" + | "scope-dropped" + | "holder-needs-reauth" + | "holder-no-token" + | "corp-changed" + | "no-corp-roles" + | "roster-empty" + | "alerts-unconfigured" + | "normal"; + +export const GRANT_HREF = "/auth/eve/link?grant=structures"; +const REAUTH_HREF = "/auth/eve/link"; + +// HolderView is declared in @/services/structures (Task 6) and imported above: +// it describes that service read's return shape, and re-declaring it here +// would give the two files a copy each to drift apart. + +export type MonitorInput = { + grantable: { characterId: number; name: string } | null; + holder: HolderView | null; + readStates: Partial>; + rosterCount: number; + webhookConfigured: boolean; +}; + +/** + * A priority cascade, most blocking first. Total over its input: every arm + * returns, so a new field cannot leave the page with no sentence to print. + * + * Scope BEFORE token, deliberately. A dropped grant and a stale token both + * want an EVE round trip, but they want DIFFERENT ones: the bare re-auth link + * is what drops the opt-in scope in the first place, so offering it to a + * scope-dropped holder sends an admin round a loop that cannot terminate. + * + * corp-changed is derived HERE, live, rather than read from + * structure_read_state.detail — the page must say so the moment affiliation + * updates, not up to an hour later when the roster job next ticks. + */ +export function monitorState(input: MonitorInput): MonitorState { + const { holder } = input; + if (!holder) return input.grantable ? "designate-needed" : "grant-needed"; + const hasScopes = + holder.scopes.includes(STRUCTURES_SCOPE) && + holder.scopes.includes(NOTIFICATIONS_SCOPE); + if (!hasScopes) return "scope-dropped"; + if (holder.tokenStatus === "needs_reauth") return "holder-needs-reauth"; + if (holder.tokenStatus === "missing" || holder.tokenStatus === "invalid") { + return "holder-no-token"; + } + if ( + holder.currentCorporationId !== null && + holder.currentCorporationId !== holder.corporationId + ) { + return "corp-changed"; + } + if (forbiddenReads(input).length > 0) return "no-corp-roles"; + if (input.rosterCount === 0) return "roster-empty"; + if (!input.webhookConfigured) return "alerts-unconfigured"; + return "normal"; +} + +/** Which of the two reads the corp refused. Both can be forbidden at once. */ +export function forbiddenReads(input: MonitorInput): ("roster" | "events")[] { + const out: ("roster" | "events")[] = []; + if (input.readStates.roster?.readStatus === "forbidden") out.push("roster"); + if (input.readStates.events?.readStatus === "forbidden") out.push("events"); + return out; +} + +const READ_LABEL: Record<"roster" | "events", string> = { + roster: "structure list", + events: "notifications", +}; + +export function monitorSentence( + state: MonitorState, + ctx: { name?: string; count?: number; forbidden?: ("roster" | "events")[] }, +): string { + const who = ctx.name ?? "The holder"; + switch (state) { + case "grant-needed": + return "No character has granted structure access."; + case "designate-needed": + return `${who} granted structure access but is not the holder.`; + case "scope-dropped": + return `${who} is the holder but no longer grants structure access.`; + case "holder-needs-reauth": + return `${who} needs to sign in to EVE again.`; + case "holder-no-token": + return `${who} has no usable EVE token.`; + case "corp-changed": + return `${who} has left the corporation this roster belongs to.`; + case "no-corp-roles": + return `The corporation refused the ${(ctx.forbidden ?? []) + .map((k) => READ_LABEL[k]) + .join(" and ")} read.`; + case "roster-empty": + return "Nothing read yet."; + case "alerts-unconfigured": + return `${ctx.count ?? 0} structures. No Discord webhook is set, so nothing is alerted.`; + case "normal": + return `${ctx.count ?? 0} structures. Alerts go to Discord.`; + } +} + +export type Remedy = { href: string; label: string }; + +/** + * Total exhaustive switch, no `default` arm: adding a MonitorState without + * deciding its remedy must be a compile error, not a silent null. + * + * Three states return null because there is nothing this app can offer. The + * corp-role grants and the webhook secret are both outside it — a button that + * cannot fix the problem is worse than a sentence that explains it. + */ +export function monitorRemedy(state: MonitorState): Remedy | null { + switch (state) { + case "grant-needed": + return { href: GRANT_HREF, label: "Grant structure access" }; + case "scope-dropped": + return { href: GRANT_HREF, label: "Re-grant structure access" }; + case "holder-needs-reauth": + case "holder-no-token": + return { href: REAUTH_HREF, label: "Re-authenticate" }; + case "designate-needed": + case "corp-changed": + case "no-corp-roles": + case "roster-empty": + case "alerts-unconfigured": + case "normal": + return null; + } +} + +/** The roster is worth rendering in every state that has one. */ +export function showsRoster(state: MonitorState): boolean { + return ( + state === "normal" || + state === "alerts-unconfigured" || + state === "no-corp-roles" || + state === "corp-changed" + ); +} + +/** + * PRODUCT.md principle 4 reserves alarm colour for what a user can and should + * fix. access-lists/view.ts:220-227 refuses `bad` on that basis; a structure in + * hull or armor reinforce is precisely the exception it carves room for — a + * fight you can still show up to. + */ +export function rowTone(state: string): "bad" | "warn" | "neutral" { + if (state === "hull_reinforce" || state === "armor_reinforce") return "bad"; + if (state.endsWith("_vulnerable")) return "warn"; + return "neutral"; +} + +export function doneStamp(at: string | undefined): string | null { + if (at === undefined || !/^\d{1,15}$/.test(at)) return null; + const d = new Date(Number(at)); + if (Number.isNaN(d.getTime())) return null; + const iso = d.toISOString(); + if (iso.length !== 24) return null; + return `${iso.slice(11, 23)} UTC`; +} + +/** + * The outcome of the press that produced this render, for the two actions + * that redirect. An unrecognized marker yields the empty string rather than + * being echoed — `Notice` renders an empty slot for it, which is the shape + * that keeps its live region announcing changes rather than being born full. + */ +export function doneNotice(done: string | undefined, at: string | undefined): string { + const stamp = doneStamp(at); + const when = stamp === null ? "" : ` at ${stamp}`; + if (done === "holder") { + return `Holder designated${when}. The next read will use it.`; + } + if (done === "check") { + return `Check queued${when}. Reload this page once the worker has run.`; + } + return ""; +} diff --git a/src/app/auth/eve/link/route.ts b/src/app/auth/eve/link/route.ts index f20b8db..f219ccb 100644 --- a/src/app/auth/eve/link/route.ts +++ b/src/app/auth/eve/link/route.ts @@ -1,11 +1,29 @@ import { NextRequest, NextResponse } from "next/server"; import { getConfig } from "@/config"; import { getDb } from "@/db"; -import { ACCESS_LISTS_SCOPE } from "@/lib/esi/client"; +import { + ACCESS_LISTS_SCOPE, + NOTIFICATIONS_SCOPE, + STRUCTURES_SCOPE, +} from "@/lib/esi/client"; import { buildEveAuthorizeUrl } from "@/lib/esi/sso"; import { getRequestAccount } from "@/lib/request-session"; import { createOauthTransaction } from "@/services/oauth-tx"; +// Opt-in only: none of these are in EVE_SSO_SCOPES, because adding one there +// would flip every character to needs_reauth at the next token-health run. +// Exact literals keyed by an allowed grant name, never a free-form scope +// parameter — the query string is attacker-controllable and must not be able +// to widen what we ask EVE for. A plain object literal also inherits +// Object.prototype's own members (toString, constructor, __proto__), so a +// bare `GRANTS[grant]` throws or returns a function for those three +// predictable strings; Object.hasOwn (same guard as core/schedules.ts's +// isJobType) is required before indexing, not optional hardening. +const GRANTS: Record = { + "access-lists": [ACCESS_LISTS_SCOPE], + structures: [STRUCTURES_SCOPE, NOTIFICATIONS_SCOPE], +}; + export async function GET(req: NextRequest) { const cfg = getConfig(); const sess = await getRequestAccount(req); @@ -15,13 +33,8 @@ export async function GET(req: NextRequest) { sessionId: sess.sessionId, accountId: sess.accountId, }); - // Opt-in only: esi-access.read_lists.v1 is deliberately NOT in - // EVE_SSO_SCOPES, because adding it there would flip every character to - // needs_reauth at the next token-health run. An exact literal, not a - // free-form scope parameter — the query string is attacker-controllable and - // must not be able to widen what we ask EVE for. - const extraScopes = - req.nextUrl.searchParams.get("grant") === "access-lists" ? [ACCESS_LISTS_SCOPE] : []; + const grant = req.nextUrl.searchParams.get("grant") ?? ""; + const extraScopes = Object.hasOwn(GRANTS, grant) ? [...GRANTS[grant]] : []; return NextResponse.redirect( buildEveAuthorizeUrl(cfg, tx.state, tx.codeChallenge, extraScopes), ); diff --git a/src/config.ts b/src/config.ts index 51bdae2..42ea6c1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -72,6 +72,7 @@ const envSchema = z.object({ DISCORD_ROLE_ID_ASSOCIATE: z.string().min(1), DISCORD_ROLE_ID_ALUMNI: z.string().min(1), DISCORD_OPS_WEBHOOK_URL: z.string().url().optional().or(z.literal("")), + DISCORD_STRUCTURE_WEBHOOK_URL: z.string().url().optional().or(z.literal("")), WANDERER_BASE_URL: z.string().url(), WANDERER_API_KEY: z.string().min(1), WANDERER_ACL_ID: z.string().min(1), @@ -162,6 +163,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env) { alumni: e.DISCORD_ROLE_ID_ALUMNI, }, opsWebhookUrl: e.DISCORD_OPS_WEBHOOK_URL || undefined, + structureWebhookUrl: e.DISCORD_STRUCTURE_WEBHOOK_URL || undefined, }, wanderer: { baseUrl: e.WANDERER_BASE_URL, diff --git a/src/core/schedules.ts b/src/core/schedules.ts index 9b02f86..364ca8d 100644 --- a/src/core/schedules.ts +++ b/src/core/schedules.ts @@ -23,6 +23,16 @@ export const JOB_CRON = { // :15 discord-roles, :02,17,32,47 location. A read-only monitor has no // reason to contend with the jobs that push member state outward. "access-lists": "25 * * * *", + // :35 is free — :00/:30 membership, :05 contacts, :10 wanderer, + // :15 discord-roles, :25 access-lists, :02,17,32,47 location. The roster + // endpoint caches for an hour, so a faster tick would re-read the same page. + structures: "35 * * * *", + // Ten minutes matches the notifications endpoint's 600 s cache exactly — + // polling faster returns the same cached page. Offset off :00/:05/:10/:15/ + // :25/:30/:35 and location's :02,17,32,47. formatCadence renders evenly + // spaced comma minutes, so the admin page shows "every 10 minutes" rather + // than the raw cron. + "structure-events": "3,13,23,33,43,53 * * * *", } as const satisfies Record; /** @@ -83,6 +93,8 @@ export const JOB_GROUP: Record = { purge: "housekeeping", location: "member-facing", "access-lists": "on-demand", + structures: "on-demand", + "structure-events": "on-demand", }; /** The strip a job type belongs to, or null when nothing schedules it. */ diff --git a/src/core/structure-event.ts b/src/core/structure-event.ts new file mode 100644 index 0000000..ebc939e --- /dev/null +++ b/src/core/structure-event.ts @@ -0,0 +1,181 @@ +/** + * Pure parsing, formatting and ordering for structure damage notifications. + * No I/O, no imports from services or db — this module is unit-tested on + * literal notification bodies. + */ + +/** + * The four damage types. Fuel, low-power, anchoring and ownership-transfer + * notifications exist and are deliberately not here: this feature alerts on + * damage. Adding one later is a one-line change to this array. + */ +export const STRUCTURE_EVENT_TYPES = [ + "StructureUnderAttack", + "StructureLostShields", + "StructureLostArmor", + "StructureDestroyed", +] as const; + +export type StructureEventType = (typeof STRUCTURE_EVENT_TYPES)[number]; + +export function isStructureEventType(type: string): type is StructureEventType { + return (STRUCTURE_EVENT_TYPES as readonly string[]).includes(type); +} + +const SCALAR_LINE = /^([A-Za-z_][A-Za-z0-9_]*):[ \t]*(.*)$/; +const ANCHOR = /^&(\S+)[ \t]+(.*)$/; +const ALIAS = /^\*(\S+)$/; + +/** + * A tolerant reader for EVE notification bodies. + * + * The bodies are YAML, but a narrow dialect: top-level scalars plus block + * sequences, with anchors used to avoid repeating a structure id. Rather than + * take a YAML dependency for that, this reads the scalars and ignores + * everything else. + * + * Three behaviours are load-bearing: + * - block sequence items (`- showinfo`) are skipped, not parsed as keys + * - `structureID: &id001 102920` yields "102920", not "&id001 102920" + * - `b: *id001` resolves to whatever `&id001` was bound to + * + * Never throws. An unparseable body yields `{}`, and the caller records the + * event without a structure name rather than dropping the alert. + */ +export function parseNotificationBody(text: string): Record { + const out: Record = {}; + const anchors: Record = {}; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trimEnd(); + // Block sequence item, or a continuation of one. Not a key. + if (/^[ \t]*-/.test(line)) continue; + const m = SCALAR_LINE.exec(line); + if (!m) continue; + const [, key, rawValue] = m; + const value = rawValue.trim(); + // A key with an empty value opens a nested block (e.g. structureShowInfoData). + // Nothing this feature reads is nested, so drop it rather than record "". + if (value === "") continue; + const anchored = ANCHOR.exec(value); + if (anchored) { + const [, name, actual] = anchored; + anchors[name] = actual.trim(); + out[key] = actual.trim(); + continue; + } + const alias = ALIAS.exec(value); + if (alias) { + // Object.hasOwn, not `in` or a bare index: `anchors` is a plain object + // literal, so `*constructor` (or `*toString`, `*__proto__`) resolves + // through the prototype chain to a function rather than `undefined`, + // and that function would then be typed as a string all the way to + // jsonb. `in` walks the same chain and would not fix it. + const resolved = Object.hasOwn(anchors, alias[1]) ? anchors[alias[1]] : undefined; + if (resolved !== undefined) out[key] = resolved; + continue; + } + out[key] = value; + } + return out; +} + +/** The body keys worth persisting. Everything else is dropped on the floor. */ +const KEPT_KEYS = [ + "corpName", + "allianceName", + "charID", + "shieldPercentage", + "armorPercentage", + "hullPercentage", + "timeLeft", + "solarsystemID", + "structureTypeID", + "ownerCorpName", + "isAbandoned", +] as const; + +export type ParsedStructureEvent = { + structureId: number | null; + details: Record; +}; + +function asNumberIfNumeric(value: string): string | number { + if (value === "") return value; + const n = Number(value); + return Number.isFinite(n) ? n : value; +} + +/** + * The parsed subset this feature persists and renders. Anything not in + * KEPT_KEYS never reaches Postgres — the notifications endpoint returns every + * notification type for the character, including personal ones. + */ +export function extractStructureEvent(text: string): ParsedStructureEvent { + const body = parseNotificationBody(text); + const rawId = body.structureID; + const parsedId = rawId === undefined ? Number.NaN : Number(rawId); + const details: Record = {}; + for (const key of KEPT_KEYS) { + const value = body[key]; + if (value !== undefined) details[key] = asNumberIfNumeric(value); + } + return { + structureId: Number.isSafeInteger(parsedId) && parsedId > 0 ? parsedId : null, + details, + }; +} + +const VERB: Record = { + StructureUnderAttack: "is under attack", + StructureLostShields: "lost shields", + StructureLostArmor: "lost armor", + StructureDestroyed: "was destroyed", +}; + +export type StructureAlertInput = { + type: string; + structureName: string | null; + typeName: string | null; + systemName: string | null; + details: Record; +}; + +/** + * One plain-text line per alert. + * + * Clamped to 1900 characters here as well as in the webhook poster. The poster + * clamps to protect Discord; this clamps so the string a test asserts on is the + * string that gets sent, rather than one silently truncated a layer later. + */ +export function formatStructureAlert(input: StructureAlertInput): string { + const subject = input.structureName ?? input.typeName ?? "A structure"; + const verb = VERB[input.type] ?? input.type; + const where = input.systemName ? ` in ${input.systemName}` : ""; + const attacker = input.details.allianceName ?? input.details.corpName ?? null; + const by = attacker ? ` — ${attacker}` : ""; + return `${subject}${where} ${verb}${by}`.slice(0, 1900); +} + +/** + * Most alarming first. Hull reinforce is the last timer before the structure + * dies, so it outranks armor; both outrank a vulnerability window that nobody + * has shot yet. + */ +const STATE_RANK: Record = { + hull_reinforce: 0, + armor_reinforce: 1, + hull_vulnerable: 2, + armor_vulnerable: 3, + shield_vulnerable: 4, +}; + +export type RosterSortable = { state: string; name: string | null }; + +export function compareRosterRows(a: RosterSortable, b: RosterSortable): number { + const ra = STATE_RANK[a.state] ?? 90; + const rb = STATE_RANK[b.state] ?? 90; + if (ra !== rb) return ra - rb; + // Ties break by name so the table does not reshuffle between renders on + // rows the state cannot distinguish. + return (a.name ?? "").localeCompare(b.name ?? ""); +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 4970439..eb45ae2 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -8,6 +8,7 @@ import { numeric, pgEnum, pgTable, + primaryKey, serial, text, timestamp, @@ -46,6 +47,36 @@ export const accessListEntryKindEnum = pgEnum("access_list_entry_kind", [ "corporation", "alliance", ]); + +export const structureReadStatusEnum = pgEnum("structure_read_status", [ + "ok", + "forbidden", + "failed", +]); +export type StructureReadStatus = (typeof structureReadStatusEnum.enumValues)[number]; + +/** + * Four distinct states, not shades of one. + * + * `seeded` — recorded without alerting: this holder had never polled, or no + * webhook is configured so there is no recipient. + * `pending` — recorded and owed an alert. + * `sent` — posted successfully. + * `abandoned` — was pending when the holder was replaced, and will never be + * posted. + * + * `abandoned` is not a reuse of `seeded` because the two answer different + * questions: "deliberately not alerted" versus "owed an alert with no valid + * recipient". Collapsing them makes it impossible to tell from the table + * whether a holder swap swallowed a live attack. + */ +export const structureAlertStatusEnum = pgEnum("structure_alert_status", [ + "seeded", + "pending", + "sent", + "abandoned", +]); +export type StructureAlertStatus = (typeof structureAlertStatusEnum.enumValues)[number]; export type AccessListReadStatus = (typeof accessListReadStatusEnum.enumValues)[number]; /** @@ -545,3 +576,116 @@ export const payoutPayment = pgTable("payout_payment", { actor: uuid("actor").references(() => account.id, { onDelete: "set null" }), note: text("note"), }); + +/** + * The designated structure holder. Singleton, like `access_list_holder`. + * + * `corporationId` is PINNED at designation rather than read live off + * `character.corporationId`, which the membership job overwrites every thirty + * minutes (src/jobs/membership.ts:125). Following it live means a holder who + * changes corp silently re-rosters against the new corp and stamps + * `missingSince` on every previous structure — indistinguishable from a mass + * destruction event, arriving during the exact incident this tool exists for. + * + * `seededAt` null means this holder has never completed a poll: the events job + * records without alerting until it is stamped. `designateHolder` writes it + * null, so replacing the holder re-seeds. + */ +export const structureHolder = pgTable( + "structure_holder", + { + id: integer("id").primaryKey(), + characterId: bigint("character_id", { mode: "number" }) + .notNull() + .references(() => character.id, { onDelete: "cascade" }), + corporationId: bigint("corporation_id", { mode: "number" }).notNull(), + designatedAt: timestamp("designated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + designatedBy: text("designated_by").notNull(), // account uuid or "system" + seededAt: timestamp("seeded_at", { withTimezone: true }), + }, + (t) => [check("structure_holder_singleton_ck", sql`${t.id} = 1`)], +); + +/** + * Read health, one row per (kind, corporation). Two timestamps for the reason + * `access_list_snapshot` gives: `observedAt` is the last SUCCESSFUL read and is + * null until there is one; `lastAttemptAt` + `readStatus` + `detail` describe + * the most recent attempt either way. + * + * Keyed by corporation because the row describes a read against one specific + * corp. Without it, replacing the holder leaves the previous corp's freshness + * and 403 state in place and the page calls the new monitor healthy on the + * strength of a read against a corp it no longer watches. + */ +export const structureReadState = pgTable( + "structure_read_state", + { + kind: text("kind").notNull(), // 'roster' | 'events' + corporationId: bigint("corporation_id", { mode: "number" }).notNull(), + observedAt: timestamp("observed_at", { withTimezone: true }), + lastAttemptAt: timestamp("last_attempt_at", { withTimezone: true }).notNull(), + readStatus: structureReadStatusEnum("read_status").notNull(), + detail: text("detail"), + }, + (t) => [primaryKey({ columns: [t.kind, t.corporationId] })], +); + +/** + * The roster. `state` is stored verbatim as text, not a pgEnum: a state string + * CCP adds next patch must not be able to fail a read of a field nothing + * branches on for correctness. + * + * `typeName` is denormalized because there is no type-id name cache to use — + * `universe_name`'s kind enum has no `type` value and `resolveEntityNames` + * deliberately drops inventory types (src/services/entity-names.ts:76-80). + * + * A structure that stops appearing gets `missingSince` stamped, never deleted: + * never remove on unknown state. From the roster's side a destroyed Astrahus + * and a 403 are identical; only the event stream tells them apart. + */ +export const structure = pgTable("structure", { + structureId: bigint("structure_id", { mode: "number" }).primaryKey(), + corporationId: bigint("corporation_id", { mode: "number" }).notNull(), + typeId: bigint("type_id", { mode: "number" }).notNull(), + typeName: text("type_name"), + systemId: bigint("system_id", { mode: "number" }).notNull(), + name: text("name"), + state: text("state").notNull(), + stateTimerStart: timestamp("state_timer_start", { withTimezone: true }), + stateTimerEnd: timestamp("state_timer_end", { withTimezone: true }), + fuelExpires: timestamp("fuel_expires", { withTimezone: true }), + observedAt: timestamp("observed_at", { withTimezone: true }).notNull(), + missingSince: timestamp("missing_since", { withTimezone: true }), +}); + +/** + * One row per structure notification ever seen. ESI's own `notification_id` is + * the primary key, which is what makes "seen" idempotent across runs. + * + * `corporationId` is stamped at insert from the holder's PINNED corp, not + * parsed from the body. It is what the sender filters on, so a row recorded + * under a previous holder can never be posted under a new one. + * + * `details` holds ONLY the parsed subset actually rendered. The notifications + * endpoint returns every notification type for the character — war decs, mail, + * kill rights, corp applications — and this job persists none of them. + */ +export const structureEvent = pgTable( + "structure_event", + { + notificationId: bigint("notification_id", { mode: "number" }).primaryKey(), + type: text("type").notNull(), + sentAt: timestamp("sent_at", { withTimezone: true }).notNull(), + structureId: bigint("structure_id", { mode: "number" }), + corporationId: bigint("corporation_id", { mode: "number" }).notNull(), + alertStatus: structureAlertStatusEnum("alert_status").notNull(), + details: jsonb("details").$type>(), + }, + // Serves the sender's hot path: pending rows for the pinned corp, oldest + // first. Without it that is a full scan of a table that only grows. + (t) => [ + index("structure_event_pending_idx").on(t.corporationId, t.alertStatus, t.sentAt), + ], +); diff --git a/src/db/tables.ts b/src/db/tables.ts index bf22dd9..ea47de7 100644 --- a/src/db/tables.ts +++ b/src/db/tables.ts @@ -35,6 +35,10 @@ export const MANAGED_TABLES = [ "access_list_snapshot", "access_list_entry", "esi_entity_name", + "structure_holder", + "structure_read_state", + "structure", + "structure_event", ] as const; /** Bare table names, unquoted — for comparing against information_schema. */ diff --git a/src/jobs/structure-events.ts b/src/jobs/structure-events.ts new file mode 100644 index 0000000..ab421c3 --- /dev/null +++ b/src/jobs/structure-events.ts @@ -0,0 +1,289 @@ +import { and, asc, eq } from "drizzle-orm"; +import type { Config } from "@/config"; +import type { Db } from "@/db"; +import { character, structure, structureEvent } from "@/db/schema"; +import { + extractStructureEvent, + formatStructureAlert, + isStructureEventType, +} from "@/core/structure-event"; +import { EsiError, NOTIFICATIONS_SCOPE } from "@/lib/esi/client"; +import type { StructureEventsEsi } from "@/lib/esi/client"; +import { + OpsWebhookError, + postStructureWebhook, + resolveStructureWebhookUrl, +} from "@/lib/ops-webhook"; +import { + getStructureHolder, + markSeeded, + recordReadState, + stillStructureHolder, +} from "@/services/structures"; +import { runJob, type JobResult } from "@/services/sync-run"; +import { getFreshAccessToken } from "@/services/tokens"; + +type Counts = { + fetched: number; + recorded: number; + alerted: number; + failedPosts: number; + noHolder: number; + scopeMissing: number; + skipped: number; + seeded: number; + unconfigured: number; +}; + +/** + * Polls the holder's notifications for structure damage and posts each newly + * recorded one to Discord. + * + * The delivery contract is at-least-once. Rows are inserted `pending` and + * flipped to `sent` only after a post succeeds, so a crash between the two + * re-sends on the next tick; a duplicate Discord post is preferred to a lost + * one. A failed post returns "partial", not "failed" — the ten-minute tick is + * the retry, and pg-boss's retry budget is for a run that accomplished nothing. + */ +export async function runStructureEventsJob(deps: { + db: Db; + cfg: Config; + esi: StructureEventsEsi; + fetchImpl?: typeof fetch; +}): Promise { + const { db, cfg, esi } = deps; + return runJob(db, "structure-events", async () => { + const counts: Counts = { + fetched: 0, + recorded: 0, + alerted: 0, + failedPosts: 0, + noHolder: 0, + scopeMissing: 0, + skipped: 0, + seeded: 0, + unconfigured: 0, + }; + + const holder = await getStructureHolder(db); + if (!holder) { + counts.noHolder = 1; + return { status: "ok", counts }; + } + + const [row] = await db + .select({ + id: character.id, + refreshTokenEnc: character.refreshTokenEnc, + tokenStatus: character.tokenStatus, + scopes: character.scopes, + }) + .from(character) + .where(eq(character.id, holder.characterId)); + if (!row) { + counts.noHolder = 1; + return { status: "ok", counts }; + } + + if (!row.scopes.includes(NOTIFICATIONS_SCOPE)) { + counts.scopeMissing = 1; + return { status: "ok", counts }; + } + + // The token branch comes BEFORE any insert or post. In dry-run + // getFreshAccessToken returns `dry_run` without a network call, so this + // job never reaches the sender — which is what stops a dry-run worker from + // consuming real pending alerts against a production database. + const token = await getFreshAccessToken( + db, + cfg, + { + id: row.id, + refreshTokenEnc: row.refreshTokenEnc, + tokenStatus: row.tokenStatus, + }, + deps.fetchImpl, + ); + if (!token.ok) { + if (token.reason === "dry_run") { + counts.skipped = 1; + return { status: "ok", counts }; + } + if (token.reason === "transient") { + return { + status: "failed", + errorSummary: `token refresh failed: ${token.detail ?? "transient"}`, + counts, + retry: true, + }; + } + return { status: "failed", errorSummary: `holder token ${token.reason}`, counts }; + } + + const at = new Date(); + let notifications; + try { + notifications = await esi.getCharacterNotifications(row.id, token.accessToken); + } catch (err) { + // A 403 here is the Director/CEO role missing in game: corp structure + // notifications are not delivered to the character at all. + const forbidden = err instanceof EsiError && err.status === 403; + const transient = err instanceof EsiError ? err.kind === "transient" : true; + await recordReadState(db, { + kind: "events", + corporationId: holder.corporationId, + status: forbidden ? "forbidden" : "failed", + detail: forbidden ? "director-role" : "read failed", + observed: false, + at, + }); + if (forbidden) { + return { status: "partial", errorSummary: "notifications forbidden", counts }; + } + return { + status: "failed", + errorSummary: "notifications read failed", + counts, + retry: transient || undefined, + }; + } + counts.fetched = notifications.length; + + // Resolve the recipient BEFORE inserting. postStructureWebhook cannot tell + // "delivered" from "nowhere to deliver" once it has returned, so a row + // inserted `pending` on a deployment with no webhook would be marked + // `sent` by a post that never happened. + const hasWebhook = resolveStructureWebhookUrl(cfg) !== undefined; + if (!hasWebhook) counts.unconfigured = 1; + const seeding = holder.seededAt === null; + + // Only the four damage types are persisted. The endpoint returns every + // notification the character has — mail, war decs, kill rights, corp + // applications — and none of those reach Postgres. + const damage = notifications.filter((n) => isStructureEventType(n.type)); + + await db.transaction(async (tx) => { + if (!(await stillStructureHolder(tx, holder.characterId, holder.designatedAt))) + return; + for (const n of damage) { + const parsed = extractStructureEvent(n.text); + const inserted = await tx + .insert(structureEvent) + .values({ + notificationId: n.notificationId, + type: n.type, + sentAt: n.timestamp, + structureId: parsed.structureId, + corporationId: holder.corporationId, + alertStatus: seeding || !hasWebhook ? "seeded" : "pending", + details: parsed.details, + }) + .onConflictDoNothing() + .returning({ id: structureEvent.notificationId }); + if (inserted.length > 0) counts.recorded += 1; + } + if (seeding || !hasWebhook) counts.seeded = counts.recorded; + if (seeding) { + await markSeeded(tx, at); + } + await recordReadState(tx, { + kind: "events", + corporationId: holder.corporationId, + status: "ok", + detail: null, + observed: true, + at, + }); + }); + + if (seeding || !hasWebhook) return { status: "ok", counts }; + + // The insert transaction's CAS only covers what happens inside it. This + // phase runs AFTER that transaction commits, so a designation change in + // the gap — or a second, overlapping run of this same job (the cron tick + // racing a "Check now") — is invisible to it. Re-check here rather than + // trust the holder snapshot read at the top of the run. + if (!(await stillStructureHolder(db, holder.characterId, holder.designatedAt))) { + return { status: "ok", counts }; + } + + // Every pending row for the PINNED corp, oldest first. This picks up + // leftovers from a previous run's failed posts, and excludes anything + // recorded under a previous holder (those were retired to `abandoned`). + const pending = await db + .select({ + notificationId: structureEvent.notificationId, + type: structureEvent.type, + structureId: structureEvent.structureId, + details: structureEvent.details, + }) + .from(structureEvent) + .where( + and( + eq(structureEvent.corporationId, holder.corporationId), + eq(structureEvent.alertStatus, "pending"), + ), + ) + .orderBy(asc(structureEvent.sentAt)); + + let firstPostError: string | undefined; + for (const event of pending) { + const [known] = event.structureId + ? await db + .select({ name: structure.name, typeName: structure.typeName }) + .from(structure) + .where(eq(structure.structureId, event.structureId)) + : []; + const content = formatStructureAlert({ + type: event.type, + structureName: known?.name ?? null, + typeName: known?.typeName ?? null, + systemName: null, + details: event.details ?? {}, + }); + try { + await postStructureWebhook(cfg, content, deps.fetchImpl); + // Conditional, not unconditional: a concurrent run (the cron tick + // racing a "Check now") can select the same pending row and post it + // too. Only the run whose UPDATE actually flips `pending` -> `sent` + // counts it, so an overlap does not double-count `counts.alerted`. + const flipped = await db + .update(structureEvent) + .set({ alertStatus: "sent" }) + .where( + and( + eq(structureEvent.notificationId, event.notificationId), + eq(structureEvent.alertStatus, "pending"), + ), + ) + .returning({ id: structureEvent.notificationId }); + if (flipped.length > 0) counts.alerted += 1; + } catch (err) { + // Leave the row pending. The ten-minute tick is the retry; burning + // pg-boss's retry budget on a Discord blip would dead-letter a job + // that read ESI successfully. + counts.failedPosts += 1; + // Only the FIRST failure's message survives into errorSummary — that + // is enough to say why, and it keeps the summary from growing with + // every subsequent row's post attempt. OpsWebhookError's own message + // is safe to surface: postOpsWebhookUrl never interpolates the url + // into it. Anything else is a throw this code did not construct, so + // its text is not trusted — same posture as the worker's boot-failure + // handler (src/worker/index.ts). + if (firstPostError === undefined) { + firstPostError = + err instanceof OpsWebhookError ? err.message : "structure alert post failed"; + } + } + } + + if (counts.failedPosts > 0) { + return { + status: "partial", + errorSummary: `some alerts failed to post: ${firstPostError}`, + counts, + }; + } + return { status: "ok", counts }; + }); +} diff --git a/src/jobs/structures.ts b/src/jobs/structures.ts new file mode 100644 index 0000000..5657fba --- /dev/null +++ b/src/jobs/structures.ts @@ -0,0 +1,241 @@ +import { and, eq, inArray, isNull, not } from "drizzle-orm"; +import type { Config } from "@/config"; +import type { Db } from "@/db"; +import { character, structure } from "@/db/schema"; +import { EsiError } from "@/lib/esi/client"; +import type { StructuresEsi } from "@/lib/esi/client"; +import { STRUCTURES_SCOPE } from "@/lib/esi/client"; +import { + getStructureHolder, + recordReadState, + stillStructureHolder, +} from "@/services/structures"; +import { runJob, type JobResult } from "@/services/sync-run"; +import { getFreshAccessToken } from "@/services/tokens"; + +type Counts = { + structures: number; + missing: number; + noHolder: number; + scopeMissing: number; + corpChanged: number; + skipped: number; + forbidden: number; +}; + +/** + * Refreshes the roster of structures the pinned corporation owns. + * + * Staged exactly like the access-lists job: no holder is a normal `ok`, the + * scope is checked against the PERSISTED grant before any network call, and + * every write CASes on the holder still being the holder. + */ +export async function runStructuresJob(deps: { + db: Db; + cfg: Config; + esi: StructuresEsi; + fetchImpl?: typeof fetch; +}): Promise { + const { db, cfg, esi } = deps; + return runJob(db, "structures", async () => { + const counts: Counts = { + structures: 0, + missing: 0, + noHolder: 0, + scopeMissing: 0, + corpChanged: 0, + skipped: 0, + forbidden: 0, + }; + + // 1. No holder. An unconfigured optional feature must not paint + // /admin/sync red — the monitor page explains the missing designation. + const holder = await getStructureHolder(db); + if (!holder) { + counts.noHolder = 1; + return { status: "ok", counts }; + } + + const [row] = await db + .select({ + id: character.id, + corporationId: character.corporationId, + refreshTokenEnc: character.refreshTokenEnc, + tokenStatus: character.tokenStatus, + scopes: character.scopes, + }) + .from(character) + .where(eq(character.id, holder.characterId)); + if (!row) { + // The holder FK cascades, so a missing character row means the + // designation was deleted concurrently. Same state as no holder. + counts.noHolder = 1; + return { status: "ok", counts }; + } + + // 2. Scope, from the PERSISTED grant and before any ESI call: calling + // anyway would spend a refresh-token rotation to earn a certain 403. + if (!row.scopes.includes(STRUCTURES_SCOPE)) { + counts.scopeMissing = 1; + return { status: "ok", counts }; + } + + // 3. The corporation is PINNED. If the holder has moved, reading their new + // corp's structures under this designation would stamp missingSince on + // every structure of the old one — a fabricated mass-destruction event. + // Refuse, and let the page ask for a re-designation. + if (row.corporationId !== holder.corporationId) { + counts.corpChanged = 1; + await recordReadState(db, { + kind: "roster", + corporationId: holder.corporationId, + status: "failed", + detail: "corp-changed", + observed: false, + at: new Date(), + }); + return { status: "partial", errorSummary: "holder left the pinned corp", counts }; + } + + // 4. Token. getFreshAccessToken has FOUR outcomes and performs its own + // invalidation CAS internally, so this job must not repeat it. + const token = await getFreshAccessToken( + db, + cfg, + { + id: row.id, + refreshTokenEnc: row.refreshTokenEnc, + tokenStatus: row.tokenStatus, + }, + deps.fetchImpl, + ); + if (!token.ok) { + if (token.reason === "dry_run") { + counts.skipped = 1; + return { status: "ok", counts }; + } + if (token.reason === "transient") { + return { + status: "failed", + errorSummary: `token refresh failed: ${token.detail ?? "transient"}`, + counts, + retry: true, + }; + } + return { status: "failed", errorSummary: `holder token ${token.reason}`, counts }; + } + + const at = new Date(); + let rows; + try { + rows = await esi.getCorporationStructures(holder.corporationId, token.accessToken); + } catch (err) { + // A 403 here is the Station_Manager role missing in game — a normal + // state this app cannot fix, not a token fault. It classifies + // `permanent` because the ESI body names a role, not a scope or token. + const forbidden = err instanceof EsiError && err.status === 403; + const transient = err instanceof EsiError ? err.kind === "transient" : true; + counts.forbidden = forbidden ? 1 : 0; + await recordReadState(db, { + kind: "roster", + corporationId: holder.corporationId, + status: forbidden ? "forbidden" : "failed", + detail: forbidden ? "station-manager-role" : "read failed", + observed: false, + at, + }); + if (forbidden) { + // Never retry a permission the app cannot obtain; the hourly tick is + // enough to notice the role being granted. + return { status: "partial", errorSummary: "roster read forbidden", counts }; + } + return { + status: "failed", + errorSummary: "roster read failed", + counts, + retry: transient || undefined, + }; + } + + // Resolve type names once per run. Best-effort: a name failure must not + // fail the roster, since nothing branches on it. + const typeIds = [...new Set(rows.map((r) => r.typeId))]; + let typeNames = new Map(); + try { + const named = await esi.getUniverseNames(typeIds); + typeNames = new Map(named.map((n) => [n.id, n.name])); + } catch { + // leave typeNames empty; rows keep whatever name they already had + } + + await db.transaction(async (tx) => { + if (!(await stillStructureHolder(tx, holder.characterId, holder.designatedAt))) + return; + const seen = rows.map((r) => r.structureId); + for (const r of rows) { + const values = { + structureId: r.structureId, + corporationId: holder.corporationId, + typeId: r.typeId, + typeName: typeNames.get(r.typeId) ?? null, + systemId: r.systemId, + name: r.name, + state: r.state, + stateTimerStart: r.stateTimerStart, + stateTimerEnd: r.stateTimerEnd, + fuelExpires: r.fuelExpires, + observedAt: at, + missingSince: null, + }; + await tx + .insert(structure) + .values(values) + .onConflictDoUpdate({ + target: structure.structureId, + // typeName only overwrites when this run resolved one, so a failed + // name lookup does not blank a name that was already good. + set: { + ...values, + typeName: typeNames.get(r.typeId) ?? undefined, + }, + }); + } + counts.structures = rows.length; + + // Absent from the response: stamp, never delete. Only rows that are not + // already stamped, so missingSince records when it FIRST went missing. + // + // A clean empty `rows` here is affirmative, not a coerced default: + // `fetchAllPages` throws on a missing or non-integer `x-pages`, so + // reaching this point with zero rows means ESI reported the corp owns + // no structures. That is unlike the access-lists case, where a nullable + // field is coalesced to `[]` in the client and "empty" and "absent" are + // genuinely indistinguishable. The branch is also self-healing: the + // upsert above writes `missingSince: null` on conflict, so a structure + // that reappears next run is cleared here rather than left stamped. + const missing = await tx + .update(structure) + .set({ missingSince: at }) + .where( + and( + eq(structure.corporationId, holder.corporationId), + isNull(structure.missingSince), + seen.length > 0 ? not(inArray(structure.structureId, seen)) : undefined, + ), + ) + .returning({ id: structure.structureId }); + counts.missing = missing.length; + + await recordReadState(tx, { + kind: "roster", + corporationId: holder.corporationId, + status: "ok", + detail: null, + observed: true, + at, + }); + }); + + return { status: "ok", counts }; + }); +} diff --git a/src/lib/esi/client.ts b/src/lib/esi/client.ts index 8417106..0e8e229 100644 --- a/src/lib/esi/client.ts +++ b/src/lib/esi/client.ts @@ -35,6 +35,22 @@ export const OPEN_WINDOW_SCOPE = "esi-ui.open_window.v1"; */ export const ACCESS_LISTS_SCOPE = "esi-access.read_lists.v1"; +/** + * Deliberately NOT in EVE_SSO_SCOPES, for the reason ACCESS_LISTS_SCOPE gives: + * adding either there would flip every character to needs_reauth at the next + * token-health run, for a feature only one character needs. + * + * Note the corporations scope is NOT `esi-universe.read_structures.v1`, which + * IS already in EVE_SSO_SCOPES and resolves a single structure's NAME for the + * location job. The two differ by one word and grant different things. + * + * Both also require an in-game corp role that no scope can grant: + * Station_Manager for the roster, and Director or CEO for corp structure + * notifications to be delivered to the character at all. + */ +export const STRUCTURES_SCOPE = "esi-corporations.read_structures.v1"; +export const NOTIFICATIONS_SCOPE = "esi-characters.read_notifications.v1"; + export class EsiError extends Error { status: number; kind: EsiErrorClass; @@ -118,6 +134,30 @@ const accessListSchema = z.object({ }) .nullish(), }); +const corporationStructuresSchema = z.array( + z.object({ + structure_id: z.number(), + type_id: z.number().int(), + system_id: z.number().int(), + name: z.string().optional(), + state: z.string(), + state_timer_start: z.string().optional(), + state_timer_end: z.string().optional(), + fuel_expires: z.string().optional(), + }), +); + +const notificationsSchema = z.array( + z.object({ + notification_id: z.number(), + type: z.string(), + timestamp: z.string(), + // Absent on some notification types. Never fail a read over a missing body: + // the event still happened and still deserves an alert. + text: z.string().optional(), + }), +); + const universeNamesSchema = z.array( z.object({ id: z.number().int(), @@ -155,6 +195,24 @@ export type EsiAccessList = { }; export type EsiEntityName = { id: number; name: string; category: string }; +export type EsiCorporationStructure = { + structureId: number; + typeId: number; + systemId: number; + name: string | null; + state: string; + stateTimerStart: Date | null; + stateTimerEnd: Date | null; + fuelExpires: Date | null; +}; + +export type EsiNotification = { + notificationId: number; + type: string; + timestamp: Date; + text: string; +}; + export interface EsiClientOptions { fetchImpl?: typeof fetch; now?: () => number; @@ -316,46 +374,56 @@ export function createEsiClient(opts: EsiClientOptions = {}) { ).map((l) => ({ labelId: l.label_id, labelName: l.label_name })); } - /** Reads ALL pages; any page failure rejects the whole call. */ - async function getAllContacts( - characterId: number, + /** + * Reads every page of a paginated ESI collection. + * + * Fails closed on a missing or non-integer `x-pages`: an unknown page count + * means an unknown result set, and both callers feed a diff that REMOVES + * (contacts deletes; the structure roster stamps missingSince). Never guess + * — spec: never remove on unknown state. + * + * Extracted from getAllContacts, whose behaviour it preserves exactly. + */ + async function fetchAllPages( + pathFor: (page: number) => string, + schema: z.ZodType, accessToken: string, - ): Promise { - const first = await request(`/characters/${characterId}/contacts/?page=1`, { - accessToken, - }); - // Fail closed: an unknown page count means an unknown contact set, and the - // downstream diff deletes. Never guess (spec: never remove on unknown state). + opts: { base?: string; compatibilityDate?: boolean } = {}, + ): Promise { + const first = await request(pathFor(1), { accessToken, ...opts }); const pagesHeader = first.headers.get("x-pages"); const pages = Number(pagesHeader); if (pagesHeader === null || !Number.isInteger(pages) || pages < 1) { throw new EsiError( - `ESI GET contacts: missing or invalid X-Pages header (${pagesHeader})`, + `ESI GET ${pathFor(1)}: missing or invalid X-Pages header (${pagesHeader})`, 0, "transient", ); } - const raw = safeParse( - contactsSchema, + const out = safeParse( + schema, await first.json(), "GET", - `/characters/${characterId}/contacts/?page=1`, + pathFor(1), first.status, ).slice(); for (let page = 2; page <= pages; page++) { - const res = await request(`/characters/${characterId}/contacts/?page=${page}`, { - accessToken, - }); - raw.push( - ...safeParse( - contactsSchema, - await res.json(), - "GET", - `/characters/${characterId}/contacts/?page=${page}`, - res.status, - ), - ); + const res = await request(pathFor(page), { accessToken, ...opts }); + out.push(...safeParse(schema, await res.json(), "GET", pathFor(page), res.status)); } + return out; + } + + /** Reads ALL pages; any page failure rejects the whole call. */ + async function getAllContacts( + characterId: number, + accessToken: string, + ): Promise { + const raw = await fetchAllPages( + (page) => `/characters/${characterId}/contacts/?page=${page}`, + contactsSchema, + accessToken, + ); return raw.map((c) => ({ contactId: c.contact_id, contactType: c.contact_type, @@ -543,6 +611,72 @@ export function createEsiClient(opts: EsiClientOptions = {}) { }; } + function optionalDate(value: string | undefined): Date | null { + if (!value) return null; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? null : d; + } + + /** + * Every structure the corporation owns. Paginated, and read through + * fetchAllPages so a missing X-Pages fails closed rather than truncating — + * the roster's missingSince stamping is a diff that removes. + * + * A 403 here means the character lacks the Station_Manager corp role. It + * classifies `permanent` (core/errors.ts: 403 is needs_reauth only when the + * body names a scope/token/authorization problem, and the role error does + * not), which is what lets the caller tell it apart from a token fault. + * Nothing is swallowed here; the caller classifies. + */ + async function getCorporationStructures( + corporationId: number, + accessToken: string, + ): Promise { + const raw = await fetchAllPages( + (page) => `/corporations/${corporationId}/structures/?page=${page}`, + corporationStructuresSchema, + accessToken, + { base: ESI_ROOT, compatibilityDate: true }, + ); + return raw.map((s) => ({ + structureId: s.structure_id, + typeId: s.type_id, + systemId: s.system_id, + name: s.name ?? null, + state: s.state, + stateTimerStart: optionalDate(s.state_timer_start), + stateTimerEnd: optionalDate(s.state_timer_end), + fuelExpires: optionalDate(s.fuel_expires), + })); + } + + /** + * The character's notifications — ALL types, not only structure ones. The + * caller filters; this client does not, because filtering here would hide + * from the test suite what the endpoint actually returns. + * + * Not paginated: ESI returns a single page of the most recent ~50 from the + * last 90 days. + */ + async function getCharacterNotifications( + characterId: number, + accessToken: string, + ): Promise { + const path = `/characters/${characterId}/notifications/`; + const res = await request(path, { + accessToken, + base: ESI_ROOT, + compatibilityDate: true, + }); + const raw = safeParse(notificationsSchema, await res.json(), "GET", path, res.status); + return raw.map((n) => ({ + notificationId: n.notification_id, + type: n.type, + timestamp: new Date(n.timestamp), + text: n.text ?? "", + })); + } + /** * Unauthenticated batch id→name resolve, chunked like resolveIds. ESI rejects * the whole chunk if any id in it is unknown, so an unresolvable id costs the @@ -584,6 +718,8 @@ export function createEsiClient(opts: EsiClientOptions = {}) { getAccessLists, getAccessList, getUniverseNames, + getCorporationStructures, + getCharacterNotifications, addContacts: ( characterId: number, accessToken: string, @@ -628,3 +764,11 @@ export type AccessListsEsi = Pick< EsiClient, "getAccessLists" | "getAccessList" | "getUniverseNames" >; + +/** The roster job's narrow view: reads only, no writes reachable. */ +export type StructuresEsi = Pick< + EsiClient, + "getCorporationStructures" | "getUniverseNames" +>; +/** The events job's narrow view. */ +export type StructureEventsEsi = Pick; diff --git a/src/lib/ops-webhook.ts b/src/lib/ops-webhook.ts index e462773..82f7e2d 100644 --- a/src/lib/ops-webhook.ts +++ b/src/lib/ops-webhook.ts @@ -39,13 +39,16 @@ export async function postOpsWebhookUrl( * Posts to the optional Discord ops webhook and THROWS OpsWebhookError on * failure. Used by the dead-letter handler, where a lost alert must retry. * No-op when no webhook is configured. + * + * `url` defaults to the ops webhook so every existing caller is unaffected; + * postStructureWebhook passes its own resolved url through instead. */ export async function postOpsWebhookOrThrow( cfg: Config, content: string, fetchImpl: typeof fetch = fetch, + url: string | undefined = cfg.discord.opsWebhookUrl, ): Promise { - const url = cfg.discord.opsWebhookUrl; if (!url) return; // Dry-run suppression. Returns SUCCESSFULLY rather than throwing: // the dead-letter handler treats a throw as "retry the alert", so throwing @@ -70,3 +73,35 @@ export async function postOpsWebhook( console.error(err instanceof Error ? err.message : err); } } + +/** + * Where a structure alert goes: the dedicated webhook, else the ops one. + * + * Exposed rather than resolved inside the poster because both the job and the + * page need to know the answer BEFORE anything is posted. A post's return + * value cannot distinguish "delivered" from "nowhere to deliver" — + * postOpsWebhookOrThrow returns successfully when no url is set — so a job that + * inferred delivery from it would mark every owed alert `sent` on a deployment + * with no webhook configured at all. + */ +export function resolveStructureWebhookUrl(cfg: Config): string | undefined { + return cfg.discord.structureWebhookUrl ?? cfg.discord.opsWebhookUrl; +} + +/** + * Posts a structure alert, THROWING when there is no webhook configured. + * + * The throw is the point: unlike the ops alerts, a dropped structure alert is + * the failure this whole feature exists to prevent. Callers must have checked + * resolveStructureWebhookUrl first and recorded the event as `seeded` if it + * returned undefined; reaching here with no url is a bug, not a configuration. + */ +export async function postStructureWebhook( + cfg: Config, + content: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const url = resolveStructureWebhookUrl(cfg); + if (!url) throw new OpsWebhookError("structure webhook not configured"); + await postOpsWebhookOrThrow(cfg, content, fetchImpl, url); +} diff --git a/src/services/audit.ts b/src/services/audit.ts index 72b41ad..01723e7 100644 --- a/src/services/audit.ts +++ b/src/services/audit.ts @@ -179,6 +179,7 @@ const NAMESPACE_TARGET_KIND = { "discord.": "discord", "payout.": "payout", "status.": "account", + "structure.": "character", "sync.": "account", "tier.": "account", "token.": "character", @@ -258,6 +259,8 @@ const DETAIL_CHARACTER_KEYS: Readonly> = { "admin.bootstrap_granted": ["characterId"], "access_list.holder_designated": ["characterId"], "access_list.holder_replaced": ["characterId", "previousCharacterId"], + "structure.holder_designated": ["characterId"], + "structure.holder_replaced": ["characterId", "previousCharacterId"], "token.subject_mismatch": ["subjectCharacterId"], }; diff --git a/src/services/structures.ts b/src/services/structures.ts new file mode 100644 index 0000000..94a23aa --- /dev/null +++ b/src/services/structures.ts @@ -0,0 +1,373 @@ +import { desc, eq } from "drizzle-orm"; +import type { Db, Dbx } from "@/db"; +import { + account, + character, + structure, + structureEvent, + structureHolder, + structureReadState, + type StructureReadStatus, +} from "@/db/schema"; +import { logAudit } from "@/services/audit"; +import { NOTIFICATIONS_SCOPE, STRUCTURES_SCOPE } from "@/lib/esi/client"; + +/** + * The holder table is a singleton enforced by `CHECK (id = 1)`. One constant so + * every read and write spells the key the same way; a literal `1` scattered + * across call sites is how a second row eventually appears. + */ +export const STRUCTURE_HOLDER_ROW_ID = 1; + +export type StructureHolder = { + characterId: number; + corporationId: number; + designatedAt: Date; + designatedBy: string; + seededAt: Date | null; +}; + +export async function getStructureHolder(dbx: Dbx): Promise { + const [row] = await dbx + .select({ + characterId: structureHolder.characterId, + corporationId: structureHolder.corporationId, + designatedAt: structureHolder.designatedAt, + designatedBy: structureHolder.designatedBy, + seededAt: structureHolder.seededAt, + }) + .from(structureHolder) + .where(eq(structureHolder.id, STRUCTURE_HOLDER_ROW_ID)); + return row ?? null; +} + +/** + * Points the monitor at a character and PINS the corporation, in one + * transaction so the audit row, the designation and the retired alerts cannot + * disagree. + * + * Three things happen together and must not be separable: + * 1. the designation is written, with `seededAt` reset to null so the new + * holder re-seeds rather than replaying a 90-day backlog; + * 2. the retirement sweep runs ONLY when the corporation actually changes. + * Replacing the holder WITHIN the same corp leaves every `pending` row + * alone — those alerts are still for the corp being watched and are + * still deliverable, so retiring them would silently drop a live attack. + * Replacing it with a holder in a DIFFERENT corp retires every `pending` + * row to `abandoned`, unfiltered by corporation — those alerts are no + * longer valid for anyone, and narrowing the WHERE to the old corp would + * leave a third corp's stale `pending` rows sitting live, ready to fire + * the moment that corp is re-designated; + * 3. the audit row records how many were retired, which is the only number + * that says whether a holder swap swallowed a live attack. + */ +export async function designateStructureHolder( + db: Db, + characterId: number, + corporationId: number, + actor: string, +): Promise<{ abandonedAlerts: number }> { + return db.transaction(async (tx) => { + const previous = await getStructureHolder(tx); + const designatedAt = new Date(); + await tx + .insert(structureHolder) + .values({ + id: STRUCTURE_HOLDER_ROW_ID, + characterId, + corporationId, + designatedAt, + designatedBy: actor, + seededAt: null, + }) + .onConflictDoUpdate({ + target: structureHolder.id, + set: { + characterId, + corporationId, + designatedAt, + designatedBy: actor, + seededAt: null, + }, + }); + + const retired = + previous && previous.corporationId !== corporationId + ? await tx + .update(structureEvent) + .set({ alertStatus: "abandoned" }) + .where(eq(structureEvent.alertStatus, "pending")) + .returning({ id: structureEvent.notificationId }) + : []; + + await logAudit(tx, { + actor, + action: previous ? "structure.holder_replaced" : "structure.holder_designated", + target: String(characterId), + details: previous + ? { + previousCharacterId: previous.characterId, + characterId, + corporationId, + abandonedAlerts: retired.length, + } + : { characterId, corporationId }, + }); + return { abandonedAlerts: retired.length }; + }); +} + +/** + * Whether the holder snapshot a job read minutes ago is STILL the live + * designation, read inside the caller's transaction. Every write CASes on + * this before touching data scoped to a holder. + * + * The character id alone cannot distinguish "still the same designation" + * from "re-designated to a different corp": `designateStructureHolder` lets + * an admin re-pin the SAME character to a NEW corporation — that is exactly + * what the page's `corp-changed` remedy instructs — and that write leaves + * `characterId` unchanged while stamping a fresh `designatedAt` and a + * different `corporationId`. A character-only CAS would pass unchanged + * across that re-designation and let a job insert or select data under the + * corp it snapshotted, silently orphaning it under a stale pinned corp. + * Comparing `designatedAt` too — rewritten on EVERY designation, including a + * same-character one — catches that case with no schema change. + * + * Accepted limit: two designations landing in the same millisecond would be + * indistinguishable. No human-driven admin action produces that. + */ +export async function stillStructureHolder( + tx: Dbx, + characterId: number, + designatedAt: Date, +): Promise { + const holder = await getStructureHolder(tx); + return ( + holder?.characterId === characterId && + holder.designatedAt.getTime() === designatedAt.getTime() + ); +} + +/** Stamps the first completed poll, which is what switches seeding off. */ +export async function markSeeded(dbx: Dbx, at: Date): Promise { + await dbx + .update(structureHolder) + .set({ seededAt: at }) + .where(eq(structureHolder.id, STRUCTURE_HOLDER_ROW_ID)); +} + +/** + * Records one read attempt. `observedAt` advances ONLY on success, so the page + * can say how stale a roster is without either lying about freshness or + * discarding the failure that made it stale. + */ +export async function recordReadState( + dbx: Dbx, + input: { + kind: "roster" | "events"; + corporationId: number; + status: StructureReadStatus; + detail?: string | null; + observed: boolean; + at: Date; + }, +): Promise { + const set: Record = { + lastAttemptAt: input.at, + readStatus: input.status, + detail: input.detail ?? null, + }; + if (input.observed) set.observedAt = input.at; + await dbx + .insert(structureReadState) + .values({ + kind: input.kind, + corporationId: input.corporationId, + observedAt: input.observed ? input.at : null, + lastAttemptAt: input.at, + readStatus: input.status, + detail: input.detail ?? null, + }) + .onConflictDoUpdate({ + target: [structureReadState.kind, structureReadState.corporationId], + set, + }); +} + +export type ReadStateRow = { + observedAt: Date | null; + lastAttemptAt: Date; + readStatus: StructureReadStatus; + detail: string | null; +}; + +export async function getReadStates( + dbx: Dbx, + corporationId: number, +): Promise> { + const rows = await dbx + .select() + .from(structureReadState) + .where(eq(structureReadState.corporationId, corporationId)); + const out: Record = {}; + for (const r of rows) { + out[r.kind] = { + observedAt: r.observedAt, + lastAttemptAt: r.lastAttemptAt, + readStatus: r.readStatus, + detail: r.detail, + }; + } + return out; +} + +export type RosterRow = { + structureId: number; + name: string | null; + typeName: string | null; + systemId: number; + state: string; + stateTimerEnd: Date | null; + fuelExpires: Date | null; + observedAt: Date; + missingSince: Date | null; +}; + +export async function getRoster(dbx: Dbx, corporationId: number): Promise { + return dbx + .select({ + structureId: structure.structureId, + name: structure.name, + typeName: structure.typeName, + systemId: structure.systemId, + state: structure.state, + stateTimerEnd: structure.stateTimerEnd, + fuelExpires: structure.fuelExpires, + observedAt: structure.observedAt, + missingSince: structure.missingSince, + }) + .from(structure) + .where(eq(structure.corporationId, corporationId)); +} + +export type EventRow = { + notificationId: number; + type: string; + sentAt: Date; + structureId: number | null; + details: Record | null; +}; + +export async function getRecentEvents( + dbx: Dbx, + corporationId: number, + limit: number, +): Promise { + return dbx + .select({ + notificationId: structureEvent.notificationId, + type: structureEvent.type, + sentAt: structureEvent.sentAt, + structureId: structureEvent.structureId, + details: structureEvent.details, + }) + .from(structureEvent) + .where(eq(structureEvent.corporationId, corporationId)) + .orderBy(desc(structureEvent.sentAt)) + .limit(limit); +} + +/** + * The character's CURRENT corporation, read fresh from Postgres. The + * designate action must not trust a corp id sent in from the client — a + * hidden form field is attacker-controlled, so the corp that gets pinned has + * to come from a server-side read of what the database actually says. + */ +export async function getCharacterCorporationId( + dbx: Dbx, + characterId: number, +): Promise { + const [row] = await dbx + .select({ corporationId: character.corporationId }) + .from(character) + .where(eq(character.id, characterId)); + return row?.corporationId ?? null; +} + +/** + * The first admin-owned character whose PERSISTED `scopes` carry both + * structure scopes. Reads `character.scopes`, never `cfg.eveSso.scopes`: + * config says what we ask for, the column says what was granted. + * + * Deterministic by character id, not insertion order, so a re-run of the + * page picks the same candidate rather than one that happens to sort + * differently row to row. + */ +export async function findGrantableCharacter( + dbx: Dbx, +): Promise<{ characterId: number; name: string; corporationId: number | null } | null> { + const rows = await dbx + .select({ + characterId: character.id, + name: character.name, + corporationId: character.corporationId, + scopes: character.scopes, + }) + .from(character) + .innerJoin(account, eq(account.id, character.accountId)) + .where(eq(account.isAdmin, true)) + .orderBy(character.id); + const found = rows.find( + (r) => r.scopes.includes(STRUCTURES_SCOPE) && r.scopes.includes(NOTIFICATIONS_SCOPE), + ); + return found + ? { + characterId: found.characterId, + name: found.name, + corporationId: found.corporationId, + } + : null; +} + +export type HolderView = { + characterId: number; + name: string; + scopes: string[]; + tokenStatus: "valid" | "invalid" | "needs_reauth" | "missing"; + /** The corp PINNED at designation. */ + corporationId: number; + /** What character.corporationId says right now — null when never resolved. */ + currentCorporationId: number | null; +}; + +/** + * Joins the designated holder to its character row for the four fields the + * page needs beyond the raw designation: name, granted scopes, token health, + * and where that character sits right now (which can drift from the corp + * pinned at designation — that drift is exactly what the page warns about). + */ +export async function toHolderView( + dbx: Dbx, + holder: StructureHolder, +): Promise { + const [row] = await dbx + .select({ + name: character.name, + scopes: character.scopes, + tokenStatus: character.tokenStatus, + currentCorporationId: character.corporationId, + }) + .from(character) + .where(eq(character.id, holder.characterId)); + if (!row) { + throw new Error(`structure holder character ${holder.characterId} not found`); + } + return { + characterId: holder.characterId, + name: row.name, + scopes: row.scopes, + tokenStatus: row.tokenStatus, + corporationId: holder.corporationId, + currentCorporationId: row.currentCorporationId, + }; +} diff --git a/src/services/sync-status.ts b/src/services/sync-status.ts index 7e13ba7..9d6d0e8 100644 --- a/src/services/sync-status.ts +++ b/src/services/sync-status.ts @@ -15,6 +15,8 @@ const KNOWN_ORDER = [ "purge", "location", "access-lists", + "structures", + "structure-events", ]; export type SyncStatusGroup = { diff --git a/src/worker/handlers.ts b/src/worker/handlers.ts index 0c7b0d5..2086a6e 100644 --- a/src/worker/handlers.ts +++ b/src/worker/handlers.ts @@ -7,10 +7,17 @@ import { runDiscordRolesJob } from "@/jobs/discord-roles"; import { runLocationJob, type LocationEsi } from "@/jobs/location"; import { runMembershipJob } from "@/jobs/membership"; import { runPurgeJob } from "@/jobs/purge"; +import { runStructureEventsJob } from "@/jobs/structure-events"; +import { runStructuresJob } from "@/jobs/structures"; import { runTokenHealthJob } from "@/jobs/token-health"; import { runWandererJob } from "@/jobs/wanderer"; import type { DiscordClient } from "@/lib/discord/rest"; -import type { AccessListsEsi, EsiClient } from "@/lib/esi/client"; +import type { + AccessListsEsi, + EsiClient, + StructureEventsEsi, + StructuresEsi, +} from "@/lib/esi/client"; import type { WandererClient } from "@/lib/wanderer/client"; import { QUEUES } from "@/worker/queues"; @@ -39,11 +46,20 @@ const tokenHealthSchema = z.object({ jobType: z.literal(QUEUES.tokenHealth) }).s const purgeSchema = z.object({ jobType: z.literal(QUEUES.purge) }).strict(); const locationSchema = z.object({ jobType: z.literal(QUEUES.location) }).strict(); const accessListsSchema = z.object({ jobType: z.literal(QUEUES.accessLists) }).strict(); +const structuresSchema = z.object({ jobType: z.literal(QUEUES.structures) }).strict(); +const structureEventsSchema = z + .object({ jobType: z.literal(QUEUES.structureEvents) }) + .strict(); export type JobDeps = { db: Db; cfg: Config; - esi: Pick & ContactsEsi & LocationEsi & AccessListsEsi; + esi: Pick & + ContactsEsi & + LocationEsi & + AccessListsEsi & + StructuresEsi & + StructureEventsEsi; wanderer: WandererClient; discord: DiscordClient; fetchImpl?: typeof fetch; @@ -95,5 +111,13 @@ export function buildJobHandlers( accessListsSchema.parse(data); await runAccessListsJob(deps); }, + [QUEUES.structures]: async (data) => { + structuresSchema.parse(data); + await runStructuresJob(deps); + }, + [QUEUES.structureEvents]: async (data) => { + structureEventsSchema.parse(data); + await runStructureEventsJob(deps); + }, }; } diff --git a/src/worker/queues.ts b/src/worker/queues.ts index 8bdfe83..1716d6e 100644 --- a/src/worker/queues.ts +++ b/src/worker/queues.ts @@ -11,6 +11,8 @@ export const QUEUES = { purge: "purge", location: "location", accessLists: "access-lists", + structures: "structures", + structureEvents: "structure-events", deadLetter: "ops-dead-letter", } as const; @@ -47,6 +49,8 @@ const JOB_QUEUES = [ QUEUES.purge, QUEUES.location, QUEUES.accessLists, + QUEUES.structures, + QUEUES.structureEvents, ] as const; export async function createQueues(boss: PgBoss): Promise { diff --git a/tests/admin-structure-actions-validation.test.ts b/tests/admin-structure-actions-validation.test.ts new file mode 100644 index 0000000..bfad420 --- /dev/null +++ b/tests/admin-structure-actions-validation.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from "vitest"; + +// designateStructureHolderAction/checkNowAction both call requireAdminAction +// before parseId, so it must resolve for the test to reach id validation at +// all. Nothing below this mock touches a real database: a bad id throws +// before designateStructureHolder or getDb are ever called. +vi.mock("@/lib/admin-guard", () => ({ + requireAdminAction: async () => ({ accountId: "00000000-0000-0000-0000-000000000000" }), +})); + +const { designateStructureHolderAction } = await import("@/app/admin/structures/actions"); + +describe("structures actions — id validation", () => { + it("rejects a non-numeric character id", async () => { + const fd = new FormData(); + fd.set("characterId", "12abc"); + await expect(designateStructureHolderAction(fd)).rejects.toThrow("invalid_id"); + }); + + it("rejects a negative character id", async () => { + const fd = new FormData(); + fd.set("characterId", "-1"); + await expect(designateStructureHolderAction(fd)).rejects.toThrow("invalid_id"); + }); + + it("rejects a missing character id", async () => { + await expect(designateStructureHolderAction(new FormData())).rejects.toThrow( + "invalid_id", + ); + }); +}); diff --git a/tests/audit-summarize.test.ts b/tests/audit-summarize.test.ts index f029e25..5458430 100644 --- a/tests/audit-summarize.test.ts +++ b/tests/audit-summarize.test.ts @@ -567,6 +567,40 @@ describe("summarizeDetails with configured tier labels", () => { ).toBe("character 90000002, was 90000001"); }); + it("renders a structure holder designation and its replacement", () => { + expect( + summarizeDetails("structure.holder_designated", { + characterId: 90000001, + corporationId: 98000001, + }), + ).toBe("character 90000001"); + expect( + summarizeDetails("structure.holder_replaced", { + previousCharacterId: 90000001, + characterId: 90000002, + corporationId: 98000001, + abandonedAlerts: 3, + }), + ).toBe("character 90000002, was 90000001, abandoned alerts 3"); + }); + + it("does not report the structure holder's corporationId as a hidden key", () => { + expect( + summarizeDetails("structure.holder_designated", { + characterId: 90000001, + corporationId: 98000001, + }), + ).not.toContain("more"); + expect( + summarizeDetails("structure.holder_replaced", { + previousCharacterId: 90000001, + characterId: 90000002, + corporationId: 98000001, + abandonedAlerts: 0, + }), + ).not.toContain("more"); + }); + it("renders a resolved character name in place of a raw id", () => { const names = new Map([["mainCharacterId", "Probe Kid"]]); expect( diff --git a/tests/auth-routes.test.ts b/tests/auth-routes.test.ts index 174e7fe..a24e4b9 100644 --- a/tests/auth-routes.test.ts +++ b/tests/auth-routes.test.ts @@ -286,6 +286,62 @@ describe("EVE link route — ?grant= is the only attacker-controllable input", ( expect(scopes).toContain("esi-characters.read_contacts.v1"); }); + it("grant=structures asks EVE for both structure scopes, and not the access-list one", async () => { + const { createSession } = await import("@/services/session"); + const { STRUCTURES_SCOPE, NOTIFICATIONS_SCOPE } = await import("@/lib/esi/client"); + const [acc] = await ctx.db.insert(account).values({}).returning(); + const sid = await createSession(ctx.db, acc.id); + const req = new NextRequest("http://localhost:3000/auth/eve/link?grant=structures"); + req.cookies.set("authgd_session", sid); + + const res = await linkRoute(req); + expect(res.status).toBe(307); + const authorize = new URL(res.headers.get("location")!); + const scopes = authorize.searchParams.get("scope")!.split(" "); + expect(scopes).toContain(STRUCTURES_SCOPE); + expect(scopes).toContain(NOTIFICATIONS_SCOPE); + expect(scopes).not.toContain(ACCESS_LISTS_SCOPE); + }); + + it("an unknown grant value (not a known name, not a raw scope) asks for no extra scope", async () => { + const { createSession } = await import("@/services/session"); + const [acc] = await ctx.db.insert(account).values({}).returning(); + const sid = await createSession(ctx.db, acc.id); + const req = new NextRequest( + "http://localhost:3000/auth/eve/link?grant=esi-corporations.read_blueprints.v1", + ); + req.cookies.set("authgd_session", sid); + + const res = await linkRoute(req); + expect(res.status).toBe(307); + const authorize = new URL(res.headers.get("location")!); + const scopes = authorize.searchParams.get("scope")!.split(" "); + expect(scopes).toEqual(["esi-characters.read_contacts.v1"]); + }); + + it("does not throw or grant anything for a prototype-chain grant value", async () => { + // GRANTS is a plain object literal, so `GRANTS[grant]` alone would throw + // or return a function for these three -- they are inherited from + // Object.prototype, not own keys. Object.hasOwn is the required guard, + // not optional hardening. + const { createSession } = await import("@/services/session"); + const [acc] = await ctx.db.insert(account).values({}).returning(); + const sid = await createSession(ctx.db, acc.id); + + for (const grant of ["toString", "constructor", "__proto__"]) { + const req = new NextRequest( + `http://localhost:3000/auth/eve/link?grant=${encodeURIComponent(grant)}`, + ); + req.cookies.set("authgd_session", sid); + const res = await linkRoute(req); + expect(res.status).toBe(307); + const authorize = new URL(res.headers.get("location")!); + const scopes = authorize.searchParams.get("scope")!.split(" "); + expect(scopes).not.toContain(ACCESS_LISTS_SCOPE); + expect(scopes).toEqual(["esi-characters.read_contacts.v1"]); + } + }); + it("any other grant value asks for no extra scope", async () => { const { createSession } = await import("@/services/session"); const [acc] = await ctx.db.insert(account).values({}).returning(); diff --git a/tests/deprovision-flow.test.ts b/tests/deprovision-flow.test.ts index ee25aac..32bea67 100644 --- a/tests/deprovision-flow.test.ts +++ b/tests/deprovision-flow.test.ts @@ -90,6 +90,11 @@ it("main leaves alliance → alumni → contacts removed, ACL removed, role chan alliances: [], }), getUniverseNames: async () => [], + // Structures: this flow never enqueues the structures job either; this + // exists only to satisfy the widened JobDeps["esi"]. + getCorporationStructures: async () => [], + // Structure events: same — this flow never enqueues the events job. + getCharacterNotifications: async () => [], }; // Wanderer: the ACL still lists the leaver's chars. diff --git a/tests/esi-client.test.ts b/tests/esi-client.test.ts index 0f0e393..d9f49e9 100644 --- a/tests/esi-client.test.ts +++ b/tests/esi-client.test.ts @@ -676,3 +676,100 @@ describe("getUniverseNames", () => { expect((err as EsiError).kind).toBe("permanent"); }); }); + +describe("paged reads fail closed", () => { + it("rejects a corporation structures read with no X-Pages header", async () => { + server.use( + http.get(`${ROOT}/corporations/98000001/structures/`, () => + HttpResponse.json([], { headers: {} }), + ), + ); + const esi = createEsiClient(); + await expect(esi.getCorporationStructures(98000001, "tok")).rejects.toThrow( + /X-Pages/i, + ); + }); +}); + +describe("getCorporationStructures", () => { + it("reads every page and maps timestamps to Date", async () => { + server.use( + http.get(`${ROOT}/corporations/98000001/structures/`, ({ request: req }) => { + const page = new URL(req.url).searchParams.get("page"); + const body = + page === "1" + ? [ + { + structure_id: 1029209158734, + type_id: 35832, + system_id: 30004268, + name: "Home Fortizar", + state: "armor_reinforce", + state_timer_end: "2026-08-25T12:00:00Z", + fuel_expires: "2026-09-01T00:00:00Z", + }, + ] + : [ + { + structure_id: 2, + type_id: 35832, + system_id: 30004268, + state: "shield_vulnerable", + }, + ]; + return HttpResponse.json(body, { headers: { "x-pages": "2" } }); + }), + ); + const esi = createEsiClient(); + const rows = await esi.getCorporationStructures(98000001, "tok"); + expect(rows).toHaveLength(2); + expect(rows[0].name).toBe("Home Fortizar"); + expect(rows[0].stateTimerEnd).toBeInstanceOf(Date); + expect(rows[1].name).toBeNull(); + expect(rows[1].fuelExpires).toBeNull(); + }); +}); + +describe("getCharacterNotifications", () => { + it("returns id, type, timestamp and raw text", async () => { + server.use( + http.get(`${ROOT}/characters/90000001/notifications/`, () => + HttpResponse.json([ + { + notification_id: 123456, + type: "StructureUnderAttack", + sender_id: 98000001, + sender_type: "corporation", + timestamp: "2026-08-24T10:00:00Z", + text: "structureID: &id001 1029209158734", + }, + ]), + ), + ); + const esi = createEsiClient(); + const rows = await esi.getCharacterNotifications(90000001, "tok"); + expect(rows).toHaveLength(1); + expect(rows[0].notificationId).toBe(123456); + expect(rows[0].type).toBe("StructureUnderAttack"); + expect(rows[0].timestamp).toBeInstanceOf(Date); + expect(rows[0].text).toContain("structureID"); + }); + + it("tolerates a notification with no text body", async () => { + server.use( + http.get(`${ROOT}/characters/90000001/notifications/`, () => + HttpResponse.json([ + { + notification_id: 7, + type: "StructureDestroyed", + sender_id: 1, + sender_type: "corporation", + timestamp: "2026-08-24T10:00:00Z", + }, + ]), + ), + ); + const esi = createEsiClient(); + expect((await esi.getCharacterNotifications(90000001, "tok"))[0].text).toBe(""); + }); +}); diff --git a/tests/nav-items.test.ts b/tests/nav-items.test.ts index d4f2de9..28ff535 100644 --- a/tests/nav-items.test.ts +++ b/tests/nav-items.test.ts @@ -27,6 +27,7 @@ describe("navFor", () => { "Audit log", "Sync", "Access lists", + "Structures", ]); expect(labels(navFor({ canReadPayouts: true, isAdmin: false }))).toEqual([ "Your account", @@ -42,9 +43,19 @@ describe("navFor", () => { "Audit log", "Sync", "Access lists", + "Structures", ]); }); + it("offers Structures to admins and nobody else", () => { + expect(labels(navFor({ canReadPayouts: false, isAdmin: true }))).toContain( + "Structures", + ); + expect(labels(navFor({ canReadPayouts: false, isAdmin: false }))).not.toContain( + "Structures", + ); + }); + // Module-level constants are shared across every call in a server process. // A call site that mutated an item rather than spreading it (as admin-nav.tsx // does for the pending badge) would corrupt the bar for every later render. @@ -64,6 +75,7 @@ describe("navFromPath", () => { "Audit log", "Sync", "Access lists", + "Structures", ]); }); diff --git a/tests/structure-event.test.ts b/tests/structure-event.test.ts new file mode 100644 index 0000000..e699cdb --- /dev/null +++ b/tests/structure-event.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; +import { + compareRosterRows, + extractStructureEvent, + formatStructureAlert, + isStructureEventType, + parseNotificationBody, + STRUCTURE_EVENT_TYPES, +} from "@/core/structure-event"; + +const UNDER_ATTACK = `allianceID: 99005338 +allianceName: Northern Coalition. +armorPercentage: 100.0 +charID: 96068617 +corpName: Ceptaerin +hullPercentage: 100.0 +shieldPercentage: 94.98 +solarsystemID: 30004268 +structureID: &id001 1029209158734 +structureShowInfoData: +- showinfo +- 35832 +- *id001 +structureTypeID: 35832`; + +const LOST_SHIELDS = `solarsystemID: 30004268 +structureID: &id001 1029209158734 +structureShowInfoData: +- showinfo +- 35832 +- *id001 +structureTypeID: 35832 +timeLeft: 892668963753 +vulnerableTime: 9000000000`; + +describe("STRUCTURE_EVENT_TYPES", () => { + it("is exactly the four damage types", () => { + expect([...STRUCTURE_EVENT_TYPES].sort()).toEqual([ + "StructureDestroyed", + "StructureLostArmor", + "StructureLostShields", + "StructureUnderAttack", + ]); + }); + + it("rejects non-damage structure notifications", () => { + expect(isStructureEventType("StructureFuelAlert")).toBe(false); + expect(isStructureEventType("StructureUnderAttack")).toBe(true); + }); +}); + +describe("parseNotificationBody", () => { + it("strips a YAML anchor from a scalar", () => { + expect(parseNotificationBody(UNDER_ATTACK).structureID).toBe("1029209158734"); + }); + + it("skips block sequence items", () => { + expect(parseNotificationBody(UNDER_ATTACK)).not.toHaveProperty("showinfo"); + expect(parseNotificationBody(UNDER_ATTACK).structureShowInfoData).toBeUndefined(); + }); + + it("resolves an alias to its anchor's value", () => { + const parsed = parseNotificationBody("a: &x 42\nb: *x"); + expect(parsed.b).toBe("42"); + }); + + it("returns an empty object for junk rather than throwing", () => { + expect(parseNotificationBody("!!! not yaml at all")).toEqual({}); + }); + + it("does not resolve an alias to a prototype-chain member", () => { + for (const name of ["constructor", "toString", "__proto__"]) { + const parsed = parseNotificationBody(`b: *${name}`); + expect(parsed.b).toBeUndefined(); + expect(typeof parsed.b).not.toBe("function"); + } + }); +}); + +describe("extractStructureEvent", () => { + it("pulls the structure id and the damage percentages", () => { + const e = extractStructureEvent(UNDER_ATTACK); + expect(e.structureId).toBe(1029209158734); + expect(e.details.shieldPercentage).toBe(94.98); + expect(e.details.corpName).toBe("Ceptaerin"); + expect(e.details.allianceName).toBe("Northern Coalition."); + }); + + it("returns a null structure id when the body will not parse", () => { + const e = extractStructureEvent("garbage"); + expect(e.structureId).toBeNull(); + expect(e.details).toEqual({}); + }); + + it("keeps timeLeft for a reinforcement notification", () => { + expect(extractStructureEvent(LOST_SHIELDS).details.timeLeft).toBe(892668963753); + }); +}); + +describe("formatStructureAlert", () => { + it("names the structure, the system and the attacker", () => { + const line = formatStructureAlert({ + type: "StructureUnderAttack", + structureName: "Home Fortizar", + typeName: "Fortizar", + systemName: "Jita", + details: { corpName: "Ceptaerin", allianceName: "Northern Coalition." }, + }); + expect(line).toContain("under attack"); + expect(line).toContain("Home Fortizar"); + expect(line).toContain("Jita"); + expect(line).toContain("Northern Coalition."); + }); + + it("falls back to the type name when the structure has no name", () => { + const line = formatStructureAlert({ + type: "StructureDestroyed", + structureName: null, + typeName: "Astrahus", + systemName: "Jita", + details: {}, + }); + expect(line).toContain("Astrahus"); + expect(line).toContain("destroyed"); + }); + + it("never exceeds the webhook clamp", () => { + const line = formatStructureAlert({ + type: "StructureUnderAttack", + structureName: "x".repeat(5000), + typeName: "Fortizar", + systemName: "Jita", + details: {}, + }); + expect(line.length).toBeLessThanOrEqual(1900); + }); +}); + +describe("compareRosterRows", () => { + it("sorts reinforced above vulnerable above healthy", () => { + const rows = [ + { state: "shield_vulnerable", name: "b" }, + { state: "online", name: "a" }, + { state: "hull_reinforce", name: "c" }, + { state: "armor_reinforce", name: "d" }, + ]; + expect([...rows].sort(compareRosterRows).map((r) => r.state)).toEqual([ + "hull_reinforce", + "armor_reinforce", + "shield_vulnerable", + "online", + ]); + }); + + it("breaks ties by name so the order is stable across runs", () => { + const rows = [ + { state: "online", name: "zeta" }, + { state: "online", name: "alpha" }, + ]; + expect([...rows].sort(compareRosterRows).map((r) => r.name)).toEqual([ + "alpha", + "zeta", + ]); + }); +}); diff --git a/tests/structure-events-job.test.ts b/tests/structure-events-job.test.ts new file mode 100644 index 0000000..a6c9fcd --- /dev/null +++ b/tests/structure-events-job.test.ts @@ -0,0 +1,246 @@ +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { Config } from "@/config"; +import { structureEvent } from "@/db/schema"; +import { runStructureEventsJob } from "@/jobs/structure-events"; +import { + NOTIFICATIONS_SCOPE, + type EsiNotification, + type StructureEventsEsi, +} from "@/lib/esi/client"; +import { designateStructureHolder, getStructureHolder } from "@/services/structures"; +import { setupTestDb, truncateAll } from "./helpers/db"; +import { testConfig } from "./helpers/config"; +import { seedAccount, seedCharacter } from "./helpers/seed"; + +const CORP = 98000001; +const HOLDER = 90000001; + +let ctx: Awaited>; +beforeAll(async () => { + ctx = await setupTestDb(); +}); +afterAll(() => ctx.cleanup()); + +let holderSeeded = false; +beforeEach(async () => { + await truncateAll(ctx.db); + holderSeeded = false; +}); + +/** Designates a holder pinned to CORP, once per test, lazily on first `run`. */ +async function ensureHolder(): Promise { + if (holderSeeded) return; + const acc = await seedAccount(ctx.db); + await seedCharacter(ctx.db, testConfig(), { + id: HOLDER, + accountId: acc.id, + corporationId: CORP, + scopes: [NOTIFICATIONS_SCOPE], + tokenStatus: "valid", + // The helper encrypts this with the test key itself — never pass a + // pre-encrypted blob (tests/helpers/seed.ts:33-50). + refreshToken: "refresh", + }); + await designateStructureHolder(ctx.db, HOLDER, CORP, acc.id); + holderSeeded = true; +} + +/** A minimal notification of the given type, defaulting to structure damage. */ +function attack(id: number, over: Partial = {}): EsiNotification { + return { + notificationId: id, + type: "StructureUnderAttack", + timestamp: new Date(Date.UTC(2026, 7, 1, 0, 0, id)), + text: `structureID: &id001 100000${id}\nsolarsystemID: 30000142\nshieldPercentage: 50.0\n`, + ...over, + }; +} + +const seedOne = () => attack(1); + +/** A non-structure notification type, which must never reach the table. */ +function mailNotification(): EsiNotification { + return { + notificationId: 777, + type: "MailboxUpdate", + timestamp: new Date(Date.UTC(2026, 7, 1)), + text: "", + }; +} + +/** + * A single fetchImpl that serves both callers the job makes: EVE SSO's token + * endpoint (always succeeds, rotating to a new blob) and the Discord webhook + * post, which either records its content into `posts` or fails when + * `postFails` is set. + */ +function buildFetch(opts: { posts?: string[]; postFails?: boolean }): typeof fetch { + return async (url: string | URL | Request, init?: RequestInit) => { + const href = typeof url === "string" ? url : url instanceof URL ? url.href : url.url; + if (href.includes("login.eveonline.com")) { + return new Response(JSON.stringify({ access_token: "at", refresh_token: "rt2" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (opts.postFails) { + return new Response("boom", { status: 500 }); + } + const body = init?.body + ? (JSON.parse(String(init.body)).content as string) + : undefined; + if (body && opts.posts) { + opts.posts.push(body); + } + return new Response(null, { status: 204 }); + }; +} + +async function run(opts: { + notifications: EsiNotification[]; + posts?: string[]; + cfg?: Config; + postFails?: boolean; +}) { + await ensureHolder(); + const esi: StructureEventsEsi = { + getCharacterNotifications: async () => opts.notifications, + }; + return runStructureEventsJob({ + db: ctx.db, + cfg: opts.cfg ?? testConfig(), + esi, + fetchImpl: buildFetch({ posts: opts.posts, postFails: opts.postFails }), + }); +} + +describe("runStructureEventsJob", () => { + it("seeds silently on the first poll and sends nothing", async () => { + const posts: string[] = []; + const res = await run({ notifications: [attack(1), attack(2)], posts }); + expect(res.status).toBe("ok"); + expect(posts).toHaveLength(0); + const rows = await ctx.db.select().from(structureEvent); + expect(rows.map((r) => r.alertStatus)).toEqual(["seeded", "seeded"]); + expect((await getStructureHolder(ctx.db))?.seededAt).toBeInstanceOf(Date); + }); + + it("alerts only on events new since the seed", async () => { + const posts: string[] = []; + await run({ notifications: [attack(1)], posts }); + await run({ notifications: [attack(1), attack(2)], posts }); + expect(posts).toHaveLength(1); + expect(posts[0]).toContain("under attack"); + }); + + it("ignores non-damage notification types entirely", async () => { + await run({ notifications: [seedOne()] }); + await run({ + notifications: [{ ...attack(9), type: "StructureFuelAlert" }, mailNotification()], + }); + const rows = await ctx.db.select().from(structureEvent); + expect(rows.map((r) => r.notificationId)).not.toContain(9); + expect(rows).toHaveLength(1); + }); + + it("records as seeded, never pending, when no webhook is configured", async () => { + const cfg = { + ...testConfig(), + discord: { + ...testConfig().discord, + opsWebhookUrl: undefined, + structureWebhookUrl: undefined, + }, + }; + await run({ notifications: [attack(1)], cfg }); // seeds + await run({ notifications: [attack(1), attack(2)], cfg }); + const rows = await ctx.db.select().from(structureEvent); + expect(rows.map((r) => r.alertStatus).sort()).toEqual(["seeded", "seeded"]); + expect(rows.some((r) => r.alertStatus === "sent")).toBe(false); + }); + + it("leaves a row pending and retries it next run when the post fails", async () => { + await run({ notifications: [attack(1)] }); // seed + const res = await run({ notifications: [attack(1), attack(2)], postFails: true }); + expect(res.status).toBe("partial"); + let [row] = await ctx.db + .select() + .from(structureEvent) + .where(eq(structureEvent.notificationId, 2)); + expect(row.alertStatus).toBe("pending"); + + const posts: string[] = []; + await run({ notifications: [attack(1), attack(2)], posts }); + expect(posts).toHaveLength(1); + [row] = await ctx.db + .select() + .from(structureEvent) + .where(eq(structureEvent.notificationId, 2)); + expect(row.alertStatus).toBe("sent"); + }); + + it("never posts a pending row belonging to another corporation", async () => { + await run({ notifications: [attack(1)] }); // seed, corp 98000001 + await ctx.db.insert(structureEvent).values({ + notificationId: 500, + type: "StructureUnderAttack", + sentAt: new Date(), + corporationId: 98000999, + alertStatus: "pending", + }); + const posts: string[] = []; + await run({ notifications: [attack(1), attack(2)], posts }); + expect(posts).toHaveLength(1); // event 2 only, never 500 + }); + + it("skips entirely in dry-run without touching the table", async () => { + const cfg = { ...testConfig(), syncMode: "dry-run" as const }; + const res = await run({ notifications: [attack(1)], cfg }); + expect(res.counts?.skipped).toBe(1); + expect(await ctx.db.select().from(structureEvent)).toHaveLength(0); + }); + + it("declines to record events when the same character is re-designated to a new corp mid-flight", async () => { + await run({ notifications: [attack(1)] }); // seed, corp 98000001 + const acc = await seedAccount(ctx.db); + const esi: StructureEventsEsi = { + getCharacterNotifications: async () => { + // Same character, re-pinned to a new corp between the job's read of + // the holder and its write — exactly what the corp-changed remedy + // instructs an admin to do. An id-only CAS would miss this. + await designateStructureHolder(ctx.db, HOLDER, 98000099, acc.id); + return [attack(2)]; + }, + }; + const posts: string[] = []; + await runStructureEventsJob({ + db: ctx.db, + cfg: testConfig(), + esi, + fetchImpl: buildFetch({ posts }), + }); + expect(posts).toHaveLength(0); + const rows = await ctx.db + .select() + .from(structureEvent) + .where(eq(structureEvent.notificationId, 2)); + expect(rows).toHaveLength(0); + }); + + it("records an event whose body will not parse, and still alerts", async () => { + await run({ notifications: [attack(1)] }); + const posts: string[] = []; + await run({ + notifications: [attack(1), { ...attack(2), text: "!!! unparseable" }], + posts, + }); + expect(posts).toHaveLength(1); + const [row] = await ctx.db + .select() + .from(structureEvent) + .where(eq(structureEvent.notificationId, 2)); + expect(row.structureId).toBeNull(); + expect(row.alertStatus).toBe("sent"); + }); +}); diff --git a/tests/structure-roster-job.test.ts b/tests/structure-roster-job.test.ts new file mode 100644 index 0000000..7774a28 --- /dev/null +++ b/tests/structure-roster-job.test.ts @@ -0,0 +1,182 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { runStructuresJob } from "@/jobs/structures"; +import { + EsiError, + NOTIFICATIONS_SCOPE, + STRUCTURES_SCOPE, + type EsiCorporationStructure, + type StructuresEsi, +} from "@/lib/esi/client"; +import { classifyEsiError } from "@/core/errors"; +import { + designateStructureHolder, + getReadStates, + getRoster, +} from "@/services/structures"; +import { setupTestDb, truncateAll } from "./helpers/db"; +import { testConfig } from "./helpers/config"; +import { seedAccount, seedCharacter } from "./helpers/seed"; + +const CORP = 98000001; +const HOLDER = 90000001; + +let ctx: Awaited>; +beforeAll(async () => { + ctx = await setupTestDb(); +}); +afterAll(() => ctx.cleanup()); +beforeEach(() => truncateAll(ctx.db)); + +/** A refresh that always succeeds, rotating to a new blob. */ +const okToken = (async () => + new Response(JSON.stringify({ access_token: "at", refresh_token: "rt2" }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as typeof fetch; + +function fakeEsi(opts: { + structures?: EsiCorporationStructure[]; + error?: Error; +}): StructuresEsi { + return { + getCorporationStructures: async () => { + if (opts.error) throw opts.error; + return opts.structures ?? []; + }, + getUniverseNames: async (ids: number[]) => + ids.map((id) => ({ id, name: `name-${id}`, category: "inventory_type" })), + }; +} + +function struct(id: number, over: Partial = {}) { + return { + structureId: id, + typeId: 35832, + systemId: 30004268, + name: `S${id}`, + state: "shield_vulnerable", + stateTimerStart: null, + stateTimerEnd: null, + fuelExpires: null, + ...over, + }; +} + +/** + * Designates a holder pinned to CORP, with both scopes granted and a live + * token, and optionally moves the character's CURRENT corp elsewhere so the + * corp-changed branch can be exercised. + */ +async function designate(opts: { currentCorp?: number; scopes?: string[] } = {}) { + const account = await seedAccount(ctx.db); + await seedCharacter(ctx.db, testConfig(), { + id: HOLDER, + accountId: account.id, + corporationId: opts.currentCorp ?? CORP, + scopes: opts.scopes ?? [STRUCTURES_SCOPE, NOTIFICATIONS_SCOPE], + tokenStatus: "valid", + // The helper encrypts this with the test key itself — never pass a + // pre-encrypted blob (tests/helpers/seed.ts:33-50). + refreshToken: "refresh", + }); + await designateStructureHolder(ctx.db, HOLDER, CORP, account.id); + return account; +} + +function run(esi: StructuresEsi, fetchImpl = okToken) { + return runStructuresJob({ db: ctx.db, cfg: testConfig(), esi, fetchImpl }); +} + +describe("runStructuresJob", () => { + it("returns ok with noHolder when nothing is designated", async () => { + const res = await run(fakeEsi({})); + expect(res.status).toBe("ok"); + expect(res.counts?.noHolder).toBe(1); + }); + + it("does not call ESI when the holder lacks the scope", async () => { + await designate({ scopes: [] }); + let called = false; + const esi: StructuresEsi = { + getCorporationStructures: async () => { + called = true; + return []; + }, + getUniverseNames: async () => [], + }; + const res = await run(esi); + expect(called).toBe(false); + expect(res.counts?.scopeMissing).toBe(1); + }); + + it("refuses to read when the holder has left the pinned corporation", async () => { + await designate({ currentCorp: 98000002 }); + const res = await run(fakeEsi({ structures: [struct(1)] })); + expect(res.counts?.corpChanged).toBe(1); + const states = await getReadStates(ctx.db, CORP); + expect(states.roster.readStatus).toBe("failed"); + expect(states.roster.detail).toBe("corp-changed"); + expect(await getRoster(ctx.db, CORP)).toHaveLength(0); + }); + + it("records forbidden and mutates no roster rows on a corp-roles 403", async () => { + await designate(); + await run(fakeEsi({ structures: [struct(1)] })); // one good read first + const res = await run( + fakeEsi({ + error: new EsiError("Character does not have required role(s)", 403, "permanent"), + }), + ); + expect(res.status).toBe("partial"); + const states = await getReadStates(ctx.db, CORP); + expect(states.roster.readStatus).toBe("forbidden"); + // the last GOOD read's timestamp survives the failure + expect(states.roster.observedAt).toBeInstanceOf(Date); + const rows = await getRoster(ctx.db, CORP); + expect(rows).toHaveLength(1); + expect(rows[0].missingSince).toBeNull(); + }); + + it("pins a corp-roles 403 as permanent, not needs_reauth", () => { + // Load-bearing on CCP's error PROSE: classifyEsiError maps 403 to + // needs_reauth only when the body names a scope/token/authorization + // problem. If CCP reworded this, `forbidden` would start reading as a + // token fault and send admins round the re-auth loop forever. + expect( + classifyEsiError(403, { error: "Character does not have required role(s)" }), + ).toBe("permanent"); + expect(classifyEsiError(403, { error: "invalid token" })).toBe("needs_reauth"); + }); + + it("stamps missingSince rather than deleting a structure that stopped appearing", async () => { + await designate(); + await run(fakeEsi({ structures: [struct(1), struct(2)] })); + await run(fakeEsi({ structures: [struct(1)] })); + const rows = await getRoster(ctx.db, CORP); + expect(rows).toHaveLength(2); + expect(rows.find((r) => r.structureId === 2)?.missingSince).toBeInstanceOf(Date); + expect(rows.find((r) => r.structureId === 1)?.missingSince).toBeNull(); + }); + + it("clears missingSince when a structure reappears", async () => { + await designate(); + await run(fakeEsi({ structures: [struct(1), struct(2)] })); + await run(fakeEsi({ structures: [struct(1)] })); + await run(fakeEsi({ structures: [struct(1), struct(2)] })); + const rows = await getRoster(ctx.db, CORP); + expect(rows.find((r) => r.structureId === 2)?.missingSince).toBeNull(); + }); + + it("keeps a good type name when the name lookup fails", async () => { + await designate(); + await run(fakeEsi({ structures: [struct(1)] })); + const esi: StructuresEsi = { + getCorporationStructures: async () => [struct(1)], + getUniverseNames: async () => { + throw new Error("names down"); + }, + }; + await run(esi); + expect((await getRoster(ctx.db, CORP))[0].typeName).toBe("name-35832"); + }); +}); diff --git a/tests/structure-schema.test.ts b/tests/structure-schema.test.ts new file mode 100644 index 0000000..c25cd51 --- /dev/null +++ b/tests/structure-schema.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, beforeAll, afterAll, beforeEach } from "vitest"; +import { sql } from "drizzle-orm"; +import { setupTestDb, truncateAll } from "./helpers/db"; +import { testConfig } from "./helpers/config"; +import { seedAccount, seedCharacter } from "./helpers/seed"; +import { MANAGED_TABLE_NAMES } from "@/db/tables"; + +let ctx: Awaited>; +beforeAll(async () => { + ctx = await setupTestDb(); +}); +afterAll(async () => { + await ctx.cleanup(); +}); +beforeEach(async () => { + await truncateAll(ctx.db); +}); + +describe("structure monitor schema", () => { + it("registers all four tables in MANAGED_TABLES", () => { + for (const t of [ + "structure_holder", + "structure_read_state", + "structure", + "structure_event", + ]) { + expect(MANAGED_TABLE_NAMES).toContain(t); + } + }); + + it("pins structure_holder to a single row", async () => { + const account = await seedAccount(ctx.db); + await seedCharacter(ctx.db, testConfig(), { id: 90000001, accountId: account.id }); + await ctx.db.execute( + sql`insert into structure_holder (id, character_id, corporation_id, designated_by) values (1, 90000001, 5, 'system')`, + ); + await expect( + ctx.db.execute( + sql`insert into structure_holder (id, character_id, corporation_id, designated_by) values (2, 90000001, 5, 'system')`, + ), + ).rejects.toThrow(); + }); + + it("rejects a second row at id = 1", async () => { + const account = await seedAccount(ctx.db); + await seedCharacter(ctx.db, testConfig(), { id: 90000001, accountId: account.id }); + await ctx.db.execute( + sql`insert into structure_holder (id, character_id, corporation_id, designated_by) values (1, 90000001, 5, 'system')`, + ); + await expect( + ctx.db.execute( + sql`insert into structure_holder (id, character_id, corporation_id, designated_by) values (1, 90000001, 5, 'system')`, + ), + ).rejects.toThrow(); + }); + + it("keys structure_read_state by (kind, corporation_id)", async () => { + await ctx.db.execute( + sql`insert into structure_read_state (kind, corporation_id, last_attempt_at, read_status) values ('roster', 98000001, now(), 'ok')`, + ); + await ctx.db.execute( + sql`insert into structure_read_state (kind, corporation_id, last_attempt_at, read_status) values ('roster', 98000002, now(), 'ok')`, + ); + await expect( + ctx.db.execute( + sql`insert into structure_read_state (kind, corporation_id, last_attempt_at, read_status) values ('roster', 98000001, now(), 'ok')`, + ), + ).rejects.toThrow(); + }); + + it("carries all four alert statuses", async () => { + const res = await ctx.db.execute( + sql`select unnest(enum_range(null::structure_alert_status))::text as v`, + ); + const values = res.rows.map((r) => (r as { v: string }).v); + expect(values.sort()).toEqual(["abandoned", "pending", "seeded", "sent"]); + }); +}); diff --git a/tests/structure-service.test.ts b/tests/structure-service.test.ts new file mode 100644 index 0000000..d5ecfa3 --- /dev/null +++ b/tests/structure-service.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it, beforeAll, afterAll, beforeEach } from "vitest"; +import { eq } from "drizzle-orm"; +import { setupTestDb, truncateAll } from "./helpers/db"; +import { testConfig } from "./helpers/config"; +import { seedAccount, seedCharacter } from "./helpers/seed"; +import { auditLog, character, structureEvent } from "@/db/schema"; +import { NOTIFICATIONS_SCOPE, STRUCTURES_SCOPE } from "@/lib/esi/client"; +import { + designateStructureHolder, + findGrantableCharacter, + getStructureHolder, + markSeeded, + stillStructureHolder, + toHolderView, +} from "@/services/structures"; + +let ctx: Awaited>; +beforeAll(async () => { + ctx = await setupTestDb(); +}); +afterAll(async () => { + await ctx.cleanup(); +}); +beforeEach(async () => { + await truncateAll(ctx.db); +}); + +describe("designateStructureHolder", () => { + it("pins the corporation and audits the designation", async () => { + const account = await seedAccount(ctx.db); + await seedCharacter(ctx.db, testConfig(), { id: 90000001, accountId: account.id }); + await designateStructureHolder(ctx.db, 90000001, 98000001, account.id); + + const holder = await getStructureHolder(ctx.db); + expect(holder).toMatchObject({ characterId: 90000001, corporationId: 98000001 }); + expect(holder?.seededAt).toBeNull(); + + const rows = await ctx.db.select().from(auditLog); + expect(rows).toHaveLength(1); + expect(rows[0].action).toBe("structure.holder_designated"); + expect(rows[0].details).toMatchObject({ + characterId: 90000001, + corporationId: 98000001, + }); + }); + + it("retires pending alerts when the holder is replaced, and says how many", async () => { + const account = await seedAccount(ctx.db); + await seedCharacter(ctx.db, testConfig(), { id: 90000001, accountId: account.id }); + await seedCharacter(ctx.db, testConfig(), { id: 90000002, accountId: account.id }); + await designateStructureHolder(ctx.db, 90000001, 98000001, account.id); + await ctx.db.insert(structureEvent).values([ + { + notificationId: 1, + type: "StructureUnderAttack", + sentAt: new Date(), + corporationId: 98000001, + alertStatus: "pending", + }, + { + notificationId: 2, + type: "StructureLostArmor", + sentAt: new Date(), + corporationId: 98000001, + alertStatus: "sent", + }, + ]); + + const result = await designateStructureHolder(ctx.db, 90000002, 98000002, account.id); + expect(result.abandonedAlerts).toBe(1); + + const [one] = await ctx.db + .select() + .from(structureEvent) + .where(eq(structureEvent.notificationId, 1)); + expect(one.alertStatus).toBe("abandoned"); + const [two] = await ctx.db + .select() + .from(structureEvent) + .where(eq(structureEvent.notificationId, 2)); + expect(two.alertStatus).toBe("sent"); + + const rows = await ctx.db.select().from(auditLog); + const replaced = rows.find((r) => r.action === "structure.holder_replaced"); + expect(replaced?.details).toMatchObject({ + previousCharacterId: 90000001, + characterId: 90000002, + abandonedAlerts: 1, + }); + }); + + it("retires nothing when the holder is replaced within the same corp", async () => { + const account = await seedAccount(ctx.db); + await seedCharacter(ctx.db, testConfig(), { id: 90000001, accountId: account.id }); + await seedCharacter(ctx.db, testConfig(), { id: 90000002, accountId: account.id }); + await designateStructureHolder(ctx.db, 90000001, 98000001, account.id); + await ctx.db.insert(structureEvent).values({ + notificationId: 1, + type: "StructureUnderAttack", + sentAt: new Date(), + corporationId: 98000001, + alertStatus: "pending", + }); + + const result = await designateStructureHolder(ctx.db, 90000002, 98000001, account.id); + expect(result.abandonedAlerts).toBe(0); + + const [one] = await ctx.db + .select() + .from(structureEvent) + .where(eq(structureEvent.notificationId, 1)); + expect(one.alertStatus).toBe("pending"); + }); + + it("resets seededAt so a new holder re-seeds", async () => { + const account = await seedAccount(ctx.db); + await seedCharacter(ctx.db, testConfig(), { id: 90000001, accountId: account.id }); + await seedCharacter(ctx.db, testConfig(), { id: 90000002, accountId: account.id }); + await designateStructureHolder(ctx.db, 90000001, 98000001, account.id); + await markSeeded(ctx.db, new Date()); + expect((await getStructureHolder(ctx.db))?.seededAt).toBeInstanceOf(Date); + await designateStructureHolder(ctx.db, 90000002, 98000002, account.id); + expect((await getStructureHolder(ctx.db))?.seededAt).toBeNull(); + }); +}); + +describe("stillStructureHolder", () => { + it("is false once another character has been designated", async () => { + const account = await seedAccount(ctx.db); + await seedCharacter(ctx.db, testConfig(), { id: 90000001, accountId: account.id }); + await seedCharacter(ctx.db, testConfig(), { id: 90000002, accountId: account.id }); + await designateStructureHolder(ctx.db, 90000001, 98000001, account.id); + const holder = await getStructureHolder(ctx.db); + expect(await stillStructureHolder(ctx.db, 90000001, holder!.designatedAt)).toBe(true); + await designateStructureHolder(ctx.db, 90000002, 98000002, account.id); + expect(await stillStructureHolder(ctx.db, 90000001, holder!.designatedAt)).toBe( + false, + ); + }); + + it("returns true when nothing has changed since the snapshot", async () => { + const account = await seedAccount(ctx.db); + await seedCharacter(ctx.db, testConfig(), { id: 90000001, accountId: account.id }); + await designateStructureHolder(ctx.db, 90000001, 98000001, account.id); + const holder = await getStructureHolder(ctx.db); + expect(await stillStructureHolder(ctx.db, 90000001, holder!.designatedAt)).toBe(true); + }); + + it("is false after a same-character re-designation to a different corp", async () => { + const account = await seedAccount(ctx.db); + await seedCharacter(ctx.db, testConfig(), { id: 90000001, accountId: account.id }); + await designateStructureHolder(ctx.db, 90000001, 98000001, account.id); + const snapshot = await getStructureHolder(ctx.db); + + // Same character, re-pinned to a different corp: the id-only CAS this + // guards against would miss this entirely. + await designateStructureHolder(ctx.db, 90000001, 98000002, account.id); + expect(await stillStructureHolder(ctx.db, 90000001, snapshot!.designatedAt)).toBe( + false, + ); + }); +}); + +describe("findGrantableCharacter", () => { + it("returns null when no character carries both scopes", async () => { + const admin = await seedAccount(ctx.db, { isAdmin: true }); + await seedCharacter(ctx.db, testConfig(), { + id: 90000001, + accountId: admin.id, + scopes: [], + }); + expect(await findGrantableCharacter(ctx.db)).toBeNull(); + }); + + it("returns null when a character has only one of the two scopes", async () => { + const admin = await seedAccount(ctx.db, { isAdmin: true }); + await seedCharacter(ctx.db, testConfig(), { + id: 90000001, + accountId: admin.id, + scopes: [STRUCTURES_SCOPE], + }); + await seedCharacter(ctx.db, testConfig(), { + id: 90000002, + accountId: admin.id, + scopes: [NOTIFICATIONS_SCOPE], + }); + expect(await findGrantableCharacter(ctx.db)).toBeNull(); + }); + + it("returns the character when it carries both scopes", async () => { + const admin = await seedAccount(ctx.db, { isAdmin: true }); + await seedCharacter(ctx.db, testConfig(), { + id: 90000001, + accountId: admin.id, + name: "Grantable One", + scopes: [STRUCTURES_SCOPE, NOTIFICATIONS_SCOPE], + corporationId: 98000001, + }); + expect(await findGrantableCharacter(ctx.db)).toMatchObject({ + characterId: 90000001, + name: "Grantable One", + corporationId: 98000001, + }); + }); + + it("ignores a character whose account is not an admin, even with both scopes", async () => { + const nonAdmin = await seedAccount(ctx.db, { isAdmin: false }); + await seedCharacter(ctx.db, testConfig(), { + id: 90000001, + accountId: nonAdmin.id, + scopes: [STRUCTURES_SCOPE, NOTIFICATIONS_SCOPE], + }); + expect(await findGrantableCharacter(ctx.db)).toBeNull(); + }); + + it("returns corporationId as null when the character has none", async () => { + const admin = await seedAccount(ctx.db, { isAdmin: true }); + await seedCharacter(ctx.db, testConfig(), { + id: 90000001, + accountId: admin.id, + scopes: [STRUCTURES_SCOPE, NOTIFICATIONS_SCOPE], + corporationId: null, + }); + expect(await findGrantableCharacter(ctx.db)).toMatchObject({ + characterId: 90000001, + corporationId: null, + }); + }); +}); + +describe("toHolderView", () => { + it("keeps the pinned corporationId distinct from the character's current one", async () => { + const account = await seedAccount(ctx.db); + await seedCharacter(ctx.db, testConfig(), { + id: 90000001, + accountId: account.id, + corporationId: 98000001, + }); + await designateStructureHolder(ctx.db, 90000001, 98000001, account.id); + + // The character moves corp after designation; the holder stays pinned. + await ctx.db + .update(character) + .set({ corporationId: 98000099 }) + .where(eq(character.id, 90000001)); + + const holder = await getStructureHolder(ctx.db); + const view = await toHolderView(ctx.db, holder!); + expect(view.corporationId).toBe(98000001); + expect(view.currentCorporationId).toBe(98000099); + expect(view.corporationId).not.toBe(view.currentCorporationId); + }); + + it("carries scopes and tokenStatus through from the character row", async () => { + const account = await seedAccount(ctx.db); + await seedCharacter(ctx.db, testConfig(), { + id: 90000001, + accountId: account.id, + scopes: [STRUCTURES_SCOPE, NOTIFICATIONS_SCOPE], + tokenStatus: "needs_reauth", + }); + await designateStructureHolder(ctx.db, 90000001, 98000001, account.id); + + const holder = await getStructureHolder(ctx.db); + const view = await toHolderView(ctx.db, holder!); + expect(view.scopes).toEqual([STRUCTURES_SCOPE, NOTIFICATIONS_SCOPE]); + expect(view.tokenStatus).toBe("needs_reauth"); + }); +}); diff --git a/tests/structure-view.test.ts b/tests/structure-view.test.ts new file mode 100644 index 0000000..f2dc848 --- /dev/null +++ b/tests/structure-view.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vitest"; +import { + monitorRemedy, + monitorSentence, + monitorState, + showsRoster, + rowTone, +} from "@/app/admin/structures/view"; +import type { HolderView } from "@/services/structures"; +import { NOTIFICATIONS_SCOPE, STRUCTURES_SCOPE } from "@/lib/esi/client"; + +function healthyHolder(): HolderView { + return { + characterId: 1, + name: "Test Holder", + scopes: [STRUCTURES_SCOPE, NOTIFICATIONS_SCOPE], + tokenStatus: "valid", + corporationId: 5, + currentCorporationId: 5, + }; +} + +const base = { + grantable: null, + holder: null, + readStates: {}, + rosterCount: 0, + webhookConfigured: true, +}; + +describe("monitorState", () => { + it("asks for a grant when nobody has one", () => { + expect(monitorState(base)).toBe("grant-needed"); + }); + + it("asks for a designation when a character has the scopes but is not the holder", () => { + expect(monitorState({ ...base, grantable: { characterId: 1, name: "A" } })).toBe( + "designate-needed", + ); + }); + + it("puts the dropped scope BEFORE the token fault", () => { + // the plain re-auth link is what DROPS the scope, so offering it first + // sends an admin round a loop that cannot terminate + const state = monitorState({ + ...base, + holder: { + characterId: 1, + name: "A", + scopes: [], + tokenStatus: "needs_reauth", + corporationId: 5, + currentCorporationId: 5, + }, + }); + expect(state).toBe("scope-dropped"); + }); + + it("reports corp-changed when the holder has left the pinned corp", () => { + expect( + monitorState({ + ...base, + holder: { + characterId: 1, + name: "A", + scopes: [STRUCTURES_SCOPE, NOTIFICATIONS_SCOPE], + tokenStatus: "valid", + corporationId: 5, + currentCorporationId: 6, + }, + }), + ).toBe("corp-changed"); + }); + + it("names which read is forbidden", () => { + const state = monitorState({ + ...base, + holder: healthyHolder(), + readStates: { events: { readStatus: "forbidden" } }, + }); + expect(state).toBe("no-corp-roles"); + expect(monitorSentence(state, { forbidden: ["events"] })).toContain("notifications"); + }); + + it("says alerts are unconfigured rather than claiming they go to Discord", () => { + expect( + monitorState({ + ...base, + holder: healthyHolder(), + rosterCount: 3, + webhookConfigured: false, + }), + ).toBe("alerts-unconfigured"); + expect(monitorState({ ...base, holder: healthyHolder(), rosterCount: 3 })).toBe( + "normal", + ); + }); + + it("offers no remedy for states an admin cannot fix from this app", () => { + expect(monitorRemedy("no-corp-roles")).toBeNull(); + expect(monitorRemedy("alerts-unconfigured")).toBeNull(); + expect(monitorRemedy("grant-needed")).toMatchObject({ + href: "/auth/eve/link?grant=structures", + }); + }); + + it("uses the re-grant link for a dropped scope and the bare link for a token fault", () => { + expect(monitorRemedy("scope-dropped")?.href).toBe("/auth/eve/link?grant=structures"); + expect(monitorRemedy("holder-needs-reauth")?.href).toBe("/auth/eve/link"); + }); + + it("returns holder-needs-reauth when the holder needs to sign in again", () => { + expect( + monitorState({ + ...base, + holder: { + characterId: 1, + name: "A", + scopes: [STRUCTURES_SCOPE, NOTIFICATIONS_SCOPE], + tokenStatus: "needs_reauth", + corporationId: 5, + currentCorporationId: 5, + }, + }), + ).toBe("holder-needs-reauth"); + }); + + it("returns holder-no-token for both missing and invalid token statuses", () => { + expect( + monitorState({ + ...base, + holder: { + characterId: 1, + name: "A", + scopes: [STRUCTURES_SCOPE, NOTIFICATIONS_SCOPE], + tokenStatus: "missing", + corporationId: 5, + currentCorporationId: 5, + }, + }), + ).toBe("holder-no-token"); + expect( + monitorState({ + ...base, + holder: { + characterId: 1, + name: "A", + scopes: [STRUCTURES_SCOPE, NOTIFICATIONS_SCOPE], + tokenStatus: "invalid", + corporationId: 5, + currentCorporationId: 5, + }, + }), + ).toBe("holder-no-token"); + }); + + it("returns roster-empty when the healthy holder has no structures to read", () => { + expect( + monitorState({ + ...base, + holder: healthyHolder(), + rosterCount: 0, + }), + ).toBe("roster-empty"); + }); +}); + +describe("showsRoster", () => { + it("renders roster for normal and alert-unconfigured states", () => { + expect(showsRoster("normal")).toBe(true); + expect(showsRoster("alerts-unconfigured")).toBe(true); + }); + + it("renders roster for broken states where seeing what is known matters", () => { + expect(showsRoster("no-corp-roles")).toBe(true); + expect(showsRoster("corp-changed")).toBe(true); + }); + + it("does not render roster for states with no data to show", () => { + expect(showsRoster("grant-needed")).toBe(false); + expect(showsRoster("roster-empty")).toBe(false); + }); +}); + +describe("rowTone", () => { + it("marks hull and armor reinforce states as bad — the fight is still on", () => { + expect(rowTone("hull_reinforce")).toBe("bad"); + expect(rowTone("armor_reinforce")).toBe("bad"); + }); + + it("marks vulnerable states as warn", () => { + expect(rowTone("shield_vulnerable")).toBe("warn"); + expect(rowTone("armor_vulnerable")).toBe("warn"); + expect(rowTone("unknown_vulnerable")).toBe("warn"); + }); + + it("marks healthy states as neutral", () => { + expect(rowTone("online")).toBe("neutral"); + expect(rowTone("unknown_state")).toBe("neutral"); + }); +}); diff --git a/tests/structure-webhook.test.ts b/tests/structure-webhook.test.ts new file mode 100644 index 0000000..d0cd2bb --- /dev/null +++ b/tests/structure-webhook.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; +import { postStructureWebhook, resolveStructureWebhookUrl } from "@/lib/ops-webhook"; +import { testConfig } from "./helpers/config"; + +function cfgWith(over: { structure?: string; ops?: string }) { + const base = testConfig(); + return { + ...base, + syncMode: "live" as const, + discord: { + ...base.discord, + structureWebhookUrl: over.structure, + opsWebhookUrl: over.ops, + }, + }; +} + +describe("resolveStructureWebhookUrl", () => { + it("prefers the structure webhook", () => { + expect( + resolveStructureWebhookUrl( + cfgWith({ structure: "https://s.example", ops: "https://o.example" }), + ), + ).toBe("https://s.example"); + }); + + it("falls back to the ops webhook", () => { + expect(resolveStructureWebhookUrl(cfgWith({ ops: "https://o.example" }))).toBe( + "https://o.example", + ); + }); + + it("is undefined when neither is set", () => { + expect(resolveStructureWebhookUrl(cfgWith({}))).toBeUndefined(); + }); +}); + +describe("postStructureWebhook", () => { + it("throws when no webhook is configured, rather than silently succeeding", async () => { + const fetchImpl = vi.fn(); + await expect( + postStructureWebhook(cfgWith({}), "boom", fetchImpl as unknown as typeof fetch), + ).rejects.toThrow(/not configured/i); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("posts to the resolved url", async () => { + const fetchImpl = vi.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response(null, { status: 204 }), + ); + await postStructureWebhook( + cfgWith({ structure: "https://s.example" }), + "hello", + fetchImpl, + ); + const url = String(fetchImpl.mock.calls[0][0]); + expect(url).toBe("https://s.example"); + }); +}); diff --git a/tests/sync-status.test.ts b/tests/sync-status.test.ts index b9eca5b..6e172dd 100644 --- a/tests/sync-status.test.ts +++ b/tests/sync-status.test.ts @@ -27,6 +27,8 @@ describe("getSyncStatus", () => { "purge", "location", "access-lists", + "structures", + "structure-events", ]; it("returns a row for every scheduled job on a fresh database", async () => {