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

This file was deleted.

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

Total: **6 open TODOs**.
Total: **5 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.
Expand All @@ -39,23 +39,21 @@ _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 (4)
### Low (3)
| 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) |
| components | bug | Add/Edit Family search: "No contacts found" empty state never renders | [→](2026-09-13-search-empty-state-never-renders.md) |
| components | refactor, missing-test | Unreachable "empty STEP_FIELDS" branch in `GroupWizard.handleNext` | [→](2026-09-13-dead-empty-fields-branch-handlenext.md) |
| services | security | `ToolService.resolveContactIds` takes table/column names from its caller (dev panel) | [→](2026-10-10-dev-panel-resolvecontactids-caller-identifiers.md) |

---

## By tag

### security (4)
### security (3)
- step6-headers-csp-build-guards — medium
- held-csp-customer-deployments — medium
- step8-fork-specific-low-risk-items — low
- dev-panel-resolvecontactids-caller-identifiers — low

### bug (2)
- step8-fork-specific-low-risk-items — low
Expand Down Expand Up @@ -84,7 +82,7 @@ _none open_
|---|---|
| components | 3 |
| routing | 2 |
| services | 1 |
| services | 0 |
| auth | 0 |
| commands | 0 |
| mp-provider | 0 |
Expand Down
10 changes: 4 additions & 6 deletions .claude/references/components/dev-panel.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Localhost-only developer overlay rendered by `ToolContainer` when `params` is pr
| `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/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(table, pk, contactIdField, recordIds)` → `ContactRecordResult` |
| `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 |
Expand Down Expand Up @@ -101,7 +101,7 @@ When missing, the form disables the submit button and surfaces an amber notice n
| `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`) |

`ContactRecordsPanel` does **not** call a stored procedure — it uses `getTableRecords` in `ToolService.resolveContactIds` (`src/services/toolService.ts:335`).
`ContactRecordsPanel` does **not** call a stored procedure — it uses `getTableRecords` in `ToolService.resolveContactIds` (`src/services/toolService.ts`).

### Glossary candidates
- **DevPanel** — localhost-only overlay defined at `src/components/dev-panel/dev-panel.tsx:44`
Expand Down Expand Up @@ -134,11 +134,9 @@ export async function resolveSelection(

// src/components/dev-panel/panels/contact-records-actions.ts
export async function resolveContactRecords(
tableName: string,
primaryKey: string,
contactIdField: string,
pageId: number,
recordIds: number[]
): Promise<ContactRecordResult>;
): Promise<ContactRecordResult | null>;

// src/components/dev-panel/panels/user-tools-actions.ts
export async function getUserTools(): Promise<string[]>;
Expand Down
2 changes: 1 addition & 1 deletion .claude/references/services/query-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export function validateColumnName(value: string): string { /* throws if ≠ /^[
| Where | Call | Purpose |
|-------|------|---------|
| `userService.ts:72, 84` | `validateGuid(guid)` | Guard `User_GUID = '...'` filter |
| `toolService.ts:307-334` | `validateColumnName`, `validatePositiveInt` | Guard `resolveContactIds` — rejects bad table/PK/contact-id-field/record-ID values |
| `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` |

Expand Down
68 changes: 20 additions & 48 deletions .claude/references/services/tool-service.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export class ToolService {
| `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` |
| `resolveContactIds(tableName, primaryKey, contactIdField, recordIds)` | `ContactRecordResult` | `getTableRecords` on `{tableName}` in batches of 100 |
| `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')` |
| `deployTool(input: DeployToolInput)` | `DeployToolResult` | SP `api_dev_DeployTool` (dev credentials) |
Expand Down Expand Up @@ -101,61 +101,33 @@ return [];

## resolveContactIds

Resolves record IDs (from a selection) to their associated Contact IDs. Works for direct Contact_ID columns AND FK traversal paths like `Participant_ID_Table.Contact_ID`.
Resolves record IDs on an MP page (a single record or a selection) to their associated Contact IDs. Works for direct Contact_ID columns AND FK traversal paths like `Participant_ID_Table.Contact_ID`.

Takes a **page ID, not table/column names**. The page's `Table_Name`, `Primary_Key` and `Contact_ID_Field` are looked up server-side through `getPageData(pageId)` and validated before interpolation, so no caller chooses what the service account reads. `FamilyService.resolveContactIdFromPage` gets the same treatment from its action.

