Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 0 additions & 28 deletions .claude/TODO/2026-09-30-step8-fork-specific-low-risk-items.md

This file was deleted.

21 changes: 9 additions & 12 deletions .claude/TODO/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ Severity tiers:
- **medium**: doc drift, missing test, refactor with real cost
- **low**: nits, minor doc fixes, stylistic improvements

Total: **3 open TODOs**.
Total: **2 open TODOs**.

> **`step6`** and **`step8`** are the remaining steps from the 2026-09-30 MPNext
> playbook audit (one PR each). `held-*` items wait on an owner decision.
> **`step6`** is the remaining step from the 2026-09-30 MPNext playbook audit.
> `held-*` items wait on an owner decision.

---

Expand All @@ -39,22 +39,19 @@ _none open_
| routing | security, drift | Step 6 — Headers, next.config, server-only, prerender + build in CI | [→](2026-09-30-step6-headers-csp-build-guards.md) |
| routing | security, doc | Held — CSP notes for customer-deployed clones | [→](2026-09-30-held-csp-customer-deployments.md) |

### Low (1)
| Area | Tags | Title | File |
|---|---|---|---|
| components | security, bug | Step 8 — Address-label caps, error boundaries, tool-params decode, codegen escaping | [→](2026-09-30-step8-fork-specific-low-risk-items.md) |
### Low (0)
_none open_

---

## By tag

### security (3)
### security (2)
- step6-headers-csp-build-guards — medium
- held-csp-customer-deployments — medium
- step8-fork-specific-low-risk-items — low

### bug (1)
- step8-fork-specific-low-risk-items — low
### bug (0)
_none open_

### drift (1)
- step6-headers-csp-build-guards — medium
Expand All @@ -77,7 +74,7 @@ _none open_

| Area | Count |
|---|---|
| components | 1 |
| components | 0 |
| routing | 2 |
| services | 0 |
| auth | 0 |
Expand Down
3 changes: 2 additions & 1 deletion .claude/references/DECISIONS.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Architectural Decisions
type: adr
last_updated: 2026-04-17
last_updated: 2026-10-10
---

# Architectural Decisions
Expand Down Expand Up @@ -84,6 +84,7 @@ Architectural decisions captured by the context-engineering review at SHA `971c4
**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()`.
**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:**
- **Enrich in `customSession`** — would hit MP API on every JWT refresh and couple sign-in availability to MP uptime.
- **Load profile server-side in layout** — blocks TTFB on every page render.
Expand Down
4 changes: 2 additions & 2 deletions .claude/references/GLOSSARY.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Glossary
type: glossary
last_updated: 2026-04-17
last_updated: 2026-10-10
---

# Glossary
Expand Down Expand Up @@ -238,7 +238,7 @@ Public-API facade class for Ministry Platform operations; normalizes friendly pa

## MPUserProfile

Enriched client-side user profile combining `dp_Users` row + joined Contact fields + `roles[]` + `userGroups[]`; loaded via `UserService.getUserProfile(userGuid)` 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 `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`
Expand Down
2 changes: 1 addition & 1 deletion .claude/references/GOTCHAS.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,7 @@ Retired 2026-10-10: applied only to the template editor's merge tokens; the edit
**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.
**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).
**Enforced where:** `src/lib/barcode-helpers.ts` (logged fallback). Log payload is `{ labelIndex, error: <error name> }` — 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)

### GOTCHA-041: Avery label cells misaligned after adding new stock
Expand Down
57 changes: 40 additions & 17 deletions .claude/references/components/address-labels.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ last_verified: 2026-04-17
## Purpose
Print USPS-compliant mailing labels (PDF + Word) from MP Contacts/Selection data, with IMb/POSTNET barcodes, and merge addresses into a user-uploaded `.docx` template (one page per address with embedded barcode image) for window-envelope mailings.

## Files (13)
## Files

Per-component `*.test.tsx` files are co-located and not listed.

