diff --git a/.env.example b/.env.example index fac7cfe..616b0ba 100644 --- a/.env.example +++ b/.env.example @@ -33,9 +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 + +# 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/README.md b/README.md index 2d9d4a3..16dcf02 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,107 @@ 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. + +**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. + +```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. 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/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/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 new file mode 100644 index 0000000..a524b9f --- /dev/null +++ b/src/components/create-mp-selection/actions.ts @@ -0,0 +1,62 @@ +"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; +} + +import { MAX_SELECTION_RECORDS } from "./constants"; + +export interface CreateMpSelectionInput { + selectionName: string; + pageId: number; + recordIds: number[]; +} + +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"); + } + + 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/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 new file mode 100644 index 0000000..ff715a4 --- /dev/null +++ b/src/components/create-mp-selection/create-mp-selection.tsx @@ -0,0 +1,257 @@ +"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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} 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"; + +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( + 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); + const [copied, setCopied] = useState(false); + + const handleOpenChange = (open: boolean) => { + setIsOpen(open); + 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); + setIsCreating(false); + } + }; + + const handleCreate = async () => { + if (!selectionName.trim() || !selectedPageId) return; + + try { + setIsCreating(true); + setError(null); + const selectionResult = await createMpSelection({ + selectionName: selectionName.trim(), + pageId: selectedPageId, + 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.toLocaleString()} record${recordIds.length !== 1 ? "s" : ""}.`} + + + + {!result ? ( +
+ {showPicker && ( +
+ + +
+ )} + +
+ + 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..fa087a5 --- /dev/null +++ b/src/components/create-mp-selection/index.ts @@ -0,0 +1,3 @@ +export { CreateMpSelection } from './create-mp-selection'; +export type { CreateMpSelectionProps, MpPageOption } from './create-mp-selection'; +export { MAX_SELECTION_RECORDS } from './constants'; 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 }, ]; 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..009a620 --- /dev/null +++ b/src/lib/dto/selections.ts @@ -0,0 +1,17 @@ +export interface SelectionResult { + pageId: number; + selectionId: number; + selectionUrl: string; +} + +export interface CreateSelectionInput { + selectionName: string; + pageId: number; + recordIds: number[]; + userId: number; +} + +export interface MpPage { + Page_ID: number; + Display_Name: string; +} 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/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.test.ts b/src/services/selectionService.test.ts new file mode 100644 index 0000000..9ff1dce --- /dev/null +++ b/src/services/selectionService.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SelectionService } from '@/services/selectionService'; + +const mockExecuteProcedureWithBody = vi.fn(); + +vi.mock('@/lib/providers/ministry-platform', () => { + return { + MPHelper: class { + executeProcedureWithBody = mockExecuteProcedureWithBody; + }, + }; +}); + +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'; + process.env.MINISTRY_PLATFORM_DOMAIN_ID = '1'; + }); + + 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 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({ + selectionName: 'Test Selection', + pageId: 292, + recordIds: [10, 20], + userId: 42, + }); + + 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 () => { + mockExecuteProcedureWithBody.mockResolvedValueOnce([ + [{ Selection_ID: 138291, Selection_Name: 'Test', Record_Count: 1 }], + ]); + + 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/292.138291'); + expect(result.pageId).toBe(292); + expect(result.selectionId).toBe(138291); + }); + + it('should throw when stored procedure returns no Selection_ID', async () => { + mockExecuteProcedureWithBody.mockResolvedValueOnce([[{}]]); + + const service = await SelectionService.getInstance(); + await expect( + service.createSelection({ + selectionName: 'Test', + pageId: 292, + recordIds: [1], + userId: 1, + }) + ).rejects.toThrow('Failed to create selection: stored procedure returned no Selection_ID'); + }); + + it('should throw when stored procedure returns empty result set', async () => { + mockExecuteProcedureWithBody.mockResolvedValueOnce([[]]); + + const service = await SelectionService.getInstance(); + await expect( + service.createSelection({ + selectionName: 'Test', + pageId: 292, + recordIds: [1], + userId: 1, + }) + ).rejects.toThrow('Failed to create selection: stored procedure returned no Selection_ID'); + }); + + it('should propagate errors from executeProcedureWithBody', async () => { + mockExecuteProcedureWithBody.mockRejectedValueOnce(new Error('Procedure execution failed')); + + const service = await SelectionService.getInstance(); + await expect( + service.createSelection({ + selectionName: 'Test', + pageId: 292, + recordIds: [1, 2, 3], + userId: 1, + }) + ).rejects.toThrow('Procedure execution failed'); + }); + + 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({ + selectionName: 'Test', + pageId: 292, + recordIds: [100, 200, 300], + userId: 5, + }); + + const callArgs = mockExecuteProcedureWithBody.mock.calls[0]; + expect(callArgs[1]['@RecordIDs']).toBe('100,200,300'); + }); + }); +}); diff --git a/src/services/selectionService.ts b/src/services/selectionService.ts new file mode 100644 index 0000000..1e0d420 --- /dev/null +++ b/src/services/selectionService.ts @@ -0,0 +1,73 @@ +import { MPHelper } from "@/lib/providers/ministry-platform"; +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; + 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 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; + + if (!recordIds || recordIds.length === 0) { + throw new Error("recordIds must not be empty"); + } + + const domainId = Number(process.env.MINISTRY_PLATFORM_DOMAIN_ID ?? "1"); + + const resultSets = await this.mp!.executeProcedureWithBody(CREATE_SELECTION_PROC, { + "@DomainID": domainId, + "@PageID": pageId, + "@UserID": userId, + "@SelectionName": selectionName, + "@RecordIDs": recordIds.join(","), + }); + + // executeProcedureWithBody returns unknown[][] — outer array = result sets, inner = rows + const firstRow = (resultSets?.[0]?.[0] ?? null) as ProcSelectionResult | null; + + if (!firstRow?.Selection_ID) { + throw new Error("Failed to create selection: stored procedure returned no Selection_ID"); + } + + const selectionId = firstRow.Selection_ID; + const mpUrl = process.env.NEXT_PUBLIC_MINISTRY_PLATFORM_URL; + const selectionUrl = `${mpUrl}/${pageId}.${selectionId}`; + + return { pageId, selectionId, selectionUrl }; + } +}