```typescript
// src/services/toolService.ts:299-350
// src/services/toolService.ts — resolveContactIds
public async resolveContactIds(
tableName: string, primaryKey: string, contactIdField: string, recordIds: number[]
): Promise<ContactRecordResult> {
const envelope = { tableName, primaryKey, contactIdField };

validateColumnName(primaryKey);
validateColumnName(tableName);
if (contactIdField.includes('.')) {
contactIdField.split('.').forEach(validateColumnName);
} else {
validateColumnName(contactIdField);
}

if (recordIds.length === 0) return { ...envelope, records: [] };

if (contactIdField === primaryKey) {
// e.g., Contacts table — PK IS the contact ID
return {
...envelope,
records: recordIds.map(id => ({ recordId: id, contactId: id })),
};
}

const contactIdResponseKey = contactIdField.includes('.')
? contactIdField.split('.').pop()!
: contactIdField;

const allRecords: ContactRecord[] = [];
for (let i = 0; i < recordIds.length; i += ToolService.BATCH_SIZE) {
const batch = recordIds.slice(i, i + ToolService.BATCH_SIZE);
batch.forEach(validatePositiveInt);
const rows = await this.mp!.getTableRecords<Record<string, number>>({
table: tableName,
select: `${primaryKey}, ${contactIdField}`,
filter: `${primaryKey} IN (${batch.join(',')})`,
});
for (const row of rows) {
allRecords.push({
recordId: row[primaryKey],
contactId: row[contactIdResponseKey],
});
}
}

return { ...envelope, records: allRecords };
pageId: number, recordIds: number[]
): Promise<ContactRecordResult | null> {
validatePositiveInt(pageId, 'pageId');
await AuthorizationService.getInstance().requireSecurityRole({ table: 'Contacts', operation: 'read' });

const page = await this.getPageData(pageId);
if (!page?.Table_Name || !page.Primary_Key || !page.Contact_ID_Field?.trim()) return null;

const tableName = validateColumnName(page.Table_Name);
const primaryKey = validateColumnName(page.Primary_Key);
const contactIdField = validateFkPath(page.Contact_ID_Field.trim(), 'Contact_ID_Field');
// ...short-circuit when contactIdField === primaryKey, else
// getTableRecords in batches of MP_FETCH_BATCH_SIZE (100)
}
```

- **Returns `null`** when the page is unknown or lacks `Table_Name` / `Primary_Key` / `Contact_ID_Field`.
- **Short-circuits** when the table's PK *is* Contact_ID (e.g., `Contacts` table).
- **Batches** at 100 IDs per request.
- **Validates** `tableName`, `primaryKey`, every segment of `contactIdField`, and every record ID.
- **Validates** `pageId`, `Table_Name` and `Primary_Key` (`validateColumnName`), `Contact_ID_Field` (`validateFkPath` — no extra columns, aliases, or non-`_TABLE` segments), and every record ID.

