diff --git a/.claude/TODO/INDEX.md b/.claude/TODO/INDEX.md index 8a74d61..3ab3e42 100644 --- a/.claude/TODO/INDEX.md +++ b/.claude/TODO/INDEX.md @@ -78,6 +78,8 @@ _none open_ | mp-schema | 0 | | contexts | 0 | | dto-constants | 0 | +| data-flow | 0 | +| security | 0 | --- diff --git a/.claude/TODO/SCHEMA.md b/.claude/TODO/SCHEMA.md index dff7774..89df230 100644 --- a/.claude/TODO/SCHEMA.md +++ b/.claude/TODO/SCHEMA.md @@ -16,7 +16,7 @@ type: meta title: severity: critical | high | medium | low tags: [security | bug | drift | missing-test | refactor | doc | perf] -area: auth | mp-provider | services | components | contexts | routing | data-flow | testing | dto-constants | utils | commands +area: auth | mp-provider | mp-schema | services | components | contexts | routing | data-flow | security | testing | dto-constants | utils | commands files: [src/path/to/file.ts] discovered: YYYY-MM-DD discovered_by: diff --git a/.claude/commands/newstoredproc.md b/.claude/commands/newstoredproc.md index c675c8c..9c5fb8a 100644 --- a/.claude/commands/newstoredproc.md +++ b/.claude/commands/newstoredproc.md @@ -167,5 +167,5 @@ All INSERT sections use `IF NOT EXISTS` guards so they skip gracefully on re-run - The unified install script at `_INSTALL/ministryplatform-install.sql` is auto-generated by the build — never edit it manually - SQL files in `src/lib/providers/ministry-platform/db/` are combined alphabetically by the build script - Domain_ID is hardcoded to `1` in role grants (matches existing pattern across all deployment scripts) -- If the procedure needs to be callable from the app, a corresponding service method should be added — see `src/services/` and `.claude/references/services.md` +- If the procedure needs to be callable from the app, a corresponding service method should be added (it must gate with `AuthorizationService.requireSecurityRole()` and must NOT pass `@DomainID` in the body) — see `src/services/`, `.claude/references/services/README.md`, and add the proc to `.claude/references/mp-schema/required-procs.md` - **`@DomainID INT` is required by the Ministry Platform API for all `api_*` stored procedures.** The platform passes the current domain ID automatically when calling procedures through the API. If this parameter is missing, the procedure will not be callable via the API even if it is registered in `dp_API_Procedures`. diff --git a/.claude/commands/newtool.md b/.claude/commands/newtool.md index a5a34a2..0819537 100644 --- a/.claude/commands/newtool.md +++ b/.claude/commands/newtool.md @@ -28,13 +28,18 @@ Create `src/app/(web)/tools/{routeName}/page.tsx`: ```tsx import { {componentName} } from "./{fileName}"; -import { parseToolParams } from "@/lib/tool-params"; +import { requireToolAccess } from "@/app/(web)/tools/require-tool-access"; +import { parseToolParams } from "@/lib/tool-params.server"; interface {componentName}PageProps { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; } export default async function {componentName}Page({ searchParams }: {componentName}PageProps) { + // Self-gating, before ANY param parsing or data call — see require-tool-access.ts. + // Pick the MP table/operation this tool actually needs. + await requireToolAccess({ table: "dp_Tools", operation: "read" }); + const params = await parseToolParams(await searchParams); return <{componentName} params={params} />; @@ -54,7 +59,7 @@ Create `src/app/(web)/tools/{routeName}/{fileName}.tsx`: ```tsx "use client"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import { useRouter } from "next/navigation"; import { ToolContainer } from "@/components/tool"; import { Users } from "lucide-react"; @@ -69,19 +74,10 @@ export function {componentName}({ params }: {componentName}Props) { const [isSaving, setIsSaving] = useState(false); const isNew = isNewRecord(params); - useEffect(() => { - console.log("Tool launched with params:", params); - console.log("Mode:", isNew ? "Create New" : "Edit Existing"); - if (params.recordDescription) { - console.log("Editing record:", params.recordDescription); - } - }, [params, isNew]); - const handleSave = async () => { setIsSaving(true); await new Promise((resolve) => setTimeout(resolve, 1000)); setIsSaving(false); - console.log("Saved!", { params }); }; const handleClose = () => { @@ -163,6 +159,8 @@ After creating all files, confirm the new tool by listing the created files and - [ ] Tool component created at `src/app/(web)/tools/{routeName}/{fileName}.tsx` - [ ] Homepage card added to `src/app/(web)/page.tsx` - [ ] All placeholder text replaced with the tool's displayName +- [ ] `page.tsx` calls `requireToolAccess()` before parsing params, and imports `parseToolParams` from `@/lib/tool-params.server` (not `@/lib/tool-params`, which must stay client-safe) +- [ ] No `console.log` (ESLint `no-console` allows only `warn`/`error`) - [ ] Component uses named export (no default export) — except `page.tsx` which requires default export per Next.js ## Multiple Tools @@ -178,5 +176,7 @@ Creates both tools and adds both cards to the homepage. ## Notes - The `ToolContainer` renders a `DevPanel` above the title bar automatically on localhost in development builds — no manual scaffolding needed. +- `tools/layout.tsx` only redirects role-less users for UX; it does **not** protect the page. Each page gates itself with `requireToolAccess` (`src/app/(web)/tools/require-tool-access.ts`), and any server action / service the tool adds must gate again with `AuthorizationService.requireSecurityRole()` (CLAUDE.md practice #12). +- Add co-located tests (`page.test.tsx`, `{fileName}.test.tsx`) — CI enforces coverage thresholds (`vitest.config.mts`). - The `page.tsx` uses a default export (Next.js requirement), but the tool component uses a named export per project convention -- Route names should be lowercase with no separators (matching existing pattern: `template`, `groupwizard`, `addresslabels`) +- Route names should be lowercase with no separators (matching existing pattern: `template`, `groupwizard`, `addresslabels`, `fieldmanagement`, `addeditfamily`) diff --git a/.claude/commands/release.md b/.claude/commands/release.md index 23726d1..54d794b 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -20,8 +20,8 @@ Create a GitHub release with auto-generated release notes from merged pull reque - Run `git log --oneline` (from last release tag to HEAD, or recent commits if first release) to understand what's new 3. **Identify PRs to include:** - - If this is the first release, run `gh pr list --state merged --limit 20 --json number,title,mergedAt,body,labels` to get recent merged PRs - - If a previous release exists, find PRs merged since that release using `gh pr list --state merged --search "merged:>YYYY-MM-DD" --json number,title,mergedAt,body,labels` + - If this is the first release, run `gh pr list --base dev --state merged --limit 20 --json number,title,mergedAt,body,labels` to get recent merged PRs + - If a previous release exists, find PRs merged since that release using `gh pr list --base dev --state merged --search "merged:>YYYY-MM-DD" --json number,title,mergedAt,body,labels` (feature PRs target `dev`; add `--base main` hotfix PRs if any) - Present the list of PRs to the user and ask which to include (default: all) - Exclude the `dev` → `main` release PR itself — it is a promotion, not a change @@ -105,7 +105,7 @@ gh release list --limit 5 git tag --sort=-version:refname | head -5 # 2. Get merged PRs since last release -gh pr list --state merged --limit 20 --json number,title,mergedAt,body,labels +gh pr list --base dev --state merged --limit 20 --json number,title,mergedAt,body,labels # 3. Get repo info for changelog URL gh repo view --json nameWithOwner --jq .nameWithOwner diff --git a/.claude/commands/update-deps.md b/.claude/commands/update-deps.md index 8195b25..a670a91 100644 --- a/.claude/commands/update-deps.md +++ b/.claude/commands/update-deps.md @@ -45,7 +45,8 @@ Record these numbers — they are the comparison for everything that follows: ```bash npm run test:run # test + file counts npm run lint -npm run build 2>&1 | grep -cE "error TS" # may be non-zero already +npm run typecheck 2>&1 | grep -cE "error TS" # may be non-zero already +npm run build # needs a valid .env.local — the app refuses to boot (even at build) on missing/weak secrets npm audit node -v && npm -v ``` @@ -123,7 +124,8 @@ Then install that **one** package, and verify: npm install @ npm run test:run npm run lint -npm run build 2>&1 | grep -cE "error TS" # compare to baseline +npm run typecheck 2>&1 | grep -cE "error TS" # compare to baseline +npm run build && npm run check:prerender ``` - Any new type error is a real break even if tests pass — find the migration @@ -207,7 +209,8 @@ adds no install scripts. Otherwise pin to the previous version and note why. held with clearing conditions**, and any pre-existing issues found but not fixed. - Add a row to the index table in `.claude/packages/README.md`. -- Add a row to the **Dependency Audit History** table in `CLAUDE.md`. +- Add a row to the **Dependency Audit History** table in `CLAUDE.md`, and update + its **Current holds** table to match the new record. - Commit in the same separable units used above (in-range / removals / each major), then open a PR targeting `dev`. diff --git a/.claude/references/DECISIONS.md b/.claude/references/DECISIONS.md index 9100dc8..f6a2234 100644 --- a/.claude/references/DECISIONS.md +++ b/.claude/references/DECISIONS.md @@ -16,6 +16,7 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 - [ADR-003: `customSession` does name-splitting only; MP profile loaded on client](#adr-003-customsession-does-name-splitting-only-mp-profile-loaded-on-client) - [ADR-004: Dual-layer route protection — edge proxy + server-component validation](#adr-004-dual-layer-route-protection--edge-proxy--server-component-validation) - [ADR-005: Route group `(web)` for authenticated pages; `/signin` and `/api/auth` outside the group](#adr-005-route-group-web-for-authenticated-pages-signin-and-apiauth-outside-the-group) +- [ADR-027: One MP OAuth client for the service account and user sign-in](#adr-027-one-mp-oauth-client-for-the-service-account-and-user-sign-in) ### MP Provider - [ADR-006: Singleton `MinistryPlatformProvider` with a shared `MinistryPlatformClient`](#adr-006-singleton-ministryplatformprovider-with-a-shared-ministryplatformclient) @@ -47,7 +48,6 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 ### Testing - [ADR-025: Vitest (not Jest) as the test runner](#adr-025-vitest-not-jest-as-the-test-runner) - [ADR-026: jsdom (not happy-dom) as the DOM environment](#adr-026-jsdom-not-happy-dom-as-the-dom-environment) -- [ADR-027: One MP OAuth client for the service account and user sign-in](#adr-027-one-mp-oauth-client-for-the-service-account-and-user-sign-in) --- @@ -58,7 +58,7 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 **Date:** 2026-04-17 **Status:** Accepted **Context:** The app authenticates users against Ministry Platform's OIDC provider, which has several non-standard traits (custom `realm` authorization param, MP-specific scope `http://www.thinkministry.com/dataplatform/scopes/all`, dedicated `/oauth/connect/userinfo` endpoint for user lookup). The auth layer must run cleanly on Next.js 16 App Router with Turbopack. `src/lib/auth.ts` still reads `NEXTAUTH_URL` / `NEXTAUTH_SECRET` as fallbacks, evidence that the codebase migrated from NextAuth. -**Decision:** Use Better Auth 1.5 (`better-auth@^1.5.5`) with the `genericOAuth` plugin, a custom `getUserInfo` that calls `/oauth/connect/userinfo`, and `mapProfileToUser` that stores the OIDC `sub` claim as a user `additionalField` named `userGuid`. Wire Next.js integration with `nextCookies()` plugin and mount `toNextJsHandler(auth)` at `/api/auth/[...all]`. +**Decision:** Use Better Auth (adopted at 1.5; now `better-auth@^1.7.7`) with the `genericOAuth` plugin, a custom `getUserInfo` that calls `/oauth/connect/userinfo`, and `mapProfileToUser` that stores the OIDC `sub` claim as a user `additionalField` named `userGuid`. Wire Next.js integration with `nextCookies()` plugin and mount `toNextJsHandler(auth)` at `/api/auth/[...all]`. **Consequences:** Custom `getUserInfo` and `mapProfileToUser` are required for MP's OIDC quirks. `additionalFields` isn't in the `genericOAuth` option types, so a `Record` cast is needed when returning from `mapProfileToUser`. First-class Next.js 16 integration keeps route handlers minimal. **Alternatives considered:** - **NextAuth v5** — heavier, less flexible for MP's custom OIDC details; migration evidence suggests it was the previous choice and was replaced. @@ -82,7 +82,7 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 **Date:** 2026-04-17 **Status:** Accepted **Context:** A tempting design is to enrich the session object inside `customSession` with the user's full MP profile (roles, user groups, Contact_ID, Image_GUID). Every consumer would then read a single object. The downside is that `customSession` runs on every cache miss, and MP profile lookups require extra MP API calls (`dp_Users` + `dp_User_Roles` + `dp_User_User_Groups`). -**Decision:** `customSession` in `src/lib/auth.ts:398-413` does only `firstName` / `lastName` splitting from `user.name` — no API calls. MP profile loading moves to the client, behind `UserProvider` (`src/contexts/user-context.tsx`), which calls the parameterless `getCurrentUserProfile()` server action on mount (the action re-derives the GUID from the session — a GUID parameter would be an IDOR) and exposes `useUser()`. +**Decision:** `customSession` (callback extracted as `enrichSession` in `src/lib/auth.ts`) does only `firstName` / `lastName` splitting from `user.name` — no API calls. MP profile loading moves to the client, behind `UserProvider` (`src/contexts/user-context.tsx`), which calls the parameterless `getCurrentUserProfile()` server action on mount (the action re-derives the GUID from the session — a GUID parameter would be an IDOR) and exposes `useUser()`. **Consequences:** `getSession()` stays cheap. Sign-in does not break when MP is down. Client components that need roles/groups must mount under `UserProvider`; every page load incurs one extra round-trip. `UserService.getUserProfile()` issues three queries (profile + roles + groups). **Note (2026-10-10):** `MPUserProfile` is now display-only (`First_Name`, `Nickname`, `Last_Name`, `Email_Address`) and `getUserProfile()` issues **one** `dp_Users` query; roles and user groups are no longer sent to the client. Security-role checks use `AuthorizationService` server-side. See `services/user-service.md`. **Alternatives considered:** @@ -95,10 +95,10 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 **Status:** Accepted **Context:** Unauthenticated requests should be redirected as early and cheaply as possible, but stale/forged cookies must still be rejected before any MP API call. Better Auth's full `auth.api.getSession()` is Node-only and too heavy for the edge runtime. **Decision:** Two-layer gate: -- **Layer 1 — edge proxy** (`src/proxy.ts`): uses `getSessionCookie()` from `better-auth/cookies` to do a cheap cookie-presence check and redirects to `/signin?callbackUrl=` if absent. Also sets an `x-pathname` header forwarding the original URL. +- **Layer 1 — edge proxy** (`src/proxy.ts`): uses `getSessionCookie()` from `better-auth/cookies` to do a cheap cookie-presence check and redirects to `/signin?callbackUrl=` if absent. Also sets an `x-pathname` header forwarding the original URL (and, since security Step 6, the per-request CSP nonce). - **Layer 2 — server component** (`src/components/layout/auth-wrapper.tsx`): calls `auth.api.getSession()` inside the `(web)` route group and redirects using the forwarded `x-pathname` if the session is missing or invalid. -**Consequences:** Fast happy path at the edge; full validation before any protected render. Auth logic lives in two files that must stay in sync. Public paths (`/api/*`, `/signin`) are whitelisted in the proxy. +**Consequences:** Fast happy path at the edge; full validation before any protected render. Auth logic lives in two files that must stay in sync. Public paths (`/api/auth`, `/api/auth/*`, `/signin`, `/auth-error` — `isPublicPath()` in `src/proxy.ts`) are allowlisted; every other `/api/*` route needs the session cookie. Since 2026-09-13 the real authorization gate is `AuthorizationService` in the services layer (see ADR-014). **Alternatives considered:** - **Single full-validation gate in the proxy** — Better Auth's full session validation isn't cheap enough for edge and has Node-only dependencies. - **Skip edge check entirely** — every request would render a server component just to redirect, slower and more expensive than a cookie peek. @@ -108,12 +108,22 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 **Date:** 2026-04-17 **Status:** Accepted **Context:** All authenticated pages need a shared layout with `AuthWrapper` and the client `Providers` (which mounts `UserProvider`). The sign-in page and Better Auth's catch-all handler must render without a valid session. -**Decision:** Use Next.js App Router route groups. Authenticated pages live under `src/app/(web)/` and inherit its `layout.tsx`. Public routes (`src/app/signin/`, `src/app/api/auth/[...all]/`) sit as siblings outside the group. +**Decision:** Use Next.js App Router route groups. Authenticated pages live under `src/app/(web)/` and inherit its `layout.tsx`. Public routes (`src/app/signin/`, `src/app/api/auth/[...all]/`, `src/app/auth-error/`, `src/app/session-error/`) sit as siblings outside the group. **Consequences:** Clean separation of public vs. authenticated surfaces. Adding a new tool is a one-directory drop under `src/app/(web)/tools/`. Easy to accidentally place a public page inside `(web)/`, so reviewers must watch for this. **Alternatives considered:** - **Per-page auth checks** — copy-paste prone; easy to forget on new pages. - **Single top-level layout with conditional branch** — awkward in the App Router model and wastes a layout slot. +### ADR-027: One MP OAuth client for the service account and user sign-in + +**Date:** 2026-10-10 +**Status:** Accepted (reverses security Step 4's dedicated OIDC client) +**Context:** Step 4 (2026-09-30) added `MP_OIDC_CLIENT_ID` / `MP_OIDC_CLIENT_SECRET` so user sign-in would run as its own MP client, with a fallback to the service account while environments were split. That meant two MP client registrations and two secrets per deployment, plus a fallback path and a warning to maintain. +**Decision:** The app has exactly one MP OAuth client, `MINISTRY_PLATFORM_CLIENT_ID` / `MINISTRY_PLATFORM_CLIENT_SECRET`, read through `getMpClient()` in `src/lib/env.ts`. It is the client-credentials service account and the OIDC sign-in client; its id is the id_token audience and the sign-out `client_id`. The `MP_OIDC_*` variables, `getOidcClient`, `warnIfSharedOidcClient` and the `auth.oidc.shared_client` event were removed. +**Consequences:** One registration and one secret per deployment, and no half-configured state. Accepted risk: that one secret is both full MP data access and user sign-in, and an id_token minted for the service account's client is a valid sign-in token. The MP client record must carry the sign-in redirect URIs (`/api/auth/callback/ministryplatform`, post-logout ``) and nothing broader. +**Alternatives considered:** +- **Dedicated OIDC client (Step 4)** — narrower blast radius per secret, but double the client management for every deployment and customer clone. + --- ## MP Provider @@ -152,11 +162,12 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 **Date:** 2026-04-17 **Status:** Accepted **Context:** MP's OAuth server returns `expires_in: 3600` (1 hour), but tokens have been observed to become invalid earlier — server restarts, credential rotations, and other backend events invalidate tokens without client notice. Trusting `expires_in` causes 401 storms. -**Decision:** Ignore `expires_in`. `TOKEN_LIFE = 5 * 60 * 1000` is hard-coded in `src/lib/providers/ministry-platform/client.ts:6` and used for both `expiresAt` and `devExpiresAt`. `ensureValidToken()` refreshes whenever `expiresAt < Date.now()`, so at most one 5-minute window is stale. -**Consequences:** Predictable refresh cadence; worst-case staleness is 5 minutes. OAuth traffic is ~12× per hour per process vs. once. No automatic recovery for sub-5-minute invalidations (no retry-on-401). +**Decision:** Ignore `expires_in`. `TOKEN_LIFE = 5 * 60 * 1000` is hard-coded in `src/lib/providers/ministry-platform/client.ts:8` and used by each `TokenPipeline` (default and dev). `TokenPipeline.ensureValid()` refreshes whenever `expiresAt < Date.now()`, so at most one 5-minute window is stale. +**Consequences:** Predictable refresh cadence; worst-case staleness is 5 minutes. OAuth traffic is ~12× per hour per process vs. once. +**Amended 2026-09-30 (security Step 5):** sub-5-minute invalidations are now recovered — the `HttpClient` 401 hook invalidates the rejected token, refreshes once (single-flight) and retries the request once; a failed refresh is negative-cached for 5–30 s (jittered). See `mp-provider/client.md`. **Alternatives considered:** - **Use `expires_in` minus 60-second buffer** — fragile against early invalidation by the MP backend. -- **Retry-on-401 with single refresh** — complicates error paths and risks infinite-loop bugs. +- **Retry-on-401 with single refresh** — originally rejected (infinite-loop risk); adopted alongside the 5-minute lifetime in Step 5, bounded to one retry. - **Heartbeat refresh** — extra cost without clear benefit. ### ADR-009: Two isolated token pipelines (default + dev) with `api_dev_*` routing @@ -164,7 +175,7 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 **Date:** 2026-04-17 **Status:** Accepted **Context:** Tools like the Deploy Tool (`DeployToolPanel` → `api_dev_DeployTool`) must run against a non-production MP credential set to avoid accidental prod mutation during local development. Mixing credentials into a single client risks state leakage between requests. -**Decision:** `MinistryPlatformClient` holds two isolated pipelines — `(token, expiresAt, httpClient)` and `(devToken, devExpiresAt, devHttpClient)`. `getClientCredentialsToken(profile: 'default' | 'dev')` selects which env-var pair to use (`MINISTRY_PLATFORM_CLIENT_ID/SECRET` vs `MINISTRY_PLATFORM_DEV_CLIENT_ID/SECRET`). `ProcedureService` routes only procedures whose name starts with `api_dev_` through the dev pipeline. +**Decision:** `MinistryPlatformClient` holds two isolated `TokenPipeline` instances — `main` and `dev` — each with its own token, expiry, single-flight refresh and `HttpClient`. `getClientCredentialsToken(profile: 'default' | 'dev')` selects which env-var pair to use (`MINISTRY_PLATFORM_CLIENT_ID/SECRET` vs `MINISTRY_PLATFORM_DEV_CLIENT_ID/SECRET`). `ProcedureService` routes only procedures whose name starts with `api_dev_` through the dev pipeline. **Consequences:** Clean isolation; dev token refresh cannot touch prod token. Two `HttpClient` instances in memory is negligible. Dev env vars are optional at startup — the dev pipeline throws a clear error only when a caller actually invokes it without the vars set. **Alternatives considered:** - **Single pipeline with a pre-call hook swapping credentials** — easy to miss a swap-back, high blast radius. @@ -197,8 +208,8 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 **Date:** 2026-04-17 **Status:** Accepted **Context:** MP's database has hundreds of tables and stored procedures (current facts baseline: 603 generated model files). Hand-maintaining a catalog would drift constantly. -**Decision:** `npm run mp:generate:models` produces `src/lib/providers/ministry-platform/models/*.ts` (TypeScript + Zod) using `--clean` to wipe the directory first; `npm run mp:generate:storedprocs` produces `.claude/references/ministryplatform.storedprocs.md`. `required-stored-procs.md` is hand-written for the handful of procs the app actually invokes (facts snapshot lists 8). -**Consequences:** Schema reference docs stay in sync with MP after a regen. `required-stored-procs.md` must be manually updated whenever a service method wraps a new proc. +**Decision:** `npm run mp:generate:models` produces `src/lib/providers/ministry-platform/models/*.ts` (TypeScript + Zod) using `--clean` to wipe the directory first; `npm run mp:generate:storedprocs` writes `mp-schema/stored-procs.md`, and the type generator writes `mp-schema/tables.md` (both keep their frontmatter; a `--search` run never overwrites the full catalog). `mp-schema/required-procs.md` is hand-written for the handful of procs the app actually invokes. +**Consequences:** Schema reference docs stay in sync with MP after a regen (move the output into `mp-schema/`). `mp-schema/required-procs.md` must be manually updated whenever a service method wraps a new proc. **Alternatives considered:** - **Hand-maintained catalog** — untenable at MP's scale. - **On-the-fly MP API lookup during agent work** — latency, no offline grep, and no stable reference. @@ -211,8 +222,8 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 **Date:** 2026-04-17 **Status:** Accepted -**Context:** All five app services (`UserService`, `ToolService`, `AddressLabelService`, `GroupService`, `FieldManagementService`) need a single shared `MPHelper` per process. Construction is currently synchronous but `initialize()` may become async (e.g., to warm a cache). Tests must be able to reset state between cases. -**Decision:** Each service has a private constructor, a private static `instance` field, and a public static `async getInstance()` that lazily constructs and awaits `initialize()` (see `src/services/userService.ts:29-35`). Tests reset via `(Service as any).instance = undefined` in `beforeEach`. +**Context:** The MP-backed app services (`UserService`, `ToolService`, `AddressLabelService`, `GroupService`, `FieldManagementService`, `FamilyService`, plus `GooglePlacesService`) need a single shared instance per process. Construction is currently synchronous but `initialize()` may become async (e.g., to warm a cache). Tests must be able to reset state between cases. +**Decision:** Each service has a private constructor, a private static `instance` field, and a public static `async getInstance()` that lazily constructs and awaits `initialize()` (see `src/services/userService.ts:30-36`). Tests reset via `(Service as any).instance = undefined` in `beforeEach`. Exceptions: `AuthorizationService` and `DomainTimezoneService` expose a **synchronous** `getInstance()`. **Consequences:** Single shared instance per process. Callers always `await` `getInstance()` even though today's `initialize()` is synchronous — future async setup won't break callers. Test isolation requires explicit reset. **Alternatives considered:** - **Module-level `const service = new Service()`** — loses test resettability. @@ -223,7 +234,7 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 **Date:** 2026-04-17 **Status:** Accepted **Context:** Components should not know MP URL shapes, `$filter` / `$select` syntax, ambiguous-column prefixing rules, or `$userId` audit wiring. Concentrating these concerns keeps query conventions consistent. -**Decision:** Server actions always follow: `auth.api.getSession()` → `Service.getInstance()` → `service.method()` → `MPHelper`. Services own query strings, batching, Zod schemas, and audit parameters. +**Decision:** Server actions always follow: `Service.getInstance()` → `service.method()` → `MPHelper`. Services own query strings, batching, Zod schemas, and audit parameters. **Amended 2026-09-13:** each MP-touching service method gates itself with `AuthorizationService.requireSecurityRole()` (reads and writes) and takes `$userId` from its return value — a bare `auth.api.getSession()` in the action is not authorization (CLAUDE.md practices #12, #13). **Consequences:** One place to change an ambiguous-column fix or a batching strategy. Clean test seam: per-service mocks of `MPHelper` cover most server-action tests. **Alternatives considered:** - **Actions call `MPHelper` directly** — duplicates query strings across features, drifts on ambiguous-column prefixes, and spreads `$userId` wiring. @@ -236,9 +247,9 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 **Date:** 2026-04-17 **Status:** Accepted -**Context:** The Better Auth session contains identity claims (`id`, `email`, `userGuid`, `firstName`, `lastName`). Downstream features need roles, user groups, `Contact_ID`, and profile image — all of which require MP API calls and should not block the auth critical path. -**Decision:** `useAppSession()` (wraps `authClient.useSession()`) exposes only the lightweight Better Auth session. A separate `UserProvider` (`src/contexts/user-context.tsx`) loads the MP profile lazily by `userGuid` via `getCurrentUserProfile()` and exposes it through `useUser()`. `Providers` (`src/app/providers.tsx`) composes them. -**Consequences:** Auth-only consumers don't pay MP API latency. MP profile loads once per session on the client; refresh is explicit via `refreshUserProfile()`. Context value and callback are memoized (`useMemo`, `useCallback`). +**Context:** The Better Auth session contains identity claims (`id`, `email`, `userGuid`, `firstName`, `lastName`). Downstream UI needs the user's MP display profile, which requires an MP API call and should not block the auth critical path. +**Decision:** `useAppSession()` (wraps `authClient.useSession()`) exposes only the lightweight Better Auth session. A separate `UserProvider` (`src/contexts/user-context.tsx`) loads the MP profile via the parameterless `getCurrentUserProfile()` action and exposes it through `useUser()`. The load is **lazy on demand**: the provider makes no MP call until a component calls `useUser()` (whose effect calls an internal `requestProfile()` from a private context, flipping a `requested` flag), and then only once a `userGuid` is in the session; all consumers share that one load. `Providers` (`src/app/providers.tsx`) mounts `UserProvider`; `useAppSession()` is a standalone hook. Both hooks are kept as a documented **extension point** for tools built on the template — since the header `UserMenu` was removed (#54) nothing outside `src/contexts/` calls them, and with lazy fetch the mounted provider costs no MP request until a tool opts in. +**Consequences:** Auth-only consumers don't pay MP API latency. Pages with no `useUser()` consumer make no profile request. MP profile loads once per provider lifetime on the client, on first use; refresh is explicit via `refreshUserProfile()`. Context value and callback are memoized (`useMemo`, `useCallback`). **Alternatives considered:** - **Single merged provider** — couples auth availability to MP availability. - **Load profile server-side in layout** — blocks TTFB on every page. @@ -259,10 +270,10 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 **Date:** 2026-04-17 **Status:** Accepted **Context:** Service code interpolates user input into MP OData `$filter` strings (e.g., `User_GUID = '${guid}'`). MP's API accepts raw filter strings — there is no parameterized query builder. Something must prevent SQL-like injection without forcing every call site to adopt a heavyweight ORM. -**Decision:** Four throwing helpers in `src/lib/validation.ts` — `validateGuid`, `validatePositiveInt`, `validateColumnName`, `escapeFilterString`. Callers invoke them on input and interpolate the returned value. Violations throw synchronously with a clear message. +**Decision:** Throwing helpers in `src/lib/validation.ts` — `validateGuid`, `validatePositiveInt`, `validateColumnName`, `validateFkPath`, `validateMailerId`, `escapeFilterString`. Callers invoke them on input and interpolate the returned value. Violations throw synchronously with a clear message. **Consequences:** Simple, testable (see `src/lib/validation.test.ts`), grep-friendly. Static types cannot enforce "this string was validated" — reviewers must spot missed validation at call sites. **Alternatives considered:** -- **Branded/opaque types** — over-engineered for a four-helper API. +- **Branded/opaque types** — over-engineered for a handful of helpers. - **Full ORM or query builder** — doesn't integrate with MP's REST surface. --- @@ -363,17 +374,7 @@ Architectural decisions captured by the context-engineering review at SHA `971c4 **Date:** 2026-04-17 **Status:** Accepted **Context:** React component tests need a DOM that supports `localStorage`, `window.location`, `Headers`, and the full `@testing-library` API surface. -**Decision:** `environment: 'jsdom'` in `vitest.config.mts` with `jsdom@^30.0.1`. +**Decision:** `environment: 'jsdom'` in `vitest.config.mts` with `jsdom@^30.1.2`. **Consequences:** Slightly slower than happy-dom on pure-DOM tests, but compatibility is higher for edge APIs (`Headers`, `Storage`, `fetch` mocks). **Alternatives considered:** - **happy-dom** — faster, but historically has gaps around `Headers`, `fetch`, and `Storage` mocks that affect MP client tests. - -### ADR-027: One MP OAuth client for the service account and user sign-in - -**Date:** 2026-10-10 -**Status:** Accepted (reverses security Step 4's dedicated OIDC client) -**Context:** Step 4 (2026-09-30) added `MP_OIDC_CLIENT_ID` / `MP_OIDC_CLIENT_SECRET` so user sign-in would run as its own MP client, with a fallback to the service account while environments were split. That meant two MP client registrations and two secrets per deployment, plus a fallback path and a warning to maintain. -**Decision:** The app has exactly one MP OAuth client, `MINISTRY_PLATFORM_CLIENT_ID` / `MINISTRY_PLATFORM_CLIENT_SECRET`, read through `getMpClient()` in `src/lib/env.ts`. It is the client-credentials service account and the OIDC sign-in client; its id is the id_token audience and the sign-out `client_id`. The `MP_OIDC_*` variables, `getOidcClient`, `warnIfSharedOidcClient` and the `auth.oidc.shared_client` event were removed. -**Consequences:** One registration and one secret per deployment, and no half-configured state. Accepted risk: that one secret is both full MP data access and user sign-in, and an id_token minted for the service account's client is a valid sign-in token. The MP client record must carry the sign-in redirect URIs (`/api/auth/callback/ministryplatform`, post-logout ``) and nothing broader. -**Alternatives considered:** -- **Dedicated OIDC client (Step 4)** — narrower blast radius per secret, but double the client management for every deployment and customer clone. diff --git a/.claude/references/GLOSSARY.md b/.claude/references/GLOSSARY.md index 1529cc6..59decf7 100644 --- a/.claude/references/GLOSSARY.md +++ b/.claude/references/GLOSSARY.md @@ -15,15 +15,15 @@ Agent-facing domain ontology for MPNext-Tools. Terms are alphabetized. Aliases a SQL-side convention: every `api_*` stored procedure MUST declare `@DomainID INT` as its first parameter; the MP REST API injects the authenticated domain automatically so TypeScript callers never pass it. **Not to be confused with:** `$userId` (app-side audit param) or `Domain_ID` (FK column value). -**Defined in:** `.claude/commands/newstoredproc.md:171` +**Defined in:** `.claude/commands/newstoredproc.md:21` **See:** `mp-schema/required-procs.md` ## $userId (aliases: MP audit user ID) -Option passed to `MPHelper.createTableRecords` / `updateTableRecords` that stamps MP audit columns with an MP `User_ID`; resolve from `session.user.userGuid` via `UserService.getUserIdByGuid`. +Option passed to `MPHelper.createTableRecords` / `updateTableRecords` (and procedure/file calls) that stamps MP audit columns with an MP `User_ID`; taken only from the return value of `AuthorizationService.requireSecurityRole()` inside the service, never from a caller (CLAUDE.md practice #13). **Not to be confused with:** `session.user.id` (Better Auth internal nanoid) or `userGuid` (MP User_GUID). -**Defined in:** `src/services/groupService.ts:232` +**Defined in:** `src/services/authorizationService.ts:200` (`requireSecurityRole`); used e.g. `src/services/groupService.ts:369` **See:** `services/README.md` ## A @@ -33,7 +33,7 @@ Option passed to `MPHelper.createTableRecords` / `updateTableRecords` that stamp MP REST error raised when a column name appears in multiple tables joined by `_TABLE` FK traversal; resolved by prefixing with `TableName.Column` or `FKColumn_TABLE.Column`. **Not to be confused with:** SQL ambiguous-column errors (different error surface). -**Defined in:** `src/services/addressLabelService.ts:28` +**Defined in:** `src/services/addressLabelService.ts:30` **See:** `services/query-patterns.md` ## api_dev_ prefix (aliases: dev procedure, DEV_PROC_PREFIX) @@ -41,12 +41,12 @@ MP REST error raised when a column name appears in multiple tables joined by `_T Procedures whose name starts with `api_dev_` (case-insensitive, trailing underscore required) are routed through an isolated dev-credential HttpClient; `api_developer_*` does NOT match. **Not to be confused with:** regular `api_*` procs (default credential pipeline). -**Defined in:** `src/lib/providers/ministry-platform/services/procedure.service.ts:6` +**Defined in:** `src/lib/providers/ministry-platform/services/procedure.service.ts:7` **See:** `mp-provider/services/procedure.md` ## AuthWrapper -Server component that runs inside the `(web)` route group, calls `auth.api.getSession()`, and redirects unauthenticated users to `/signin?callbackUrl=`; layer-2 session validation complementing the proxy's layer-1 cookie check. +Server component that runs inside the `(web)` route group, calls `auth.api.getSession()`, redirects unauthenticated users to `/signin?callbackUrl=` and sessions without a `userGuid` to `/session-error`; layer-2 session validation complementing the proxy's layer-1 cookie check. **Not to be confused with:** `proxy` (edge-layer cookie check only). **Defined in:** `src/components/layout/auth-wrapper.tsx:5` @@ -67,7 +67,7 @@ Server component that runs inside the `(web)` route group, calls `auth.api.getSe Query-string parameter on `/signin` that captures the originally requested URL; preserved across the OAuth redirect so the user lands back on the exact tool + params after sign-in. **Not to be confused with:** `post_logout_redirect_uri` (endsession flow). -**Defined in:** `src/proxy.ts:29` +**Defined in:** `src/proxy.ts:82` **See:** `routing/proxy.md` ## client credentials flow (aliases: OAuth2 client credentials, machine-to-machine auth) @@ -75,7 +75,7 @@ Query-string parameter on `/signin` that captures the originally requested URL; OAuth2 grant type used by the provider to acquire tokens for server-to-MP calls via `POST {baseUrl}/oauth/connect/token` with `grant_type=client_credentials`; distinct from the Better Auth user-login OAuth flow. **Not to be confused with:** `genericOAuth` (user-facing OIDC authorization code flow). -**Defined in:** `src/lib/providers/ministry-platform/auth/client-credentials.ts:3` +**Defined in:** `src/lib/providers/ministry-platform/auth/client-credentials.ts:27` **See:** `mp-provider/client.md` ## cookieCache (aliases: JWT cookie cache) @@ -83,7 +83,7 @@ OAuth2 grant type used by the provider to acquire tokens for server-to-MP calls Better Auth stateless session strategy: the `session_data` cookie (encrypted JWE, 1-hour `maxAge`, `refreshCache: false`) caches the session with no database — subsequent `getSession()` calls decrypt the cookie directly. The session itself has a hard 12 h ceiling (`expiresIn`). See `auth/sessions.md`. **Not to be confused with:** server-side session storage (not used here). -**Defined in:** `src/lib/auth.ts:12` +**Defined in:** `src/lib/auth.ts:1060` **See:** `auth/sessions.md` ## CredentialProfile (aliases: default, dev) @@ -91,7 +91,7 @@ Better Auth stateless session strategy: the `session_data` cookie (encrypted JWE Union type `'default' | 'dev'` selecting which pair of env vars supplies the client id/secret; `'dev'` is used only by `ProcedureService` when executing `api_dev_*` stored procedures. **Not to be confused with:** Better Auth's OAuth `providerId`. -**Defined in:** `src/lib/providers/ministry-platform/auth/client-credentials.ts:1` +**Defined in:** `src/lib/providers/ministry-platform/auth/client-credentials.ts:6` **See:** `mp-provider/client.md` ## customSession (aliases: customSession plugin, customSessionClient) @@ -99,7 +99,7 @@ Union type `'default' | 'dev'` selecting which pair of env vars supplies the cli Better Auth plugin wrapping the server `auth` config that enriches every `getSession()` response; here it performs name-splitting only (no API calls). Client-side `customSessionClient()` provides type inference but does NOT expose `additionalFields`. **Not to be confused with:** `UserProvider` (loads the full MP profile). -**Defined in:** `src/lib/auth.ts:97` +**Defined in:** `src/lib/auth.ts:1116` (callback: `enrichSession`) **See:** `auth/sessions.md` ## D @@ -132,10 +132,10 @@ MP OAuth endpoint at `/oauth/connect/endsession` that ends the SSO session and r ## escapeFilterString (aliases: MP filter quote escape) -Helper that escapes `'` -> `''`, `%` -> `[%]`, `_` -> `[_]` before interpolating user input into an MP `$filter` string; used by `GroupService.searchContacts`/`searchGroups`. +Helper that escapes `[` -> `[[]`, `%` -> `[%]`, `_` -> `[_]`, `'` -> `''` (and maps quote look-alikes to `_`; throws on control chars or > `MAX_SEARCH_TERM_LENGTH`) before interpolating user input into an MP `$filter` LIKE string; used by `GroupService`, `FamilyService`, and `ToolService`. **Not to be confused with:** plain quote-doubling (insufficient — misses wildcards). -**Defined in:** `src/lib/validation.ts:25` +**Defined in:** `src/lib/validation.ts:127` **See:** `services/query-patterns.md` ## G @@ -145,7 +145,7 @@ Helper that escapes `'` -> `''`, `%` -> `[%]`, `_` -> `[_]` before interpolating Better Auth plugin wiring arbitrary OIDC providers; configured with `providerId: "ministryplatform"`, explicit authorize/token/endsession endpoints and **no** `discoveryUrl` (since security Step 4: no MP call at boot; the id_token is verified by the app in `getUserInfo` against lazily loaded discovery + JWKS), the app's single MP client (`MINISTRY_PLATFORM_CLIENT_ID`, shared with the service account), `openid` + MP all scope (no `offline_access`), `pkce: false` (MP advertises `S256` in discovery but rejects the token exchange with `invalid_grant`), no nonce (MP omits the claim), and `realm=realm` authorization param. As of Better Auth 1.7 it registers providers as first-class **social** providers, so the callback URL is the core `/api/auth/callback/{providerId}` — not the former `/api/auth/oauth2/callback/{providerId}`. **Not to be confused with:** `client credentials flow` (server-to-server, no user). -**Defined in:** `src/lib/auth.ts:32` +**Defined in:** `src/lib/auth.ts:699` (`ministryPlatformProviderConfig`) **See:** `auth/oauth-flow.md` ## H @@ -155,7 +155,7 @@ Better Auth plugin wiring arbitrary OIDC providers; configured with `providerId: Low-level fetch wrapper for the provider stack; injects `Authorization: Bearer ` via closure, serializes JSON or FormData, URL-encodes query params, throws formatted `Error`s on non-2xx. **Not to be confused with:** `MinistryPlatformClient` (token lifecycle owner). -**Defined in:** `src/lib/providers/ministry-platform/utils/http-client.ts:4` +**Defined in:** `src/lib/providers/ministry-platform/utils/http-client.ts:91` **See:** `mp-provider/client.md` ## I @@ -165,7 +165,7 @@ Low-level fetch wrapper for the provider stack; injects `Authorization: Bearer < USPS 65-bar, 4-state (T/D/A/F) mail barcode encoding a 20-digit tracking block + optional 5/9/11-digit routing code; encoded by the hand-rolled `imbEncode()`. **Not to be confused with:** POSTNET (legacy tall/short barcode). -**Defined in:** `src/lib/imb-encoder.ts:14` +**Defined in:** `src/lib/imb-encoder.ts:320` **See:** `utils/barcodes.md` ## L @@ -201,15 +201,15 @@ Secondary tab in `/tools/addresslabels` that lets the user upload a `.docx` temp USPS-assigned 6- or 9-digit identifier embedded in the IMb tracking block; the serial-number length is derived from Mailer ID length (9 vs 6 digits). **Not to be confused with:** Service Type ID (3-digit IMb field). -**Defined in:** `src/lib/barcode-helpers.ts:21` +**Defined in:** `src/lib/barcode-helpers.ts:22` **See:** `utils/barcodes.md` ## MinistryPlatformClient -Token-lifecycle + HttpClient holder; owns two independent pipelines (default + dev) each with its own token cache, `expiresAt`, and `HttpClient`. +Token-lifecycle + HttpClient holder; owns two independent `TokenPipeline`s (default + dev), each with its own token cache, `expiresAt`, single-flight refresh, 401 retry hook, and `HttpClient`. **Not to be confused with:** `HttpClient` (low-level fetch wrapper). -**Defined in:** `src/lib/providers/ministry-platform/client.ts:21` +**Defined in:** `src/lib/providers/ministry-platform/client.ts:93` **See:** `mp-provider/client.md` ## MinistryPlatformProvider (aliases: provider singleton) @@ -233,15 +233,15 @@ Pattern of mocking a constructable import as a `class { method = mockFn }` liter Public-API facade class for Ministry Platform operations; normalizes friendly param names (`select`, `filter`, `top`) to `$`-prefixed OData params, offers optional Zod validation on create/update, and delegates to the singleton `MinistryPlatformProvider`. **Not to be confused with:** `MinistryPlatformProvider` (singleton internal). -**Defined in:** `src/lib/providers/ministry-platform/helper.ts:34` +**Defined in:** `src/lib/providers/ministry-platform/helper.ts:55` **See:** `mp-provider/README.md` ## MPUserProfile -Display-only client-side user profile: `First_Name`, `Nickname`, `Last_Name`, `Email_Address` (joined from the `dp_Users` row's Contact) — no IDs, phone, image, roles, or user groups; loaded via `getCurrentUserProfile()` → `UserService.getUserProfile(userGuid)` (single query) and cached in `UserContext`. +Display-only client-side user profile: `First_Name`, `Nickname`, `Last_Name`, `Email_Address` (joined from the `dp_Users` row's Contact) — no IDs, phone, image, roles, or user groups; loaded via the parameterless `getCurrentUserProfile()` → `UserService.getUserProfile(userGuid)` (single query) and cached in `UserContext`. **Not to be confused with:** Better Auth `session.user` (name/email/id only). -**Defined in:** `src/lib/providers/ministry-platform/types/user-profile.types.ts:1` +**Defined in:** `src/lib/providers/ministry-platform/types/user-profile.types.ts:10` **See:** `contexts/user-provider.md` ## P @@ -259,7 +259,7 @@ Row describing how one column is presented on an MP page: order, group, required TS interface describing the first-result-set row returned by `api_Tools_GetPageData`: `Page_ID`, `Display_Name`, `Singular_Name`, `Table_Name`, `Primary_Key`, and optional tool-config fields. **Not to be confused with:** `PageListItem` (list view) or `PageField` (per-field metadata). -**Defined in:** `src/lib/tool-params.ts:3` +**Defined in:** `src/lib/tool-params.ts:14` **See:** `utils/tool-params.md` ## parseToolParams (aliases: ToolParams) @@ -267,7 +267,7 @@ TS interface describing the first-result-set row returned by `api_Tools_GetPageD Single ingestion point for Ministry Platform tool URL query strings (`pageID`, `s`, `sc`, `p`, `q`, `v`, `recordID`, `recordDescription`, `addl`); hydrates `pageData` and returns a typed `ToolParams`. **Not to be confused with:** framework `searchParams` (raw Next.js input). -**Defined in:** `src/lib/tool-params.ts:30` +**Defined in:** `src/lib/tool-params.server.ts:43` (`ToolParams` type: `src/lib/tool-params.ts:28`) **See:** `utils/tool-params.md` ## POSTNET @@ -275,15 +275,15 @@ Single ingestion point for Ministry Platform tool URL query strings (`pageID`, ` Legacy USPS tall/short barcode for 5/9/11-digit ZIPs with Mod-10 check digit; used as IMb fallback when Mailer ID is absent or IMb encoding throws. **Not to be confused with:** IMb (modern 4-state replacement). -**Defined in:** `src/lib/postnet-encoder.ts:11` +**Defined in:** `src/lib/postnet-encoder.ts:31` **See:** `utils/barcodes.md` ## proxy (aliases: proxy.ts, middleware (old name)) -Next.js 16 renamed `middleware.ts`/`middleware()` to `proxy.ts`/`proxy()`; here it performs a session-cookie-presence check via `getSessionCookie()` (no JWT decode) and forwards `pathname + search` as the `x-pathname` header. +Next.js 16 renamed `middleware.ts`/`middleware()` to `proxy.ts`/`proxy()`; here it performs a session-cookie-presence check via `getSessionCookie()` (no JWT decode) forwards `pathname + search` as the `x-pathname` header, and sets the per-request CSP nonce. **Not to be confused with:** `AuthWrapper` (server-component layer-2 check). -**Defined in:** `src/proxy.ts:4` +**Defined in:** `src/proxy.ts:37` **See:** `routing/proxy.md` ## R @@ -303,7 +303,7 @@ Next.js App Router convention: a directory wrapped in parentheses groups routes Ministry Platform user-scoped record set (identified by `Selection_ID` + `User_ID` + `Page_ID`) fetched via `api_Common_GetSelection`; the "selected records" context passed to cloud tools. **Not to be confused with:** browser text selection or client-side state. -**Defined in:** `src/services/toolService.ts:152` +**Defined in:** `src/services/toolService.ts:162` (`getSelectionRecordIds`) **See:** `mp-schema/required-procs.md` ## service singleton (aliases: getInstance pattern, lazy async singleton) @@ -311,7 +311,7 @@ Ministry Platform user-scoped record set (identified by `Selection_ID` + `User_I Application service class with private constructor and static `getInstance(): Promise` that lazily constructs and initializes a single shared instance holding an `MPHelper`; reset in tests via `(ServiceClass as any).instance = undefined`. **Not to be confused with:** `MinistryPlatformProvider` singleton (provider stack internal). -**Defined in:** `src/services/toolService.ts:81` +**Defined in:** `src/services/toolService.ts:102` **See:** `services/README.md` ## shadcn/ui (aliases: shadcn) @@ -327,7 +327,7 @@ Component primitive library installed via the shadcn CLI using `new-york` style, Test pattern: set `(ServiceClass as any).instance = undefined` inside `beforeEach` to clear cached service singletons between tests. **Not to be confused with:** `vi.resetModules()` (full module-cache clear). -**Defined in:** `src/services/toolService.test.ts:22` +**Defined in:** `src/services/toolService.test.ts:49` **See:** `testing/mocks.md` ## SkipRecord @@ -352,8 +352,8 @@ MP REST convention for following a foreign key: suffix the FK column with `_TABL Constant `5 * 60 * 1000` (ms); `MinistryPlatformClient` refreshes after this hard-coded 5-minute window for safety, ignoring MP's `expires_in`. -**Not to be confused with:** `cookieCache.maxAge` (Better Auth 1-hour JWT). -**Defined in:** `src/lib/providers/ministry-platform/client.ts:6` +**Not to be confused with:** `cookieCache.maxAge` (Better Auth 1-hour JWE cookie cache). +**Defined in:** `src/lib/providers/ministry-platform/client.ts:8` **See:** `mp-provider/client.md` ## ToolContainer @@ -371,7 +371,7 @@ Client-side full-screen flex shell that composes `DevPanel` (conditional), `Tool Thin React hook that returns only the `data` portion of Better Auth's `authClient.useSession()`; the preferred client-side session accessor (drops `isPending`/`error`/`refetch`). **Not to be confused with:** `useUser` (loads full MP profile from UserContext). -**Defined in:** `src/contexts/session-context.tsx:7` +**Defined in:** `src/contexts/use-app-session.ts:7` **See:** `contexts/session.md` ## userGuid (aliases: User_GUID, MP User GUID) @@ -379,7 +379,7 @@ Thin React hook that returns only the `data` portion of Better Auth's `authClien MP Ministry Platform `User_GUID` (UUID) stored on `session.user` via Better Auth `additionalFields` and populated from the OIDC `sub` claim by `mapProfileToUser`; REQUIRED for every MP API lookup of the signed-in user — `session.user.id` is a Better Auth internal nanoid and is NOT interchangeable. **Not to be confused with:** `session.user.id` (BA nanoid) or `User_ID` (MP int surrogate). -**Defined in:** `src/lib/auth.ts:24` +**Defined in:** `src/lib/auth.ts:353` (`userAdditionalFields.userGuid`) **See:** `auth/user-identity.md` ## UserProvider @@ -397,7 +397,7 @@ Client React provider mounted in `src/app/providers.tsx` that watches the Better Throws if the input does not match `/^[A-Za-z_][A-Za-z0-9_]*$/`; used before interpolating column names into selects. **Not to be confused with:** `escapeFilterString` (value escaping, not identifier validation). -**Defined in:** `src/lib/validation.ts:18` +**Defined in:** `src/lib/validation.ts:40` **See:** `dto-constants/constants.md` ## validateGuid @@ -405,7 +405,7 @@ Throws if the input does not match `/^[A-Za-z_][A-Za-z0-9_]*$/`; used before int Throws on malformed GUID (accepts upper/lower hex); used before interpolating `User_GUID` into MP filters. **Not to be confused with:** runtime Zod schema validation. -**Defined in:** `src/lib/validation.ts:4` +**Defined in:** `src/lib/validation.ts:20` **See:** `dto-constants/constants.md` ## validatePositiveInt @@ -413,7 +413,7 @@ Throws on malformed GUID (accepts upper/lower hex); used before interpolating `U Throws on non-integer, zero, or negative input; strictly positive (`>= 1`). **Not to be confused with:** non-negative integer checks (this rejects `0`). -**Defined in:** `src/lib/validation.ts:11` +**Defined in:** `src/lib/validation.ts:33` **See:** `dto-constants/constants.md` ## vi.hoisted @@ -441,5 +441,5 @@ Vitest helper that declares variables which must exist before `vi.mock()` factor Request header set by `src/proxy.ts` on passthrough responses; carries `pathname + search` so downstream server components (notably `AuthWrapper`) can build a correct `callbackUrl`. **Not to be confused with:** `NextRequest.nextUrl.pathname` (available only in the proxy layer). -**Defined in:** `src/proxy.ts:13` +**Defined in:** `src/proxy.ts:58` **See:** `routing/proxy.md` diff --git a/.claude/references/GOTCHAS.md b/.claude/references/GOTCHAS.md index 4b48070..a378b9b 100644 --- a/.claude/references/GOTCHAS.md +++ b/.claude/references/GOTCHAS.md @@ -80,31 +80,31 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** You pass `session.user.id` to an MP query expecting `dp_Users.User_GUID`. MP returns 0 rows, joins fail, or audit columns show the wrong user. **Root cause:** `session.user.id` is Better Auth's internal nanoid-style ID (e.g. `1gYSNMvy6OqAm9q3DdVhtKj3Czkxd0ms`), NOT the MP `User_GUID` (UUID). The MP `sub` claim is stored in a separate `userGuid` additionalField. **Fix:** Always use `session.user.userGuid` for MP API lookups. Needs a cast at the call site: `const userGuid = (session.user as { userGuid?: string }).userGuid`. -**Enforced where:** `src/lib/auth.ts:22-30` (additionalFields definition), `src/contexts/user-context.tsx:29` (cast pattern), `src/auth.test.ts:189-207` (regression test). +**Enforced where:** `src/lib/auth.ts` (`userAdditionalFields`), `src/contexts/user-context.tsx:29` (cast pattern), `src/auth.test.ts:203` (regression test). **Related:** [auth/user-identity.md](auth/user-identity.md), [services/](services/) ### GOTCHA-002: TypeScript error "Property userGuid does not exist on type..." **Symptom:** TS complains about `session.user.userGuid` even though the value exists at runtime. -**Root cause:** Better Auth 1.5's `customSessionClient` does not propagate `additionalFields` into its inferred session type. Runtime is correct, types are not. +**Root cause:** Better Auth's `customSessionClient` does not propagate `additionalFields` into its inferred session type. Runtime is correct, types are not. **Fix:** Cast at the read site: `(session.user as { userGuid?: string }).userGuid`. Do not try to extend Better Auth's types globally. -**Enforced where:** `src/contexts/user-context.tsx:29`, `src/lib/auth.ts:85` (map function also needs a cast). +**Enforced where:** `src/contexts/user-context.tsx:29`, `src/lib/auth.ts` (`mapProfileToUser` returns `as Record`). **Related:** [auth/README.md](auth/README.md) ### GOTCHA-003: Proxy lets forged/expired session cookies through **Symptom:** Proxy does not redirect a broken session to `/signin`; the request reaches a route that then errors or returns empty data. **Root cause:** `src/proxy.ts` uses `getSessionCookie()` from `better-auth/cookies`, which only checks for cookie presence — it does not decode or validate the JWT. Any forged or expired cookie passes the proxy. -**Fix:** Every protected page must render inside `(web)/layout.tsx` so `AuthWrapper` runs `auth.api.getSession()` on the server. Protected API/server actions must also call `auth.api.getSession()` themselves. Never rely on the proxy alone for auth. -**Enforced where:** `src/proxy.ts:24-26` (cookie-presence check), `src/components/layout/auth-wrapper.tsx:5-20` (real session check). +**Fix:** Every protected page must render inside `(web)/layout.tsx` so `AuthWrapper` runs `auth.api.getSession()` on the server. Server actions and services that touch MP data must call `AuthorizationService.requireSecurityRole()` (CLAUDE.md practice #12). Never rely on the proxy alone for auth. +**Enforced where:** `src/proxy.ts:91-95` (cookie-presence check), `src/components/layout/auth-wrapper.tsx:5-27` (real session check). **Related:** [auth/](auth/), [routing/](routing/) ### GOTCHA-004: User lands on tool index instead of specific tool + params after sign-in **Symptom:** After OAuth round-trip the user is dropped at `/` or the tool index rather than the originally requested URL. **Root cause:** `callbackUrl` is dropped somewhere in the round-trip. The proxy must forward `x-pathname` on the pass-through request, and `/signin` redirects must set `callbackUrl` to that original path. `AuthWrapper` must read `x-pathname` to build the redirect. -**Fix:** Preserve both the `x-pathname` header in `src/proxy.ts:13` and the `hdrs.get("x-pathname")` read in `src/components/layout/auth-wrapper.tsx:13`. Do not remove either without updating the other. -**Enforced where:** `src/proxy.ts:12-15,28-30`, `src/components/layout/auth-wrapper.tsx:13-16`. +**Fix:** Preserve both the `x-pathname` header in `src/proxy.ts:58` and the `hdrs.get("x-pathname")` read in `src/components/layout/auth-wrapper.tsx:13`. Do not remove either without updating the other. +**Enforced where:** `src/proxy.ts:57-58` (`forwardedHeaders.set('x-pathname', fullPath)`), `src/components/layout/auth-wrapper.tsx:13-16`. **Related:** [auth/oauth-flow.md](auth/oauth-flow.md) ### GOTCHA-005: Sign-out redirects to `http://localhost:3000` in production @@ -127,8 +127,8 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** `Created_By`, `Modified_By`, `dp_Audit_Log` rows show the test user, an empty user, or the wrong MP user. **Root cause:** Caller passed `session.user.id` (the Better Auth internal) as `$userId`. MP expects the numeric `User_ID` (integer), not a GUID and certainly not a nanoid. -**Fix:** Resolve the numeric MP `User_ID` before the MP call: `const userId = await UserService.getInstance().then(s => s.getUserIdByGuid(session.user.userGuid))`, then pass that integer as `$userId`. -**Enforced where:** `src/services/groupService.ts:232,248` (correct pattern — uses `userId: number`), `src/services/userService.ts:68-79` (`getUserIdByGuid` helper). +**Fix:** Take `$userId` from the return value of `AuthorizationService.getInstance().requireSecurityRole(...)` inside the **service** (it resolves the session's `userGuid` to the numeric `dp_Users.User_ID`). Never accept it from an action argument or caller payload (CLAUDE.md practice #13). +**Enforced where:** `src/services/authorizationService.ts` (`requireSecurityRole` returns the acting `User_ID`); e.g. `src/services/groupService.ts:369,385`, `src/services/toolService.ts` (`deployTool`). **Related:** [auth/user-identity.md](auth/user-identity.md), [services/](services/) --- @@ -140,7 +140,7 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** Fresh server boot, first MP call returns 401 with an empty Bearer. **Root cause:** Service method forgot to `await this.client.ensureValidToken()` before `getHttpClient().get()`. Since 2026-09-30 (Step 5) the `HttpClient` 401 hook refreshes and retries once, so this now costs an extra round trip instead of failing — but it is still a bug; do not rely on the hook for a cold client. **Fix:** Every MP-provider service method that does a network call must start with `await this.client.ensureValidToken()` (or `ensureValidDevToken()` for `api_dev_*` procs). -**Enforced where:** `src/lib/providers/ministry-platform/client.ts` (`TokenPipeline.ensureValid`). Seen correctly applied in e.g. `src/lib/providers/ministry-platform/services/file.service.ts:48`, `communication.service.ts:20,40`. +**Enforced where:** `src/lib/providers/ministry-platform/client.ts` (`TokenPipeline.ensureValid`). Seen correctly applied in e.g. `src/lib/providers/ministry-platform/services/file.service.ts:49`, `communication.service.ts:28,51`. **Related:** [mp-provider/](mp-provider/) ### GOTCHA-009: N concurrent MP calls on cold server trigger N OAuth token POSTs (RESOLVED) @@ -164,7 +164,7 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** You hand-edit a model file, run `npm run mp:generate:models`, and the edit is gone. **Root cause:** `npm run mp:generate:models` uses the `--clean` flag, which deletes every `.ts` file in the output dir before regeneration. **Fix:** Never hand-edit anything under `models/`. Add runtime types to `src/lib/dto/` or wrap the generated model with a DTO. If the generator output is wrong, fix the generator in `src/lib/providers/ministry-platform/scripts/generate-types.ts`. -**Enforced where:** `src/lib/providers/ministry-platform/scripts/generate-types.ts:570-581`. +**Enforced where:** `src/lib/providers/ministry-platform/scripts/generate-types.ts:577-581` (`options.clean`). **Related:** [mp-provider/](mp-provider/) ### GOTCHA-012: Ambiguous column error when `$filter` joins tables @@ -172,7 +172,7 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** MP REST call errors with "ambiguous column name" when querying a table whose columns exist on the base table and a joined table. **Root cause:** MP REST does not auto-qualify columns that appear in both base and joined tables. The `Contact_ID` on `Contacts` is ambiguous with `Contact_ID` on `Contact_Addresses`, for example. **Fix:** Prefix every ambiguous column with its table: `Contacts.Contact_ID` instead of bare `Contact_ID`. For FK traversal use `FKColumn_TABLE.Column` (e.g. `Contact_ID_TABLE.First_Name`). For multi-level FK chains use underscores between `_TABLE_` hops and a single dot before the final field: `Building_ID_TABLE_Location_ID_TABLE.Congregation_ID`. -**Enforced where:** Documented in CLAUDE.md practice #10. Examples: `src/services/userService.ts:85` (FK traversal), `src/services/addressLabelService.ts:83` (multi-level). +**Enforced where:** Documented in CLAUDE.md practice #10. Examples: `src/services/userService.ts:63` (FK traversal), `src/services/addressLabelService.ts:32-38` (multi-level). **Related:** [services/query-patterns.md](services/query-patterns.md) ### GOTCHA-013: Passing `@DomainID` in `executeProcedureWithBody` causes parameter-count mismatch @@ -180,7 +180,7 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** Stored procedure call errors with "Procedure expected parameter @DomainID which was not supplied" or "procedure has too many arguments". **Root cause:** The MP REST API injects `@DomainID` automatically from the OAuth token context. Passing it in the body double-binds the parameter. **Fix:** Never include `@DomainID` in the body passed to `executeProcedureWithBody`. DO still declare `@DomainID INT` as the first parameter in the SP DDL — it is required for MP API exposure. See user's persistent memory: "@DomainID Required — All api_* stored procs MUST have @DomainID INT as first parameter for MP API". -**Enforced where:** `src/services/toolService.ts:126-129` (comment + correct pattern), `src/services/toolService.ts:185-187`. +**Enforced where:** `src/services/toolService.ts:133-135` (comment + correct pattern in `getPageData`); every `executeProcedureWithBody` call in `toolService.ts` omits `@DomainID`. **Related:** [mp-schema/](mp-schema/), [services/](services/) ### GOTCHA-014: Deploy-tool call hits production MP instance by accident @@ -188,7 +188,7 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** The deploy-tool flow writes rows into the production `dp_Tools` table. **Root cause:** Procedures prefixed with `api_dev_` are routed to the dev credentials pipeline by the provider. Without that prefix, `DeployTool` would run under production credentials. **Fix:** Keep the `api_dev_` prefix on any proc that should stay out of prod. Ensure `MINISTRY_PLATFORM_DEV_CLIENT_ID` / `MINISTRY_PLATFORM_DEV_CLIENT_SECRET` are set in `.env.local`. Keep the dev-panel localhost-only (see GOTCHA-033). Never rename these procs without checking the ProcedureService routing. -**Enforced where:** `src/services/toolService.ts:242-247`, `src/lib/providers/ministry-platform/client.ts` (`dev` `TokenPipeline`). +**Enforced where:** `src/services/toolService.ts:266-297` (`deployTool`), `src/lib/providers/ministry-platform/services/procedure.service.ts` (`DEV_PROC_PREFIX`), `src/lib/providers/ministry-platform/client.ts` (`dev` `TokenPipeline`). **Related:** [mp-provider/client.md](mp-provider/client.md) ### GOTCHA-015: File uploaded but no `$userId` audit trail @@ -196,7 +196,7 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** `dp_Files` rows appear after upload but `Modified_By` is null or the system user. **Root cause:** `FileService.uploadFiles` sends `userId` only as a `$userId` query-string parameter, not as a FormData field. If the caller forgets to pass `params.userId` at all, no audit trail is recorded. **Fix:** Always pass `params.userId` to `uploadFiles`. Verify MP records the audit by checking `dp_Audit_Log` rows after upload. -**Enforced where:** `src/lib/providers/ministry-platform/services/file.service.ts:67-77`. +**Enforced where:** `src/lib/providers/ministry-platform/services/file.service.ts:100` (`$userId` query param in `uploadFiles`). **Related:** [mp-provider/services/](mp-provider/services/) ### GOTCHA-016: `createCommunication` with empty `[]` attachments sends JSON not multipart @@ -204,15 +204,15 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** `createCommunication(info, [])` does not exercise the multipart path even though an array was passed. **Root cause:** Branch condition is `attachments && attachments.length > 0`. An empty array is truthy for `&&` but `length > 0` fails, so it falls through to the JSON path. This is the intended behavior but a footgun for tests that assume "array passed ⇒ multipart used". **Fix:** Pass `undefined` (or omit the second argument) when there are no attachments. Don't assert `postFormData` was called on an empty-array fixture. -**Enforced where:** `src/lib/providers/ministry-platform/services/communication.service.ts:22,42`. +**Enforced where:** `src/lib/providers/ministry-platform/services/communication.service.ts:32,55`. **Related:** [mp-provider/services/](mp-provider/services/) ### GOTCHA-017: `getTables('Contacts')` returns multiple tables -**Symptom:** Calling `getTables('Contacts')` returns `Contacts`, `Contact_Addresses`, `Contact_Log`, `Contact_Attributes`, etc. — not just the exact match you wanted. -**Root cause:** The MP REST `$search` parameter is a substring match, not an exact match. -**Fix:** Always post-filter: `tables.find((t) => t.Table_Name === exactName)`. Return `tables.find(...) ?? tables[0]` or raise if no exact match found. -**Enforced where:** `src/services/fieldManagementService.ts:70-71`. +**Symptom:** `getTables(term)` can return more than the one table you asked for (other tables matching the search term), in no guaranteed order. +**Root cause:** The MP REST `$search` parameter is a search, not an exact-name lookup. (A live `$search=Contacts` on 2026-10-11 returned a single entry, but callers must not rely on that.) +**Fix:** Always post-filter on the **`Name`** key (a `/tables` entry has no `Table_Name`): `tables.find((t) => t.Name === exactName) ?? null`. Never fall back to `tables[0]` — that silently uses a different table. +**Enforced where:** `src/services/fieldManagementService.ts:87`. **Related:** [services/](services/) --- @@ -223,16 +223,16 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** User types `O'Brien` into a search box; MP request fails with 400 or — worse — a SQL-like injection token is embedded verbatim into the filter. **Root cause:** MP `$filter` accepts raw OData-style strings. If user input is interpolated without escaping, single quotes close the literal prematurely. -**Fix:** Always escape before embedding: `term.replace(/'/g, "''")`. For LIKE patterns with wildcards also escape `%` and `_`: use `escapeFilterString` from `src/lib/validation.ts:25-30`. Documented in CLAUDE.md practice #11. -**Enforced where:** `src/lib/validation.ts:25-30` (helper), `src/services/toolService.ts:229` (correct inline), `src/services/groupService.ts` passim. +**Fix:** Always escape before embedding: `term.replace(/'/g, "''")`. For LIKE patterns use `escapeFilterString` from `src/lib/validation.ts:127` (bracket-escapes `[`, `%`, `_`, doubles `'`, rejects control chars and terms over `MAX_SEARCH_TERM_LENGTH`). Documented in CLAUDE.md practice #11. +**Enforced where:** `src/lib/validation.ts:127` (helper), `src/services/toolService.ts:254`, `src/services/groupService.ts` passim. **Related:** [services/query-patterns.md](services/query-patterns.md), [dto-constants/](dto-constants/) ### GOTCHA-019: MP filter breaks on non-numeric record ID (NaN interpolation) **Symptom:** MP returns 400 or empty result because the filter reads `WHERE Record_ID = NaN`. **Root cause:** `parseInt('abc', 10)` returns `NaN`; if the service doesn't validate before interpolating into `` `Record_ID = ${id}` ``, `NaN` slips through. -**Fix:** Call `validatePositiveInt(id)` from `src/lib/validation.ts:11-16` before building any `` `ID = ${id}` `` filter. Services like `addressLabelService` batch-validate in loops; copy that pattern. -**Enforced where:** `src/services/addressLabelService.ts:76` (positive pattern), `src/lib/validation.ts:11-16` (helper). +**Fix:** Call `validatePositiveInt(id)` from `src/lib/validation.ts:33` before building any `` `ID = ${id}` `` filter. Services like `addressLabelService` batch-validate in loops; copy that pattern. +**Enforced where:** `src/services/addressLabelService.ts:83` (positive pattern), `src/lib/validation.ts:33` (helper). **Related:** [services/query-patterns.md](services/query-patterns.md) ### GOTCHA-020: Field-management partial save leaves `dp_Page_Fields` inconsistent @@ -240,7 +240,7 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** User edits 20 fields, save fails halfway; some changes are committed and others are not — no rollback. **Root cause:** `FieldManagementService.updatePageFieldOrder` calls `api_MPNextTools_UpdatePageFieldOrder` once per field, 5 at a time, with no transaction wrapper. A failure in batch 3 leaves batches 1-2 committed. **Fix:** Caller should wrap in `try/catch` and surface partial-save state. For true atomicity, rewrite `api_MPNextTools_UpdatePageFieldOrder` to accept a table-valued parameter and commit all rows in a single SP transaction. -**Enforced where:** `src/services/fieldManagementService.ts:74-109`. +**Enforced where:** `src/services/fieldManagementService.ts:105-130` (`updatePageFieldOrder`, `CONCURRENCY = 5`). **Related:** [services/](services/), [components/field-management.md](components/field-management.md) ### GOTCHA-021: Negative `Page_Field_ID` values sent to MP if passed through carelessly @@ -248,7 +248,7 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** MP rejects update with "PK violation" or silently creates a bogus row because a negative `Page_Field_ID` was sent over the wire. **Root cause:** `fetchPageFieldData` synthesizes client-only IDs starting at `-1` and decrementing (`nextId--`) for rows that exist in the table metadata but not yet in `dp_Page_Fields`. These negative IDs are sentinel values for "not yet persisted" and must never be sent back to MP. **Fix:** Never send `Page_Field_ID` to MP. `FieldOrderPayload` intentionally omits it. When you add a new payload type, omit `Page_Field_ID` explicitly. -**Enforced where:** `src/components/field-management/actions.ts:34-42` (negative ID synthesis). +**Enforced where:** `src/components/field-management/actions.ts:61-71` (negative ID synthesis). **Related:** [components/field-management.md](components/field-management.md) ### GOTCHA-022: SkipRecord / FetchAddressLabelsResult indexing returns undefined @@ -284,7 +284,7 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** `npm test -- src/services/toolService.test.ts` passes, but `npm run test:run` fails that same file with bogus mock return values from a previous test file. **Root cause:** The service singleton (`ServiceClass.instance`) is cached across test files. The previous file's mock `MPHelper` is still attached. **Fix:** Reset the singleton in `beforeEach`: `(ServiceClass as any).instance = undefined`. -**Enforced where:** `src/services/toolService.test.ts:19-23` (canonical pattern). +**Enforced where:** `src/services/toolService.test.ts:46-49` (canonical pattern). **Related:** [testing/mocks.md](testing/mocks.md) ### GOTCHA-026: Server action test crashes with `headers(...).get is not a function` @@ -292,7 +292,7 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** Testing a server action: `TypeError: (intermediate value).get is not a function` inside Better Auth. **Root cause:** `next/headers` was mocked with a plain object (`{}`) or `new Map()`. Better Auth internally calls `.get('cookie')` on a `Headers` instance — plain objects don't implement that interface. **Fix:** `vi.mock('next/headers', () => ({ headers: vi.fn().mockResolvedValue(new Headers()) }))`. Use a real `Headers` instance. -**Enforced where:** `src/components/dev-panel/panels/user-tools-actions.test.ts:17-19` (canonical pattern). +**Enforced where:** `src/components/dev-panel/panels/user-tools-actions.test.ts:16-18` (canonical pattern). **Related:** [testing/mocks.md](testing/mocks.md) --- @@ -311,8 +311,8 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** A newly added public route (e.g. `/healthz`) bounces to `/signin` which bounces back. **Root cause:** The proxy's early-return whitelist was not updated for the new path. -**Fix:** Add the path to the early-return branch in `src/proxy.ts:18`. The current whitelist is `pathname.startsWith('/api') || pathname === '/signin'`. -**Enforced where:** `src/proxy.ts:18`. +**Fix:** Add the path to `isPublicPath()` in `src/proxy.ts:26-35`. The current allowlist is `/api/auth`, `/api/auth/*`, `/signin` and `/auth-error` (exact-segment matches; every other `/api/*` route needs a session cookie). +**Enforced where:** `src/proxy.ts:26-35` (`isPublicPath`); `src/proxy.test.ts`. **Related:** [routing/](routing/) ### GOTCHA-029: CI runs `next lint` and fails — command unknown @@ -320,7 +320,7 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** CI fails with "Unknown command: lint" or similar from a script that invokes `next lint`. **Root cause:** Next.js 16 removed `next lint`. This project uses ESLint flat config directly via `eslint .`. **Fix:** Use `npm run lint`. Config is native flat config in `eslint.config.mjs`. -**Enforced where:** `package.json:9` (`"lint": "eslint ."`). +**Enforced where:** `package.json:13` (`"lint": "eslint ."`). **Related:** CLAUDE.md "Next.js 16 Notes" --- @@ -348,15 +348,15 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** Client component crashes on render with `useUser must be used within a UserProvider`. **Root cause:** Consumer was rendered outside the `` subtree, or inside a server component that never reaches `providers.tsx`. **Fix:** Ensure the component is a descendant of ``, which is mounted in `src/app/providers.tsx`. Convert the component to a client component (`"use client"`) if it needs `useUser()`. -**Enforced where:** `src/contexts/user-context.tsx:76-82`. +**Enforced where:** `src/contexts/user-context.tsx:76-82` (`useUser`). **Related:** [contexts/](contexts/) ### GOTCHA-031: `userProfile` stays `null` and `isLoading` never resolves when session has no `userGuid` **Symptom:** Loading spinner spins forever for a user whose session does not have a `userGuid` (e.g. edge-case cookie migration). **Root cause:** The effect gating `loadUserProfile` only runs when `userGuid` is present AND session is settled; the "settled but no guid" branch does not set `isLoading = false` in the initial render. -**Fix:** Gate UI on `isLoading` only when a `userGuid` is expected. Handle the missing case explicitly in consumer components. See `user-context.tsx:51-58`. -**Enforced where:** `src/contexts/user-context.tsx:51-58`. +**Fix:** Gate UI on `isLoading` only when a `userGuid` is expected. Handle the missing case explicitly in consumer components. See `user-context.tsx:52-60`. (Inside `(web)` this state is unreachable in practice: `AuthWrapper` redirects a session with no `userGuid` to `/session-error`.) +**Enforced where:** `src/contexts/user-context.tsx:52-60`, `src/components/layout/auth-wrapper.tsx:24-27`. **Related:** [contexts/](contexts/) ### GOTCHA-032: `AuthWrapper` silently stops authenticating after adding `"use client"` @@ -364,7 +364,7 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** You add `"use client"` to `auth-wrapper.tsx` for a quick fix; the app compiles but every user is treated as signed in (or the route is never protected). **Root cause:** `AuthWrapper` must be a server component — it calls `await headers()` and `await auth.api.getSession({ headers })`. These are server-only APIs. Adding `"use client"` silently no-ops the redirect. **Fix:** Keep `auth-wrapper.tsx` as a server component. Move any interactive state into a child client component. -**Enforced where:** `src/components/layout/auth-wrapper.tsx:1-20` (no `"use client"`). +**Enforced where:** `src/components/layout/auth-wrapper.tsx` (no `"use client"`). **Related:** [components/layout.md](components/layout.md), [auth/](auth/) ### GOTCHA-033: Dev overlay unexpectedly appears in production @@ -372,7 +372,7 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** The amber dev-panel banner shows up in a deployed environment. **Root cause:** The self-gating lives inside `DevPanel` (`process.env.NODE_ENV === "development"` AND hostname === localhost) — not in `ToolContainer`. `ToolContainer` always renders `` when `params` is provided. **Fix:** Trust the DevPanel self-gate. If you need to hide the overlay locally (e.g. for a screenshot), drop the `params` prop from `ToolContainer`. Do NOT remove the self-gate. -**Enforced where:** `src/components/dev-panel/dev-panel.tsx:20-22,56-58`. +**Enforced where:** `src/components/dev-panel/dev-panel.tsx:14-22,59-61` (`isLocalhost`, `isDevBuild`). **Related:** [components/dev-panel.md](components/dev-panel.md) ### GOTCHA-034: Dev-panel server actions callable in production despite UI hidden @@ -388,7 +388,7 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** The dev panel's user-tools check shows a red "NOT AUTHORIZED" banner even though the user can obviously access the tool in prod. **Root cause:** `NEXT_PUBLIC_PROD_URL` is unset (or has a trailing slash mismatch). The check is `path.includes(prodToolUrl)` where `prodToolUrl = ${prodBase}${pathname}`; if `prodBase` is empty, the match can never succeed. **Fix:** Set `NEXT_PUBLIC_PROD_URL` in `.env.local` to the deployed tool base URL (no trailing slash). The component already renders a helpful amber banner when unset — check for that banner instead of the red one. -**Enforced where:** `src/components/dev-panel/panels/user-tools-panel.tsx:38-42,112-133` (handles the unset case explicitly). +**Enforced where:** `src/components/dev-panel/panels/user-tools-panel.tsx:41-44,115` (handles the unset case explicitly). **Related:** [components/dev-panel.md](components/dev-panel.md) ### GOTCHA-036: Edit-mode Review screen shows "ID: <n>" for Primary Contact / Parent Group @@ -403,7 +403,7 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs **Symptom:** You rename the field in the Zod schema and the form compiles, but the Review step or form submission silently drops the value. **Root cause:** The field name contains a hyphen and is referenced as a quoted string literal (`'Secure_Check-in'`) across schema, `STEP_FIELDS`, `SwitchRow`, and `step-review`. A refactor tool that only renames symbols will miss the string literals. -**Fix:** Grep-search for `Secure_Check-in` across `src/components/group-wizard` and rename at all five sites. TypeScript will not help you here — the key is a string literal, not a symbol. +**Fix:** Grep-search for `Secure_Check-in` across `src/components/group-wizard` and rename at every site (`schema.ts` declaration, `STEP_FIELDS`, defaults; `step-settings.tsx`; two reads in `step-review.tsx`). TypeScript will not help you here — the key is a string literal, not a symbol. **Enforced where:** `src/components/group-wizard/schema.ts:43` (declaration). **Related:** [components/group-wizard.md](components/group-wizard.md) @@ -438,7 +438,7 @@ Retired 2026-10-10: applied only to the template editor's merge tokens; the edit ### GOTCHA-040: IMb barcode silently becomes POSTNET (or disappears) with no log **Symptom:** User configures IMb with a Mailer ID, prints labels, and sees POSTNET (or no barcode) on the output. -**Root cause:** `preEncodeBarcodes` wraps `imbEncode` in a bare `try {} catch {}` with no logging, falling through to POSTNET. Also, if `config.mailerId` is empty (`''`), the `config.barcodeFormat === 'imb' && config.mailerId` guard skips IMb entirely and falls through silently. +**Root cause:** `preEncodeBarcodes` falls back to POSTNET when `imbEncode` throws (originally inside a bare `try {} catch {}` with no logging — now logged, see Fix). Also, if `config.mailerId` is empty (`''`), the `config.barcodeFormat === 'imb' && config.mailerId` guard skips IMb entirely and falls through silently. **Fix:** (a) Users: enter a valid 6- or 9-digit USPS Mailer ID. (b) Code: fixed — the fallback is logged (`console.warn`) and the Mailer ID is validated. **Enforced where:** `src/lib/barcode-helpers.ts` (logged fallback). Log payload is `{ labelIndex, error: }` — no label name, address, or routing digits (rule 14) — so locate the row by its index in the request. **Related:** [utils/](utils/), [components/address-labels.md](components/address-labels.md) @@ -454,9 +454,9 @@ Retired 2026-10-10: applied only to the template editor's merge tokens; the edit ### GOTCHA-042: Tool page crashes when it assumes `params.pageData` is present **Symptom:** Tool route throws "Cannot read properties of undefined" on `params.pageData.Table_Name` or similar. -**Root cause:** `parseToolParams` catches errors from `ToolService.getPageData` and leaves `pageData` as `undefined`. The SP `api_Tools_GetPageData` may not exist yet in the target MP instance (dev/new installs). +**Root cause:** `parseToolParams` (`src/lib/tool-params.server.ts`) catches errors from `ToolService.getPageData` and leaves `pageData` as `undefined`. The SP `api_Tools_GetPageData` may not exist yet in the target MP instance (dev/new installs). **Fix:** Always treat `pageData` as optional at consumption sites: guard with `if (params.pageData) { ... }`. Never destructure it without a null check. -**Enforced where:** `src/lib/tool-params.ts:54-61` (catch + undefined assignment). +**Enforced where:** `src/lib/tool-params.server.ts:65-76` (catch + undefined assignment). **Related:** [utils/](utils/) ### GOTCHA-043: `pageID` / `recordID` values of `NaN` appear in downstream state diff --git a/.claude/references/INDEX.md b/.claude/references/INDEX.md index 6436a51..a53bcf0 100644 --- a/.claude/references/INDEX.md +++ b/.claude/references/INDEX.md @@ -1,7 +1,7 @@ --- title: Reference Index type: index -last_updated: 2026-04-17 +last_updated: 2026-10-10 --- # Reference Index @@ -13,6 +13,7 @@ Agent-facing navigation map for MPNext-Tools. For each question pattern, find th | If you need to... | Read | |---|---| | Understand OAuth, sessions, or route protection | [auth/README.md](auth/README.md) | +| Check who may use a tool, the endpoint surface, CSP/headers, or security findings | [security/README.md](security/README.md) | | Distinguish `session.user.id` from `userGuid` | [auth/user-identity.md](auth/user-identity.md) | | Trace the MP provider / API client stack | [mp-provider/README.md](mp-provider/README.md) | | Look up an MP service (table, procedure, file, etc.) | [mp-provider/services/](mp-provider/services/) | @@ -20,6 +21,7 @@ Agent-facing navigation map for MPNext-Tools. For each question pattern, find th | See which MP tables/FKs the app actually touches | [mp-schema/data-model-map.md](mp-schema/data-model-map.md) | | Understand an app service's role or query patterns | [services/README.md](services/README.md) | | Avoid MP query pitfalls (ambiguous columns, FK traversal, escaping) | [services/query-patterns.md](services/query-patterns.md) | +| Send or read an MP date/datetime (`DomainTimezoneService`) | [ministryplatform.datetimehandling.md](ministryplatform.datetimehandling.md) | | Find a component's location or role | [components/README.md](components/README.md) | | Understand the Tool framework (ToolContainer, header, footer) | [components/tool-framework.md](components/tool-framework.md) | | Understand a React context (UserProvider, useAppSession) | [contexts/README.md](contexts/README.md) | @@ -30,6 +32,9 @@ Agent-facing navigation map for MPNext-Tools. For each question pattern, find th | Set up Vitest or fix a mock pattern | [testing/setup.md](testing/setup.md), [testing/mocks.md](testing/mocks.md) | | Find a DTO or shared constant | [dto-constants/README.md](dto-constants/README.md) | | Look up barcode, label-stock, or tool-param utilities | [utils/README.md](utils/README.md) | +| Upgrade dependencies / check what is held back | [../packages/README.md](../packages/README.md) (newest dated file), [../commands/update-deps.md](../commands/update-deps.md) | +| Scaffold a tool or stored proc, or cut a release | [../commands/](../commands/) (`newtool`, `newstoredproc`, `release`, `update-deps`) | +| Port a past fix into a derived repo | [../playbooks/](../playbooks/) | | Look up a domain term | [GLOSSARY.md](GLOSSARY.md) | | Understand why a design choice was made | [DECISIONS.md](DECISIONS.md) | | Avoid a known trap | [GOTCHAS.md](GOTCHAS.md) | @@ -42,24 +47,27 @@ Agent-facing navigation map for MPNext-Tools. For each question pattern, find th | **auth** | [auth/README.md](auth/README.md) | oauth-flow, sessions, route-protection, user-identity | | **mp-provider** | [mp-provider/README.md](mp-provider/README.md) | architecture, client, type-generation, error-handling + 6 services/ | | **mp-schema** | [mp-schema/README.md](mp-schema/README.md) | tables, stored-procs, required-procs, data-model-map | -| **services** | [services/README.md](services/README.md) | query-patterns + 5 per-service docs | -| **components** | [components/README.md](components/README.md) | tool-framework, layout, ui + 6 feature docs | +| **services** | [services/README.md](services/README.md) | query-patterns + 6 per-service docs | +| **components** | [components/README.md](components/README.md) | tool-framework, layout, ui + 4 feature docs (address-labels, dev-panel, field-management, group-wizard) | | **contexts** | [contexts/README.md](contexts/README.md) | user-provider, session | | **routing** | [routing/README.md](routing/README.md) | app-router, proxy | | **data-flow** | [data-flow/README.md](data-flow/README.md) | call-graphs, error-catalog | | **testing** | [testing/README.md](testing/README.md) | setup, mocks, cookbook, inventory | | **dto-constants** | [dto-constants/README.md](dto-constants/README.md) | dtos, constants | | **utils** | [utils/README.md](utils/README.md) | barcodes, label-stock, tool-params | +| **security** | [security/README.md](security/README.md) | single file: authorization policy, endpoint surface, headers/CSP, findings | ## Cross-cutting -- [GLOSSARY.md](GLOSSARY.md) — 52 domain terms (alphabetized, one-line definitions) -- [DECISIONS.md](DECISIONS.md) — 26 architectural decisions (ADRs) grouped by domain -- [GOTCHAS.md](GOTCHAS.md) — 43 symptom-first traps with fixes +- [GLOSSARY.md](GLOSSARY.md) — 50 domain terms (alphabetized, one-line definitions) +- [DECISIONS.md](DECISIONS.md) — 27 architectural decisions (ADR-001–027; ADR-023 superseded) grouped by domain +- [GOTCHAS.md](GOTCHAS.md) — 47 symptom-first traps (GOTCHA-001–047; 038/039 retired) with fixes +- [ministryplatform.datetimehandling.md](ministryplatform.datetimehandling.md) — MP date/time boundary rules (`DomainTimezoneService`) +- [../../.github/SECURITY.md](../../.github/SECURITY.md) — human-facing security policy (vulnerability reporting); agent detail lives in [security/README.md](security/README.md) ## Open TODOs -- [`../TODO/INDEX.md`](../TODO/INDEX.md) — 39 open TODOs by severity / tag / area (critical → low) +- [`../TODO/INDEX.md`](../TODO/INDEX.md) — open TODOs by severity / tag / area (open work only; closed items are deleted) - [`../TODO/SCHEMA.md`](../TODO/SCHEMA.md) — required format for new TODO files ## Meta @@ -68,7 +76,7 @@ Agent-facing navigation map for MPNext-Tools. For each question pattern, find th - [`_meta/conventions.md`](_meta/conventions.md) — writing rules for editing these docs - [`_meta/verification-rules.md`](_meta/verification-rules.md) — how to verify claims - [`_meta/schemas/`](_meta/schemas/) — templates for every doc type -- [`_meta/facts/`](_meta/facts/) — frozen facts snapshots per review date (baseline: `2026-04-17.md` at SHA `971c40b`) +- [`_meta/facts/`](_meta/facts/) — frozen facts snapshots per review date (baseline: `2026-04-17.md` at SHA `971c40b`; read the newest file for current counts) ## How to maintain diff --git a/.claude/references/_meta/conventions.md b/.claude/references/_meta/conventions.md index 3d048c6..607cc24 100644 --- a/.claude/references/_meta/conventions.md +++ b/.claude/references/_meta/conventions.md @@ -8,7 +8,7 @@ Agent-only. Human readers use `README.md` at the project root. ## Writing rules - **Terse.** Bullets, tables, frontmatter, code blocks. No narrative prose. No "this document explains..." openings. -- **Verify every factual claim.** Grep/Read the source or use context7 for framework API claims. Never trust training data for Next.js 16 / React 19 / Zod v4 / Better Auth 1.5 / Vitest 4. +- **Verify every factual claim.** Grep/Read the source or use context7 for framework API claims. Never trust training data for Next.js 16 / React 19 / Zod v4 / Better Auth 1.7 / Vitest 5. - **Copy-paste examples from real source.** Do NOT invent code. Pull from actual `src/**` files. - **Link, don't repeat.** If another doc covers it, link. - **One responsibility per file.** If a doc is doing two things, split it. diff --git a/.claude/references/_meta/facts/2026-10-10.md b/.claude/references/_meta/facts/2026-10-10.md new file mode 100644 index 0000000..b71ce0a --- /dev/null +++ b/.claude/references/_meta/facts/2026-10-10.md @@ -0,0 +1,76 @@ +--- +title: Facts Snapshot 2026-10-10 +type: meta +git_sha: 61566687ed38c3dd8011a2db47f26b9ab496ad40 +--- + +## Purpose +Authoritative counts for the full documentation review on branch +`docs/full-doc-review`. Supersedes `2026-09-13.md` (kept as history; do not +edit). All figures below were measured, not carried forward. + +## Git baseline +- **SHA:** `61566687ed38c3dd8011a2db47f26b9ab496ad40` (`feat(no-access): sign-out form for users with no MP security role (#55)`) +- **Branch:** `docs/full-doc-review` (off `dev`) +- **Created:** 2026-10-10 +- Changes since `2026-09-13.md`: GrapesJS `template-editor` removed (#40), header `UserMenu` removed and `handleSignOut` moved to `src/components/shared-actions/sign-out.ts` (#54), `/no-access` sign-out form (#55). + +## Counts (authoritative) + +- **Test files:** 118 +- **Test cases:** 2,185 (from `npx vitest run`, all passing) +- **Addendum 2026-10-11** (after #57–#60): 120 files / 2,237 cases; the per-area table in `testing/inventory.md` is current. +- **Vitest:** 5.0.3 installed (`vitest` / `@vitest/coverage-v8` `^5.0.0` in `package.json`); jsdom 30.1.2 +- **Source files in the coverage denominator:** 118 (3,910 statements) + +### Coverage (from `vitest run --coverage`) + +| Metric | Result | +|---|---| +| Statements | **99.13%** (3,876/3,910) | +| Lines | **99.80%** (3,581/3,588) | +| Branches | 93.32% (2,490/2,668) | +| Functions | 99.56% (916/920) | + +Enforced thresholds (`vitest.config.mts`): statements 97, lines 98. Branches +and functions are deliberately unenforced. + +### Test files by area + +| Area | Files | Cases | +|---|---|---| +| `src/app/**` | 22 | 406 | +| `src/` root (`auth.*` ×10, `proxy`) | 11 | 250 | +| `src/components/address-labels` | 11 | 126 | +| `src/components/dev-panel` | 11 | 102 | +| `src/components/field-management` | 7 | 113 | +| `src/components/group-wizard` | 12 | 159 | +| `src/components/{layout,shared-actions,tool}` | 5 | 44 | +| `src/contexts` | 2 | 13 | +| `src/lib/providers/ministry-platform` | 14 | 481 | +| `src/lib/**` (excl. MP provider) | 14 | 255 | +| `src/services` | 9 | 236 | +| **Total** | **118** | **2,185** | + +Per-file list: `../../testing/inventory.md`. + +## Components (`src/components/`) + +- **Feature folders (4):** `address-labels` (24 files), `dev-panel` (3 + `panels/` 20), `field-management` (16), `group-wizard` (26) +- **Framework folders (4):** `layout` (3), `shared-actions` (7 incl. `README.md`), `tool` (5), `ui` (22) +- **shadcn/ui primitives (22):** `alert`, `alert-dialog`, `avatar`, `badge`, `breadcrumb`, `button`, `card`, `checkbox`, `command`, `dialog`, `drawer`, `dropdown-menu`, `form`, `input`, `label`, `popover`, `radio-group`, `select`, `skeleton`, `switch`, `textarea`, `tooltip` +- **Shared actions (3):** `getCurrentUserProfile` (`user.ts`), `getMpTimezone` (`domain.ts`), `handleSignOut` (`sign-out.ts`) +- No `user-menu/` or `template-editor/` folder exists any more. + +## Tool routes (`src/app/(web)/tools/`) +`addeditfamily`, `addresslabels`, `fieldmanagement`, `groupwizard`, `template` + +## Contexts (`src/contexts/`) +`user-context.tsx` (`UserProvider`, `useUser`), `use-app-session.ts` +(`useAppSession`, `SessionData`), `index.ts` barrel. Neither hook has a +consumer outside `src/contexts/` at this SHA. + +## Services (9) +`addressLabelService`, `authorizationService`, `domainTimezoneService`, +`familyService`, `fieldManagementService`, `googlePlacesService`, +`groupService`, `toolService`, `userService` diff --git a/.claude/references/_meta/schemas/adr-entry.md b/.claude/references/_meta/schemas/adr-entry.md index 969a95e..c17a0c0 100644 --- a/.claude/references/_meta/schemas/adr-entry.md +++ b/.claude/references/_meta/schemas/adr-entry.md @@ -3,13 +3,13 @@ title: ADR Entry Template type: meta --- -Copy this block into `DECISIONS.md`; one section per decision. +Copy this block into `DECISIONS.md` under the matching `## ` heading (and add a TOC line); one section per decision. Never renumber or delete an ADR — supersede it, or append an `**Amended YYYY-MM-DD:**` line. ```markdown -## ADR-NNN: +### ADR-NNN: <title> **Date:** YYYY-MM-DD -**Status:** Accepted | Superseded by ADR-XXX +**Status:** Accepted | Superseded by ADR-XXX | Superseded (YYYY-MM-DD) — <reason> **Context:** why the question came up **Decision:** what we chose **Consequences:** tradeoffs / downstream constraints diff --git a/.claude/references/_meta/schemas/gotcha-entry.md b/.claude/references/_meta/schemas/gotcha-entry.md index 4865463..9176f6b 100644 --- a/.claude/references/_meta/schemas/gotcha-entry.md +++ b/.claude/references/_meta/schemas/gotcha-entry.md @@ -3,10 +3,10 @@ title: GOTCHA Entry Template type: meta --- -Copy this block into `GOTCHAS.md`; one section per trap. +Copy this block into `GOTCHAS.md` under the matching `## <Area>` heading (and add a TOC line); one section per trap. IDs are never reused — a dead entry becomes `### GOTCHA-NNN: _Retired_` with a one-line reason. ```markdown -## GOTCHA-NNN: <symptom-focused title> +### GOTCHA-NNN: <symptom-focused title> **Symptom:** what goes wrong **Root cause:** underlying reason diff --git a/.claude/references/_meta/schemas/reference-doc.md b/.claude/references/_meta/schemas/reference-doc.md index 765531e..ec4adee 100644 --- a/.claude/references/_meta/schemas/reference-doc.md +++ b/.claude/references/_meta/schemas/reference-doc.md @@ -8,7 +8,7 @@ Copy this schema when writing any reference doc under a domain subfolder (e.g., ```markdown --- title: <concise title> -domain: auth | mp-provider | services | components | contexts | routing | data-flow | testing | dto-constants | utils +domain: auth | mp-provider | mp-schema | services | components | contexts | routing | data-flow | security | testing | dto-constants | utils type: reference applies_to: [src/path/to/file.ts] symbols: [exportName1, exportName2] diff --git a/.claude/references/_meta/schemas/todo.md b/.claude/references/_meta/schemas/todo.md index b07492d..d861e53 100644 --- a/.claude/references/_meta/schemas/todo.md +++ b/.claude/references/_meta/schemas/todo.md @@ -10,7 +10,7 @@ Used for files under `.claude/TODO/YYYY-MM-DD-<slug>.md`. See `.claude/TODO/SCHE title: <actionable title> severity: critical | high | medium | low tags: [security | bug | drift | missing-test | refactor | doc | perf] -area: auth | mp-provider | services | components | contexts | routing | data-flow | testing | dto-constants | utils | commands +area: auth | mp-provider | mp-schema | services | components | contexts | routing | data-flow | security | testing | dto-constants | utils | commands files: [src/path/to/file.ts] discovered: YYYY-MM-DD discovered_by: <shard-name> diff --git a/.claude/references/_meta/verification-rules.md b/.claude/references/_meta/verification-rules.md index 12feee6..cf680a4 100644 --- a/.claude/references/_meta/verification-rules.md +++ b/.claude/references/_meta/verification-rules.md @@ -14,7 +14,7 @@ Applies to every doc under `.claude/references/**/*.md`. | Line number ref | `Read` with offset — content matches | | Count (e.g., "22 components", "507 tests") | Generate current count via `Glob`/`Grep` — fix inline if drift; drop TODO if structural | | Version (package version) | `Read` `package.json` — match | -| Framework API claim (Next.js 16, React 19, Zod v4, Better Auth 1.5, Vitest 4) | **context7 `query-docs`** — do not trust training | +| Framework API claim (Next.js 16, React 19, Zod v4, Better Auth 1.7, Vitest 5) | **context7 `query-docs`** — do not trust training | | Behavior claim | `Read` referenced source — confirm the claim holds | | Cross-ref to another doc | Target doc exists at that path | diff --git a/.claude/references/auth/README.md b/.claude/references/auth/README.md index 58a44c5..8832add 100644 --- a/.claude/references/auth/README.md +++ b/.claude/references/auth/README.md @@ -21,17 +21,18 @@ Better Auth (`1.7.x`) wired to Ministry Platform OIDC via the `genericOAuth` plu | `src/lib/auth.ts` | Server Better Auth config (plugins: `genericOAuth`, `customSession`, `nextCookies`) | | `src/lib/auth-client.ts` | Client `authClient` (`customSessionClient`) | | `src/lib/env.ts` | `getMpBaseUrl()`, `getAuthBaseUrl()`, `getMpClient()` — validated env readers | -| `src/app/api/auth/[...all]/route.ts` | Route handler (`toNextJsHandler(auth)`) | -| `src/app/signin/page.tsx` | Sign-in page — auto-redirects via `authClient.signIn.social({ provider: "ministryplatform" })` | +| `src/app/api/auth/[...all]/route.ts` | Route handler — deny-by-default allowlist + `/sign-in/social` body filter in front of `toNextJsHandler(auth)` | +| `src/app/signin/page.tsx` + `sign-in-content.tsx` | Sign-in page (server shell, `force-dynamic`) — the client body auto-redirects via `authClient.signIn.social({ provider: "ministryplatform", callbackURL })` | | `src/proxy.ts` | Route protection (session-cookie presence check + `x-pathname` forwarding) | | `src/components/layout/auth-wrapper.tsx` | Server-component guard — redirects to `/signin` with `callbackUrl` | | `src/components/shared-actions/sign-out.ts` | `handleSignOut()` — clears the session, then `endsession` with `client_id` (+ `id_token_hint` when this process has it) | | `src/contexts/user-context.tsx` | `UserProvider` — loads MP profile client-side from `userGuid` | -| `src/contexts/session-context.tsx` | `useAppSession()` — thin wrapper around `authClient.useSession()` | +| `src/contexts/use-app-session.ts` | `useAppSession()` — thin wrapper around `authClient.useSession()` (returns `data`) | | `src/auth.test.ts` | Config pins, `customSession`, `mapProfileToUser` | | `src/auth.user-info.test.ts` | `getUserInfo` with genuinely signed id_tokens (verification, alg confusion, sub binding) | | `src/auth.oidc-discovery.test.ts` | MP down/hanging at boot and at the callback; lazy verifier units | | `src/auth.id-token-sign-in.test.ts` / `src/auth.shared-instance.test.ts` | F12 layers / one instance per process | +| `src/auth.secret-guard.test.ts`, `src/auth.session-config.test.ts`, `src/auth.session-lifetime.test.ts`, `src/auth.user-oauth-tokens.test.ts`, `src/auth.ip-address.test.ts` | Boot refusals, cookie-cache config, 12 h cap, token stripping, client-IP headers | | `src/test-utils/mock-oidc.ts` | Mock MP OIDC provider (per-endpoint failure modes, `signIdToken`) | | `src/proxy.test.ts` | Proxy route-protection tests | | `src/components/layout/auth-wrapper.test.tsx` | `AuthWrapper` redirect tests | diff --git a/.claude/references/auth/oauth-flow.md b/.claude/references/auth/oauth-flow.md index 8becae6..d426be3 100644 --- a/.claude/references/auth/oauth-flow.md +++ b/.claude/references/auth/oauth-flow.md @@ -2,7 +2,7 @@ title: OAuth Flow (genericOAuth, lazy id_token verification, OIDC endsession) domain: auth type: reference -applies_to: [src/lib/auth.ts, src/lib/env.ts, src/app/signin/page.tsx, src/app/api/auth/[...all]/route.ts, src/components/shared-actions/sign-out.ts, src/app/session-error/page.tsx] +applies_to: [src/lib/auth.ts, src/lib/env.ts, src/app/signin/sign-in-content.tsx, src/app/api/auth/[...all]/route.ts, src/components/shared-actions/sign-out.ts, src/app/session-error/page.tsx] symbols: [auth, ministryPlatformProviderConfig, lazyIdTokenVerifier, sharedInstance, getMpClient, handleSignOut] related: [sessions.md, user-identity.md, route-protection.md, ../security/README.md] last_verified: 2026-10-10 @@ -17,7 +17,7 @@ id_token verification, profile mapping, and RP-initiated logout via `endsession` - `src/lib/env.ts` — `getMpBaseUrl()`, `getAuthBaseUrl()`, `getMpClient()` (validated env readers) - `src/app/signin/sign-in-content.tsx` — calls `authClient.signIn.social({ provider: "ministryplatform", callbackURL })` - `src/app/api/auth/[...all]/route.ts` — deny-by-default allowlist in front of `toNextJsHandler(auth)` -- `src/components/shared-actions/sign-out.ts` — `handleSignOut()` server action (called by the sign-out forms on `/no-access` and `/session-error`) +- `src/components/shared-actions/sign-out.ts` — `handleSignOut()` server action (the `<form action>` on `/no-access` and `/session-error`) - Tests: `src/auth.oidc-discovery.test.ts` (outages, lazy discovery), `src/auth.user-info.test.ts` (`getUserInfo` with signed tokens), `src/auth.id-token-sign-in.test.ts` (F12), `src/auth.shared-instance.test.ts`, `src/auth.test.ts` (config pins), `src/components/shared-actions/sign-out.test.ts`; mock MP in `src/test-utils/mock-oidc.ts` ## Key concepts @@ -73,11 +73,11 @@ and the next sign-in recovers on the same process. `PROVIDER_NOT_FOUND` / 2. The `id_token_hint` is taken from that URL **only if its origin is the MP origin**. 3. The end-session URL is rebuilt: - `${MP}/oauth/connect/endsession?post_logout_redirect_uri=<BETTER_AUTH_URL>&client_id=<OIDC client>[&id_token_hint=…]` + `${MP}/oauth/connect/endsession?post_logout_redirect_uri=<BETTER_AUTH_URL>&client_id=<MINISTRY_PLATFORM_CLIENT_ID>[&id_token_hint=…]` — `post_logout_redirect_uri` exactly as registered (better-auth would add a trailing `/` and drop `client_id` when it has a hint). 4. `redirect()` — a `NEXT_REDIRECT` throw. Invoke it as a `<form action>` (as - `/session-error` does) so Next handles the redirect. A future client caller + `/no-access` and `/session-error` do) so Next handles the redirect. A future client caller that `await`s it in try/catch must call `unstable_rethrow(err)` **first**, or it swallows the navigation as an "error". diff --git a/.claude/references/auth/route-protection.md b/.claude/references/auth/route-protection.md index 248e8ca..d042d24 100644 --- a/.claude/references/auth/route-protection.md +++ b/.claude/references/auth/route-protection.md @@ -3,13 +3,13 @@ title: Route Protection (proxy + AuthWrapper) domain: auth type: reference applies_to: [src/proxy.ts, src/components/layout/auth-wrapper.tsx, src/app/(web)/layout.tsx] -symbols: [proxy, config, AuthWrapper] -related: [sessions.md, oauth-flow.md] -last_verified: 2026-07-09 +symbols: [proxy, config, isPublicPath, AuthWrapper] +related: [sessions.md, oauth-flow.md, ../routing/proxy.md] +last_verified: 2026-10-10 --- ## Purpose -Dual-layer route protection: edge-level cookie presence check in `src/proxy.ts` (Next.js 16 proxy, formerly middleware), plus a server-component `AuthWrapper` that verifies the decoded session and preserves the original URL as `callbackUrl` through the OAuth round-trip. +Dual-layer route protection: a cookie presence check in `src/proxy.ts` (Next.js 16 proxy, formerly middleware), plus a server-component `AuthWrapper` that verifies the decoded session and preserves the original URL as `callbackUrl` through the OAuth round-trip. ## Files - `src/proxy.ts` — proxy function (exported as `proxy`, not `middleware`) + matcher @@ -18,61 +18,40 @@ Dual-layer route protection: edge-level cookie presence check in `src/proxy.ts` - `src/components/layout/auth-wrapper.test.tsx` — `AuthWrapper` redirect/callbackUrl tests - `src/app/(web)/layout.tsx` — mounts `<AuthWrapper>` around authenticated pages - `src/app/session-error/page.tsx` — recovery page for broken (no-`userGuid`) sessions; **outside** `(web)` +- `src/app/(web)/no-access/page.tsx` — where `ToolsLayout` sends signed-in users with no MP security role; has a sign-out form ## Key concepts - **Next.js 16 renamed `middleware.ts` → `proxy.ts`** and `middleware()` → `proxy()`. The matcher is still exported as `config`. -- **Layer 1 (proxy):** `getSessionCookie()` from `better-auth/cookies` — **cookie-presence check only**, no JWT decode, no DB (there's no DB). Fast path gate at the edge. +- **Layer 1 (proxy):** `getSessionCookie()` from `better-auth/cookies` — **cookie-presence check only**, no cookie decryption, no DB (there's no DB). Runs on the Node.js runtime (Next 16 `proxy` cannot run on edge). - **Layer 2 (`AuthWrapper`):** `auth.api.getSession({ headers })` in a server component — decodes the cookie, validates session, redirects to `/signin` with `callbackUrl` if invalid/missing. It **also** redirects a session that exists but has no `userGuid` to `/session-error` (see "Broken-session recovery" below). - **`x-pathname` forwarding:** proxy copies `pathname + search` into a request header. `AuthWrapper` reads it via `headers()` to build an accurate `callbackUrl` even when the proxy itself lets the request through (valid cookie, decoded session invalid). -- **Public paths (proxy bypass):** `/api/*` (Better Auth endpoints) and `/signin`. Static (`_next/static`, `_next/image`, `favicon.ico`, `assets/`) are excluded via the matcher. +- **Public paths (proxy bypass):** `/api/auth` + `/api/auth/*` (Better Auth endpoints — every other `/api/*` is gated), `/signin` and `/auth-error`. Static (`_next/static/`, `_next/image`, `favicon.ico`, `assets/`) are excluded via the anchored matcher. -## Proxy function (verbatim, `src/proxy.ts`) +## Proxy function (`src/proxy.ts`) -```typescript -// src/proxy.ts:4-42 -export async function proxy(request: NextRequest) { - const { pathname } = request.nextUrl; - const fullPath = pathname + request.nextUrl.search; - - // Forward the original URL (pathname + search) to downstream server - // components via a request header, so AuthWrapper (and anything else - // that needs to redirect to /signin) can build a correct callbackUrl - // even when the proxy itself lets the request through. - const forwardedHeaders = new Headers(request.headers); - forwardedHeaders.set('x-pathname', fullPath); - const passThrough = () => - NextResponse.next({ request: { headers: forwardedHeaders } }); - - // Early returns for public paths - if (pathname.startsWith('/api') || pathname === '/signin') { - console.log(`Proxy: Allowing public path ${pathname}`); - return passThrough(); - } - - try { - const sessionCookie = getSessionCookie(request); - - if (!sessionCookie) { - console.log("Proxy: Redirecting to signin - no session cookie"); - const signinUrl = new URL('/signin', request.url); - signinUrl.searchParams.set('callbackUrl', fullPath); - return NextResponse.redirect(signinUrl); - } +The full annotated source (abridged, CSP construction elided) lives in +[`../routing/proxy.md`](../routing/proxy.md#api--interface). The gate itself: - console.log(`Proxy: Allowing request to ${pathname}`); - return passThrough(); - - } catch (error) { - console.error('Proxy: Error checking session:', error); - const signinUrl = new URL('/signin', request.url); - signinUrl.searchParams.set('callbackUrl', fullPath); - return NextResponse.redirect(signinUrl); - } +```typescript +// src/proxy.ts +function isPublicPath(pathname: string): boolean { + return ( + pathname === '/api/auth' || + pathname.startsWith('/api/auth/') || + pathname === '/signin' || + pathname === '/auth-error' + ); } +// inside proxy(): +if (isPublicPath(pathname)) return passThrough(); // x-pathname + CSP forwarded +const sessionCookie = getSessionCookie(request); // try/catch → redirectToSignin() +if (!sessionCookie) return redirectToSignin(); // /signin?callbackUrl=<path+search> +return passThrough(); + export const config = { matcher: [ - '/((?!_next/static|_next/image|favicon.ico|assets/).*)', + '/((?!_next/static/|_next/image$|favicon\\.ico$|assets/).*)', ], }; ``` @@ -95,9 +74,8 @@ export async function AuthWrapper({ children }: { children: React.ReactNode }) { redirect(`${signinUrl.pathname}${signinUrl.search}`); } - // A session without a userGuid is unusable: every MP lookup keys off userGuid, - // and without it the header avatar/menu never renders — leaving the user with - // no way to sign out. Route these broken sessions to a recovery page instead. + // A session without a userGuid is unusable: every MP lookup keys off userGuid. + // Route these broken sessions to a recovery page that CAN sign them out. const userGuid = (session.user as { userGuid?: string | null }).userGuid; if (!userGuid) { redirect("/session-error"); @@ -110,7 +88,7 @@ export async function AuthWrapper({ children }: { children: React.ReactNode }) { Mounted via: ```typescript -// src/app/(web)/layout.tsx:34-42 +// src/app/(web)/layout.tsx:34-42 (inside WebLayout) <AuthWrapper> <Providers> <div className={...}> @@ -126,11 +104,12 @@ Mounted via: | Path pattern | Proxy behavior | AuthWrapper behavior | |---|---|---| -| `/api/*` | pass through (public) | not mounted (no `(web)` layout) | -| `/signin` | pass through (public) | not mounted | +| `/api/auth`, `/api/auth/*` | pass through (public; route itself is allowlisted) | not mounted (no `(web)` layout) | +| other `/api/*` | redirect to `/signin` if no cookie | not mounted | +| `/signin`, `/auth-error` | pass through (public) | not mounted | | `/session-error` | pass through if cookie present; else → `/signin` | not mounted (outside `(web)`) — no redirect loop | -| `/_next/static/*`, `/_next/image/*`, `/favicon.ico`, `/assets/*` | excluded by matcher | not mounted | -| `/tools/...`, `/home`, etc. | redirect to `/signin?callbackUrl=<path>` if no cookie | `/signin` if session missing; `/session-error` if session present but no `userGuid` | +| `/_next/static/*`, `/_next/image`, `/favicon.ico`, `/assets/*` | excluded by matcher | not mounted | +| `/`, `/no-access`, `/tools/...` | redirect to `/signin?callbackUrl=<path>` if no cookie | `/signin` if session missing; `/session-error` if session present but no `userGuid` | ## `callbackUrl` preservation end-to-end @@ -142,17 +121,17 @@ Mounted via: 6. `AuthWrapper` confirms decoded session — if ever absent, falls back to `/signin?callbackUrl=<x-pathname value or "/">`. Tested: -- `src/proxy.test.ts:98-107` — proxy attaches `callbackUrl` with query string preserved -- `src/components/layout/auth-wrapper.test.tsx:51-94` — `AuthWrapper` reads `x-pathname` and falls back to `/` +- `src/proxy.test.ts:150-159` — proxy attaches `callbackUrl` with query string preserved +- `src/components/layout/auth-wrapper.test.tsx:51-94` — `AuthWrapper` reads `x-pathname`, falls back to `/`, URL-encodes the query ## Broken-session recovery (`/session-error`) A session can be authenticated (cookie present, `getSession()` returns a user) yet have **no `userGuid`** — e.g. after a better-auth upgrade drops the field (see `user-identity.md`), or a user row created before the field existed. Such a -session is a trap: every MP lookup keys off `userGuid`, so the header -avatar/menu never render — and the normal sign-out control lives *in* that menu, -so the user is stuck with no way out. +session is a trap: every MP lookup keys off `userGuid`, so nothing works — and +the app has no sign-out control in its shell (the only ones are the forms on +`/session-error` and `/no-access`), so the user would be stuck. `AuthWrapper` defends against this independent of root cause: a session with no `userGuid` → `redirect("/session-error")`. The page (`src/app/session-error/page.tsx`) @@ -160,8 +139,8 @@ renders a recovery screen with a `handleSignOut` form button, giving the user an unconditional exit. Why this placement: -- **Not the proxy:** it does a cookie-*presence* check and never decodes the - JWT, so it can't cheaply see `userGuid`. +- **Not the proxy:** it does a cookie-*presence* check and never decrypts the + JWE `session_data` cookie, so it can't cheaply see `userGuid`. - **Not cookie deletion in the wrapper:** Next.js forbids cookie mutation during a Server Component render (only actions/route handlers) — which is exactly why recovery uses a server-action form button, not an auto-clear. @@ -175,9 +154,9 @@ healthy session → renders children. ## Gotchas - **Next.js 16 rename:** anything that refers to `middleware.ts` or `middleware()` is stale. This project uses `proxy.ts` / `proxy()`. -- **Proxy is a cookie-presence check only.** A forged/expired/invalid cookie will pass the proxy and only be caught by `AuthWrapper`. This is by design (edge speed), but it means route guards **must** rely on `AuthWrapper` (or equivalent `auth.api.getSession()` check) for correctness — never the proxy alone. -- **`/api/auth/*` is public at the proxy layer.** Better Auth's handler validates its own auth; protected app APIs should check `auth.api.getSession()` in the route handler. -- **`x-pathname` forwarding:** proxy only forwards it via `NextResponse.next({ request: { headers: forwardedHeaders } })`. When the proxy redirects, the header is not used — `AuthWrapper` only sees it on pass-through requests (`src/proxy.test.ts:178-186`). +- **Proxy is a cookie-presence check only.** A forged/expired/invalid cookie will pass the proxy and only be caught by `AuthWrapper`. This is by design (it runs on every matched request), but it means route guards **must** rely on `AuthWrapper` (or equivalent `auth.api.getSession()` check) for correctness — never the proxy alone. +- **`/api/auth/*` is public at the proxy layer.** The route is a deny-by-default allowlist in front of Better Auth's handler. Any other `/api/*` route is cookie-gated by the proxy, but its handler must still authorize (`AuthorizationService`) — see `../security/README.md`. +- **`x-pathname` forwarding:** proxy only forwards it via `NextResponse.next({ request: { headers: forwardedHeaders } })`. When the proxy redirects, the header is not used — `AuthWrapper` only sees it on pass-through requests (`src/proxy.test.ts:230-238`). - **Trailing-slash fallback:** if `x-pathname` is missing (e.g., request didn't route through the proxy), `AuthWrapper` falls back to `"/"` (`src/components/layout/auth-wrapper.tsx:13`). ## Related docs diff --git a/.claude/references/auth/sessions.md b/.claude/references/auth/sessions.md index 18a7678..b16068b 100644 --- a/.claude/references/auth/sessions.md +++ b/.claude/references/auth/sessions.md @@ -2,7 +2,7 @@ title: Sessions (stateless, encrypted cookie cache + customSession) domain: auth type: reference -applies_to: [src/lib/auth.ts, src/lib/env.ts, src/lib/auth-client.ts, src/contexts/session-context.tsx] +applies_to: [src/lib/auth.ts, src/lib/env.ts, src/lib/auth-client.ts, src/contexts/use-app-session.ts] symbols: [auth, authClient, useAppSession, Session, SessionData, enrichSession, stripUserOAuthTokens, assertAuthEnvironment, parseIpAddressOptions] related: [oauth-flow.md, user-identity.md, route-protection.md, ../security/README.md] last_verified: 2026-09-30 @@ -21,9 +21,9 @@ the cookie was a readable JWT, and the user's MP access/refresh tokens rode in a - `src/lib/auth.ts` — `session`, `account`, `databaseHooks`, `advanced`, `assertAuthEnvironment`, `parseIpAddressOptions`, `enrichSession`, `stripUserOAuthTokens` -- `src/lib/env.ts` — `getMpBaseUrl()`, `getAuthBaseUrl()` +- `src/lib/env.ts` — `getMpBaseUrl()`, `getAuthBaseUrl()`, `getMpClient()` - `src/lib/auth-client.ts` — client `authClient` with `customSessionClient<typeof auth>()` -- `src/contexts/session-context.tsx` — `useAppSession()` hook +- `src/contexts/use-app-session.ts` — `useAppSession()` hook (returns `authClient.useSession().data`) + `SessionData` type - Tests: `src/auth.session-lifetime.test.ts` (clock walks), `src/auth.session-config.test.ts` (cookie names/attrs, decrypted `session_data`, withheld fields), `src/auth.user-oauth-tokens.test.ts`, `src/auth.secret-guard.test.ts`, diff --git a/.claude/references/auth/user-identity.md b/.claude/references/auth/user-identity.md index 597a929..69195a6 100644 --- a/.claude/references/auth/user-identity.md +++ b/.claude/references/auth/user-identity.md @@ -3,20 +3,21 @@ title: User Identity — user.id vs userGuid (CRITICAL) domain: auth type: reference applies_to: [src/lib/auth.ts, src/contexts/user-context.tsx] -symbols: [userGuid, user.id, additionalFields] -related: [sessions.md, oauth-flow.md, ../services/user-service.md] -last_verified: 2026-07-09 +symbols: [userGuid, mpEmail, user.id, userAdditionalFields, mapProfileToUser, accountSubject] +related: [sessions.md, oauth-flow.md, ../services/user-service.md, ../contexts/user-provider.md] +last_verified: 2026-10-10 --- ## Purpose Explain the single most consequential gotcha in this codebase: `session.user.id` is **Better Auth's internal nanoid**, not the Ministry Platform `User_GUID`. The MP `User_GUID` lives in `session.user.userGuid` (populated from the OIDC `sub` claim via `additionalFields` + `mapProfileToUser`). Every MP API lookup keyed by the signed-in user **must** use `userGuid`. ## Files -- `src/lib/auth.ts:22-30` — `user.additionalFields.userGuid` declaration -- `src/lib/auth.ts:82-86` — `mapProfileToUser` writes `userGuid: profile.id` -- `src/lib/auth.ts:69-75` — `getUserInfo` returns `id: profile.sub` +- `src/lib/auth.ts:352-363` — `userAdditionalFields` (`userGuid`, `mpEmail`) +- `src/lib/auth.ts:777` — `accountSubject` reads `profile.sub` (the account key) +- `src/lib/auth.ts:778-892` — `getUserInfo` returns `{ sub, email: <sub>@mp.invalid, mpEmail, ... }` +- `src/lib/auth.ts:899-917` — `mapProfileToUser` writes `userGuid: sub` (validated by `extractUserGuid`) - `src/contexts/user-context.tsx:29` — canonical consumer cast -- `src/auth.test.ts:189-207` — test asserting the distinction +- `src/auth.test.ts:194-222` — field-persistence guard + test asserting the distinction ## Identity table @@ -24,17 +25,23 @@ Explain the single most consequential gotcha in this codebase: `session.user.id` |---|---|---|---| | `session.user.id` | Better Auth generates internally on user creation | `1gYSNMvy6OqAm9q3DdVhtKj3Czkxd0ms` (nanoid-style) | Auth guards only (`if (!session?.user?.id) throw ...`) | | `session.user.userGuid` | MP OIDC `sub` claim → stored via `additionalFields` | `ab12cd34-ef56-7890-abcd-ef1234567890` (UUID) | **All MP API lookups** (`dp_Users.User_GUID`, profile fetching, `$userId` audit) | -| Account row `accountId` | `getUserInfo` returns `id: profile.sub`, Better Auth stores it on the account, **not** on the user | `ab12cd34-ef56-7890-abcd-ef1234567890` | Better Auth internal (account-to-user link) | +| `session.user.email` | `syntheticEmailForSub(sub)` — `<sub>@mp.invalid`, **not** a real address | `ab12cd34-...@mp.invalid` | Better Auth identity keying only; display the real address from `session.user.mpEmail` | +| Account row `accountId` | `accountSubject` reads `profile.sub` (returned by `getUserInfo`); Better Auth stores it on the account, **not** on the user | `ab12cd34-ef56-7890-abcd-ef1234567890` | Better Auth internal (account-to-user link) | ## Why `user.id` ≠ `sub` -Better Auth explicitly strips the incoming `id` when creating the user row, using the value only as the `accountId` on the linked account record. The user's `id` is a fresh server-generated token. This is why `mapProfileToUser` must write `userGuid` separately as an `additionalField`. +Better Auth uses the provider subject (`accountSubject` → `profile.sub`) only as the `accountId` on the linked account record. The user's `id` is a fresh server-generated token. This is why `mapProfileToUser` must write `userGuid` separately as an `additionalField`. -## `additionalFields.userGuid` declaration (verbatim) +## `userAdditionalFields` declaration (verbatim) ```typescript -// src/lib/auth.ts — extracted to an exported const so a test can assert against it +// src/lib/auth.ts:352-363 — extracted to an exported const so a test can assert against it export const userAdditionalFields = { userGuid: { + type: "string" as const, + required: true, + input: true, + }, + mpEmail: { type: "string" as const, required: false, input: true, @@ -47,21 +54,23 @@ user: { }, ``` -> ⚠️ **`userGuid` MUST keep `input: true`.** It is populated server-side from the +> ⚠️ **`userGuid` (and `mpEmail`) MUST keep `input: true`.** It is populated server-side from the > OAuth profile via `mapProfileToUser`, not by user input. As of better-auth > 1.6, the provider-profile field parser (`parseAdditionalUserInput` in > `node_modules/better-auth/dist/db/schema.mjs`) no longer lets an > `input: false` additionalField through when a value is supplied — in 1.6.11 it > throws `"userGuid is not allowed to be set"`, so the OAuth-created user never > gets a `userGuid`. Result: `session.user.userGuid` is `undefined` → every -> tool redirects to `/session-error`, and MP profile lookups break. There is no user-facing -> form that sets this field (genericOAuth only; no email/password signup or -> update-user endpoint), so `input: true` carries no practical risk here. +> tool redirects to `/session-error`, and MP profile lookups break. `input: true` +> also makes the field writable through Better Auth's `/update-user` endpoint; +> that endpoint is closed at the route layer (`disabledAuthPaths` in +> `src/lib/auth.ts` and the allowlist in `src/app/api/auth/[...all]/route.ts`) — +> that is the control, not this flag. > Guarded by `src/auth.test.ts` (runs the real better-auth parser against the > real `userAdditionalFields` config). - `input: true` — see the warning above. The field is only ever set server-side by `mapProfileToUser`. -- `required: false` — the field is optional in the TS type, so consumers must null-check. +- `required: true` — better-auth refuses to create a user record with no MP identity (fail closed). Sessions minted before this, or by a future regression, can still lack it — `AuthWrapper` routes those to `/session-error`, and consumers still null-check. ## Better Auth upgrade checklist @@ -84,10 +93,18 @@ After **any** better-auth version change: ## `mapProfileToUser` (verbatim) ```typescript -// src/lib/auth.ts:82-86 +// src/lib/auth.ts:899-917 (comments trimmed) mapProfileToUser: (profile) => { + const sub = extractUserGuid(profile); + if (!sub) { + throw new Error("mapProfileToUser: profile has no usable sub"); + } return { - userGuid: profile.id, + userGuid: sub, + email: syntheticEmailForSub(sub), + mpEmail: + (profile as unknown as { mpEmail?: string | null }).mpEmail ?? + null, } as Record<string, unknown>; }, ``` @@ -104,6 +121,8 @@ const session = await auth.api.getSession({ headers: await headers() }); if (!session?.user?.id) { throw new Error("Authentication required"); // guard on any identity } +// Feature actions/services must authorize via AuthorizationService, not just +// check a session — see ../security/README.md. // For MP API lookups, use userGuid (NOT user.id): const userGuid = (session.user as { userGuid?: string }).userGuid; @@ -141,12 +160,12 @@ const loadUserProfile = useCallback(async () => { - Server: `(session.user as Record<string, unknown>).userGuid as string` **or** `(session.user as { userGuid?: string }).userGuid` - Client: `(session?.user as { userGuid?: string } | undefined)?.userGuid` -This is a Better Auth type-inference limitation as of `better-auth@^1.5.5`. Runtime behavior is correct. +This is a Better Auth type-inference limitation (still present on `better-auth@^1.7.7`; the casts remain in `user-context.tsx` and `auth-wrapper.tsx`). Runtime behavior is correct. ## Test evidence ```typescript -// src/auth.test.ts:189-207 +// src/auth.test.ts:203-222 it('should distinguish user.id (Better Auth internal) from userGuid (MP User_GUID)', () => { const mpUserGuid = 'ab12cd34-ef56-7890-abcd-ef1234567890'; const betterAuthId = '1gYSNMvy6OqAm9q3DdVhtKj3Czkxd0ms'; @@ -169,7 +188,7 @@ it('should distinguish user.id (Better Auth internal) from userGuid (MP User_GUI - **Most common bug:** calling an MP service with `session.user.id` instead of `session.user.userGuid` — returns empty results or throws "user not found" because `user.id` is a nanoid, not a UUID in `dp_Users.User_GUID`. - **Don't rename `userGuid` to `mpUserGuid` / `userId`.** Call sites grep on exactly `userGuid`. -- **`userGuid` can be `undefined`** if the user row was created before `additionalFields.userGuid` was added, or if `mapProfileToUser` ran before a schema change. Always null-check. +- **`userGuid` can be `undefined`** on a session minted before the field existed or by a better-auth regression (the 1.6 incident). Always null-check. - **`user.id` is still required** — use it only as a "session exists" sentinel (`if (!session?.user?.id)`), never for DB joins. ## Glossary candidates @@ -178,5 +197,5 @@ it('should distinguish user.id (Better Auth internal) from userGuid (MP User_GUI ## Related docs - `sessions.md` — session shape and why the cast is necessary - `oauth-flow.md` — how `sub` flows into `userGuid` -- `../services/user-service.md` — `UserService.getUserProfile(userGuid)` consumer (if authored) -- `../contexts/user-provider.md` — canonical client-side consumer (if authored) +- `../services/user-service.md` — `UserService.getUserProfile(userGuid)` consumer +- `../contexts/user-provider.md` — canonical client-side consumer diff --git a/.claude/references/components/README.md b/.claude/references/components/README.md index 19483da..92b8b41 100644 --- a/.claude/references/components/README.md +++ b/.claude/references/components/README.md @@ -21,14 +21,14 @@ Reference docs for `src/components/` — the tool layout framework, layout wrapp ## Code surfaces | Path | Role | |------|------| -| `src/components/tool/` | `ToolContainer`, `ToolHeader`, `ToolFooter` — 4 files | +| `src/components/tool/` | `ToolContainer`, `ToolHeader`, `ToolFooter` + barrel + `tool-framework.test.tsx` — 5 files | | `src/components/layout/` | `AuthWrapper` server component + test — 3 files | | `src/components/ui/` | 22 shadcn/ui primitives | | `src/components/shared-actions/` | Cross-feature server actions — `user.ts`, `domain.ts`, `sign-out.ts` + tests (+ local `README.md`) | -| `src/components/address-labels/` | Address-label feature — 13 files | -| `src/components/dev-panel/` | Dev overlay — 3 files + `panels/` subdir | -| `src/components/field-management/` | Field-order editor — 10 files | -| `src/components/group-wizard/` | Group wizard — 14 files | +| `src/components/address-labels/` | Address-label feature — 24 files (incl. 11 tests) | +| `src/components/dev-panel/` | Dev overlay — 3 files + `panels/` subdir (20 files) | +| `src/components/field-management/` | Field-order editor — 16 files (incl. 7 tests) | +| `src/components/group-wizard/` | Group wizard — 26 files (incl. 12 tests) | ## Conventions (domain-wide) - File names: `kebab-case.tsx` (e.g., `tool-container.tsx`) diff --git a/.claude/references/components/address-labels.md b/.claude/references/components/address-labels.md index 4a60f44..c9d1aac 100644 --- a/.claude/references/components/address-labels.md +++ b/.claude/references/components/address-labels.md @@ -24,7 +24,7 @@ related: - ../utils/label-stock.md - ../services/address-label-service.md - tool-framework.md -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose @@ -46,12 +46,13 @@ Per-component `*.test.tsx` files are co-located and not listed. | `src/components/address-labels/mail-merge-tab.tsx` | Client mail-merge UI: sample download, template upload, merge + download | | `src/components/address-labels/sample-template.ts` | `'use server'` — `generateSampleTemplate()` produces the sample `.docx` with merge tokens | | `src/components/address-labels/word-document.ts` | `buildWordDocument()` — Avery-style `.docx` via `docx` package with BMP barcodes | -| `src/components/address-labels/actions.ts` | Server actions (see API below); shared `prepareLabels` validation helper | +| `src/components/address-labels/actions.ts` | Server actions (see API below); `requireAccess` (`AuthorizationService` gate) + shared `prepareLabels` validation helper | | `src/components/address-labels/limits.ts` | Shared size caps: `MAX_TEMPLATE_BYTES` (2 MB) + `MAX_TEMPLATE_LABEL` (`'2MB'`), `MAX_LABELS` (10,000), `TEMPLATE_ZIP_LIMITS` (`maxEntries: 1_000`, `maxTotalBytes: 64 MB`). Imported by both client (`mail-merge-tab.tsx`) and server (`actions.ts`, `schemas.ts`) | | `src/components/address-labels/schemas.ts` | Zod schemas + `parseLabelInput(labels, config)` — server-side validation of client-sent labels/config | | `src/lib/zip-guard.ts` | `assertZipWithinLimits` / `ZipLimitError` — zip-bomb pre-flight run before PizZip | | `src/components/address-labels/actions.test.ts` | Vitest suite covering all four server actions | | `src/components/address-labels/word-document.test.ts` | Vitest suite for `buildWordDocument` geometry paths | +| `src/components/address-labels/sample-template.test.ts` | `generateSampleTemplate` | | `src/components/address-labels/merge-template.integration.test.ts` | Real (unmocked) `mergeTemplate` + PizZip run, incl. a forged-size zip bomb | Tool route: `src/app/(web)/tools/addresslabels/page.tsx` → `address-labels.tsx` (orchestrates form, fetch, summary, tabs, PDF/Word download). @@ -166,10 +167,10 @@ const SERVICE_TYPES = [{id:'040',…}, {id:'300',…}, {id:'044',…}, {id:'700' ## How it works ### fetchAddressLabels (selection vs single-record) -- `actions.ts:28` `getSession()` asserts `session.user.id` or throws `Unauthorized`. -- Selection mode (`params.s && params.pageID`): resolves MP `User_ID` from `session.user.userGuid` via `UserService.getUserIdByGuid`, then `ToolService.getSelectionRecordIds(s, userId, pageID)` → contact IDs → `AddressLabelService.getAddressesForContacts()`. -- Single-record mode (`params.recordID !== -1`): `AddressLabelService.getAddressForContact(recordID)`. -- `filterAndTransform()` (`actions.ts:42`) iterates rows, applies skip rules in fixed order (opted_out → no_address → no_postal_code → no_barcode), dedups by `Household_ID` in household mode, sorts by `postalCode` ascending. +- `requireAccess('Contacts', 'read')` (`actions.ts:43`) → `AuthorizationService.requireSecurityRole`; throws on no session / no qualifying MP security role. The services gate again (defence in depth). +- Selection mode (`params.s && params.pageID`): `ToolService.getSelectionRecordIds(s, pageID)` → contact IDs → `AddressLabelService.getAddressesForContacts()`. The acting MP `User_ID` for the selection SP comes from the gate inside the service, never from the action. +- Single-record mode (`params.recordID` set and `!== -1`): `AddressLabelService.getAddressForContact(recordID)`. +- `filterAndTransform()` (`actions.ts:51`) iterates rows, applies skip rules in fixed order (opted_out → no_address → no_postal_code → no_barcode → no_household), dedups by `Household_ID` in household mode, sorts by `postalCode` ascending. ### prepareLabels (shared input validation) Called by `generateLabelPdf`, `generateLabelDocx`, `mergeTemplate` after `requireAccess('Contacts', 'read')`. Labels/config are caller-shaped input (they round-trip through the client after `fetchAddressLabels`), so every render action re-validates them. Returns `{ ok: true, labels, config, stock }` or `{ ok: false, result }` (the action returns `result` as-is). Order: @@ -209,7 +210,7 @@ Called by `generateLabelPdf`, `generateLabelDocx`, `mergeTemplate` after `requir ## Usage -Dashboard card entry (from `src/app/(web)/page.tsx:41`): +Dashboard card entry (from `src/app/(web)/page.tsx:27`): ```tsx <Card className="flex flex-col"> <CardHeader> @@ -226,7 +227,7 @@ Dashboard card entry (from `src/app/(web)/page.tsx:41`): </Card> ``` -Tool page wiring (from `src/app/(web)/tools/addresslabels/address-labels.tsx:180`): +Tool page wiring (abridged, from `src/app/(web)/tools/addresslabels/address-labels.tsx:183`): ```tsx <ToolContainer params={params} title="Address Labels" onClose={handleClose} hideFooter> <div className="px-6 py-4 space-y-6 max-w-2xl"> @@ -251,12 +252,12 @@ Tool page wiring (from `src/app/(web)/tools/addresslabels/address-labels.tsx:180 ## Gotchas -- **Mailer ID is user-supplied** (not an env var). `LabelConfig.mailerId` persists in `localStorage`; both the PDF (`address-labels.tsx:98`) and `mergeTemplate` (`mail-merge-tab.tsx:111`) reject IMb generation unless the length is exactly 6 or 9. `preEncodeBarcodes` silently falls back from IMb → POSTNET when `mailerId` is empty (`src/lib/barcode-helpers.ts:41`). +- **Mailer ID is user-supplied** (not an env var). `LabelConfig.mailerId` persists in `localStorage`; both the PDF/Word handlers (`address-labels.tsx:100,139`) and `mergeTemplate` (`mail-merge-tab.tsx:111`) reject IMb generation unless the length is exactly 6 or 9. `preEncodeBarcodes` falls back from IMb → POSTNET when `mailerId` is empty (silently) or IMb encoding throws (`console.warn`) (`src/lib/barcode-helpers.ts:53-66`). - **Label geometry is in PDF points** (72pt = 1in). `buildWordDocument` converts via `convertInchesToTwip(ptToIn(...))`. Adding a new `LABEL_STOCKS` entry in the wrong unit silently produces misaligned pages. - **React-pdf type cast**: `pdf(doc as any)` in `generateLabelPdf` (`actions.ts`) is a runtime-safe cast because `LabelDocument` returns `<Document>` at its root; `@react-pdf/renderer` types don't permit a wrapper with its own props. - **PizZip/docxtemplater expect strings, not Buffers**, as image tag values — `mergeTemplate` keys the buffer map with `barcode_${i}` strings; passing the buffer directly makes the image module misinterpret it as a cached result (`mergeTemplate` in `actions.ts`). - **Size limits are coupled to the request-body ceiling.** `next.config.ts` sets `experimental.serverActions.bodySizeLimit: "4mb"` (Next default 1 MB made the template limit unreachable) to stay under Vercel's 4.5 MB function request cap. A 2 MB template is ~2.7 MB as base64, leaving ~1.3 MB for the label list on a merge. Change `limits.ts` and `next.config.ts` together. -- **Selection mode requires MP `User_ID`** (not Better Auth `user.id`). `getMPUserId()` does a GUID→ID lookup per call — no memoization. +- **Selection mode requires MP `User_ID`** (not Better Auth `user.id`). It is resolved by the `AuthorizationService` gate inside `ToolService.getSelectionRecordIds` (memoized per request), not by the action. ## Related docs diff --git a/.claude/references/components/dev-panel.md b/.claude/references/components/dev-panel.md index 7f90812..7454a31 100644 --- a/.claude/references/components/dev-panel.md +++ b/.claude/references/components/dev-panel.md @@ -14,6 +14,7 @@ applies_to: - src/components/dev-panel/panels/user-tools-actions.ts - src/components/dev-panel/panels/deploy-tool-panel.tsx - src/components/dev-panel/panels/deploy-tool-actions.ts + - src/components/dev-panel/panels/require-dev-session.ts symbols: - DevPanel - ParamsPanel @@ -28,12 +29,13 @@ symbols: - listPagesAction - listRolesAction - getDeployToolEnvStatusAction + - requireDevSession related: - tool-framework.md - ../services/tool-service.md - - ../routing/tool-params.md - - ../required-stored-procs.md -last_verified: 2026-04-17 + - ../utils/tool-params.md + - ../mp-schema/stored-procs.md +last_verified: 2026-10-10 --- ## Purpose @@ -47,21 +49,23 @@ Localhost-only developer overlay rendered by `ToolContainer` when `params` is pr | `src/components/dev-panel/dev-panel.test.tsx` | Shell tests (gating, localStorage persistence) — sub-panels fully mocked | | `src/components/dev-panel/panels/params-panel.tsx` | Renders `ToolParams` as a labelled grid + raw JSON (pure client, no server action) | | `src/components/dev-panel/panels/selection-panel.tsx` | Resolves `params.s` → record IDs via `resolveSelection`, bubbles IDs up via `onRecordIdsResolved` | -| `src/components/dev-panel/panels/selection-actions.ts` | `resolveSelection(selectionId, pageId)` → `{ recordIds, count }` (auth-gated) | -| `src/components/dev-panel/panels/selection-actions.test.ts` | Tests for auth/userGuid guards and delegation | +| `src/components/dev-panel/panels/selection-actions.ts` | `resolveSelection(selectionId, pageId)` → `{ recordIds, count }` (`requireDevSession`) | +| `src/components/dev-panel/panels/selection-actions.test.ts` | Tests for the dev-session guard and delegation | | `src/components/dev-panel/panels/contact-records-panel.tsx` | Maps record IDs → Contact IDs via `resolveContactRecords`, driven by `params.recordID` or upstream selection | | `src/components/dev-panel/panels/contact-records-actions.ts` | `resolveContactRecords(pageId, recordIds)` → `ContactRecordResult \| null` (page metadata resolved server-side) | | `src/components/dev-panel/panels/user-tools-panel.tsx` | Lists the user's authorized tool paths, computes `isAuthorized` against `NEXT_PUBLIC_PROD_URL + pathname`, reports status up via `onAuthorizationChange` | -| `src/components/dev-panel/panels/user-tools-actions.ts` | `getUserTools()` → `string[]` (paths, auth-gated, resolves `userGuid` → `User_ID`) | -| `src/components/dev-panel/panels/user-tools-actions.test.ts` | Tests for auth/userGuid guards and delegation | +| `src/components/dev-panel/panels/user-tools-actions.ts` | `getUserTools()` → `string[]` (paths; `requireDevSession`; acting `User_ID` resolved by the gate inside `ToolService.getUserTools`) | +| `src/components/dev-panel/panels/user-tools-actions.test.ts` | Tests for the dev-session guard, refusal propagation, delegation | | `src/components/dev-panel/panels/deploy-tool-panel.tsx` | Form: tool-name/launch-page/description/flags/pages/roles + server-side env-status badge; rendered only when user is NOT authorized for the current tool path | -| `src/components/dev-panel/panels/deploy-tool-actions.ts` | `listPagesAction`, `listRolesAction`, `deployToolAction`, `getDeployToolEnvStatusAction` — all gated on `NODE_ENV !== 'production'` + session | +| `src/components/dev-panel/panels/deploy-tool-actions.ts` | `listPagesAction`, `listRolesAction`, `deployToolAction`, `getDeployToolEnvStatusAction` — all via `requireDevSession("Deploy Tool")` | | `src/components/dev-panel/panels/deploy-tool-actions.test.ts` | Tests for prod guard, session guard, delegation, env-status reporting | +| `src/components/dev-panel/panels/require-dev-session.ts` | `requireDevSession(featureLabel = "Dev panel")` — shared guard for **every** dev-panel action: throws when `NODE_ENV === "production"`, then requires a session with `user.id`; returns the session. CLAUDE.md rule-12 carve-out (the services it calls gate again) | +| `src/components/dev-panel/panels/*-panel.test.tsx`, `require-dev-session.test.ts` | Per-panel and guard tests (11 test files in the folder tree in total) | ## Key concepts ### Triple-gate rendering -`DevPanel` returns `null` unless **all three** hold (`src/components/dev-panel/dev-panel.tsx:56-58`): +`DevPanel` returns `null` unless **all three** hold (`src/components/dev-panel/dev-panel.tsx:59-61`): 1. `mounted === true` — avoids SSR/CSR mismatch (value flipped inside `useEffect`) 2. `process.env.NODE_ENV === "development"` (`isDevBuild()`) 3. `window.location.hostname === "localhost" || "127.0.0.1"` (`isLocalhost()`) @@ -74,7 +78,7 @@ Rendered from `ToolContainer` (`src/components/tool/tool-container.tsx:37`): `{p - Restored on mount via `setIsOpen(readOpenState())` in the same `useEffect` that sets `mounted` ### Sub-panel composition -Inside the expanded body (`src/components/dev-panel/dev-panel.tsx:94-109`): +Inside the expanded body (`src/components/dev-panel/dev-panel.tsx:102-111`): 1. `ParamsPanel` — always 2. `SelectionPanel` — bubbles `selectionRecordIds` up via `onRecordIdsResolved` 3. `ContactRecordsPanel` — consumes `params.recordID` OR `selectionRecordIds` from (2) @@ -87,7 +91,7 @@ Inside the expanded body (`src/components/dev-panel/dev-panel.tsx:94-109`): - If `NEXT_PUBLIC_PROD_URL` is unset, renders an amber warning and sets `isAuthorized = false` (so Deploy Tool stays visible) ### Deploy Tool env status -`getDeployToolEnvStatusAction` server-side checks two env vars (`src/components/dev-panel/panels/deploy-tool-actions.ts:46-55`): +`getDeployToolEnvStatusAction` server-side checks two env vars (`src/components/dev-panel/panels/deploy-tool-actions.ts:37-46`): - `MINISTRY_PLATFORM_DEV_CLIENT_ID` - `MINISTRY_PLATFORM_DEV_CLIENT_SECRET` @@ -96,10 +100,10 @@ When missing, the form disables the submit button and surfaces an amber notice n ### Stored procedures called from the dev-panel | SP | Called via | From file | |----|-----------|-----------| -| `api_Common_GetSelection` | `ToolService.getSelectionRecordIds` | `src/services/toolService.ts:154` (via `selection-actions.ts`) | -| `api_Tools_GetUserTools` | `ToolService.getUserTools` | `src/services/toolService.ts:185` (via `user-tools-actions.ts`) | -| `api_dev_DeployTool` | `ToolService.deployTool` | `src/services/toolService.ts:268` (via `deploy-tool-actions.ts`) | -| `api_MPNextTools_GetPages` | `ToolService.listPages` | `src/services/toolService.ts:207` (via `deploy-tool-actions.ts`) | +| `api_Common_GetSelection` | `ToolService.getSelectionRecordIds` | `src/services/toolService.ts:162` (via `selection-actions.ts`) | +| `api_Tools_GetUserTools` | `ToolService.getUserTools` | `src/services/toolService.ts:196` (via `user-tools-actions.ts`) | +| `api_dev_DeployTool` | `ToolService.deployTool` | `src/services/toolService.ts:272` (via `deploy-tool-actions.ts`) | +| `api_MPNextTools_GetPages` | `ToolService.listPages` | `src/services/toolService.ts:223` (via `deploy-tool-actions.ts`) | `ContactRecordsPanel` does **not** call a stored procedure — it uses `getTableRecords` in `ToolService.resolveContactIds` (`src/services/toolService.ts`). @@ -174,8 +178,8 @@ interface DeployToolPanelProps { - `DevPanel` returns `null` on first render (pre-`useEffect`), then re-evaluates the three gates — so the bar appears only after hydration on a qualifying localhost dev build - Toggle button writes `"1"`/`"0"` to `localStorage["mp-dev-panel:open"]`; reads in `useEffect` prevent SSR mismatch - Sub-panels are arranged so state flows downward from `SelectionPanel` → `ContactRecordsPanel` and upward from `UserToolsPanel` → `DevPanel` (`isAuthorized`) → `DeployToolPanel` (via conditional render). Successful deploy bumps `userToolsRefreshKey`, which re-triggers `UserToolsPanel`'s fetch, which flips `isAuthorized` and unmounts `DeployToolPanel`. -- Every server action begins with `auth.api.getSession({ headers: await headers() })` and extracts `userGuid` (not `session.user.id`) for MP lookups — see `../auth/sessions.md` -- `deploy-tool-actions.ts` adds `process.env.NODE_ENV === "production"` → throw before any session check (belt-and-suspenders against accidental exposure) +- Every dev-panel server action begins with `await requireDevSession(...)` (`panels/require-dev-session.ts`): `NODE_ENV === "production"` → throw, then a Better Auth session with `user.id` is required +- The actions pass no user identity to the services; `ToolService` resolves the acting MP `User_ID` from `session.user.userGuid` through `AuthorizationService.requireSecurityRole` (see `../auth/sessions.md`) ## Usage @@ -194,30 +198,33 @@ if (!isDevBuild()) return null; if (!isLocalhost()) return null; ``` -Server-action guard pattern, from `src/components/dev-panel/panels/deploy-tool-actions.ts:13-21`: +Server-action guard, from `src/components/dev-panel/panels/require-dev-session.ts:26-37` (docstring elided): ```typescript -async function requireDevSession(): Promise<void> { +export async function requireDevSession( + featureLabel: string = "Dev panel" +): Promise<NonNullable<Awaited<ReturnType<typeof auth.api.getSession>>>> { if (process.env.NODE_ENV === "production") { - throw new Error("Deploy Tool is not available in production."); + throw new Error(`${featureLabel} is not available in production.`); } const session = await auth.api.getSession({ headers: await headers() }); if (!session?.user?.id) { throw new Error("Unauthorized - Missing user session data"); } + return session; } ``` ## Gotchas -- **Don't ship DevPanel to production.** The `DevPanel` component gates on `NODE_ENV === "development"` AND `hostname === "localhost" | "127.0.0.1"`. Removing either check (or setting `NODE_ENV=development` on a deployed build) would leak deploy-tool and session internals. Defense-in-depth: `deploy-tool-actions.ts` also throws in production before any session check. -- **Server actions in a dev-only code path are still reachable.** `/* "use server" */` exports are addressable over the network regardless of where they're imported. `requireDevSession` in `deploy-tool-actions.ts` enforces the `NODE_ENV` check server-side; `selection-actions.ts`, `contact-records-actions.ts`, and `user-tools-actions.ts` only check for a valid session — they do NOT gate on `NODE_ENV`. This is by design (those same services back real features), but it means the "dev-only" label applies to the **UI** only, not the actions. (See TODO `2026-04-17-components-dev-panel-prod-guard-gap.md`.) +- **Don't ship DevPanel to production.** The `DevPanel` component gates on `NODE_ENV === "development"` AND `hostname === "localhost" | "127.0.0.1"`. Removing either check (or setting `NODE_ENV=development` on a deployed build) would leak deploy-tool and session internals. Defense-in-depth: every dev-panel action calls `requireDevSession`, which throws in production before any session check. +- **Server actions in a dev-only code path are still reachable.** `"use server"` exports are addressable over the network regardless of where they're imported. That is why all four action files (`selection-`, `contact-records-`, `user-tools-`, `deploy-tool-actions.ts`) call `requireDevSession`, which enforces the `NODE_ENV` check server-side. Any new dev-panel action must do the same. - **Missing `NEXT_PUBLIC_PROD_URL` makes the authorization check non-functional.** When unset, `UserToolsPanel` cannot match production URLs and always treats the user as unauthorized, which keeps `DeployToolPanel` visible even for already-registered tools. -- **`session.user.id` is NOT `User_GUID`.** Every action extracts `userGuid` from `session.user` (see `../auth/sessions.md`) before calling `UserService.getUserIdByGuid`. +- **`session.user.id` is NOT `User_GUID`.** `requireDevSession` only checks that `user.id` exists; the MP `User_ID` is resolved from `session.user.userGuid` by `AuthorizationService` inside the service (see `../auth/sessions.md`). ## Related docs - `tool-framework.md` — `ToolContainer` that mounts this overlay - `../services/tool-service.md` — `ToolService.getSelectionRecordIds`, `getUserTools`, `deployTool`, `resolveContactIds`, `listPages`, `listRoles` -- `../routing/tool-params.md` — `ToolParams` shape + `parseToolParams` -- `../required-stored-procs.md` — `api_Common_GetSelection`, `api_Tools_GetUserTools` call contracts +- `../utils/tool-params.md` — `ToolParams` shape + parsing +- `../mp-schema/stored-procs.md` — `api_Common_GetSelection`, `api_Tools_GetUserTools` signatures - `../auth/sessions.md` — why `userGuid` (not `user.id`) is used for MP lookups diff --git a/.claude/references/components/field-management.md b/.claude/references/components/field-management.md index 3e18a0a..caebeb8 100644 --- a/.claude/references/components/field-management.md +++ b/.claude/references/components/field-management.md @@ -32,7 +32,7 @@ related: - ../services/field-management-service.md - ../mp-schema/required-procs.md - tool-framework.md -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose @@ -44,10 +44,10 @@ Drag-and-drop editor for Ministry Platform page field layout: pick a page, reord | `src/components/field-management/index.ts` | Barrel export — re-exports `PageSearch`, `FieldOrderEditor`, and shared types | | `src/components/field-management/types.ts` | `PageListItem`, `PageField`, `PageFieldData`, `FieldOrderPayload`, `FieldOrderEditorHandle` interfaces | | `src/components/field-management/actions.ts` | Server actions: `fetchPages`, `fetchPageFieldData`, `savePageFieldOrder` | -| `src/components/field-management/actions.test.ts` | Vitest tests for the three server actions | +| `src/components/field-management/actions.test.ts` | Vitest tests for the three server actions (7 test files in the folder: one per source file except `index.ts`/`types.ts`) | | `src/components/field-management/page-search.tsx` | Combobox (Popover + cmdk) for picking a MP page; loads list via `fetchPages` | -| `src/components/field-management/field-order-editor.tsx` | `forwardRef` editor shell — renders flat or grouped mode, wires `DragDropProvider`, exposes `getSavePayload` / `moveHiddenToOther` via `useImperativeHandle` | -| `src/components/field-management/use-field-order-state.ts` | Hook owning groupedFields map, groupOrder list, fieldLookup, `isDirty`, DnD handlers, `addGroup`/`removeGroup`/`moveHiddenToOther`/`buildSavePayload` | +| `src/components/field-management/field-order-editor.tsx` | `forwardRef` editor shell — renders flat or grouped mode, wires `DragDropProvider`, exposes `getSavePayload` / `moveHiddenToOther` / `hideAllSeparators` via `useImperativeHandle` (`field-order-editor.tsx:42-46`) | +| `src/components/field-management/use-field-order-state.ts` | Hook owning groupedFields map, groupOrder list, fieldLookup, `isDirty`, DnD handlers, `addGroup`/`removeGroup`/`moveHiddenToOther`/`hideAllSeparators`/`updateField`/`buildSavePayload` | | `src/components/field-management/sortable-group.tsx` | `@dnd-kit/react` sortable group container (header, collapse, delete-empty) | | `src/components/field-management/sortable-field-item.tsx` | Sortable field row with expandable detail panel (label / default / filter / depends-on / switches) | | `src/components/field-management/new-group-dialog.tsx` | Dialog for creating a new group (validates name, rejects duplicates case-insensitively) | @@ -58,27 +58,28 @@ Tool page consumer: `src/app/(web)/tools/fieldmanagement/field-management.tsx`. - **Page field**: a row in `dp_Page_Fields` describing how one column is presented on a given MP page (order, group, required, hidden, label, default, filter, depends-on, writing-assistant flag). - **Flat vs grouped mode**: if every incoming field has `Group_Name === null` the hook stores them under the sentinel key `"__flat__"` (constant `FLAT_GROUP`, `use-field-order-state.ts:8`) and renders a single list. Adding a group transitions to grouped mode — flat fields move into `"99 - Other Fields"`. - **`"99 - Other Fields"` pinned bucket**: constant `OTHER_FIELDS_GROUP` (`field-order-editor.tsx:14`, `use-field-order-state.ts:9`). Always rendered last, cannot be dragged (`isPinned` disables its sortable handle), cannot be removed, and catches fields the user marks Hidden when they click **Move Hidden to Other**. -- **Negative `Page_Field_ID`**: `fetchPageFieldData` merges table columns that aren't in `dp_Page_Fields` yet and assigns them synthetic IDs starting at `-1` and decrementing (`actions.ts:34,42`). These IDs only exist client-side as React keys — the save payload is keyed by `Page_ID + Field_Name`, not ID. -- **Group renumbering on save**: `buildSavePayload` strips any `^\d+\s*-\s*` prefix and re-emits groups as `"1 - …"`, `"2 - …"`, … in current display order; `"99 - Other Fields"` keeps its literal name and `"__flat__"` becomes `Group_Name: null` (`use-field-order-state.ts:176-215`). -- **Schema-required lock**: when `tableMetadata.Columns[n].IsRequired` is true, the field's `Required` switch is forced on and disabled (`sortable-field-item.tsx:126-134`). The badge also shows "Required" based on `schemaRequired || field.Required`. -- **Imperative handle**: parent tool page keeps an editor `ref` and calls `ref.current.getSavePayload()` before invoking `savePageFieldOrder`; `ref.current.moveHiddenToOther()` powers the footer button. +- **Separators**: `PageField.isSeparator` is derived server-side from `tableMetadata` (`DataType === 'Separator'`, `actions.ts:51-55,68`); it is client-only and not part of `FieldOrderPayload`. Separator rows render differently (`sortable-field-item.tsx:30,54-85`), never show the Required badge, and `hideAllSeparators()` marks every separator `Hidden` and moves them into `"99 - Other Fields"` (`use-field-order-state.ts:187-225`). +- **Negative `Page_Field_ID`**: `fetchPageFieldData` merges table columns that aren't in `dp_Page_Fields` yet and assigns them synthetic IDs starting at `-1` and decrementing (`actions.ts:61,71`). These IDs only exist client-side as React keys — the save payload is keyed by `Page_ID + Field_Name`, not ID. +- **Group renumbering on save**: `buildSavePayload` strips any `^\d+\s*-\s*` prefix and re-emits groups as `"1 - …"`, `"2 - …"`, … in current display order; `"99 - Other Fields"` keeps its literal name and `"__flat__"` becomes `Group_Name: null` (`use-field-order-state.ts:238-277`). +- **Schema-required lock**: when `tableMetadata.Columns[n].IsRequired` is true, the field's `Required` switch is forced on and disabled (`sortable-field-item.tsx:171-177`). The badge also shows "Required" based on `schemaRequired || field.Required` (non-separators only, `sortable-field-item.tsx:71`). +- **Imperative handle**: parent tool page keeps an editor `ref` and calls `ref.current.getSavePayload()` before invoking `savePageFieldOrder`; `ref.current.moveHiddenToOther()` and `ref.current.hideAllSeparators()` power the toolbar buttons (`field-management.tsx:72-78`). ## API / Interface ### Server actions — `actions.ts` ```typescript -// src/components/field-management/actions.ts:14 +// src/components/field-management/actions.ts:29 export async function fetchPages(): Promise<PageListItem[]> -// src/components/field-management/actions.ts:20 +// src/components/field-management/actions.ts:35 export async function fetchPageFieldData(pageId: number, tableName: string): Promise<PageFieldData> -// src/components/field-management/actions.ts:61 +// src/components/field-management/actions.ts:91 export async function savePageFieldOrder( fields: FieldOrderPayload[] ): Promise<{ success: boolean; error?: string }> ``` -All three call the internal `getSession()` guard (`actions.ts:8-12`), which throws `Error('Unauthorized')` when `session.user.id` is missing. `savePageFieldOrder` catches errors and returns `{ success: false, error }`; the other two let `Unauthorized` propagate. +All three call the internal `requireAccess(table, operation)` gate (`actions.ts:21-26`) → `AuthorizationService.requireSecurityRole`: `fetchPages` → `('dp_Pages', 'read')`, `fetchPageFieldData` → `('dp_Page_Fields', 'read')`, `savePageFieldOrder` → `('dp_Page_Fields', 'update')`. `savePageFieldOrder` catches errors (including a refusal) and returns `{ success: false, error }`; the other two let the refusal propagate. `FieldManagementService` gates again and takes `$userId` from its own gate. ### Types — `types.ts` ```typescript @@ -103,21 +104,23 @@ export interface PageField { Depends_On_Field: string | null; Field_Label: string | null; Writing_Assistant_Enabled: boolean; + isSeparator: boolean; // client-only; derived from table metadata } -// src/components/field-management/types.ts:24 +// src/components/field-management/types.ts:25 export interface PageFieldData { fields: PageField[]; tableMetadata: TableMetadata | null; } -// src/components/field-management/types.ts:29 -export interface FieldOrderPayload { /* same shape as PageField minus Page_Field_ID */ } +// src/components/field-management/types.ts:30 +export interface FieldOrderPayload { /* same shape as PageField minus Page_Field_ID and isSeparator */ } -// src/components/field-management/types.ts:43 +// src/components/field-management/types.ts:44 export interface FieldOrderEditorHandle { getSavePayload: () => FieldOrderPayload[]; moveHiddenToOther: () => void; + hideAllSeparators: () => void; } ``` @@ -129,7 +132,7 @@ export interface FieldOrderEditorHandle { | `NewGroupDialog` | `new-group-dialog.tsx:23` | `{ open, onOpenChange, onCreateGroup: (name) => void, existingGroupNames: string[] }` | | `SortableGroup` | `sortable-group.tsx:22` | `{ groupName, index, fieldIds, fieldLookup, isPinned, onRemove, onUpdateField, schemaRequiredFields }` | | `SortableFieldItem` | `sortable-field-item.tsx:19` | `{ field, index, groupName, onUpdateField, schemaRequired }` | -| `useFieldOrderState` | `use-field-order-state.ts:30` | `(fields: PageField[]) => FieldOrderState` — see the interface at `use-field-order-state.ts:14-28` | +| `useFieldOrderState` | `use-field-order-state.ts:31` | `(fields: PageField[]) => FieldOrderState` — see the interface at `use-field-order-state.ts:14-29` | ### Service delegation `actions.ts` calls `FieldManagementService.getInstance()` for all three operations: @@ -144,14 +147,14 @@ See [`../services/field-management-service.md`](../services/field-management-ser ## How it works - **Step 1 — page select**: tool page renders `PageSearch`; `fetchPages` runs once (guarded by `fetchedRef`, `page-search.tsx:32,36-42`) and caches the full page list in component state. Selection triggers `fetchPageFieldData` and advances to step 2. -- **Merge pass**: `fetchPageFieldData` (`actions.ts:30-56`) fetches existing `dp_Page_Fields` rows and the table metadata in parallel, then appends any `tableMetadata.Columns` that (a) aren't `IsPrimaryKey` and (b) aren't already in the returned fields. New entries get negative IDs, `View_Order = maxViewOrder + 1 + n`, `Required = col.IsRequired`, and all other props defaulted. -- **Hook initialization**: `initializeGroupedFields` (`use-field-order-state.ts:234-259`) checks whether any field has a non-null `Group_Name`; if not → flat mode, otherwise bucket by group and sort each bucket by `View_Order`. `initializeGroupOrder` (`use-field-order-state.ts:261-283`) sorts group names by the minimum `View_Order` of their members, and pins `"99 - Other Fields"` last. -- **Drag lifecycle**: `handleDragStart` snapshots both structures (`use-field-order-state.ts:54-59`), `handleDragOver` applies `@dnd-kit/helpers`' `move()` to `groupedFields` on every item drag and no-ops for group drags (`use-field-order-state.ts:62-66`), `handleDragEnd` either restores the snapshot on cancel or — for group drags — re-runs `move()` on `groupOrder` and re-pins `"99 - Other Fields"` last (`use-field-order-state.ts:69-92`). -- **Dirty tracking**: every state mutator (`addGroup`, `removeGroup`, `moveHiddenToOther`, `updateField`, and any non-canceled drag) sets `isDirty = true`. `FieldOrderEditor` forwards it through `onDirtyChange`; the tool page shows an "Unsaved changes" pill and clears it after a successful save + re-fetch. +- **Merge pass**: `fetchPageFieldData` (`actions.ts:35-89`) fetches existing `dp_Page_Fields` rows and the table metadata in parallel, tags each existing row with `isSeparator`, then appends any `tableMetadata.Columns` that (a) aren't `IsPrimaryKey` and (b) aren't already in the returned fields. New entries get negative IDs, `View_Order = maxViewOrder + 1 + n`, `Required = col.IsRequired` (always `false` for separators), and all other props defaulted. +- **Hook initialization**: `initializeGroupedFields` (`use-field-order-state.ts:297-322`) checks whether any field has a non-null `Group_Name`; if not → flat mode, otherwise bucket by group and sort each bucket by `View_Order`. `initializeGroupOrder` (`use-field-order-state.ts:324-341`) sorts group names by the minimum `View_Order` of their members, and pins `"99 - Other Fields"` last. +- **Drag lifecycle**: `handleDragStart` snapshots both structures (`use-field-order-state.ts:55-60`), `handleDragOver` applies `@dnd-kit/helpers`' `move()` to `groupedFields` on every item drag and no-ops for group drags (`use-field-order-state.ts:63-67`), `handleDragEnd` either restores the snapshot on cancel or — for group drags — re-runs `move()` on `groupOrder` and re-pins `"99 - Other Fields"` last (`use-field-order-state.ts:70-93`). +- **Dirty tracking**: every state mutator (`addGroup`, `removeGroup`, `moveHiddenToOther`, `hideAllSeparators`, `updateField`, and any non-canceled drag) sets `isDirty = true`. `FieldOrderEditor` forwards it through `onDirtyChange`; the tool page shows an "Unsaved changes" pill and clears it after a successful save + re-fetch. - **Save flow**: tool page calls `editorRef.current.getSavePayload()` → `savePageFieldOrder(payload)`. On success it toasts, re-fetches via `fetchPageFieldData` so synthetic negative IDs are replaced with real persisted IDs, and resets `isDirty`. ## Usage -From the tool page (`src/app/(web)/tools/fieldmanagement/field-management.tsx:27-64`): +From the tool page (`src/app/(web)/tools/fieldmanagement/field-management.tsx:27-64`, one comment elided): ```typescript const editorRef = useRef<FieldOrderEditorHandle>(null); @@ -196,7 +199,7 @@ const handleSave = async () => { Mounting the editor: ```typescript -// src/app/(web)/tools/fieldmanagement/field-management.tsx:174-179 +// src/app/(web)/tools/fieldmanagement/field-management.tsx:189-194 <FieldOrderEditor ref={editorRef} fields={fieldData.fields} @@ -207,14 +210,14 @@ Mounting the editor: ## Gotchas - **Synthetic `Page_Field_ID < 0`** — any ID below zero was invented by `fetchPageFieldData` and is only a React key. Do **not** send it to MP. The save payload schema (`FieldOrderPayload`) intentionally omits `Page_Field_ID`; the stored proc matches by `@PageID + @FieldName`. -- **Group numbers renumber on every save** — the user-visible "1 - …", "2 - …" prefixes are stripped and reassigned by `buildSavePayload` from current display order (`use-field-order-state.ts:188-193`). The `GROUP_PREFIX_REGEX` is `/^\d+\s*-\s*/` (`use-field-order-state.ts:10`); renaming a group to e.g. `"Contact Details"` without a prefix works and will become `"1 - Contact Details"` on save. +- **Group numbers renumber on every save** — the user-visible "1 - …", "2 - …" prefixes are stripped and reassigned by `buildSavePayload` from current display order (`use-field-order-state.ts:251-254`). The `GROUP_PREFIX_REGEX` is `/^\d+\s*-\s*/` (`use-field-order-state.ts:10`); renaming a group to e.g. `"Contact Details"` without a prefix works and will become `"1 - Contact Details"` on save. - **`"99 - Other Fields"` is a string literal, not a number** — it's compared by exact string match. Do not change its casing or spacing anywhere (`field-order-editor.tsx:14`, `use-field-order-state.ts:9`). -- **Deleting a group only works when empty** — `removeGroup` silently no-ops if the group still holds fields (`use-field-order-state.ts:123-124`); the UI enforces this by only rendering the trash icon when `fieldIds.length === 0`. -- **Primary key columns are skipped during merge** — any `IsPrimaryKey` column from `tableMetadata` is excluded (`actions.ts:38`), so PK fields that already exist in `dp_Page_Fields` stay but new ones won't be synthesized. -- **Schema-required overrides UI `Required`** — switch is disabled when `schemaRequired` is true (`sortable-field-item.tsx:127`). Flipping the underlying DB `Required` column for such a field is impossible from this UI; only `tableMetadata` can clear it. +- **Deleting a group only works when empty** — `removeGroup` silently no-ops if the group still holds fields (`use-field-order-state.ts:142`); the UI enforces this by only rendering the trash icon when `fieldIds.length === 0`. +- **Primary key columns are skipped during merge** — any `IsPrimaryKey` column from `tableMetadata` is excluded (`actions.ts:65`), so PK fields that already exist in `dp_Page_Fields` stay but new ones won't be synthesized. +- **Schema-required overrides UI `Required`** — switch is disabled when `schemaRequired` is true (`sortable-field-item.tsx:172`). Flipping the underlying DB `Required` column for such a field is impossible from this UI; only `tableMetadata` can clear it. - **`fetchPages` is fired once per mount** — `PageSearch` uses `fetchedRef` to block re-fetches; remounting the component (e.g., after a tool refresh) is the only way to refresh the page list. -- **`savePageFieldOrder` batches 5 procs at a time** — the service loops `fields` in chunks of `CONCURRENCY = 5` and awaits each batch (`src/services/fieldManagementService.ts:74,91-107`). A partial failure in batch N will short-circuit later batches, leaving `dp_Page_Fields` in a mixed state. -- **`session.user.id` check, not `userGuid`** — `getSession()` rejects on missing `user.id`, which is Better Auth's internal ID. The stored procs don't actually need the MP User_GUID, so this is intentional, but stays inconsistent with the rule in `CLAUDE.md` about preferring `userGuid` for MP lookups. +- **`savePageFieldOrder` batches 5 procs at a time** — the service loops `fields` in chunks of `CONCURRENCY = 5` and awaits each batch (`src/services/fieldManagementService.ts:88,115-117`). A partial failure in batch N will short-circuit later batches, leaving `dp_Page_Fields` in a mixed state. +- **Page-layout writes are domain-wide** — `savePageFieldOrder` changes `dp_Page_Fields` for every user in the MP domain, which is why both the action and `FieldManagementService.updatePageFieldOrder` require the `dp_Page_Fields` `update` role rather than a bare session. ## Related docs - [`../services/field-management-service.md`](../services/field-management-service.md) — `FieldManagementService` singleton, `executeProcedureWithBody` calls, batching diff --git a/.claude/references/components/group-wizard.md b/.claude/references/components/group-wizard.md index 99ebdff..8b3365a 100644 --- a/.claude/references/components/group-wizard.md +++ b/.claude/references/components/group-wizard.md @@ -18,6 +18,7 @@ symbols: - GroupSearch - groupWizardSchema - GROUP_WIZARD_DEFAULTS + - buildGroupWizardDefaults - STEP_FIELDS - WIZARD_STEPS - GroupWizardFormData @@ -32,7 +33,7 @@ related: - ../services/group-service.md - ../components/tool-framework.md - ../auth/sessions.md -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose @@ -44,8 +45,8 @@ Six-step create/edit wizard for Ministry Platform `Groups` records. Client-side |---|---| | `src/components/group-wizard/index.ts` | Barrel export (components, schema, types, constants) | | `src/components/group-wizard/types.ts` | `GroupWizardLookups`, `WIZARD_STEPS`, result/error types | -| `src/components/group-wizard/schema.ts` | Zod `groupWizardSchema`, `STEP_FIELDS`, `GROUP_WIZARD_DEFAULTS` | -| `src/components/group-wizard/actions.ts` | Server actions: lookups, contact/group search, fetch/create/update | +| `src/components/group-wizard/schema.ts` | Zod `groupWizardSchema`, `STEP_FIELDS`, `GROUP_WIZARD_DEFAULTS`, `buildGroupWizardDefaults(mpTimezone)` | +| `src/components/group-wizard/actions.ts` | Server actions: lookups, contact/group search, fetch/create/update; `requireAccess` gate + server-side `parseGroupPayload` | | `src/components/group-wizard/wizard-stepper.tsx` | Desktop ordered-list stepper + mobile progress bar | | `src/components/group-wizard/wizard-navigation.tsx` | Back / Next / Cancel / Submit footer | | `src/components/group-wizard/step-identity.tsx` | Step 0 — `Group_Name`, `Group_Type_ID`, dates, `Reason_Ended`, description | @@ -61,11 +62,11 @@ Route host (outside the component folder): | File | Role | |---|---| -| `src/app/(web)/tools/groupwizard/page.tsx` | Server entry; awaits `searchParams`, calls `parseToolParams` | -| `src/app/(web)/tools/groupwizard/group-wizard.tsx` | Client shell: `useForm`, step gating, submit dispatch, ToolContainer | +| `src/app/(web)/tools/groupwizard/page.tsx` | Server entry; `requireToolAccess({ table: "Groups", operation: "read" })` first, then `parseToolParams` (`@/lib/tool-params.server`) + `getMpTimezone()` in parallel | +| `src/app/(web)/tools/groupwizard/group-wizard.tsx` | Client shell (`{ params, mpTimezone }`): `useForm`, step gating, submit dispatch, ToolContainer | | `src/app/(web)/tools/groupwizard/loading.tsx` | Route loading skeleton | -Every component under `src/components/group-wizard/` has a co-located `*.test.tsx`. +Every component under `src/components/group-wizard/` has a co-located `*.test.tsx`; `actions.ts` and `schema.ts` have `*.test.ts` (12 test files). ## Key concepts @@ -86,36 +87,38 @@ Source: `src/components/group-wizard/types.ts:51-58` ### Create vs edit mode -- Mode is derived in the client shell (`src/app/(web)/tools/groupwizard/group-wizard.tsx:41-42`): +- Mode is derived in the client shell (`src/app/(web)/tools/groupwizard/group-wizard.tsx:42-43`): - `isNew = isNewRecord(params)` (from `@/lib/tool-params`) - `isEditMode = !isNew && !!params.recordID && params.recordID > 0` -- Edit mode effect (`group-wizard.tsx:69-84`) calls `fetchGroupRecord(recordID)` and `form.reset(result.data)`. -- `handleSubmit` (`group-wizard.tsx:121-136`) dispatches to `updateGroup` when editing, `createGroup` otherwise. +- Edit mode effect (`group-wizard.tsx:74-108`) calls `fetchGroupRecord(recordID)` and `form.reset(result.data)`. +- `handleSubmit` (`group-wizard.tsx:140-155`) dispatches to `updateGroup` when editing, `createGroup` otherwise. - Submit success → `submitResult` state → `StepReview` renders a success screen (`step-review.tsx:81-108`) with "Create Another" (new only) + "Close". -### Form state (React Hook Form 7.71.1 + Zod 4.3.6) +### Form state (`react-hook-form` ^7.89.0 + `zod` ^4.6.5) -`src/app/(web)/tools/groupwizard/group-wizard.tsx:54-58`: +`src/app/(web)/tools/groupwizard/group-wizard.tsx:57-63`: ```typescript +const initialDefaults = useMemo(() => buildGroupWizardDefaults(mpTimezone), [mpTimezone]); + const form = useForm<GroupWizardFormData>({ resolver: zodResolver(groupWizardSchema), - defaultValues: GROUP_WIZARD_DEFAULTS, + defaultValues: initialDefaults, mode: "onTouched", }); ``` - Steps consume `useFormContext<GroupWizardFormData>()` rather than prop drilling (`step-identity.tsx:29`, `step-organization.tsx:39`, etc.). -- `<Form {...form}>` wraps the entire flow in `group-wizard.tsx:188`. -- Per-step validation on Next: `form.trigger(STEP_FIELDS[currentStep])` (`group-wizard.tsx:96-109`). -- Completed steps tracked in `Set<number>` local state; clicking a past/completed step is allowed, future ones are gated (`wizard-stepper.tsx:22-41`, `group-wizard.tsx:115-119`). +- `<Form {...form}>` wraps the entire flow in `group-wizard.tsx:207`. +- Per-step validation on Next: `form.trigger(STEP_FIELDS[currentStep])` (`group-wizard.tsx:120-128`). +- Completed steps tracked in `Set<number>` local state; clicking a past/completed step is allowed, future ones are gated (`wizard-stepper.tsx:22-39`, `group-wizard.tsx:134-138`). ### Contact / group display maps IDs round-trip through the form; human-readable names are held outside RHF: -- `contactDisplayMap: Map<number, string>` and `groupDisplayMap: Map<number, string>` in the shell (`group-wizard.tsx:51-52`). -- Populated by `onContactSelect` / `onGroupSelect` callbacks from the combobox widgets (`group-wizard.tsx:86-94`). +- `contactDisplayMap: Map<number, string>` and `groupDisplayMap: Map<number, string>` in the shell (`group-wizard.tsx:52-53`). +- Populated by `onContactSelect` / `onGroupSelect` callbacks from the combobox widgets (`group-wizard.tsx:110-118`). - **Edit mode:** `fetchGroupRecord` returns `displayNames`, and the edit-mode effect seeds both maps before `form.reset`, so loaded `Primary_Contact` / `Parent_Group` / `Promote_to_Group` / `Descended_From` show names, not `ID: <n>`. ### Debounced typeahead (ContactSearch / GroupSearch) @@ -132,7 +135,7 @@ IDs round-trip through the form; human-readable names are held outside RHF: ```typescript export { StepIdentity, StepOrganization, StepMeeting, StepAttributes, StepSettings, StepReview }; export { WizardStepper, WizardNavigation, ContactSearch, GroupSearch }; -export { groupWizardSchema, GROUP_WIZARD_DEFAULTS, STEP_FIELDS }; +export { groupWizardSchema, GROUP_WIZARD_DEFAULTS, buildGroupWizardDefaults, STEP_FIELDS }; export type { GroupWizardFormData }; export { WIZARD_STEPS }; export type { @@ -149,13 +152,16 @@ fetchGroupWizardLookups(): Promise<GroupWizardLookups> searchContacts(term: string): Promise<ContactSearchResult[]> // returns [] when term.length < 2 searchGroups(term: string): Promise<GroupSearchResult[]> // returns [] when term.length < 2 fetchGroupRecord(groupId: number): - Promise<{ success: true; data: GroupWizardFormData } | ActionError> + Promise<{ success: true; data: GroupWizardFormData; + displayNames: { contacts: Record<number, string>; groups: Record<number, string> } } | ActionError> createGroup(data: GroupWizardFormData): Promise<CreateGroupResult | ActionError> updateGroup(groupId: number, data: GroupWizardFormData): Promise<UpdateGroupResult | ActionError> ``` -- All actions gate on `auth.api.getSession({ headers: await headers() })` and throw `'Unauthorized'` if no session (`actions.ts:17-21`). -- `createGroup` / `updateGroup` additionally resolve the MP `$userId` from `session.user.userGuid` via `UserService.getUserIdByGuid` (`actions.ts:23-29, 69, 84`). +- All actions call `requireAccess(table, operation)` (`actions.ts:28-33`) → `AuthorizationService.requireSecurityRole`: `Groups`/`read` for lookups, `searchGroups`, `fetchGroupRecord`; `Contacts`/`read` for `searchContacts`; `Groups`/`create` and `Groups`/`update` for the writes. The gate runs **before** the short-query `[]` return. +- `fetchGroupRecord` / `createGroup` / `updateGroup` catch errors (including a refusal) and return `ActionError`; the lookup and search actions let the refusal propagate. +- `createGroup` / `updateGroup` re-validate the payload server-side with `parseGroupPayload` (`actions.ts:44-51`, `groupWizardSchema.safeParse`); failure → `Invalid group data: <field names>` (names only, never values). +- No action passes `$userId`: `GroupService` takes write attribution from its own `requireSecurityRole` call (CLAUDE.md rule 13). - All delegate to `GroupService.getInstance()` from `src/services/groupService.ts`. ### Step component prop contracts @@ -183,16 +189,16 @@ updateGroup(groupId: number, data: GroupWizardFormData): Promise<UpdateGroupResu ## How it works -1. Route `/tools/groupwizard` resolves `searchParams` via `parseToolParams` (`page.tsx:9`). -2. Client shell initialises RHF with `GROUP_WIZARD_DEFAULTS`, mode `onTouched`. +1. Route `/tools/groupwizard` gates with `requireToolAccess` (`page.tsx:12`), then resolves `searchParams` via `parseToolParams` and the MP time zone via `getMpTimezone()` (`page.tsx:14-17`). +2. Client shell initialises RHF with `buildGroupWizardDefaults(mpTimezone)` (`Start_Date` = today in the MP domain zone), mode `onTouched`. 3. Mount effects load `fetchGroupWizardLookups()`; in edit mode, also `fetchGroupRecord(recordID)` → `form.reset(data)`. 4. `<WizardStepper>` renders steps; non-current step clicks are only allowed if `completedSteps.has(index) || index < currentStep`. 5. `<WizardNavigation>` "Next" calls `handleNext`: runs `form.trigger(STEP_FIELDS[currentStep])`; on success, marks current step complete and advances. -6. On final step, the primary button switches to Create/Save (`wizard-navigation.tsx:55-69`). Clicking calls `handleSubmit`: +6. On final step, the primary button switches to Create/Save (`wizard-navigation.tsx:55-68`). Clicking calls `handleSubmit`: - `createGroup(data)` or `updateGroup(recordID, data)` → service → MP API. - Success → `submitResult` state → `StepReview` shows success screen; stepper + nav are hidden. - - Failure → `loadError` is set, which replaces the form body with an `Alert` (`group-wizard.tsx:180-186`). -7. "Create Another" resets the form + step state; "Close" calls `router.back()`. + - Failure → `loadError` is set, which replaces the form body with an `Alert` (`group-wizard.tsx:199-205`). +7. "Create Another" resets the form to `buildGroupWizardDefaults(mpTimezone)` + step state; "Close" calls `router.back()`. ## Usage @@ -200,19 +206,28 @@ Route-level mount (`src/app/(web)/tools/groupwizard/page.tsx`): ```typescript import { GroupWizard } from "./group-wizard"; -import { parseToolParams } from "@/lib/tool-params"; +import { requireToolAccess } from "@/app/(web)/tools/require-tool-access"; +import { parseToolParams } from "@/lib/tool-params.server"; +import { getMpTimezone } from "@/components/shared-actions/domain"; interface GroupWizardPageProps { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; } export default async function GroupWizardPage({ searchParams }: GroupWizardPageProps) { - const params = await parseToolParams(await searchParams); - return <GroupWizard params={params} />; + // Self-gating, before ANY param parsing or data call — see require-tool-access.ts. + await requireToolAccess({ table: "Groups", operation: "read" }); + + const [params, mpTimezone] = await Promise.all([ + parseToolParams(await searchParams), + getMpTimezone(), + ]); + + return <GroupWizard params={params} mpTimezone={mpTimezone} />; } ``` -Client shell dispatch (`src/app/(web)/tools/groupwizard/group-wizard.tsx:121-136`): +Client shell dispatch (`src/app/(web)/tools/groupwizard/group-wizard.tsx:140-155`): ```typescript const handleSubmit = useCallback(async () => { @@ -236,16 +251,16 @@ const handleSubmit = useCallback(async () => { ## Gotchas - **Quoted special-character field name.** `Secure_Check-in` contains a hyphen — it is referenced with bracket/string syntax throughout: `schema.ts:43` (`'Secure_Check-in': z.boolean()`), `STEP_FIELDS` uses `'Secure_Check-in' as keyof GroupWizardFormData` (`schema.ts:63`), `step-settings.tsx:115`, `step-review.tsx:200, 206`. Do not rename without also updating all string literals. -- **`Start_Date` default is `new Date().toISOString().split('T')[0]`** (`schema.ts:71`). This is evaluated at module load, which in a Next.js App Router client bundle means it is fixed at the first render of the client component (fine), but tests importing the module will capture the test-run date. -- **Per-step validation gates Next, not form submit.** `form.trigger([fields])` is called in `handleNext`; `handleSubmit` (`group-wizard.tsx:121`) does **not** re-run Zod validation before calling the server action. Server action relies on RHF+Zod having already kept the form valid. `GROUP_WIZARD_DEFAULTS` uses `undefined as unknown as number` casts for four required ID fields (`Group_Type_ID`, `Congregation_ID`, `Ministry_ID`, `Primary_Contact`, `schema.ts:69-76`) — those will fail Zod on `trigger` before the user can reach Review, which is the intended safety net. +- **`Start_Date` default comes from `buildGroupWizardDefaults(mpTimezone)`** (`schema.ts:136-140`), i.e. today's date in the MP domain time zone. The static `GROUP_WIZARD_DEFAULTS` leaves `Start_Date: ''` (`schema.ts:90`) — use the builder, never `new Date().toISOString()` (CLAUDE.md rule 17). +- **Per-step validation gates Next, not form submit.** `form.trigger([fields])` is called in `handleNext`; the client `handleSubmit` (`group-wizard.tsx:140`) does **not** re-run Zod. The server does: `createGroup`/`updateGroup` run `parseGroupPayload` before any MP call. `GROUP_WIZARD_DEFAULTS` uses `undefined as unknown as number` casts for four required ID fields (`Group_Type_ID`, `Congregation_ID`, `Ministry_ID`, `Primary_Contact`, `schema.ts:88-95`) — those fail Zod on `trigger` before the user can reach Review. - **No unsaved-changes guard.** Navigating away (closing the tool, clicking Cancel, or hitting browser back) discards form state silently — no confirmation dialog. Multi-step progress is lost. Known UX gap; no TODO filed. -- **Search actions silent-return `[]` for short queries.** `searchContacts` / `searchGroups` return `[]` if `term.length < 2` (`actions.ts:39, 46`). The combobox also enforces this client-side (`contact-search.tsx:38-41`, `group-search.tsx:45-48`). Do not rely on server validation alone. +- **Search actions silent-return `[]` for short queries.** `searchContacts` / `searchGroups` return `[]` if `term.length < 2` (`actions.ts:61, 68`), after the authorization gate. The combobox also enforces this client-side (`contact-search.tsx:38-41`, `group-search.tsx:45-48`). Do not rely on server validation alone. - **`ContactSearch` has no "clear" affordance.** Unlike `GroupSearch` (which shows a "Clear selection" row when `value` is set, `group-search.tsx:108-122`), `ContactSearch` cannot be cleared — users must pick a different contact. `Primary_Contact` is required so this is intentional, but it means stray test selections can't be undone without a form reset. -- **Step click gating can orphan steps after edit-and-jump.** Clicking Edit on a Review card jumps to that step; if the user then goes Back/Next through, later steps remain in `completedSteps` even if their now-dependent values changed — Next does re-validate, but already-advanced steps are not re-validated until the user explicitly revisits them (`group-wizard.tsx:115-119`). +- **Step click gating can orphan steps after edit-and-jump.** Clicking Edit on a Review card jumps to that step; if the user then goes Back/Next through, later steps remain in `completedSteps` even if their now-dependent values changed — Next does re-validate, but already-advanced steps are not re-validated until the user explicitly revisits them (`group-wizard.tsx:134-138`). ## Related docs - `../services/group-service.md` — `GroupService` (`createGroup`, `updateGroup`, `getGroup`, `fetchAllLookups`, `searchContacts`, `searchGroups`) - `../components/tool-framework.md` — `ToolContainer` wrapper used by the route shell -- `../auth/sessions.md` — `session.user.userGuid` → `UserService.getUserIdByGuid` chain used by `createGroup`/`updateGroup` -- `../routing/tool-params.md` — `ToolParams`, `isNewRecord`, `parseToolParams` contract consumed by the page +- `../auth/sessions.md` — `session.user.userGuid` (the gate resolves the MP `User_ID` from it) +- `../utils/tool-params.md` — `ToolParams`, `isNewRecord`, `parseToolParams` contract consumed by the page diff --git a/.claude/references/components/layout.md b/.claude/references/components/layout.md index cd82a58..8505690 100644 --- a/.claude/references/components/layout.md +++ b/.claude/references/components/layout.md @@ -9,14 +9,17 @@ applies_to: - src/components/shared-actions/user.ts - src/components/shared-actions/user.test.ts - src/components/shared-actions/sign-out.ts + - src/components/shared-actions/sign-out.test.ts + - src/components/shared-actions/domain.ts + - src/components/shared-actions/domain.test.ts - src/components/shared-actions/README.md -symbols: [AuthWrapper, getCurrentUserProfile] +symbols: [AuthWrapper, getCurrentUserProfile, getMpTimezone, handleSignOut] related: - ../auth/README.md - ../routing/README.md - ../services/README.md - tool-framework.md -last_verified: 2026-09-13 +last_verified: 2026-10-10 --- ## Purpose @@ -28,7 +31,10 @@ last_verified: 2026-09-13 - `src/components/layout/index.ts` — barrel: `AuthWrapper` - `src/components/shared-actions/user.ts` — `getCurrentUserProfile` server action - `src/components/shared-actions/user.test.ts` — unit tests for the action -- `src/components/shared-actions/sign-out.ts` — `handleSignOut` server action (see `auth/oauth-flow.md` § Sign-out flow) +- `src/components/shared-actions/sign-out.ts` — `handleSignOut` server action (see `../auth/oauth-flow.md` § Sign-out flow); bound to the sign-out `<form action>` on `/no-access` and `/session-error`, the app's only sign-out controls +- `src/components/shared-actions/sign-out.test.ts` — end-session URL / `id_token_hint` / env-guard tests +- `src/components/shared-actions/domain.ts` — `getMpTimezone` server action (session-only carve-out; see source docstring) +- `src/components/shared-actions/domain.test.ts` — unit tests for the action - `src/components/shared-actions/README.md` — when-to-shard-vs-colocate guidelines ## Key concepts @@ -36,7 +42,7 @@ last_verified: 2026-09-13 - Session validation is stateless: `auth.api.getSession({ headers })` reads the Better Auth JWT cookie (see `../auth/README.md`). - Redirect uses a synthetic `http://placeholder` base so we can use `URL` + `searchParams` to build the querystring safely without string concat. - The `x-pathname` header is set upstream by the proxy (`src/proxy.ts`) so the server component can see the original requested URL; it falls back to `/` when absent. -- `shared-actions/user.ts` is marked `'use server'` at the top of the file — all exports are server actions. +- `user.ts`, `domain.ts` and `sign-out.ts` are each marked `'use server'` at the top of the file — all exports are server actions. - Shared actions re-validate auth inside each action (`auth.api.getSession(...)`) — they do not trust the caller. - `getCurrentUserProfile` takes **no parameters**. The MP `User_GUID` is derived from the session inside the action. A caller-supplied GUID would be a live IDOR: server actions are caller-shaped POST endpoints, so any authenticated MP user could have read another user's contact details, roles, and user groups. - The guard keys on a non-empty-string `session.user.userGuid` (declared `required: true` in `src/lib/auth.ts`), not `session.user.id` — the latter is Better Auth's internal ID and its presence does not prove an MP identity exists. @@ -125,7 +131,9 @@ export async function getCurrentUserProfile(): Promise<MPUserProfile | undefined ## Shared Actions catalog | Export | File | Purpose | |---|---|---| -| `getCurrentUserProfile()` | `src/components/shared-actions/user.ts:25` | Fetch the **calling** user's MP profile (`MPUserProfile`); `User_GUID` comes from the session, never from a parameter. Throws `Unauthorized` if the session has no `userGuid`. Backed by `UserService.getUserProfile`. | +| `getCurrentUserProfile()` | `src/components/shared-actions/user.ts:25` | Fetch the **calling** user's MP profile (`MPUserProfile`); `User_GUID` comes from the session, never from a parameter. Throws `Unauthorized` if the session has no `userGuid`. Backed by `UserService.getUserProfile`. Consumer: `src/contexts/user-context.tsx`. | +| `getMpTimezone()` | `src/components/shared-actions/domain.ts:29` | IANA time zone of the MP domain. Requires a session (`session.user.id`), throws `Authentication required` otherwise; deliberately not role-gated (rule-12 carve-out). Backed by `DomainTimezoneService`. Consumer: `src/app/(web)/tools/groupwizard/page.tsx`. | +| `handleSignOut()` | `src/components/shared-actions/sign-out.ts:50` | `auth.api.signOut` first, then `redirect()` to MP `/oauth/connect/endsession` with `post_logout_redirect_uri` + `client_id` (+ `id_token_hint` only from an MP-origin logout URL). Consumers: `src/app/(web)/no-access/page.tsx`, `src/app/session-error/page.tsx`. | Guidelines (verbatim from `src/components/shared-actions/README.md`): - Place actions here when they are **used by multiple components across different features**, provide **shared utility**, or handle **cross-cutting concerns**. @@ -146,14 +154,16 @@ Guidelines (verbatim from `src/components/shared-actions/README.md`): - throws `Unauthorized` when `userGuid` is an empty string - throws `Unauthorized` when `auth.api.getSession()` returns `null` - propagates service-layer errors +- `src/components/shared-actions/domain.test.ts` — 6 cases (session required, service delegation, error propagation) +- `src/components/shared-actions/sign-out.test.ts` — 13 cases (9 `it` + one 4-row `it.each`): session cleared first, end-session URL shape, MP-origin-only `id_token_hint`, env guards (missing/non-https MP URL, missing auth URL, missing client secret), `NEXTAUTH_URL` fallback, trailing-slash normalization -Both test files use `vi.hoisted()` to share mock references (required pattern — see `../testing/README.md`). +All four test files use `vi.hoisted()` to share mock references (required pattern — see `../testing/README.md`). ## Gotchas - **Must stay a server component.** Adding `"use client"` to `auth-wrapper.tsx` silently breaks auth — client components cannot call `auth.api.getSession()` with server headers. Put any interactive state into a child that you render from inside `<AuthWrapper>`. - **`redirect()` throws.** `next/navigation` `redirect()` aborts rendering by throwing a magic error. Do not wrap in try/catch; do not add code after the redirect call expecting it to run on the unauthenticated branch. - **`callbackUrl` relies on `x-pathname`.** If a route bypasses the proxy (or a future proxy matcher excludes it), `x-pathname` will be missing and unauthenticated users land on `/` after sign-in. Verify proxy matcher coverage in `src/proxy.ts` when adding new protected routes. -- **Shared actions must re-validate auth.** `getCurrentUserProfile` calls `auth.api.getSession()` itself rather than trusting caller context. Any new action added here must do the same (see `../auth/README.md` for session access patterns). +- **Shared actions must re-validate auth.** `getCurrentUserProfile` and `getMpTimezone` call `auth.api.getSession()` themselves rather than trusting caller context. Any new action added here must do the same (see `../auth/README.md` for session access patterns). - **Never re-add an identity parameter.** `getCurrentUserProfile` is a CLAUDE.md rule-12 carve-out from the `AuthorizationService` gate on the grounds that it returns only the caller's own profile. That justification holds only because there is no GUID argument to forge; adding one re-opens the IDOR. ## Related docs diff --git a/.claude/references/components/tool-framework.md b/.claude/references/components/tool-framework.md index bfd13e4..a5dfbeb 100644 --- a/.claude/references/components/tool-framework.md +++ b/.claude/references/components/tool-framework.md @@ -7,12 +7,13 @@ applies_to: - src/components/tool/tool-header.tsx - src/components/tool/tool-footer.tsx - src/components/tool/index.ts + - src/components/tool/tool-framework.test.tsx symbols: [ToolContainer, ToolHeader, ToolFooter] related: - dev-panel.md - layout.md - ui.md -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose @@ -23,11 +24,12 @@ Reusable layout shell used by every tool page: a dark slate title bar, a scrolla - `src/components/tool/tool-header.tsx` — dark slate (`bg-slate-700`) title bar with optional info tooltip - `src/components/tool/tool-footer.tsx` — white footer with Close + Save buttons and loading state - `src/components/tool/index.ts` — barrel: `ToolContainer`, `ToolFooter`, `ToolHeader` +- `src/components/tool/tool-framework.test.tsx` — 13 cases covering all three (`DevPanel` mocked; `params`/`infoContent`/`hideFooter` branches) ## Key concepts - Composition via `children`; no internal router or data fetching - `"use client"` only on `ToolContainer` (it imports the client-only `DevPanel`); `ToolHeader` and `ToolFooter` are plain React (no `"use client"` directive, but contain no async server work — safe to import from either) -- Dev overlay gating lives inside `DevPanel`, not `ToolContainer`: `ToolContainer` always renders `<DevPanel />` when `params` is passed, and `DevPanel` itself early-returns `null` when `NODE_ENV !== "development"` or hostname is not `localhost`/`127.0.0.1` (`src/components/dev-panel/dev-panel.tsx:20,56-58`) +- Dev overlay gating lives inside `DevPanel`, not `ToolContainer`: `ToolContainer` always renders `<DevPanel />` when `params` is passed, and `DevPanel` itself early-returns `null` when `NODE_ENV !== "development"` or hostname is not `localhost`/`127.0.0.1` (`src/components/dev-panel/dev-panel.tsx:14-22,59-61`) - Footer buttons: Close is an `outline` variant; Save uses `bg-cyan-600 hover:bg-cyan-700` and swaps label to `"Saving..."` while `isSaving` - Header info tooltip uses shadcn `Tooltip` + `Info` icon from lucide, anchored to `side="left"` with `max-w-xs` @@ -50,7 +52,7 @@ interface ToolContainerProps { children: React.ReactNode; } ``` -`ToolParams` comes from `src/lib/tool-params.ts:17-28`. +`ToolParams` comes from `src/lib/tool-params.ts:28-39`. ### `ToolHeader` Source: `src/components/tool/tool-header.tsx:9-12` diff --git a/.claude/references/components/ui.md b/.claude/references/components/ui.md index 515283b..9aafab9 100644 --- a/.claude/references/components/ui.md +++ b/.claude/references/components/ui.md @@ -9,7 +9,7 @@ symbols: [Button, Dialog, Tooltip, Form, Select, DropdownMenu, Command, Drawer, related: - tool-framework.md - ../testing/README.md -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose @@ -49,16 +49,16 @@ From `components.json`: | `badge.tsx` | `Badge`, `badgeVariants` — small status pill | | `breadcrumb.tsx` | `Breadcrumb` + parts — navigation trail | | `button.tsx` | `Button`, `buttonVariants` — variants: `default | destructive | outline | secondary | ghost | link`; sizes: `default | sm | lg | icon` | -| `card.tsx` | `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter` | +| `card.tsx` | `Card`, `CardHeader`, `CardTitle`, `CardAction`, `CardDescription`, `CardContent`, `CardFooter` | | `checkbox.tsx` | `Checkbox` — Radix checkbox primitive | | `command.tsx` | `Command` + parts — cmdk-based searchable list (used inside popovers) | | `dialog.tsx` | `Dialog` + parts — generic modal | | `drawer.tsx` | `Drawer` + parts — vaul bottom sheet | | `dropdown-menu.tsx` | `DropdownMenu` + parts — Radix menu | -| `form.tsx` | `Form`, `FormField`, `FormItem`, `FormLabel`, `FormControl`, `FormDescription`, `FormMessage` — react-hook-form bindings | +| `form.tsx` | `Form`, `FormField`, `FormItem`, `FormLabel`, `FormControl`, `FormDescription`, `FormMessage`, `useFormField` — react-hook-form bindings | | `input.tsx` | `Input` — text input | | `label.tsx` | `Label` — Radix label | -| `popover.tsx` | `Popover`, `PopoverTrigger`, `PopoverContent` — Radix popover | +| `popover.tsx` | `Popover`, `PopoverTrigger`, `PopoverContent`, `PopoverAnchor` — Radix popover | | `radio-group.tsx` | `RadioGroup`, `RadioGroupItem` | | `select.tsx` | `Select` + parts — Radix select (non-searchable) | | `skeleton.tsx` | `Skeleton` — shimmer placeholder | @@ -66,24 +66,24 @@ From `components.json`: | `textarea.tsx` | `Textarea` — multiline input | | `tooltip.tsx` | `Tooltip`, `TooltipTrigger`, `TooltipContent`, `TooltipProvider` — auto-wraps with `TooltipProvider` internally | -(22 files — matches the facts snapshot for 2026-04-17.) +(22 files — re-counted 2026-10-10; see `../_meta/facts/2026-10-10.md`.) ## Key concepts - **new-york style** — tighter spacing, subtler borders than the `default` style - **slate base color with CSS variables** — theme tokens live in `src/app/globals.css` (e.g., `--primary`, `--background`). Components reference `bg-primary`, `text-foreground`, etc., not raw slate shades. - **lucide icons** — all iconography is `lucide-react` (e.g., `Info` in `tool-header.tsx`, `ChevronDown`/`Terminal` in `dev-panel.tsx`). Do not introduce another icon set. - **cva variants** — primitives with variants (`Button`, `Badge`) use `class-variance-authority`; `buttonVariants` / `badgeVariants` are exported so consumers can apply button styling to a `<Link>` via the shadcn `asChild` pattern. -- **Radix primitives under the hood** — most files re-export a Radix package with Tailwind classes applied (`@radix-ui/react-tooltip`, `@radix-ui/react-dialog`, etc.). +- **Radix primitives under the hood** — most files wrap an individual `@radix-ui/react-*` package (`@radix-ui/react-tooltip`, `@radix-ui/react-dialog`, etc.; `Slot` from `@radix-ui/react-slot`) with Tailwind classes applied. +- **Plain function components with `data-slot`** — no `forwardRef` anywhere in `src/components/ui/` (React 19 passes `ref` as a prop); each part sets a `data-slot` attribute. - **`cn()` utility** — every primitive uses `cn()` from `@/lib/utils` for class merging. ## Conventions when editing / adding - Run `npx shadcn add <name>` to pull a new primitive — it will respect `components.json` (style + aliases + icon lib). - Do not refactor shadcn primitives into default exports; the project enforces **named exports only** across `src/**`. -- Keep `"use client"` at the top of each primitive file — these always render interactively. +- Keep `"use client"` where shadcn put it: the 14 Radix/stateful primitives have it; `alert`, `badge`, `breadcrumb`, `button`, `card`, `input`, `skeleton`, `textarea` do not (server-renderable). - New primitives should re-use `cn()` and CSS-variable theme tokens; avoid hardcoding `slate-*` colors. ## Gotchas -- **`dialog.tsx` mixes patterns.** It uses both direct assignment and `forwardRef` in places — works, but inconsistent with the rest. Avoid copy-pasting its style as the blueprint; prefer `tooltip.tsx` or `popover.tsx`. - **`Tooltip` auto-wraps with `TooltipProvider`.** You don't need to add a provider at the app root when using `<Tooltip>` directly (see `src/components/ui/tooltip.tsx:21-29`). But if you mount many tooltips, wrapping a subtree in a single `<TooltipProvider>` manually can still be a perf win. - **Save button in `ToolFooter` is hardcoded cyan**, not `Button`'s `default` variant. See `tool-framework.md`. diff --git a/.claude/references/contexts/README.md b/.claude/references/contexts/README.md index 9730be6..d12e971 100644 --- a/.claude/references/contexts/README.md +++ b/.claude/references/contexts/README.md @@ -5,7 +5,7 @@ domain: contexts --- ## What's in this domain -Two React client contexts composed at the app shell: `UserProvider` (enriched MP profile, fetched from `userGuid`) and `useAppSession` (thin wrapper around Better Auth's `authClient.useSession()`). `session-context.tsx` is a hook module, not a `Context` — naming is legacy. +Two React client contexts composed at the app shell: `UserProvider` (enriched MP profile, fetched from `userGuid`) and `useAppSession` (thin wrapper around Better Auth's `authClient.useSession()`). `use-app-session.ts` is a hook module, not a `Context`. Both hooks are kept as a documented **extension point** for tools built on the template — nothing outside `src/contexts/` calls them today (the header `UserMenu` was removed in #54). `UserProvider` is mounted by `src/app/providers.tsx` but fetches **lazily**: it makes no MP call until some component calls `useUser()`, and all consumers share that one load. ## File map | File | Purpose | When to read | @@ -16,10 +16,10 @@ Two React client contexts composed at the app shell: `UserProvider` (enriched MP ## Code surfaces | Path | Role | |------|------| -| `src/contexts/user-context.tsx` | `UserProvider` + `useUser` hook (loads `MPUserProfile` from `userGuid`) | -| `src/contexts/user-context.test.tsx` | 6 test cases: throw-outside-provider, load-with-guid, null-session, missing-guid, error path, refresh | -| `src/contexts/session-context.tsx` | `useAppSession()` + `SessionData` type alias | -| `src/contexts/session-context.test.tsx` | 2 test cases: data passthrough, null session | +| `src/contexts/user-context.tsx` | `UserProvider` + `useUser` hook (lazily loads `MPUserProfile` from `userGuid` on first `useUser()`) | +| `src/contexts/user-context.test.tsx` | 14 test cases: throw-outside-provider, load-with-guid, null-session, missing-guid, error path, refresh, plus refresh-without-guid, pending state, non-Error rejection, undefined profile, error cleared on refresh, plus lazy fetch (no consumer → no call, two consumers → one call, late consumer triggers the call) | +| `src/contexts/use-app-session.ts` | `useAppSession()` + `SessionData` type alias | +| `src/contexts/use-app-session.test.ts` | 2 test cases: data passthrough, null session | | `src/contexts/index.ts` | Barrel: `UserProvider`, `useUser`, `useAppSession`, `SessionData` | | `src/app/providers.tsx` | Client `Providers` — mounts `<UserProvider>` + Sonner `<Toaster>` | diff --git a/.claude/references/contexts/session.md b/.claude/references/contexts/session.md index 10e006c..ad03412 100644 --- a/.claude/references/contexts/session.md +++ b/.claude/references/contexts/session.md @@ -2,30 +2,31 @@ title: useAppSession wrapper domain: contexts type: reference -applies_to: [src/contexts/session-context.tsx, src/contexts/session-context.test.tsx] +applies_to: [src/contexts/use-app-session.ts, src/contexts/use-app-session.test.ts] symbols: [useAppSession, SessionData] related: [user-provider.md, ../auth/README.md] -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose Thin hook that returns only the `data` portion of Better Auth's `authClient.useSession()`. Encapsulates the session-access pattern so callers don't destructure `{ data, isPending, error }` inline at every consumer. ## Files -- `src/contexts/session-context.tsx` — `useAppSession()` + `SessionData` type alias -- `src/contexts/session-context.test.tsx` — 2 test cases (passthrough + null) -- `src/lib/auth-client.ts` — `authClient = createAuthClient({ plugins: [genericOAuthClient(), customSessionClient<typeof auth>()] })` +- `src/contexts/use-app-session.ts` — `useAppSession()` + `SessionData` type alias +- `src/contexts/use-app-session.test.ts` — 2 test cases (passthrough + null) +- `src/lib/auth-client.ts` — `authClient = createAuthClient({ plugins: [customSessionClient<typeof auth>()] })` (no `genericOAuthClient()` — removed in Better Auth 1.7; see the note in that file) ## Key concepts -- **Not a React Context** — `session-context.tsx` defines no `createContext`. The filename is legacy; it is purely a hook module. +- **Not a React Context** — `use-app-session.ts` defines no `createContext`; it is purely a hook module. +- **Extension point, no current consumers** — nothing outside `src/contexts/` imports `useAppSession` (the header `UserMenu` that used it was removed in #54); it is kept, with `useUser()`, as the template's documented client-side session/profile surface for new tools. It makes no MP call (session data only). - **`authClient.useSession()` is a Better Auth nanostore hook** — provided by `better-auth/react`; refetches on visibility / network events. - **`SessionData` is inferred from `authClient.$Infer.Session`** — the `customSessionClient<typeof auth>()` plugin flows server-side `customSession` return types to the client. -- **`userGuid`** lives on `session.user` because of the server-side `customSession` plugin (see `../auth/`), but its type is not part of `SessionData` by default — consumers assert via `(session?.user as { userGuid?: string })` (example: `src/contexts/user-context.tsx:29`). +- **`userGuid`** lives on `session.user` because of the server-side `customSession` plugin (see `../auth/`), but its type is not part of `SessionData` by default — consumers assert via `(session?.user as { userGuid?: string })` (example: `src/contexts/user-context.tsx:42`). ## API / Interface ```typescript -// src/contexts/session-context.tsx +// src/contexts/use-app-session.ts "use client"; import { authClient } from "@/lib/auth-client"; @@ -41,8 +42,8 @@ export function useAppSession() { Barrel re-export (`src/contexts/index.ts`): ```typescript -export { useAppSession } from "./session-context"; -export type { SessionData } from "./session-context"; +export { useAppSession } from "./use-app-session"; +export type { SessionData } from "./use-app-session"; ``` ## How it works @@ -54,10 +55,10 @@ export type { SessionData } from "./session-context"; - **Single import path for consumers** — `import { useAppSession } from '@/contexts'` instead of `import { authClient } from '@/lib/auth-client'` every time. - **Stable API surface** — if Better Auth's hook signature changes, only this module updates. - **Intent signaling** — "app session" name documents that this is the authenticated-user session, not a cookie/session-storage primitive. -- **Limitation**: drops `isPending`. Components that must distinguish "loading" from "signed-out" (like `UserProvider`) bypass this hook and call `authClient.useSession()` directly (`src/contexts/user-context.tsx:22`). +- **Limitation**: drops `isPending`. Components that must distinguish "loading" from "signed-out" (like `UserProvider`) bypass this hook and call `authClient.useSession()` directly (`src/contexts/user-context.tsx:35`). ## Usage -Consumer pattern (test file `session-context.test.tsx:17-26`): +Consumer pattern (test file `use-app-session.test.ts:17-26`): ```typescript mockUseSession.mockReturnValue({ data: mockSessionData }); @@ -68,11 +69,11 @@ const result = useAppSession(); When a component needs `isPending` too, import `authClient` directly: ```typescript -// src/contexts/user-context.tsx:22 +// src/contexts/user-context.tsx:35 const { data: session, isPending } = authClient.useSession(); ``` -## Test coverage (`session-context.test.tsx`) +## Test coverage (`use-app-session.test.ts`) | Test | Line | Asserts | |---|---|---| @@ -80,8 +81,8 @@ const { data: session, isPending } = authClient.useSession(); | returns null when no session | 28 | `{ data: null }` → hook returns `null` | ## Gotchas -- Use the wrapper for read-only session access; drop to `authClient.useSession()` when you need `isPending` / `error` / `refetch` (`src/contexts/user-context.tsx:22`). -- The `customSession` plugin only runs server-side; `SessionData.user.userGuid` is present at runtime but may require a type assertion (see pattern in `user-context.tsx:29`). +- Use the wrapper for read-only session access; drop to `authClient.useSession()` when you need `isPending` / `error` / `refetch` (`src/contexts/user-context.tsx:35`). +- The `customSession` plugin only runs server-side; `SessionData.user.userGuid` is present at runtime but may require a type assertion (see pattern in `user-context.tsx:42`). - Do not import from `@/lib/auth-client` in a feature component purely to read the session — prefer `useAppSession` from `@/contexts` for consistency. ## Related docs diff --git a/.claude/references/contexts/user-provider.md b/.claude/references/contexts/user-provider.md index ed61eda..54086a8 100644 --- a/.claude/references/contexts/user-provider.md +++ b/.claude/references/contexts/user-provider.md @@ -5,26 +5,29 @@ type: reference applies_to: [src/contexts/user-context.tsx, src/contexts/user-context.test.tsx, src/app/providers.tsx] symbols: [UserProvider, useUser, MPUserProfile] related: [session.md, ../auth/README.md, ../services/README.md] -last_verified: 2026-09-13 +last_verified: 2026-10-10 --- ## Purpose -Client context that watches the Better Auth session, extracts `userGuid` (OIDC `sub` → MP `User_GUID`), and loads the display-only `MPUserProfile` (names + email; no IDs, roles, or groups) via the `getCurrentUserProfile` server action. `userGuid` gates *whether* the load fires; the action re-derives it server-side and takes no argument. +Client context that watches the Better Auth session, extracts `userGuid` (OIDC `sub` → MP `User_GUID`), and loads the display-only `MPUserProfile` (names + email; no IDs, roles, or groups) via the `getCurrentUserProfile` server action. The load is **lazy**: nothing is fetched until a component calls `useUser()`. `userGuid` gates *whether* the load fires; the action re-derives it server-side and takes no argument. + +Kept as an **extension point** for tools built on the template: no component outside `src/contexts/` calls `useUser()` today (the header `UserMenu` was removed in #54), so the mounted provider costs nothing until a tool opts in. ## Files - `src/contexts/user-context.tsx` — provider + hook + error handling -- `src/contexts/user-context.test.tsx` — 6 test cases covering load/error/refresh/missing-guid +- `src/contexts/user-context.test.tsx` — 14 test cases covering load/error/refresh/missing-guid, edge branches, and lazy fetch - `src/app/providers.tsx` — mounts `<UserProvider>` at the app shell - `src/components/shared-actions/user.ts` — `getCurrentUserProfile()` server action (no parameters; derives the GUID from the session) - `src/services/userService.ts` — `UserService.getUserProfile(guid)` downstream lookup - `src/lib/providers/ministry-platform/types/user-profile.types.ts` — `MPUserProfile` shape ## Key concepts -- **Client-side profile load** — MP profile is fetched after mount, not injected by the server. Page renders before `userProfile` is available (`isLoading` starts `true`). -- **`userGuid` is the key** — `session.user.id` is Better Auth's internal ID; `session.user.userGuid` is the MP `User_GUID`. Only `userGuid` is used for MP lookups (`user-context.tsx:27-29`). -- **Effect gating** — the effect only fires load when `!isPending && userGuid`; the else-branch (`!isPending && !session`) clears state. If the session exists but has no `userGuid`, **neither branch runs** (`user-context.tsx:51-59`; test at `user-context.test.tsx:88-99`). -- **Error state is local** — `getCurrentUserProfile` rejections are caught; `error` is exposed on context, `userProfile` is reset to `null`, `isLoading` ends `false` (`user-context.tsx:43-48`). -- **`useUser` throws outside a provider** — guard at `user-context.tsx:79-81`. +- **Lazy, client-side profile load** — MP profile is fetched after the first `useUser()` consumer mounts, not on provider mount and not injected by the server. Page renders before `userProfile` is available (`isLoading` starts `true`). +- **One load, shared** — `useUser()` runs an effect that calls an internal `requestProfile()` (from a private `UserProfileRequestContext`), which sets the provider's `requested` state to `true`. Setting it again is a React bail-out, so N consumers cause one fetch. The private context keeps `useUser()`'s return shape unchanged. +- **`userGuid` is the key** — `session.user.id` is Better Auth's internal ID; `session.user.userGuid` is the MP `User_GUID`. Only `userGuid` is used for MP lookups (`user-context.tsx:40-42`). +- **Effect gating** — the provider effect does nothing until `requested`; then it fires load when `!isPending && userGuid`, and the else-branch (`!isPending && !session`) clears state. If the session exists but has no `userGuid`, **neither branch runs** (test: "should not fetch profile when session has no userGuid"). +- **Error state is local** — `getCurrentUserProfile` rejections are caught; `error` is exposed on context, `userProfile` is reset to `null`, `isLoading` ends `false`. +- **`useUser` throws outside a provider** — guard at the end of `useUser()`. ## API / Interface @@ -52,9 +55,10 @@ export interface MPUserProfile { ``` ## How it works -- On mount: `const { data: session, isPending } = authClient.useSession()`. +- On mount: `const { data: session, isPending } = authClient.useSession()`; `requested` starts `false` — **no fetch yet**. - `userGuid = (session?.user as { userGuid?: string } | undefined)?.userGuid`. -- `useEffect` fires `loadUserProfile()` when `!isPending && userGuid` truthy. +- A consumer's `useUser()` effect calls `requestProfile()` → `setRequested(true)` (idempotent). +- Provider `useEffect` fires `loadUserProfile()` when `requested && !isPending && userGuid`. - `loadUserProfile`: - Sets `isLoading=true`, `error=null`. - Calls `getCurrentUserProfile()` — no argument; the action re-derives the GUID from the server-side session. @@ -75,6 +79,10 @@ import { UserProvider } from "@/contexts/user-context"; import { Toaster } from "sonner"; import { ReactNode } from "react"; +interface ProvidersProps { + children: ReactNode; +} + export function Providers({ children }: ProvidersProps) { return ( <UserProvider> @@ -85,11 +93,18 @@ export function Providers({ children }: ProvidersProps) { } ``` -Consumer hook (from `src/contexts/user-context.tsx:77-83`): +Consumer hook (from `src/contexts/user-context.tsx`): ```typescript export function useUser() { const context = useContext(UserContext); + const requestProfile = useContext(UserProfileRequestContext); + + // The first mounted consumer triggers the provider's one profile load. + useEffect(() => { + requestProfile?.(); + }, [requestProfile]); + if (context === undefined) { throw new Error("useUser must be used within a UserProvider"); } @@ -100,10 +115,12 @@ export function useUser() { ## Load lifecycle (call graph) ``` -UserProvider mount +UserProvider mount (no fetch; requested = false) └─ authClient.useSession() → { data: session, isPending } └─ userGuid = session.user.userGuid - └─ useEffect [!isPending && userGuid] +first useUser() consumer mounts + └─ useEffect → requestProfile() → setRequested(true) (later consumers: no-op) + └─ provider useEffect [requested && !isPending && userGuid] └─ loadUserProfile() └─ getCurrentUserProfile() (server action: src/components/shared-actions/user.ts:25) └─ auth.api.getSession({ headers }) (re-verifies session; throws Unauthorized unless user.userGuid is a non-empty string) @@ -124,12 +141,21 @@ UserProvider mount | no fetch when session lacks userGuid | 88 | `data: { user: { id } }` (no `userGuid`) → action not called | | handles profile load error | 101 | rejected promise → `error` set, `userProfile=null` | | `refreshUserProfile` re-fetches | 119 | second call returns updated profile; action called twice | +| refresh with no `userGuid` clears profile | 160 | guard inside `loadUserProfile`; action not called | +| stays loading while session pending | 177 | `isPending: true` → `isLoading` stays `true` | +| non-Error rejection wrapped | 186 | `error` is a real `Error` ("Failed to load user profile") | +| undefined profile → no profile | 202 | `profile ?? null` | +| error cleared on successful refresh | 217 | `setError(null)` before reload | +| no call when nothing calls `useUser()` | 246 | provider mounted with a `userGuid` session but no consumer → action not called | +| two consumers share one call | 264 | both consumers see the profile; action called once | +| late consumer triggers the call | 288 | no call until a consumer is rendered; then exactly one | ## Gotchas -- Reading `useUser().userProfile` before load returns `null` — gate UI on `isLoading` (inline; `user-context.tsx:24,51-59`). -- Consumer must be a client component and wrapped by `<UserProvider>` — the hook throws otherwise (`user-context.tsx:79-81`). -- Session without `userGuid` leaves `isLoading` at its previous value — if the session switches to "missing guid" mid-lifecycle, neither branch of the effect clears it (`user-context.tsx:51-59`). Prefer `useAppSession` + explicit checks in components that need to distinguish "loading" vs "guid missing". -- Direct import from `@/lib/auth-client` bypasses the `useAppSession` wrapper — `UserProvider` itself does this (`user-context.tsx:22`) because it needs `isPending` which `useAppSession` drops. +- Reading `useUser().userProfile` before load returns `null` — gate UI on `isLoading`. +- Consumer must be a client component and wrapped by `<UserProvider>` — the hook throws otherwise. +- Once requested, the profile stays loaded for the provider's lifetime even if every consumer unmounts — `requested` never resets; a session/`userGuid` change re-runs the load. +- Session without `userGuid` leaves `isLoading` at its previous value — if the session switches to "missing guid" mid-lifecycle, neither branch of the effect clears it. Prefer `useAppSession` + explicit checks in components that need to distinguish "loading" vs "guid missing". +- Direct import from `@/lib/auth-client` bypasses the `useAppSession` wrapper — `UserProvider` itself does this because it needs `isPending` which `useAppSession` drops. ## Related docs - `session.md` — why `useAppSession` is separate diff --git a/.claude/references/data-flow/README.md b/.claude/references/data-flow/README.md index 0873549..a7d2cbc 100644 --- a/.claude/references/data-flow/README.md +++ b/.claude/references/data-flow/README.md @@ -11,11 +11,12 @@ Call graphs for key end-to-end flows plus an error catalog grouped by origin lay | File | Purpose | When to read | |------|---------|--------------| | `call-graphs.md` | 7 key flows with entry points, stack traces, side effects, error paths | Tracing a user action end-to-end | -| `error-catalog.md` | Error types by origin layer (auth, proxy, service, MP provider, HTTP, Zod) | Debugging errors or designing error handling | +| `error-catalog.md` | Error types by origin layer (OAuth/session, MP HTTP client, authorization, validation, service, action envelopes) | Debugging errors or designing error handling | ## Related domains - `../auth/README.md` — auth flows touch many call graphs - `../mp-provider/README.md` — MP stack behind many flows - `../services/README.md` — service layer is the middle tier +- `../security/README.md` — `AuthorizationService` gate that every flow passes through - `../routing/README.md` — tool page convention referenced by flow 2 - `../components/README.md` — feature components that originate each flow diff --git a/.claude/references/data-flow/call-graphs.md b/.claude/references/data-flow/call-graphs.md index bd0380a..3618fa7 100644 --- a/.claude/references/data-flow/call-graphs.md +++ b/.claude/references/data-flow/call-graphs.md @@ -4,8 +4,8 @@ domain: data-flow type: reference applies_to: [src/proxy.ts, src/components/layout/auth-wrapper.tsx, src/lib/auth.ts, src/app/(web)/tools/**, src/services/**, src/contexts/user-context.tsx] symbols: [] -related: [error-catalog.md, ../auth/README.md, ../mp-provider/README.md, ../services/README.md] -last_verified: 2026-04-17 +related: [error-catalog.md, ../auth/README.md, ../mp-provider/README.md, ../services/README.md, ../security/README.md] +last_verified: 2026-10-10 --- ## Flow 1: Auth — protected route hit @@ -14,32 +14,33 @@ last_verified: 2026-04-17 **Entry point:** `src/proxy.ts:proxy` **Stack trace:** -1. `src/proxy.ts:4` — `proxy(request)` sets `x-pathname` header on forwarded request headers (`src/proxy.ts:12-13`). -2. `src/proxy.ts:18` — early return `passThrough()` if path is `/api/*` or `/signin`. -3. `src/proxy.ts:24` — `getSessionCookie(request)` from `better-auth/cookies`. -4. `src/proxy.ts:26-31` — if no cookie: build `/signin?callbackUrl=<fullPath>` and `NextResponse.redirect(signinUrl)`; stop. -5. If cookie present: `src/proxy.ts:34` — `passThrough()`; request reaches `src/app/(web)/layout.tsx:28` (`WebLayout`). +1. `src/proxy.ts:37` — `proxy(request)` builds the CSP + nonce, then sets `x-pathname` (`src/proxy.ts:58`), `x-nonce` and the CSP header on the forwarded request headers. +2. `src/proxy.ts:86-88` — early `passThrough()` if `isPublicPath(pathname)` (`/api/auth`, `/api/auth/*`, `/signin`, `/auth-error` — `src/proxy.ts:26-35`). +3. `src/proxy.ts:91` — `getSessionCookie(request)` from `better-auth/cookies`. +4. `src/proxy.ts:93-95` — if no cookie: `redirectToSignin()` (`src/proxy.ts:80-84`) → `NextResponse.redirect('/signin?callbackUrl=<fullPath>')`; stop. +5. If cookie present: `src/proxy.ts:97` — `passThrough()`; request reaches `src/app/(web)/layout.tsx:28` (`WebLayout`). 6. `src/app/(web)/layout.tsx:34` — layout wraps children in `<AuthWrapper>`. 7. `src/components/layout/auth-wrapper.tsx:6-7` — `await headers()` then `auth.api.getSession({ headers })`. 8. `src/components/layout/auth-wrapper.tsx:9-17` — on no session: read `x-pathname`, build `/signin?callbackUrl=...`, `redirect(...)`. -9. Browser lands on `src/app/signin/page.tsx:7` (`SignInContent`). -10. `src/app/signin/sign-in-content.tsx` — `authClient.signIn.social({ provider: "ministryplatform", callbackURL })` kicks off OAuth. See `../auth/oauth-flow.md`. +9. `src/components/layout/auth-wrapper.tsx:25-28` — session without `userGuid` → `redirect("/session-error")`. +10. Browser lands on `src/app/signin/page.tsx:25` (`SignIn`, `force-dynamic`), which renders `SignInContent` inside `<Suspense>`. +11. `src/app/signin/sign-in-content.tsx:179` — `authClient.signIn.social({ provider: "ministryplatform", callbackURL })` kicks off OAuth. See `../auth/oauth-flow.md`. **Side effects:** -- Request header set (internal): `x-pathname = <pathname + search>` (`src/proxy.ts:13`). -- HTTP 302 to `/signin?callbackUrl=...` from proxy when cookie absent (`src/proxy.ts:30`). +- Request headers set (internal): `x-pathname = <pathname + search>`, `x-nonce`, CSP header (`src/proxy.ts:57-67`). +- Response headers: CSP (+ `Reporting-Endpoints` when configured) on every proxied response (`withCsp`, `src/proxy.ts:69-75`). +- HTTP 307 redirect to `/signin?callbackUrl=...` from proxy when cookie absent (`NextResponse.redirect` default status). - Server-side `redirect(...)` (Next.js exception) from AuthWrapper when session absent (`src/components/layout/auth-wrapper.tsx:16`). -- Log: `Proxy: Redirecting to signin - no session cookie` (`src/proxy.ts:27`). **Error paths:** -- Cookie-read throws: caught at `src/proxy.ts:36-41`, logs and redirects to `/signin` with the same callbackUrl. -- `auth.api.getSession` throws: **not** wrapped in `AuthWrapper` — propagates to Next.js error boundary (`src/app/(web)/error.tsx`). +- Cookie-read throws: caught at `src/proxy.ts:99-105`, logs `proxy.session_check_failed` (message only, never the path) and redirects to `/signin` with the same callbackUrl. +- `auth.api.getSession` throws: **not** wrapped in `AuthWrapper` — propagates to the nearest error boundary (`src/app/(web)/error.tsx`). **Return shape:** `NextResponse` (redirect or pass-through) from proxy; rendered React tree from `WebLayout` when authed. **Related:** -- `../auth/oauth-flow.md` — full OAuth round-trip (continues from step 10) -- `../auth/sessions.md` — JWT cookie, `cookieCache`, `customSession` +- `../auth/oauth-flow.md` — full OAuth round-trip (continues from step 11) +- `../auth/sessions.md` — JWE cookie cache, `customSession` - `../routing/proxy.md` — proxy matcher and header forwarding --- @@ -51,28 +52,32 @@ last_verified: 2026-04-17 **Stack trace:** 1. `src/app/(web)/layout.tsx:34` — `AuthWrapper` gate (see Flow 1). -2. `src/app/(web)/tools/addresslabels/page.tsx:8` — `AddressLabelsPage({ searchParams })` awaits `searchParams` (Next.js 16 async dynamic API). -3. `src/app/(web)/tools/addresslabels/page.tsx:9` — `parseToolParams(await searchParams)`. -4. `src/lib/tool-params.ts:30` — `parseToolParams` reads `pageID`, `s`, `sc`, `p`, `q`, `v`, `recordID`, `recordDescription`, `addl`. -5. `src/lib/tool-params.ts:53-60` — if `pageID` present: `ToolService.getInstance()` → `toolService.getPageData(parsedPageID)`. On throw: `console.warn` and set `pageData = undefined` (`src/lib/tool-params.ts:57-60`). -6. `src/services/toolService.ts:123` — `getPageData(pageID)`. -7. `src/services/toolService.ts:127` — `this.mp!.executeProcedureWithBody('api_Tools_GetPageData', { '@PageID': pageID })`. -8. `src/lib/providers/ministry-platform/helper.ts:544` — `MPHelper.executeProcedureWithBody` → `provider.executeProcedureWithBody` → `ProcedureService` → `HttpClient.post('/procs/api_Tools_GetPageData', ...)`. -9. `src/services/toolService.ts:132-137` — unwraps `result[0][0]` as `PageData`. -10. `src/app/(web)/tools/addresslabels/page.tsx:11` — renders `<AddressLabels params={params} />` (client subtree). +2. `src/app/(web)/tools/layout.tsx:31-34` — `AuthorizationService.getInstance().hasSecurityRole()`; `redirect("/no-access")` if false. UX only — **not** enforcement for the page (Next 16 renders the page segment independently of the layout). +3. `src/app/(web)/tools/addresslabels/page.tsx:9` — `AddressLabelsPage({ searchParams })`. +4. `src/app/(web)/tools/addresslabels/page.tsx:11` — `requireToolAccess({ table: 'Contacts', operation: 'read' })` (`src/app/(web)/tools/require-tool-access.ts:29-36`) → `requireSecurityRole`; `UnauthorizedError` → `redirect("/no-access")`, any other error rethrows. +5. `src/app/(web)/tools/addresslabels/page.tsx:13` — `parseToolParams(await searchParams)` (Next.js 16 async dynamic API). +6. `src/lib/tool-params.server.ts:43` — `parseToolParams` reads `pageID`, `s`, `sc`, `p`, `q`, `v`, `recordID`, `recordDescription`, `addl`; integers via `parseIntOrUndefined` (non-numeric → `undefined`), `recordDescription` via `safeDecode`. +7. `src/lib/tool-params.server.ts:66-77` — if `pageID` parsed: dynamic `import("@/services/toolService")` → `ToolService.getInstance()` → `toolService.getPageData(parsedPageID)`. On throw: `console.warn('tool_params.page_data_unavailable', { pageID })` and `pageData = undefined`. +8. `src/services/toolService.ts:126-130` — `getPageData(pageID)` gates `dp_Pages`/`read` (memoized — same request). +9. `src/services/toolService.ts:134` — `this.mp!.executeProcedureWithBody('api_Tools_GetPageData', { '@PageID': pageID })`. +10. `src/lib/providers/ministry-platform/helper.ts:578` — `MPHelper.executeProcedureWithBody` → `provider.executeProcedureWithBody` → `ProcedureService` → `HttpClient.post('/procs/api_Tools_GetPageData', ...)`. +11. `src/services/toolService.ts:139-142` — unwraps `result[0][0]` as `PageData`. +12. `src/app/(web)/tools/addresslabels/page.tsx:15` — renders `<AddressLabels params={params} />` (client subtree). **Side effects:** -- MP API call: `POST /procs/api_Tools_GetPageData` (first tool page hit within token TTL triggers token refresh — see `../mp-provider/client.md`). +- MP API calls: `GET /tables/dp_Users` + `GET /tables/dp_User_Roles` (authorization decision, once per request via React `cache()`), then `POST /procs/api_Tools_GetPageData`. First call within token TTL triggers a token fetch — see `../mp-provider/client.md`. - Cookie set: none (read-only flow). **Error paths:** -- `api_Tools_GetPageData` not found / SP errors → `HttpClient.post` throws `Error('POST /procs/... failed: ...')` at `src/lib/providers/ministry-platform/utils/http-client.ts:59` → rethrown by `ToolService.getPageData` at `src/services/toolService.ts:138-140` → caught in `parseToolParams` at `src/lib/tool-params.ts:57-60` (swallowed to `pageData = undefined`). -- All other `parseToolParams` field parsing: synchronous — invalid `Number` produces `NaN`, no throw. +- No security role → `redirect("/no-access")` (layout, and again at `requireToolAccess`). +- MP unreachable during the gate → error propagates to `src/app/(web)/error.tsx` (not converted to "no access"). +- `api_Tools_GetPageData` failure → `HttpClient` throws `Error('POST /procs/... failed: <status>')` at `src/lib/providers/ministry-platform/utils/http-client.ts:226` → rethrown by `ToolService.getPageData` → swallowed in `parseToolParams` (`pageData = undefined`). -**Return shape:** `ToolParams` per `src/lib/tool-params.ts:17-28` (fields + optional `pageData: PageData`). +**Return shape:** `ToolParams` per `src/lib/tool-params.ts:28-39` (fields + optional `pageData: PageData`). **Related:** - `../services/tool-service.md` — `getPageData` contract +- `../utils/tool-params.md` — parser details - `../mp-schema/required-procs.md` — `api_Tools_GetPageData` shape - `../routing/app-router.md` — tool page convention @@ -84,37 +89,33 @@ last_verified: 2026-04-17 **Entry point:** `src/components/dev-panel/panels/selection-actions.ts:resolveSelection` (dev-panel path) **or** `src/components/address-labels/actions.ts:fetchAddressLabels` (tool path). **Stack trace (dev-panel variant):** -1. `src/components/dev-panel/panels/selection-actions.ts:13` — `resolveSelection(selectionId, pageId)` (server action). -2. `src/components/dev-panel/panels/selection-actions.ts:17` — `auth.api.getSession({ headers: await headers() })`; throws `'Unauthorized'` at line 18 if no user. -3. `src/components/dev-panel/panels/selection-actions.ts:20-21` — reads `(session.user as {userGuid}).userGuid`; throws `'User GUID not found in session'` if absent. -4. `src/components/dev-panel/panels/selection-actions.ts:23-24` — `UserService.getInstance()` → `userService.getUserIdByGuid(userGuid)`. -5. `src/services/userService.ts:68-79` — `getUserIdByGuid` runs `mp.getTableRecords('dp_Users', { select: 'User_ID', filter: "User_GUID = '<guid>'", top: 1 })`; throws `'User not found'` at line 76 if empty. -6. `src/components/dev-panel/panels/selection-actions.ts:26-27` — `ToolService.getInstance()` → `toolService.getSelectionRecordIds(selectionId, userId, pageId)`. -7. `src/services/toolService.ts:152-174` — `getSelectionRecordIds` calls `mp.executeProcedureWithBody('api_Common_GetSelection', { '@SelectionID', '@UserID', '@PageID' })` (line 154) and finds the result set containing `Record_ID` (line 163). -8. Returns `{ recordIds, count }` to client. +1. `src/components/dev-panel/panels/selection-actions.ts:11` — `resolveSelection(selectionId, pageId)` (server action). +2. `src/components/dev-panel/panels/selection-actions.ts:15` — `requireDevSession('Dev panel')` (`src/components/dev-panel/panels/require-dev-session.ts`): throws in production, or if no session `user.id`. +3. `src/components/dev-panel/panels/selection-actions.ts:17-20` — `ToolService.getInstance()` → `toolService.getSelectionRecordIds(selectionId, pageId)`. +4. `src/services/toolService.ts:163-166` — `requireSecurityRole({ table: 'dp_Selections', operation: 'read' })` returns the acting MP `User_ID` (resolved from `session.user.userGuid` → `dp_Users.User_ID` inside `AuthorizationService`). +5. `src/services/toolService.ts:168-172` — `mp.executeProcedureWithBody('api_Common_GetSelection', { '@SelectionID', '@UserID': userId, '@PageID' })`. +6. `src/services/toolService.ts:175-182` — finds the result set containing `Record_ID` and maps it. +7. Returns `{ recordIds, count }` to client. -**Stack trace (address-labels variant):** same, but step 1 is `src/components/address-labels/actions.ts:99` `fetchAddressLabels(params, config)` and `getMPUserId` is the helper at `src/components/address-labels/actions.ts:34-40`. +**Stack trace (address-labels variant):** step 1 is `src/components/address-labels/actions.ts:116` `fetchAddressLabels(params, config)`, step 2 is `requireAccess('Contacts', 'read')` (line 120), then the same `getSelectionRecordIds(params.s, params.pageID)` call (line 130). **Side effects:** -- MP API call: `POST /procs/api_Common_GetSelection` (`src/services/toolService.ts:154`). -- MP API call: `GET /tables/dp_Users?$select=User_ID&$filter=User_GUID='<guid>'` (`src/services/userService.ts:69-74`). -- The MP procedure is **user-scoped** — `@UserID` must be the correct MP numeric ID or an empty set is returned. +- MP API calls: `GET /tables/dp_Users` + `GET /tables/dp_User_Roles` (gate, memoized per request), `POST /procs/api_Common_GetSelection`. +- The MP procedure is **user-scoped** — `@UserID` comes only from the gate, so a caller cannot read another user's selection. **Error paths:** -- No session → `Error('Unauthorized')` thrown at `src/components/dev-panel/panels/selection-actions.ts:18`. -- Missing `userGuid` on session → `Error('User GUID not found in session')` at `src/components/dev-panel/panels/selection-actions.ts:21`. -- User GUID not in MP → `Error('User not found')` at `src/services/userService.ts:76`. -- MP procedure failure → `HttpClient.post` throws at `src/lib/providers/ministry-platform/utils/http-client.ts:59`; rethrown by `ToolService.getSelectionRecordIds` at `src/services/toolService.ts:171-173`. +- Production build → `Error('Dev panel is not available in production.')` from `requireDevSession`. +- No session → `Error('Unauthorized - Missing user session data')` from `requireDevSession`. +- No MP user / no role / roles unconfigured → `UnauthorizedError` from `requireSecurityRole` (logged as `mp.read.unauthorized`). +- MP procedure failure → `HttpClient` throws at `src/lib/providers/ministry-platform/utils/http-client.ts:226`; rethrown by `ToolService.getSelectionRecordIds`. -**Return shape:** `{ recordIds: number[], count: number }` (dev-panel variant — `SelectionResult` at `src/components/dev-panel/panels/selection-actions.ts:8-11`). +**Return shape:** `{ recordIds: number[], count: number }` (dev-panel variant — `SelectionResult` at `src/components/dev-panel/panels/selection-actions.ts:6-9`). **Related:** - `../services/tool-service.md` — `getSelectionRecordIds` contract - `../mp-schema/required-procs.md` — `api_Common_GetSelection` - `../components/dev-panel.md` — selection panel UI -> **Note:** JSDoc at `src/services/toolService.ts:145` says `api_CloudTools_GetSelection` but the call at line 154 uses `api_Common_GetSelection`. Tracked in TODO `2026-04-17-services-stale-cloudtools-comment.md`. - --- ## Flow 4: Address label fetch @@ -123,23 +124,23 @@ last_verified: 2026-04-17 **Entry point:** `src/components/address-labels/actions.ts:fetchAddressLabels` **Stack trace:** -1. `src/components/address-labels/actions.ts:99` — `fetchAddressLabels(params, config)`. -2. `src/components/address-labels/actions.ts:103` — `getSession()` (local helper at line 28); throws `'Unauthorized'` if no `session.user.id`. -3. `src/components/address-labels/actions.ts:105` — `AddressLabelService.getInstance()`. -4. **Selection mode** (`params.s && params.pageID`, line 107): `getMPUserId(session)` (line 109, helper at lines 34-40) → `ToolService.getInstance().getSelectionRecordIds(...)` (line 111) — see Flow 3 steps 6-7. -5. `src/components/address-labels/actions.ts:117` — `addressService.getAddressesForContacts(contactIds)`. -6. `src/services/addressLabelService.ts:69-90` — batches in groups of 100 (`BATCH_SIZE` at line 21); for each batch validates every ID via `validatePositiveInt`, calls `mp.getTableRecords({ table: 'Contacts', select: SELECT_FIELDS, filter: 'Contact_ID IN (...)', orderBy: 'Household_ID_TABLE_Address_ID_TABLE.Postal_Code' })`. -7. **Single-record mode** (`params.recordID && params.recordID !== -1`, line 119): `addressService.getAddressForContact(params.recordID)` (line 121) → `src/services/addressLabelService.ts:95-104`. -8. `src/components/address-labels/actions.ts:42-97` — `filterAndTransform(rows, config)`: applies skip rules (opt-out, no address, no postal code, no barcode), deduplicates households when `addressMode === 'household'`, sorts by `postalCode`. +1. `src/components/address-labels/actions.ts:116` — `fetchAddressLabels(params, config)`. +2. `src/components/address-labels/actions.ts:120` — `requireAccess('Contacts', 'read')` (helper at lines 44-49 → `requireSecurityRole`). +3. `src/components/address-labels/actions.ts:122` — `AddressLabelService.getInstance()`. +4. **Selection mode** (`params.s && params.pageID`, line 124): `ToolService.getInstance().getSelectionRecordIds(params.s, params.pageID)` (lines 129-130) — see Flow 3 steps 4-6. Empty result → `{ printable: [], skipped: [] }`. +5. `src/components/address-labels/actions.ts:136` — `addressService.getAddressesForContacts(contactIds)`. +6. `src/services/addressLabelService.ts:70-97` — gates `Contacts`/`read`, then batches in groups of `MP_FETCH_BATCH_SIZE` (100, `src/lib/constants.ts`); for each batch validates every ID via `validatePositiveInt`, calls `mp.getTableRecords({ table: 'Contacts', select: SELECT_FIELDS, filter: 'Contact_ID IN (...)', orderBy: 'Household_ID_TABLE_Address_ID_TABLE.Postal_Code' })`. +7. **Single-record mode** (`params.recordID && params.recordID !== -1`, line 138): `addressService.getAddressForContact(params.recordID)` (line 140) → `src/services/addressLabelService.ts:102-117` (gate, `validatePositiveInt`, `top: 1`). +8. `src/components/address-labels/actions.ts:52-114` — `filterAndTransform(rows, config)`: applies skip rules (opt-out, no address, no postal code, no barcode unless `includeMissingBarcodes`, no household in household mode), deduplicates households when `addressMode === 'household'`, sorts by `postalCode`. **Side effects:** -- MP API calls: one `POST /procs/api_Common_GetSelection` per selection fetch; `ceil(contactIds.length / 100)` calls to `GET /tables/Contacts` with FK-joined select. +- MP API calls: gate reads (once per request); one `POST /procs/api_Common_GetSelection` per selection fetch; `ceil(contactIds.length / 100)` calls to `GET /tables/Contacts` with FK-joined select. - No DB writes. **Error paths:** -- No session → `Error('Unauthorized')` at `src/components/address-labels/actions.ts:30`. -- Invalid `Contact_ID` in batch → `validatePositiveInt` throws at `src/services/addressLabelService.ts:76` (selection mode only; single-record mode at line 95 does not validate). -- MP failure → `HttpClient.get` throws at `src/lib/providers/ministry-platform/utils/http-client.ts:32`; propagates through service and action; unhandled on server (becomes `Error` in client action response). +- Not authorized → `UnauthorizedError` from `requireAccess` (action) or the service gate. +- Invalid `Contact_ID` → `validatePositiveInt` throws `'Expected positive integer'` (both modes). +- MP failure → `HttpClient` throws at `src/lib/providers/ministry-platform/utils/http-client.ts:226`; propagates through service and action (no try/catch in `fetchAddressLabels`). **Return shape:** `FetchAddressLabelsResult` (`{ printable: LabelData[], skipped: SkipRecord[] }`) from `@/lib/dto`. @@ -157,23 +158,23 @@ last_verified: 2026-04-17 **Stack trace:** 1. `src/contexts/user-context.tsx:21` — `UserProvider({ children })` renders. -2. `src/contexts/user-context.tsx:22` — `authClient.useSession()` (reactive subscription to JWT cookie cache). +2. `src/contexts/user-context.tsx:22` — `authClient.useSession()` (reactive subscription to the session cookie cache). 3. `src/contexts/user-context.tsx:29` — derive `userGuid = (session?.user as { userGuid?: string } | undefined)?.userGuid`. -4. `src/contexts/user-context.tsx:51-58` — `useEffect` fires when `!isPending && userGuid`: calls `loadUserProfile()` (line 53). +4. `src/contexts/user-context.tsx:51-58` — `useEffect` fires when `!isPending && userGuid`: calls `loadUserProfile()` (line 54). 5. `src/contexts/user-context.tsx:31-49` — `loadUserProfile` sets `isLoading=true`, calls `getCurrentUserProfile()` (line 41) with no arguments. 6. `src/components/shared-actions/user.ts:25` — server action `getCurrentUserProfile()`; takes no parameters, so a caller cannot name another user (IDOR). 7. `src/components/shared-actions/user.ts:26-28` — `auth.api.getSession({ headers: await headers() })`, then derive `userGuid` from `session.user`; throws `'Unauthorized'` unless it is a non-empty string. -8. `src/components/shared-actions/user.ts:30-31` — `UserService.getInstance()` → `userService.getUserProfile(userGuid)`. +8. `src/components/shared-actions/user.ts:30-31` — `UserService.getInstance()` → `userService.getUserProfile(userGuid)`. No `AuthorizationService` gate — documented rule-12 carve-out (own profile only). 9. `src/services/userService.ts` `getUserProfile` — runs 1 MP query: `mp.getTableRecords('dp_Users', { filter: "User_GUID = '<guid>'", select: "Contact_ID_TABLE.First_Name,Contact_ID_TABLE.Nickname,Contact_ID_TABLE.Last_Name,Contact_ID_TABLE.Email_Address", top: 1 })`. 10. Same method — maps the row explicitly to `{ First_Name, Nickname, Last_Name, Email_Address: ?? null }` (no spread, so nothing else MP returns reaches the client). 11. `src/contexts/user-context.tsx:42` — `setUserProfile(profile ?? null)`. **Side effects:** - MP API calls: 1 `GET /tables/dp_Users` per mount (first run per fresh process triggers token fetch). -- No cookie changes (session read from JWT cache). +- No cookie changes (session read from the cookie cache). **Error paths:** -- Server action throws → caught at `src/contexts/user-context.tsx:43-46` → sets `error` state, `userProfile = null`. +- Server action throws → caught at `src/contexts/user-context.tsx:43-45` → sets `error` state, `userProfile = null`. - Session `userGuid` missing or empty → `Error('Unauthorized')` at `src/components/shared-actions/user.ts:28`. (Not keyed on `session.user.id` — that is Better Auth's internal ID and does not prove an MP identity.) - Profile row missing → `userProfile = undefined` returned by `getUserProfile` (`if (!row) return undefined`); normalized to `null` in context (line 42). @@ -192,7 +193,7 @@ last_verified: 2026-04-17 **Entry points:** `src/app/(web)/no-access/page.tsx` and `src/app/session-error/page.tsx` — `<form action={handleSignOut}>`. **Stack trace:** -1. `src/components/shared-actions/sign-out.ts` — `handleSignOut` (`'use server'`). +1. `src/components/shared-actions/sign-out.ts:50` — `handleSignOut` (`'use server'`). 2. `auth.api.signOut({ headers, body: { disableRedirect: true } })` — clears the Better Auth session cookie **first** (via the `nextCookies()` plugin), and returns better-auth's provider logout URL. 3. `idTokenHintFrom(...)` — takes `id_token_hint` from that URL only if its origin is the MP origin. 4. Rebuilds `${getMpBaseUrl()}/oauth/connect/endsession?post_logout_redirect_uri=<getAuthBaseUrl()>&client_id=<getMpClient().clientId>[&id_token_hint=…]`. @@ -219,39 +220,41 @@ last_verified: 2026-04-17 ## Flow 7: Record create (group create) **Trigger:** user completes the Group Wizard and clicks "Submit" on the final step. -**Entry point:** `src/app/(web)/tools/groupwizard/group-wizard.tsx:121` (`handleSubmit`) → server action `createGroup`. +**Entry point:** `src/app/(web)/tools/groupwizard/group-wizard.tsx:140` (`handleSubmit`) → server action `createGroup`. **Stack trace:** -1. `src/app/(web)/tools/groupwizard/group-wizard.tsx:121-129` — `handleSubmit` calls `updateGroup(params.recordID, data)` in edit mode (line 126) or `createGroup(data)` in create mode (line 128). -2. `src/components/group-wizard/actions.ts:65` — `createGroup(data)` server action. -3. `src/components/group-wizard/actions.ts:69` — `getSession()` (helper at lines 17-21); throws `'Unauthorized'` if no `session.user.id`. -4. `src/components/group-wizard/actions.ts:70` — `getMPUserId(session)` (helper at lines 23-29) → reads `userGuid` → `UserService.getUserIdByGuid(userGuid)` → throws `'User not found'` if missing (`src/services/userService.ts:76`). -5. `src/components/group-wizard/actions.ts:71-72` — `GroupService.getInstance()` → `service.createGroup(data, userId)`. -6. `src/services/groupService.ts:227-237` — `createGroup`: - - `prepareForApi(data)` (line 231, defined at line 18) — coerces dates, maps form fields to MP columns. - - `mp.createTableRecords('Groups', [apiData], { $select: 'Group_ID, Group_Name', $userId: userId })` (line 232). -7. `src/lib/providers/ministry-platform/helper.ts:171-213` — `MPHelper.createTableRecords`: - - If `schema` provided (not in this flow), runs `schema.parse(record)` per record (lines 185-197); throws `'Validation failed for record <i>: ...'` on failure. - - `provider.createTableRecords(table, validatedRecords, providerParams)` (line 202). +1. `src/app/(web)/tools/groupwizard/group-wizard.tsx:140-155` — `handleSubmit` calls `updateGroup(params.recordID, data)` in edit mode (line 145) or `createGroup(data)` in create mode (line 147). +2. `src/components/group-wizard/actions.ts:94` — `createGroup(data)` server action. +3. `src/components/group-wizard/actions.ts:98` — `requireAccess('Groups', 'create')` → `requireSecurityRole`. +4. `src/components/group-wizard/actions.ts:99-100` — `parseGroupPayload(data)` (`groupWizardSchema.safeParse`, strips unknown keys); invalid → `{ success: false, error: 'Invalid group data: <field paths>' }`. +5. `src/components/group-wizard/actions.ts:101-102` — `GroupService.getInstance()` → `service.createGroup(parsed.data)` (no user ID passed). +6. `src/services/groupService.ts:365-379` — `createGroup`: + - `requireSecurityRole({ table: 'Groups', operation: 'create' })` → `$userId` (memoized decision from step 3). + - `prepareForApi(data)` (defined at line 95) — keeps only `GROUP_WRITABLE_FIELDS`, converts `Start_Date` / `End_Date` / `Promotion_Date` via `DomainTimezoneService.toMpSqlDatetime`. + - `mp.createTableRecords('Groups', [apiData], { $select: 'Group_ID, Group_Name', $userId })`. +7. `src/lib/providers/ministry-platform/helper.ts:197-243` — `MPHelper.createTableRecords`: + - If `schema` provided (not in this flow), runs `schema.parse(record)` per record (lines 209-228); throws `MPValidationError` (`'Validation failed for record <i>: ...'`) on failure. + - `provider.createTableRecords(table, validatedRecords, providerParams)` (line 232). 8. `MinistryPlatformProvider` → `TableService.createTableRecords` → `HttpClient.post('/tables/Groups', [apiData], { $select: ..., $userId: ... })`. -9. Returns array of created records; action unwraps `result[0]` at `src/services/groupService.ts:236`. -10. `src/components/group-wizard/actions.ts:73` — returns `{ success: true, groupId, groupName }`. -11. Back on client (`src/app/(web)/tools/groupwizard/group-wizard.tsx:131-134`): on success, sets `submitResult`; on failure, `setLoadError(result.error)`. +9. Returns array of created records; service unwraps `result[0]` at `src/services/groupService.ts:378`. +10. `src/components/group-wizard/actions.ts:103` — returns `{ success: true, groupId, groupName }`. +11. Back on client (`src/app/(web)/tools/groupwizard/group-wizard.tsx:150-154`): on success, `setSubmitResult(...)`; on failure, `setLoadError(result.error)`. **Side effects:** - MP API call: `POST /tables/Groups` with `$userId=<mp user id>` → MP writes one row to `Groups` table (`Group_ID` auto-generated). - DB write: `Groups` row (and any FK constraints MP enforces, e.g. `Group_Type_ID`). -- **No Zod validation** is currently wired for `createGroup` (the `GroupService.createGroup` call does not pass a `schema`) — schema validation only runs when callers opt in per helper contract at `src/lib/providers/ministry-platform/helper.ts:183`. +- Input validation happens in the action (`groupWizardSchema`) and the service allowlist; `MPHelper`'s optional `schema` parameter is **not** used here. **Error paths:** -- Unauthorized / missing userGuid / user not found → thrown at `src/components/group-wizard/actions.ts:19`, `:25`, or `src/services/userService.ts:76`; caught at `src/components/group-wizard/actions.ts:74-76` and returned as `{ success: false, error }`. -- MP HTTP failure → `HttpClient.post` throws at `src/lib/providers/ministry-platform/utils/http-client.ts:59`; propagates to action catch block → `{ success: false, error: <message> }`. -- Zod validation (when enabled by caller) → `Error('Validation failed for record <i>: ...')` at `src/lib/providers/ministry-platform/helper.ts:189-196`, prior to any HTTP call. +- Not authorized → `UnauthorizedError` from `requireAccess` or the service gate; caught at `src/components/group-wizard/actions.ts:104-106` and returned as `{ success: false, error }`. +- Payload fails `groupWizardSchema` → `{ success: false, error: 'Invalid group data: ...' }` (field names only, never values). +- `toMpSqlDatetime` on an unparseable date → `Error('toMpSqlDatetime: value could not be parsed as a date')`. +- MP HTTP failure → `HttpClient` throws at `src/lib/providers/ministry-platform/utils/http-client.ts:226`; propagates to action catch block → `{ success: false, error: <message> }`. -**Return shape:** `CreateGroupResult | ActionError` from `./types` — on success `{ success: true, groupId: number, groupName: string }`. +**Return shape:** `CreateGroupResult | ActionError` — on success `{ success: true, groupId: number, groupName: string }`. **Related:** - `../services/group-service.md` — `GroupService.createGroup` + `prepareForApi` - `../components/group-wizard.md` — wizard steps and form - `../mp-provider/services/table.md` — `createTableRecords` path -- `../services/query-patterns.md` — Zod schema opt-in pattern +- `../ministryplatform.datetimehandling.md` — date conversion at the MP boundary diff --git a/.claude/references/data-flow/error-catalog.md b/.claude/references/data-flow/error-catalog.md index 9cc820a..cba6977 100644 --- a/.claude/references/data-flow/error-catalog.md +++ b/.claude/references/data-flow/error-catalog.md @@ -3,81 +3,103 @@ title: Error catalog domain: data-flow type: reference applies_to: [src/proxy.ts, src/lib/auth.ts, src/lib/providers/ministry-platform/**, src/services/**, src/components/**/actions.ts, src/lib/validation.ts] -symbols: [Error] -related: [call-graphs.md, ../auth/README.md, ../mp-provider/error-handling.md] -last_verified: 2026-04-17 +symbols: [Error, UnauthorizedError, MPValidationError, PartialSaveError, ZipLimitError] +related: [call-graphs.md, ../auth/README.md, ../mp-provider/error-handling.md, ../security/README.md] +last_verified: 2026-10-10 --- ## Purpose Every error thrown in `src/**` categorized by origin layer. Useful for debugging a stack trace, designing a new error flow, or deciding where to insert a try/catch. +All server-side logs and thrown messages follow CLAUDE.md rule 14: **identifiers and shape only** — never record content, `$filter` strings, request or response bodies. + ## By layer ### OAuth / session | Type | Thrown at | Caught at | User-facing | Logged | |---|---|---|---|---| -| `getUserInfo` userinfo fetch non-OK → returns `null` (no throw) | `src/lib/auth.ts:59-65` | Better Auth internals abort user creation on `null` | sign-in silently fails, OAuth loop repeats | yes, `console.error("getUserInfo - Failed to fetch user info:", status)` | -| `Error('MINISTRY_PLATFORM_BASE_URL is not configured')` | `src/components/shared-actions/sign-out.ts` (now thrown by `src/lib/env.ts` readers) | propagates (uncaught) | server-action error surfaces to caller | no | -| `authClient.getSession()` rejection on /signin | `src/app/signin/page.tsx:16` (promise) | `src/app/signin/page.tsx:30-40` | falls through to `signIn.oauth2()` redirect | yes, `console.error("Failed to check session:", err)` | -| Proxy `getSessionCookie` throw (cookie parse error) | `src/proxy.ts:24` | `src/proxy.ts:36-41` | redirect to `/signin?callbackUrl=...` | yes, `console.error('Proxy: Error checking session:', error)` | -| No session cookie (control-flow, not throw) | `src/proxy.ts:26` check | `src/proxy.ts:26-31` | redirect to `/signin?callbackUrl=...` | yes, `console.log("Proxy: Redirecting to signin - no session cookie")` | +| `getUserInfo` refusal → returns `null` (never throws) — missing/invalid id_token, claim failure, userinfo non-OK / non-JSON / network error, no usable `sub`, `sub` mismatch | `src/lib/auth.ts:778-870` | Better Auth callback treats `null` as failure; `onAPIError.errorURL` (`src/lib/auth.ts:1053-1055`) | redirect to `/auth-error?error=...` (public path), no session | yes — one JSON line on `console.error` via `logAuthEvent` (e.g. `auth.userinfo.fetch_failed` with `reason`/`status`, `auth.userinfo.invalid_sub`) | +| `src/lib/env.ts` reader throws (`getMpBaseUrl` / `getAuthBaseUrl` / `getMpClient`) — unset/malformed value, never echoed | `src/lib/env.ts` (e.g. from `handleSignOut` in `src/components/shared-actions/sign-out.ts`) | propagates (uncaught) | server-action error surfaces to caller | no | +| `authClient.getSession()` rejection on /signin | `src/app/signin/sign-in-content.tsx:229` (promise) | same file, `.catch` at line 240 | falls through to `startOAuth()` → `signIn.social` | yes, `console.error("SignIn: Failed to check session:", err)` | +| `signIn.social` throw / rejection | `src/app/signin/sign-in-content.tsx:179` | same file, lines 185-205 | inline "Failed to start sign-in" + retry button | yes, `console.error("SignIn: authClient.signIn.social ...")` | +| Proxy `getSessionCookie` throw (cookie parse error) | `src/proxy.ts:91` | `src/proxy.ts:99-105` | redirect to `/signin?callbackUrl=...` | yes, `console.error('proxy.session_check_failed', { message })` | +| No session cookie (control-flow, not throw) | `src/proxy.ts:93` check | `src/proxy.ts:94` `redirectToSignin()` | redirect to `/signin?callbackUrl=...` | no | | No session at `AuthWrapper` (control-flow) | `src/components/layout/auth-wrapper.tsx:9` check | `src/components/layout/auth-wrapper.tsx:16` `redirect()` | redirect to `/signin?callbackUrl=...` via Next `redirect()` (throws `NEXT_REDIRECT`) | no | -| `Error('Failed to get client credentials token: ${statusText}')` | `src/lib/providers/ministry-platform/auth/client-credentials.ts:25` | `src/lib/providers/ministry-platform/client.ts:63` logs, re-throws | server-side 500; tool pages fail to render | yes, `logger.error("Failed to refresh token:", error)` | -| `Error('Dev client credentials are not configured…')` | `src/lib/providers/ministry-platform/auth/client-credentials.ts:36-38` | `src/lib/providers/ministry-platform/client.ts:89` re-throws; ultimately surfaced in deploy-tool UI | Deploy Tool panel surfaces error to user via `setError` | yes, `logger.error("Failed to refresh dev token:", error)` | -| `fetch` network `TypeError` (token POST) | `src/lib/providers/ministry-platform/auth/client-credentials.ts:16` | `src/lib/providers/ministry-platform/client.ts:63,89` logs, re-throws | same as above | yes | +| Session without `userGuid` (control-flow) | `src/components/layout/auth-wrapper.tsx:25-26` | `redirect("/session-error")` (line 27) | recovery page with a sign-out form | no | +| `Error('Failed to get client credentials token: <status> <statusText>')` / `': <error name>'` / `': invalid token response'` | `src/lib/providers/ministry-platform/auth/client-credentials.ts:59,64,82` | `src/lib/providers/ministry-platform/client.ts:59-64` sets a negative-cache window, logs, re-throws | server-side 500; tool pages fail to render | yes, `logger.error("mp.token.refresh_failed", { profile, error: <name> })` | +| `Error('MP access token unavailable: recent refresh failed')` | `src/lib/providers/ministry-platform/client.ts:42` — within the negative-cache window after a failed refresh | propagates | as above | no (the original failure was logged) | +| `Error('Dev client credentials are not configured…')` | `src/lib/providers/ministry-platform/auth/client-credentials.ts:93-95` | `client.ts` refresh path logs + re-throws; surfaced in deploy-tool UI | Deploy Tool panel shows the error | yes, `mp.token.refresh_failed` with `profile: 'dev'` | ### MP API (HTTP client) -All MP REST errors originate in `src/lib/providers/ministry-platform/utils/http-client.ts` as `Error` with message shape `${METHOD} ${endpoint} failed: ${status} ${statusText}[ - ${body}]`. They propagate back to the service that called them (`table.service`, `procedure.service`, `file.service`, etc.), which `logger.error`-logs and re-throws unmodified via `throw error`. +All MP REST errors originate in `HttpClient.request` / `HttpClient.send` (`src/lib/providers/ministry-platform/utils/http-client.ts`). Endpoints in messages are **redacted** (`redactEndpoint` — path only, GUIDs replaced); bodies and query strings never appear. Provider services (`table.service`, `procedure.service`, `file.service`, …) log `mp.<area>.<op>_failed` with the error **name** and re-throw unmodified. | Type | Thrown at | Caught at | User-facing | Logged | |---|---|---|---|---| -| `GET ${endpoint} failed: ${status} ${statusText} - ${body}` | `src/lib/providers/ministry-platform/utils/http-client.ts:32` | service catch (e.g., `table.service.ts:29`) logs + re-throws; bubbles to action | server action throws → component catch sets error state | yes, `logger.error("GET Request failed:", {status, statusText, url, responseBody})` | -| `POST ${endpoint} failed: ${status} ${statusText} - ${body}` (JSON) | `src/lib/providers/ministry-platform/utils/http-client.ts:59` | service catch (e.g., `table.service.ts:49`, `procedure.service.ts:78`) re-throws | as above | yes | -| `POST ${endpoint} failed: ${status} ${statusText}` (FormData; **no body** included) | `src/lib/providers/ministry-platform/utils/http-client.ts:79` | `file.service.ts:80` re-throws | upload error surfaces to component | no — FormData path does NOT log the response body (gotcha) | -| `PUT ${endpoint} failed: ${status} ${statusText}` (JSON) | `src/lib/providers/ministry-platform/utils/http-client.ts:112` | `table.service.ts:68`, `file.service.ts:129` re-throw | as above | yes, `logger.error("PUT Request failed:", {...})` | -| `PUT ${endpoint} failed: ${status} ${statusText}` (FormData; no body logging) | `src/lib/providers/ministry-platform/utils/http-client.ts:132` | `file.service.ts:129` re-throws | as above | no body logged | -| `DELETE ${endpoint} failed: ${status} ${statusText}` | `src/lib/providers/ministry-platform/utils/http-client.ts:150` | `table.service.ts:148`, `file.service.ts:151` re-throw | as above | no body logged | -| `GET /files/${uniqueId} failed: ${status} ${statusText}` (unauthenticated file content) | `src/lib/providers/ministry-platform/services/file.service.ts:177` | `file.service.ts:183` logs + re-throws | as above | yes, `logger.error("Error getting file content by unique ID:", error)` | -| Empty/missing result set (not a throw) from stored procs | n/a | `src/services/toolService.ts:132-137,161-170,189-194`; `src/services/fieldManagementService.ts:46-50,58-62` length checks | returns `null` or `[]` — silently treated as "no data" | no | +| `${METHOD} ${endpoint} failed: ${status} ${statusText}` (any method, JSON or FormData) | `http-client.ts:226` | provider service catch (e.g. `services/table.service.ts:28-30`, `services/procedure.service.ts:48-50,76-78`) logs + re-throws; bubbles to app service → action | action throws or returns `{ success: false, error }` | yes, `mp.request.failed` `{ method, endpoint, status, statusText }` + `mp.<area>.<op>_failed` | +| `${METHOD} ${endpoint} failed: ${errorName}` — network error, timeout (`AbortSignal.timeout`), or refused redirect (`redirect: 'error'`) | `http-client.ts:264` | as above | as above | yes, `mp.request.failed` `{ method, endpoint, error }` | +| `${METHOD} ${endpoint} failed: unexpected content-type` / `: invalid JSON response` | `http-client.ts:52,58` (`readJsonResponse`) | as above | as above | provider service log only | +| Unsafe endpoint (`..`, encoded separators, etc.) / `Error('Invalid MP API base URL')` | `http-client.ts:161,170,174` (`assertSafeEndpoint`) | as above | as above | provider service log only | +| 401 → one token refresh + retry; refresh failure falls through to the normal status error | `http-client.ts:202-216` | — | — | yes, `mp.token.refresh_after_401_failed` | +| `GET /files/{uniqueId} failed: ${status} ${statusText}` (unauthenticated file content) | `services/file.service.ts:213` | `file.service.ts:217-219` logs + re-throws | as above | yes, `mp.files.get_content_failed` | +| `Error('Invalid record IDs')` (delete) | `services/table.service.ts:138` | `table.service.ts:147-149` | as above | yes, `mp.table.delete_failed` | +| Empty/missing result set (not a throw) from stored procs | n/a | `src/services/toolService.ts:139-144,175-184,206-211`; `src/services/fieldManagementService.ts:52-56,68-72` length checks | returns `null` or `[]` — silently treated as "no data" | no | -**HTTP status → source note.** Message carries only `status`/`statusText` verbatim from the fetch response. 400 / 403 / 404 / 409 / 413 / 500 (e.g., "Ambiguous column name") all surface through the same string template — the layer does **not** branch on status code. Callers must pattern-match on the message if they need to differentiate. +**HTTP status → source note.** Message carries only `status`/`statusText`. 400 / 403 / 404 / 409 / 413 / 500 (e.g., "Ambiguous column name") all surface through the same template — the layer does **not** branch on status (except the 401 retry). The MP response body is never included, so diagnose a 500 from MP's own logs, not the app's. -### Validation +### Authorization | Type | Thrown at | Caught at | User-facing | Logged | |---|---|---|---|---| -| `Error('Validation failed for record ${i}: ${zod message}')` (create) | `src/lib/providers/ministry-platform/helper.ts:189` | `helper.ts:209-212` re-throws via wrapper | server action bubbles to component | no (helper strips Zod `.issues` — only the `message` survives; structured errors lost — gotcha) | -| `Error('Validation failed for record ${i}: ${zod message}')` (update) | `src/lib/providers/ministry-platform/helper.ts:273` | `helper.ts:291-293` re-throws | as above | no — same Zod `.issues` stripping | -| `Error('Invalid GUID format: ${value}')` | `src/lib/validation.ts:6` (`validateGuid`) | server-action catch (e.g., `userService` caller) | component catches → `setError` | no | -| `Error('Expected positive integer, got: ${value}')` | `src/lib/validation.ts:13` (`validatePositiveInt`) | service callsite try/catch propagates to action | component catches → `setError` | no | -| `Error('Invalid column name: ${value}')` | `src/lib/validation.ts:20` (`validateColumnName`) | `toolService.resolveContactIds` callsite → action | component catches → `setError` | no | -| `Error('Unauthorized')` | `src/components/shared-actions/user.ts:28` — session has no non-empty `userGuid` | `src/contexts/user-context.tsx:43-46` try/catch → `error` state | silent (consumers of `useUser()` inspect `error`) | no | -| `UnauthorizedError('Not authorized')` (`code: "UNAUTHORIZED"`) | `src/services/authorizationService.ts:186` (`requireSecurityRole`) — the gate every feature action and service method calls | component `try/catch` → `setError` (or `ActionError` shape for group-wizard) | toast / inline error | yes — `console.warn` of `{table, operation, reason, userId}` only (identifiers/shape, never record content) | -| `Error('Unauthorized - Missing user session data')` | `src/components/dev-panel/panels/require-dev-session.ts:34` — single guard shared by every dev-panel action | component catch → `setError` | as above | no | +| `UnauthorizedError('Not authorized')` (`code: "UNAUTHORIZED"`) — no session, no `userGuid`, no `dp_Users` row, `MP_SECURITY_ROLES` unconfigured, or no matching role | `src/services/authorizationService.ts:224` (`requireSecurityRole`) — the gate every feature action and MP-touching service method calls | tool pages: `requireToolAccess` → `redirect("/no-access")`; actions: component `try/catch` or `{ success: false, error }` envelope | `/no-access` page, or inline error | yes — `console.warn('mp.read.unauthorized' \| 'mp.write.unauthorized', { table, operation, reason, userId })`, plus `mp.write.non_user` when no MP user was resolved | +| MP failure while resolving `User_ID` / roles | `src/services/authorizationService.ts:162` (re-throw) | **never** converted to "not authorized"; propagates to error boundary / action catch | error page or inline error | MP-layer logs only | +| `hasSecurityRole()` — never throws; returns `false` on any error | `src/services/authorizationService.ts:235-244` | `src/app/(web)/tools/layout.tsx:31` → `redirect("/no-access")` | `/no-access` | no | +| `Error('Unauthorized')` | `src/components/shared-actions/user.ts:28` — session has no non-empty `userGuid` | `src/contexts/user-context.tsx:43-45` try/catch → `error` state | silent (consumers of `useUser()` inspect `error`) | no | +| `Error('Unauthorized - Missing user session data')` | `src/components/dev-panel/panels/require-dev-session.ts:34` — single guard shared by every dev-panel action | component catch → `setError` | inline | no | | ``Error(`${featureLabel} is not available in production.`)`` | `src/components/dev-panel/panels/require-dev-session.ts:30` (`featureLabel` defaults to `"Dev panel"`; deploy-tool passes `"Deploy Tool"`) | dev-panel / deploy-tool panel catch → `setError` | "production" warning to user | no | -| Zod `ZodError` via `zodResolver` (group wizard) | `src/components/group-wizard/schema.ts` (`groupWizardSchema`) | RHF `form.trigger(...)` in `src/app/(web)/tools/groupwizard/group-wizard.tsx:104` | in-form field error messages | no | -| `Error('Input must contain only digits')` (IMb) | `src/lib/imb-encoder.ts:323` | `src/lib/barcode-helpers.ts:48` **silent catch — falls through to POSTNET** | no user-facing error; label prints without IMb | no (silent) | -| `Error('Input must be 20, 25, 29, or 31 digits (got N)')` | `src/lib/imb-encoder.ts:328` | `src/lib/barcode-helpers.ts:48` silent fallthrough | as above | no | -| `Error('Invalid routing code length: N')` | `src/lib/imb-encoder.ts:249` | `src/lib/barcode-helpers.ts:48` silent fallthrough | as above | no | -| `Error('Codeword out of range: N')` | `src/lib/imb-encoder.ts:364` | `src/lib/barcode-helpers.ts:48` silent fallthrough | as above | no | -| `Error('POSTNET input must contain only digits')` | `src/lib/postnet-encoder.ts:35` | `src/lib/barcode-helpers.ts:58` silent catch → retry with 5-digit substring at `:62`, then final silent catch | label prints without barcode | no (silent) | -| `Error('POSTNET input must be 5, 9, or 11 digits (got N)')` | `src/lib/postnet-encoder.ts:39` | `src/lib/barcode-helpers.ts:58,62` silent catches | as above | no (silent) | + +### Validation + +Validator messages name the field, never the value (`src/lib/validation.ts` header). + +| Type | Thrown at | Caught at | User-facing | Logged | +|---|---|---|---|---| +| `MPValidationError` — `'Validation failed for record ${i}: ${zod message}'`, keeps `recordIndex` + `zodError` (create) | `src/lib/providers/ministry-platform/helper.ts:217` (class at `:33-41`) | re-thrown out of `createTableRecords` | server action bubbles to component | no | +| `MPValidationError` (update; `partial` → `schema.partial()`) | `src/lib/providers/ministry-platform/helper.ts:305` | re-thrown out of `updateTableRecords` | as above | no | +| `Error('Invalid GUID format')` | `src/lib/validation.ts:22` (`validateGuid`) | caller propagates | inline error | no | +| `Error('Expected positive integer')` / `'Expected positive integer for ${field}'` | `src/lib/validation.ts:35` (`validatePositiveInt`) | caller propagates / action envelope | inline error | no | +| `Error('Invalid column name')` | `src/lib/validation.ts:42` (`validateColumnName`) | `ToolService.resolveContactIds` / `FamilyService.resolveContactIdFromPage` → action | inline error | no | +| `Error('Invalid ${field}')` (e.g. `Invalid Contact_ID_Field`) | `src/lib/validation.ts:66,75` (`validateFkPath`); `src/services/familyService.ts:171` | as above | inline error | no | +| `Error('Invalid search value: ...')` — non-string, > 100 chars, control characters | `src/lib/validation.ts:129-137` (`escapeFilterString`) | search action → component | search shows error / no results | no | +| `'Mailer ID must be exactly 6 or 9 digits, got: N'` | `src/lib/validation.ts:87` (`validateMailerId`) | `prepareLabels` → envelope | inline | no | +| `'Invalid group data: <field paths>'` (envelope, not throw) | `parseGroupPayload` in `src/components/group-wizard/actions.ts` (`groupWizardSchema.safeParse`) | `createGroup` / `updateGroup` actions | inline | no | +| `'Invalid family data. Check these fields: <paths>'` (envelope) | `saveFamily` in `src/app/(web)/tools/addeditfamily/actions.ts` (`HouseholdSchema.safeParse`) | same action | inline | no | +| Zod `ZodError` via `zodResolver` (group wizard) | `src/components/group-wizard/schema.ts` (`groupWizardSchema`) | RHF `form.trigger(...)` in `src/app/(web)/tools/groupwizard/group-wizard.tsx:123` | in-form field error messages | no | +| `Error('Input must contain only digits')` (IMb) | `src/lib/imb-encoder.ts:323` | `src/lib/barcode-helpers.ts:60` catch — falls through to POSTNET | no user-facing error; label prints without IMb | yes, `console.warn('[preEncodeBarcodes] IMb encode failed, ...', { labelIndex, error: <name> })` | +| `Error('Input must be 20, 25, 29, or 31 digits (got N)')` | `src/lib/imb-encoder.ts:328` | `src/lib/barcode-helpers.ts:60` | as above | as above | +| `Error('Invalid routing code length: N')` | `src/lib/imb-encoder.ts:249` | `src/lib/barcode-helpers.ts:60` | as above | as above | +| `Error('Codeword out of range: N')` | `src/lib/imb-encoder.ts:364` | `src/lib/barcode-helpers.ts:60` | as above | as above | +| `Error('POSTNET input must contain only digits')` | `src/lib/postnet-encoder.ts:35` | `src/lib/barcode-helpers.ts:71` catch → retry with 5-digit substring, then final catch at `:79` | label prints without barcode | yes, `console.warn` per fallback step | +| `Error('POSTNET input must be 5, 9, or 11 digits (got N)')` | `src/lib/postnet-encoder.ts:39` | `src/lib/barcode-helpers.ts:71,79` | as above | as above | ### Service | Type | Thrown at | Caught at | User-facing | Logged | |---|---|---|---|---| -| `Error('User not found')` | `src/services/userService.ts:76` (`getUserIdByGuid`) | caller propagates → component catch | inline error / toast | no | -| `Error('Tool Name is required')` | `src/services/toolService.ts:248` (`deployTool` guard) | deploy-tool UI catch → `setError` | inline error | no | -| `Error('Launch Page is required')` | `src/services/toolService.ts:249` | deploy-tool UI | inline | no | -| `Error('Tool Name must be 30 characters or fewer')` | `src/services/toolService.ts:250` | deploy-tool UI | inline | no | -| `Error('Description must be 100 characters or fewer')` | `src/services/toolService.ts:251` | deploy-tool UI | inline | no | -| `Error('Launch Page must be 1024 characters or fewer')` | `src/services/toolService.ts:252` | deploy-tool UI | inline | no | -| `Error('Additional Data must be 65 characters or fewer')` | `src/services/toolService.ts:253` | deploy-tool UI | inline | no | -| `Error('Deploy did not return a tool row — check stored procedure permissions and dev credentials.')` | `src/services/toolService.ts:273` | `src/components/dev-panel/panels/deploy-tool-actions.ts` → panel catch | inline error | no | -| Procedure invocation failure from `api_MPNextTools_UpdatePageFieldOrder` (propagates from http-client) | thrown at `http-client.ts:59`, bubbles up via `src/services/fieldManagementService.ts:93-107` | `src/components/field-management/actions.ts:69` → `{ success: false, error }` | inline error | yes (http-client layer) | +| `Error('Tool Name is required')` | `src/services/toolService.ts:277` (`deployTool` guard) | deploy-tool UI catch → `setError` | inline error | no | +| `Error('Launch Page is required')` | `src/services/toolService.ts:278` | deploy-tool UI | inline | no | +| `Error('Tool Name must be 30 characters or fewer')` | `src/services/toolService.ts:279` | deploy-tool UI | inline | no | +| `Error('Description must be 100 characters or fewer')` | `src/services/toolService.ts:280` | deploy-tool UI | inline | no | +| `Error('Launch Page must be 1024 characters or fewer')` | `src/services/toolService.ts:281` | deploy-tool UI | inline | no | +| `Error('Additional Data must be 65 characters or fewer')` | `src/services/toolService.ts:282` | deploy-tool UI | inline | no | +| `Error('Deploy did not return a tool row — check stored procedure permissions and dev credentials.')` | `src/services/toolService.ts:302` | `src/components/dev-panel/panels/deploy-tool-actions.ts` → panel catch | inline error | no | +| `PartialSaveError(progress, underlying)` — any failure mid-`saveHousehold` | `src/services/familyService.ts:24-29` (wrapped by `wrap()` in `saveHousehold`) | `saveFamily` action → `{ success: false, error, progress }` | inline error; client keeps IDs already created | MP-layer logs only | +| `Error('Could not find an available envelope number after 5 attempts ...')` | `src/services/familyService.ts:749` (`resolveUniqueEnvelopeNo`) | wrapped as `PartialSaveError` → `saveFamily` envelope | inline | no | +| `Error('Google Places API key is not configured. ...')` | `src/services/googlePlacesService.ts:86` | `placeDetails` action envelope; `placeAutocomplete` short-circuits on `isEnabled()` | inline | no | +| `Error('toMpSqlDatetime: value must be a non-empty string or Date')` / `'... could not be parsed as a date'` | `src/services/domainTimezoneService.ts:286,297` | write path (`GroupService`, `FamilyService`) → action envelope | inline | no | +| `Error('parseMpDatetime: value could not be parsed as a date')` | `src/services/domainTimezoneService.ts:314` | caller | inline | no | +| Procedure failure from `api_MPNextTools_UpdatePageFieldOrder` (propagates from http-client) | `http-client.ts:226`, via `src/services/fieldManagementService.ts:115-132` | `src/components/field-management/actions.ts:99-100` → `{ success: false, error }` | inline error | yes (http-client + procedure service) | ### Client-fetch (actions returning `{success, error}` envelope) @@ -85,7 +107,7 @@ These do NOT throw to the component; server action returns `{ success: false, er | Type | Thrown at (internal) | Caught at | User-facing | Logged | |---|---|---|---|---| -| `'Too many labels (N); the limit is 10,000 per request'` / `'Invalid label data'` / `'Invalid label settings'` | `parseLabelInput` in `src/components/address-labels/schemas.ts` (Zod `safeParse`; count cap = `MAX_LABELS`), called from `prepareLabels` in `actions.ts` | `generateLabelPdf` / `generateLabelDocx` / `mergeTemplate` return the envelope | tab components `setError` | no (errors name the problem, never the payload) | +| `'Too many labels (N); the limit is 10,000 per request'` / `'Invalid label data'` / `'Invalid label settings'` | `parseLabelInput` in `src/components/address-labels/schemas.ts` (Zod `safeParse`; count cap = `MAX_LABELS` in `limits.ts`), called from `prepareLabels` in `actions.ts` | `generateLabelPdf` / `generateLabelDocx` / `mergeTemplate` return the envelope | tab components `setError` | no (errors name the problem, never the payload) | | `Unknown label stock: ${id}` (control-flow branch, not throw) | `prepareLabels` in `src/components/address-labels/actions.ts` | all three render actions return `{success:false}` | mail-merge / PDF tab `setError` | no | | `'Start position is beyond the end of the sheet'` | `prepareLabels` — `config.startPosition > stock.columns * stock.rows` | all three render actions | inline | no | | `'No labels to print' / 'No labels to export' / 'No addresses to merge'` | `prepareLabels` (`emptyMessage` arg, per action) | tab components `setError` | inline | no | @@ -97,40 +119,42 @@ These do NOT throw to the component; server action returns `{ success: false, er | PDF render failure (`@react-pdf/renderer` `toBlob`) | throws inside try in `generateLabelPdf` (`src/components/address-labels/actions.ts`) | same function wraps as `{success:false, error: message}` | inline error | yes, `console.error('generateLabelPdf error:', describeError(error))` | | Docx render failure (`Packer.toBuffer`) | throws inside try in `generateLabelDocx` | same function wraps as envelope | inline error | yes, `console.error('generateLabelDocx error:', describeError(error))` | | Docxtemplater render error (tag mismatch) | throws inside `doc.render` in `mergeTemplate` | same function wraps; if message includes `'tag'` returns `Template error: ... Check that merge tokens are correctly formatted.` | inline error | yes, `console.error('mergeTemplate error:', describeError(error))` | -| Profile fetch failure in `UserProvider` | `src/components/shared-actions/user.ts:28` (`Unauthorized` — session has no `userGuid`) or downstream MP error from `UserService.getUserProfile` | `src/contexts/user-context.tsx:43-46` try/catch sets `error` state | silent (consumers of `useUser()` inspect `error`) | no | -| `Error("useUser must be used within a UserProvider")` | `src/contexts/user-context.tsx:79` | uncaught — surfaces to nearest React error boundary | white-screen error unless a boundary is present | no | +| Profile fetch failure in `UserProvider` | `src/components/shared-actions/user.ts:28` (`Unauthorized` — session has no `userGuid`) or downstream MP error from `UserService.getUserProfile` | `src/contexts/user-context.tsx:43-45` try/catch sets `error` state | silent (consumers of `useUser()` inspect `error`) | no | +| `Error("useUser must be used within a UserProvider")` | `src/contexts/user-context.tsx:80` | uncaught — surfaces to nearest React error boundary | error boundary UI | no | | `Error("useFormField should be used within <FormField>")` | `src/components/ui/form.tsx:53` | uncaught — React error boundary | same as above | no | -| "IMb requires a 6 or 9 digit USPS Mailer ID" (control-flow) | `src/components/address-labels/mail-merge-tab.tsx:111-114` | same function → `setError` early return | inline error | no | +| "IMb requires a 6 or 9 digit USPS Mailer ID" (control-flow) | `src/components/address-labels/mail-merge-tab.tsx:112` | same function → `setError` early return | inline error | no | ## Error-handling patterns ### Server action failures Two shapes exist; actions are inconsistent. -- **Throw-then-catch (most actions):** action throws a plain `Error`; calling component wraps call in `try/catch` and calls `setError(err instanceof Error ? err.message : 'fallback')`. Examples: `selection-actions.ts`, `user-tools-actions.ts`, `field-management/actions.ts`, `contact-records-actions.ts`. -- **Envelope shape (`ActionError`):** action returns `{ success: false, error: string }`. Used by `address-labels/actions.ts` (PDF/docx/merge), `group-wizard/actions.ts` (`fetchGroupRecord`, `createGroup`, `updateGroup`), `field-management/actions.ts:61-72` (`savePageFieldOrder`). -- **Mixed in same file** is common — auth/validation errors throw; domain operations return envelope. No central policy; see `address-labels/actions.ts` for both shapes in one file. +- **Throw-then-catch:** action throws (including `UnauthorizedError` from `requireAccess()`); calling component wraps call in `try/catch` and calls `setError(err instanceof Error ? err.message : 'fallback')`. Examples: `selection-actions.ts`, `user-tools-actions.ts`, `contact-records-actions.ts`, `fetchAddressLabels`, `field-management/actions.ts` (`fetchPages`, `fetchPageFieldData`), `group-wizard/actions.ts` (`fetchGroupWizardLookups`, `searchContacts`, `searchGroups`). +- **Envelope shape (`ActionError`):** action returns `{ success: false, error: string }`. Used by `address-labels/actions.ts` (PDF/docx/merge), `group-wizard/actions.ts` (`fetchGroupRecord`, `createGroup`, `updateGroup`), `field-management/actions.ts` (`savePageFieldOrder`), `addeditfamily/actions.ts` (`fetchHousehold`, `resolveContactIdFromPage`, `placeDetails`, `saveFamily` — the last adds `progress`). +- **Mixed in same file** is common — no central policy; see `address-labels/actions.ts` for both shapes in one file. ### HTTP error shape (MP API) -All errors from `http-client.ts` follow: `${METHOD} ${endpoint} failed: ${status} ${statusText}${body ? ` - ${body}` : ''}`. GET / JSON-POST / JSON-PUT / GET /files/{uniqueId} include the body; FormData POST / FormData PUT / DELETE do **NOT** (inconsistent — gotcha). +All errors from `http-client.ts` follow `${METHOD} ${redactedEndpoint} failed: ${status} ${statusText}` (or `: ${errorName}` for transport failures). No body is ever included — for any method or content type. ### Zod validation errors (helper.ts) -`MPHelper.createTableRecords` and `MPHelper.updateTableRecords` catch `schema.parse` throws and re-wrap them as plain `Error` with message `Validation failed for record ${i}: ${validationError.message}`. The original `ZodError.issues` array is **discarded** — only `validationError.message` (Zod's default `issues` string) survives. Callers cannot programmatically inspect field-level issues. See `src/lib/providers/ministry-platform/helper.ts:189` and `:273`. +`MPHelper.createTableRecords` and `MPHelper.updateTableRecords` re-wrap a `ZodError` from `schema.parse` as `MPValidationError` (`src/lib/providers/ministry-platform/helper.ts:33-41`), which subclasses `Error` and keeps `recordIndex` and the original `zodError` — callers can check `instanceof MPValidationError` for field-level issues. Non-Zod throws are wrapped in a plain `Error` with the same message prefix. -### Silent catches (deliberate fallbacks) -- **Barcode encoding cascade.** `src/lib/barcode-helpers.ts:48,58,62` — on IMb failure, silently fall through to POSTNET; on POSTNET failure, silently retry with first 5 digits; on second POSTNET failure, silently return label with no barcode. No `console.error`. Label prints with best-available barcode or none. Test coverage: `src/lib/barcode-helpers.test.ts`. -- **`ToolService.getPageData` / `getUserTools` / `getSelectionRecordIds` length checks.** `src/services/toolService.ts:132,161,189` — when the stored procedure returns empty/missing result sets, the services return `null` / `[]` rather than throwing. Callers must differentiate "no data" from "error" by checking the return value. -- **`parseToolParams` failures** (if added later) — see `src/lib/tool-params.ts`; existing conventions prefer returning defaults over throwing. +### Silent / quiet fallbacks (deliberate) +- **Barcode encoding cascade.** `src/lib/barcode-helpers.ts:60,71,79` — on IMb failure, fall through to POSTNET; on POSTNET failure, retry with first 5 digits; on second failure, return the label with no barcode. Each step logs a `console.warn` with the label index and error name only. Test coverage: `src/lib/barcode-helpers.test.ts`. +- **`ToolService.getPageData` / `getUserTools` / `getSelectionRecordIds` length checks** — when the stored procedure returns empty/missing result sets, the services return `null` / `[]` rather than throwing. Callers must differentiate "no data" from "error" by checking the return value. +- **`parseToolParams`** (`src/lib/tool-params.server.ts`) — a `getPageData` failure is swallowed to `pageData = undefined` with `console.warn('tool_params.page_data_unavailable', { pageID })`; bad integers become `undefined`, a malformed `recordDescription` escape falls back to the raw value. +- **`GooglePlacesService.resolveApiKey`** — an MP lookup failure is swallowed and falls through to `GOOGLE_PLACES_API_KEY`. The `dp_Configuration_Settings`/`read` gate runs before this, outside the try/catch, so an `UnauthorizedError` is never swallowed. ### Uncaught errors → React error boundary -- `useUser` outside `UserProvider` — `src/contexts/user-context.tsx:79`. +- Boundaries: `src/app/(web)/error.tsx` (inside the app shell), `src/app/error.tsx`, `src/app/global-error.tsx` (root layout failures). +- `useUser` outside `UserProvider` — `src/contexts/user-context.tsx:80`. - `useFormField` outside `<FormField>` — `src/components/ui/form.tsx:53`. -No app-level error boundary exists as of baseline SHA `971c40b`. These crashes surface to Next.js's default error page. ### Unlogged failures -Validation errors (`src/lib/validation.ts`), user-facing session errors (`'Unauthorized'`, `'User GUID not found in session'`), and all envelope-shaped action errors (`{success:false, error}`) are NOT logged server-side. Only HTTP-level failures go through `logger.error`. Server logs will not show Zod validation problems or auth rejections unless the error is re-thrown into the http path. +Validation errors (`src/lib/validation.ts`), session errors (`'Unauthorized'`, `'Unauthorized - Missing user session data'`), and envelope-shaped action errors (`{success:false, error}`) are NOT logged server-side. Authorization denials **are** logged (`mp.read.unauthorized` / `mp.write.unauthorized`), as are HTTP-level failures (`mp.request.failed`). ## Related docs - [call-graphs.md](call-graphs.md) — error paths in the context of full request flows - [../mp-provider/error-handling.md](../mp-provider/error-handling.md) — MP provider-specific error flow -- [../GOTCHAS.md](../GOTCHAS.md) — symptom-first index of known traps (populated in parallel by gotchas consolidator) +- [../security/README.md](../security/README.md) — authorization gate and denial logging +- [../GOTCHAS.md](../GOTCHAS.md) — symptom-first index of known traps - [../auth/README.md](../auth/README.md) — session/OAuth error surface diff --git a/.claude/references/dto-constants/README.md b/.claude/references/dto-constants/README.md index 1af0469..d3477f0 100644 --- a/.claude/references/dto-constants/README.md +++ b/.claude/references/dto-constants/README.md @@ -5,25 +5,29 @@ domain: dto-constants --- ## What's in this domain -Hand-written application-level DTOs/ViewModels (`src/lib/dto/`), shared runtime constants (`SERVICE_TYPES`, `LABEL_STOCKS`, `WIZARD_STEPS`), and the SQL-safety validation helpers in `src/lib/validation.ts`. +Hand-written application-level DTOs/ViewModels (`src/lib/dto/`), shared runtime constants (`MP_FETCH_BATCH_SIZE`, `SERVICE_TYPES`, `LABEL_STOCKS`, `WIZARD_STEPS`), and the SQL-safety validation helpers in `src/lib/validation.ts`. ## File map | File | Purpose | When to read | |------|---------|--------------| -| `dtos.md` | Catalog of hand-written DTOs (`LabelData`, `LabelConfig`, `SkipRecord`, etc.), fields, consumers | Adding a new DTO or wiring up a label/export feature | -| `constants.md` | Shared `as const`/module-level constants, magic-number audit, validation helpers (`validateGuid`, `escapeFilterString`) | Before hardcoding a new ID, enum, or SQL filter fragment | +| `dtos.md` | Catalog of hand-written DTOs (`LabelData`, `LabelConfig`, `SkipRecord`, family `Household` / `HouseholdSchema`, etc.), fields, consumers | Adding a new DTO or wiring up a label/export/family feature | +| `constants.md` | Shared `as const`/module-level constants, magic-number audit, validation helpers (`validateGuid`, `validateFkPath`, `escapeFilterString`, …) | Before hardcoding a new ID, enum, or SQL filter fragment | ## Code surfaces | Path | Role | |------|------| -| `src/lib/dto/address-label.dto.ts` | All current DTOs + `SERVICE_TYPES` constant | +| `src/lib/dto/address-label.dto.ts` | Address-label DTOs + `SERVICE_TYPES` constant | +| `src/lib/dto/family.ts` | Add/Edit Family DTOs (Zod schemas + types, empty-record factories) | | `src/lib/dto/index.ts` | Barrel re-export for `@/lib/dto` | -| `src/lib/validation.ts` | SQL-safety helpers (GUID, positive int, column name, LIKE escape) | -| `src/lib/validation.test.ts` | 19 tests across the 4 helpers | +| `src/lib/constants.ts` | `MP_FETCH_BATCH_SIZE` | +| `src/lib/validation.ts` | SQL-safety helpers (GUID, positive int, column name, FK path, LIKE escape) + `validateMailerId` | +| `src/lib/validation.test.ts` | 33 tests across the 6 helpers + a rule-14 message suite | | `src/lib/label-stock.ts` | `LABEL_STOCKS` constant (Avery sheet geometries) | | `src/lib/barcode-helpers.ts` | Pre-encodes `LabelData[]` using `LabelConfig` | +| `src/components/address-labels/limits.ts` | Template / label-count / zip limits | +| `src/components/address-labels/schemas.ts` | Server-side Zod validation for `LabelData[]` + `LabelConfig` | | `src/lib/tool-params.ts` | `PageData`, `ToolParams` URL-param view models | | `src/components/group-wizard/types.ts` | `WIZARD_STEPS` constant + wizard view models | diff --git a/.claude/references/dto-constants/constants.md b/.claude/references/dto-constants/constants.md index e866ff9..694e0b9 100644 --- a/.claude/references/dto-constants/constants.md +++ b/.claude/references/dto-constants/constants.md @@ -2,10 +2,10 @@ title: Shared Constants & Validation Helpers domain: dto-constants type: reference -applies_to: [src/lib/dto/address-label.dto.ts, src/lib/label-stock.ts, src/lib/validation.ts, src/components/group-wizard/types.ts, src/services/addressLabelService.ts, src/services/toolService.ts, src/components/address-labels/limits.ts] -symbols: [SERVICE_TYPES, LABEL_STOCKS, LabelStockConfig, LabelPosition, WIZARD_STEPS, WizardStepIndex, validateGuid, validatePositiveInt, validateColumnName, escapeFilterString, BATCH_SIZE, MAX_TEMPLATE_BYTES, MAX_TEMPLATE_LABEL, MAX_LABELS, TEMPLATE_ZIP_LIMITS] -related: [dtos.md, ../services/README.md, ../utils/README.md] -last_verified: 2026-04-17 +applies_to: [src/lib/constants.ts, src/lib/dto/address-label.dto.ts, src/lib/label-stock.ts, src/lib/validation.ts, src/components/group-wizard/types.ts, src/services/familyService.ts, src/components/address-labels/limits.ts] +symbols: [MP_FETCH_BATCH_SIZE, SERVICE_TYPES, LABEL_STOCKS, LabelStockConfig, LabelPosition, WIZARD_STEPS, WizardStepIndex, validateGuid, validatePositiveInt, validateColumnName, validateFkPath, validateMailerId, escapeFilterString, MAX_SEARCH_TERM_LENGTH, MAX_TEMPLATE_BYTES, MAX_TEMPLATE_LABEL, MAX_LABELS, TEMPLATE_ZIP_LIMITS] +related: [dtos.md, ../services/README.md, ../services/query-patterns.md, ../utils/README.md] +last_verified: 2026-10-10 --- ## Purpose @@ -13,24 +13,32 @@ Inventory of module-level runtime constants and SQL-safety validation helpers. A ## Files +- `src/lib/constants.ts` — `MP_FETCH_BATCH_SIZE = 100` (shared IN-clause batch size) - `src/lib/dto/address-label.dto.ts:26-32` — `SERVICE_TYPES` - `src/lib/label-stock.ts` — `LABEL_STOCKS` (Avery sheet dimensions) -- `src/lib/validation.ts` — 4 SQL-safety helpers + 2 regexes -- `src/lib/validation.test.ts` — 19 test cases +- `src/lib/validation.ts` — 5 filter/select guards + `validateMailerId`, `MAX_SEARCH_TERM_LENGTH` +- `src/lib/validation.test.ts` — 33 test cases - `src/components/group-wizard/types.ts:51-60` — `WIZARD_STEPS`, `WizardStepIndex` -- `src/services/addressLabelService.ts:21` — `BATCH_SIZE = 100` (module-local) -- `src/services/toolService.ts:283` — `BATCH_SIZE = 100` (class static) +- `src/components/address-labels/limits.ts` — `MAX_TEMPLATE_BYTES`, `MAX_TEMPLATE_LABEL`, `MAX_LABELS`, `TEMPLATE_ZIP_LIMITS` ## Key concepts -- Constants are **not centralized** — each lives next to its consumer (`src/lib/dto/*`, `src/lib/label-stock.ts`, `src/components/*/types.ts`). -- `validation.ts` is the single SQL-safety entry point; all services route user input through one of its four helpers before interpolating into filter strings. -- Two independent `BATCH_SIZE = 100` literals exist (services shard TODO candidate). +- Constants are **not centralized** — each lives next to its consumer (`src/lib/dto/*`, `src/lib/label-stock.ts`, `src/components/*/types.ts`, `src/components/address-labels/limits.ts`). `src/lib/constants.ts` holds only the cross-service `MP_FETCH_BATCH_SIZE`. +- `validation.ts` is the single SQL-safety entry point; services route every interpolated value through one of its helpers before building a `$filter` / `$select`. +- Validator errors name the field, never the value (CLAUDE.md rule 14), and every validator checks the runtime type (server-action payloads are caller-shaped). ## API / Interface ### Runtime enum-like constants +```typescript +// src/lib/constants.ts +/** Max contact/record IDs to bundle in a single MP $filter `IN (...)` clause. */ +export const MP_FETCH_BATCH_SIZE = 100; +``` + +Used by `AddressLabelService.getAddressesForContacts` and `ToolService.resolveContactIds`. + ```typescript // src/lib/dto/address-label.dto.ts:26-32 export const SERVICE_TYPES = [ @@ -42,7 +50,7 @@ export const SERVICE_TYPES = [ ] as const; ``` -USPS service type codes drive `LabelConfig.serviceType`. Default selected in UI is `'040'` (`src/app/(web)/tools/addresslabels/address-labels.tsx:53`). +USPS service type codes drive `LabelConfig.serviceType`; `labelConfigSchema` (`src/components/address-labels/schemas.ts`) restricts it to these ids server-side. Default selected in UI is `'040'` (`src/app/(web)/tools/addresslabels/address-labels.tsx:53`). ```typescript // src/lib/label-stock.ts:23 @@ -54,7 +62,7 @@ export const LABEL_STOCKS: LabelStockConfig[] = [ ]; ``` -Dimensions in PDF points (72pt = 1in). `getLabelStock(id)` lookup + `getLabelPosition(stock, index)` iteration at `src/lib/label-stock.ts:82-98`. +Dimensions in PDF points (72pt = 1in). `getLabelStock(id)` lookup + `getLabelPosition(stock, index)` at `src/lib/label-stock.ts:82-98`. ```typescript // src/components/group-wizard/types.ts:51-60 @@ -75,44 +83,54 @@ export type WizardStepIndex = 0 | 1 | 2 | 3 | 4 | 5; ```typescript const GUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const COLUMN_NAME_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/; +const MAILER_ID_REGEX = /^(\d{6}|\d{9})$/; export function validateGuid(value: string): string; -export function validatePositiveInt(value: number): number; +export function validatePositiveInt(value: number, field?: string): number; // positive SAFE integer export function validateColumnName(value: string): string; +export function validateFkPath(value: unknown, field = 'column path'): string; // `A_TABLE.Col`, ≤4 `_TABLE` segments, ≤256 chars +export function validateMailerId(value: string): string; // exactly 6 or 9 digits +export const MAX_SEARCH_TERM_LENGTH = 100; export function escapeFilterString(value: string): string; ``` -`escapeFilterString` handles three characters (MP filter uses SQL LIKE semantics): +`escapeFilterString` (MP filter uses SQL `LIKE` semantics): ```typescript -// src/lib/validation.ts:25-30 +// src/lib/validation.ts:127-144 (abridged) export function escapeFilterString(value: string): string { + // throws on non-string, length > MAX_SEARCH_TERM_LENGTH, or ASCII control chars return value - .replace(/'/g, "''") + .replace(/\[/g, '[[]') .replace(/%/g, '[%]') - .replace(/_/g, '[_]'); + .replace(/_/g, '[_]') + .replace(/'/g, "''") + .replace(QUOTE_LOOKALIKES, '_'); } ``` ## Consumer map -| Symbol | Consumers | +| Symbol | Consumers (non-test) | |--------|-----------| -| `SERVICE_TYPES` | `src/components/address-labels/address-labels-form.tsx:9,128` | -| `LABEL_STOCKS` | `src/lib/label-stock.ts` (internal), `src/components/address-labels/address-labels-form.tsx`, `src/app/(web)/tools/addresslabels/address-labels.tsx` | -| `getLabelStock` | `src/components/address-labels/actions.ts:21`, `src/app/(web)/tools/addresslabels/address-labels.tsx` | +| `MP_FETCH_BATCH_SIZE` | `src/services/addressLabelService.ts`, `src/services/toolService.ts` | +| `SERVICE_TYPES` | `src/components/address-labels/address-labels-form.tsx:9,128`, `src/components/address-labels/schemas.ts` | +| `LABEL_STOCKS` | `src/lib/label-stock.ts` (internal), `src/components/address-labels/address-labels-form.tsx` | +| `getLabelStock` | `src/components/address-labels/actions.ts`, `src/app/(web)/tools/addresslabels/address-labels.tsx` | | `WIZARD_STEPS` | `src/components/group-wizard/*` | -| `validateGuid` | `src/services/userService.ts:72,84` | -| `validatePositiveInt` | `src/services/toolService.ts:334`, `src/services/addressLabelService.ts:76`, `src/services/groupService.ts:176` | -| `validateColumnName` | `src/services/toolService.ts:307,308,310,312` | -| `escapeFilterString` | `src/services/groupService.ts:152,163` | +| `validateGuid` | `src/services/userService.ts`, `src/services/authorizationService.ts`, `src/lib/auth.ts`, `src/lib/providers/ministry-platform/services/guards.ts` | +| `validatePositiveInt` | `src/services/{toolService,addressLabelService,groupService,familyService}.ts`, `src/app/(web)/tools/addeditfamily/actions.ts`, `src/lib/providers/ministry-platform/services/guards.ts` | +| `validateColumnName` | `src/services/toolService.ts`, `src/services/familyService.ts` | +| `validateFkPath` | `src/services/toolService.ts`, `src/services/familyService.ts` | +| `validateMailerId` | `src/lib/barcode-helpers.ts`, `src/components/address-labels/actions.ts` | +| `escapeFilterString` | `src/services/groupService.ts`, `src/services/familyService.ts`, `src/services/toolService.ts` (`listRoles`) | ## How it works -- `as const` tuples give literal-typed ids to `LabelConfig.serviceType`, `WIZARD_STEPS`, etc. +- `as const` tuples give literal-typed ids to `SERVICE_TYPES`, `WIZARD_STEPS`, etc. - `LABEL_STOCKS` is a mutable-typed array (`LabelStockConfig[]`) — NOT `as const`. If mutation-safety is desired this is a refactor candidate. -- `validateGuid` / `validatePositiveInt` / `validateColumnName` throw on bad input; callers rely on the throw (no return-based error). -- `escapeFilterString` returns the escaped string; callers inline-interpolate it into the OData `$filter`. +- The validators throw on bad input; callers rely on the throw (no return-based error). +- `escapeFilterString` returns the escaped string; callers interpolate it into the `$filter`. ## Magic-number / hardcoded-ID audit @@ -120,29 +138,32 @@ Items found in `src/` outside `src/lib/dto/` and `src/lib/validation.ts`: | Literal | Location | Meaning | Extraction candidate? | |---------|----------|---------|-----------------------| -| `BATCH_SIZE = 100` | `src/services/addressLabelService.ts:21` (module) + `src/services/toolService.ts:283` (class static) | MP record fetch batch size | Yes — two independent copies, merge into single export (e.g. `src/lib/constants.ts`) | +| `MP_FETCH_BATCH_SIZE = 100` | `src/lib/constants.ts` | MP record fetch batch size | Done — single shared export | | `MAX_TEMPLATE_BYTES = 2 * 1024 * 1024` / `MAX_TEMPLATE_LABEL = '2MB'` | `src/components/address-labels/limits.ts` | Mail-merge `.docx` template cap; one export used by client `mail-merge-tab.tsx` and server `actions.ts` (`mergeTemplate`) | Done — single shared constant | | `MAX_LABELS = 10_000` | `src/components/address-labels/limits.ts` | Labels per render request (`parseLabelInput` in `schemas.ts`) | Done | | `TEMPLATE_ZIP_LIMITS = { maxEntries: 1_000, maxTotalBytes: 64 MB }` | `src/components/address-labels/limits.ts` | Zip-bomb bounds passed to `assertZipWithinLimits` (`src/lib/zip-guard.ts`) | Done | | `bodySizeLimit: "4mb"` | `next.config.ts` (`experimental.serverActions`) | Server-action request cap; sized with `limits.ts` under Vercel's 4.5 MB body cap | Coupled config — change with `limits.ts` | | `'040'` default | `src/app/(web)/tools/addresslabels/address-labels.tsx:53` | Default USPS service type | Consider `DEFAULT_SERVICE_TYPE = SERVICE_TYPES[0].id` | -| `serialLength = mailerId.length === 6 ? 9 : 6` | `src/lib/barcode-helpers.ts:23` | USPS IMb rule: 6-digit MID → 9-digit serial, 9-digit MID → 6-digit serial | Domain rule — documented in comment, OK | -| `barcodeId = '00'` | `src/lib/barcode-helpers.ts:22` | IMb header byte | OK (domain constant, inline) | +| `serialLength = mailerId.length === 6 ? 9 : 6` | `src/lib/barcode-helpers.ts:25` | USPS IMb rule: 6-digit MID → 9-digit serial, 9-digit MID → 6-digit serial | Domain rule — documented in comment, OK | +| `barcodeId = '00'` | `src/lib/barcode-helpers.ts:24` | IMb header byte | OK (domain constant, inline) | +| `DEFAULT_FAMILY_DEFAULTS` — `congregationId: 1`, `sourceId: 18`, `householdPositionId: 1`, `participantTypeId: 4` | `src/services/familyService.ts:73-81` | Add/Edit Family form defaults (MP lookup IDs) | Yes — domain-specific MP IDs; candidates for `dp_Configuration_Settings` | +| `DONOR_DEFAULTS` — `Statement_Frequency_ID: 1`, `Statement_Type_ID: 1`, `Statement_Method_ID: 1` | `src/services/familyService.ts:31-36` | New donor statement settings (Quarterly / Individual / Postal Mail) | Yes — same reason | +| `Contact_Status_ID = 1` | `src/services/familyService.ts:131` (`searchContacts` filter); `|| 1` fallback in `saveMember` | "Active" contact status | Yes | -**No hardcoded MP table-IDs, Event_Type_IDs, Contact_Status_IDs, Page_IDs, or Household_Position_IDs appear in `src/` business code.** (Verified via `Grep` for `Page_ID\s*=\s*\d+`, `Event_Type_ID\s*=\s*\d+`, `Group_Type_ID\s*=\s*\d+`, `Contact_Status_ID\s*=\s*\d+`, `Household_Position_ID\s*=\s*\d+` — zero hits in `src/` outside generated `models/` and doc-comment examples.) +Group wizard, address labels, field management and tool code contain no hardcoded MP lookup IDs. The family tool does (above) — the MP IDs it assumes must exist in the target domain. ## Usage -Service-side SQL-safety pattern (from `src/services/groupService.ts:150-177`): +Service-side SQL-safety pattern (from `src/services/groupService.ts:257-291`): ```typescript import { escapeFilterString, validatePositiveInt } from '@/lib/validation'; const escaped = escapeFilterString(term); -// ... filter: `Group_Name LIKE '%${escaped}%'` +// ... filter: `Group_Name LIKE '${escaped}%' AND End_Date IS NULL` // Numeric path safety -filter: `Group_ID = ${validatePositiveInt(groupId)}` +filter: `Groups.Group_ID = ${validatePositiveInt(groupId)}` ``` ```typescript @@ -156,14 +177,14 @@ const records = await this.mp!.getTableRecords<MPUserProfile>({ ## Gotchas -- **Two `BATCH_SIZE = 100` constants.** Module-local in `addressLabelService.ts:21`; class-static in `toolService.ts:283`. Drift risk if MP API limit changes. See TODO `2026-04-17-dto-constants-batchsize-duplication.md`. -- **Template size cap is one constant now.** The former duplicated 5 MB literals (`MAX_TEMPLATE_SIZE` in `actions.ts`, `MAX_FILE_SIZE` in `mail-merge-tab.tsx`) were replaced by `MAX_TEMPLATE_BYTES` (2 MB) in `src/components/address-labels/limits.ts`. Resolved. Raising it means re-checking `next.config.ts` `bodySizeLimit` and Vercel's 4.5 MB cap together. -- **`escapeFilterString` escapes `%` + `_` + `'`** — not generic SQL escaping, specific to MP OData/LIKE. Do NOT use it as a universal SQL sanitizer. -- **`validatePositiveInt` rejects 0** (strictly positive). If you need non-negative int accept `>= 0`, write a new helper. -- **`validateGuid` accepts upper- or lower-case hex** but NOT braces (no `{xxx-xxx}` form). Confirmed by `src/lib/validation.test.ts:11-13`. +- **Template size cap is one constant.** The former duplicated 5 MB literals were replaced by `MAX_TEMPLATE_BYTES` (2 MB) in `src/components/address-labels/limits.ts`. Raising it means re-checking `next.config.ts` `bodySizeLimit` and Vercel's 4.5 MB cap together. +- **`escapeFilterString` is LIKE-specific** (`[`, `%`, `_`, `'`, quote look-alikes) and throws on >100 chars or control characters — not a universal SQL sanitizer, and not for identifiers (use `validateColumnName` / `validateFkPath`). +- **`validatePositiveInt` rejects 0** (strictly positive) and anything beyond `Number.MAX_SAFE_INTEGER`. If you need non-negative ints, write a new helper. +- **`validateGuid` accepts upper- or lower-case hex** (`/i`) but NOT braces (no `{xxx-xxx}` form) — the regex is anchored. ## Related docs - `dtos.md` — DTO shapes that USE these constants (e.g., `LabelConfig.serviceType: string` maps to `SERVICE_TYPES[i].id`) -- `../services/README.md` — all four validation helpers are consumed here +- `../services/query-patterns.md` — how services apply the validators +- `../services/README.md` — the services that consume them - `../utils/README.md` — barcode/label-stock utilities (separate domain; this doc only inventories the constants) diff --git a/.claude/references/dto-constants/dtos.md b/.claude/references/dto-constants/dtos.md index 895e3c0..51da197 100644 --- a/.claude/references/dto-constants/dtos.md +++ b/.claude/references/dto-constants/dtos.md @@ -2,28 +2,31 @@ title: Hand-written DTOs / ViewModels domain: dto-constants type: reference -applies_to: [src/lib/dto/address-label.dto.ts, src/lib/dto/index.ts, src/lib/tool-params.ts, src/components/group-wizard/types.ts, src/services/addressLabelService.ts] -symbols: [LabelData, SkipReason, SkipRecord, AddressMode, BarcodeFormat, LabelConfig, FetchAddressLabelsResult, ContactAddressRow, PageData, ToolParams, LookupItem, GroupWizardLookups, ContactSearchResult, GroupSearchResult, CreateGroupResult, UpdateGroupResult, ActionError] -related: [constants.md, ../services/README.md, ../mp-provider/README.md] -last_verified: 2026-04-17 +applies_to: [src/lib/dto/address-label.dto.ts, src/lib/dto/family.ts, src/lib/dto/index.ts, src/lib/tool-params.ts, src/components/group-wizard/types.ts, src/components/address-labels/schemas.ts, src/services/addressLabelService.ts] +symbols: [LabelData, SkipReason, SkipRecord, AddressMode, BarcodeFormat, LabelConfig, FetchAddressLabelsResult, Household, FamilyMember, FamilyAddress, FamilyMemberParticipant, FamilyLookups, FamilyDefaults, LookupOption, StateOption, CountryOption, SaveProgress, SavedMemberId, HouseholdSchema, ContactAddressRow, PageData, ToolParams, LookupItem, GroupWizardLookups, ContactSearchResult, GroupSearchResult, CreateGroupResult, UpdateGroupResult, ActionError] +related: [constants.md, ../services/README.md, ../services/family-service.md, ../mp-provider/README.md] +last_verified: 2026-10-10 --- ## Purpose -Hand-written application-layer view models, separate from the 603 auto-generated MP table/view models in `src/lib/providers/ministry-platform/models/`. DTOs here shape data for UI + server actions; they are not 1:1 with DB rows. +Hand-written application-layer view models, separate from the auto-generated MP models in `src/lib/providers/ministry-platform/models/` (301 table interfaces + 301 `*Schema.ts` Zod schemas + `index.ts`). DTOs here shape data for UI + server actions; they are not 1:1 with DB rows. ## Files -- `src/lib/dto/address-label.dto.ts` — all current `@/lib/dto` exports (one file) -- `src/lib/dto/index.ts` — barrel re-export +- `src/lib/dto/address-label.dto.ts` — address-label DTOs + `SERVICE_TYPES` +- `src/lib/dto/family.ts` — Add/Edit Family DTOs: Zod schemas (`HouseholdSchema` etc.) with inferred types, plain interfaces, and `emptyAddress` / `emptyMember` / `emptyHousehold` factories +- `src/lib/dto/index.ts` — barrel re-export of both files - `src/lib/tool-params.ts` — `PageData`, `ToolParams` (URL-param view models, not re-exported from `@/lib/dto`) - `src/components/group-wizard/types.ts` — wizard view models co-located with the feature -- `src/services/addressLabelService.ts:4-19` — `ContactAddressRow` (MP-row DTO co-located with service) +- `src/components/address-labels/schemas.ts` — server-side Zod schemas for `LabelData[]` / `LabelConfig` (`labelDataSchema`, `labelsSchema`, `labelConfigSchema`, `parseLabelInput`) +- `src/services/addressLabelService.ts:7-22` — `ContactAddressRow` (MP-row DTO co-located with service) ## Key concepts - **DTO (Data Transfer Object) / ViewModel** — hand-written TypeScript type shaping data between layers (service → action → component, or URL → server). Distinct from generated MP schemas which mirror DB columns. -- **Barrel import** — consumers use `import type { ... } from '@/lib/dto'`; internal file is `address-label.dto.ts` but all access goes through `index.ts`. -- **Co-location** — feature-specific view models (e.g. `GroupSearchResult`, `WIZARD_STEPS`) live next to the feature, not in `src/lib/dto/`. Only cross-feature DTOs live in `src/lib/dto/`. +- **Barrel import** — address-label consumers use `import type { ... } from '@/lib/dto'`. Family consumers (`addeditfamily/actions.ts`, `add-edit-family.tsx`, `familyService.ts`) import `@/lib/dto/family` directly; both paths resolve to the same module. +- **Co-location** — feature-specific view models (e.g. `GroupSearchResult`, `WIZARD_STEPS`) live next to the feature, not in `src/lib/dto/`. +- **Two `ContactSearchResult` types** — `@/lib/dto/family` (`{ contactId, displayName, detail }`) and `src/components/group-wizard/types.ts` (`{ Contact_ID, Display_Name, Email_Address }`). Same name, different shapes; import the one for your feature. ## API / Interface @@ -71,7 +74,33 @@ export interface FetchAddressLabelsResult { } ``` -### `src/services/addressLabelService.ts:4-19` +### `src/lib/dto/family.ts` (abridged) + +```typescript +export const FamilyAddressSchema = z.object({ addressId, addressLine1, addressLine2, city, state, region, postalCode, countryCode }); +export const FamilyMemberParticipantSchema = z.object({ participantId, participantTypeId, notes }); +export const FamilyMemberSchema = z.object({ contactId, firstName, lastName, /* ...names, IDs, contact info */, envelopeNo, participant, donorId, isDonor }); +export const HouseholdSchema = z.object({ + householdId, householdName, householdPhone, congregationId, sourceId, + address: FamilyAddressSchema, alternateMailingAddress: FamilyAddressSchema, + seasonStart, seasonEnd, repeatsAnnually, areHeadsMarried, + members: z.array(FamilyMemberSchema), +}); +export type Household = z.infer<typeof HouseholdSchema>; // likewise FamilyAddress, FamilyMember, FamilyMemberParticipant + +export interface FamilyLookups { congregations, sources, householdPositions, participantTypes, maritalStatuses, + prefixes, suffixes, genders, contactStatuses, primaryLanguages, faithBackgrounds: LookupOption[]; + states: StateOption[]; countries: CountryOption[]; } +export interface FamilyDefaults { congregationId; sourceId; countryCode; state; householdPositionId; participantTypeId; showEnvelopeNumbers; } +export interface ContactSearchResult { contactId: number; displayName: string; detail: string; } +export interface SavedMemberId { tempContactId; contactId; participantId; donorId; envelopeNo; envelopeBumped; } +export interface SaveProgress { mainAddressId; altAddressId; householdId; members: SavedMemberId[]; } +``` + +- `HouseholdSchema` is the runtime guard for the `saveFamily` action (`safeParse` before `FamilyService.saveHousehold`). +- `SaveProgress` is returned on success **and** on `PartialSaveError`, so the client can retry without duplicating records. + +### `src/services/addressLabelService.ts:7-22` ```typescript export interface ContactAddressRow { @@ -92,7 +121,7 @@ export interface ContactAddressRow { } ``` -### `src/lib/tool-params.ts:3-28` +### `src/lib/tool-params.ts:14-39` ```typescript export interface PageData { @@ -163,34 +192,33 @@ export interface ActionError { success: false; error: string; } ## DTO consumer map -| DTO | Defined | Imported by | +| DTO | Defined | Imported by (non-test) | |-----|---------|-------------| -| `LabelData` | `src/lib/dto/address-label.dto.ts:1` | `src/lib/barcode-helpers.ts:3`, `src/lib/barcode-helpers.test.ts:3`, `src/components/address-labels/actions.ts:14`, `src/components/address-labels/actions.test.ts:91`, `src/components/address-labels/address-label.tsx:4`, `src/components/address-labels/label-document.tsx:5`, `src/components/address-labels/mail-merge-tab.tsx:11`, `src/components/address-labels/word-document.ts:17`, `src/components/address-labels/word-document.test.ts:5`, `src/app/(web)/tools/addresslabels/address-labels.tsx:13` | +| `LabelData` | `src/lib/dto/address-label.dto.ts:1` | `src/lib/barcode-helpers.ts`, `src/components/address-labels/{actions,schemas,word-document}.ts`, `src/components/address-labels/{address-label,label-document,mail-merge-tab}.tsx`, `src/app/(web)/tools/addresslabels/address-labels.tsx` | | `SkipReason` | `src/lib/dto/address-label.dto.ts:15` | (indirect, through `SkipRecord`) | -| `SkipRecord` | `src/lib/dto/address-label.dto.ts:17` | `src/components/address-labels/actions.ts:15`, `src/components/address-labels/mail-merge-tab.tsx:11`, `src/components/address-labels/address-labels-summary.tsx:6`, `src/app/(web)/tools/addresslabels/address-labels.tsx:13` | -| `AddressMode` | `src/lib/dto/address-label.dto.ts:23` | `src/components/address-labels/address-labels-form.tsx:10` | -| `BarcodeFormat` | `src/lib/dto/address-label.dto.ts:24` | `src/components/address-labels/address-labels-form.tsx:10` | -| `LabelConfig` | `src/lib/dto/address-label.dto.ts:34` | `src/lib/barcode-helpers.ts:3`, `src/components/address-labels/actions.ts:16`, `src/components/address-labels/address-labels-form.tsx:10`, `src/components/address-labels/mail-merge-tab.tsx:11`, `src/app/(web)/tools/addresslabels/address-labels.tsx:13`, plus tests | -| `FetchAddressLabelsResult` | `src/lib/dto/address-label.dto.ts:44` | `src/components/address-labels/actions.ts:17` | -| `ContactAddressRow` | `src/services/addressLabelService.ts:4` | `src/components/address-labels/actions.ts:11` | -| `PageData` | `src/lib/tool-params.ts:3` | Tools routing layer (URL → page metadata) | -| `ToolParams` | `src/lib/tool-params.ts:17` | Tools routing layer | -| Group-wizard view models | `src/components/group-wizard/types.ts` | Group-wizard components + actions | +| `SkipRecord` | `src/lib/dto/address-label.dto.ts:17` | `src/components/address-labels/actions.ts`, `mail-merge-tab.tsx`, `address-labels-summary.tsx`, `src/app/(web)/tools/addresslabels/address-labels.tsx` | +| `AddressMode` / `BarcodeFormat` | `src/lib/dto/address-label.dto.ts:23-24` | `src/components/address-labels/address-labels-form.tsx` | +| `LabelConfig` | `src/lib/dto/address-label.dto.ts:34` | `src/lib/barcode-helpers.ts`, `src/components/address-labels/{actions,schemas}.ts`, `address-labels-form.tsx`, `mail-merge-tab.tsx`, `src/app/(web)/tools/addresslabels/address-labels.tsx` | +| `FetchAddressLabelsResult` | `src/lib/dto/address-label.dto.ts:44` | `src/components/address-labels/actions.ts` | +| Family DTOs / `HouseholdSchema` | `src/lib/dto/family.ts` | `src/services/familyService.ts`, `src/app/(web)/tools/addeditfamily/actions.ts`, `src/app/(web)/tools/addeditfamily/add-edit-family.tsx` | +| `ContactAddressRow` | `src/services/addressLabelService.ts:7` | `src/components/address-labels/actions.ts` | +| `PageData` / `ToolParams` | `src/lib/tool-params.ts:14`, `:28` | `src/lib/tool-params.server.ts`, `src/services/toolService.ts`, tool pages/components, dev-panel | +| Group-wizard view models | `src/components/group-wizard/types.ts` | Group-wizard components + actions, `src/services/groupService.ts` | ## How it works -- `@/lib/dto` is the only centralized DTO module. Address-label feature is the single "cross-concern" set because it is consumed by services, actions, components, and a PDF document renderer. +- `@/lib/dto` holds the cross-concern DTO sets: address labels (consumed by services, actions, components, PDF/Word renderers) and Add/Edit Family (service, actions, client component). - Feature-local DTOs (group wizard, tool-params) are **not** re-exported through `@/lib/dto` — they live beside their consumers. -- Service-layer row shapes (e.g. `ContactAddressRow`) are DTOs of the **MP-response shape** (matches `$select` field list at `src/services/addressLabelService.ts:23-38`), not generated from schema. -- `LabelData` is transformed from `ContactAddressRow` in `filterAndTransform()` at `src/components/address-labels/actions.ts:42-96`. -- `preEncodeBarcodes(labels, config)` at `src/lib/barcode-helpers.ts:33` mutates `LabelData[]` by adding `barStates` + `barType` based on `LabelConfig`. +- Service-layer row shapes (e.g. `ContactAddressRow`) are DTOs of the **MP-response shape** (matches the `SELECT_FIELDS` list at `src/services/addressLabelService.ts:24-39`), not generated from schema. +- `LabelData` is transformed from `ContactAddressRow` in `filterAndTransform()` at `src/components/address-labels/actions.ts:52-114`. +- `preEncodeBarcodes(labels, config)` at `src/lib/barcode-helpers.ts:45` returns a new `LabelData[]` with `barStates` + `barType` added according to `LabelConfig`. ## Usage Consumer example (form + server action): ```typescript -// src/components/address-labels/actions.ts:13-18 +// src/components/address-labels/actions.ts:11-16 import type { LabelData, SkipRecord, @@ -207,13 +235,14 @@ import type { AddressMode, BarcodeFormat, LabelConfig } from '@/lib/dto'; ## Gotchas -- **Naming drift between DTO and service row.** `LabelData` uses camelCase (`addressLine1`, `postalCode`) while `ContactAddressRow` uses MP field names (`Address_Line_1`, `Postal_Code`). Transform happens at `src/components/address-labels/actions.ts:42-96`. Do NOT propagate snake_case MP names into UI-layer DTOs. -- **`SkipRecord.name` is the computed display label**, not `Display_Name`. It is either `Household_Name` (household mode) or `Display_Name` (individual mode), fallback chain at `src/components/address-labels/actions.ts:51-53`. +- **Naming drift between DTO and service row.** `LabelData` uses camelCase (`addressLine1`, `postalCode`) while `ContactAddressRow` uses MP field names (`Address_Line_1`, `Postal_Code`). Transform happens in `filterAndTransform` (`src/components/address-labels/actions.ts:52-114`). Do NOT propagate snake_case MP names into UI-layer DTOs. +- **`SkipRecord.name` is the computed display label**, not `Display_Name`. It is either `Household_Name` (household mode, falling back to `Display_Name`) or `Display_Name` (individual mode) — `src/components/address-labels/actions.ts:61-63`. - **`FetchAddressLabelsResult` has fields `printable` + `skipped`** — NOT `labels` / `totalFetched`. -- **DTOs are not validated at runtime.** No Zod schema wraps `LabelConfig` etc. User input in `mailerId` is only stripped to 9 digits at `src/components/address-labels/address-labels-form.tsx:109`. +- **Runtime validation lives beside the action, not on the DTO.** `LabelData` / `LabelConfig` are plain interfaces; the render actions validate caller input with `parseLabelInput` (`src/components/address-labels/schemas.ts`), which bounds string lengths, caps the count at `MAX_LABELS`, restricts `serviceType` to `SERVICE_TYPES` ids, and **drops** `barStates` / `barType` (computed server-side). The form also strips `mailerId` to ≤9 digits (`src/components/address-labels/address-labels-form.tsx:109`). Family DTOs are the exception: they *are* Zod schemas. ## Related docs - `constants.md` — `SERVICE_TYPES`, `LABEL_STOCKS`, `WIZARD_STEPS`, SQL validation helpers - `../services/README.md` — services using `validatePositiveInt` / `escapeFilterString` / `validateGuid` + returning these DTOs +- `../services/family-service.md` — consumer of the family DTOs - `../mp-provider/README.md` — generated Zod schemas (application-level DTOs exist alongside those, by design) diff --git a/.claude/references/ministryplatform.datetimehandling.md b/.claude/references/ministryplatform.datetimehandling.md index ae88778..93ef870 100644 --- a/.claude/references/ministryplatform.datetimehandling.md +++ b/.claude/references/ministryplatform.datetimehandling.md @@ -24,7 +24,7 @@ await tz.toMpSqlDatetime(new Date()); // → MP-TZ wall-clock for "now" await tz.parseMpDatetime('2026-05-17 12:00:00'); // → Date instant ``` -For client-side rendering, expose the IANA zone through `getMpTimezone()` in `src/components/shared-actions/domain.ts` and thread it as a prop into the component that needs to format MP datetimes. +For client-side rendering, expose the IANA zone through the `getMpTimezone()` server action in `src/components/shared-actions/domain.ts` (session-checked, not role-gated — a documented rule-12 carve-out) and thread it as a prop into the component that needs to format MP datetimes. ### `toMpSqlDatetime(value)` — write path @@ -41,6 +41,8 @@ Returns the SQL datetime string MP's table API expects (`YYYY-MM-DD HH:MM:SS`). The rule: **strings with no zone marker are wall-clock**, strings/Dates with explicit zone info are instants that get converted. +**Zod validation:** generated `*Schema.ts` files validate `DateTime`/`Timestamp` columns with a regex that accepts this `YYYY-MM-DD HH:MM:SS` output as well as ISO forms — `^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$` (space or `T`, optional seconds/fraction, optional `Z`/offset). `Date` columns accept a bare `YYYY-MM-DD` or the same date with a time part. So passing a generated `schema` to `createTableRecords`/`updateTableRecords` is compatible with `toMpSqlDatetime`. The regex checks shape only — it does not stop an ISO-`Z` value from being sent; routing through `toMpSqlDatetime` is still what keeps the stored time right. + ### `parseMpDatetime(value)` — read path arithmetic Use when you need a `Date` instant to do real arithmetic on a value MP returned. For pure display, prefer `Intl.DateTimeFormat({ timeZone })` against the raw string. @@ -120,13 +122,13 @@ The shared signature of these bugs: a `Date` object that crosses a zone boundary ## Windows ↔ IANA zone names -MP's `/domain` endpoint returns `TimeZoneName` as a **Windows** zone (e.g. `"Eastern Standard Time"`). `Intl.DateTimeFormat` requires **IANA** (e.g. `"America/New_York"`). `DomainTimezoneService` maps between them. If a new MP deployment surfaces an unmapped zone, `resolveIanaTimezone` throws with the unmapped name — extend the table rather than silently falling back to the server's local zone. +MP's `/domain` endpoint returns `TimeZoneName` as a **Windows** zone (e.g. `"Eastern Standard Time"`). `Intl.DateTimeFormat` requires **IANA** (e.g. `"America/New_York"`). `DomainTimezoneService` maps between them (`WINDOWS_TO_IANA`). `resolveIanaTimezone` also passes through values that already look like IANA names (contain `/`) and maps `UTC`/`Etc/UTC` to `Etc/UTC`. If a new MP deployment surfaces an unmapped Windows zone, it throws with the unmapped name — extend the table rather than silently falling back to the server's local zone. ## Testing When a test exercises code that goes through `DomainTimezoneService`: -1. **Mock `MPHelper.getDomainInfo`** to return a known `TimeZoneName` — use `vi.hoisted()` because the service's `MPHelper` is constructed at module-load time. -2. **Reset the singleton** between tests: `(DomainTimezoneService as any).instance = null` in `beforeEach`. +1. **Mock `MPHelper.getDomainInfo`** to return a known `TimeZoneName` — declare the mock with `vi.hoisted()` so the `vi.mock('@/lib/providers/ministry-platform', ...)` factory can reference it (the service constructs its `MPHelper` when the singleton is first created). +2. **Reset the singleton** between tests: `(DomainTimezoneService as any).instance = null` in `beforeEach` (or call `clearCache()` to drop only the cached zone). 3. **Use `mockReset()` (not `clearAllMocks()`)** on the `getDomainInfo` mock when you queue per-test responses with `mockResolvedValueOnce`. `clearAllMocks` doesn't drain those queues, and tests that don't hit `getMpTimezone()` leave queue entries behind that leak forward. 4. **Run under multiple `TZ` env vars** — at minimum `TZ=UTC` and `TZ=America/Los_Angeles`. The original bug was invisible when developer machines and the server happened to be in the same zone as the MP domain. diff --git a/.claude/references/mp-provider/README.md b/.claude/references/mp-provider/README.md index c9836eb..15ea94f 100644 --- a/.claude/references/mp-provider/README.md +++ b/.claude/references/mp-provider/README.md @@ -38,7 +38,7 @@ Consumers (services in src/services/, server actions) src/lib/providers/ministry-platform/utils/http-client.ts │ fetch() ▼ - Ministry Platform REST API (https://<domain>.ministryplatformapi.com) + Ministry Platform REST API (MINISTRY_PLATFORM_BASE_URL, e.g. https://mpi.ministryplatform.com/ministryplatformapi) ``` ## File map @@ -47,7 +47,7 @@ Consumers (services in src/services/, server actions) |------|---------|--------------| | `architecture.md` | Stack layers, singleton rationale, entry-point semantics | First — to place other docs | | `client.md` | `MinistryPlatformClient`, OAuth2 client credentials, token refresh, dev-token pipeline | When debugging auth or adding a new token-consuming surface | -| `type-generation.md` | `generate-types.ts` CLI, `--clean`, `--zod`, `--detailed`, model output (603 files) | When running `npm run mp:generate:models` or changing the generator | +| `type-generation.md` | `generate-types.ts` CLI, `--clean`, `--zod`, `--detailed`, model output (615 files) | When running `npm run mp:generate:models` or changing the generator | | `error-handling.md` | How MP API errors (401/404/409/500), token-refresh errors, Zod errors surface | When diagnosing a failure at any layer | Services (each MP service wrapping one area of the API) live in `services/` and are documented separately. @@ -62,9 +62,9 @@ Services (each MP service wrapping one area of the API) live in `services/` and | `src/lib/providers/ministry-platform/index.ts` | Barrel: `MinistryPlatformProvider`, `MPHelper`, `MinistryPlatformClient`, types, models, services, auth | | `src/lib/providers/ministry-platform/auth/client-credentials.ts` | `getClientCredentialsToken(profile)` | | `src/lib/providers/ministry-platform/utils/http-client.ts` | `HttpClient` — fetch wrapper | -| `src/lib/providers/ministry-platform/utils/logger.ts` | `logger` — console wrapper gated on `NODE_ENV` | +| `src/lib/providers/ministry-platform/utils/logger.ts` | `logger` — `[MP]`-prefixed `console.error` wrapper (no debug channel); `errorName` | | `src/lib/providers/ministry-platform/scripts/generate-types.ts` | Type-generator CLI | -| `src/lib/providers/ministry-platform/models/` | 603 generated model + Zod schema files | +| `src/lib/providers/ministry-platform/models/` | 615 generated model + Zod schema files (307 tables) | ## Related domains - `../services/README.md` — app-level service singletons that instantiate `MPHelper` diff --git a/.claude/references/mp-provider/architecture.md b/.claude/references/mp-provider/architecture.md index 210aec0..735b208 100644 --- a/.claude/references/mp-provider/architecture.md +++ b/.claude/references/mp-provider/architecture.md @@ -15,14 +15,14 @@ related: - client.md - error-handling.md - ../services/README.md -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose Four-layer SDK: facade (`MPHelper`) → singleton orchestrator (`MinistryPlatformProvider`) → per-domain services → HTTP/auth client (`MinistryPlatformClient` + `HttpClient`). Consumers instantiate `MPHelper` freely; state (token, services) lives on the singleton. ## Files -- `src/lib/providers/ministry-platform/helper.ts` — `MPHelper` class, param-mapping + Zod validation +- `src/lib/providers/ministry-platform/helper.ts` — `MPHelper` class, param-mapping + Zod validation; `MPValidationError` - `src/lib/providers/ministry-platform/provider.ts` — `MinistryPlatformProvider` singleton, holds `MinistryPlatformClient` + six services - `src/lib/providers/ministry-platform/client.ts` — OAuth2 token lifecycle + HttpClient factory - `src/lib/providers/ministry-platform/index.ts` — barrel export @@ -33,7 +33,7 @@ Four-layer SDK: facade (`MPHelper`) → singleton orchestrator (`MinistryPlatfor - **Facade (`MPHelper`)** — friendly param names (`select`, `filter`, `top`) mapped to `$`-prefixed OData params (`$select`, `$filter`, `$top`); optional Zod validation on create/update - **Singleton (`MinistryPlatformProvider`)** — private constructor + `getInstance()`; six services share one `MinistryPlatformClient` so they share one token cache -- **Client (`MinistryPlatformClient`)** — holds cached token, `expiresAt`, and an `HttpClient` bound to `() => this.token` so services always see the latest token without being re-wired +- **Client (`MinistryPlatformClient`)** — holds two internal `TokenPipeline`s (`default`, `dev`), each with its cached token, `expiresAt`, and an `HttpClient` bound to `() => this.token` so services always see the latest token without being re-wired (see `client.md`) - **HTTP (`HttpClient`)** — one instance per pipeline (default + dev), methods: `get`, `post`, `postFormData`, `put`, `putFormData`, `delete` - **Services** — six domain services instantiated once in the provider constructor (`TableService`, `ProcedureService`, `CommunicationService`, `MetadataService`, `DomainService`, `FileService`) @@ -44,7 +44,7 @@ Four-layer SDK: facade (`MPHelper`) → singleton orchestrator (`MinistryPlatfor Every `new MPHelper()` instance resolves to the same singleton under the hood: ```typescript -// src/lib/providers/ministry-platform/helper.ts:41-43 +// src/lib/providers/ministry-platform/helper.ts:62-64 constructor() { this.provider = MinistryPlatformProvider.getInstance(); } @@ -58,8 +58,8 @@ Consequence: creating many `MPHelper` instances is cheap — they all share toke |-------|---------| | Table CRUD | `getTableRecords<T>(params)`, `createTableRecords<T>(table, records, params?)`, `updateTableRecords<T>(table, records, params?)`, `deleteTableRecords<T>(table, ids, params?)` | | Copy / recurrence | `copyRecord<T>(table, recordId, pattern, params?)`, `copyRecordWithSubpages<T>(table, recordId, copyParams, params?)`, `generateSequence(pattern)` | -| Stored procedures | `getProcedures(search?)`, `executeProcedure(procedure, params?)`, `executeProcedureWithBody(procedure, parameters)` | -| Communications | `createCommunication(communication, attachments?)`, `sendMessage(message, attachments?)` | +| Stored procedures | `getProcedures(search?)`, `executeProcedure(procedure, params?)`, `executeProcedureWithBody(procedure, parameters, queryParams?)` | +| Communications | `createCommunication(communication, attachments?, params?)`, `sendMessage(message, attachments?, params?)` (`params` = `{ $userId }`) | | Files | `getFilesByRecord`, `uploadFiles`, `updateFile`, `deleteFile`, `getFileContentByUniqueId`, `getFileMetadata`, `getFileMetadataByUniqueId` | | Domain | `getDomainInfo()`, `getGlobalFilters(params?)` | | Metadata | `getTables(search?)`, `refreshMetadata()` | @@ -94,7 +94,7 @@ private constructor() { Most methods are thin pass-throughs to one of the six services. The exception is `generateSequence`, which calls the client's HttpClient directly: ```typescript -// src/lib/providers/ministry-platform/provider.ts:228-244 +// src/lib/providers/ministry-platform/provider.ts:228-245 public async generateSequence(pattern: RecurrencePattern): Promise<string[]> { await this.client.ensureValidToken(); const queryParams: QueryParams = { /* ...pattern fields... */ }; @@ -118,9 +118,9 @@ export * from './auth'; ## How it works - **Instantiation flow:** `new MPHelper()` → `MinistryPlatformProvider.getInstance()` → on first call: `new MinistryPlatformClient()` + six `new XService(this.client)` — all share the same client reference -- **Token flow:** each service calls `this.client.ensureValidToken()` before making a request; the token value is read lazily by `HttpClient` via the getter `() => this.token` closed over at client construction time +- **Token flow:** each service calls `this.client.ensureValidToken()` before making a request; the token value is read lazily by `HttpClient` via the getter `() => this.token` closed over at `TokenPipeline` construction time - **Param normalization:** `MPHelper.getTableRecords` rewrites friendly names to `$`-prefixed params and forwards to `provider.getTableRecords(table, params)` -- **Validation:** optional `schema?: ZodObject<ZodRawShape>` in create/update params. If present, `MPHelper` calls `schema.parse(record)` (or `schema.partial().parse(record)` for updates when `partial !== false`) and rewraps the error as `Validation failed for record ${index}: ...` +- **Validation:** optional `schema?: ZodObject<ZodRawShape>` in create/update params. If present, `MPHelper` calls `schema.parse(record)` (or `schema.partial().parse(record)` for updates when `partial !== false`); a `ZodError` is rethrown as `MPValidationError` (`helper.ts:33`, extends `Error`, carries `recordIndex` + `zodError`, message `Validation failed for record ${index}: ...`). `MPValidationError` is exported from `helper.ts` only, not the barrel - **Service isolation:** `TableService` / `ProcedureService` / etc. each manage their own endpoint URLs + request shapes; the provider only delegates ## Usage @@ -140,8 +140,9 @@ const contacts = await mp.getTableRecords<Contact>({ ``` ```typescript -// Validated create (from helper.ts JSDoc examples) -import { ContactLogSchema } from '@/lib/providers/ministry-platform/models'; +// Validated create (adapted from helper.ts JSDoc). Schemas are NOT in the models barrel +// (models/index.ts exports interfaces only) — import the *Schema file directly. +import { ContactLogSchema } from '@/lib/providers/ministry-platform/models/ContactLogSchema'; await mp.createTableRecords('Contact_Log', [{ Contact_ID: 12345, @@ -154,9 +155,11 @@ await mp.createTableRecords('Contact_Log', [{ }); ``` +Generated Zod schemas validate `DateTime`/`Timestamp` columns with a regex that accepts the `YYYY-MM-DD HH:MM:SS` string `toMpSqlDatetime` returns as well as ISO forms (`T` separator, optional `Z`/offset), so `schema` and CLAUDE.md rule 17 combine as shown. See `type-generation.md` § Zod type mapping and `../ministryplatform.datetimehandling.md`. + ## Gotchas -- **Singleton state is process-global.** In tests, reset with a mocked `getInstance` (see `helper.test.ts:50-82`) or mock the client + services directly (see `provider.test.ts:60-103`). The provider itself has no `reset()` method. +- **Singleton state is process-global.** In tests, reset with a mocked `getInstance` (see `helper.test.ts:56-88`) or mock the client + services directly (see `provider.test.ts:60-102`). The provider itself has no `reset()` method. - **`MPHelper` constructor returns before any API call.** Token acquisition is deferred to the first method call that hits `ensureValidToken()`; construction is cheap. - **Do not cache an `HttpClient` reference across tokens.** Services should always go through `this.client.getHttpClient()` (not capture the returned value) — only the instance is stable, token value inside is not. - **Do not call MPHelper directly from components.** Wrap it in a service class (`src/services/*.ts`) per project convention. diff --git a/.claude/references/mp-provider/client.md b/.claude/references/mp-provider/client.md index 9e4c6c6..2c55eaa 100644 --- a/.claude/references/mp-provider/client.md +++ b/.claude/references/mp-provider/client.md @@ -21,7 +21,7 @@ related: - architecture.md - error-handling.md - ../auth/README.md -last_verified: 2026-09-30 +last_verified: 2026-10-10 --- ## Purpose diff --git a/.claude/references/mp-provider/error-handling.md b/.claude/references/mp-provider/error-handling.md index 9e3af0c..5e14474 100644 --- a/.claude/references/mp-provider/error-handling.md +++ b/.claude/references/mp-provider/error-handling.md @@ -12,11 +12,12 @@ symbols: - HttpClient - MinistryPlatformClient - MPHelper + - MPValidationError - errorName related: - client.md - architecture.md -last_verified: 2026-09-30 +last_verified: 2026-10-10 --- ## Purpose @@ -25,7 +26,7 @@ How Ministry Platform API errors, token-refresh errors, input-guard refusals and ## Key concepts - **Every layer re-throws.** Services log a structured event (`logger.error('mp.<area>.<op>_failed', { ...ids, error: errorName(e) })`) and re-throw the original error. -- **Error shape is `Error`, not a custom class.** Consumers must read `.message` for the status code. +- **Error shape is `Error`.** HTTP/token/guard failures are plain `Error`s — consumers must read `.message` for the status code. The one subclass is `MPValidationError` (Zod failures in `MPHelper`, see below). - **Guards throw before any token or network work**, with a fixed message naming the field. - **One automatic retry exists:** a 401 triggers a forced token refresh and a single retry. Nothing else is retried. @@ -38,6 +39,7 @@ How Ministry Platform API errors, token-refresh errors, input-guard refusals and | Guard | Non-array `ids` to `deleteTableRecords` | `Invalid record IDs` | | Guard | Bad file unique ID | `Invalid GUID format` | | HttpClient | Unsafe endpoint (traversal, `?#\`, encoded forms, control chars) | `Refusing unsafe MP API endpoint` | +| HttpClient | Base URL fails to parse | `Invalid MP API base URL` | | HttpClient | Non-2xx (after the one 401 retry) | `${METHOD} ${endpoint} failed: ${status} ${statusText}` | | HttpClient | Network error / timeout / refused redirect | `${METHOD} ${endpoint} failed: ${errorName}` (e.g. `TimeoutError`) | | HttpClient | 2xx with non-JSON content-type | `${METHOD} ${endpoint} failed: unexpected content-type` | @@ -48,7 +50,7 @@ How Ministry Platform API errors, token-refresh errors, input-guard refusals and | Token | Within negative-cache window | `MP access token unavailable: recent refresh failed` | | Token | Dev env vars missing | `Dev client credentials are not configured...` | | FileService | Download non-2xx | `GET /files/{uniqueId} failed: ${status} ${statusText}` | -| MPHelper | Zod parse fail on create/update | `Validation failed for record ${index}: ...` (no API call made) | +| MPHelper | Zod parse fail on create/update | `MPValidationError` (`helper.ts:33`): `Validation failed for record ${index}: ...`, plus `.recordIndex` and `.zodError` (no API call made). A non-`ZodError` throw from `parse` becomes a plain `Error` with the same message prefix | `${endpoint}` is the path only, with GUIDs replaced by `{uniqueId}`. @@ -69,7 +71,7 @@ All are written as `console.error('[MP]', event, fields)` via `logger.error`. - **Detecting 401/404:** `e.message.includes('404')`. There is no structured error type. - **A 401 you see is after a retry** with a freshly issued token — it is not a stale-cache race. -- **Zod failures short-circuit the API call**, and the re-wrapped `Error` only carries `.message`. +- **Zod failures short-circuit the API call.** Check `instanceof MPValidationError` (import from `@/lib/providers/ministry-platform/helper` — it is not re-exported by the barrel) to read `.zodError.issues`. ## Related docs - `client.md` — token pipelines, HttpClient request path diff --git a/.claude/references/mp-provider/services/communication.md b/.claude/references/mp-provider/services/communication.md index 03054f0..530d310 100644 --- a/.claude/references/mp-provider/services/communication.md +++ b/.claude/references/mp-provider/services/communication.md @@ -5,22 +5,23 @@ type: reference applies_to: [src/lib/providers/ministry-platform/services/communication.service.ts] symbols: [CommunicationService, createCommunication, sendMessage] related: [../README.md, file.md] -last_verified: 2026-09-30 +last_verified: 2026-10-10 --- ## Purpose Create MP `Communications` records (renders + schedules delivery) and send direct messages via `/messages`. Both support optional file attachments via multipart branch. ## Files -- `src/lib/providers/ministry-platform/services/communication.service.ts` — implementation (80 lines) -- `src/lib/providers/ministry-platform/services/communication.service.test.ts` — tests (162 lines) +- `src/lib/providers/ministry-platform/services/communication.service.ts` — implementation (95 lines) +- `src/lib/providers/ministry-platform/services/communication.service.test.ts` — tests (225 lines) ## Key concepts - **Two endpoints, same pattern:** - `POST /communications` — creates a `Communications` row, renders body, schedules delivery. Supports `Email | Text | Letter` (`CommunicationInfo.CommunicationType`). - `POST /messages` — direct send; addresses are passed in the payload (no template/contact resolution). - **Attachment branch:** if `attachments && attachments.length > 0`, serialize the info object as a JSON string under the FormData key `communication` / `message` and append files as `file-{i}`. -- **Empty array = no attachments** — `attachments: []` takes the JSON branch (`communication.service.test.ts:107-114`). +- **Empty array = no attachments** — `attachments: []` takes the JSON branch (`communication.service.test.ts:150-161`). +- **`$userId` (audit attribution)** — optional third arg `params?: Pick<TableQueryParams, '$userId'>`; only `$userId` is forwarded as a query param on both branches (`toQueryParams`, `communication.service.ts:7-10`). ## API / Interface @@ -29,12 +30,14 @@ Signatures copied from `src/lib/providers/ministry-platform/services/communicati ```typescript public async createCommunication( communication: CommunicationInfo, - attachments?: File[] + attachments?: File[], + params?: UserIdParams // Pick<TableQueryParams, '$userId'> ): Promise<Communication> public async sendMessage( message: MessageInfo, - attachments?: File[] + attachments?: File[], + params?: UserIdParams ): Promise<Communication> ``` @@ -94,30 +97,31 @@ export interface Communication { ``` ## How it works -- Branch on `attachments?.length > 0` (`communication.service.ts:22`, `:42`). +- Branch on `attachments && attachments.length > 0` (`communication.service.ts:32`, `:55`). - Non-attachment path: spread info object (`{ ...communication }`) — creates a shallow copy before the HTTP POST. - Attachment path delegates to private `createCommunicationWithAttachments` / `sendMessageWithAttachments`: ```typescript -// communication.service.ts:53-65 +// communication.service.ts:66-79 private async createCommunicationWithAttachments( communication: CommunicationInfo, - attachments: File[] + attachments: File[], + queryParams?: QueryParams ): Promise<Communication> { const formData = new FormData(); formData.append('communication', JSON.stringify(communication)); attachments.forEach((file, index) => { formData.append(`file-${index}`, file, file.name); }); - return await this.client.getHttpClient().postFormData<Communication>('/communications', formData); + return await this.client.getHttpClient().postFormData<Communication>('/communications', formData, queryParams); } ``` -- `sendMessage` variant uses FormData key `message` instead of `communication` (`communication.service.ts:72`). +- `sendMessage` variant uses FormData key `message` instead of `communication` (`communication.service.ts:87`). ## Usage -From tests (`communication.service.test.ts:67-85`): +From tests (`communication.service.test.ts:110-128`): ```typescript const file = new File(['content'], 'test.pdf', { type: 'application/pdf' }); @@ -128,19 +132,19 @@ const result = await service.createCommunication(communicationInfo, [file]); // formData.get('file-0').name === 'test.pdf' ``` -Note: no application-layer wrapper currently calls these methods (greppable via `Grep createCommunication src/services`). They are exposed through `MPHelper` (`helper.ts:559, :573`) and ready for use by `src/components/**/actions.ts`. +Note: no application-layer wrapper currently calls these methods (greppable via `Grep createCommunication src/services`). They are exposed through `MPHelper` (`helper.ts:595, :611`) and ready for use by `src/components/**/actions.ts`. ## Error handling | Operation | Error | Test location | |---|---|---| -| `createCommunication` | generic POST failure | `communication.service.test.ts:101-105` | -| `sendMessage` | generic POST failure | `communication.service.test.ts:145-149` | +| `createCommunication` | generic POST failure | `communication.service.test.ts:144-148` | +| `sendMessage` | generic POST failure | `communication.service.test.ts:208-212` | -Token validation happens once per outer call (`communication.service.test.ts:152-161`). +Token validation happens once per outer call (`communication.service.test.ts:215-224`). ## Gotchas -- **Empty `attachments: []` takes the JSON branch** — the guard is `attachments.length > 0`, not just truthiness. If you want to force multipart with no files, you cannot (`communication.service.ts:22`). +- **Empty `attachments: []` takes the JSON branch** — the guard is `attachments.length > 0`, not just truthiness. If you want to force multipart with no files, you cannot (`communication.service.ts:32`). - **Response shape uses MP database snake_case column names** (`Communication_ID`, `Author_User_ID`) while the request shape uses camelCase (`AuthorUserId`). Do not assume symmetry. - **Attachment limit** — not enforced at this layer. MP API enforces server-side; expect 413 Payload Too Large on oversize. - **JSON-stringifying the payload twice** — on multipart, the info object is `JSON.stringify`'d into a single FormData field. Server must parse it back. No merge with top-level form fields. diff --git a/.claude/references/mp-provider/services/domain.md b/.claude/references/mp-provider/services/domain.md index ad66cf7..41ca63a 100644 --- a/.claude/references/mp-provider/services/domain.md +++ b/.claude/references/mp-provider/services/domain.md @@ -5,15 +5,15 @@ type: reference applies_to: [src/lib/providers/ministry-platform/services/domain.service.ts] symbols: [DomainService, getDomainInfo, getGlobalFilters] related: [../README.md, metadata.md] -last_verified: 2026-09-30 +last_verified: 2026-10-10 --- ## Purpose Read domain-level configuration (display name, timezone, culture, MFA settings) and fetch the lookup values for the domain's global filter. ## Files -- `src/lib/providers/ministry-platform/services/domain.service.ts` — implementation (42 lines) -- `src/lib/providers/ministry-platform/services/domain.service.test.ts` — tests (94 lines) +- `src/lib/providers/ministry-platform/services/domain.service.ts` — implementation (53 lines) +- `src/lib/providers/ministry-platform/services/domain.service.test.ts` — tests (128 lines) ## Key concepts - `getDomainInfo` hits `/domain` (no params). Authenticated; ties to the current access token's domain. @@ -79,7 +79,7 @@ export interface GlobalFilterParams { ## Usage -From tests (`domain.service.test.ts:66-74`): +From tests (`domain.service.test.ts:86-94`): ```typescript const filters = [{ Key: 1, Value: 'Global' }]; @@ -89,20 +89,19 @@ const result = await service.getGlobalFilters({ $userId: 123 } as any); // expect(mockHttpClient.get).toHaveBeenCalledWith('/domain/filters', { $userId: 123 }); ``` -Also exposed via `MPHelper.getDomainInfo` / `.getGlobalFilters` (`src/lib/providers/ministry-platform/helper.ts:346, :373`) — no direct callers in `src/services/**` or `src/components/**` as of this revision (greppable: no hits). +Also exposed via `MPHelper.getDomainInfo` / `.getGlobalFilters` (`src/lib/providers/ministry-platform/helper.ts:380, :406`). Only app caller: `DomainTimezoneService.getMpTimezone` (`src/services/domainTimezoneService.ts:259`) reads `getDomainInfo().TimeZoneName`; `getGlobalFilters` has no app callers. ## Error handling | Status | Operation | Test location | |---|---|---| -| 403 Forbidden | `getGlobalFilters` | `domain.service.test.ts:76-80` | -| 500 Internal Server Error | `getDomainInfo` | `domain.service.test.ts:48-52` | +| 403 Forbidden | `getGlobalFilters` | `domain.service.test.ts:111-115` | +| 500 Internal Server Error | `getDomainInfo` | `domain.service.test.ts:68-72` | ## Gotchas -- **`Key: 0` = unfiltered records** — documented in the JSDoc (`domain.service.ts:28-29`). Treat 0 as sentinel, not as a real filter id. +- **`Key: 0` = unfiltered records** — documented in the JSDoc (`domain.service.ts:28-30`). Treat 0 as sentinel, not as a real filter id. - **No `$ignorePermissions`** — requests already run as the admin-level service account; the flag could only widen that. A non-integer `$userId` throws `Expected positive integer for user ID` before any request. - **`GlobalFilterTableName` is optional** — on domains with no global filter configured, `DomainInfo.GlobalFilterTableName` is `undefined`, and `getGlobalFilters` still returns `[]` (not an error). -- **Response type is loose where MP sends more fields** — test fixture `{ DomainName: 'Test', DomainId: 1 }` does not match `DomainInfo` (which uses `DisplayName`, no `DomainId`). The mock is intentionally loose; production MP returns the `DomainInfo` shape. ## Related docs - `../README.md` — provider overview diff --git a/.claude/references/mp-provider/services/file.md b/.claude/references/mp-provider/services/file.md index e92e99e..891e278 100644 --- a/.claude/references/mp-provider/services/file.md +++ b/.claude/references/mp-provider/services/file.md @@ -5,19 +5,19 @@ type: reference applies_to: [src/lib/providers/ministry-platform/services/file.service.ts] symbols: [FileService, getFilesByRecord, uploadFiles, updateFile, deleteFile, getFileContentByUniqueId, getFileMetadata, getFileMetadataByUniqueId] related: [../README.md, table.md, communication.md] -last_verified: 2026-09-30 +last_verified: 2026-10-10 --- ## Purpose CRUD on files attached to MP records via `/files/*` REST endpoints. Uploads use `multipart/form-data`; downloads by UniqueFileId are public (no auth header). ## Files -- `src/lib/providers/ministry-platform/services/file.service.ts` — implementation (213 lines) -- `src/lib/providers/ministry-platform/services/file.service.test.ts` — tests (332 lines) +- `src/lib/providers/ministry-platform/services/file.service.ts` — implementation (251 lines) +- `src/lib/providers/ministry-platform/services/file.service.test.ts` — tests (446 lines) ## Key concepts -- Uploads use `FormData` with field key pattern `file-{index}` per file (`file.service.ts:52-54`). Optional params (`description`, `isDefaultImage`, `longestDimension`) are appended as both form fields AND query string params (`file.service.ts:57-71`). -- `getFileContentByUniqueId` is the only method that does **NOT** call `ensureValidToken` and does **NOT** send an Authorization header — it uses raw `fetch()` (`file.service.ts:159-185`). +- Uploads use `FormData` with field key pattern `file-{index}` per file (`file.service.ts:81-83`). Optional params (`description`, `isDefaultImage`, `longestDimension`) are appended as both form fields AND query string params (`file.service.ts:85-100`). +- `getFileContentByUniqueId` is the only method that does **NOT** call `ensureValidToken` and does **NOT** send an Authorization header — it uses raw `fetch()` (`file.service.ts:190-221`). - Query-string `$` prefix pattern: `$description`, `$default`, `$longestDimension`, `$fileName`, `$userId`, `$thumbnail`. - `isDefaultImage: boolean` maps to query param `$default` (name mismatch is intentional). - `FileDescription` shape: `src/lib/providers/ministry-platform/types/provider.types.ts:19-34`. @@ -75,7 +75,7 @@ public async getFileMetadataByUniqueId(uniqueFileId: string): Promise<FileDescri ### Param types -`FileUploadParams` (`provider.types.ts:168`): +`FileUploadParams` (`provider.types.ts:167`): ```typescript export interface FileUploadParams { @@ -86,18 +86,18 @@ export interface FileUploadParams { } ``` -`FileUpdateParams` (`provider.types.ts:175`) adds `fileName?: string`. +`FileUpdateParams` (`provider.types.ts:174`) adds `fileName?: string`. ## How it works -- `uploadFiles` builds FormData: appends each file as `file-{i}`, then appends optional params as string form fields. Separately builds `queryParams` with `$`-prefixed keys and calls `postFormData(endpoint, formData, queryParams)` (`file.service.ts:50-77`). -- `updateFile` — same pattern but PUT, file field is `file` (singular) not `file-0`, and an extra `fileName`/`$fileName` is supported (`file.service.ts:87-131`). -- `getFileContentByUniqueId` builds URL via `client.getHttpClient().buildUrl(...)`, then uses global `fetch` with `method: 'GET'` only (no headers). Throws `Error` with `"GET /files/{id} failed: {status} {statusText}"` on non-ok response (`file.service.ts:176-178`). -- `deleteFile` omits the `$userId` query param entirely when `userId` is undefined (`file.service.ts:143-146`). -- `getFilesByRecord` sends `{ $default: 'true'|'false' }` only when `defaultOnly !== undefined` (`file.service.ts:23-26`). +- `uploadFiles` builds FormData: appends each file as `file-{i}`, then appends optional params as string form fields. Separately builds `queryParams` with `$`-prefixed keys and calls `postFormData(endpoint, formData, queryParams)` (`file.service.ts:69-111`). +- `updateFile` — same pattern but PUT, file field is `file` (singular) not `file-0`, and an extra `fileName`/`$fileName` is supported (`file.service.ts:116-161`). +- `getFileContentByUniqueId` builds URL via `client.getHttpClient().buildUrl(...)`, then uses global `fetch` with `method: 'GET'`, `redirect: 'error'`, and `AbortSignal.timeout(FILE_CONTENT_TIMEOUT_MS)` (no headers). Throws `Error` with `"GET /files/{uniqueId} failed: {status} {statusText}"` (literal `{uniqueId}`, never the ID) on non-ok response (`file.service.ts:212-214`). +- `deleteFile` omits the `$userId` query param entirely when `userId` is falsy (`file.service.ts:174-177`). +- `getFilesByRecord` sends `{ $default: 'true'|'false' }` only when `defaultOnly !== undefined` (`file.service.ts:51-54`). ## Usage -From test fixtures (`file.service.test.ts:97-119`): +From test fixtures (`file.service.test.ts:108-131`): ```typescript const file = new File(['a'], 'doc.pdf', { type: 'application/pdf' }); @@ -111,10 +111,10 @@ await service.uploadFiles('Contacts', 10, [file], { // Query string: $description, $default, $longestDimension, $userId ``` -Public blob download (`file.service.test.ts:230-243`): +Public blob download (`file.service.test.ts:242-259`): ```typescript -const blob = await service.getFileContentByUniqueId('abc'); +const blob = await service.getFileContentByUniqueId('0f8fad5b-d9cb-469f-a165-70867728950e'); // must be a GUID // No Authorization header, no ensureValidToken ``` @@ -122,19 +122,19 @@ const blob = await service.getFileContentByUniqueId('abc'); | Status | Operation | Test location | |---|---|---| -| 404 Not Found | `getFilesByRecord` / metadata lookups | `file.service.test.ts:62-66`, `:307-311`, `:326-330` | -| 404 Not Found | `getFileContentByUniqueId` (thrown as `GET /files/... failed: 404 Not Found`) | `file.service.test.ts:281-292` | -| 403 Forbidden | `deleteFile` | `file.service.test.ts:222-226` | -| 413 Payload Too Large | `uploadFiles` | `file.service.test.ts:133-140` | -| 500 Internal Server Error | `updateFile` | `file.service.test.ts:197-201` | +| 404 Not Found | `getFilesByRecord` / metadata lookups | `file.service.test.ts:74-78`, `:323-327`, `:342-346` | +| 404 Not Found | `getFileContentByUniqueId` (thrown as `GET /files/... failed: 404 Not Found`) | `file.service.test.ts:297-309` | +| 403 Forbidden | `deleteFile` | `file.service.test.ts:234-238` | +| 413 Payload Too Large | `uploadFiles` | `file.service.test.ts:145-152` | +| 500 Internal Server Error | `updateFile` | `file.service.test.ts:209-213` | ## Gotchas -- **`isDefaultImage` → `$default`** — param name changes between the TS interface and the query string. Don't search for `$isDefaultImage`; it is always `$default` (`file.service.ts:69, :118`). +- **`isDefaultImage` → `$default`** — param name changes between the TS interface and the query string. Don't search for `$isDefaultImage`; it is always `$default` (`file.service.ts:98, :148`). - **`getFileContentByUniqueId` bypasses auth** — relies on MP's public blob endpoint (so the unique ID is a download capability: it never appears in logs or messages, which say `{uniqueId}`). Its raw `fetch` has its own `FILE_CONTENT_TIMEOUT_MS` (20 s) and `redirect: "error"`, but no token refresh. - **Every path segment is validated** before any token work: table name (`Invalid table name`), record/file ID (`Expected positive integer for record ID` / `file ID`), unique ID (`Invalid GUID format`). - **Duplicate param transmission** — `uploadFiles` and `updateFile` send the same optional values in both FormData AND query string. Intentional (MP API accepts either) but doubles the wire payload for large descriptions. -- **`userId` is NOT put in FormData for uploads** — only the query string gets `$userId` (`file.service.ts:71`). Compare to other optional params which go to both. -- **`$thumbnail` omitted when undefined** — only `thumbnail === true | false` sends it; implicit `undefined` skips (`file.service.ts:165-167`). +- **`userId` is NOT put in FormData for uploads** — only the query string gets `$userId` (`file.service.ts:100`). Compare to other optional params which go to both. +- **`$thumbnail` omitted when undefined** — only `thumbnail === true | false` sends it; implicit `undefined` skips (`file.service.ts:197-199`). ## Related docs - `../README.md` — provider overview diff --git a/.claude/references/mp-provider/services/metadata.md b/.claude/references/mp-provider/services/metadata.md index ce01780..cccbdc1 100644 --- a/.claude/references/mp-provider/services/metadata.md +++ b/.claude/references/mp-provider/services/metadata.md @@ -4,22 +4,22 @@ domain: mp-provider type: reference applies_to: [src/lib/providers/ministry-platform/services/metadata.service.ts] symbols: [MetadataService, getTables, refreshMetadata] -related: [../README.md, table.md, ../../ministryplatform.schema.md] -last_verified: 2026-09-30 +related: [../README.md, table.md, ../../mp-schema/tables.md] +last_verified: 2026-10-11 --- ## Purpose Discover MP tables available to the current user, and invalidate the MP metadata cache after schema changes. ## Files -- `src/lib/providers/ministry-platform/services/metadata.service.ts` — implementation (39 lines, smallest service) -- `src/lib/providers/ministry-platform/services/metadata.service.test.ts` — tests (102 lines) +- `src/lib/providers/ministry-platform/services/metadata.service.ts` — implementation (38 lines, smallest service) +- `src/lib/providers/ministry-platform/services/metadata.service.test.ts` — tests (113 lines) ## Key concepts - `refreshMetadata` is a `GET /refreshMetadata` (no body) — MP uses this to trigger cache invalidation across all servers. - `getTables` is an authenticated list endpoint; an optional `search` string filters server-side via `?$search=`. -- Empty-string search is coerced to "no search" — `!search` check drops it (`metadata.service.ts:32`, verified by `metadata.service.test.ts:75-81`). -- Result type `TableMetadata[]` is a loose shape with optional `Columns?: ColumnMetadata[]` — real endpoint returns only basic table info unless columns are expanded. +- Empty-string search is coerced to "no search" — `!search` check drops it (`metadata.service.ts:32`, verified by `metadata.service.test.ts:87-93`). +- Result type `TableMetadata[]`: `Name` (the table name), optional `Columns?: ColumnMetadata[]`, and an index signature. A live `/tables?$search=Contacts` response (2026-10-11) carried exactly `Name`, `AccessLevel`, `SpecialPermissions`, `Columns` per entry — **no `Table_ID`, `Table_Name` or `Display_Name`** (the type used to declare those three; it was corrected to `Name`). `generate-types.ts` builds every model from these `Columns`, and still reads the name defensively as `Name ?? Table_Name` (`generate-types.ts:141-145`). ## API / Interface @@ -40,19 +40,16 @@ public async getTables(search?: string): Promise<TableMetadata[]> ### Type shape -From `src/lib/providers/ministry-platform/types/provider.types.ts:265`: +From `src/lib/providers/ministry-platform/types/provider.types.ts:264`: ```typescript export interface TableMetadata { - Table_ID: number; - Table_Name: string; - Display_Name: string; - Description?: string; + Name: string; // the API key is `Name`, not `Table_Name` Columns?: ColumnMetadata[]; - [key: string]: unknown; + [key: string]: unknown; // AccessLevel, SpecialPermissions (untyped for now) } -// :274 +// (after TableMetadata) export interface ColumnMetadata { Name: string; DataType: ParameterDataType; @@ -76,34 +73,35 @@ export interface ColumnMetadata { ## Usage -Only caller in the app is `FieldManagementService.getTableMetadata` (`src/services/fieldManagementService.ts:65-72`): +Only caller in the app is `FieldManagementService.getTableMetadata` (`src/services/fieldManagementService.ts:75-88`): ```typescript public async getTableMetadata(tableName: string): Promise<TableMetadata | null> { + await AuthorizationService.getInstance().requireSecurityRole({ + table: "dp_Tables", + operation: "read", + }); const tables = await this.mp!.getTables(tableName); - - if (tables.length === 0) return null; - - // getTables with search may return multiple matches — find the exact one - return tables.find((t) => t.Table_Name === tableName) ?? tables[0]; + return tables.find((t) => t.Name === tableName) ?? null; } ``` -Note the consumer guard: MP `$search` is substring-match, so callers must filter for exact name. `getTables('Contacts')` can return `Contact_Addresses`, `Contact_Log`, etc. +Note the consumer guard: `$search` is not an exact-name lookup and may return several tables, so callers must filter on `Name === exact` and treat "no exact match" as absent rather than taking the first result. ## Error handling | Status | Operation | Test location | |---|---|---| -| 403 Forbidden | `getTables` | `metadata.service.test.ts:83-87` | -| 500 Internal Server Error | `refreshMetadata` | `metadata.service.test.ts:46-50` | +| 403 Forbidden | `getTables` | `metadata.service.test.ts:95-99` | +| 500 Internal Server Error | `refreshMetadata` | `metadata.service.test.ts:58-62` | ## Gotchas -- **`$search` is substring-match** — always post-filter on `Table_Name === exact` if you need the exact table. Pattern used by `FieldManagementService`. +- **`$search` is not an exact lookup** — always post-filter on `Name === exact` if you need the exact table (and return nothing on a miss — never `tables[0]`). Pattern used by `FieldManagementService`. +- **The name key is `Name`** — there is no `Table_Name` on a `/tables` entry; code matching on `Table_Name` silently never matches. - **`refreshMetadata` has no return data** — it's fire-and-forget. Uses `GET` (not `POST`) even though it has side effects. -- **No `Columns` populated by default** — generic `getTables` call returns a list without the `Columns` array. To get column-level metadata in this codebase, generate types via `npm run mp:generate:models` (see CLAUDE.md). +- **`Columns` come back on the list call** — `generate-types.ts` relies on them (it only falls back to record sampling under `--detailed` when a table has none). For committed column-level types, use the generated models (`npm run mp:generate:models`). ## Related docs - `../README.md` — provider overview - `table.md` — uses names discovered here as the `table` argument -- `../../ministryplatform.schema.md` — auto-generated schema dump (authoritative at build time) +- `../../mp-schema/tables.md` — generated table catalog (snapshot) diff --git a/.claude/references/mp-provider/services/procedure.md b/.claude/references/mp-provider/services/procedure.md index e3f06b0..2bea311 100644 --- a/.claude/references/mp-provider/services/procedure.md +++ b/.claude/references/mp-provider/services/procedure.md @@ -4,8 +4,8 @@ domain: mp-provider type: reference applies_to: [src/lib/providers/ministry-platform/services/procedure.service.ts] symbols: [ProcedureService, getProcedures, executeProcedure, executeProcedureWithBody] -related: [../README.md, table.md, ../../required-stored-procs.md] -last_verified: 2026-09-30 +related: [../README.md, table.md, ../../mp-schema/required-procs.md] +last_verified: 2026-10-10 --- ## Purpose @@ -13,12 +13,12 @@ Execute MP stored procedures over REST at `/procs/{name}`. Supports query-string ## Files - `src/lib/providers/ministry-platform/services/procedure.service.ts` — implementation (100 lines) -- `src/lib/providers/ministry-platform/services/procedure.service.test.ts` — tests (242 lines) +- `src/lib/providers/ministry-platform/services/procedure.service.test.ts` — tests (260 lines) ## Key concepts - **Dev-credential routing:** procedures whose name begins with `api_dev_` (case-insensitive, requires trailing underscore) use `client.ensureValidDevToken()` + `client.getDevHttpClient()`. All other procedures use the default pipeline. - **`@DomainID` convention:** every `api_*` procedure must declare `@DomainID INT` as its first parameter. The MP API injects the current domain automatically — callers do NOT pass it. See `src/lib/providers/ministry-platform/db/api_Common_StoredProcParameters.sql:92` (`WHERE p.ParameterName <> '@DomainID'`) and `.claude/commands/newstoredproc.md:171`. -- **Result shape:** `Promise<unknown[][]>` — an outer array of result sets, each an array of rows. Callers index `result[0]` for the first result set (see `src/services/toolService.ts:132`, `:161-167`). +- **Result shape:** `Promise<unknown[][]>` — an outer array of result sets, each an array of rows. Callers index `result[0]` for the first result set (see `src/services/toolService.ts:139-140`, `:175-181`). - Body parameter keys use `@ParamName` convention (matching SQL parameter names). ## API / Interface @@ -35,7 +35,8 @@ public async executeProcedure( public async executeProcedureWithBody( procedure: string, - parameters: Record<string, unknown> + parameters: Record<string, unknown>, + queryParams?: QueryParams // e.g. { $userId } for audit attribution ): Promise<unknown[][]> ``` @@ -45,20 +46,20 @@ public async executeProcedureWithBody( |---|---|---|---| | `getProcedures` | GET | `/procs` (optional `?$search=`) | Default only (never dev) | | `executeProcedure` | GET | `/procs/{name}` (params → query string) | Default or dev (by prefix) | -| `executeProcedureWithBody` | POST | `/procs/{name}` (params → body) | Default or dev (by prefix) | +| `executeProcedureWithBody` | POST | `/procs/{name}` (params → body, `queryParams` → query string) | Default or dev (by prefix) | ### Dev-prefix detection ```typescript // procedure.service.ts:98-100 function isDevProcedure(procedure: string): boolean { - return procedure.trim().toLowerCase().startsWith(DEV_PROC_PREFIX); // 'api_dev_' + return procedure.toLowerCase().startsWith(DEV_PROC_PREFIX); // 'api_dev_' } ``` - Matches `api_dev_DeployTool`, `API_DEV_X`, `Api_Dev_Y`. -- Does NOT match `api_developer_GetThing` (trailing underscore required). Verified by `procedure.service.test.ts:144-153`. -- `getProcedures` never uses the dev pipeline regardless of `$search` value (`procedure.service.test.ts:75-82`). +- Does NOT match `api_developer_GetThing` (trailing underscore required). Verified by `procedure.service.test.ts:153-162`. +- `getProcedures` never uses the dev pipeline regardless of `$search` value (`procedure.service.test.ts:87-94`). ## How it works - Both execute methods call `resolveHttpClient(procedure)` which branches on `isDevProcedure` and invokes the matching `ensureValid*Token` + `get*HttpClient` pair (`procedure.service.ts:87-95`). @@ -69,21 +70,24 @@ function isDevProcedure(procedure: string): boolean { | Procedure | Caller | Credential | Doc | |---|---|---|---| -| `api_Tools_GetPageData` | `src/services/toolService.ts:127` | default | `../../required-stored-procs.md` | -| `api_Common_GetSelection` | `src/services/toolService.ts:154` | default | `../../required-stored-procs.md` | -| `api_Tools_GetUserTools` | `src/services/toolService.ts:185` | default | `../../required-stored-procs.md` | -| `api_MPNextTools_GetPages` | `src/services/toolService.ts:207`, `fieldManagementService.ts:44` | default | — | -| `api_MPNextTools_GetPageFields` | `src/services/fieldManagementService.ts:54` | default | — | -| `api_MPNextTools_UpdatePageFieldOrder` | `src/services/fieldManagementService.ts:94` | default | — | -| `api_dev_DeployTool` | `src/services/toolService.ts:268` | **dev** | — | -| `api_CloudTools_GetSelection` | (legacy reference in facts) | default | — | +| `api_Tools_GetPageData` | `src/services/toolService.ts:134` | default | `../../mp-schema/required-procs.md` | +| `api_Common_GetSelection` | `src/services/toolService.ts:168` | default | `../../mp-schema/required-procs.md` | +| `api_Tools_GetUserTools` | `src/services/toolService.ts:202` | default | `../../mp-schema/required-procs.md` | +| `api_MPNextTools_GetPages` | `src/services/toolService.ts:228`, `fieldManagementService.ts:50` | default | `../../mp-schema/required-procs.md` | +| `api_MPNextTools_GetPageFields` | `src/services/fieldManagementService.ts:64` | default | `../../mp-schema/required-procs.md` | +| `api_MPNextTools_UpdatePageFieldOrder` | `src/services/fieldManagementService.ts:118` | default | `../../mp-schema/required-procs.md` | +| `api_dev_DeployTool` | `src/services/toolService.ts:297` | **dev** | `../../mp-schema/required-procs.md` | ## Usage -Real caller from `src/services/toolService.ts:127-140`: +Real caller from `src/services/toolService.ts:126-148`: ```typescript public async getPageData(pageID: number): Promise<PageData | null> { + await AuthorizationService.getInstance().requireSecurityRole({ + table: 'dp_Pages', + operation: 'read', + }); try { // DomainID is automatically injected by MP API const result = await this.mp!.executeProcedureWithBody('api_Tools_GetPageData', { @@ -102,28 +106,28 @@ public async getPageData(pageID: number): Promise<PageData | null> { } ``` -Dev-pipeline example from `src/services/toolService.ts:268`: +Dev-pipeline example from `src/services/toolService.ts:297`: ```typescript -const resultSets = await this.mp!.executeProcedureWithBody('api_dev_DeployTool', payload); +const resultSets = await this.mp!.executeProcedureWithBody('api_dev_DeployTool', payload, { $userId }); ``` ## Error handling | Status | Test location | |---|---| -| 403 Forbidden | `procedure.service.test.ts:69-73` | -| 404 Not Found | `procedure.service.test.ts:114-118` | -| 500 Internal Server Error | `procedure.service.test.ts:177-183` | +| 403 Forbidden | `procedure.service.test.ts:81-85` | +| 404 Not Found | `procedure.service.test.ts:123-127` | +| 500 Internal Server Error | `procedure.service.test.ts:195-201` | ## Gotchas -- **Never pass `@DomainID`** — the MP API injects it from the authenticated domain context. Passing it manually causes a parameter mismatch. Comments in real callers repeat this: `// DomainID is automatically injected by MP API` (`src/services/toolService.ts:126`, `:178`). +- **Never pass `@DomainID`** — the MP API injects it from the authenticated domain context. Passing it manually causes a parameter mismatch. Comments in real callers repeat this: `// DomainID is automatically injected by MP API` (`src/services/toolService.ts:133`, `:192`). - **Every `api_*` procedure definition must declare `@DomainID INT` first** — MP API will not expose procedures that omit it (`.claude/commands/newstoredproc.md:171`). This is SQL-side, not TS-side. - **Dev-prefix match is case-insensitive** — whitespace anywhere in a procedure name is now refused by the identifier guard. -- **Result shape is nested `unknown[][]`** — callers must guard for empty result sets and empty row arrays before indexing (`src/services/toolService.ts:132, :189`). +- **Result shape is nested `unknown[][]`** — callers must guard for empty result sets and empty row arrays before indexing (`src/services/toolService.ts:139, :206`). ## Related docs - `../README.md` — provider overview - `table.md` — sibling table CRUD service -- `../../required-stored-procs.md` — the three stored procs required by the app at runtime, with param shapes -- `../../ministryplatform.storedprocs.md` — full auto-generated procedure catalog +- `../../mp-schema/required-procs.md` — the procs the app calls at runtime, with param shapes +- `../../mp-schema/stored-procs.md` — full auto-generated procedure catalog diff --git a/.claude/references/mp-provider/services/table.md b/.claude/references/mp-provider/services/table.md index 44ce62d..9be5b72 100644 --- a/.claude/references/mp-provider/services/table.md +++ b/.claude/references/mp-provider/services/table.md @@ -5,23 +5,23 @@ type: reference applies_to: [src/lib/providers/ministry-platform/services/table.service.ts] symbols: [TableService, getTableRecords, createTableRecords, updateTableRecords, deleteTableRecords, copyRecord, copyRecordWithSubpages] related: [../README.md, procedure.md, file.md, ../../services/README.md] -last_verified: 2026-09-30 +last_verified: 2026-10-10 --- ## Purpose HTTP-level wrapper around the Ministry Platform REST `/tables/{table}` endpoints. Stateless; each method validates its path segments, calls `client.ensureValidToken()`, then the underlying `HttpClient` verb. No record-body validation at this layer — Zod `schema` validation lives one level up in `MPHelper` (see Gotchas). ## Files -- `src/lib/providers/ministry-platform/services/table.service.ts` — implementation (150 lines) -- `src/lib/providers/ministry-platform/services/table.service.test.ts` — tests (519 lines) +- `src/lib/providers/ministry-platform/services/table.service.ts` — implementation (152 lines) +- `src/lib/providers/ministry-platform/services/table.service.test.ts` — tests (636 lines) ## Key concepts - Constructor takes a `MinistryPlatformClient`; no singleton. - Every method: `ensureValidToken()` → `getHttpClient().{verb}()` → return typed result. - `TableQueryParams` include `$select`, `$filter`, `$orderby`, `$groupby`, `$having`, `$top`, `$skip`, `$distinct`, `$userId`, `$globalFilterId`, `$allowCreate` (see `src/lib/providers/ministry-platform/types/provider.types.ts:1`). - Table name is validated (`sanitizeIdentifier` via `tableEndpoint` in `services/guards.ts`) and then URL-encoded on every endpoint; `recordId` (copy methods) and each `ids` entry (delete) must be positive safe integers. All checks run before any token work and throw a fixed message naming the field. -- **Partial vs full updates** are not parameters on `TableService` — they are a `MPHelper.updateTableRecords` concern (`partial: boolean`, default `true`; see `src/lib/providers/ministry-platform/helper.ts:266-267`). -- **`schema` (Zod validation)** is also not a `TableService` parameter — it is a `MPHelper` concern (`src/lib/providers/ministry-platform/helper.ts:175`, `:256`). `TableService` itself does not validate. +- **Partial vs full updates** are not parameters on `TableService` — they are a `MPHelper.updateTableRecords` concern (`partial: boolean`, default `true`; see `src/lib/providers/ministry-platform/helper.ts:286-287`). +- **`schema` (Zod validation)** is also not a `TableService` parameter — it is a `MPHelper` concern (`src/lib/providers/ministry-platform/helper.ts:201`, `:286`). `TableService` itself does not validate. ## API / Interface @@ -75,28 +75,28 @@ public async deleteTableRecords<T extends TableRecord = TableRecord>( | `deleteTableRecords` | DELETE | `/tables/{table}` (ids merged into query as `id: number[]`) | ## How it works -- `deleteTableRecords` merges `ids` into query params as `id: [1, 2, 3]` (`table.service.ts:141`), not in the body. +- `deleteTableRecords` merges `ids` into query params as `id: [1, 2, 3]` (`table.service.ts:145`), not in the body. - `createTableRecords` / `updateTableRecords` pass `records` array as the body and `params` as query string. -- `copyRecord` does NOT copy sub-pages or files; `copyRecordWithSubpages` can via `CopyParameters.SubpageIds` and `CopyParameters.CopyFiles`. Both create `dp_Sequences` entries for native MP series linkage (`table.service.ts:73-77`, `:100-105`). -- `RecurrencePattern` and `CopyParameters` defined in `src/lib/providers/ministry-platform/types/provider.types.ts:221, :246`. +- `copyRecord` does NOT copy sub-pages or files; `copyRecordWithSubpages` can via `CopyParameters.SubpageIds` and `CopyParameters.CopyFiles`. Both create `dp_Sequences` entries for native MP series linkage (JSDoc at `table.service.ts:73`, `:101`). +- `RecurrencePattern` and `CopyParameters` defined in `src/lib/providers/ministry-platform/types/provider.types.ts:220, :245`. - Errors from `HttpClient` propagate unchanged after `logger.error('mp.table.<op>_failed', { table, recordId?, error: errorName(e) })`. ## Usage -Real callers (via `MPHelper`, not `TableService` directly — see ADR in Gotchas): +Real callers (via `MPHelper`, not `TableService` directly — see CLAUDE.md "Data Flow"): ```typescript -// src/services/groupService.ts:232 — create with $userId +// src/services/groupService.ts:374 — create with $userId (from the AuthorizationService gate) const result = await this.mp!.createTableRecords('Groups', [apiData], { $select: 'Group_ID, Group_Name', - $userId: userId, + $userId, }); -// src/services/groupService.ts:245 — partial update +// src/services/groupService.ts:398 — partial update const result = await this.mp!.updateTableRecords('Groups', [apiData], { partial: true, $select: 'Group_ID, Group_Name', - $userId: userId, + $userId, }); // src/services/userService.ts (getUserProfile) — filtered read @@ -106,10 +106,11 @@ const records = await this.mp!.getTableRecords<MPUserProfile>({ ... }); With Zod validation (recommended — enforced in `MPHelper`, not `TableService`): ```typescript -// from src/lib/providers/ministry-platform/helper.ts:161-169 (doc example) +// adapted from src/lib/providers/ministry-platform/helper.ts:186-196 (JSDoc example) +// Schemas are not in the models barrel: import from '.../models/ContactLogSchema'. await mp.createTableRecords('Contact_Log', [{ Contact_ID: 12345, - Contact_Date: new Date().toISOString(), + Contact_Date: await tz.toMpSqlDatetime(new Date()), // DomainTimezoneService (CLAUDE.md rule 17) Made_By: 1, Notes: 'Follow-up call completed' }], { @@ -118,27 +119,29 @@ await mp.createTableRecords('Contact_Log', [{ }); ``` +Generated Zod schemas validate `DateTime`/`Timestamp` columns with a regex that accepts the `YYYY-MM-DD HH:MM:SS` string `toMpSqlDatetime` returns as well as ISO forms (`T` separator, optional `Z`/offset), so `schema` and CLAUDE.md rule 17 combine as shown. See `../type-generation.md` § Zod type mapping and `../../ministryplatform.datetimehandling.md`. + ## Error handling -Errors from the underlying `HttpClient` propagate after being logged (`table.service.ts:28`, `:48`, `:67`, `:95`, `:124`, `:147`). Covered by tests: +Errors from the underlying `HttpClient` propagate after being logged (`table.service.ts:29`, `:47`, `:65`, `:93`, `:121`, `:148`). Covered by tests: | Status | Operation | Test location | |---|---|---| -| 400 Bad Request | create (validation failure) | `table.service.test.ts:184-192` | -| 400 Bad Request | copyRecord | `table.service.test.ts:430-444` | -| 403 Forbidden | delete (permission denied) | `table.service.test.ts:323-331` | -| 404 Not Found | get (table/record missing) | `table.service.test.ts:100-108` | -| 404 Not Found | update (record missing) | `table.service.test.ts:241-249` | -| 404 Not Found | delete (record missing) | `table.service.test.ts:313-321` | -| 409 Conflict | create (duplicate key) | `table.service.test.ts:194-202` | -| 500 Server Error | copyRecordWithSubpages | `table.service.test.ts:501-517` | -| auth refresh failure | any | `table.service.test.ts:110-118` | +| 400 Bad Request | create (validation failure) | `table.service.test.ts:196-204` | +| 400 Bad Request | copyRecord | `table.service.test.ts:435-449` | +| 403 Forbidden | delete (permission denied) | `table.service.test.ts:335-343` | +| 404 Not Found | get (table/record missing) | `table.service.test.ts:112-120` | +| 404 Not Found | update (record missing) | `table.service.test.ts:253-261` | +| 404 Not Found | delete (record missing) | `table.service.test.ts:325-333` | +| 409 Conflict | create (duplicate key) | `table.service.test.ts:206-214` | +| 500 Server Error | copyRecordWithSubpages | `table.service.test.ts:506-522` | +| auth refresh failure | any | `table.service.test.ts:122-130` | ## Gotchas -- **`schema` and `partial` params are not on `TableService`** — they are added by `MPHelper` (`helper.ts:175, :256-258`). Calling `new TableService(...).createTableRecords(..., { schema })` is a type error. -- **Ambiguous columns** — when `$filter` / `$select` traverses FKs, prefix tables (e.g., `Contacts.Contact_ID`, `Contact_ID_TABLE.First_Name`). See CLAUDE.md practice #10 and `../../services/README.md`. +- **`schema` and `partial` params are not on `TableService`** — they are added by `MPHelper` (`helper.ts:201, :286-287`). Calling `new TableService(...).createTableRecords(..., { schema })` is a type error. +- **Ambiguous columns** — when `$filter` / `$select` traverses FKs, prefix tables (e.g., `Contacts.Contact_ID`, `Contact_ID_TABLE.First_Name`). See CLAUDE.md practice #10 and `../../services/query-patterns.md`. - **Escape user input in filters** — always `term.replace(/'/g, "''")` before interpolating into `$filter` (CLAUDE.md practice #11). -- **`deleteTableRecords` requires at least one id** — empty array produces `?id=` which is provider-dependent; no runtime guard here. +- **`deleteTableRecords` requires at least one id** — an empty array passes the guard and sends `DELETE /tables/{table}` with no `id` params (an empty array serializes to nothing); callers must check length. - **URL-encoding is applied to table names only** — column names inside `$filter` / `$select` are not encoded by the service. ## Related docs diff --git a/.claude/references/mp-provider/type-generation.md b/.claude/references/mp-provider/type-generation.md index 27a59e1..abfa134 100644 --- a/.claude/references/mp-provider/type-generation.md +++ b/.claude/references/mp-provider/type-generation.md @@ -5,27 +5,37 @@ type: reference applies_to: - src/lib/providers/ministry-platform/scripts/generate-types.ts - src/lib/providers/ministry-platform/scripts/codegen-escape.ts + - src/lib/providers/ministry-platform/scripts/generate-storedprocs.ts - src/lib/providers/ministry-platform/models/ + - .claude/references/mp-schema/tables.md + - .claude/references/mp-schema/stored-procs.md symbols: - generate-types - commentText - propertyKey - positiveInt + - mapDataTypeToZod + - MP_DATETIME_PATTERN + - MP_DATE_PATTERN + - generateIndexFile + - generateSchemaDocument related: - architecture.md - ../mp-schema/README.md -last_verified: 2026-04-17 +last_verified: 2026-10-11 --- ## Purpose -`tsx` script that queries Ministry Platform's metadata API via `MPHelper.getTables()` and writes TypeScript interfaces, optional Zod schemas, a barrel `index.ts`, and a summary schema doc. Produces the 603 model files under `src/lib/providers/ministry-platform/models/` consumed by the barrel `export * from './models'` in `index.ts`. +`tsx` script that queries Ministry Platform's metadata API via `MPHelper.getTables()` and writes TypeScript interfaces, optional Zod schemas, a barrel `index.ts`, and the committed table catalog `../mp-schema/tables.md`. Produces the 615 model files (307 interfaces + 307 Zod schemas + `index.ts`, as of the 2026-10-11 regeneration) under `src/lib/providers/ministry-platform/models/` consumed by the barrel `export * from './models'` in `index.ts`. ## Files - `src/lib/providers/ministry-platform/scripts/generate-types.ts` — CLI - `src/lib/providers/ministry-platform/scripts/codegen-escape.ts` — escaping helpers for MP metadata interpolated into generated source (+ `codegen-escape.test.ts`) +- `src/lib/providers/ministry-platform/scripts/generate-types.test.ts` — unit tests for the Zod mapping, barrel and catalog generators +- `src/lib/providers/ministry-platform/scripts/generate-storedprocs.ts` — sibling CLI writing `../mp-schema/stored-procs.md` (+ `generate-storedprocs.test.ts`) - `src/lib/providers/ministry-platform/scripts/README.md` — human-facing usage -- `src/lib/providers/ministry-platform/models/` — output: 603 `.ts` files (verified by snapshot: `.claude/references/_meta/facts/2026-04-17.md`) -- `.claude/references/ministryplatform.schema.md` — schema doc written by the same script +- `src/lib/providers/ministry-platform/models/` — output: 615 `.ts` files (307 tables × interface + schema, plus `index.ts`) +- `.claude/references/mp-schema/tables.md` — table catalog written by the same script, frontmatter included (`last_verified` = generation date). Written only on a full run; a `--search` run skips it so a partial table list never overwrites the catalog ## Key concepts @@ -56,11 +66,11 @@ last_verified: 2026-04-17 | `--clean` | `-c` | flag | `false` | Delete all `.ts` files in output dir before generating | | `--help` | `-h` | flag | — | Show help, exit | -Source: `generate-types.ts:56-104`. +Source: `generate-types.ts:50-98`. ### Required env vars -Validated at startup (`generate-types.ts:512-528`): +Validated at startup (`generate-types.ts:545-561`): - `MINISTRY_PLATFORM_BASE_URL` - `MINISTRY_PLATFORM_CLIENT_ID` - `MINISTRY_PLATFORM_CLIENT_SECRET` @@ -73,8 +83,8 @@ If missing, prints each missing var and `process.exit(1)`. |------|-----------|-------| | `{PascalCaseTable}.ts` | always (valid name) | `export interface {Name} { ... }` + `export type {Name}Record = {Name}` | | `{PascalCaseTable}Schema.ts` | `--zod` AND table has columns | `export const {Name}Schema = z.object({ ... })` + `export type {Name}Input = z.infer<...>` | -| `index.ts` | always | `export * from './{Name}';` for every generated file | -| `ministryplatform.schema.md` | always | Written to `.claude/references/ministryplatform.schema.md` — one-line summary per table | +| `index.ts` | always | `export * from "./{Name}";` for every **interface** file present in the output dir after the run (`generateIndexFile` scans the directory, sorted), so a `--search` run keeps the other tables in the barrel. `*Schema.ts` companions are **not** re-exported; import them by path (`@/lib/providers/ministry-platform/models/ContactLogSchema`) | +| `../mp-schema/tables.md` | full runs (no `--search`) | Written to `.claude/references/mp-schema/tables.md` with its frontmatter — one-line summary per table (`SCHEMA_DOC_PATH`) | ### TypeScript type mapping @@ -83,9 +93,9 @@ From `mapDataTypeToTypeScript` (`generate-types.ts`): | MP `ParameterDataType` | TS (required) | Notes | |---|---|---| | `String`/`Text`/`LargeString` | `string /* max N chars */` | size comment only if `positiveInt(Size)` | -| `Email` | `string /* email */` | | +| `Email` | `string /* email */` | `/* email, max N chars */` when sized | | `Phone` | `string /* phone number */` | | -| `Url` | `string /* URL */` | | +| `Url` | `string /* URL */` | `/* URL, max N chars */` when sized | | `Integer16`/`Integer32`/`Integer64` | `number /* ..-bit integer */` | | | `Decimal`/`Real`/`Money` | `number /* decimal */` or `/* currency amount */` | | | `Counter` | `number /* auto-increment */` | | @@ -94,9 +104,9 @@ From `mapDataTypeToTypeScript` (`generate-types.ts`): | `Time` | `string /* ISO time (HH:MM:SS) */` | | | `DateTime`/`Timestamp` | `string /* ISO datetime */` | | | `Guid` | `string /* GUID/UUID */` | | -| `Binary`/`Image` | `Blob \| string` | | +| `Binary`/`Image` | `Blob \| string /* binary data */` | | | `Xml` | `string /* XML content */` | | -| `Password`/`SecretKey` | `string` | | +| `Password`/`SecretKey` | `string /* password hash */` / `string /* secret key */` | | | `Separator` | `never` | filtered out of interfaces | | default | `unknown` | | @@ -117,16 +127,18 @@ From `mapDataTypeToZod` (`generate-types.ts`; `N` = `positiveInt(col.Size)`, `.m | `Integer*`/`Counter` | `z.number().int()` | | `Decimal`/`Real`/`Money` | `z.number()` | | `Boolean` | `z.boolean()` | -| `Date` | `z.string().date()` | -| `DateTime`/`Timestamp` | `z.string().datetime()` | +| `Date` | `z.string().regex(/MP_DATE_PATTERN/)` — `YYYY-MM-DD`, optionally followed by a time part as below (MP returns Date columns as `YYYY-MM-DDT00:00:00`) | +| `DateTime`/`Timestamp` | `z.string().regex(/MP_DATETIME_PATTERN/)` — `^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z\|[+-]\d{2}:?\d{2})?$` | | `Guid` | `z.string().uuid()` | | default | `z.unknown()` | Non-required columns chain `.nullable()`. +**Why a regex, not `z.string().datetime()`/`.date()`:** MP stores and returns wall-clock values with no zone marker, and `DomainTimezoneService.toMpSqlDatetime()` emits `YYYY-MM-DD HH:MM:SS` (CLAUDE.md rule 17). Zod 4's `.datetime()` requires a `T` and a `Z`/offset, so it rejected exactly those values. The pattern accepts both wall-clock and ISO (space or `T`, optional seconds/fraction, optional `Z`/`±HH:MM`). It is emitted inline in each `*Schema.ts` (no shared import), so generated files stay standalone; the source of truth is the `MP_DATETIME_PATTERN` / `MP_DATE_PATTERN` constants in `generate-types.ts`. + ### Naming -- `sanitizeTypeName` (`generate-types.ts:140-154`) splits on `-_\s/`, title-cases each piece, strips non-alphanumerics, and prepends `_` if the name starts with a digit +- `sanitizeTypeName` (`generate-types.ts:134-148`) splits on `-_\s/`, title-cases each piece, strips non-alphanumerics, and prepends `_` if the name starts with a digit ## How it works @@ -138,14 +150,14 @@ Non-required columns chain `.nullable()`. 6. For each table with a valid `Name`: - Build `{PascalCase}.ts` from column metadata (default) or sampled records (`--detailed` on tables without columns) - If `--zod` and table has columns, write `{PascalCase}Schema.ts` -7. Write `index.ts` barrel exports -8. Write `.claude/references/ministryplatform.schema.md` via `generateSchemaDocument` +7. Write `index.ts` barrel from a scan of the output directory (`generateIndexFile`) +8. Unless `--search` was given, write `.claude/references/mp-schema/tables.md` (with frontmatter) via `generateSchemaDocument` 9. Print summary: `Successfully generated N table types [+ M Zod schemas] (X total files)` ## Usage ```bash -# Canonical: regenerate all 603 files, clean first, include Zod schemas +# Canonical: regenerate all model files, clean first, include Zod schemas npm run mp:generate:models # Filtered + custom output @@ -168,12 +180,13 @@ The canonical npm script: ## Gotchas - **Never hand-edit files under `models/`.** The `--clean` flag in `mp:generate:models` deletes every `.ts` file in the output directory before regenerating. Any local edits are lost. -- **`--clean` is scoped to `.ts` extension.** Non-ts files in the output dir survive (see `generate-types.ts:575-579`). -- **`--detailed` only fires when columns are missing.** `generate-types.ts:608-622` — `if (options.detailed && (!table.Columns || table.Columns.length === 0))`. If every table has column metadata, `--detailed` has no effect and doesn't incur sampling costs. -- **Schema doc path is hard-coded.** `generateSchemaDocument` always writes to `./.claude/references/ministryplatform.schema.md` relative to `process.cwd()` (`generate-types.ts:658`). Running the script from a different cwd can write the doc in an unexpected place. -- **`require.main === module` guard is CJS-only.** The script uses `require.main` at `generate-types.ts:712` — works under `tsx` (CJS shim) but would need adjustment if the project moves to pure ESM for scripts. +- **`--clean` is scoped to `.ts` extension.** Non-ts files in the output dir survive (see `generate-types.ts:603-614`); and it only runs when the output dir already exists. +- **`--detailed` only fires when columns are missing.** `generate-types.ts:641` — `if (options.detailed && (!table.Columns || table.Columns.length === 0))`. If every table has column metadata, `--detailed` has no effect and doesn't incur sampling costs. +- **Catalog path is relative to cwd.** `SCHEMA_DOC_PATH` (`.claude/references/mp-schema/tables.md`) is resolved against `process.cwd()`, so run the script from the repo root (the npm scripts do). +- **`--search` never rewrites the catalogs.** `generate-types.ts` skips `tables.md` on a `-s` run; `generate-storedprocs.ts` refuses `-s` without an explicit `-o`. +- **`require.main === module` guard is CJS-only.** The script checks `require.main` at `generate-types.ts:751` (guarded with `typeof require`, so Vitest can import the exported generators without running `main()`) — works under `tsx` (CJS shim) but would need adjustment if the project moves to pure ESM for scripts. - **Zod schemas do not infer nullability from MP metadata perfectly.** `mapDataTypeToZod` uses `.nullable()` for non-required fields, so schemas match the TypeScript `| null` emission, but do not match the `?:` optional marker on the interface. Consumers doing `Schema.parse(record)` on a POSTed record must include the key (or use `.optional()` themselves). ## Related docs - `architecture.md` — where generated models plug into the stack -- `../mp-schema/README.md` — summary schema doc the script emits +- `../mp-schema/README.md` — the catalogs the generators write (`tables.md`, `stored-procs.md`) diff --git a/.claude/references/mp-schema/README.md b/.claude/references/mp-schema/README.md index 8189467..ab774ff 100644 --- a/.claude/references/mp-schema/README.md +++ b/.claude/references/mp-schema/README.md @@ -11,19 +11,19 @@ Auto-generated catalogs of Ministry Platform tables and stored procedures, plus | File | Purpose | When to read | |------|---------|--------------| -| `tables.md` | 301 MP tables with PKs, FKs, access levels (auto-generated) | Looking up a table name, PK, or FK relationship before writing a query | -| `stored-procs.md` | 532 MP stored procedures with parameter signatures (auto-generated) | Verifying a proc name, its parameters, or bounded string sizes | -| `required-procs.md` | The 7 procs the app actively calls via `ToolService` + `FieldManagementService` + infrastructure docs | Working on `ToolService`, `FieldManagementService`, page/selection lookups, or user tool routing | -| `data-model-map.md` | Map from MP schema → which tables, FK traversals, and procs the app actually queries (19 hot tables, 7 procs) | Understanding data access footprint or impact of schema changes | +| `tables.md` | 307 MP tables with PKs, FKs, access levels (auto-generated) | Looking up a table name, PK, or FK relationship before writing a query | +| `stored-procs.md` | 539 MP stored procedures with parameter signatures (auto-generated) | Verifying a proc name, its parameters, or bounded string sizes | +| `required-procs.md` | The 7 procs the app actively calls via `ToolService` + `FieldManagementService`, plus the installed-but-uncalled procs in `db/` | Working on `ToolService`, `FieldManagementService`, page/selection lookups, or user tool routing | +| `data-model-map.md` | Map from MP schema → which tables, FK traversals, and procs the app actually queries (34 tables, 7 procs) | Understanding data access footprint or impact of schema changes | ## Regeneration commands | Output | Command | Generator | |---|---|---| -| `tables.md` content | `npm run mp:generate:models` | `src/lib/providers/ministry-platform/scripts/generate-types.ts` (runs with `--clean --zod`, emits `.ts` + `Schema.ts` under `src/lib/providers/ministry-platform/models/`) | -| `stored-procs.md` content | `npm run mp:generate:storedprocs` | `src/lib/providers/ministry-platform/scripts/generate-storedprocs.ts` | +| `tables.md` | `npm run mp:generate:models` | `src/lib/providers/ministry-platform/scripts/generate-types.ts` (runs with `--clean --zod`, emits `.ts` + `Schema.ts` under `src/lib/providers/ministry-platform/models/`, and writes `tables.md` directly — skipped on a `--search` run) | +| `stored-procs.md` | `npm run mp:generate:storedprocs` | `src/lib/providers/ministry-platform/scripts/generate-storedprocs.ts` (writes `stored-procs.md` directly; `-o` overrides, and a `-s` run requires `-o`) | -The auto-generated markdown in `tables.md` and `stored-procs.md` is a snapshot — rerun the generator when MP schema drifts. +Both generators write the whole file, frontmatter included, with `last_verified` set to the generation date — do not hand-edit them; regenerate instead. Last regenerated from the live MP instance on 2026-10-11. ## Code surfaces @@ -32,7 +32,7 @@ The auto-generated markdown in `tables.md` and `stored-procs.md` is a snapshot | `src/lib/providers/ministry-platform/models/` | Generated TS types + Zod schemas for all MP tables | | `src/lib/providers/ministry-platform/services/procedure.service.ts` | HTTP client for `/procs/{name}` | | `src/lib/providers/ministry-platform/helper.ts` | `MPHelper` facade (`executeProcedure`, `executeProcedureWithBody`, `getTableRecords`, etc.) | -| `src/services/toolService.ts` | App-level service wrapping the 3 required procs | +| `src/services/toolService.ts` | App-level service wrapping 5 of the 7 procs (`fieldManagementService.ts` wraps the other `api_MPNextTools_*` procs) | ## Related domains diff --git a/.claude/references/mp-schema/data-model-map.md b/.claude/references/mp-schema/data-model-map.md index cbbd604..965b195 100644 --- a/.claude/references/mp-schema/data-model-map.md +++ b/.claude/references/mp-schema/data-model-map.md @@ -5,7 +5,7 @@ type: reference applies_to: [src/services/**, src/components/**/actions.ts] symbols: [] related: [tables.md, stored-procs.md, required-procs.md, ../services/query-patterns.md] -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose @@ -17,27 +17,22 @@ Call site = one invocation of `getTableRecords`/`createTableRecords`/`updateTabl | Table | Call sites | Consumers | |---|---|---| -| `Groups` | 4 | `src/services/groupService.ts:164` (searchGroups), `:174` (getGroup), `:232` (createGroup), `:245` (updateGroup) | -| `Contacts` | 3 | `src/services/groupService.ts:153` (searchContacts), `src/services/addressLabelService.ts:79` (getAddressesForContacts), `:96` (getAddressForContact) | -| `dp_Users` | 2 | `src/services/authorizationService.ts` (`resolveUserId`), `src/services/userService.ts` (`getUserProfile`) | -| `dp_User_Roles` | 1 | `src/services/authorizationService.ts` (`loadSecurityRoles`) | -| `dp_Roles` | 1 | `src/services/toolService.ts:232` (listRoles) | -| `Group_Types` | 1 | `src/services/groupService.ts:63` (fetchAllLookups) | -| `Ministries` | 1 | `src/services/groupService.ts:68` | -| `Congregations` | 1 | `src/services/groupService.ts:74` | -| `Meeting_Days` | 1 | `src/services/groupService.ts:80` | -| `Meeting_Frequencies` | 1 | `src/services/groupService.ts:85` | -| `Meeting_Durations` | 1 | `src/services/groupService.ts:90` | -| `Life_Stages` | 1 | `src/services/groupService.ts:95` | -| `Group_Focuses` | 1 | `src/services/groupService.ts:100` | -| `Priorities` | 1 | `src/services/groupService.ts:105` | -| `Rooms` | 1 | `src/services/groupService.ts:110` | -| `Books` | 1 | `src/services/groupService.ts:116` | -| `dp_SMS_Numbers` | 1 | `src/services/groupService.ts:121` | -| `Group_Ended_Reasons` | 1 | `src/services/groupService.ts:127` | -| `<dynamic>` | 1 | `src/services/toolService.ts:335` (resolveContactIds — table name supplied at runtime from `api_Tools_GetPageData` result) | - -Unique tables touched (excluding the dynamic case): **18**. Lookup tables at `groupService.fetchAllLookups` dominate the count but each fires once per wizard load. +| `Contacts` | 10 | `src/services/groupService.ts:248` (searchContacts); `src/services/addressLabelService.ts:86` (getAddressesForContacts), `:109` (getAddressForContact); `src/services/familyService.ts:118` (searchContacts), `:200`, `:243` (getHousehold), `:614`/`:621` (saveMember update/create), `:668` (link `Participant_Record`), `:794` (link `Donor_Record`) | +| `Groups` | 4 | `src/services/groupService.ts:263` (searchGroups), `:280` (getGroup), `:374` (createGroup), `:398` (updateGroup) | +| `Donors` | 4 | `src/services/familyService.ts:465` (getNextEnvelopeNumber), `:738` (resolveUniqueEnvelopeNo), `:772`/`:788` (upsertDonor update/create) | +| `Households` | 3 | `src/services/familyService.ts:214` (getHousehold), `:515`/`:524` (saveHousehold create/update) | +| `Addresses` | 2 | `src/services/familyService.ts:564`/`:572` (upsertAddress update/create) | +| `Participants` | 2 | `src/services/familyService.ts:633`/`:646` (saveMember update/create) | +| `Congregations` | 2 | `src/services/groupService.ts:164` (fetchAllLookups), `src/services/familyService.ts:360` (getLookups) | +| `dp_Users` | 2 | `src/services/authorizationService.ts:44` (`resolveUserId`), `src/services/userService.ts:60` (`getUserProfile`) | +| `dp_User_Roles` | 1 | `src/services/authorizationService.ts:57` (`loadSecurityRoles`) | +| `dp_Roles` | 1 | `src/services/toolService.ts:257` (listRoles) | +| `dp_Configuration_Settings` | 1 | `src/services/googlePlacesService.ts:35` (resolveApiKey — `GoogleMapsAPIKey`) | +| `Group_Types`, `Ministries`, `Meeting_Days`, `Meeting_Frequencies`, `Meeting_Durations`, `Life_Stages`, `Group_Focuses`, `Priorities`, `Rooms`, `Books`, `dp_SMS_Numbers`, `Group_Ended_Reasons` | 1 each | `src/services/groupService.ts:153`–`:217` (fetchAllLookups) | +| `Household_Sources`, `Household_Positions`, `Participant_Types`, `Marital_Statuses`, `Prefixes`, `Suffixes`, `Genders`, `Contact_Statuses`, `Primary_Languages`, `Faith_Backgrounds`, `Countries` | 1 each | `src/services/familyService.ts:366`–`:416` (getLookups) | +| `<dynamic>` | 2 | `src/services/toolService.ts:372` (resolveContactIds — table from `api_Tools_GetPageData`), `src/services/familyService.ts:182` (resolveContactIdFromPage — table/PK/FK path from the caller, validated with `validateColumnName`/`validateFkPath`) | + +Unique tables touched (excluding the dynamic cases): **34**. Lookup tables (`groupService.fetchAllLookups`, `familyService.getLookups`) dominate the count but each fires once per wizard/form load. ## FK traversals used @@ -45,23 +40,18 @@ Scanned from `$select`/`orderBy` strings using the `_TABLE` suffix convention. | FK chain (in `$select`/`orderBy`) | Base table | Consumer (file:line) | Purpose | |---|---|---|---| -| `Contact_ID_TABLE.First_Name` | `dp_Users` | `src/services/userService.ts` (`getUserProfile`) | User profile name (via linked Contact) | -| `Contact_ID_TABLE.Nickname` | `dp_Users` | `src/services/userService.ts` (`getUserProfile`) | Preferred display name | -| `Contact_ID_TABLE.Last_Name` | `dp_Users` | `src/services/userService.ts` (`getUserProfile`) | User profile surname | -| `Contact_ID_TABLE.Email_Address` | `dp_Users` | `src/services/userService.ts` (`getUserProfile`) | User profile email | -| `Role_ID_TABLE.Role_Name` | `dp_User_Roles` | `src/services/authorizationService.ts` (`loadSecurityRoles`) | Security-role names for the authorization gate | -| `Group_Type_ID_TABLE.Group_Type` | `Groups` | `src/services/groupService.ts:166` | Group search result badge | -| `Household_ID_TABLE.Household_Name` | `Contacts` | `src/services/addressLabelService.ts:29` | Household label on mailing labels | -| `Household_ID_TABLE.Bulk_Mail_Opt_Out` | `Contacts` | `src/services/addressLabelService.ts:30` | Opt-out filter for bulk mailings | -| `Household_ID_TABLE_Address_ID_TABLE.Address_Line_1` | `Contacts` | `src/services/addressLabelService.ts:31` | 2-level FK → household address line 1 | -| `Household_ID_TABLE_Address_ID_TABLE.Address_Line_2` | `Contacts` | `src/services/addressLabelService.ts:32` | 2-level FK → household address line 2 | -| `Household_ID_TABLE_Address_ID_TABLE.City` | `Contacts` | `src/services/addressLabelService.ts:33` | 2-level FK → city | -| `Household_ID_TABLE_Address_ID_TABLE.[State/Region]` | `Contacts` | `src/services/addressLabelService.ts:34` | 2-level FK → state (bracketed due to `/`) | -| `Household_ID_TABLE_Address_ID_TABLE.Postal_Code` | `Contacts` | `src/services/addressLabelService.ts:35`, `:83` (orderBy) | 2-level FK → ZIP; also `orderBy` | -| `Household_ID_TABLE_Address_ID_TABLE.Bar_Code` | `Contacts` | `src/services/addressLabelService.ts:36` | 2-level FK → USPS delivery barcode | -| `Household_ID_TABLE_Address_ID_TABLE.Delivery_Point_Code` | `Contacts` | `src/services/addressLabelService.ts:37` | 2-level FK → delivery point | - -Note the `Contacts.Household_ID` disambiguation at `src/services/addressLabelService.ts:28` — required because `Household_ID` exists on both `Contacts` and the joined `Households` table. See `../services/query-patterns.md` for the rule. +| `Contact_ID_TABLE.{First_Name,Nickname,Last_Name,Email_Address}` | `dp_Users` | `src/services/userService.ts:63` (`getUserProfile`) | User profile via linked Contact | +| `Role_ID_TABLE.Role_Name` | `dp_User_Roles` | `src/services/authorizationService.ts:59` (`loadSecurityRoles`) | Security-role names for the authorization gate | +| `Group_Type_ID_TABLE.Group_Type` | `Groups` | `src/services/groupService.ts:265` | Group search result badge | +| `Primary_Contact_TABLE.Display_Name`, `Parent_Group_TABLE.Group_Name`, `Promote_to_Group_TABLE.Group_Name`, `Descended_From_TABLE.Group_Name` (all `AS` aliases) | `Groups` | `src/services/groupService.ts:280` (getGroup) | Display names for edit-mode seeding | +| `Household_ID_TABLE.Household_Name` | `Contacts` | `src/services/addressLabelService.ts:30` | Household label on mailing labels | +| `Household_ID_TABLE.Bulk_Mail_Opt_Out` | `Contacts` | `src/services/addressLabelService.ts:31` | Opt-out filter for bulk mailings | +| `Household_ID_TABLE_Address_ID_TABLE.{Address_Line_1,Address_Line_2,City,[State/Region],Postal_Code,Bar_Code,Delivery_Point_Code}` | `Contacts` | `src/services/addressLabelService.ts:32-38`, `:90` (orderBy `Postal_Code`) | 2-level FK → household address (`[State/Region]` bracketed due to `/`) | +| `Household_ID_TABLE_Address_ID_TABLE.Address_Line_1` | `Contacts` | `src/services/familyService.ts:129` (searchContacts) | Address hint in contact search | +| `Address_ID_TABLE.*`, `Alternate_Mailing_Address_TABLE.*` (`AS Addr1_*` / `Addr2_*`) | `Households` | `src/services/familyService.ts:227-238` | Main + alternate mailing address | +| `Participant_Record_TABLE.Participant_Type_ID`, `Donor_Record_TABLE.Envelope_No` | `Contacts` | `src/services/familyService.ts:267-268` | Member participant type + envelope | + +Note the `Contacts.Household_ID` disambiguation at `src/services/addressLabelService.ts:29` — required because `Household_ID` exists on both `Contacts` and the joined `Households` table. `familyService.getHousehold` prefixes every column (`Households.*`, `Contacts.*`) for the same reason. See `../services/query-patterns.md` for the rule. ## Stored procs called in code @@ -69,29 +59,30 @@ Cross-referenced with `required-procs.md`. All call via `MPHelper.executeProcedu | Proc | Caller (file:line) | Purpose | |---|---|---| -| `api_Tools_GetPageData` | `src/services/toolService.ts:127` (getPageData) | Resolve Page_ID → table name + primary key | -| `api_Common_GetSelection` | `src/services/toolService.ts:154` (getSelectionRecordIds) | Selection_ID → Record_ID[] | -| `api_Tools_GetUserTools` | `src/services/toolService.ts:185` (getUserTools) | User_ID → authorized Tool_Path[] | -| `api_MPNextTools_GetPages` | `src/services/toolService.ts:207` (listPages), `src/services/fieldManagementService.ts:44` (getPages) | List MP pages for pickers | -| `api_MPNextTools_GetPageFields` | `src/services/fieldManagementService.ts:54` (getPageFields) | Per-page field metadata | -| `api_MPNextTools_UpdatePageFieldOrder` | `src/services/fieldManagementService.ts:94` (updatePageFieldOrder) | Upsert field config (batched, concurrency 5) | -| `api_dev_DeployTool` | `src/services/toolService.ts:268` (deployTool) | Dev-creds deployment of a tool | +| `api_Tools_GetPageData` | `src/services/toolService.ts:134` (getPageData) | Resolve Page_ID → table name + primary key | +| `api_Common_GetSelection` | `src/services/toolService.ts:168` (getSelectionRecordIds) | Selection_ID → Record_ID[] | +| `api_Tools_GetUserTools` | `src/services/toolService.ts:202` (getUserTools) | User_ID → authorized Tool_Path[] | +| `api_MPNextTools_GetPages` | `src/services/toolService.ts:228` (listPages), `src/services/fieldManagementService.ts:50` (getPages) | List MP pages for pickers | +| `api_MPNextTools_GetPageFields` | `src/services/fieldManagementService.ts:64` (getPageFields) | Per-page field metadata | +| `api_MPNextTools_UpdatePageFieldOrder` | `src/services/fieldManagementService.ts:118` (updatePageFieldOrder) | Upsert field config (batched, concurrency 5) | +| `api_dev_DeployTool` | `src/services/toolService.ts:297` (deployTool) | Dev-creds deployment of a tool | ## Common filter patterns -- `End_Date IS NULL` — active-row filter on `Ministries`, `Congregations`, `Groups`. `src/services/groupService.ts:71`, `:77`, `:167`. -- `Active = 1` — `dp_SMS_Numbers` active filter. `src/services/groupService.ts:124`. -- `Bookable = 1` — `Rooms` filter. `src/services/groupService.ts:113`. -- `<Name> LIKE '<escaped>%'` — prefix search (user-provided term is passed through `escapeFilterString`). `src/services/groupService.ts:156` (contacts), `:167` (groups); `src/services/toolService.ts:229` uses `%term%` for roles. -- `<PK> IN (<csv>)` — batched ID lookups (batch size 100). `src/services/addressLabelService.ts:82`, `src/services/toolService.ts:338`. -- `User_GUID = '<validated>'` — GUID lookup on `dp_Users` (passed through `validateGuid`). `src/services/userService.ts` (`getUserProfile`), `src/services/authorizationService.ts` (`resolveUserId`). -- `<PK> = <validatedInt>` — single-record lookup (input through `validatePositiveInt`). `src/services/groupService.ts:176`. +- `End_Date IS NULL` — active-row filter on `Ministries`, `Congregations`, `Groups`. `src/services/groupService.ts:161`, `:167`, `:266`; `src/services/familyService.ts:363`. +- `Active = 1` — `dp_SMS_Numbers` active filter. `src/services/groupService.ts:214`. +- `Bookable = 1` — `Rooms` filter. `src/services/groupService.ts:203`. +- `<Name> LIKE '<escaped>%'` — prefix search (user-provided term is passed through `escapeFilterString`). `src/services/groupService.ts:251` (contacts), `:266` (groups); `src/services/familyService.ts:131` (contacts, plus `Contact_Status_ID = 1`); `src/services/toolService.ts:254` uses `%term%` for roles. +- `<PK> IN (<csv>)` — batched ID lookups (`MP_FETCH_BATCH_SIZE` = 100, `src/lib/constants.ts`). `src/services/addressLabelService.ts:89`, `src/services/toolService.ts:375`. +- `User_GUID = '<validated>'` — GUID lookup on `dp_Users` (passed through `validateGuid`). `src/services/userService.ts:62`, `src/services/authorizationService.ts:47`. +- `<PK> = <validatedInt>` — single-record lookup (input through `validatePositiveInt`). `src/services/groupService.ts:289`, `src/services/familyService.ts:185`, `:206`. +- `MAX(Envelope_No) AS Highest` — aggregate in `$select` on `Donors`. `src/services/familyService.ts:467`. -All user-supplied strings in filters are escaped via `escapeFilterString` or validated via `validateGuid`/`validatePositiveInt`/`validateColumnName` from `src/lib/validation.ts`. +All user-supplied strings in filters are escaped via `escapeFilterString` or validated via `validateGuid`/`validatePositiveInt`/`validateColumnName`/`validateFkPath` from `src/lib/validation.ts`. ## Scope -Code touches **19 unique tables** (+1 runtime-resolved table in `ToolService.resolveContactIds`) out of **301** in the MP schema (~6%). The remaining ~94% are untouched by application code; they are not enumerated here. Full schema is in `tables.md`. +Code touches **34 unique tables** (+ runtime-resolved tables in `ToolService.resolveContactIds` and `FamilyService.resolveContactIdFromPage`) out of **301** in the MP schema (~11%). The rest are untouched by application code; they are not enumerated here. Full schema is in `tables.md`. ## Related docs diff --git a/.claude/references/mp-schema/required-procs.md b/.claude/references/mp-schema/required-procs.md index e0fde22..bf1f7ed 100644 --- a/.claude/references/mp-schema/required-procs.md +++ b/.claude/references/mp-schema/required-procs.md @@ -5,7 +5,7 @@ type: reference applies_to: [src/services/toolService.ts, src/services/fieldManagementService.ts, src/lib/providers/ministry-platform/services/procedure.service.ts, src/lib/providers/ministry-platform/helper.ts] symbols: [api_Tools_GetPageData, api_Common_GetSelection, api_Tools_GetUserTools, api_MPNextTools_GetPages, api_MPNextTools_GetPageFields, api_MPNextTools_UpdatePageFieldOrder, api_dev_DeployTool] related: [stored-procs.md, ../services/README.md] -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose @@ -16,8 +16,8 @@ Stored procedures actively invoked by the application (7 procs). All are called | Proc | Caller | Notes | |------|--------|-------| | `api_Tools_GetPageData` | `ToolService.getPageData()` | Page metadata lookup | -| `api_Common_GetSelection` | `ToolService.getSelectionRecordIds()` | Selection → Record_ID[] | -| `api_Tools_GetUserTools` | `ToolService.getUserTools()` | User → tool paths | +| `api_Common_GetSelection` | `ToolService.getSelectionRecordIds()` | Selection → Record_ID[]; `@UserID` from the auth gate | +| `api_Tools_GetUserTools` | `ToolService.getUserTools()` | User → tool paths; `@UserId` from the auth gate | | `api_MPNextTools_GetPages` | `ToolService.listPages()`, `FieldManagementService.getPages()` | No params; in-memory filter | | `api_MPNextTools_GetPageFields` | `FieldManagementService.getPageFields()` | Per-page field metadata | | `api_MPNextTools_UpdatePageFieldOrder` | `FieldManagementService.updatePageFieldOrder()` | Per-field mutation | @@ -27,7 +27,8 @@ Stored procedures actively invoked by the application (7 procs). All are called - HTTP: `POST /procs/{procedureName}` with body `{ "@ParamName": value, ... }` - Return: `unknown[][]` (array of result sets, each an array of row objects) -- `@DomainID` is auto-injected by the MP API +- `@DomainID` is auto-injected by the MP API — never pass it. SQL side: every `api_*` proc must declare `@DomainID INT` as its **first** parameter or MP will not expose it; all procs in `src/lib/providers/ministry-platform/db/` do +- Every caller runs `AuthorizationService.requireSecurityRole(...)` before the call; user IDs passed to procs come from that gate's return value, never from the caller Call chain: @@ -45,12 +46,15 @@ Server Action → {ToolService | FieldManagementService} (singleton) → MPHelpe |-----------|-----------|-----------|-------------| | @PageID | Input | Integer32 | Ministry Platform Page ID | -**Return:** First result set, first row → `PageData` (`src/lib/tool-params.ts`) with fields: `Page_ID`, `Display_Name`, `Table_Name`, `Primary_Key`. +**Return:** First result set, first row → `PageData` (`src/lib/tool-params.ts:14`): `Page_ID`, `Display_Name`, `Singular_Name`, `Table_Name`, `Primary_Key`, plus optional fields such as `Contact_ID_Field`, `Selected_Record_Expression`, `Filter_Clause`. -**Called by:** `ToolService.getPageData(pageID)` at `src/services/toolService.ts:123` +**DDL:** not in this repo (MP-provided proc). + +**Called by:** `ToolService.getPageData(pageID)` at `src/services/toolService.ts:126` (call at `:134`) **Consumer(s):** -- `fetchAddressLabels()` in `src/components/address-labels/actions.ts` +- `resolveContactIdFromPage()` in `src/app/(web)/tools/addeditfamily/actions.ts` +- `ToolService.resolveContactIds()` (internal) → `resolveContactRecords()` in `src/components/dev-panel/panels/contact-records-actions.ts` --- @@ -66,7 +70,9 @@ Server Action → {ToolService | FieldManagementService} (singleton) → MPHelpe **Return:** Searches all result sets for one containing `Record_ID` column → extracts `number[]`. -**Called by:** `ToolService.getSelectionRecordIds(selectionId, userId, pageId)` at `src/services/toolService.ts:152` +**DDL:** not in this repo (MP-provided proc). + +**Called by:** `ToolService.getSelectionRecordIds(selectionId, pageId)` at `src/services/toolService.ts:162` (call at `:168`). `@UserID` is the gate's return value — never caller-supplied. **Consumer(s):** - `fetchAddressLabels()` in `src/components/address-labels/actions.ts` @@ -84,7 +90,9 @@ Server Action → {ToolService | FieldManagementService} (singleton) → MPHelpe **Return:** First result set → array of `{ Tool_Path: string }`. -**Called by:** `ToolService.getUserTools(userId)` at `src/services/toolService.ts:183` +**DDL:** not in this repo (MP-provided proc). + +**Called by:** `ToolService.getUserTools()` at `src/services/toolService.ts:196` (call at `:202`). `@UserId` is the gate's return value. **Consumer(s):** - `getUserTools()` in `src/components/dev-panel/panels/user-tools-actions.ts` @@ -93,7 +101,7 @@ Server Action → {ToolService | FieldManagementService} (singleton) → MPHelpe ## api_MPNextTools_GetPages -**Purpose:** Lists every MP `dp_Pages` row that MPNext Tools cares about (for deploy-tool and field-management pickers). SP takes no search param; callers filter in-memory and cap results. +**Purpose:** Lists every MP `dp_Pages` row (`Page_ID, Display_Name, Table_Name`, ordered by `Display_Name`) for the deploy-tool and field-management pickers. SP takes no search param; callers filter in-memory and cap results. | Parameter | Direction | Data Type | Description | |-----------|-----------|-----------|-------------| @@ -104,8 +112,8 @@ Server Action → {ToolService | FieldManagementService} (singleton) → MPHelpe **DDL:** `src/lib/providers/ministry-platform/db/api_MPNextTools_GetPages.sql` **Called by:** -- `ToolService.listPages(search?)` at `src/services/toolService.ts:206` -- `FieldManagementService.getPages()` at `src/services/fieldManagementService.ts:43` +- `ToolService.listPages(search?)` at `src/services/toolService.ts:223` (in-memory filter on `Display_Name`/`Table_Name`, capped at 100) +- `FieldManagementService.getPages()` at `src/services/fieldManagementService.ts:45` --- @@ -121,7 +129,7 @@ Server Action → {ToolService | FieldManagementService} (singleton) → MPHelpe **DDL:** `src/lib/providers/ministry-platform/db/api_MPNextTools_GetPageFields.sql` -**Called by:** `FieldManagementService.getPageFields(pageId)` at `src/services/fieldManagementService.ts:53` +**Called by:** `FieldManagementService.getPageFields(pageId)` at `src/services/fieldManagementService.ts:59` --- @@ -131,23 +139,23 @@ Server Action → {ToolService | FieldManagementService} (singleton) → MPHelpe | Parameter | Direction | Data Type | Description | |-----------|-----------|-----------|-------------| -| @PageID | Input | Integer32 | Page ID | -| @FieldName | Input | String | Field column name | -| @GroupName | Input | String (nullable) | UI group name | -| @ViewOrder | Input | Integer32 | Display order | -| @Required | Input | Boolean | Required flag | -| @Hidden | Input | Boolean | Hidden flag | -| @DefaultValue | Input | String (nullable) | Default literal | -| @FilterClause | Input | String (nullable) | DDL filter | -| @DependsOnField | Input | String (nullable) | Dependency field name | -| @FieldLabel | Input | String (nullable) | Display label | -| @WritingAssistantEnabled | Input | Boolean | AI-assist toggle | +| @PageID | Input | INT | Page ID | +| @FieldName | Input | VARCHAR(64) | Field column name | +| @GroupName | Input | NVARCHAR(75) = NULL | UI group name | +| @ViewOrder | Input | SMALLINT = NULL | Display order | +| @Required | Input | BIT = 0 | Required flag | +| @Hidden | Input | BIT = 0 | Hidden flag | +| @DefaultValue | Input | NVARCHAR(128) = NULL | Default literal | +| @FilterClause | Input | NVARCHAR(2000) = NULL | Lookup filter clause | +| @DependsOnField | Input | VARCHAR(64) = NULL | Dependency field name | +| @FieldLabel | Input | NVARCHAR(64) = NULL | Display label | +| @WritingAssistantEnabled | Input | BIT = 0 | AI-assist toggle | **Return:** Not consumed. **DDL:** `src/lib/providers/ministry-platform/db/api_MPNextTools_UpdatePageFieldOrder.sql` -**Called by:** `FieldManagementService.updatePageFieldOrder(fields[])` at `src/services/fieldManagementService.ts:76` +**Called by:** `FieldManagementService.updatePageFieldOrder(...)` at `src/services/fieldManagementService.ts:90` (call at `:118`, batches of `CONCURRENCY = 5`) --- @@ -157,22 +165,22 @@ Server Action → {ToolService | FieldManagementService} (singleton) → MPHelpe | Parameter | Direction | Data Type | Description | |-----------|-----------|-----------|-------------| -| @ToolName | Input | String(30) | Tool name | -| @LaunchPage | Input | String(1024) | Launch URL / route | -| @Description | Input | String(100) nullable | Tool description | -| @LaunchWithCredentials | Input | Bit (0/1) | Include creds on launch | -| @LaunchWithParameters | Input | Bit (0/1) | Include params on launch | -| @LaunchInNewTab | Input | Bit (0/1) | Open in new tab | -| @ShowOnMobile | Input | Bit (0/1) | Visible on mobile | -| @PageIDs | Input | String (CSV, nullable) | Comma-joined `Page_ID` list | -| @AdditionalData | Input | String(65) nullable | Free-form metadata | -| @RoleIDs | Input | String (CSV, nullable) | Comma-joined `Role_ID` list | +| @ToolName | Input | NVARCHAR(30) | Tool name | +| @LaunchPage | Input | NVARCHAR(1024) | Launch URL / route | +| @Description | Input | NVARCHAR(100) = NULL | Tool description | +| @LaunchWithCredentials | Input | BIT = 1 | Include creds on launch | +| @LaunchWithParameters | Input | BIT = 1 | Include params on launch | +| @LaunchInNewTab | Input | BIT = 0 | Open in new tab | +| @ShowOnMobile | Input | BIT = 0 | Visible on mobile | +| @PageIDs | Input | NVARCHAR(MAX) = NULL | Comma-joined `Page_ID` list | +| @AdditionalData | Input | NVARCHAR(65) = NULL | Applied to each inserted `dp_Tool_Pages` row | +| @RoleIDs | Input | NVARCHAR(MAX) = NULL | Comma-joined `Role_ID` list; Administrators auto-included | **Return:** 3 result sets — `[toolRow]`, `pageRows[]`, `roleRows[]`. Consumed as `DeployToolResult`. **DDL:** `src/lib/providers/ministry-platform/db/api_dev_DeployTool.sql` -**Called by:** `ToolService.deployTool(input)` at `src/services/toolService.ts:247` +**Called by:** `ToolService.deployTool(input)` at `src/services/toolService.ts:272` (call at `:297`, with `{ $userId }` from the gate as a query param) --- @@ -186,9 +194,18 @@ Server Action → {ToolService | FieldManagementService} (singleton) → MPHelpe | `src/services/toolService.ts` | Application service wrapping `api_Tools_GetPageData`, `api_Common_GetSelection`, `api_Tools_GetUserTools`, `api_MPNextTools_GetPages` (partial), `api_dev_DeployTool` | | `src/services/fieldManagementService.ts` | Application service wrapping `api_MPNextTools_GetPages`, `api_MPNextTools_GetPageFields`, `api_MPNextTools_UpdatePageFieldOrder` | -## Note on `api_CloudTools_GetSelection` +## Installed but not called by app code + +These ship in `src/lib/providers/ministry-platform/db/` (combined into `_INSTALL/ministryplatform-install.sql` by `npm run mp:build:install`) but nothing in `src/` invokes them. All declare `@DomainID INT` first. + +| Proc | DDL | +|---|---| +| `api_Common_GenerateSlug` | `db/api_Common_GenerateSlug.sql` | +| `api_Common_StoredProcParameters` | `db/api_Common_StoredProcParameters.sql` (skips `@DomainID` when listing params, `:92`) | +| `api_dev_GetProcedureDefinition` | `db/api_dev_GetProcedureDefinition.sql` | +| `api_MPNextTools_GetAuditLog` | `db/api_MPNextTools_GetAuditLog.sql` | -The facts snapshot lists `api_CloudTools_GetSelection` as a referenced proc. As of 2026-04-17, only the newer `api_Common_GetSelection` is invoked from `ToolService`. The `CloudTools` name survives in code comments only — see TODO `2026-04-17-services-stale-cloudtools-comment.md`. +`api_CloudTools_GetSelection` (in `stored-procs.md`) is the legacy name; only `api_Common_GetSelection` is called. ## Related docs diff --git a/.claude/references/mp-schema/stored-procs.md b/.claude/references/mp-schema/stored-procs.md index cf805bb..6f606b6 100644 --- a/.claude/references/mp-schema/stored-procs.md +++ b/.claude/references/mp-schema/stored-procs.md @@ -2,12 +2,12 @@ title: Ministry Platform Stored Procedures domain: mp-schema type: generated-reference -last_verified: 2026-04-17 +last_verified: 2026-10-11 source: npm run mp:generate:storedprocs --- ## Purpose -Auto-generated reference of every Ministry Platform stored procedure exposed via the MP API, with its input parameters and bounded string sizes. Regenerate with `npm run mp:generate:storedprocs` (runs `src/lib/providers/ministry-platform/scripts/generate-storedprocs.ts`). +Auto-generated reference of every Ministry Platform stored procedure exposed via the MP API, with its input parameters and bounded string sizes. Regenerate with `npm run mp:generate:storedprocs` (runs `src/lib/providers/ministry-platform/scripts/generate-storedprocs.ts`). Do not edit by hand — the generator overwrites this file. ## How to call - HTTP: `POST /procs/{procName}` with JSON body `{ "@ParamName": value, ... }` (or GET for simple query params). @@ -17,14 +17,14 @@ Auto-generated reference of every Ministry Platform stored procedure exposed via ## Generated metadata -**Generated (source):** 2026-04-16T02:10:03.119Z -**Procedures:** 532 +**Generated (source):** 2026-10-11T11:13:44.159Z +**Procedures:** 539 --- All procedures listed with input parameters. String sizes shown where bounded, e.g. `String(50)`. -### api_* (526) +### api_* (533) - `api_Advanced_EventsAndRoomsByRecord(@RecordID: Integer32)` - `api_Advanced_GetRecurringItems(@SequenceID: Integer32)` @@ -90,6 +90,7 @@ All procedures listed with input parameters. String sizes shown where bounded, e - `api_CIM_GetCheckInHouseholds(@SearchTerm: String(50), @RestrictToEventList: String(2000), @IsAttended: Boolean, @HouseholdId: Integer32, @AllowPhoneNumber: Boolean, @AllowIdCardBarcode: Boolean, @AllowLastName: Boolean)` - `api_CIM_GetCheckInResults(@HouseholdID: Integer32, @EventList: Text, @IsAttended: Boolean, @ID_Card: String(50), @GuestContactID: Integer32)` - `api_CIM_GetCheckInRooms(@EventList: String(2000), @ActiveOnly: Boolean)` +- `api_CIM_GetClassroomParticipants(@EventID: Integer32, @GroupParticipantIds: Text, @APIClientID: String(256))` - `api_CIM_GetDomainTimezone(no parameters)` - `api_Classroom_GetCongregations(no parameters)` - `api_Classroom_GetDropDownData(no parameters)` @@ -115,7 +116,7 @@ All procedures listed with input parameters. String sizes shown where bounded, e - `api_CloudTools_ResetUserSecurity(@ContactID: Integer32, @UserID: Integer32)` - `api_CloudTools_UpdateCommunicationMessage(@CommunicationMessageID: Integer32, @StatusID: Integer32, @UserID: Integer32)` - `api_Common_FindMatchingContact(@FirstName: String(50), @LastName: String(50), @Suffix: String(10), @EmailAddress: String(150), @Phone: String(50), @RequireEmail: Boolean)` -- `api_Common_GenerateSlug(@TableName: String(50), @RecordID: Integer32, @SubPage: String(50))` +- `api_Common_GenerateSlug(@TableName: String(75), @UserID: Integer32, @RecordID: Integer32, @SubPage: String(50), @SelectionName: String(50))` - `api_Common_GetConfigurationLists(@ApplicationCode: String(50))` - `api_Common_GetConfigurationSettings(@ApplicationCode: String(50), @KeyName: String(50))` - `api_Common_GetCongregations(no parameters)` @@ -352,6 +353,9 @@ All procedures listed with input parameters. String sizes shown where bounded, e - `api_ListSync_GetListSubscribers(@PublicationID: Integer32)` - `api_ListSync_RemoveSubscriber(@ContactID: Integer32, @PublicationID: Integer32)` - `api_MatchOrCreateContact(@FirstName: String(50), @LastName: String(50), @EmailAddress: String(255), @PhoneNumber: String(25), @Address: String(75), @City: String(50), @State: String(2), @Zip: String(15))` +- `api_mcp_CreatePageView(@UserID: Integer32, @TableName: String(128), @ViewTitle: String(50), @ViewClause: String(4000), @FieldList: String(4000), @OrderBy: String(255), @Description: String(500), @PageID: Integer32, @UserGroupID: Integer32, @Global: Boolean)` +- `api_mcp_CreateSelection(@UserID: Integer32, @TableName: String(128), @SelectionName: String(50), @Records: Xml)` +- `api_mcp_GetAuditLog(@UserID: Integer32, @TableName: String(128), @PageID: Integer32, @RecordID: Integer32, @AuditUserID: Integer32, @AuditUserName: String(128), @DaysBack: Integer32, @StartDate: DateTime, @EndDate: DateTime, @AuditTypeID: Integer32, @FieldName: String(100), @IncludeDetail: Boolean, @DetailFormat: String(10), @TopN: Integer32, @MaxValueLength: Integer32)` - `api_MOBILE_Dashboard_Kid(no parameters)` - `api_MOBILE_GetCareCaseDetail(@careCaseID: Integer32)` - `api_MOBILE_GetCareCases(@showAll: Boolean)` @@ -361,6 +365,9 @@ All procedures listed with input parameters. String sizes shown where bounded, e - `api_MOBILE_GetPersonFiles(@contactID: Integer32)` - `api_MOBILE_GetYearGivingByType(@year: Integer32)` - `api_MOBILE_PersonLookup(@searchItem: String(50), @searchItem2: String(50))` +- `api_MPNextTools_GetPageFields(@PageID: Integer32)` +- `api_MPNextTools_GetPages(no parameters)` +- `api_MPNextTools_UpdatePageFieldOrder(@PageID: Integer32, @FieldName: String(64), @GroupName: String(75), @ViewOrder: Integer16, @Required: Boolean, @Hidden: Boolean, @DefaultValue: String(128), @FilterClause: String(2000), @DependsOnField: String(64), @FieldLabel: String(64), @WritingAssistantEnabled: Boolean)` - `api_MPP_DeleteEventGroup(@EventID: Integer32, @GroupID: Integer32)` - `api_MPP_DeleteMeetingParticipants(@EventParticipantID: Integer32)` - `api_MPP_DeleteRecurringDonations(@SubscriptionCode: String(50))` @@ -472,8 +479,8 @@ All procedures listed with input parameters. String sizes shown where bounded, e - `api_OGCC_GetEncryptionKey(no parameters)` - `api_OGCC_GetPledgeCampainsWithPledges(no parameters)` - `api_OGCC_GetProgramsAndPaymentTypes(no parameters)` -- `api_PAPP_GetContact_JSON(@contactGUID: String(256))` -- `api_PAPP_VolunteerBirthdays_JSON(no parameters)` +- `api_PAPP_GetContact_JSON(@contactGUID: String(256), @UserID: String(40))` +- `api_PAPP_VolunteerBirthdays_JSON(@UserID: String(40), @RestrictCampusID: Integer32)` - `api_PocketPlatform_Device_Updater(no parameters)` - `api_PocketPlatform_Set_LatestSermonDate(no parameters)` - `api_Tools_AppendSelection(@SelectionID: Integer32, @IDs: Text)` @@ -491,9 +498,9 @@ All procedures listed with input parameters. String sizes shown where bounded, e - `api_Tools_GetDefaultEventParticipants(@EventId: Integer32)` - `api_Tools_GetDepositDataToExportToRealmAccounting(@DepositID: Integer32)` - `api_Tools_GetDomainData(no parameters)` -- `api_Tools_GetDonationPossibleMatches(@FirstName: String(50), @LastName: String(50), @Phone: String(25), @Email: String(255), @AddressLine1: String(75), @DateOfBirth: String(10))` +- `api_Tools_GetDonationPossibleMatches(@FirstName: String(50), @LastName: String(50), @Phone: String(25), @Email: String(255), @AddressLine1: String(75), @DateOfBirth: String(10), @MinimumMatchCount: Integer32, @MinorRegistrationMode: Boolean)` - `api_Tools_GetGroupAttendance(@EventId: Integer32, @GroupId: Integer32)` -- `api_Tools_GetHouseholdsWithHeads(@Search: Text)` +- `api_Tools_GetHouseholdsWithHeads(@Search: String(200))` - `api_Tools_GetMatchingPrograms(@Program_ID: Integer32, @Program_Name: String(130), @Account_Number: String(25), @Congregation: Integer32)` - `api_Tools_GetMergeTags(@PageId: Integer32)` - `api_Tools_GetOrphanedHouseholds(@DeleteContactIds: String(4000))` @@ -508,7 +515,7 @@ All procedures listed with input parameters. String sizes shown where bounded, e - `api_Tools_GetRolePages(@RoleId: Integer32)` - `api_Tools_GetSelectedBatchSummaries(@SelectionId: Integer32, @BatchId: Integer32)` - `api_Tools_GetSelectedContacts(@SelectionId: Integer32, @SelectFields: String(4000))` -- `api_Tools_GetSelectedDonations(@SelectionId: Integer32, @DonationId: Integer32)` +- `api_Tools_GetSelectedDonations(@SelectionId: Integer32, @DonationId: Integer32, @UserId: Integer32)` - `api_Tools_GetSelectedDonationsSummary(@SelectionId: Integer32, @DonationId: Integer32)` - `api_Tools_GetSelectedEventParticipants(@EventId: Integer32)` - `api_Tools_GetSelectedPaymentsSummary(@SelectionId: Integer32, @PaymentId: Integer32)` @@ -528,7 +535,7 @@ All procedures listed with input parameters. String sizes shown where bounded, e - `api_Tools_GetUserTemplates(@UserId: Integer32, @CurrentTemplateId: Integer32)` - `api_Tools_GetUserTools(@UserId: Integer32)` - `api_Tools_LoadFormById(@FormId: Integer32)` -- `api_Tools_MoveFilesToAnotherRecord(@PageName: String(64), @PrimaryRecordId: Integer32, @RecordIds: String(4000))` +- `api_Tools_MoveFilesToAnotherRecord(@TableName: String(64), @PrimaryRecordId: Integer32, @RecordIds: String(4000))` - `api_Tools_NextAvailableEnvelopeNumber(@NumberOfEnvelopes: Integer32, @ContactID: Integer32, @CongregationID: Integer32)` - `api_Tools_PostContactMergeCleanUp(@ContactID: Integer32)` - `api_Tools_ReassignUserToContactId(@UserId: Integer32, @ContactId: Integer32)` diff --git a/.claude/references/mp-schema/tables.md b/.claude/references/mp-schema/tables.md index 11e5d0c..1235a51 100644 --- a/.claude/references/mp-schema/tables.md +++ b/.claude/references/mp-schema/tables.md @@ -2,22 +2,25 @@ title: Ministry Platform Schema — Tables domain: mp-schema type: generated-reference -last_verified: 2026-04-17 +last_verified: 2026-10-11 source: npm run mp:generate:models --- ## Purpose -Auto-generated catalog of Ministry Platform tables, primary keys, and foreign key relationships. Regenerate with `npm run mp:generate:models` (runs `src/lib/providers/ministry-platform/scripts/generate-types.ts` with `--clean --zod`, emitting to `src/lib/providers/ministry-platform/models/`). +Auto-generated catalog of Ministry Platform tables, primary keys, and foreign key relationships. Regenerate with `npm run mp:generate:models` (runs `src/lib/providers/ministry-platform/scripts/generate-types.ts` with `--clean --zod`, emitting to `src/lib/providers/ministry-platform/models/`). Do not edit by hand — the generator overwrites this file. ## Generated metadata -**Generated (source):** 2026-04-16T02:07:32.640Z | **Tables:** 301 +**Generated (source):** 2026-10-11T11:13:43.173Z | **Tables:** 307 **Type files:** `src/lib/providers/ministry-platform/models/{PascalCaseTableName}.ts` and `{PascalCaseTableName}Schema.ts` **Access:** R=Read, RW=ReadWrite, RWAD=ReadWriteAssignDelete --- +### _Deployment_Logs [RWAD] [None] +PK: `Log_ID` | FK: Deployment_ID→_Deployments + ### _Deployments [R] [None] PK: `Deployment_ID` @@ -222,7 +225,7 @@ PK: `Donor_ID` | FK: Contact_ID→Contacts, Statement_Frequency_ID→Statement_F ### dp_Account_Information [RWAD] [None] PK: `Account_Information_ID` | FK: Congregation_ID→Congregations, Integration_Definition_Type_ID→dp_Integration_Definition_Types -### dp_API_Clients [RWAD] [FileAttach, DataExport] +### dp_API_Clients [RWAD] [FileAttach, DataExport, SecureRecord] PK: `API_Client_ID` | FK: Client_User_ID→dp_Users, Authentication_Flow_ID→dp_Authentication_Flows ### dp_Application_Labels [RWAD] [FileAttach, DataExport] @@ -234,15 +237,24 @@ PK: `Audit_Retention_Message_ID` | FK: _Audit_Retention_Policy_ID→dp_Audit_Ret ### dp_Audit_Retention_Policies [RWAD] [FileAttach, DataExport] PK: `Audit_Retention_Policy_ID` | FK: Retention_Type_ID→dp_Audit_Retention_Types, Audit_Type_ID→dp_Audit_Types +### dp_Audit_Retention_Runs [RWAD] [DataExport] +PK: `Audit_Retention_Run_ID` + ### dp_Authentication_Log [RWAD] [FileAttach, DataExport, SecureRecord] PK: `Authentication_ID` ### dp_Bookmarks [RWAD] [FileAttach, DataExport] PK: `Bookmark_ID` +### dp_Charts [R] [None] +PK: `Chart_ID` | FK: Page_ID→dp_Pages, Aggregation_Type_ID→dp_Aggregation_Types, Default_Chart_Type_ID→dp_Chart_Types + ### dp_Communication_Messages [RWAssign] [DataExport] PK: `Communication_Message_ID` | FK: Communication_ID→dp_Communications, Action_Status_ID→dp_Communication_Action_Statuses, Contact_ID→Contacts +### dp_Communication_Messages_Archived [RWAssign] [DataExport] +PK: `Communication_Message_Archived_ID` | FK: Original_Communication_ID→dp_Communications, Action_Status_ID→dp_Communication_Action_Statuses, Contact_ID→Contacts + ### dp_Communication_Publications [RWAD] [FileAttach, DataExport] PK: `Communication_Publication_ID` | FK: Communication_ID→dp_Communications, Publication_ID→dp_Publications @@ -424,7 +436,7 @@ PK: `Form_Response_Answer_ID` | FK: Form_Field_ID→Form_Fields, Form_Response_I PK: `Form_Response_ID` | FK: Form_ID→Forms, Contact_ID→Contacts, Event_ID→Events, Pledge_Campaign_ID→Pledge_Campaigns, Opportunity_ID→Opportunities, Opportunity_Response→Responses, Congregation_ID→Congregations, Event_Participant_ID→Event_Participants ### Forms [RWAD] [FileAttach, DataExport, SecureRecord] -PK: `Form_ID` | FK: Congregation_ID→Congregations, Primary_Contact→Contacts, Response_Message→dp_Communication_Templates +PK: `Form_ID` | FK: Congregation_ID→Congregations, Primary_Contact→Contacts, Response_Message→dp_Communication_Templates, Product_ID→Products, Program_ID→Programs ### Frequencies [RWAD] [None] PK: `Frequency_ID` @@ -511,7 +523,7 @@ PK: `Invoice_Source_ID` PK: `Invoice_Status_ID` ### Invoices [RWAD] [FileAttach, DataExport, SecureRecord] -PK: `Invoice_ID` | FK: Purchaser_Contact_ID→Contacts, Invoice_Status_ID→Invoice_Statuses, Congregation_ID→Congregations, Invoice_Source→Invoice_Sources +PK: `Invoice_ID` | FK: Purchaser_Contact_ID→Contacts, Invoice_Status_ID→Invoice_Statuses, Congregation_ID→Congregations, Invoice_Source→Invoice_Sources, Form_Response_ID→Form_Responses ### Item_Priorities [R] [None] PK: `Item_Priority_ID` @@ -726,6 +738,9 @@ PK: `Device_ID` | FK: User_ID→dp_Users, App_ID→Pocket_Platform_Apps ### Pocket_Platform_Migrations [R] [None] PK: `Pocket_Platform_Migrations_ID` +### Pocket_Platform_Social_Media_Providers [R] [None] +PK: `Social_Media_Provider_ID` + ### Prefixes [R] [None] PK: `Prefix_ID` @@ -849,6 +864,9 @@ PK: `Statement_Method_ID` ### Statement_Types [R] [None] PK: `Statement_Type_ID` +### Statistics_Logs [R] [None] +PK: `Log_ID` + ### Suffixes [R] [None] PK: `Suffix_ID` @@ -879,7 +897,7 @@ PK: `ID` ### vw_mp_contact_mail_name [R] [DataExport] PK: `Contact_ID` -### vw_mp_giving_unit_summary [R] [DataExport] +### vw_mp_giving_unit_summary_standard [R] [DataExport] PK: `Household_ID` | FK: Household_ID→Households, Contact_ID→Contacts ### vw_mp_Participation_Compliance [R] [DataExport] @@ -903,6 +921,15 @@ PK: `ID` | FK: Response_ID→Responses, Group_Role_ID→Group_Roles, Participant ### vw_mp_User_Rights [R] [DataExport] PK: `View_ID` | FK: Contact_ID→Contacts, User_ID→dp_Users +### Wait_Stats_Baseline [R] [None] +PK: `WaitType` + +### Wait_Stats_History [R] [None] +PK: `WaitStatsID` + +### Wait_Types [R] [None] +PK: `Wait_Type_ID` + ### Weekly_Snapshots [R] [None] PK: `Weekly_Snapshot_ID` | FK: Congregation_ID→Congregations @@ -912,11 +939,3 @@ PK: `Wifi_Device_Session_ID` | FK: Wifi_Device_ID→Wifi_Devices ### Wifi_Devices [RWAD] [None] PK: `Wifi_Device_ID` | FK: Contact_ID→Contacts -### Z_Event_Notifications [R] [None] -PK: `Z_Event_Notifications_ID` - -### Z_Form_Notifications [R] [None] -PK: `Z_Form_Notifications_ID` - -### Z_Opp_Notifications [R] [None] -PK: `Z_Opp_Notifications_ID` diff --git a/.claude/references/routing/README.md b/.claude/references/routing/README.md index 4ff33ed..3f940cd 100644 --- a/.claude/references/routing/README.md +++ b/.claude/references/routing/README.md @@ -5,32 +5,36 @@ domain: routing --- ## What's in this domain -Next.js 16 App Router layout, the `(web)` route group that wraps protected pages with auth, and the `proxy.ts` edge gate (renamed from `middleware.ts` in Next.js 16). +Next.js 16 App Router layout, the `(web)` route group that wraps protected pages with auth, and the `proxy.ts` request gate (renamed from `middleware.ts` in Next.js 16; runs on the Node.js runtime). ## File map | File | Purpose | When to read | |------|---------|--------------| -| `app-router.md` | Route group `(web)`, nested layouts, dashboard, tool routes, public `/signin`, `/api/auth/[...all]` | Adding/moving a route, understanding layout nesting | -| `proxy.md` | Next.js 16 `proxy()` function, cookie-only auth check, public path whitelist, `x-pathname` forwarding | Changing auth gate, adding a new public path | +| `app-router.md` | Route group `(web)`, nested layouts, dashboard, tool routes, `/no-access`, public `/signin` + `/auth-error`, `/session-error`, `/api/auth/[...all]` | Adding/moving a route, understanding layout nesting | +| `proxy.md` | Next.js 16 `proxy()` function, cookie-only auth check, public path whitelist, `x-pathname` forwarding, CSP nonce | Changing auth gate, adding a new public path | ## Code surfaces | Path | Role | |------|------| -| `src/proxy.ts` | Edge proxy — cookie check + redirect to `/signin` | +| `src/proxy.ts` | Proxy — cookie check + redirect to `/signin`, per-request CSP | | `src/proxy.test.ts` | Proxy behavior tests | | `src/app/layout.tsx` | Root HTML shell (`<html><body>`) | | `src/app/(web)/layout.tsx` | Route-group layout — mounts `AuthWrapper` + `Providers` + fonts | -| `src/app/(web)/tools/layout.tsx` | Tools sub-layout — full-height gray background | +| `src/app/(web)/tools/layout.tsx` | Tools sub-layout — full-height gray background; UX redirect of role-less users to `/no-access` | +| `src/app/(web)/tools/require-tool-access.ts` | `requireToolAccess()` — page-level enforcement every tool page awaits first | | `src/app/(web)/page.tsx` | Dashboard (5 tool cards) | -| `src/app/(web)/home/page.tsx` | Redirects to `/` via `redirect('/')` | +| `src/app/(web)/no-access/page.tsx` | No-security-role explanation + sign-out form | | `src/app/(web)/error.tsx` | Client error boundary for `(web)` | +| `src/app/error.tsx` / `src/app/global-error.tsx` | Boundaries outside `(web)` / for a root-layout throw | | `src/app/signin/page.tsx` | Public OAuth sign-in page | -| `src/app/api/auth/[...all]/route.ts` | Better Auth catch-all handler | +| `src/app/auth-error/page.tsx` | Public OAuth-failure page | +| `src/app/session-error/page.tsx` | Recovery page for a session with no `userGuid` (outside `(web)`) | +| `src/app/api/auth/[...all]/route.ts` | Better Auth catch-all handler behind a deny-by-default allowlist | | `src/app/providers.tsx` | Client context composition (`UserProvider` + `Toaster`) | | `src/components/layout/auth-wrapper.tsx` | Server component full session check | ## Dual-layer auth gate -- **Layer 1 — `proxy.ts`** (edge): cookie presence only, no API call +- **Layer 1 — `proxy.ts`** (Node.js runtime): cookie presence only, no API call - **Layer 2 — `AuthWrapper`** (server component): full `auth.api.getSession()` validation - See `proxy.md` for rationale diff --git a/.claude/references/routing/app-router.md b/.claude/references/routing/app-router.md index cc22369..68c7b75 100644 --- a/.claude/references/routing/app-router.md +++ b/.claude/references/routing/app-router.md @@ -2,48 +2,54 @@ title: App Router layout domain: routing type: reference -applies_to: [src/app/layout.tsx, src/app/(web)/layout.tsx, src/app/(web)/page.tsx, src/app/(web)/home/page.tsx, src/app/(web)/tools/layout.tsx, src/app/(web)/error.tsx, src/app/signin/page.tsx, src/app/api/auth/[...all]/route.ts, src/app/providers.tsx] -symbols: [RootLayout, WebLayout, ToolsLayout, Home, SignIn, Providers] +applies_to: [src/app/layout.tsx, src/app/(web)/layout.tsx, src/app/(web)/page.tsx, src/app/(web)/no-access/page.tsx, src/app/(web)/tools/layout.tsx, src/app/(web)/error.tsx, src/app/error.tsx, src/app/global-error.tsx, src/app/signin/page.tsx, src/app/auth-error/page.tsx, src/app/session-error/page.tsx, src/app/api/auth/[...all]/route.ts, src/app/providers.tsx] +symbols: [RootLayout, WebLayout, ToolsLayout, NoAccessPage, SignIn, Providers] related: [proxy.md, ../auth/README.md, ../components/README.md, ../contexts/README.md] -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose -Next.js 16 App Router tree. A `(web)` route group gates all authenticated pages with `AuthWrapper` + client `Providers`; `/signin` and `/api/auth/*` sit outside the group so they render without a session. +Next.js 16 App Router tree. A `(web)` route group gates all authenticated pages with `AuthWrapper` + client `Providers`; `/signin`, `/auth-error`, `/session-error` and `/api/auth/*` sit outside the group so they render without `AuthWrapper`. ## Files - `src/app/layout.tsx` — root layout, imports `globals.css`, renders `<html lang="en"><body>{children}</body></html>` - `src/app/(web)/layout.tsx` — group layout; wraps children in `AuthWrapper` then `Providers`, injects Geist fonts, sets `metadata` + `viewport` - `src/app/(web)/page.tsx` — dashboard `/` — 5 tool cards (Template, Address Labels, Group Wizard, Field Management, Add/Edit Family) -- `src/app/(web)/home/page.tsx` — `/home` → `redirect('/')` +- `src/app/(web)/no-access/page.tsx` — `/no-access` — explains the refusal to signed-in users with no MP security role and offers a sign-out `<form action={handleSignOut}>`; inside `(web)` but outside `tools/` - `src/app/(web)/tools/layout.tsx` — tools sub-layout, `flex flex-col h-screen bg-gray-50`; role-less users get a UX redirect to `/no-access` — **not** enforcement (a layout redirect does not stop a page segment rendering in Next 16) - `src/app/(web)/tools/require-tool-access.ts` — `requireToolAccess()`, the page-level enforcement every tool page awaits first - `src/app/(web)/tools/<tool>/page.tsx` — per-tool entry (`addeditfamily`, `addresslabels`, `fieldmanagement`, `groupwizard`, `template`) -- `src/app/(web)/error.tsx` — `"use client"` error boundary for the group -- `src/app/signin/page.tsx` — public OAuth redirect page -- `src/app/api/auth/[...all]/route.ts` — Better Auth catch-all handler (`export const { GET, POST } = toNextJsHandler(auth)`) +- `src/app/(web)/error.tsx` — `"use client"` error boundary for the group (renders inside the shell) +- `src/app/error.tsx` — error boundary for routes outside `(web)`; `src/app/global-error.tsx` — last-resort boundary for a root-layout throw +- `src/app/signin/page.tsx` — public OAuth redirect page (server component, `dynamic = "force-dynamic"`; the client body is `sign-in-content.tsx`) +- `src/app/auth-error/page.tsx` — public landing page for OAuth callback failures (`onAPIError`) +- `src/app/session-error/page.tsx` — recovery page for a session with no `userGuid`; outside `(web)` so it cannot loop through `AuthWrapper` +- `src/app/api/auth/[...all]/route.ts` — Better Auth catch-all, wrapped in a deny-by-default allowlist (`allowedAuthRoutes`) and a `POST /sign-in/social` body filter in front of `toNextJsHandler(auth)` - `src/app/providers.tsx` — `"use client"` composition (`UserProvider` + sonner `Toaster`) ## Key concepts - **Route group `(web)`** — parens in directory name group routes without contributing a URL segment; used to apply a shared layout (auth) to everything under it without affecting the URL -- **Public routes sit outside `(web)`** — `src/app/signin/` and `src/app/api/` are siblings of `(web)`, so they skip `AuthWrapper` +- **Public routes sit outside `(web)`** — `src/app/signin/`, `src/app/auth-error/`, `src/app/session-error/` and `src/app/api/` are siblings of `(web)`, so they skip `AuthWrapper` (`/session-error` is still cookie-gated by the proxy) - **Nested layouts compose top-down** — `RootLayout` → `WebLayout` (inside `(web)`) → `ToolsLayout` (inside `(web)/tools/`) - **Root layout is async** (`async function RootLayout`) — `src/app/layout.tsx:3` - **WebLayout is async** (`async function WebLayout`) — `src/app/(web)/layout.tsx:28` -- **Error boundary per group** — `src/app/(web)/error.tsx` only catches errors inside `(web)` +- **Error boundary per group** — `src/app/(web)/error.tsx` only catches errors inside `(web)`; `src/app/error.tsx` covers the routes outside it - **Named-exports rule does NOT apply to route files** — Next.js App Router requires `export default` for `layout.tsx`, `page.tsx`, `loading.tsx`, `error.tsx`, and `route.ts` GET/POST (these are named exports on route handlers) ## Route tree ``` / ├── / # (web)/page.tsx — dashboard -├── /home # (web)/home/page.tsx — redirects to / +├── /no-access # (web)/no-access/page.tsx — no MP security role; sign-out form ├── /tools/template ├── /tools/addresslabels ├── /tools/groupwizard ├── /tools/fieldmanagement +├── /tools/addeditfamily ├── /signin # public -└── /api/auth/* # public (Better Auth catch-all) +├── /auth-error # public (OAuth callback failures) +├── /session-error # outside (web); proxy still requires the cookie +└── /api/auth/* # public, allowlisted (Better Auth catch-all) ``` ## API / Interface @@ -69,21 +75,18 @@ export default async function WebLayout({ } ``` -`src/app/(web)/home/page.tsx`: +`src/app/api/auth/[...all]/route.ts` (abridged — not a bare `toNextJsHandler` export): ```typescript -import { redirect } from 'next/navigation'; - -export default function Home() { - redirect('/'); -} -``` - -`src/app/api/auth/[...all]/route.ts`: -```typescript -import { auth } from "@/lib/auth"; -import { toNextJsHandler } from "better-auth/next-js"; - -export const { GET, POST } = toNextJsHandler(auth); +export const allowedAuthRoutes = { + GET: ["/get-session", "/callback/ministryplatform"], + POST: ["/sign-in/social"], +} as const; + +const handlers = toNextJsHandler(auth); +// guard(): non-allowlisted path (or a POST /sign-in/social body failing +// isAllowedSignInSocialBody) → plain 404; otherwise withNoStore(await handler(request)) +export const GET = guard("GET", handlers.GET); +export const POST = guard("POST", handlers.POST); ``` `src/app/providers.tsx`: @@ -109,7 +112,7 @@ export function Providers({ children }: ProvidersProps) { - If segment lives under `(web)/`: `RootLayout` → `WebLayout` (mounts `AuthWrapper`) → (optional `ToolsLayout`) → `page.tsx` - `AuthWrapper` calls `auth.api.getSession({ headers: await headers() })`; missing session triggers `redirect('/signin?callbackUrl=...')` - Once session validated, `Providers` mounts client context tree (`UserProvider` fetches MP profile client-side) -- `/signin` skips the whole group tree — rendered under `RootLayout` only +- `/signin`, `/auth-error` and `/session-error` skip the whole group tree — rendered under `RootLayout` only ## Next.js 16 routing notes - **Dev output path**: `next dev` writes to `.next/dev` (not `.next`) — see `proxy.md` and Next.js 16 upgrade guide diff --git a/.claude/references/routing/proxy.md b/.claude/references/routing/proxy.md index e2fe20b..391ee57 100644 --- a/.claude/references/routing/proxy.md +++ b/.claude/references/routing/proxy.md @@ -1,101 +1,101 @@ --- -title: Proxy (Next.js 16 edge gate) +title: Proxy (Next.js 16 request gate) domain: routing type: reference -applies_to: [src/proxy.ts, src/proxy.test.ts, src/components/layout/auth-wrapper.tsx] -symbols: [proxy, config] +applies_to: [src/proxy.ts, src/proxy.test.ts, src/lib/security-headers.ts, src/components/layout/auth-wrapper.tsx] +symbols: [proxy, config, isPublicPath] related: [app-router.md, ../auth/README.md, ../components/README.md] -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose -Edge-runtime gate that does a cheap cookie-presence check and redirects unauthenticated users to `/signin?callbackUrl=...` before any server component runs. Real session validation happens downstream in `AuthWrapper`. +Request gate in front of every page: a cheap cookie-presence check that redirects unauthenticated users to `/signin?callbackUrl=...` before any server component runs, plus the per-request CSP nonce. Real session validation happens downstream in `AuthWrapper`. ## Files - `src/proxy.ts` — `proxy()` function + `config` matcher -- `src/proxy.test.ts` — behavior tests (public paths, redirect, callback URL, error paths, matcher, `x-pathname` forwarding) +- `src/proxy.test.ts` — behavior tests (public paths, redirect, callback URL, error paths, matcher anchoring, `x-pathname` forwarding, CSP) +- `src/lib/security-headers.ts` — `buildCsp`, `generateNonce`, `cspHeaderName`, `cspReportUri`, `reportingEndpointsHeader` - `src/components/layout/auth-wrapper.tsx` — layer-2 full session validation ## Key concepts -- **Next.js 16 rename** — `middleware.ts` → `proxy.ts`, `export function middleware` → `export function proxy`. Same runtime and semantics; rename only. Codemod: `npx @next/codemod@latest middleware-to-proxy .` -- **Cookie-only check at the edge** — uses `getSessionCookie` from `better-auth/cookies`; no DB or API call, no JWT decode +- **Next.js 16 rename** — `middleware.ts` → `proxy.ts`, `export function middleware` → `export function proxy`. Codemod: `npx @next/codemod@latest middleware-to-proxy .` +- **Runtime is Node.js, not edge** — in Next.js 16 `proxy` always runs on the `nodejs` runtime and the `runtime` config option cannot be set (edge is only available by staying on the deprecated `middleware`) +- **Cookie-only check** — uses `getSessionCookie` from `better-auth/cookies`; no MP/API call, no cookie decryption - **Full validation is deferred** to `AuthWrapper` server component — see "Why dual-layer" below -- **Public-path whitelist** — `/api/*` and `/signin` bypass the cookie check +- **Public-path whitelist** (`isPublicPath`) — exactly `/api/auth`, `/api/auth/*`, `/signin` and `/auth-error` bypass the cookie check. Every other `/api/*` route is gated - **`x-pathname` header forwarding** — proxy writes pathname + search onto request headers via `NextResponse.next({ request: { headers } })` so `AuthWrapper` can reconstruct the original URL for `callbackUrl` even on passthroughs -- **Matcher** excludes `_next/static`, `_next/image`, `favicon.ico`, and `assets/` +- **CSP + nonce** — a fresh nonce per request; the policy is set on the **request** headers (Next reads the nonce from there at render time) and on every **response**, redirects included. `x-nonce` is also forwarded. See `../security/README.md` +- **Matcher** excludes `_next/static/`, `_next/image`, `favicon.ico` and `assets/` — each exclusion anchored, so `/favicon.ico-admin` or `/_next/imagery` is still gated ## API / Interface -`src/proxy.ts` (entire file): +`src/proxy.ts` (abridged — CSP construction elided): ```typescript -import { NextResponse, NextRequest } from 'next/server'; -import { getSessionCookie } from 'better-auth/cookies'; +function isPublicPath(pathname: string): boolean { + return ( + pathname === '/api/auth' || + pathname.startsWith('/api/auth/') || + pathname === '/signin' || + pathname === '/auth-error' + ); +} export async function proxy(request: NextRequest) { const { pathname } = request.nextUrl; const fullPath = pathname + request.nextUrl.search; - + // ...nonce + CSP built here... const forwardedHeaders = new Headers(request.headers); forwardedHeaders.set('x-pathname', fullPath); + forwardedHeaders.set('x-nonce', nonce); + forwardedHeaders.set(headerName, csp); + const passThrough = () => - NextResponse.next({ request: { headers: forwardedHeaders } }); + withCsp(NextResponse.next({ request: { headers: forwardedHeaders } })); + const redirectToSignin = () => { /* /signin?callbackUrl=<fullPath>, withCsp */ }; - // Early returns for public paths - if (pathname.startsWith('/api') || pathname === '/signin') { - console.log(`Proxy: Allowing public path ${pathname}`); - return passThrough(); - } + if (isPublicPath(pathname)) return passThrough(); try { const sessionCookie = getSessionCookie(request); - - if (!sessionCookie) { - console.log("Proxy: Redirecting to signin - no session cookie"); - const signinUrl = new URL('/signin', request.url); - signinUrl.searchParams.set('callbackUrl', fullPath); - return NextResponse.redirect(signinUrl); - } - - console.log(`Proxy: Allowing request to ${pathname}`); + if (!sessionCookie) return redirectToSignin(); return passThrough(); - } catch (error) { - console.error('Proxy: Error checking session:', error); - const signinUrl = new URL('/signin', request.url); - signinUrl.searchParams.set('callbackUrl', fullPath); - return NextResponse.redirect(signinUrl); + // Identifier/shape only — never the request path or query string. + console.error('proxy.session_check_failed', { message: /* error.message */ }); + return redirectToSignin(); } } export const config = { matcher: [ - '/((?!_next/static|_next/image|favicon.ico|assets/).*)', + '/((?!_next/static/|_next/image$|favicon\\.ico$|assets/).*)', ], }; ``` ## Public paths (whitelist) -| Path prefix | Reason | +| Path | Reason | |---|---| -| `/api` (includes `/api/auth/*`) | Better Auth catch-all and any server API must be reachable without a session | +| `/api/auth`, `/api/auth/*` | Better Auth catch-all — itself deny-by-default (allowlist in `src/app/api/auth/[...all]/route.ts`). Exact segment match, so `/api/authz` is **not** public | | `/signin` | OAuth sign-in page itself must render unauthenticated | +| `/auth-error` | OAuth callback failures land here with no session; gating it would bounce to `/signin`, which auto-restarts OAuth — an infinite loop | -Anything else requires a session cookie. +Anything else — including any other `/api/*` route and `/session-error` — requires a session cookie. ## Why dual-layer (proxy + AuthWrapper) | Layer | Runs in | Check | Cost | Failure mode | |---|---|---|---|---| -| `proxy()` | Edge runtime, every request | Cookie presence only | ~µs, no I/O | Redirects to `/signin` | -| `AuthWrapper` | Server component (Node), once per page render | `auth.api.getSession()` — full JWT validate + custom session | ms, async | Redirects to `/signin` | +| `proxy()` | Node.js runtime, every matched request | Cookie presence only | ~µs, no I/O | Redirects to `/signin` | +| `AuthWrapper` | Server component, once per page render | `auth.api.getSession()` — decrypts/validates the JWE session cookie + `customSession` | ms, async | No session → `/signin`; session without `userGuid` → `/session-error` | -- Edge can't run Node-only Better Auth internals cheaply — cookie check is the fast path +- The proxy runs on every matched request (pages, RSC fetches, server-action POSTs), so it stays a cheap cookie check; the full check runs where the page renders - An attacker with a stale/forged cookie passes the proxy but fails `AuthWrapper` - `AuthWrapper` also mints `session.user.userGuid` via the custom session plugin — required before any MP API call (see `../auth/README.md`) ## `x-pathname` forwarding `AuthWrapper` reads this header to build a correct `callbackUrl` when the proxy let the request through but the full session check fails: -`src/components/layout/auth-wrapper.tsx:5-19`: +`src/components/layout/auth-wrapper.tsx:5-31` (comments trimmed): ```typescript export async function AuthWrapper({ children }: { children: React.ReactNode }) { const hdrs = await headers(); @@ -108,42 +108,47 @@ export async function AuthWrapper({ children }: { children: React.ReactNode }) { redirect(`${signinUrl.pathname}${signinUrl.search}`); } + const userGuid = (session.user as { userGuid?: string | null }).userGuid; + if (!userGuid) { + redirect("/session-error"); + } + return <>{children}</>; } ``` -Only `passThrough()` (which calls `NextResponse.next({ request: { headers } })`) propagates the header. Redirect responses do not — by the time the browser re-requests `/signin`, the pathname is already in `callbackUrl`. +Only `passThrough()` (which calls `NextResponse.next({ request: { headers } })`) propagates `x-pathname`. Redirect responses do not — by the time the browser re-requests `/signin`, the pathname is already in `callbackUrl`. ## How it works (flow) Protected route hit: 1. Browser → `/tools/template?recordID=123` 2. `proxy()` checks matcher → applies 3. Builds `forwardedHeaders` with `x-pathname: /tools/template?recordID=123` -4. Path is not `/api` nor `/signin` → run cookie check +4. Path is not public (`isPublicPath`) → run cookie check 5. `getSessionCookie(request)` → present → `NextResponse.next(...)` with forwarded headers 6. Next.js routes to `(web)/tools/template/page.tsx` — `WebLayout` mounts `AuthWrapper` 7. `AuthWrapper` calls `auth.api.getSession(...)` with forwarded headers → validates -8. If valid → render; if not → `redirect('/signin?callbackUrl=/tools/template?recordID=123')` +8. If valid → render; if not → `redirect('/signin?callbackUrl=/tools/template?recordID=123')` (or `/session-error` when the session has no `userGuid`) -## Next.js 16 specifics (verified via context7) +## Next.js 16 specifics - File must be named `proxy.ts` (or `proxy.js`) at project root or `src/` - Function export must be named `proxy` (not `middleware`) - `config` shape unchanged — `{ matcher: [...] }` -- `middleware.js` is **deprecated** in Next.js 16 (docs source: `next.js/docs/01-app/02-guides/upgrading/version-16.mdx`) +- `middleware` is **deprecated** in Next.js 16; `proxy` runs on `nodejs` only and setting `runtime` throws (source: `node_modules/next/dist/docs/01-app/02-guides/upgrading/version-16.md`, `.../03-api-reference/03-file-conventions/proxy.md`) ## Test coverage (`src/proxy.test.ts`) -- Public paths: `/api/*`, nested `/api/*`, `/signin` — all skip cookie check +- Public paths: `/api/auth`, nested `/api/auth/*`, `/signin`, `/auth-error` skip the cookie check; `/api/other` is gated - Protected paths: no cookie → redirect to `/signin` with `callbackUrl` (with and without query string) - Error during `getSessionCookie` → redirect to `/signin` with `callbackUrl` -- `x-pathname` forwarded on public paths and authenticated passthrough -- Matcher config exported correctly (excludes `_next/static`, `_next/image`, `favicon.ico`, `assets/`) +- `x-pathname` forwarded on public paths and authenticated passthrough, not on redirects +- Matcher: exempt paths skipped; prefix look-alikes (`/favicon.ico-admin`, `/_next/imagery`, `/assets-admin`, …) still matched +- CSP: set on request and response with the same per-request nonce, on redirects too; `CSP_REPORT_URI` → `Reporting-Endpoints` ## Gotchas -- **Adding a public route**: must add a branch to the `if (pathname.startsWith('/api') || pathname === '/signin')` check in `src/proxy.ts:18`, otherwise proxy redirects before the page is reachable -- **Expecting full session in proxy**: don't add DB/API calls here — edge runtime and cold-path latency. Use `AuthWrapper` instead +- **Adding a public route**: must add it to `isPublicPath()` in `src/proxy.ts`, otherwise proxy redirects before the page is reachable. Use exact or segment-anchored matches, never a bare `startsWith` prefix +- **Expecting full session in proxy**: don't add MP/API calls here — it runs on every matched request. Use `AuthWrapper` instead - **Redirect responses don't carry `x-pathname`**: only `NextResponse.next()` forwards the header; don't add logic that expects the header on a redirect response -- **Matcher regex**: any new static directory you don't want gated must be added to the exclusion group (currently `_next/static`, `_next/image`, `favicon.ico`, `assets/`) -- **Edge runtime**: proxy runs on the edge; Node-only APIs unavailable +- **Matcher regex**: any new static directory you don't want gated must be added to the exclusion group (currently `_next/static/`, `_next/image`, `favicon.ico`, `assets/`), anchored like the existing entries — an excluded path also gets no CSP header ## Related docs - `app-router.md` — route tree, `(web)` group, where `AuthWrapper` mounts diff --git a/.claude/references/security/README.md b/.claude/references/security/README.md index d5e13ac..94af00b 100644 --- a/.claude/references/security/README.md +++ b/.claude/references/security/README.md @@ -47,22 +47,26 @@ rendered, so the gate runs at every layer that is independently reachable: | Layer | File | Call | |---|---|---| -| Page | `src/app/(web)/tools/layout.tsx` | `hasSecurityRole()` then `redirect("/no-access")` | -| Action | `src/components/<feature>/actions.ts` | `requireAccess()` wrapping `requireSecurityRole()` | -| Service | `src/services/*.ts` | `requireSecurityRole()` on **every** method, reads included | +| Page | `src/app/(web)/tools/<tool>/page.tsx` | first statement `await requireToolAccess({ table, operation })` (`src/app/(web)/tools/require-tool-access.ts`) → `requireSecurityRole()`; a refusal redirects to `/no-access` | +| Action | `src/components/<feature>/actions.ts`, `src/app/(web)/tools/addeditfamily/actions.ts` | `requireAccess()` wrapping `requireSecurityRole()` | +| Service | `src/services/*.ts` | `requireSecurityRole()` on every MP-data method, reads included. Exceptions: `UserService.getUserProfile` (own-profile carve-out), `FamilyService.getDefaults` (static), `DomainTimezoneService.*` (domain config). `GooglePlacesService.*` gates `dp_Configuration_Settings`/`read` on every call, cached key or not | -A layout gate covers its child pages for free — React renders the layout first -and only renders `children` once it returns. +`src/app/(web)/tools/layout.tsx` also calls `hasSecurityRole()` and redirects +to `/no-access`, but that is **UX only, not enforcement**: Next 16 renders each +route segment independently, so a layout `redirect()` does not stop the page +segment running or its output reaching the RSC payload, and layouts are not +re-rendered on client navigation. That is why every tool page gates itself +(`require-tool-access.test.ts` fails if one does not). ### `AuthorizationService` design points - **Two entry points.** `requireSecurityRole()` throws `UnauthorizedError` and logs a structured denial — it is the enforcement point, and it returns the acting MP `User_ID`. `hasSecurityRole()` returns a decision without logging — - for UI affordances and the layout redirect, **never** as enforcement. + for UI affordances and the `tools/layout.tsx` redirect, **never** as enforcement. - **Per-request memoization via React `cache()`** — not module-level, not TTL. - The gate runs up to three times per request; this collapses that to one MP - read. Nothing crosses requests, which is what keeps a role revoked in MP + The gate runs up to three times per request (page, action, service); this + collapses that to one MP read. Nothing crosses requests, which is what keeps a role revoked in MP effective on the user's *very next* request. - **Fails closed.** No session, no `userGuid`, no `dp_Users` row, or no role means refused. @@ -306,8 +310,8 @@ So this app keys on the only identifier MP guarantees unique — the OIDC `sub` ``` - The **real** address is preserved separately as `mpEmail`, for display only. - Nothing in the UI reads `session.user.email`; the header uses - `MPUserProfile.Email_Address` straight from MP. + Nothing in the UI reads `session.user.email`; the profile `UserProvider` + loads (`MPUserProfile.Email_Address`) comes straight from MP. - `account.accountLinking.enabled = false` is the second lock. - `emailVerified` reports the provider's actual claim instead of asserting `true`. @@ -449,8 +453,8 @@ clone made before 2026-09-30 should:** window whose `User_ID` doesn't match the signed-in person. 4. Port the session hardening above. -`SECURITY.md` at the repo root carries the same list for people who never open -`.claude/`. +`.github/SECURITY.md` (GitHub's security policy location) carries the same list +for people who never open `.claude/`. --- @@ -474,11 +478,17 @@ Structured events alerts can grep on: |---|---| | `mp.read.unauthorized` | role gate refuses a read | | `mp.write.unauthorized` | role gate refuses a write | -| `mp.write.non_user` | a write ran with no resolved acting user | +| `mp.write.non_user` | the gate refused a call whose session resolved to no MP `User_ID` (any reason except `no_session`) | | `mp.request.failed` | a non-2xx MP HTTP response | | `auth.userinfo.invalid_sub` | MP userinfo returned no usable `sub` | | `auth.userinfo.fetch_failed` | MP userinfo endpoint returned non-2xx | | `ui.render.error` | a React error boundary caught a render error | +| `proxy.session_check_failed` | `getSessionCookie` threw in `src/proxy.ts` (request redirected to `/signin`) | + +The OIDC sign-in events (`auth.oidc.discovery_failed`, +`auth.userinfo.id_token_unverified`, `auth.userinfo.id_token_claims_invalid`, +`auth.userinfo.sub_mismatch`) are listed with their `reason` fields in +[`../auth/oauth-flow.md`](../auth/oauth-flow.md#failure-modes-and-log-events-identifiers-only). --- @@ -489,7 +499,7 @@ segment, so one boundary cannot do every job: | File | Catches | Why separate | |---|---|---| -| `src/app/(web)/error.tsx` | anything below the `(web)` layout | renders **inside** the shell, so the header and **sign-out survive** | +| `src/app/(web)/error.tsx` | anything below the `(web)` layout | renders **inside** the shell (the `(web)` layout, `AuthWrapper` and `Providers` survive) | | `src/app/error.tsx` | `/signin`, `/session-error`, `/auth-error` | those routes have no shell | | `src/app/global-error.tsx` | a throw in the root layout itself | replaces it; imports nothing from the app. Has a plain `<a href="/">` that works without JS, and "Try again" is disabled until hydration (`useSyncExternalStore`) so a pre-hydration click is not swallowed | @@ -532,8 +542,8 @@ Closed in this repo, from the upstream MPNext hardening playbook **Not applicable to this repo:** F4 (no `Made_By` / contact-log feature, and attribution was already server-authoritative — no server action ever accepted a -`userId` parameter), F10 and F11 (no `ContactService`; `getMpTimezone` is a -documented carve-out). +`userId` parameter) and F10 (no `ContactService`). F11 *was* ported (2026-09-30): +`getMpTimezone` now requires a session — see [Carve-outs](#carve-outs). ### F8 (PKCE) — closed as not-possible, with evidence @@ -562,6 +572,5 @@ and no code-bearing URLs in logs. ### Still open — needs a human -- **The enforced-CSP browser walk has not been done.** Headers and nonce - coverage were verified against `next start`; a human still needs to click - through every Radix surface under enforcement. +Nothing at present. The enforced-CSP browser walk was completed 2026-10-10 — +see [`CSP_ENFORCE`](#csp_enforce). diff --git a/.claude/references/services/README.md b/.claude/references/services/README.md index 29d45c9..84472fd 100644 --- a/.claude/references/services/README.md +++ b/.claude/references/services/README.md @@ -5,17 +5,20 @@ domain: services --- ## What's in this domain -Application service layer — singletons that wrap `MPHelper` and expose domain operations (tools, users, groups, address labels, field management) to server actions. All 5 services follow the same lazy-initialized `getInstance()` pattern. +Application service layer — singletons in `src/services/` that wrap `MPHelper` (or, for Google Places, an external provider) and expose domain operations to server actions and server components. 9 services; every file starts with `import "server-only"`. ## File map | File | Purpose | When to read | |------|---------|--------------| | `query-patterns.md` | MP REST query rules: `_TABLE_` FK traversal, ambiguous-column prefixing, quote escaping, `$userId` audit, batching | Before writing/modifying any MP query in a service | -| `tool-service.md` | `ToolService` — pages, user tools, selection resolution, contact-ID resolver, dev-tool deploy | Editing `src/services/toolService.ts` or its callers | +| `tool-service.md` | `ToolService` — page metadata, user tools, selection resolution, contact-ID resolver, dev-tool deploy | Editing `src/services/toolService.ts` or its callers | | `user-service.md` | `UserService` — display-only profile fetch (names + email) | Editing user profile/session code | | `address-label-service.md` | `AddressLabelService` — Contacts→Households→Addresses multi-level FK join | Editing address-label feature | -| `group-service.md` | `GroupService` — lookup fetching, contact/group search, group CRUD with date coercion | Editing group-wizard feature | +| `group-service.md` | `GroupService` — lookups, contact/group search, group read/create/update with column allowlist | Editing group-wizard feature | | `field-management-service.md` | `FieldManagementService` — page field editor backing store, `api_MPNextTools_*` SPs | Editing field-management feature | +| `family-service.md` | `FamilyService` — Add/Edit Family: household load/save, lookups, envelope numbers | Editing the addeditfamily tool | + +`AuthorizationService` is documented in `../security/README.md`; `DomainTimezoneService` in `../ministryplatform.datetimehandling.md`. `GooglePlacesService` has no dedicated doc (see inventory below). ## Code surfaces | Path | Role | @@ -23,27 +26,46 @@ Application service layer — singletons that wrap `MPHelper` and expose domain | `src/services/toolService.ts` | `ToolService` singleton | | `src/services/userService.ts` | `UserService` singleton | | `src/services/addressLabelService.ts` | `AddressLabelService` singleton | -| `src/services/groupService.ts` | `GroupService` singleton | +| `src/services/groupService.ts` | `GroupService` singleton + `GROUP_WRITABLE_FIELDS` | | `src/services/fieldManagementService.ts` | `FieldManagementService` singleton | +| `src/services/familyService.ts` | `FamilyService` singleton + `PartialSaveError` | +| `src/services/authorizationService.ts` | `AuthorizationService` singleton + `UnauthorizedError`, `resolveRolePolicy` | +| `src/services/domainTimezoneService.ts` | `DomainTimezoneService` singleton + `resolveIanaTimezone`, `domainTimezoneService` instance | +| `src/services/googlePlacesService.ts` | `GooglePlacesService` singleton | | `src/services/*.test.ts` | Vitest suites (one per service) | -| `src/lib/providers/ministry-platform/helper.ts` | `MPHelper` — the only dependency services construct | -| `src/lib/validation.ts` | `validateGuid`, `validatePositiveInt`, `validateColumnName`, `escapeFilterString` — used by services | - -## Services inventory (5) -| Service | One-liner | -|---------|-----------| -| `ToolService` | Resolves MP pages, user tools, selection record IDs, contact IDs; also deploys dev tools | -| `UserService` | Fetches MP user profile (by `User_GUID`) plus roles and user-group memberships | -| `AddressLabelService` | Pulls mailing addresses for contacts via multi-level FK traversal | -| `GroupService` | Group-wizard lookups, contact/group search, group create/update with date coercion | -| `FieldManagementService` | Lists MP pages, reads `dp_Page_Fields`, persists field-order edits | +| `src/lib/providers/ministry-platform/helper.ts` | `MPHelper` — the MP dependency services construct | +| `src/lib/validation.ts` | `validateGuid`, `validatePositiveInt`, `validateColumnName`, `validateFkPath`, `escapeFilterString` — used by services | +| `src/lib/constants.ts` | `MP_FETCH_BATCH_SIZE` (100) — IN-clause batch size | + +## Services inventory (9) +| Service | One-liner | Key methods | +|---------|-----------|-------------| +| `ToolService` | MP page metadata, user tools, selection record IDs, page-record → Contact_ID resolution; dev-tool deploy | `getPageData`, `getSelectionRecordIds`, `getUserTools`, `resolveContactIds`, `listPages`, `listRoles`, `deployTool` | +| `UserService` | Signed-in user's display profile (names + email) by `User_GUID` | `getUserProfile` | +| `AddressLabelService` | Mailing addresses for contacts via multi-level FK traversal | `getAddressesForContacts`, `getAddressForContact` | +| `GroupService` | Group-wizard lookups, contact/group search, group read/create/update | `fetchAllLookups`, `searchContacts`, `searchGroups`, `getGroup`, `createGroup`, `updateGroup` | +| `FieldManagementService` | Lists MP pages, reads `dp_Page_Fields` + table metadata, persists field-order edits | `getPages`, `getPageFields`, `getTableMetadata`, `updatePageFieldOrder` | +| `FamilyService` | Add/Edit Family: contact search, household load/save, lookups, envelope numbers | `searchContacts`, `resolveContactIdFromPage`, `getHousehold`, `getLookups`, `getDefaults`, `getNextEnvelopeNumber`, `saveHousehold` | +| `AuthorizationService` | Role gate for all MP-data access (`MP_SECURITY_ROLES`, fails closed); returns acting MP `User_ID` | `requireSecurityRole`, `hasSecurityRole` | +| `DomainTimezoneService` | MP domain time zone (cached) and wall-clock ↔ instant conversion at the MP boundary | `getMpTimezone`, `toMpSqlDatetime`, `parseMpDatetime`, `clearCache` | +| `GooglePlacesService` | Address autocomplete; API key from MP `dp_Configuration_Settings` (`COMMON`/`GoogleMapsAPIKey`), else `GOOGLE_PLACES_API_KEY`, cached per process | `isEnabled`, `autocomplete`, `getPlaceDetails` | + +## Singleton shapes +- `static async getInstance(): Promise<T>` — `ToolService`, `UserService`, `AddressLabelService`, `GroupService`, `FieldManagementService`, `FamilyService`, `GooglePlacesService` (callers `await`). +- `static getInstance(): T` (synchronous) — `AuthorizationService`, `DomainTimezoneService`. + +## Authorization +- Every MP-touching method calls `AuthorizationService.getInstance().requireSecurityRole({ table, operation })` first — reads included (CLAUDE.md rule 12). Writes take `$userId` from its return value (rule 13). +- Methods with **no** gate: `UserService.getUserProfile` (rule-12 carve-out via `shared-actions/user.ts`), `FamilyService.getDefaults` (static data, no MP call), `DomainTimezoneService.*` (domain config only). +- `GooglePlacesService` gates every public method (`isEnabled`, `autocomplete`, `getPlaceDetails`) on `dp_Configuration_Settings`/`read` — on each call, not only on a cache miss, because the resolved API key and provider are cached per process. The gate runs outside `resolveApiKey`'s try/catch, so a refusal throws `UnauthorizedError` instead of falling back to the env var. ## Data flow ``` Server Action ("use server") - -> auth.api.getSession({ headers }) // validate session - -> Service.getInstance() // lazy singleton + -> requireAccess(table, op) // AuthorizationService.requireSecurityRole (action layer) + -> await Service.getInstance() // lazy singleton -> service method (typed) + -> AuthorizationService.requireSecurityRole(...) // service layer; returns User_ID -> MPHelper.getTableRecords / executeProcedureWithBody / createTableRecords / updateTableRecords -> Ministry Platform REST API ``` @@ -52,6 +74,7 @@ Server Action ("use server") - `../mp-provider/README.md` — `MPHelper` and underlying HTTP/provider layer - `../mp-schema/README.md` — table/stored-proc reference used by these services - `../data-flow/README.md` — end-to-end server action → service → MP flow -- `../auth/README.md` — `session.user.userGuid` used before calling services +- `../security/README.md` — `AuthorizationService`, write attribution +- `../auth/README.md` — `session.user.userGuid` (resolved to `User_ID` by the gate) - `../components/README.md` — server actions that call services - `../testing/README.md` — mock patterns (`vi.hoisted`, MPHelper class mock, singleton reset) diff --git a/.claude/references/services/address-label-service.md b/.claude/references/services/address-label-service.md index 4076a1c..3fe76b6 100644 --- a/.claude/references/services/address-label-service.md +++ b/.claude/references/services/address-label-service.md @@ -5,19 +5,22 @@ type: reference applies_to: [src/services/addressLabelService.ts, src/services/addressLabelService.test.ts] symbols: [AddressLabelService, ContactAddressRow] related: [query-patterns.md, ../dto-constants/dtos.md, ../components/address-labels.md] -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose Fetch mailing addresses for one or many contacts by traversing `Contacts → Households → Addresses` via multi-level FK joins. Backs the address-label print feature. ## Files -- `src/services/addressLabelService.ts` — singleton (105 lines) -- `src/services/addressLabelService.test.ts` — 110 lines; covers singleton, happy path, batching (250 IDs → multiple calls), empty input, single-contact lookup +- `src/services/addressLabelService.ts` — singleton (118 lines) +- `src/services/addressLabelService.test.ts` — 154 lines; covers singleton, happy path, batching (250 IDs → multiple calls), empty input, single-contact lookup, invalid IDs (NaN/0/negative) rejected before the MP call + +## Authorization +Both methods call `AuthorizationService.getInstance().requireSecurityRole({ table: 'Contacts', operation: 'read' })` first — bulk names + mailing addresses are the app's largest PII surface. The action (`fetchAddressLabels`) gates again; per-request `cache()` memoization keeps it to one MP read. ## Singleton ```typescript -// src/services/addressLabelService.ts:45-63 +// src/services/addressLabelService.ts:46-64 export class AddressLabelService { private static instance: AddressLabelService; private mp: MPHelper | null = null; @@ -31,13 +34,13 @@ export class AddressLabelService { | Method | Returns | Behavior | |--------|---------|----------| -| `getAddressesForContacts(contactIds: number[])` | `ContactAddressRow[]` | Batches of 100, validates each ID with `validatePositiveInt`, orders by Postal_Code | +| `getAddressesForContacts(contactIds: number[])` | `ContactAddressRow[]` | Batches of `MP_FETCH_BATCH_SIZE` (100, `src/lib/constants.ts`), validates each ID with `validatePositiveInt`, orders by Postal_Code | | `getAddressForContact(contactId: number)` | `ContactAddressRow \| null` | Validates ID with `validatePositiveInt`, single-row lookup, `top: 1` | ## Shared select (multi-level FK traversal) ```typescript -// src/services/addressLabelService.ts:23-38 +// src/services/addressLabelService.ts:24-39 const SELECT_FIELDS = [ 'Contact_ID', 'Display_Name', @@ -65,17 +68,19 @@ Key rules (see `query-patterns.md`): ## getAddressesForContacts (batched) ```typescript -// src/services/addressLabelService.ts:21, 69-90 -const BATCH_SIZE = 100; - +// src/services/addressLabelService.ts:70-97 (MP_FETCH_BATCH_SIZE = 100 from src/lib/constants.ts) async getAddressesForContacts(contactIds: number[]): Promise<ContactAddressRow[]> { + await AuthorizationService.getInstance().requireSecurityRole({ + table: 'Contacts', + operation: 'read', + }); if (contactIds.length === 0) return []; const results: ContactAddressRow[] = []; - for (let i = 0; i < contactIds.length; i += BATCH_SIZE) { - const batch = contactIds.slice(i, i + BATCH_SIZE); - batch.forEach(validatePositiveInt); + for (let i = 0; i < contactIds.length; i += MP_FETCH_BATCH_SIZE) { + const batch = contactIds.slice(i, i + MP_FETCH_BATCH_SIZE); + batch.forEach((id) => validatePositiveInt(id)); const idList = batch.join(', '); const rows = await this.mp!.getTableRecords<ContactAddressRow>({ @@ -102,8 +107,12 @@ async getAddressesForContacts(contactIds: number[]): Promise<ContactAddressRow[] ## getAddressForContact ```typescript -// src/services/addressLabelService.ts:95-106 +// src/services/addressLabelService.ts:102-117 async getAddressForContact(contactId: number): Promise<ContactAddressRow | null> { + await AuthorizationService.getInstance().requireSecurityRole({ + table: 'Contacts', + operation: 'read', + }); validatePositiveInt(contactId); const rows = await this.mp!.getTableRecords<ContactAddressRow>({ @@ -119,7 +128,7 @@ async getAddressForContact(contactId: number): Promise<ContactAddressRow | null> ## Return type ```typescript -// src/services/addressLabelService.ts:4-19 +// src/services/addressLabelService.ts:7-22 export interface ContactAddressRow { Contact_ID: number; Display_Name: string; @@ -149,7 +158,7 @@ The server action (`src/components/address-labels/actions.ts`) transforms `Conta | `fetchAddressLabels` | `src/components/address-labels/actions.ts` | `getAddressesForContacts` (selection mode), `getAddressForContact` (single record) | ## Gotchas -- Returning `[]` for empty input skips the API call — don't "helpfully" add a default. +- Returning `[]` for empty input skips the API call (the gate still runs first) — don't "helpfully" add a default. - `Contacts.Household_ID` must stay prefixed — removing the `Contacts.` prefix will 500 with "ambiguous column". - `[State/Region]` must stay in brackets in the select; the TS key is `'State/Region'` (single-quoted). - `ContactAddressRow` returned from MP can have `Household_ID: null` when the contact isn't linked to a household — downstream filters must handle it. diff --git a/.claude/references/services/family-service.md b/.claude/references/services/family-service.md new file mode 100644 index 0000000..61ae4e6 --- /dev/null +++ b/.claude/references/services/family-service.md @@ -0,0 +1,89 @@ +--- +title: FamilyService +domain: services +type: reference +applies_to: [src/services/familyService.ts, src/services/familyService.test.ts, src/lib/dto/family.ts] +symbols: [FamilyService, PartialSaveError] +related: [query-patterns.md, tool-service.md, ../dto-constants/dtos.md, ../routing/app-router.md, ../ministryplatform.datetimehandling.md] +last_verified: 2026-10-10 +--- + +## Purpose +Backs the Add/Edit Family tool (`src/app/(web)/tools/addeditfamily/`): contact search, household load, lookup lists, envelope numbering, and the multi-step household save (addresses → household → contacts → participants → donors). + +## Files +- `src/services/familyService.ts` — singleton (801 lines) +- `src/services/familyService.test.ts` — 1015 lines +- `src/lib/dto/family.ts` — `HouseholdSchema` (Zod) + `Household`, `FamilyMember`, `FamilyLookups`, `FamilyDefaults`, `SaveProgress`, `SavedMemberId`, `ContactSearchResult` — see `../dto-constants/dtos.md` + +## Singleton +```typescript +// src/services/familyService.ts:95-106 +public static async getInstance(): Promise<FamilyService> { + if (!FamilyService.instance) { + FamilyService.instance = new FamilyService(); + FamilyService.instance.mp = new MPHelper(); // no separate initialize() + } + return FamilyService.instance; +} +``` + +## API + +| Method | Returns | Gate `table` / `operation` | Notes | +|--------|---------|----------------------------|-------| +| `searchContacts(term)` | `ContactSearchResult[]` (≤25) | `Contacts` / `read` | `[]` for trimmed term < 2 chars; `escapeFilterString`; `Display_Name LIKE '{escaped}%' AND Contact_Status_ID = 1`; detail = email, else household `Address_Line_1` | +| `resolveContactIdFromPage(tableName, primaryKey, recordId, contactIdField)` | `number \| null` | `{tableName}` / `read` | Validates all four args **before** the gate (`validateColumnName` ×2, `validatePositiveInt`, `validateFkPath`); `select: '{fkPath} AS Resolved_Contact_ID'` | +| `getHousehold(contactId)` | `Household \| null` | `Households` / `read` | Contact → `Household_ID`, then household (+ both addresses via `Address_ID_TABLE` / `Alternate_Mailing_Address_TABLE`) and members in parallel | +| `getLookups()` | `FamilyLookups` | `Contacts` / `read` | 12 lookup tables via `Promise.all`; `states` is the hard-coded `US_STATES` list | +| `getDefaults()` | `FamilyDefaults` | none | Returns a copy of the static `DEFAULT_FAMILY_DEFAULTS`; touches no MP data | +| `getNextEnvelopeNumber()` | `number` | `Contacts` / `read` | `MAX(Envelope_No) + 1` on `Donors` (global — `Donors` has no `Congregation_ID`) | +| `saveHousehold(household)` | `SaveProgress` | `Households` / `update` | Multi-step write; gate's `User_ID` is `$userId` for every write | + +## resolveContactIdFromPage +- Callers must pass page metadata resolved server-side from a page ID (`ToolService.getPageData`), never caller-supplied names. Both callers do: the `resolveContactIdFromPage` action and `src/app/(web)/tools/addeditfamily/page.tsx` (via `parseToolParams` → `pageData`). +- An empty `contactIdField` returns `null` after the gate. + +## saveHousehold — order of writes + +```typescript +// src/services/familyService.ts:474-543 (abridged) +const userId = await AuthorizationService.getInstance().requireSecurityRole({ + table: 'Households', + operation: 'update', +}); +progress.mainAddressId = await wrap(() => this.upsertAddress(household.address, userId)); +progress.altAddressId = /* alt address only if addressLine1 present */; +// create or partial-update Households (householdId === 0 → create) +for (const member of household.members) { + // skip blank new members; saveMember → Contacts, Participants, Donors +} +return progress; +``` + +- `wrap()` turns any failure into `PartialSaveError(progress, underlying)`; `saveFamily` returns `progress` to the client so a retry can reuse IDs already created. **Not transactional.** +- `upsertAddress` — skipped when line 1 and postal code are both blank. +- `saveMember` — new participant gets `Participant_Start_Date = toMpSqlDatetime(new Date())` and back-links `Contacts.Participant_Record`; new donor gets `Setup_Date` the same way, `DONOR_DEFAULTS` (statement frequency/type/method `1`), and back-links `Contacts.Donor_Record`. +- `resolveUniqueEnvelopeNo` — re-checks the requested envelope number against other donors and bumps to `getNextEnvelopeNumber()` on conflict, up to 5 attempts, then throws. Every interpolated ID is `validatePositiveInt`-checked. +- `Date_of_Birth`, `Season_Start`, `Season_End` go through a local `toDatetime()` (appends `T00:00:00` to a date-only string), not `DomainTimezoneService`. + +## Consumers + +| Caller | File | Method(s) | +|--------|------|-----------| +| `searchContacts` | `src/app/(web)/tools/addeditfamily/actions.ts` | `searchContacts` | +| `fetchFamilyLookups` | same | `getLookups` | +| `fetchFamilyDefaults` | same | `getDefaults` | +| `fetchHousehold` | same | `getHousehold` | +| `resolveContactIdFromPage` | same | `ToolService.getPageData`, then `resolveContactIdFromPage` | +| `fetchNextEnvelopeNumber` | same | `getNextEnvelopeNumber` | +| `saveFamily` | same | `saveHousehold` (after `HouseholdSchema.safeParse`) | +| `AddEditFamilyPage` | `src/app/(web)/tools/addeditfamily/page.tsx` | `resolveContactIdFromPage` | + +Every action calls `requireAccess()` (→ `requireSecurityRole`) first. The same actions file also calls `GooglePlacesService` (`placesEnabled`, `placeAutocomplete`, `placeDetails`); those actions gate `Addresses`/`read`, and the service gates again on `dp_Configuration_Settings`/`read`. + +## Related docs +- `query-patterns.md` — validation, escaping, `$userId` +- `tool-service.md` — `getPageData`, sibling `resolveContactIds` +- `../dto-constants/dtos.md` — family DTOs +- `../security/README.md` — authorization gate diff --git a/.claude/references/services/field-management-service.md b/.claude/references/services/field-management-service.md index 59cf4d0..3a7362b 100644 --- a/.claude/references/services/field-management-service.md +++ b/.claude/references/services/field-management-service.md @@ -5,19 +5,29 @@ type: reference applies_to: [src/services/fieldManagementService.ts, src/services/fieldManagementService.test.ts] symbols: [FieldManagementService, PageListItem, PageField] related: [query-patterns.md, ../mp-schema/required-procs.md, ../components/field-management.md] -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose -Reads MP page list + `dp_Page_Fields` rows for the drag-and-drop field editor, merges in table-metadata columns to surface unsaved fields, and persists reorders via `api_MPNextTools_UpdatePageFieldOrder` (concurrency-capped at 5). +Reads MP page list, `dp_Page_Fields` rows and table metadata for the drag-and-drop field editor (the `fetchPageFieldData` action merges metadata columns in to surface unsaved fields), and persists reorders via `api_MPNextTools_UpdatePageFieldOrder` (concurrency-capped at 5). ## Files -- `src/services/fieldManagementService.ts` — 110 lines -- `src/services/fieldManagementService.test.ts` — 254 lines +- `src/services/fieldManagementService.ts` — 134 lines +- `src/services/fieldManagementService.test.ts` — 303 lines + +## Authorization +Every method calls `AuthorizationService.getInstance().requireSecurityRole(...)` first (see `../security/README.md`). `updatePageFieldOrder` is a **domain-wide** configuration write (changes page layout for every MP user), and takes `$userId` from the gate's return value. + +| Method | Gate `table` / `operation` | +|--------|---------------------------| +| `getPages` | `dp_Pages` / `read` | +| `getPageFields` | `dp_Page_Fields` / `read` | +| `getTableMetadata` | `dp_Tables` / `read` | +| `updatePageFieldOrder` | `dp_Page_Fields` / `update` | ## Singleton ```typescript -// src/services/fieldManagementService.ts:25-41 +// src/services/fieldManagementService.ts:27-43 export class FieldManagementService { private static instance: FieldManagementService; private mp: MPHelper | null = null; @@ -33,13 +43,13 @@ export class FieldManagementService { |--------|---------|-------| | `getPages()` | `PageListItem[]` | SP `api_MPNextTools_GetPages` | | `getPageFields(pageId)` | `PageField[]` | SP `api_MPNextTools_GetPageFields` | -| `getTableMetadata(tableName)` | `TableMetadata \| null` | `mp.getTables(tableName)` — exact-match preferred | -| `updatePageFieldOrder(fields[])` | `void` | SP `api_MPNextTools_UpdatePageFieldOrder` per field (batches of 5 via `Promise.all`) | +| `getTableMetadata(tableName)` | `TableMetadata \| null` | `mp.getTables(tableName)` — exact `Name` match only, else `null` | +| `updatePageFieldOrder(fields[])` | `void` | SP `api_MPNextTools_UpdatePageFieldOrder` per field (batches of 5 via `Promise.all`), `$userId` from gate | ## Types ```typescript -// src/services/fieldManagementService.ts:4-23 +// src/services/fieldManagementService.ts:6-25 export interface PageListItem { Page_ID: number; Display_Name: string; @@ -65,7 +75,7 @@ export interface PageField { ## getPages ```typescript -// src/services/fieldManagementService.ts:43-51 +// src/services/fieldManagementService.ts:45-57 (gate call omitted) public async getPages(): Promise<PageListItem[]> { const result = await this.mp!.executeProcedureWithBody('api_MPNextTools_GetPages', {}); if (result && result.length > 0 && result[0].length > 0) { @@ -80,7 +90,7 @@ Same SP also used by `ToolService.listPages` (which wraps it with in-memory sear ## getPageFields ```typescript -// src/services/fieldManagementService.ts:53-63 +// src/services/fieldManagementService.ts:59-73 (gate call omitted) public async getPageFields(pageId: number): Promise<PageField[]> { const result = await this.mp!.executeProcedureWithBody('api_MPNextTools_GetPageFields', { "@PageID": pageId, @@ -95,21 +105,21 @@ public async getPageFields(pageId: number): Promise<PageField[]> { ## getTableMetadata ```typescript -// src/services/fieldManagementService.ts:65-72 +// src/services/fieldManagementService.ts:75-88 (gate call and comment omitted) public async getTableMetadata(tableName: string): Promise<TableMetadata | null> { const tables = await this.mp!.getTables(tableName); - if (tables.length === 0) return null; - // getTables with search may return multiple matches — find the exact one - return tables.find((t) => t.Table_Name === tableName) ?? tables[0]; + return tables.find((t) => t.Name === tableName) ?? null; } ``` -`mp.getTables()` delegates to MP's metadata endpoint; `search` is a LIKE-style param so a prefix match may return multiple tables. +`mp.getTables()` delegates to MP's `GET /tables?$search=`, which can return several tables. Each entry's name is under **`Name`** — a live `/tables?$search=Contacts` response (2026-10-11) carried exactly `Name`, `AccessLevel`, `SpecialPermissions`, `Columns` per entry; there is no `Table_Name`. The match is exact and case-sensitive (the page's `Table_Name` and the `/tables` `Name` both come from MP's own table metadata, so they agree verbatim). + +There is **no fallback to `tables[0]`**: a near-miss would merge another table's columns into this page's layout. With no exact match it returns `null`, which `fetchPageFieldData` already handles — it merges no extra columns and tags no separators, so the editor shows only the page's existing `dp_Page_Fields` rows. ## updatePageFieldOrder — concurrency-capped ```typescript -// src/services/fieldManagementService.ts:74-109 +// src/services/fieldManagementService.ts:88-133 private static readonly CONCURRENCY = 5; public async updatePageFieldOrder( @@ -127,6 +137,12 @@ public async updatePageFieldOrder( Writing_Assistant_Enabled: boolean; }[] ): Promise<void> { + const $userId = await AuthorizationService.getInstance().requireSecurityRole({ + table: "dp_Page_Fields", + operation: "update", + }); + const queryParams = { $userId }; + for (let i = 0; i < fields.length; i += FieldManagementService.CONCURRENCY) { const batch = fields.slice(i, i + FieldManagementService.CONCURRENCY); await Promise.all(batch.map((f) => @@ -142,13 +158,13 @@ public async updatePageFieldOrder( "@DependsOnField": f.Depends_On_Field, "@FieldLabel": f.Field_Label, "@WritingAssistantEnabled": f.Writing_Assistant_Enabled, - }) + }, queryParams) )); } } ``` -- One SP call per field, 5 parallel calls per batch. +- One SP call per field, 5 parallel calls per batch, each carrying `$userId` for MP audit. - Not atomic — partial failures leave some fields updated, others not. The server action wraps in try/catch and surfaces an error to the UI. ## Stored procedures @@ -159,7 +175,7 @@ public async updatePageFieldOrder( | `api_MPNextTools_GetPageFields` | `getPageFields` | `@PageID INT` | | `api_MPNextTools_UpdatePageFieldOrder` | `updatePageFieldOrder` (1-per-field) | `@PageID, @FieldName, @GroupName, @ViewOrder, @Required, @Hidden, @DefaultValue, @FilterClause, @DependsOnField, @FieldLabel, @WritingAssistantEnabled` | -Each SP accepts `@DomainID INT` as the implicit first parameter (auto-injected by MP API — see MP install scripts in `src/lib/providers/ministry-platform/db/`). +Each SP declares `@DomainID INT` as its first parameter (auto-injected by MP API — see MP install scripts in `src/lib/providers/ministry-platform/db/`). See `../mp-schema/required-procs.md` for full parameter/return docs. @@ -167,13 +183,13 @@ See `../mp-schema/required-procs.md` for full parameter/return docs. | Action | File | |--------|------| -| `fetchPages` | `src/components/field-management/actions.ts` | +| `fetchPages` | `src/components/field-management/actions.ts` — gate `dp_Pages`/`read` | | `fetchPageFieldData` | `src/components/field-management/actions.ts` (parallels `getPageFields` + `getTableMetadata`, merges unsaved rows with negative `Page_Field_ID` values) | -| `savePageFieldOrder` | `src/components/field-management/actions.ts` — wraps `updatePageFieldOrder` in try/catch | +| `savePageFieldOrder` | `src/components/field-management/actions.ts` — gate `dp_Page_Fields`/`update`, wraps `updatePageFieldOrder` in try/catch | ## Gotchas - `updatePageFieldOrder` is not transactional. A mid-batch failure leaves `dp_Page_Fields` partially updated. -- `getTableMetadata` falls back to `tables[0]` when there's no exact `Table_Name` match — this can misbehave if MP returns a prefix-matching unrelated table. Exact match is preferred; the caller assumes this is usually right. +- `getTableMetadata` matches on `Name`, not `Table_Name` — `/tables` entries have no `Table_Name` key (the `PageListItem.Table_Name` from `dp_Pages` is a different shape). No exact match → `null`, never the first search result. - `getPages` shape is `result[0]` — not `result[0][0]`. Different from `getPageData` in ToolService (which returns a single row). ## Related docs diff --git a/.claude/references/services/group-service.md b/.claude/references/services/group-service.md index 9a06d8f..63c44ef 100644 --- a/.claude/references/services/group-service.md +++ b/.claude/references/services/group-service.md @@ -3,21 +3,21 @@ title: GroupService domain: services type: reference applies_to: [src/services/groupService.ts, src/services/groupService.test.ts] -symbols: [GroupService] -related: [query-patterns.md, ../components/group-wizard.md] -last_verified: 2026-04-17 +symbols: [GroupService, GROUP_WRITABLE_FIELDS, GetGroupResult, GroupWizardDisplayNames] +related: [query-patterns.md, ../components/group-wizard.md, ../ministryplatform.datetimehandling.md] +last_verified: 2026-10-10 --- ## Purpose -Backs the group-wizard feature: parallel lookup-table fetch, contact/group search, and group create/update with date-string coercion and `$userId` audit. +Backs the group-wizard feature: parallel lookup-table fetch, contact/group search, group read (with display names for FK fields), and group create/update through a column allowlist, MP-TZ date conversion and gate-sourced `$userId` audit. ## Files -- `src/services/groupService.ts` — 252 lines -- `src/services/groupService.test.ts` — 401 lines +- `src/services/groupService.ts` — 405 lines +- `src/services/groupService.test.ts` — 581 lines ## Singleton ```typescript -// src/services/groupService.ts:27-45 +// src/services/groupService.ts:113-131 export class GroupService { private static instance: GroupService; private mp: MPHelper | null = null; @@ -27,6 +27,18 @@ export class GroupService { } ``` +## Authorization +Every method calls `AuthorizationService.getInstance().requireSecurityRole(...)` first (see `../security/README.md`). Writes use its return value as `$userId` — callers never pass a user ID. + +| Method | Gate `table` / `operation` | +|--------|---------------------------| +| `fetchAllLookups` | `Groups` / `read` | +| `searchContacts` | `Contacts` / `read` | +| `searchGroups` | `Groups` / `read` | +| `getGroup` | `Groups` / `read` | +| `createGroup` | `Groups` / `create` | +| `updateGroup` | `Groups` / `update` | + ## API | Method | Returns | Notes | @@ -34,14 +46,14 @@ export class GroupService { | `fetchAllLookups()` | `GroupWizardLookups` | 13 lookup tables via `Promise.all` | | `searchContacts(term)` | `ContactSearchResult[]` (≤20) | `escapeFilterString()`, `Display_Name LIKE '{escaped}%'` | | `searchGroups(term)` | `GroupSearchResult[]` (≤20) | `escapeFilterString()`, `Group_Name LIKE '{escaped}%' AND End_Date IS NULL`, FK-traverses `Group_Type` | -| `getGroup(groupId)` | `GroupWizardFormData \| null` | `validatePositiveInt(groupId)`, strips time from ISO dates | -| `createGroup(data, userId)` | `{ Group_ID, Group_Name }` | `prepareForApi()` coerces date strings, passes `$userId` | -| `updateGroup(groupId, data, userId)` | `{ Group_ID, Group_Name }` | `partial: true`, `$userId` | +| `getGroup(groupId)` | `GetGroupResult \| null` (`{ data, displayNames }`) | `validatePositiveInt(groupId)`, FK display-name joins, strips time from ISO dates | +| `createGroup(data)` | `{ Group_ID, Group_Name }` | `prepareForApi()` allowlist + date conversion; `$userId` from gate | +| `updateGroup(groupId, data)` | `{ Group_ID, Group_Name }` | `validatePositiveInt(groupId)`, `partial: true`, `Group_ID` written last; `$userId` from gate | ## fetchAllLookups — 13 parallel queries ```typescript -// src/services/groupService.ts:47-149 (abridged — 13 entries) +// src/services/groupService.ts:133-239 (abridged — 13 entries) const [groupTypes, ministries, congregations, meetingDays, ...] = await Promise.all([ this.mp!.getTableRecords({ table: 'Group_Types', select: 'Group_Type_ID, Group_Type', orderBy: 'Group_Type' }), this.mp!.getTableRecords({ table: 'Ministries', select: 'Ministry_ID, Ministry_Name', filter: 'End_Date IS NULL', orderBy: 'Ministry_Name' }), @@ -66,7 +78,7 @@ Active-only filters applied on `Ministries`, `Congregations`, `Rooms` (`Bookable ## searchContacts / searchGroups ```typescript -// src/services/groupService.ts:151-171 +// src/services/groupService.ts:241-270 (gate calls omitted) async searchContacts(term: string): Promise<ContactSearchResult[]> { const escaped = escapeFilterString(term); return this.mp!.getTableRecords<ContactSearchResult>({ @@ -90,99 +102,110 @@ async searchGroups(term: string): Promise<GroupSearchResult[]> { } ``` -- `escapeFilterString()` from `src/lib/validation.ts` — escapes `'`, `%`, `_` +- `escapeFilterString()` from `src/lib/validation.ts` — bracket-escapes `[`, `%`, `_`, doubles `'`; throws on >100 chars or control characters - `searchGroups` FK-traverses `Group_Type_ID_TABLE.Group_Type` to inline the type name -## getGroup — date coercion on read +## getGroup — display names + date strip on read ```typescript -// src/services/groupService.ts:173-225 (abridged) -async getGroup(groupId: number): Promise<GroupWizardFormData | null> { - const records = await this.mp!.getTableRecords<Record<string, unknown>>({ - table: 'Groups', - filter: `Group_ID = ${validatePositiveInt(groupId)}`, - top: 1, - }); - if (!records || records.length === 0) return null; - - const r = records[0]; - return { - Group_Name: r.Group_Name as string, - // ...48+ fields mapped - Start_Date: r.Start_Date ? (r.Start_Date as string).split('T')[0] : '', - End_Date: r.End_Date ? (r.End_Date as string).split('T')[0] : null, - Promotion_Date: r.Promotion_Date ? (r.Promotion_Date as string).split('T')[0] : null, - 'Secure_Check-in': (r['Secure_Check-in'] as boolean) ?? false, - // ... - }; -} +// src/services/groupService.ts:272-363 (abridged) +const records = await this.mp!.getTableRecords<Record<string, unknown>>({ + table: 'Groups', + select: [ + 'Groups.*', + 'Primary_Contact_TABLE.Display_Name AS Primary_Contact_Display_Name', + 'Parent_Group_TABLE.Group_Name AS Parent_Group_Name', + 'Promote_to_Group_TABLE.Group_Name AS Promote_to_Group_Name', + 'Descended_From_TABLE.Group_Name AS Descended_From_Name', + ].join(', '), + filter: `Groups.Group_ID = ${validatePositiveInt(groupId)}`, + top: 1, +}); +if (!records || records.length === 0) return null; +// ...map ~40 fields into GroupWizardFormData, e.g. +// Start_Date: r.Start_Date ? (r.Start_Date as string).split('T')[0] : '', +// 'Secure_Check-in': (r['Secure_Check-in'] as boolean) ?? false, +return { data, displayNames: { contacts, groups } }; ``` -- MP returns ISO datetimes; form expects `YYYY-MM-DD`. `split('T')[0]` strips the time. -- `Secure_Check-in` uses bracket access — hyphen in the column name. +- `Groups.*` / `Groups.Group_ID` are prefixed because the FK joins make bare names ambiguous. +- `AS` aliases seed `displayNames.contacts` (`Primary_Contact`) and `displayNames.groups` (`Parent_Group`, `Promote_to_Group`, `Descended_From`) so edit mode needs no second round trip. +- MP returns wall-clock datetimes; the form expects `YYYY-MM-DD`, so `split('T')[0]` strips the time. -## createGroup / updateGroup — date coercion on write +## createGroup / updateGroup — allowlist + MP-TZ dates on write ```typescript -// src/services/groupService.ts:10-25 -function toDatetime(value: string | null | undefined): string | null { - if (!value) return null; - if (value.includes('T')) return value; - return `${value}T00:00:00Z`; -} - -function prepareForApi(data: GroupWizardFormData): Record<string, unknown> { - return { - ...data, - Start_Date: toDatetime(data.Start_Date), - End_Date: toDatetime(data.End_Date), - Promotion_Date: toDatetime(data.Promotion_Date), - }; +// src/services/groupService.ts:88-111 +const DATE_FIELDS = ['Start_Date', 'End_Date', 'Promotion_Date'] as const; + +async function prepareForApi(data: Partial<GroupWizardFormData>): Promise<Record<string, unknown>> { + const source = data as Record<string, unknown>; + const picked: Record<string, unknown> = {}; + for (const field of GROUP_WRITABLE_FIELDS) { + if (Object.hasOwn(source, field)) picked[field] = source[field]; + } + const tz = DomainTimezoneService.getInstance(); + for (const field of DATE_FIELDS) { + if (!Object.hasOwn(picked, field)) continue; + const value = picked[field] as string | null | undefined; + picked[field] = value ? await tz.toMpSqlDatetime(value) : null; + } + return picked; } ``` +- `GROUP_WRITABLE_FIELDS` (`src/services/groupService.ts:40-86`) — frozen allowlist of exactly the columns the wizard edits; equals the union of `STEP_FIELDS` in `src/components/group-wizard/schema.ts` (pinned by a test). Any other key (`Domain_ID`, `_Last_Attendance_Posted`, a smuggled `Group_ID`) is dropped. +- Dates go through `DomainTimezoneService.toMpSqlDatetime` (MP-TZ wall clock) — see `../ministryplatform.datetimehandling.md`. + ```typescript -// src/services/groupService.ts:227-251 -async createGroup(data, userId) { - const apiData = prepareForApi(data); +// src/services/groupService.ts:365-404 (abridged) +async createGroup(data: GroupWizardFormData) { + const $userId = await AuthorizationService.getInstance().requireSecurityRole({ table: 'Groups', operation: 'create' }); + const apiData = await prepareForApi(data); const result = await this.mp!.createTableRecords('Groups', [apiData], { $select: 'Group_ID, Group_Name', - $userId: userId, + $userId, }); return result[0] as unknown as { Group_ID: number; Group_Name: string }; } -async updateGroup(groupId, data, userId) { - const apiData = { Group_ID: groupId, ...prepareForApi(data as GroupWizardFormData) }; +async updateGroup(groupId: number, data: Partial<GroupWizardFormData>) { + const $userId = await AuthorizationService.getInstance().requireSecurityRole({ table: 'Groups', operation: 'update' }); + validatePositiveInt(groupId); + const apiData = { ...(await prepareForApi(data)), Group_ID: groupId }; const result = await this.mp!.updateTableRecords('Groups', [apiData], { partial: true, $select: 'Group_ID, Group_Name', - $userId: userId, + $userId, }); return result[0] as unknown as { Group_ID: number; Group_Name: string }; } ``` -- `$userId` — numeric MP User ID for audit. Resolve from session via `UserService.getUserIdByGuid(session.user.userGuid)`. +- `$userId` — numeric MP `User_ID` returned by the gate; the only source of write attribution (CLAUDE.md rule 13). +- `Group_ID` is spread **last** from the validated argument, so nothing in the payload can retarget the update. - `partial: true` on update — omitted fields are left untouched. - `$select` limits returned columns to `Group_ID, Group_Name`. ## Consumers -| Action | File | -|--------|------| -| `fetchGroupWizardLookups` | `src/components/group-wizard/actions.ts` | -| `searchContacts` / `searchGroups` | `src/components/group-wizard/actions.ts` | -| `fetchGroupRecord` | `src/components/group-wizard/actions.ts` | -| `createGroup` / `updateGroup` | `src/components/group-wizard/actions.ts` | +All in `src/components/group-wizard/actions.ts`; each action calls `requireAccess()` (→ `requireSecurityRole`) first. + +| Action | Service method | +|--------|----------------| +| `fetchGroupWizardLookups` | `fetchAllLookups` | +| `searchContacts` / `searchGroups` | `searchContacts` / `searchGroups` (action returns `[]` for terms < 2 chars) | +| `fetchGroupRecord` | `getGroup` | +| `createGroup` / `updateGroup` | `createGroup` / `updateGroup` (action Zod-parses the payload with `groupWizardSchema` first) | ## Gotchas -- `searchContacts(term)` assumes `term.length >= 2` — the action validates that; if a service caller forgets, an empty prefix query returns everything up to `top: 20`. +- `searchContacts(term)` assumes `term.length >= 2` — the action enforces that; if a service caller forgets, an empty prefix query returns everything up to `top: 20`. - `getGroup` returns `null` on "not found"; `createGroup`/`updateGroup` do not — they throw on API error. -- All three lookup active-filters (`End_Date IS NULL` for Ministries/Congregations, `Bookable = 1` for Rooms, `Active = 1` for SMS) must stay — UI relies on active-only lists. -- Passing `userId = session.user.id` instead of the MP User_ID produces corrupt audit rows. Always resolve via `UserService.getUserIdByGuid()`. +- All lookup active-filters (`End_Date IS NULL` for Ministries/Congregations, `Bookable = 1` for Rooms, `Active = 1` for SMS) must stay — UI relies on active-only lists. +- A new wizard field must go into **both** `STEP_FIELDS` and `GROUP_WRITABLE_FIELDS`; otherwise the allowlist test fails and the field is never written. ## Related docs - `query-patterns.md` — validation, escaping, FK traversal, `$userId` - `../components/group-wizard.md` — consuming UI and schema -- `user-service.md` — `getUserIdByGuid` for `$userId` resolution +- `../ministryplatform.datetimehandling.md` — `DomainTimezoneService` conversion rules +- `../security/README.md` — authorization gate and write attribution diff --git a/.claude/references/services/query-patterns.md b/.claude/references/services/query-patterns.md index eac666d..cff7650 100644 --- a/.claude/references/services/query-patterns.md +++ b/.claude/references/services/query-patterns.md @@ -2,25 +2,26 @@ title: MP query patterns for services domain: services type: reference -applies_to: [src/services/toolService.ts, src/services/userService.ts, src/services/addressLabelService.ts, src/services/groupService.ts, src/services/fieldManagementService.ts, src/lib/validation.ts] -symbols: [validateGuid, validatePositiveInt, validateColumnName, escapeFilterString] +applies_to: [src/services/toolService.ts, src/services/userService.ts, src/services/addressLabelService.ts, src/services/groupService.ts, src/services/fieldManagementService.ts, src/services/familyService.ts, src/services/authorizationService.ts, src/lib/validation.ts, src/lib/constants.ts] +symbols: [validateGuid, validatePositiveInt, validateColumnName, validateFkPath, escapeFilterString, MP_FETCH_BATCH_SIZE] related: [../mp-provider/architecture.md, ../mp-schema/required-procs.md, tool-service.md, user-service.md, address-label-service.md, group-service.md] -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose Conventions for building safe MP REST queries inside service methods — FK traversal via `_TABLE`, ambiguous-column prefixing, quote escaping, `$userId` audit, and batching. ## Files -- `src/lib/validation.ts` — `validateGuid`, `validatePositiveInt`, `validateColumnName`, `escapeFilterString` +- `src/lib/validation.ts` — `validateGuid`, `validatePositiveInt`, `validateColumnName`, `validateFkPath`, `escapeFilterString` (also `validateMailerId`, not a filter guard) +- `src/lib/constants.ts` — `MP_FETCH_BATCH_SIZE` (100) - `src/services/*.ts` — all call sites follow these rules ## Key concepts - **Implicit joins.** MP's REST API auto-joins tables named via `_TABLE` in `$select`. If a column appears in more than one joined table, the API returns an **ambiguous column** error. - **`_TABLE` FK traversal.** Suffix an FK column with `_TABLE` then dot-access a field on the target table. Chain `_TABLE_` between levels; use the dot **only** before the final field. -- **Quote-escape all user input.** `'` → `''` via `escapeFilterString()` (also `%` → `[%]`, `_` → `[_]` for LIKE). -- **Typed ID validators.** `validateGuid`, `validatePositiveInt`, `validateColumnName` throw before a value reaches any filter string. -- **`$userId` audit.** Passing `$userId` to `createTableRecords` / `updateTableRecords` stamps MP audit columns with that MP User ID (not the Better Auth session user ID). +- **Quote-escape all user input.** `'` → `''` via `escapeFilterString()` (also `[` → `[[]`, `%` → `[%]`, `_` → `[_]` for LIKE; quote look-alikes → `_`). +- **Typed ID validators.** `validateGuid`, `validatePositiveInt`, `validateColumnName`, `validateFkPath` throw before a value reaches any filter/select string. Errors name the field, never the value (CLAUDE.md rule 14). +- **`$userId` audit.** Passing `$userId` to `createTableRecords` / `updateTableRecords` / `executeProcedureWithBody` stamps the MP audit log with that MP `User_ID`. Its only source is `AuthorizationService.requireSecurityRole()`'s return value, taken inside the service (CLAUDE.md rule 13). ## FK traversal @@ -59,7 +60,7 @@ const records = await this.mp!.getTableRecords<MPUserProfile>({ ### Multi-level FK traversal — `AddressLabelService` ```typescript -// src/services/addressLabelService.ts:23-38 +// src/services/addressLabelService.ts:24-39 const SELECT_FIELDS = [ 'Contact_ID', 'Display_Name', @@ -91,19 +92,22 @@ const records = await mp.getTableRecords<{ Role_Name: string }>({ ## Quote escaping (SQL-injection prevention) ```typescript -// src/lib/validation.ts +// src/lib/validation.ts:127-144 (abridged) export function escapeFilterString(value: string): string { + // throws on non-string, length > MAX_SEARCH_TERM_LENGTH (100), or ASCII control chars return value - .replace(/'/g, "''") // escape single quotes (SQL-literal escape) + .replace(/\[/g, '[[]') // FIRST — `[` opens a LIKE character class .replace(/%/g, '[%]') // escape LIKE wildcard - .replace(/_/g, '[_]'); // escape LIKE any-single-char wildcard + .replace(/_/g, '[_]') // escape LIKE any-single-char wildcard + .replace(/'/g, "''") // escape single quotes (SQL-literal escape) + .replace(QUOTE_LOOKALIKES, '_'); // curly/fullwidth quotes → single-char wildcard } ``` Used where? ```typescript -// src/services/groupService.ts:151-160 +// src/services/groupService.ts:241-255 (gate call omitted) async searchContacts(term: string): Promise<ContactSearchResult[]> { const escaped = escapeFilterString(term); return this.mp!.getTableRecords<ContactSearchResult>({ @@ -116,75 +120,72 @@ async searchContacts(term: string): Promise<ContactSearchResult[]> { } ``` -Inline escape (no `%`/`_` wildcards): `ToolService.listRoles` +Contains-match: `ToolService.listRoles` ```typescript -// src/services/toolService.ts:226-239 +// src/services/toolService.ts:252-255 const filter = term - ? `Role_Name LIKE '%${term.replace(/'/g, "''")}%'` + ? `Role_Name LIKE '%${escapeFilterString(term)}%'` : undefined; ``` +Also used by `FamilyService.searchContacts` (`src/services/familyService.ts:116`). + ## Typed validators — fail fast before building a filter ```typescript // src/lib/validation.ts -export function validateGuid(value: string): string { /* /^[0-9a-f]{8}-.../ throws on fail */ } -export function validatePositiveInt(value: number): number { /* throws on 0, negative, non-int */ } +export function validateGuid(value: string): string { /* /^[0-9a-f]{8}-.../i throws on fail */ } +export function validatePositiveInt(value: number, field?: string): number { /* throws on non-number, 0, negative, non-safe-integer */ } export function validateColumnName(value: string): string { /* throws if ≠ /^[A-Za-z_][A-Za-z0-9_]*$/ */ } +export function validateFkPath(value: unknown, field = 'column path'): string { /* `X_TABLE.Col` / `A_TABLE_B_TABLE.Col`; ≤4 FK segments, ≤256 chars; no spaces, commas, AS */ } ``` | Where | Call | Purpose | |-------|------|---------| | `userService.ts` (`getUserProfile`), `authorizationService.ts` (`resolveUserId`) | `validateGuid(...)` | Guard `User_GUID = '...'` filter | -| `toolService.ts:307-334` | `validateColumnName`, `validateFkPath`, `validatePositiveInt` | Guard `resolveContactIds` — rejects bad page-metadata table/PK/contact-id-field and record-ID values | -| `addressLabelService.ts:76` | `batch.forEach(validatePositiveInt)` | Guard batched `Contact_ID IN (...)` | -| `groupService.ts:176` | `validatePositiveInt(groupId)` | Guard `Group_ID = ${id}` in `getGroup` | +| `toolService.ts:335-371` | `validateColumnName`, `validateFkPath`, `validatePositiveInt` | Guard `resolveContactIds` — rejects bad page-metadata table/PK/contact-id-field and record-ID values | +| `familyService.ts:167-174` | `validateColumnName`, `validateFkPath`, `validatePositiveInt` | Guard `resolveContactIdFromPage` (same page-metadata shape) | +| `addressLabelService.ts:83`, `:107` | `validatePositiveInt` | Guard batched `Contact_ID IN (...)` and single `Contact_ID = ${id}` | +| `groupService.ts:289`, `:390` | `validatePositiveInt(groupId)` | Guard `Groups.Group_ID = ${id}` in `getGroup`; `Group_ID` in `updateGroup` | ## `$userId` for MP audit trail -`createTableRecords` / `updateTableRecords` accept a `$userId` option — MP records it as the audit user for the created/updated row. Pass the numeric MP `User_ID` (not `User_GUID`, not `session.user.id`). +`createTableRecords` / `updateTableRecords` (and `executeProcedureWithBody` via its third `queryParams` argument) accept a `$userId` option — MP records it as the audit user. It must be the numeric MP `User_ID` (not `User_GUID`, not `session.user.id`), and it comes **only** from the gate's return value inside the service — never a method parameter, never an action payload. ```typescript -// src/services/groupService.ts:227-237 -async createGroup(data, userId) { - const apiData = prepareForApi(data); - const result = await this.mp!.createTableRecords('Groups', [apiData], { - $select: 'Group_ID, Group_Name', - $userId: userId, +// src/services/groupService.ts:365-379 +async createGroup(data: GroupWizardFormData) { + // Gate FIRST; its return value is the ONLY source of write attribution. + const $userId = await AuthorizationService.getInstance().requireSecurityRole({ + table: 'Groups', + operation: 'create', }); - return result[0] as unknown as { Group_ID: number; Group_Name: string }; -} - -// src/services/groupService.ts:239-251 -async updateGroup(groupId, data, userId) { - const apiData = { Group_ID: groupId, ...prepareForApi(data as GroupWizardFormData) }; - const result = await this.mp!.updateTableRecords('Groups', [apiData], { - partial: true, + const apiData = await prepareForApi(data); + const result = await this.mp!.createTableRecords('Groups', [apiData], { $select: 'Group_ID, Group_Name', - $userId: userId, + $userId, }); return result[0] as unknown as { Group_ID: number; Group_Name: string }; } ``` -To resolve the numeric MP User ID from the session's `User_GUID`, call `UserService.getUserIdByGuid(userGuid)`. +Same pattern: `GroupService.updateGroup`, `FamilyService.saveHousehold` (one gate; `userId` threaded through every address/household/contact/participant/donor write), `FieldManagementService.updatePageFieldOrder` and `ToolService.deployTool` (SP calls with `{ $userId }`). `User_GUID` → `User_ID` resolution lives in `resolveUserId` inside `src/services/authorizationService.ts`. ## Batching guards Guard empty arrays, batch large IN-lists to ≤100 IDs per request: ```typescript -// src/services/addressLabelService.ts:21, 69-90 -const BATCH_SIZE = 100; - +// src/services/addressLabelService.ts:70-97 (gate call omitted) +// MP_FETCH_BATCH_SIZE = 100 — src/lib/constants.ts async getAddressesForContacts(contactIds: number[]): Promise<ContactAddressRow[]> { if (contactIds.length === 0) return []; const results: ContactAddressRow[] = []; - for (let i = 0; i < contactIds.length; i += BATCH_SIZE) { - const batch = contactIds.slice(i, i + BATCH_SIZE); - batch.forEach(validatePositiveInt); + for (let i = 0; i < contactIds.length; i += MP_FETCH_BATCH_SIZE) { + const batch = contactIds.slice(i, i + MP_FETCH_BATCH_SIZE); + batch.forEach((id) => validatePositiveInt(id)); const idList = batch.join(', '); const rows = await this.mp!.getTableRecords<ContactAddressRow>({ @@ -201,7 +202,7 @@ async getAddressesForContacts(contactIds: number[]): Promise<ContactAddressRow[] } ``` -`ToolService.resolveContactIds` uses the same pattern with `BATCH_SIZE = 100` at `src/services/toolService.ts:283, 332-339`. +`ToolService.resolveContactIds` uses the same `MP_FETCH_BATCH_SIZE` loop at `src/services/toolService.ts:369-384`. ## Filtering quick reference @@ -231,7 +232,7 @@ filter: "Group_ID = 5 AND Group_Role_ID = 7 AND End_Date IS NULL" |--------|-------|-------|----------------| | `State/Region` | Addresses | `/` | Bracket: `[State/Region]` in select; `'State/Region'` as TS key | | `Allow_Check-in` | Events | `-` | Bracket in select; `obj["Allow_Check-in"]` in TS | -| `Secure_Check-in` | Groups | `-` | `r['Secure_Check-in']` in TS (see `groupService.ts:214`) | +| `Secure_Check-in` | Groups | `-` | `r['Secure_Check-in']` in TS (see `groupService.ts:327`) | ## MPHelper parameter mapping @@ -254,11 +255,12 @@ filter: "Group_ID = 5 AND Group_Role_ID = 7 AND End_Date IS NULL" ## Gotchas - Forgetting to prefix an ambiguous column in `$select` or `$filter` → MP returns "Ambiguous column name" 500. Rule of thumb: any time you use `_TABLE` traversal AND the base table shares a column name with the related table, prefix the base-table column. - Using dots between FK levels (`Building_ID_TABLE.Location_ID_TABLE.Column`) — invalid; use `_TABLE_` underscores. -- Passing Better Auth `session.user.id` as `$userId` — it's a Better Auth internal ID, not the MP `User_ID`. Resolve via `UserService.getUserIdByGuid(session.user.userGuid)` first. -- Building a filter from unescaped user input → risk of broken queries and injection. Always `escapeFilterString()` or `term.replace(/'/g, "''")`. +- Taking `$userId` from anywhere but `requireSecurityRole()`'s return value (a method parameter, the action payload, `session.user.id`) — `session.user.id` is a Better Auth internal ID, not the MP `User_ID`, and a caller-supplied value is forgeable. +- Building a filter from unescaped user input → risk of broken queries and injection. Always `escapeFilterString()` for strings, a typed validator for IDs/column names. ## Related docs - `../mp-provider/architecture.md` — `MPHelper` facade (what services call) - `../mp-schema/required-procs.md` — stored procs called from services - `../auth/sessions.md` — `userGuid` vs `user.id` -- `tool-service.md`, `user-service.md`, `address-label-service.md`, `group-service.md`, `field-management-service.md` +- `tool-service.md`, `user-service.md`, `address-label-service.md`, `group-service.md`, `field-management-service.md`, `family-service.md` +- `../security/README.md` — authorization gate and write attribution diff --git a/.claude/references/services/tool-service.md b/.claude/references/services/tool-service.md index 28f61aa..5ad5186 100644 --- a/.claude/references/services/tool-service.md +++ b/.claude/references/services/tool-service.md @@ -5,7 +5,7 @@ type: reference applies_to: [src/services/toolService.ts, src/services/toolService.test.ts] symbols: [ToolService, ContactRecord, ContactRecordResult, PageLookup, RoleLookup, DeployToolInput, DeployToolResult] related: [query-patterns.md, ../mp-schema/required-procs.md, ../components/dev-panel.md] -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose @@ -13,11 +13,24 @@ Resolves MP page metadata, user tool authorization, selection record IDs, and co ## Files - `src/services/toolService.ts` — singleton implementation -- `src/services/toolService.test.ts` — 548 lines; covers every public method including error paths, batching, and `deployTool` payload shaping +- `src/services/toolService.test.ts` — 672 lines; covers every public method including error paths, batching, authorization gating, and `deployTool` payload shaping + +## Authorization +Every public method calls `AuthorizationService.getInstance().requireSecurityRole({ table, operation })` before touching MP (see `../security/README.md`). The returned `User_ID` is the acting user for `getSelectionRecordIds` / `getUserTools` and the `$userId` for `deployTool` — callers never pass a user ID. + +| Method | Gate `table` / `operation` | +|--------|---------------------------| +| `getPageData` | `dp_Pages` / `read` | +| `getSelectionRecordIds` | `dp_Selections` / `read` | +| `getUserTools` | `dp_Tools` / `read` | +| `listPages` | `dp_Pages` / `read` | +| `listRoles` | `dp_Roles` / `read` | +| `deployTool` | `dp_Tools` / `create` | +| `resolveContactIds` | `Contacts` / `read` (plus `dp_Pages` via `getPageData`) | ## Singleton ```typescript -// src/services/toolService.ts:81-115 +// src/services/toolService.ts:84-118 export class ToolService { private static instance: ToolService; private mp: MPHelper | null = null; @@ -38,8 +51,8 @@ export class ToolService { | Method | Returns | Calls | |--------|---------|-------| | `getPageData(pageID: number)` | `PageData \| null` | SP `api_Tools_GetPageData` | -| `getSelectionRecordIds(selectionId, userId, pageId)` | `number[]` | SP `api_Common_GetSelection` | -| `getUserTools(userId: number)` | `string[]` (tool paths) | SP `api_Tools_GetUserTools` | +| `getSelectionRecordIds(selectionId, pageId)` | `number[]` | SP `api_Common_GetSelection` (`@UserID` from the gate) | +| `getUserTools()` | `string[]` (tool paths) | SP `api_Tools_GetUserTools` (`@UserId` from the gate) | | `resolveContactIds(pageId, recordIds)` | `ContactRecordResult \| null` | `getPageData(pageId)`, then `getTableRecords` on the page's table in batches of 100 | | `listPages(search?: string)` | `PageLookup[]` (max 100) | SP `api_MPNextTools_GetPages`, filtered in-memory | | `listRoles(search?: string)` | `RoleLookup[]` (max 100) | `getTableRecords('dp_Roles')` | @@ -48,7 +61,7 @@ export class ToolService { ## getPageData ```typescript -// src/services/toolService.ts:123-141 +// src/services/toolService.ts:126-148 const result = await this.mp!.executeProcedureWithBody('api_Tools_GetPageData', { "@PageID": pageID }); @@ -65,7 +78,11 @@ return null; ## getSelectionRecordIds ```typescript -// src/services/toolService.ts:152-174 +// src/services/toolService.ts:162-188 +const userId = await AuthorizationService.getInstance().requireSecurityRole({ + table: 'dp_Selections', + operation: 'read', +}); const result = await this.mp!.executeProcedureWithBody('api_Common_GetSelection', { '@SelectionID': selectionId, '@UserID': userId, @@ -83,11 +100,16 @@ return []; ``` - Some procs return metadata in set 0 and records later; the scan picks whichever set contains `Record_ID`. +- `@UserID` is the gate's return value, never a caller argument: a selection belongs to a specific MP user, so accepting a user ID from the payload would let any caller read someone else's selection. ## getUserTools ```typescript -// src/services/toolService.ts:183-198 +// src/services/toolService.ts:196-215 +const userId = await AuthorizationService.getInstance().requireSecurityRole({ + table: 'dp_Tools', + operation: 'read', +}); const result = await this.mp!.executeProcedureWithBody('api_Tools_GetUserTools', { "@UserId": userId }); @@ -97,7 +119,7 @@ if (result && result.length > 0 && result[0].length > 0) { return []; ``` -- Caller must already have resolved numeric `User_ID` from `session.user.userGuid` (typically via `UserService.getUserIdByGuid`) +- Takes no arguments: the numeric `User_ID` comes from `requireSecurityRole`, which resolves `session.user.userGuid` → `dp_Users.User_ID` ## resolveContactIds @@ -131,7 +153,7 @@ public async resolveContactIds( **Return type:** ```typescript -// src/services/toolService.ts:5-15 +// src/services/toolService.ts:8-18 export interface ContactRecord { recordId: number; contactId: number; } export interface ContactRecordResult { tableName: string; @@ -144,8 +166,9 @@ export interface ContactRecordResult { ## listPages / listRoles (deploy-tool pickers) ```typescript -// src/services/toolService.ts:206-220 +// src/services/toolService.ts:223-241 public async listPages(search?: string): Promise<PageLookup[]> { + await AuthorizationService.getInstance().requireSecurityRole({ table: 'dp_Pages', operation: 'read' }); const result = await this.mp!.executeProcedureWithBody('api_MPNextTools_GetPages', {}); const rows = (result?.[0] as PageLookup[] | undefined) ?? []; const term = search?.trim().toLowerCase(); @@ -160,11 +183,12 @@ public async listPages(search?: string): Promise<PageLookup[]> { - Reuses `api_MPNextTools_GetPages` (also used by `FieldManagementService.getPages`); SP has no search param so filter happens in-memory. ```typescript -// src/services/toolService.ts:226-239 +// src/services/toolService.ts:247-264 public async listRoles(search?: string): Promise<RoleLookup[]> { + await AuthorizationService.getInstance().requireSecurityRole({ table: 'dp_Roles', operation: 'read' }); const term = search?.trim(); const filter = term - ? `Role_Name LIKE '%${term.replace(/'/g, "''")}%'` + ? `Role_Name LIKE '%${escapeFilterString(term)}%'` : undefined; return this.mp!.getTableRecords<RoleLookup>({ table: 'dp_Roles', @@ -175,13 +199,17 @@ public async listRoles(search?: string): Promise<RoleLookup[]> { }); } ``` -- Inline single-quote escape (no `%`/`_` allowed in the wildcard position since the wildcards are at the outer `'%{term}%'`). +- Escaped with `escapeFilterString` (`src/lib/validation.ts`): bracket-escapes `[`, `%`, `_` and doubles `'`, so user input matches literally inside the outer `'%…%'`. ## deployTool (localhost-only dev feature) ```typescript -// src/services/toolService.ts:247-281 +// src/services/toolService.ts:272-310 public async deployTool(input: DeployToolInput): Promise<DeployToolResult> { + const $userId = await AuthorizationService.getInstance().requireSecurityRole({ + table: 'dp_Tools', + operation: 'create', + }); // Pre-flight validation if (!input.toolName.trim()) throw new Error('Tool Name is required'); if (!input.launchPage.trim()) throw new Error('Launch Page is required'); @@ -203,7 +231,7 @@ public async deployTool(input: DeployToolInput): Promise<DeployToolResult> { '@RoleIDs': input.roleIds.length ? input.roleIds.join(',') : null, }; - const resultSets = await this.mp!.executeProcedureWithBody('api_dev_DeployTool', payload); + const resultSets = await this.mp!.executeProcedureWithBody('api_dev_DeployTool', payload, { $userId }); const [toolRows, pageRows, roleRows] = resultSets ?? []; const tool = (toolRows?.[0] as DeployedToolRow | undefined); if (!tool) { @@ -220,6 +248,7 @@ public async deployTool(input: DeployToolInput): Promise<DeployToolResult> { - SP name prefix `api_dev_` → MP provider switches to `MINISTRY_PLATFORM_DEV_*` client credentials - Returns 3 result sets: tool row, page rows, role rows - Booleans encoded as `1`/`0` (BIT), empty arrays as `null` +- `$userId` (audit attribution) comes only from the gate's return value — `deployToolAction` passes no user ID ## Stored procedures called @@ -235,10 +264,13 @@ public async deployTool(input: DeployToolInput): Promise<DeployToolResult> { | Action | File | Method(s) used | |--------|------|----------------| -| `fetchAddressLabels` | `src/components/address-labels/actions.ts` | `getPageData`, `getSelectionRecordIds` | +| `fetchAddressLabels` | `src/components/address-labels/actions.ts` | `getSelectionRecordIds` | +| `parseToolParams` | `src/lib/tool-params.server.ts` | `getPageData` (dynamic import) | +| `resolveContactIdFromPage` | `src/app/(web)/tools/addeditfamily/actions.ts` | `getPageData` | | `resolveSelection` | `src/components/dev-panel/panels/selection-actions.ts` | `getSelectionRecordIds` | | `getUserTools` | `src/components/dev-panel/panels/user-tools-actions.ts` | `getUserTools` | -| Deploy tool panel | `src/components/dev-panel/panels/deploy-tool-actions.ts` | `listPages`, `listRoles`, `deployTool` | +| `resolveContactRecords` | `src/components/dev-panel/panels/contact-records-actions.ts` | `resolveContactIds` | +| `listPagesAction`, `listRolesAction`, `deployToolAction` | `src/components/dev-panel/panels/deploy-tool-actions.ts` | `listPages`, `listRoles`, `deployTool` | ## Related docs - `query-patterns.md` — shared query rules (validation, batching, quote escaping) diff --git a/.claude/references/services/user-service.md b/.claude/references/services/user-service.md index a7eb01c..ed2495c 100644 --- a/.claude/references/services/user-service.md +++ b/.claude/references/services/user-service.md @@ -5,7 +5,7 @@ type: reference applies_to: [src/services/userService.ts, src/services/userService.test.ts] symbols: [UserService, MPUserProfile] related: [query-patterns.md, ../auth/sessions.md, ../components/layout.md] -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose @@ -89,10 +89,17 @@ Deliberately minimal (client-bound DTO). Formerly also carried `User_ID`, `User_ > Pass `session.user.userGuid`, **not** `session.user.id`. Better Auth's `user.id` is the internal auth record ID, not the MP `User_GUID`. ```typescript -// src/components/shared-actions/user.ts — correct usage -const profile = await UserService.getInstance().then(s => s.getUserProfile(session.user.userGuid)); +// src/components/shared-actions/user.ts — getCurrentUserProfile (takes no arguments) +const session = await auth.api.getSession({ headers: await headers() }); +const userGuid = (session?.user as Record<string, unknown> | undefined)?.userGuid; +if (typeof userGuid !== 'string' || userGuid.length === 0) throw new Error('Unauthorized'); + +const userService = await UserService.getInstance(); +return userService.getUserProfile(userGuid); ``` +The GUID comes from the session only — never an action parameter (a `userGuid` argument was an IDOR). + See `../auth/sessions.md` for `userGuid` vs `user.id`. ## Consumers @@ -102,7 +109,7 @@ See `../auth/sessions.md` for `userGuid` vs `user.id`. | `getCurrentUserProfile` | `src/components/shared-actions/user.ts` | `getUserProfile` | ## Gotchas -- `getUserProfile` does NOT re-check the session; caller is responsible for `auth.api.getSession()`. +- `getUserProfile` does NOT re-check the session and has **no** `AuthorizationService` gate — the only service method without one. Its sole caller, `getCurrentUserProfile`, is a documented CLAUDE.md rule-12 carve-out (own profile only). Any new caller must gate itself. - If `dp_Users` has 0 rows for the GUID, `getUserProfile` returns `undefined` (not throw). - The `select` string has no spaces after some commas (`...Nickname,Contact_ID_TABLE...`) — MP REST accepts this; do not "fix" unless the API starts rejecting it. diff --git a/.claude/references/testing/README.md b/.claude/references/testing/README.md index 0a92324..514d250 100644 --- a/.claude/references/testing/README.md +++ b/.claude/references/testing/README.md @@ -5,21 +5,22 @@ domain: testing --- ## What's in this domain -Vitest test runner config, global setup, mocking patterns, and inventory of 116 test files (1,535 cases) co-located next to source. Coverage over authored code is 98.84% statements / 99.70% lines, enforced by thresholds in `vitest.config.mts`. +Vitest test runner config, global setup, mocking patterns, and inventory of 120 test files (2,237 cases) co-located next to source. Coverage over authored code is 99.13% statements / 99.80% lines (2026-10-10), enforced by thresholds (97 / 98) in `vitest.config.mts`. ## File map | File | Purpose | When to read | |------|---------|--------------| | `setup.md` | `vitest.config.mts`, `src/test-setup.ts`, env vars, jsdom, v8 coverage, the `coverage.include` contract and enforced thresholds | Adding Vitest config or adjusting global setup | -| `mocks.md` | Mandatory mock patterns (`vi.hoisted`, MPHelper mock class, singleton reset, auth/headers, fake timers) | Writing any new test that mocks an import | +| `mocks.md` | Mandatory mock patterns (`vi.hoisted`, MPHelper mock class, singleton reset, `AuthorizationService` gate, auth/headers, fake timers) | Writing any new test that mocks an import | | `cookbook.md` | Copy-paste recipes pulled from real tests (service, server action, component, OAuth) | Writing a new test in an existing category | -| `inventory.md` | All 116 test files grouped by area with one-liner coverage | Locating where a surface is tested | +| `inventory.md` | All 120 test files grouped by area | Locating where a surface is tested | ## Code surfaces | Path | Role | |------|------| | `vitest.config.mts` | Runner config (jsdom, globals, v8 coverage + thresholds, `@` alias) | -| `src/test-setup.ts` | Env stubs + `@testing-library/jest-dom` | +| `src/test-setup.ts` | Env stubs + `ResizeObserver` stub + `@testing-library/jest-dom/vitest` | +| `src/test-utils/mock-oidc.ts` | Test-only mock MP OIDC provider for `src/auth.*.test.ts` | | `src/**/*.test.ts` | Unit tests (services, lib, auth, proxy) | | `src/**/*.test.tsx` | React component/hook tests | diff --git a/.claude/references/testing/cookbook.md b/.claude/references/testing/cookbook.md index 356a673..df0d1fc 100644 --- a/.claude/references/testing/cookbook.md +++ b/.claude/references/testing/cookbook.md @@ -5,7 +5,7 @@ type: reference applies_to: [src/**/*.test.ts, src/**/*.test.tsx] symbols: [ToolService, fetchAddressLabels, DevPanel, MinistryPlatformClient] related: [mocks.md, setup.md] -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose @@ -13,7 +13,7 @@ Copy-paste starting points for the four test flavors in this codebase. Each reci ## Recipe 1 — Service test (wraps MPHelper) -**Source:** `src/services/toolService.test.ts` +**Source:** `src/services/toolService.test.ts` (abridged — docstring and other cases elided) ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -33,29 +33,45 @@ vi.mock('@/lib/providers/ministry-platform', () => { }; }); +const { mockRequireSecurityRole, mockHasSecurityRole } = vi.hoisted(() => ({ + mockRequireSecurityRole: vi.fn(async () => 42), + mockHasSecurityRole: vi.fn(async () => true), +})); + +vi.mock('@/services/authorizationService', () => ({ + AuthorizationService: { + getInstance: () => ({ + requireSecurityRole: mockRequireSecurityRole, + hasSecurityRole: mockHasSecurityRole, + }), + }, + UnauthorizedError: class UnauthorizedError extends Error {}, +})); + describe('ToolService', () => { beforeEach(() => { vi.clearAllMocks(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any (ToolService as any).instance = undefined; }); - it('should call executeProcedureWithBody with correct params', async () => { - const mockPageData = { - Page_ID: 292, - Display_Name: 'Contacts', - Table_Name: 'Contacts', - Primary_Key: 'Contact_ID', - }; - mockExecuteProcedureWithBody.mockResolvedValueOnce([[mockPageData]]); - - const service = await ToolService.getInstance(); - const result = await service.getPageData(292); - - expect(mockExecuteProcedureWithBody).toHaveBeenCalledWith('api_Tools_GetPageData', { - '@PageID': 292, + describe('getPageData', () => { + it('should call executeProcedureWithBody with correct params', async () => { + const mockPageData = { + Page_ID: 292, + Display_Name: 'Contacts', + Table_Name: 'Contacts', + Primary_Key: 'Contact_ID', + }; + mockExecuteProcedureWithBody.mockResolvedValueOnce([[mockPageData]]); + + const service = await ToolService.getInstance(); + const result = await service.getPageData(292); + + expect(mockExecuteProcedureWithBody).toHaveBeenCalledWith('api_Tools_GetPageData', { + '@PageID': 292, + }); + expect(result).toEqual(mockPageData); }); - expect(result).toEqual(mockPageData); }); }); ``` @@ -63,12 +79,13 @@ describe('ToolService', () => { Checklist: - `vi.hoisted()` mock fns - Mock `MPHelper` as class with method fields +- Mock the `AuthorizationService` gate (services gate every MP call); its return value is the `$userId` the service must use — see `mocks.md` - Reset singleton in `beforeEach` - Mock return shape matches real MP API (SP calls return `[[row, ...]]` — array of result sets) -## Recipe 2 — Server action test (auth + services + external libs) +## Recipe 2 — Server action test (gate + services + external libs) -**Source:** `src/components/address-labels/actions.test.ts` +**Source:** `src/components/address-labels/actions.test.ts` (abridged — PDF/DOCX/zip/barcode lib mocks and most cases elided) ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -77,28 +94,41 @@ const mockGetSession = vi.hoisted(() => vi.fn()); const mockGetSelectionRecordIds = vi.hoisted(() => vi.fn()); const mockGetAddressesForContacts = vi.hoisted(() => vi.fn()); const mockGetAddressForContact = vi.hoisted(() => vi.fn()); -const mockGetUserIdByGuid = vi.hoisted(() => vi.fn()); +const mockRequireSecurityRole = vi.hoisted(() => vi.fn()); vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: mockGetSession } }, + auth: { + api: { + getSession: mockGetSession, + }, + }, })); vi.mock('next/headers', () => ({ headers: vi.fn().mockResolvedValue(new Headers()), })); -vi.mock('@/services/toolService', () => ({ - ToolService: { - getInstance: vi.fn().mockResolvedValue({ - getSelectionRecordIds: mockGetSelectionRecordIds, +vi.mock('@/services/authorizationService', () => ({ + AuthorizationService: { + getInstance: () => ({ + requireSecurityRole: mockRequireSecurityRole, + hasSecurityRole: async () => { + try { + await mockRequireSecurityRole(); + return true; + } catch { + return false; + } + }, }), }, + UnauthorizedError: class UnauthorizedError extends Error {}, })); -vi.mock('@/services/userService', () => ({ - UserService: { +vi.mock('@/services/toolService', () => ({ + ToolService: { getInstance: vi.fn().mockResolvedValue({ - getUserIdByGuid: mockGetUserIdByGuid, + getSelectionRecordIds: mockGetSelectionRecordIds, }), }, })); @@ -112,16 +142,25 @@ vi.mock('@/services/addressLabelService', () => ({ }, })); +// ... @react-pdf/renderer, docxtemplater, pizzip, zip-guard, barcode mocks ... + import { fetchAddressLabels } from './actions'; -import type { LabelConfig, LabelData } from '@/lib/dto'; +import type { LabelConfig } from '@/lib/dto'; import type { ToolParams } from '@/lib/tool-params'; describe('fetchAddressLabels', () => { + const defaultConfig: LabelConfig = { /* stockId '5160', household, postnet, ... */ }; + beforeEach(() => { - mockGetSession.mockResolvedValue({ - user: { id: 'user-1', userGuid: '550e8400-e29b-41d4-a716-446655440000' } + mockGetSession.mockResolvedValue({ user: { id: 'user-1', userGuid: '550e8400-e29b-41d4-a716-446655440000' } }); + mockRequireSecurityRole.mockReset(); + mockRequireSecurityRole.mockImplementation(async () => { + const session = await mockGetSession(); + if (!session?.user?.id) throw new Error('Unauthorized'); + return 42; }); - mockGetUserIdByGuid.mockResolvedValue(42); + mockGetSelectionRecordIds.mockReset(); + mockGetAddressesForContacts.mockReset(); mockGetAddressForContact.mockReset(); }); @@ -134,14 +173,15 @@ describe('fetchAddressLabels', () => { ``` Checklist: -- `@/lib/auth` + `next/headers` always mocked together -- Every service the action touches gets its own `getInstance` mock -- External libs (`@react-pdf/renderer`, `docxtemplater`, etc.) stubbed with class/POJO depending on how the action instantiates them +- Mock the `AuthorizationService` gate — feature actions call `requireSecurityRole`, not `auth.api.getSession` directly. Here the gate mock is driven by `mockGetSession` so session-shape tests keep their meaning; override with `mockRequireSecurityRole.mockRejectedValueOnce(...)` to test a refusal +- `@/lib/auth` + `next/headers` mocked together +- Every service the action touches gets its own `getInstance` mock (async services → `mockResolvedValue`) +- External libs (`@react-pdf/renderer`, `docxtemplater`, `pizzip`, etc.) stubbed with class/POJO depending on how the action instantiates them - Session fixture uses realistic GUID so validation passes ## Recipe 3 — React component test (localhost/env gating) -**Source:** `src/components/dev-panel/dev-panel.test.tsx` +**Source:** `src/components/dev-panel/dev-panel.test.tsx` (abridged) ```typescript import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; @@ -206,7 +246,7 @@ Checklist: ## Recipe 4 — OAuth flow test (fake timers) -**Source:** `src/lib/providers/ministry-platform/client.test.ts` +**Source:** `src/lib/providers/ministry-platform/client.test.ts` (abridged — console-silencing `beforeEach` and other cases elided) ```typescript import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; diff --git a/.claude/references/testing/inventory.md b/.claude/references/testing/inventory.md index 4ee0c33..9b4007a 100644 --- a/.claude/references/testing/inventory.md +++ b/.claude/references/testing/inventory.md @@ -5,102 +5,213 @@ type: reference applies_to: [src/**/*.test.ts, src/**/*.test.tsx] symbols: [] related: [setup.md, cookbook.md] -last_verified: 2026-09-13 +last_verified: 2026-10-10 --- ## Purpose -Test files grouped by area. Totals from the current facts snapshot: -**116 files / 1,535 test cases** (`vitest run`, see -`../_meta/facts/2026-09-13.md`). - -> The per-file tables below were written at 37 files and have NOT been expanded -> to all 116. They remain accurate for the files they list. For anything not -> listed, the co-location rule is reliable: a source file `foo.ts` is tested by -> `foo.test.ts` beside it. Counts by area: -> -> | Area | Files | -> |---|---| -> | `src/app/**` | 23 | -> | `src/components/group-wizard` | 12 | -> | `src/lib/providers/ministry-platform` | 12 | -> | `src/lib/**` (excl. MP provider) | 12 | -> | `src/components/dev-panel` | 11 | -> | `src/components/address-labels` | 10 | -> | `src/components/template-editor` | 10 | -> | `src/services` | 9 | -> | `src/components/field-management` | 7 | -> | `src/components/*` (layout, shared-actions, tool, user-menu) | 6 | -> | `src/contexts` | 2 | -> | `src/` (proxy, auth) | 2 | - -## Ministry Platform provider (12 files) - -| Test File | What It Covers | -|---|---| -| `src/lib/providers/ministry-platform/helper.test.ts` | MPHelper CRUD, Zod validation, procedures, files, copy/recurrence/generateSequence | -| `src/lib/providers/ministry-platform/provider.test.ts` | Singleton, service delegation, generateSequence with optional fields | -| `src/lib/providers/ministry-platform/client.test.ts` | OAuth token lifecycle, refresh, concurrent calls (uses `vi.useFakeTimers`) | -| `src/lib/providers/ministry-platform/utils/http-client.test.ts` | HTTP methods, URL building, FormData, error handling | -| `src/lib/providers/ministry-platform/utils/logger.test.ts` | Debug vs production gating, error always logs | -| `src/lib/providers/ministry-platform/auth/client-credentials.test.ts` | OAuth2 `client_credentials` token flow | -| `src/lib/providers/ministry-platform/services/table.service.test.ts` | CRUD + `copyRecord` + `copyRecordWithSubpages` | -| `src/lib/providers/ministry-platform/services/communication.service.test.ts` | Communications and messages with/without attachments | -| `src/lib/providers/ministry-platform/services/domain.service.test.ts` | Domain info, global filters | -| `src/lib/providers/ministry-platform/services/metadata.service.test.ts` | Metadata refresh, tables search | -| `src/lib/providers/ministry-platform/services/procedure.service.test.ts` | Procedure listing + query/body execution with URL encoding | -| `src/lib/providers/ministry-platform/services/file.service.test.ts` | File CRUD, FormData building, unauthenticated blob retrieval | - -## Services (5 files) - -| Test File | What It Covers | -|---|---| -| `src/services/toolService.test.ts` | Page data, user tools, selection records, contact resolution, `listPages`, `listRoles`, `deployTool` | -| `src/services/groupService.test.ts` | Group CRUD and membership | -| `src/services/fieldManagementService.test.ts` | Pages, fields, table metadata, batched order updates | -| `src/services/addressLabelService.test.ts` | Address fetching, batching, single contact | -| `src/services/userService.test.ts` | Profile with roles/groups, `getUserIdByGuid`, GUID validation | - -## Components (10 files) - -| Test File | What It Covers | -|---|---| -| `src/components/address-labels/actions.test.ts` | PDF, DOCX, and template merge actions + error branches | -| `src/components/address-labels/word-document.test.ts` | `buildWordDocument` page layout and barcode branches | -| `src/components/field-management/actions.test.ts` | `fetchPages` / `fetchPageFieldData` column merge / `savePageFieldOrder` | -| `src/components/dev-panel/dev-panel.test.tsx` | Localhost + `NODE_ENV` gating, localStorage persistence, open/close toggling | -| `src/components/dev-panel/panels/deploy-tool-actions.test.ts` | Deploy-tool server actions (authz, validation, SP payload shaping) | -| `src/components/dev-panel/panels/selection-actions.test.ts` | Selection resolution server actions | -| `src/components/dev-panel/panels/user-tools-actions.test.ts` | Authorization checks, session validation | -| `src/components/layout/auth-wrapper.test.tsx` | `AuthWrapper` render gating based on session | -| `src/components/shared-actions/sign-out.test.ts` | `handleSignOut`: session cleared first, MP-origin-only `id_token_hint`, end-session URL shape | -| `src/components/shared-actions/user.test.ts` | `getCurrentUserProfile` session-derived GUID lookup, `Unauthorized` on missing/empty `userGuid`, IDOR regression (caller-forged argument ignored), error propagation | - -## Core lib (8 files) - -| Test File | What It Covers | -|---|---| -| `src/lib/validation.test.ts` | GUID / positive int / column name / filter escaping | -| `src/lib/barcode-helpers.test.ts` | Barcode generation utilities | -| `src/lib/imb-encoder.test.ts` | USPS Intelligent Mail barcode encoding | -| `src/lib/postnet-encoder.test.ts` | POSTNET barcode encoding | -| `src/lib/label-stock.test.ts` | Label stock definitions and calculations | -| `src/lib/barcode-image.test.ts` | BMP rendering for IMb/POSTNET with JSON fallback | -| `src/auth.test.ts` | Name splitting, session structure, OAuth config | -| `src/proxy.test.ts` | Route protection, public paths, session, errors | - -## Contexts (2 files) - -| Test File | What It Covers | -|---|---| -| `src/contexts/user-context.test.tsx` | `UserProvider` lifecycle, profile loading, errors | -| `src/contexts/session-context.test.tsx` | `useAppSession` hook wrapper | - -## Totals -- Files in this inventory: 37 (12 provider + 5 services + 10 component + 8 lib + 2 contexts) +Every test file, grouped by area, with its case count from `vitest run` +(2026-10-11): **120 files / 2,237 test cases**. See +`../_meta/facts/2026-10-10.md`. Counts drift — trust a fresh `npx vitest run` +over this table. Co-location rule: `foo.ts` is tested by `foo.test.ts` beside it. +The "What It Covers" column is filled where it was verified; blank rows are +self-describing by file name. + +## Counts by area + +| Area | Files | Cases | +|---|---|---| +| `src/app/**` (pages, route handler, providers) | 22 | 406 | +| `src/` root (`auth.*`, `proxy`) | 11 | 250 | +| `src/components/address-labels` | 11 | 126 | +| `src/components/dev-panel` | 11 | 102 | +| `src/components/field-management` | 7 | 113 | +| `src/components/group-wizard` | 12 | 159 | +| `src/components/{layout,shared-actions,tool}` | 5 | 44 | +| `src/contexts` | 2 | 16 | +| `src/lib/providers/ministry-platform` | 16 | 521 | +| `src/lib/**` (excl. MP provider) | 14 | 255 | +| `src/services` | 9 | 245 | +| **Total** | **120** | **2,237** | + +## `src/app/**` (pages, route handler, providers) (22 files, 406 cases) + +| Test File | Cases | What It Covers | +|---|---|---| +| `src/app/(web)/error.test.tsx` | 12 | | +| `src/app/(web)/no-access/page.test.tsx` | 4 | `/no-access` page incl. the `handleSignOut` form | +| `src/app/(web)/page.test.tsx` | 12 | | +| `src/app/(web)/tools/addeditfamily/actions.test.ts` | 43 | | +| `src/app/(web)/tools/addeditfamily/add-edit-family.test.tsx` | 43 | | +| `src/app/(web)/tools/addeditfamily/page.test.tsx` | 10 | | +| `src/app/(web)/tools/addresslabels/address-labels.test.tsx` | 23 | | +| `src/app/(web)/tools/addresslabels/page.test.tsx` | 2 | | +| `src/app/(web)/tools/fieldmanagement/field-management.test.tsx` | 15 | | +| `src/app/(web)/tools/fieldmanagement/page.test.tsx` | 4 | | +| `src/app/(web)/tools/groupwizard/group-wizard.test.tsx` | 20 | | +| `src/app/(web)/tools/groupwizard/page.test.tsx` | 3 | | +| `src/app/(web)/tools/layout.test.tsx` | 5 | | +| `src/app/(web)/tools/require-tool-access.test.ts` | 9 | `requireToolAccess` page-level gate; asserts every tool page self-gates | +| `src/app/(web)/tools/template/page.test.tsx` | 4 | | +| `src/app/(web)/tools/template/template-tool.test.tsx` | 12 | | +| `src/app/api/auth/[...all]/route.test.ts` | 77 | | +| `src/app/auth-error/page.test.tsx` | 16 | | +| `src/app/global-error.test.tsx` | 8 | | +| `src/app/providers.test.tsx` | 3 | | +| `src/app/session-error/page.test.tsx` | 3 | | +| `src/app/signin/page.test.tsx` | 78 | | + +## `src/` root (`auth.*`, `proxy`) (11 files, 250 cases) + +| Test File | Cases | What It Covers | +|---|---|---| +| `src/auth.id-token-sign-in.test.ts` | 16 | | +| `src/auth.ip-address.test.ts` | 24 | | +| `src/auth.oidc-discovery.test.ts` | 28 | | +| `src/auth.secret-guard.test.ts` | 25 | | +| `src/auth.session-config.test.ts` | 9 | | +| `src/auth.session-lifetime.test.ts` | 7 | | +| `src/auth.shared-instance.test.ts` | 5 | | +| `src/auth.test.ts` | 51 | Name splitting, session structure, OAuth config | +| `src/auth.user-info.test.ts` | 41 | | +| `src/auth.user-oauth-tokens.test.ts` | 6 | | +| `src/proxy.test.ts` | 38 | Route protection, public paths, session, errors | + +## `src/components/address-labels` (11 files, 126 cases) + +| Test File | Cases | What It Covers | +|---|---|---| +| `src/components/address-labels/actions.test.ts` | 53 | PDF, DOCX, and template merge actions + error branches | +| `src/components/address-labels/address-label.test.tsx` | 7 | | +| `src/components/address-labels/address-labels-form.test.tsx` | 15 | | +| `src/components/address-labels/address-labels-summary.test.tsx` | 8 | | +| `src/components/address-labels/imb-barcode.test.tsx` | 6 | | +| `src/components/address-labels/label-document.test.tsx` | 5 | | +| `src/components/address-labels/mail-merge-tab.test.tsx` | 14 | | +| `src/components/address-labels/merge-template.integration.test.ts` | 2 | Real (unmocked) `mergeTemplate` + PizZip run, incl. a forged-size zip bomb | +| `src/components/address-labels/postnet-barcode.test.tsx` | 5 | | +| `src/components/address-labels/sample-template.test.ts` | 3 | | +| `src/components/address-labels/word-document.test.ts` | 8 | `buildWordDocument` page layout and barcode branches | + +## `src/components/dev-panel` (11 files, 102 cases) + +| Test File | Cases | What It Covers | +|---|---|---| +| `src/components/dev-panel/dev-panel.test.tsx` | 11 | Localhost + `NODE_ENV` gating, localStorage persistence, open/close toggling | +| `src/components/dev-panel/panels/contact-records-actions.test.ts` | 3 | | +| `src/components/dev-panel/panels/contact-records-panel.test.tsx` | 11 | | +| `src/components/dev-panel/panels/deploy-tool-actions.test.ts` | 16 | Deploy-tool server actions (prod guard, session guard, delegation, env status) | +| `src/components/dev-panel/panels/deploy-tool-panel.test.tsx` | 22 | | +| `src/components/dev-panel/panels/params-panel.test.tsx` | 8 | | +| `src/components/dev-panel/panels/require-dev-session.test.ts` | 6 | `requireDevSession`: production refusal, session requirement | +| `src/components/dev-panel/panels/selection-actions.test.ts` | 4 | Selection resolution server action (dev-session guard, delegation) | +| `src/components/dev-panel/panels/selection-panel.test.tsx` | 9 | | +| `src/components/dev-panel/panels/user-tools-actions.test.ts` | 3 | Dev-session guard, refusal propagation, delegation to `ToolService.getUserTools` | +| `src/components/dev-panel/panels/user-tools-panel.test.tsx` | 9 | | + +## `src/components/field-management` (7 files, 113 cases) + +| Test File | Cases | What It Covers | +|---|---|---| +| `src/components/field-management/actions.test.ts` | 18 | `fetchPages` / `fetchPageFieldData` column merge / `savePageFieldOrder` | +| `src/components/field-management/field-order-editor.test.tsx` | 10 | | +| `src/components/field-management/new-group-dialog.test.tsx` | 9 | | +| `src/components/field-management/page-search.test.tsx` | 8 | | +| `src/components/field-management/sortable-field-item.test.tsx` | 22 | | +| `src/components/field-management/sortable-group.test.tsx` | 13 | | +| `src/components/field-management/use-field-order-state.test.ts` | 33 | | + +## `src/components/group-wizard` (12 files, 159 cases) + +| Test File | Cases | What It Covers | +|---|---|---| +| `src/components/group-wizard/actions.test.ts` | 32 | | +| `src/components/group-wizard/contact-search.test.tsx` | 11 | | +| `src/components/group-wizard/group-search.test.tsx` | 13 | | +| `src/components/group-wizard/schema.test.ts` | 22 | | +| `src/components/group-wizard/step-attributes.test.tsx` | 11 | | +| `src/components/group-wizard/step-identity.test.tsx` | 13 | | +| `src/components/group-wizard/step-meeting.test.tsx` | 11 | | +| `src/components/group-wizard/step-organization.test.tsx` | 9 | | +| `src/components/group-wizard/step-review.test.tsx` | 9 | | +| `src/components/group-wizard/step-settings.test.tsx` | 10 | | +| `src/components/group-wizard/wizard-navigation.test.tsx` | 11 | | +| `src/components/group-wizard/wizard-stepper.test.tsx` | 7 | | + +## `src/components/{layout,shared-actions,tool}` (5 files, 44 cases) + +| Test File | Cases | What It Covers | +|---|---|---| +| `src/components/layout/auth-wrapper.test.tsx` | 6 | `AuthWrapper` redirect to `/signin` (callbackUrl) or `/session-error`; renders children when authenticated | +| `src/components/shared-actions/domain.test.ts` | 6 | `getMpTimezone`: refuses without a session, delegates to `DomainTimezoneService` | +| `src/components/shared-actions/sign-out.test.ts` | 13 | `handleSignOut`: session cleared first, MP-origin-only `id_token_hint`, end-session URL shape, env guards | +| `src/components/shared-actions/user.test.ts` | 6 | `getCurrentUserProfile` session-derived GUID lookup, `Unauthorized` on missing/empty `userGuid`, IDOR regression (caller-forged argument ignored), error propagation | +| `src/components/tool/tool-framework.test.tsx` | 13 | `ToolContainer` / `ToolHeader` / `ToolFooter` optional-prop branches | + +## `src/contexts` (2 files, 16 cases) + +| Test File | Cases | What It Covers | +|---|---|---| +| `src/contexts/use-app-session.test.ts` | 2 | `useAppSession` hook wrapper | +| `src/contexts/user-context.test.tsx` | 14 | `UserProvider` lifecycle, profile loading, errors, edge branches | + +## `src/lib/providers/ministry-platform` (16 files, 521 cases) + +| Test File | Cases | What It Covers | +|---|---|---| +| `src/lib/providers/ministry-platform/auth/client-credentials.test.ts` | 29 | OAuth2 `client_credentials` token flow | +| `src/lib/providers/ministry-platform/client.test.ts` | 36 | OAuth token lifecycle, refresh, concurrent calls (uses `vi.useFakeTimers`) | +| `src/lib/providers/ministry-platform/helper.test.ts` | 67 | MPHelper CRUD, Zod validation, procedures, files, copy/recurrence/generateSequence | +| `src/lib/providers/ministry-platform/provider.test.ts` | 29 | Singleton, service delegation, generateSequence with optional fields | +| `src/lib/providers/ministry-platform/scripts/codegen-escape.test.ts` | 25 | Codegen escaping: `commentText`, `propertyKey`, `positiveInt` | +| `src/lib/providers/ministry-platform/services/communication.service.test.ts` | 12 | Communications and messages with/without attachments | +| `src/lib/providers/ministry-platform/services/domain.service.test.ts` | 8 | Domain info, global filters | +| `src/lib/providers/ministry-platform/services/file.service.test.ts` | 39 | File CRUD, FormData building, unauthenticated blob retrieval | +| `src/lib/providers/ministry-platform/services/guards.test.ts` | 53 | `sanitizeIdentifier` / `sanitizeRecordId` / `sanitizeUniqueId` / `tableEndpoint` / `errorName` | +| `src/lib/providers/ministry-platform/services/metadata.service.test.ts` | 7 | Metadata refresh, tables search | +| `src/lib/providers/ministry-platform/services/procedure.service.test.ts` | 19 | Procedure listing + query/body execution with URL encoding | +| `src/lib/providers/ministry-platform/services/table.service.test.ts` | 49 | CRUD + `copyRecord` + `copyRecordWithSubpages` | +| `src/lib/providers/ministry-platform/utils/http-client.test.ts` | 104 | HTTP methods, URL building, FormData, error handling | +| `src/lib/providers/ministry-platform/utils/logger.test.ts` | 4 | Debug vs production gating, error always logs | +| `src/lib/providers/ministry-platform/scripts/generate-storedprocs.test.ts` | 4 | | +| `src/lib/providers/ministry-platform/scripts/generate-types.test.ts` | 36 | | + +## `src/lib/**` (excl. MP provider) (14 files, 255 cases) + +| Test File | Cases | What It Covers | +|---|---|---| +| `src/lib/auth-client.test.ts` | 4 | | +| `src/lib/barcode-helpers.test.ts` | 14 | Barcode generation utilities | +| `src/lib/barcode-image.test.ts` | 6 | BMP rendering for IMb/POSTNET with JSON fallback | +| `src/lib/dto/address-label.dto.test.ts` | 5 | | +| `src/lib/dto/family.test.ts` | 9 | | +| `src/lib/env.test.ts` | 45 | `getMpBaseUrl` / `getAuthBaseUrl` / `getMpClient` validation | +| `src/lib/imb-encoder.test.ts` | 10 | USPS Intelligent Mail barcode encoding | +| `src/lib/label-stock.test.ts` | 9 | Label stock definitions and calculations | +| `src/lib/postnet-encoder.test.ts` | 9 | POSTNET barcode encoding | +| `src/lib/providers/google-places/provider.test.ts` | 11 | | +| `src/lib/security-headers.test.ts` | 28 | `buildCsp`, `generateNonce`, CSP report URI / `Reporting-Endpoints` header | +| `src/lib/tool-params.test.ts` | 28 | `parseToolParams` (from `tool-params.server.ts`: input shapes, NaN guard, decoding, `pageData`), `isNewRecord`, `isEditMode` | +| `src/lib/validation.test.ts` | 60 | GUID / positive int / column name / filter escaping | +| `src/lib/zip-guard.test.ts` | 17 | `assertZipWithinLimits` / `ZipLimitError` | + +## `src/services` (9 files, 245 cases) + +| Test File | Cases | What It Covers | +|---|---|---| +| `src/services/addressLabelService.test.ts` | 9 | Address fetching, batching, single contact | +| `src/services/authorizationService.test.ts` | 32 | `requireSecurityRole` / `hasSecurityRole`, `resolveRolePolicy`, `MP_SECURITY_ROLES` fails closed | +| `src/services/domainTimezoneService.test.ts` | 22 | `resolveIanaTimezone`, `getMpTimezone`, `toMpSqlDatetime`, `parseMpDatetime` | +| `src/services/familyService.test.ts` | 56 | | +| `src/services/fieldManagementService.test.ts` | 21 | Pages, fields, table metadata, batched order updates | +| `src/services/googlePlacesService.test.ts` | 18 | | +| `src/services/groupService.test.ts` | 27 | Group CRUD, lookups, contact/group search | +| `src/services/toolService.test.ts` | 53 | Page data, user tools, selection records, contact resolution, `listPages`, `listRoles`, `deployTool` | +| `src/services/userService.test.ts` | 7 | `getUserProfile`: display fields only, single query, GUID validation, not-found, error propagation | ## Known uncovered surfaces -- `src/lib/providers/ministry-platform/scripts/` (`build-sql-install.ts`, `generate-types.ts`, `generate-storedprocs.ts`) — CLI build scripts exercised via `npm run mp:build:install` / `npm run mp:generate:models` +- `src/lib/providers/ministry-platform/scripts/` CLI build scripts (`build-sql-install.ts`, `generate-types.ts`, `generate-storedprocs.ts`) — excluded from coverage; exercised via `npm run mp:build:install` / `npm run mp:generate:models`. Only the escaping helpers have a unit test (`codegen-escape.test.ts`). - Auto-generated model files under `src/lib/providers/ministry-platform/models/` — excluded from coverage via `vitest.config.mts` +- `src/components/ui/**` (vendored shadcn primitives) — excluded from coverage ## Related docs - `setup.md` — runner config diff --git a/.claude/references/testing/mocks.md b/.claude/references/testing/mocks.md index c350c52..6b30ffc 100644 --- a/.claude/references/testing/mocks.md +++ b/.claude/references/testing/mocks.md @@ -3,9 +3,9 @@ title: Mocking Patterns domain: testing type: reference applies_to: [src/**/*.test.ts, src/**/*.test.tsx] -symbols: [vi.hoisted, vi.mock, MPHelper] +symbols: [vi.hoisted, vi.mock, MPHelper, AuthorizationService] related: [setup.md, cookbook.md, inventory.md] -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose @@ -66,33 +66,50 @@ Every method that the service under test calls must be declared as a class field ## Pattern: Singleton reset between tests -All services in `src/services/` use a private static `instance` field (`toolService.ts:82`, `userService.ts:12`, etc.). Without resetting, cached instances hold stale mocked methods across tests (including stale `mockClear`ed state). +All services in `src/services/` use a private static `instance` field (`toolService.ts:85`, `userService.ts:13`, etc.). Without resetting, cached instances hold stale mocked methods across tests (including stale `mockClear`ed state). ```typescript -// From src/services/toolService.test.ts:19-23 +// From src/services/toolService.test.ts:46-50 beforeEach(() => { vi.clearAllMocks(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any (ToolService as any).instance = undefined; }); ``` -Required for: `ToolService`, `UserService`, `AddressLabelService`, `GroupService`, `FieldManagementService`. +Required for every service's own test: `AddressLabelService`, `AuthorizationService`, `DomainTimezoneService`, `FamilyService`, `FieldManagementService`, `GooglePlacesService`, `GroupService`, `ToolService`, `UserService` (all 9 `src/services/*.test.ts` files do it). -## Pattern: Mocking service singletons in server-action tests +## Pattern: Mocking the `AuthorizationService` gate -Server actions call `ServiceClass.getInstance()` (async). Mock the static method to return an object shaped like the service API used by the action. +Every MP-touching service method and feature server action calls `AuthorizationService.getInstance().requireSecurityRole(...)` (CLAUDE.md rule 12), which returns the acting MP `User_ID`. Tests of anything *above* the gate mock it so they exercise their own logic; the gate has its own tests in `src/services/authorizationService.test.ts`. Note `getInstance` is **synchronous** here (as is `DomainTimezoneService`'s; the other seven services' are async). ```typescript -// From src/components/dev-panel/panels/user-tools-actions.test.ts:21-35 -vi.mock('@/services/userService', () => ({ - UserService: { - getInstance: vi.fn().mockResolvedValue({ - getUserIdByGuid: mockGetUserIdByGuid, +// From src/services/toolService.test.ts:28-42 +const { mockRequireSecurityRole, mockHasSecurityRole } = vi.hoisted(() => ({ + mockRequireSecurityRole: vi.fn(async () => 42), + mockHasSecurityRole: vi.fn(async () => true), +})); + +vi.mock('@/services/authorizationService', () => ({ + AuthorizationService: { + getInstance: () => ({ + requireSecurityRole: mockRequireSecurityRole, + hasSecurityRole: mockHasSecurityRole, }), }, + UnauthorizedError: class UnauthorizedError extends Error {}, })); +``` +- The stub's return value (`42`) is the only source of `$userId` write attribution (rule 13), so service tests assert `$userId: 42` to prove the service takes it from the gate. +- To test a refusal: `mockRequireSecurityRole.mockRejectedValueOnce(new UnauthorizedError(...))`. +- `src/components/address-labels/actions.test.ts:33-49` shows a variant whose `hasSecurityRole` delegates to the same mock. + +## Pattern: Mocking service singletons in server-action tests + +Server actions call `ServiceClass.getInstance()` (async). Mock the static method to return an object shaped like the service API used by the action. + +```typescript +// From src/components/dev-panel/panels/user-tools-actions.test.ts:20-26 vi.mock('@/services/toolService', () => ({ ToolService: { getInstance: vi.fn().mockResolvedValue({ @@ -106,10 +123,10 @@ When mocking multiple services, each `getInstance` returns only the methods the ## Pattern: Mocking auth + `next/headers` for server actions -Most server actions do `await auth.api.getSession({ headers: await headers() })`. +Code that reads the session directly — the `AuthorizationService` gate itself, `requireDevSession`, `AuthWrapper`, and the `shared-actions/` carve-outs — does `await auth.api.getSession({ headers: await headers() })`. Feature actions mostly reach it only through the gate, so their tests mock the gate (above) and often also these two. ```typescript -// From src/components/dev-panel/panels/user-tools-actions.test.ts:9-19 +// From src/components/dev-panel/panels/user-tools-actions.test.ts:8-18 vi.mock('@/lib/auth', () => ({ auth: { api: { @@ -128,7 +145,7 @@ Session shapes used in tests: // Authenticated { user: { id: 'internal-id', userGuid: '550e8400-e29b-41d4-a716-446655440000' } } -// Missing userGuid (tests "User GUID not found") +// Missing userGuid (e.g. getCurrentUserProfile → throws 'Unauthorized') { user: { id: 'internal-id' } } // Unauthenticated (tests "Unauthorized") @@ -178,7 +195,7 @@ vi.mock('next/navigation', () => ({ Used in `src/lib/providers/ministry-platform/client.test.ts` to test OAuth token refresh without waiting real time. ```typescript -// From src/lib/providers/ministry-platform/client.test.ts:21-34 +// From src/lib/providers/ministry-platform/client.test.ts:33-46 beforeEach(async () => { vi.clearAllMocks(); vi.useFakeTimers(); @@ -213,7 +230,7 @@ await waitFor(() => { ## Pattern: Per-test env stubbing ```typescript -// From src/components/dev-panel/dev-panel.test.tsx:35-45 +// From src/components/dev-panel/dev-panel.test.tsx:44-54 beforeEach(() => { vi.stubEnv("NODE_ENV", "development"); setHostname("localhost"); diff --git a/.claude/references/testing/setup.md b/.claude/references/testing/setup.md index 87777fa..39babdc 100644 --- a/.claude/references/testing/setup.md +++ b/.claude/references/testing/setup.md @@ -5,7 +5,7 @@ type: reference applies_to: [vitest.config.mts, src/test-setup.ts, package.json] symbols: [defineConfig] related: [mocks.md, cookbook.md] -last_verified: 2026-09-13 +last_verified: 2026-10-10 --- ## Purpose @@ -17,18 +17,20 @@ Vitest runner config, global setup, and commands. Everything the harness needs b and Vite warned it would break when `configLoader: 'native'` becomes the default. `.mts` is resolved automatically, so no script changes were needed. Being real ESM, it must use `import.meta.dirname`, not `__dirname`. -- `src/test-setup.ts` — env stubs + `@testing-library/jest-dom` import +- `src/test-setup.ts` — env stubs, `ResizeObserver` stub, `@testing-library/jest-dom/vitest` import +- `src/test-utils/mock-oidc.ts` — test-only mock MP OIDC provider (`installMockOidc()`, RS256-signed id_tokens, `CookieJar`, `codeFlow`, …) imported only by `src/auth.*.test.ts`; excluded from coverage - `package.json` — `test`, `test:run`, `test:coverage` scripts -## Versions (from `.claude/references/_meta/facts/2026-09-13.md`) +## Versions (from `package.json`, 2026-10-10; installed vitest 5.0.3) | Package | Version | |---|---| -| vitest | ^4.1.0 | -| @vitest/coverage-v8 | ^4.1.0 | -| @testing-library/react | ^16.3.2 | -| @testing-library/jest-dom | ^6.9.1 | -| jsdom | ^28.1.0 | -| @vitejs/plugin-react | ^6.0.1 | +| vitest | ^5.0.0 | +| @vitest/coverage-v8 | ^5.0.0 | +| @testing-library/react | ^16.3.3 | +| @testing-library/user-event | ^14.6.7 | +| @testing-library/jest-dom | ^7.0.1 | +| jsdom | ^30.1.2 | +| @vitejs/plugin-react | ^6.1.2 | ## Commands | Script | Behavior | @@ -92,6 +94,7 @@ export default defineConfig({ 'src/test-setup.ts', '**/*.d.ts', '**/*.{test,spec}.{ts,tsx}', + 'src/test-utils/**', // Test-only helpers (mock OIDC server), imported only by tests 'src/lib/providers/ministry-platform/models/**', // Auto-generated files 'src/lib/providers/ministry-platform/scripts/**', // Build-time CLI scripts 'src/components/ui/**', // Vendored shadcn/ui primitives @@ -108,6 +111,11 @@ export default defineConfig({ // `import.meta.dirname`, not `__dirname`: this file is ESM (.mts), and // Vite's native config loader has no CommonJS globals. '@': path.resolve(import.meta.dirname, './src'), + // `server-only` throws unless resolved under the `react-server` export + // condition, which Next applies to server code and Vitest does not. Map + // it to the package's own no-op entry so modules marked server-only can + // still be unit tested. The guard itself is exercised by `next build`. + 'server-only': path.resolve(import.meta.dirname, './node_modules/server-only/empty.js'), }, }, }); @@ -115,23 +123,39 @@ export default defineConfig({ ## Key settings - **`coverage.include`** — REQUIRED. Without it the v8 provider only reports files a test imported, so untested files vanish from the report instead of counting as 0%. Note `coverage.all` was removed in Vitest 5 and directory exclusions must end in `**`. -- **`coverage.thresholds`** — statements 97 / lines 98, enforced by the `test:coverage` CI job. Branches and functions are deliberately unenforced. +- **`coverage.thresholds`** — statements 97 / lines 98, enforced by the `npm run test:coverage` step of the CI `test` job. Branches and functions are deliberately unenforced. - **`environment: 'jsdom'`** — DOM available without a browser (see `../DECISIONS.md` for rationale vs happy-dom) - **`globals: true`** — `describe/it/expect/vi` available without imports (tests still import explicitly by convention) - **`plugins: [react()]`** — `@vitejs/plugin-react` required for `.tsx` files and JSX transform - **`include`** — matches `src/**/*.{test,spec}.{ts,tsx}` (both extensions supported) - **Alias `@` -> `./src`** — mirrors `tsconfig.json` path alias - **Coverage provider: `v8`** — not istanbul; faster, native to Node -- **Coverage excludes** — auto-generated MP models under `src/lib/providers/ministry-platform/models/` are excluded so generated field counts don't dominate coverage +- **Coverage excludes** — generated MP models, build-time scripts, `src/test-utils/**`, vendored `src/components/ui/**`, `loading.tsx`, the two font/metadata layouts, `index.ts` barrels and `types.ts` modules (see the config above) +- **`server-only` alias** — maps `server-only` to its no-op `empty.js` so modules marked `import 'server-only'` (CLAUDE.md rule 15) can be unit-tested; the real guard is exercised by `next build` ## `src/test-setup.ts` (full source) ```typescript -import '@testing-library/jest-dom'; +// jest-dom v7 moved the Vitest `expect` type augmentation to this entry point. +// The bare '@testing-library/jest-dom' import registers the matchers at runtime +// but only augments Jest's types, so `toBeInTheDocument` etc. fail type checking. +import '@testing-library/jest-dom/vitest'; import { vi } from 'vitest'; +if (typeof globalThis.ResizeObserver === 'undefined') { + class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} + } + globalThis.ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver; +} + // Mock environment variables for tests vi.stubEnv('MINISTRY_PLATFORM_BASE_URL', 'https://test-mp.example.com'); +// One MP client serves both the service account and user sign-in (see +// getMpClient in src/lib/env.ts). `test-client-id` is the id_token audience +// the mock OIDC providers sign for (MOCK_CLIENT_ID in src/test-utils/mock-oidc.ts). vi.stubEnv('MINISTRY_PLATFORM_CLIENT_ID', 'test-client-id'); vi.stubEnv('MINISTRY_PLATFORM_CLIENT_SECRET', 'test-client-secret'); vi.stubEnv('MINISTRY_PLATFORM_DEV_CLIENT_ID', 'test-dev-client-id'); @@ -143,7 +167,9 @@ vi.stubEnv('NODE_ENV', 'test'); ``` ## Env var handling -- `vi.stubEnv()` seeds required env vars at test bootstrap so modules that read `process.env` at import time (e.g. `src/lib/auth.ts`, `src/lib/providers/ministry-platform/config.ts`) don't throw. +- `vi.stubEnv()` seeds required env vars at test bootstrap so modules that read `process.env` at import time (e.g. `src/lib/auth.ts`, `src/lib/env.ts`) don't throw. +- `src/lib/auth.ts` skips `assertAuthEnvironment` when `process.env.VITEST` is set (Vitest sets it); `src/auth.secret-guard.test.ts` clears it to prove the guard runs on import. +- Suites using `src/test-utils/mock-oidc.ts` run under `// @vitest-environment node` (`jose` rejects jsdom-realm typed arrays). - Tests may call `vi.stubEnv('NODE_ENV', 'development')` per-test and `vi.unstubAllEnvs()` in `afterEach` (see `src/components/dev-panel/dev-panel.test.tsx`). ## Coverage @@ -151,14 +177,15 @@ vi.stubEnv('NODE_ENV', 'test'); - Reporters: `text` (console), `json` (machine-readable), `html` (`coverage/index.html`) - Run: `npm run test:coverage` or `npx vitest run --coverage --coverage.reportOnFailure` -## Counts (facts snapshot 2026-04-17) -- **Test files:** 37 -- **Test cases:** 507 (from `vitest run`) +## Counts (facts snapshot 2026-10-10) +- **Test files:** 120 +- **Test cases:** 2,237 (from `vitest run`, 2026-10-11) +- See `../_meta/facts/2026-10-10.md` ## Conventions - Co-locate: `foo.ts` -> `foo.test.ts`; `foo.tsx` -> `foo.test.tsx` - Per-file: `import { describe, it, expect, vi, beforeEach } from 'vitest'` even though `globals: true` (consistency) -- React tests use `@testing-library/react` + `jest-dom` matchers (auto-extended via setup file) +- React tests use `@testing-library/react` + `jest-dom` matchers (auto-extended via setup file). The setup file must import `@testing-library/jest-dom/vitest`, not the bare package: since jest-dom v7 only `/vitest` augments Vitest's `expect` types, so the bare import passes tests but fails `tsc`/`next build`. ## Related docs - `mocks.md` — required mock patterns diff --git a/.claude/references/utils/README.md b/.claude/references/utils/README.md index 83df48c..a0775d2 100644 --- a/.claude/references/utils/README.md +++ b/.claude/references/utils/README.md @@ -12,20 +12,23 @@ Hand-rolled, framework-agnostic utilities under `src/lib/`: USPS barcode encoder |------|---------|--------------| | `barcodes.md` | `imbEncode()`, `postnetEncode()`, `buildRoutingCode()`, `buildImbTrackingCode()`, `preEncodeBarcodes()`, BMP image renderers | Touching address-label barcode generation, Word-document export, or adjusting IMb service type / mailer ID | | `label-stock.md` | Avery 5160/5161/5162/5163 dimensions, `getLabelPosition()` page/row/column math | Adding a new label stock, tweaking PDF/Word layout | -| `tool-params.md` | `parseToolParams()`, `PageData`, `isNewRecord()`, `isEditMode()`, standard MP tool query params (`pageID`, `s`, `recordID`, `recordDescription`, …) | Building or modifying any tool page (`src/app/(web)/tools/*/page.tsx`) | +| `tool-params.md` | `parseToolParams()` (server-only), `PageData`, `isNewRecord()`, `isEditMode()`, standard MP tool query params (`pageID`, `s`, `recordID`, `recordDescription`, …), shadcn `cn()` | Building or modifying any tool page (`src/app/(web)/tools/*/page.tsx`) | ## Code surfaces | Path | Role | |------|------| | `src/lib/imb-encoder.ts` | USPS-B-3200 Intelligent Mail Barcode (IMb) 4-state encoder → 65 `BarState` bars | | `src/lib/postnet-encoder.ts` | POSTNET tall/short bar encoder with check digit | -| `src/lib/barcode-helpers.ts` | Routing-code and tracking-code builders; `preEncodeBarcodes()` batch encoder with IMb→POSTNET fallback | +| `src/lib/barcode-helpers.ts` | Routing-code and tracking-code builders (Mailer ID validated); `preEncodeBarcodes()` batch encoder with logged IMb→POSTNET fallback | | `src/lib/barcode-image.ts` | `imbBarcodeToBmp()` + `postnetBarcodeToBmp()` — 24-bit BMP renderers for Word embedding | | `src/lib/label-stock.ts` | `LABEL_STOCKS` array, `getLabelStock()`, `getLabelPosition()` (points-based geometry) | -| `src/lib/tool-params.ts` | `ToolParams`, `PageData`, `parseToolParams()` (calls `ToolService.getPageData`), `isNewRecord()`, `isEditMode()` | -| `src/lib/utils.ts` | shadcn `cn(...inputs)` — `twMerge(clsx(...))`; 27 consumers total (22 under `src/components/ui/**` + 5 feature components) | +| `src/lib/tool-params.ts` | Client-safe: `ToolParams`, `PageData`, `isNewRecord()`, `isEditMode()` | +| `src/lib/tool-params.server.ts` | `server-only`: `parseToolParams()` (dynamically imports `ToolService` for `getPageData`) | +| `src/lib/zip-guard.ts` | `assertZipWithinLimits()` / `ZipLimitError` — .docx template zip-bomb guard (see `../components/address-labels.md`) | +| `src/lib/utils.ts` | shadcn `cn(...inputs)` — `twMerge(clsx(...))`; 26 non-test consumers (22 under `src/components/ui/**` + 4 feature components) | ## Related domains - `../components/README.md` — `address-labels` consumes every barcode utility + `label-stock`; `tool` / `dev-panel` consume `tool-params` - `../services/README.md` — `ToolService.getPageData(pageID)` is invoked by `parseToolParams()` +- `../dto-constants/constants.md` — `validateMailerId` and other validators used by these utilities - `../routing/README.md` — tool pages pass Next.js `searchParams` into `parseToolParams()` diff --git a/.claude/references/utils/barcodes.md b/.claude/references/utils/barcodes.md index 9600ca5..9a5be1d 100644 --- a/.claude/references/utils/barcodes.md +++ b/.claude/references/utils/barcodes.md @@ -20,7 +20,7 @@ symbols: related: - ../components/README.md - label-stock.md -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose @@ -80,6 +80,7 @@ export function buildImbTrackingCode( serviceType: string, serialNumber: number, ): string; +// Calls validateMailerId(mailerId) first — throws unless exactly 6 or 9 digits. // Layout: [barcodeId='00'(2)] + [serviceType(3)] + [mailerId(6|9)] + [serial(9|6)] // Always 20 digits total. @@ -138,12 +139,12 @@ export function postnetBarcodeToBmp( ### BMP rendering - Simple uncompressed BMP: 14-byte file header + 40-byte DIB (BITMAPINFOHEADER) + bottom-up 24-bit BGR pixel rows padded to 4-byte boundary. -- IMb: bar width = `max(floor(width/65 * 0.4), 1)`; per-state `topOffset`/`barHeight` as fractions of image height: +- IMb: pitch = `width / barStates.length`; bar width = `max(floor(pitch * 0.4), 1)`; per-state `topOffset`/`barHeight` as fractions of image height: - `T`: top 0.35, height 0.3 - `D`: top 0.15, height 0.85 - `A`: top 0.00, height 0.65 - `F`: top 0.00, height 1.00 -- POSTNET: tall = full height; short = 0.4 × height; bars bottom-aligned. +- POSTNET: same pitch/bar-width formula over the bar count; tall = full height; short = 0.4 × height; bars bottom-aligned. ### Routing & tracking helpers - `buildRoutingCode('32904-7322', '20')` → `'32904732220'` (11 digits) @@ -154,7 +155,7 @@ export function postnetBarcodeToBmp( ## Usage ### Generating barcodes for an address-label print run -From `src/components/address-labels/actions.ts:22-26`: +From `src/components/address-labels/actions.ts:19-29` (excerpt): ```typescript import { preEncodeBarcodes } from '@/lib/barcode-helpers'; @@ -165,7 +166,7 @@ import { imbBarcodeToBmp, postnetBarcodeToBmp } from '@/lib/barcode-image'; `preEncodeBarcodes(labels, config)` runs before PDF/Word export so each row carries a `barStates`/`barType` pair consumed by the renderers. ### Word-document embedding -From `src/components/address-labels/word-document.ts:18`: +From `src/components/address-labels/word-document.ts:17`: ```typescript import { imbBarcodeToBmp, postnetBarcodeToBmp } from '@/lib/barcode-image'; @@ -174,11 +175,11 @@ import { imbBarcodeToBmp, postnetBarcodeToBmp } from '@/lib/barcode-image'; Word accepts BMP natively, so the renderer returns raw `Buffer`s that can be inline-embedded without converting to PNG. ### PDF-preview rendering -- `src/components/address-labels/imb-barcode.tsx` — `@react-pdf/renderer` `<View>` component for PDF output; imports `type BarState` from `@/lib/imb-encoder`. +- `src/components/address-labels/imb-barcode.tsx` — `@react-pdf/renderer` `<View>` component for PDF output; takes the 65-char `barStates` string (renders nothing unless length is exactly 65) and keeps its own `BAR_DIMS` table — it does not import from `@/lib/imb-encoder`. - `src/components/address-labels/postnet-barcode.tsx` — `@react-pdf/renderer` `<View>` component for PDF output; imports `type PostnetBar` from `@/lib/postnet-encoder`. ### IMb test vector (USPS-B-3200 example 4) -From `src/lib/imb-encoder.test.ts:11-13`: +From `src/lib/imb-encoder.test.ts:9-13` (abridged): ```typescript expect(imbEncode('0123456709498765432101234567891').join('')).toBe( @@ -188,8 +189,9 @@ expect(imbEncode('0123456709498765432101234567891').join('')).toBe( ## Gotchas - **IMb → POSTNET fallback is logged, but without record content.** In `preEncodeBarcodes` (`src/lib/barcode-helpers.ts`), each encode failure emits `console.warn('[preEncodeBarcodes] IMb encode failed, falling back to POSTNET', { labelIndex, error })` (POSTNET retry adds `routingLength`; final failure logs `... no barcode will be rendered`). `error` is the error's **name** only (`describeFailure`) — label name, address, ZIP/routing digits and the error message are deliberately omitted (CLAUDE.md rule 14). Find the bad row by `labelIndex` in the request. Users still see POSTNET silently on a subtly wrong mailer config. See [GOTCHA-040](../GOTCHAS.md). -- **POSTNET `barStates` is JSON, IMb `barStates` is a raw string.** Consumers must branch on `barType` before rendering. Enforced at `src/lib/barcode-helpers.ts:59` (IMb) vs `:70,78` (POSTNET) and `src/lib/barcode-image.ts:64` (IMb expects plain string) vs `:98-103` (POSTNET parses JSON and silently falls back to a 1×1 BMP on parse failure). +- **POSTNET `barStates` is JSON, IMb `barStates` is a raw string.** Consumers must branch on `barType` before rendering. Enforced at `src/lib/barcode-helpers.ts:59` (IMb) vs `:70,78` (POSTNET) and `src/lib/barcode-image.ts:63` (IMb expects plain string) vs `:97-103` (POSTNET parses JSON and silently falls back to a 1×1 BMP on parse failure). - **`buildRoutingCode` treats DP `'00'` as absent.** Pass a real 1–2 digit delivery point; `'00'` returns a 5/9-digit routing, not 7/11-digit. See `src/lib/barcode-helpers.ts:13`. +- **An invalid Mailer ID never reaches the encoder silently.** `buildImbTrackingCode` throws via `validateMailerId` (caught by the IMb `try` → POSTNET fallback), and the render actions reject a malformed Mailer ID up front in `prepareLabels` (`src/components/address-labels/actions.ts`). - **BMP invalid-input behavior differs.** `imbBarcodeToBmp` silently skips unknown bar chars; `postnetBarcodeToBmp` returns a 1×1 fallback on JSON parse failure — neither throws. ## Related docs diff --git a/.claude/references/utils/label-stock.md b/.claude/references/utils/label-stock.md index cb6a009..d54922b 100644 --- a/.claude/references/utils/label-stock.md +++ b/.claude/references/utils/label-stock.md @@ -13,7 +13,7 @@ symbols: related: - barcodes.md - ../components/README.md -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose @@ -84,7 +84,7 @@ All four: `pageWidth=612`, `pageHeight=792`, `rowGap=0`. ## Usage ### Looking up a stock in a server action -From `src/components/address-labels/actions.ts:21`: +From `src/components/address-labels/actions.ts:19`: ```typescript import { getLabelStock } from '@/lib/label-stock'; @@ -106,15 +106,15 @@ import type { LabelStockConfig } from '@/lib/label-stock'; ``` ### Word export (geometry only) -From `src/components/address-labels/word-document.ts:16`: +From `src/components/address-labels/word-document.ts:15`: ```typescript import type { LabelStockConfig } from '@/lib/label-stock'; ``` ## Gotchas -- **`getLabelStock` returns `undefined` for unknown IDs.** Callers must guard before dereferencing — see `src/app/(web)/tools/addresslabels/address-labels.tsx:64-65` and `src/components/address-labels/actions.ts:135-138`. The test `should return undefined for unknown stock ID` (`src/lib/label-stock.test.ts:28`) pins the behavior. -- **`startPosition` offset is handled by the caller**, not by `getLabelPosition`. Features that "skip N used labels" add the offset to the index before calling. +- **`getLabelStock` returns `undefined` for unknown IDs.** Callers must guard before dereferencing — see `src/app/(web)/tools/addresslabels/address-labels.tsx:64-65` and `prepareLabels` in `src/components/address-labels/actions.ts` (`Unknown label stock` envelope). The test `should return undefined for unknown stock ID` (`src/lib/label-stock.test.ts:28`) pins the behavior. +- **`startPosition` offset is handled by the caller**, not by `getLabelPosition`. `label-document.tsx` skips `startPosition - 1` cells before placing labels. `prepareLabels` rejects a `startPosition` beyond `columns * rows`. - **Landscape / non-Letter stocks are not modeled.** If a new stock is added, it must follow the 612×792 Letter-portrait assumption or the PDF layout code in `label-document.tsx` needs updating. ## Related docs diff --git a/.claude/references/utils/tool-params.md b/.claude/references/utils/tool-params.md index 3f867e3..fc254de 100644 --- a/.claude/references/utils/tool-params.md +++ b/.claude/references/utils/tool-params.md @@ -4,6 +4,7 @@ domain: utils type: reference applies_to: - src/lib/tool-params.ts + - src/lib/tool-params.server.ts - src/lib/utils.ts symbols: - ToolParams @@ -15,21 +16,22 @@ symbols: related: - ../services/README.md - ../components/README.md -last_verified: 2026-04-17 +last_verified: 2026-10-10 --- ## Purpose -Parses the Ministry Platform-standard tool URL query string (`pageID`, `s`, `recordID`, `recordDescription`, etc.) into a typed `ToolParams` object, optionally hydrating `pageData` via `ToolService.getPageData()`. Every tool under `src/app/(web)/tools/*/page.tsx` funnels through this. The sibling `src/lib/utils.ts` holds the shadcn `cn()` Tailwind class merger (documented below for completeness). +Parses the Ministry Platform-standard tool URL query string (`pageID`, `s`, `recordID`, `recordDescription`, etc.) into a typed `ToolParams` object, optionally hydrating `pageData` via `ToolService.getPageData()`. Every tool under `src/app/(web)/tools/*/page.tsx` funnels through this. The parser lives in the server-only `src/lib/tool-params.server.ts`; the types and pure predicates stay in the client-safe `src/lib/tool-params.ts`. The sibling `src/lib/utils.ts` holds the shadcn `cn()` Tailwind class merger (documented below for completeness). ## Files -- `src/lib/tool-params.ts` — parser + `ToolParams`, `PageData`, `isNewRecord`, `isEditMode` -- `src/lib/utils.ts` — shadcn `cn()` (`twMerge(clsx(...))`), 27 consumers - -No test file exists for `tool-params.ts` (see TODO drop). +- `src/lib/tool-params.ts` — **client-safe**: `ToolParams`, `PageData`, `isNewRecord`, `isEditMode`. Must never import a service (CLAUDE.md rule 16). +- `src/lib/tool-params.server.ts` — `import "server-only"`; `parseToolParams` (+ private `parseIntOrUndefined`, `safeDecode`). Imports `ToolService` via dynamic `import()`. +- `src/lib/tool-params.test.ts` — covers input shapes, NaN guard, `recordDescription` decoding, `pageData` hydration, both predicates +- `src/lib/utils.ts` — shadcn `cn()` (`twMerge(clsx(...))`), 26 non-test consumers ## Key concepts - **MP tool URL contract** — When Ministry Platform launches a tool, it appends a standard query string naming the page, selection, and (optionally) target record. `parseToolParams` is the single ingestion point. -- **Page hydration** — If `pageID` is present, `parseToolParams` eagerly fetches `PageData` via the `api_Tools_GetPageData` stored procedure (through `ToolService`). Failure is non-fatal: it's caught and logged, leaving `pageData: undefined`. +- **Page hydration** — If `pageID` is present, `parseToolParams` eagerly fetches `PageData` via the `api_Tools_GetPageData` stored procedure (through `ToolService`, which gates `dp_Pages`/`read`). Failure — including an authorization refusal — is non-fatal: it's caught and logged, leaving `pageData: undefined`. +- **Gate before parse** — every tool page calls `requireToolAccess(...)` (`src/app/(web)/tools/require-tool-access.ts`) *before* `parseToolParams`. - **`recordID === -1` means "new record"** — MP uses `-1` to signal new-record intent; `isNewRecord()` treats `-1` or `undefined` as new; `isEditMode()` is its negation. - **`searchParams` is polymorphic** — Accepts both a `URLSearchParams` (runtime) and Next.js 16's awaited `{ [k]: string | string[] | undefined }` shape. @@ -86,8 +88,10 @@ export async function parseToolParams( ): Promise<ToolParams>; // - Integer params parsed via parseIntOrUndefined (NaN → undefined) // - recordDescription goes through safeDecode (decodeURIComponent, raw on URIError) -// - pageData fetched via ToolService.getInstance().getPageData(pageID); -// failures are caught and logged (pageData stays undefined) +// - pageData fetched via (await import("@/services/toolService")).ToolService +// .getInstance().getPageData(pageID); failures are caught and logged +// (pageData stays undefined) +// Defined in src/lib/tool-params.server.ts (server-only). export function isNewRecord(params: ToolParams): boolean; // params.recordID === -1 || params.recordID === undefined @@ -105,30 +109,36 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } ``` -Standard shadcn helper for merging Tailwind class strings while deduplicating conflicting utilities. Used by every component in `src/components/ui/**` (22 files) plus select feature components. +Standard shadcn helper for merging Tailwind class strings while deduplicating conflicting utilities. Used by every component in `src/components/ui/**` (22 files) plus 4 feature components (`field-management/page-search.tsx`, `group-wizard/{contact-search,group-search,wizard-stepper}.tsx`). ## How it works 1. `parseToolParams` normalizes `searchParams` reads through a single `getValue(key)` shim (handles both `URLSearchParams` and Next.js's awaited object shape; arrays collapse to first element). 2. Raw strings are converted: numeric fields via `parseIntOrUndefined` (finite integer or `undefined`), `recordDescription` via `safeDecode` (`decodeURIComponent`, falling back to the raw string on a malformed escape such as a stray `%`). -3. If `pageID` parsed as a number, `ToolService.getInstance()` + `getPageData(pageID)` is awaited. +3. If `pageID` parsed as a number, `ToolService` is dynamically imported and `getInstance()` + `getPageData(pageID)` is awaited. 4. Any error fetching page data is swallowed with `console.warn('tool_params.page_data_unavailable', { pageID })` and `pageData` remains `undefined` — **tools must handle that case**. 5. `isNewRecord` / `isEditMode` are pure predicates. ## Usage ### In a tool page (Next.js 16 server component) -From `src/app/(web)/tools/addresslabels/page.tsx:2-11`: +From `src/app/(web)/tools/addresslabels/page.tsx:1-16`: ```typescript -import { parseToolParams } from '@/lib/tool-params'; +import { AddressLabels } from './address-labels'; +import { requireToolAccess } from '@/app/(web)/tools/require-tool-access'; +import { parseToolParams } from '@/lib/tool-params.server'; interface AddressLabelsPageProps { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; } export default async function AddressLabelsPage({ searchParams }: AddressLabelsPageProps) { + // Self-gating, before ANY param parsing or data call — see require-tool-access.ts. + await requireToolAccess({ table: 'Contacts', operation: 'read' }); + const params = await parseToolParams(await searchParams); + return <AddressLabels params={params} />; } ``` @@ -148,7 +158,8 @@ import { ToolParams, isNewRecord, isEditMode } from "@/lib/tool-params"; ``` ## Consumers -Server-side (call `parseToolParams`): +Server-side (call `parseToolParams`, imported from `@/lib/tool-params.server`): +- `src/app/(web)/tools/addeditfamily/page.tsx` - `src/app/(web)/tools/addresslabels/page.tsx` - `src/app/(web)/tools/fieldmanagement/page.tsx` - `src/app/(web)/tools/groupwizard/page.tsx` @@ -157,12 +168,13 @@ Server-side (call `parseToolParams`): Client/UI (consume the `ToolParams` type and/or predicates): - `src/components/tool/tool-container.tsx` - `src/components/dev-panel/dev-panel.tsx` + `panels/*` -- Each tool's root component (`group-wizard.tsx`, `field-management.tsx`, `template-tool.tsx`, `address-labels.tsx`) +- Each tool's root component (`add-edit-family.tsx`, `group-wizard.tsx`, `field-management.tsx`, `template-tool.tsx`, `address-labels.tsx`) ## Gotchas -- **`pageData` may be `undefined` even when `pageID` is set.** The fetch is wrapped in `try/catch` and swallowed — callers must guard. See `src/lib/tool-params.ts:54-60`. +- **`pageData` may be `undefined` even when `pageID` is set.** The fetch is wrapped in `try/catch` and swallowed — callers must guard. See `src/lib/tool-params.server.ts:64-78`. - **`recordDescription` is URI-decoded; other strings are not.** Only `recordDescription` passes through `safeDecode`. `q` and `addl` come through raw. A malformed escape (`50% off`) used to throw `URIError` and crash the tool page; it now comes through raw. - **Non-numeric integer params become `undefined`, not `NaN`.** `parseIntOrUndefined` returns `undefined` unless the parse is finite (GOTCHA-043). Callers still validate range (e.g. `> 0`) themselves. +- **Never import `parseToolParams` (or any service) into `tool-params.ts`.** A client component importing it would drag `next/headers` into the client graph and fail the Turbopack build; a dynamic `import()` there still creates the graph edge (CLAUDE.md rule 16). - **`searchParams` must be awaited first.** Next.js 16 dynamic APIs (`params`, `searchParams`) are `Promise`s; `await searchParams` in the page, then pass the result to `parseToolParams`. ## Related docs diff --git a/SECURITY.md b/.github/SECURITY.md similarity index 100% rename from SECURITY.md rename to .github/SECURITY.md diff --git a/CLAUDE.md b/CLAUDE.md index 6e23640..7158454 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,10 +61,10 @@ to keep them from diverging. - **Optional**: `AUTH_IP_ADDRESS_HEADERS` / `AUTH_TRUSTED_PROXIES` — client-IP source for rate limiting (default on Vercel: `x-vercel-forwarded-for,x-real-ip`; see `.env.example`) - **Git hooks**: `npm install` runs `prepare`, which sets `core.hooksPath=.githooks`; `.githooks/pre-commit` refuses staged `.env*` files other than `.env.example` - **`MP_SECURITY_ROLES` fails closed**: `*` = any MP security role, a comma list = only those roles, **unset/blank/`","` = nobody** (every tool redirects to `/no-access`). Set it in every environment. -- **Services Layer**: Singleton service classes in `src/services/` wrap MPHelper for domain logic (ToolService, UserService, AddressLabelService, GroupService, FieldManagementService) +- **Services Layer**: Singleton service classes in `src/services/` wrap MPHelper for domain logic (AuthorizationService, DomainTimezoneService, ToolService, UserService, AddressLabelService, GroupService, FamilyService, FieldManagementService, GooglePlacesService) - **Contexts**: React context providers in `src/contexts/` (UserProvider) composed in `src/app/providers.tsx`; `useAppSession()` wraps Better Auth's `authClient.useSession()` - **UI**: Radix UI primitives + shadcn/ui components in `src/components/ui/`, Tailwind CSS v4 -- **Validation**: Zod v4 (`zod@^4.3`) — note: different API from Zod v3 (e.g., `z.object()` vs `z.interface()`) +- **Validation**: Zod v4 (`zod@^4.6`) — different API from Zod v3 (e.g., top-level `z.email()`/`z.url()`, `error` instead of `message` for custom messages); check context7 before relying on v3 habits - **Path Alias**: `@/*` maps to `src/*` ## Next.js 16 Notes @@ -107,7 +107,7 @@ to keep them from diverging. ``` src/components/ ├── address-labels/ # Address label printing & mail merge -├── dev-panel/ # Unified dev panel (localhost-only) - params, selection, contact records, user tools +├── dev-panel/ # Unified dev panel (localhost-only) - params, selection, contact records, user tools, deploy tool ├── field-management/ # Drag-and-drop MP page field order editor (PageSearch + FieldOrderEditor) ├── group-wizard/ # Multi-step group creation/edit wizard (6 steps) ├── layout/ # Layout components (AuthWrapper) @@ -120,6 +120,8 @@ src/components/ └── index.ts # Barrel exports ``` +Exception: the Add/Edit Family tool keeps its UI and `actions.ts` co-located in its route folder, `src/app/(web)/tools/addeditfamily/`. + ## Data Flow Server actions in `actions.ts` should call **service classes** (not MPHelper directly): @@ -156,10 +158,10 @@ import { authClient } from '@/lib/auth-client'; const { data: session, isPending } = authClient.useSession(); // Ministry Platform models (generated) -import { Congregation } from '@/lib/providers/ministry-platform/models'; +import { Congregations } from '@/lib/providers/ministry-platform/models'; -// Ministry Platform Zod schemas (for runtime validation) -import { ContactLogSchema } from '@/lib/providers/ministry-platform/models'; +// Ministry Platform Zod schemas (for runtime validation) — not in the models barrel; import the file directly +import { ContactLogSchema } from '@/lib/providers/ministry-platform/models/ContactLogSchema'; // Ministry Platform helper (used by services, not directly by components) import { MPHelper } from '@/lib/providers/ministry-platform'; @@ -197,53 +199,62 @@ export default MyComponent; // ❌ Avoid ## Validation Best Practices -When working with Ministry Platform data: +When working with Ministry Platform data — inside a **service** method, with `$userId` taken from the authorization gate (rule 13): ```typescript import { MPHelper } from '@/lib/providers/ministry-platform'; -import { ContactLogSchema } from '@/lib/providers/ministry-platform/models'; +import { ContactLogSchema } from '@/lib/providers/ministry-platform/models/ContactLogSchema'; +import { AuthorizationService } from '@/services/authorizationService'; const mp = new MPHelper(); +// The gate returns the acting MP dp_Users.User_ID — the only valid $userId source +const userId = await AuthorizationService.getInstance().requireSecurityRole({ + table: 'Contact_Log', + operation: 'create', +}); + // ✅ Good: Validate data before creating records await mp.createTableRecords('Contact_Log', records, { schema: ContactLogSchema, - $userId: currentUser.Contact_ID + $userId: userId }); // ✅ Good: Partial validation for updates (default) await mp.updateTableRecords('Contact_Log', partialRecords, { schema: ContactLogSchema, partial: true, // default, allows partial updates - $userId: currentUser.Contact_ID + $userId: userId }); // ✅ Good: Strict validation for full record updates await mp.updateTableRecords('Contact_Log', fullRecords, { schema: ContactLogSchema, partial: false, // require all fields - $userId: currentUser.Contact_ID + $userId: userId }); // ⚠️ Acceptable: Skip validation (backward compatible) await mp.createTableRecords('Contact_Log', records, { - $userId: currentUser.Contact_ID + $userId: userId }); ``` +Generated schemas validate `DateTime`/`Timestamp` columns with a regex that accepts both MP wall-clock (`YYYY-MM-DD HH:MM:SS`, what `DomainTimezoneService.toMpSqlDatetime()` returns) and ISO forms (`T` separator, optional `Z`/offset), so `schema` and rule 17 work together. Import a schema from its own file (`models/ContactLogSchema`) — the `models` barrel re-exports types only. + ## Testing - **Framework**: Vitest with jsdom environment, `@testing-library/react` + `@testing-library/user-event` for hooks/components, v8 coverage - **Counts**: test/file totals live in `.claude/references/_meta/facts/` (bit-rots quickly — trust `vitest run` output over any doc claim) -- **Coverage is enforced**: `vitest.config.mts` sets `thresholds: { statements: 97, lines: 98 }`, checked by the existing `test:coverage` CI job. Branches/functions are deliberately unenforced. Raise the thresholds as coverage rises; never lower them to green a red build. +- **Coverage is enforced**: `vitest.config.mts` sets `thresholds: { statements: 97, lines: 98 }`, checked by the "Test with coverage" step of the CI `test` job. Branches/functions are deliberately unenforced. Raise the thresholds as coverage rises; never lower them to green a red build. - **`coverage.include` is load-bearing**: without `include: ['src/**/*.{ts,tsx}']` the v8 provider reports only files a test imported, so untested files vanish instead of counting as 0%. Two traps: `coverage.all` was **removed in Vitest 5** (setting it is a type error and does nothing), and directory exclusions must end in `**` — a bare `'src/components/ui/'` matches nothing. -- **Config**: `vitest.config.ts` (runner), `src/test-setup.ts` (env vars + jest-dom) +- **Config**: `vitest.config.mts` (runner), `src/test-setup.ts` (env vars + jest-dom) - **jest-dom import**: `src/test-setup.ts` must import `@testing-library/jest-dom/vitest`, **not** the bare `@testing-library/jest-dom`. Since jest-dom v7 only the `/vitest` entry augments Vitest's `expect` types; the bare import registers matchers at runtime, so tests pass while `next build` fails with `Property 'toBeInTheDocument' does not exist`. - **Co-location**: Test files live next to source — `foo.ts` → `foo.test.ts` - **Critical**: Use `vi.hoisted()` for any mock variables referenced inside `vi.mock()` factories (hoisting causes `ReferenceError` otherwise) - **MPHelper mock**: Use mock class (`MPHelper: class { method = mockFn; }`), not `vi.fn().mockImplementation()` - **Singleton reset**: Reset `(ServiceClass as any).instance = undefined` in `beforeEach` to prevent state leakage -- **Server action tests**: Mock `@/lib/auth` (`auth.api.getSession`), `next/headers` (`headers()`), and service singletons +- **Server action tests**: Mock `@/lib/auth` (`auth.api.getSession`), `next/headers` (`headers()`), the `AuthorizationService` gate (`requireSecurityRole`), and service singletons - **CI type-checks**: `npm run typecheck` (`tsc --noEmit`) runs as a required step in the `test` job, alongside `npm run lint`. `tsconfig.json` includes `**/*.ts`/`**/*.tsx`, so a type error in a *test* file breaks `npm run build` — that used to reach `dev` unnoticed because CI ran tests only. - See **[Testing Reference](.claude/references/testing/README.md)** for all mock patterns, coverage data, and test inventory @@ -259,7 +270,7 @@ condition that clears it. Do not re-derive that analysis. - **Node**: pinned to the **Node 24 LTS line** — `engines.node` is `^24.15.0` (the `24.15` floor is jsdom 30's, the strictest dev dependency). `@types/node` - is pinned to the matching major (`^24.13.4`); do not let it drift ahead of the + is pinned to the matching major (`^24.19.2`); do not let it drift ahead of the runtime. `.nvmrc` holds `24` and CI reads it via `node-version-file`. **Vercel** deploys the latest `24.x` for this range (it only offers majors: 24.x/22.x/20.x), so `engines.node` overrides whatever the project's diff --git a/README.md b/README.md index 9709027..8efd05f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,9 @@ [![Radix UI](https://img.shields.io/badge/Radix_UI-primitives-161618?logo=radixui&logoColor=white)](https://www.radix-ui.com/) [![Lucide](https://img.shields.io/badge/Lucide-icons-F56565?logo=lucide&logoColor=white)](https://lucide.dev/) -A Ministry Platform page tools application powered by Next.js 16, React 19, Better Auth, and a comprehensive Ministry Platform REST API integration with TypeScript and Zod validation. +A Ministry Platform page tools application powered by Next.js 16, React 19, Better Auth, and a Ministry Platform REST API integration with TypeScript and Zod validation. + +MPNext-Tools is a **template**: churches fork or copy it and run their own instance. If you run a copy, read [`.github/SECURITY.md`](.github/SECURITY.md) — fixes made here do not reach your fork automatically. ## Table of Contents @@ -23,12 +25,9 @@ A Ministry Platform page tools application powered by Next.js 16, React 19, Bett - [Quick Setup](#quick-setup) - [Manual Setup](#manual-setup) - [OAuth Client Setup](#oauth-client-setup) + - [Production Deployment](#production-deployment) - [Project Structure](#project-structure) - [Tools](#tools) - - [Address Labels](#address-labels) - - [Field Management](#field-management) - - [Template Tool](#template-tool) - - [Building Custom Tools](#building-custom-tools) - [Ministry Platform Integration](#ministry-platform-integration) - [Components](#components) - [Services](#services) @@ -37,19 +36,21 @@ A Ministry Platform page tools application powered by Next.js 16, React 19, Bett - [Claude Code Commands](#claude-code-commands) - [Documentation](#documentation) - [Code Style & Conventions](#code-style--conventions) +- [Security](#security) ## Features -- **Authentication**: Better Auth with Ministry Platform OAuth (genericOAuth plugin), stateless encrypted-cookie sessions, and OIDC RP-initiated logout -- **Tools Framework**: Reusable components for building Ministry Platform page tools with URL parameter parsing and dual-mode support (create/edit) -- **Address Labels**: Print address labels with USPS Intelligent Mail Barcodes (IMb), POSTNET fallback, and Word document mail merge -- **Modern UI**: 22 Radix UI + shadcn/ui components with Tailwind CSS v4 -- **Type-Safe API**: Full TypeScript strict mode with auto-generated model and schema files from Ministry Platform -- **REST API Client**: Six specialized services covering tables, procedures, communications, files, metadata, and domain operations -- **Type Generation**: CLI tool to generate TypeScript interfaces and Zod v4 schemas from MP database schema -- **Validation**: Optional Zod v4 runtime validation in MPHelper before API calls -- **Testing**: Comprehensive Vitest 4.1 suite (see `.claude/references/_meta/facts/` for the authoritative snapshot) -- **Setup Wizard**: Interactive CLI setup with environment configuration, dependency management, and build verification +- **Authentication**: Better Auth with Ministry Platform OAuth/OIDC (genericOAuth plugin), stateless encrypted-cookie sessions, and OIDC RP-initiated logout +- **Authorization**: every MP read and write is gated by MP security role (`MP_SECURITY_ROLES`, fails closed); users without an allowed role land on `/no-access` +- **Tools Framework**: reusable components for building Ministry Platform page tools, with URL parameter parsing and create/edit dual mode +- **Address Labels**: print labels with USPS Intelligent Mail Barcodes (IMb), POSTNET fallback, and Word document mail merge +- **Group Wizard**: six-step create/edit wizard for MP `Groups` records +- **Field Management**: drag-and-drop editor for MP page field order, groups and per-field flags +- **Add/Edit Family**: household editor with contact search, address autocomplete (Google Places) and envelope numbering +- **Type-Safe API**: TypeScript strict mode with model and Zod v4 schema files generated from your MP instance +- **Security hardening**: nonce-based CSP, security headers, boot-time refusal of weak secrets and malformed URLs, sign-in rate limiting +- **Testing**: Vitest 5 suite with enforced coverage thresholds (see [Testing](#testing)) +- **Setup Wizard**: interactive CLI for environment configuration, dependency install, type generation and build verification ## Architecture @@ -58,9 +59,9 @@ A Ministry Platform page tools application powered by Next.js 16, React 19, Bett - **Next.js 16** with App Router and Turbopack (default bundler for dev and build) - **React 19** with Server Components by default - **TypeScript 6.0** in strict mode -- **Tailwind CSS v4** with `@tailwindcss/postcss` plugin +- **Tailwind CSS v4** with the `@tailwindcss/postcss` plugin - **Zod v4** for runtime validation -- **Vitest 4.1** for testing with v8 coverage +- **Vitest 5** with jsdom and v8 coverage ### Ministry Platform Integration @@ -74,20 +75,24 @@ MPHelper (Public API Facade) -> HttpClient (Raw HTTP with Bearer Auth) ``` -- REST API client with automatic OAuth2 client credentials token management (5-minute refresh buffer) -- Type-safe models and Zod validation schemas auto-generated from the MP schema +- REST API client using OAuth2 client credentials; the token is re-fetched every 5 minutes, well inside its 1-hour lifetime +- Type-safe models and Zod validation schemas generated from the MP schema - Six services: Table, Procedure, Communication, File, Metadata, Domain -### Authentication +### Authentication & Authorization -Better Auth with Ministry Platform OAuth via genericOAuth plugin (`src/lib/auth.ts`): +Better Auth with Ministry Platform OAuth via the genericOAuth plugin (`src/lib/auth.ts`): -- **Stateless sessions** (no database required): hard 12-hour lifetime, encrypted cookie cache, no user OAuth tokens stored, and boot-time refusal of a weak/missing `BETTER_AUTH_SECRET` or bad base URLs -- **Custom session enrichment** with name splitting via `customSession` plugin +- **One MP API client** (`MINISTRY_PLATFORM_CLIENT_ID`) serves as both the server-side service account and the sign-in client +- **Stateless sessions** (no database): hard 12-hour lifetime, encrypted (JWE) cookie cache, no user OAuth tokens stored, and boot-time refusal of a weak/missing `BETTER_AUTH_SECRET` or malformed base URLs - **OIDC RP-initiated logout** for proper MP session termination -- **Dual-layer route protection**: proxy-level cookie check (`src/proxy.ts`) + component-level session validation (`AuthWrapper`) -- **Client-side profile loading**: `UserProvider` context fetches full `MPUserProfile` (roles, groups, contact data) on mount -- **Critical**: `session.user.id` is Better Auth's internal ID. Use `session.user.userGuid` for all MP API lookups. +- **Layered protection**: + 1. `src/proxy.ts` — session-cookie check on every non-public route (plus the CSP nonce) + 2. `AuthWrapper` — server-side session validation; redirects to `/signin`, or to `/session-error` for a session with no `userGuid` + 3. `requireToolAccess()` — every tool page checks the caller's MP security role before parsing params or loading data + 4. `AuthorizationService.requireSecurityRole()` — every server action and service method that touches MP data, reads included +- **Client-side profile**: `UserProvider` loads the signed-in user's display profile (name, nickname, email) +- **Critical**: `session.user.id` is Better Auth's internal ID. Use `session.user.userGuid` for all MP lookups. ### Data Flow @@ -99,12 +104,12 @@ Component -> Server Action -> Service (singleton) -> MPHelper -> Ministry Platfo - **Node.js**: **v24.15.0 or later on the 24.x line** (pinned via `engines.node` in `package.json`, `.nvmrc`, and the setup script). Node 24 is the current LTS and the version Vercel deploys; 20.x and 22.x are not supported. Use `nvm use` (or `fnm use`) to pick it up from `.nvmrc`. - **Package Manager**: npm -- **Ministry Platform**: Active instance with API credentials and OAuth client configured -- **Ministry Platform Database**: SQL install script applied (see [Database Setup](#database-setup) below) +- **Ministry Platform**: active instance with an API client configured (see [OAuth Client Setup](#oauth-client-setup)) +- **Ministry Platform Database**: SQL install script applied (see [Database Setup](#database-setup)) ### Database Setup -The repo ships a bundled SQL install script at `_INSTALL/ministryplatform-install.sql`. It contains the stored procedures and DDL that several tools depend on (for example, the Field Management tool requires `api_MPNextTools_GetPages`, `api_MPNextTools_GetPageFields`, and `api_MPNextTools_UpdatePageFieldOrder`). +The repo ships a bundled SQL install script at `_INSTALL/ministryplatform-install.sql`. It contains the stored procedures and DDL several tools depend on (for example, Field Management requires `api_MPNextTools_GetPages`, `api_MPNextTools_GetPageFields`, and `api_MPNextTools_UpdatePageFieldOrder`). Deploy it once per Ministry Platform database (and again whenever the SQL bundle changes): @@ -112,13 +117,13 @@ Deploy it once per Ministry Platform database (and again whenever the SQL bundle 2. Open `_INSTALL/ministryplatform-install.sql` 3. Execute the script against the MP database -The script is regenerated automatically as part of `npm run build` (via `npm run mp:build:install`), bundling everything under `src/lib/providers/ministry-platform/db/` into the single install file. +The script is regenerated automatically as part of `npm run build` (via the same script as `npm run mp:build:install`), bundling everything under `src/lib/providers/ministry-platform/db/` into the single install file. ## Getting Started ### Quick Setup -The interactive setup wizard automates the entire configuration process: +The interactive setup wizard automates the configuration: ```bash git clone <repository-url> @@ -128,13 +133,13 @@ npm run setup ``` The setup wizard will: -1. Verify Node.js version (v20+ required) -2. Detect project origin and configure git -3. Create `.env.local` from `.env.example` (if needed) -4. Prompt for Ministry Platform host and derive API and file URLs automatically -5. Configure Ministry Platform API client credentials -6. Auto-generate `BETTER_AUTH_SECRET` -7. Install and update dependencies +1. Verify the Node.js version (24.x, at least 24.15.0) +2. Detect the project origin (template clone → convert to a fork or reinitialize git) +3. Check git status +4. Create `.env.local` from `.env.example` (if needed) +5. Validate environment variables — prompts for the MP host (deriving `MINISTRY_PLATFORM_BASE_URL` and `NEXT_PUBLIC_MINISTRY_PLATFORM_FILE_URL`), the API client ID/secret, and `MP_SECURITY_ROLES`, and auto-generates `BETTER_AUTH_SECRET` +6. Run `npm install` +7. Run `npm update` 8. Regenerate Ministry Platform types against your MP instance 9. Generate the stored procedure reference (powers Claude Code suggestions) 10. Run a production build to verify configuration @@ -164,53 +169,57 @@ cd MPNext-Tools npm install ``` +`npm install` also runs the `prepare` script, which points git at `.githooks/` — its pre-commit hook refuses to commit any `.env*` file other than `.env.example`. + #### 2. Environment Configuration ```bash cp .env.example .env.local ``` -Update `.env.local` with the required variables: +Set the required variables in `.env.local`. `.env.example` documents every variable inline. ```env -# Generate with: openssl rand -base64 32 +# Generate with: openssl rand -base64 32 (at least 32 characters) BETTER_AUTH_SECRET=your_generated_secret -# Update for production +# An origin only; https:// for every real host (http:// only for localhost) BETTER_AUTH_URL=http://localhost:3000 -# Ministry Platform API Configuration +# The app's one MP API client — service account AND sign-in client MINISTRY_PLATFORM_CLIENT_ID=MPNext MINISTRY_PLATFORM_CLIENT_SECRET=your_client_secret MINISTRY_PLATFORM_BASE_URL=https://your-instance.ministryplatform.com/ministryplatformapi + +# Who may use the tools. FAILS CLOSED: unset/blank = nobody. +# "*" = any MP security role; or a comma list, e.g. Administrators,Staff +MP_SECURITY_ROLES=* ``` -**Optional environment variables** (see `.env.example` for full inline documentation): +**The app refuses to start** (including `next build`) when `BETTER_AUTH_SECRET` is missing, shorter than 32 characters or Better Auth's public default; when `BETTER_AUTH_SECRETS` is set; or when a base URL is malformed or non-https. `NEXTAUTH_SECRET` / `NEXTAUTH_URL` are accepted as fallbacks for migrations from NextAuth. + +**Optional environment variables:** | Variable | Purpose | |----------|---------| -| `MINISTRY_PLATFORM_DEV_CLIENT_ID` | Dev/staging-only MP API client ID. Ignored in production builds. | -| `MINISTRY_PLATFORM_DEV_CLIENT_SECRET` | Dev/staging-only MP API client secret. Ignored in production builds. | -| `GOOGLE_PLACES_API_KEY` | Enables address autocomplete (falls back to MP `dp_Configuration_Settings` → `GoogleMapsAPIKey` first). | -| `NEXT_PUBLIC_MINISTRY_PLATFORM_FILE_URL` | MP file URL — derived from the MP host by the setup wizard. | -| `NEXT_PUBLIC_APP_NAME` | Application display name. Default: `MPNextApp`. | -| `NEXT_PUBLIC_PROD_URL` | Production URL used by the Authorized Tools debug panel for path comparison. | +| `MINISTRY_PLATFORM_DEV_CLIENT_ID` / `_SECRET` | Dev/staging-only MP API client, used only to run `api_dev_*` procedures from the dev panel's Deploy Tool. Ignored in production builds. | +| `GOOGLE_PLACES_API_KEY` | Address autocomplete in Add/Edit Family. The MP `dp_Configuration_Settings` value (`COMMON` / `GoogleMapsAPIKey`) wins; this variable is the fallback. Neither set = plain text input. | +| `NEXT_PUBLIC_MINISTRY_PLATFORM_FILE_URL` | MP file URL — derived from the MP host by the setup wizard; also allowed by the CSP. | +| `NEXT_PUBLIC_PROD_URL` | Production URL used by the dev panel's Authorized Tools check. | +| `CSP_ENFORCE` | The CSP is enforced by default; only the exact string `false` switches to report-only. | +| `CSP_REPORT_URI` | Optional https:// CSP violation collector. | +| `AUTH_IP_ADDRESS_HEADERS` | Client-IP header(s) for sign-in rate limiting. Default on Vercel: `x-vercel-forwarded-for,x-real-ip`. | +| `AUTH_TRUSTED_PROXIES` | Proxy IPs/CIDRs to skip when walking an appended `X-Forwarded-For` chain. | #### 3. Regenerate Ministry Platform Types -The repo ships with pre-generated models under `src/lib/providers/ministry-platform/models/` so a fresh clone can build immediately. Regenerate them against **your own** MP instance to pick up custom tables, columns, and constraints: +The repo ships pre-generated models under `src/lib/providers/ministry-platform/models/` so a fresh clone builds immediately. Regenerate them against **your own** MP instance to pick up custom tables, columns, and constraints: ```bash npm run mp:generate:models ``` -This connects to your Ministry Platform API, fetches all table metadata, and regenerates: -- TypeScript interfaces for each table -- Zod v4 validation schemas for runtime validation -- Schema documentation at `.claude/references/ministryplatform.schema.md` -- Output to `src/lib/providers/ministry-platform/models/` - -Run this any time your MP schema changes. The `--clean` flag in `npm run mp:generate:models` removes stale files before regenerating. +This connects to your MP API, fetches all table metadata, and regenerates TypeScript interfaces and Zod v4 schemas into `src/lib/providers/ministry-platform/models/` (the `--clean` flag removes stale files first). Run it whenever your MP schema changes. Optionally, also refresh the stored procedure reference (used by Claude Code for query suggestions): @@ -218,22 +227,7 @@ Optionally, also refresh the stored procedure reference (used by Claude Code for npm run mp:generate:storedprocs ``` -**Advanced options:** -```bash -# Generate types for specific tables only -npx tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts -s "Contact" - -# Generate without Zod schemas -npx tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts -o ./types - -# Generate with detailed mode (samples records for better type inference) -npx tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts -d --sample-size 10 - -# See all options -npx tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts --help -``` - -> **Running the generators directly?** Pass `--conditions=react-server` to `tsx`, as above. The MP client modules start with `import 'server-only'`, which throws under plain Node without that condition. The `npm run mp:generate*` scripts already include it, so `npm run mp:generate -- -s "Contact"` is the shorter equivalent. +See [Type Generation](#type-generation) for advanced options. #### 4. Run the Development Server @@ -243,206 +237,198 @@ npm run dev 1. Navigate to [http://localhost:3000](http://localhost:3000) 2. You'll be redirected to Ministry Platform login -3. After successful login, you'll see the dashboard with available tools +3. After signing in you'll see the dashboard with the available tools (or `/no-access` if you hold none of the roles in `MP_SECURITY_ROLES`) --- ### OAuth Client Setup -Before running the application, configure an OAuth 2.0 / OIDC client in Ministry Platform. - -Log in as an administrator and navigate to **Administration > API Clients**. +The app uses **one** MP API client for both server-side data access and user sign-in. In Ministry Platform, sign in as an administrator and go to **Administration > API Clients**. #### Basic Settings -- **Client ID**: Your client ID (must match `MINISTRY_PLATFORM_CLIENT_ID` in `.env.local`) -- **Client Secret**: Generate a secure secret +- **Client ID**: must match `MINISTRY_PLATFORM_CLIENT_ID` +- **Client Secret**: generate a secure secret (→ `MINISTRY_PLATFORM_CLIENT_SECRET`) - **Display Name**: MPNext-Tools (or your preferred name) -- **Client User**: Create a scoped user or use API User +- **Client User**: a scoped user (it is the audit-log user for writes made without a signed-in user) #### Redirect URIs (Required) -Separate each entry with a semicolon (`;`): +Separate multiple entries with a semicolon (`;`). The path is `<BETTER_AUTH_URL>/api/auth/callback/ministryplatform`: | Environment | URI | |-------------|-----| -| Development | `http://localhost:3000/api/auth/oauth2/callback/ministry-platform` | -| Production | `https://yourdomain.com/api/auth/oauth2/callback/ministry-platform` | - -> The callback path uses Better Auth's genericOAuth plugin convention: `/api/auth/oauth2/callback/{providerId}`. +| Development | `http://localhost:3000/api/auth/callback/ministryplatform` | +| Production | `https://yourdomain.com/api/auth/callback/ministryplatform` | #### Post-Logout Redirect URIs (Required) +Exactly `<BETTER_AUTH_URL>`, with no trailing slash: + | Environment | URI | |-------------|-----| | Development | `http://localhost:3000` | | Production | `https://yourdomain.com` | -> Post-logout redirect URIs are **required** for proper OIDC logout. Without them, users will be auto-logged back in after signing out (SSO behavior). - -#### Generate Better Auth Secret - -```bash -openssl rand -base64 32 -``` +> Without a post-logout redirect URI, users are signed straight back in after signing out (SSO behavior). --- ### Production Deployment -1. Deploy `_INSTALL/ministryplatform-install.sql` to the production Ministry Platform database (see [Database Setup](#database-setup)) -2. Update `BETTER_AUTH_URL` to your production domain -3. Add production redirect and post-logout redirect URIs to Ministry Platform OAuth client -4. Ensure all environment variables are set in your hosting provider -5. Enable HTTPS/SSL certificates -6. Run `npm run mp:generate:models` to ensure types are current -7. Test the complete authentication flow +1. Deploy `_INSTALL/ministryplatform-install.sql` to the production MP database (see [Database Setup](#database-setup)) +2. Set every required variable in your hosting provider — including `MP_SECURITY_ROLES`, which fails closed — with `BETTER_AUTH_URL` set to your production origin (https) +3. Add the production redirect and post-logout redirect URIs to the MP API client +4. On a proxy other than Vercel, set `AUTH_IP_ADDRESS_HEADERS` (or `AUTH_TRUSTED_PROXIES`) to match it +5. Run `npm run mp:generate:models` so types match your instance +6. Test the complete sign-in, tool access and sign-out flow ## Project Structure ``` MPNext-Tools/ +├── .github/ +│ ├── SECURITY.md # Vulnerability reporting policy +│ └── workflows/test.yml # CI: lint, typecheck, coverage, build, prerender check +├── .githooks/pre-commit # Blocks staged .env* files (except .env.example) +├── _INSTALL/ministryplatform-install.sql # Bundled MP SQL install (generated at build) ├── src/ -│ ├── app/ # Next.js App Router -│ │ ├── (web)/ # Protected route group (AuthWrapper + Providers) -│ │ │ ├── page.tsx # Dashboard with tool cards -│ │ │ ├── home/ # Redirect to / -│ │ │ ├── tools/ # Tools section -│ │ │ │ ├── layout.tsx # Full-height gray background -│ │ │ │ ├── addresslabels/ # Address label printing tool -│ │ │ │ ├── fieldmanagement/ # MP page field layout editor -│ │ │ │ └── template/ # Template tool (scaffold/demo) -│ │ │ └── layout.tsx # Auth + Providers wrapper -│ │ ├── api/auth/[...all]/ # Better Auth route handler -│ │ ├── signin/ # OAuth sign-in page -│ │ ├── layout.tsx # Root HTML layout -│ │ └── providers.tsx # Context providers (UserProvider) +│ ├── app/ # Next.js App Router +│ │ ├── (web)/ # Protected route group (AuthWrapper + Providers) +│ │ │ ├── layout.tsx +│ │ │ ├── page.tsx # Dashboard with tool cards +│ │ │ ├── error.tsx +│ │ │ ├── no-access/ # Users without an allowed MP security role +│ │ │ └── tools/ +│ │ │ ├── layout.tsx +│ │ │ ├── require-tool-access.ts # Per-page role gate (every tool page calls it first) +│ │ │ ├── addeditfamily/ # Add/Edit Family (UI + actions co-located here) +│ │ │ ├── addresslabels/ # Address label printing +│ │ │ ├── fieldmanagement/ # MP page field layout editor +│ │ │ ├── groupwizard/ # Group create/edit wizard +│ │ │ └── template/ # Template tool (scaffold/demo) +│ │ ├── api/auth/[...all]/ # Better Auth handler (deny-by-default route allowlist) +│ │ ├── auth-error/ # OAuth callback failure page (public) +│ │ ├── session-error/ # Session without userGuid; sign-out escape hatch +│ │ ├── signin/ # OAuth sign-in page +│ │ ├── error.tsx, global-error.tsx # Error boundaries +│ │ ├── layout.tsx # Root HTML layout +│ │ └── providers.tsx # Context providers (UserProvider) │ │ -│ ├── components/ # React components -│ │ ├── address-labels/ # Address label printing & mail merge -│ │ ├── field-management/ # Drag-and-drop MP page field order editor -│ │ ├── layout/ # AuthWrapper (server component) -│ │ ├── shared-actions/ # Cross-feature server actions -│ │ ├── tool/ # Tool framework (Container, Header, Footer) -│ │ ├── ui/ # 22 shadcn/ui components -│ │ └── dev-panel/ # Unified dev panel (localhost-only) +│ ├── components/ # See Component Organization below +│ ├── contexts/ # user-context.tsx, use-app-session.ts, index.ts │ │ -│ ├── contexts/ # React Context providers -│ │ ├── user-context.tsx # UserProvider (MP profile + roles) -│ │ ├── session-context.tsx # useAppSession() hook -│ │ └── index.ts -│ │ -│ ├── lib/ # Shared libraries -│ │ ├── auth.ts # Better Auth server config -│ │ ├── auth-client.ts # Better Auth client hooks -│ │ ├── dto/ # Hand-written DTOs/ViewModels -│ │ ├── tool-params.ts # Tool URL parameter parsing -│ │ ├── utils.ts # Tailwind cn() utility -│ │ ├── imb-encoder.ts # USPS Intelligent Mail barcode encoder -│ │ ├── postnet-encoder.ts # POSTNET barcode encoder -│ │ ├── barcode-helpers.ts # Barcode generation utilities -│ │ ├── label-stock.ts # Label stock definitions (Avery 5160, etc.) +│ ├── lib/ +│ │ ├── auth.ts, auth-client.ts # Better Auth server config / client hooks +│ │ ├── env.ts # Validated env access (getMpBaseUrl, getAuthBaseUrl, getMpClient) +│ │ ├── security-headers.ts # CSP + security headers +│ │ ├── tool-params.ts # Client-safe tool URL param types/helpers +│ │ ├── tool-params.server.ts # parseToolParams() (server-only) +│ │ ├── imb-encoder.ts, postnet-encoder.ts, barcode-helpers.ts, barcode-image.ts +│ │ ├── label-stock.ts # Label stock definitions (Avery 5160, etc.) +│ │ ├── constants.ts, utils.ts, validation.ts, zip-guard.ts +│ │ ├── dto/ # Hand-written DTOs (address-label.dto.ts, family.ts) │ │ └── providers/ -│ │ └── ministry-platform/ # Ministry Platform provider -│ │ ├── auth/ # Client credentials OAuth -│ │ ├── services/ # 6 API services -│ │ ├── models/ # Generated types + Zod schemas -│ │ ├── types/ # Type definitions -│ │ ├── utils/ # HttpClient utility -│ │ ├── scripts/ # Type generation CLI -│ │ ├── docs/ # Provider documentation -│ │ ├── client.ts # Core MP client (token management) -│ │ ├── provider.ts # Singleton provider -│ │ ├── helper.ts # Public API (MPHelper) -│ │ └── index.ts # Barrel export -│ │ -│ ├── services/ # Application services (singletons) -│ │ ├── addressLabelService.ts # Address fetching & label data -│ │ ├── toolService.ts # Page data and user permissions -│ │ └── userService.ts # User profile with roles/groups +│ │ ├── google-places/ # Google Places API client +│ │ └── ministry-platform/ # MP provider +│ │ ├── auth/ # Client-credentials OAuth +│ │ ├── db/ # SQL stored procedures/DDL (bundled into _INSTALL) +│ │ ├── docs/ # Provider documentation +│ │ ├── models/ # Generated types + Zod schemas +│ │ ├── scripts/ # Type/proc generators, SQL bundler +│ │ ├── services/ # 6 API services +│ │ ├── types/, utils/ # Type definitions, HttpClient +│ │ └── client.ts, provider.ts, helper.ts, index.ts │ │ -│ ├── proxy.ts # Route protection (session cookie check) -│ └── test-setup.ts # Vitest environment setup +│ ├── services/ # 9 application services (singletons, server-only) +│ ├── proxy.ts # Route protection (session cookie check + CSP nonce) +│ └── test-setup.ts # Vitest environment setup │ ├── scripts/ -│ └── setup.ts # Interactive setup wizard +│ ├── setup.ts # Interactive setup wizard +│ └── check-prerender.mjs # Fails if a route was statically prerendered (no CSP nonce) │ -├── .claude/ # Claude Code configuration -│ ├── commands/ # 5 Claude Code skills -│ └── references/ # 6 reference documents +├── .claude/ # Claude Code configuration +│ ├── commands/ # 4 Claude Code commands +│ ├── references/ # Agent-facing reference docs (start at INDEX.md) +│ ├── packages/ # Dependency audit records +│ ├── playbooks/ # Porting guides for downstream forks +│ └── TODO/ # Known open issues │ -├── .env.example # Environment template -├── CLAUDE.md # Development guide -├── vitest.config.ts # Vitest configuration -├── components.json # shadcn/ui config (new-york style, slate theme) -├── next.config.ts # Next.js configuration -├── tsconfig.json # TypeScript strict mode, @/* alias -└── package.json # Dependencies and scripts +├── .env.example # Environment template (every variable documented) +├── CLAUDE.md # Development guide +├── vitest.config.mts # Vitest configuration (coverage thresholds) +├── components.json # shadcn/ui config (new-york style, slate theme) +├── next.config.ts # Next.js configuration +├── tsconfig.json # TypeScript strict mode, @/* alias +└── package.json # Dependencies and scripts ``` ## Tools -The application provides a dashboard at `/` with cards linking to each available tool. All tools are protected behind Ministry Platform OAuth authentication. +The dashboard at `/` shows a card for each tool. All tools require a Ministry Platform sign-in **and** an MP security role allowed by `MP_SECURITY_ROLES`. + +| Route | Tool | +|-------|------| +| `/tools/addresslabels` | Address Labels | +| `/tools/groupwizard` | Group Wizard | +| `/tools/fieldmanagement` | Field Management | +| `/tools/addeditfamily` | Add/Edit Family | +| `/tools/template` | Template (reference implementation) | ### Address Labels **Route**: `/tools/addresslabels` -A tool for printing address labels with USPS Intelligent Mail Barcodes (IMb) and optional Word document mail merge. +Prints address labels with USPS Intelligent Mail Barcodes (IMb) and optional Word document mail merge. -**Features:** -- Fetch addresses from Ministry Platform selections or saved selections -- Generate USPS Intelligent Mail Barcodes (IMb) with configurable Mailer ID -- POSTNET barcode fallback for simpler addressing -- Print-ready label sheets (Avery 5160 and other stock formats) +- Fetch addresses from Ministry Platform selections or the current record +- USPS Intelligent Mail Barcodes (IMb) with configurable Mailer ID, POSTNET fallback +- Print-ready label sheets (Avery 5160 and other stock formats) as PDF - Word document mail merge with `.docx` template support - Address validation and skip reporting -- Configurable label formats and barcode options -**Components** (`src/components/address-labels/`): -- `AddressLabelsForm` — Configuration form (address source, barcode format, label stock) -- `AddressLabelsSummary` — Results summary with skip reporting -- `LabelDocument` — Print-ready label sheet layout -- `AddressLabel` — Individual label rendering -- `ImbBarcode` — USPS Intelligent Mail barcode SVG -- `PostnetBarcode` — POSTNET barcode SVG -- `MailMergeTab` — Word document mail merge interface +**Components** (`src/components/address-labels/`): `AddressLabelsForm`, `AddressLabelsSummary`, `LabelDocument`, `AddressLabel`, `ImbBarcode`, `PostnetBarcode`, `MailMergeTab`. + +### Group Wizard + +**Route**: `/tools/groupwizard` + +A six-step create/edit wizard for MP `Groups` records — identity, organization, meeting, attributes, settings, review — with per-step React Hook Form + Zod validation and contact/group search comboboxes. Opened from a Groups record it edits that group; otherwise it creates one. + +**Components** (`src/components/group-wizard/`): `StepIdentity`, `StepOrganization`, `StepMeeting`, `StepAttributes`, `StepSettings`, `StepReview`, `WizardStepper`, `WizardNavigation`, `ContactSearch`, `GroupSearch`. ### Field Management **Route**: `/tools/fieldmanagement` -A drag-and-drop editor for configuring Ministry Platform page field layout — reorder fields, assign groups, and toggle per-field flags (required, hidden, writing-assistant) without touching the MP UI. +A drag-and-drop editor for Ministry Platform page field layout — reorder fields, assign groups, and toggle per-field flags (required, hidden, writing-assistant) without touching the MP UI. -**Features:** - Searchable page picker sourced from `dp_Pages` - Drag-and-drop reordering within and across field groups - Inline editing for label, default value, filter clause, depends-on field, and writing-assistant toggle - Create new groups on the fly -- Auto-merges table columns not yet present in `dp_Page_Fields` so new columns can be added to a page layout +- Auto-merges table columns not yet present in `dp_Page_Fields` + +**Components** (`src/components/field-management/`): `PageSearch`, `FieldOrderEditor`, `SortableGroup`, `SortableFieldItem`, `NewGroupDialog`, `useFieldOrderState`. -**Components** (`src/components/field-management/`): -- `PageSearch` — Searchable page picker -- `FieldOrderEditor` — Main drag-and-drop editor surface -- `SortableGroup` — Droppable group container -- `SortableFieldItem` — Draggable field row with inline flag editors -- `NewGroupDialog` — Create a new field group -- `useFieldOrderState` — State hook for field order/edits +**Stored procedures**: `api_MPNextTools_GetPages`, `api_MPNextTools_GetPageFields`, `api_MPNextTools_UpdatePageFieldOrder`. -**Stored procedures:** -- `api_MPNextTools_GetPages` — List configurable pages -- `api_MPNextTools_GetPageFields` — Fetch current `dp_Page_Fields` rows for a page -- `api_MPNextTools_UpdatePageFieldOrder` — Upsert field order and per-field flags +### Add/Edit Family + +**Route**: `/tools/addeditfamily` + +A household editor: search for a contact (or open from a record), then edit the household, its address (with Google Places autocomplete when configured) and its members, and save. Assigns a unique envelope number when needed. Its UI and server actions live in the route folder, `src/app/(web)/tools/addeditfamily/`. ### Template Tool **Route**: `/tools/template` -A reference implementation demonstrating the tool framework pattern. Use this as a starting point for building new tools. +A reference implementation of the tool framework pattern. Use it as a starting point for new tools. -**Structure:** ``` src/app/(web)/tools/template/ -├── page.tsx # Server component — parses URL params via parseToolParams() +├── page.tsx # Server component — requireToolAccess(), then parseToolParams() +├── loading.tsx # Loading state └── template-tool.tsx # Client component — renders inside ToolContainer ``` @@ -450,40 +436,39 @@ src/app/(web)/tools/template/ All tools follow a consistent pattern: -1. **Server component** (`page.tsx`) parses URL search params and fetches page metadata +1. **Server component** (`page.tsx`) calls `requireToolAccess(...)` **first**, then parses URL search params with `parseToolParams()` and fetches page metadata 2. **Client component** renders the UI inside `ToolContainer` with header/footer actions -3. **Server actions** (`actions.ts`) call service singletons for MP operations -4. Tools receive standard MP parameters: `pageID`, `s` (selection), `recordID`, `recordDescription` +3. **Server actions** (`actions.ts`) call service singletons, which gate every MP call with `AuthorizationService` +4. Tools receive standard MP parameters: `pageID`, `s` (selection), `sc`, `p`, `q`, `v`, `recordID`, `recordDescription`, `addl` -**Quick start:** +**Quick start** — use the Claude Code command to scaffold a new tool: ```bash -# Use the Claude Code command to scaffold a new tool /newtool MyToolName ``` -Or manually: -1. Copy the `template` folder to create your new tool directory -2. Rename files and components -3. Implement your logic inside the `ToolContainer` +Or manually: copy the `template` folder, rename files and components, and implement your logic inside `ToolContainer`. -**URL Parameter Utilities** (`src/lib/tool-params.ts`): -- `parseToolParams(searchParams)` — Parses and fetches page metadata -- `isNewRecord(params)` — Returns true if `recordID === -1` or undefined -- `isEditMode(params)` — Returns true if editing an existing record +**URL parameter utilities**: +- `parseToolParams(searchParams)` (`src/lib/tool-params.server.ts`) — parses params and fetches page metadata +- `isNewRecord(params)` / `isEditMode(params)` (`src/lib/tool-params.ts`, client-safe) — create vs. edit mode ## Ministry Platform Integration ### MPHelper — Public API -The main entry point for interacting with Ministry Platform: +`MPHelper` is the entry point to the MP REST API. Application code calls it from **service classes** in `src/services/`, never from components or server actions, and every call is preceded by the authorization gate: ```typescript import { MPHelper } from '@/lib/providers/ministry-platform'; -import { ContactLogSchema } from '@/lib/providers/ministry-platform/models'; +import { ContactLogSchema } from '@/lib/providers/ministry-platform/models/ContactLogSchema'; +import { AuthorizationService } from '@/services/authorizationService'; +import { DomainTimezoneService } from '@/services/domainTimezoneService'; const mp = new MPHelper(); +const authz = AuthorizationService.getInstance(); -// Get records with query parameters +// Read — gated like a write +await authz.requireSecurityRole({ table: 'Contacts', operation: 'read' }); const contacts = await mp.getTableRecords({ table: 'Contacts', filter: 'Contact_Status_ID=1', @@ -492,21 +477,24 @@ const contacts = await mp.getTableRecords({ top: 50 }); -// Create records with Zod validation (recommended) +// Create — $userId comes from the gate, datetimes from DomainTimezoneService. +// The generated schema accepts MP wall-clock datetimes (`YYYY-MM-DD HH:MM:SS`). +const userId = await authz.requireSecurityRole({ table: 'Contact_Log', operation: 'create' }); await mp.createTableRecords('Contact_Log', [{ Contact_ID: 12345, - Contact_Date: new Date().toISOString(), - Made_By: 1, + Contact_Date: await DomainTimezoneService.getInstance().toMpSqlDatetime(new Date()), + Made_By: userId, Notes: 'Follow-up call completed' }], { schema: ContactLogSchema, - $userId: 1 + $userId: userId }); -// Update with partial validation (default) -await mp.updateTableRecords('Contact_Log', records, { +// Partial update with Zod validation +await mp.updateTableRecords('Contact_Log', [{ Contact_Log_ID: 67890, Notes: 'Updated' }], { schema: ContactLogSchema, - partial: true + partial: true, + $userId: userId }); // Execute stored procedures @@ -516,7 +504,7 @@ const results = await mp.executeProcedureWithBody('api_Custom_Procedure', { // File operations const files = await mp.getFilesByRecord({ - tableName: 'Contacts', + table: 'Contacts', recordId: 12345 }); ``` @@ -528,26 +516,29 @@ const files = await mp.getFilesByRecord({ | **Table** | CRUD operations | `getTableRecords`, `createTableRecords`, `updateTableRecords`, `deleteTableRecords` | | **Procedure** | Stored procedures | `getProcedures`, `executeProcedure`, `executeProcedureWithBody` | | **Communication** | Email/SMS with attachments | `createCommunication`, `sendMessage` | -| **File** | File management | `getFilesByRecord`, `uploadFiles`, `updateFile`, `deleteFile`, `getFileContentByUniqueId` | +| **File** | File management | `getFilesByRecord`, `uploadFiles`, `updateFile`, `deleteFile`, `getFileContentByUniqueId`, `getFileMetadata` | | **Metadata** | Schema info | `getTables`, `refreshMetadata` | | **Domain** | Domain config | `getDomainInfo`, `getGlobalFilters` | -### Type Generation +MPHelper also exposes `copyRecord`, `copyRecordWithSubpages` and `generateSequence`. See the [provider docs](src/lib/providers/ministry-platform/docs/README.md). -Generate TypeScript interfaces and Zod schemas from your Ministry Platform database: +### Type Generation ```bash -# Generate all types with Zod schemas (recommended) +# Generate all types with Zod schemas into models/ (recommended) npm run mp:generate:models # Generate for specific tables -npx tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts -s "Contact" +npm run mp:generate -- -s "Contact" + +# Generate with detailed mode (samples records for better type inference) +npm run mp:generate -- -d --sample-size 10 # See all options -npx tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts --help +npm run mp:generate -- --help ``` -> **Running the generators directly?** Pass `--conditions=react-server` to `tsx`, as above. The MP client modules start with `import 'server-only'`, which throws under plain Node without that condition. The `npm run mp:generate*` scripts already include it, so `npm run mp:generate -- -s "Contact"` is the shorter equivalent. +> **Running the generators directly?** Pass `--conditions=react-server` to `tsx` (e.g. `npx tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts --help`). The MP client modules start with `import 'server-only'`, which throws under plain Node without that condition. The `npm run mp:generate*` scripts already include it. **CLI Options:** | Flag | Description | Default | @@ -559,105 +550,70 @@ npx tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/ge | `-d, --detailed` | Sample records for type inference | `false` | | `--sample-size <num>` | Records to sample | `5` | -**Generated output** (for `mp:generate:models`): -- TypeScript interfaces for every MP table with JSDoc comments, FK annotations, and type constraints -- Matching Zod validation schemas with email/URL/length validators -- A barrel export `index.ts` -- Schema documentation at `.claude/references/ministryplatform.schema.md` +**Generated output** (for `mp:generate:models`): one TypeScript interface per MP table (JSDoc, FK annotations, constraints), a matching `<Table>Schema.ts` Zod schema per table, and a barrel `index.ts` that re-exports the **types** — import a schema from its own file, e.g. `@/lib/providers/ministry-platform/models/ContactLogSchema`. ## Components ### UI Components (`src/components/ui/`) -22 shadcn/ui components built with Radix UI primitives and Tailwind CSS v4 (new-york style, slate theme, lucide icons): - -Alert, Alert Dialog, Avatar, Badge, Breadcrumb, Button, Card, Checkbox, Command, Dialog, Drawer, Dropdown Menu, Form, Input, Label, Popover, Radio Group, Select, Skeleton, Switch, Textarea, Tooltip +22 shadcn/ui components built on Radix UI primitives and Tailwind CSS v4 (new-york style, slate theme, lucide icons). -### Layout Components (`src/components/layout/`) +### Layout & Tool Framework -- **AuthWrapper** — Server component that validates session via `auth.api.getSession()` and redirects to `/signin` if unauthenticated - -### Tool Framework (`src/components/tool/`) - -- **ToolContainer** — Full-screen flex layout with header, scrollable content, and footer actions. Automatically renders `DevPanel` above the title bar on localhost (NODE_ENV=development only). -- **ToolHeader** — Dark slate title bar with optional info tooltip +- **AuthWrapper** (`layout/`) — server component; validates the session via `auth.api.getSession()`, redirects to `/signin` if unauthenticated and to `/session-error` if the session has no `userGuid` +- **ToolContainer** (`tool/`) — full-screen layout with header, scrollable content, and footer actions. Renders `DevPanel` above the title bar on localhost in development only. +- **ToolHeader** — dark slate title bar with optional info tooltip - **ToolFooter** — Close/Save action buttons with loading state ### Feature Components -- **address-labels/** — 12 components for address label printing, barcode generation, and mail merge -- **field-management/** — Drag-and-drop editor for MP page field order and flags (`PageSearch`, `FieldOrderEditor`, sortable group/field items, `useFieldOrderState`) -- **dev-panel/** — Unified developer overlay (localhost-only) showing parsed URL params, MP selection data, contact records, and authorized tools -- **shared-actions/** — Cross-feature server actions (`getCurrentUserProfile()` — returns the *calling* user's MP profile; the `User_GUID` comes from the session, never from a parameter); `handleSignOut()` — app sign-out plus MP RP-initiated logout, used by the sign-out forms on `/no-access` and `/session-error` +- **address-labels/** — label printing, IMb/POSTNET barcodes, PDF/DOCX output, mail merge +- **group-wizard/** — six-step group create/edit wizard +- **field-management/** — drag-and-drop MP page field order editor +- **dev-panel/** — localhost-only developer overlay: parsed URL params, MP selection data, contact records, authorized tools, deploy tool +- **shared-actions/** — cross-feature server actions: `getCurrentUserProfile()` (the *calling* user's profile; the `User_GUID` comes from the session, never a parameter), domain config, and `handleSignOut()` (app sign-out plus MP RP-initiated logout, used by the sign-out forms on `/no-access` and `/session-error`) -All components use kebab-case file naming, PascalCase component names, and named exports with barrel index files. +All components use kebab-case file names, PascalCase component names, and named exports with barrel `index.ts` files. ## Services -Application services provide business logic abstraction with the singleton pattern: - -| Service | File | Purpose | Key Methods | -|---------|------|---------|-------------| -| **UserService** | `userService.ts` | User profile with roles and groups | `getUserProfile(userGuid)` — parallel queries for user data, roles, and group memberships | -| **ToolService** | `toolService.ts` | Page metadata and permissions | `getPageData(pageID)`, `getUserTools(domainId, userId)` — via stored procedures | -| **AddressLabelService** | `addressLabelService.ts` | Address fetching and label data | Address queries from MP selections and saved selections for label printing | +Application services in `src/services/` are server-only singletons that wrap MPHelper: -**Data flow:** -``` -Server Action -> Service.getInstance() -> MPHelper -> Ministry Platform API -``` - -**DTOs** (`src/lib/dto/`): Hand-written application-level types including `LabelData`, `LabelConfig`, `SkipRecord`, `AddressMode`, `BarcodeFormat`, and more. +| Service | Purpose | Key Methods | +|---------|---------|-------------| +| **AuthorizationService** | Role gate for all MP data access (`MP_SECURITY_ROLES`, fails closed); returns the acting MP `User_ID` for write attribution | `requireSecurityRole`, `hasSecurityRole` | +| **DomainTimezoneService** | MP domain time zone and wall-clock ↔ instant conversion at the MP boundary | `getMpTimezone`, `toMpSqlDatetime`, `parseMpDatetime` | +| **ToolService** | Page metadata, selections, the user's authorized tools, dev-tool deploy | `getPageData`, `getSelectionRecordIds`, `getUserTools`, `resolveContactIds`, `listPages`, `listRoles`, `deployTool` | +| **UserService** | Signed-in user's display profile by `User_GUID` | `getUserProfile` | +| **AddressLabelService** | Mailing addresses for contacts | `getAddressesForContacts`, `getAddressForContact` | +| **GroupService** | Group Wizard lookups, search, read/create/update | `fetchAllLookups`, `searchContacts`, `searchGroups`, `getGroup`, `createGroup`, `updateGroup` | +| **FieldManagementService** | MP pages, page fields, table metadata, field-order saves | `getPages`, `getPageFields`, `getTableMetadata`, `updatePageFieldOrder` | +| **FamilyService** | Add/Edit Family: contact search, household load/save, lookups, envelope numbers | `searchContacts`, `resolveContactIdFromPage`, `getHousehold`, `getLookups`, `getDefaults`, `getNextEnvelopeNumber`, `saveHousehold` | +| **GooglePlacesService** | Address autocomplete (key from MP config, else env) | `isEnabled`, `autocomplete`, `getPlaceDetails` | + +**DTOs** (`src/lib/dto/`): hand-written application types — address labels (`LabelData`, `LabelConfig`, `SkipRecord`, …) and family (`Household` and related schemas). ## Testing -### Overview - -- **Framework**: Vitest 4.1 with jsdom environment -- **Libraries**: @testing-library/react, @testing-library/jest-dom -- **Coverage**: v8 provider -- **Total**: **621 test cases across 43 test files** (authoritative snapshot: `.claude/references/_meta/facts/`) - -### Running Tests +- **Framework**: Vitest 5 with jsdom +- **Libraries**: `@testing-library/react`, `@testing-library/user-event`, `@testing-library/jest-dom` +- **Coverage**: v8 provider, **enforced** thresholds (statements 97%, lines 98%) in `vitest.config.mts` +- **Co-location**: `foo.ts` → `foo.test.ts`, next to the source +- **Counts**: test totals drift quickly — trust `npm run test:run`; the latest snapshot lives in `.claude/references/_meta/facts/` ```bash npm test # Watch mode npm run test:run # Single run -npm run test:coverage # With coverage report +npm run test:coverage # With coverage report (what CI runs) ``` -### Test Coverage - -| Area | Test File | What's Covered | -|------|-----------|----------------| -| MPHelper | `helper.test.ts` | All CRUD operations, Zod validation, domain/metadata/procedure/file methods | -| HTTP Client | `http-client.test.ts` | URL building, GET/POST/PUT/DELETE, FormData uploads, error handling | -| Table Service | `table.service.test.ts` | Table CRUD with query params, error codes (404, 401, 409) | -| Address Label Service | `addressLabelService.test.ts` | Address fetching, label data generation | -| Address Label Actions | `actions.test.ts` | Address label server actions | -| MP Client | `client.test.ts` | OAuth token lifecycle, refresh, concurrent calls, expiry buffer | -| Auth | `auth.test.ts` | Custom session, name splitting, OAuth config, userGuid mapping | -| Provider | `provider.test.ts` | Singleton pattern, service delegation | -| Proxy | `proxy.test.ts` | Route protection, public paths, session cookie checks | -| Tool Service | `toolService.test.ts` | Page data, user tools, stored procedure calls | -| Selection Panel | `selection-actions.test.ts` | Selection resolution server actions | -| User Context | `user-context.test.tsx` | UserProvider lifecycle, profile loading, error handling | -| User Service | `userService.test.ts` | Profile with roles/groups, parallel queries | -| User Tools Panel | `user-tools-actions.test.ts` | Authorization checks, session validation | -| User Menu | `actions.test.ts` | Sign-out action, OIDC logout redirect | -| Shared Actions | `user.test.ts` | getCurrentUserProfile session-derived GUID, unauthorized paths, IDOR regression | -| Session Context | `session-context.test.tsx` | useAppSession hook wrapper | -| IMb Encoder | `imb-encoder.test.ts` | USPS Intelligent Mail barcode encoding | -| POSTNET Encoder | `postnet-encoder.test.ts` | POSTNET barcode encoding | -| Barcode Helpers | `barcode-helpers.test.ts` | Barcode generation utilities | -| Label Stock | `label-stock.test.ts` | Label stock definitions and calculations | - -### Key Testing Patterns +Per-file coverage and the full test inventory are in the [Testing Reference](.claude/references/testing/README.md). +**Key testing patterns**: - **`vi.hoisted()`** for mock variables referenced inside `vi.mock()` factories -- **MPHelper mock**: Use mock class syntax, not `vi.fn().mockImplementation()` +- **MPHelper mock**: use mock class syntax, not `vi.fn().mockImplementation()` - **Singleton reset**: `(ServiceClass as any).instance = undefined` in `beforeEach` -- **Server action mocks**: Mock `@/lib/auth`, `next/headers`, and service singletons +- **Server action mocks**: mock `@/lib/auth`, `next/headers`, and service singletons - **Fake timers**: `vi.useFakeTimers()` for token expiry testing ## Development @@ -667,176 +623,106 @@ npm run test:coverage # With coverage report ```bash # Development npm run dev # Start dev server (Turbopack) -npm run build # Production build (includes type checking) +npm run build # Bundle SQL install script, then production build (with type checking) npm start # Start production server +npm run check:prerender # After a build: fail if a route was statically prerendered (no CSP nonce) # Code Quality npm run lint # ESLint (native flat config) +npm run typecheck # tsc --noEmit npm test # Vitest watch mode npm run test:run # Single test run npm run test:coverage # Coverage report # Ministry Platform npm run mp:generate:models # Generate types + Zod schemas to models/ (recommended) +npm run mp:generate # Type generator with your own flags (-- --help) npm run mp:generate:storedprocs # Generate stored procedure reference +npm run mp:build:install # Bundle db/ SQL into _INSTALL/ministryplatform-install.sql # Setup npm run setup # Interactive setup wizard npm run setup:check # Validate setup without changes ``` +CI (`.github/workflows/test.yml`) runs lint, typecheck, tests with coverage, the build, and the prerender check as steps of a single required `test` job. + +### Branching + +`dev` is the default branch: branch from it and open PRs against it (squash-merged). `main` is production and only receives `dev` through a release PR (merge commit, never squash). See [CLAUDE.md § Branching & Release](CLAUDE.md#branching--release). + ### Key Dependencies | Package | Version | Purpose | |---------|---------|---------| -| next | ^16.1.7 | App Router, Turbopack, React Server Components | -| react | ^19.2.4 | UI framework | -| better-auth | ^1.5.5 | OAuth authentication with genericOAuth plugin | -| zod | ^4.3.6 | Runtime validation (v4 API) | -| react-hook-form | ^7.71.1 | Form state management | +| next | ^16.4.0 | App Router, Turbopack, React Server Components | +| react | ^19.3.0 | UI framework | +| better-auth | ^1.7.7 | OAuth authentication with genericOAuth plugin | +| jose | ^6.2.12 | id_token (JWT) verification | +| zod | ^4.6.5 | Runtime validation (v4 API) | +| react-hook-form | ^7.89.0 | Form state management | | tailwindcss | ^4.2.0 | Utility-first CSS | -| lucide-react | ^1.8.0 | Icon library | -| vitest | ^4.1.0 | Test framework | -| typescript | ^6.0.2 | Type safety | +| lucide-react | ^1.55.0 | Icon library | +| @react-pdf/renderer | ^4.9.0 | Label sheet PDFs | +| docx / docxtemplater | ^9.9.0 / ^3.71.0 | Word document output and mail merge | +| @dnd-kit/react | ^0.5.0 | Drag and drop (Field Management) | +| vitest | ^5.0.0 | Test framework | +| typescript | ^6.0.3 | Type safety | + +Dependency audits and known version holds are recorded in [`.claude/packages/`](.claude/packages/). ## Claude Code Commands -This project includes 5 custom [Claude Code](https://claude.ai/code) commands to streamline development: +This project includes 4 custom [Claude Code](https://claude.ai/code) commands in `.claude/commands/`: | Command | Description | |---------|-------------| -| `/audit-deps` | Security audit + dependency update analysis with CVE checks | -| `/branch-commit [args]` | Create branch and commit, optionally from GitHub issue | -| `/newtool [name]` | Scaffold a new tool with route, component, and homepage card | -| `/pr [args]` | Create a validated pull request with auto-detected issue links | -| `/release [args]` | Create a GitHub release with calver versioning and auto-generated changelog | - -### `/audit-deps` - -Runs `npm audit`, searches for CVEs affecting major dependencies, categorizes updates as safe (patch/minor) vs major, and generates a prioritized action plan. - -### `/branch-commit` - -```bash -/branch-commit # Prompts for branch name and message -/branch-commit #123 # Auto-generates from GitHub issue -/branch-commit feature/my-change: Add new feature -``` - -Branch naming: `fix/issue-<id>-<slug>` for bugs, `feature/issue-<id>-<slug>` for features. - -### `/newtool` - -```bash -/newtool TeamRoster # Creates route, component, and homepage card -/newtool EventCheckin, BatchEmail # Multiple tools at once -``` - -Generates: route page, client component with ToolContainer, and a card on the dashboard. - -### `/pr` - -```bash -/pr # Create PR to main -/pr --base dev # Target dev branch -/pr --draft # Draft PR -/pr #123 # Link to issue -``` - -Validates: not on main, no uncommitted changes, branch pushed, no duplicate PR. - -### `/release` - -```bash -/release # Auto-detect version, generate changelog -/release --draft # Create as draft release -/release --prerelease # Mark as pre-release -``` - -Uses calver format: `v{YYYY}.{MM}.{DD}.{HHmm}`. Categorizes PRs as features, fixes, docs, or maintenance. - -### Command Files - -``` -.claude/commands/ -├── audit-deps.md -├── branch-commit.md -├── newtool.md -├── pr.md -└── release.md -``` +| `/newtool <name>[, <name>…]` | Scaffold one or more tools: a self-gating route page, a client component in `ToolContainer`, and a dashboard card | +| `/newstoredproc <name>` | Scaffold an `api_*` stored procedure (with `@DomainID INT` as the first parameter) and add it to the SQL install bundle | +| `/release` | Promote `dev` → `main` with a merge commit, then tag `main` with calver `vYYYY.MM.DD.HHmm` and publish a GitHub release (`--draft`, `--prerelease`, `--tag`, `--target`, `--since`) | +| `/update-deps` | Apply in-range updates, evaluate each major separately, sweep OSV.dev for advisories, and write a record to `.claude/packages/` (`--security-only`, `--check`) | ## Documentation | Document | Description | |----------|-------------| -| **[CLAUDE.md](CLAUDE.md)** | Development guide — commands, architecture, code style, testing patterns | -| **[Ministry Platform Provider](src/lib/providers/ministry-platform/docs/README.md)** | Complete REST API client documentation | -| **[Type Generator](src/lib/providers/ministry-platform/scripts/README.md)** | CLI tool for generating TypeScript types from MP schema | -| **[Auth Reference](.claude/references/auth/README.md)** | Better Auth config, OAuth flow, session access, userGuid vs user.id | -| **[Components Reference](.claude/references/components/README.md)** | Component inventory with compliance status | -| **[Services Reference](.claude/references/services/README.md)** | Service layer docs, MP query patterns, DTOs | +| **[CLAUDE.md](CLAUDE.md)** | Development guide — commands, architecture, rules, testing patterns | +| **[Security Policy](.github/SECURITY.md)** | How to report a vulnerability; required steps for downstream forks | +| **[Reference Index](.claude/references/INDEX.md)** | Entry point to all agent-facing reference docs | +| **[Ministry Platform Provider](src/lib/providers/ministry-platform/docs/README.md)** | REST API client documentation | +| **[Type Generator](src/lib/providers/ministry-platform/scripts/README.md)** | CLI for generating TypeScript types from the MP schema | +| **[Auth Reference](.claude/references/auth/README.md)** | Better Auth config, OAuth flow, sessions, userGuid vs user.id | +| **[Security Reference](.claude/references/security/README.md)** | Authorization model, write attribution, logging rules, headers/CSP | +| **[Components Reference](.claude/references/components/README.md)** | Component inventory | +| **[Services Reference](.claude/references/services/README.md)** | Service layer, MP query patterns | | **[Testing Reference](.claude/references/testing/README.md)** | Vitest patterns, mock setup, coverage data | -| **[MP Schema Reference](.claude/references/ministryplatform.schema.md)** | Auto-generated database schema | -| **[MP Stored Procedures](.claude/references/ministryplatform.storedprocs.md)** | Auto-generated stored procedure reference | +| **[Decisions](.claude/references/DECISIONS.md)** / **[Gotchas](.claude/references/GOTCHAS.md)** | Architectural decisions and known traps | +| **[MP Schema Reference](.claude/references/mp-schema/README.md)** | Generated table and stored procedure catalogs | ## Code Style & Conventions -### Import Paths +See [CLAUDE.md](CLAUDE.md) for the full rules. The essentials: -Use the `@/*` path alias for all internal imports: -```typescript -import { MPHelper } from '@/lib/providers/ministry-platform'; -import { Button } from '@/components/ui/button'; -import { AuthWrapper } from '@/components/layout'; -import { ToolContainer } from '@/components/tool'; -import { useUser, useAppSession } from '@/contexts'; -``` - -### Naming Conventions - -| Convention | Usage | -|------------|-------| -| **PascalCase** | Components, types, interfaces | -| **camelCase** | Functions, variables | -| **kebab-case** | All files and folders | -| **snake_case** | Ministry Platform API fields | - -### Component Conventions - -- React Server Components by default; `"use client"` only when needed -- Named exports only (no default exports) -- Feature components organized in kebab-case folders with `index.ts` barrel exports -- Server actions co-located as `actions.ts` in feature folders -- Shared actions in `src/components/shared-actions/` - -### Component Organization - -``` -src/components/ -├── address-labels/ # Address label printing & mail merge (12 files) -├── field-management/ # Drag-and-drop MP page field order editor -├── layout/ # AuthWrapper -├── shared-actions/ # Cross-feature server actions -├── tool/ # Tool framework (Container, Header, Footer) -├── ui/ # shadcn/ui components (22) -└── dev-panel/ # Unified dev panel (localhost-only) -``` +- **`@/*` path alias** for all internal imports +- **Naming**: PascalCase for components/types, camelCase for functions/variables, kebab-case for all files and folders, snake_case for Ministry Platform API fields +- **React Server Components by default**; `"use client"` only when needed +- **Named exports only** (no default exports, except where Next.js requires them) +- **Feature folders** in kebab-case with `index.ts` barrels; server actions co-located as `actions.ts`; shared actions in `src/components/shared-actions/` +- **Server modules start with `import 'server-only'`** +- **Authorize, don't just authenticate**: MP-touching actions and service methods call `AuthorizationService.requireSecurityRole()`, reads included +- **Use `session.user.userGuid`** for MP lookups (not `session.user.id`) +- **Convert datetimes at the MP boundary** with `DomainTimezoneService` +- **Never edit generated files** in `src/lib/providers/ministry-platform/models/` — regenerate with `npm run mp:generate:models` +- Access MP fields with special characters using bracket notation: `event["Allow_Check-in"]` -### Best Practices +## Security -1. **Use service classes in server actions** — call `src/services/` singletons, not MPHelper directly -2. **Validate at API boundaries** — pass optional `schema` parameter in `createTableRecords()` / `updateTableRecords()` -3. **Regenerate types** after MP schema changes: `npm run mp:generate:models` -4. **Never edit generated files** in `src/lib/providers/ministry-platform/models/` -5. **Run tests** before committing: `npm run test:run` -6. **Use `session.user.userGuid`** for all MP API lookups (not `session.user.id`) -7. Access MP fields with special characters using bracket notation: `event["Allow_Check-in"]` +Please **do not** open a public issue for a security problem. Report it privately as described in [`.github/SECURITY.md`](.github/SECURITY.md), which also lists the steps every downstream fork must take. ## License -Private +No license has been chosen for this repository yet. Until one is added, all rights are reserved by default. (`"private": true` in `package.json` only prevents publishing to npm.) ## Support -For Ministry Platform API documentation, refer to your instance's API documentation portal. +For Ministry Platform API documentation, see your instance's API documentation portal. For bugs and feature requests, open a [GitHub issue](https://github.com/MinistryPlatform-Community/MPNext-Tools/issues). diff --git a/scripts/setup.ts b/scripts/setup.ts index 5df063d..45e96b6 100644 --- a/scripts/setup.ts +++ b/scripts/setup.ts @@ -1239,7 +1239,7 @@ async function runInteractiveSetup(options: SetupOptions): Promise<number> { printStepHeader(9, totalSteps, 'Generating stored procedure reference'); console.log( chalk.gray( - ' Produces .claude/references/ministryplatform.storedprocs.md — used by Claude Code' + ' Produces .claude/references/mp-schema/stored-procs.md — used by Claude Code' ) ); console.log(chalk.gray(' for stored-proc query suggestions and authoring assistance.')); diff --git a/src/app/(web)/error.tsx b/src/app/(web)/error.tsx index 353e583..fc099bb 100644 --- a/src/app/(web)/error.tsx +++ b/src/app/(web)/error.tsx @@ -6,9 +6,9 @@ import { useEffect } from "react"; * Error boundary for everything below the `(web)` layout. * * Placement is the whole design: because this sits INSIDE the `(web)` segment, - * the shell — header, user menu and critically the SIGN-OUT control — survives - * the error. A single boundary at `src/app/error.tsx` would replace the shell - * and strand the user with no way out. + * the `(web)` layout (AuthWrapper + Providers) survives the error. A single + * boundary at `src/app/error.tsx` would replace that shell. (There is no + * in-shell sign-out control; sign-out lives on `/no-access` and `/session-error`.) * * Two details that bite: * diff --git a/src/app/(web)/tools/fieldmanagement/field-management.test.tsx b/src/app/(web)/tools/fieldmanagement/field-management.test.tsx index d038be0..102b3a0 100644 --- a/src/app/(web)/tools/fieldmanagement/field-management.test.tsx +++ b/src/app/(web)/tools/fieldmanagement/field-management.test.tsx @@ -170,7 +170,7 @@ describe('FieldManagement', () => { it('shows column count when tableMetadata has Columns', async () => { mockFetchPageFieldData.mockResolvedValueOnce({ fields: [{ Page_Field_ID: 1 }], - tableMetadata: { Table_Name: 'Contacts', Columns: [{ Name: 'A' }, { Name: 'B' }] }, + tableMetadata: { Name: 'Contacts', Columns: [{ Name: 'A' }, { Name: 'B' }] }, }); await selectPage(); diff --git a/src/components/field-management/actions.test.ts b/src/components/field-management/actions.test.ts index f345f9a..e78597e 100644 --- a/src/components/field-management/actions.test.ts +++ b/src/components/field-management/actions.test.ts @@ -168,7 +168,7 @@ describe('field-management actions', () => { }, ]); mockGetTableMetadata.mockResolvedValueOnce({ - Table_Name: 'Contacts', + Name: 'Contacts', Columns: [ { Name: 'Contact_ID', IsPrimaryKey: true, IsRequired: true }, { Name: 'First_Name', IsPrimaryKey: false, IsRequired: true }, @@ -188,7 +188,7 @@ describe('field-management actions', () => { mockGetSession.mockResolvedValueOnce(authedSession); mockGetPageFields.mockResolvedValueOnce([]); mockGetTableMetadata.mockResolvedValueOnce({ - Table_Name: 'Contacts', + Name: 'Contacts', Columns: [ { Name: 'First_Name', IsPrimaryKey: false, IsRequired: true }, { Name: 'Last_Name', IsPrimaryKey: false, IsRequired: false }, @@ -220,7 +220,7 @@ describe('field-management actions', () => { }, ]); mockGetTableMetadata.mockResolvedValueOnce({ - Table_Name: 'Contacts', + Name: 'Contacts', Columns: [ { Name: 'First_Name', IsPrimaryKey: false, IsRequired: true }, { Name: 'Last_Name', IsPrimaryKey: false, IsRequired: false }, @@ -237,7 +237,7 @@ describe('field-management actions', () => { mockGetSession.mockResolvedValueOnce(authedSession); mockGetPageFields.mockResolvedValueOnce([]); mockGetTableMetadata.mockResolvedValueOnce({ - Table_Name: 'Contacts', + Name: 'Contacts', Columns: [ { Name: 'Contact_ID', IsPrimaryKey: true, IsRequired: true }, { Name: 'First_Name', IsPrimaryKey: false, IsRequired: true }, @@ -283,7 +283,7 @@ describe('field-management actions', () => { }, ]); mockGetTableMetadata.mockResolvedValueOnce({ - Table_Name: 'Contacts', + Name: 'Contacts', Columns: [ { Name: 'General_Section', DataType: 'Separator', IsPrimaryKey: false, IsRequired: false }, { Name: 'First_Name', DataType: 'String', IsPrimaryKey: false, IsRequired: true }, @@ -318,7 +318,7 @@ describe('field-management actions', () => { }, ]); mockGetTableMetadata.mockResolvedValueOnce({ - Table_Name: 'Contacts', + Name: 'Contacts', Columns: [ { Name: 'First_Name', DataType: 'String', IsPrimaryKey: false, IsRequired: false }, { Name: 'Address_Section', DataType: 'Separator', IsPrimaryKey: false, IsRequired: false }, @@ -344,7 +344,7 @@ describe('field-management actions', () => { mockGetSession.mockResolvedValueOnce(authedSession); mockGetPageFields.mockResolvedValueOnce([]); mockGetTableMetadata.mockResolvedValueOnce({ - Table_Name: 'Contacts', + Name: 'Contacts', Columns: [ { Name: 'Weird_Separator', DataType: 'Separator', IsPrimaryKey: false, IsRequired: true }, ], @@ -376,7 +376,7 @@ describe('field-management actions', () => { }, ]); mockGetTableMetadata.mockResolvedValueOnce({ - Table_Name: 'Contacts', + Name: 'Contacts', Columns: [], }); @@ -405,7 +405,7 @@ describe('field-management actions', () => { }, ]); mockGetTableMetadata.mockResolvedValueOnce({ - Table_Name: 'Contacts', + Name: 'Contacts', Columns: [ { Name: 'First_Name', IsPrimaryKey: false, IsRequired: true }, { Name: 'Last_Name', IsPrimaryKey: false, IsRequired: false }, diff --git a/src/components/field-management/field-order-editor.test.tsx b/src/components/field-management/field-order-editor.test.tsx index 1db1dfb..7bb7107 100644 --- a/src/components/field-management/field-order-editor.test.tsx +++ b/src/components/field-management/field-order-editor.test.tsx @@ -193,7 +193,7 @@ describe('FieldOrderEditor', () => { <FieldOrderEditor fields={[]} tableMetadata={{ - Table_Name: 'Contacts', + Name: 'Contacts', Columns: [ { Name: 'First_Name', IsRequired: true }, { Name: 'Last_Name', IsRequired: false }, diff --git a/src/components/layout/auth-wrapper.tsx b/src/components/layout/auth-wrapper.tsx index 91ed6de..99c4eb9 100644 --- a/src/components/layout/auth-wrapper.tsx +++ b/src/components/layout/auth-wrapper.tsx @@ -17,8 +17,8 @@ export async function AuthWrapper({ children }: { children: React.ReactNode }) { } // A session without a userGuid is unusable: every MP lookup keys off userGuid, - // and without it the header avatar/menu never renders — which leaves the user - // with no way to even sign out (the trap behind the better-auth 1.6 regression). + // so the app would render dead with no way to sign out (the trap behind the + // better-auth 1.6 regression). // Route these broken sessions to a recovery page that CAN sign them out, // rather than rendering a dead app. /session-error lives outside the (web) // route group, so it is not wrapped by AuthWrapper and cannot redirect-loop. diff --git a/src/components/shared-actions/README.md b/src/components/shared-actions/README.md index f81624c..3bcec59 100644 --- a/src/components/shared-actions/README.md +++ b/src/components/shared-actions/README.md @@ -17,12 +17,12 @@ Keep actions with their component folder when: ## Examples -**Shared Actions (place here):** -- `user.ts` - Current-user profile lookup used by contexts and components +**Shared Actions (in this folder):** +- `user.ts` - `getCurrentUserProfile`: the calling user's profile (used by `UserProvider` in `src/contexts/user-context.tsx`) +- `domain.ts` - `getMpTimezone`: the MP domain's IANA time zone (used by the group wizard page) - `sign-out.ts` - `handleSignOut`: app sign-out plus MP RP-initiated logout (used by the sign-out forms on `/no-access` and `/session-error`) -- `auth.ts` - Authentication actions used across the app -- `analytics.ts` - Analytics tracking used by multiple features -- `notifications.ts` - Notification system used app-wide + +`user.ts` and `domain.ts` are documented authorization carve-outs (CLAUDE.md rule 12), each justified in the file; `sign-out.ts` reads no MP data. A new shared action that touches MP data must call `AuthorizationService` instead, or carry its own written justification. **Feature-Specific Actions (keep co-located):** - `components/address-labels/actions.ts` - Address-label fetch and mail merge diff --git a/src/contexts/user-context.test.tsx b/src/contexts/user-context.test.tsx index ec171b1..2eabcd9 100644 --- a/src/contexts/user-context.test.tsx +++ b/src/contexts/user-context.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { renderHook, waitFor, act } from '@testing-library/react'; +import { renderHook, waitFor, act, render, screen } from '@testing-library/react'; import { ReactNode } from 'react'; const { mockUseSession, mockGetCurrentUserProfile } = vi.hoisted(() => ({ @@ -233,3 +233,81 @@ describe('UserProvider edge branches', () => { expect(result.current.userProfile).toEqual({ Contact_ID: 7 }); }); }); + +/** + * Lazy fetch: `UserProvider` makes no MP call until a component calls + * `useUser()`, and every consumer shares the one load. + */ +describe('UserProvider lazy fetch', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('makes no profile call when nothing calls useUser()', async () => { + mockUseSession.mockReturnValue({ + data: { user: { id: 'internal-id', userGuid: 'guid-123' } }, + isPending: false, + }); + + render( + <UserProvider> + <p>no consumer</p> + </UserProvider> + ); + + expect(screen.getByText('no consumer')).toBeInTheDocument(); + // Let any (incorrectly) scheduled effects and microtasks flush. + await act(async () => {}); + expect(mockGetCurrentUserProfile).not.toHaveBeenCalled(); + }); + + it('makes exactly one profile call shared by two consumers', async () => { + mockUseSession.mockReturnValue({ + data: { user: { id: 'internal-id', userGuid: 'guid-123' } }, + isPending: false, + }); + mockGetCurrentUserProfile.mockResolvedValue({ First_Name: 'John' }); + + function Consumer({ label }: { label: string }) { + const { userProfile, isLoading } = useUser(); + return <p>{isLoading ? `${label}: loading` : `${label}: ${userProfile?.First_Name}`}</p>; + } + + render( + <UserProvider> + <Consumer label="a" /> + <Consumer label="b" /> + </UserProvider> + ); + + expect(await screen.findByText('a: John')).toBeInTheDocument(); + expect(screen.getByText('b: John')).toBeInTheDocument(); + expect(mockGetCurrentUserProfile).toHaveBeenCalledTimes(1); + }); + + it('fetches only once a consumer mounts later', async () => { + mockUseSession.mockReturnValue({ + data: { user: { id: 'internal-id', userGuid: 'guid-123' } }, + isPending: false, + }); + mockGetCurrentUserProfile.mockResolvedValue({ First_Name: 'John' }); + + function Consumer() { + const { userProfile } = useUser(); + return <p>name: {userProfile?.First_Name ?? '-'}</p>; + } + + const { rerender } = render(<UserProvider>{null}</UserProvider>); + await act(async () => {}); + expect(mockGetCurrentUserProfile).not.toHaveBeenCalled(); + + rerender( + <UserProvider> + <Consumer /> + </UserProvider> + ); + + expect(await screen.findByText('name: John')).toBeInTheDocument(); + expect(mockGetCurrentUserProfile).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/contexts/user-context.tsx b/src/contexts/user-context.tsx index 967cae2..a24dae8 100644 --- a/src/contexts/user-context.tsx +++ b/src/contexts/user-context.tsx @@ -14,12 +14,26 @@ interface UserContextValue { const UserContext = createContext<UserContextValue | undefined>(undefined); +/** + * Internal: lets `useUser()` tell the provider that a consumer exists. + * Kept in its own context so the public `useUser()` return shape is unchanged + * and the stable callback never re-renders consumers. + */ +const UserProfileRequestContext = createContext<(() => void) | undefined>(undefined); + interface UserProviderProps { children: ReactNode; } +/** + * Loads the signed-in user's MP display profile **lazily**: no + * `getCurrentUserProfile()` call is made until a component calls `useUser()`. + * The first consumer flips `requested`; later consumers share the same load + * (setting `requested` to `true` again is a no-op re-render bail-out). + */ export function UserProvider({ children }: UserProviderProps) { const { data: session, isPending } = authClient.useSession(); + const [requested, setRequested] = useState(false); const [userProfile, setUserProfile] = useState<MPUserProfile | null>(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState<Error | null>(null); @@ -28,6 +42,8 @@ export function UserProvider({ children }: UserProviderProps) { // Better Auth generates its own internal user.id, so we use userGuid for MP lookups. const userGuid = (session?.user as { userGuid?: string } | undefined)?.userGuid; + const requestProfile = useCallback(() => setRequested(true), []); + const loadUserProfile = useCallback(async () => { if (!userGuid) { setUserProfile(null); @@ -49,6 +65,7 @@ export function UserProvider({ children }: UserProviderProps) { }, [userGuid]); useEffect(() => { + if (!requested) return; if (!isPending && userGuid) { // eslint-disable-next-line react-hooks/set-state-in-effect loadUserProfile(); @@ -56,7 +73,7 @@ export function UserProvider({ children }: UserProviderProps) { setUserProfile(null); setIsLoading(false); } - }, [userGuid, isPending, loadUserProfile, session]); + }, [requested, userGuid, isPending, loadUserProfile, session]); const refreshUserProfile = useCallback(async () => { await loadUserProfile(); @@ -68,14 +85,23 @@ export function UserProvider({ children }: UserProviderProps) { ); return ( - <UserContext.Provider value={value}> - {children} - </UserContext.Provider> + <UserProfileRequestContext.Provider value={requestProfile}> + <UserContext.Provider value={value}> + {children} + </UserContext.Provider> + </UserProfileRequestContext.Provider> ); } export function useUser() { const context = useContext(UserContext); + const requestProfile = useContext(UserProfileRequestContext); + + // The first mounted consumer triggers the provider's one profile load. + useEffect(() => { + requestProfile?.(); + }, [requestProfile]); + if (context === undefined) { throw new Error("useUser must be used within a UserProvider"); } diff --git a/src/lib/providers/ministry-platform/docs/README.md b/src/lib/providers/ministry-platform/docs/README.md index 323d2da..9b09ffe 100644 --- a/src/lib/providers/ministry-platform/docs/README.md +++ b/src/lib/providers/ministry-platform/docs/README.md @@ -32,6 +32,7 @@ ministry-platform/ │ └── types.ts # Auth-related types ├── services/ # Service layer │ ├── index.ts +│ ├── guards.ts # Path-segment validation (table/proc names, IDs, GUIDs) │ ├── table.service.ts │ ├── procedure.service.ts │ ├── communication.service.ts @@ -39,13 +40,15 @@ ministry-platform/ │ ├── domain.service.ts │ └── file.service.ts ├── models/ # Generated data models (PascalCase, one per MP table) +│ ├── index.ts # Barrel — re-exports interfaces only, NOT the *Schema files │ ├── <Table>.ts # TypeScript interface -│ └── <Table>Schema.ts # Matching Zod schema +│ └── <Table>Schema.ts # Matching Zod schema (import by path) ├── types/ # Hand-written type definitions │ ├── index.ts │ ├── provider.types.ts │ └── user-profile.types.ts ├── utils/ # Utilities +│ ├── index.ts │ ├── http-client.ts │ └── logger.ts ├── scripts/ # Type generation CLI (see scripts/README.md) @@ -85,7 +88,7 @@ await mp.createTableRecords('Contact_Log', [{ ## Environment Variables ```env -MINISTRY_PLATFORM_BASE_URL=https://your-instance.ministryplatform.com +MINISTRY_PLATFORM_BASE_URL=https://your-instance.ministryplatform.com/ministryplatformapi MINISTRY_PLATFORM_CLIENT_ID=your_client_id MINISTRY_PLATFORM_CLIENT_SECRET=your_client_secret @@ -122,14 +125,15 @@ mp.deleteTableRecords<T>(table, ids, params?) ```typescript mp.executeProcedure(procedure, params?) -mp.executeProcedureWithBody(procedure, parameters) +mp.getProcedures(search?) +mp.executeProcedureWithBody(procedure, parameters, queryParams?) ``` ### Communications ```typescript -mp.createCommunication(communication, attachments?) -mp.sendMessage(message, attachments?) +mp.createCommunication(communication, attachments?, params?) // params: { $userId } +mp.sendMessage(message, attachments?, params?) ``` ### Files @@ -139,6 +143,9 @@ mp.uploadFiles(params) mp.getFilesByRecord(params) mp.updateFile(params) mp.deleteFile(params) +mp.getFileContentByUniqueId(params) +mp.getFileMetadata(params) +mp.getFileMetadataByUniqueId(params) ``` ### Metadata diff --git a/src/lib/providers/ministry-platform/helper.test.ts b/src/lib/providers/ministry-platform/helper.test.ts index 2feb593..28facf8 100644 --- a/src/lib/providers/ministry-platform/helper.test.ts +++ b/src/lib/providers/ministry-platform/helper.test.ts @@ -496,7 +496,7 @@ describe('MPHelper', () => { it('should get tables list', async () => { const tables = [ - { Table_ID: 1, Table_Name: 'Contacts', Display_Name: 'Contacts' }, + { Name: 'Contacts', Columns: [] }, ]; mockGetTables.mockResolvedValueOnce(tables); @@ -508,8 +508,8 @@ describe('MPHelper', () => { it('should search tables by name', async () => { const tables = [ - { Table_ID: 1, Table_Name: 'Contacts', Display_Name: 'Contacts' }, - { Table_ID: 2, Table_Name: 'Contact_Log', Display_Name: 'Contact Log' }, + { Name: 'Contacts', Columns: [] }, + { Name: 'Contact_Log', Columns: [] }, ]; mockGetTables.mockResolvedValueOnce(tables); diff --git a/src/lib/providers/ministry-platform/helper.ts b/src/lib/providers/ministry-platform/helper.ts index ca9eef7..c5e6c45 100644 --- a/src/lib/providers/ministry-platform/helper.ts +++ b/src/lib/providers/ministry-platform/helper.ts @@ -176,9 +176,10 @@ export class MPHelper { * }); * * @example - * // Create with Zod validation (recommended) - * import { ContactLogSchema } from '@/lib/providers/ministry-platform/models'; + * // Create with a datetime, validated against the generated schema + * // (its datetime rule accepts MP wall-clock `YYYY-MM-DD HH:MM:SS`) * import { DomainTimezoneService } from '@/services/domainTimezoneService'; + * import { ContactLogSchema } from '@/lib/providers/ministry-platform/models/ContactLogSchema'; * * // MP stores datetimes as wall-clock in the domain's TZ, not UTC. * // Route every datetime write through DomainTimezoneService. @@ -268,7 +269,7 @@ export class MPHelper { * * @example * // Update with Zod validation (recommended) - * import { ContactLogSchema } from '@/lib/providers/ministry-platform/models'; + * import { ContactLogSchema } from '@/lib/providers/ministry-platform/models/ContactLogSchema'; * * const updatedLogs = await mp.updateTableRecords('Contact_Log', [{ * Contact_Log_ID: 67890, diff --git a/src/lib/providers/ministry-platform/models/AccountingCompaniesSchema.ts b/src/lib/providers/ministry-platform/models/AccountingCompaniesSchema.ts index 8912953..f12cd88 100644 --- a/src/lib/providers/ministry-platform/models/AccountingCompaniesSchema.ts +++ b/src/lib/providers/ministry-platform/models/AccountingCompaniesSchema.ts @@ -12,7 +12,7 @@ export const AccountingCompaniesSchema = z.object({ List_Non_Cash_Gifts: z.boolean(), Statement_Footer: z.string().max(500).nullable(), Statement_Letter: z.string().max(2147483647).nullable(), - Statement_Cutoff_Date: z.string().datetime().nullable(), + Statement_Cutoff_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Statement_Cutoff_Automation_ID: z.number().int().nullable(), Standard_Statement: z.number().int().nullable(), Formal_Salutation: z.boolean(), diff --git a/src/lib/providers/ministry-platform/models/ActivityLogSchema.ts b/src/lib/providers/ministry-platform/models/ActivityLogSchema.ts index 8af1cb3..7eb0c64 100644 --- a/src/lib/providers/ministry-platform/models/ActivityLogSchema.ts +++ b/src/lib/providers/ministry-platform/models/ActivityLogSchema.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; export const ActivityLogSchema = z.object({ Activity_Log_ID: z.number().int(), - Activity_Date: z.string().datetime(), + Activity_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Activity_Type: z.string().max(75), Record_Name: z.string().max(75), Contact_ID: z.number().int(), diff --git a/src/lib/providers/ministry-platform/models/ActivityLogStagingSchema.ts b/src/lib/providers/ministry-platform/models/ActivityLogStagingSchema.ts index 23c00b3..9c9e6b4 100644 --- a/src/lib/providers/ministry-platform/models/ActivityLogStagingSchema.ts +++ b/src/lib/providers/ministry-platform/models/ActivityLogStagingSchema.ts @@ -4,7 +4,7 @@ export const ActivityLogStagingSchema = z.object({ Activity_Log_Staging_ID: z.number().int(), Page_ID: z.number().int(), Last_PK: z.number().int(), - Last_Date: z.string().datetime(), + Last_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), }); export type ActivityLogStagingInput = z.infer<typeof ActivityLogStagingSchema>; diff --git a/src/lib/providers/ministry-platform/models/AddressesSchema.ts b/src/lib/providers/ministry-platform/models/AddressesSchema.ts index a82fd08..6087972 100644 --- a/src/lib/providers/ministry-platform/models/AddressesSchema.ts +++ b/src/lib/providers/ministry-platform/models/AddressesSchema.ts @@ -19,11 +19,11 @@ export const AddressesSchema = z.object({ Time_Zone: z.string().max(50).nullable(), Bar_Code: z.string().max(50).nullable(), Area_Code: z.string().max(50).nullable(), - Last_Validation_Attempt: z.string().datetime().nullable(), + Last_Validation_Attempt: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), County: z.string().max(50).nullable(), Validated: z.boolean().nullable(), Do_Not_Validate: z.boolean(), - Last_GeoCode_Attempt: z.string().datetime().nullable(), + Last_GeoCode_Attempt: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Country: z.string().max(100).nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/AudienceMembersSchema.ts b/src/lib/providers/ministry-platform/models/AudienceMembersSchema.ts index 7149796..1f03058 100644 --- a/src/lib/providers/ministry-platform/models/AudienceMembersSchema.ts +++ b/src/lib/providers/ministry-platform/models/AudienceMembersSchema.ts @@ -4,11 +4,11 @@ export const AudienceMembersSchema = z.object({ Audience_Member_ID: z.number().int(), Audience_ID: z.number().int(), Contact_ID: z.number().int(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Forced_Filter: z.boolean(), Active: z.boolean(), - Last_Update_Date: z.string().datetime(), + Last_Update_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), }); export type AudienceMembersInput = z.infer<typeof AudienceMembersSchema>; diff --git a/src/lib/providers/ministry-platform/models/AudiencesSchema.ts b/src/lib/providers/ministry-platform/models/AudiencesSchema.ts index f165108..fd6fb08 100644 --- a/src/lib/providers/ministry-platform/models/AudiencesSchema.ts +++ b/src/lib/providers/ministry-platform/models/AudiencesSchema.ts @@ -6,9 +6,9 @@ export const AudiencesSchema = z.object({ Description: z.string().max(255).nullable(), Processing_Order: z.number().int(), Active: z.boolean(), - Last_Update_Date: z.string().datetime().nullable(), + Last_Update_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Last_Update_Status: z.string().max(256).nullable(), - Next_Update_Date: z.string().datetime().nullable(), + Next_Update_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), }); export type AudiencesInput = z.infer<typeof AudiencesSchema>; diff --git a/src/lib/providers/ministry-platform/models/BackgroundChecks.ts b/src/lib/providers/ministry-platform/models/BackgroundChecks.ts index 258f554..eae88c8 100644 --- a/src/lib/providers/ministry-platform/models/BackgroundChecks.ts +++ b/src/lib/providers/ministry-platform/models/BackgroundChecks.ts @@ -133,6 +133,12 @@ export interface BackgroundChecks { * Max length: 45 characters */ IP_Address?: string /* max 45 chars */ | null; + + Background_Check_InProgress?: string /* ISO datetime */ | null; + + Background_Check_Cancelled?: string /* ISO datetime */ | null; + + Invitation_Guid?: string /* GUID/UUID */ | null; } export type BackgroundChecksRecord = BackgroundChecks; diff --git a/src/lib/providers/ministry-platform/models/BackgroundChecksSchema.ts b/src/lib/providers/ministry-platform/models/BackgroundChecksSchema.ts index 1578ce8..8e3e92b 100644 --- a/src/lib/providers/ministry-platform/models/BackgroundChecksSchema.ts +++ b/src/lib/providers/ministry-platform/models/BackgroundChecksSchema.ts @@ -4,8 +4,8 @@ export const BackgroundChecksSchema = z.object({ Background_Check_ID: z.number().int(), Contact_ID: z.number().int(), Requesting_Ministry: z.number().int().nullable(), - Background_Check_Submitted: z.string().datetime().nullable(), - Background_Check_Returned: z.string().datetime().nullable(), + Background_Check_Submitted: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Background_Check_Returned: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), All_Clear: z.boolean().nullable(), Notes: z.string().max(500).nullable(), Theft: z.boolean().nullable(), @@ -15,7 +15,7 @@ export const BackgroundChecksSchema = z.object({ Battery: z.boolean().nullable(), Traffic: z.boolean().nullable(), Other: z.boolean().nullable(), - Background_Check_Started: z.string().datetime(), + Background_Check_Started: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Reference_Number: z.string().max(50).nullable(), Report_Url: z.string().url().nullable(), Background_Check_Type_ID: z.number().int().nullable(), @@ -24,7 +24,7 @@ export const BackgroundChecksSchema = z.object({ Middle_Name: z.string().max(50).nullable(), Last_Name: z.string().max(50).nullable(), Maiden_Name: z.string().max(50).nullable(), - Date_Of_Birth: z.string().datetime().nullable(), + Date_Of_Birth: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Gender: z.string().max(50).nullable(), Race: z.string().max(50).nullable(), Address_Line_1: z.string().max(128).nullable(), @@ -35,10 +35,13 @@ export const BackgroundChecksSchema = z.object({ DL_Number: z.string().max(50).nullable(), Jurisdiction: z.string().max(128).nullable(), DL_State: z.string().max(50).nullable(), - Background_Check_Expires: z.string().datetime().nullable(), + Background_Check_Expires: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Background_Check_Status_ID: z.number().int().nullable(), - Status_Date: z.string().datetime().nullable(), + Status_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), IP_Address: z.string().max(45).nullable(), + Background_Check_InProgress: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Background_Check_Cancelled: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Invitation_Guid: z.string().uuid().nullable(), }); export type BackgroundChecksInput = z.infer<typeof BackgroundChecksSchema>; diff --git a/src/lib/providers/ministry-platform/models/BatchesSchema.ts b/src/lib/providers/ministry-platform/models/BatchesSchema.ts index bfdc574..490cd8a 100644 --- a/src/lib/providers/ministry-platform/models/BatchesSchema.ts +++ b/src/lib/providers/ministry-platform/models/BatchesSchema.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; export const BatchesSchema = z.object({ Batch_ID: z.number().int(), Batch_Name: z.string().max(75), - Setup_Date: z.string().datetime(), + Setup_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Batch_Total: z.number(), Item_Count: z.number().int(), Batch_Entry_Type_ID: z.number().int(), @@ -11,7 +11,7 @@ export const BatchesSchema = z.object({ Default_Program: z.number().int().nullable(), Source_Event: z.number().int().nullable(), Deposit_ID: z.number().int().nullable(), - Finalize_Date: z.string().datetime().nullable(), + Finalize_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Congregation_ID: z.number().int().nullable(), _Import_Counter: z.number().int().nullable(), _Source_File: z.string().max(50).nullable(), diff --git a/src/lib/providers/ministry-platform/models/BookCheckoutsSchema.ts b/src/lib/providers/ministry-platform/models/BookCheckoutsSchema.ts index e5f71e6..02f5c27 100644 --- a/src/lib/providers/ministry-platform/models/BookCheckoutsSchema.ts +++ b/src/lib/providers/ministry-platform/models/BookCheckoutsSchema.ts @@ -4,9 +4,9 @@ export const BookCheckoutsSchema = z.object({ Book_Checkout_ID: z.number().int(), Book_ID: z.number().int(), Contact_ID: z.number().int(), - Checkout_Date: z.string().datetime(), - Due_Date: z.string().datetime().nullable(), - Date_Returned: z.string().datetime().nullable(), + Checkout_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + Due_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Date_Returned: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Notes: z.string().max(2147483647).nullable(), Is_Returned: z.number().int(), }); diff --git a/src/lib/providers/ministry-platform/models/BooksSchema.ts b/src/lib/providers/ministry-platform/models/BooksSchema.ts index df2164d..9097f32 100644 --- a/src/lib/providers/ministry-platform/models/BooksSchema.ts +++ b/src/lib/providers/ministry-platform/models/BooksSchema.ts @@ -6,7 +6,7 @@ export const BooksSchema = z.object({ ISBN: z.string().max(15).nullable(), Description: z.string().max(255).nullable(), Cost: z.number().nullable(), - Start_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Genre_ID: z.number().int().nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/BuildingsSchema.ts b/src/lib/providers/ministry-platform/models/BuildingsSchema.ts index f46cad5..053486d 100644 --- a/src/lib/providers/ministry-platform/models/BuildingsSchema.ts +++ b/src/lib/providers/ministry-platform/models/BuildingsSchema.ts @@ -5,11 +5,11 @@ export const BuildingsSchema = z.object({ Building_Name: z.string().max(50), Location_ID: z.number().int(), Description: z.string().max(255).nullable(), - Next_Inspection_Date: z.string().datetime().nullable(), + Next_Inspection_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Building_Coordinator: z.number().int().nullable(), - Date_Acquired: z.string().datetime().nullable(), - Date_Constructed: z.string().datetime().nullable(), - Date_Retired: z.string().datetime().nullable(), + Date_Acquired: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Date_Constructed: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Date_Retired: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), }); export type BuildingsInput = z.infer<typeof BuildingsSchema>; diff --git a/src/lib/providers/ministry-platform/models/CareCasesSchema.ts b/src/lib/providers/ministry-platform/models/CareCasesSchema.ts index 910276b..795a0bb 100644 --- a/src/lib/providers/ministry-platform/models/CareCasesSchema.ts +++ b/src/lib/providers/ministry-platform/models/CareCasesSchema.ts @@ -6,8 +6,8 @@ export const CareCasesSchema = z.object({ Description: z.string().max(512).nullable(), Household_ID: z.number().int(), Contact_ID: z.number().int().nullable(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Care_Case_Type_ID: z.number().int(), Location_ID: z.number().int().nullable(), Case_Manager: z.number().int().nullable(), diff --git a/src/lib/providers/ministry-platform/models/ChurchAssociationsSchema.ts b/src/lib/providers/ministry-platform/models/ChurchAssociationsSchema.ts index 0c59bab..4856a4a 100644 --- a/src/lib/providers/ministry-platform/models/ChurchAssociationsSchema.ts +++ b/src/lib/providers/ministry-platform/models/ChurchAssociationsSchema.ts @@ -8,8 +8,8 @@ export const ChurchAssociationsSchema = z.object({ Address_ID: z.number().int().nullable(), Website: z.string().url().nullable(), Year_Established: z.number().int().nullable(), - End_Date: z.string().datetime().nullable(), - Review_Date: z.string().datetime().nullable(), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), + Review_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), }); export type ChurchAssociationsInput = z.infer<typeof ChurchAssociationsSchema>; diff --git a/src/lib/providers/ministry-platform/models/CongregationAuditsSchema.ts b/src/lib/providers/ministry-platform/models/CongregationAuditsSchema.ts index 61ed434..e463ef3 100644 --- a/src/lib/providers/ministry-platform/models/CongregationAuditsSchema.ts +++ b/src/lib/providers/ministry-platform/models/CongregationAuditsSchema.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; export const CongregationAuditsSchema = z.object({ Congregation_Audit_ID: z.number().int(), Household_ID: z.number().int(), - Change_Date: z.string().datetime(), + Change_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Prior_Congregation: z.number().int(), New_Congregation: z.number().int(), Notes: z.string().max(300).nullable(), diff --git a/src/lib/providers/ministry-platform/models/Congregations.ts b/src/lib/providers/ministry-platform/models/Congregations.ts index d29eb8a..4957e8c 100644 --- a/src/lib/providers/ministry-platform/models/Congregations.ts +++ b/src/lib/providers/ministry-platform/models/Congregations.ts @@ -112,11 +112,6 @@ export interface Congregations { */ Vanco_Giving_PCCT?: string /* max 50 chars */ | null; - /** - * Max length: 50 characters - */ - Vanco_Signing_Key?: string /* max 50 chars */ | null; - /** * Max length: 16 characters */ diff --git a/src/lib/providers/ministry-platform/models/CongregationsSchema.ts b/src/lib/providers/ministry-platform/models/CongregationsSchema.ts index 99d9294..6e0db79 100644 --- a/src/lib/providers/ministry-platform/models/CongregationsSchema.ts +++ b/src/lib/providers/ministry-platform/models/CongregationsSchema.ts @@ -4,8 +4,8 @@ export const CongregationsSchema = z.object({ Congregation_ID: z.number().int(), Congregation_Name: z.string().max(50), Description: z.string().max(255).nullable(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Accounting_Company_ID: z.number().int(), Available_Online: z.boolean(), Location_ID: z.number().int().nullable(), @@ -34,7 +34,6 @@ export const CongregationsSchema = z.object({ Event_Registration_Handoff_URL: z.string().url().nullable(), Vanco_Tenant: z.string().max(50).nullable(), Vanco_Giving_PCCT: z.string().max(50).nullable(), - Vanco_Signing_Key: z.string().max(50).nullable(), Organization_Code: z.string().max(16).nullable(), Sacrament_Place_ID: z.number().int().nullable(), Dark_Mode_Logo_URL: z.string().max(500).nullable(), diff --git a/src/lib/providers/ministry-platform/models/ContactAttributesSchema.ts b/src/lib/providers/ministry-platform/models/ContactAttributesSchema.ts index c2955ef..07c44b2 100644 --- a/src/lib/providers/ministry-platform/models/ContactAttributesSchema.ts +++ b/src/lib/providers/ministry-platform/models/ContactAttributesSchema.ts @@ -4,8 +4,8 @@ export const ContactAttributesSchema = z.object({ Contact_Attribute_ID: z.number().int(), Contact_ID: z.number().int(), Attribute_ID: z.number().int(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Notes: z.string().max(2000).nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/ContactHouseholdsSchema.ts b/src/lib/providers/ministry-platform/models/ContactHouseholdsSchema.ts index f2e83b8..5d5c763 100644 --- a/src/lib/providers/ministry-platform/models/ContactHouseholdsSchema.ts +++ b/src/lib/providers/ministry-platform/models/ContactHouseholdsSchema.ts @@ -8,7 +8,7 @@ export const ContactHouseholdsSchema = z.object({ Household_Type_ID: z.number().int().nullable(), Primary_Family: z.boolean(), Notes: z.string().max(2000).nullable(), - End_Date: z.string().datetime().nullable(), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), }); export type ContactHouseholdsInput = z.infer<typeof ContactHouseholdsSchema>; diff --git a/src/lib/providers/ministry-platform/models/ContactIdentifierLogSchema.ts b/src/lib/providers/ministry-platform/models/ContactIdentifierLogSchema.ts index ebfb3e2..8f887dd 100644 --- a/src/lib/providers/ministry-platform/models/ContactIdentifierLogSchema.ts +++ b/src/lib/providers/ministry-platform/models/ContactIdentifierLogSchema.ts @@ -6,7 +6,7 @@ export const ContactIdentifierLogSchema = z.object({ Source_System_Name: z.string().max(50), Source_Type_Name: z.string().max(128), Identifier_Value: z.string().max(4000), - _Date_Inserted: z.string().datetime(), + _Date_Inserted: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), }); export type ContactIdentifierLogInput = z.infer<typeof ContactIdentifierLogSchema>; diff --git a/src/lib/providers/ministry-platform/models/ContactLogSchema.ts b/src/lib/providers/ministry-platform/models/ContactLogSchema.ts index 52323ff..f74c311 100644 --- a/src/lib/providers/ministry-platform/models/ContactLogSchema.ts +++ b/src/lib/providers/ministry-platform/models/ContactLogSchema.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; export const ContactLogSchema = z.object({ Contact_Log_ID: z.number().int(), Contact_ID: z.number().int(), - Contact_Date: z.string().datetime(), + Contact_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Contact_Log_Type_ID: z.number().int().nullable(), Made_By: z.number().int(), Notes: z.string().max(2000), diff --git a/src/lib/providers/ministry-platform/models/ContactPrivateNotesSchema.ts b/src/lib/providers/ministry-platform/models/ContactPrivateNotesSchema.ts index 9fdbb6a..bec75e4 100644 --- a/src/lib/providers/ministry-platform/models/ContactPrivateNotesSchema.ts +++ b/src/lib/providers/ministry-platform/models/ContactPrivateNotesSchema.ts @@ -5,7 +5,7 @@ export const ContactPrivateNotesSchema = z.object({ User_ID: z.number().int(), Contact_ID: z.number().int().nullable(), Notes: z.string().max(2000), - Start_Date: z.string().datetime(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), }); export type ContactPrivateNotesInput = z.infer<typeof ContactPrivateNotesSchema>; diff --git a/src/lib/providers/ministry-platform/models/ContactRelationshipsSchema.ts b/src/lib/providers/ministry-platform/models/ContactRelationshipsSchema.ts index dd4a514..b9469a7 100644 --- a/src/lib/providers/ministry-platform/models/ContactRelationshipsSchema.ts +++ b/src/lib/providers/ministry-platform/models/ContactRelationshipsSchema.ts @@ -5,8 +5,8 @@ export const ContactRelationshipsSchema = z.object({ Contact_ID: z.number().int(), Relationship_ID: z.number().int(), Related_Contact_ID: z.number().int(), - Start_Date: z.string().datetime().nullable(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Notes: z.string().max(255).nullable(), _Triggered_By: z.number().int().nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/ContactStagingSchema.ts b/src/lib/providers/ministry-platform/models/ContactStagingSchema.ts index 25369b1..3a91b26 100644 --- a/src/lib/providers/ministry-platform/models/ContactStagingSchema.ts +++ b/src/lib/providers/ministry-platform/models/ContactStagingSchema.ts @@ -17,14 +17,14 @@ export const ContactStagingSchema = z.object({ Last_Name: z.string().max(50).nullable(), Suffix_ID: z.number().int().nullable(), Nickname: z.string().max(50).nullable(), - Date_of_Birth: z.string().datetime().nullable(), + Date_of_Birth: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Gender_ID: z.number().int().nullable(), Marital_Status_ID: z.number().int().nullable(), Contact_Status_ID: z.number().int(), Existing_Household_Record: z.number().int().nullable(), Household_Position_ID: z.number().int().nullable(), Existing_Participant_Record: z.number().int().nullable(), - Participant_Start_Date: z.string().datetime().nullable(), + Participant_Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Participant_Type_ID: z.number().int().nullable(), Participant_Notes: z.string().max(1000).nullable(), Existing_Donor_Record: z.number().int().nullable(), @@ -36,7 +36,7 @@ export const ContactStagingSchema = z.object({ Fax_Phone: z.string().nullable(), Existing_User_Account: z.number().int().nullable(), Web_Page: z.string().url().nullable(), - Anniversary_Date: z.string().datetime().nullable(), + Anniversary_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), HS_Graduation_Year: z.number().int().nullable(), Home_Phone: z.string().nullable(), Congregation_ID: z.number().int().nullable(), @@ -48,8 +48,8 @@ export const ContactStagingSchema = z.object({ Postal_Code: z.string().max(15).nullable(), Foreign_Country: z.string().max(50).nullable(), ID_Card: z.string().max(50).nullable(), - Date_of_Death: z.string().datetime().nullable(), - _Contact_Setup_Date: z.string().datetime().nullable(), + Date_of_Death: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), + _Contact_Setup_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), }); export type ContactStagingInput = z.infer<typeof ContactStagingSchema>; diff --git a/src/lib/providers/ministry-platform/models/ContactsSchema.ts b/src/lib/providers/ministry-platform/models/ContactsSchema.ts index 2a704c9..a38b93a 100644 --- a/src/lib/providers/ministry-platform/models/ContactsSchema.ts +++ b/src/lib/providers/ministry-platform/models/ContactsSchema.ts @@ -11,14 +11,14 @@ export const ContactsSchema = z.object({ Last_Name: z.string().max(50).nullable(), Suffix_ID: z.number().int().nullable(), Nickname: z.string().max(50).nullable(), - Date_of_Birth: z.string().datetime().nullable(), + Date_of_Birth: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Gender_ID: z.number().int().nullable(), Marital_Status_ID: z.number().int().nullable(), Contact_Status_ID: z.number().int(), Household_ID: z.number().int().nullable(), Household_Position_ID: z.number().int().nullable(), - Anniversary_Date: z.string().datetime().nullable(), - Date_of_Death: z.string().datetime().nullable(), + Anniversary_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), + Date_of_Death: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Participant_Record: z.number().int().nullable(), Donor_Record: z.number().int().nullable(), Email_Address: z.string().email().max(254).nullable(), @@ -41,7 +41,7 @@ export const ContactsSchema = z.object({ User_Account: z.number().int().nullable(), ID_Card: z.string().max(50).nullable(), Contact_GUID: z.string().uuid(), - _Contact_Setup_Date: z.string().datetime(), + _Contact_Setup_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Email_Verified: z.boolean(), Mobile_Phone_Verified: z.boolean(), Maiden_Name: z.string().max(50).nullable(), diff --git a/src/lib/providers/ministry-platform/models/ContributionStatementsSchema.ts b/src/lib/providers/ministry-platform/models/ContributionStatementsSchema.ts index 3791af3..868a2e8 100644 --- a/src/lib/providers/ministry-platform/models/ContributionStatementsSchema.ts +++ b/src/lib/providers/ministry-platform/models/ContributionStatementsSchema.ts @@ -12,8 +12,8 @@ export const ContributionStatementsSchema = z.object({ Archived: z.boolean(), Archived_Campaign: z.number().int().nullable(), Alternate_Archived_Campaign: z.number().int().nullable(), - Last_Change_By_Routine: z.string().datetime(), - Last_Statement_File: z.string().datetime().nullable(), + Last_Change_By_Routine: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + Last_Statement_File: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), }); export type ContributionStatementsInput = z.infer<typeof ContributionStatementsSchema>; diff --git a/src/lib/providers/ministry-platform/models/DeploymentLogs.ts b/src/lib/providers/ministry-platform/models/DeploymentLogs.ts new file mode 100644 index 0000000..8507f54 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/DeploymentLogs.ts @@ -0,0 +1,29 @@ +/** + * Interface for _Deployment_Logs +* Table: _Deployment_Logs + * Access Level: ReadWriteAssignDelete + * Special Permissions: None + * Generated from column metadata + */ +export interface DeploymentLogs { + + Log_ID: number /* 32-bit integer */; // Primary Key + + /** + * Max length: 128 characters + */ + Log_Title: string /* max 128 chars */; + + Log_Date: string /* ISO datetime */; + + /** + * Max length: 2147483647 characters + */ + Log_Content?: string /* max 2147483647 chars */ | null; + + Log_Code?: number /* 32-bit integer */ | null; + + Deployment_ID?: number /* 32-bit integer */ | null; // Foreign Key -> _Deployments.Deployment_ID +} + +export type DeploymentLogsRecord = DeploymentLogs; diff --git a/src/lib/providers/ministry-platform/models/DeploymentLogsSchema.ts b/src/lib/providers/ministry-platform/models/DeploymentLogsSchema.ts new file mode 100644 index 0000000..dcf4de3 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/DeploymentLogsSchema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const DeploymentLogsSchema = z.object({ + Log_ID: z.number().int(), + Log_Title: z.string().max(128), + Log_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + Log_Content: z.string().max(2147483647).nullable(), + Log_Code: z.number().int().nullable(), + Deployment_ID: z.number().int().nullable(), +}); + +export type DeploymentLogsInput = z.infer<typeof DeploymentLogsSchema>; diff --git a/src/lib/providers/ministry-platform/models/DeploymentsSchema.ts b/src/lib/providers/ministry-platform/models/DeploymentsSchema.ts index 36c73b9..b6c0f93 100644 --- a/src/lib/providers/ministry-platform/models/DeploymentsSchema.ts +++ b/src/lib/providers/ministry-platform/models/DeploymentsSchema.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; export const DeploymentsSchema = z.object({ Deployment_ID: z.number().int(), - Deployment_Date: z.string().datetime(), + Deployment_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Application_Code: z.string().max(16), Build_Version: z.string().max(16), Notes: z.string().max(255).nullable(), diff --git a/src/lib/providers/ministry-platform/models/DepositsSchema.ts b/src/lib/providers/ministry-platform/models/DepositsSchema.ts index e2c4a0b..0780f5d 100644 --- a/src/lib/providers/ministry-platform/models/DepositsSchema.ts +++ b/src/lib/providers/ministry-platform/models/DepositsSchema.ts @@ -4,7 +4,7 @@ export const DepositsSchema = z.object({ Deposit_ID: z.number().int(), Deposit_Name: z.string().max(75), Deposit_Total: z.number(), - Deposit_Date: z.string().datetime(), + Deposit_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Account_Number: z.string().max(30), Batch_Count: z.number().int(), Exported: z.boolean(), diff --git a/src/lib/providers/ministry-platform/models/DonationDistributionsSchema.ts b/src/lib/providers/ministry-platform/models/DonationDistributionsSchema.ts index 9f6c605..6dc1a18 100644 --- a/src/lib/providers/ministry-platform/models/DonationDistributionsSchema.ts +++ b/src/lib/providers/ministry-platform/models/DonationDistributionsSchema.ts @@ -13,7 +13,7 @@ export const DonationDistributionsSchema = z.object({ Donation_Source_ID: z.number().int().nullable(), _Vendor_Pledge_Code: z.string().max(16).nullable(), Projected_Gift_Frequency: z.number().int().nullable(), - _Last_Statement_Review: z.string().datetime().nullable(), + _Last_Statement_Review: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Soft_Credit_Statement_ID: z.number().int().nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/DonationsSchema.ts b/src/lib/providers/ministry-platform/models/DonationsSchema.ts index 9b0da6a..123448a 100644 --- a/src/lib/providers/ministry-platform/models/DonationsSchema.ts +++ b/src/lib/providers/ministry-platform/models/DonationsSchema.ts @@ -4,7 +4,7 @@ export const DonationsSchema = z.object({ Donation_ID: z.number().int(), Donor_ID: z.number().int(), Donation_Amount: z.number(), - Donation_Date: z.string().datetime(), + Donation_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Payment_Type_ID: z.number().int(), Item_Number: z.string().max(15).nullable(), Batch_ID: z.number().int().nullable(), @@ -24,7 +24,7 @@ export const DonationsSchema = z.object({ OCR_Data: z.string().max(1000).nullable(), Third_Party_Note: z.string().max(2147483647).nullable(), Statement_ID: z.number().int().nullable(), - _Last_Statement_Review: z.string().datetime().nullable(), + _Last_Statement_Review: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Multiple_Donor_Match: z.boolean(), _Is_Check_Scan: z.boolean().nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/DonorsSchema.ts b/src/lib/providers/ministry-platform/models/DonorsSchema.ts index 5b712e2..5ef31c2 100644 --- a/src/lib/providers/ministry-platform/models/DonorsSchema.ts +++ b/src/lib/providers/ministry-platform/models/DonorsSchema.ts @@ -11,10 +11,10 @@ export const DonorsSchema = z.object({ Notes: z.string().max(500).nullable(), Always_Soft_Credit: z.number().int().nullable(), Always_Pledge_Credit: z.number().int().nullable(), - Setup_Date: z.string().datetime(), + Setup_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), First_Contact_Made: z.boolean().nullable(), - _First_Donation_Date: z.string().datetime().nullable(), - _Last_Donation_Date: z.string().datetime().nullable(), + _First_Donation_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + _Last_Donation_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Donation_Frequency_ID: z.number().int().nullable(), Donation_Level_ID: z.number().int().nullable(), Donor_Code: z.string().max(16).nullable(), diff --git a/src/lib/providers/ministry-platform/models/DpApiClients.ts b/src/lib/providers/ministry-platform/models/DpApiClients.ts index 9206817..e2b78be 100644 --- a/src/lib/providers/ministry-platform/models/DpApiClients.ts +++ b/src/lib/providers/ministry-platform/models/DpApiClients.ts @@ -2,7 +2,7 @@ * Interface for dp_API_Clients * Table: dp_API_Clients * Access Level: ReadWriteAssignDelete - * Special Permissions: FileAttach, DataExport + * Special Permissions: FileAttach, DataExport, SecureRecord * Generated from column metadata */ export interface DpApiClients { diff --git a/src/lib/providers/ministry-platform/models/DpAuditRetentionMessagesSchema.ts b/src/lib/providers/ministry-platform/models/DpAuditRetentionMessagesSchema.ts index 84d87d2..3328b1c 100644 --- a/src/lib/providers/ministry-platform/models/DpAuditRetentionMessagesSchema.ts +++ b/src/lib/providers/ministry-platform/models/DpAuditRetentionMessagesSchema.ts @@ -4,7 +4,7 @@ export const DpAuditRetentionMessagesSchema = z.object({ Audit_Retention_Message_ID: z.number().int(), _Audit_Retention_Policy_ID: z.number().int().nullable(), _Message_Code: z.number().int().nullable(), - _Message_Time: z.string().datetime().nullable(), + _Message_Time: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), _Error_Severity: z.number().int().nullable(), _Message: z.string().max(2000).nullable(), _Message_Sql: z.string().max(1000).nullable(), diff --git a/src/lib/providers/ministry-platform/models/DpAuditRetentionPolicies.ts b/src/lib/providers/ministry-platform/models/DpAuditRetentionPolicies.ts index b490adc..f0616ec 100644 --- a/src/lib/providers/ministry-platform/models/DpAuditRetentionPolicies.ts +++ b/src/lib/providers/ministry-platform/models/DpAuditRetentionPolicies.ts @@ -19,7 +19,7 @@ export interface DpAuditRetentionPolicies { Is_Active: boolean; // Has Default - Detail_Retention_Days?: number /* 32-bit integer */ | null; + Max_Audits_Per_Run: number /* 32-bit integer */; // Has Default } export type DpAuditRetentionPoliciesRecord = DpAuditRetentionPolicies; diff --git a/src/lib/providers/ministry-platform/models/DpAuditRetentionPoliciesSchema.ts b/src/lib/providers/ministry-platform/models/DpAuditRetentionPoliciesSchema.ts index 7573622..d02156e 100644 --- a/src/lib/providers/ministry-platform/models/DpAuditRetentionPoliciesSchema.ts +++ b/src/lib/providers/ministry-platform/models/DpAuditRetentionPoliciesSchema.ts @@ -7,7 +7,7 @@ export const DpAuditRetentionPoliciesSchema = z.object({ Audit_Type_ID: z.number().int().nullable(), Audit_Retention_Days: z.number().int(), Is_Active: z.boolean(), - Detail_Retention_Days: z.number().int().nullable(), + Max_Audits_Per_Run: z.number().int(), }); export type DpAuditRetentionPoliciesInput = z.infer<typeof DpAuditRetentionPoliciesSchema>; diff --git a/src/lib/providers/ministry-platform/models/DpAuditRetentionRuns.ts b/src/lib/providers/ministry-platform/models/DpAuditRetentionRuns.ts new file mode 100644 index 0000000..41eb4ea --- /dev/null +++ b/src/lib/providers/ministry-platform/models/DpAuditRetentionRuns.ts @@ -0,0 +1,33 @@ +/** + * Interface for dp_Audit_Retention_Runs +* Table: dp_Audit_Retention_Runs + * Access Level: ReadWriteAssignDelete + * Special Permissions: DataExport + * Generated from column metadata + */ +export interface DpAuditRetentionRuns { + + Audit_Retention_Run_ID: number /* 32-bit integer */; // Primary Key + + Run_Date: string /* ISO datetime */; + + Active_Erase_Policies: number /* 32-bit integer */; + + Active_Retain_Policies: number /* 32-bit integer */; + + Max_Minutes: number /* 32-bit integer */; + + Policies_Run: number /* 32-bit integer */; + + Duration_Minutes: number /* 32-bit integer */; + + Deleted_Audits: number /* 32-bit integer */; + + Deleted_Details: number /* 32-bit integer */; + + Remaining_Audits: number /* 32-bit integer */; + + Remaining_Details: number /* 32-bit integer */; +} + +export type DpAuditRetentionRunsRecord = DpAuditRetentionRuns; diff --git a/src/lib/providers/ministry-platform/models/DpAuditRetentionRunsSchema.ts b/src/lib/providers/ministry-platform/models/DpAuditRetentionRunsSchema.ts new file mode 100644 index 0000000..51c7539 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/DpAuditRetentionRunsSchema.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +export const DpAuditRetentionRunsSchema = z.object({ + Audit_Retention_Run_ID: z.number().int(), + Run_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + Active_Erase_Policies: z.number().int(), + Active_Retain_Policies: z.number().int(), + Max_Minutes: z.number().int(), + Policies_Run: z.number().int(), + Duration_Minutes: z.number().int(), + Deleted_Audits: z.number().int(), + Deleted_Details: z.number().int(), + Remaining_Audits: z.number().int(), + Remaining_Details: z.number().int(), +}); + +export type DpAuditRetentionRunsInput = z.infer<typeof DpAuditRetentionRunsSchema>; diff --git a/src/lib/providers/ministry-platform/models/DpAuthenticationLogSchema.ts b/src/lib/providers/ministry-platform/models/DpAuthenticationLogSchema.ts index 47fa7d8..2991cd3 100644 --- a/src/lib/providers/ministry-platform/models/DpAuthenticationLogSchema.ts +++ b/src/lib/providers/ministry-platform/models/DpAuthenticationLogSchema.ts @@ -5,11 +5,11 @@ export const DpAuthenticationLogSchema = z.object({ User_Name: z.string().max(256), Server_Name: z.string().max(75), IP_Address: z.string().max(50), - Date_Time: z.string().datetime(), + Date_Time: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), User_ID: z.number().int().nullable(), Referrer: z.string().max(2048).nullable(), Keep_Signed_In: z.boolean(), - Last_Token_Request: z.string().datetime().nullable(), + Last_Token_Request: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), }); export type DpAuthenticationLogInput = z.infer<typeof DpAuthenticationLogSchema>; diff --git a/src/lib/providers/ministry-platform/models/DpCharts.ts b/src/lib/providers/ministry-platform/models/DpCharts.ts new file mode 100644 index 0000000..44e0f40 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/DpCharts.ts @@ -0,0 +1,45 @@ +/** + * Interface for dp_Charts +* Table: dp_Charts + * Access Level: Read + * Special Permissions: None + * Generated from column metadata + */ +export interface DpCharts { + + Chart_ID: number /* 32-bit integer */; // Primary Key + + Page_ID: number /* 32-bit integer */; // Foreign Key -> dp_Pages.Page_ID + + /** + * Max length: 50 characters + */ + Chart_Name: string /* max 50 chars */; + + /** + * Max length: 2000 characters + */ + Label_Field: string /* max 2000 chars */; + + /** + * Max length: 2000 characters + */ + Data_Field: string /* max 2000 chars */; + + /** + * Max length: 256 characters + */ + Sort_Field?: string /* max 256 chars */ | null; + + Aggregation_Type_ID: number /* 32-bit integer */; // Foreign Key -> dp_Aggregation_Types.Aggregation_Type_ID + + Default_Chart_Type_ID: number /* 32-bit integer */; // Foreign Key -> dp_Chart_Types.Chart_Type_ID + + Position: number /* 32-bit integer */; // Has Default + + Active: boolean; // Has Default + + Cache_Minutes: number /* 16-bit integer */; // Has Default +} + +export type DpChartsRecord = DpCharts; diff --git a/src/lib/providers/ministry-platform/models/DpChartsSchema.ts b/src/lib/providers/ministry-platform/models/DpChartsSchema.ts new file mode 100644 index 0000000..390e719 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/DpChartsSchema.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +export const DpChartsSchema = z.object({ + Chart_ID: z.number().int(), + Page_ID: z.number().int(), + Chart_Name: z.string().max(50), + Label_Field: z.string().max(2000), + Data_Field: z.string().max(2000), + Sort_Field: z.string().max(256).nullable(), + Aggregation_Type_ID: z.number().int(), + Default_Chart_Type_ID: z.number().int(), + Position: z.number().int(), + Active: z.boolean(), + Cache_Minutes: z.number().int(), +}); + +export type DpChartsInput = z.infer<typeof DpChartsSchema>; diff --git a/src/lib/providers/ministry-platform/models/DpCommunicationMessagesArchived.ts b/src/lib/providers/ministry-platform/models/DpCommunicationMessagesArchived.ts new file mode 100644 index 0000000..875ce82 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/DpCommunicationMessagesArchived.ts @@ -0,0 +1,58 @@ +/** + * Interface for dp_Communication_Messages_Archived +* Table: dp_Communication_Messages_Archived + * Access Level: ReadWriteAssign + * Special Permissions: DataExport + * Generated from column metadata + */ +export interface DpCommunicationMessagesArchived { + + Communication_Message_Archived_ID: number /* 32-bit integer */; // Primary Key + + Original_Communication_Message_ID: number /* 32-bit integer */; + + Original_Communication_ID: number /* 32-bit integer */; // Foreign Key -> dp_Communications.Communication_ID + + Action_Status_ID: number /* 32-bit integer */; // Foreign Key -> dp_Communication_Action_Statuses.Action_Status_ID + + Action_Status_Time: string /* ISO datetime */; + + Message_Sent_Time?: string /* ISO datetime */ | null; + + /** + * Max length: 1024 characters + */ + Action_Text?: string /* max 1024 chars */ | null; + + Contact_ID?: number /* 32-bit integer */ | null; // Foreign Key -> Contacts.Contact_ID + + /** + * Max length: 256 characters + */ + From: string /* max 256 chars */; + + /** + * Max length: 256 characters + */ + To: string /* max 256 chars */; + + /** + * Max length: 256 characters + */ + Reply_To?: string /* max 256 chars */ | null; + + /** + * Max length: 1000 characters + */ + Subject?: string /* max 1000 chars */ | null; + + Deleted: boolean; // Has Default + + _Date_Submitted?: string /* ISO datetime */ | null; // Read Only + + _Priority: number /* 32-bit integer */; // Read Only, Has Default + + _Text_Segments?: number /* 32-bit integer */ | null; // Read Only +} + +export type DpCommunicationMessagesArchivedRecord = DpCommunicationMessagesArchived; diff --git a/src/lib/providers/ministry-platform/models/DpCommunicationMessagesArchivedSchema.ts b/src/lib/providers/ministry-platform/models/DpCommunicationMessagesArchivedSchema.ts new file mode 100644 index 0000000..f7e3358 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/DpCommunicationMessagesArchivedSchema.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +export const DpCommunicationMessagesArchivedSchema = z.object({ + Communication_Message_Archived_ID: z.number().int(), + Original_Communication_Message_ID: z.number().int(), + Original_Communication_ID: z.number().int(), + Action_Status_ID: z.number().int(), + Action_Status_Time: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + Message_Sent_Time: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Action_Text: z.string().max(1024).nullable(), + Contact_ID: z.number().int().nullable(), + From: z.string().max(256), + To: z.string().max(256), + Reply_To: z.string().max(256).nullable(), + Subject: z.string().max(1000).nullable(), + Deleted: z.boolean(), + _Date_Submitted: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + _Priority: z.number().int(), + _Text_Segments: z.number().int().nullable(), +}); + +export type DpCommunicationMessagesArchivedInput = z.infer<typeof DpCommunicationMessagesArchivedSchema>; diff --git a/src/lib/providers/ministry-platform/models/DpCommunicationMessagesSchema.ts b/src/lib/providers/ministry-platform/models/DpCommunicationMessagesSchema.ts index a6b7739..a07fef1 100644 --- a/src/lib/providers/ministry-platform/models/DpCommunicationMessagesSchema.ts +++ b/src/lib/providers/ministry-platform/models/DpCommunicationMessagesSchema.ts @@ -4,7 +4,7 @@ export const DpCommunicationMessagesSchema = z.object({ Communication_Message_ID: z.number().int(), Communication_ID: z.number().int(), Action_Status_ID: z.number().int(), - Action_Status_Time: z.string().datetime(), + Action_Status_Time: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Action_Text: z.string().max(1024).nullable(), Contact_ID: z.number().int().nullable(), From: z.string().max(256), @@ -13,7 +13,7 @@ export const DpCommunicationMessagesSchema = z.object({ Subject: z.string().max(500).nullable(), Body: z.string().max(2147483647).nullable(), Deleted: z.boolean(), - _Date_Submitted: z.string().datetime().nullable(), + _Date_Submitted: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), _Priority: z.number().int(), _Text_Segments: z.number().int().nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/DpCommunicationsSchema.ts b/src/lib/providers/ministry-platform/models/DpCommunicationsSchema.ts index b4f2ce0..0ff6d0c 100644 --- a/src/lib/providers/ministry-platform/models/DpCommunicationsSchema.ts +++ b/src/lib/providers/ministry-platform/models/DpCommunicationsSchema.ts @@ -15,13 +15,13 @@ export const DpCommunicationsSchema = z.object({ From_SMS_Number: z.number().int().nullable(), From_Contact: z.number().int(), Reply_to_Contact: z.number().int(), - Start_Date: z.string().datetime(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Time_Zone: z.unknown().nullable(), Locale: z.unknown().nullable(), Bulk_Email: z.boolean(), Template: z.boolean(), Active: z.boolean(), - Expire_Date: z.string().datetime().nullable(), + Expire_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Template_User: z.number().int().nullable(), Template_User_Group: z.number().int().nullable(), Communication_GUID: z.string().uuid(), diff --git a/src/lib/providers/ministry-platform/models/DpExportLogSchema.ts b/src/lib/providers/ministry-platform/models/DpExportLogSchema.ts index 5f7ae21..237acc8 100644 --- a/src/lib/providers/ministry-platform/models/DpExportLogSchema.ts +++ b/src/lib/providers/ministry-platform/models/DpExportLogSchema.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; export const DpExportLogSchema = z.object({ Export_ID: z.number().int(), - Date_Time: z.string().datetime(), + Date_Time: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), User_Name: z.string().max(256).nullable(), User_ID: z.number().int(), Table_Name: z.unknown(), diff --git a/src/lib/providers/ministry-platform/models/DpInboundSmsSchema.ts b/src/lib/providers/ministry-platform/models/DpInboundSmsSchema.ts index 859a87b..7d3d64a 100644 --- a/src/lib/providers/ministry-platform/models/DpInboundSmsSchema.ts +++ b/src/lib/providers/ministry-platform/models/DpInboundSmsSchema.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; export const DpInboundSmsSchema = z.object({ Inbound_SMS_ID: z.number().int(), - Event_Time: z.string().datetime(), + Event_Time: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Message_ID: z.string().max(64), Message_From: z.string().max(13), Message_To: z.string().max(1024), diff --git a/src/lib/providers/ministry-platform/models/DpNotificationsSchema.ts b/src/lib/providers/ministry-platform/models/DpNotificationsSchema.ts index a36fc3d..d94da34 100644 --- a/src/lib/providers/ministry-platform/models/DpNotificationsSchema.ts +++ b/src/lib/providers/ministry-platform/models/DpNotificationsSchema.ts @@ -4,8 +4,8 @@ export const DpNotificationsSchema = z.object({ Notification_ID: z.number().int(), User_ID: z.number().int(), Recurrence_Name: z.string().max(256), - Last_Execution: z.string().datetime().nullable(), - Next_Execution: z.string().datetime().nullable(), + Last_Execution: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Next_Execution: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Notification_Type: z.unknown(), Template_ID: z.number().int().nullable(), User_Group_ID: z.number().int().nullable(), diff --git a/src/lib/providers/ministry-platform/models/DpProcessSubmissionsSchema.ts b/src/lib/providers/ministry-platform/models/DpProcessSubmissionsSchema.ts index d1ef4d1..f59a128 100644 --- a/src/lib/providers/ministry-platform/models/DpProcessSubmissionsSchema.ts +++ b/src/lib/providers/ministry-platform/models/DpProcessSubmissionsSchema.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; export const DpProcessSubmissionsSchema = z.object({ Process_Submission_ID: z.number().int(), Submitted_By: z.number().int(), - Date_Submitted: z.string().datetime(), + Date_Submitted: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Process_ID: z.number().int(), Process_Submission_Status_ID: z.number().int(), _Record_ID: z.number().int(), diff --git a/src/lib/providers/ministry-platform/models/DpPublicationsSchema.ts b/src/lib/providers/ministry-platform/models/DpPublicationsSchema.ts index 3dc2e88..7444099 100644 --- a/src/lib/providers/ministry-platform/models/DpPublicationsSchema.ts +++ b/src/lib/providers/ministry-platform/models/DpPublicationsSchema.ts @@ -9,7 +9,7 @@ export const DpPublicationsSchema = z.object({ Available_Online: z.boolean().nullable(), Online_Sort_Order: z.number().int().nullable(), MailChimp_List: z.string().max(50).nullable(), - Last_Sync_Date: z.string().datetime().nullable(), + Last_Sync_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Sync_Nightly: z.boolean(), Name: z.string().max(25), On_Connection_Card: z.boolean(), diff --git a/src/lib/providers/ministry-platform/models/DpRoles.ts b/src/lib/providers/ministry-platform/models/DpRoles.ts index bcba1c3..104b562 100644 --- a/src/lib/providers/ministry-platform/models/DpRoles.ts +++ b/src/lib/providers/ministry-platform/models/DpRoles.ts @@ -28,6 +28,8 @@ export interface DpRoles { Parish_Assignable: boolean; // Has Default Texting_Override: boolean; // Has Default + + Texting_Character_Limit: number /* 32-bit integer */; // Has Default } export type DpRolesRecord = DpRoles; diff --git a/src/lib/providers/ministry-platform/models/DpRolesSchema.ts b/src/lib/providers/ministry-platform/models/DpRolesSchema.ts index 2aed07d..769c62a 100644 --- a/src/lib/providers/ministry-platform/models/DpRolesSchema.ts +++ b/src/lib/providers/ministry-platform/models/DpRolesSchema.ts @@ -9,6 +9,7 @@ export const DpRolesSchema = z.object({ Role_Type_ID: z.number().int().nullable(), Parish_Assignable: z.boolean(), Texting_Override: z.boolean(), + Texting_Character_Limit: z.number().int(), }); export type DpRolesInput = z.infer<typeof DpRolesSchema>; diff --git a/src/lib/providers/ministry-platform/models/DpTasksSchema.ts b/src/lib/providers/ministry-platform/models/DpTasksSchema.ts index 5742e97..02b07cd 100644 --- a/src/lib/providers/ministry-platform/models/DpTasksSchema.ts +++ b/src/lib/providers/ministry-platform/models/DpTasksSchema.ts @@ -5,8 +5,8 @@ export const DpTasksSchema = z.object({ Title: z.string().max(75).nullable(), Author_User_ID: z.number().int(), Assigned_User_ID: z.number().int().nullable(), - Start_Date: z.string().datetime().nullable(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Completed: z.boolean(), Description: z.string().max(2147483647).nullable(), _Record_ID: z.number().int().nullable(), diff --git a/src/lib/providers/ministry-platform/models/DpWebhookInvocationsSchema.ts b/src/lib/providers/ministry-platform/models/DpWebhookInvocationsSchema.ts index 26ae882..074f1e6 100644 --- a/src/lib/providers/ministry-platform/models/DpWebhookInvocationsSchema.ts +++ b/src/lib/providers/ministry-platform/models/DpWebhookInvocationsSchema.ts @@ -4,8 +4,8 @@ export const DpWebhookInvocationsSchema = z.object({ Webhook_Invocation_ID: z.number().int(), Webhook_ID: z.number().int(), Record_ID: z.number().int(), - Created: z.string().datetime(), - Updated: z.string().datetime().nullable(), + Created: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + Updated: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Status_ID: z.number().int(), Retries_Left: z.unknown(), Uri: z.string().url().nullable(), diff --git a/src/lib/providers/ministry-platform/models/EquipmentSchema.ts b/src/lib/providers/ministry-platform/models/EquipmentSchema.ts index ac8db2f..7ac6edc 100644 --- a/src/lib/providers/ministry-platform/models/EquipmentSchema.ts +++ b/src/lib/providers/ministry-platform/models/EquipmentSchema.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; export const EquipmentSchema = z.object({ Equipment_ID: z.number().int(), Equipment_Name: z.string().max(50), - Date_Acquired: z.string().datetime(), + Date_Acquired: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Equipment_Type_ID: z.number().int().nullable(), Room_ID: z.number().int(), Model_Name: z.string().max(50).nullable(), @@ -12,7 +12,7 @@ export const EquipmentSchema = z.object({ Bookable: z.boolean(), Separately_Insured: z.boolean(), Purchase_Price: z.number().nullable(), - Date_Retired: z.string().datetime().nullable(), + Date_Retired: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Equipment_Coordinator: z.number().int().nullable(), Auto_Approve: z.boolean(), Quantity: z.number().int(), diff --git a/src/lib/providers/ministry-platform/models/EventParticipantsSchema.ts b/src/lib/providers/ministry-platform/models/EventParticipantsSchema.ts index ee29fbe..926ecc4 100644 --- a/src/lib/providers/ministry-platform/models/EventParticipantsSchema.ts +++ b/src/lib/providers/ministry-platform/models/EventParticipantsSchema.ts @@ -5,13 +5,13 @@ export const EventParticipantsSchema = z.object({ Event_ID: z.number().int(), Participant_ID: z.number().int(), Participation_Status_ID: z.number().int(), - Time_In: z.string().datetime().nullable(), - Time_Confirmed: z.string().datetime().nullable(), - Time_Out: z.string().datetime().nullable(), + Time_In: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Time_Confirmed: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Time_Out: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Notes: z.string().max(4000).nullable(), Group_Participant_ID: z.number().int().nullable(), "Check-in_Station": z.string().max(50).nullable(), - _Setup_Date: z.string().datetime().nullable(), + _Setup_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Group_ID: z.number().int().nullable(), Room_ID: z.number().int().nullable(), Call_Parents: z.boolean().nullable(), diff --git a/src/lib/providers/ministry-platform/models/EventsSchema.ts b/src/lib/providers/ministry-platform/models/EventsSchema.ts index eec398a..202ced3 100644 --- a/src/lib/providers/ministry-platform/models/EventsSchema.ts +++ b/src/lib/providers/ministry-platform/models/EventsSchema.ts @@ -12,8 +12,8 @@ export const EventsSchema = z.object({ Primary_Contact: z.number().int(), Participants_Expected: z.number().int().nullable(), Minutes_for_Setup: z.number().int(), - Event_Start_Date: z.string().datetime(), - Event_End_Date: z.string().datetime(), + Event_Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + Event_End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Minutes_for_Cleanup: z.number().int(), Cancelled: z.boolean(), _Approved: z.boolean().nullable(), @@ -21,8 +21,8 @@ export const EventsSchema = z.object({ Featured_On_Calendar: z.boolean(), Online_Registration_Product: z.number().int().nullable(), Registration_Form: z.number().int().nullable(), - Registration_Start: z.string().datetime().nullable(), - Registration_End: z.string().datetime().nullable(), + Registration_Start: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Registration_End: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Registration_Active: z.boolean(), Register_Into_Series: z.boolean(), External_Registration_URL: z.string().url().nullable(), diff --git a/src/lib/providers/ministry-platform/models/FeedbackEntriesSchema.ts b/src/lib/providers/ministry-platform/models/FeedbackEntriesSchema.ts index 7a2cfc4..4812725 100644 --- a/src/lib/providers/ministry-platform/models/FeedbackEntriesSchema.ts +++ b/src/lib/providers/ministry-platform/models/FeedbackEntriesSchema.ts @@ -6,13 +6,13 @@ export const FeedbackEntriesSchema = z.object({ Entry_Title: z.string().max(50), Feedback_Type_ID: z.number().int(), Program_ID: z.number().int().nullable(), - Date_Submitted: z.string().datetime(), + Date_Submitted: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Visibility_Level_ID: z.number().int(), Description: z.string().max(2000).nullable(), Ongoing_Need: z.boolean(), Assigned_To: z.number().int().nullable(), Care_Outcome_ID: z.number().int().nullable(), - Outcome_Date: z.string().datetime().nullable(), + Outcome_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Approved: z.boolean(), Care_Case_ID: z.number().int().nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/FormResponsesSchema.ts b/src/lib/providers/ministry-platform/models/FormResponsesSchema.ts index 9d9665e..8b2f50d 100644 --- a/src/lib/providers/ministry-platform/models/FormResponsesSchema.ts +++ b/src/lib/providers/ministry-platform/models/FormResponsesSchema.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; export const FormResponsesSchema = z.object({ Form_Response_ID: z.number().int(), Form_ID: z.number().int(), - Response_Date: z.string().datetime(), + Response_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), IP_Address: z.string().max(45).nullable(), Contact_ID: z.number().int().nullable(), First_Name: z.string().max(50).nullable(), @@ -21,7 +21,7 @@ export const FormResponsesSchema = z.object({ Opportunity_Response: z.number().int().nullable(), Congregation_ID: z.number().int().nullable(), Notification_Sent: z.boolean(), - Expires: z.string().datetime().nullable(), + Expires: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Event_Participant_ID: z.number().int().nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/Forms.ts b/src/lib/providers/ministry-platform/models/Forms.ts index 284c627..a2d4593 100644 --- a/src/lib/providers/ministry-platform/models/Forms.ts +++ b/src/lib/providers/ministry-platform/models/Forms.ts @@ -45,6 +45,10 @@ export interface Forms { Expiring_Soon_Days?: number /* 32-bit integer */ | null; Force_Login: boolean; // Has Default + + Product_ID?: number /* 32-bit integer */ | null; // Foreign Key -> Products.Product_ID + + Program_ID?: number /* 32-bit integer */ | null; // Foreign Key -> Programs.Program_ID } export type FormsRecord = Forms; diff --git a/src/lib/providers/ministry-platform/models/FormsSchema.ts b/src/lib/providers/ministry-platform/models/FormsSchema.ts index f4632d9..3bd0d47 100644 --- a/src/lib/providers/ministry-platform/models/FormsSchema.ts +++ b/src/lib/providers/ministry-platform/models/FormsSchema.ts @@ -8,7 +8,7 @@ export const FormsSchema = z.object({ Get_Contact_Info: z.boolean(), Get_Address_Info: z.boolean(), Form_GUID: z.string().uuid(), - End_Date: z.string().datetime().nullable(), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Complete_Message: z.string().max(2147483647).nullable(), Primary_Contact: z.number().int().nullable(), Notify: z.boolean(), @@ -16,6 +16,8 @@ export const FormsSchema = z.object({ Months_Till_Expires: z.number().int().nullable(), Expiring_Soon_Days: z.number().int().nullable(), Force_Login: z.boolean(), + Product_ID: z.number().int().nullable(), + Program_ID: z.number().int().nullable(), }); export type FormsInput = z.infer<typeof FormsSchema>; diff --git a/src/lib/providers/ministry-platform/models/GroupAttributesSchema.ts b/src/lib/providers/ministry-platform/models/GroupAttributesSchema.ts index a4f844f..0459339 100644 --- a/src/lib/providers/ministry-platform/models/GroupAttributesSchema.ts +++ b/src/lib/providers/ministry-platform/models/GroupAttributesSchema.ts @@ -4,8 +4,8 @@ export const GroupAttributesSchema = z.object({ Group_Attribute_ID: z.number().int(), Attribute_ID: z.number().int(), Group_ID: z.number().int(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Notes: z.string().max(255).nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/GroupInquiriesSchema.ts b/src/lib/providers/ministry-platform/models/GroupInquiriesSchema.ts index 5276455..e71b293 100644 --- a/src/lib/providers/ministry-platform/models/GroupInquiriesSchema.ts +++ b/src/lib/providers/ministry-platform/models/GroupInquiriesSchema.ts @@ -4,7 +4,7 @@ export const GroupInquiriesSchema = z.object({ Group_Inquiry_ID: z.number().int(), Group_ID: z.number().int(), Contact_ID: z.number().int().nullable(), - Inquiry_Date: z.string().datetime(), + Inquiry_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), First_Name: z.string().max(50).nullable(), Last_Name: z.string().max(50).nullable(), Phone: z.string().nullable(), diff --git a/src/lib/providers/ministry-platform/models/GroupParticipantsSchema.ts b/src/lib/providers/ministry-platform/models/GroupParticipantsSchema.ts index 498a1d6..92797f0 100644 --- a/src/lib/providers/ministry-platform/models/GroupParticipantsSchema.ts +++ b/src/lib/providers/ministry-platform/models/GroupParticipantsSchema.ts @@ -5,8 +5,8 @@ export const GroupParticipantsSchema = z.object({ Group_ID: z.number().int(), Participant_ID: z.number().int(), Group_Role_ID: z.number().int(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Employee_Role: z.boolean(), Hours_Per_Week: z.number().int().nullable(), Notes: z.string().max(500).nullable(), @@ -14,10 +14,10 @@ export const GroupParticipantsSchema = z.object({ Email_Opt_Out: z.boolean(), Share_With_Group: z.boolean(), Auto_Promote: z.boolean(), - _First_Attendance: z.string().datetime().nullable(), - _Second_Attendance: z.string().datetime().nullable(), - _Third_Attendance: z.string().datetime().nullable(), - _Last_Attendance: z.string().datetime().nullable(), + _First_Attendance: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + _Second_Attendance: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + _Third_Attendance: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + _Last_Attendance: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Show_Email: z.boolean(), Show_Phone: z.boolean(), Show_Last_Name: z.boolean(), diff --git a/src/lib/providers/ministry-platform/models/GroupsSchema.ts b/src/lib/providers/ministry-platform/models/GroupsSchema.ts index c16c3fd..f23dcb4 100644 --- a/src/lib/providers/ministry-platform/models/GroupsSchema.ts +++ b/src/lib/providers/ministry-platform/models/GroupsSchema.ts @@ -8,8 +8,8 @@ export const GroupsSchema = z.object({ Congregation_ID: z.number().int(), Primary_Contact: z.number().int(), Description: z.string().max(2000).nullable(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Target_Size: z.number().int().nullable(), Parent_Group: z.number().int().nullable(), Priority_ID: z.number().int().nullable(), @@ -26,8 +26,8 @@ export const GroupsSchema = z.object({ Required_Book: z.number().int().nullable(), Descended_From: z.number().int().nullable(), Reason_Ended: z.number().int().nullable(), - _Last_Attendance_Posted: z.string().datetime().nullable(), - _Last_Group_Member_Changed: z.string().datetime().nullable(), + _Last_Attendance_Posted: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + _Last_Group_Member_Changed: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), "Secure_Check-in": z.boolean(), Suppress_Nametag: z.boolean(), Suppress_Care_Note: z.boolean(), @@ -35,7 +35,7 @@ export const GroupsSchema = z.object({ Promote_to_Group: z.number().int().nullable(), Age_in_Months_to_Promote: z.number().int().nullable(), Promote_Weekly: z.boolean(), - Promotion_Date: z.string().datetime().nullable(), + Promotion_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Promote_Participants_Only: z.boolean(), Send_Attendance_Notification: z.boolean(), Send_Service_Notification: z.boolean(), @@ -43,7 +43,7 @@ export const GroupsSchema = z.object({ SMS_Number: z.number().int().nullable(), Default_Meeting_Room: z.number().int().nullable(), Create_Next_Meeting: z.boolean(), - Next_Scheduled_Meeting: z.string().datetime().nullable(), + Next_Scheduled_Meeting: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Available_On_App: z.boolean().nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/HouseholdCareLogSchema.ts b/src/lib/providers/ministry-platform/models/HouseholdCareLogSchema.ts index c2cc0cb..309eabe 100644 --- a/src/lib/providers/ministry-platform/models/HouseholdCareLogSchema.ts +++ b/src/lib/providers/ministry-platform/models/HouseholdCareLogSchema.ts @@ -5,7 +5,7 @@ export const HouseholdCareLogSchema = z.object({ Household_ID: z.number().int(), Care_Type_ID: z.number().int(), Care_Outcome_ID: z.number().int(), - Date_Provided: z.string().datetime().nullable(), + Date_Provided: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Provided_By: z.number().int(), Notes: z.string().max(2000).nullable(), Care_Amount: z.number().nullable(), @@ -14,7 +14,7 @@ export const HouseholdCareLogSchema = z.object({ PRIVATE: z.boolean(), Contact_ID: z.number().int().nullable(), Completed: z.boolean(), - Action_Date: z.string().datetime().nullable(), + Action_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), }); export type HouseholdCareLogInput = z.infer<typeof HouseholdCareLogSchema>; diff --git a/src/lib/providers/ministry-platform/models/HouseholdIdentifierLogSchema.ts b/src/lib/providers/ministry-platform/models/HouseholdIdentifierLogSchema.ts index b28aacd..b4e8fd4 100644 --- a/src/lib/providers/ministry-platform/models/HouseholdIdentifierLogSchema.ts +++ b/src/lib/providers/ministry-platform/models/HouseholdIdentifierLogSchema.ts @@ -6,7 +6,7 @@ export const HouseholdIdentifierLogSchema = z.object({ Source_System_Name: z.string().max(50), Source_Type_Name: z.string().max(128), Identifier_Value: z.string().max(4000), - _Date_Inserted: z.string().datetime(), + _Date_Inserted: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), }); export type HouseholdIdentifierLogInput = z.infer<typeof HouseholdIdentifierLogSchema>; diff --git a/src/lib/providers/ministry-platform/models/HouseholdsSchema.ts b/src/lib/providers/ministry-platform/models/HouseholdsSchema.ts index 6b15340..5bba643 100644 --- a/src/lib/providers/ministry-platform/models/HouseholdsSchema.ts +++ b/src/lib/providers/ministry-platform/models/HouseholdsSchema.ts @@ -12,12 +12,12 @@ export const HouseholdsSchema = z.object({ Home_Phone_Unlisted: z.boolean(), Home_Address_Unlisted: z.boolean(), Bulk_Mail_Opt_Out: z.boolean(), - _First_Donation: z.string().datetime().nullable(), - _Last_Donation: z.string().datetime().nullable(), - _Last_Activity: z.string().datetime().nullable(), + _First_Donation: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + _Last_Donation: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + _Last_Activity: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Alternate_Mailing_Address: z.number().int().nullable(), - Season_Start: z.string().datetime().nullable(), - Season_End: z.string().datetime().nullable(), + Season_Start: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), + Season_End: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Repeats_Annually: z.boolean(), Driving_Distance: z.number().nullable(), Driving_Time: z.number().nullable(), diff --git a/src/lib/providers/ministry-platform/models/Invoices.ts b/src/lib/providers/ministry-platform/models/Invoices.ts index c081b0c..0d88c13 100644 --- a/src/lib/providers/ministry-platform/models/Invoices.ts +++ b/src/lib/providers/ministry-platform/models/Invoices.ts @@ -37,6 +37,8 @@ export interface Invoices { * Max length: 255 characters */ Cancel_Reason?: string /* max 255 chars */ | null; + + Form_Response_ID?: number /* 32-bit integer */ | null; // Foreign Key -> Form_Responses.Form_Response_ID } export type InvoicesRecord = Invoices; diff --git a/src/lib/providers/ministry-platform/models/InvoicesSchema.ts b/src/lib/providers/ministry-platform/models/InvoicesSchema.ts index 6ab8f18..89c0d43 100644 --- a/src/lib/providers/ministry-platform/models/InvoicesSchema.ts +++ b/src/lib/providers/ministry-platform/models/InvoicesSchema.ts @@ -5,13 +5,14 @@ export const InvoicesSchema = z.object({ Purchaser_Contact_ID: z.number().int(), Invoice_Status_ID: z.number().int(), Invoice_Total: z.number(), - Invoice_Date: z.string().datetime(), + Invoice_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Notes: z.string().max(4000).nullable(), Currency: z.string().max(25).nullable(), Congregation_ID: z.number().int().nullable(), Invoice_GUID: z.string().uuid(), Invoice_Source: z.number().int().nullable(), Cancel_Reason: z.string().max(255).nullable(), + Form_Response_ID: z.number().int().nullable(), }); export type InvoicesInput = z.infer<typeof InvoicesSchema>; diff --git a/src/lib/providers/ministry-platform/models/JourneysSchema.ts b/src/lib/providers/ministry-platform/models/JourneysSchema.ts index db3ebf3..042f99f 100644 --- a/src/lib/providers/ministry-platform/models/JourneysSchema.ts +++ b/src/lib/providers/ministry-platform/models/JourneysSchema.ts @@ -8,8 +8,8 @@ export const JourneysSchema = z.object({ Gamify: z.boolean().nullable(), Active: z.boolean().nullable(), Badge: z.string().max(400).nullable(), - Start_Date: z.string().datetime().nullable(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Finish_Message: z.string().max(150).nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/LocationsSchema.ts b/src/lib/providers/ministry-platform/models/LocationsSchema.ts index 349a468..2812235 100644 --- a/src/lib/providers/ministry-platform/models/LocationsSchema.ts +++ b/src/lib/providers/ministry-platform/models/LocationsSchema.ts @@ -7,8 +7,8 @@ export const LocationsSchema = z.object({ Description: z.string().max(255).nullable(), Location_Type_ID: z.number().int(), Address_ID: z.number().int(), - Move_In_Date: z.string().datetime().nullable(), - Move_Out_Date: z.string().datetime().nullable(), + Move_In_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Move_Out_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Phone: z.string().nullable(), Location_Group_ID: z.number().int().nullable(), Mailing_Address_ID: z.number().int().nullable(), diff --git a/src/lib/providers/ministry-platform/models/MaintenanceRequestsSchema.ts b/src/lib/providers/ministry-platform/models/MaintenanceRequestsSchema.ts index baea4af..96989df 100644 --- a/src/lib/providers/ministry-platform/models/MaintenanceRequestsSchema.ts +++ b/src/lib/providers/ministry-platform/models/MaintenanceRequestsSchema.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; export const MaintenanceRequestsSchema = z.object({ Maintenance_Request_ID: z.number().int(), Submitted_For: z.number().int(), - Request_Date: z.string().datetime(), + Request_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Request_Title: z.string().max(50), Description: z.string().max(4000).nullable(), Notes: z.string().max(4000).nullable(), diff --git a/src/lib/providers/ministry-platform/models/MinistriesSchema.ts b/src/lib/providers/ministry-platform/models/MinistriesSchema.ts index 9238797..9a5dfa9 100644 --- a/src/lib/providers/ministry-platform/models/MinistriesSchema.ts +++ b/src/lib/providers/ministry-platform/models/MinistriesSchema.ts @@ -5,8 +5,8 @@ export const MinistriesSchema = z.object({ Ministry_Name: z.string().max(50), Nickname: z.string().max(50).nullable(), Description: z.string().max(255).nullable(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Primary_Contact: z.number().int(), Parent_Ministry: z.number().int().nullable(), Available_Online: z.boolean(), diff --git a/src/lib/providers/ministry-platform/models/MpVwLastKnownActivitySchema.ts b/src/lib/providers/ministry-platform/models/MpVwLastKnownActivitySchema.ts index c55133f..b65d47e 100644 --- a/src/lib/providers/ministry-platform/models/MpVwLastKnownActivitySchema.ts +++ b/src/lib/providers/ministry-platform/models/MpVwLastKnownActivitySchema.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; export const MpVwLastKnownActivitySchema = z.object({ Participant_ID: z.number().int(), - Last_Known_Activity: z.string().datetime().nullable(), + Last_Known_Activity: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), }); export type MpVwLastKnownActivityInput = z.infer<typeof MpVwLastKnownActivitySchema>; diff --git a/src/lib/providers/ministry-platform/models/NeedsSchema.ts b/src/lib/providers/ministry-platform/models/NeedsSchema.ts index 81f98d0..7406c81 100644 --- a/src/lib/providers/ministry-platform/models/NeedsSchema.ts +++ b/src/lib/providers/ministry-platform/models/NeedsSchema.ts @@ -7,10 +7,10 @@ export const NeedsSchema = z.object({ Need_Campaign_ID: z.number().int(), Need_Type_ID: z.number().int().nullable(), Other_Need: z.string().max(255).nullable(), - Target_Date: z.string().datetime().nullable(), + Target_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Complete: z.boolean(), Need_Provider_ID: z.number().int().nullable(), - Date_Assigned: z.string().datetime().nullable(), + Date_Assigned: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Notes: z.string().max(500).nullable(), Need_Guid: z.string().uuid(), Care_Case_ID: z.number().int().nullable(), diff --git a/src/lib/providers/ministry-platform/models/OpportunitiesSchema.ts b/src/lib/providers/ministry-platform/models/OpportunitiesSchema.ts index 4756855..47d8284 100644 --- a/src/lib/providers/ministry-platform/models/OpportunitiesSchema.ts +++ b/src/lib/providers/ministry-platform/models/OpportunitiesSchema.ts @@ -8,8 +8,8 @@ export const OpportunitiesSchema = z.object({ Program_ID: z.number().int(), Visibility_Level_ID: z.number().int(), Contact_Person: z.number().int(), - Publish_Date: z.string().datetime(), - Opportunity_Date: z.string().datetime().nullable(), + Publish_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + Opportunity_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Duration_in_Hours: z.number().int().nullable(), Add_to_Group: z.number().int().nullable(), Add_to_Event: z.number().int().nullable(), @@ -25,7 +25,7 @@ export const OpportunitiesSchema = z.object({ Custom_Form: z.number().int().nullable(), Response_Message: z.number().int().nullable(), Close_Responses: z.boolean(), - Date_To_Remind: z.string().datetime().nullable(), + Date_To_Remind: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Optional_Reminder_Message: z.number().int().nullable(), Send_To_Heads: z.boolean().nullable(), _Maximum_Needed_Met: z.boolean(), diff --git a/src/lib/providers/ministry-platform/models/OpportunityAttributesSchema.ts b/src/lib/providers/ministry-platform/models/OpportunityAttributesSchema.ts index b68918e..1655165 100644 --- a/src/lib/providers/ministry-platform/models/OpportunityAttributesSchema.ts +++ b/src/lib/providers/ministry-platform/models/OpportunityAttributesSchema.ts @@ -4,8 +4,8 @@ export const OpportunityAttributesSchema = z.object({ Opportunity_Attribute_ID: z.number().int(), Attribute_ID: z.number().int(), Opportunity_ID: z.number().int(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Notes: z.string().max(255).nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/ParticipantCertificationsSchema.ts b/src/lib/providers/ministry-platform/models/ParticipantCertificationsSchema.ts index 46e40b2..b4d1de7 100644 --- a/src/lib/providers/ministry-platform/models/ParticipantCertificationsSchema.ts +++ b/src/lib/providers/ministry-platform/models/ParticipantCertificationsSchema.ts @@ -4,10 +4,10 @@ export const ParticipantCertificationsSchema = z.object({ Participant_Certification_ID: z.number().int(), Participant_ID: z.number().int(), Certification_Type_ID: z.number().int(), - Certification_Submitted: z.string().datetime(), - Certification_Completed: z.string().datetime().nullable(), + Certification_Submitted: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + Certification_Completed: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Passed: z.boolean().nullable(), - Certification_Expires: z.string().datetime().nullable(), + Certification_Expires: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Certification_GUID: z.string().uuid(), Notes: z.string().max(500).nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/ParticipantMilestonesSchema.ts b/src/lib/providers/ministry-platform/models/ParticipantMilestonesSchema.ts index 3887034..f000892 100644 --- a/src/lib/providers/ministry-platform/models/ParticipantMilestonesSchema.ts +++ b/src/lib/providers/ministry-platform/models/ParticipantMilestonesSchema.ts @@ -5,7 +5,7 @@ export const ParticipantMilestonesSchema = z.object({ Participant_ID: z.number().int(), Milestone_ID: z.number().int(), Program_ID: z.number().int(), - Date_Accomplished: z.string().datetime().nullable(), + Date_Accomplished: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Event_ID: z.number().int().nullable(), Witness: z.number().int().nullable(), At_Prior_Church: z.boolean(), diff --git a/src/lib/providers/ministry-platform/models/ParticipantsSchema.ts b/src/lib/providers/ministry-platform/models/ParticipantsSchema.ts index eadccc2..23b3010 100644 --- a/src/lib/providers/ministry-platform/models/ParticipantsSchema.ts +++ b/src/lib/providers/ministry-platform/models/ParticipantsSchema.ts @@ -7,16 +7,16 @@ export const ParticipantsSchema = z.object({ Participant_Type_ID: z.number().int(), Member_Status_ID: z.number().int().nullable(), Participant_Engagement_ID: z.number().int().nullable(), - Participant_Start_Date: z.string().datetime(), - Participant_End_Date: z.string().datetime().nullable(), + Participant_Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + Participant_End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Notes: z.string().max(1000).nullable(), - _First_Attendance_Ever: z.string().datetime().nullable(), - _Second_Attendance_Ever: z.string().datetime().nullable(), - _Third_Attendance_Ever: z.string().datetime().nullable(), - _Last_Attendance_Ever: z.string().datetime().nullable(), + _First_Attendance_Ever: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + _Second_Attendance_Ever: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + _Third_Attendance_Ever: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + _Last_Attendance_Ever: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), _Background_Check_Type: z.string().max(128).nullable(), _Background_Check_Status: z.string().max(50).nullable(), - _Background_Check_Date: z.string().datetime().nullable(), + _Background_Check_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Church_of_Record: z.number().int().nullable(), Baptism_Parish_Name: z.string().max(75).nullable(), Baptism_Parish_Address: z.string().max(254).nullable(), diff --git a/src/lib/providers/ministry-platform/models/PaymentsSchema.ts b/src/lib/providers/ministry-platform/models/PaymentsSchema.ts index f6b5542..67daa39 100644 --- a/src/lib/providers/ministry-platform/models/PaymentsSchema.ts +++ b/src/lib/providers/ministry-platform/models/PaymentsSchema.ts @@ -4,7 +4,7 @@ export const PaymentsSchema = z.object({ Payment_ID: z.number().int(), Payment_Total: z.number(), Contact_ID: z.number().int(), - Payment_Date: z.string().datetime(), + Payment_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Gateway_Response: z.string().max(500).nullable(), Transaction_Code: z.string().max(50).nullable(), Notes: z.string().max(4000).nullable(), diff --git a/src/lib/providers/ministry-platform/models/PersonnelAssignmentsSchema.ts b/src/lib/providers/ministry-platform/models/PersonnelAssignmentsSchema.ts index 6fca363..df39168 100644 --- a/src/lib/providers/ministry-platform/models/PersonnelAssignmentsSchema.ts +++ b/src/lib/providers/ministry-platform/models/PersonnelAssignmentsSchema.ts @@ -6,8 +6,8 @@ export const PersonnelAssignmentsSchema = z.object({ Personnel_Assignment_Type_ID: z.number().int(), Location_ID: z.number().int().nullable(), Assignment_Role_ID: z.number().int().nullable(), - Assignment_Start: z.string().datetime().nullable(), - Assignment_End: z.string().datetime().nullable(), + Assignment_Start: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Assignment_End: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Primary_Assignment: z.boolean(), Assignment_Comments: z.string().max(2000).nullable(), Mail_To_Location: z.boolean().nullable(), diff --git a/src/lib/providers/ministry-platform/models/PersonnelBenefitsSchema.ts b/src/lib/providers/ministry-platform/models/PersonnelBenefitsSchema.ts index dde7661..1531a0e 100644 --- a/src/lib/providers/ministry-platform/models/PersonnelBenefitsSchema.ts +++ b/src/lib/providers/ministry-platform/models/PersonnelBenefitsSchema.ts @@ -4,8 +4,8 @@ export const PersonnelBenefitsSchema = z.object({ Personnel_Benefit_ID: z.number().int(), Personnel_ID: z.number().int(), Benefit_Type_ID: z.number().int(), - Start_Date: z.string().datetime().nullable(), - Expiration_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Expiration_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Benefit_Notes: z.string().max(2000).nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/PersonnelCommentsSchema.ts b/src/lib/providers/ministry-platform/models/PersonnelCommentsSchema.ts index 119458d..6bfdb4d 100644 --- a/src/lib/providers/ministry-platform/models/PersonnelCommentsSchema.ts +++ b/src/lib/providers/ministry-platform/models/PersonnelCommentsSchema.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; export const PersonnelCommentsSchema = z.object({ Personnel_Comment_ID: z.number().int(), Personnel_ID: z.number().int(), - Comment_Date: z.string().datetime(), + Comment_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Personnel_Comment_Type_ID: z.number().int(), Comment: z.string().max(2000), }); diff --git a/src/lib/providers/ministry-platform/models/PersonnelEstatePlansSchema.ts b/src/lib/providers/ministry-platform/models/PersonnelEstatePlansSchema.ts index bea80b6..afe4428 100644 --- a/src/lib/providers/ministry-platform/models/PersonnelEstatePlansSchema.ts +++ b/src/lib/providers/ministry-platform/models/PersonnelEstatePlansSchema.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; export const PersonnelEstatePlansSchema = z.object({ Personnel_Estate_Plan_ID: z.number().int(), Personnel_ID: z.number().int(), - Living_Will_Last_Updated: z.string().datetime().nullable(), + Living_Will_Last_Updated: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Living_Will_Filed_At: z.string().max(100).nullable(), Living_Will_Surrogate_Name: z.string().max(150).nullable(), Living_Will_Relationship: z.string().max(150).nullable(), @@ -11,10 +11,10 @@ export const PersonnelEstatePlansSchema = z.object({ Living_Will_Phones: z.string().max(100).nullable(), Living_Will_Advance_Health_Directive: z.boolean().nullable(), Power_of_Attorney_Filed_At: z.string().max(100).nullable(), - Power_of_Attorney_Date_Completed: z.string().datetime().nullable(), + Power_of_Attorney_Date_Completed: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Power_of_Attorney_Copy_to_PCP: z.boolean().nullable(), Power_of_Attorney_Agent_Has_Copy: z.boolean().nullable(), - Will_Last_Updated_Date: z.string().datetime().nullable(), + Will_Last_Updated_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Will_Filed_At: z.string().max(100).nullable(), Will_Executor_Name: z.string().max(150).nullable(), Will_Address: z.string().max(500).nullable(), diff --git a/src/lib/providers/ministry-platform/models/PersonnelOrdinationSchema.ts b/src/lib/providers/ministry-platform/models/PersonnelOrdinationSchema.ts index 5a666ba..7f759ed 100644 --- a/src/lib/providers/ministry-platform/models/PersonnelOrdinationSchema.ts +++ b/src/lib/providers/ministry-platform/models/PersonnelOrdinationSchema.ts @@ -6,27 +6,27 @@ export const PersonnelOrdinationSchema = z.object({ Deacon_Ordained_Here: z.number().int().nullable(), Deacon_Ordained_By: z.string().max(100).nullable(), First_Mass: z.string().max(100).nullable(), - Candidacy: z.string().datetime().nullable(), - Rite_Of_Lector: z.string().datetime().nullable(), - Rite_Of_Acolyte: z.string().datetime().nullable(), - Date_To_Priesthood: z.string().datetime().nullable(), + Candidacy: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), + Rite_Of_Lector: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), + Rite_Of_Acolyte: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), + Date_To_Priesthood: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Religious_Order_ID: z.number().int().nullable(), Religious_Order_Status_ID: z.number().int().nullable(), - Incardination_Date: z.string().datetime().nullable(), + Incardination_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Place_of_Incardination: z.string().max(100).nullable(), Profession_Of_Faith: z.boolean().nullable(), Letter_Of_Suitability: z.boolean().nullable(), - Excardination_Date: z.string().datetime().nullable(), + Excardination_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Place_of_Excardination: z.string().max(100).nullable(), - Date_of_Arrival: z.string().datetime().nullable(), - Date_of_Departure: z.string().datetime().nullable(), + Date_of_Arrival: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), + Date_of_Departure: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Comments: z.string().max(2147483647).nullable(), - Date_of_Deacon_Ordination: z.string().datetime().nullable(), - Priesthood_Date_of_Ordination: z.string().datetime().nullable(), + Date_of_Deacon_Ordination: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), + Priesthood_Date_of_Ordination: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Priesthood_Ordained_Here: z.number().int().nullable(), Priesthood_Ordained_By: z.string().max(100).nullable(), - Date_of_First_Profession: z.string().datetime().nullable(), - Date_of_Profession: z.string().datetime().nullable(), + Date_of_First_Profession: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), + Date_of_Profession: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), }); export type PersonnelOrdinationInput = z.infer<typeof PersonnelOrdinationSchema>; diff --git a/src/lib/providers/ministry-platform/models/PersonnelResumeItemsSchema.ts b/src/lib/providers/ministry-platform/models/PersonnelResumeItemsSchema.ts index be3b761..1ff73c5 100644 --- a/src/lib/providers/ministry-platform/models/PersonnelResumeItemsSchema.ts +++ b/src/lib/providers/ministry-platform/models/PersonnelResumeItemsSchema.ts @@ -6,9 +6,9 @@ export const PersonnelResumeItemsSchema = z.object({ Resume_Item: z.string().max(249), Resume_Item_Type_ID: z.number().int(), Resume_Item_Notes: z.string().max(2000).nullable(), - Date_Started: z.string().datetime().nullable(), - Date_Achieved: z.string().datetime().nullable(), - Date_Expires: z.string().datetime().nullable(), + Date_Started: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Date_Achieved: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Date_Expires: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Location_ID: z.number().int().nullable(), Place: z.string().max(500).nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/PersonnelSchema.ts b/src/lib/providers/ministry-platform/models/PersonnelSchema.ts index 131e79b..8898cbf 100644 --- a/src/lib/providers/ministry-platform/models/PersonnelSchema.ts +++ b/src/lib/providers/ministry-platform/models/PersonnelSchema.ts @@ -4,25 +4,25 @@ export const PersonnelSchema = z.object({ Personnel_ID: z.number().int(), Contact_ID: z.number().int(), Personnel_Type_ID: z.number().int().nullable(), - Start_Date: z.string().datetime().nullable(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Personnel_Record_Status_ID: z.number().int().nullable(), Congregation_ID: z.number().int().nullable(), Personnel_Notes: z.string().max(2000).nullable(), - Hire_Date: z.string().datetime().nullable(), + Hire_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), ID_Number: z.string().max(50).nullable(), Citizenship_Type_ID: z.number().int().nullable(), Citizen_Of: z.string().max(249).nullable(), - Document_Expiration: z.string().datetime().nullable(), + Document_Expiration: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Passport_On_File: z.boolean().nullable(), - Passport_Expiration: z.string().datetime().nullable(), + Passport_Expiration: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Form_I9: z.boolean().nullable(), Eligible_For_Benefits: z.boolean().nullable(), Deferred_or_Vested: z.boolean().nullable(), Physician_On_File: z.boolean().nullable(), - Termination_Date: z.string().datetime().nullable(), + Termination_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Termination_Reason: z.string().max(249).nullable(), - Date_Retired: z.string().datetime().nullable(), + Date_Retired: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Personnel_Category_ID: z.number().int(), }); diff --git a/src/lib/providers/ministry-platform/models/PledgeAdjustmentsSchema.ts b/src/lib/providers/ministry-platform/models/PledgeAdjustmentsSchema.ts index fc38443..45b1b03 100644 --- a/src/lib/providers/ministry-platform/models/PledgeAdjustmentsSchema.ts +++ b/src/lib/providers/ministry-platform/models/PledgeAdjustmentsSchema.ts @@ -4,7 +4,7 @@ export const PledgeAdjustmentsSchema = z.object({ Pledge_Adjustment_ID: z.number().int(), Pledge_ID: z.number().int(), Pledge_Adjustment_Type_ID: z.number().int(), - Adjustment_Date: z.string().datetime(), + Adjustment_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Adjustment_Amount: z.number(), Prior_Amount: z.number(), Notes: z.string().max(2000).nullable(), diff --git a/src/lib/providers/ministry-platform/models/PledgeCampaignsSchema.ts b/src/lib/providers/ministry-platform/models/PledgeCampaignsSchema.ts index 790c0a1..9864413 100644 --- a/src/lib/providers/ministry-platform/models/PledgeCampaignsSchema.ts +++ b/src/lib/providers/ministry-platform/models/PledgeCampaignsSchema.ts @@ -7,12 +7,12 @@ export const PledgeCampaignsSchema = z.object({ Pledge_Campaign_Type_ID: z.number().int(), Description: z.string().max(500).nullable(), Campaign_Goal: z.number(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Event_ID: z.number().int().nullable(), Program_ID: z.number().int().nullable(), - Registration_Start: z.string().datetime().nullable(), - Registration_End: z.string().datetime().nullable(), + Registration_Start: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Registration_End: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Maximum_Registrants: z.number().int().nullable(), Registration_Deposit: z.number().nullable(), Fundraising_Goal: z.number().nullable(), diff --git a/src/lib/providers/ministry-platform/models/Pledges.ts b/src/lib/providers/ministry-platform/models/Pledges.ts index 1461ddf..320a36c 100644 --- a/src/lib/providers/ministry-platform/models/Pledges.ts +++ b/src/lib/providers/ministry-platform/models/Pledges.ts @@ -57,6 +57,11 @@ export interface Pledges { _Last_Installment_Date?: string /* ISO datetime */ | null; // Read Only, Computed Batch_ID?: number /* 32-bit integer */ | null; // Foreign Key -> Batches.Batch_ID + + /** + * Max length: 16 characters + */ + _Vendor_Pledge_Code?: string /* max 16 chars */ | null; // Read Only } export type PledgesRecord = Pledges; diff --git a/src/lib/providers/ministry-platform/models/PledgesSchema.ts b/src/lib/providers/ministry-platform/models/PledgesSchema.ts index 6b2560a..08253e3 100644 --- a/src/lib/providers/ministry-platform/models/PledgesSchema.ts +++ b/src/lib/providers/ministry-platform/models/PledgesSchema.ts @@ -9,7 +9,7 @@ export const PledgesSchema = z.object({ Installments_Planned: z.number().int(), Installments_Per_Year: z.number().int(), _Installment_Amount: z.number().nullable(), - First_Installment_Date: z.string().datetime(), + First_Installment_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Notes: z.string().max(500).nullable(), Beneficiary: z.string().max(50).nullable(), Trip_Leader: z.boolean(), @@ -20,8 +20,9 @@ export const PledgesSchema = z.object({ Donation_Source_ID: z.number().int().nullable(), Send_Pledge_Statement: z.boolean(), _Original_Amount: z.number().nullable(), - _Last_Installment_Date: z.string().datetime().nullable(), + _Last_Installment_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Batch_ID: z.number().int().nullable(), + _Vendor_Pledge_Code: z.string().max(16).nullable(), }); export type PledgesInput = z.infer<typeof PledgesSchema>; diff --git a/src/lib/providers/ministry-platform/models/PocketPlatformDevicesSchema.ts b/src/lib/providers/ministry-platform/models/PocketPlatformDevicesSchema.ts index 3fd3edf..7ae31b2 100644 --- a/src/lib/providers/ministry-platform/models/PocketPlatformDevicesSchema.ts +++ b/src/lib/providers/ministry-platform/models/PocketPlatformDevicesSchema.ts @@ -4,8 +4,8 @@ export const PocketPlatformDevicesSchema = z.object({ Device_ID: z.number().int(), Hardware_ID: z.string().max(50), User_ID: z.number().int().nullable(), - First_Seen: z.string().datetime(), - Last_Seen: z.string().datetime(), + First_Seen: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + Last_Seen: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Platform: z.string().max(50).nullable(), App_ID: z.number().int().nullable(), }); diff --git a/src/lib/providers/ministry-platform/models/PocketPlatformSocialMediaProviders.ts b/src/lib/providers/ministry-platform/models/PocketPlatformSocialMediaProviders.ts new file mode 100644 index 0000000..008b49e --- /dev/null +++ b/src/lib/providers/ministry-platform/models/PocketPlatformSocialMediaProviders.ts @@ -0,0 +1,18 @@ +/** + * Interface for Pocket_Platform_Social_Media_Providers +* Table: Pocket_Platform_Social_Media_Providers + * Access Level: Read + * Special Permissions: None + * Generated from column metadata + */ +export interface PocketPlatformSocialMediaProviders { + + Social_Media_Provider_ID: number /* 32-bit integer */; // Primary Key + + /** + * Max length: 100 characters + */ + Social_Media_Provider: string /* max 100 chars */; +} + +export type PocketPlatformSocialMediaProvidersRecord = PocketPlatformSocialMediaProviders; diff --git a/src/lib/providers/ministry-platform/models/PocketPlatformSocialMediaProvidersSchema.ts b/src/lib/providers/ministry-platform/models/PocketPlatformSocialMediaProvidersSchema.ts new file mode 100644 index 0000000..c4277f6 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/PocketPlatformSocialMediaProvidersSchema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const PocketPlatformSocialMediaProvidersSchema = z.object({ + Social_Media_Provider_ID: z.number().int(), + Social_Media_Provider: z.string().max(100), +}); + +export type PocketPlatformSocialMediaProvidersInput = z.infer<typeof PocketPlatformSocialMediaProvidersSchema>; diff --git a/src/lib/providers/ministry-platform/models/ProceduresSchema.ts b/src/lib/providers/ministry-platform/models/ProceduresSchema.ts index c5483c8..57e5620 100644 --- a/src/lib/providers/ministry-platform/models/ProceduresSchema.ts +++ b/src/lib/providers/ministry-platform/models/ProceduresSchema.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; export const ProceduresSchema = z.object({ Procedure_ID: z.number().int(), Procedure_Title: z.string().max(50), - Creation_Date: z.string().datetime().nullable(), + Creation_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Active: z.boolean(), User_ID: z.number().int(), Ministry_ID: z.number().int().nullable(), diff --git a/src/lib/providers/ministry-platform/models/ProgramGroupsSchema.ts b/src/lib/providers/ministry-platform/models/ProgramGroupsSchema.ts index 69bdd88..029d25a 100644 --- a/src/lib/providers/ministry-platform/models/ProgramGroupsSchema.ts +++ b/src/lib/providers/ministry-platform/models/ProgramGroupsSchema.ts @@ -5,8 +5,8 @@ export const ProgramGroupsSchema = z.object({ Program_ID: z.number().int(), Group_ID: z.number().int(), Room_ID: z.number().int().nullable(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), }); export type ProgramGroupsInput = z.infer<typeof ProgramGroupsSchema>; diff --git a/src/lib/providers/ministry-platform/models/ProgramsSchema.ts b/src/lib/providers/ministry-platform/models/ProgramsSchema.ts index 93f7ad7..6aa221c 100644 --- a/src/lib/providers/ministry-platform/models/ProgramsSchema.ts +++ b/src/lib/providers/ministry-platform/models/ProgramsSchema.ts @@ -5,8 +5,8 @@ export const ProgramsSchema = z.object({ Program_Name: z.string().max(130), Congregation_ID: z.number().int(), Ministry_ID: z.number().int(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Program_Type_ID: z.number().int().nullable(), Leadership_Team: z.number().int().nullable(), Primary_Contact: z.number().int(), diff --git a/src/lib/providers/ministry-platform/models/ResponseFollowUpsSchema.ts b/src/lib/providers/ministry-platform/models/ResponseFollowUpsSchema.ts index 47d1dd2..1849581 100644 --- a/src/lib/providers/ministry-platform/models/ResponseFollowUpsSchema.ts +++ b/src/lib/providers/ministry-platform/models/ResponseFollowUpsSchema.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; export const ResponseFollowUpsSchema = z.object({ Response_Follow_Up_ID: z.number().int(), Response_ID: z.number().int(), - Follow_Up_Date: z.string().datetime(), + Follow_Up_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Action_Type_ID: z.number().int(), Notes: z.string().max(1000), Made_By: z.number().int().nullable(), diff --git a/src/lib/providers/ministry-platform/models/ResponsesSchema.ts b/src/lib/providers/ministry-platform/models/ResponsesSchema.ts index 215bafb..eca52de 100644 --- a/src/lib/providers/ministry-platform/models/ResponsesSchema.ts +++ b/src/lib/providers/ministry-platform/models/ResponsesSchema.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; export const ResponsesSchema = z.object({ Response_ID: z.number().int(), - Response_Date: z.string().datetime(), + Response_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Opportunity_ID: z.number().int(), Participant_ID: z.number().int(), Comments: z.string().max(2000).nullable(), diff --git a/src/lib/providers/ministry-platform/models/RoomsSchema.ts b/src/lib/providers/ministry-platform/models/RoomsSchema.ts index c326698..8dd8440 100644 --- a/src/lib/providers/ministry-platform/models/RoomsSchema.ts +++ b/src/lib/providers/ministry-platform/models/RoomsSchema.ts @@ -10,7 +10,7 @@ export const RoomsSchema = z.object({ Default_Room_Layout: z.number().int().nullable(), Room_Usage_Type_ID: z.number().int().nullable(), Bookable: z.boolean(), - Last_Remodel_Date: z.string().datetime().nullable(), + Last_Remodel_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Parent_Room_ID: z.number().int().nullable(), Auto_Approve: z.boolean(), Print_Server_ID: z.number().int().nullable(), diff --git a/src/lib/providers/ministry-platform/models/SacramentsSchema.ts b/src/lib/providers/ministry-platform/models/SacramentsSchema.ts index 6631027..d02201b 100644 --- a/src/lib/providers/ministry-platform/models/SacramentsSchema.ts +++ b/src/lib/providers/ministry-platform/models/SacramentsSchema.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; export const SacramentsSchema = z.object({ Sacrament_ID: z.number().int(), Sacrament_Type_ID: z.number().int(), - Date_Received: z.string().datetime().nullable(), + Date_Received: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Date_Received_Accuracy_ID: z.number().int().nullable(), Participant_ID: z.number().int(), Participant_Name: z.string().max(100).nullable(), @@ -20,11 +20,11 @@ export const SacramentsSchema = z.object({ Mother_ID: z.number().int().nullable(), Name_of_Mother: z.string().max(100).nullable(), Profession_of_Faith: z.boolean().nullable(), - Profession_of_Faith_Date: z.string().datetime().nullable(), + Profession_of_Faith_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Verified: z.boolean().nullable(), Notification_Received: z.boolean().nullable(), Place_of_Burial: z.string().max(128).nullable(), - Date_of_Burial: z.string().datetime().nullable(), + Date_of_Burial: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), Spouse_ID: z.number().int().nullable(), Spouse_Name: z.string().max(100).nullable(), Interfaith: z.boolean().nullable(), diff --git a/src/lib/providers/ministry-platform/models/ScheduledDonationsSchema.ts b/src/lib/providers/ministry-platform/models/ScheduledDonationsSchema.ts index 22e0fa3..ae967b5 100644 --- a/src/lib/providers/ministry-platform/models/ScheduledDonationsSchema.ts +++ b/src/lib/providers/ministry-platform/models/ScheduledDonationsSchema.ts @@ -6,8 +6,8 @@ export const ScheduledDonationsSchema = z.object({ Donor_Account_ID: z.number().int().nullable(), Day_of_Month: z.unknown(), Amount: z.number(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Target_Event: z.number().int().nullable(), Notes: z.string().max(1000).nullable(), Payment_Type_ID: z.number().int(), diff --git a/src/lib/providers/ministry-platform/models/StaffSchema.ts b/src/lib/providers/ministry-platform/models/StaffSchema.ts index 79fbbb4..3311bec 100644 --- a/src/lib/providers/ministry-platform/models/StaffSchema.ts +++ b/src/lib/providers/ministry-platform/models/StaffSchema.ts @@ -4,8 +4,8 @@ export const StaffSchema = z.object({ Staff_ID: z.number().int(), Contact_ID: z.number().int(), Title: z.string().max(50), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Show_Online: z.boolean(), Online_Order: z.unknown(), Facebook_URL: z.string().url().nullable(), diff --git a/src/lib/providers/ministry-platform/models/StatisticsLogs.ts b/src/lib/providers/ministry-platform/models/StatisticsLogs.ts new file mode 100644 index 0000000..8a03449 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/StatisticsLogs.ts @@ -0,0 +1,24 @@ +/** + * Interface for Statistics_Logs +* Table: Statistics_Logs + * Access Level: Read + * Special Permissions: None + * Generated from column metadata + */ +export interface StatisticsLogs { + + Log_ID: number /* 32-bit integer */; // Primary Key + + Log_Date: string /* ISO datetime */; + + /** + * Max length: 75 characters + */ + Table_Name: string /* max 75 chars */; + + Duration?: number /* 32-bit integer */ | null; + + Sample_Percent?: unknown | null; +} + +export type StatisticsLogsRecord = StatisticsLogs; diff --git a/src/lib/providers/ministry-platform/models/StatisticsLogsSchema.ts b/src/lib/providers/ministry-platform/models/StatisticsLogsSchema.ts new file mode 100644 index 0000000..1b6d3cf --- /dev/null +++ b/src/lib/providers/ministry-platform/models/StatisticsLogsSchema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const StatisticsLogsSchema = z.object({ + Log_ID: z.number().int(), + Log_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + Table_Name: z.string().max(75), + Duration: z.number().int().nullable(), + Sample_Percent: z.unknown().nullable(), +}); + +export type StatisticsLogsInput = z.infer<typeof StatisticsLogsSchema>; diff --git a/src/lib/providers/ministry-platform/models/VolunteerUnavailableDatesSchema.ts b/src/lib/providers/ministry-platform/models/VolunteerUnavailableDatesSchema.ts index 91df0a3..e35f09f 100644 --- a/src/lib/providers/ministry-platform/models/VolunteerUnavailableDatesSchema.ts +++ b/src/lib/providers/ministry-platform/models/VolunteerUnavailableDatesSchema.ts @@ -3,8 +3,8 @@ import { z } from 'zod'; export const VolunteerUnavailableDatesSchema = z.object({ Volunteer_Unavailable_Date_ID: z.number().int(), Contact_ID: z.number().int(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)"), }); export type VolunteerUnavailableDatesInput = z.infer<typeof VolunteerUnavailableDatesSchema>; diff --git a/src/lib/providers/ministry-platform/models/VwMpGivingUnitSummarySchema.ts b/src/lib/providers/ministry-platform/models/VwMpGivingUnitSummarySchema.ts deleted file mode 100644 index 6713dcb..0000000 --- a/src/lib/providers/ministry-platform/models/VwMpGivingUnitSummarySchema.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { z } from 'zod'; - -export const VwMpGivingUnitSummarySchema = z.object({ - Household_ID: z.number().int().nullable(), - Household_Name: z.string().max(75), - Primary_Individuals: z.string().max(103).nullable(), - Giving_Unit_Type: z.string().max(9), - Giving_Notes: z.string().max(97), - Giving_YTD: z.number(), - Giving_YTD_Last_Year: z.number(), - Giving_Variance_Last_YTD: z.number().nullable(), - Giving_Last_Year: z.number(), - Online_Giving_Notes: z.string().max(47), - Online_Giving_YTD: z.number(), - Online_Giving_YTD_Last_Year: z.number(), - Online_Giving_Variance_Last_YTD: z.number().nullable(), - Online_Giving_Last_Year: z.number(), - Gifts_YTD: z.number().int(), - Gifts_YTD_Last_Year: z.number().int(), - Gifts_Variance_Last_YTD: z.number().int().nullable(), - Gifts_Last_Year: z.number().int(), - Online_Gifts_YTD: z.number().int(), - Online_Gifts_YTD_Last_Year: z.number().int(), - Online_Gifts_Variance_Last_YTD: z.number().int().nullable(), - Online_Gifts_Last_Year: z.number().int(), - Giver_Notes: z.string().max(102), - Givers_YTD: z.number().int(), - Givers_Last_Year: z.number().int(), - Comparison_Notes: z.string().max(171), - Date_Range_Before: z.string().max(4000).nullable(), - Date_Range_After: z.string().max(4000).nullable(), - Giving_Before: z.number(), - Giving_After: z.number(), - Giving_After_Variance: z.number().nullable(), - Gifts_Before: z.number().int(), - Gifts_After: z.number().int(), - Gifts_After_Variance: z.number().int().nullable(), - Online_Giving_Before: z.number(), - Online_Giving_After: z.number(), - Online_Giving_After_Variance: z.number().nullable(), - Online_Gifts_Before: z.number().int(), - Online_Gifts_After: z.number().int(), - Online_Gifts_After_Variance: z.number().int().nullable(), - HH_Member_Notes: z.string().max(93), - Last_Name: z.string().max(50).nullable(), - First_Name: z.string().max(50).nullable(), - Nickname: z.string().max(50).nullable(), - Email_Address: z.string().email().max(254).nullable(), - Mobile_Phone: z.string().max(25).nullable(), - Household_Position: z.string().max(25).nullable(), - Gender: z.string().max(25).nullable(), - Contact_Status: z.string().max(50), - Member_Status: z.string().max(50).nullable(), - Participant_Type: z.string().max(50).nullable(), - Age: z.number().int().nullable(), - Contact_ID: z.number().int(), - Home_Phone: z.string().max(25).nullable(), - Congregation_ID: z.number().int().nullable(), - Congregation_Name: z.string().max(50).nullable(), - First_HH_Donation: z.string().datetime().nullable(), - Last_HH_Donation: z.string().datetime().nullable(), - Months_Since_Last_HH_Donation: z.number().int().nullable(), - Spouse_Last_Name: z.string().max(50).nullable(), - Spouse_First_Name: z.string().max(50).nullable(), - Spouse_Nickname: z.string().max(50).nullable(), - Spouse_Email: z.string().email().max(254).nullable(), - Spouse_Mobile: z.string().max(25).nullable(), - Spouse_HH_Position: z.string().max(25).nullable(), - Spouse_Gender: z.string().max(25).nullable(), - Spouse_Contact_Status: z.string().max(50).nullable(), - Spouse_Member_Status: z.string().max(50).nullable(), - Spouse_Participant_Type: z.string().max(50).nullable(), - Spouse_Age: z.number().int().nullable(), -}); - -export type VwMpGivingUnitSummaryInput = z.infer<typeof VwMpGivingUnitSummarySchema>; diff --git a/src/lib/providers/ministry-platform/models/VwMpGivingUnitSummary.ts b/src/lib/providers/ministry-platform/models/VwMpGivingUnitSummaryStandard.ts similarity index 61% rename from src/lib/providers/ministry-platform/models/VwMpGivingUnitSummary.ts rename to src/lib/providers/ministry-platform/models/VwMpGivingUnitSummaryStandard.ts index a6308aa..04e60d9 100644 --- a/src/lib/providers/ministry-platform/models/VwMpGivingUnitSummary.ts +++ b/src/lib/providers/ministry-platform/models/VwMpGivingUnitSummaryStandard.ts @@ -1,13 +1,13 @@ /** - * Interface for vw_mp_giving_unit_summary -* Table: vw_mp_giving_unit_summary + * Interface for vw_mp_giving_unit_summary_standard +* Table: vw_mp_giving_unit_summary_standard * Access Level: Read * Special Permissions: DataExport * Generated from column metadata */ -export interface VwMpGivingUnitSummary { +export interface VwMpGivingUnitSummaryStandard { - Household_ID?: number /* 32-bit integer */ | null; // Primary Key, Foreign Key -> Households.Household_ID + Household_ID: number /* 32-bit integer */; // Primary Key, Foreign Key -> Households.Household_ID /** * Max length: 75 characters @@ -15,38 +15,40 @@ export interface VwMpGivingUnitSummary { Household_Name: string /* max 75 chars */; /** - * Max length: 103 characters + * Max length: 105 characters */ - Primary_Individuals?: string /* max 103 chars */ | null; + Primary_Individuals?: string /* max 105 chars */ | null; /** - * Max length: 9 characters + * Max length: 10 characters */ - Giving_Unit_Type: string /* max 9 chars */; + Giving_Unit_Type: string /* max 10 chars */; + + Last_Refresh: string /* ISO datetime */; /** - * Max length: 97 characters + * Max length: 100 characters */ - Giving_Notes: string /* max 97 chars */; + Giving_Notes: string /* max 100 chars */; Giving_YTD: number /* currency amount */; Giving_YTD_Last_Year: number /* currency amount */; - Giving_Variance_Last_YTD?: number /* currency amount */ | null; + Giving_Variance_Last_YTD: number /* currency amount */; Giving_Last_Year: number /* currency amount */; /** - * Max length: 47 characters + * Max length: 50 characters */ - Online_Giving_Notes: string /* max 47 chars */; + Online_Giving_Notes: string /* max 50 chars */; Online_Giving_YTD: number /* currency amount */; Online_Giving_YTD_Last_Year: number /* currency amount */; - Online_Giving_Variance_Last_YTD?: number /* currency amount */ | null; + Online_Giving_Variance_Last_YTD: number /* currency amount */; Online_Giving_Last_Year: number /* currency amount */; @@ -54,7 +56,7 @@ export interface VwMpGivingUnitSummary { Gifts_YTD_Last_Year: number /* 32-bit integer */; - Gifts_Variance_Last_YTD?: number /* 32-bit integer */ | null; + Gifts_Variance_Last_YTD: number /* 32-bit integer */; Gifts_Last_Year: number /* 32-bit integer */; @@ -62,62 +64,62 @@ export interface VwMpGivingUnitSummary { Online_Gifts_YTD_Last_Year: number /* 32-bit integer */; - Online_Gifts_Variance_Last_YTD?: number /* 32-bit integer */ | null; + Online_Gifts_Variance_Last_YTD: number /* 32-bit integer */; Online_Gifts_Last_Year: number /* 32-bit integer */; /** - * Max length: 102 characters + * Max length: 120 characters */ - Giver_Notes: string /* max 102 chars */; + Giver_Notes: string /* max 120 chars */; Givers_YTD: number /* 32-bit integer */; Givers_Last_Year: number /* 32-bit integer */; /** - * Max length: 171 characters + * Max length: 200 characters */ - Comparison_Notes: string /* max 171 chars */; + Comparison_Notes: string /* max 200 chars */; /** - * Max length: 4000 characters + * Max length: 50 characters */ - Date_Range_Before?: string /* max 4000 chars */ | null; + Date_Range_Before: string /* max 50 chars */; /** - * Max length: 4000 characters + * Max length: 50 characters */ - Date_Range_After?: string /* max 4000 chars */ | null; + Date_Range_After: string /* max 50 chars */; Giving_Before: number /* currency amount */; Giving_After: number /* currency amount */; - Giving_After_Variance?: number /* currency amount */ | null; + Giving_After_Variance: number /* currency amount */; Gifts_Before: number /* 32-bit integer */; Gifts_After: number /* 32-bit integer */; - Gifts_After_Variance?: number /* 32-bit integer */ | null; + Gifts_After_Variance: number /* 32-bit integer */; Online_Giving_Before: number /* currency amount */; Online_Giving_After: number /* currency amount */; - Online_Giving_After_Variance?: number /* currency amount */ | null; + Online_Giving_After_Variance: number /* currency amount */; Online_Gifts_Before: number /* 32-bit integer */; Online_Gifts_After: number /* 32-bit integer */; - Online_Gifts_After_Variance?: number /* 32-bit integer */ | null; + Online_Gifts_After_Variance: number /* 32-bit integer */; /** - * Max length: 93 characters + * Max length: 120 characters */ - HH_Member_Notes: string /* max 93 chars */; + HH_Member_Notes: string /* max 120 chars */; /** * Max length: 50 characters @@ -137,12 +139,9 @@ export interface VwMpGivingUnitSummary { /** * Max length: 254 characters */ - Email_Address?: string /* email, max 254 chars */ | null; + Email_Address?: string /* max 254 chars */ | null; - /** - * Max length: 25 characters - */ - Mobile_Phone?: string /* max 25 chars */ | null; + Mobile_Phone?: string /* phone number */ | null; /** * Max length: 25 characters @@ -169,14 +168,11 @@ export interface VwMpGivingUnitSummary { */ Participant_Type?: string /* max 50 chars */ | null; - Age?: number /* 32-bit integer */ | null; + Age?: unknown | null; Contact_ID: number /* 32-bit integer */; // Foreign Key -> Contacts.Contact_ID - /** - * Max length: 25 characters - */ - Home_Phone?: string /* max 25 chars */ | null; + Home_Phone?: string /* phone number */ | null; Congregation_ID?: number /* 32-bit integer */ | null; @@ -189,7 +185,7 @@ export interface VwMpGivingUnitSummary { Last_HH_Donation?: string /* ISO datetime */ | null; - Months_Since_Last_HH_Donation?: number /* 32-bit integer */ | null; + Months_Since_Last_HH_Donation: number /* 32-bit integer */; /** * Max length: 50 characters @@ -209,12 +205,9 @@ export interface VwMpGivingUnitSummary { /** * Max length: 254 characters */ - Spouse_Email?: string /* email, max 254 chars */ | null; + Spouse_Email?: string /* max 254 chars */ | null; - /** - * Max length: 25 characters - */ - Spouse_Mobile?: string /* max 25 chars */ | null; + Spouse_Mobile?: string /* phone number */ | null; /** * Max length: 25 characters @@ -241,7 +234,7 @@ export interface VwMpGivingUnitSummary { */ Spouse_Participant_Type?: string /* max 50 chars */ | null; - Spouse_Age?: number /* 32-bit integer */ | null; + Spouse_Age?: unknown | null; } -export type VwMpGivingUnitSummaryRecord = VwMpGivingUnitSummary; +export type VwMpGivingUnitSummaryStandardRecord = VwMpGivingUnitSummaryStandard; diff --git a/src/lib/providers/ministry-platform/models/VwMpGivingUnitSummaryStandardSchema.ts b/src/lib/providers/ministry-platform/models/VwMpGivingUnitSummaryStandardSchema.ts new file mode 100644 index 0000000..7028df5 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/VwMpGivingUnitSummaryStandardSchema.ts @@ -0,0 +1,77 @@ +import { z } from 'zod'; + +export const VwMpGivingUnitSummaryStandardSchema = z.object({ + Household_ID: z.number().int(), + Household_Name: z.string().max(75), + Primary_Individuals: z.string().max(105).nullable(), + Giving_Unit_Type: z.string().max(10), + Last_Refresh: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + Giving_Notes: z.string().max(100), + Giving_YTD: z.number(), + Giving_YTD_Last_Year: z.number(), + Giving_Variance_Last_YTD: z.number(), + Giving_Last_Year: z.number(), + Online_Giving_Notes: z.string().max(50), + Online_Giving_YTD: z.number(), + Online_Giving_YTD_Last_Year: z.number(), + Online_Giving_Variance_Last_YTD: z.number(), + Online_Giving_Last_Year: z.number(), + Gifts_YTD: z.number().int(), + Gifts_YTD_Last_Year: z.number().int(), + Gifts_Variance_Last_YTD: z.number().int(), + Gifts_Last_Year: z.number().int(), + Online_Gifts_YTD: z.number().int(), + Online_Gifts_YTD_Last_Year: z.number().int(), + Online_Gifts_Variance_Last_YTD: z.number().int(), + Online_Gifts_Last_Year: z.number().int(), + Giver_Notes: z.string().max(120), + Givers_YTD: z.number().int(), + Givers_Last_Year: z.number().int(), + Comparison_Notes: z.string().max(200), + Date_Range_Before: z.string().max(50), + Date_Range_After: z.string().max(50), + Giving_Before: z.number(), + Giving_After: z.number(), + Giving_After_Variance: z.number(), + Gifts_Before: z.number().int(), + Gifts_After: z.number().int(), + Gifts_After_Variance: z.number().int(), + Online_Giving_Before: z.number(), + Online_Giving_After: z.number(), + Online_Giving_After_Variance: z.number(), + Online_Gifts_Before: z.number().int(), + Online_Gifts_After: z.number().int(), + Online_Gifts_After_Variance: z.number().int(), + HH_Member_Notes: z.string().max(120), + Last_Name: z.string().max(50).nullable(), + First_Name: z.string().max(50).nullable(), + Nickname: z.string().max(50).nullable(), + Email_Address: z.string().max(254).nullable(), + Mobile_Phone: z.string().nullable(), + Household_Position: z.string().max(25).nullable(), + Gender: z.string().max(25).nullable(), + Contact_Status: z.string().max(50), + Member_Status: z.string().max(50).nullable(), + Participant_Type: z.string().max(50).nullable(), + Age: z.unknown().nullable(), + Contact_ID: z.number().int(), + Home_Phone: z.string().nullable(), + Congregation_ID: z.number().int().nullable(), + Congregation_Name: z.string().max(50).nullable(), + First_HH_Donation: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Last_HH_Donation: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), + Months_Since_Last_HH_Donation: z.number().int(), + Spouse_Last_Name: z.string().max(50).nullable(), + Spouse_First_Name: z.string().max(50).nullable(), + Spouse_Nickname: z.string().max(50).nullable(), + Spouse_Email: z.string().max(254).nullable(), + Spouse_Mobile: z.string().nullable(), + Spouse_HH_Position: z.string().max(25).nullable(), + Spouse_Gender: z.string().max(25).nullable(), + Spouse_Contact_Status: z.string().max(50).nullable(), + Spouse_Member_Status: z.string().max(50).nullable(), + Spouse_Participant_Type: z.string().max(50).nullable(), + Spouse_Age: z.unknown().nullable(), +}); + +export type VwMpGivingUnitSummaryStandardInput = z.infer<typeof VwMpGivingUnitSummaryStandardSchema>; diff --git a/src/lib/providers/ministry-platform/models/VwMpParticipationComplianceDetailsSchema.ts b/src/lib/providers/ministry-platform/models/VwMpParticipationComplianceDetailsSchema.ts index 627975d..f3f0614 100644 --- a/src/lib/providers/ministry-platform/models/VwMpParticipationComplianceDetailsSchema.ts +++ b/src/lib/providers/ministry-platform/models/VwMpParticipationComplianceDetailsSchema.ts @@ -7,8 +7,8 @@ export const VwMpParticipationComplianceDetailsSchema = z.object({ Participant_ID: z.number().int(), Group_Participant_ID: z.number().int(), Group_Role_ID: z.number().int(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Background_Check_Type_ID: z.number().int().nullable(), Background_Check: z.string().max(13), Certification_Type_ID: z.number().int().nullable(), diff --git a/src/lib/providers/ministry-platform/models/VwMpParticipationComplianceSchema.ts b/src/lib/providers/ministry-platform/models/VwMpParticipationComplianceSchema.ts index 9de68e1..d395080 100644 --- a/src/lib/providers/ministry-platform/models/VwMpParticipationComplianceSchema.ts +++ b/src/lib/providers/ministry-platform/models/VwMpParticipationComplianceSchema.ts @@ -5,8 +5,8 @@ export const VwMpParticipationComplianceSchema = z.object({ Group_ID: z.number().int(), Participant_ID: z.number().int(), Group_Role_ID: z.number().int(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Attribute_Alignment: z.string().max(4000).nullable(), Background_Check: z.string().max(13).nullable(), Certification: z.string().max(13).nullable(), diff --git a/src/lib/providers/ministry-platform/models/VwMpPersonnelAuditOverviewSchema.ts b/src/lib/providers/ministry-platform/models/VwMpPersonnelAuditOverviewSchema.ts index 60f61fa..383caf3 100644 --- a/src/lib/providers/ministry-platform/models/VwMpPersonnelAuditOverviewSchema.ts +++ b/src/lib/providers/ministry-platform/models/VwMpPersonnelAuditOverviewSchema.ts @@ -7,7 +7,7 @@ export const VwMpPersonnelAuditOverviewSchema = z.object({ Personnel_ID: z.number().int(), Description: z.string().max(50), User: z.string().max(254), - Date: z.string().datetime(), + Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), On_Behalf_Of: z.string().max(256), Impersonated_By: z.string().max(256), Field: z.string().max(50).nullable(), diff --git a/src/lib/providers/ministry-platform/models/VwMpProjectedScheduledDonationsSchema.ts b/src/lib/providers/ministry-platform/models/VwMpProjectedScheduledDonationsSchema.ts index 645e8c8..2a43ec7 100644 --- a/src/lib/providers/ministry-platform/models/VwMpProjectedScheduledDonationsSchema.ts +++ b/src/lib/providers/ministry-platform/models/VwMpProjectedScheduledDonationsSchema.ts @@ -9,8 +9,8 @@ export const VwMpProjectedScheduledDonationsSchema = z.object({ Amount: z.number(), Day_of_Month_1: z.unknown(), Day_of_Month_2: z.unknown().nullable(), - Start_Date: z.string().datetime(), - End_Date: z.string().datetime().nullable(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + End_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Target_Event: z.number().int().nullable(), Notes: z.string().max(1000).nullable(), Payment_Type_ID: z.number().int(), @@ -18,7 +18,7 @@ export const VwMpProjectedScheduledDonationsSchema = z.object({ Gift_Frequency: z.string().max(128), Number_Of_Installments: z.number().int().nullable(), Third_Party_Note: z.string().max(1000).nullable(), - Projected_Donation_Date: z.string().datetime().nullable(), + Projected_Donation_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), }); export type VwMpProjectedScheduledDonationsInput = z.infer<typeof VwMpProjectedScheduledDonationsSchema>; diff --git a/src/lib/providers/ministry-platform/models/WaitStatsBaseline.ts b/src/lib/providers/ministry-platform/models/WaitStatsBaseline.ts new file mode 100644 index 0000000..f7cde39 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/WaitStatsBaseline.ts @@ -0,0 +1,26 @@ +/** + * Interface for Wait_Stats_Baseline +* Table: Wait_Stats_Baseline + * Access Level: Read + * Special Permissions: None + * Generated from column metadata + */ +export interface WaitStatsBaseline { + + /** + * Max length: 60 characters + */ + WaitType: string /* max 60 chars */; // Primary Key + + WaitingTasksCount: number /* 64-bit integer */; + + WaitTimeMS: number /* 64-bit integer */; + + MaxWaitTimeMS: number /* 64-bit integer */; + + SignalWaitTimeMS: number /* 64-bit integer */; + + SnapshotDT: string /* ISO datetime */; +} + +export type WaitStatsBaselineRecord = WaitStatsBaseline; diff --git a/src/lib/providers/ministry-platform/models/WaitStatsBaselineSchema.ts b/src/lib/providers/ministry-platform/models/WaitStatsBaselineSchema.ts new file mode 100644 index 0000000..32bad73 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/WaitStatsBaselineSchema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const WaitStatsBaselineSchema = z.object({ + WaitType: z.string().max(60), + WaitingTasksCount: z.number().int(), + WaitTimeMS: z.number().int(), + MaxWaitTimeMS: z.number().int(), + SignalWaitTimeMS: z.number().int(), + SnapshotDT: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), +}); + +export type WaitStatsBaselineInput = z.infer<typeof WaitStatsBaselineSchema>; diff --git a/src/lib/providers/ministry-platform/models/WaitStatsHistory.ts b/src/lib/providers/ministry-platform/models/WaitStatsHistory.ts new file mode 100644 index 0000000..4f7dada --- /dev/null +++ b/src/lib/providers/ministry-platform/models/WaitStatsHistory.ts @@ -0,0 +1,42 @@ +/** + * Interface for Wait_Stats_History +* Table: Wait_Stats_History + * Access Level: Read + * Special Permissions: None + * Generated from column metadata + */ +export interface WaitStatsHistory { + + WaitStatsID: number /* 32-bit integer */; // Primary Key + + SnapshotDT: string /* ISO datetime */; // Has Default + + /** + * Max length: 60 characters + */ + WaitType: string /* max 60 chars */; + + WaitingTasksCount: number /* 64-bit integer */; + + WaitTimeMS: number /* 64-bit integer */; + + MaxWaitTimeMS: number /* 64-bit integer */; + + SignalWaitTimeMS: number /* 64-bit integer */; + + ResourceWaitTimeMS?: number /* 64-bit integer */ | null; // Read Only, Computed + + SnapshotHour?: number /* 32-bit integer */ | null; // Read Only, Computed + + SnapshotDay?: string /* ISO date (YYYY-MM-DD) */ | null; // Read Only, Computed + + SnapshotISOYear?: number /* 16-bit integer */ | null; // Read Only, Computed + + SnapshotWeek?: number /* 32-bit integer */ | null; // Read Only, Computed + + SnapshotMonth?: number /* 32-bit integer */ | null; // Read Only, Computed + + SnapshotYear?: number /* 32-bit integer */ | null; // Read Only, Computed +} + +export type WaitStatsHistoryRecord = WaitStatsHistory; diff --git a/src/lib/providers/ministry-platform/models/WaitStatsHistorySchema.ts b/src/lib/providers/ministry-platform/models/WaitStatsHistorySchema.ts new file mode 100644 index 0000000..e562547 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/WaitStatsHistorySchema.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +export const WaitStatsHistorySchema = z.object({ + WaitStatsID: z.number().int(), + SnapshotDT: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + WaitType: z.string().max(60), + WaitingTasksCount: z.number().int(), + WaitTimeMS: z.number().int(), + MaxWaitTimeMS: z.number().int(), + SignalWaitTimeMS: z.number().int(), + ResourceWaitTimeMS: z.number().int().nullable(), + SnapshotHour: z.number().int().nullable(), + SnapshotDay: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "Expected YYYY-MM-DD (optionally with a time)").nullable(), + SnapshotISOYear: z.number().int().nullable(), + SnapshotWeek: z.number().int().nullable(), + SnapshotMonth: z.number().int().nullable(), + SnapshotYear: z.number().int().nullable(), +}); + +export type WaitStatsHistoryInput = z.infer<typeof WaitStatsHistorySchema>; diff --git a/src/lib/providers/ministry-platform/models/WaitTypes.ts b/src/lib/providers/ministry-platform/models/WaitTypes.ts new file mode 100644 index 0000000..26b0af6 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/WaitTypes.ts @@ -0,0 +1,23 @@ +/** + * Interface for Wait_Types +* Table: Wait_Types + * Access Level: Read + * Special Permissions: None + * Generated from column metadata + */ +export interface WaitTypes { + + Wait_Type_ID: number /* 32-bit integer */; // Primary Key + + /** + * Max length: 60 characters + */ + Wait_Type: string /* max 60 chars */; + + /** + * Max length: 60 characters + */ + Category: string /* max 60 chars */; +} + +export type WaitTypesRecord = WaitTypes; diff --git a/src/lib/providers/ministry-platform/models/WaitTypesSchema.ts b/src/lib/providers/ministry-platform/models/WaitTypesSchema.ts new file mode 100644 index 0000000..1abb633 --- /dev/null +++ b/src/lib/providers/ministry-platform/models/WaitTypesSchema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const WaitTypesSchema = z.object({ + Wait_Type_ID: z.number().int(), + Wait_Type: z.string().max(60), + Category: z.string().max(60), +}); + +export type WaitTypesInput = z.infer<typeof WaitTypesSchema>; diff --git a/src/lib/providers/ministry-platform/models/WeeklySnapshotsSchema.ts b/src/lib/providers/ministry-platform/models/WeeklySnapshotsSchema.ts index f57a460..801845b 100644 --- a/src/lib/providers/ministry-platform/models/WeeklySnapshotsSchema.ts +++ b/src/lib/providers/ministry-platform/models/WeeklySnapshotsSchema.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; export const WeeklySnapshotsSchema = z.object({ Weekly_Snapshot_ID: z.number().int(), - Start_Date: z.string().datetime(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), Congregation_ID: z.number().int(), Active_People_7Days: z.number().int(), Active_People_1Month: z.number().int(), diff --git a/src/lib/providers/ministry-platform/models/WifiDeviceSessionsSchema.ts b/src/lib/providers/ministry-platform/models/WifiDeviceSessionsSchema.ts index 217eedb..41d41a5 100644 --- a/src/lib/providers/ministry-platform/models/WifiDeviceSessionsSchema.ts +++ b/src/lib/providers/ministry-platform/models/WifiDeviceSessionsSchema.ts @@ -4,8 +4,8 @@ export const WifiDeviceSessionsSchema = z.object({ Wifi_Device_Session_ID: z.number().int(), Wifi_Device_ID: z.number().int(), Session_GUID: z.string().max(128), - Session_Start: z.string().datetime(), - Session_End: z.string().datetime().nullable(), + Session_Start: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), + Session_End: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)").nullable(), Wifi_Space: z.string().max(50), Duration_in_Minutes: z.number().int(), Contact_ID_Value: z.string().max(50).nullable(), diff --git a/src/lib/providers/ministry-platform/models/WifiDevicesSchema.ts b/src/lib/providers/ministry-platform/models/WifiDevicesSchema.ts index a58707b..dea9dca 100644 --- a/src/lib/providers/ministry-platform/models/WifiDevicesSchema.ts +++ b/src/lib/providers/ministry-platform/models/WifiDevicesSchema.ts @@ -8,7 +8,7 @@ export const WifiDevicesSchema = z.object({ Last_Name: z.string().max(50).nullable(), Email_Address: z.string().email().max(254).nullable(), Mobile_Phone: z.string().nullable(), - Start_Date: z.string().datetime(), + Start_Date: z.string().regex(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)"), }); export type WifiDevicesInput = z.infer<typeof WifiDevicesSchema>; diff --git a/src/lib/providers/ministry-platform/models/ZEventNotifications.ts b/src/lib/providers/ministry-platform/models/ZEventNotifications.ts deleted file mode 100644 index 7a1b9fd..0000000 --- a/src/lib/providers/ministry-platform/models/ZEventNotifications.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Interface for Z_Event_Notifications -* Table: Z_Event_Notifications - * Access Level: Read - * Special Permissions: None - * Generated from column metadata - */ -export interface ZEventNotifications { - - Event_ID: number /* 32-bit integer */; - - Registrant_Message?: number /* 32-bit integer */ | null; - - RM_Template?: number /* 32-bit integer */ | null; - - Optional_Reminder_Message?: number /* 32-bit integer */ | null; - - OR_Template?: number /* 32-bit integer */ | null; - - Attendee_Message?: number /* 32-bit integer */ | null; - - AM_Template?: number /* 32-bit integer */ | null; -} - -export type ZEventNotificationsRecord = ZEventNotifications; diff --git a/src/lib/providers/ministry-platform/models/ZEventNotificationsSchema.ts b/src/lib/providers/ministry-platform/models/ZEventNotificationsSchema.ts deleted file mode 100644 index 9c01b44..0000000 --- a/src/lib/providers/ministry-platform/models/ZEventNotificationsSchema.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { z } from 'zod'; - -export const ZEventNotificationsSchema = z.object({ - Event_ID: z.number().int(), - Registrant_Message: z.number().int().nullable(), - RM_Template: z.number().int().nullable(), - Optional_Reminder_Message: z.number().int().nullable(), - OR_Template: z.number().int().nullable(), - Attendee_Message: z.number().int().nullable(), - AM_Template: z.number().int().nullable(), -}); - -export type ZEventNotificationsInput = z.infer<typeof ZEventNotificationsSchema>; diff --git a/src/lib/providers/ministry-platform/models/ZFormNotifications.ts b/src/lib/providers/ministry-platform/models/ZFormNotifications.ts deleted file mode 100644 index 5708bfa..0000000 --- a/src/lib/providers/ministry-platform/models/ZFormNotifications.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Interface for Z_Form_Notifications -* Table: Z_Form_Notifications - * Access Level: Read - * Special Permissions: None - * Generated from column metadata - */ -export interface ZFormNotifications { - - Form_ID: number /* 32-bit integer */; - - Response_Message?: number /* 32-bit integer */ | null; - - RM_Template?: number /* 32-bit integer */ | null; -} - -export type ZFormNotificationsRecord = ZFormNotifications; diff --git a/src/lib/providers/ministry-platform/models/ZFormNotificationsSchema.ts b/src/lib/providers/ministry-platform/models/ZFormNotificationsSchema.ts deleted file mode 100644 index d832b4e..0000000 --- a/src/lib/providers/ministry-platform/models/ZFormNotificationsSchema.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { z } from 'zod'; - -export const ZFormNotificationsSchema = z.object({ - Form_ID: z.number().int(), - Response_Message: z.number().int().nullable(), - RM_Template: z.number().int().nullable(), -}); - -export type ZFormNotificationsInput = z.infer<typeof ZFormNotificationsSchema>; diff --git a/src/lib/providers/ministry-platform/models/ZOppNotifications.ts b/src/lib/providers/ministry-platform/models/ZOppNotifications.ts deleted file mode 100644 index 9f8b80f..0000000 --- a/src/lib/providers/ministry-platform/models/ZOppNotifications.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Interface for Z_Opp_Notifications -* Table: Z_Opp_Notifications - * Access Level: Read - * Special Permissions: None - * Generated from column metadata - */ -export interface ZOppNotifications { - - Opportunity_ID: number /* 32-bit integer */; - - Response_Message?: number /* 32-bit integer */ | null; - - RM_Template?: number /* 32-bit integer */ | null; - - Optional_Reminder_Message?: number /* 32-bit integer */ | null; - - OR_Template?: number /* 32-bit integer */ | null; -} - -export type ZOppNotificationsRecord = ZOppNotifications; diff --git a/src/lib/providers/ministry-platform/models/ZOppNotificationsSchema.ts b/src/lib/providers/ministry-platform/models/ZOppNotificationsSchema.ts deleted file mode 100644 index b97098a..0000000 --- a/src/lib/providers/ministry-platform/models/ZOppNotificationsSchema.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { z } from 'zod'; - -export const ZOppNotificationsSchema = z.object({ - Opportunity_ID: z.number().int(), - Response_Message: z.number().int().nullable(), - RM_Template: z.number().int().nullable(), - Optional_Reminder_Message: z.number().int().nullable(), - OR_Template: z.number().int().nullable(), -}); - -export type ZOppNotificationsInput = z.infer<typeof ZOppNotificationsSchema>; diff --git a/src/lib/providers/ministry-platform/models/index.ts b/src/lib/providers/ministry-platform/models/index.ts index 3abc4d1..ef2e1c4 100644 --- a/src/lib/providers/ministry-platform/models/index.ts +++ b/src/lib/providers/ministry-platform/models/index.ts @@ -1,40 +1,39 @@ // Auto-generated index file -// Generated on: 2026-04-16T02:07:32.635Z -// Successfully generated 301 of 301 tables +// Generated on: 2026-10-11T11:13:43.166Z +// Exports all 307 table type files in this directory -export * from "./Deployments"; -export * from "./AccountTypes"; export * from "./AccountingCompanies"; +export * from "./AccountTypes"; export * from "./ActivityLog"; export * from "./ActivityLogStaging"; export * from "./Addresses"; -export * from "./AlternateEmailTypes"; export * from "./AlternateEmails"; +export * from "./AlternateEmailTypes"; export * from "./AssignmentRoles"; -export * from "./AttributeTypes"; export * from "./Attributes"; +export * from "./AttributeTypes"; export * from "./AudienceAudienceFilters"; export * from "./AudienceFilters"; export * from "./AudienceMembers"; export * from "./AudienceMembersStaging"; export * from "./AudienceOperators"; export * from "./Audiences"; +export * from "./BackgroundChecks"; export * from "./BackgroundCheckStatuses"; export * from "./BackgroundCheckTypes"; -export * from "./BackgroundChecks"; export * from "./Banks"; export * from "./BatchEntryTypes"; +export * from "./Batches"; export * from "./BatchTypes"; export * from "./BatchUsageTypes"; -export * from "./Batches"; export * from "./BeneficiaryRelationships"; export * from "./BenefitTypes"; export * from "./BookCheckouts"; export * from "./Books"; export * from "./Buildings"; export * from "./CampaignGoals"; -export * from "./CareCaseTypes"; export * from "./CareCases"; +export * from "./CareCaseTypes"; export * from "./CareOutcomes"; export * from "./CareTypes"; export * from "./CertificationTypes"; @@ -51,9 +50,9 @@ export * from "./ContactLog"; export * from "./ContactLogTypes"; export * from "./ContactPrivateNotes"; export * from "./ContactRelationships"; +export * from "./Contacts"; export * from "./ContactStaging"; export * from "./ContactStatuses"; -export * from "./Contacts"; export * from "./Continents"; export * from "./ContributionStatementDonors"; export * from "./ContributionStatements"; @@ -61,12 +60,14 @@ export * from "./Countries"; export * from "./Currencies"; export * from "./CustomWidgetDs"; export * from "./DateAccuracies"; +export * from "./DeploymentLogs"; +export * from "./Deployments"; export * from "./Deposits"; export * from "./DonationDistributions"; export * from "./DonationFrequencies"; export * from "./DonationLevels"; -export * from "./DonationSources"; export * from "./Donations"; +export * from "./DonationSources"; export * from "./DonorAccounts"; export * from "./Donors"; export * from "./DpAccountInformation"; @@ -74,14 +75,17 @@ export * from "./DpApiClients"; export * from "./DpApplicationLabels"; export * from "./DpAuditRetentionMessages"; export * from "./DpAuditRetentionPolicies"; +export * from "./DpAuditRetentionRuns"; export * from "./DpAuthenticationLog"; export * from "./DpBookmarks"; +export * from "./DpCharts"; export * from "./DpCommunicationMessages"; +export * from "./DpCommunicationMessagesArchived"; export * from "./DpCommunicationPublications"; +export * from "./DpCommunications"; export * from "./DpCommunicationSnippets"; export * from "./DpCommunicationTemplates"; export * from "./DpCommunicationTypes"; -export * from "./DpCommunications"; export * from "./DpConfigurationLists"; export * from "./DpConfigurationSettings"; export * from "./DpContactPublications"; @@ -93,13 +97,13 @@ export * from "./DpInboundSms"; export * from "./DpIntegrationDefinitionTypes"; export * from "./DpNotificationPageRecords"; export * from "./DpNotificationPageViews"; +export * from "./DpNotifications"; export * from "./DpNotificationSelections"; export * from "./DpNotificationSubPageViews"; -export * from "./DpNotifications"; export * from "./DpPageViews"; +export * from "./DpProcesses"; export * from "./DpProcessSteps"; export * from "./DpProcessSubmissions"; -export * from "./DpProcesses"; export * from "./DpPublications"; export * from "./DpRecordInsights"; export * from "./DpRecordSecurity"; @@ -114,8 +118,8 @@ export * from "./DpUserGlobalFilters"; export * from "./DpUserGroups"; export * from "./DpUserPreferences"; export * from "./DpUserRoles"; -export * from "./DpUserUserGroups"; export * from "./DpUsers"; +export * from "./DpUserUserGroups"; export * from "./DpViewKeys"; export * from "./DpWebhookInvocations"; export * from "./DpWebhooks"; @@ -126,15 +130,15 @@ export * from "./EventGroups"; export * from "./EventMetrics"; export * from "./EventParticipants"; export * from "./EventRooms"; +export * from "./Events"; export * from "./EventServices"; export * from "./EventTypes"; -export * from "./Events"; export * from "./FaithBackgrounds"; export * from "./FeedbackEntries"; export * from "./FeedbackTypes"; export * from "./FollowUpActionTypes"; -export * from "./FormFieldTypes"; export * from "./FormFields"; +export * from "./FormFieldTypes"; export * from "./FormResponseAnswers"; export * from "./FormResponses"; export * from "./Forms"; @@ -149,34 +153,34 @@ export * from "./GroupInquiries"; export * from "./GroupParticipants"; export * from "./GroupRoleDirections"; export * from "./GroupRoleIntensities"; -export * from "./GroupRoleTypes"; export * from "./GroupRoles"; +export * from "./GroupRoleTypes"; export * from "./GroupRooms"; -export * from "./GroupTypes"; export * from "./Groups"; +export * from "./GroupTypes"; export * from "./HouseholdCareLog"; export * from "./HouseholdIdentifierLog"; export * from "./HouseholdPositions"; +export * from "./Households"; export * from "./HouseholdSources"; export * from "./HouseholdTypes"; -export * from "./Households"; export * from "./ImportDestinations"; export * from "./ImportTemplates"; export * from "./Industries"; export * from "./InvoiceDetail"; +export * from "./Invoices"; export * from "./InvoiceSources"; export * from "./InvoiceStatuses"; -export * from "./Invoices"; export * from "./ItemPriorities"; export * from "./ItemStatuses"; export * from "./Journeys"; export * from "./Letters"; export * from "./LifeStages"; export * from "./LocationCategories"; -export * from "./LocationGroupTypes"; export * from "./LocationGroups"; -export * from "./LocationTypes"; +export * from "./LocationGroupTypes"; export * from "./Locations"; +export * from "./LocationTypes"; export * from "./MaintenanceRequests"; export * from "./MappingValues"; export * from "./MaritalStatuses"; @@ -195,9 +199,9 @@ export * from "./MpVwPossibleLeaders"; export * from "./MpVwPrimaryHh"; export * from "./NeedCampaigns"; export * from "./NeedProviders"; +export * from "./Needs"; export * from "./NeedTypeProviders"; export * from "./NeedTypes"; -export * from "./Needs"; export * from "./Occupations"; export * from "./Opportunities"; export * from "./OpportunityAttributes"; @@ -205,39 +209,40 @@ export * from "./OrdinationTypes"; export * from "./ParticipantCertifications"; export * from "./ParticipantEngagement"; export * from "./ParticipantMilestones"; -export * from "./ParticipantTypes"; export * from "./Participants"; +export * from "./ParticipantTypes"; export * from "./ParticipationItems"; export * from "./ParticipationRequirements"; export * from "./ParticipationStatuses"; export * from "./PaymentDetail"; -export * from "./PaymentTypes"; export * from "./Payments"; +export * from "./PaymentTypes"; export * from "./Personnel"; -export * from "./PersonnelAssignmentTypes"; export * from "./PersonnelAssignments"; +export * from "./PersonnelAssignmentTypes"; export * from "./PersonnelBeneficiaries"; export * from "./PersonnelBenefits"; export * from "./PersonnelCategories"; -export * from "./PersonnelCommentTypes"; export * from "./PersonnelComments"; +export * from "./PersonnelCommentTypes"; export * from "./PersonnelEstatePlans"; export * from "./PersonnelOrdination"; export * from "./PersonnelRecordStatuses"; -export * from "./PersonnelResumeItemTypes"; export * from "./PersonnelResumeItems"; +export * from "./PersonnelResumeItemTypes"; export * from "./PersonnelTypes"; export * from "./Perspectives"; export * from "./PlannedContacts"; -export * from "./PledgeAdjustmentTypes"; export * from "./PledgeAdjustments"; -export * from "./PledgeCampaignTypes"; +export * from "./PledgeAdjustmentTypes"; export * from "./PledgeCampaigns"; +export * from "./PledgeCampaignTypes"; export * from "./PledgeFrequencies"; -export * from "./PledgeStatuses"; export * from "./Pledges"; +export * from "./PledgeStatuses"; export * from "./PocketPlatformDevices"; export * from "./PocketPlatformMigrations"; +export * from "./PocketPlatformSocialMediaProviders"; export * from "./Prefixes"; export * from "./PrimaryLanguages"; export * from "./PrintServers"; @@ -247,29 +252,29 @@ export * from "./ProductOptionGroups"; export * from "./ProductOptionPrices"; export * from "./Products"; export * from "./ProgramGroups"; -export * from "./ProgramTypes"; export * from "./Programs"; +export * from "./ProgramTypes"; export * from "./Relationships"; -export * from "./ReligiousOrderStatuses"; export * from "./ReligiousOrders"; +export * from "./ReligiousOrderStatuses"; export * from "./RequestStatuses"; export * from "./ResponseFollowUps"; export * from "./ResponseResults"; export * from "./Responses"; export * from "./RoomLayouts"; -export * from "./RoomUsageTypes"; export * from "./Rooms"; +export * from "./RoomUsageTypes"; export * from "./RsvpStatuses"; export * from "./SacramentPlaces"; +export * from "./Sacraments"; export * from "./SacramentSponsors"; export * from "./SacramentTypes"; -export * from "./Sacraments"; -export * from "./ScheduleRoles"; -export * from "./ScheduleStatuses"; export * from "./ScheduledDonationDistributions"; export * from "./ScheduledDonations"; export * from "./ScheduledParticipants"; +export * from "./ScheduleRoles"; export * from "./Schedules"; +export * from "./ScheduleStatuses"; export * from "./ServiceTypes"; export * from "./Servicing"; export * from "./SponsorTypes"; @@ -279,6 +284,7 @@ export * from "./StatementFrequencies"; export * from "./StatementHeaders"; export * from "./StatementMethods"; export * from "./StatementTypes"; +export * from "./StatisticsLogs"; export * from "./Suffixes"; export * from "./SuggestionTypes"; export * from "./TextingComplianceLevels"; @@ -289,7 +295,7 @@ export * from "./VolunteerUnavailableDates"; export * from "./VwMpCampaignGoals"; export * from "./VwMpContactDetails"; export * from "./VwMpContactMailName"; -export * from "./VwMpGivingUnitSummary"; +export * from "./VwMpGivingUnitSummaryStandard"; export * from "./VwMpParticipationCompliance"; export * from "./VwMpParticipationComplianceDetails"; export * from "./VwMpPersonnelAuditOverview"; @@ -297,9 +303,9 @@ export * from "./VwMpProjectedScheduledDonations"; export * from "./VwMpResponseQualificationDetails"; export * from "./VwMpResponseQualifications"; export * from "./VwMpUserRights"; +export * from "./WaitStatsBaseline"; +export * from "./WaitStatsHistory"; +export * from "./WaitTypes"; export * from "./WeeklySnapshots"; -export * from "./WifiDeviceSessions"; export * from "./WifiDevices"; -export * from "./ZEventNotifications"; -export * from "./ZFormNotifications"; -export * from "./ZOppNotifications"; +export * from "./WifiDeviceSessions"; diff --git a/src/lib/providers/ministry-platform/scripts/README.md b/src/lib/providers/ministry-platform/scripts/README.md index c287488..042ef7e 100644 --- a/src/lib/providers/ministry-platform/scripts/README.md +++ b/src/lib/providers/ministry-platform/scripts/README.md @@ -15,14 +15,14 @@ A CLI utility that generates TypeScript interfaces and Zod schemas from your Min - ✅ **Zod Schema Generation**: Optional runtime validation schemas with length and type constraints - ✅ **Flexible Output**: Choose your output directory - ✅ **Search Filtering**: Generate types for specific tables only -- ✅ **Auto-generated Index**: Creates barrel exports for easy importing +- ✅ **Auto-generated Index**: Creates an `index.ts` barrel re-exporting every generated **interface** file (the `*Schema.ts` files are not re-exported — import them by path) ## Prerequisites Ensure your environment contains the required Ministry Platform configuration: ```env -MINISTRY_PLATFORM_BASE_URL=https://your-domain.ministryplatformapi.com +MINISTRY_PLATFORM_BASE_URL=https://your-domain.ministryplatform.com/ministryplatformapi MINISTRY_PLATFORM_CLIENT_ID=your_client_id MINISTRY_PLATFORM_CLIENT_SECRET=your_client_secret ``` @@ -83,43 +83,48 @@ npx tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/ge |--------|-------|-------------|---------| | `--output` | `-o` | Output directory for generated types | `./generated-types` | | `--search` | `-s` | Filter tables by search term | (none) | -| `--detailed` | `-d` | Generate detailed types by sampling records | `false` | +| `--detailed` | `-d` | Sample records to infer types — only for tables that have no column metadata | `false` | | `--sample-size` | | Number of records to sample in detailed mode | `5` | | `--zod` | `-z` | Generate Zod schemas for runtime validation | `false` | +| `--clean` | `-c` | Delete every `.ts` file in an existing output directory before generating | `false` | | `--help` | `-h` | Show help message | | ## Output Examples ### Standard Interface +Excerpt of the committed `models/Contacts.ts`: + ```typescript /** * Interface for Contacts - * Table: Contacts +* Table: Contacts * Access Level: ReadWriteAssignDelete * Special Permissions: FileAttach, DataExport, SecureRecord * Generated from column metadata */ -export interface ContactsRecord { - Contact_ID: number; // Primary Key - +export interface Contacts { + + Contact_ID: number /* 32-bit integer */; // Primary Key + /** * Max length: 125 characters */ - Display_Name: string; - + Display_Name: string /* max 125 chars */; + /** * Max length: 254 characters */ - Email_Address?: string /* email */ | null; - + Email_Address?: string /* email, max 254 chars */ | null; + Mobile_Phone?: string /* phone number */ | null; - Contact_GUID: string /* GUID/UUID */; - + + Contact_GUID: string /* GUID/UUID */; // Has Default + // ... additional fields } -export type Contacts = ContactsRecord; +export type ContactsRecord = Contacts; ``` ### Zod Schema (with --zod flag) @@ -133,6 +138,9 @@ export const ContactsSchema = z.object({ Email_Address: z.string().email().max(254).nullable(), Mobile_Phone: z.string().nullable(), Contact_GUID: z.string().uuid(), + // Date columns: YYYY-MM-DD with an optional time part. DateTime columns use the + // same shape with the time required — MP wall-clock ("2026-05-17 14:30:00") or ISO. + Date_of_Birth: z.string().regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/, "...").nullable(), // ... additional fields }); @@ -143,7 +151,8 @@ export type ContactsInput = z.infer<typeof ContactsSchema>; ```typescript import { MPHelper } from '@/lib/providers/ministry-platform'; -import { Contacts, ContactsSchema } from './generated-types'; +import { Contacts } from './generated-types'; // barrel: interfaces only +import { ContactsSchema } from './generated-types/ContactsSchema'; // schemas: import by path const mp = new MPHelper(); @@ -160,36 +169,39 @@ await mp.createTableRecords('Contacts', [validatedContact]); ## Recommended Workflow -1. Generate types to the models directory: +1. Regenerate the committed models (all tables, cleaned first): ```bash - npx tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts \ - -o src/lib/providers/ministry-platform/models \ - --zod \ - --search "Contact" + npm run mp:generate:models ``` + A `--search` run into the same directory is safe: `index.ts` is rebuilt from every type file in the output directory, so other tables stay exported, and the `mp-schema/tables.md` catalog is left untouched (it is only rewritten on a full run). 2. Import and use in your app: ```typescript - import { ContactLog, ContactLogSchema } from '@/lib/providers/ministry-platform/models'; + import { ContactLog } from '@/lib/providers/ministry-platform/models'; + import { ContactLogSchema } from '@/lib/providers/ministry-platform/models/ContactLogSchema'; ``` 3. Re-run when schema changes to keep types in sync ## Package.json Scripts -Add to your `package.json` for easier access: +Already defined in `package.json`: ```json { "scripts": { "mp:generate": "tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts", - "mp:generate:models": "tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts -o src/lib/providers/ministry-platform/models --zod" + "mp:generate:models": "tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts -o src/lib/providers/ministry-platform/models --zod --clean", + "mp:generate:storedprocs": "tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-storedprocs.ts", + "mp:build:install": "tsx src/lib/providers/ministry-platform/scripts/build-sql-install.ts" } } ``` -Then run with: -```bash -npm run mp:generate -npm run mp:generate:models -``` +## Other scripts in this folder + +- `generate-storedprocs.ts` — writes the stored-procedure reference to `.claude/references/mp-schema/stored-procs.md` (with frontmatter; `-o`, `-s`, `-h` — a `-s` run requires `-o` so it cannot overwrite the full catalog). +- `build-sql-install.ts` — combines `../db/*.sql` into `_INSTALL/ministryplatform-install.sql`; skips when the content hash is unchanged. Imports nothing from the provider, so it needs no `--conditions` flag. +- `codegen-escape.ts` — escaping helpers (`propertyKey`, `commentText`, `positiveInt`) for MP metadata interpolated into generated source. + +The type generator also writes the table catalog `.claude/references/mp-schema/tables.md` (with frontmatter) on full runs; a `--search` run skips it, and rebuilds `models/index.ts` from every type file in the output directory so other tables stay exported. diff --git a/src/lib/providers/ministry-platform/scripts/generate-storedprocs.test.ts b/src/lib/providers/ministry-platform/scripts/generate-storedprocs.test.ts new file mode 100644 index 0000000..1a68fc2 --- /dev/null +++ b/src/lib/providers/ministry-platform/scripts/generate-storedprocs.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi } from 'vitest'; + +// The script loads .env files and imports the MP client at module scope; keep +// both out of the test process. +vi.mock('dotenv', () => ({ config: vi.fn() })); +vi.mock('../helper', () => ({ MPHelper: class {} })); + +import { STORED_PROCS_DOC_PATH, generateDocument, formatParamSignature } from './generate-storedprocs'; +import type { ProcedureInfo } from '../types'; + +const procs = [ + { + Name: 'api_Foo_Get', + Parameters: [ + { Name: '@Id', DataType: 'Integer32', Size: 4, Direction: 'Input' }, + { Name: '@Name', DataType: 'String', Size: 50, Direction: 'Input' }, + { Name: '@RETURN_VALUE', DataType: 'Integer32', Size: 4, Direction: 'ReturnValue' }, + ], + }, + { Name: 'cleanup', Parameters: [] }, +] as unknown as ProcedureInfo[]; + +describe('generate-storedprocs', () => { + const now = new Date('2026-10-11T08:00:00.000Z'); + + it('defaults to the committed mp-schema catalog', () => { + expect(STORED_PROCS_DOC_PATH).toBe('.claude/references/mp-schema/stored-procs.md'); + }); + + it('emits the catalog frontmatter with last_verified', () => { + const md = generateDocument(procs, undefined, now); + expect( + md.startsWith( + '---\ntitle: Ministry Platform Stored Procedures\ndomain: mp-schema\ntype: generated-reference\n' + + 'last_verified: 2026-10-11\nsource: npm run mp:generate:storedprocs\n---\n', + ), + ).toBe(true); + expect(md).toContain('## How to call'); + expect(md).toContain('**Generated (source):** 2026-10-11T08:00:00.000Z'); + expect(md).toContain('**Procedures:** 2'); + expect(md).not.toContain('**Filter:**'); + }); + + it('groups by prefix with Other last and records a filter', () => { + const md = generateDocument(procs, 'Foo', now); + expect(md).toContain('**Filter:** "Foo"'); + expect(md.indexOf('### api_* (1)')).toBeLessThan(md.indexOf('### Other (1)')); + expect(md).toContain('- `api_Foo_Get(@Id: Integer32, @Name: String(50))`'); + expect(md).toContain('- `cleanup(no parameters)`'); + }); + + it('omits the return value from signatures', () => { + expect(formatParamSignature(procs[0].Parameters)).not.toContain('RETURN_VALUE'); + }); +}); diff --git a/src/lib/providers/ministry-platform/scripts/generate-storedprocs.ts b/src/lib/providers/ministry-platform/scripts/generate-storedprocs.ts index 9fae460..6a86d84 100644 --- a/src/lib/providers/ministry-platform/scripts/generate-storedprocs.ts +++ b/src/lib/providers/ministry-platform/scripts/generate-storedprocs.ts @@ -22,8 +22,13 @@ envFiles.forEach(file => { import { MPHelper } from "../helper"; import type { ProcedureInfo, ParameterInfo } from "../types"; +/** The committed catalog this script refreshes (with its frontmatter). */ +export const STORED_PROCS_DOC_PATH = '.claude/references/mp-schema/stored-procs.md'; + interface CLIOptions { output: string; + /** True when -o was given; a --search run only writes when it was. */ + outputExplicit?: boolean; search?: string; help?: boolean; } @@ -31,7 +36,7 @@ interface CLIOptions { function parseArguments(): CLIOptions { const args = process.argv.slice(2); const options: CLIOptions = { - output: path.resolve(process.cwd(), '.claude/references/ministryplatform.storedprocs.md'), + output: path.resolve(process.cwd(), STORED_PROCS_DOC_PATH), }; for (let i = 0; i < args.length; i++) { @@ -41,6 +46,7 @@ function parseArguments(): CLIOptions { case "-o": case "--output": options.output = args[++i]; + options.outputExplicit = true; break; case "-s": case "--search": @@ -80,13 +86,14 @@ Prerequisites: Supports .env.local, .env.development, and .env files (loaded in that order) Options: - -o, --output <path> Output file path (default: .claude/references/ministryplatform.storedprocs.md) + -o, --output <path> Output file path (default: .claude/references/mp-schema/stored-procs.md; + a -s run needs -o, so it never overwrites the full catalog) -s, --search <term> Optional search term to filter procedures -h, --help Show this help message Examples: npm run mp:generate:storedprocs - npm run mp:generate:storedprocs -- -s "Contact" + npm run mp:generate:storedprocs -- -s "Contact" -o ./contact-procs.md npm run mp:generate:storedprocs -- -o ./my-procs-reference.md `); } @@ -108,7 +115,7 @@ function validateEnvironment() { } } -function groupByPrefix(procedures: ProcedureInfo[]): Map<string, ProcedureInfo[]> { +export function groupByPrefix(procedures: ProcedureInfo[]): Map<string, ProcedureInfo[]> { const groups = new Map<string, ProcedureInfo[]>(); for (const proc of procedures) { @@ -140,7 +147,7 @@ function groupByPrefix(procedures: ProcedureInfo[]): Map<string, ProcedureInfo[] return sorted; } -function formatParamSignature(params: ParameterInfo[]): string { +export function formatParamSignature(params: ParameterInfo[]): string { const inputParams = params.filter(p => p.Direction !== "ReturnValue"); if (inputParams.length === 0) return "(no parameters)"; @@ -153,12 +160,25 @@ function formatParamSignature(params: ParameterInfo[]): string { }).join(", ") + ")"; } -function generateDocument(procedures: ProcedureInfo[], search?: string): string { +export function generateDocument(procedures: ProcedureInfo[], search?: string, now: Date = new Date()): string { const groups = groupByPrefix(procedures); - let md = "# Ministry Platform Stored Procedures Reference\n\n"; - md += "This document provides a summary of stored procedures available via the Ministry Platform API for LLM assistants working on the MPNext project.\n\n"; - md += `**Generated:** ${new Date().toISOString()}\n`; + let md = "---\n"; + md += "title: Ministry Platform Stored Procedures\n"; + md += "domain: mp-schema\n"; + md += "type: generated-reference\n"; + md += `last_verified: ${now.toISOString().slice(0, 10)}\n`; + md += "source: npm run mp:generate:storedprocs\n"; + md += "---\n\n"; + md += "## Purpose\n"; + md += "Auto-generated reference of every Ministry Platform stored procedure exposed via the MP API, with its input parameters and bounded string sizes. Regenerate with `npm run mp:generate:storedprocs` (runs `src/lib/providers/ministry-platform/scripts/generate-storedprocs.ts`). Do not edit by hand — the generator overwrites this file.\n\n"; + md += "## How to call\n"; + md += "- HTTP: `POST /procs/{procName}` with JSON body `{ \"@ParamName\": value, ... }` (or GET for simple query params).\n"; + md += "- Code: use `MPHelper.executeProcedure()` / `executeProcedureWithBody()` (`src/lib/providers/ministry-platform/helper.ts`), which delegate to `ProcedureService`.\n"; + md += "- Return shape: `unknown[][]` — an array of result sets, each an array of row objects.\n"; + md += "- `@DomainID` is auto-injected by the MP API — do not pass it.\n\n"; + md += "## Generated metadata\n\n"; + md += `**Generated (source):** ${now.toISOString()}\n`; md += `**Procedures:** ${procedures.length}\n`; if (search) { md += `**Filter:** "${search}"\n`; @@ -188,6 +208,11 @@ async function main() { return; } + if (options.search && !options.outputExplicit) { + console.error(`A --search run lists only matching procedures; pass -o <path> so it does not overwrite ${STORED_PROCS_DOC_PATH}.`); + process.exit(1); + } + validateEnvironment(); console.log("Generating stored procedure reference from Ministry Platform...\n"); @@ -240,6 +265,7 @@ async function main() { } } -if (require.main === module) { +// Guarded so tests can import the pure generators without running main(). +if (typeof require !== 'undefined' && typeof module !== 'undefined' && require.main === module) { main().catch(console.error); } diff --git a/src/lib/providers/ministry-platform/scripts/generate-types.test.ts b/src/lib/providers/ministry-platform/scripts/generate-types.test.ts new file mode 100644 index 0000000..78e5c69 --- /dev/null +++ b/src/lib/providers/ministry-platform/scripts/generate-types.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, vi } from 'vitest'; +import { z } from 'zod'; + +// The script loads .env files and imports the MP client at module scope; keep +// both out of the test process. +vi.mock('dotenv', () => ({ config: vi.fn() })); +vi.mock('../helper', () => ({ MPHelper: class {} })); + +import { + MP_DATE_PATTERN, + MP_DATETIME_PATTERN, + SCHEMA_DOC_PATH, + generateIndexFile, + generateSchemaDocument, + generateZodSchema, + mapDataTypeToZod, +} from './generate-types'; +import type { TableMetadata } from '../types'; + +type Col = Parameters<typeof mapDataTypeToZod>[0]; +const col = (DataType: Col['DataType'], IsRequired = true): Col => + ({ Name: 'X', DataType, IsRequired, Size: 0 }) as Col; + +/** Evaluate an emitted zod expression the way the generated file would. */ +function evalZod(expr: string): z.ZodType { + return new Function('z', `return ${expr};`)(z) as z.ZodType; +} + +describe('mapDataTypeToZod — DateTime/Timestamp', () => { + const accepts = [ + '2026-05-17 14:30:00', // DomainTimezoneService.toMpSqlDatetime output + '2026-05-17 14:30', + '2026-05-17T14:30:00', // what MP returns on reads + '2026-05-17T14:30:00.123', + '2026-05-17T14:30:00.1234567', + '2026-05-17T03:33:00.000Z', // ISO instant + '2026-05-17T03:33:00-04:00', + '2026-05-17 03:33:00+0530', + ]; + const rejects = [ + '2026-05-17', + '14:30:00', + '2026-05-17 14', + '2026/05/17 14:30:00', + 'not a date', + '', + '2026-05-17 14:30:00 junk', + ]; + + it.each(['DateTime', 'Timestamp'] as const)('%s emits a regex, not z.string().datetime()', (t) => { + const out = mapDataTypeToZod(col(t)); + expect(out).not.toContain('.datetime('); + expect(out).toContain('.regex('); + }); + + it.each(accepts)('accepts %j', (v) => { + expect(evalZod(mapDataTypeToZod(col('DateTime'))).safeParse(v).success).toBe(true); + }); + + it.each(rejects)('rejects %j', (v) => { + expect(evalZod(mapDataTypeToZod(col('DateTime'))).safeParse(v).success).toBe(false); + }); + + it('is nullable when not required', () => { + const schema = evalZod(mapDataTypeToZod(col('DateTime', false))); + expect(schema.safeParse(null).success).toBe(true); + expect(schema.safeParse('2026-05-17 14:30:00').success).toBe(true); + }); + + it('emits the exported pattern', () => { + expect(mapDataTypeToZod(col('DateTime'))).toContain(`/${MP_DATETIME_PATTERN}/`); + }); +}); + +describe('mapDataTypeToZod — Date', () => { + it.each(['2026-05-17', '2026-05-17 00:00:00', '2026-05-17T00:00:00', '2026-05-17T00:00:00Z'])( + 'accepts %j', + (v) => { + expect(evalZod(mapDataTypeToZod(col('Date'))).safeParse(v).success).toBe(true); + }, + ); + + it.each(['05/17/2026', '2026-5-17', '2026-05-17 junk'])('rejects %j', (v) => { + expect(evalZod(mapDataTypeToZod(col('Date'))).safeParse(v).success).toBe(false); + }); + + it('emits the exported pattern', () => { + expect(mapDataTypeToZod(col('Date'))).toContain(`/${MP_DATE_PATTERN}/`); + }); +}); + +describe('generateZodSchema', () => { + it('produces a standalone module whose datetime field accepts MP wall-clock', () => { + const table = { + Name: 'Contact_Log', + Columns: [ + { Name: 'Contact_Log_ID', DataType: 'Integer32', IsRequired: true, Size: 4 }, + { Name: 'Contact_Date', DataType: 'DateTime', IsRequired: true, Size: 8 }, + { Name: 'Notes', DataType: 'String', IsRequired: false, Size: 2000 }, + ], + } as unknown as TableMetadata; + const src = generateZodSchema(table, 'Contact_Log'); + expect(src).toContain("import { z } from 'zod';"); + expect(src).toContain('export const ContactLogSchema = z.object({'); + + const body = src.slice(src.indexOf('z.object('), src.indexOf('});') + 2); + const schema = evalZod(body); + expect( + schema.safeParse({ Contact_Log_ID: 1, Contact_Date: '2026-05-17 14:30:00', Notes: null }).success, + ).toBe(true); + expect( + schema.safeParse({ Contact_Log_ID: 1, Contact_Date: '05/17/2026', Notes: null }).success, + ).toBe(false); + }); +}); + +describe('generateIndexFile', () => { + const now = new Date('2026-10-11T12:00:00Z'); + + it('exports every type file in the directory, not only this run', () => { + // A --search run only regenerated Contacts; the others are already there. + const files = [ + 'Contacts.ts', + 'ContactsSchema.ts', + 'Households.ts', + 'HouseholdsSchema.ts', + 'Events.ts', + 'index.ts', + ]; + const out = generateIndexFile(files, now); + expect(out).toContain('export * from "./Contacts";'); + expect(out).toContain('export * from "./Households";'); + expect(out).toContain('export * from "./Events";'); + expect(out).toContain('Exports all 3 table type files'); + }); + + it('skips schema companions, the barrel, tests and non-ts files', () => { + const out = generateIndexFile(['B.ts', 'BSchema.ts', 'index.ts', 'B.test.ts', 'x.d.ts', 'README.md'], now); + expect(out.match(/export \* from/g)).toHaveLength(1); + expect(out).not.toContain('BSchema'); + }); + + it('keeps a table type whose own name ends in Schema', () => { + // Table "Foo_Schema" -> FooSchema.ts + FooSchemaSchema.ts, with no Foo.ts. + const out = generateIndexFile(['FooSchema.ts', 'FooSchemaSchema.ts'], now); + expect(out).toContain('export * from "./FooSchema";'); + expect(out).not.toContain('"./FooSchemaSchema"'); + }); + + it('is sorted and deterministic', () => { + const out = generateIndexFile(['Zeta.ts', 'Alpha.ts', 'Mid.ts'], now); + expect(out.indexOf('Alpha')).toBeLessThan(out.indexOf('Mid')); + expect(out.indexOf('Mid')).toBeLessThan(out.indexOf('Zeta')); + }); +}); + +describe('generateSchemaDocument', () => { + const now = new Date('2026-10-11T12:34:56.000Z'); + const tables = [ + { + Name: 'Contacts', + AccessLevel: 'ReadWriteAssignDelete', + Columns: [ + { Name: 'Contact_ID', DataType: 'Integer32', IsRequired: true, Size: 4, IsPrimaryKey: true }, + { + Name: 'Household_ID', + DataType: 'Integer32', + IsRequired: false, + Size: 4, + IsForeignKey: true, + ReferencedTable: 'Households', + }, + ], + }, + { Name: 'Account_Types', AccessLevel: 'Read', Columns: [] }, + ] as unknown as TableMetadata[]; + + it('targets the committed mp-schema catalog', () => { + expect(SCHEMA_DOC_PATH).toBe('.claude/references/mp-schema/tables.md'); + }); + + it('emits the catalog frontmatter with last_verified', () => { + const md = generateSchemaDocument(tables, './src/lib/providers/ministry-platform/models', now); + expect( + md.startsWith( + '---\ntitle: Ministry Platform Schema — Tables\ndomain: mp-schema\ntype: generated-reference\n' + + 'last_verified: 2026-10-11\nsource: npm run mp:generate:models\n---\n', + ), + ).toBe(true); + expect(md).toContain('## Purpose'); + expect(md).toContain('**Generated (source):** 2026-10-11T12:34:56.000Z | **Tables:** 2'); + expect(md).toContain('`src/lib/providers/ministry-platform/models/{PascalCaseTableName}.ts`'); + }); + + it('lists tables sorted with PK/FK and access abbreviations', () => { + const md = generateSchemaDocument(tables, 'models', now); + expect(md.indexOf('### Account_Types [R]')).toBeLessThan(md.indexOf('### Contacts [RWAD]')); + expect(md).toContain('PK: `Contact_ID` | FK: Household_ID→Households'); + }); + + it('normalizes Windows separators in the type-file path', () => { + const md = generateSchemaDocument(tables, 'src\\lib\\models\\', now); + expect(md).toContain('`src/lib/models/{PascalCaseTableName}.ts`'); + }); +}); diff --git a/src/lib/providers/ministry-platform/scripts/generate-types.ts b/src/lib/providers/ministry-platform/scripts/generate-types.ts index ca18cbb..74c33a5 100644 --- a/src/lib/providers/ministry-platform/scripts/generate-types.ts +++ b/src/lib/providers/ministry-platform/scripts/generate-types.ts @@ -37,13 +37,6 @@ interface ColumnMetadata { HasDefault?: boolean; } -// API returns 'Name' but TableMetadata type defines 'Table_Name' -// Use Omit to avoid type conflict and support both -interface TableMetadataWithName extends Omit<TableMetadata, 'Table_Name'> { - Name?: string; - Table_Name?: string; -} - interface CLIOptions { outputDir: string; search?: string; @@ -138,12 +131,6 @@ Examples: `); } -function getTableName(table: TableMetadata): string | undefined { - // API returns 'Name' but types define 'Table_Name' - support both - const extendedTable = table as TableMetadataWithName; - return extendedTable.Name ?? extendedTable.Table_Name; -} - function sanitizeTypeName(name: string): string { // Convert table name to PascalCase and remove special characters let result = name @@ -224,7 +211,22 @@ function mapDataTypeToTypeScript(dataType: ParameterDataType, isRequired: boolea return isRequired ? baseType : `${baseType} | null`; } -function mapDataTypeToZod(col: ColumnMetadata): string { +// MP stores datetimes as wall-clock values in the domain's time zone and returns +// them without a zone marker; DomainTimezoneService.toMpSqlDatetime() emits +// `YYYY-MM-DD HH:MM:SS`. Zod's z.string().datetime() / .date() accept only ISO +// forms (and .datetime() demands T + Z/offset), so they would reject exactly the +// values CLAUDE.md rule 17 requires. These patterns accept both: space or `T` +// separator, optional seconds/fraction, optional `Z` or `±HH:MM` offset. +// Emitted inline (as regex literal source) so each generated *Schema.ts stays a +// standalone file that only imports zod. +export const MP_DATETIME_PATTERN = + String.raw`^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$`; +// Date columns: a bare date, or a date with a time part (MP returns Date columns +// as `YYYY-MM-DDT00:00:00`, and toMpSqlDatetime('YYYY-MM-DD') yields `... 00:00:00`). +export const MP_DATE_PATTERN = + String.raw`^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$`; + +export function mapDataTypeToZod(col: ColumnMetadata): string { let zodType = ""; // `.max(${size})` is executable code in the generated schema, not a comment. const size = positiveInt(col.Size); @@ -268,14 +270,14 @@ function mapDataTypeToZod(col: ColumnMetadata): string { zodType = "z.boolean()"; break; case "Date": - zodType = "z.string().date()"; + zodType = `z.string().regex(/${MP_DATE_PATTERN}/, "Expected YYYY-MM-DD (optionally with a time)")`; break; case "Time": zodType = "z.string()"; // Could add time format validation break; case "DateTime": case "Timestamp": - zodType = "z.string().datetime()"; + zodType = `z.string().regex(/${MP_DATETIME_PATTERN}/, "Expected MP datetime YYYY-MM-DD HH:MM[:SS] (space or T, optional Z/offset)")`; break; case "Guid": zodType = "z.string().uuid()"; @@ -291,7 +293,7 @@ function mapDataTypeToZod(col: ColumnMetadata): string { return zodType; } -function generateZodSchema(table: TableMetadata, tableName: string): string { +export function generateZodSchema(table: TableMetadata, tableName: string): string { const typeName = sanitizeTypeName(tableName); const schemaName = `${typeName}Schema`; @@ -345,7 +347,7 @@ function generateDetailedTypeDefinition( sampleRecords?: Record<string, unknown>[], tableName?: string ): string { - const name = tableName || getTableName(table) || 'Unknown'; + const name = tableName || table.Name || 'Unknown'; const interfaceName = sanitizeTypeName(name); const typeName = `${interfaceName}Record`; @@ -465,52 +467,76 @@ function generateTableDocumentation(table: TableMetadata, tableName: string): st return md; } -function generateSchemaDocument(tables: TableMetadata[], outputDir: string): string { +export const SCHEMA_DOC_PATH = '.claude/references/mp-schema/tables.md'; + +/** Forward-slash, no leading `./` — the form the committed doc uses. */ +function displayPath(p: string): string { + return p.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, ''); +} + +export function generateSchemaDocument( + tables: TableMetadata[], + outputDir: string, + now: Date = new Date(), +): string { const validTables = tables .filter(table => { - const name = getTableName(table); + const name = table.Name; return name && typeof name === 'string'; }) .sort((a, b) => { - const nameA = getTableName(a) || ''; - const nameB = getTableName(b) || ''; + const nameA = a.Name || ''; + const nameB = b.Name || ''; return nameA.localeCompare(nameB); }); - let md = `# Ministry Platform Schema Reference\n\n`; - md += `**Generated:** ${new Date().toISOString()} | **Tables:** ${validTables.length}\n\n`; - md += `**Type files:** \`${outputDir}/{PascalCaseTableName}.ts\` and \`{PascalCaseTableName}Schema.ts\`\n`; + const dir = displayPath(outputDir); + + let md = `---\n`; + md += `title: Ministry Platform Schema — Tables\n`; + md += `domain: mp-schema\n`; + md += `type: generated-reference\n`; + md += `last_verified: ${now.toISOString().slice(0, 10)}\n`; + md += `source: npm run mp:generate:models\n`; + md += `---\n\n`; + md += `## Purpose\n`; + md += `Auto-generated catalog of Ministry Platform tables, primary keys, and foreign key relationships. Regenerate with \`npm run mp:generate:models\` (runs \`src/lib/providers/ministry-platform/scripts/generate-types.ts\` with \`--clean --zod\`, emitting to \`src/lib/providers/ministry-platform/models/\`). Do not edit by hand — the generator overwrites this file.\n\n`; + md += `## Generated metadata\n\n`; + md += `**Generated (source):** ${now.toISOString()} | **Tables:** ${validTables.length}\n\n`; + md += `**Type files:** \`${dir}/{PascalCaseTableName}.ts\` and \`{PascalCaseTableName}Schema.ts\`\n`; md += `**Access:** R=Read, RW=ReadWrite, RWAD=ReadWriteAssignDelete\n\n`; md += `---\n\n`; validTables.forEach(table => { - const tableName = getTableName(table)!; + const tableName = table.Name; md += generateTableDocumentation(table, tableName); }); return md; } -function generateIndexFile(tables: TableMetadata[], generatedFiles: string[]): string { - // Filter tables to only include those that were successfully generated - const validTables = tables.filter(table => { - const name = getTableName(table); - return name && - typeof name === 'string' && - generatedFiles.includes(`${sanitizeTypeName(name)}.ts`); - }); - - const exports = validTables - .map(table => { - const name = getTableName(table)!; - const typeName = sanitizeTypeName(name); - return `export * from "./${typeName}";`; - }) +/** + * Builds the models barrel from every type file present in the output + * directory — not just the tables fetched this run — so a `--search` run adds + * or refreshes its tables without dropping the rest from `index.ts`. + * Zod schema files (`FooSchema.ts` next to `Foo.ts`) are not re-exported; + * import them by path. + */ +export function generateIndexFile(fileNames: string[], now: Date = new Date()): string { + const tsFiles = new Set( + fileNames.filter(f => f.endsWith('.ts') && !f.endsWith('.d.ts') && !f.endsWith('.test.ts') && f !== 'index.ts'), + ); + const typeFiles = [...tsFiles] + .filter(f => !(f.endsWith('Schema.ts') && tsFiles.has(f.replace(/Schema\.ts$/, '.ts')))) + .sort((a, b) => a.localeCompare(b)); + + const exports = typeFiles + .map(f => `export * from "./${f.replace(/\.ts$/, '')}";`) .join("\n"); return `// Auto-generated index file -// Generated on: ${new Date().toISOString()} -// Successfully generated ${validTables.length} of ${tables.length} tables +// Generated on: ${now.toISOString()} +// Exports all ${typeFiles.length} table type files in this directory ${exports} `; @@ -563,7 +589,7 @@ async function main() { // Debug: Check for tables with invalid names const invalidTables = tables.filter(table => { - const name = getTableName(table); + const name = table.Name; return !name || typeof name !== 'string'; }); if (invalidTables.length > 0) { @@ -599,7 +625,7 @@ async function main() { for (const table of tables) { // Skip tables with invalid names - const rawName = getTableName(table); + const rawName = table.Name; if (!rawName || typeof rawName !== 'string') { console.log(` ⚠ Skipping table with invalid name:`, table); continue; @@ -655,23 +681,28 @@ async function main() { // Generate index file const indexPath = path.join(options.outputDir, "index.ts"); - const indexContent = generateIndexFile(tables, generatedFiles); + const indexContent = generateIndexFile(fs.readdirSync(options.outputDir)); fs.writeFileSync(indexPath, indexContent); console.log(` ✓ index.ts (barrel export)`); - // Generate schema documentation - console.log("\n📝 Generating schema documentation..."); - const schemaDocPath = path.resolve(process.cwd(), '.claude/references/ministryplatform.schema.md'); - const schemaDocContent = generateSchemaDocument(tables, options.outputDir); + // Generate schema documentation — only on a full run: a --search run sees + // a subset of tables and would overwrite the committed catalog with it. + if (options.search) { + console.log(`\n📝 Skipping ${SCHEMA_DOC_PATH} (partial --search run; regenerate without -s to refresh it)`); + } else { + console.log("\n📝 Generating schema documentation..."); + const schemaDocPath = path.resolve(process.cwd(), SCHEMA_DOC_PATH); + const schemaDocContent = generateSchemaDocument(tables, options.outputDir); - const schemaDocDir = path.dirname(schemaDocPath); - if (!fs.existsSync(schemaDocDir)) { - fs.mkdirSync(schemaDocDir, { recursive: true }); - } + const schemaDocDir = path.dirname(schemaDocPath); + if (!fs.existsSync(schemaDocDir)) { + fs.mkdirSync(schemaDocDir, { recursive: true }); + } - fs.writeFileSync(schemaDocPath, schemaDocContent); - console.log(` ✓ ministryplatform.schema.md`); + fs.writeFileSync(schemaDocPath, schemaDocContent); + console.log(` ✓ ${SCHEMA_DOC_PATH}`); + } const zodSchemaCount = options.zodSchemas ? successfulTables : 0; console.log(`\n🎉 Successfully generated ${successfulTables} table types${zodSchemaCount > 0 ? ` + ${zodSchemaCount} Zod schemas` : ''} (${generatedFiles.length} total files) in ${options.outputDir}`); @@ -716,6 +747,7 @@ async function main() { } } -if (require.main === module) { +// Guarded so tests can import the pure generators without running main(). +if (typeof require !== 'undefined' && typeof module !== 'undefined' && require.main === module) { main().catch(console.error); } diff --git a/src/lib/providers/ministry-platform/types/provider.types.ts b/src/lib/providers/ministry-platform/types/provider.types.ts index e6ea194..c0f210a 100644 --- a/src/lib/providers/ministry-platform/types/provider.types.ts +++ b/src/lib/providers/ministry-platform/types/provider.types.ts @@ -261,11 +261,16 @@ export interface RequestBody { [key: string]: unknown; } +/** + * One entry from `GET /tables`. Verified against a live response (2026-10-11): + * each entry carries exactly `Name`, `AccessLevel`, `SpecialPermissions` and + * `Columns` — there is no `Table_ID`, `Table_Name` or `Display_Name`. + * `AccessLevel`/`SpecialPermissions` are left to the index signature until a + * caller needs them typed. + */ export interface TableMetadata { - Table_ID: number; - Table_Name: string; - Display_Name: string; - Description?: string; + /** The table's name, e.g. `Contacts` (the API's key is `Name`, not `Table_Name`). */ + Name: string; Columns?: ColumnMetadata[]; [key: string]: unknown; } diff --git a/src/services/fieldManagementService.test.ts b/src/services/fieldManagementService.test.ts index f9886ff..b581dee 100644 --- a/src/services/fieldManagementService.test.ts +++ b/src/services/fieldManagementService.test.ts @@ -178,26 +178,47 @@ describe('FieldManagementService', () => { expect(result).toBeNull(); }); - it('should return exact match by Table_Name when multiple tables returned', async () => { - const contactsTable = { Table_ID: 1, Table_Name: 'Contacts', Display_Name: 'Contacts' }; - const otherTable = { Table_ID: 2, Table_Name: 'Contact_Log', Display_Name: 'Contact Log' }; - mockGetTables.mockResolvedValueOnce([otherTable, contactsTable]); + it('returns the exact Name match when it is not the first search result', async () => { + const contactLog = { Name: 'Contact_Log', Columns: [] }; + const contacts = { Name: 'Contacts', Columns: [] }; + const contactAttributes = { Name: 'Contact_Attributes', Columns: [] }; + mockGetTables.mockResolvedValueOnce([contactLog, contacts, contactAttributes]); const service = await FieldManagementService.getInstance(); const result = await service.getTableMetadata('Contacts'); - expect(result).toBe(contactsTable); + expect(mockGetTables).toHaveBeenCalledWith('Contacts'); + expect(result).toBe(contacts); }); - it('should return first result if no exact match found', async () => { - const firstTable = { Table_ID: 1, Table_Name: 'Contact_Log', Display_Name: 'Contact Log' }; - const secondTable = { Table_ID: 2, Table_Name: 'Contact_Attributes', Display_Name: 'Contact Attributes' }; - mockGetTables.mockResolvedValueOnce([firstTable, secondTable]); + it('returns null (no fallback to the first result) when nothing matches exactly', async () => { + mockGetTables.mockResolvedValueOnce([ + { Name: 'Contact_Log', Columns: [] }, + { Name: 'Contact_Attributes', Columns: [] }, + ]); const service = await FieldManagementService.getInstance(); const result = await service.getTableMetadata('Contacts'); - expect(result).toBe(firstTable); + expect(result).toBeNull(); + }); + + it('does not match on a legacy Table_Name key (the API returns Name)', async () => { + mockGetTables.mockResolvedValueOnce([{ Name: 'Contacts_Backup', Table_Name: 'Contacts' }]); + + const service = await FieldManagementService.getInstance(); + const result = await service.getTableMetadata('Contacts'); + + expect(result).toBeNull(); + }); + + it('matches case-sensitively', async () => { + mockGetTables.mockResolvedValueOnce([{ Name: 'Contacts', Columns: [] }]); + + const service = await FieldManagementService.getInstance(); + const result = await service.getTableMetadata('contacts'); + + expect(result).toBeNull(); }); }); diff --git a/src/services/fieldManagementService.ts b/src/services/fieldManagementService.ts index 0d01389..2704036 100644 --- a/src/services/fieldManagementService.ts +++ b/src/services/fieldManagementService.ts @@ -79,10 +79,12 @@ export class FieldManagementService { }); const tables = await this.mp!.getTables(tableName); - if (tables.length === 0) return null; - - // getTables with search may return multiple matches — find the exact one - return tables.find((t) => t.Table_Name === tableName) ?? tables[0]; + // `$search` can return several tables. Take only the exact `Name` match + // (the API's key — there is no `Table_Name` on a /tables entry). No + // fallback to `tables[0]`: a near-miss would merge another table's columns + // into this page's layout. `null` is the "no metadata" case the caller + // already handles — it merges nothing and tags no separators. + return tables.find((t) => t.Name === tableName) ?? null; } private static readonly CONCURRENCY = 5; diff --git a/src/services/googlePlacesService.test.ts b/src/services/googlePlacesService.test.ts index 4fa9355..8270fc1 100644 --- a/src/services/googlePlacesService.test.ts +++ b/src/services/googlePlacesService.test.ts @@ -4,6 +4,19 @@ const { mockGetTableRecords } = vi.hoisted(() => ({ mockGetTableRecords: vi.fn(), })); +const { mockRequireSecurityRole } = vi.hoisted(() => ({ + mockRequireSecurityRole: vi.fn(async () => 42), +})); + +vi.mock("@/services/authorizationService", () => ({ + AuthorizationService: { + getInstance: () => ({ + requireSecurityRole: mockRequireSecurityRole, + }), + }, + UnauthorizedError: class UnauthorizedError extends Error {}, +})); + vi.mock("@/lib/providers/ministry-platform", () => ({ MPHelper: class { getTableRecords = mockGetTableRecords; @@ -29,6 +42,7 @@ vi.mock("@/lib/providers/google-places", () => ({ })); import { GooglePlacesService } from "./googlePlacesService"; +import { UnauthorizedError } from "@/services/authorizationService"; describe("GooglePlacesService", () => { beforeEach(() => { @@ -143,4 +157,73 @@ describe("GooglePlacesService", () => { expect(mockGetTableRecords).toHaveBeenCalledTimes(1); }); }); + + describe("authorization gate", () => { + const configGate = { table: "dp_Configuration_Settings", operation: "read" }; + const details = { + placeId: "p1", + formattedAddress: "", + addressLine1: "", + city: "", + state: "", + postalCode: "", + countryCode: "", + }; + + it("gates every public method on dp_Configuration_Settings read", async () => { + mockGetTableRecords.mockResolvedValueOnce([{ Value: "mp-key-123" }]); + mockAutocomplete.mockResolvedValue([]); + mockGetPlaceDetails.mockResolvedValue(details); + const service = await GooglePlacesService.getInstance(); + + await service.isEnabled(); + await service.autocomplete("123 Main", "t"); + await service.getPlaceDetails("p1", "t"); + + expect(mockRequireSecurityRole).toHaveBeenCalledTimes(3); + for (const call of mockRequireSecurityRole.mock.calls) { + expect(call).toEqual([configGate]); + } + }); + + it.each([ + ["isEnabled", (s: GooglePlacesService) => s.isEnabled()], + ["autocomplete", (s: GooglePlacesService) => s.autocomplete("123 Main", "t")], + ["getPlaceDetails", (s: GooglePlacesService) => s.getPlaceDetails("p1", "t")], + ])("%s: a denied gate throws and makes no MP or Google call", async (_name, call) => { + vi.stubEnv("GOOGLE_PLACES_API_KEY", "env-key-456"); + mockRequireSecurityRole.mockRejectedValueOnce(new UnauthorizedError("denied")); + const service = await GooglePlacesService.getInstance(); + + await expect(call(service)).rejects.toBeInstanceOf(UnauthorizedError); + expect(mockGetTableRecords).not.toHaveBeenCalled(); + expect(mockAutocomplete).not.toHaveBeenCalled(); + expect(mockGetPlaceDetails).not.toHaveBeenCalled(); + }); + + it.each([ + ["isEnabled", (s: GooglePlacesService) => s.isEnabled()], + ["autocomplete", (s: GooglePlacesService) => s.autocomplete("123 Main", "t")], + ["getPlaceDetails", (s: GooglePlacesService) => s.getPlaceDetails("p1", "t")], + ])( + "%s: a denied gate throws even when an authorized caller already cached the key", + async (_name, call) => { + mockGetTableRecords.mockResolvedValueOnce([{ Value: "mp-key-123" }]); + mockAutocomplete.mockResolvedValue([]); + mockGetPlaceDetails.mockResolvedValue(details); + const service = await GooglePlacesService.getInstance(); + // Authorized caller warms both the key and the provider cache. + await service.autocomplete("warm", "t0"); + mockGetTableRecords.mockClear(); + mockAutocomplete.mockClear(); + mockGetPlaceDetails.mockClear(); + + mockRequireSecurityRole.mockRejectedValueOnce(new UnauthorizedError("denied")); + await expect(call(service)).rejects.toBeInstanceOf(UnauthorizedError); + expect(mockGetTableRecords).not.toHaveBeenCalled(); + expect(mockAutocomplete).not.toHaveBeenCalled(); + expect(mockGetPlaceDetails).not.toHaveBeenCalled(); + }, + ); + }); }); diff --git a/src/services/googlePlacesService.ts b/src/services/googlePlacesService.ts index 435c762..b81f377 100644 --- a/src/services/googlePlacesService.ts +++ b/src/services/googlePlacesService.ts @@ -1,5 +1,6 @@ import "server-only"; import { MPHelper } from "@/lib/providers/ministry-platform"; +import { AuthorizationService } from "@/services/authorizationService"; import { GooglePlacesProvider } from "@/lib/providers/google-places"; import type { PlacePrediction, PlaceDetails } from "@/lib/providers/google-places"; @@ -27,6 +28,8 @@ export class GooglePlacesService { * 3. null (feature disabled) * * Cached on the singleton so MP is queried at most once per process lifetime. + * Never call this without `requireAccess()` first: the cache is process-wide, + * so the gate must run on every call, not only on a cache miss. */ private async resolveApiKey(): Promise<string | null> { if (this.resolvedKey !== undefined) return this.resolvedKey; @@ -52,7 +55,26 @@ export class GooglePlacesService { return this.resolvedKey; } + /** + * Authorization gate for every public method (CLAUDE.md rule 12). + * + * The key comes from an MP read of `dp_Configuration_Settings`, and once + * resolved it is cached on this process-wide singleton. Gating only the + * cache-miss path would let an unauthorized caller use a key an authorized + * caller had already loaded, so each public method gates before touching + * either the MP read or the cached key/provider. It runs outside + * `resolveApiKey`'s try/catch so a refusal is never swallowed into the + * env-var fallback. + */ + private async requireAccess(): Promise<void> { + await AuthorizationService.getInstance().requireSecurityRole({ + table: "dp_Configuration_Settings", + operation: "read", + }); + } + public async isEnabled(): Promise<boolean> { + await this.requireAccess(); return (await this.resolveApiKey()) !== null; } @@ -69,11 +91,13 @@ export class GooglePlacesService { } async autocomplete(input: string, sessionToken: string): Promise<PlacePrediction[]> { + await this.requireAccess(); const provider = await this.getProvider(); return provider.autocomplete(input, sessionToken); } async getPlaceDetails(placeId: string, sessionToken: string): Promise<PlaceDetails> { + await this.requireAccess(); const provider = await this.getProvider(); return provider.getPlaceDetails(placeId, sessionToken); } diff --git a/src/services/toolService.test.ts b/src/services/toolService.test.ts index ced62af..54a3967 100644 --- a/src/services/toolService.test.ts +++ b/src/services/toolService.test.ts @@ -45,7 +45,6 @@ vi.mock('@/services/authorizationService', () => ({ describe('ToolService', () => { beforeEach(() => { vi.clearAllMocks(); - (ToolService as any).instance = undefined; });