Skip to content

Regulations tracker with AI regulation impact analysis#4198

Draft
gorkem-bwl wants to merge 95 commits into
developfrom
feat/regulations-tracker
Draft

Regulations tracker with AI regulation impact analysis#4198
gorkem-bwl wants to merge 95 commits into
developfrom
feat/regulations-tracker

Conversation

@gorkem-bwl

Copy link
Copy Markdown
Contributor

Summary

Adds the Regulations Tracker module — a window into the public Global AI Regulations feed — plus an impact analysis layer that maps regulation changes to an organisation's own governance entities.

Regulations tracker (base module)

  • Weekly sync of the public Global AI Regulations feed (per-country regulations, timelines, change history) with content-hash change detection. No scraping, no external service; VerifyWise never writes back to the feed.
  • Per-country tracking: each organisation chooses which countries to follow.
  • Browse, Tracked, Country detail, Horizon (changelog), Deadlines, and Frameworks views in a dedicated sidebar module.
  • Deep-linked in-app notifications and an email digest when a tracked country's regulations change or a new country appears.
  • Admin "check for updates now" on-demand sync, recipient configuration, and run-status reporting.
  • Four-table data model (global catalog + tenant tracking/settings + singleton meta), a day-one seed of full per-country detail (60 countries), and a weekly BullMQ job with feed-validation gates.

Impact analysis (detect-only)

  • When a tracked country's regulation changes, organisations with an LLM key configured see which of their AI systems, controls, policies, vendors, and assessments are affected, each with a one-sentence rationale.
  • Two-stage approach: a deterministic, tenant-scoped candidate filter (region and framework matching) narrows the set, then the organisation's own LLM (reusing the AI Advisor key mechanism) decides which candidates are genuinely affected and why. The LLM can only filter and annotate the candidate set — it can never introduce an entity that was not already found deterministically.
  • Runs during the weekly sync, isolated so a failure for one organisation never affects the sync or other tenants; results are cached per regulation version. The change notification carries the headline impact counts.
  • New impact panel on the country detail page, plus a Settings toggle to enable/disable analysis, an LLM-key status indicator, and a "last impact run" line.
  • Organisations without an LLM key keep the existing behaviour and receive a one-line prompt to configure a key; organisations that disable the toggle receive neither the panel nor the prompt.

Notes

  • Impact analysis is gated on an LLM key and an admin toggle; it adds no required configuration for organisations that do not use it.
  • Known V1 scope: standalone policies are matched only when linked to an affected control; country-to-region matching is coarse and refined by the LLM. Remediation task creation and audit-evidence export are intentionally out of scope for this version.
  • New strings are translated for German, French, and Spanish. A new database migration adds the impact-analysis table and two settings columns.

Design for a Regulations Tracker module mirroring AI Trust Index:
weekly BullMQ poll of the public Global AI Regulations feed, per-country
hash-based change detection, per-org tracking + configurable recipients,
in-app + email notifications, and a Browse/Tracked/Settings/Detail UI.

Includes source-feed evaluation and consolidated edge-case handling
(feed-floor guards, soft-delete by presentSlugs, first-seed suppression,
cosmetic-hash unstructured path, HTML escaping, BullMQ obliterate ordering).
15 tasks across 5 phases (migration/models/seed, feed+utils, weekly
job+notifications+template, routes+controllers, frontend), mirroring the
AI Trust Index module with complete code per step and TDD cycles.
## Changes
- Create Servers/utils/regulationsTracker.utils.ts with renderChangeLine,
  escapeHtml, currentIsoWeek (pure), getMetaQuery, upsertFeedTx (transactional)
- Create Servers/utils/__tests__/regulationsTracker.utils.test.ts with 5 TDD
  tests for the 3 pure functions; all pass
- Fix pre-existing unused IManifest import in regulationsTrackerFeed.ts

## Benefits
- Pure functions are fully unit-tested (TDD: red → green)
- upsertFeedTx is idempotent (hash-diff gating), row-lock safe, and tracks
  first-seed, newly-removed slugs, and per-country change lines
…task 6)

## Changes
- listCountries(filters): catalogue query with optional region/q filters
- getCountryRow(slug): single country detail including raw data blob
- listTracked(orgId): per-org tracked list joined to catalogue
- trackCountry(orgId, slug, userId): idempotent insert via ON CONFLICT DO NOTHING
- trackCountriesBulk(orgId, slugs, userId): sequential bulk wrapper
- untrackCountry(orgId, slug): delete tracked row
- getSettings(orgId): fetch notification settings row
- upsertSettings(orgId, userIds, emails, userId): ON CONFLICT DO UPDATE
- getAffectedOrgsBySlugs(slugs): find orgs tracking any of the given slugs (weekly job input)
- resolveEmailRecipients(orgId): configured user emails + free-text emails, deduped
- resolveInAppUserIds(orgId): org Admins (JOIN roles) ∪ configured user ids, deduped

## Notes
- normalizeSlug reused from existing private function (not redeclared)
- Admin-role query uses JOIN roles r ON r.id = u.role_id WHERE r.name IN ('Admin','SuperAdmin')
  — confirmed against invitation.utils.ts:72 and user.utils.ts:86, exact same schema
- All tenant queries filter by organization_id
- Build: tsc clean; tests: 5/5 passed
## Changes
- Add `syncRegulationsTracker.ts`: weekly BullMQ job that fetches the
  regulations feed, upserts the catalog via `upsertFeedTx`, fans out
  in-app notifications (`createNotificationQuery`) and email digests
  (`sendAutomationEmail`) to affected orgs.
- Export `sectionMjml` pure helper (builds MJML fragments for the email
  digest) and `DigestItem` interface.
- Suppress first-seed notifications and week-guard duplicate runs.
- Add `__tests__/syncRegulationsTracker.test.ts` covering the two
  `sectionMjml` branches (empty list → "", HTML-escaping of names).

## Adaptations from brief
- Imported `createNotificationQuery` (real export name) instead of
  `createNotification` (brief alias). Signature:
  `createNotificationQuery(notification: ICreateNotification, organizationId: number, transaction?)`.