**Return type:**
```typescript
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,18 @@ describe('resolveContactRecords', () => {
};
mockResolveContactIds.mockResolvedValueOnce(expected);

const result = await resolveContactRecords('Contacts', 'Contact_ID', 'Contact_ID', [1, 2, 3]);
const result = await resolveContactRecords(7, [1, 2, 3]);

expect(mockRequireDevSession).toHaveBeenCalledWith('Dev panel');
expect(mockResolveContactIds).toHaveBeenCalledWith('Contacts', 'Contact_ID', 'Contact_ID', [1, 2, 3]);
expect(mockResolveContactIds).toHaveBeenCalledWith(7, [1, 2, 3]);
expect(result).toEqual(expected);
});

it('propagates a refusal from requireDevSession without calling the service', async () => {
mockRequireDevSession.mockRejectedValueOnce(new Error('Unauthorized - Missing user session data'));

await expect(
resolveContactRecords('Contacts', 'Contact_ID', 'Contact_ID', [1])
resolveContactRecords(7, [1])
).rejects.toThrow('Unauthorized - Missing user session data');
expect(mockResolveContactIds).not.toHaveBeenCalled();
});
Expand All @@ -54,7 +54,7 @@ describe('resolveContactRecords', () => {
mockResolveContactIds.mockRejectedValueOnce(new Error('Not authorized'));

await expect(
resolveContactRecords('Contacts', 'Contact_ID', 'Contact_ID', [1])
resolveContactRecords(7, [1])
).rejects.toThrow('Not authorized');
});
});
13 changes: 8 additions & 5 deletions src/components/dev-panel/panels/contact-records-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@ import { requireDevSession } from './require-dev-session';
import { ToolService } from '@/services/toolService';
import type { ContactRecordResult } from '@/services/toolService';

/**
* Takes the page ID, not table/column names: the service resolves the page's
* metadata server-side, so the caller never chooses what the service account
* reads.
*/
export async function resolveContactRecords(
tableName: string,
primaryKey: string,
contactIdField: string,
pageId: number,
recordIds: number[]
): Promise<ContactRecordResult> {
): Promise<ContactRecordResult | null> {
await requireDevSession('Dev panel');

const toolService = await ToolService.getInstance();
return toolService.resolveContactIds(tableName, primaryKey, contactIdField, recordIds);
return toolService.resolveContactIds(pageId, recordIds);
}
37 changes: 27 additions & 10 deletions src/components/dev-panel/panels/contact-records-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,30 @@ describe("ContactRecordsPanel", () => {
});

it("renders nothing when there is neither a single record nor a selection", () => {
const params: ToolParams = { pageData };
const params: ToolParams = { pageID: 292, pageData };
const { container } = render(<ContactRecordsPanel params={params} />);
expect(container.firstChild).toBeNull();
});

it("does not resolve when there is no page ID", () => {
const params: ToolParams = { pageData, recordID: 1 };
render(<ContactRecordsPanel params={params} />);
expect(mockResolveContactRecords).not.toHaveBeenCalled();
});

it("renders nothing when the service returns null (page has no contact metadata)", async () => {
mockResolveContactRecords.mockResolvedValueOnce(null);

const { container } = render(<ContactRecordsPanel params={{ pageID: 292, pageData, recordID: 1 }} />);

await waitFor(() => {
expect(screen.queryByText("Development Mode - Loading Contact Records...")).not.toBeInTheDocument();
});
expect(container.firstChild).toBeNull();
});

it("renders nothing when recordID is 0 (falsy for hasSingleRecord) and no selection", () => {
const params: ToolParams = { pageData, recordID: 0 };
const params: ToolParams = { pageID: 292, pageData, recordID: 0 };
const { container } = render(<ContactRecordsPanel params={params} />);
expect(container.firstChild).toBeNull();
});
Expand All @@ -56,7 +73,7 @@ describe("ContactRecordsPanel", () => {
records: [{ recordId: 1, contactId: 100 }],
});

const params: ToolParams = { pageData, recordID: 1 };
const params: ToolParams = { pageID: 292, pageData, recordID: 1 };
render(<ContactRecordsPanel params={params} />);

expect(
Expand All @@ -68,7 +85,7 @@ describe("ContactRecordsPanel", () => {
expect(screen.getByText("Development Mode - Contact Records")).toBeInTheDocument();
});
expect(screen.getByText("1 contact resolved")).toBeInTheDocument();
expect(mockResolveContactRecords).toHaveBeenCalledWith("Contacts", "Contact_ID", "Contact_ID", [1]);
expect(mockResolveContactRecords).toHaveBeenCalledWith(292, [1]);
});

it("resolves a selection of record IDs and pluralizes the count", async () => {
Expand All @@ -82,21 +99,21 @@ describe("ContactRecordsPanel", () => {
],
});

const params: ToolParams = { pageData };
const params: ToolParams = { pageID: 292, pageData };
render(<ContactRecordsPanel params={params} selectionRecordIds={[1, 2]} />);

expect(screen.getByText(/Resolving Contact IDs from selection/)).toBeInTheDocument();

await waitFor(() => {
expect(screen.getByText("2 contacts resolved")).toBeInTheDocument();
});
expect(mockResolveContactRecords).toHaveBeenCalledWith("Contacts", "Contact_ID", "Contact_ID", [1, 2]);
expect(mockResolveContactRecords).toHaveBeenCalledWith(292, [1, 2]);
});

it("shows an error state when resolution fails", async () => {
mockResolveContactRecords.mockRejectedValueOnce(new Error("resolve failed"));

render(<ContactRecordsPanel params={{ pageData, recordID: 1 }} />);
render(<ContactRecordsPanel params={{ pageID: 292, pageData, recordID: 1 }} />);

await waitFor(() => {
expect(screen.getByText("Development Mode - Contact Records Error")).toBeInTheDocument();
Expand All @@ -107,7 +124,7 @@ describe("ContactRecordsPanel", () => {
it("shows a generic error message for a non-Error rejection", async () => {
mockResolveContactRecords.mockRejectedValueOnce("nope");

render(<ContactRecordsPanel params={{ pageData, recordID: 1 }} />);
render(<ContactRecordsPanel params={{ pageID: 292, pageData, recordID: 1 }} />);

await waitFor(() => {
expect(screen.getByText("Failed to resolve contact records")).toBeInTheDocument();
Expand All @@ -122,7 +139,7 @@ describe("ContactRecordsPanel", () => {
records: Array.from({ length: 7 }, (_, i) => ({ recordId: i + 1, contactId: 100 + i })),
});

render(<ContactRecordsPanel params={{ pageData }} selectionRecordIds={[1, 2, 3, 4, 5, 6, 7]} />);
render(<ContactRecordsPanel params={{ pageID: 292, pageData }} selectionRecordIds={[1, 2, 3, 4, 5, 6, 7]} />);

await waitFor(() => {
expect(screen.getByText("+2 more")).toBeInTheDocument();
Expand All @@ -137,7 +154,7 @@ describe("ContactRecordsPanel", () => {
records: [{ recordId: 1, contactId: 100 }],
});

render(<ContactRecordsPanel params={{ pageData, recordID: 1 }} />);
render(<ContactRecordsPanel params={{ pageID: 292, pageData, recordID: 1 }} />);

await waitFor(() => {
expect(screen.getByText("View Raw JSON")).toBeInTheDocument();
Expand Down
Loading
Loading