Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
09ffe79
feat: route MP datetime through DomainTimezoneService at the boundary
chriskehayias May 30, 2026
dad2296
refactor: eliminate hardcoded Northwoods references
chriskehayias May 30, 2026
69b620e
build: fix SDK build typecheck and unblock ESLint
chriskehayias May 30, 2026
56f2e9d
updated release command
chriskehayias May 30, 2026
c9145bf
fix: resolve pre-existing lint errors surfaced after ESLint unblock
chriskehayias May 30, 2026
3f9931b
chore(deps): fix 2 moderate CVEs and apply safe dependency updates
chriskehayias May 30, 2026
8716b29
chore(deps): bump @types/node to 22.19.19 in @mpnext/embed-sdk
chriskehayias May 30, 2026
8d73450
chore(deps): bump typescript to 6.0.3 in workspace packages
chriskehayias May 30, 2026
5091448
chore(deps): upgrade concurrently 9.2.1 -> 10.0.0
chriskehayias May 30, 2026
eb29288
chore(env): document all env vars and remove NEXTAUTH fallbacks
chriskehayias May 30, 2026
c46ac75
docs(readme): correct inaccurate claims after full-app review
chriskehayias May 30, 2026
4bb5ca3
fix(http-client): remove request-body logging from MP HTTP client (se…
chriskehayias May 30, 2026
d713870
chore(logging): remove leftover debug console.log statements
chriskehayias May 30, 2026
1bcf79c
refactor(env): validate required env vars with a fail-fast helper
chriskehayias May 30, 2026
3a9bf45
fix(signin): handle rejected getSession with error + retry instead of…
chriskehayias May 30, 2026
55eb66f
chore(embed): drop unused Idempotency-Key CORS header (no dedup imple…
chriskehayias May 30, 2026
7302e0f
docs(claude): correct stale widget count, bundle name, gzip size, and…
chriskehayias May 30, 2026
428fcc9
chore: canonicalize project name to MPNext-Widgets
chriskehayias May 30, 2026
7f92203
chore(node): enforce Node >=20.9 via engines + engine-strict
chriskehayias May 30, 2026
49d50bd
chore(license): add UNLICENSED license field and proprietary LICENSE …
chriskehayias May 30, 2026
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
311 changes: 89 additions & 222 deletions .claude/commands/release.md

Large diffs are not rendered by default.

986 changes: 986 additions & 0 deletions .claude/playbooks/port-mp-datetime-handling.md

Large diffs are not rendered by default.

134 changes: 134 additions & 0 deletions .claude/references/ministryplatform.datetimehandling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# MP Date/Time Handling Reference

This document covers how date and datetime values must flow between the UI, our services, and the Ministry Platform (MP) API. Use it whenever you add a new MP date field, audit a server action that writes dates, or debug a "the saved date is wrong" report.

## Why MP is not UTC

MP stores datetimes as **wall-clock values in the domain's configured time zone** (e.g. `2026-05-17 23:33:00` is literally "11:33 PM in this church's time zone"). It does **not** normalize to UTC on the way in or out. The domain's time zone is exposed via `MPHelper.getDomainInfo().TimeZoneName`.

If you send a value tagged as UTC, MP stores it as if those UTC clock numbers were the local clock numbers — the saved record drifts by the MP-to-UTC offset. The same anti-pattern in reverse on the read path causes drift on display and compounds across edits.

A real symptom of this bug: a Contact Log entry created at 11:33 PM Eastern on 2026-05-17 saved as 2026-05-16 at 8:00 PM. The form appended `T00:00:00.000Z` to a date string, and the service ran `new Date(...).getFullYear()` on the result. Each save shifted the date by the offset between the Node server's local time and UTC. Editing read the already-shifted date and applied the same transform again, so the date moved backwards another day every edit.

## The service

`src/services/domainTimezoneService.ts` — singleton, server-side, cached per process. Always go through this; never reach into `MPHelper.getDomainInfo()` directly to read `TimeZoneName`.

```ts
import { DomainTimezoneService } from "@/services/domainTimezoneService";

const tz = DomainTimezoneService.getInstance();
await tz.getMpTimezone(); // → "America/New_York" (IANA)
await tz.toMpSqlDatetime("2026-05-17"); // → "2026-05-17 00:00:00"
await tz.toMpSqlDatetime(new Date()); // → MP-TZ wall-clock for "now"
await tz.parseMpDatetime("2026-05-17 12:00:00"); // → Date instant
```

For client-side rendering, expose the IANA zone through `getMpTimezone()` in `src/app/actions/domain.ts` and thread it as a prop into the component that needs to format MP datetimes.

### `toMpSqlDatetime(value)` — write path

Returns the SQL datetime string MP's table API expects (`YYYY-MM-DD HH:MM:SS`).

| Input | Treated as | Output |
| --- | --- | --- |
| `"2026-05-17"` | MP-TZ wall-clock midnight | `"2026-05-17 00:00:00"` |
| `"2026-05-17 14:30:00"` | MP-TZ wall-clock (already SQL) | `"2026-05-17 14:30:00"` |
| `"2026-05-17T14:30"` | MP-TZ wall-clock | `"2026-05-17 14:30:00"` |
| `"2026-05-17T03:33:00.000Z"` | UTC instant | converted to MP-TZ |
| `"2026-05-17T03:33:00-04:00"` | Instant at offset | converted to MP-TZ |
| `Date` instance | UTC instant | converted to MP-TZ |

The rule: **strings with no zone marker are wall-clock**, strings/Dates with explicit zone info are instants that get converted.

### `parseMpDatetime(value)` — read path arithmetic

Use when you need a `Date` instant to do real arithmetic on a value MP returned. For pure display, prefer `Intl.DateTimeFormat({ timeZone })` against the raw string.

## Recipes

### Writing a date-only field (`<input type="date">`)

```tsx
// Client component — send the raw string, no Z, no time.
const payload = { Contact_Date: form.contactDate /* "2026-05-17" */ };

// Server action / service
const tz = DomainTimezoneService.getInstance();
const mpDate = await tz.toMpSqlDatetime(payload.Contact_Date);
// → "2026-05-17 00:00:00"
```

### Writing a datetime field with a "save at current moment" intent

```ts
const tz = DomainTimezoneService.getInstance();
const mpDate = await tz.toMpSqlDatetime(new Date());
// → MP-TZ wall-clock representation of the server's "now"
```

### Pre-filling an edit form from a stored MP value

MP returns datetimes as wall-clock strings in MP-TZ (no zone marker). For a date input, take the date portion directly — **do not** parse with `new Date()`:

```tsx
setValue("contactDate", log.Contact_Date.split("T")[0]);
```

For a `datetime-local` input, trim to `YYYY-MM-DDTHH:MM`:

```tsx
function toDatetimeLocalValue(mpDate: string): string {
const normalized = mpDate.replace(" ", "T");
return normalized.length >= 16 ? normalized.slice(0, 16) : `${normalized.slice(0, 10)}T00:00`;
}
```

### Displaying a stored MP datetime in the browser

`new Date(stringFromMp).toLocaleDateString(...)` parses the string as **browser-local**, which silently disagrees with MP-TZ. Format with an explicit `timeZone`:

```tsx
return new Intl.DateTimeFormat("en-US", {
timeZone: mpTimezone,
month: "short", day: "numeric", year: "numeric",
hour: "numeric", minute: "2-digit",
}).format(instant);
```

For embed-SDK Web Components specifically, the IANA zone must be fetched (or returned in the API payload) and passed into the component — see `src/services/fullCalendarService.ts` for an example of routing the value through `DomainTimezoneService.toMpSqlDatetime` on the server side before MP `$filter` is composed.

### Filtering on a date column in `$filter`

`$filter` strings are interpreted in MP-TZ. Quote the value and use MP-TZ wall-clock:

```ts
filter: `Contact_Date >= '2026-05-01' AND Contact_Date < '2026-06-01'`
```

Do not convert filter values to UTC. If you have a `Date` instant or an ISO/Z-tagged string in JS, run it through `tz.toMpSqlDatetime(instant)` first.

## Anti-patterns

| ❌ Don't | ✅ Do |
| --- | --- |
| ``Contact_Date: `${date}T00:00:00.000Z` `` | `Contact_Date: date` |
| `new Date(formValue).toISOString()` | `await tz.toMpSqlDatetime(formValue)` |
| `new Date(mpValue).getFullYear()` etc. | `await tz.parseMpDatetime(mpValue)` or `Intl.DateTimeFormat({ timeZone })` |
| `new Date(mpValue).toLocaleString(...)` for display | `Intl.DateTimeFormat("en-US", { timeZone: mpTimezone, ... })` |
| Reading domain TZ ad-hoc per request | `DomainTimezoneService.getInstance().getMpTimezone()` (cached) |

The shared signature of these bugs: a `Date` object that crosses a zone boundary silently. Whenever you see `new Date(...)` near an MP read/write, ask "what zone is this assumed to be in, and what zone is the caller expecting back?"

## Windows ↔ IANA zone names

MP's `/domain` endpoint returns `TimeZoneName` as a **Windows** zone (e.g. `"Eastern Standard Time"`). `Intl.DateTimeFormat` requires **IANA** (e.g. `"America/New_York"`). `DomainTimezoneService` maps between them. If a new MP deployment surfaces an unmapped zone, `resolveIanaTimezone` throws with the unmapped name — extend the table rather than silently falling back to the server's local zone.

## Testing

When a test exercises code that goes through `DomainTimezoneService`:

1. **Mock `MPHelper.getDomainInfo`** to return a known `TimeZoneName` — use `vi.hoisted()` because the singleton's `MPHelper` is constructed at module-load time.
2. **Reset the singleton** between tests: `(DomainTimezoneService as any).instance = null` in `beforeEach`.
3. **Use `mockReset()` (not `clearAllMocks()`)** on the `getDomainInfo` mock. `clearAllMocks` doesn't drain `mockResolvedValueOnce` queues, and tests that don't hit `getMpTimezone()` leave queue entries behind that leak forward.
4. **Run under multiple `TZ` env vars** — at minimum `TZ=UTC` and `TZ=America/Los_Angeles`. The original bug was invisible when developer machines and the server happened to be in the same zone as the MP domain.
2 changes: 1 addition & 1 deletion .claude/references/ministryplatform.schema.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Ministry Platform Schema Reference

This document provides a summary of Ministry Platform database tables for LLM assistants working on the NorthwoodsNext project.
This document provides a summary of Ministry Platform database tables for LLM assistants working on this project.

**Generated:** 2026-03-25T14:07:09.466Z
**Tables:** 301
Expand Down
34 changes: 26 additions & 8 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,38 @@ BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_SECRET=your-better-auth-secret-min-32-chars

# ============================================================================
# OIDC Provider (Ministry Platform OAuth)
# OIDC Provider (Ministry Platform OAuth — interactive user login)
# ============================================================================
# Credentials for the OAuth client used to sign users in. If unset, the code
# falls back to MINISTRY_PLATFORM_CLIENT_ID / _SECRET below (src/lib/auth.ts),
# so you only need these if your login client differs from the API client.
OIDC_CLIENT_ID=your-oidc-client-id
OIDC_CLIENT_SECRET=your-oidc-client-secret

# ============================================================================
# Ministry Platform API (service-to-service)
# Ministry Platform API (service-to-service / client credentials)
# ============================================================================
# Base URL must include the /ministryplatformapi suffix; server code strips it
# where a bare host is needed (src/lib/embed/config.ts). Required by the type
# generator (pnpm generate:types) and all MP-backed services.
MINISTRY_PLATFORM_BASE_URL=https://your-mp-instance.com/ministryplatformapi
MINISTRY_PLATFORM_CLIENT_ID=your-client-id
MINISTRY_PLATFORM_CLIENT_SECRET=your-client-secret

# Organization display name baked into the embed SDK at build time (e.g. the
# SMS opt-in consent text in the profile widget). Unset falls back to a neutral
# phrase ("our organization"). Consumed by Vite at build (packages/embed-sdk).
VITE_ORG_NAME=

# ============================================================================
# Public Keys (exposed to the browser)
# ============================================================================
NEXT_PUBLIC_MINISTRY_PLATFORM_FILE_URL=https://your-mp-instance.com/ministryplatformapi/files
NEXT_PUBLIC_APP_NAME=MPNext-Components
NEXT_PUBLIC_APP_NAME=MPNext-Widgets

# NEXT_PUBLIC_APP_VERSION is NOT set here — it is derived automatically at build
# time from the repo's VERSION file (next.config.ts), defaulting to "dev".
# Setting it manually has no effect.

# ============================================================================
# Embed Widget Settings
Expand All @@ -31,7 +46,10 @@ NEXT_PUBLIC_APP_NAME=MPNext-Components
# Generate via: node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
EMBED_JWT_SECRET=

# Comma-separated origins allowed to call the embed API
# Comma-separated origins allowed to call the embed API.
# On Vercel, VERCEL_URL and VERCEL_PROJECT_PRODUCTION_URL (injected
# automatically by the platform) are also added to the allowlist — no need to
# set those yourself locally.
EMBED_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173

# ============================================================================
Expand All @@ -45,8 +63,8 @@ RECAPTCHA_SECRET_KEY=
# Demo Access Control (src/app/(demo)/...)
# ============================================================================
# Comma-separated MP User_Group_ID values whose members can view /demo pages.
# Defaults to "73" in code, which is a Northwoods-specific group ID — set this
# to your own staff/admin group(s) for any other tenant.
# No default — set this to your own staff/admin group(s). If unset (and
# DEMO_PUBLIC_ACCESS is not enabled), group-based demo access is denied.
DEMO_ACCESS_GROUP_IDS=

# Set to "true" or "authenticated" to grant any signed-in user access to /demo
Expand All @@ -57,8 +75,8 @@ DEMO_PUBLIC_ACCESS=
# Full Calendar Admin (src/services/fullCalendarService.ts)
# ============================================================================
# Comma-separated MP User_Group_ID values whose members are treated as
# calendar admins. Defaults to "22" in code (Northwoods-specific) — override
# for your tenant.
# calendar admins. No default — set this for your tenant. If unset, no users
# are treated as calendar admins.
CALENDAR_ADMIN_GROUP_IDS=

# ============================================================================
Expand Down
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
engine-strict=true
31 changes: 20 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
# CLAUDE.md - MPNext-Components
# CLAUDE.md - MPNext-Widgets

## Overview

**pnpm monorepo**: Component-only extraction from NorthwoodsNext. Contains 3 embed SDK widgets (user-menu, add-to-calendar, full-calendar) with their supporting API routes, services, and shared types. The embed SDK builds framework-agnostic Web Components (Shadow DOM) loaded via `<script>` on external sites.
**pnpm monorepo**: Component-only embed SDK extraction. Contains 5 embed SDK widgets (user-menu, add-to-calendar, full-calendar, profile, my-invoices) with their supporting API routes, services, and shared types. The embed SDK builds framework-agnostic Web Components (Shadow DOM) loaded via `<script>` on external sites.

## Structure

```
src/ # Next.js 16 (App Router)
├── app/api/embed/ # Widget API endpoints (subset for 3 widgets)
├── services/ # Singleton services (addToCalendar, fullCalendar, profile, subscription, user)
├── app/api/embed/ # Widget API endpoints (subset for 5 widgets)
├── services/ # Singleton services (addToCalendar, fullCalendar, profile, subscription, user, invoice, domainTimezone)
├── lib/embed/ # Widget auth (JWT, CORS, tenant config)
├── lib/providers/ministry-platform/ # MP REST API (MPHelper, models, auth)
packages/
├── embed-sdk/ # @mpnext/embed-sdk (Vite library)
│ ├── src/components/ # 3 Web Components (next-* custom elements)
│ ├── src/components/ # 5 Web Components (next-* custom elements)
│ ├── src/shared/ # base-widget.ts, api-client.ts, cdn-loader.ts
│ └── demo-*.html # Per-widget demo pages + index.html
└── types/ # @mpnext/types (Zod schemas + TS interfaces)
Expand Down Expand Up @@ -43,26 +43,32 @@ Manual widget testing via `pnpm test:widget` (opens http://localhost:5173). Play

**Playwright test account**: `PLAYWRIGHT_MP_USERNAME` / `PLAYWRIGHT_MP_PASSWORD` in `.env.local`. This is a non-admin MP OAuth user with **MFA disabled**.

**Dev tenant**: `northwoods-dev` in `src/lib/embed/config.ts`. Allowed origins: `localhost:3000`, `localhost:5173` (and 127.0.0.1 variants). Init token: `northwoods-dev_dev-secret`.
**Dev auth**: Widget session auth is origin-based — no tenant id or init token. The `/api/embed/session` route validates the request origin against `EMBED_ALLOWED_ORIGINS` (`src/lib/embed/config.ts`). Local dev origins: `localhost:3000`, `localhost:5173` (and 127.0.0.1 variants).

## Widget Architecture

1. External site loads `nw-embed.es.js` via `<script type="module">`
1. External site loads `next-embed.es.js` via `<script type="module">`
2. `MPNextEmbed.init()` sets token provider
3. Token provider fetches JWT (5-min expiry) from `/api/embed/session`
4. Widgets render in Shadow DOM; API calls use Bearer token with auto-refresh on 401

**Design**: Web Components + Shadow DOM (no framework deps, ~5KB gzip), JWT+CORS auth, idempotency keys, multi-tenant origin allowlists.
**Design**: Web Components + Shadow DOM (no framework deps, ~33KB gzip), JWT+CORS auth, multi-tenant origin allowlists.

**3 widgets**: `next-user-menu`, `next-add-to-calendar`, `next-full-calendar`
**5 widgets**: `next-user-menu`, `next-add-to-calendar`, `next-full-calendar`, `next-profile`, `next-my-invoices`

**MP widget styling**: `public/embed-sdk/mp-widget-overrides.css` injected into MP Shadow DOM widgets via `customcss` attribute. User-menu applies this automatically.

## Services (src/services/)

All services follow singleton pattern: `const svc = await ServiceName.getInstance()`. Each wraps `MPHelper`.

Services: `addToCalendar`, `fullCalendar`, `profile`, `subscription`, `user`
Services: `addToCalendar`, `fullCalendar`, `profile`, `subscription`, `user`, `invoice`, `domainTimezone`

## MP Date/Time Handling

**Convert all date/time values at the MP boundary** — use `DomainTimezoneService` (never raw `new Date(x).toISOString()` or `getFullYear()`) when sending or receiving datetime fields, since MP stores wall-clock values in the domain's time zone, not UTC. Server-side, route writes/filters through `DomainTimezoneService.getInstance().toMpSqlDatetime(...)`. Client-side, format MP values with `Intl.DateTimeFormat({ timeZone })` using the IANA zone from `getMpTimezone()` (`src/app/actions/domain.ts`).

See **[Date/Time Handling Reference](.claude/references/ministryplatform.datetimehandling.md)**.

## Code Conventions

Expand Down Expand Up @@ -109,8 +115,11 @@ await mp.executeProcedure('ProcName', { param: 'value' });
| `src/lib/embed/auth.ts` | `requireWidgetAuth()` -- accepts `widget: string \| string[]` |
| `src/lib/embed/config.ts` | Tenant configs & allowed origins |
| `src/lib/embed/jwt.ts` | Widget JWT creation/verification |
| `packages/embed-sdk/src/index.ts` | SDK entry point -- registers 3 widgets |
| `packages/embed-sdk/src/index.ts` | SDK entry point -- registers 5 widgets |
| `packages/embed-sdk/src/shared/base-widget.ts` | Abstract base class (Shadow DOM, token mgmt, fetch) |
| `packages/embed-sdk/vite.config.ts` | Vite library mode (ES + UMD output) |
| `public/embed-sdk/mp-widget-overrides.css` | Brand CSS for MP Shadow DOM widgets |
| `.claude/references/ministryplatform.query-syntax.md` | MP REST API query syntax reference (`$filter`, `$select`, `_TABLE` traversal) |
| `.claude/references/ministryplatform.datetimehandling.md` | How to send/receive MP datetimes safely via `DomainTimezoneService`, anti-patterns, Windows↔IANA mapping, test guidance |
| `src/services/domainTimezoneService.ts` | Singleton: MP domain TZ → IANA, `toMpSqlDatetime`, `parseMpDatetime` |
| `src/app/actions/domain.ts` | `getMpTimezone()` server action for client-side `Intl.DateTimeFormat` rendering |
15 changes: 15 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
Copyright (c) 2026 ACS Technologies (ACST). All rights reserved.

This software and its associated source code, documentation, and assets
(the "Software") are proprietary and confidential to ACS Technologies (ACST).

No part of the Software may be used, copied, modified, merged, published,
distributed, sublicensed, or disclosed, in whole or in part, by any means,
without the prior explicit written permission of ACS Technologies (ACST).

Unauthorized use, reproduction, or distribution of the Software, or any
portion of it, is strictly prohibited.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
Loading
Loading