types: restore Supabase row typing, fix parseInput inference (586→76 tsc errors)

The installed supabase-js (2.99, 5 generics) is incompatible with
@supabase/ssr@0.6.1 (calls it with 3), collapsing the server client's
schema to `never` — the root cause of ~450 of the 586 hidden type errors
(next.config typescript.ignoreBuildErrors masks them all).

- lib/supabase/server.ts: annotate the factory as SupabaseClient<Database>
  and bridge the ssr/js impedance mismatch at the one documented boundary;
  type the setAll cookie params.
- lib/supabase/proxy.ts, proxy.ts: type the setAll cookie params.
- lib/auth-helpers.ts: parseInput now infers its result from the schema via
  z.infer (was a fragile ZodSchema<T> that widened outputs to {}).
- lib/validations.ts: annotate flexibleDateTime's output type.
- types/database.ts: add class_signup_tx / class_cancel_tx RPC signatures
  (called but absent; pending full regeneration).
- tsconfig.json: target ES2020 (door-log-logic uses BigInt literals).
- vitest.setup.ts: import beforeAll/afterAll instead of relying on globals.

No runtime behavior change; 639 tests still green. ignoreBuildErrors stays
on until the remaining errors reach zero.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Moheeb Zara
2026-08-01 18:40:33 -07:00
co-authored by Claude Opus 4.8
parent 9ac0c2486c
commit 1766780bca
10 changed files with 281 additions and 16 deletions
+230
View File
@@ -0,0 +1,230 @@
# Audit & Architecture Plan — hackerspace-management (2026-08)
Branch: `audit/cleanup-architecture`. Method: 14-agent adversarial audit (each security/correctness
finding independently verified against RLS policies, code authz gates, and DB constraints before it
counts). **All 16 risk findings survived verification; 0 were refuted** — the finders were disciplined,
not noise. Every finding below has file:line evidence and a concrete failure scenario.
## Baseline health (verified today, main @ 9ac0c24)
| Check | State |
|---|---|
| `tsc --noEmit` | **586 errors**, all hidden by `next.config.mjs typescript.ignoreBuildErrors:true` |
| `eslint .` | **fails** — no flat config present (ESLint 9 needs `eslint.config.js`) |
| `vitest run` (unit) | **639 tests / 32 files, all green** (~11s) |
| integration / e2e | present but self-skip without a DB / are largely vacuous (see Tests) |
| `build` | passes *because* type errors are ignored |
| Package manager | **pnpm** (not npm); there is **no `typecheck` script** |
**Headline:** this is a well-built codebase. The server-action pattern (`parseInput`(Zod) →
`requireMemberWithRole` → `.eq('space_id', self.space_id)` → `logActivity`) is uniform and correct, RLS
is enabled on all 50 tables, and the layering the brief asks for is ~70% already present. The real work
is (a) a handful of concrete bugs, (b) turning type-checking back on, (c) making tests actually test,
and (d) subdividing giant files along seams that already exist — **evolve, don't rewrite.**
---
## 1. Correctness & security findings (ranked, all verified)
### HIGH
**H1 — Invite `max_uses` is a check-then-increment TOCTOU** · `lib/auth-actions.ts:210`
Two users redeem a `max_uses=1` invite simultaneously: both read `uses_count=0`, both pass the JS cap
check, both insert a membership, and the non-atomic `uses_count = invite.uses_count + 1` write leaves
the counter at 1 instead of 2. Single-use invites become multi-use; counter corrupts.
*Fix:* one atomic guarded statement — `UPDATE space_invites SET uses_count = uses_count + 1 WHERE id = :id
AND (max_uses IS NULL OR uses_count < max_uses) RETURNING id`; only proceed if a row came back.
### MEDIUM
**M1 — Board can re-arm an existing admin-granting invite** · `lib/actions/invites.ts:89` (privilege escalation)
`updateInvite` runs the `canAssignInviteRole` guard **only when `role` is in the payload**. A board member
(who cannot *mint* an admin invite) can call `updateInvite(id, { is_enabled:true, max_uses:5 })` on an
exhausted/disabled admin invite — no `role` field — and re-arm it. RLS `invites_update` only checks
admin/board, so nothing stops it. Board thereby distributes admin access it can't create directly.
*Fix:* load the existing invite's role and require `canAssignInviteRole(member.role, existingRole)`
whenever any privilege-affecting field (`is_enabled→true`, `max_uses`, `expires_at`) changes.
**M2 — `appealIncident` role gate is broader than the RLS write; silent partial failure** · `lib/actions/incidents.ts:247`
Gated by `requireMember` (any member — the docstring intends reporters to appeal), but the incident
`UPDATE` is restricted to admin/board by `incidents_update` RLS. A reporter's appeal **inserts a draft
proposal, silently updates 0 incident rows, and returns success** — orphaned proposal, incident never
linked. *Fix:* do the incident update via admin client after re-authorizing caller as reporter-or-admin,
**or** add a reporter clause to `incidents_update` RLS; and assert rows-affected before creating the proposal.
**M3 — SSRF filter misses hex IPv4-mapped IPv6** · `lib/door-logic.ts:72`
`isBlockedDoorIp` blocks the dotted form `::ffff:169.254.169.254` but not the hex form `::ffff:a9fe:a9fe`
(= 169.254.169.254 IMDS) or `::ffff:7f00:1` (= 127.0.0.1). Defense-in-depth gap on the door egress /
metadata-endpoint boundary. Node resolvers currently emit the dotted form, so not exploitable *today*,
but it's a latent SSRF bypass. *Fix:* decode `::ffff:HHHH:HHHH` to its 32-bit IPv4 and run the same checks.
**M4 — `logCashPayment` stamps `last_paid_at` from a caller-supplied (backdatable) date** · `lib/actions/payments.ts:56`
**M5 — `linkPaymentToMember` overwrites `last_paid_at` with the linked payment's date** · `lib/actions/payments.ts:104`
Both unconditionally set `last_paid_at` and force `payment_status='current'` with no `GREATEST` guard.
Linking/logging a backdated (e.g. CSV-imported) payment **regresses** a member's dues state — a
currently-paid member can be pushed backward or a lapsed member wrongly shown current. `flexibleDateTime`
has no lower bound, so historical dates are real inputs. *Fix:* advance-only (`GREATEST(last_paid_at, txn)`),
derive `payment_status` from the resulting effective date.
**M6 — Null deref crashes the members list on search** · `app/(app)/members/members-client.tsx:110`
`m.display_name.toLowerCase()` / `m.email.toLowerCase()` — both columns are nullable (schema + types;
the `WHERE email IS NOT NULL` partial index proves null-email rows are by design, e.g. imported members).
Typing any character into the search box throws `TypeError` and blanks the whole members page.
*Fix:* optional-chain like the adjacent `handle` already does.
### LOW (confirmed)
- **L1** `reserveEquipment` doesn't verify the manager-supplied `memberId` is in the caller's space (its
sibling `signUpForClass` does) — mis-attributed reservation **within** the space; no cross-tenant leak. `equipment.ts:249`
- **L2** Unverified members (pending approval in `require_approval` spaces) can create/open proposals and
cast counted votes before an admin vets them. Arguable-by-design; RLS also permits it. `proposals.ts:150`
- **L3** `markOnboardingStepDone` read-modify-writes the `onboarding_progress` JSON in JS → concurrent
step completions can be lost (last-write-wins). `onboarding.ts:142`
- **L4** Email dates render in server-local TZ (no `timeZone` option) → off-by-one-day in renewal/booking
emails depending on the droplet's `TZ`. `notifications-logic.ts:114`
- **L5** `parseHeatSyncLog`: (a) a stale `H:M:E` time-of-day bleeds into later events (fabricated audit
timestamp); (b) one high-half token can be reused for two adjacent grants (wrong card numbers). `door-log-logic.ts:78,91`
- **L6** Null deref in the payment link-picker initials (`m.display_name.split(' ')`). `payments-client.tsx:377`
- **L7** Unparseable CSV `transaction_date` throws mid-import → nothing imports, no toast, button stuck
on "Importing…" forever (no `try/finally`). `import-client.tsx:215`
### Public / webhook / cron surface — CLEAN
Stripe (per-space signing secret, replay-protected via unique insert), door inbound (per-connection vault
bearer + `timingSafeEqual` behind a length pre-check, uniform 401/404), both crons (`CRON_SECRET` via
`timingSafeEqual`, fail-closed 503), public forms (`getPublicForm` excludes `members` visibility, writes
scoped to the looked-up form's space), `/track` (192-bit reporter token, redacted projection). Only nits:
two **auth-gated** DoS-hardening gaps on door inbound (trusts `Content-Length`; DB lookup before rate-limit).
### RLS cross-check — CLEAN
All 50 tables have RLS. A programmatic check of all 147 anon-client writes against 287 policies found
**zero** writes to a table lacking that operation's policy. The two policy-less tables
(`notification_preferences`, `stripe_webhook_events`) are admin-only by design and correctly scoped. The
sole `WITH CHECK(true)` is the intentional space-creation onboarding path.
---
## 2. Type safety — the linchpin (path to remove `ignoreBuildErrors`)
**Root cause of ~450 of the 586 errors:** version drift. `package.json` pins `supabase-js ^2.49.4` but the
install resolved to **2.99.0**, whose `SupabaseClient` grew from 3 to 5 generics. `@supabase/ssr@0.6.1`'s
`createServerClient` still passes the old 3-generic shape, feeding the *Schema object* into the *SchemaName
string* slot. The constraint violation collapses the client's `Schema` to **`never`**, so every
`.from().select()` row is `never` (→ 396 × TS2339 "property does not exist on never"), every insert value
is `never`, every rpc arg is `undefined`. This is exactly why `lib/actions/*` (admin client, supabase-js
direct) mostly type-check while every `app/**/page.tsx` (ssr client) is a wall of `never`.
**Proven interim fix (verified compiling):** annotate the two factory returns as `SupabaseClient<Database>`
in `lib/supabase/server.ts` + `lib/supabase/proxy.ts` — re-asserting `Database` lets the 5-generic client
recompute `Schema` and restores full row typing, clearing ~450 errors. Proper fix: align versions
(`@supabase/ssr ≥0.7` for supabase-js 2.9x, or pin supabase-js to the 2.5x line) so the lockfile stops drifting.
**Second cause:** `types/database.ts` is partially hand-maintained and **out of sync** with the 54 SQL
migrations — missing RPCs (`class_signup_tx`, `class_cancel_tx` are *called* but absent), missing the
`__InternalSupabase` marker. *Fix:* regenerate wholesale via `supabase gen types typescript`, then delete
the `as string` casts it forced throughout `lib/actions/`.
**Ordered path to drop `ignoreBuildErrors`:** (1) align supabase versions / annotate the two factories →
clears ~450; (2) regenerate `types/database.ts` → clears the RPC/enum union noise; (3) fix ~30 enum-widening
`setState` casts + the 2 null derefs (M6, L6); (4) fix test-file types + `vitest.setup.ts`
`beforeAll/afterAll` imports; (5) remove the flag and add `tsc --noEmit` to CI.
> Note on the brief's "split `types/database.ts` (1958 lines)": **don't physically split it.** It's a
> generated build artifact — the next `supabase gen types` overwrites it wholesale, reverting any hand-split.
> The generated/hand-written seam the brief wants **already runs between `types/database.ts` and `lib/types.ts`.**
> The right move is to keep `database.ts` pristine (add a "GENERATED — do not edit" banner) and split the
> **hand-written** side (`lib/types.ts` → `types/domain/*`). Delivered in the refactor plan below.
---
## 3. Tests — green but under-testing the risk
- **CRITICAL** No test exercises admin-client actions for cross-tenant (IDOR) scoping — the #1 stated risk
is untested. Nothing would catch a dropped `.eq('space_id', …)`.
- **HIGH** CI runs **no tests at all**; the integration suite self-skips without a DB, so "green" is empty.
- **HIGH** The authz primitives (`requireMember`/`requireMemberWithRole`/`getAuthMember`) have zero tests.
- **HIGH** `__tests__/actions.test.ts` asserts on **string literals that check themselves** (e.g.
`` const query = `.eq('space_id', …)`; expect(query).toContain('space_id') ``) — never imports an action.
The describe blocks ("Row Level Security", "Role-Based Access") give false confidence.
- **MEDIUM** e2e specs are unauthenticated/vacuous; `components.test.tsx` tests stub components defined
inside the test file; concurrency/money only covered at the DB layer, not the action layer.
- `vitest.setup.ts` calls `beforeAll/afterAll` without importing them (works only via `globals:true`, untyped).
*Plan:* add a real action-layer IDOR test (seed spaces A/B, call each id-taking action as an A-admin with a
B id, assert rejection + B untouched); unit-test the authz primitives; replace the tautological
`actions.test.ts` with tests that drive real actions through a mock client and inspect the `.eq` calls;
make CI fail on 0 executed integration tests.
---
## 4. Dead code & duplication
- `lib/security.ts`: 8 of 12 exports unused in production (7 test-only, 1 fully dead);
`hasSqlInjectionPatterns` is security theater over parameterized queries. Prune to what's used.
- `lib/validations.ts`: 6 exported schemas never referenced (`signInSchema`, `signUpSchema`,
`createSpaceSchema`, `joinSpaceSchema`, `proposalStatuses`, `updateAreaLeadRoleSchema`) + 1 test-only.
- Orphaned files: `components/task-claim-button.tsx`, `components/theme-provider.tsx`; dead
`roleDisplayName` in `lib/role-labels.ts`; duplicate `escapeHtml` (dead one in `security.ts`).
- No shared date/number formatter — **68 ad-hoc `toLocale*` call sites** with copy-pasted option objects
across ~15 files. `classes.ts` repeats a notification fan-out 3×; `door.ts` rebuilds an adapter query 5×.
## 5. Maintainability offenders
- `ops-client.tsx` (956) — God component: 3 modals + 2 row renderers + 4 tab bodies + filtering, **and it
writes secrets/area-leads directly from the browser** (bypassing the existing `lib/actions/secrets.ts` /
`area-leads.ts` — skips Zod + activity log). `settings-client.tsx` / `members-client.tsx` (663 each) fuse
4 tabs / table+toolbar+2 modals.
- **HIGH footgun:** `MemberFormFields` is declared **inside** `members-client.tsx`'s render → remounts and
**loses input focus on every keystroke**. `members-client.tsx:197`
- `forms.ts submitForm` is a ~200-line monolith with 4 overlapping email-linking functions;
`import-client.tsx` embeds a CSV parser + auto-mapper that belong in `lib/`.
---
## 6. Architecture & refactor plan (evolve, not rewrite)
The five layers already exist: **validation** (`lib/validations.ts`) · **guard/auth** (`lib/auth-helpers.ts`
+ `lib/*-guard.ts`) · **pure logic** (`lib/*-logic.ts`) · **server actions** (`lib/actions/*.ts`) · **data
access** (`lib/supabase/*`). Feature-folders already exist (`lib/door/`, `lib/stripe/`, `lib/secrets/`) and
UI decomposition already has an exemplar in `app/(app)/customize/` (thin orchestrator + `panels/` + shared
`types.ts`). The plan **promotes these existing patterns** — every giant file splits along seams that are
already marked by section comments or sub-components.
**Target conventions:** `types/database.ts` = only generated file (pristine) · `types/domain/*` =
hand-written domain types (from `lib/types.ts`) · `lib/validations/<feature>.ts` + `primitives.ts` +
`index.ts` barrel (keeps `@/lib/validations` working for all 44 importers) · `lib/<feature>/{logic,guard}.ts`
· `lib/actions/<feature>/*.ts` behind `lib/actions/index.ts` barrel · `app/(app)/<route>/{page,
<route>-client,panels/,components/,types}.tsx`. A new feature = **one file per layer, wired only through
barrels** (full add-a-feature guide goes in `ARCHITECTURE.md`).
**Reviewable slice sequence (each gated on `tsc` + `test` + `build`):**
0. **Enabler** — GENERATED banner on `types/database.ts`; document regen command. (no behavior change)
1. **`lib/validations.ts` → `lib/validations/<feature>.ts` + `primitives.ts` + barrel.** Proves the
barrel-preserving technique on the most-imported module (44 importers, zero call-site churn).
2. **`lib/types.ts` → `types/domain/{index,governance,reexports}.ts`**; convert governance rows to
`Tables<'proposals'>` aliases; leave `lib/types.ts` as a re-export shim (11 importers). *This is the
brief's "split the types file," done correctly.*
3. **`lib/actions/classes.ts` → `classes/{definitions,sessions,signups,instructor}.ts` + `_guard`/`_forms`.**
4. **`lib/actions/door.ts` → `door/{connections,operate,self-entry}.ts` + `_guards`/`_audit`.**
5. **`lib/actions/forms.ts` → `forms/{manage,submit,member}.ts` + `_guard`.**
6. **`ops-client.tsx` → `panels/` + `components/`** *and* replace the direct browser writes with the
existing server actions (behavior change — ships alone).
7. **`settings-client.tsx` → `panels/`.**
8. **`members-client.tsx` → `components/`** + fix the `MemberFormFields`-remount focus bug.
9. **Cleanup** — delete the `lib/types.ts` shim + dead validations/security exports once importers move.
---
## Proposed execution order (fixes first, then refactor)
- **Phase A — tooling & type safety** (no behavior change): ESLint flat config, `typecheck` script, align
supabase versions / annotate the two clients, regenerate `types/database.ts`, work toward removing
`ignoreBuildErrors`. Gate each on `tsc`/`test`/`build`.
- **Phase B — confirmed fixes** (call out the behavior-changing ones): H1, M1–M6, then the LOW batch, each
with a regression test. This is where the IDOR test + authz-primitive tests land.
- **Phase C — refactor slices 0–9** above, one reviewable commit each.
- **Phase D — `ARCHITECTURE.md`** documenting the resulting structure + add-a-feature guide.
Nothing is committed yet beyond this document. **No pushes to `main` without your explicit approval.**
+2 -3
View File
@@ -124,13 +124,12 @@ export async function joinSpace(formData: {
// Try the new multi-code invites table first. Falls back to the legacy
// spaces.invite_code (a permanent default) if the code isn't found there.
let invite: { id: string; space_id: string; max_uses: number | null; uses_count: number; expires_at: string | null; is_enabled: boolean; role: string } | null = null
const { data: inviteRow } = await admin
.from('space_invites')
.select('id, space_id, max_uses, uses_count, expires_at, is_enabled, role')
.eq('code', code)
.maybeSingle()
if (inviteRow) invite = inviteRow as typeof invite
const invite = inviteRow
let spaceId: string | null = null
if (invite) {
@@ -171,7 +170,7 @@ export async function joinSpace(formData: {
.from('space_members')
.select('id')
.eq('user_id', user.id)
.in('status', ACTIVE_STATUSES as unknown as string[])
.in('status', ACTIVE_STATUSES)
.maybeSingle()
if (existingMembership) {
return { error: 'You are already a member of a space.' }
+5 -2
View File
@@ -1,6 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Tables } from '@/types/database'
import type { ZodSchema } from 'zod'
import type { ZodTypeAny, TypeOf } from 'zod'
import { ACTIVE_STATUSES, hasRole, isPrivilegeEligible, type Role } from '@/lib/permissions'
/**
@@ -97,7 +97,10 @@ export async function requireMemberWithRole(
* so callers can `if (!v.ok) return { error: v.error }` and have `v.data`
* narrowed to T.
*/
export function parseInput<T>(schema: ZodSchema<T>, input: unknown): Result<T> {
export function parseInput<S extends ZodTypeAny>(
schema: S,
input: unknown,
): Result<TypeOf<S>> {
const parsed = schema.safeParse(input)
if (!parsed.success) {
return { ok: false, error: parsed.error.errors[0]?.message ?? 'Invalid input' }
+5 -2
View File
@@ -1,6 +1,9 @@
import { createServerClient } from '@supabase/ssr'
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
/** Shape `@supabase/ssr` passes to `setAll` (typing lost to the ssr/js version drift). */
type CookiesToSet = { name: string; value: string; options: CookieOptions }[]
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
@@ -16,7 +19,7 @@ export async function updateSession(request: NextRequest) {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
setAll(cookiesToSet: CookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value),
)
+21 -4
View File
@@ -1,16 +1,27 @@
import { createServerClient } from '@supabase/ssr'
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { cookies } from 'next/headers'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Database } from '@/types/database'
/** Shape `@supabase/ssr` passes to `setAll` (typing lost to the ssr/js version drift). */
type CookiesToSet = { name: string; value: string; options: CookieOptions }[]
/**
* Especially important if using Fluid compute: Don't put this client in a
* global variable. Always create a new client within each function when using
* it.
*
* The explicit `SupabaseClient<Database>` return annotation is load-bearing:
* `@supabase/ssr`'s `createServerClient` still returns supabase-js's older
* 3-generic client shape, which — against the installed 5-generic supabase-js —
* collapses the schema to `never` (every `.from().select()` row becomes `never`).
* Re-asserting `SupabaseClient<Database>` here lets the client recompute its
* schema from `Database`, restoring full row typing across every caller.
*/
export async function createClient() {
export async function createClient(): Promise<SupabaseClient<Database>> {
const cookieStore = await cookies()
return createServerClient<Database>(
const client = createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
@@ -18,7 +29,7 @@ export async function createClient() {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
setAll(cookiesToSet: CookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options),
@@ -32,4 +43,10 @@ export async function createClient() {
},
},
)
// Single documented boundary cast: `createServerClient` returns the older
// 3-generic client shape; re-assert the 5-generic `SupabaseClient<Database>`
// so callers get full row typing. This is the ONE place the impedance
// mismatch is bridged.
return client as unknown as SupabaseClient<Database>
}
+1 -1
View File
@@ -19,7 +19,7 @@ import { API_METHODS } from './api-call-logic'
* Returns a Zod schema that produces a `string | null` (after preprocess +
* `.datetime().nullable()`).
*/
export const flexibleDateTime = () =>
export const flexibleDateTime = (): z.ZodType<string | null, z.ZodTypeDef, unknown> =>
z.preprocess(
(val) => {
if (val === null || val === undefined || val === '') return null
+5 -2
View File
@@ -1,7 +1,10 @@
import { updateSession } from '@/lib/supabase/proxy'
import { createServerClient } from '@supabase/ssr'
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
/** Shape `@supabase/ssr` passes to `setAll` (typing lost to the ssr/js version drift). */
type CookiesToSet = { name: string; value: string; options: CookieOptions }[]
// Public routes are reachable without an authenticated session. The
// /resources subsite is the legacy hackerspace.sh content baked in; the
// five individual resource routes (/zine, /governance, /space-after-dark,
@@ -77,7 +80,7 @@ export async function proxy(request: NextRequest) {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
setAll(cookiesToSet: CookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
)
+1 -1
View File
@@ -6,7 +6,7 @@
"esnext"
],
"allowJs": true,
"target": "ES6",
"target": "ES2020",
"skipLibCheck": true,
"strict": true,
"noEmit": true,
+10
View File
@@ -1702,6 +1702,16 @@ export type Database = {
Args: { sid: string; perm: string }
Returns: { member_id: string }[]
}
// Hand-added to match scripts/045_class_signup_concurrency.sql until this
// file is regenerated from the live schema (`supabase gen types typescript`).
class_signup_tx: {
Args: { p_session_id: string; p_space_id: string; p_member_id: string }
Returns: { signup_id: string | null; signup_status: string | null; err: string | null }[]
}
class_cancel_tx: {
Args: { p_session_id: string; p_space_id: string; p_member_id: string }
Returns: { cancelled_id: string | null; promoted_id: string | null; err: string | null }[]
}
}
Enums: {
area_lead_status: "active" | "vacant" | "handoff"
+1 -1
View File
@@ -1,5 +1,5 @@
import '@testing-library/jest-dom'
import { expect, afterEach, vi } from 'vitest'
import { expect, afterEach, beforeAll, afterAll, vi } from 'vitest'
import { cleanup } from '@testing-library/react'
// Cleanup after each test