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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
55 changes: 55 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 }`
111 changes: 111 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -390,6 +397,7 @@ MPNext/
│ ├── services/ # Application services
│ │ ├── contactService.ts
│ │ ├── contactLogService.ts
│ │ ├── selectionService.ts
│ │ ├── userService.ts
│ │ └── toolService.ts
│ │
Expand Down Expand Up @@ -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/`)
Expand All @@ -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.
Expand Down Expand Up @@ -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
<CreateMpSelection
pageId={292}
recordIds={[1001, 1002, 1003]}
defaultSelectionName="My Contacts"
/>

// Multi-page mode with page picker
<CreateMpSelection
pageOptions={[
{ pageId: 292, label: "Contacts" },
{ pageId: 2, label: "Households" },
]}
recordIds={selectedIds}
onPageChange={(page) => 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.
Expand Down
2 changes: 1 addition & 1 deletion next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

88 changes: 88 additions & 0 deletions scripts/api_Custom_CreateSelection.sql
Original file line number Diff line number Diff line change
@@ -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('<r>' + REPLACE(@RecordIDs, ',', '</r><r>') + '</r>' 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
Loading