- `type` and `entity_type` cast through `unknown` because
  `"regulations_tracker"` and `"regulation_country"` are not yet in the
  `NotificationType`/`NotificationEntityType` enums; casts are safe and
  the DB column is VARCHAR — enums can be extended as a follow-up.
- `entity_id` omitted (ICreateNotification.entity_id is `number`;
  country slugs are strings — storing the slug there would be a type
  violation).
- `sendAutomationEmail(to, subject, body, attachments?)` arity matches
  the brief's `(emails, subject, html, undefined)` exactly.
…ests

## Changes
- Fixed silent bug where the digest email "Changed" section showed only
  country names and never what changed: `ch?.detail` was always undefined
  because CountryChange has no `detail` field. Now uses `ch.lines.join(", ")`
  to surface the rendered change lines.
- Removed `slugs` array from the per-org bucket (was only kept for a
  `void slugs` suppressor) — now unused after the removed-branch no longer
  looks up changeBySlug.
- Removed `slug` array from removed-branch push; removed items carry no
  change detail (correct).
- Added three mocked unit tests for syncRegulationsTracker:
  1. Week-guard skip: getMetaQuery returns current ISO week → skipped, no fetch
  2. First-seed suppression: wasFirstSeed=true → orgsEmailed=0, orgsNotified=0
  3. Changed-country path: verifies email sent, in-app created, and digest
     detail contains the joined lines string (proving the critical fix)
## Changes
- automationProducer.ts: add scheduleRegulationsTrackerSync() (Monday 06:00 UTC, no obliterate)
- automationWorker.ts: add dispatch branch for regulations_tracker_sync job name
- producer.ts: import and call scheduleRegulationsTrackerSync() inside addAllJobs(), placed after scheduleAiTrustIndexSync()
- syncRegulationsTracker.ts: remove unused _CountryChange type alias (TS6196 fix)

## Benefits
- Regulations tracker feed syncs automatically every Monday at 06:00 UTC
- Worker correctly routes the job to syncRegulationsTracker()
- Non-obliterating scheduler — safe to run alongside other repeatable jobs
## Changes
- Add Servers/controllers/regulationsTracker.ctrl.ts with 8 handlers:
  getCountries, getCountryDetail, getTracked (any auth user);
  trackCountryCtrl, trackBulkCtrl, untrackCountryCtrl, updateSettingsCtrl (admin-gated)
- Add Servers/routes/regulationsTracker.route.ts with 8 routes under /api/regulations-tracker
- Register route in Servers/app.ts next to aiTrustIndex mount
- Regenerate swagger.yaml and docs/api-docs/src/config/endpoints.ts (677 ops, 0 drift)

## Pattern
Mirrors aiTrustIndex.ctrl.ts exactly: local isAdmin inline helper,
logProcessing/logSuccess/logFailure from utils/logger/logHelper,
STATUS_CODE from utils/statusCode.utils.
## Changes
- Add Servers/controllers/__tests__/regulationsTracker.ctrl.test.ts
- Mirror aiTrustIndex.ctrl.test.ts harness: jest.mock hoisted, mockRes helper, async handlers
- Cover all 8 handlers: getCountries, getCountryDetail, getTracked, trackCountryCtrl, trackBulkCtrl, untrackCountryCtrl, getSettingsCtrl, updateSettingsCtrl

## Test cases (26 total)
- getCountries: 200 response, query param forwarding
- getCountryDetail: live 200, 404 unknown slug, stale fallback on feed error
- getTracked: 200 response, tenant isolation (org A/B scoped by organizationId)
- trackCountryCtrl: 403 Editor, 403 Auditor, 400 missing slug, 200 Admin, 200 SuperAdmin, tenant isolation (orgId passed to util)
- trackBulkCtrl: 403 non-admin, 400 non-array slugs, 400 non-string slug, 200 Admin
- untrackCountryCtrl: 403 non-admin, 200 idempotent (never-tracked), 200 success + slug echo
- getSettingsCtrl: 200 for authenticated user
- updateSettingsCtrl: 403 non-admin, 400 bad email, 400 non-integer userId, 200 Admin valid input
## Changes
- Add global beforeEach(() => jest.clearAllMocks()) so mock call counts
  do not accumulate across tests
- Remove now-redundant mockClear() in the "org A cannot see org B" test
  (global reset makes it unnecessary)
- getCountries 200 test: assert res.json was called with the
  STATUS_CODE[200]-wrapped payload { message: "OK", data: [...] },
  checking that data contains an object with slug "eu"
- Auditor 403 test: add expect(trackCountry).not.toHaveBeenCalled()
  mirroring the Editor 403 test, proving the admin gate blocks writes
