From 0dba1d5c0c4b72cfb47de665062a002c814a0804 Mon Sep 17 00:00:00 2001 From: Chris Kehayias Date: Wed, 20 May 2026 09:43:59 -0400 Subject: [PATCH 1/2] feat(google-places): add server-side Places provider + service Adds a reusable Google Places integration following the existing provider/service pattern (mirrors MP). All calls run on the server so the API key never leaves the host. Provider (src/lib/providers/google-places/) - GooglePlacesProvider class wrapping the Places API v1 REST endpoints (places:autocomplete, places/{id}) - Uses session tokens per autocomplete -> details cycle so Google bills a single suggestion-session instead of per-keystroke - X-Goog-FieldMask trims response payloads to only what the client uses Service (src/services/googlePlacesService.ts) - Singleton matching the existing service pattern - isEnabled() returns true only when GOOGLE_PLACES_API_KEY is set; the consumer should fall back gracefully when missing - Lazy provider instantiation on first call Config - Adds GOOGLE_PLACES_API_KEY to .env.example with notes on which API to enable (Places API New / v1) and that keys should be restricted by IP/service rather than HTTP referrer since calls are server-side Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 15 +++ src/lib/providers/google-places/index.ts | 2 + src/lib/providers/google-places/provider.ts | 120 ++++++++++++++++++++ src/lib/providers/google-places/types.ts | 16 +++ src/services/googlePlacesService.ts | 40 +++++++ 5 files changed, 193 insertions(+) create mode 100644 src/lib/providers/google-places/index.ts create mode 100644 src/lib/providers/google-places/provider.ts create mode 100644 src/lib/providers/google-places/types.ts create mode 100644 src/services/googlePlacesService.ts diff --git a/.env.example b/.env.example index 02300cc..11c6e12 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,21 @@ MINISTRY_PLATFORM_BASE_URL=https://mpi.ministryplatform.com/ministryplatformapi MINISTRY_PLATFORM_DEV_CLIENT_ID= MINISTRY_PLATFORM_DEV_CLIENT_SECRET= +# ============================================================================= +# Google Places (optional) +# ============================================================================= +# Enables address autocomplete on the Address Line 1 field in the Add/Edit +# Family tool (and any other tool that uses GooglePlacesService). +# +# When unset, the tool falls back to a plain text input — no errors, no UI +# change beyond losing the suggestion dropdown. +# +# Get a key at https://console.cloud.google.com and enable the "Places API +# (New)" — the v1 REST endpoints. Calls go through our server actions, so +# restrict the key by IP / service in Google Cloud Console rather than by +# HTTP referrer. +GOOGLE_PLACES_API_KEY= + # ============================================================================= # NEXT Public Keys # ============================================================================= diff --git a/src/lib/providers/google-places/index.ts b/src/lib/providers/google-places/index.ts new file mode 100644 index 0000000..f1ae4ed --- /dev/null +++ b/src/lib/providers/google-places/index.ts @@ -0,0 +1,2 @@ +export { GooglePlacesProvider } from "./provider"; +export type { PlacePrediction, PlaceDetails } from "./types"; diff --git a/src/lib/providers/google-places/provider.ts b/src/lib/providers/google-places/provider.ts new file mode 100644 index 0000000..a6ad662 --- /dev/null +++ b/src/lib/providers/google-places/provider.ts @@ -0,0 +1,120 @@ +import type { PlacePrediction, PlaceDetails } from "./types"; + +const AUTOCOMPLETE_URL = "https://places.googleapis.com/v1/places:autocomplete"; +const PLACE_DETAILS_URL_BASE = "https://places.googleapis.com/v1/places"; + +interface AutocompleteResponse { + suggestions?: Array<{ + placePrediction?: { + placeId: string; + text?: { text?: string }; + structuredFormat?: { + mainText?: { text?: string }; + secondaryText?: { text?: string }; + }; + }; + }>; +} + +interface PlaceDetailsResponse { + id: string; + formattedAddress?: string; + addressComponents?: Array<{ + longText?: string; + shortText?: string; + types?: string[]; + }>; +} + +export class GooglePlacesProvider { + constructor(private readonly apiKey: string) { + if (!apiKey) { + throw new Error("GooglePlacesProvider requires a non-empty API key"); + } + } + + async autocomplete(input: string, sessionToken: string): Promise { + const trimmed = input.trim(); + if (trimmed.length < 3) return []; + + const res = await fetch(AUTOCOMPLETE_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Goog-Api-Key": this.apiKey, + "X-Goog-FieldMask": + "suggestions.placePrediction.placeId,suggestions.placePrediction.text,suggestions.placePrediction.structuredFormat", + }, + body: JSON.stringify({ + input: trimmed, + sessionToken, + includedPrimaryTypes: ["street_address", "premise", "subpremise"], + }), + }); + + if (!res.ok) { + const errText = await res.text(); + throw new Error(`Google Places autocomplete failed: ${res.status} ${errText}`); + } + + const data = (await res.json()) as AutocompleteResponse; + return (data.suggestions ?? []) + .map((s) => s.placePrediction) + .filter((p): p is NonNullable => Boolean(p?.placeId)) + .map((p) => ({ + placeId: p.placeId, + primary: p.structuredFormat?.mainText?.text ?? p.text?.text ?? "", + secondary: p.structuredFormat?.secondaryText?.text ?? "", + full: p.text?.text ?? "", + })); + } + + async getPlaceDetails(placeId: string, sessionToken: string): Promise { + const url = new URL(`${PLACE_DETAILS_URL_BASE}/${encodeURIComponent(placeId)}`); + url.searchParams.set("sessionToken", sessionToken); + + const res = await fetch(url.toString(), { + method: "GET", + headers: { + "X-Goog-Api-Key": this.apiKey, + "X-Goog-FieldMask": "id,formattedAddress,addressComponents", + }, + }); + + if (!res.ok) { + const errText = await res.text(); + throw new Error(`Google Places details failed: ${res.status} ${errText}`); + } + + const data = (await res.json()) as PlaceDetailsResponse; + const components = data.addressComponents ?? []; + + const pick = (type: string, prefer: "long" | "short" = "long"): string => { + const c = components.find((x) => x.types?.includes(type)); + if (!c) return ""; + return (prefer === "short" ? c.shortText : c.longText) ?? ""; + }; + + const streetNumber = pick("street_number"); + const route = pick("route"); + const addressLine1 = [streetNumber, route].filter(Boolean).join(" ").trim(); + const city = + pick("locality") || + pick("postal_town") || + pick("sublocality_level_1") || + pick("administrative_area_level_2"); + const state = pick("administrative_area_level_1", "short"); + const postalCode = pick("postal_code"); + const countryCode = pick("country", "short"); + + return { + placeId: data.id, + formattedAddress: data.formattedAddress ?? "", + addressLine1, + city, + state, + postalCode, + countryCode, + }; + } +} diff --git a/src/lib/providers/google-places/types.ts b/src/lib/providers/google-places/types.ts new file mode 100644 index 0000000..ae69026 --- /dev/null +++ b/src/lib/providers/google-places/types.ts @@ -0,0 +1,16 @@ +export interface PlacePrediction { + placeId: string; + primary: string; + secondary: string; + full: string; +} + +export interface PlaceDetails { + placeId: string; + formattedAddress: string; + addressLine1: string; + city: string; + state: string; + postalCode: string; + countryCode: string; +} diff --git a/src/services/googlePlacesService.ts b/src/services/googlePlacesService.ts new file mode 100644 index 0000000..d8d47e4 --- /dev/null +++ b/src/services/googlePlacesService.ts @@ -0,0 +1,40 @@ +import { GooglePlacesProvider } from "@/lib/providers/google-places"; +import type { PlacePrediction, PlaceDetails } from "@/lib/providers/google-places"; + +export class GooglePlacesService { + private static instance: GooglePlacesService; + private provider: GooglePlacesProvider | null = null; + + private constructor() {} + + public static async getInstance(): Promise { + if (!GooglePlacesService.instance) { + GooglePlacesService.instance = new GooglePlacesService(); + } + return GooglePlacesService.instance; + } + + public isEnabled(): boolean { + return Boolean(process.env.GOOGLE_PLACES_API_KEY); + } + + private getProvider(): GooglePlacesProvider { + if (this.provider) return this.provider; + const apiKey = process.env.GOOGLE_PLACES_API_KEY; + if (!apiKey) { + throw new Error( + "GOOGLE_PLACES_API_KEY is not configured. Add it to .env.local to enable address autocomplete.", + ); + } + this.provider = new GooglePlacesProvider(apiKey); + return this.provider; + } + + async autocomplete(input: string, sessionToken: string): Promise { + return this.getProvider().autocomplete(input, sessionToken); + } + + async getPlaceDetails(placeId: string, sessionToken: string): Promise { + return this.getProvider().getPlaceDetails(placeId, sessionToken); + } +} From 281c7eebc68dc2c9927b78a3c37281a2de524080 Mon Sep 17 00:00:00 2001 From: Chris Kehayias Date: Wed, 20 May 2026 09:45:02 -0400 Subject: [PATCH 2/2] feat(addeditfamily): add Add/Edit Family tool A new tool for creating and editing MP households end-to-end: search existing families, edit household + members + addresses, or start fresh from the search bar. Modeled on MP's own cloud Add/Edit Family but built on this project's MP REST + service pattern. DTOs (src/lib/dto/family.ts) - Household, FamilyMember, FamilyAddress + Zod schemas - SaveProgress / SavedMemberId for partial-failure tracking - emptyHousehold/emptyMember factories with defaults seeded Service (src/services/familyService.ts) - Singleton matching the existing service pattern - searchContacts: typeahead against Contacts joined to household address - getHousehold: single round-trip with FK-joined main + alt address columns and member rows (Participant_Record + Donor_Record joined for Participant_Type_ID and Envelope_No) - resolveContactIdFromPage: honors ToolParams.pageData.Contact_ID_Field so the tool works from any MP page whose metadata exposes the path back to a Contact_ID (donors, participants, custom pages, etc.) - getLookups: parallel fetch of every dropdown (congregations, sources, household positions, participant types, marital statuses, prefixes, suffixes, genders, contact statuses, primary languages, faith backgrounds, countries) + a hard-coded US states list (MP has no States table; MP's cloud tool also ships its own) - getNextEnvelopeNumber: MAX(Envelope_No) + 1 against Donors - saveHousehold: progress-aware multi-step upsert with race-hardened envelope assignment. Throws PartialSaveError carrying the progress so the UI can patch local state with real IDs even on partial failure -- retries don't duplicate Server actions (src/app/(web)/tools/addeditfamily/actions.ts) - Thin wrappers around the service with auth gating and unified ActionError shape that includes optional progress for the partial- failure path - placesEnabled/placeAutocomplete/placeDetails for Google Places UI (src/app/(web)/tools/addeditfamily/add-edit-family.tsx) - Single screen: search bar, household panel, address tabs (main/alt with season fields), member cards (compact + More toggle), Add Member button, Save/Close in ToolContainer footer - Donor toggle locks (with tooltip) when a Donor record exists; envelope # appears when Donor is on, with an "Assign Next Envelope #" button that fetches MAX+1 from the server - AddressLine1Autocomplete (Google Places) auto-enables when the key is configured server-side; falls back to a plain Input otherwise - Dirty-check on Close: snapshot the household on load/save, compare via JSON equality, show an AlertDialog before discarding - Save flow: applies progress (real contact/participant/donor/address IDs replace temp negatives) on both success and partial failure; re-loads from MP after success to show the normalized server view; surface errors via sonner toast. Envelope bumps from the race- hardening loop emit a separate warning toast naming the new number Page wiring (src/app/(web)/tools/addeditfamily/page.tsx) - Resolves recordID -> Contact_ID server-side using pageData metadata so launching the tool from a non-Households page lands on the right family without a round-trip Homepage - Adds an Add/Edit Family card to src/app/(web)/page.tsx Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/(web)/page.tsx | 14 + src/app/(web)/tools/addeditfamily/actions.ts | 142 ++ .../tools/addeditfamily/add-edit-family.tsx | 1239 +++++++++++++++++ src/app/(web)/tools/addeditfamily/page.tsx | 40 + src/lib/dto/family.ts | 190 +++ src/lib/dto/index.ts | 25 + src/services/familyService.ts | 726 ++++++++++ 7 files changed, 2376 insertions(+) create mode 100644 src/app/(web)/tools/addeditfamily/actions.ts create mode 100644 src/app/(web)/tools/addeditfamily/add-edit-family.tsx create mode 100644 src/app/(web)/tools/addeditfamily/page.tsx create mode 100644 src/lib/dto/family.ts create mode 100644 src/services/familyService.ts diff --git a/src/app/(web)/page.tsx b/src/app/(web)/page.tsx index 1048698..addfe12 100644 --- a/src/app/(web)/page.tsx +++ b/src/app/(web)/page.tsx @@ -79,6 +79,20 @@ export default function Home() { + + + + Add/Edit Family + + Add/Edit Family tool for Ministry Platform + + + + + + + + ); diff --git a/src/app/(web)/tools/addeditfamily/actions.ts b/src/app/(web)/tools/addeditfamily/actions.ts new file mode 100644 index 0000000..d70d856 --- /dev/null +++ b/src/app/(web)/tools/addeditfamily/actions.ts @@ -0,0 +1,142 @@ +"use server"; + +import { auth } from "@/lib/auth"; +import { headers } from "next/headers"; +import { FamilyService, PartialSaveError } from "@/services/familyService"; +import { GooglePlacesService } from "@/services/googlePlacesService"; +import { getCurrentUserIdFromSession } from "@/components/shared-actions/user"; +import type { + ContactSearchResult, + FamilyDefaults, + FamilyLookups, + Household, + SaveProgress, +} from "@/lib/dto/family"; +import type { PlacePrediction, PlaceDetails } from "@/lib/providers/google-places"; + +export type ActionError = { success: false; error: string; progress?: SaveProgress }; + +async function getSession() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) throw new Error("Unauthorized"); + return session; +} + +export async function searchContacts(term: string): Promise { + await getSession(); + const service = await FamilyService.getInstance(); + return service.searchContacts(term); +} + +export async function fetchFamilyLookups(): Promise { + await getSession(); + const service = await FamilyService.getInstance(); + return service.getLookups(); +} + +export async function fetchFamilyDefaults(): Promise { + await getSession(); + const service = await FamilyService.getInstance(); + return service.getDefaults(); +} + +export async function fetchHousehold( + contactId: number, +): Promise<{ success: true; household: Household } | ActionError> { + try { + await getSession(); + const service = await FamilyService.getInstance(); + const household = await service.getHousehold(contactId); + if (!household) return { success: false, error: "Household not found" }; + return { success: true, household }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Failed to load household", + }; + } +} + +export async function resolveContactIdFromPage(args: { + tableName: string; + primaryKey: string; + recordId: number; + contactIdField: string; +}): Promise<{ success: true; contactId: number | null } | ActionError> { + try { + await getSession(); + const service = await FamilyService.getInstance(); + const contactId = await service.resolveContactIdFromPage( + args.tableName, + args.primaryKey, + args.recordId, + args.contactIdField, + ); + return { success: true, contactId }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Failed to resolve contact", + }; + } +} + +export async function fetchNextEnvelopeNumber(): Promise { + await getSession(); + const service = await FamilyService.getInstance(); + return service.getNextEnvelopeNumber(); +} + +export async function placesEnabled(): Promise { + await getSession(); + const service = await GooglePlacesService.getInstance(); + return service.isEnabled(); +} + +export async function placeAutocomplete( + input: string, + sessionToken: string, +): Promise { + await getSession(); + if (input.trim().length < 3) return []; + const service = await GooglePlacesService.getInstance(); + if (!service.isEnabled()) return []; + return service.autocomplete(input, sessionToken); +} + +export async function placeDetails( + placeId: string, + sessionToken: string, +): Promise<{ success: true; details: PlaceDetails } | ActionError> { + try { + await getSession(); + const service = await GooglePlacesService.getInstance(); + const details = await service.getPlaceDetails(placeId, sessionToken); + return { success: true, details }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Failed to fetch place details", + }; + } +} + +export async function saveFamily( + household: Household, +): Promise<{ success: true; progress: SaveProgress } | ActionError> { + try { + const session = await getSession(); + const userId = await getCurrentUserIdFromSession(session); + const service = await FamilyService.getInstance(); + const progress = await service.saveHousehold(household, userId); + return { success: true, progress }; + } catch (error) { + if (error instanceof PartialSaveError) { + return { success: false, error: error.message, progress: error.progress }; + } + return { + success: false, + error: error instanceof Error ? error.message : "Failed to save family", + }; + } +} diff --git a/src/app/(web)/tools/addeditfamily/add-edit-family.tsx b/src/app/(web)/tools/addeditfamily/add-edit-family.tsx new file mode 100644 index 0000000..7b3211f --- /dev/null +++ b/src/app/(web)/tools/addeditfamily/add-edit-family.tsx @@ -0,0 +1,1239 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback, useMemo } from "react"; +import { useRouter } from "next/navigation"; +import { ToolContainer } from "@/components/tool"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, +} from "@/components/ui/command"; +import { ChevronsUpDown, Users, UserPlus, ChevronDown, ChevronRight } from "lucide-react"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { ToolParams, isNewRecord } from "@/lib/tool-params"; +import { + emptyHousehold, + emptyMember, + type Household, + type FamilyLookups, + type FamilyDefaults, + type FamilyMember, + type FamilyAddress, + type ContactSearchResult, + type SaveProgress, +} from "@/lib/dto/family"; +import { + searchContacts, + fetchFamilyLookups, + fetchFamilyDefaults, + fetchHousehold, + fetchNextEnvelopeNumber, + saveFamily, + placesEnabled, + placeAutocomplete, + placeDetails, +} from "./actions"; +import type { PlacePrediction } from "@/lib/providers/google-places"; + +interface AddEditFamilyProps { + params: ToolParams; + initialContactId?: number | null; +} + +export function AddEditFamily({ params, initialContactId }: AddEditFamilyProps) { + const router = useRouter(); + const [isSaving, setIsSaving] = useState(false); + const [lookups, setLookups] = useState(null); + const [defaults, setDefaults] = useState(null); + const [household, setHousehold] = useState(null); + const [originalSnapshot, setOriginalSnapshot] = useState(null); + const [placesOn, setPlacesOn] = useState(false); + const [loadError, setLoadError] = useState(null); + const [confirmCloseOpen, setConfirmCloseOpen] = useState(false); + const [addressTab, setAddressTab] = useState<"main" | "alt">("main"); + const [expandedMembers, setExpandedMembers] = useState>(new Set()); + const isNew = isNewRecord(params); + + const isDirty = useMemo( + () => household !== null && JSON.stringify(household) !== originalSnapshot, + [household, originalSnapshot], + ); + + useEffect(() => { + let cancelled = false; + Promise.all([fetchFamilyLookups(), fetchFamilyDefaults(), placesEnabled()]) + .then(([l, d, p]) => { + if (cancelled) return; + setLookups(l); + setDefaults(d); + setPlacesOn(p); + }) + .catch((err) => !cancelled && setLoadError(String(err))); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!defaults) return; + if (initialContactId && initialContactId > 0) { + loadHousehold(initialContactId); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialContactId, defaults]); + + const loadHousehold = useCallback(async (contactId: number) => { + const result = await fetchHousehold(contactId); + if (result.success) { + setHousehold(result.household); + setOriginalSnapshot(JSON.stringify(result.household)); + setExpandedMembers(new Set()); + } else { + setLoadError(result.error); + } + }, []); + + const startNewFamily = useCallback( + (lastName: string) => { + if (!defaults) return; + const seed = emptyHousehold(defaults, lastName); + setHousehold(seed); + setOriginalSnapshot(JSON.stringify(seed)); + setExpandedMembers(new Set([-1])); + }, + [defaults], + ); + + const applySaveProgress = useCallback((progress: SaveProgress) => { + setHousehold((h) => { + if (!h) return h; + const idMap = new Map(progress.members.map((m) => [m.tempContactId, m])); + return { + ...h, + householdId: progress.householdId ?? h.householdId, + address: { + ...h.address, + addressId: progress.mainAddressId ?? h.address.addressId, + }, + alternateMailingAddress: { + ...h.alternateMailingAddress, + addressId: progress.altAddressId ?? h.alternateMailingAddress.addressId, + }, + members: h.members.map((m) => { + const saved = idMap.get(m.contactId); + if (!saved) return m; + return { + ...m, + contactId: saved.contactId, + participant: m.participant + ? { ...m.participant, participantId: saved.participantId ?? m.participant.participantId } + : m.participant, + donorId: saved.donorId ?? m.donorId, + envelopeNo: saved.envelopeNo ?? m.envelopeNo, + }; + }), + }; + }); + }, []); + + const updateHousehold = (patch: Partial) => + setHousehold((h) => (h ? { ...h, ...patch } : h)); + + const updateAddress = (which: "main" | "alt", patch: Partial) => + setHousehold((h) => { + if (!h) return h; + return which === "main" + ? { ...h, address: { ...h.address, ...patch } } + : { ...h, alternateMailingAddress: { ...h.alternateMailingAddress, ...patch } }; + }); + + const updateMember = (contactId: number, patch: Partial) => + setHousehold((h) => { + if (!h) return h; + return { + ...h, + members: h.members.map((m) => (m.contactId === contactId ? { ...m, ...patch } : m)), + }; + }); + + const addMember = () => { + if (!household || !defaults) return; + const nextId = Math.min(0, ...household.members.map((m) => m.contactId)) - 1; + const newMember = emptyMember(nextId, household.householdName, defaults); + setHousehold({ ...household, members: [...household.members, newMember] }); + setExpandedMembers((set) => new Set([...set, nextId])); + }; + + const toggleExpanded = (contactId: number) => + setExpandedMembers((set) => { + const next = new Set(set); + if (next.has(contactId)) next.delete(contactId); + else next.add(contactId); + return next; + }); + + const handleAssignEnvelope = async (contactId: number) => { + try { + const next = await fetchNextEnvelopeNumber(); + updateMember(contactId, { envelopeNo: next, isDonor: true }); + } catch (err) { + toast.error( + `Failed to fetch next envelope #: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + }; + + const handleSave = async () => { + if (!household) return; + setIsSaving(true); + try { + const result = await saveFamily(household); + if (result.success) { + applySaveProgress(result.progress); + const reloadContactId = + result.progress.members.find((m) => m.contactId > 0)?.contactId ?? null; + if (reloadContactId) { + const reload = await fetchHousehold(reloadContactId); + if (reload.success) { + setHousehold(reload.household); + setOriginalSnapshot(JSON.stringify(reload.household)); + } + } + const bumped = result.progress.members.filter( + (m) => m.envelopeBumped && m.envelopeNo !== null, + ); + if (bumped.length > 0) { + toast.warning( + `Envelope number was already taken — reassigned to ${bumped + .map((m) => `#${m.envelopeNo}`) + .join(", ")}`, + ); + } else { + toast.success("Family saved"); + } + } else { + if (result.progress) applySaveProgress(result.progress); + toast.error(`Save failed: ${result.error}`); + } + } catch (error) { + toast.error( + `Save failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } finally { + setIsSaving(false); + } + }; + + const handleClose = () => { + if (isDirty) { + setConfirmCloseOpen(true); + } else { + router.back(); + } + }; + + return ( + <> + +

