Agents
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
# FORGE parity spec — rebuilding "Let it Snow" on FORGE (Husky design)
|
||||
|
||||
Deep-check of the initial app (`/Users/dmytrotkachenko/WebstormProjects/Let It Snow`)
|
||||
cross-referenced with `storage-dump.json` (its localStorage export) and the Husky
|
||||
design template. Goal: a **functionally near-identical** app under FORGE's stack
|
||||
(Vite+React+TS client · Express+TS server · Postgres `forge`) with a **new view**
|
||||
built from Husky's hand-rolled chart components.
|
||||
|
||||
Sources: 6 discovery agents (analytics charts · KPI/SLA/finance · board behavior ·
|
||||
data inventory · Husky design · architecture docs). All findings self-verified.
|
||||
|
||||
---
|
||||
|
||||
## 0. Load-bearing architecture facts
|
||||
|
||||
1. **Every board is a READ-ONLY mirror** rebuilt from storage by background scans.
|
||||
No drag-and-drop write-back to ServiceNow/Jira anywhere (the ADO board has local
|
||||
DnD only; the one write action is "Create Jira"). → FORGE keeps its model: the
|
||||
Chrome extension scans and POSTs to `/api/sync`; the web app renders DB state and
|
||||
links out to ServiceNow/Jira for changes. **No transitions to build.**
|
||||
2. **Single analytics source** in the app: `analytics_data` (954 `ticketsMeta` records
|
||||
+ precomputed aggregations). Everything else enriches it, joined on the **RITM number**.
|
||||
3. **No charting library** in either app — Let it Snow hand-rolls SVG/CSS; Husky does too.
|
||||
FORGE rebuilds charts as Husky components (or a real lib if we choose; spec assumes Husky-style).
|
||||
4. **Money rule:** `finalCost = 0` means *absence of price, not free work* — never counts
|
||||
as money, never gets a size, always a "missing cost" margin leak.
|
||||
5. **Currency:** display currency (default GBP); **fixed** cross-rates `1 GBP = 1.2 EUR = 20 MXN`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Feature/view map (initial app)
|
||||
|
||||
| Screen | Rebuild target in FORGE |
|
||||
|---|---|
|
||||
| Popup (RITM list + Jira tab) | N/A (extension already syncs; optional toolbar) |
|
||||
| **Dashboard: Kanban board + List view** | **Board page** (7-state columns, filters, card fields, list toggle) |
|
||||
| **Analytics panel — 3 tabs: Overall / Active / PMs KPI** | **3 analytics pages** (the bulk of "missing statistics") |
|
||||
| **PM Insights popup** (alert KPIs + drill-downs + Excel) | **Insights page** |
|
||||
| At-risk / AI Advisor | **OUT OF SCOPE** (user excluded AI) |
|
||||
| Jira board (read-only mirror) | **Jira board page** (later phase) |
|
||||
| Size CALC (labor→size estimator) | **Tool** (later phase) |
|
||||
| Figma links, Teams, Weather | out of scope / optional |
|
||||
|
||||
Excluded per DEAD_CODE.md: `melody.js`, removed finance-filter UI, live-FX fetch,
|
||||
USD from FX cache, Assist/Jira bar-graph toggle (replaced by dual calendars).
|
||||
|
||||
---
|
||||
|
||||
## 2. Statistics / charts inventory (29) → Husky component mapping
|
||||
|
||||
### TAB 1 — Overall (11)
|
||||
| # | Chart | Data | Husky component |
|
||||
|---|---|---|---|
|
||||
| 1 | Opened per month (grouped bars ±YoY, Brand/Market/CGO sub-group, bar/line) | `openedByMonth`/`ByDay`, `ticketsMeta` | new `GroupedBars` (from `MonthlyByUser`) + `TrendArea` |
|
||||
| 2 | Closed per month | `closedByMonth` | same |
|
||||
| 3 | Revenue per month (closed, cost/tickets, ±YoY) | `ticketsMeta.finalCost`+FX | `GroupedBars`/`TrendArea` |
|
||||
| 4 | Lifetime-at-close histogram (6 buckets) + median/avg/closed tiles | `ticketsMeta` open→close | `BucketBars` + `KpiTile`×3 |
|
||||
| 5 | Opened YoY pie (fair/full) | `openedByDay` | `StateDonut`/`DonutChart` |
|
||||
| 6 | Closed YoY pie | `closedByDay` | donut |
|
||||
| 7 | By business unit (per-year, brand drill-down) | `ticketsMeta.businessUnit` | `BucketBars` + expandable |
|
||||
| 8 | By requester (bars + YoY, market drill-down) | `byRequester` | `RequestorBars` + drill |
|
||||
| 9 | Ticket-share donut (#/%, top 18 + Others) | `byRequester` | `StateDonut`/`InsightChart` pie |
|
||||
| 10 | By brand (per-year segments, market drill, YoY) | `ticketsMeta.brand/market` | `BucketBars` segmented |
|
||||
| 11 | By market | same | `BucketBars` |
|
||||
|
||||
### TAB 2 — Active (6) — from live board tickets (status='active')
|
||||
| # | Chart | Data | Husky component |
|
||||
|---|---|---|---|
|
||||
| 12 | Open-not-closed by month | active tickets | `TrendArea`/bars |
|
||||
| 13 | By status (6 kanban cats) | active | `BucketBars` (state colors) |
|
||||
| 14 | Age histogram (0-50…200+ days) | active openedAt | `BucketBars` |
|
||||
| 15 | Time-in-status min/median/max | active | grouped mini-bars |
|
||||
| 16 | By brand, status-segmented (market drill) | active | stacked `BucketBars` |
|
||||
| 17 | Jira status durations (workflow order) | `jira_status_map.statusDurations` | `BucketBars` ordered |
|
||||
|
||||
### TAB 3 — PMs KPI (12)
|
||||
| # | Chart | Data | Husky component |
|
||||
|---|---|---|---|
|
||||
| 18 | Workload by month (revenue, prev/cur yr, per-PM) | `ticketsMeta` closed cost | `GroupedBars` + `MonthlyByUser` |
|
||||
| 19 | PM Engagement (backlog/Assist✓/Jira✓/Stale) + **dual daily-activity heatmaps** | live `meta.activity` + `jira movements` | `AssigneeBars` + **new `CalendarHeatmap`** |
|
||||
| 20 | Missing final cost (ranked PM bars + drill) | `ticketsMeta.finalCost` null | `AssigneeBars` + list |
|
||||
| 21 | Awaiting PO (ranked, Cost/Avg-d/Max-d, sortable) | finance PO + `sn_waiting_po_meta` + Jira | `AssigneeBars` + table |
|
||||
| 22 | No-cost by brand/market | derived | `BucketBars`×2 |
|
||||
| 23 | Awaiting-PO by brand/market | derived | `BucketBars`×2 |
|
||||
| 24 | **On-Time Delivery** heatmap (PM×size, cur+prev yr) + editable norms | close-days vs `otd_day_norms` | **new `KpiHeatmap`** |
|
||||
| 25 | **Avg days to close** heatmap | vs `avgdays_day_norms` | `KpiHeatmap` |
|
||||
| 26 | **Time to assign PM** heatmap | `firstAssignedDate` vs `asla_day_norms` | `KpiHeatmap` |
|
||||
| 27 | **Time to send preview** heatmap | `inUatAt` vs `psla_day_norms` | `KpiHeatmap` |
|
||||
| 28 | **Avg TTFR** heatmap | `ttfrMinutes` vs `lisr_ttfr_norms` (hrs) | `KpiHeatmap` |
|
||||
| 29 | **Avg PM response** heatmap | `clientRespMinutes` vs `lisr_cresp_norms` | `KpiHeatmap` |
|
||||
|
||||
**New components needed** (not in Husky): `KpiHeatmap` (PM×size grid vs norms, green/red),
|
||||
`CalendarHeatmap` (GitHub-style daily activity), `GroupedBars` (multi-year month bars — extend `MonthlyByUser`), a period/granularity control (year/quarter/month + ±YoY).
|
||||
|
||||
### KPI definitions (SLA grid #24–29)
|
||||
- **TTFR** = `ttfrMinutes/1440` (days), anchor `openedDate`, norm hours.
|
||||
- **PM response (cresp)** = `clientRespMinutes/1440`, anchor `openedDate`. (Our reply speed, not client wait.)
|
||||
- **Avg close** = `daysBetween(opened, closed)`, anchor `closedDate`.
|
||||
- **OTD** = same duration scored vs `otd_day_norms`; on-time% = onTime·100/scored.
|
||||
- **Assign SLA** = `max(0, daysBetween(fulfillment, firstAssigned))`, anchor `fulfillmentDate`.
|
||||
- **Preview SLA** = `daysBetween(toDoAt, inUatAt)`, anchor `inUatAt`.
|
||||
- Grid cell = `{avgDays, count, onTime, onTimePct}`, per PM×size; "No cost" column norm = 1d.
|
||||
- Periods: current vs previous Year, and Q1–Q4 each.
|
||||
|
||||
---
|
||||
|
||||
## 3. PM Insights alert KPIs (the Insights page)
|
||||
|
||||
Thresholds `insights_thresholds` (days): `unassigned:1, assigned:2, hold:5, wip:7,
|
||||
customerReplied:3, awaiting:7, lifetime:90, jiraStuck:7, jiraUAT:7, noChase:5,
|
||||
waitingPo1:7, waitingPo2:14, waitingPo3:21`.
|
||||
|
||||
Alert lists: Unassigned · Open/Assigned-long · On-Hold (age OR jiraBreached OR clientOwed)
|
||||
· WIP-stalled (Jira age) · WIP-no-Jira · Customer-replied · Awaiting-info · Lifetime-monsters
|
||||
(≥90d) · Inactive-requester · **∑ Total Alerts**. Waiting-PO with L1/L2/L3 escalation +
|
||||
`waitingPoRevenue`. PM-KPI table: **# Tickets · $ Revenue · ⚠ Rev-at-Risk** (sortable,
|
||||
hidden PMs from `pm_kpi_settings`). Excel export preserves PM→Size→Ticket grouping.
|
||||
|
||||
Support rules: `_jiraBreached`, `_clientOwed` (client replied last ≥1d), `_isAwaitingAgency`
|
||||
(WIP + last touch by client → "Customer replied").
|
||||
|
||||
---
|
||||
|
||||
## 4. Board page (7-state kanban + list)
|
||||
|
||||
Columns (fixed order): **Unassigned → Open/Assigned → On Hold → [WIP · Customer-replied ·
|
||||
Awaiting]* → Closed/Awaiting-PO**. `*` middle-3 reorderable via group-by (6 perms).
|
||||
- **Customer-replied** is a pseudo-status: WIP ticket where `lastActivityBy` ∉ colleague roster.
|
||||
- Column SLA subtitles ("assign within Nd", "max Nd in progress", …); per-column count.
|
||||
- Filters: search, brand, market, assignee(+region EU/LATAM sentinels), requester, custom-label,
|
||||
jira-assignee, jira-status, staleness (stale/updated/inactive/PO/jira-stuck/missing-cost), hide-empty, hide-cost.
|
||||
- Sort (14): default/number/status-age/lifetime/jira/brand/cost/due/analyzed.
|
||||
- **16 toggleable card fields** (`card_fields`): stateBadge, shortDesc, description, assignee,
|
||||
group, brand-market pill, raisedBy, lifetime, dueDate, stateChanged, lastActivity, comments,
|
||||
createJira, jiraStatus, jiraBar, teamsLink.
|
||||
- Card click → opens ServiceNow in new tab (no in-app transition). List view = same data as table.
|
||||
- Waiting-PO detection: synthetic closed card OR Jira "Waiting PO" OR empty PO cell in finance Excel.
|
||||
|
||||
---
|
||||
|
||||
## 5. Data contract — what to ingest (fixes "not all statistics moved")
|
||||
|
||||
FORGE currently seeds only 3 dump keys (`sn_tickets`, `analytics_meta_cache`, `jira_status_map`
|
||||
→ 966 tickets). The analytics engine needs the **full** dump:
|
||||
|
||||
| Dump key | Rows | Feeds |
|
||||
|---|---|---|
|
||||
| **`analytics_data.ticketsMeta`** | **954** | THE analytics dataset (SLA/cost/jira/dates) — charts 1-11,18-29 |
|
||||
| `analytics_data.{openedByMonth,closedByMonth,openedByDay,closedByDay}` | — | time-series (or recompute server-side) |
|
||||
| `analytics_data.byRequester(Numbers)` | 280 | requester/brand/market drill-downs |
|
||||
| `analytics_meta_cache` | 965 | closed-ticket enrichment |
|
||||
| `sn_tickets` | 100 | active board + activity timeline |
|
||||
| `sn_waiting_po_meta` | 884 | waiting-PO backlog |
|
||||
| `jira_status_map` | 127 | RITM↔Jira, statusDurations |
|
||||
| `jira_board_state`/`snapshot` | 1045 | Jira board page |
|
||||
| `finance_xlsx_data` | 1128 | PO/invoicing/milestones |
|
||||
| `insights_thresholds`, `pm_kpi_settings` | — | insights + PM roster |
|
||||
| `fx_rates_cache`, `lis_size_calc_cfg` | — | FX + size model |
|
||||
| `brand_colors`, `snow_colors`, `sn_states_order` | — | palettes + column order |
|
||||
| windowLocalStorage `*_norms` (psla/otd/ttfr/cresp/avgdays/asla) | — | SLA target lines |
|
||||
|
||||
**Ticket master fields** (from `ticketsMeta`): `number, shortDesc, state, year, brand, market,
|
||||
businessUnit, assignedTo, openedBy, openedDate, closedDate, firstAssignedDate, firstReplyAt,
|
||||
fulfillmentDate, toDoAt, inUatAt, ttfrMinutes, clientRespMinutes, finalCost, currencyCode, jiraKey`.
|
||||
|
||||
**Coverage:** ~24 months (2024-08 → 2026-08); 45 brands, 41 markets, 8 BUs, 10 PMs, 280 requesters;
|
||||
GBP/EUR/MXN. Data-quality: businessUnit casing dupes, currency blanks/junk ("NO"/"PART"),
|
||||
"(Inactive)" suffixes, finalCost is a string, two date formats — **normalize on ingest**.
|
||||
|
||||
---
|
||||
|
||||
## 6. Design mapping (Husky as template)
|
||||
|
||||
- **Stack/tokens:** Husky's SCSS-module system, `:root` tokens, `card`+`mono-label`+`srOnly`
|
||||
skeleton, `useInView`+`useCountUp` reveal hooks. Component = folder (`index.tsx` + `.module.scss`).
|
||||
- **Reuse verbatim:** `KpiTile`, `StateDonut`, `BucketBars`, `AssigneeBars`, `RequestorBars`,
|
||||
`TrendArea`, `MonthlyByUser`, `InsightChart`, plus `Stats`-page archetypes (`ActivityList`,
|
||||
`IssuesBySeverityCard`, `DonutCard`).
|
||||
- **Build new (Husky-styled):** `KpiHeatmap`, `CalendarHeatmap`, `GroupedBars`, period control.
|
||||
- **Palettes:** size `{XS:#14b8a6,S:#1a73e8,M:#f59e0b,L:#10b981,XL:#8b5cf6,XXL:#ef4444}`;
|
||||
year `['#f97316','#1a73e8','#16a34a','#dc2626','#7c3aed']`; state colors per §4; brand from `brand_colors`.
|
||||
- **FORGE already uses the indigo palette** (`#6366f1`) matching Let it Snow's `UI.primary` — keep it,
|
||||
or adopt Husky's Fluent blue. **DECISION NEEDED.**
|
||||
|
||||
---
|
||||
|
||||
## 7. Phased build plan
|
||||
|
||||
- **Phase 0 — Full data ingest (foundation).** Extend `dump-to-archives` + schema/seed to load
|
||||
`analytics_data.ticketsMeta` (954) + finance + waiting-PO + jira board + thresholds + norms +
|
||||
size cfg + palettes. Normalize dimensions. This alone restores the data behind every stat.
|
||||
- **Phase 1 — Server analytics engine.** Compute aggregations from the DB (opened/closed by
|
||||
month/day, byRequester/brand/market/BU, revenue/month, lifetime buckets, the 6 SLA metrics
|
||||
PM×size, missing-cost, waiting-PO, engagement). Expose `/api/analytics/*`. Tests.
|
||||
- **Phase 2 — Overall tab (charts 1-11).** New view on Husky components.
|
||||
- **Phase 3 — Active tab (12-17) + Board page redesign** (7-state, filters, card fields, list).
|
||||
- **Phase 4 — PMs KPI tab (18-29):** `KpiHeatmap` + editable norms + engagement + `CalendarHeatmap`.
|
||||
- **Phase 5 — PM Insights page** (alerts, drill-downs, Excel export).
|
||||
- **Phase 6 — Jira board page · Size CALC · finance detail** (as desired).
|
||||
|
||||
Each phase: engineer→reviewer→verifier; dba for schema; designer for new components.
|
||||
|
||||
---
|
||||
|
||||
## 8. Decisions to resolve before/within the build
|
||||
|
||||
1. **Size algorithm** — three coexist (nearest-nominal / largest-fits / Excel-MATCH). Pick one
|
||||
(recommend **composite** for cards, as the app defaults).
|
||||
2. **`psla_day_norms` default mismatch** (dashboard `{XS:1…XXL:15}` vs collector `{XS:3…XXL:30}`). Pick one.
|
||||
3. **FX** — keep fixed GBP-base rates (recommended; live-fetch was retired).
|
||||
4. **Palette** — keep FORGE indigo, or switch to Husky Fluent blue.
|
||||
5. **Scope of charts for v1** — all 29, or the high-value subset first (recommend Overall + SLA heatmaps).
|
||||
6. **Brand naming** — popup says "FORGE Tasks", product "Let it Snow" → FORGE.
|
||||
7. **Boards** — SNOW board is core; Jira/ADO boards are later/optional.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Artifact index
|
||||
- [engineer-20260829-122201](engineer-20260829-122201.md) — PM Insights: category items in "Alert categories" + "Watch list" now full-width single-column accordions with more space (grid→flex column, roomier card/row padding). CSS-only. v2.3.2. verifier PASS.
|
||||
- [engineer-20260829-120746](engineer-20260829-120746.md) — PM Insights: "Alert categories" + "Watch list" now collapsible accordions (new `Section`, `ChevronIcon`, `.grid[hidden]` collapse, `aria-expanded`/`aria-controls`). v2.3.1. verifier PASS.
|
||||
- [engineer-20260827-190753](engineer-20260827-190753.md) — Jira statusDurations+movements + chart #17 (Active tab), /api/analytics/jira-durations, seed-enriched from board_state. v2.3.0.
|
||||
- [engineer-20260827-185932](engineer-20260827-185932.md) — Extension Jira sync (SNOW→Jira, attach-only /api/sync/jira, custom-domain + PAT). v2.2.0. Live-verified attach-only.
|
||||
|
||||
One line per artifact (newest first) + open threads. Read on demand; don't bulk-load.
|
||||
|
||||
- [devops-20260827-182020](devops-20260827-182020.md) — Synology `push-to-nas.sh` deploy script + `.deploy.env.example` + `.env.example` rewrite + SETUP §6; RITM number → SNOW link in table & board cards.
|
||||
- **v2.0.0 / FORGE 2.0** — CHANGELOG.md created, versions bumped 1.0.0→2.0.0 (root+client), brand → "FORGE 2.0". Security audit HIGH-1/2/3 + MED-1 fixed (admin-deletes-admin only, last-admin guard, session purge on delete, session regenerate on login, 404 on missing). Verified live.
|
||||
- [audit-20260827-180746](audit-20260827-180746.md) — EXHAUSTIVE parity gap analysis (initial app → FORGE). Done: Overall (~9/11), Active (5/6), 6 SLA heatmaps, Insights, 6-col board+export, RBAC. Missing: PMs-KPI charts 18–23 + period/editable-norms, YoY pies #5/#6, #17 jira durations, #15 min/median/max, Jira board, Size CALC, options/config editors, deep sync (SCTASK/jira/closed/finance/statusDurations), board sort/group/card-fields/filters, 7th synthetic column, insights Excel export. **Open:** prioritized phases A–F for principal.
|
||||
- [security-20260827-180635](security-20260827-180635.md) — RBAC/token/user audit. 3 HIGH (lead-deletes-admin, last-admin lockout, session survives user delete), 2 MED, 3 LOW. Gating matrix + token-hash non-leak verified. **Open:** fixes to engineer/dba.
|
||||
- [architect-20260827-180544](architect-20260827-180544.md) — ADR: extension as single sync engine for SNOW + Jira. Keep SNOW same-origin session; add Jira via direct REST + API token; new attach-only `POST /api/sync/jira` (no DB reshape, enrich `jira` JSONB). **Open:** engineer to build endpoint + Phase B.
|
||||
- [designer-20260827-180842](designer-20260827-180842.md) — Excel export design pass: indigo frozen header, per-column widths, right-aligned numbers, #,##0 cost, short dates, grid borders. Validated → valid xlsx.
|
||||
- [engineer-20260827-180000](engineer-20260827-180000.md) — implemented RBAC (4 roles), Admin page (Users+Tokens), filtered Excel export, full-width charts, donut fix. **verifier PASS.**
|
||||
- [principal-20260827-175448](principal-20260827-175448.md) — Auth/RBAC (4 roles) + API-token page + filtered Excel export. Plan + capability matrix.
|
||||
- [FORGE-parity-spec](FORGE-parity-spec.md) — full inventory of the initial app (29 charts, PM Insights, boards) → data contract → Husky component mapping → phased build plan. Phases 0–3, 5 + Overall-fidelity done.
|
||||
- [verifier-log](verifier-log.md) — running PASS/REDO log of every verifier gate.
|
||||
|
||||
## Delivered so far (all verifier-PASS)
|
||||
- Phase 0 data ingest · 1 analytics engine · 2 Overall tab (+ full fidelity) · 3 Active tab + board redesign · 5 PM Insights.
|
||||
- verifier-log.md · verifier PASS on engineer-20260827-180000 (RBAC+token page+filtered xlsx+full-width+donut fix); live DB verified donut center=966, Rowena≈8%; schema additive; token_hash not leaked
|
||||
- verifier PASS on engineer-20260827-185932 (extension Jira-sync v2.2.0): attach-only /api/sync/jira + attachJira; TEMP-TABLE smoke proved no SNOW-field clobber, no SQLi, jsonb merge preserves omitted keys; schema unchanged (additive).
|
||||
- verifier-20260827 · verifier · PASS on Jira statusDurations+chart #17 (v2.3.0) — see verifier-log.md
|
||||
@@ -0,0 +1,324 @@
|
||||
# ADR: The Chrome extension as the single sync engine for SNOW + Jira
|
||||
|
||||
- **Status:** Proposed (design input for a later build — no code here)
|
||||
- **Date:** 2026-08-27
|
||||
- **Author:** architect
|
||||
- **Supersedes:** nothing — extends the existing SNOW-only sync (`extension/`, `/api/sync`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
Today the extension syncs **ServiceNow only**. `background.js` runs a collector in the
|
||||
`rbassist.service-now.com` page (`world: MAIN`), pages the Table API same-origin using the
|
||||
live session cookie + `g_ck`, maps `sc_req_item` rows to the FORGE ticket shape, and POSTs
|
||||
them in 100-row chunks to `<serverUrl>/api/sync` with `Authorization: Bearer fg_…`.
|
||||
|
||||
Jira data currently only exists in the **seed dump** (`jira_status_map` 127 rows,
|
||||
`jira_board_state` 1045 rows — see `FORGE-parity-spec.md`). It goes stale the moment the
|
||||
dump is loaded. The live `tickets.jira` JSONB is typed as **`JiraInfo`** and is only ever
|
||||
written by the seed path, never refreshed. **Its declared shape is not 5 fields** — it is
|
||||
**6** (`server/types.ts:11-18`, mirrored in `client/src/types/ticket.types.ts:9-16`):
|
||||
|
||||
```ts
|
||||
interface JiraInfo {
|
||||
status?; statusChangedAt?; key?; url?; assignee?; // the 5 populated by seed today
|
||||
movements?: { at: string; who: string }[]; // ALREADY declared — see §5
|
||||
}
|
||||
```
|
||||
|
||||
The seed only populates the first five; `movements` exists in the type but is currently
|
||||
unwritten. This matters: `movements` is **not** a field we get to invent (§5).
|
||||
|
||||
We want **one "Sync now"** to refresh both sources so the Active tab (chart 17 Jira status
|
||||
durations, chart 19/20 movements) and the future Jira board page stay live.
|
||||
|
||||
**Load-bearing finding — the current upsert cannot be reused for Jira as-is.**
|
||||
`server/db.ts` `upsertTickets` ON CONFLICT does **`status=EXCLUDED.status`,
|
||||
`state=EXCLUDED.state`, `short_desc=EXCLUDED.short_desc`, `assigned_to=EXCLUDED.assigned_to`,
|
||||
`assignment_group=EXCLUDED.assignment_group`, `last_activity_at/by=EXCLUDED.…`,
|
||||
`updated_at=EXCLUDED.…`** — these are **overwritten, not COALESCE-preserved**. A Jira-only
|
||||
payload routed through `/api/sync` would pass `normalizeIncoming`, which defaults
|
||||
`status→'active'`, `state→''`, `shortDesc→''`, `assignedTo→null`, `assignmentGroup→null`,
|
||||
`last_activity_*→null` — **wiping the SNOW core fields of every matched ticket** (and
|
||||
mis-flipping closed RITMs back to active). Only `jira` itself is COALESCE-merged. So Jira
|
||||
must **not** ride the same endpoint/upsert.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
1. **Keep SNOW exactly as-is** (in-page same-origin session collector → `/api/sync`).
|
||||
2. **Add Jira as a second transport in the same extension**, using **direct Jira Cloud REST
|
||||
from the service worker** authenticated with a **Jira API token** (email + token, HTTP
|
||||
Basic). No Jira browser tab required.
|
||||
3. **Route Jira through a new, dedicated server endpoint `/api/sync/jira`** that performs an
|
||||
**attach-only UPDATE** — it writes **only** `jira` (JSONB) and `jira_key`, keyed by
|
||||
RITM `number`, and **never touches** `status/state/assignee/activity`. This sidesteps the
|
||||
clobber above and gives clean partial-failure semantics.
|
||||
4. **No DB shape change.** Jira status/durations/movements go **inside the existing `jira`
|
||||
JSONB**, which the extension sends as one enriched object. Additive only.
|
||||
5. **"Sync now" = SNOW first, then Jira** (sequential, so Jira attaches to freshly-synced
|
||||
rows), each phase chunked and reporting its own count.
|
||||
|
||||
### Why the SNOW-session / Jira-API-key split (not one mechanism)
|
||||
|
||||
| | ServiceNow | Jira Cloud |
|
||||
|---|---|---|
|
||||
| Auth we have | Live browser **session cookie + `g_ck`** | First-class **API token** (Atlassian id.atlassian.com → API tokens) |
|
||||
| Personal API token | Not reliably available / instance-policy dependent; storing SNOW creds is worse | Designed for exactly this; scoped, revocable |
|
||||
| Needs a logged-in tab | **Yes** (already the case; user is in SNOW all day) | **No** — SW fetch with host permission works headless |
|
||||
| CSRF | `X-UserToken: g_ck` required | Not applicable (Basic auth) |
|
||||
|
||||
The split is the *cheap* option on both sides: SNOW keeps the zero-secret session approach
|
||||
that already works; Jira uses the mechanism Atlassian actually blesses. Forcing symmetry
|
||||
(e.g. scraping a Jira tab same-origin) would add a fragile MAIN-world collector and require
|
||||
the user to keep a Jira tab open — strictly worse than a token.
|
||||
|
||||
### Does Jira require a board id? **Yes — and support a list, default one.**
|
||||
|
||||
To reproduce the original board (`rapidView=13793`) and its **per-status durations +
|
||||
movements**, the extension reads the **Agile REST** endpoint
|
||||
`GET /rest/agile/1.0/board/{boardId}/issue` (issues in board order) plus each issue's
|
||||
`changelog` (`GET /rest/api/3/issue/{key}?expand=changelog`) to reconstruct status transition
|
||||
timestamps → durations/movements. **Board order and column mapping only exist per board**, so
|
||||
a board id is mandatory for board-faithful output. A pure JQL search (`/rest/api/3/search`)
|
||||
does *not* need a board but loses column order and the board's status→column mapping.
|
||||
|
||||
**Recommendation:** primary input is **one board id** (the `13793` analogue). Store it as a
|
||||
**list** so a second board can be added later without a settings migration, but the UI
|
||||
defaults to a single field. Provide an **optional JQL override** for power cases (e.g.
|
||||
`project = XYZ AND updated >= -14d`); when JQL is set it augments the board fetch's filter,
|
||||
it does not replace the board (we still need the board for column mapping).
|
||||
|
||||
---
|
||||
|
||||
## 3. Settings schema (extension options → `chrome.storage.local`)
|
||||
|
||||
```
|
||||
{
|
||||
// FORGE (unchanged names — back-compat with today's build)
|
||||
serverUrl: "https://forge.mycloud.dp.ua", // FORGE API domain
|
||||
token: "fg_…", // FORGE portal key, minted at Admin→Tokens, revocable
|
||||
|
||||
// Jira (new)
|
||||
jira: {
|
||||
baseUrl: "https://rocketmill.atlassian.net", // Jira Cloud site
|
||||
email: "svc-forge@…", // Atlassian account email (Basic auth username)
|
||||
apiToken: "ATATT…", // Jira API token (Basic auth password) — SECRET
|
||||
boardIds: [13793], // list; UI defaults to one
|
||||
jql: "" // optional override/filter, may be blank
|
||||
closedLookbackDays: 14 // 0 = active-only (see §6)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- FORGE Basic-of-nothing: FORGE keeps `Authorization: Bearer <fg_ token>`.
|
||||
- Jira auth header: `Authorization: Basic base64(email + ":" + apiToken)`.
|
||||
- On **Save**, request host permission for **both** origins (as options.js already does for
|
||||
the FORGE origin): the FORGE server origin **and** `https://<site>.atlassian.net/*`.
|
||||
- A **"Test Jira"** button (mirror of the existing "Test") calls
|
||||
`GET {baseUrl}/rest/api/3/myself` and reports 200/401.
|
||||
|
||||
---
|
||||
|
||||
## 4. Two transports & manifest implications
|
||||
|
||||
- **SNOW:** unchanged. `host_permissions: ["https://rbassist.service-now.com/*"]` stays
|
||||
required; collector runs in the page; session cookie + `g_ck` do the work.
|
||||
- **Jira:** fetched **from the service worker** (not a page). In MV3, a service-worker
|
||||
`fetch` to a host listed in `host_permissions` is **exempt from page CORS** — the extension
|
||||
is treated as a first-party origin for granted hosts, so Atlassian's (restrictive) CORS
|
||||
headers are irrelevant. **This only holds with the host permission granted**; without it the
|
||||
fetch is a normal cross-origin call and fails preflight.
|
||||
- Add the Jira site to **`optional_host_permissions`** and request it dynamically at Save
|
||||
time (same pattern as the FORGE origin today), rather than hard-coding a static
|
||||
`host_permissions` entry — the site host is per-deployment and least-privilege favors
|
||||
granting exactly the one instance the user configures. `optional_host_permissions` already
|
||||
contains `https://*/*`, which technically covers it, but an explicit narrow grant is
|
||||
cleaner and survives a future tightening of that wildcard.
|
||||
- No new manifest `permissions` needed (`storage`, `scripting` already present; Jira uses
|
||||
neither `scripting` nor `tabs`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Dedup & merge
|
||||
|
||||
- **FORGE key is `number`** (RITM). No ticket dupes — that invariant is untouched; Jira never
|
||||
inserts a ticket.
|
||||
- **RITM ↔ Jira link:** for each Jira issue, resolve the RITM number from
|
||||
**`customfield_26001`** (holds the RITM), falling back to a **summary regex** (`/RITM\d+/`),
|
||||
exactly as the initial app did. Build a map `RITM number → enriched jira object`.
|
||||
- **Jira issue with no RITM:** **skip it and count it.** FORGE is RITM-centric and single-user;
|
||||
an unlinked Jira issue has nowhere to attach and creating a ghost ticket would violate the
|
||||
"no dupes / SNOW owns the ticket row" model. Report the unlinked count in the sync status so
|
||||
the user knows a link (customfield/summary) is missing. (A future "orphan Jira" store is out
|
||||
of scope — not kanban, not this ADR.)
|
||||
- **What lands in `jira` JSONB (superset of today's shape):**
|
||||
```
|
||||
{ key, url, status, statusChangedAt, assignee, // 5 existing fields, unchanged
|
||||
movements, // EXISTING field — reuse shape [{ at, who }] (NOT { from, to, at })
|
||||
statusDurations, // NEW additive — { "In Progress": mins, "In UAT": mins, ... }
|
||||
board } // NEW additive — { id, column } for the board page
|
||||
```
|
||||
- **`movements` already exists in the `JiraInfo` type** (`server/types.ts:17` +
|
||||
`client/src/types/ticket.types.ts:15`) as **`{ at: string; who: string }[]`**. The extension
|
||||
**must populate that existing shape**, not redefine it to `{ from, to, at }`. Reconstruct
|
||||
`who` from the changelog author and `at` from the transition timestamp. (I confirmed
|
||||
`movements` currently has **no runtime consumer** — only the two type declarations — so a
|
||||
different shape *could* be adopted, but doing so is a deliberate change to the `JiraInfo`
|
||||
contract in two TS files, not "additive JSONB." **Recommendation: keep `{ at, who }`.** If the
|
||||
chart genuinely needs `from`/`to`, add them as *extra optional* keys on each entry
|
||||
(`{ at, who, from?, to? }`) rather than dropping `at`/`who` — that stays backward-compatible
|
||||
and is still a one-line `JiraInfo` edit, called out here so the engineer expects it.)
|
||||
- **`statusDurations` and `board` are genuinely new**, additive optional keys — add them to the
|
||||
`JiraInfo` interface (both files) alongside `movements`. This is a **type-declaration touch,
|
||||
not a DB shape change**: the `jira` column is already `JSONB` and stores whatever the object
|
||||
holds. Flagging it explicitly so it isn't mistaken for a zero-code change.
|
||||
- **Server (DB) shape change: none to columns.** The `jira` column is already `JSONB`. The new
|
||||
endpoint replaces the whole object per ticket (the extension always sends the complete
|
||||
enriched object it just computed), so there is no partial-merge ambiguity and no reshape of
|
||||
any existing column. `jira_key` (existing TEXT column) is set from `jira.key` when present.
|
||||
The only code-level shape edit is the additive `JiraInfo` TS interface above.
|
||||
|
||||
### New endpoint contract (attach-only)
|
||||
|
||||
```
|
||||
POST /api/sync/jira (requireToken — same fg_ Bearer as /api/sync)
|
||||
body: { issues: [ { number, jira: {…enriched…} }, … ] } // chunked, 100
|
||||
per row: UPDATE tickets
|
||||
SET jira = $2::jsonb,
|
||||
jira_key = COALESCE($3, jira_key),
|
||||
synced_at = NOW()
|
||||
WHERE number = $1
|
||||
resp: { updated: <rows hit>, unmatched: <numbers not found>, unlinked: <issues w/o RITM> }
|
||||
```
|
||||
|
||||
Because it is an **UPDATE … WHERE number =**, a Jira payload for an RITM not yet in FORGE
|
||||
simply affects 0 rows (counted as `unmatched`) — it **cannot** create a stub row or flip
|
||||
`status`/`state`/`assignee`. This is the whole reason for a separate endpoint rather than
|
||||
folding into `/api/sync`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Sync flow & scheduling
|
||||
|
||||
`Sync now` (popup) → service worker `runSync()`:
|
||||
|
||||
1. **Phase A — SNOW** (unchanged): collect active RITMs same-origin → `POST /api/sync` in
|
||||
100-chunks. On failure: **abort before Phase B** (don't attach Jira to a stale ticket set)
|
||||
and report the SNOW error as today.
|
||||
2. **Phase B — Jira:** for each `boardId`, fetch board issues (+ changelog), resolve RITM,
|
||||
build enriched `jira` objects, → `POST /api/sync/jira` in 100-chunks. On failure: Phase A is
|
||||
**already committed and intact**; surface a *warning* ("SNOW synced ✓, Jira failed: …")
|
||||
rather than a hard error. Report `updated / unmatched / unlinked`.
|
||||
3. Push a combined `{ state, snowCount, jiraUpdated, jiraUnmatched, at }` to `syncStatus` for
|
||||
the popup.
|
||||
|
||||
- **Sequential, not parallel:** Jira must attach to rows SNOW just wrote.
|
||||
- **Chunking:** 100 on both push directions (matches today). Jira *read* is paged by the Agile
|
||||
API (`maxResults`/`startAt`, 50–100) — page defensively with a hard ceiling like the SNOW
|
||||
collector's `offset < 2000` guard.
|
||||
- **Separate endpoint, not folded:** decided in §2/§5 — clobber-safety + independent
|
||||
partial-failure reporting.
|
||||
- **Scheduling:** keep **manual "Sync now"** for v1 (single-user, user is at the desk). A
|
||||
`chrome.alarms` periodic sync is a trivial later add but out of scope; note that periodic
|
||||
Jira sync consumes API-token rate budget even when idle.
|
||||
|
||||
---
|
||||
|
||||
## 7. Scope of tickets
|
||||
|
||||
- **SNOW:** stays **active-only** (the collector query is `active=true`).
|
||||
- **Jira:** default **active board + a small closed lookback** (`closedLookbackDays`, e.g. 14)
|
||||
so *recently* closed issues' durations/movements stay fresh for charts 17/19/20. `0` =
|
||||
active-only.
|
||||
- **Not** full history. Trade-offs:
|
||||
- *For historical sync:* analytics freshness on old tickets.
|
||||
- *Against (decisive):* Jira Cloud API-token rate limits + wall-clock cost of walking every
|
||||
issue's changelog; and history is already owned by the **seed dump**
|
||||
(`analytics_data.ticketsMeta` 954 rows is THE historical dataset). Live-syncing 1000+
|
||||
closed issues on every "Sync now" is wasteful and slow.
|
||||
- **Verdict:** *seed handles history; the extension keeps active + a short closed window.*
|
||||
If someone needs a full historical refresh, that's a re-seed, not a per-click sync.
|
||||
|
||||
---
|
||||
|
||||
## 8. Security
|
||||
|
||||
- **Jira API token is a real secret in `chrome.storage.local`** — which is **not encrypted at
|
||||
rest** and is readable by anyone with the OS user's Chrome profile on disk. Mitigations to
|
||||
bake into the build:
|
||||
- Use a **dedicated low-privilege Jira service account** with **read-only** project access,
|
||||
not a personal admin token. Blast radius on leak = read a board.
|
||||
- Store **email + API token**, never a password. API tokens are individually revocable from
|
||||
Atlassian without disturbing the account.
|
||||
- **Never log** the token, the `Authorization` header, or issue bodies (the SNOW collector
|
||||
already treats `g_ck`/token this way — hold the same line for Jira).
|
||||
- Document in options UI that the token is stored locally and to revoke it from Atlassian if
|
||||
the machine is compromised.
|
||||
- **FORGE `fg_` token** is already **revocable via Admin→Tokens** and `requireToken`-gated —
|
||||
rotate freely; the new `/api/sync/jira` reuses the **same** Bearer, no new server secret.
|
||||
- **CORS / host model:** covered in §4 — Jira REST works *because* the SW holds the Jira host
|
||||
permission (CORS-exempt for granted hosts); grant exactly the one Atlassian site,
|
||||
least-privilege, requested at Save.
|
||||
- **Server input hardening:** `/api/sync/jira` must validate `number` is present and `jira` is
|
||||
an object, and (like `sanitizeActivity`) coerce the enriched sub-fields before writing JSONB,
|
||||
so a malformed `movements`/`statusDurations` can't later crash a render.
|
||||
|
||||
---
|
||||
|
||||
## 9. Alternatives considered
|
||||
|
||||
- **A. Fold Jira into `/api/sync`.** Rejected — the ON CONFLICT overwrites SNOW core fields
|
||||
from a thin Jira payload (§1). Would require rewriting the upsert to COALESCE `status`/
|
||||
`state`/`assignee`, which then breaks SNOW's own need to *set* those. A second endpoint is
|
||||
simpler and safer than making one upsert serve two very different payloads.
|
||||
- **B. Scrape Jira same-origin from a Jira tab (mirror SNOW).** Rejected — needs a logged-in
|
||||
Jira tab, a fragile MAIN-world collector, and gives no advantage over the sanctioned API
|
||||
token.
|
||||
- **C. Server-side Jira sync (cron on the FORGE box, no extension).** Rejected for now — it
|
||||
moves the Jira secret to the server (fine) but **splits sync into two engines**, contradicting
|
||||
the goal of *one* "Sync now", and the RITM↔Jira resolution logic would live in two places.
|
||||
Revisit only if we later want unattended periodic sync.
|
||||
- **D. New `jira_*` columns / a `jira_status` table.** Rejected — violates "prefer additive
|
||||
JSONB, no existing-column reshape"; the `jira` JSONB already exists and is the right home.
|
||||
- **E. JQL-only, no board id.** Rejected as the default — loses column order/mapping the board
|
||||
page needs; kept as an optional filter override.
|
||||
|
||||
---
|
||||
|
||||
## 10. Risks
|
||||
|
||||
- Jira **changelog walking** is the expensive part; a large board × per-issue changelog fetch
|
||||
can be slow / hit rate limits. Mitigate with the closed-lookback window and paging ceilings.
|
||||
- **`customfield_26001` id is instance-specific** — if it differs on this Jira site the
|
||||
RITM-link falls back to summary regex only; surface the `unlinked` count so this is visible.
|
||||
- **`optional_host_permissions` wildcard** (`https://*/*`) is broad; the explicit Atlassian
|
||||
grant is cleaner but the wildcard's presence is a standing review flag (unrelated to this
|
||||
change, worth noting to `/security`).
|
||||
- Token in `chrome.storage.local` (§8) — accepted risk, mitigated by service-account scope.
|
||||
|
||||
---
|
||||
|
||||
## 11. Migration
|
||||
|
||||
- **DB:** **none.** No column add, no reshape — `jira` JSONB and `jira_key` already exist.
|
||||
`initDB()` is untouched; no manual SQL script, no rollback needed.
|
||||
- **Types:** **additive** — extend the existing `JiraInfo` interface (`server/types.ts` +
|
||||
`client/src/types/ticket.types.ts`) with new optional keys `statusDurations` and `board`;
|
||||
**reuse** the already-declared `movements?: { at, who }[]` (do not redefine it). Optional
|
||||
keys keep every existing consumer compiling.
|
||||
- **Server:** purely **additive** — new `POST /api/sync/jira` handler + a small
|
||||
`attachJira(number, jira)` in `server/db.ts`. Old clients that only hit `/api/sync` keep
|
||||
working unchanged.
|
||||
- **Extension:** additive options fields + Phase B. An un-upgraded extension still syncs SNOW.
|
||||
- **Rollout order:** ship the server endpoint first (inert until called) → then the extension
|
||||
update → then populate the Jira settings. Fully backward-compatible at every step.
|
||||
|
||||
---
|
||||
|
||||
## Next
|
||||
engineer — build the additive `POST /api/sync/jira` + `attachJira()` (attach-only UPDATE, §5)
|
||||
first; then extend the extension options (§3 schema) and `background.js` Phase B (§6). Route
|
||||
the DB touch through /dba and the Jira-token storage through /security before merge.
|
||||
@@ -0,0 +1,301 @@
|
||||
# audit — Gap analysis: "Let it Snow" → FORGE (what has NOT been ported)
|
||||
|
||||
**Date:** 2026-08-27 · Author: audit (read-only) · Scope: full parity sweep
|
||||
**Checklist source:** [FORGE-parity-spec.md](FORGE-parity-spec.md) (29 charts + PM-Insights + boards + data contract)
|
||||
**Compared against:** live FORGE code (`server/*.ts`, `client/src/**`, `extension/*`) and the initial app source
|
||||
(`/Users/dmytrotkachenko/WebstormProjects/Let It Snow/Let it Snow/*.js`, verified by grep — files are huge).
|
||||
|
||||
Legend: ✅ done · 🟡 partial · ❌ missing. Value: **MH** must-have · **NTH** nice-to-have · **LOW**.
|
||||
|
||||
---
|
||||
|
||||
## 0. Executive summary
|
||||
|
||||
FORGE has delivered a solid **read-only spine**: full data ingest (966 tickets + finance + jira + config),
|
||||
a server analytics engine, the **Overall** tab (~9 of 11 charts), the **Active** tab (5 of 6), the **6 SLA
|
||||
heatmaps** (charts 24–29), **PM Insights** (alert groups + waiting-PO + per-PM roll-up), a **6-column board**
|
||||
with list view + filtered Excel export, and **new** RBAC/Users/Tokens admin (beyond the original).
|
||||
|
||||
What is **not** ported is roughly **half of the analytics surface and nearly all of the operator tooling**:
|
||||
|
||||
- **PMs-KPI tab** is only the 6 heatmaps — charts **18–23** (workload, engagement + dual calendar heatmaps,
|
||||
missing-cost, awaiting-PO money, brand/market sub-cards) are **absent**, and the heatmaps have **no
|
||||
period/year granularity and no editable norms**.
|
||||
- **Board** is missing the 7th synthetic column, 13 of 16 card fields, all 14 sorts, all 6 group-by
|
||||
permutations, and ~9 of 11 filters (region, staleness, jira, requester, custom-label…).
|
||||
- **Whole features missing:** the standalone **Jira board**, the **Size CALC** estimator, the **finance
|
||||
view**, and the entire **options/config editor surface** (brand colours, norms, thresholds, card-field
|
||||
toggles, colleagues/region, currency, custom labels).
|
||||
- **Sync** is shallow: active-**RITM only**, 9 fields, **no SCTASK, no Jira, no closed, no finance,
|
||||
no statusDurations** collectors.
|
||||
- Overall/Active charts lack the **YoY pies (#5/#6)**, **CGO overlay**, **#/% toggle**, **Jira status
|
||||
durations (#17)**, and **time-in-status min/median/max (#15)**.
|
||||
|
||||
Per the spec, **AI Advisor / at-risk, weather** are intentionally out of scope (confirmed below).
|
||||
|
||||
---
|
||||
|
||||
## 1. Overall analytics tab — `client/src/pages/Overview/index.tsx` + `server/analytics.ts:getOverview`
|
||||
|
||||
| # | Item | Status | Note / where it lives |
|
||||
|---|---|:--:|---|
|
||||
| 1 | Opened per month | ✅ | `GroupedBars` — multi-year grouped, bars/line toggle, YoY badge. |
|
||||
| 2 | Closed per month | ✅ | `GroupedBars`, same. |
|
||||
| 3 | Revenue per month (cost/tickets, ±YoY) | ✅ | `GroupedBars` + Cost/Tickets toggle in Overview; GBP via fixed FX. |
|
||||
| 4 | Lifetime-at-close histogram + median/avg/closed tiles | ✅ | `BarList` 6 buckets + median tile. `avgDays`/`closed` computed server-side. |
|
||||
| 5 | **Opened YoY pie (fair/full)** | ❌ | No opened-YoY donut. Would live in Overview as a `DonutChart`; server has `openedByMonth` but no fair-window (same-day-of-year) series. **MH** |
|
||||
| 6 | **Closed YoY pie (fair/full)** | ❌ | Same — no closed-YoY donut / fair-vs-full comparison. **MH** |
|
||||
| 7 | By business unit (per-year, **brand drill**) | 🟡 | Flat `BarList` (`byBusinessUnit`), **no per-year split, no brand drill-down**. `server/analytics.ts:169`. **NTH** |
|
||||
| 8 | By requester (bars + YoY, **market drill**) | 🟡 | `ExpandBarList` drills requester→**brand** (spec wants →market), **no YoY**. `analytics.ts:170`. **NTH** |
|
||||
| 9 | Ticket-share donut (#/%, top 18 + Others) | 🟡 | `DonutChart` on `byRequesterShare` (full 966 dist — the REDO fix). **No #/% toggle, no top-18+Others rollup.** **NTH** |
|
||||
| 10 | By brand (per-year **segments**, market drill, YoY) | 🟡 | `ExpandBarList` brand→market counts only. **No per-year segments, no YoY, no size/status segmentation.** **NTH** |
|
||||
| 11 | By market | 🟡 | Flat `BarList`, top-20. No brand drill / segments. **LOW** |
|
||||
| — | Toggle: bars/line | ✅ | `GroupedBars` per-chart. |
|
||||
| — | Toggle: ±YoY | 🟡 | Shown as a computed badge, not a user toggle; only on the month `GroupedBars`. **LOW** |
|
||||
| — | Toggle: cost/tickets | 🟡 | Only on the Revenue chart; original applies it across money charts. **LOW** |
|
||||
| — | Toggle: #/% | ❌ | No percentage mode anywhere. **NTH** |
|
||||
| — | Growth badges | 🟡 | Only the month-total YoY badge; no per-series/per-requester growth. **LOW** |
|
||||
| — | **CGO overlay** | ❌ | Original overlays a "CGO" series per group (`dashboard.js` `CgoBars`/`CGO_REQUESTER`, toggle). Not in FORGE — needs a CGO flag on tickets + overlay in `GroupedBars`. **NTH** |
|
||||
|
||||
**Bottom line:** the month/revenue/lifetime core is faithful; the **two YoY pies are missing outright**, and
|
||||
the by-dimension charts are **flat counts without the per-year segmentation, drill, and YoY** the original had.
|
||||
|
||||
---
|
||||
|
||||
## 2. Active tab — `client/src/pages/Active/index.tsx` (client-computed from active tickets)
|
||||
|
||||
| # | Item | Status | Note / where it lives |
|
||||
|---|---|:--:|---|
|
||||
| 12 | Open-not-closed by month | ✅ | `TrendChart` area, `openedByMonth`. |
|
||||
| 13 | By status (kanban cats) | ✅ | `BarList` over `BOARD_COLUMNS`, state colours. |
|
||||
| 14 | Age histogram (0-50…200+ d) | ✅ | `BarList`, 5 buckets. |
|
||||
| 15 | **Time-in-status min/median/max** | 🟡 | Only **median** days-in-state per column (`Active/index.tsx:62`). No min/max grouped mini-bars. **NTH** |
|
||||
| 16 | By brand, **status-segmented** (market drill) | 🟡 | Flat `BarList` of active-by-brand. **No status segmentation, no market drill.** **NTH** |
|
||||
| 17 | **Jira status durations (workflow order)** | ❌ | Requires `statusDurations` from `jira_status_map` — **not ingested** (only `status`/`statusChangedAt`/`key` land in `jira` JSONB; see `scripts/dump-to-archives.mjs:36`). No chart. **MH** for Jira ops. |
|
||||
|
||||
---
|
||||
|
||||
## 3. PMs-KPI tab — `client/src/pages/SlaKpi/index.tsx` + `server/analytics.ts:getSlaHeatmaps`
|
||||
|
||||
The whole tab is currently **only** the 6 SLA heatmaps. The original "PMs KPI" tab was 12 items (18–29).
|
||||
|
||||
### 3a. The 6 SLA heatmaps (24–29) — present but reduced
|
||||
| # | Item | Status | Note |
|
||||
|---|---|:--:|---|
|
||||
| 24 | On-Time Delivery heatmap | ✅ | `otd` metric, PM×size, on-time% vs `otd` norms. |
|
||||
| 25 | Avg days to close heatmap | ✅ | `avgclose` vs `avgdays`. |
|
||||
| 26 | Time to assign PM heatmap | ✅ | `assign` = `max(0, fulfillment→firstAssigned)` vs `asla`. |
|
||||
| 27 | Time to send preview heatmap | ✅ | `preview` = `toDoAt→inUatAt` vs `psla`. |
|
||||
| 28 | Avg TTFR heatmap | ✅ | `ttfr` = `ttfrMinutes/1440` vs `ttfr` norms (hrs). |
|
||||
| 29 | Avg PM response heatmap | ✅ | `cresp` = `clientRespMinutes/1440` vs `cresp` norms. |
|
||||
| — | **Current + previous year** side by side | ❌ | `getSlaHeatmaps` computes one all-time grid; original renders cur-yr + prev-yr heatmaps. **MH** |
|
||||
| — | **Period granularity (year / Q1-Q4 / month)** | ❌ | No period control at all. **MH** |
|
||||
| — | **Editable norms panels** | ❌ | Norms are read-only from `app_config`/`config.json`; original has inline norm editors (`buildNormsPanel` etc.) that persist. Needs a write endpoint + UI. **MH** |
|
||||
| — | Hidden PMs from `pm_kpi_settings` | ❌ | `getSlaHeatmaps:223` has a stubbed `hidden = new Set()` — never populated. **NTH** |
|
||||
|
||||
### 3b. Charts 18–23 — entirely missing
|
||||
| # | Item | Status | Note / where it'd live |
|
||||
|---|---|:--:|---|
|
||||
| 18 | **Workload by month** (revenue prev/cur yr, per-PM) | ❌ | New `GroupedBars`+per-PM series; new server agg (closed cost by PM×month). **MH** |
|
||||
| 19 | **PM Engagement** (backlog/Assist✓/Jira✓/Stale) + **dual daily-activity calendar heatmaps** | ❌ | Needs `CalendarHeatmap` component (GitHub-style) + activity/jira-movement ingest per day. Original: `EngagementKpi()` with PM-row accordion. **MH** |
|
||||
| 20 | **Missing final cost** (ranked PM bars + drill) | ❌ | `AssigneeBars` + list; server: closed rows where `finalCost` null, grouped by PM. Partial data exists. **MH** (margin-leak) |
|
||||
| 21 | **Awaiting PO** (ranked, Cost / Avg-d / Max-d, sortable) | 🟡 | Insights has a count + L1/L2/L3 + revenue, but **no per-PM Cost/Avg-days/Max-days sortable table**. **MH** |
|
||||
| 22 | No-cost by **brand / market** | ❌ | Two `BucketBars`. **NTH** |
|
||||
| 23 | Awaiting-PO by **brand / market** | ❌ | Two `BucketBars`. **NTH** |
|
||||
|
||||
**Bottom line:** PMs-KPI is the **largest single gap** — 6 of 12 charts absent, and the 6 present ones lack
|
||||
year/period slicing and the editable norms that make them operational.
|
||||
|
||||
---
|
||||
|
||||
## 4. Board — `client/src/pages/Board/index.tsx`, `utils/board.ts`, `components/Board/BoardCard`
|
||||
|
||||
| Item | Status | Note / where it lives |
|
||||
|---|:--:|---|
|
||||
| 7-state columns | 🟡 | **6 columns** built (`unassigned, open, hold, wip, replied, awaiting`; `board.ts:18`). **7th synthetic `Closed / Awaiting-PO` column is missing** (original `CLOSED / AWAITING PO – SYNTHETIC` overlay). **MH** |
|
||||
| Customer-replied pseudo-status | ✅ | `boardColumn`/`isAwaitingAgency` — WIP + last touch ∉ colleague roster. Faithful. |
|
||||
| Column SLA subtitles + per-column count | ✅ | `col.slaVerb(thr)` + count badge. |
|
||||
| Group-by (6 permutations of WIP·Replied·Awaiting) | ❌ | Column order is fixed; original has 6 orderings (`wip-agency-await` … `await-agency-wip`). **NTH** |
|
||||
| **16 toggleable card fields** (`card_fields`) | ❌ | `BoardCard` is a **fixed** layout showing ~8 fields (number, jira status, link, shortDesc, brand·market, assignee, in-state days, lifetime, last activity). **None toggleable.** Missing: description, raisedBy, dueDate, stateChanged, comments, createJira, jiraBar, teamsLink. Original fields: `cf-stateBadge/shortDesc/description/assignee/assignmentGroup/brandMarket/raisedBy/lifetime/dueDate/stateChanged/lastActivity/comments/createJira/showJiraStatus/showJiraBar/teamsChannelLink`. **NTH** |
|
||||
| Custom labels registry | ❌ | Original `custom_label` system (per-ticket labels + filter). Absent. **NTH** |
|
||||
| Staleness / PO / jira-stuck / missing-cost badges | ❌ | `BoardCard` has no badges beyond the jira-status chip. **NTH** |
|
||||
| **Sort (14 options)** | ❌ | No sort control. Original: `default, number, status-age-asc/desc, lifetime-asc/desc, jira-assignee, jira-status, brand, cost-asc/desc, due-asc/desc, analyzed`. **NTH** |
|
||||
| Filters: search / brand / market / assignee | ✅ | Present in the board header. |
|
||||
| Filters: **region EU/LATAM** sentinels | ❌ | `latamAssignees` is ingested (`config.json`) but the assignee dropdown has no EU/LATAM region grouping. **NTH** |
|
||||
| Filters: requester, custom-label, jira-assignee, jira-status | ❌ | None. **NTH** |
|
||||
| Filters: staleness (stale/updated/inactive/PO/jira-stuck/missing-cost), hide-empty, hide-cost | ❌ | Original `group-by` chips + status-label filters. Absent. **NTH** |
|
||||
| List view | ✅ | `TicketTable` toggle (fixed columns, not the same field set). |
|
||||
| Card click behaviour | 🟡 | Opens an in-app `TicketDetailModal` (+ a SNOW link icon). Original opens ServiceNow directly. Acceptable divergence. |
|
||||
| Filtered Excel export | ✅ | **New** — `utils/excel.utils.ts`, 17 cols, honours active filters. |
|
||||
| Azure DevOps focus board | ❌ | Out of scope per spec (§0). **LOW** |
|
||||
|
||||
---
|
||||
|
||||
## 5. PM Insights — `client/src/pages/Insights/index.tsx` + `server/insights.ts`
|
||||
|
||||
| Item | Status | Note / where it lives |
|
||||
|---|:--:|---|
|
||||
| Alert groups (Unassigned, Open/Assigned, On-Hold, WIP-stalled, WIP-no-Jira, Customer-replied, Awaiting) | ✅ | `insights.ts` 9 groups; support rules `jiraBreached`/`clientOwed`/`isAwaitingAgency` ported. |
|
||||
| Informational groups (Lifetime ≥90d, Inactive requester) | ✅ | Present, excluded from Total Alerts. |
|
||||
| ∑ Total Alerts + Revenue-at-Risk | ✅ | Distinct-count reconciled (verifier-log). |
|
||||
| Waiting-PO count + revenue + **L1/L2/L3** | 🟡 | Counts + revenue present; **no per-level (L1/L2/L3) drill-down lists**. `insights.ts:140`. **NTH** |
|
||||
| PM-KPI table (# Tickets · $ Revenue · ⚠ Rev-at-Risk) | 🟡 | Table present; **not sortable**, and **hidden-PMs from `pm_kpi_settings` not honoured**. **NTH** |
|
||||
| **Excel export of the insights subtree** (PM→Size→Ticket) | ❌ | Original `xl-export.js` writes PM/Size/Ticket-grouped sheets. FORGE has board export only. **MH** |
|
||||
| **Region / PO / people filters** | ❌ | Original insights has EU/LATAM region + PO + people filters. Absent. **NTH** |
|
||||
| **Per-PM size stats** | ❌ | No size breakdown inside the PM roll-up. **NTH** |
|
||||
| **"Missing tickets" overlay** | ❌ | Original reconciliation overlay (tickets present in one source, absent in another). Absent. **LOW** |
|
||||
|
||||
---
|
||||
|
||||
## 6. Jira board page — **MISSING ENTIRELY** ❌ (MH for Jira ops, else NTH)
|
||||
|
||||
Original `jira-board.html` + `jira-board.js` (2378 LOC): columns from `jira_board_state`, cards, RITM↔Jira
|
||||
link (`jira-ritm-link.js`), quick-filters, settings panel (board URL, scan controls, PO-cache clear), toasts,
|
||||
scan countdown. FORGE has **no route, no page, no `jira_board_state` ingest** (`dump-to-archives.mjs` only
|
||||
reads `jira_status_map`). Would live as `client/src/pages/JiraBoard/` + a `jira_board` archive/table + a
|
||||
`/api/jira/board` endpoint.
|
||||
|
||||
---
|
||||
|
||||
## 7. Sync / extension — `extension/background.js` (+ `popup.js`, `options.js`)
|
||||
|
||||
FORGE's collector is **active-RITM only, shallow**. Original `background.js` (5219 LOC) is far deeper.
|
||||
|
||||
| Aspect | Status | Note |
|
||||
|---|:--:|---|
|
||||
| SNOW active RITM scan | ✅ | `pageCollector` pages `sc_req_item`, `assignment_groupLIKEMarketing Web Presence`. |
|
||||
| Field depth | 🟡 | Only **9 fields** (number, short_desc, state, assigned_to, group, opened_at, due_date, updated_by, updated_on). No brand/market/BU/requester/cost/activity timeline/state-change history. Rich fields survive only from the **seed**, kept alive by COALESCE upsert. **MH** |
|
||||
| **SCTASK support** | ❌ | RITM-only. Original scans **SCTASK** too (`background.js` `sctask`/`SCTASK`). **NTH** |
|
||||
| **Closed-ticket collector** | ❌ | No closed scan; closed data is seed-only, goes stale. **MH** |
|
||||
| **Jira collector (+ statusDurations)** | ❌ | No Jira scanning; `jira_status_map`/`statusDurations`/`jira_board_state` never refreshed. **MH** |
|
||||
| **Finance xlsx scan** | ❌ | Original scans the SharePoint finance workbook (`finance_xlsx`); FORGE finance is a one-shot seed from the dump. **MH** |
|
||||
| Popup = RITM list + Jira tab | 🟡 | FORGE popup is **sync-only** (server URL + token + "Sync now"). Original popup is a RITM browser + Jira tab. **LOW** (by design — web app replaces it) |
|
||||
|
||||
---
|
||||
|
||||
## 8. Finance — `server/db.ts:applyFinance` + `scripts/dump-to-archives.mjs:106`
|
||||
|
||||
| Item | Status | Note |
|
||||
|---|:--:|---|
|
||||
| Finance ingest (cost / currency / PO / invoiced) | ✅ | `finance.json` (860 rows) → fills `finalCost`/`poNumber`/`invoiced`, recomputes size. |
|
||||
| Milestone / PM / state columns | 🟡 | **Parsed into `finance.json`** (`milestone`, `pm`, `state`) **but never surfaced** — DB has no columns for them and no UI reads them. **NTH** |
|
||||
| **Finance view** (invoicing / milestone / filters) | ❌ | No finance page. PO/invoiced only appear as an Excel column + waiting-PO signal. **NTH** |
|
||||
|
||||
---
|
||||
|
||||
## 9. Size CALC tool — **MISSING ENTIRELY** ❌ (NTH)
|
||||
|
||||
Original `size-calc.js` (692 LOC): a labor→size estimator (inputs by **role × hours × rate**, config
|
||||
`lis_size_calc_cfg`, `MATCH`/`composite` algorithms). FORGE has a server `sizeOf(cost)` helper
|
||||
(`db.ts:156`) that assigns sizes to tickets, but **no interactive estimator tool/page**. Would live as
|
||||
`client/src/pages/SizeCalc/` + a `lis_size_calc_cfg` config key.
|
||||
|
||||
---
|
||||
|
||||
## 10. Config / options editors — **MISSING ENTIRELY** ❌ (mixed)
|
||||
|
||||
FORGE loads config **read-only** from `config.json`/`app_config` (seeded once). The original `options.html`/
|
||||
`options.js` (629 LOC) is a rich settings surface with **no FORGE equivalent** (the FORGE Admin page is
|
||||
Users+Tokens only). Missing editors, each `GET /api/config` reads but nothing writes:
|
||||
|
||||
| Setting (original id) | Status | Value |
|
||||
|---|:--:|---|
|
||||
| Brand colours editor (`brand_colors`, add/reset) | ❌ | **NTH** — 45 brands seeded, not editable in-app. |
|
||||
| SLA norms editors (otd/avgdays/asla/psla/ttfr/cresp) | ❌ | **MH** — required by §3a editable-norms gap. |
|
||||
| Insights thresholds editor (`insights_thresholds`) | ❌ | **NTH** |
|
||||
| Size cost-thresholds editor (`otd_cost_thresh`) | ❌ | **NTH** |
|
||||
| Card-field toggles (`cf-*`, 16) + mobile fields (`mf-*`) | ❌ | **NTH** — pairs with the board card-fields gap. |
|
||||
| Colleague names (`colleagueNames`) / LATAM assignees (`latamAssignees`) | ❌ | **NTH** — seeded, not editable. |
|
||||
| Display currency (`displayCurrency`) | ❌ | **LOW** — fixed GBP. |
|
||||
| Custom labels (`custom-labels`, clear-all) | ❌ | **NTH** |
|
||||
| SNOW field colours (`color-*`, 6) | ❌ | **LOW** — SNOW-page injection only, N/A to web app. |
|
||||
| Card layout breakpoints (`card_layout`) | ❌ | **LOW** |
|
||||
| Scan intervals / enables (analyticsIntervalHours, snRescanInterval, dashboardEnabled, jiraNotificationsEnabled, soundEnabled, analyzeButtonsEnabled) | ❌ | **LOW** — extension/SNOW-page behaviour. |
|
||||
| Teams bot (`teamsBotEnabled`, chat names) | ❌ | **LOW** — out of scope. |
|
||||
|
||||
---
|
||||
|
||||
## 11. Other
|
||||
|
||||
| Item | Status | Note |
|
||||
|---|:--:|---|
|
||||
| AI Advisor / at-risk (`advisor-*.js`, `advisor-server/`) | ❌ (intended) | **Excluded per spec §1** ("user excluded AI"). Confirmed out of scope — do not port. |
|
||||
| Teams integration (`teamsChannelLink`, teams bot) | ❌ | Out of scope-ish. **LOW** |
|
||||
| Figma links (`figma-links.js`, 244 LOC) | ❌ | Optional per spec. **LOW** |
|
||||
| Weather | — (no gap) | Excluded per spec **and dead in the original** — manifest keeps open-meteo/nominatim host perms but no weather JS remains. Nothing to port. |
|
||||
| Docs page (`docs.html`/`docs.js`) | ❌ | In-app help. **LOW** |
|
||||
| Archive import / export | 🟡 | `scripts/dump-to-archives.mjs` + `npm run reseed` is a **manual CLI** path; no in-app import/export UI. **LOW** |
|
||||
| SCTASK support | ❌ | See §7 — RITM-only. **NTH** |
|
||||
| Extension popup RITM browser | 🟡 | See §7. **LOW** |
|
||||
| **New in FORGE (beyond original):** RBAC (4 roles), Users/Tokens Admin page, session auth, Postgres, server analytics engine | ✅ | Net-additive; no parity gap. |
|
||||
| Legacy/unrouted FORGE pages: `pages/Dashboard`, `pages/Stats` | — | `Dashboard` is unrouted (superseded by `Board`); `Stats` is a thin `/stats` count page. Housekeeping, not a gap. |
|
||||
|
||||
---
|
||||
|
||||
## 12. Recommended next phases (prioritized)
|
||||
|
||||
**Phase A — PMs-KPI completion (highest value, biggest gap).**
|
||||
1. Add **year/period granularity + current-vs-previous-year** to the 6 SLA heatmaps (`getSlaHeatmaps` + period control). *(MH)*
|
||||
2. **Editable norms** — a `POST /api/config/norms` write path + inline norm panels (also unblocks the options editor). *(MH)*
|
||||
3. Charts **18 (workload/month per-PM)**, **20 (missing final cost)**, **21 (awaiting-PO Cost/Avg/Max sortable)**. *(MH)*
|
||||
4. Chart **19 engagement + `CalendarHeatmap`** (needs per-day activity/jira-movement ingest). *(MH, larger)*
|
||||
5. Charts **22/23** brand-market sub-cards. *(NTH)*
|
||||
|
||||
**Phase B — Overall/Active fidelity.**
|
||||
6. **YoY pies #5/#6** (fair-window + full) as `DonutChart`s. *(MH)*
|
||||
7. **#17 Jira status durations** (requires `statusDurations` ingest — see Phase D). *(MH)*
|
||||
8. Per-year segmentation + drill + YoY on by-BU/by-brand/by-market/by-requester; **#15 min/median/max**; **#16 status-segmented brand**; **#/% toggle**; **CGO overlay**. *(NTH)*
|
||||
|
||||
**Phase C — Board operability.**
|
||||
9. **Sort (14)** + **group-by (6)** controls. *(NTH)*
|
||||
10. **Toggleable card fields (16)** + badges (stale/PO/jira-stuck/missing-cost). *(NTH)*
|
||||
11. Filters: region EU/LATAM, requester, jira-status/assignee, staleness, custom labels. *(NTH)*
|
||||
12. **7th synthetic Closed/Awaiting-PO column.** *(MH)*
|
||||
|
||||
**Phase D — Deeper sync (unblocks a lot above).**
|
||||
13. Extend the collector: **richer RITM fields + closed scan + Jira (statusDurations, jira_board_state) + finance scan + SCTASK.** *(MH — currently rich data is seed-only and goes stale.)*
|
||||
|
||||
**Phase E — Insights depth.**
|
||||
14. **Excel export of the insights subtree** (PM→Size→Ticket); waiting-PO L1/L2/L3 drill; sortable PM table + hidden-PM support; region/PO/people filters. *(MH for export, else NTH.)*
|
||||
|
||||
**Phase F — Standalone features.**
|
||||
15. **Jira board page** (needs Phase D jira ingest). *(MH for Jira ops.)*
|
||||
16. **Options/config editor** page (brand colours, thresholds, card fields, colleagues/region, currency, custom labels). *(NTH.)*
|
||||
17. **Size CALC** estimator; **finance view** (milestone/invoicing — data already parsed). *(NTH.)*
|
||||
|
||||
**Do not port:** AI Advisor/at-risk, weather (excluded); Teams/Figma/ADO board/docs page (low/optional).
|
||||
|
||||
---
|
||||
|
||||
## 13. Addendum — refinements from the source inventory (corroborated)
|
||||
|
||||
A parallel deep read of the initial app's source confirmed the above and adds:
|
||||
|
||||
- **Board columns:** the original renders **5 live columns** (Open/Assigned, On Hold, WIP, Customer-replied,
|
||||
Awaiting) **+ the synthetic Closed/Awaiting-PO** column; Unassigned is folded into Open/Assigned. FORGE
|
||||
builds 6 (splits Unassigned out) and still lacks the synthetic 7th — the §4 "7th column MH" gap stands.
|
||||
- **Per-chart Excel export:** in the original **every analytics chart** carries its own Excel button
|
||||
(`_xlData`/`_xlBtn` → `xl-export.js`). FORGE has **no per-chart export** — only the board export.
|
||||
Add as an **NTH** gap across Overall/Active/PMs-KPI. Insights groups also each export (see §5, MH).
|
||||
- **Missing-tickets overlay** lives on the **3 SLA/OTD charts** (OTD/AvgDays/Assign/Preview), grouping the
|
||||
timestamp-less tickets by current Jira status (reachedUat / beforeUat / noJira) — reclassify the §5
|
||||
"missing-tickets overlay" as part of the **PMs-KPI SLA charts** (still ❌).
|
||||
- **Collector scope:** the original SN scan handles **RITM + SCTASK + INC** (not just RITM), computes
|
||||
per-ticket `statusDurations` from status-change history, and runs a **Jira scan as a tail after every SN
|
||||
scan**. FORGE's collector does none of this (§7) — add **INC** alongside the SCTASK gap.
|
||||
- **Threshold value drift:** FORGE `config.json` has `waitingPo3: 18` and `customerReplied: 1`, vs the
|
||||
original defaults `waitingPo3: 21` / `customerReplied: 3`. Minor, but reconcile when the norms/thresholds
|
||||
editor lands (§10). **LOW**
|
||||
- **Engagement KPI** reads the **live board** (not `ticketsMeta`), with twin Assist|Jira calendar heatmaps
|
||||
and a ~14-column sortable drill-down — confirms the §3b #19 scope (**MH**, larger build).
|
||||
- **Size CALC** uses 4 currencies (GBP/EUR/MXN + MXN·Brazil uplift ×1.111), roles DEV/CM/QA + auto-PM,
|
||||
PM-budget tiering (`pmThreshold`/`pmHi`/`pmLo`) — confirms §9.
|
||||
- **AI Advisor** is a full masked-digest + Node-bridge-to-Claude-CLI subsystem (`advisor-*.js` +
|
||||
`advisor-server/`) with the At-risk breach engine — **correctly excluded**, do not port.
|
||||
- **Archive import/export** exists as in-app **⬇ Archive / ⬇ Active** buttons in the original (FORGE has only
|
||||
the `dump-to-archives.mjs` CLI) — the §11 🟡 stands.
|
||||
|
||||
None of these change the phase priorities; they sharpen scope for Phases A, D, and E.
|
||||
|
||||
## Next
|
||||
principal — sequence Phase A (PMs-KPI) as the next build; confirm which board/insights sub-items are in v1 scope.
|
||||
@@ -0,0 +1,31 @@
|
||||
# designer — Excel export design pass
|
||||
|
||||
**Date:** 2026-08-27 · Task: review the ticket Excel export (column sizes, colours, styling).
|
||||
|
||||
## Before
|
||||
`utils/excel.utils.ts` had a bare schema — no widths, no header styling, no alignment,
|
||||
raw SNOW timestamps, no number formatting. Functional but ugly.
|
||||
|
||||
## After (FORGE-branded, token-derived colours)
|
||||
- **Header row**: indigo `#6366F1` (`--primary`) background, white **bold** centred text,
|
||||
row height 22, **frozen** (`stickyRowsCount: 1`) so it stays visible when scrolling.
|
||||
- **Column widths** tuned per field: Number 15, State 20, Assignee/Requester 22,
|
||||
Short description **46 + wrap**, Group 30, Brand/Market 14, Lifetime/In-state 12,
|
||||
Last activity 14, Jira 14, Jira key 12, Cost 11, Ccy 7, PO 15, Link 34.
|
||||
- **Alignment**: numeric columns (Lifetime, In state, Cost) right-aligned; dates + Ccy
|
||||
centred; text left. All vertically centred.
|
||||
- **Number format**: Cost `#,##0` (thousands separators).
|
||||
- **Dates**: `Last activity` rendered as a short local date (`25 Aug 26`) instead of the raw
|
||||
`2026-08-25 12:00:00` SNOW timestamp.
|
||||
- **Grid**: thin light-grey borders `#E2E8F0` (`--border`) on every cell.
|
||||
- **Font**: Calibri 11 (header) / 10 (body).
|
||||
- Columns reordered so the human-readable ones (desc, brand, market, requester) sit before
|
||||
the group/analytics fields.
|
||||
|
||||
## Evidence
|
||||
- client `tsc --noEmit` → clean; `vite build` → OK.
|
||||
- Node render (`write-excel-file/node`, all style options): produced a valid **"Microsoft
|
||||
Excel 2007+"** file (PK/zip header, 4 KB) — every styling option accepted, no throw.
|
||||
|
||||
## Next: done — export honours the FORGE palette + is readable. (Excel colour-coding by
|
||||
state/size intentionally skipped to keep it clean; can add if wanted.)
|
||||
@@ -0,0 +1,35 @@
|
||||
# devops + designer — Synology deploy script, .env, and RITM→SNOW link
|
||||
|
||||
**Date:** 2026-08-27
|
||||
|
||||
## Synology deploy (devops) — ported from the Husky template (per user request)
|
||||
Two scripts, matching Husky's proven pattern (superseded my first single-script cut):
|
||||
- **`scripts/push-to-nas.sh`** (local, `npm run deploy`): preflight (SSH key pinned via
|
||||
`-i $NAS_KEY -o IdentitiesOnly=yes` — fixes the WebStorm multi-identity MaxAuthTries
|
||||
failure) → test gate → rsync tree to NAS (openrsync→tar-over-ssh fallback for macOS)
|
||||
→ run `deploy.sh` over SSH forwarding `--pull`/`--fresh`. Excludes node_modules/dist/
|
||||
`.env`/`.deploy.env`/`storage-dump.json`/`.idea`/`claude_artifacts`.
|
||||
- **`scripts/deploy.sh`** (on the NAS): Synology PATH hardening + `DOCKER_SUDO` autodetect
|
||||
→ optional `git pull` → `compose build` → `up -d --remove-orphans` → poll app container
|
||||
for Docker `healthy` (≤120s). `--fresh` = `down --remove-orphans` **never `-v`** → the
|
||||
`forge-db` volume is preserved. Adapted from Husky: removed the `ai.env` check, fixed the
|
||||
`.env` required-vars message, `HOST_PORT_FALLBACK=3089`, corrected all "external Postgres /
|
||||
no volumes" comments (FORGE bundles the db + `forge-db` volume).
|
||||
- **Config baked in** (defaults = the real NAS: `192.168.50.2` / `d.tkachenko` / `2323` /
|
||||
`/volume1/docker/forge` / `~/.ssh/id_ed25519`), overridable via env or an optional
|
||||
`.deploy.env` (`.deploy.env.example`, gitignored). Both scripts `bash -n` clean + executable.
|
||||
- **`.env.example`** rewritten: local vs prod split; compose overrides DATABASE_URL/PORT/NODE_ENV;
|
||||
prod requires `SESSION_SECRET`, `AUTH_USER`, `AUTH_PASS`, `POSTGRES_PASSWORD`. Stale `ADMIN_KEY`
|
||||
removed (tokens are admin-role gated now); port note corrected to the published lane.
|
||||
- **`package.json`** `deploy` script added. **`docs/SETUP.md`** §6 documents the flow.
|
||||
- Secrets never leave the NAS: `.env` is created once on the NAS and is never synced.
|
||||
- NOT run — deploy needs explicit sign-off + a reachable NAS; script is created only.
|
||||
|
||||
## RITM → SNOW link (designer)
|
||||
- The ticket **number is now a link to ServiceNow** in the table Number column and on
|
||||
board cards — inline external-link glyph, opens a new tab, `stopPropagation` so it
|
||||
doesn't also open the detail modal. Removed the now-redundant separate external icons.
|
||||
- `client/src/components/TicketTable/index.tsx` + `.module.scss` (`.numLink`),
|
||||
`components/Board/BoardCard/index.tsx` + `.module.scss`. tsc + build clean.
|
||||
|
||||
## Next: done. (Deploy: fill `.deploy.env`, create the NAS `.env`, `npm run deploy`.)
|
||||
@@ -0,0 +1,43 @@
|
||||
# engineer — RBAC + Token page + Filtered Excel export + full-width + donut fix
|
||||
|
||||
**Date:** 2026-08-27 · From: [principal-20260827-175448](principal-20260827-175448.md)
|
||||
|
||||
## What changed
|
||||
|
||||
### 1. Roles / RBAC
|
||||
- `server/db.ts`: `app_users.role TEXT NOT NULL DEFAULT 'viewer'` (additive `ALTER … ADD COLUMN IF NOT EXISTS`).
|
||||
`seedUser` forces the bootstrap `AUTH_USER` to `admin` (and repairs pre-role rows). Added
|
||||
`Role`/`ROLES`/`isRole`, `listUsers`, `createUser` (23505 → 'exists'), `deleteUser`.
|
||||
- `server/auth.ts`: session `user = {username, role}`; `verifyLogin` returns the role;
|
||||
`requireRole(min)` — 401 unauth, **403** under-privileged.
|
||||
- `index.ts` route gates:
|
||||
- viewer: `/api/tickets`, `/api/tickets/:number`, `/api/stats`, `/api/config`
|
||||
- pm: `/api/analytics/*`, `/api/insights`
|
||||
- lead: `GET/POST /api/users`, `DELETE /api/users/:username`
|
||||
- admin: `GET/POST /api/tokens`, `POST /api/tokens/:id/revoke`
|
||||
- **Only admin may create an admin** (server 403 + client dropdown limits leadership to ≤lead).
|
||||
- Client: `roleAtLeast`/`ROLE_LABELS`; App route guards (reports→pm, /admin→lead);
|
||||
Sidebar shows Analytics only for pm+, Admin only for lead+; TopBar shows role.
|
||||
|
||||
### 2. API token page — `pages/Admin` (Tokens tab, admin only)
|
||||
List (label/id/last-used/expires/revoked) + create (raw `fg_…` shown once, copy) + revoke.
|
||||
Replaces the `x-admin-key` HTTP gate with the admin session role. `mint-token` CLI unchanged.
|
||||
|
||||
### 3. Filtered Excel export
|
||||
`utils/excel.utils.ts` (`write-excel-file` dep, as in Husky) → exports the board's **currently
|
||||
filtered** rows. Button in the board header. 17 columns incl. lifetime, days-in-state, cost, PO, link.
|
||||
|
||||
### 4. Also
|
||||
- Overall verifier REDO fixed: donut now fed `byRequesterShare` (full 966-ticket distribution),
|
||||
not the top-20 nested `byRequester` → center total 966, correct %.
|
||||
- All analytics pages `max-width: none` (charts span the full container width).
|
||||
|
||||
## Verification (live, role forge_app DB)
|
||||
- server tsc / client tsc clean; client vite build OK; `npm test` 9 pass.
|
||||
- Gating matrix reproduced end-to-end: viewer → tickets/config 200, analytics/insights/users/tokens **403**;
|
||||
pm → analytics/insights 200, users/tokens **403**; admin → all 200. viewer POST /api/users **403**.
|
||||
- Bootstrap `admin` row correctly migrated to role `admin` (not the 'viewer' default).
|
||||
|
||||
## Next
|
||||
security — audit the authz (public domain): privilege escalation, self-delete/last-admin, token exposure.
|
||||
verifier — gate the full change.
|
||||
@@ -0,0 +1,25 @@
|
||||
# engineer — Extension Jira sync (SNOW + Jira, per ADR)
|
||||
|
||||
**Date:** 2026-08-27 · Implements [architect-20260827-180544](architect-20260827-180544.md). v2.2.0.
|
||||
|
||||
## Built
|
||||
- **Server** `server/db.ts` `attachJira(items)` — attach-only: `UPDATE tickets SET
|
||||
jira = COALESCE(jira,'{}') || $2::jsonb, jira_key = COALESCE($3, jira_key) WHERE number=$1`.
|
||||
Never touches status/state/assignee/activity. `index.ts` `POST /api/sync/jira`
|
||||
(token-authed, rate-limited) → validates {items:[{number,jira,jiraKey}]} → attachJira.
|
||||
- **Extension** `background.js`: `collectFromJira(cfg)` pulls `/rest/agile/1.0/board/{id}/issue`
|
||||
(paged), auth = Basic(email+token) or Bearer PAT; resolves RITM via customfield_26001 / `RITM\d+`;
|
||||
builds {number, jira:{key,status,statusChangedAt,assignee,url:baseUrl/browse/KEY}}; POSTs to
|
||||
`/api/sync/jira`. Wired into runSync AFTER SNOW; Jira is best-effort (never fails SNOW).
|
||||
`options.html/js`: Jira base URL, email, API token/PAT, board id(s); requests host perm for the Jira origin.
|
||||
|
||||
## Verified (live, remote DB)
|
||||
- POST /api/sync/jira for RITM2653436 → matched:1; AFTER: status/state/assigned_to UNCHANGED,
|
||||
jira_key=WFN-605, jira.status In QA, custom url support.dataart.com/browse/WFN-605. no-token → 401.
|
||||
- server tsc clean; extension node --check OK; 9/9 tests. DB restored via reseed.
|
||||
|
||||
## Not yet (follow-ups)
|
||||
- Jira changelog fetch → statusDurations/movements (chart #17/#19). v1 does status/key/assignee/url.
|
||||
- Live Jira REST untested (needs a real instance + creds); logic follows the ADR.
|
||||
|
||||
## Next: done (v1). Configure the extension options with the Jira board id + token to use it.
|
||||
@@ -0,0 +1,22 @@
|
||||
# engineer — Jira statusDurations + movements + chart #17
|
||||
|
||||
**Date:** 2026-08-27 · v2.3.0
|
||||
|
||||
## Built
|
||||
- **Seed**: `dump-to-archives.mjs` enriches the jira map with `statusDurations` (ms) from
|
||||
`jira_board_state.colIssues[*]._statusDurations` (keyed to RITM via customfield_26001), and
|
||||
adds `jiraColumns` (workflow order) to config. 95 jira entries / 73 DB tickets get durations.
|
||||
- **Types**: `JiraInfo` += `statusDurations: Record<string,number>`, `movements:{at,who?,from?,to?}[]`.
|
||||
`ForgeConfig` += `jiraColumns`.
|
||||
- **Server**: `analytics.getJiraDurations()` averages `jira.statusDurations` per status (ms→days),
|
||||
in `jiraColumns` order, min-2 tickets. `GET /api/analytics/jira-durations` (requireRole pm).
|
||||
- **Client**: Active-stats page fetches it and renders a "Jira status durations (avg days)" BarList.
|
||||
- **Extension**: `collectFromJira` adds `expand=changelog`; `jiraFromChangelog(issue)` reconstructs
|
||||
time-per-status (created → transitions → now) and movements, sent inside `jira`.
|
||||
|
||||
## Verified (live)
|
||||
- `/api/analytics/jira-durations` → 18 statuses, workflow-ordered (Analysis…Closed) then extras;
|
||||
e.g. UAT 16.1d/21, Closed 23.5d/16, In-Progress 4.9d/37. server+client tsc clean; build OK; 9/9 tests.
|
||||
|
||||
## Not yet: live changelog untested (no Jira instance); movements only stored (chart #19 later).
|
||||
## Next: done.
|
||||
@@ -0,0 +1,31 @@
|
||||
# engineer — PM Insights: Alert categories + Watch list as accordions
|
||||
|
||||
**Date:** 2026-08-29 · v2.3.1 · branch `feature/insights-accordions`
|
||||
|
||||
## Built
|
||||
- **Client** (`pages/Insights/index.tsx`): new `Section({title, count, open, onToggle, children})`
|
||||
accordion — a `<button className={sectionHead} aria-expanded aria-controls>` (chevron + title +
|
||||
a category-count badge) over a `<div id={bodyId} className={grid} hidden={!open}>`. The two
|
||||
sections **Alert categories** and **Watch list** are wrapped in it. Added `sections` state
|
||||
(`{alerts, watch}`, both default **open**) + `toggleSection`. The per-card `open` state (which
|
||||
ticket group is expanded) and the **Per-PM roll-up** table are untouched — Per-PM stays a plain
|
||||
`<h2>` (not requested).
|
||||
- **Icons** (`components/icons/index.tsx`): added `ChevronIcon` (right caret, shared `base()`
|
||||
pattern), rotated 90° via CSS when its section is open.
|
||||
- **Styles** (`Insights.module.scss`): `.section` / `.sectionHead` / `.chev` / `.sectionTitle` /
|
||||
`.sectionCount` reusing the old `.h2` typographic treatment; `.sectionHead[aria-expanded='true']
|
||||
.chev { transform: rotate(90deg) }`; and `.grid[hidden] { display: none }` — required, since
|
||||
`.grid { display: grid }` would otherwise beat the native `hidden` attribute (specificity 0,2,0
|
||||
vs 0,1,0).
|
||||
- **Version/docs**: root + client `package.json` 2.3.0 → 2.3.1; CHANGELOG `[2.3.1]`.
|
||||
|
||||
## Verified
|
||||
- client `tsc --noEmit` clean (no errors outside node_modules); `vite build` OK (139 modules).
|
||||
- **verifier PASS** (see `verifier-log.md` 2026-08-29 12:07): independently reproduced tsc + build;
|
||||
grepped the compiled bundle to confirm `._grid…[hidden]{display:none}` co-exists with
|
||||
`._grid…{display:grid}` and wins on specificity **and** source order; `aria-controls` always
|
||||
resolves (body is `hidden`, not unmounted); slugs `insights-alert-categories` /
|
||||
`insights-watch-list` are distinct; per-card expand byte-for-byte unchanged → no regression;
|
||||
no `package.json` dep change.
|
||||
|
||||
## Next: done.
|
||||
@@ -0,0 +1,28 @@
|
||||
# engineer — PM Insights: full-width category items with more space
|
||||
|
||||
**Date:** 2026-08-29 · v2.3.2 · branch `feature/insights-fullwidth-items`
|
||||
|
||||
## Built
|
||||
Follow-up to the section accordions (v2.3.1, merged as PR #1). The categories inside
|
||||
**Alert categories** and **Watch list** were a narrow multi-column grid of click-to-expand
|
||||
cards; they are now a **full-width, single-column stack** with more breathing room.
|
||||
|
||||
**CSS-only** — `client/src/pages/Insights/Insights.module.scss`:
|
||||
- `.grid`: `display:grid; grid-template-columns: repeat(auto-fill, minmax(300px,1fr))` →
|
||||
`display:flex; flex-direction:column; gap:$s-4`. Each `.card` now spans full width, one
|
||||
per row, with a larger inter-card gap. `.grid[hidden]{display:none}` kept (section-collapse
|
||||
still works).
|
||||
- `.cardHead` padding `12px 14px` → `$s-4 $s-5` (16/20); `.cardTitle` 13px → 14px.
|
||||
- `.row` padding `6px 14px` → `$s-2 $s-5` (8/20); `.emptyRow` `10px 14px` → `$s-3 $s-5`.
|
||||
- `.list` max-height 340 → 440px (fuller expanded list on the wider rows).
|
||||
- `Insights/index.tsx` unchanged — each item was already an accordion.
|
||||
- Version root+client 2.3.1 → 2.3.2; CHANGELOG `[2.3.2]`.
|
||||
|
||||
## Verified
|
||||
- client `tsc --noEmit` clean; `vite build` OK. Full gate `npm test` = client 6/6 + root 3/3
|
||||
(root deps freshly installed — that was the earlier deploy blocker, unrelated to this change).
|
||||
- Compiled CSS confirms `._grid_doeho_78{display:flex;flex-direction:column;gap:16px}` +
|
||||
`[hidden]{display:none}`.
|
||||
- verifier: (see verifier-log entry for 2026-08-29).
|
||||
|
||||
## Next: done.
|
||||
@@ -0,0 +1,50 @@
|
||||
# principal — Auth/RBAC + Token page + Filtered Excel export
|
||||
|
||||
**Date:** 2026-08-27
|
||||
**Request:** (1) Users with roles to log into the portal; (2) an API-token creation page;
|
||||
(3) save tickets to Excel honoring the active filters.
|
||||
|
||||
## 1. Roles & capability model
|
||||
|
||||
Internal role keys (hierarchy `viewer < pm < lead < admin`), display labels:
|
||||
|
||||
| key | label | Tasks (board/tickets/closed) | Reports (stats/insights) | Add users | API tokens |
|
||||
|---|---|:--:|:--:|:--:|:--:|
|
||||
| `viewer` | Viewer | ✓ | — | — | — |
|
||||
| `pm` | PM | ✓ | ✓ | — | — |
|
||||
| `lead` | Project Leadership | ✓ | ✓ | ✓ | — |
|
||||
| `admin` | Admin | ✓ | ✓ | ✓ | ✓ |
|
||||
|
||||
- `/api/config` + `/api/stats` stay viewer-accessible (board needs colours/colleagues/counts).
|
||||
- `/api/sync` stays token-only (extension), unchanged.
|
||||
- Server enforces per-route via `requireRole(min)`; client hides nav/pages a role can't use
|
||||
(defense-in-depth; the server is the gate).
|
||||
- Seeded `AUTH_USER` account is forced to role `admin` (bootstrap).
|
||||
|
||||
## 2. Route → minimum role
|
||||
|
||||
| Route | Min role |
|
||||
|---|---|
|
||||
| `GET /api/tickets`, `/api/tickets/:number`, `/api/stats`, `/api/config` | viewer |
|
||||
| `GET /api/analytics/*`, `/api/insights` | pm |
|
||||
| `GET/POST /api/users` (list/create) | lead |
|
||||
| `GET/POST /api/tokens` (list/create) | admin |
|
||||
|
||||
## 3. Token page
|
||||
Admin-only page: list existing tokens (label, id, created, last-used, expiry, revoked) +
|
||||
create (returns the raw `fg_…` once) + revoke. Replaces the `x-admin-key` HTTP gate with the
|
||||
admin session role; the `mint-token` CLI stays.
|
||||
|
||||
## 4. Filtered Excel export
|
||||
Board/tickets "Export Excel" button → exports the CURRENTLY FILTERED rows client-side via
|
||||
`write-excel-file` (the Husky template's dep). Columns: Number, State, Assignee, Group, Brand,
|
||||
Market, Requester, Lifetime(d), Days-in-state, Last activity, Jira status, Cost, Link.
|
||||
|
||||
## Plan / routing
|
||||
- dba/engineer — `app_users.role` (additive), user CRUD, `requireRole`, route gating.
|
||||
- security — audit the authz before merge (public domain).
|
||||
- engineer — Admin page (Users + Tokens tabs), export util, role-gated nav.
|
||||
- verifier — gate the result.
|
||||
|
||||
## Next
|
||||
engineer — implement server RBAC + user/token endpoints, then the client Admin page + export.
|
||||
@@ -0,0 +1,73 @@
|
||||
# security — RBAC + token-management + user-management audit
|
||||
|
||||
**Date:** 2026-08-27 · From: [engineer-20260827-180000](engineer-20260827-180000.md), [principal-20260827-175448](principal-20260827-175448.md)
|
||||
**Scope:** the new RBAC / API-token / user-management surface on the public domain. Read-only audit (no edits).
|
||||
**Out of scope (per task):** DB-superuser-in-`.env` (tracked separately), analytics math.
|
||||
|
||||
## Method / evidence
|
||||
- Read `server/auth.ts`, `server/tokens.ts`, `server/db.ts`, `index.ts`, client `App.tsx`/services.
|
||||
- Full route inventory via grep (`app.get|post|put|delete|use`) — 15 API routes, all accounted for below.
|
||||
- Grepped for the removed `ADMIN_KEY`/`x-admin-key` gate → **no matches** (fully removed). No `dangerouslySetInnerHTML` in `client/src`.
|
||||
- Live DB (role `forge_app`, `31.133.115.50:5440/forge`): `current_user=forge_app`; `SELECT count(*) FROM app_users WHERE role='admin'` → **1**; the sole account `admin` is role `admin` (bootstrap migrated correctly). No destructive SQL run.
|
||||
|
||||
## Verified correct (no action)
|
||||
- **Route gating matches the spec matrix.** viewer: `/api/tickets`,`/api/tickets/:number`,`/api/stats`,`/api/config` (`requireAuth`). pm: `/api/analytics/overview|sla`,`/api/insights` (`requireRole('pm')`). lead: `GET/POST /api/users`,`DELETE /api/users/:username`. admin: `GET/POST /api/tokens`,`POST /api/tokens/:id/revoke`. `/api/sync` token-only.
|
||||
- **401 vs 403 correct** (`auth.ts:46-61`): unauth→401, under-privileged→403.
|
||||
- **Role cannot be spoofed** — read from the server-side PG session store (`req.session.user`, `auth.ts:14`), never from client input.
|
||||
- **Admin-creation guard present** (`index.ts:249`): non-admin creating `role==='admin'` → 403.
|
||||
- **`isRole` validation** (`db.ts:191`): arbitrary/invalid role string → 400 `invalid_role`.
|
||||
- **Self-delete guard present** (`index.ts:262`).
|
||||
- **Tokens:** admin-only; raw `fg_…` returned once (`tokens.ts:81-89`), only `sha256(secret)` persisted; `listTokens` selects id/token_id/label/dates/revoked — **never `token_hash`** (`tokens.ts:97-106`); revoke honored (`resolveToken` filters `revoked=false AND not expired`, `tokens.ts:62`); constant-time `timingSafeEqual` compare.
|
||||
- **Cookie hygiene:** httpOnly, sameSite=lax, secure-in-prod, `SESSION_SECRET` required in prod (`process.exit(1)`), login rate-limited (20/15min), bcrypt dummy-hash compare for unknown users (timing-uniform), bcrypt cost 12.
|
||||
- **No secret leak:** `/api/users`→id/username/role/dates only (no `password_hash`); `/api/me`→`{username,role}` only.
|
||||
- **Client gating is defense-in-depth only** (`App.tsx` `Navigate` redirects); the server is the true gate.
|
||||
|
||||
---
|
||||
|
||||
## Ranked findings
|
||||
|
||||
### HIGH-1 — A `lead` can delete `admin` accounts (no target-rank check)
|
||||
**Where:** `index.ts:260` (`DELETE /api/users/:username` gated only at `requireRole('lead')`) + `server/db.ts:227` (`deleteUser` = unconditional `DELETE FROM app_users WHERE username=$1`).
|
||||
**Exploit:** The create path forbids a non-admin from making an admin (`index.ts:249`), but the delete path has **no symmetric rank check** — a `lead` (or a compromised lead session) can delete any user, admins included. A lead can therefore remove every admin even though it can never create one.
|
||||
**Impact:** Privilege-boundary violation. Combined with "only an admin creates an admin," a lead that deletes all admins permanently strips the org of token-management / admin-create ability until a server reboot re-seeds `AUTH_USER`.
|
||||
**Fix:** Enforce actor-outranks-target in the delete path — refuse deleting a user whose role rank ≥ the actor's (a lead may not delete admins/leads), or raise `DELETE /api/users/:username` to admin-only. Hand to **engineer** (route + `deleteUser`).
|
||||
|
||||
### HIGH-2 — Last-admin lockout (no guard on deleting/removing the final admin)
|
||||
**Where:** `server/db.ts:227` `deleteUser`; no count check anywhere. There is **no demote endpoint** (confirmed — only `seedUser` ever writes `role`, `db.ts:179`), so deletion is the only removal path.
|
||||
**Exploit:** Live state is a **single** admin (`admin`). Deleting it (reachable today by any lead per HIGH-1, or by a second admin) leaves zero admins. `/api/tokens*` then 403s for everyone; no one can create a new admin.
|
||||
**Impact:** Availability/integrity loss of the admin tier. Recovery requires shell/redeploy access — `seedUser` re-inserts `AUTH_USER` as admin only when its row is absent (`db.ts:176-185`), and only that one account.
|
||||
**Fix:** Guard against removing the last remaining admin (`SELECT count(*) … role='admin'` before delete; refuse if target is the only admin). Same for any future demote. Hand to **engineer/dba**.
|
||||
|
||||
### HIGH-3 — Deleting a user does not invalidate their active session
|
||||
**Where:** `auth.ts:46-61` (`requireAuth`/`requireRole` read identity+role from `req.session.user` set once at login, `index.ts:47`); session `maxAge` 7 days (`auth.ts:40`); session rows persist in `user_sessions`. `deleteUser` (`db.ts:227`) removes only the `app_users` row.
|
||||
**Exploit:** An off-boarded/removed user keeps their cookie working at their cached role for up to 7 days. A deleted **lead** still passes `requireRole('lead')` and can keep deleting users (feeding HIGH-1) until session expiry. Role is never re-validated against the DB per request.
|
||||
**Impact:** On a public domain, account removal is not effective access revocation; no mechanism to force-logout a user.
|
||||
**Fix:** On delete (and any future role change), purge that user's sessions — e.g. `DELETE FROM user_sessions WHERE (sess->'user'->>'username') = $1` — or re-check user existence/role from `app_users` per request (short-cached). Hand to **engineer/dba**.
|
||||
|
||||
### MEDIUM-1 — No session-fixation protection on login
|
||||
**Where:** `index.ts:43-49` assigns `req.session.user = ok` on the pre-existing session id; no `req.session.regenerate()` at the privilege transition.
|
||||
**Exploit:** Classic session fixation — an attacker who can plant a session cookie (e.g. via a sibling/adjacent context) rides the same id after the victim authenticates and it becomes privileged. Mitigated but not eliminated by httpOnly + sameSite=lax.
|
||||
**Fix:** Regenerate the session id on successful login before writing `user`. Hand to **engineer**.
|
||||
|
||||
### MEDIUM-2 — Weak password policy on a public login
|
||||
**Where:** `index.ts:246` — minimum 6 chars, no complexity/denylist. Throttle is per-IP only (`loginLimiter` 20/15min, `index.ts:41`).
|
||||
**Exploit:** Created accounts can carry trivially guessable passwords; per-IP throttling doesn't stop distributed/slow guessing against a public domain.
|
||||
**Fix:** Raise minimum (~12 chars) and/or add a zxcvbn/denylist check; consider per-account lockout in addition to per-IP. Hand to **engineer**.
|
||||
|
||||
### LOW-1 — No HTTP security headers (helmet absent)
|
||||
**Where:** app has no `helmet`/CSP (grep empty); serves SPA + API on a public domain.
|
||||
**Impact:** No `X-Frame-Options`/`frame-ancestors` (clickjacking of the authenticated UI), no HSTS, no `X-Content-Type-Options`. Adjacent to the auth perimeter, not RBAC-specific.
|
||||
**Fix:** Add `helmet` with a same-origin CSP + HSTS-in-prod. Hand to **engineer/devops**.
|
||||
|
||||
### LOW-2 — Silent no-op on delete/revoke of a non-existent id
|
||||
**Where:** `db.ts:227` `deleteUser`, `tokens.ts:108` `revokeToken` — no affected-row check. `DELETE /api/users/:missing` and revoke of an unknown token id both return `{success:true}`.
|
||||
**Impact:** Cosmetic/idempotency only (no enumeration leak — always success). Consider returning 404 when 0 rows affected so the admin UI reflects reality. Hand to **engineer**.
|
||||
|
||||
### LOW-3b (informational) — CSRF on state-changing POSTs rests on `sameSite=lax`
|
||||
State-changing routes (`/login`, `/api/users`, `/api/tokens*`, `DELETE /api/users/:username`) carry no CSRF token; cross-site protection is provided by the `sameSite=lax` cookie (`auth.ts:38`). Defensible today. If any of these routes ever move to `sameSite=none` (e.g. cross-origin embedding), add an explicit CSRF token. No fix required now.
|
||||
|
||||
### LOW-3 (informational) — Leads self-propagate the user-management tier
|
||||
**Where:** `index.ts:248-249` blocks only `role==='admin'` for non-admins; a lead may create unlimited additional `lead`s. This is per the capability spec ("leadership tops out at lead"), so it is by design — but note it compounds HIGH-1/HIGH-3: a lead can mint a co-lead, and either can delete the sole admin. No fix required; revisit if leads should not be able to grant lead.
|
||||
|
||||
## Next
|
||||
engineer/dba — fix HIGH-1 (target-rank check on delete), HIGH-2 (last-admin guard), HIGH-3 (session purge on delete). engineer — MEDIUM-1 (session regenerate), MEDIUM-2 (password policy), LOW-1/2. Then re-audit the delete path.
|
||||
@@ -0,0 +1,20 @@
|
||||
- 2026-08-27 16:05 · security · FORGE public-domain read-only audit · VERDICT: REDO · re-ran: git ls-files/check-ignore (data+storage-dump NOT ignored, git add -n stages both), read index.ts+tokens.ts+tickets.ts+db.ts+Dockerfile+compose+.env, grep client href · probed: anon GET /api/tickets has no auth middleware=public PII; storage-dump.json=9.3MB real @reckitt.com emails would be git-committed (MISSED); javascript: href in TicketTable:60 unsanitized; ADMIN_KEY-unset fails closed 403 · REDO: 1 material missed PII file + 2 framing fixes
|
||||
- 2026-08-27 16:06 · security · FORGE audit RESUBMIT (4 gaps fixed) · VERDICT: PASS · re-ran: grep -c unique reckitt emails=15 (agent said 14, trivial undercount), confirmed storage-dump.json exit-1 + git add -n stages it, index.ts:21 global json pre-auth, no read-route limiter · probed: storage-dump git-leak vector; LOW-7 pre-auth buffering; rate-limit gap on reads · PASS
|
||||
- 2026-08-27 16:08 · architect/review · FORGE fidelity+template assessment · VERDICT: REDO · re-ran: read db.ts/index.ts/background.js/tokens.ts/Dockerfile/compose + node seed inspect(active=100,jira=127,closed=965,all RITM) + grep Closed/Stats/getStats · probed: sync-payload-vs-upsert destructive(confirmed), unauth GET routes(confirmed), RITM-only collector+docs(confirmed), token/Dockerfile parity(confirmed) · REDO: 2 grounding gaps (wrong seed counts; missed mixed-currency cost sum)
|
||||
- 2026-08-27 16:08 · reviewer · FORGE correctness+convention review · VERDICT: REDO · re-ran: root/client tsc=0, vitest 3+6=9 pass, lint=eslint-not-found, pg DATE parse off-by-one confirmed (Kyiv->08-24, UTC->08-25) · probed: non-string activity.t crash (no error boundary confirmed), CLIENT_DIST dev overshoot, admin-key !== , extension host-permission grant (handled in options.js), MV3 SW async lifecycle · REDO: 1 gap (extension area-3 absent from findings/affirmations; substance sound on my audit)
|
||||
- 2026-08-27 16:09 · architect/review · FORGE fidelity+template assessment (round 2) · VERDICT: PASS · re-ran: node seed recount(active=100,jira=127,closed=965,all RITM) + README:47-48 grep(confirms stale '111 active'/'846 closed') + reconfirmed fromClosed drops currencyCode & Closed totalCost=sum(finalCost) · probed: corrected counts match source, README doc-drift grounded, currency mixed-sum grounded · PASS
|
||||
- 2026-08-27 16:17 · principal(3-review batch) · FORGE review fixes: dates/upsert/sanitize/jira/seed · VERDICT: PASS · re-ran: server tsc=0, client tsc=0, npm test 9pass(6+3), live PG TEMP-table smoke (active-wins+dateOnly+COALESCE all held under +3 TZ), hostile sanitizeActivity/parseDate probes · probed: toISOString off-by-one (dateOnly emits 2025-12-18 where due_date=Date@T22:00Z would shift), thin-sync nulling richness (COALESCE+CASE preserved finalCost/desc/brand/jira/84-activity), non-string activity.t & TS-bypass parseDate (all coerced/null, no throw) · PASS
|
||||
- 2026-08-27 16:29 · security · read-API auth + least-priv DB role swap · VERDICT: PASS · re-ran: server tsc=0, client tsc=0, client build OK, npm test=9 pass, live DB (forge_app rolsuper/createdb/createrole=f, owns tickets/api_tokens/app_users/user_sessions, tickets=966), live boot smoke (no-session reads=401, /healthz=200, /api/sync no-token=401, wrong+unknown pw=401, correct login=200 HttpOnly+SameSite=Lax cookie, authed stats=966/100/866, /api/me=admin, logout then stats=401) · probed: unguarded read route (none), fail-closed after logout, timing-uniform unknown-user, SQLi on read filters (parameterized) · PASS
|
||||
- 2026-08-27 17:01 · analytics-milestone(P0-2) · ingest+engine+client analytics · VERDICT: PASS · re-ran: SQL coverage(966/100/866,ttfr953/cd844/todo659/uat519/size774/ob954,cfg8keys) + math(rev2026-07=24160,Finish190/Durex123,Health167/Hygiene166,median42/avg65,Alesya 73.06d/404/15%) all reproduced via SQL+live app; server tsc OK, client tsc OK, vitest 9 pass, vite build OK · probed: unauth 401 on all 3 endpoints(live boot), empty/insufficient series guards, unmapped ccy NO/PART→rate1, DATE local-parts no UTC shift, no dangerouslySetInnerHTML · PASS
|
||||
- 2026-08-27 17:05 · engineer · Phase3 Active tab + board redesign · VERDICT: PASS · re-ran: server tsc OK, client tsc OK, vite build OK, npm test 9 pass, live DB board-dist {awaiting65,hold6,open5,replied17,wip7}=100, progress 24→7wip/17replied, app_config colleagues 8 incl Alesya · probed: colleague-rule wip-vs-replied split (reproduced), empty unassigned column (0→No tickets), null last_activity_at (1 row→timeAgo em-dash), read-only (no PATCH/PUT/DELETE/DnD; only POST is login/logout; cards link to ServiceNow) · PASS
|
||||
- 2026-08-27 17:27 · engineer/insights · PM Insights phase (finance ingest + engine + client) · VERDICT: PASS · re-ran: server tsc=0, client tsc=0, client build OK, tests 3+6=9 pass, DB counts active_cost=54/po_notnull=860/active_po_blank=73 exact, getInsights live=totalAlerts65/revAtRisk77373/waitingPo74(14·1·12)/topPM Alesya, live E2E unauth=401→login200→insights200 · probed: distinct-vs-doublecount (ΣgroupProb=65==totalAlerts==ΣpmAlerts, revreconciles), info-groups excluded (lifetime32/inactive6 not in 65), unmapped currency string→rate1 no crash + 46 null-cost→0, auth gate live 401 · PASS
|
||||
- 2026-08-27 17:55 · engineer · Overall tab full fidelity · VERDICT: REDO · re-ran: server tsc OK, client tsc OK, npm test 9 pass, vite build OK, live psql byBrand/byRequester nested aggregation · probed: donut source cap (top-20 feeds share donut → center 'total'=375 not 966, %s inflated 2.6x), tie-order Strepsils/Nurofen, YoY single-year null, div-by-zero guards, auth 401 gate · REDO: 1 gap (donut misrepresents ticket share)
|
||||
- 2026-08-27 18:07 · engineer · RBAC+tokens+filtered-xlsx+full-width+donut · VERDICT: PASS · re-ran: server tsc 0, client tsc 0, client vite build OK, tests 9 pass (6 client+3 root), live DB introspection · probed: lead POST role=admin blocked server-side 403 (index.ts:249); listTokens SQL omits token_hash (tokens.ts:98); donut center=966 live (all 966 rows have requester, Rowena 74≈8% not 375/20%); resolveToken filters revoked=false+expiry; export passes filtered set; app_users.role additive text NOT NULL default viewer, no column reshaped; only admin(role=admin) remains · PASS (note: no last-admin-delete guard — lead can delete an admin, non-blocking per task)
|
||||
- 2026-08-27 18:09 · security · RBAC+token+user-mgmt audit · VERDICT: PASS · re-ran: greps(ADMIN_KEY/dangerouslySetInnerHTML/helmet/regenerate→none; route inventory→14 API routes match matrix) + read auth/db/tokens/index + client App/admin.service · probed: lead→delete-admin(HIGH-1 real), deleted-session-survives(HIGH-3 real), last-admin-lockout no-demote-endpoint(HIGH-2 real), listTokens no token_hash(holds) · PASS
|
||||
- 2026-08-27 18:10 · architect · ADR: extension single sync engine SNOW+Jira · VERDICT: REDO · re-ran: grep server/db.ts upsert (clobber confirmed L466-483, jira=COALESCE L488), index.ts normalizeIncoming (status→active L172), manifest.json (host_perms confirmed), background.js (CHUNK=100/world:MAIN/offset<2000 confirmed), types.ts+client ticket.types.ts · probed: movements JSONB sub-field already declared {at,who}[] in 2 type files (ADR claims 5-field shape + adds conflicting {from,to,at}); no-RITM skip logic; separate-endpoint clobber-avoidance verified sound · REDO: 1 gap
|
||||
- 2026-08-27 18:12 · architect · ADR: extension single sync engine SNOW+Jira (round 2) · VERDICT: PASS · re-ran: re-read ADR §1/§5/§11 vs code — JiraInfo 6 fields confirmed (server/types.ts:11-18, movements:17; client ticket.types.ts:9-16,:15); §5 reuses {at,who} + cites both files + flags statusDurations/board as new additive type-touch; §11 Types:additive bullet present · probed: movements shape now honest (no invented {from,to,at}); no runtime consumer claim matches grep; separate-endpoint clobber-avoidance still intact · PASS
|
||||
- 2026-08-27 19:02 · engineer · Extension Jira-sync (SNOW+Jira attach-only) · VERDICT: PASS · re-ran: server tsc 0, node --check bg/options/popup OK, tests 9 pass (6 client+3 root), live psql TEMP-TABLE smoke of exact attachJira UPDATE (real RITM2653436 untouched) · probed: Jira payload with top-level "status"+SQLi string lands ONLY inside jira jsonb (ticket status stays closed, table survives)→no clobber/no injection; '||' merge preserves omitted keys (extraKept=KEEPME, assignee=Old Person); jira_key WFN-100→605 via COALESCE; non-matching number→UPDATE 0 (no stub); endpoint requireToken not session (401 w/o bearer, index.ts:241); schema additive-only (jira JSONB pre-existing, all ALTER ADD COLUMN IF NOT EXISTS) · PASS
|
||||
- 2026-08-27 19:11 · engineer · Jira statusDurations+movements+chart #17 · VERDICT: PASS · re-ran: server+client tsc=0, root vitest 3/3, client vitest 6/6, live SQL agg on tickets(jira?statusDurations)=73 rows, booted dist+curl endpoint · probed: 401 unauth (got 401), min-2 filter drops Cancelled n=1, ordering known-workflow-first + extras avgDays-desc, ms/86.4M day math matches SQL exactly (UAT 16.14d/21, Closed 23.51d/16, InProgress 4.90d/37), guards NaN/<=0, client null-safe (jiraDur=[] + length>0 gate), static SQL no user input, additive jsonb no DDL · PASS
|
||||
- 2026-08-29 11:20 · devops · SSH NAS host → mycloud.dp.ua:2323 · VERDICT: PASS · re-ran: grep 192.168.50.2 (only comment+artifact), grep NAS_HOST (all mycloud), bash -n push-to-nas.sh OK · probed: all ssh/scp/rsync hops use $NAS_HOST not hardcoded IP; port 2323 preserved; illustrative comment+historical artifact intentional · PASS
|
||||
- 2026-08-29 12:07 · engineer · Insights alert/watch sections as accordions · VERDICT: PASS · re-ran: tsc -p (clean), vite build (139 modules, exit 0), grep compiled CSS (.grid[hidden]{display:none} present, specificity/order beats .grid), token defs in globals.scss · probed: collapse hides grid via hidden+override; aria-controls id always-rendered (no dangling ref when collapsed); unique slugs alert-categories/watch-list (no dup id); per-card open state diff-unchanged (no regression); chevron rotate(90deg) bound to aria-expanded=true · PASS
|
||||
- 2026-08-29 12:23 · designer/engineer · Insights full-width accordion items (CSS) · VERDICT: PASS · re-ran: vite build OK, tsc clean, npm test 9/9 (client 6 + root 3), grepped dist CSS · probed: section hidden-collapse specificity (.grid[hidden] 0,2,0 beats .grid 0,1,0 → display:none wins), flex-column full-width via default align-items:stretch, .row internal grid columns intact + tokens compile (sass would error if undefined) · PASS
|
||||
Reference in New Issue
Block a user