Repository navigation
feat(suppliers): track data gap time to fix per supplier #3572
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5c5416d
50b49e5
2bdca3f
e6683b0
bd5bd6e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,135 @@ | ||||||||||||||||||
| """Data gap time to fix: public.data_gap_events. | ||||||||||||||||||
|
|
||||||||||||||||||
| Mirrors supabase/migrations/20261024060000_data_gap_time_to_fix.sql exactly: | ||||||||||||||||||
| same table, columns, constraints, indexes, RLS policy and grants. One row per | ||||||||||||||||||
| data gap on a traceability lot (a missing KDE or a failed CTE validation). A | ||||||||||||||||||
| partial unique index allows exactly one open row per gap identity. A closed | ||||||||||||||||||
| gap carries the closing record id and its SHA-256 as the receipt. | ||||||||||||||||||
|
|
||||||||||||||||||
| Additive and idempotent (IF NOT EXISTS, drop-if-exists-then-create policy, | ||||||||||||||||||
| role-guarded grants), so the plain SQL file can run ahead of it. Downgrade | ||||||||||||||||||
| drops only what this revision created. | ||||||||||||||||||
|
|
||||||||||||||||||
| Revision ID: 41f96588d87c | ||||||||||||||||||
| Revises: 1faec7148e78 | ||||||||||||||||||
| Create Date: 2026-10-24 | ||||||||||||||||||
| """ | ||||||||||||||||||
|
|
||||||||||||||||||
| from typing import Sequence, Union | ||||||||||||||||||
|
|
||||||||||||||||||
| from alembic import op | ||||||||||||||||||
|
|
||||||||||||||||||
| revision: str = "41f96588d87c" # pragma: allowlist secret | ||||||||||||||||||
| down_revision: Union[str, Sequence[str], None] = "1faec7148e78" # pragma: allowlist secret | ||||||||||||||||||
| branch_labels = None | ||||||||||||||||||
| depends_on = None | ||||||||||||||||||
|
|
||||||||||||||||||
| _UPGRADE_STATEMENTS: tuple[str, ...] = ( | ||||||||||||||||||
| """ | ||||||||||||||||||
| create table if not exists public.data_gap_events ( | ||||||||||||||||||
| id uuid primary key default gen_random_uuid(), | ||||||||||||||||||
| tenant_id uuid not null, | ||||||||||||||||||
| supplier_key text not null, | ||||||||||||||||||
| lot_code text not null, | ||||||||||||||||||
| cte_type text not null, | ||||||||||||||||||
| gap_kind text not null, | ||||||||||||||||||
| gap_key text not null, | ||||||||||||||||||
| opened_at timestamptz not null default now(), | ||||||||||||||||||
| closed_at timestamptz, | ||||||||||||||||||
| closing_record_id uuid, | ||||||||||||||||||
| closing_record_hash text, | ||||||||||||||||||
| constraint data_gap_events_gap_kind_check | ||||||||||||||||||
| check (gap_kind in ('missing_kde', 'cte_validation_failed')), | ||||||||||||||||||
| -- An open gap carries no receipt; a closed gap always carries one. | ||||||||||||||||||
| constraint data_gap_events_close_receipt_check | ||||||||||||||||||
| check ( | ||||||||||||||||||
| (closed_at is null and closing_record_id is null and closing_record_hash is null) | ||||||||||||||||||
| or ( | ||||||||||||||||||
| closed_at is not null | ||||||||||||||||||
| and closed_at >= opened_at | ||||||||||||||||||
| and closing_record_hash ~ '^[0-9a-f]{64}$' | ||||||||||||||||||
| ) | ||||||||||||||||||
| ) | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Closed-gap receipt check is incompleteMedium Severity The Additional Locations (1)Reviewed by Cursor Bugbot for commit bd5bd6e. Configure here. |
||||||||||||||||||
| ); | ||||||||||||||||||
| """, | ||||||||||||||||||
| """ | ||||||||||||||||||
| -- Exactly one open row per gap identity. Closed rows are history and may repeat. | ||||||||||||||||||
| create unique index if not exists uq_data_gap_events_open | ||||||||||||||||||
| on public.data_gap_events (tenant_id, supplier_key, lot_code, cte_type, gap_kind, gap_key) | ||||||||||||||||||
| where closed_at is null; | ||||||||||||||||||
| """, | ||||||||||||||||||
| """ | ||||||||||||||||||
| -- Close path: every open gap for a tenant lot and CTE, regardless of supplier. | ||||||||||||||||||
| create index if not exists idx_data_gap_events_open_lot | ||||||||||||||||||
| on public.data_gap_events (tenant_id, lot_code, cte_type) | ||||||||||||||||||
| where closed_at is null; | ||||||||||||||||||
| """, | ||||||||||||||||||
| """ | ||||||||||||||||||
| -- Read path: per supplier summary. | ||||||||||||||||||
| create index if not exists idx_data_gap_events_supplier | ||||||||||||||||||
| on public.data_gap_events (tenant_id, supplier_key); | ||||||||||||||||||
| """, | ||||||||||||||||||
| """ | ||||||||||||||||||
| alter table public.data_gap_events enable row level security; | ||||||||||||||||||
| """, | ||||||||||||||||||
| """ | ||||||||||||||||||
| alter table public.data_gap_events force row level security; | ||||||||||||||||||
| """, | ||||||||||||||||||
| """ | ||||||||||||||||||
| -- CASE form, not OR: public.get_tenant_context() raises when app.tenant_id is | ||||||||||||||||||
| -- unset and Postgres does not order OR operands, so the sysadmin arm is tested | ||||||||||||||||||
| -- first (same predicate shape as alembic v096 and v134). | ||||||||||||||||||
| drop policy if exists tenant_isolation_data_gap_events on public.data_gap_events; | ||||||||||||||||||
| """, | ||||||||||||||||||
| """ | ||||||||||||||||||
| create policy tenant_isolation_data_gap_events on public.data_gap_events | ||||||||||||||||||
| for all | ||||||||||||||||||
| using ( | ||||||||||||||||||
| case | ||||||||||||||||||
| when (select current_setting('regengine.is_sysadmin', true)) = 'true' | ||||||||||||||||||
| and current_user = 'regengine_sysadmin' then true | ||||||||||||||||||
| else tenant_id = (select public.get_tenant_context()) | ||||||||||||||||||
| end | ||||||||||||||||||
| ) | ||||||||||||||||||
| with check ( | ||||||||||||||||||
| case | ||||||||||||||||||
| when (select current_setting('regengine.is_sysadmin', true)) = 'true' | ||||||||||||||||||
| and current_user = 'regengine_sysadmin' then true | ||||||||||||||||||
| else tenant_id = (select public.get_tenant_context()) | ||||||||||||||||||
| end | ||||||||||||||||||
| ); | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. New table omitted from RLS coverageMedium Severity
Triggered by project rule: Cursor Bugbot Review Guidelines Reviewed by Cursor Bugbot for commit 50b49e5. Configure here. |
||||||||||||||||||
| """, | ||||||||||||||||||
| """ | ||||||||||||||||||
| -- Runtime role allowlist (alembic v111): new tables are inaccessible to the | ||||||||||||||||||
| -- app role until a migration names them. No DELETE: gap history is evidence. | ||||||||||||||||||
| -- Not exposed through the Supabase Data API; the backend reads and writes it. | ||||||||||||||||||
| do $$ | ||||||||||||||||||
| begin | ||||||||||||||||||
| if exists (select 1 from pg_roles where rolname = 'anon') then | ||||||||||||||||||
| revoke all on public.data_gap_events from anon; | ||||||||||||||||||
| end if; | ||||||||||||||||||
| if exists (select 1 from pg_roles where rolname = 'authenticated') then | ||||||||||||||||||
| revoke all on public.data_gap_events from authenticated; | ||||||||||||||||||
| end if; | ||||||||||||||||||
| if exists (select 1 from pg_roles where rolname = 'regengine') then | ||||||||||||||||||
| grant select, insert, update on public.data_gap_events to regengine; | ||||||||||||||||||
| end if; | ||||||||||||||||||
|
Comment on lines
+114
to
+116
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🔎 Supported by static analysis
Restrict gap-event updates and protect closed rows.
Use column-level Suggested grant fix
📝 Committable suggestion
|
||||||||||||||||||
| if exists (select 1 from pg_roles where rolname = 'regengine_sysadmin') then | ||||||||||||||||||
| grant select on public.data_gap_events to regengine_sysadmin; | ||||||||||||||||||
| end if; | ||||||||||||||||||
| end $$; | ||||||||||||||||||
| """, | ||||||||||||||||||
| ) | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| def upgrade() -> None: | ||||||||||||||||||
| for statement in _UPGRADE_STATEMENTS: | ||||||||||||||||||
| op.execute(statement) | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| def downgrade() -> None: | ||||||||||||||||||
| op.execute("DROP POLICY IF EXISTS tenant_isolation_data_gap_events ON public.data_gap_events") | ||||||||||||||||||
| op.execute("DROP INDEX IF EXISTS public.idx_data_gap_events_supplier") | ||||||||||||||||||
| op.execute("DROP INDEX IF EXISTS public.idx_data_gap_events_open_lot") | ||||||||||||||||||
| op.execute("DROP INDEX IF EXISTS public.uq_data_gap_events_open") | ||||||||||||||||||
| op.execute("DROP TABLE IF EXISTS public.data_gap_events") | ||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| import { describe, expect, it } from 'vitest'; | ||
| import { render, screen } from '@testing-library/react'; | ||
| import { GapTimeToFix } from '@/app/dashboard/suppliers/gap-time-to-fix'; | ||
| import type { SupplierGapTimeToFix } from '@/types/api'; | ||
|
|
||
| const NOW = Date.UTC(2026, 0, 2); | ||
| const HOUR_MS = 3_600_000; | ||
|
|
||
| const summary = (overrides: Partial<SupplierGapTimeToFix>): SupplierGapTimeToFix => ({ | ||
| supplier_key: 'k', | ||
| open_gap_count: 0, | ||
| median_time_to_fix_seconds: null, | ||
| oldest_open_gap_opened_at: null, | ||
| at_risk: false, | ||
| at_risk_gap_count: 0, | ||
| past_internal_target: false, | ||
| has_data: true, | ||
| ...overrides, | ||
| }); | ||
|
|
||
| describe('GapTimeToFix', () => { | ||
| it('shows No data when the supplier has no gap history', () => { | ||
| render(<GapTimeToFix summary={undefined} unavailable={false} now={NOW} />); | ||
| expect(screen.getByText('Gap time to fix: No data')).toBeInTheDocument(); | ||
| expect(screen.queryByText(/At risk/)).not.toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('shows No data when the summary reports has_data false', () => { | ||
| render(<GapTimeToFix summary={summary({ has_data: false })} unavailable={false} now={NOW} />); | ||
| expect(screen.getByText('Gap time to fix: No data')).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('cites 21 CFR 1.1455(c)(1) in the empty state', () => { | ||
| render(<GapTimeToFix summary={undefined} unavailable={false} now={NOW} />); | ||
| expect(screen.getByRole('link', { name: /21 CFR 1\.1455\(c\)\(1\)/ })).toHaveAttribute( | ||
| 'href', | ||
| 'https://www.ecfr.gov/current/title-21/section-1.1455', | ||
| ); | ||
| }); | ||
|
|
||
| it('says unavailable instead of No data when the request failed', () => { | ||
| render(<GapTimeToFix summary={undefined} unavailable now={NOW} />); | ||
| expect(screen.getByText('Gap time to fix unavailable')).toBeInTheDocument(); | ||
| expect(screen.queryByText(/No data/)).not.toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('renders open count, median, oldest open gap, and the request due badge with its citation', () => { | ||
| render( | ||
| <GapTimeToFix | ||
| summary={summary({ | ||
| open_gap_count: 2, | ||
| median_time_to_fix_seconds: 5_400, | ||
| oldest_open_gap_opened_at: new Date(NOW - 25 * HOUR_MS).toISOString(), | ||
| at_risk: true, | ||
| at_risk_gap_count: 1, | ||
| past_internal_target: true, | ||
| })} | ||
| unavailable={false} | ||
| now={NOW} | ||
| />, | ||
| ); | ||
| expect(screen.getByText('2 open gaps')).toBeInTheDocument(); | ||
| expect(screen.getByText('Median time to fix: 1 h 30 min')).toBeInTheDocument(); | ||
| expect(screen.getByText('Oldest open gap: 1 d 1 h')).toBeInTheDocument(); | ||
| expect(screen.getByText('At risk: open when FDA records response is due')).toBeInTheDocument(); | ||
| expect(screen.getByRole('link', { name: /21 CFR 1\.1455\(c\)\(1\)/ })).toHaveAttribute( | ||
| 'href', | ||
| 'https://www.ecfr.gov/current/title-21/section-1.1455', | ||
| ); | ||
| expect(screen.getByText('Internal target: fixed within 24 hours of flag')).toBeInTheDocument(); | ||
| expect(screen.getByText('Past internal target')).toBeInTheDocument(); | ||
| expect(screen.queryByText(/over 24 hours/)).not.toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('shows the internal target without a regulatory citation when not at risk', () => { | ||
| render( | ||
| <GapTimeToFix | ||
| summary={summary({ open_gap_count: 1, past_internal_target: true })} | ||
| unavailable={false} | ||
| now={NOW} | ||
| />, | ||
| ); | ||
| expect(screen.getByText('Past internal target')).toBeInTheDocument(); | ||
| expect(screen.queryByText(/At risk/)).not.toBeInTheDocument(); | ||
| expect(screen.queryByRole('link', { name: /1\.1455/ })).not.toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('says No fixes yet when no gap has closed and hides the badge when not at risk', () => { | ||
| render(<GapTimeToFix summary={summary({ open_gap_count: 1 })} unavailable={false} now={NOW} />); | ||
| expect(screen.getByText('1 open gap')).toBeInTheDocument(); | ||
| expect(screen.getByText('Median time to fix: No fixes yet')).toBeInTheDocument(); | ||
| expect(screen.queryByText(/At risk/)).not.toBeInTheDocument(); | ||
| expect(document.body.textContent).not.toMatch(/NaN|undefined/); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| import { ExternalLink } from 'lucide-react'; | ||
|
|
||
| import { Badge } from '@/components/ui/badge'; | ||
| import type { SupplierGapTimeToFix } from '@/types/api'; | ||
|
|
||
| const RECORDS_REQUEST_URL = 'https://www.ecfr.gov/current/title-21/section-1.1455'; | ||
|
|
||
| /** Whole seconds to a short duration, largest two units. */ | ||
| function formatDuration(totalSeconds: number): string { | ||
| if (totalSeconds < 60) return 'under 1 min'; | ||
| const days = Math.floor(totalSeconds / 86_400); | ||
| const hours = Math.floor((totalSeconds % 86_400) / 3_600); | ||
| const minutes = Math.floor((totalSeconds % 3_600) / 60); | ||
| if (days > 0) return hours > 0 ? `${days} d ${hours} h` : `${days} d`; | ||
| if (hours > 0) return minutes > 0 ? `${hours} h ${minutes} min` : `${hours} h`; | ||
| return `${minutes} min`; | ||
| } | ||
|
|
||
| function openFor(openedAt: string | null, now: number): string | null { | ||
| if (!openedAt) return null; | ||
| const opened = Date.parse(openedAt); | ||
| if (Number.isNaN(opened)) return null; | ||
| return formatDuration(Math.max(0, (now - opened) / 1000)); | ||
| } | ||
|
|
||
| interface GapTimeToFixProps { | ||
| summary: SupplierGapTimeToFix | undefined; | ||
| /** True when the time to fix request failed. */ | ||
| unavailable: boolean; | ||
| now: number; | ||
| } | ||
|
|
||
| /** Per facility data gap time to fix: open gaps, median fix time, oldest open gap. */ | ||
| export function GapTimeToFix({ summary, unavailable, now }: GapTimeToFixProps) { | ||
| if (unavailable) { | ||
| return <p className="mt-2 text-small text-re-text-tertiary">Gap time to fix unavailable</p>; | ||
| } | ||
|
|
||
| const citation = ( | ||
| <a | ||
| href={RECORDS_REQUEST_URL} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="inline-flex items-center gap-1 text-re-text-tertiary underline underline-offset-2 hover:text-re-text-primary" | ||
| > | ||
| 21 CFR 1.1455(c)(1) | ||
| <ExternalLink className="h-3 w-3" strokeWidth={1.75} aria-hidden="true" /> | ||
| </a> | ||
| ); | ||
|
|
||
| if (!summary || !summary.has_data) { | ||
| return ( | ||
| <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-small text-re-text-tertiary"> | ||
| <span>Gap time to fix: No data</span> | ||
| {citation} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| const median = summary.median_time_to_fix_seconds; | ||
| const oldest = openFor(summary.oldest_open_gap_opened_at, now); | ||
|
|
||
| return ( | ||
| <div | ||
| className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-small text-re-text-secondary" | ||
| aria-label="Gap time to fix" | ||
| > | ||
| <span> | ||
| {summary.open_gap_count} open gap{summary.open_gap_count !== 1 ? 's' : ''} | ||
| </span> | ||
| <span>Median time to fix: {median === null ? 'No fixes yet' : formatDuration(median)}</span> | ||
| {oldest !== null && <span>Oldest open gap: {oldest}</span>} | ||
| <span>Internal target: fixed within 24 hours of flag</span> | ||
| {summary.past_internal_target && ( | ||
| <Badge tone="warning" size="sm"> | ||
| Past internal target | ||
| </Badge> | ||
| )} | ||
| {summary.at_risk && ( | ||
| <span className="inline-flex items-center gap-2"> | ||
| <Badge tone="danger" size="sm"> | ||
| At risk: open when FDA records response is due | ||
| </Badge> | ||
| {citation} | ||
| </span> | ||
| )} | ||
| </div> | ||
| ); | ||
| } |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration |
Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: regengine/RegEngine
Length of output: 2752
Reject null closing receipts on closed gaps.
The closed branch of
data_gap_events_close_receipt_checkdoes not require either receipt field. PostgreSQL treats a CHECK condition as satisfied when it evaluates to true or null. Withclosed_atset andclosing_record_hashnull, the regex comparison yields null, so the whole constraint evaluates to null and the row passes. The same applies whenclosing_record_idis null. Add both non-null tests to the closed branch. Keep the regex test.Proposed fix
or ( closed_at is not null and closed_at >= opened_at + and closing_record_id is not null + and closing_record_hash is not null and closing_record_hash ~ '^[0-9a-f]{64}$' )Apply the same change to the
data_gap_events_close_receipt_checkdefinition insupabase/migrations/20261024060000_data_gap_time_to_fix.sql. Both files define the table withcreate table if not exists, so edit bothcreate tablestatements. Do not add a separateALTER TABLE ... ADD CONSTRAINTstep. The SQL migration is pending approval and reported as unapplied, so the edited definition applies to a fresh table. A separateADD CONSTRAINTwith the same name would fail on a table created from the corrected definition.📝 Committable suggestion