| File | Role |
|------|------|
Expand All @@ -44,9 +46,13 @@ Print USPS-compliant mailing labels (PDF + Word) from MP Contacts/Selection data
| `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) |
| `src/components/address-labels/actions.ts` | Server actions (see API below); 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/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).

Expand Down Expand Up @@ -118,24 +124,26 @@ export async function fetchAddressLabels(
config: LabelConfig,
): Promise<FetchAddressLabelsResult>;

type ActionResult = { success: true; data: string } | { success: false; error: string };

// Renders LabelDocument to a PDF via @react-pdf/renderer; returns base64 or { success: false, error }.
export async function generateLabelPdf(
labels: LabelData[],
config: LabelConfig,
): Promise<{ success: true; data: string } | { success: false; error: string }>;
rawLabels: LabelData[],
rawConfig: LabelConfig,
): Promise<ActionResult>;

// Builds an Avery-layout .docx via `docx` + BMP barcodes; returns base64 or error.
export async function generateLabelDocx(
labels: LabelData[],
config: LabelConfig,
): Promise<{ success: true; data: string } | { success: false; error: string }>;
rawLabels: LabelData[],
rawConfig: LabelConfig,
): Promise<ActionResult>;

// Merges user-uploaded .docx template (base64, ≤5MB) with address rows + embedded barcode images.
// Merges user-uploaded .docx template (base64, ≤ MAX_TEMPLATE_BYTES = 2MB) with address rows + embedded barcode images.
export async function mergeTemplate(
templateBase64: string,
labels: LabelData[],
config: LabelConfig,
): Promise<{ success: true; data: string } | { success: false; error: string }>;
rawLabels: LabelData[],
rawConfig: LabelConfig,
): Promise<ActionResult>;
```