## Changes
- Add Clients/src/application/repository/regulationsTracker.repository.ts with 8 methods (getCountries, getCountryDetail, getTracked, trackCountry, trackBulk, untrackCountry, getSettings, updateSettings) hitting /regulations-tracker/* endpoints
- Add Clients/src/application/hooks/useRegulationsTracker.ts exporting 8 hooks (useCountries, useCountryDetail, useTracked, useTrackCountry, useUntrackCountry, useTrackBulk, useSettings, useUpdateSettings) mirroring useAiTrustIndex patterns

## Details
- Repository uses apiServices from infrastructure/api/networkServices, extracts .data from all responses (STATUS_CODE envelope)
- trackCountry sends body { slug } (confirmed against trackCountryCtrl)
- trackBulk sends body { slugs } (confirmed against trackBulkCtrl)
- updateSettings sends body { recipient_user_ids, recipient_emails } (snake_case, confirmed against updateSettingsCtrl)
- Hooks use const KEY = "regulations-tracker", keepPreviousData on read queries, mutations invalidate KEY
## Changes
- Add RegulationsTrackerSidebar.context.tsx — safe-context provider with
  trackedCount badge, mirrors AITrustIndexSidebarContext pattern exactly
- Add RegulationsTrackerSidebar.tsx — SidebarShell with Browse/Tracked/Settings
  menu items (Settings gated behind isAdmin), mirrors AITrustIndexSidebar
- Add RegulationsTracker/index.tsx — Navigate redirect to /browse, same as ATI
- Add Browse/index.tsx — country catalogue with region filter + SearchBox,
  per-row Track/Untrack, bulk-select + Track selected, pagination
- Add Tracked/index.tsx — client-side sorted/paginated tracked countries list
  with Untrack action, EmptyState with tips, mirrors ATI Tracked tab
- Add Settings/index.tsx — admin-gated AutoCompleteField user picker +
  ChipInput emails, debounced auto-save via useUpdateSettings, mirrors ATI
- Add CountryDetail/index.tsx — regulations list, timeline, change history;
  feed disclaimer rendered VERBATIM from meta.disclaimer/scopeStatement;
  stale:true shows warning banner + chip
- Add useTrackerAlert.tsx — shared error toast hook, mirrors useTrustIndexAlert
- Add 22 Regulations Tracker strings to i18n/translations.ts for de/fr/es
  (including truncated-prefix keys the audit script extracts from JSX)

## Gates
- typecheck: pass (0 errors)
- i18n:audit:strict: pass (0 gaps, 5965/5965 covered for de/fr/es)
## Changes
- CountryDetail: extract stale-banner amber hex values (#FFFBEA, #B45309,
  #92400E) to named module-level consts (STALE_BANNER_BG/ICON_COLOR/TEXT_COLOR)
  with a comment explaining no matching palette token exists (same approach as
  AITrustIndex/AppDetail which also hardcodes its error-banner colors)
- CountryDetail: py: 8 → py: "64px" (loading spinner box)
- Browse:        py: 6 → py: "48px" (loading spinner box)
- Settings:      py: 6 → py: "48px" (loading spinner box)
- Tracked:       py: 6 → py: "48px" (loading spinner box)
- Run prettier --write; all 7 previously-warned files now pass format-check
## Changes
- uiSlice.ts: add "regulations-tracker" to AppModule union type
- useActiveModule.ts: detect /regulations-tracker path, navigate to /browse on switch, whitelist in localStorage validator
- routes.tsx: lazy imports for RegulationsTracker index/Browse/Tracked/Settings/CountryDetail + 5 Route registrations under /regulations-tracker (bare path uses index component redirect, :slug param matches CountryDetail useParams)
- Dashboard/index.tsx: wrap provider tree with RegulationsTrackerSidebarProvider
- ContextSidebar/index.tsx: import context safe hook + RegulationsTrackerSidebar component, add "regulations-tracker" switch case mirroring ai-trust-index
- AppSwitcher/index.tsx: add Scale icon + "Regulations tracker" module entry (sentence case)
- breadcrumbs/index.tsx: skip home breadcrumb for /regulations-tracker paths

## Benefits
- All /regulations-tracker/* routes are reachable via React Router
- Module auto-detects from URL and persists in localStorage
- Sidebar and nav entry wire up exactly following the AI Trust Index pattern
## Changes
- Added German, French, and Spanish translations for the AppSwitcher
  module description "Track AI regulations and compliance requirements
  across jurisdictions" in Clients/src/i18n/translations.ts
- Applied prettier formatting to routes.tsx and Dashboard/index.tsx
  which were flagged by the format-check gate

## Benefits
- Fixes i18n:audit:strict (was 3 gaps in de/fr/es, now 0)
- Fixes format-check (all files now pass Prettier style check)
- All pre-PR gates pass: typecheck, i18n:audit:strict, format-check
…il proxy shape

## Changes

- listCountries now accepts organizationId as first arg and LEFT JOINs
  regulation_tracked_countries to produce (t.id IS NOT NULL) AS is_tracked
  on every row, scoped per-org. Column aliases prefixed with c. to avoid
  ambiguity after the join.

- getCountryRow now accepts organizationId and applies the same LEFT JOIN,
  returning is_tracked alongside the data JSONB column.

- getCountries controller: passes req.organizationId! as first arg to listCountries.

- getCountryDetail controller:
  - Calls getCountryRow(slug, req.organizationId!) so is_tracked is available.
  - Live path: spreads live + stale:false + is_tracked:local.is_tracked.
  - Stale path (was broken): was returning { country: local.data } which the
    frontend couldn't read. Now spreads ...local.data at root + stale:true +
    is_tracked:local.is_tracked, matching the shape CountryDetail reads at
    data?.data.

- Updated regulationsTracker.ctrl.test.ts mocks and assertions for the new
  signatures (orgId first arg, is_tracked in mock return values).

## Benefits

- Browse page Track/Untrack label and bulk-select filtering now work correctly
  per org (is_tracked was always false before).
- CountryDetail toggle correctly reflects tracking state in both live and stale
  fallback paths.
- Stale fallback no longer renders blank — both paths return the same root shape.
## Blocking correctness

FIX 1 — Notification enum crash (weekly job abort):
- Add NotificationType.REGULATIONS_TRACKER and
  NotificationEntityType.REGULATION_COUNTRY to i.notification.ts
- Replace `as unknown as` casts in syncRegulationsTracker.ts with real
  enum members
- Migration 20260626114016 extends enum_notification_type +
  enum_notification_entity_type so Postgres INSERT no longer throws

FIX 2 — Stale fallback blank page during feed outage:
- CountryDetail now shows a "Last known summary" SectionCard when stale=true
  and no regulations/timeline/change_history are present (manifest-summary
  shape). Shows regulationCount + history.lastChange entries + a clear message.
  The live (non-stale) empty state is unchanged.

FIX 3 — Bulk-track DoS guards:
- trackBulkCtrl: empty array now returns 400 "slugs must be a non-empty array"
  and >200 slugs returns 400 "too many slugs (max 200)" — matching aiTrustIndex

## Should-fix correctness

FIX 4 — Email digest double headers:
- Remove static "Changed" / "No longer in the feed" headers from
  regulations-tracker-digest.mjml; sectionMjml already emits them.
  Empty sections now render no orphan header.

FIX 6 — Stale fallback false-trigger on casing mismatch:
- getCountryDetail now passes local.slug (canonical/normalized) to
  fetchCountryDetail instead of raw req.params.slug

FIX 7 — Feed validation gates on valid (filtered) count:
- validateManifest now filters to valid entries first, then applies
  ABSOLUTE_FLOOR and 50%-drop gates against validCount — a feed with
  many malformed entries no longer passes the gates
- Updated existing tests; added two new tests covering healthy rawCount
  with majority-malformed entries
FIX 5 — Region dropdown collapses after filter selection:
- Browse now fetches an unfiltered country list (useCountries({})) as a stable
  source for the region dropdown. Selecting a region no longer removes other
  regions from the list.

FIX 8 — escapeHtml: already consolidated in regulations module.
  regulationsTracker.utils.ts exports the single definition; syncRegulationsTracker
  imports it from there. No code change needed; verified and documented.

FIX 9 — Shared CountryRowCard component:
- Extract CountryRowCard.tsx + CountryRow interface (shared between Browse and
  Tracked). Both pages now use the component; duplicated inline markup removed.

FIX 10 — upsertFeedTx N+1 SELECT → single prefetch:
- Replace per-country SELECT inside the loop with a single
  SELECT slug, hash FROM regulation_countries WHERE slug = ANY(:slugs)
  into a Map, looked up per-iteration. All existing behavior preserved.

i18n: add de/fr/es translations for "Last known summary" and
"Last recorded changes" (new stale-summary banner strings from FIX 2)
…ender from DB

The catalog previously stored only the manifest summary (slug/name/region/
hash/history), so country detail pages were blank until the live feed resolved
and blank entirely when it was slow or unreachable.

## Changes
- Regenerate the seed snapshot with full per-country detail (regulations,
  timeline, scope/obligations/penalties, disclaimer/meta) from the 60
  /country/<slug> endpoints; stored as data = { ...country, meta }.
- upsertFeedTx accepts an optional detailBySlug map and stores full detail when
  provided, falling back to the manifest summary otherwise.
- Weekly sync fetches full detail for new/hash-changed countries (via
  getStoredHashes) before upserting; per-country fetch failure is non-fatal.
- Detail controller normalizes both the live and stored paths to one flat shape
  (regulations/timeline/meta at the root) that the detail page reads, fixing a
  live/stale shape divergence.

## Benefits
- Country detail renders complete content from our DB instantly, offline-safe.
- Fresh installs get full data day-one from the committed snapshot.
## Changes
- Show the country flag emoji (from the feed) in Browse/Tracked row cards and
  the country detail header; globe icon remains the fallback.
- Surface flag from the catalog data JSONB in the list queries (listCountries,
  listTracked).
- Tracked rows in Browse now show a green check instead of a disabled empty
  checkbox; only untracked rows render the bulk-select checkbox.
- 8px gap between country rows on Browse and Tracked.
- Alias country_slug AS slug in the tracked-list query so row navigation,
  untrack, and keys resolve correctly.
…ete regulation fields)

The detail page read invented field names (effective_date/description/url,
timeline title) that don't exist in the feed, so effective dates, regulation
detail, timeline text, and the narrative summaries rendered blank.

## Changes
- Fix Regulation/TimelineEvent interfaces to the real feed fields.
- Regulation cards now show type, effectiveDate, scope, key obligations,
  max penalty, industry tags, and the source link.
- Timeline renders the real date + description.
- New Overview section surfaces oneLiner, executiveSummary, and
  practicalTakeaway (stored but never displayed before).
…ings

## Changes
Add DE/FR/ES translations for 18 UI-chrome strings introduced by the
Regulations Tracker Horizon, Deadlines, and Frameworks pages:
- Page titles: Horizon, Deadlines, International frameworks
- Section labels: Key obligations, Key principles, Practical takeaway
- Status labels: Scheduled, Not yet scheduled, Max penalty:
- Subtitle/description strings for all three tabs
- Empty-state messages (no changes/deadlines/frameworks recorded)
- Stale-data notices (showing last known data; live unavailable)

## Benefits
`npm run i18n:audit:strict` now reports 0 gaps for DE, FR, and ES
(was 18 gaps each, 54 total).
Surfaces the three public feeds the app previously ignored, each as a new tab
in the module sidebar with backend proxy + DB-cached fallback.

## Backend
- Migration adds horizon/deadlines/frameworks JSONB columns to the meta
  singleton (global, non-tenant reference data).
- Feed fetchers (fetchHorizon/fetchDeadlines/fetchSnapshot) + getGlobalFeed/
  setGlobalFeeds utils.
- GET /horizon, /deadlines, /frameworks endpoints: live feed first, fall back
  to the cached snapshot (stale flag) when the feed is unreachable.
- Weekly sync refreshes all three (best-effort; never aborts the country sync).

## Frontend
- Horizon (changelog), Deadlines (scheduled + unscheduled milestones), and
  Frameworks (11 international frameworks) pages.
- Three new sidebar tabs + routes + active-tab detection.

## Note
- Swagger/endpoints regenerated (680 ops, no drift).
## Changes
- Backend: new `enrichWithFlags()` helper in `regulationsTracker.ctrl.ts`
  batches a single parameterized SQL lookup (`slug = ANY(:slugs)`) against
  the global `regulation_countries` catalog and attaches `countryFlag` to
  each deadline item; applied to both `deadlines` and `unscheduled` arrays
  on both the live-fetch and stored-fallback paths; best-effort (silently
  returns items unchanged if the catalog query fails)
- Frontend: added `countryFlag?: string` to `Deadline` and `Unscheduled`
  interfaces; prepends the flag emoji inline with the country name in both
  the Scheduled and Not-yet-scheduled sections; falls back cleanly to name
  only when no flag is available

## Notes
- Browse page already renders flags via CountryRowCard — no change needed;
  missing flags in local dev are a stale-seed issue fixed by a sync run
- No new API routes added; no swagger regeneration required
- 67/67 unit tests pass; Servers build, format-check, Clients typecheck and
  format-check all clean
…d where to manage keys

## Changes
- Backend: `getSettingsCtrl` now fetches the LLM key list once and derives two
  additional non-secret fields — `llm_key_provider` (the `name` column of
  `keys[0]`, e.g. "Anthropic") and `llm_key_model` (e.g. "gpt-4o") — alongside
  the existing `has_llm_key` boolean. Both are `null` when no key exists. The
  same key that `runImpactAnalysis` will use (most-recently-created, via
  `ORDER BY created_at DESC`) is what is surfaced here.

- Frontend: the LLM-key status block on the Regulations Tracker Settings page
  now branches on the key situation:
  - No key: "Impact analysis needs an LLM key. Add an LLM key to turn this on."
    with a SPA navigate link to /settings/apikeys.
  - Key present: "Impact analysis will use your {provider} key ({model})."
    with a "Manage keys" link to /settings/apikeys. Falls back to
    "Impact analysis is active. Manage keys." if provider is unexpectedly null.

- i18n: added de/fr/es translations for the two new link strings ("Add an LLM
  key", "Manage keys") so i18n:audit:strict passes at 100% coverage.
…lines

Adds a horizontal 12-month runway strip above the Scheduled/Not-yet-scheduled
lists on the Regulations Tracker Deadlines page. The strip shows each upcoming
month as a grid column; deadline markers (flag emoji or brand-colored dot) are
placed in the matching month column. Months within 90 days get a faint brand
tint; the current month has a 2px left accent border. Clicking a marker
scroll-jumps to the matching row in the Scheduled list with a 1.5s brand-outline
highlight. Respects prefers-reduced-motion. New i18n strings added for de/fr/es.
Ran `generate:swagger` and `generate:endpoints` to pick up the two
impact-analysis routes added in the route layer but missing from the
generated artefacts:
  GET  /regulations-tracker/countries/{slug}/impact
  POST /regulations-tracker/countries/{slug}/impact/refresh

`check:api-drift` now passes (683 Express endpoints = 683 Swagger ops).
…ages

## Changes
- Add shared helper `statusVariant.ts` exporting `regulationStatusVariant()`
  mapping feed status values to semantic Chip variants:
  in-force → success, passed-not-active → info, proposed/draft → warning,
  policy-only/voluntary/unknown → default
- CountryDetail: replace 15-line inline ternary chain with single helper call
- Deadlines (scheduled + unscheduled): replace variant="default" with helper
- Frameworks: replace variant="default" with helper for f.status chip;
  namedDocuments chips stay default (free-text, non-semantic)
- Horizon: change-type chip (added/updated/status-change) set to variant="info"
  (visibly colored, not gray-on-gray; status helper not applicable here)

## Benefits
- Single source of truth for status→colour mapping
- All 5 feed status values now semantically coloured
- Passes typecheck + format-check gates
…n heat tint, real tooltips on markers

## Changes
- FIX 1: Remove 2px brand-green left border (today-marker rail) from the
  current month column entirely — no replacement marker.
- FIX 2: Reduce urgency heat tint alpha from 0.06 to 0.035 for the first
  3 months (≤90 days) — same logic, barely-there tint.
- FIX 3: Replace native title= browser tooltips on deadline markers with
  VWTooltip (header = regulation name, content = date + country flag/name,
  placement=top). aria-label retained for a11y. Removed unused isCurrentMonth
  variable. Removed redundant title= attribute.
…scoverability callout

## Changes
- Replace single-column stack of framework cards with a responsive CSS grid
  (1 col on xs, 2 col on md+) using MUI sx breakpoint object syntax
- Tighten key-principles list item font/line-height (12px / 1.4) so half-width
  cards stay compact
- Add subtle info callout above the grid: tells users the EU AI Act lives under
  each country, not in Frameworks; links to /regulations-tracker/browse via
  useNavigate (SPA navigation, no full reload)
- Add "Find them in Browse." to de/fr/es dictionaries in translations.ts

## Benefits
- Framework tab is easier to scan at a glance — two cards per row reduces
  excessive vertical scrolling
- Users who expect the EU AI Act here are guided to Browse rather than hitting
  a dead end
Add an animated checklist panel inside the cadence-note box on the
Regulations Tracker Settings page that educates the user about what
the manual sync actually does while it runs.

## Changes
- `Settings/index.tsx`: local `SyncState` machine (idle/running/done/error)
  drives four labelled stages: "Retrieving the latest regulations feed",
  "Validating the feed", "Comparing against your tracked countries",
  "Finishing up". Stages advance on a 800/850/900 ms simulation timer;
  active stage shows a CircularProgress spinner (size 12), completed stages
  show a green Check icon, upcoming stages are 40% opacity with a dot.
- Sim and real mutation run concurrently. Fast-forward: if the mutation
  resolves before the sim reaches stage 3, the stage-3 timer reads
  `mutationSettledRef` and resolves immediately. Hold-last-stage: if the
  sim finishes first, stage 3 stays spinning until `onSuccess`/`onError`
  fires. Error: clears all timers and transitions to error state inline.
- Done state shows the result counts ("Done — N changed, N removed") or
  "Already up to date (N)." and a Dismiss button. Error state shows an
  inline error message instead of the showError snackbar to avoid double-
  notification. Button disabled while running.
- `i18n/translations.ts`: added 2 new strings (aria-label + error message)
  in de/fr/es to keep i18n:audit:strict green.

## Benefits
- Informs users about what the sync actually does server-side (honest —
  these stages genuinely run; only the per-stage timing is simulated).
- Keyboard-accessible (role=status, aria-live=polite); reduced-motion safe
  (text labels + icons carry meaning, spinner is decoration only).
- No backend change; purely a frontend state machine.
…-enrichment ANY() syntax bug

The deadlines flag enrichment used 'slug = ANY(:slugs)', which Sequelize
expands to a comma list ANY('a','b') — a syntax error that the helper's
try/catch silently swallowed, so flags never appeared. Switched to IN (:slugs).
Added an idempotent migration backfilling data->>'flag' into existing
regulation_countries rows from the seed snapshot (older installs seeded before
flags were in the snapshot had null flags).
… A queries

## Changes
- Replace all 6 `= ANY(:param)` usages in getCandidates with `IN (:param)`,
  which Sequelize expands correctly for JS array replacements.
  `= ANY(:param)` produces `= ANY('a','b')` — a PostgreSQL syntax error
  whenever the array has 2+ values.
- Fix pre-existing table name bug: `project_frameworks` → `projects_frameworks`
  (matches the actual verifywise schema; caused the same runtime failure).

## Empty-array guards
- `:frameworks` (systems + controls): sentinel `["__none__"]` applied at
  binding site when frameworks array is empty.
- `:frameworkExposure`: already guarded by mapFrameworksToExposure().
- `:projectIds` (assessments + vendors): gated by `if (candidateProjectIds.length)`
  / `candidateProjectIds.length > 0 ? ... : [-1]` — already safe.
- `:controlIds` (policies): gated by `if (controlIds.length)` — already safe.

## Verified
- IN with 1 value, 3 values, and __none__ sentinel all execute without error
  against the real PostgreSQL DB.
- Empty array `IN ()` confirmed to error on this Sequelize version — sentinel
  guard is mandatory.

## Tests
- 37/37 unit tests pass (36 existing + 1 new regression guard that asserts
  no generated SQL contains `= ANY(`).
- `npm run build` clean.
…t, last-changed, tracked-since on tracked page

## Changes
- CountryRowCard: wrap the `checkbox` slot in a fixed 20×20 px flex container
  so tracked rows (CheckSquare icon) and untracked rows (native input) always
  start the card body at the same x-position; Tracked page (no checkbox) gets
  an empty 20 px slot keeping the same alignment.
- CountryRow interface: add optional `regulation_count`, `last_changed_at`,
  `created_at` fields (already returned by listTracked, dropped at the TS layer).
- CountryRowCard: add `showMeta` prop (default false). When true, renders a
  secondary 12 px tertiary line showing "{n} regulation(s) · Last changed {date}
  · Tracked since {date}", only for fields that are present.
- Tracked page: pass `showMeta` to CountryRowCard; fields flow through from the
  existing useTracked() → data.data array without any API change.
- Browse page: unchanged — no showMeta prop passed, no metadata line rendered.

## Gates
- typecheck: clean
- i18n:audit:strict: clean (metadata built as computed JS strings, not static
  JSX text nodes or prop literals — not scanned by the audit)
- format-check: clean
…new-count, controller SQL, guards, cleanup)

## Changes

- FIX 1: Runway click-to-jump — build a `markerIdxMap` (deadline → array index)
  once in RunwayCalendar instead of calling `findIndex` per marker. findIndex
  always returned the first match on duplicate entries, jumping to the wrong row.
  Markers now carry the identical index used by Scheduled list row ids.

- FIX 2 (backend): Add `newlyAdded: number` to syncRegulationsTracker return
  type (public + internal) and all return sites. The count was already computed
  from upsertFeedTx; now it surfaces to the caller so the Settings
  "check for updates" message `r.newlyAdded` is populated rather than undefined.
  Updated test assertion shape accordingly.

- FIX 3: Move `enrichWithFlags` (raw SQL) from the controller into
  `regulationsTracker.utils.ts` — thin-controller convention. Removed the local
  copy and added an import. Also removed the now-unused `QueryTypes`/`sequelize`
  imports from the controller.

- FIX 4: Parallelize the two enrichWithFlags calls in getDeadlines (live path
  and stored-fallback path) using Promise.all — they are independent DB queries.

- FIX 5: Wrap `getSettings` call in `getImpactAnalysis` in its own try/catch;
  on failure, default to enabled (fail-open) so a transient settings blip does
  not convert this previously-settings-free GET into a 500. Updated test.

- FIX 6: Guard `formatDate` in CountryRowCard.tsx — check `isNaN(d.getTime())`
  before calling toLocaleDateString; return null on bad input. Call sites guard
  the null with an `if (d)` before pushing to metaParts.

- FIX 7: Replace hand-rolled clickable Box span (hardcoded #0F5A47) in
  Frameworks/index.tsx with `VWLink onClick=...` — proper theming, focus/keyboard
  semantics, and no hardcoded hex.

- FIX 8 (no-op): Translation entries for RegulationsTracker strings kept.
  `npm run i18n:audit:strict` requires them (DOM-translator still needs the
  dictionary entries); removing them would cause coverage gaps.

- FIX 9: Add explicit invariant comment to STAGE_DELAYS in Settings/index.tsx
  documenting that STAGE_DELAYS.length === SYNC_STAGES.length - 1.

- NON-FIX: Add explanatory comment to `mapFrameworksToExposure` in
  regulationImpact.utils.ts noting why ISO 42001 / NIST AI RMF are absent from
  the mapping (no matching enum value in vendor.regulatory_exposure).

## Gates
- `cd Servers && npm run build` ✓
- `npx jest syncRegulationsTracker|regulationImpact|regulationsTracker.ctrl` — 88/88 ✓
- `cd Clients && npm run typecheck` ✓
- `cd Clients && npm run i18n:audit:strict` — 0 gaps ✓
- `cd Clients && npm run format-check` ✓
- `cd Servers && npm run format-check` ✓
Redesigns the Timeline SectionCard from a disconnected bullet-dot list
into a proper connected vertical timeline with:

- A 1px absolute-positioned vertical rail (palette.border.dark) spanning
  the dot column, inset top/bottom by 6px so it starts/ends at dot centres.
- Two-layer dot rings: outer border ring + inner filled dot. Latest event
  gets a brand-primary ring + brand dot (20px); older events get a muted
  border ring + border-dark dot (16px) for clear "most-recent" emphasis.
- Dots use palette.background.main fill so the rail visually starts/ends
  at them rather than bleeding through.
- Spacing expanded from 8px to 20px pb per row (breathing room for the
  rail); date typography shifted to brand-primary on latest event, darker
  secondary on older events; description lineHeight 1.6.
- All spacing uses pixel strings; no hardcoded hex — palette tokens only.
- Zero new dependencies; aria-hidden on decorative elements.

Gates: typecheck clean, i18n:audit:strict 100% coverage, format-check clean.
…d and untracked

The untracked row used a native <input type=checkbox> which renders larger
than the tracked state's 16px CheckSquare icon, so ticked boxes looked smaller
than unticked ones. Replace the native input with a button(role=checkbox)
rendering the same 16px Lucide Square / CheckSquare icon, so both states share
an identical box model and size, with keyboard focus support.
## Changes
- Cron `regulations_tracker_sync` moved from Mondays 06:00 UTC (`0 6 * * 1`)
  to every day 06:00 UTC (`0 6 * * *`).
- Idempotency guard switched from ISO-week to UTC-day: new `currentIsoDay()`
  helper (YYYY-MM-DD); `runSync` now skips only when the stored key equals
  today. Without this, a daily cron would no-op Tue-Sun because the ISO week
  was unchanged.
- Reuse the existing `last_run_week VARCHAR(10)` column to hold the day key
  (fits "YYYY-MM-DD" exactly) — no migration. Renamed `clearLastRunWeek` ->
  `clearLastRunDay`.
- Surface feed staleness: `getSettingsCtrl` now returns `feed_last_data_update`,
  read from the stored horizon blob's `meta.lastDataUpdate` (the feed's own
  data-update date, independent of when our sync last ran). Null-safe, no extra
  external call. Lets the UI warn when the upstream feed itself goes stale —
  previously indistinguishable from "nothing changed".
- Digest email subject de-weeklied ("Global AI regulations — update").
- Doc/comment drift fixed (weekly -> daily) across the module, the domain
  reference doc, and the CLAUDE.md feature index; V1 limitation note updated to
  flag the higher LLM fan-out under daily cadence.

## Benefits
- A regulation change is picked up — and tracked-country customers alerted —
  the day it lands rather than waiting up to a week.
- No-change days stay cheap: hash-diff means 1 manifest fetch, 0 detail fetches.

## Notes
- Pre-existing: the day watermark commits inside upsertFeedTx before
  notification dispatch, so a same-day dispatch failure delays the alert to the
  next run. The daily cadence shrinks that window from ~7 days to ~1.
- Always pulls from verifywise.ai (servers are behind a firewall; no webhook,
  no feed-origin override).

## Tests
- Added currentIsoDay unit tests + getSettingsCtrl feed_last_data_update tests;
  updated the sync skip test to day semantics. tsc clean; 66/66 pass.
Adds six user-guide articles (browse, tracked, horizon, deadlines, frameworks,
settings) covering every Regulations Tracker sidebar page plus impact analysis,
and registers them in the content map and collection config. These pages
referenced helpArticlePath ids that previously resolved to no content, so the
in-app help drawer opened empty.
…wse controls by role

Tracking (track / untrack / bulk-track) was restricted to Admin and SuperAdmin
on the backend, and the Browse page showed the controls to every user, so
ineligible roles got a failing click. Tracking is now allowed for Admin,
SuperAdmin, and Editor via a canTrack helper on the three track endpoints
(Settings, sync, and refresh stay admin-only). The Browse page now gates the
per-row Track/Untrack button, the row checkbox, and the bulk-track toolbar
behind the same canTrack role check, so other roles see a read-only catalogue.
Updated the controller role tests and the Browse user-guide article.
The MJML digest header still read 'Global AI regulations — weekly update'
after the weekly->daily cadence switch (the .mjml template was missed by the
earlier .ts-only sweep). Aligns it with the code-side subject ('— update')
so daily change alerts are not mislabelled as weekly.
…y drill-down

## Changes
- Empty-state copy on Horizon, Frameworks and Deadlines reframed from terse
  'no data recorded yet' (reads as broken) to reassuring 'you're all caught up /
  will appear here once published' guidance.
- Deadlines rows (Scheduled + Not-yet-scheduled) now link the country name to its
  detail page (/regulations-tracker/<slug>) with hover affordance + keyboard
  access, closing the dead-end where deadlines couldn't reach country detail.

## Benefits
- New/quiet orgs no longer see pages that look broken on first open.
- Deadlines becomes a navigable entry point into full country detail, not a
  read-only list.
The per-org impact summary (which of YOUR systems/controls/policies/vendors a
change affects) was only ever appended to the in-app notification message — the
smallest surface — while the spacious email digest never carried it. This puts
the verbose, valuable content where there's room for it.

## Changes
- New impactSectionMjml() renders a 'How these changes affect your organization'
  block in the email: per affected country, each entity group (systems, controls,
  policies, vendors, assessments) listed with the specific item name AND the
  LLM's one-line reason. Empty groups are omitted; the whole section is omitted
  when nothing is affected or impact is off/keyless.
- renderDigest() now takes an optional per-country impact list; new
  {{impactSection}} placeholder added to the MJML template after the removed
  section.
- Impact results computed during the in-app loop are captured in impactBySlug
  and reused for the email. Email-only orgs (no in-app recipients) now also get
  impact: a backfill pass runs analysis for changed countries not yet analyzed,
  honoring the same LLM-key/enabled gate and the per-run IMPACT_MAX_ANALYSES cap.
- In-app notifications keep the short counts-only suffix (right for a cramped
  surface); the email gets the detail.

## Tests
- tsc clean; existing syncRegulationsTracker suite (10 tests) passes.
…g an org

A super-admin is org-less and gets read-only access when viewing any organization
(the backend blocks all non-GET writes with 403). The Regulations Tracker UI was
showing them write controls that would fail: the Browse track/untrack/bulk buttons,
the country-detail Track button, the impact Re-analyse button, and the editable
Settings view. Each now excludes super-admins (and keeps tracking to admins/editors,
re-analyse and settings to admins), so a super-admin sees the module read-only.
Updated the Browse user-guide article to note this.
…try to the sync

## Changes
- Log a per-run feed-diff quality split: how many changed countries arrived
  with a structured field-level diff vs. hash-only (no structured diff). The
  hash-only case is also warned with the affected slugs, since those produce
  generic impact verdicts.
- Tally impact-analysis outcomes (ok / skipped_no_candidates / error / no_key /
  cached) across every org x country pass via a new tallyImpact() helper, and
  emit them in an end-of-run 'value telemetry' log line.

## Benefits
- Makes the real value of the LLM-enriched digest measurable in production:
  whether the upstream feed actually publishes change-specific diffs, and how
  often the impact pass yields a usable verdict versus having nothing to judge.
… structured diff

## Problem
When the feed moved a country's hash but carried no structured field-level
diff (unstructured change), the impact pass still ran. With nothing concrete
to judge, the LLM was told '(no structured diff available)' and produced a
generic verdict — a misleading 'how this affects you' block built on no real
change. This is the current live-feed state for every country, so admins would
get generic impact panels rather than change-specific value.

## Changes
- Thread the per-country 'unstructured' flag from the feed diff into the
  notification/email buckets.
- Gate both impact-analysis call sites (in-app pass and email backfill) on
  !unstructured: no diff -> no LLM call, no impact panel, no LLM spend.
- Suppress the 'configure an LLM key' nudge for unstructured changes too, since
  a key would not have produced a panel there either.
- The plain change notification and the 'Changed' email section still fire so
  the org always knows the country changed.

## Benefits
- The enriched impact block now appears only when there is a real, specific
  change to reason about — so when it shows, it carries genuine signal.
- Avoids wasted LLM calls on no-op hash moves.

## Tests
- Added a case asserting an unstructured change makes no runImpactAnalysis call,
  still notifies (no 'Impact:' suffix, no key nudge), and emails with an empty
  impact section. Full suite: 11 passing.
…crumbs and Activity tab

## Changes
- Frameworks: equal-width, responsive two-column grid (repeat(2, minmax(0, 1fr))
  + align-items: start) so a long unbreakable string can no longer widen one
  column. Long named-document tags now wrap inside the card instead of
  overflowing the page (replaced the non-wrapping Chip with a wrapping pill).
- Renamed the "Horizon" tab to "Activity" (sidebar label + page title). The page
  is a backward-looking changelog, so "Horizon" (which reads as future-looking)
  was misleading.
- Tracked: removed the empty leading checkbox slot so flags sit flush against the
  card padding (the slot now only renders when a checkbox is supplied; Browse,
  which passes one, is unchanged).
- Breadcrumbs: added route label + icon mappings for every regulations-tracker
  tab and a country-detail pattern, so the second crumb is split (e.g. "Browse"
  instead of "Regulations Tracker / Bro...") and every crumb shows an icon.
- Reworded the Deadlines empty-state ("they are published") and added the new
  user-facing strings to de/fr/es. i18n strict: 0 gaps.

## Benefits
- Consistent, responsive layout with no horizontal overflow.
- Clearer navigation and accurate tab wording.
…ons to the changed regulation

## Problem
buildContext built the LLM impact prompt with two flaws:
1. It matched the wrong change field names ("status" / "effectiveDate") instead
   of the feed shape ("regulation.status" / "regulation.effectiveDate"), so
   status and effective-date changes were silently dropped — the LLM never saw
   them.
2. It flattened obligations from every regulation in the country (46 lines for
   the US), drowning the few duties that actually changed and working against
   the "judge the change, not the regulation" instruction.

## Changes
- Match the feed RegulationChange shape exactly, and include the changed
  regulation name in each status/effective-date change line.
- Collect the names of the regulation(s) that changed and scope obligations to
  just those (matched against regs[].name). Fall back to all obligations when no
  specific regulation can be identified (e.g. a regulationCount-only change or an
  added regulation whose value does not line up), so the prompt is never empty.

## Benefits
- The impact analysis now sees the real, full change set, and the prompt
  foregrounds the new/altered duties instead of the whole regulatory baseline —
  sharper verdicts and fewer tokens.

## Tests
- Added buildContext coverage: correct field capture, obligation scoping,
  count-only fallback, added-regulation matching, and missing-history. 45 tests
  passing across the impact + sync suites.
…d migrations from review

Addresses verified findings from a full-branch expert review.

## Sync pipeline
- Replace the module-level syncInProgress boolean with a Postgres advisory lock
  (acquireSyncLock) so concurrency is enforced across worker processes/pods, not
  just within one Node process. Keep the boolean as a cheap same-process
  fast-path. Fail open if the lock machinery errors.
- Isolate each org's notification/email dispatch in its own try/catch so one org
  failure no longer aborts the loop and starves every subsequent org; record a
  "partial" run status when any org fails.
- Remove queue.obliterate from the vendor and report schedulers. It wiped the
  ENTIRE shared BullMQ queue, making job survival order-dependent and at risk of
  silently dropping the regulations-tracker daily job. Repeatable adds are
  idempotent by repeat key. Drop the now-stale ordering comment.

## Impact analysis
- validateVerdicts: coerce numeric-string ids that some models emit instead of
  silently dropping the verdict; the sentKeys guard still rejects hallucinated
  ids. Added tests.
- parseJsonLoose: scan brace depth to find the first top-level object instead of
  lastIndexOf, which mis-bounds when the model appends prose containing a brace.

## Controller
- GET /frameworks no longer writes setGlobalFeeds: a GET must not mutate shared
  cross-tenant state; the daily sync owns that cache write. Drop the now-unused
  import.
- Return a generic "Internal server error" from all 14 500 responses instead of
  the raw error message (which could leak SQL/schema detail). Detail is still
  logged via logFailure.

## Migrations
- Widen regulation_tracker_meta.last_run_week VARCHAR(10) to VARCHAR(20).
- Drop the redundant idx_reg_impact_org_slug index (the UNIQUE org+slug
  constraint already creates an identical index).
- Seed down: stop the bare DELETE FROM regulation_countries that would wipe the
  global catalog for all tenants on a seed-only rollback; reset markers only.
- Guard fs.readFileSync in the seed (throw with a clear message) and the flag
  backfill (skip gracefully) when the snapshot file is absent.
- Use ALTER TABLE IF EXISTS in the impact-table down for out-of-order rollback.

## Frontend
- Settings: drop the dead SuperAdmin branch in isAdmin (unreachable behind
  !isSuperAdmin).

## Notes
- getAffectedOrgsBySlugs intentionally keeps no is_active filter (removed
  countries must still notify); documented to prevent a wrong "fix".

All gates green: server build, 47 server tests, client typecheck, format-check.
@gorkem-bwl gorkem-bwl marked this pull request as draft June 29, 2026 12:02
## Problem
The Tracked/Browse/Deadlines pages showed no country flags. The manifest feed
does NOT carry a per-country flag (only the detail endpoint does, inconsistently),
so every daily sync overwrote regulation_countries.data with flag-less manifest
entries — wiping the one-time backfill migration and leaving data->>flag NULL for
all 60 countries.

## Fix
Treat the committed seed snapshot as the durable source of truth for flags. Add
a lazily-loaded slug -> emoji map and re-inject the flag into every row stored by
upsertFeedTx (withFlag), so flags survive every sync regardless of feed shape.
Presentation-only — never part of the change-detection hash.

## Benefits
- Flags now render and stay rendered after each daily sync.
- No dependency on the feed providing flags.

Verified: build clean, 11 sync tests pass, flags render on the Tracked page.
Existing local rows were repaired in place; new installs get flags via the
seed + this injection.
@MuhammadKhalilzadeh MuhammadKhalilzadeh added this to the 2.5 milestone Jul 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants