From 936327c771719c454f3185bce8fde83a02c7727b Mon Sep 17 00:00:00 2001 From: wantmoore Date: Fri, 27 Feb 2026 15:26:01 -0500 Subject: [PATCH 1/5] feat: add CreateMpSelection component for creating MP selections from filtered record sets Adds a reusable component that POSTs filtered record IDs to MinistryPlatform as a permanent named selection (dp_Selections + dp_Selected_Records) and returns a deep-link URL for opening the selection directly in the MP web app. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 1 + src/components/create-mp-selection/actions.ts | 55 ++++++ .../create-mp-selection.tsx | 186 ++++++++++++++++++ src/components/create-mp-selection/index.ts | 2 + src/lib/dto/index.ts | 1 + src/lib/dto/selections.ts | 35 ++++ src/services/selectionService.test.ts | 156 +++++++++++++++ src/services/selectionService.ts | 71 +++++++ 8 files changed, 507 insertions(+) create mode 100644 src/components/create-mp-selection/actions.ts create mode 100644 src/components/create-mp-selection/create-mp-selection.tsx create mode 100644 src/components/create-mp-selection/index.ts create mode 100644 src/lib/dto/selections.ts create mode 100644 src/services/selectionService.test.ts create mode 100644 src/services/selectionService.ts diff --git a/.env.example b/.env.example index fac7cfe..138b25d 100644 --- a/.env.example +++ b/.env.example @@ -39,3 +39,4 @@ MINISTRY_PLATFORM_BASE_URL=https://mpi.ministryplatform.com/ministryplatformapi # ============================================================================= NEXT_PUBLIC_MINISTRY_PLATFORM_FILE_URL=https://mpi.ministryplatform.com/ministryplatformapi/files NEXT_PUBLIC_APP_NAME=MPNextApp +NEXT_PUBLIC_MINISTRY_PLATFORM_URL=https://my.grangerchurch.com diff --git a/src/components/create-mp-selection/actions.ts b/src/components/create-mp-selection/actions.ts new file mode 100644 index 0000000..ba0c939 --- /dev/null +++ b/src/components/create-mp-selection/actions.ts @@ -0,0 +1,55 @@ +"use server"; + +import { auth } from "@/lib/auth"; +import { headers } from "next/headers"; +import { SelectionService } from "@/services/selectionService"; +import { SelectionResult } from "@/lib/dto/selections"; + +function getUserGuid(session: { user: Record }): string { + const guid = session.user.userGuid as string | undefined; + if (!guid) { + throw new Error("User GUID not found in session"); + } + return guid; +} + +export interface CreateMpSelectionInput { + selectionName: string; + pageId: number; + recordIds: number[]; +} + +export async function createMpSelection( + input: CreateMpSelectionInput +): Promise { + try { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + throw new Error("Authentication required"); + } + + const userGuid = getUserGuid(session); + + const { MPHelper } = await import("@/lib/providers/ministry-platform"); + const mp = new MPHelper(); + + const users = await mp.getTableRecords<{ User_ID: number }>({ + table: "dp_Users", + filter: `User_GUID = '${userGuid}'`, + select: "User_ID", + top: 1, + }); + + if (!users || users.length === 0 || !users[0].User_ID) { + throw new Error("Unable to determine user User_ID"); + } + + const userId = users[0].User_ID; + + const selectionService = await SelectionService.getInstance(); + return await selectionService.createSelection({ ...input, userId }); + } catch (error) { + console.error("Error creating MP selection:", error); + throw error instanceof Error ? error : new Error("Failed to create MP selection"); + } +} diff --git a/src/components/create-mp-selection/create-mp-selection.tsx b/src/components/create-mp-selection/create-mp-selection.tsx new file mode 100644 index 0000000..da0cddc --- /dev/null +++ b/src/components/create-mp-selection/create-mp-selection.tsx @@ -0,0 +1,186 @@ +"use client"; + +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Copy, ExternalLink } from "lucide-react"; +import { createMpSelection } from "./actions"; +import { SelectionResult } from "@/lib/dto/selections"; + +export interface CreateMpSelectionProps { + pageId: number; + recordIds: number[]; + defaultSelectionName?: string; + triggerLabel?: string; + onSuccess?: (result: SelectionResult) => void; + disabled?: boolean; +} + +export function CreateMpSelection({ + pageId, + recordIds, + defaultSelectionName = "", + triggerLabel = "Create MP Selection", + onSuccess, + disabled = false, +}: CreateMpSelectionProps) { + const [isOpen, setIsOpen] = useState(false); + const [selectionName, setSelectionName] = useState(defaultSelectionName); + const [isCreating, setIsCreating] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const [copied, setCopied] = useState(false); + + const handleOpenChange = (open: boolean) => { + setIsOpen(open); + if (!open) { + setSelectionName(defaultSelectionName); + setError(null); + setResult(null); + setCopied(false); + setIsCreating(false); + } + }; + + const handleCreate = async () => { + if (!selectionName.trim()) return; + + try { + setIsCreating(true); + setError(null); + const selectionResult = await createMpSelection({ + selectionName: selectionName.trim(), + pageId, + recordIds, + }); + setResult(selectionResult); + if (onSuccess) { + onSuccess(selectionResult); + } + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to create MP selection"; + setError(message); + } finally { + setIsCreating(false); + } + }; + + const handleCopy = async () => { + if (!result?.selectionUrl) return; + try { + await navigator.clipboard.writeText(result.selectionUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Fallback: do nothing silently + } + }; + + return ( + + + + + + + Create MP Selection + + {result + ? "Your selection has been created in Ministry Platform." + : `Create a named selection from ${recordIds.length} record${recordIds.length !== 1 ? "s" : ""}.`} + + + + {!result ? ( +
+
+ + setSelectionName(e.target.value)} + placeholder="Enter a name for this selection" + disabled={isCreating} + /> +
+ + {error && ( +

{error}

+ )} + +
+ + +
+
+ ) : ( +
+
+ +
+ + +
+ {copied && ( +

Copied to clipboard!

+ )} +
+ +
+ + +
+
+ )} +
+
+ ); +} diff --git a/src/components/create-mp-selection/index.ts b/src/components/create-mp-selection/index.ts new file mode 100644 index 0000000..c7957e5 --- /dev/null +++ b/src/components/create-mp-selection/index.ts @@ -0,0 +1,2 @@ +export { CreateMpSelection } from './create-mp-selection'; +export type { CreateMpSelectionProps } from './create-mp-selection'; diff --git a/src/lib/dto/index.ts b/src/lib/dto/index.ts index 3f1a437..f295bfd 100644 --- a/src/lib/dto/index.ts +++ b/src/lib/dto/index.ts @@ -1,2 +1,3 @@ export * from './contacts'; export * from './contact-logs'; +export * from './selections'; diff --git a/src/lib/dto/selections.ts b/src/lib/dto/selections.ts new file mode 100644 index 0000000..e1c6358 --- /dev/null +++ b/src/lib/dto/selections.ts @@ -0,0 +1,35 @@ +export interface DpSelectionCreate { + [key: string]: unknown; + Selection_Name: string; + Record_Count: number; + Is_Temporary: boolean; + User_ID_Owner: number; +} + +export interface DpSelectionRecord { + [key: string]: unknown; + Selection_ID: number; + Selection_Name: string; + Record_Count: number; + Is_Temporary: boolean; + User_ID_Owner: number; +} + +export interface DpSelectedRecordCreate { + [key: string]: unknown; + Selection_ID: number; + Record_ID: number; +} + +export interface SelectionResult { + pageId: number; + selectionId: number; + selectionUrl: string; +} + +export interface CreateSelectionInput { + selectionName: string; + pageId: number; + recordIds: number[]; + userId: number; +} diff --git a/src/services/selectionService.test.ts b/src/services/selectionService.test.ts new file mode 100644 index 0000000..ac930ec --- /dev/null +++ b/src/services/selectionService.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SelectionService } from '@/services/selectionService'; + +const mockCreateTableRecords = vi.fn(); + +vi.mock('@/lib/providers/ministry-platform', () => { + return { + MPHelper: class { + createTableRecords = mockCreateTableRecords; + }, + }; +}); + +describe('SelectionService', () => { + beforeEach(() => { + vi.clearAllMocks(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (SelectionService as any).instance = undefined; + process.env.NEXT_PUBLIC_MINISTRY_PLATFORM_URL = 'https://my.grangerchurch.com'; + }); + + describe('getInstance', () => { + it('should return a singleton instance', async () => { + const instance1 = await SelectionService.getInstance(); + const instance2 = await SelectionService.getInstance(); + expect(instance1).toBe(instance2); + }); + }); + + describe('createSelection', () => { + it('should throw when recordIds is empty', async () => { + const service = await SelectionService.getInstance(); + await expect( + service.createSelection({ + selectionName: 'Test', + pageId: 292, + recordIds: [], + userId: 1, + }) + ).rejects.toThrow('recordIds must not be empty'); + }); + + it('should call dp_Selections first then dp_Selected_Records', async () => { + mockCreateTableRecords + .mockResolvedValueOnce([{ Selection_ID: 138291, Selection_Name: 'Test', Record_Count: 2, Is_Temporary: false, User_ID_Owner: 42 }]) + .mockResolvedValueOnce([{}, {}]); + + const service = await SelectionService.getInstance(); + await service.createSelection({ + selectionName: 'Test Selection', + pageId: 292, + recordIds: [10, 20], + userId: 42, + }); + + expect(mockCreateTableRecords).toHaveBeenCalledTimes(2); + expect(mockCreateTableRecords).toHaveBeenNthCalledWith(1, 'dp_Selections', [ + { + Selection_Name: 'Test Selection', + Record_Count: 2, + Is_Temporary: false, + User_ID_Owner: 42, + }, + ]); + expect(mockCreateTableRecords).toHaveBeenNthCalledWith(2, 'dp_Selected_Records', [ + { Selection_ID: 138291, Record_ID: 10 }, + { Selection_ID: 138291, Record_ID: 20 }, + ]); + }); + + it('should build the correct selectionUrl from NEXT_PUBLIC_MINISTRY_PLATFORM_URL', async () => { + mockCreateTableRecords + .mockResolvedValueOnce([{ Selection_ID: 138291 }]) + .mockResolvedValueOnce([{}]); + + const service = await SelectionService.getInstance(); + const result = await service.createSelection({ + selectionName: 'Test', + pageId: 292, + recordIds: [1], + userId: 1, + }); + + expect(result.selectionUrl).toBe('https://my.grangerchurch.com/mp/292.138291'); + expect(result.pageId).toBe(292); + expect(result.selectionId).toBe(138291); + }); + + it('should throw when MP returns no Selection_ID', async () => { + mockCreateTableRecords.mockResolvedValueOnce([{}]); + + const service = await SelectionService.getInstance(); + await expect( + service.createSelection({ + selectionName: 'Test', + pageId: 292, + recordIds: [1], + userId: 1, + }) + ).rejects.toThrow('Failed to create selection: no Selection_ID returned'); + }); + + it('should throw when MP returns empty array for dp_Selections', async () => { + mockCreateTableRecords.mockResolvedValueOnce([]); + + const service = await SelectionService.getInstance(); + await expect( + service.createSelection({ + selectionName: 'Test', + pageId: 292, + recordIds: [1], + userId: 1, + }) + ).rejects.toThrow('Failed to create selection: no Selection_ID returned'); + }); + + it('should propagate errors from the bulk insert call', async () => { + mockCreateTableRecords + .mockResolvedValueOnce([{ Selection_ID: 999 }]) + .mockRejectedValueOnce(new Error('Bulk insert failed')); + + const service = await SelectionService.getInstance(); + await expect( + service.createSelection({ + selectionName: 'Test', + pageId: 292, + recordIds: [1, 2, 3], + userId: 1, + }) + ).rejects.toThrow('Bulk insert failed'); + }); + + it('should use the Selection_ID from dp_Selections for all dp_Selected_Records rows', async () => { + const selectionId = 55555; + mockCreateTableRecords + .mockResolvedValueOnce([{ Selection_ID: selectionId }]) + .mockResolvedValueOnce([{}, {}, {}]); + + const service = await SelectionService.getInstance(); + await service.createSelection({ + selectionName: 'Test', + pageId: 292, + recordIds: [100, 200, 300], + userId: 5, + }); + + const secondCall = mockCreateTableRecords.mock.calls[1]; + const selectedRecords = secondCall[1] as Array<{ Selection_ID: number; Record_ID: number }>; + expect(selectedRecords).toHaveLength(3); + selectedRecords.forEach((row) => { + expect(row.Selection_ID).toBe(selectionId); + }); + expect(selectedRecords.map((r) => r.Record_ID)).toEqual([100, 200, 300]); + }); + }); +}); diff --git a/src/services/selectionService.ts b/src/services/selectionService.ts new file mode 100644 index 0000000..659123a --- /dev/null +++ b/src/services/selectionService.ts @@ -0,0 +1,71 @@ +import { MPHelper } from "@/lib/providers/ministry-platform"; +import { + CreateSelectionInput, + DpSelectedRecordCreate, + DpSelectionCreate, + DpSelectionRecord, + SelectionResult, +} from "@/lib/dto/selections"; + +export class SelectionService { + private static instance: SelectionService; + private mp: MPHelper | null = null; + + private constructor() { + this.initialize(); + } + + public static async getInstance(): Promise { + if (!SelectionService.instance) { + SelectionService.instance = new SelectionService(); + await SelectionService.instance.initialize(); + } + return SelectionService.instance; + } + + private async initialize(): Promise { + this.mp = new MPHelper(); + } + + public async createSelection(input: CreateSelectionInput): Promise { + const { selectionName, pageId, recordIds, userId } = input; + + if (!recordIds || recordIds.length === 0) { + throw new Error("recordIds must not be empty"); + } + + const selectionHeader: DpSelectionCreate = { + Selection_Name: selectionName, + Record_Count: recordIds.length, + Is_Temporary: false, + User_ID_Owner: userId, + }; + + const selectionResult = await this.mp!.createTableRecords( + "dp_Selections", + [selectionHeader] + ); + + if (!selectionResult || selectionResult.length === 0) { + throw new Error("Failed to create selection: no Selection_ID returned"); + } + + const selectionId = (selectionResult[0] as DpSelectionRecord).Selection_ID; + + if (!selectionId) { + throw new Error("Failed to create selection: no Selection_ID returned"); + } + + const selectedRecords: DpSelectedRecordCreate[] = recordIds.map((id) => ({ + Selection_ID: selectionId, + Record_ID: id, + })); + + await this.mp!.createTableRecords("dp_Selected_Records", selectedRecords); + + const mpUrl = process.env.NEXT_PUBLIC_MINISTRY_PLATFORM_URL; + const selectionUrl = `${mpUrl}/mp/${pageId}.${selectionId}`; + + return { pageId, selectionId, selectionUrl }; + } +} From c799f6bbb7dfd215c7a0b11aa87280135fd7d809 Mon Sep 17 00:00:00 2001 From: wantmoore Date: Sat, 28 Feb 2026 00:29:28 -0500 Subject: [PATCH 2/5] feat: add CreateMpSelection component with stored procedure backend Add a reusable dialog component that saves record IDs as named MP Selections via custom stored procedures, replacing direct dp_* table access which is blocked by API permissions. Key changes: - CreateMpSelection component with page picker, auto-timestamped names, copy-to-clipboard, and deep-link to MP - SelectionService using api_custom_CreateSelection and api_custom_GetPages stored procedures - Demo page at /create-mp-selection with contact list and page mapping - HttpClient POST error handling now includes response body - New env vars: MINISTRY_PLATFORM_DOMAIN_ID, updated NEXT_PUBLIC_MINISTRY_PLATFORM_URL docs Co-Authored-By: Claude Opus 4.6 --- .env.example | 9 +- CHANGELOG.md | 55 ++++++ next-env.d.ts | 2 +- package-lock.json | 1 + src/app/(web)/create-mp-selection/actions.ts | 58 ++++++ src/app/(web)/create-mp-selection/page.tsx | 183 ++++++++++++++++++ src/app/(web)/page.tsx | 14 ++ src/components/create-mp-selection/actions.ts | 7 + .../create-mp-selection/constants.ts | 1 + .../create-mp-selection.tsx | 89 ++++++++- src/components/create-mp-selection/index.ts | 3 +- src/lib/dto/selections.ts | 28 +-- .../ministry-platform/utils/http-client.ts | 9 +- src/services/selectionService.ts | 66 ++++--- 14 files changed, 457 insertions(+), 68 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/app/(web)/create-mp-selection/actions.ts create mode 100644 src/app/(web)/create-mp-selection/page.tsx create mode 100644 src/components/create-mp-selection/constants.ts diff --git a/.env.example b/.env.example index 138b25d..616b0ba 100644 --- a/.env.example +++ b/.env.example @@ -33,10 +33,17 @@ BETTER_AUTH_URL=http://localhost:3000 MINISTRY_PLATFORM_CLIENT_ID=MPNext MINISTRY_PLATFORM_CLIENT_SECRET= MINISTRY_PLATFORM_BASE_URL=https://mpi.ministryplatform.com/ministryplatformapi +# Domain ID for your MP installation (almost always 1 for single-tenant). +# Required by the api_custom_CreateSelection stored procedure. +MINISTRY_PLATFORM_DOMAIN_ID=1 # ============================================================================= # NEXT Public Keys # ============================================================================= NEXT_PUBLIC_MINISTRY_PLATFORM_FILE_URL=https://mpi.ministryplatform.com/ministryplatformapi/files NEXT_PUBLIC_APP_NAME=MPNextApp -NEXT_PUBLIC_MINISTRY_PLATFORM_URL=https://my.grangerchurch.com + +# The Ministry Platform application URL (where users access pages/views). +# This is NOT the API URL — it's used to build deep-links like 292.12345 +# for selections, page views, etc. Include the /mp path segment. +NEXT_PUBLIC_MINISTRY_PLATFORM_URL=https://my.yourorg.com/mp diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a2f2a5c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,55 @@ +# Changelog + +## [Unreleased] + +### Added + +- **CreateMpSelection component** — reusable dialog that saves a list of record IDs as a named Selection in Ministry Platform and returns a deep-link URL. + - Page picker dropdown when multiple `pageOptions` are provided; single-page mode when only one option or a fixed `pageId` is given. + - Auto-generated timestamped selection name (regenerated on each dialog open). + - Copy-to-clipboard and "Open in MP" deep-link buttons on success. + - Client-side and server-side guard against exceeding `MAX_SELECTION_RECORDS` (1,500). + - `onSuccess` and `onPageChange` callback props. +- **SelectionService** — singleton service wrapping stored procedure calls for selection management. + - `createSelection()` — calls `api_custom_CreateSelection` stored procedure. + - `getPages()` — calls `api_custom_GetPages` stored procedure to retrieve page options dynamically. +- **Demo page** (`/create-mp-selection`) — interactive demo that loads 20 contacts with checkboxes, fetches page options from the API, maps record IDs per page type (Contact, Household, Participant, Donor), and wires up the `CreateMpSelection` component. +- **DTO types** — `SelectionResult`, `CreateSelectionInput`, `MpPage` in `src/lib/dto/selections.ts`. +- **Home page card** linking to the Create MP Selection demo. +- **Environment variables**: + - `MINISTRY_PLATFORM_DOMAIN_ID` — domain ID for stored procedure calls (default `1`). + - `NEXT_PUBLIC_MINISTRY_PLATFORM_URL` — MP application URL used for deep-link construction (updated docs to clarify this is NOT the API URL). + +### Changed + +- **HttpClient POST error handling** — POST failures now log the response body and include it in the thrown error message, matching the existing GET behavior. +- **selections DTO cleanup** — removed unused `DpSelectionCreate`, `DpSelectionRecord`, and `DpSelectedRecordCreate` interfaces that referenced inaccessible `dp_*` tables. +- **SelectionService rewrite** — replaced direct `dp_Selections`/`dp_Selected_Records` table inserts (which fail due to API permissions) with the `api_custom_CreateSelection` stored procedure. + +### Required Database Objects + +This feature depends on two custom stored procedures that must be installed on the Ministry Platform database. See the PR description for full SQL definitions. + +#### `api_custom_CreateSelection` + +Creates a selection header and its selected records in a single transaction. + +| Parameter | Type | Description | +| ---------------- | -------------- | ------------------------------------------------ | +| `@DomainID` | INT | MP domain ID (usually `1`) | +| `@PageID` | INT | The MP page the records belong to | +| `@UserID` | INT | `dp_Users.User_ID` of the logged-in user | +| `@SelectionName` | NVARCHAR(255) | Display name for the selection | +| `@RecordIDs` | NVARCHAR(MAX) | Comma-separated record IDs (e.g., `1001,1002`) | + +**Returns:** `{ Selection_ID, Selection_Name, Record_Count }` + +#### `api_custom_GetPages` + +Returns page metadata from `dp_Pages` (which is not accessible via the REST API). + +| Parameter | Type | Description | +| ------------- | ------------- | ------------------------------------------ | +| `@SearchName` | NVARCHAR(255) | Optional filter on `Display_Name` (LIKE) | + +**Returns:** `{ Page_ID, Display_Name }` diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package-lock.json b/package-lock.json index 3839e12..7acc609 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6695,6 +6695,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", diff --git a/src/app/(web)/create-mp-selection/actions.ts b/src/app/(web)/create-mp-selection/actions.ts new file mode 100644 index 0000000..e18889f --- /dev/null +++ b/src/app/(web)/create-mp-selection/actions.ts @@ -0,0 +1,58 @@ +"use server"; + +import { auth } from "@/lib/auth"; +import { headers } from "next/headers"; +import { MPHelper } from "@/lib/providers/ministry-platform"; +import { SelectionService } from "@/services/selectionService"; +import { MpPage } from "@/lib/dto/selections"; + +export interface DemoContact { + contactId: number; + name: string; + email: string; + householdId: number | null; + participantId: number | null; + donorId: number | null; +} + +export async function getMpPages(searchName?: string): Promise { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + throw new Error("Authentication required"); + } + + const selectionService = await SelectionService.getInstance(); + return await selectionService.getPages(searchName); +} + +export async function getDemoContacts(): Promise { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + throw new Error("Authentication required"); + } + + const mp = new MPHelper(); + const records = await mp.getTableRecords<{ + Contact_ID: number; + First_Name: string; + Last_Name: string; + Email_Address: string | null; + Household_ID: number | null; + Participant_Record: number | null; + Donor_Record: number | null; + }>({ + table: "Contacts", + select: + "Contact_ID, First_Name, Last_Name, Email_Address, Household_ID, Participant_Record, Donor_Record", + top: 20, + }); + + return records.map((r) => ({ + contactId: r.Contact_ID, + name: `${r.First_Name} ${r.Last_Name}`.trim(), + email: r.Email_Address ?? "", + householdId: r.Household_ID ?? null, + participantId: r.Participant_Record ?? null, + donorId: r.Donor_Record ?? null, + })); +} diff --git a/src/app/(web)/create-mp-selection/page.tsx b/src/app/(web)/create-mp-selection/page.tsx new file mode 100644 index 0000000..41801cf --- /dev/null +++ b/src/app/(web)/create-mp-selection/page.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import { CreateMpSelection, type MpPageOption } from "@/components/create-mp-selection"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { getDemoContacts, getMpPages, type DemoContact } from "./actions"; + +// The four key pages and how they map to Contact record fields +const PAGE_FIELD_MAP: Record = { + Contacts: "contactId", + Households: "householdId", + Participants: "participantId", + Donors: "donorId", +}; + +// Display names to match against dp_Pages.Display_Name +const KEY_PAGE_NAMES = Object.keys(PAGE_FIELD_MAP); + +export default function CreateMpSelectionDemoPage() { + const [contacts, setContacts] = useState([]); + const [pageOptions, setPageOptions] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [selectedPage, setSelectedPage] = useState(null); + const [lastResult, setLastResult] = useState(null); + + useEffect(() => { + Promise.all([getDemoContacts(), getMpPages()]) + .then(([contactData, pages]) => { + setContacts(contactData); + + // Filter to the four key pages + const options = pages + .filter((p) => + KEY_PAGE_NAMES.some( + (name) => p.Display_Name.toLowerCase() === name.toLowerCase() + ) + ) + .map((p) => ({ pageId: p.Page_ID, label: p.Display_Name })); + + setPageOptions(options); + }) + .catch((err) => console.error("Failed to load demo data:", err)) + .finally(() => setLoading(false)); + }, []); + + const toggleId = (id: number) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const toggleAll = () => { + if (selectedIds.size === contacts.length) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(contacts.map((c) => c.contactId))); + } + }; + + // Derive the record IDs based on which page is selected + const recordIdsForPage = useMemo(() => { + if (!selectedPage) return Array.from(selectedIds); + + const field = PAGE_FIELD_MAP[selectedPage.label]; + if (!field || field === "contactId") return Array.from(selectedIds); + + // Map selected contactIds to the corresponding field values, filtering nulls + const selectedContacts = contacts.filter((c) => + selectedIds.has(c.contactId) + ); + return selectedContacts + .map((c) => c[field] as number | null) + .filter((id): id is number => id != null); + }, [selectedIds, selectedPage, contacts]); + + const allSelected = + contacts.length > 0 && selectedIds.size === contacts.length; + + return ( +
+
+

+ Create MP Selection +

+

+ Select contacts below, choose a target page, and save them as a named + Selection in Ministry Platform. +

+
+ + {/* Contact list */} + + + + Contacts + + + + {/* Header row */} +
+ + Contact ID + Name + Email +
+ + {loading ? ( +
+ Loading contacts... +
+ ) : contacts.length === 0 ? ( +
+ No contacts found. +
+ ) : ( + contacts.map((contact, i) => ( +
toggleId(contact.contactId)} + > + toggleId(contact.contactId)} + aria-label={`Select ${contact.name}`} + onClick={(e) => e.stopPropagation()} + /> + + {contact.contactId} + + {contact.name} + + {contact.email} + +
+ )) + )} +
+
+ + {/* Action bar */} +
+