Other exported helpers:
Expand Down Expand Up @@ -163,8 +171,20 @@ const SERVICE_TYPES = [{id:'040',…}, {id:'300',…}, {id:'044',…}, {id:'700'
- 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.

### 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:
1. `parseLabelInput(rawLabels, rawConfig)` (`schemas.ts`):
- array length > `MAX_LABELS` → `Too many labels (N); the limit is 10,000 per request` (checked before Zod)
- `labelsSchema` — bounded strings (`name`/`addressLine1`/`addressLine2` ≤256, `city` ≤128, `state` ≤64, `postalCode` ≤32, `barCode` ≤64, `deliveryPointCode` ≤16); `barStates`/`barType` are **stripped** (Zod `z.object` drops unknown keys) — only `preEncodeBarcodes` may set them. Failure → `Invalid label data`
- `labelConfigSchema` — `stockId` ≤64, `addressMode`/`barcodeFormat` enums, `startPosition` int ≥1, `mailerId` ≤9, `serviceType` ∈ `SERVICE_TYPES` ids. Failure → `Invalid label settings`
- Errors name the problem, never the payload.
2. `getLabelStock(config.stockId)` → `Unknown label stock: ${stockId}`.
3. `config.startPosition > stock.columns * stock.rows` → `Start position is beyond the end of the sheet` (it is a count of blank cells to emit, so unbounded = unbounded pages).
4. Empty labels → per-action message (`No labels to print` / `No labels to export` / `No addresses to merge`).
5. IMb with a Mailer ID → `validateMailerId`; failure returns its message (fallback `Invalid Mailer ID`).

### generateLabelPdf
- Calls `preEncodeBarcodes(labels, config)` then renders `<LabelDocument>` through `pdf()` from `@react-pdf/renderer` → `toBlob()` → base64. Unknown `stockId` or empty labels return `{ success: false }`.
- `prepareLabels(..., 'No labels to print')`, then `preEncodeBarcodes(labels, config)`, then renders `<LabelDocument>` through `pdf()` from `@react-pdf/renderer` → `toBlob()` → base64.

### LabelDocument layout
- `skipCount = startPosition - 1`; `labelsPerPage = stock.columns * stock.rows`; `pageCount = ceil((skipCount + labels.length) / labelsPerPage)`.
Expand All @@ -175,13 +195,15 @@ const SERVICE_TYPES = [{id:'040',…}, {id:'300',…}, {id:'044',…}, {id:'700'
- Barcodes embedded via `ImageRun({ type: 'bmp' })` using `imbBarcodeToBmp` / `postnetBarcodeToBmp` from `src/lib/barcode-image.ts`.

### mergeTemplate (.docx mail merge)
- Rejects `templateBase64` if `Math.ceil(length * 0.75) > 5 * 1024 * 1024`.
- Size check first, on the encoded string, before any decoding: non-string or `Math.ceil(length * 0.75) > MAX_TEMPLATE_BYTES` → `Template file exceeds 2MB limit`.
- `prepareLabels(..., 'No addresses to merge')`.
- Zip-bomb guard: `assertZipWithinLimits(Buffer.from(templateBase64, 'base64'), TEMPLATE_ZIP_LIMITS)` (`src/lib/zip-guard.ts`) **before** PizZip sees the archive. Walks the central directory, caps entry count (1,000) and summed declared uncompressed size (64 MB), rejects ZIP64 / encrypted / non-STORE-DEFLATE entries, then inflates each entry with `maxOutputLength` = declared size so a lying header fails at that size. `ZipLimitError` message is returned; any other throw → `Not a valid .docx file`. See `../data-flow/error-catalog.md` for the full message list.
- `preEncodeBarcodes()` then builds `addresses[]` rows with `{ Name, AddressLine1, AddressLine2, City, State, PostalCode, Barcode: 'barcode_N', isNotLast }` plus a `Map<string,Buffer>` of BMP barcodes keyed by `barcode_N`.
- `Docxtemplater` with `docxtemplater-image` — `getImage` resolves key strings to buffers; `getSize` returns `[200, 25]` for the `Barcode` tag.
- Render errors containing "tag" are wrapped as `Template error: …`.

### Mail merge tab upload flow
- Only `.docx`, `file.size <= 5MB`; base64-encoded client-side via `FileReader`.
- Only `.docx`, `file.size <= MAX_TEMPLATE_BYTES` (2 MB, from `limits.ts` — same constant the server enforces; error `Template file must be under 2MB`); base64-encoded client-side via `FileReader`. Label text shows `max {MAX_TEMPLATE_LABEL}`.
- Re-validates 6/9-digit Mailer ID before calling `mergeTemplate`.
- Downloads result blob as `merged-letters.docx`.

Expand Down Expand Up @@ -231,8 +253,9 @@ Tool page wiring (from `src/app/(web)/tools/addresslabels/address-labels.tsx:180

- **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`).
- **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 `actions.ts:156` 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 (`actions.ts:227`).
- **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.

## Related docs
Expand Down
2 changes: 1 addition & 1 deletion .claude/references/components/user-menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Dropdown menu that displays the signed-in MP user's name + email and triggers RP

## Key concepts
- `UserMenu` is a **client component** (`"use client"`) — wraps a caller-supplied trigger via `<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>`.
- `userProfile` prop is typed `MPUserProfile` (`src/lib/providers/ministry-platform/types/user-profile.types.ts:1-13`) — expects the enriched MP profile, typically supplied by `UserProvider` / `useUser()`.
- `userProfile` prop is typed `MPUserProfile` (`src/lib/providers/ministry-platform/types/user-profile.types.ts`) — display-only DTO (`First_Name`, `Nickname`, `Last_Name`, `Email_Address`), typically supplied by `UserProvider` / `useUser()`. Every field it carries is rendered here.
- Display name uses `Nickname || First_Name`, then `Last_Name` (`user-menu.tsx:49-50`).
- Sign-out is the only menu item currently (`userMenuItems` array, `user-menu.tsx:21-27`). Icon is `@heroicons/react/24/outline`'s `ArrowRightOnRectangleIcon`.
- `onClose?` optional callback fires before the action — used by callers that render the trigger inside a larger popover/sidebar to close the outer surface.
Expand Down
Loading
Loading