Add/Edit Family

+

+ Search to find an existing household to edit, or click + New Family to create one. +

+ + } + onSave={handleSave} + onClose={handleClose} + isSaving={isSaving} + > +
+ {loadError && ( +
+ {loadError} +
+ )} + + + + {!household ? ( + + + Search to find an existing household, or click + New Family in the + search results to start a new one. + + + ) : lookups && defaults ? ( + <> + + +
+ {household.members.map((member, idx) => ( + updateHousehold({ areHeadsMarried: v })} + expanded={expandedMembers.has(member.contactId)} + onToggleExpanded={() => toggleExpanded(member.contactId)} + lookups={lookups} + onChange={(patch) => updateMember(member.contactId, patch)} + onAssignEnvelope={() => handleAssignEnvelope(member.contactId)} + /> + ))} + + +
+ + + ) : ( + + + Loading… + + + )} +
+
+ + + + Discard unsaved changes? + + You have unsaved changes to this household. Closing will discard them. + + + + Keep editing + { + setConfirmCloseOpen(false); + router.back(); + }} + > + Discard + + + + + + ); +} + +// ============================================================================ +// Search bar +// ============================================================================ + +interface FamilySearchBarProps { + onLoadExisting: (contactId: number) => void; + onCreateNew: (lastName: string) => void; + disabled?: boolean; +} + +function FamilySearchBar({ onLoadExisting, onCreateNew, disabled }: FamilySearchBarProps) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const debounceRef = useRef | null>(null); + + const doSearch = useCallback(async (term: string) => { + if (term.trim().length < 2) { + setResults([]); + return; + } + setIsSearching(true); + try { + setResults(await searchContacts(term)); + } catch { + setResults([]); + } finally { + setIsSearching(false); + } + }, []); + + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => doSearch(query), 300); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [query, doSearch]); + + return ( + + + + + + + + + + + + {isSearching && ( +
+ Searching… +
+ )} + {!isSearching && query.trim().length >= 2 && results.length === 0 && ( + No contacts found. + )} + {!isSearching && query.trim().length < 2 && ( +
+ Type at least 2 characters +
+ )} + {results.length > 0 && ( + + {results.map((r) => ( + { + onLoadExisting(r.contactId); + setOpen(false); + setQuery(""); + }} + > +
+ {r.displayName} + {r.detail && ( + {r.detail} + )} +
+
+ ))} +
+ )} + {query.trim().length >= 2 && ( + + { + onCreateNew(query.trim()); + setOpen(false); + setQuery(""); + }} + > + + + + New Family with last name “{query.trim()}” + + + + )} +
+
+
+
+
+
+ ); +} + +// ============================================================================ +// Household panel (household fields + address tabs) +// ============================================================================ + +interface HouseholdPanelProps { + household: Household; + lookups: FamilyLookups; + onChange: (patch: Partial) => void; + onAddressChange: (which: "main" | "alt", patch: Partial) => void; + addressTab: "main" | "alt"; + onAddressTabChange: (tab: "main" | "alt") => void; + placesOn: boolean; +} + +function HouseholdPanel({ + household, + lookups, + onChange, + onAddressChange, + addressTab, + onAddressTabChange, + placesOn, +}: HouseholdPanelProps) { + const currentAddress = + addressTab === "main" ? household.address : household.alternateMailingAddress; + + return ( + + +
+ + onChange({ householdName: e.target.value })} + /> + + + onChange({ congregationId: v })} + /> + + + onChange({ householdPhone: e.target.value })} + /> + + + onChange({ sourceId: v })} + /> + +
+ +
+
+ onAddressTabChange("main")} + > + Main Address + + onAddressTabChange("alt")} + > + Alt Address + +
+ +
+ + onAddressChange(addressTab, { countryCode: v })} + /> + + + {placesOn ? ( + onAddressChange(addressTab, { addressLine1: v })} + onSelect={(d) => + onAddressChange(addressTab, { + addressLine1: d.addressLine1 || currentAddress.addressLine1, + city: d.city || currentAddress.city, + state: d.state || currentAddress.state, + postalCode: d.postalCode || currentAddress.postalCode, + countryCode: d.countryCode || currentAddress.countryCode, + }) + } + /> + ) : ( + + onAddressChange(addressTab, { addressLine1: e.target.value }) + } + /> + )} + + + + onAddressChange(addressTab, { addressLine2: e.target.value }) + } + /> + + + onAddressChange(addressTab, { city: e.target.value })} + /> + + + onAddressChange(addressTab, { state: v })} + /> + + + + onAddressChange(addressTab, { postalCode: e.target.value }) + } + /> + + + {addressTab === "alt" && ( + <> + + + onChange({ seasonStart: e.target.value || null }) + } + /> + + + onChange({ seasonEnd: e.target.value || null })} + /> + +
+ onChange({ repeatsAnnually: Boolean(v) })} + /> + +
+ + )} +
+
+
+
+ ); +} + +// ============================================================================ +// Member card +// ============================================================================ + +interface MemberCardProps { + member: FamilyMember; + index: number; + isHead1: boolean; + isHead2: boolean; + areHeadsMarried: boolean; + onMarriedChange: (value: boolean) => void; + expanded: boolean; + onToggleExpanded: () => void; + lookups: FamilyLookups; + onChange: (patch: Partial) => void; + onAssignEnvelope: () => void; +} + +function MemberCard({ + member, + index, + isHead1, + isHead2, + areHeadsMarried, + onMarriedChange, + expanded, + onToggleExpanded, + lookups, + onChange, + onAssignEnvelope, +}: MemberCardProps) { + const title = isHead1 + ? "Head of House 1" + : isHead2 + ? "Head of House 2" + : `Family Member ${index + 1}`; + + return ( + + +
+
+ + {title} + {member.contactId > 0 && ( + #{member.contactId} + )} +
+ {isHead2 && ( +
+ onMarriedChange(Boolean(v))} + /> + +
+ )} +
+ +
+ + onChange({ genderId: v })} + allowClear + /> + + + onChange({ firstName: e.target.value })} + /> + + + onChange({ householdPositionId: v })} + /> + + + + onChange({ + participant: { + participantId: member.participant?.participantId ?? 0, + participantTypeId: v, + notes: member.participant?.notes ?? null, + }, + }) + } + /> + + + onChange({ emailAddress: e.target.value })} + /> + + + onChange({ mobilePhone: e.target.value })} + /> + +
+ + + + {expanded && ( +
+ + onChange({ prefixId: v })} + allowClear + /> + + + onChange({ nickname: e.target.value })} + /> + + + onChange({ middleName: e.target.value })} + /> + + + onChange({ maidenName: e.target.value })} + /> + + + onChange({ lastName: e.target.value })} + /> + + + onChange({ suffixId: v })} + allowClear + /> + + + onChange({ birthDate: e.target.value || null })} + /> + + + onChange({ maritalStatusId: v })} + allowClear + /> + + + onChange({ contactStatusId: v })} + /> + + + onChange({ primaryLanguageId: v || null })} + allowClear + /> + + + onChange({ faithBackgroundId: v || null })} + allowClear + /> + +
+
+ onChange({ bulkEmailOpt: Boolean(v) })} + /> + +
+ 0)} + onChange={(v) => + onChange({ isDonor: v, ...(v ? {} : { envelopeNo: null }) }) + } + /> +
+ {member.isDonor && ( +
+ + + onChange({ + envelopeNo: e.target.value ? Number(e.target.value) : null, + }) + } + /> + + +
+ )} +
+ )} +
+
+ ); +} + +// ============================================================================ +// Donor toggle (locked when a Donor record exists) +// ============================================================================ + +interface DonorToggleProps { + contactId: number; + isDonor: boolean; + hasExistingDonor: boolean; + onChange: (value: boolean) => void; +} + +function DonorToggle({ contactId, isDonor, hasExistingDonor, onChange }: DonorToggleProps) { + const checkbox = ( + { + if (hasExistingDonor) return; + onChange(Boolean(v)); + }} + /> + ); + + const row = ( +
+ {checkbox} + +
+ ); + + if (!hasExistingDonor) return row; + + return ( + + + {row} + + Donor record exists — removing donors is not supported from this tool. + + + + ); +} + +// ============================================================================ +// Address Line 1 autocomplete (Google Places — server-routed) +// ============================================================================ + +interface AddressLine1AutocompleteProps { + value: string; + onTextChange: (value: string) => void; + onSelect: (details: { + addressLine1: string; + city: string; + state: string; + postalCode: string; + countryCode: string; + }) => void; +} + +function AddressLine1Autocomplete({ + value, + onTextChange, + onSelect, +}: AddressLine1AutocompleteProps) { + const [predictions, setPredictions] = useState([]); + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [sessionToken, setSessionToken] = useState(() => crypto.randomUUID()); + const debounceRef = useRef | null>(null); + const justSelected = useRef(false); + const blurTimer = useRef | null>(null); + + useEffect(() => { + if (justSelected.current) { + justSelected.current = false; + return; + } + if (debounceRef.current) clearTimeout(debounceRef.current); + if (value.trim().length < 3) { + setPredictions([]); + return; + } + debounceRef.current = setTimeout(async () => { + setIsLoading(true); + try { + const results = await placeAutocomplete(value, sessionToken); + setPredictions(results); + } catch { + setPredictions([]); + } finally { + setIsLoading(false); + } + }, 300); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [value, sessionToken]); + + const handleSelect = async (p: PlacePrediction) => { + justSelected.current = true; + setIsOpen(false); + const result = await placeDetails(p.placeId, sessionToken); + setSessionToken(crypto.randomUUID()); + if (result.success) { + onSelect({ + addressLine1: result.details.addressLine1 || p.primary, + city: result.details.city, + state: result.details.state, + postalCode: result.details.postalCode, + countryCode: result.details.countryCode, + }); + } else { + onTextChange(p.full); + } + setPredictions([]); + }; + + return ( +
+ { + onTextChange(e.target.value); + setIsOpen(true); + }} + onFocus={() => setIsOpen(true)} + onBlur={() => { + blurTimer.current = setTimeout(() => setIsOpen(false), 150); + }} + /> + {isOpen && (predictions.length > 0 || isLoading) && ( +
+ {isLoading && ( +
Searching…
+ )} + {predictions.map((p) => ( + + ))} +
+ )} +
+ ); +} + +// ============================================================================ +// Small helpers +// ============================================================================ + +function Field({ + label, + required, + children, +}: { + label: string; + required?: boolean; + children: React.ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} + +function TabButton({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +interface LookupSelectProps { + value: number; + options: { id: number; name: string }[]; + onChange: (value: number) => void; + allowClear?: boolean; +} + +function LookupSelect({ value, options, onChange, allowClear }: LookupSelectProps) { + return ( + + ); +} + +function StateSelect({ + value, + options, + onChange, +}: { + value: string | null; + options: { code: string; name: string }[]; + onChange: (value: string) => void; +}) { + return ( + + ); +} + +function CountrySelect({ + value, + options, + onChange, +}: { + value: string | null; + options: { code: string; name: string }[]; + onChange: (value: string) => void; +}) { + const trimmed = useMemo(() => options.slice(0, 250), [options]); + return ( + + ); +} + +function dateInputValue(iso: string | null | undefined): string { + if (!iso) return ""; + return iso.split("T")[0]; +} diff --git a/src/app/(web)/tools/addeditfamily/page.tsx b/src/app/(web)/tools/addeditfamily/page.tsx new file mode 100644 index 0000000..8e46393 --- /dev/null +++ b/src/app/(web)/tools/addeditfamily/page.tsx @@ -0,0 +1,40 @@ +import { AddEditFamily } from "./add-edit-family"; +import { parseToolParams } from "@/lib/tool-params"; +import { FamilyService } from "@/services/familyService"; + +interface AddEditFamilyPageProps { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +} + +export default async function AddEditFamilyPage({ searchParams }: AddEditFamilyPageProps) { + const params = await parseToolParams(await searchParams); + + let initialContactId: number | null = null; + if ( + params.recordID && + params.recordID > 0 && + params.pageData?.Table_Name && + params.pageData?.Primary_Key && + params.pageData?.Contact_ID_Field + ) { + try { + const service = await FamilyService.getInstance(); + initialContactId = await service.resolveContactIdFromPage( + params.pageData.Table_Name, + params.pageData.Primary_Key, + params.recordID, + params.pageData.Contact_ID_Field, + ); + } catch (error) { + console.warn("Failed to resolve Contact_ID from page record:", error); + } + } + + return ; +} + +export async function generateMetadata() { + return { + title: "Add/Edit Family", + }; +} diff --git a/src/lib/dto/family.ts b/src/lib/dto/family.ts new file mode 100644 index 0000000..4452c73 --- /dev/null +++ b/src/lib/dto/family.ts @@ -0,0 +1,190 @@ +import { z } from "zod"; + +export const FamilyAddressSchema = z.object({ + addressId: z.number().int(), + addressLine1: z.string().nullable(), + addressLine2: z.string().nullable(), + city: z.string().nullable(), + state: z.string().nullable(), + region: z.string().nullable(), + postalCode: z.string(), + countryCode: z.string().nullable(), +}); + +export type FamilyAddress = z.infer; + +export const FamilyMemberParticipantSchema = z.object({ + participantId: z.number().int(), + participantTypeId: z.number().int(), + notes: z.string().nullable().optional(), +}); + +export type FamilyMemberParticipant = z.infer; + +export const FamilyMemberSchema = z.object({ + contactId: z.number().int(), + displayName: z.string().optional(), + firstName: z.string(), + middleName: z.string(), + maidenName: z.string(), + lastName: z.string(), + nickname: z.string(), + prefixId: z.number().int(), + suffixId: z.number().int(), + birthDate: z.string().nullable(), + genderId: z.number().int(), + maritalStatusId: z.number().int(), + mobilePhone: z.string(), + emailAddress: z.string(), + bulkEmailOpt: z.boolean(), + envelopeNo: z.number().int().nullable(), + contactStatusId: z.number().int(), + primaryLanguageId: z.number().int().nullable(), + faithBackgroundId: z.number().int().nullable(), + householdPositionId: z.number().int(), + participant: FamilyMemberParticipantSchema.nullable(), + donorId: z.number().int().nullable(), + isDonor: z.boolean(), +}); + +export type FamilyMember = z.infer; + +export const HouseholdSchema = z.object({ + householdId: z.number().int(), + householdName: z.string(), + householdPhone: z.string(), + congregationId: z.number().int(), + sourceId: z.number().int(), + address: FamilyAddressSchema, + alternateMailingAddress: FamilyAddressSchema, + seasonStart: z.string().nullable(), + seasonEnd: z.string().nullable(), + repeatsAnnually: z.boolean(), + areHeadsMarried: z.boolean(), + members: z.array(FamilyMemberSchema), +}); + +export type Household = z.infer; + +export interface LookupOption { + id: number; + name: string; +} + +export interface StateOption { + code: string; + name: string; +} + +export interface CountryOption { + code: string; + name: string; +} + +export interface FamilyLookups { + congregations: LookupOption[]; + sources: LookupOption[]; + householdPositions: LookupOption[]; + participantTypes: LookupOption[]; + maritalStatuses: LookupOption[]; + prefixes: LookupOption[]; + suffixes: LookupOption[]; + genders: LookupOption[]; + contactStatuses: LookupOption[]; + primaryLanguages: LookupOption[]; + faithBackgrounds: LookupOption[]; + states: StateOption[]; + countries: CountryOption[]; +} + +export interface FamilyDefaults { + congregationId: number; + sourceId: number; + countryCode: string; + state: string; + householdPositionId: number; + participantTypeId: number; + showEnvelopeNumbers: boolean; +} + +export interface ContactSearchResult { + contactId: number; + displayName: string; + detail: string; +} + +export interface SavedMemberId { + tempContactId: number; + contactId: number; + participantId: number | null; + donorId: number | null; + envelopeNo: number | null; + envelopeBumped: boolean; +} + +export interface SaveProgress { + mainAddressId: number | null; + altAddressId: number | null; + householdId: number | null; + members: SavedMemberId[]; +} + +export function emptyAddress(): FamilyAddress { + return { + addressId: 0, + addressLine1: null, + addressLine2: null, + city: null, + state: null, + region: null, + postalCode: "", + countryCode: null, + }; +} + +export function emptyMember(contactId: number, lastName: string, defaults: FamilyDefaults): FamilyMember { + return { + contactId, + firstName: "", + middleName: "", + maidenName: "", + lastName, + nickname: "", + prefixId: 0, + suffixId: 0, + birthDate: null, + genderId: 0, + maritalStatusId: 0, + mobilePhone: "", + emailAddress: "", + bulkEmailOpt: false, + envelopeNo: null, + contactStatusId: 1, + primaryLanguageId: null, + faithBackgroundId: null, + householdPositionId: defaults.householdPositionId, + participant: { participantId: 0, participantTypeId: defaults.participantTypeId, notes: null }, + donorId: null, + isDonor: false, + }; +} + +export function emptyHousehold(defaults: FamilyDefaults, lastName = ""): Household { + return { + householdId: 0, + householdName: lastName, + householdPhone: "", + congregationId: defaults.congregationId, + sourceId: defaults.sourceId, + address: { ...emptyAddress(), countryCode: defaults.countryCode, state: defaults.state }, + alternateMailingAddress: { ...emptyAddress(), countryCode: defaults.countryCode, state: defaults.state }, + seasonStart: null, + seasonEnd: null, + repeatsAnnually: false, + areHeadsMarried: false, + members: [ + emptyMember(-1, lastName, defaults), + emptyMember(-2, lastName, defaults), + ], + }; +} diff --git a/src/lib/dto/index.ts b/src/lib/dto/index.ts index b133351..812b38e 100644 --- a/src/lib/dto/index.ts +++ b/src/lib/dto/index.ts @@ -9,3 +9,28 @@ export type { } from './address-label.dto'; export { SERVICE_TYPES } from './address-label.dto'; + +export type { + FamilyAddress, + FamilyMember, + FamilyMemberParticipant, + Household, + FamilyLookups, + FamilyDefaults, + LookupOption, + StateOption, + CountryOption, + ContactSearchResult, + SavedMemberId, + SaveProgress, +} from './family'; + +export { + FamilyAddressSchema, + FamilyMemberSchema, + FamilyMemberParticipantSchema, + HouseholdSchema, + emptyAddress, + emptyMember, + emptyHousehold, +} from './family'; diff --git a/src/services/familyService.ts b/src/services/familyService.ts new file mode 100644 index 0000000..7e0b50f --- /dev/null +++ b/src/services/familyService.ts @@ -0,0 +1,726 @@ +import { MPHelper } from "@/lib/providers/ministry-platform"; +import { escapeFilterString, validatePositiveInt, validateColumnName } from "@/lib/validation"; +import type { + ContactSearchResult, + CountryOption, + FamilyDefaults, + FamilyLookups, + Household, + LookupOption, + StateOption, + FamilyMember, + SaveProgress, + SavedMemberId, +} from "@/lib/dto/family"; + +export class PartialSaveError extends Error { + constructor(public progress: SaveProgress, public underlying: unknown) { + super(underlying instanceof Error ? underlying.message : String(underlying)); + this.name = "PartialSaveError"; + } +} + +const DONOR_DEFAULTS = { + Statement_Frequency_ID: 1, // Quarterly + Statement_Type_ID: 1, // Individual + Statement_Method_ID: 1, // Postal Mail + Cancel_Envelopes: false, +}; + +const US_STATES: StateOption[] = [ + { code: "AL", name: "Alabama" }, { code: "AK", name: "Alaska" }, + { code: "AZ", name: "Arizona" }, { code: "AR", name: "Arkansas" }, + { code: "CA", name: "California" }, { code: "CO", name: "Colorado" }, + { code: "CT", name: "Connecticut" }, { code: "DE", name: "Delaware" }, + { code: "DC", name: "District of Columbia" }, { code: "FL", name: "Florida" }, + { code: "GA", name: "Georgia" }, { code: "HI", name: "Hawaii" }, + { code: "ID", name: "Idaho" }, { code: "IL", name: "Illinois" }, + { code: "IN", name: "Indiana" }, { code: "IA", name: "Iowa" }, + { code: "KS", name: "Kansas" }, { code: "KY", name: "Kentucky" }, + { code: "LA", name: "Louisiana" }, { code: "ME", name: "Maine" }, + { code: "MD", name: "Maryland" }, { code: "MA", name: "Massachusetts" }, + { code: "MI", name: "Michigan" }, { code: "MN", name: "Minnesota" }, + { code: "MS", name: "Mississippi" }, { code: "MO", name: "Missouri" }, + { code: "MT", name: "Montana" }, { code: "NE", name: "Nebraska" }, + { code: "NV", name: "Nevada" }, { code: "NH", name: "New Hampshire" }, + { code: "NJ", name: "New Jersey" }, { code: "NM", name: "New Mexico" }, + { code: "NY", name: "New York" }, { code: "NC", name: "North Carolina" }, + { code: "ND", name: "North Dakota" }, { code: "OH", name: "Ohio" }, + { code: "OK", name: "Oklahoma" }, { code: "OR", name: "Oregon" }, + { code: "PA", name: "Pennsylvania" }, { code: "RI", name: "Rhode Island" }, + { code: "SC", name: "South Carolina" }, { code: "SD", name: "South Dakota" }, + { code: "TN", name: "Tennessee" }, { code: "TX", name: "Texas" }, + { code: "UT", name: "Utah" }, { code: "VT", name: "Vermont" }, + { code: "VA", name: "Virginia" }, { code: "WA", name: "Washington" }, + { code: "WV", name: "West Virginia" }, { code: "WI", name: "Wisconsin" }, + { code: "WY", name: "Wyoming" }, + { code: "AS", name: "American Samoa" }, { code: "GU", name: "Guam" }, + { code: "MP", name: "Northern Mariana Islands" }, { code: "PR", name: "Puerto Rico" }, + { code: "VI", name: "U.S. Virgin Islands" }, + { code: "AA", name: "Armed Forces Americas" }, + { code: "AE", name: "Armed Forces Europe" }, + { code: "AP", name: "Armed Forces Pacific" }, +]; + +const DEFAULT_FAMILY_DEFAULTS: FamilyDefaults = { + congregationId: 1, + sourceId: 18, + countryCode: "US", + state: "", + householdPositionId: 1, + participantTypeId: 4, + showEnvelopeNumbers: true, +}; + +function toIsoOrNull(value: string | null | undefined): string | null { + if (!value) return null; + if (value.startsWith("0001-01-01")) return null; + return value; +} + +function toDatetime(value: string | null | undefined): string | null { + if (!value) return null; + if (value.includes("T")) return value; + return `${value}T00:00:00`; +} + +export class FamilyService { + private static instance: FamilyService; + private mp: MPHelper | null = null; + + private constructor() {} + + public static async getInstance(): Promise { + if (!FamilyService.instance) { + FamilyService.instance = new FamilyService(); + FamilyService.instance.mp = new MPHelper(); + } + return FamilyService.instance; + } + + async searchContacts(term: string): Promise { + const trimmed = term.trim(); + if (trimmed.length < 2) return []; + const escaped = escapeFilterString(trimmed); + + const rows = await this.mp!.getTableRecords<{ + Contact_ID: number; + Display_Name: string; + Email_Address: string | null; + Household_ID_TABLE_Address_ID_TABLE_Address_Line_1?: string | null; + }>({ + table: "Contacts", + select: [ + "Contact_ID", + "Display_Name", + "Email_Address", + "Household_ID_TABLE_Address_ID_TABLE.Address_Line_1", + ].join(", "), + filter: `Display_Name LIKE '${escaped}%' AND Contact_Status_ID = 1`, + orderBy: "Display_Name", + top: 25, + }); + + return rows.map((r) => ({ + contactId: r.Contact_ID, + displayName: r.Display_Name, + detail: + r.Email_Address ?? + r.Household_ID_TABLE_Address_ID_TABLE_Address_Line_1 ?? + "", + })); + } + + async resolveContactIdFromPage( + tableName: string, + primaryKey: string, + recordId: number, + contactIdField: string, + ): Promise { + validatePositiveInt(recordId); + validateColumnName(primaryKey); + const fkPath = contactIdField.trim(); + if (!fkPath) return null; + + const select = fkPath.includes("_TABLE") + ? `${fkPath} AS Resolved_Contact_ID` + : `${validateColumnName(fkPath)} AS Resolved_Contact_ID`; + + const rows = await this.mp!.getTableRecords<{ Resolved_Contact_ID: number | null }>({ + table: tableName, + select, + filter: `${primaryKey} = ${recordId}`, + top: 1, + }); + + const id = rows[0]?.Resolved_Contact_ID; + return id ? Number(id) : null; + } + + async getHousehold(contactId: number): Promise { + validatePositiveInt(contactId); + + const contactRows = await this.mp!.getTableRecords<{ + Contact_ID: number; + Household_ID: number | null; + }>({ + table: "Contacts", + select: "Contact_ID, Household_ID", + filter: `Contact_ID = ${contactId}`, + top: 1, + }); + + const householdId = contactRows[0]?.Household_ID; + if (!householdId) return null; + + const [households, members] = await Promise.all([ + this.mp!.getTableRecords>({ + table: "Households", + select: [ + "Households.Household_ID", + "Households.Household_Name", + "Households.Home_Phone", + "Households.Congregation_ID", + "Households.Household_Source_ID", + "Households.Address_ID", + "Households.Alternate_Mailing_Address", + "Households.Season_Start", + "Households.Season_End", + "Households.Repeats_Annually", + "Address_ID_TABLE.Address_Line_1 AS Addr1_Line1", + "Address_ID_TABLE.Address_Line_2 AS Addr1_Line2", + "Address_ID_TABLE.City AS Addr1_City", + 'Address_ID_TABLE."State/Region" AS Addr1_State', + "Address_ID_TABLE.Postal_Code AS Addr1_Postal", + "Address_ID_TABLE.Country_Code AS Addr1_Country", + "Alternate_Mailing_Address_TABLE.Address_Line_1 AS Addr2_Line1", + "Alternate_Mailing_Address_TABLE.Address_Line_2 AS Addr2_Line2", + "Alternate_Mailing_Address_TABLE.City AS Addr2_City", + 'Alternate_Mailing_Address_TABLE."State/Region" AS Addr2_State', + "Alternate_Mailing_Address_TABLE.Postal_Code AS Addr2_Postal", + "Alternate_Mailing_Address_TABLE.Country_Code AS Addr2_Country", + ].join(", "), + filter: `Households.Household_ID = ${householdId}`, + top: 1, + }), + this.mp!.getTableRecords>({ + table: "Contacts", + select: [ + "Contacts.Contact_ID", + "Contacts.Display_Name", + "Contacts.First_Name", + "Contacts.Middle_Name", + "Contacts.Last_Name", + "Contacts.Maiden_Name", + "Contacts.Nickname", + "Contacts.Prefix_ID", + "Contacts.Suffix_ID", + "Contacts.Date_of_Birth", + "Contacts.Gender_ID", + "Contacts.Marital_Status_ID", + "Contacts.Mobile_Phone", + "Contacts.Email_Address", + "Contacts.Bulk_Email_Opt_Out", + "Contacts.Contact_Status_ID", + "Contacts.Primary_Language_ID", + "Contacts.Faith_Background_ID", + "Contacts.Household_Position_ID", + "Contacts.Participant_Record", + "Contacts.Donor_Record", + "Participant_Record_TABLE.Participant_Type_ID AS Participant_Type_ID", + "Donor_Record_TABLE.Envelope_No AS Envelope_No", + ].join(", "), + filter: `Contacts.Household_ID = ${householdId}`, + orderBy: "Contacts.Household_Position_ID, Contacts.Date_of_Birth", + }), + ]); + + const h = households[0]; + if (!h) return null; + + const buildAddress = (prefix: "Addr1" | "Addr2", addressId: number) => ({ + addressId, + addressLine1: (h[`${prefix}_Line1`] as string | null) ?? null, + addressLine2: (h[`${prefix}_Line2`] as string | null) ?? null, + city: (h[`${prefix}_City`] as string | null) ?? null, + state: (h[`${prefix}_State`] as string | null) ?? null, + region: null, + postalCode: (h[`${prefix}_Postal`] as string | null) ?? "", + countryCode: (h[`${prefix}_Country`] as string | null) ?? null, + }); + + const householdMembers: FamilyMember[] = members.map((m) => ({ + contactId: m.Contact_ID as number, + displayName: (m.Display_Name as string) ?? "", + firstName: (m.First_Name as string) ?? "", + middleName: (m.Middle_Name as string) ?? "", + maidenName: (m.Maiden_Name as string) ?? "", + lastName: (m.Last_Name as string) ?? "", + nickname: (m.Nickname as string) ?? "", + prefixId: (m.Prefix_ID as number) ?? 0, + suffixId: (m.Suffix_ID as number) ?? 0, + birthDate: toIsoOrNull(m.Date_of_Birth as string | null), + genderId: (m.Gender_ID as number) ?? 0, + maritalStatusId: (m.Marital_Status_ID as number) ?? 0, + mobilePhone: (m.Mobile_Phone as string) ?? "", + emailAddress: (m.Email_Address as string) ?? "", + bulkEmailOpt: Boolean(m.Bulk_Email_Opt_Out), + envelopeNo: (m.Envelope_No as number | null) ?? null, + contactStatusId: (m.Contact_Status_ID as number) ?? 1, + primaryLanguageId: (m.Primary_Language_ID as number | null) ?? null, + faithBackgroundId: (m.Faith_Background_ID as number | null) ?? null, + householdPositionId: (m.Household_Position_ID as number) ?? 0, + participant: m.Participant_Record + ? { + participantId: m.Participant_Record as number, + participantTypeId: (m.Participant_Type_ID as number) ?? 0, + notes: null, + } + : null, + donorId: (m.Donor_Record as number | null) ?? null, + isDonor: Boolean(m.Donor_Record), + })); + + return { + householdId: h.Household_ID as number, + householdName: (h.Household_Name as string) ?? "", + householdPhone: (h.Home_Phone as string) ?? "", + congregationId: (h.Congregation_ID as number) ?? 0, + sourceId: (h.Household_Source_ID as number) ?? 0, + address: buildAddress("Addr1", (h.Address_ID as number | null) ?? 0), + alternateMailingAddress: buildAddress( + "Addr2", + (h.Alternate_Mailing_Address as number | null) ?? 0, + ), + seasonStart: toIsoOrNull(h.Season_Start as string | null), + seasonEnd: toIsoOrNull(h.Season_End as string | null), + repeatsAnnually: Boolean(h.Repeats_Annually), + areHeadsMarried: false, + members: householdMembers, + }; + } + + async getLookups(): Promise { + type Row = Record & Record; + const [ + congregations, + sources, + householdPositions, + participantTypes, + maritalStatuses, + prefixes, + suffixes, + genders, + contactStatuses, + primaryLanguages, + faithBackgrounds, + countries, + ] = await Promise.all([ + this.mp!.getTableRecords>({ + table: "Congregations", + select: "Congregation_ID, Congregation_Name", + filter: "End_Date IS NULL", + orderBy: "Congregation_Name", + }), + this.mp!.getTableRecords>({ + table: "Household_Sources", + select: "Household_Source_ID, Household_Source", + orderBy: "Household_Source", + }), + this.mp!.getTableRecords>({ + table: "Household_Positions", + select: "Household_Position_ID, Household_Position", + orderBy: "Household_Position_ID", + }), + this.mp!.getTableRecords>({ + table: "Participant_Types", + select: "Participant_Type_ID, Participant_Type", + orderBy: "Participant_Type", + }), + this.mp!.getTableRecords>({ + table: "Marital_Statuses", + select: "Marital_Status_ID, Marital_Status", + orderBy: "Marital_Status", + }), + this.mp!.getTableRecords>({ + table: "Prefixes", + select: "Prefix_ID, Prefix", + orderBy: "Prefix", + }), + this.mp!.getTableRecords>({ + table: "Suffixes", + select: "Suffix_ID, Suffix", + orderBy: "Suffix", + }), + this.mp!.getTableRecords>({ + table: "Genders", + select: "Gender_ID, Gender", + orderBy: "Gender_ID", + }), + this.mp!.getTableRecords>({ + table: "Contact_Statuses", + select: "Contact_Status_ID, Contact_Status", + orderBy: "Contact_Status_ID", + }), + this.mp!.getTableRecords>({ + table: "Primary_Languages", + select: "Primary_Language_ID, Primary_Language", + orderBy: "Primary_Language", + }), + this.mp!.getTableRecords>({ + table: "Faith_Backgrounds", + select: "Faith_Background_ID, Faith_Background", + orderBy: "Faith_Background", + }), + this.mp!.getTableRecords<{ Country_Code: string | null; Country: string | null }>({ + table: "Countries", + select: "Country_Code, Country", + filter: "Country_Code IS NOT NULL", + orderBy: "Country", + }), + ]); + + const mapLookup = ( + rows: Row[], + idKey: K, + nameKey: N, + ): LookupOption[] => + rows.map((r) => ({ id: r[idKey] as number, name: r[nameKey] as string })); + + const countryList: CountryOption[] = countries + .filter((c) => c.Country_Code && c.Country) + .map((c) => ({ code: c.Country_Code!, name: c.Country! })); + + return { + congregations: mapLookup(congregations, "Congregation_ID", "Congregation_Name"), + sources: mapLookup(sources, "Household_Source_ID", "Household_Source"), + householdPositions: mapLookup(householdPositions, "Household_Position_ID", "Household_Position"), + participantTypes: mapLookup(participantTypes, "Participant_Type_ID", "Participant_Type"), + maritalStatuses: mapLookup(maritalStatuses, "Marital_Status_ID", "Marital_Status"), + prefixes: mapLookup(prefixes, "Prefix_ID", "Prefix"), + suffixes: mapLookup(suffixes, "Suffix_ID", "Suffix"), + genders: mapLookup(genders, "Gender_ID", "Gender"), + contactStatuses: mapLookup(contactStatuses, "Contact_Status_ID", "Contact_Status"), + primaryLanguages: mapLookup(primaryLanguages, "Primary_Language_ID", "Primary_Language"), + faithBackgrounds: mapLookup(faithBackgrounds, "Faith_Background_ID", "Faith_Background"), + states: US_STATES, + countries: countryList, + }; + } + + getDefaults(): FamilyDefaults { + return { ...DEFAULT_FAMILY_DEFAULTS }; + } + + async getNextEnvelopeNumber(): Promise { + // MAX() returns a single row even on an empty table — both ORDER BY DESC + // TOP 1 and MAX() walk the index, but MAX is one round-trip with one row. + // Note: Donors has no Congregation_ID column, so envelope numbers are + // global across the MP instance (matches MP's own Add/Edit Family tool). + const rows = await this.mp!.getTableRecords<{ Highest: number | null }>({ + table: "Donors", + select: "MAX(Envelope_No) AS Highest", + filter: "Envelope_No IS NOT NULL", + }); + const highest = rows[0]?.Highest ?? 0; + return highest + 1; + } + + async saveHousehold(household: Household, userId: number): Promise { + const progress: SaveProgress = { + mainAddressId: null, + altAddressId: null, + householdId: household.householdId > 0 ? household.householdId : null, + members: [], + }; + + const wrap = async (fn: () => Promise): Promise => { + try { + return await fn(); + } catch (e) { + throw new PartialSaveError(progress, e); + } + }; + + progress.mainAddressId = await wrap(() => this.upsertAddress(household.address, userId)); + progress.altAddressId = household.alternateMailingAddress.addressLine1?.trim() + ? await wrap(() => this.upsertAddress(household.alternateMailingAddress, userId)) + : null; + + const householdPayload = { + Household_Name: household.householdName, + Home_Phone: household.householdPhone || null, + Congregation_ID: household.congregationId, + Household_Source_ID: household.sourceId, + Address_ID: progress.mainAddressId, + Alternate_Mailing_Address: progress.altAddressId, + Season_Start: toDatetime(household.seasonStart), + Season_End: toDatetime(household.seasonEnd), + Repeats_Annually: household.repeatsAnnually, + }; + + if (household.householdId === 0) { + const created = await wrap(() => + this.mp!.createTableRecords<{ Household_ID: number } & typeof householdPayload>( + "Households", + [householdPayload as { Household_ID: number } & typeof householdPayload], + { $select: "Household_ID", $userId: userId }, + ), + ); + progress.householdId = (created[0] as { Household_ID: number }).Household_ID; + } else { + await wrap(() => + this.mp!.updateTableRecords( + "Households", + [{ Household_ID: household.householdId, ...householdPayload }], + { partial: true, $userId: userId }, + ), + ); + progress.householdId = household.householdId; + } + + for (const member of household.members) { + const hasName = member.firstName.trim().length > 0; + if (!hasName && member.contactId < 0) continue; + const savedMember = await wrap(() => + this.saveMember(member, progress.householdId!, household.householdName, userId), + ); + progress.members.push(savedMember); + } + + return progress; + } + + private async upsertAddress( + address: Household["address"], + userId: number, + ): Promise { + const hasContent = + (address.addressLine1?.trim().length ?? 0) > 0 || + (address.postalCode?.trim().length ?? 0) > 0; + if (!hasContent) return null; + + const payload = { + Address_Line_1: address.addressLine1 ?? "", + Address_Line_2: address.addressLine2 ?? null, + City: address.city ?? null, + "State/Region": address.state ?? null, + Postal_Code: address.postalCode ?? null, + Country_Code: address.countryCode ?? null, + }; + + if (address.addressId && address.addressId > 0) { + await this.mp!.updateTableRecords( + "Addresses", + [{ Address_ID: address.addressId, ...payload }], + { partial: true, $userId: userId }, + ); + return address.addressId; + } + + const created = await this.mp!.createTableRecords<{ Address_ID: number } & typeof payload>( + "Addresses", + [payload as { Address_ID: number } & typeof payload], + { $select: "Address_ID", $userId: userId }, + ); + return (created[0] as { Address_ID: number }).Address_ID; + } + + private async saveMember( + member: FamilyMember, + householdId: number, + fallbackLastName: string, + userId: number, + ): Promise { + const lastName = member.lastName.trim() || fallbackLastName; + const displayName = `${lastName}, ${member.firstName.trim()}`.trim(); + + const contactPayload = { + Display_Name: displayName, + First_Name: member.firstName || null, + Middle_Name: member.middleName || null, + Last_Name: lastName, + Maiden_Name: member.maidenName || null, + Nickname: member.nickname || member.firstName || null, + Prefix_ID: member.prefixId || null, + Suffix_ID: member.suffixId || null, + Date_of_Birth: toDatetime(member.birthDate), + Gender_ID: member.genderId || null, + Marital_Status_ID: member.maritalStatusId || null, + Mobile_Phone: member.mobilePhone || null, + Email_Address: member.emailAddress || null, + Bulk_Email_Opt_Out: member.bulkEmailOpt, + Household_ID: householdId, + Household_Position_ID: member.householdPositionId, + Contact_Status_ID: member.contactStatusId || 1, + Primary_Language_ID: member.primaryLanguageId, + Faith_Background_ID: member.faithBackgroundId, + Company: false, + }; + + let contactId: number; + if (member.contactId > 0) { + await this.mp!.updateTableRecords( + "Contacts", + [{ Contact_ID: member.contactId, ...contactPayload }], + { partial: true, $userId: userId }, + ); + contactId = member.contactId; + } else { + const created = await this.mp!.createTableRecords<{ Contact_ID: number } & typeof contactPayload>( + "Contacts", + [contactPayload as { Contact_ID: number } & typeof contactPayload], + { $select: "Contact_ID", $userId: userId }, + ); + contactId = (created[0] as { Contact_ID: number }).Contact_ID; + } + + let participantId: number | null = member.participant?.participantId ?? null; + const participantTypeId = member.participant?.participantTypeId ?? 0; + if (participantTypeId > 0) { + if (participantId && participantId > 0) { + await this.mp!.updateTableRecords( + "Participants", + [ + { + Participant_ID: participantId, + Participant_Type_ID: participantTypeId, + }, + ], + { partial: true, $userId: userId }, + ); + } else { + const created = await this.mp!.createTableRecords<{ + Participant_ID: number; + Contact_ID: number; + Participant_Type_ID: number; + Participant_Start_Date: string; + }>( + "Participants", + [ + { + Contact_ID: contactId, + Participant_Type_ID: participantTypeId, + Participant_Start_Date: new Date().toISOString(), + } as { + Participant_ID: number; + Contact_ID: number; + Participant_Type_ID: number; + Participant_Start_Date: string; + }, + ], + { $select: "Participant_ID", $userId: userId }, + ); + participantId = (created[0] as { Participant_ID: number }).Participant_ID; + await this.mp!.updateTableRecords( + "Contacts", + [{ Contact_ID: contactId, Participant_Record: participantId }], + { partial: true, $userId: userId }, + ); + } + } + + let donorId: number | null = member.donorId; + let envelopeNo: number | null = member.envelopeNo; + let envelopeBumped = false; + if (member.isDonor) { + const result = await this.upsertDonor( + contactId, + member.donorId, + member.envelopeNo, + userId, + ); + donorId = result.donorId; + envelopeNo = result.envelopeNo; + envelopeBumped = result.bumped; + } + + return { + tempContactId: member.contactId, + contactId, + participantId, + donorId, + envelopeNo, + envelopeBumped, + }; + } + + /** + * Re-check envelope uniqueness against the Donors table to harden against + * a race where two clients pulled the same MAX+1. If the requested number + * is already taken by a different donor, bump to current MAX+1 and retry + * (up to 5 attempts to bound the cost in pathological cases). + * + * Returns the number that was actually safe to use, and whether it was + * bumped from the requested value. + */ + private async resolveUniqueEnvelopeNo( + requested: number, + excludeDonorId: number | null, + ): Promise<{ envelopeNo: number; bumped: boolean }> { + let candidate = requested; + for (let attempt = 0; attempt < 5; attempt++) { + const filter = + excludeDonorId && excludeDonorId > 0 + ? `Envelope_No = ${candidate} AND Donor_ID <> ${excludeDonorId}` + : `Envelope_No = ${candidate}`; + const conflicts = await this.mp!.getTableRecords<{ Donor_ID: number }>({ + table: "Donors", + select: "Donor_ID", + filter, + top: 1, + }); + if (conflicts.length === 0) { + return { envelopeNo: candidate, bumped: candidate !== requested }; + } + candidate = await this.getNextEnvelopeNumber(); + } + throw new Error( + `Could not find an available envelope number after 5 attempts (last tried: ${candidate}). ` + + `Another transaction may be assigning envelopes simultaneously — please retry.`, + ); + } + + private async upsertDonor( + contactId: number, + existingDonorId: number | null, + envelopeNo: number | null, + userId: number, + ): Promise<{ donorId: number; envelopeNo: number | null; bumped: boolean }> { + let finalEnvelopeNo: number | null = envelopeNo; + let bumped = false; + if (envelopeNo !== null && envelopeNo > 0) { + const resolved = await this.resolveUniqueEnvelopeNo(envelopeNo, existingDonorId); + finalEnvelopeNo = resolved.envelopeNo; + bumped = resolved.bumped; + } + + if (existingDonorId && existingDonorId > 0) { + await this.mp!.updateTableRecords( + "Donors", + [{ Donor_ID: existingDonorId, Envelope_No: finalEnvelopeNo }], + { partial: true, $userId: userId }, + ); + return { donorId: existingDonorId, envelopeNo: finalEnvelopeNo, bumped }; + } + + const payload = { + Contact_ID: contactId, + Envelope_No: finalEnvelopeNo, + Setup_Date: new Date().toISOString(), + ...DONOR_DEFAULTS, + }; + const created = await this.mp!.createTableRecords<{ Donor_ID: number } & typeof payload>( + "Donors", + [payload as { Donor_ID: number } & typeof payload], + { $select: "Donor_ID", $userId: userId }, + ); + const newDonorId = (created[0] as { Donor_ID: number }).Donor_ID; + await this.mp!.updateTableRecords( + "Contacts", + [{ Contact_ID: contactId, Donor_Record: newDonorId }], + { partial: true, $userId: userId }, + ); + return { donorId: newDonorId, envelopeNo: finalEnvelopeNo, bumped }; + } +}