+ {selectedIds.size === 0 + ? "Select records above to enable the button" + : `${selectedIds.size} contact${selectedIds.size !== 1 ? "s" : ""} selected → ${recordIdsForPage.length} record ID${recordIdsForPage.length !== 1 ? "s" : ""} for selection`} +

+ + setLastResult( + `Selection #${result.selectionId} created — ${result.selectionUrl}` + ) + } + /> +
+ + {/* Success feedback */} + {lastResult && ( +
+ Last result: + {lastResult} +
+ )} +
+ ); +} diff --git a/src/app/(web)/page.tsx b/src/app/(web)/page.tsx index 96a5fa2..2945b9c 100644 --- a/src/app/(web)/page.tsx +++ b/src/app/(web)/page.tsx @@ -38,6 +38,20 @@ export default function Home() { + + + + Create MP Selection + + Save a filtered set of record IDs as a named Selection in Ministry Platform and get a deep-link URL back + + + + + + + + ); diff --git a/src/components/create-mp-selection/actions.ts b/src/components/create-mp-selection/actions.ts index ba0c939..a524b9f 100644 --- a/src/components/create-mp-selection/actions.ts +++ b/src/components/create-mp-selection/actions.ts @@ -13,6 +13,8 @@ function getUserGuid(session: { user: Record }): string { return guid; } +import { MAX_SELECTION_RECORDS } from "./constants"; + export interface CreateMpSelectionInput { selectionName: string; pageId: number; @@ -23,6 +25,11 @@ export async function createMpSelection( input: CreateMpSelectionInput ): Promise { try { + if (input.recordIds.length > MAX_SELECTION_RECORDS) { + throw new Error( + `Too many records: ${input.recordIds.length} exceeds the maximum of ${MAX_SELECTION_RECORDS}` + ); + } const session = await auth.api.getSession({ headers: await headers() }); if (!session?.user?.id) { throw new Error("Authentication required"); diff --git a/src/components/create-mp-selection/constants.ts b/src/components/create-mp-selection/constants.ts new file mode 100644 index 0000000..21a1a4b --- /dev/null +++ b/src/components/create-mp-selection/constants.ts @@ -0,0 +1 @@ +export const MAX_SELECTION_RECORDS = 1500; diff --git a/src/components/create-mp-selection/create-mp-selection.tsx b/src/components/create-mp-selection/create-mp-selection.tsx index da0cddc..ff715a4 100644 --- a/src/components/create-mp-selection/create-mp-selection.tsx +++ b/src/components/create-mp-selection/create-mp-selection.tsx @@ -4,6 +4,13 @@ import React, { useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Dialog, DialogContent, @@ -14,27 +21,57 @@ import { } from "@/components/ui/dialog"; import { Copy, ExternalLink } from "lucide-react"; import { createMpSelection } from "./actions"; +import { MAX_SELECTION_RECORDS } from "./constants"; import { SelectionResult } from "@/lib/dto/selections"; -export interface CreateMpSelectionProps { +function generateDefaultName(base: string): string { + const now = new Date(); + const timestamp = now.toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + return base ? `${base} ${timestamp}` : timestamp; +} + +export interface MpPageOption { pageId: number; + label: string; +} + +export interface CreateMpSelectionProps { + pageId?: number; + pageOptions?: MpPageOption[]; recordIds: number[]; defaultSelectionName?: string; triggerLabel?: string; onSuccess?: (result: SelectionResult) => void; + onPageChange?: (page: MpPageOption | null) => void; disabled?: boolean; } export function CreateMpSelection({ pageId, + pageOptions = [], recordIds, defaultSelectionName = "", triggerLabel = "Create MP Selection", onSuccess, + onPageChange, disabled = false, }: CreateMpSelectionProps) { + // If pageOptions has entries, show a picker; otherwise use the fixed pageId prop. + const showPicker = pageOptions.length > 1; + const exceedsLimit = recordIds.length > MAX_SELECTION_RECORDS; const [isOpen, setIsOpen] = useState(false); - const [selectionName, setSelectionName] = useState(defaultSelectionName); + const [selectionName, setSelectionName] = useState( + generateDefaultName(defaultSelectionName) + ); + const [selectedPageId, setSelectedPageId] = useState( + pageOptions.length === 1 ? pageOptions[0].pageId : pageId + ); const [isCreating, setIsCreating] = useState(false); const [error, setError] = useState(null); const [result, setResult] = useState(null); @@ -42,8 +79,11 @@ export function CreateMpSelection({ const handleOpenChange = (open: boolean) => { setIsOpen(open); - if (!open) { - setSelectionName(defaultSelectionName); + if (open) { + // Regenerate timestamp on each open so the name is unique + setSelectionName(generateDefaultName(defaultSelectionName)); + } else { + setSelectedPageId(pageOptions.length === 1 ? pageOptions[0].pageId : pageId); setError(null); setResult(null); setCopied(false); @@ -52,14 +92,14 @@ export function CreateMpSelection({ }; const handleCreate = async () => { - if (!selectionName.trim()) return; + if (!selectionName.trim() || !selectedPageId) return; try { setIsCreating(true); setError(null); const selectionResult = await createMpSelection({ selectionName: selectionName.trim(), - pageId, + pageId: selectedPageId, recordIds, }); setResult(selectionResult); @@ -88,7 +128,11 @@ export function CreateMpSelection({ return ( - @@ -98,12 +142,39 @@ export function CreateMpSelection({ {result ? "Your selection has been created in Ministry Platform." - : `Create a named selection from ${recordIds.length} record${recordIds.length !== 1 ? "s" : ""}.`} + : `Create a named selection from ${recordIds.length.toLocaleString()} record${recordIds.length !== 1 ? "s" : ""}.`} {!result ? (
+ {showPicker && ( +
+ + +
+ )} +
{isCreating ? "Creating..." : "Create Selection"} diff --git a/src/components/create-mp-selection/index.ts b/src/components/create-mp-selection/index.ts index c7957e5..fa087a5 100644 --- a/src/components/create-mp-selection/index.ts +++ b/src/components/create-mp-selection/index.ts @@ -1,2 +1,3 @@ export { CreateMpSelection } from './create-mp-selection'; -export type { CreateMpSelectionProps } from './create-mp-selection'; +export type { CreateMpSelectionProps, MpPageOption } from './create-mp-selection'; +export { MAX_SELECTION_RECORDS } from './constants'; diff --git a/src/lib/dto/selections.ts b/src/lib/dto/selections.ts index e1c6358..009a620 100644 --- a/src/lib/dto/selections.ts +++ b/src/lib/dto/selections.ts @@ -1,26 +1,3 @@ -export interface DpSelectionCreate { - [key: string]: unknown; - Selection_Name: string; - Record_Count: number; - Is_Temporary: boolean; - User_ID_Owner: number; -} - -export interface DpSelectionRecord { - [key: string]: unknown; - Selection_ID: number; - Selection_Name: string; - Record_Count: number; - Is_Temporary: boolean; - User_ID_Owner: number; -} - -export interface DpSelectedRecordCreate { - [key: string]: unknown; - Selection_ID: number; - Record_ID: number; -} - export interface SelectionResult { pageId: number; selectionId: number; @@ -33,3 +10,8 @@ export interface CreateSelectionInput { recordIds: number[]; userId: number; } + +export interface MpPage { + Page_ID: number; + Display_Name: string; +} diff --git a/src/lib/providers/ministry-platform/utils/http-client.ts b/src/lib/providers/ministry-platform/utils/http-client.ts index 29caf4e..56a958c 100644 --- a/src/lib/providers/ministry-platform/utils/http-client.ts +++ b/src/lib/providers/ministry-platform/utils/http-client.ts @@ -48,7 +48,14 @@ export class HttpClient { }); if (!response.ok) { - throw new Error(`POST ${endpoint} failed: ${response.status} ${response.statusText}`); + const errorBody = await response.text().catch(() => ""); + console.error("POST Request failed:", { + status: response.status, + statusText: response.statusText, + url, + responseBody: errorBody + }); + throw new Error(`POST ${endpoint} failed: ${response.status} ${response.statusText}${errorBody ? ` — ${errorBody}` : ""}`); } return await response.json() as T; diff --git a/src/services/selectionService.ts b/src/services/selectionService.ts index 659123a..1e0d420 100644 --- a/src/services/selectionService.ts +++ b/src/services/selectionService.ts @@ -1,11 +1,14 @@ import { MPHelper } from "@/lib/providers/ministry-platform"; -import { - CreateSelectionInput, - DpSelectedRecordCreate, - DpSelectionCreate, - DpSelectionRecord, - SelectionResult, -} from "@/lib/dto/selections"; +import { CreateSelectionInput, MpPage, SelectionResult } from "@/lib/dto/selections"; + +const CREATE_SELECTION_PROC = "api_custom_CreateSelection"; +const GET_PAGES_PROC = "api_custom_GetPages"; + +interface ProcSelectionResult { + Selection_ID: number; + Selection_Name: string; + Record_Count: number; +} export class SelectionService { private static instance: SelectionService; @@ -27,6 +30,16 @@ export class SelectionService { this.mp = new MPHelper(); } + public async getPages(searchName?: string): Promise { + const params: Record = {}; + if (searchName) { + params["@SearchName"] = searchName; + } + const resultSets = await this.mp!.executeProcedureWithBody(GET_PAGES_PROC, params); + const rows = (resultSets?.[0] ?? []) as MpPage[]; + return rows; + } + public async createSelection(input: CreateSelectionInput): Promise { const { selectionName, pageId, recordIds, userId } = input; @@ -34,37 +47,26 @@ export class SelectionService { throw new Error("recordIds must not be empty"); } - const selectionHeader: DpSelectionCreate = { - Selection_Name: selectionName, - Record_Count: recordIds.length, - Is_Temporary: false, - User_ID_Owner: userId, - }; - - const selectionResult = await this.mp!.createTableRecords( - "dp_Selections", - [selectionHeader] - ); + const domainId = Number(process.env.MINISTRY_PLATFORM_DOMAIN_ID ?? "1"); - if (!selectionResult || selectionResult.length === 0) { - throw new Error("Failed to create selection: no Selection_ID returned"); - } + const resultSets = await this.mp!.executeProcedureWithBody(CREATE_SELECTION_PROC, { + "@DomainID": domainId, + "@PageID": pageId, + "@UserID": userId, + "@SelectionName": selectionName, + "@RecordIDs": recordIds.join(","), + }); - const selectionId = (selectionResult[0] as DpSelectionRecord).Selection_ID; + // executeProcedureWithBody returns unknown[][] — outer array = result sets, inner = rows + const firstRow = (resultSets?.[0]?.[0] ?? null) as ProcSelectionResult | null; - if (!selectionId) { - throw new Error("Failed to create selection: no Selection_ID returned"); + if (!firstRow?.Selection_ID) { + throw new Error("Failed to create selection: stored procedure returned no Selection_ID"); } - const selectedRecords: DpSelectedRecordCreate[] = recordIds.map((id) => ({ - Selection_ID: selectionId, - Record_ID: id, - })); - - await this.mp!.createTableRecords("dp_Selected_Records", selectedRecords); - + const selectionId = firstRow.Selection_ID; const mpUrl = process.env.NEXT_PUBLIC_MINISTRY_PLATFORM_URL; - const selectionUrl = `${mpUrl}/mp/${pageId}.${selectionId}`; + const selectionUrl = `${mpUrl}/${pageId}.${selectionId}`; return { pageId, selectionId, selectionUrl }; } From d19a8bee0c3e2585fdc34c6877a92c3fef989044 Mon Sep 17 00:00:00 2001 From: wantmoore Date: Sat, 28 Feb 2026 00:35:31 -0500 Subject: [PATCH 3/5] docs: add CreateMpSelection feature and stored procedures to README Document the new CreateMpSelection component, its usage, required stored procedures (api_custom_CreateSelection, api_custom_GetPages), environment variables, and demo page in the project README. Co-Authored-By: Claude Opus 4.6 --- README.md | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/README.md b/README.md index 2d9d4a3..92a4302 100644 --- a/README.md +++ b/README.md @@ -302,6 +302,7 @@ MPNext/ │ │ ├── (web)/ # Protected route group │ │ │ ├── contactlookup/ # Contact lookup demo │ │ │ │ └── [guid]/ # Dynamic contact detail page +│ │ │ ├── create-mp-selection/ # Selection creation demo │ │ │ ├── home/ # Home redirect │ │ │ ├── tools/ # Tools framework │ │ │ │ └── template/ # Template tool example @@ -323,6 +324,11 @@ MPNext/ │ │ │ ├── contact-lookup-results.tsx │ │ │ ├── actions.ts │ │ │ └── index.ts +│ │ ├── create-mp-selection/ # MP Selection creation +│ │ │ ├── create-mp-selection.tsx +│ │ │ ├── constants.ts +│ │ │ ├── actions.ts +│ │ │ └── index.ts │ │ ├── contact-lookup-details/ # Contact details feature │ │ │ ├── contact-lookup-details.tsx │ │ │ ├── actions.ts @@ -362,6 +368,7 @@ MPNext/ │ │ ├── dto/ # Application DTOs/ViewModels │ │ │ ├── contacts.ts │ │ │ ├── contact-logs.ts +│ │ │ ├── selections.ts │ │ │ └── index.ts │ │ ├── tool-params.ts # Tool parameter utilities │ │ ├── utils.ts # General utilities @@ -390,6 +397,7 @@ MPNext/ │ ├── services/ # Application services │ │ ├── contactService.ts │ │ ├── contactLogService.ts +│ │ ├── selectionService.ts │ │ ├── userService.ts │ │ └── toolService.ts │ │ @@ -535,6 +543,7 @@ Built with Radix UI primitives and styled with Tailwind CSS. Located in `src/com - **contact-lookup**: Contact search with fuzzy matching - **contact-lookup-details**: Detailed contact view with logs - **contact-logs**: Full CRUD for contact interaction history +- **create-mp-selection**: Save filtered record IDs as named MP Selections with deep-link URLs - **user-menu**: User profile dropdown with sign-out ### Tool Components (`src/components/tool/`) @@ -554,6 +563,7 @@ Application services provide business logic abstraction over the Ministry Platfo | **ContactService** | `contactService.ts` | Contact search and updates | | **ContactLogService** | `contactLogService.ts` | Contact log CRUD with validation | | **UserService** | `userService.ts` | User profile retrieval | +| **SelectionService** | `selectionService.ts` | Create MP Selections via stored procedures | | **ToolService** | `toolService.ts` | Tool page data and user permissions | All services follow the singleton pattern and use `MPHelper` for API communication. @@ -589,6 +599,98 @@ Tools receive standard MP parameters like `pageID`, `s` (selection), and `record See the [template tool](src/app/(web)/tools/template/) for implementation details. +## Create MP Selection + +The **CreateMpSelection** component lets users save a filtered set of record IDs as a named Selection in Ministry Platform, then provides a deep-link URL to open that selection directly in MP. + +### How It Works + +1. User selects records and clicks "Save as MP Selection" +2. A dialog opens with an auto-generated timestamped name and (optionally) a page picker dropdown +3. On submit, the server action calls `SelectionService.createSelection()` which executes the `api_custom_CreateSelection` stored procedure +4. The dialog shows the resulting deep-link URL with copy-to-clipboard and "Open in MP" buttons + +### Component Usage + +```tsx +import { CreateMpSelection } from '@/components/create-mp-selection'; + +// Single page mode + + +// Multi-page mode with page picker + console.log(page)} + onSuccess={(result) => console.log(result.selectionUrl)} +/> +``` + +### Required Stored Procedures + +This feature requires two custom stored procedures installed on your Ministry Platform database. These are needed because `dp_Selections`, `dp_Selected_Records`, and `dp_Pages` are not accessible via the REST API. + +#### `api_custom_CreateSelection` + +Creates a selection header and inserts selected records in a single transaction. + +```sql +CREATE PROCEDURE [dbo].[api_custom_CreateSelection] + @DomainID INT, + @PageID INT, + @UserID INT, + @SelectionName NVARCHAR(255), + @RecordIDs NVARCHAR(MAX) +AS BEGIN + SET NOCOUNT ON; + INSERT INTO dp_Selections (Selection_Name, Page_ID, User_ID) + VALUES (@SelectionName, @PageID, @UserID); + DECLARE @Selection_ID INT = SCOPE_IDENTITY(); + INSERT INTO dp_Selected_Records (Selection_ID, Record_ID) + SELECT @Selection_ID, CAST(LTRIM(RTRIM(value)) AS INT) + FROM STRING_SPLIT(@RecordIDs, ',') + WHERE LTRIM(RTRIM(value)) != ''; + SELECT @Selection_ID AS Selection_ID, @SelectionName AS Selection_Name, + (SELECT COUNT(*) FROM dp_Selected_Records WHERE Selection_ID = @Selection_ID) AS Record_Count; +END +``` + +#### `api_custom_GetPages` + +Returns page metadata from `dp_Pages` with optional name filtering. + +```sql +CREATE PROCEDURE [dbo].[api_custom_GetPages] + @SearchName NVARCHAR(255) = NULL +AS BEGIN + SET NOCOUNT ON; + SELECT Page_ID, Display_Name + FROM dp_Pages + WHERE @SearchName IS NULL + OR Display_Name LIKE '%' + @SearchName + '%' + ORDER BY Display_Name; +END +``` + +### Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `MINISTRY_PLATFORM_DOMAIN_ID` | No | Domain ID for stored procedure calls (default: `1`) | +| `NEXT_PUBLIC_MINISTRY_PLATFORM_URL` | Yes | MP application URL for deep-links (e.g., `https://my.yourorg.com/mp`) — **not** the API URL | + +### Demo Page + +A working demo is available at `/create-mp-selection` that loads contacts, fetches page options dynamically, and demonstrates the full selection creation flow. + ## Testing The project uses **Vitest 4.0** with comprehensive test coverage for critical functionality. From 0e24fcd41a1ba9140a4ba402cc776aaecdd8e65c Mon Sep 17 00:00:00 2001 From: wantmoore Date: Wed, 4 Mar 2026 17:04:34 -0500 Subject: [PATCH 4/5] feat: add sidebar nav link, SQL scripts, and README install docs Add Create Selection to sidebar navigation, include stored procedure SQL install scripts, and document installation steps in README. Co-Authored-By: Claude Opus 4.6 --- README.md | 9 +++ scripts/api_Custom_CreateSelection.sql | 88 ++++++++++++++++++++++++++ scripts/api_custom_GetPages.sql | 73 +++++++++++++++++++++ src/components/layout/sidebar.tsx | 11 +++- 4 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 scripts/api_Custom_CreateSelection.sql create mode 100644 scripts/api_custom_GetPages.sql diff --git a/README.md b/README.md index 92a4302..16dcf02 100644 --- a/README.md +++ b/README.md @@ -638,6 +638,15 @@ import { CreateMpSelection } from '@/components/create-mp-selection'; This feature requires two custom stored procedures installed on your Ministry Platform database. These are needed because `dp_Selections`, `dp_Selected_Records`, and `dp_Pages` are not accessible via the REST API. +**Install scripts** are provided in the `scripts/` directory and must be run against your Ministry Platform SQL Server database by a database administrator: + +| Script | Purpose | +|--------|---------| +| [`scripts/api_Custom_CreateSelection.sql`](scripts/api_Custom_CreateSelection.sql) | Creates selections with record IDs | +| [`scripts/api_custom_GetPages.sql`](scripts/api_custom_GetPages.sql) | Returns pages with optional name search | + +Each script is self-contained — it creates the stored procedure, registers it in `dp_API_Procedures`, and grants execute permission to the Administrators role. + #### `api_custom_CreateSelection` Creates a selection header and inserts selected records in a single transaction. diff --git a/scripts/api_Custom_CreateSelection.sql b/scripts/api_Custom_CreateSelection.sql new file mode 100644 index 0000000..026b960 --- /dev/null +++ b/scripts/api_Custom_CreateSelection.sql @@ -0,0 +1,88 @@ +-- ============================================= +-- Ministry Platform Stored Procedure Install +-- Generated: 2026-03-03 +-- ============================================= +-- NOTE: Run this script against your Ministry Platform database +-- ============================================= + +SET ANSI_NULLS ON +GO +SET QUOTED_IDENTIFIER ON +GO +SET NOCOUNT ON +GO + +/****** Object: StoredProcedure [dbo].[api_Custom_CreateSelection] Script Date: 3/3/2026 ******/ +DROP PROCEDURE IF EXISTS [dbo].[api_Custom_CreateSelection] +GO + +-- ============================================= +-- api_Custom_CreateSelection +-- ============================================= +-- Description: Creates a new MP Selection with the given records for a user/page. +-- Last Modified: 3/3/2026 +-- ============================================= +CREATE PROCEDURE [dbo].[api_Custom_CreateSelection] + @DomainID INT, + @PageID INT, + @UserID INT, + @SelectionName NVARCHAR(255), + @RecordIDs NVARCHAR(MAX) -- comma-separated, e.g. '1001,1002,1003' +AS +BEGIN + SET NOCOUNT ON; + + INSERT INTO dp_Selections (Selection_Name, Page_ID, User_ID) + VALUES (@SelectionName, @PageID, @UserID); + + DECLARE @SelectionID INT = SCOPE_IDENTITY(); + + INSERT INTO dp_Selected_Records (Selection_ID, Record_ID) + SELECT @SelectionID, CAST(LTRIM(RTRIM(x.value('.', 'VARCHAR(MAX)'))) AS INT) + FROM ( + SELECT CAST('' + REPLACE(@RecordIDs, ',', '') + '' AS XML) + ) t(xml) + CROSS APPLY t.xml.nodes('/r') AS n(x) + WHERE LTRIM(RTRIM(x.value('.', 'VARCHAR(MAX)'))) != ''; + + SELECT + @SelectionID AS Selection_ID, + @SelectionName AS Selection_Name, + (SELECT COUNT(*) FROM dp_Selected_Records WHERE Selection_ID = @SelectionID) AS Record_Count; +END +GO + +-- ============================================= +-- SP MetaData Install +-- ============================================= +DECLARE @spName NVARCHAR(128) = 'api_Custom_CreateSelection'; +DECLARE @spDescription NVARCHAR(500) = 'Creates a new MP Selection with the given records for a user/page.'; + +IF NOT EXISTS ( + SELECT API_Procedure_ID FROM dp_API_Procedures WHERE Procedure_Name = @spName +) +BEGIN + INSERT INTO dp_API_Procedures (Procedure_Name, Description) + VALUES (@spName, @spDescription); +END + +-- Grant to Administrators Role +DECLARE @AdminRoleID INT = ( + SELECT Role_ID FROM dp_Roles WHERE Role_Name = 'Administrators' +); + +IF NOT EXISTS ( + SELECT 1 + FROM dp_Role_API_Procedures RP + INNER JOIN dp_API_Procedures AP ON AP.API_Procedure_ID = RP.API_Procedure_ID + WHERE AP.Procedure_Name = @spName AND RP.Role_ID = @AdminRoleID +) +BEGIN + INSERT INTO dp_Role_API_Procedures (Domain_ID, API_Procedure_ID, Role_ID) + VALUES ( + 1, + (SELECT API_Procedure_ID FROM dp_API_Procedures WHERE Procedure_Name = @spName), + @AdminRoleID + ); +END +GO diff --git a/scripts/api_custom_GetPages.sql b/scripts/api_custom_GetPages.sql new file mode 100644 index 0000000..23a0712 --- /dev/null +++ b/scripts/api_custom_GetPages.sql @@ -0,0 +1,73 @@ +-- ============================================= +-- Ministry Platform Stored Procedure Install +-- Generated: 2026-03-03 +-- ============================================= +-- NOTE: Run this script against your Ministry Platform database +-- ============================================= + +SET ANSI_NULLS ON +GO +SET QUOTED_IDENTIFIER ON +GO +SET NOCOUNT ON +GO + +/****** Object: StoredProcedure [dbo].[api_custom_GetPages] Script Date: 3/3/2026 ******/ +DROP PROCEDURE IF EXISTS [dbo].[api_custom_GetPages] +GO + +-- ============================================= +-- api_custom_GetPages +-- ============================================= +-- Description: Returns MP pages with optional search by display name. +-- Last Modified: 3/3/2026 +-- ============================================= +CREATE PROCEDURE [dbo].[api_custom_GetPages] + @DomainID INT, + @SearchName NVARCHAR(255) = NULL +AS +BEGIN + SET NOCOUNT ON; + + SELECT Page_ID, Display_Name + FROM dp_Pages + WHERE @SearchName IS NULL + OR Display_Name LIKE '%' + @SearchName + '%' + ORDER BY Display_Name +END +GO + +-- ============================================= +-- SP MetaData Install +-- ============================================= +DECLARE @spName NVARCHAR(128) = 'api_custom_GetPages'; +DECLARE @spDescription NVARCHAR(500) = 'Returns MP pages with optional search by display name.'; + +IF NOT EXISTS ( + SELECT API_Procedure_ID FROM dp_API_Procedures WHERE Procedure_Name = @spName +) +BEGIN + INSERT INTO dp_API_Procedures (Procedure_Name, Description) + VALUES (@spName, @spDescription); +END + +-- Grant to Administrators Role +DECLARE @AdminRoleID INT = ( + SELECT Role_ID FROM dp_Roles WHERE Role_Name = 'Administrators' +); + +IF NOT EXISTS ( + SELECT 1 + FROM dp_Role_API_Procedures RP + INNER JOIN dp_API_Procedures AP ON AP.API_Procedure_ID = RP.API_Procedure_ID + WHERE AP.Procedure_Name = @spName AND RP.Role_ID = @AdminRoleID +) +BEGIN + INSERT INTO dp_Role_API_Procedures (Domain_ID, API_Procedure_ID, Role_ID) + VALUES ( + 1, + (SELECT API_Procedure_ID FROM dp_API_Procedures WHERE Procedure_Name = @spName), + @AdminRoleID + ); +END +GO diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 749aa12..782d58b 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -1,7 +1,11 @@ "use client"; import { XMarkIcon } from "@heroicons/react/24/outline"; -import { HomeIcon, UsersIcon } from "@heroicons/react/24/outline"; +import { + HomeIcon, + UsersIcon, + QueueListIcon, +} from "@heroicons/react/24/outline"; interface SidebarProps { isOpen: boolean; @@ -11,6 +15,11 @@ interface SidebarProps { const navigation = [ { name: "Dashboard", href: "/", icon: HomeIcon }, { name: "Contact Lookup", href: "/contactlookup", icon: UsersIcon }, + { + name: "Create Selection", + href: "/create-mp-selection", + icon: QueueListIcon, + }, // { name: 'Calendar', href: '/calendar', icon: CalendarIcon }, // { name: 'Settings', href: '/settings', icon: CogIcon }, ]; From 5587d078a1d020a2b4a1899ff285e6336106888e Mon Sep 17 00:00:00 2001 From: wantmoore Date: Wed, 4 Mar 2026 17:08:15 -0500 Subject: [PATCH 5/5] fix: update tests to match stored procedure refactor - Update selectionService tests to mock executeProcedureWithBody instead of createTableRecords (matching the stored procedure implementation) - Add missing text() mock to http-client error response test Co-Authored-By: Claude Opus 4.6 --- .../utils/http-client.test.ts | 1 + src/services/selectionService.test.ts | 80 ++++++++----------- 2 files changed, 35 insertions(+), 46 deletions(-) diff --git a/src/lib/providers/ministry-platform/utils/http-client.test.ts b/src/lib/providers/ministry-platform/utils/http-client.test.ts index d261a3b..23a0c84 100644 --- a/src/lib/providers/ministry-platform/utils/http-client.test.ts +++ b/src/lib/providers/ministry-platform/utils/http-client.test.ts @@ -242,6 +242,7 @@ describe('HttpClient', () => { ok: false, status: 400, statusText: 'Bad Request', + text: () => Promise.resolve(''), }); await expect( diff --git a/src/services/selectionService.test.ts b/src/services/selectionService.test.ts index ac930ec..9ff1dce 100644 --- a/src/services/selectionService.test.ts +++ b/src/services/selectionService.test.ts @@ -1,12 +1,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { SelectionService } from '@/services/selectionService'; -const mockCreateTableRecords = vi.fn(); +const mockExecuteProcedureWithBody = vi.fn(); vi.mock('@/lib/providers/ministry-platform', () => { return { MPHelper: class { - createTableRecords = mockCreateTableRecords; + executeProcedureWithBody = mockExecuteProcedureWithBody; }, }; }); @@ -17,6 +17,7 @@ describe('SelectionService', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (SelectionService as any).instance = undefined; process.env.NEXT_PUBLIC_MINISTRY_PLATFORM_URL = 'https://my.grangerchurch.com'; + process.env.MINISTRY_PLATFORM_DOMAIN_ID = '1'; }); describe('getInstance', () => { @@ -40,10 +41,10 @@ describe('SelectionService', () => { ).rejects.toThrow('recordIds must not be empty'); }); - it('should call dp_Selections first then dp_Selected_Records', async () => { - mockCreateTableRecords - .mockResolvedValueOnce([{ Selection_ID: 138291, Selection_Name: 'Test', Record_Count: 2, Is_Temporary: false, User_ID_Owner: 42 }]) - .mockResolvedValueOnce([{}, {}]); + it('should call executeProcedureWithBody with correct params', async () => { + mockExecuteProcedureWithBody.mockResolvedValueOnce([ + [{ Selection_ID: 138291, Selection_Name: 'Test Selection', Record_Count: 2 }], + ]); const service = await SelectionService.getInstance(); await service.createSelection({ @@ -53,25 +54,20 @@ describe('SelectionService', () => { userId: 42, }); - expect(mockCreateTableRecords).toHaveBeenCalledTimes(2); - expect(mockCreateTableRecords).toHaveBeenNthCalledWith(1, 'dp_Selections', [ - { - Selection_Name: 'Test Selection', - Record_Count: 2, - Is_Temporary: false, - User_ID_Owner: 42, - }, - ]); - expect(mockCreateTableRecords).toHaveBeenNthCalledWith(2, 'dp_Selected_Records', [ - { Selection_ID: 138291, Record_ID: 10 }, - { Selection_ID: 138291, Record_ID: 20 }, - ]); + expect(mockExecuteProcedureWithBody).toHaveBeenCalledTimes(1); + expect(mockExecuteProcedureWithBody).toHaveBeenCalledWith('api_custom_CreateSelection', { + '@DomainID': 1, + '@PageID': 292, + '@UserID': 42, + '@SelectionName': 'Test Selection', + '@RecordIDs': '10,20', + }); }); it('should build the correct selectionUrl from NEXT_PUBLIC_MINISTRY_PLATFORM_URL', async () => { - mockCreateTableRecords - .mockResolvedValueOnce([{ Selection_ID: 138291 }]) - .mockResolvedValueOnce([{}]); + mockExecuteProcedureWithBody.mockResolvedValueOnce([ + [{ Selection_ID: 138291, Selection_Name: 'Test', Record_Count: 1 }], + ]); const service = await SelectionService.getInstance(); const result = await service.createSelection({ @@ -81,13 +77,13 @@ describe('SelectionService', () => { userId: 1, }); - expect(result.selectionUrl).toBe('https://my.grangerchurch.com/mp/292.138291'); + expect(result.selectionUrl).toBe('https://my.grangerchurch.com/292.138291'); expect(result.pageId).toBe(292); expect(result.selectionId).toBe(138291); }); - it('should throw when MP returns no Selection_ID', async () => { - mockCreateTableRecords.mockResolvedValueOnce([{}]); + it('should throw when stored procedure returns no Selection_ID', async () => { + mockExecuteProcedureWithBody.mockResolvedValueOnce([[{}]]); const service = await SelectionService.getInstance(); await expect( @@ -97,11 +93,11 @@ describe('SelectionService', () => { recordIds: [1], userId: 1, }) - ).rejects.toThrow('Failed to create selection: no Selection_ID returned'); + ).rejects.toThrow('Failed to create selection: stored procedure returned no Selection_ID'); }); - it('should throw when MP returns empty array for dp_Selections', async () => { - mockCreateTableRecords.mockResolvedValueOnce([]); + it('should throw when stored procedure returns empty result set', async () => { + mockExecuteProcedureWithBody.mockResolvedValueOnce([[]]); const service = await SelectionService.getInstance(); await expect( @@ -111,13 +107,11 @@ describe('SelectionService', () => { recordIds: [1], userId: 1, }) - ).rejects.toThrow('Failed to create selection: no Selection_ID returned'); + ).rejects.toThrow('Failed to create selection: stored procedure returned no Selection_ID'); }); - it('should propagate errors from the bulk insert call', async () => { - mockCreateTableRecords - .mockResolvedValueOnce([{ Selection_ID: 999 }]) - .mockRejectedValueOnce(new Error('Bulk insert failed')); + it('should propagate errors from executeProcedureWithBody', async () => { + mockExecuteProcedureWithBody.mockRejectedValueOnce(new Error('Procedure execution failed')); const service = await SelectionService.getInstance(); await expect( @@ -127,14 +121,13 @@ describe('SelectionService', () => { recordIds: [1, 2, 3], userId: 1, }) - ).rejects.toThrow('Bulk insert failed'); + ).rejects.toThrow('Procedure execution failed'); }); - it('should use the Selection_ID from dp_Selections for all dp_Selected_Records rows', async () => { - const selectionId = 55555; - mockCreateTableRecords - .mockResolvedValueOnce([{ Selection_ID: selectionId }]) - .mockResolvedValueOnce([{}, {}, {}]); + it('should join recordIds as comma-separated string', async () => { + mockExecuteProcedureWithBody.mockResolvedValueOnce([ + [{ Selection_ID: 55555, Selection_Name: 'Test', Record_Count: 3 }], + ]); const service = await SelectionService.getInstance(); await service.createSelection({ @@ -144,13 +137,8 @@ describe('SelectionService', () => { userId: 5, }); - const secondCall = mockCreateTableRecords.mock.calls[1]; - const selectedRecords = secondCall[1] as Array<{ Selection_ID: number; Record_ID: number }>; - expect(selectedRecords).toHaveLength(3); - selectedRecords.forEach((row) => { - expect(row.Selection_ID).toBe(selectionId); - }); - expect(selectedRecords.map((r) => r.Record_ID)).toEqual([100, 200, 300]); + const callArgs = mockExecuteProcedureWithBody.mock.calls[0]; + expect(callArgs[1]['@RecordIDs']).toBe('100,200,300'); }); }); });