Add unified field mapping UI for integrations

Adds a reusable FieldMappingEditor component to the integration
detail page that works across all providers (Monday.com, Google
Sheets, Airtable, Planning Center). Users can visually map card
fields (core + dynamic FormTemplate fields) to external columns.

- New GET /api/integrations/source-fields returns curated core
  card fields plus the org's default FormTemplate dynamic fields
  (prefixed with fieldData.)
- flattenCardFieldData helper hoists fieldData entries to top-
  level keys before pushCard so dynamic fields are mappable
- Monday provider now upserts: re-syncing a card updates the
  existing item instead of creating duplicates, and the
  mondayItemId is persisted back to the ResponseCard

Made-with: Cursor
This commit is contained in:
Randall Stillwell 2026-04-17 15:53:49 -05:00
parent a83c41694f
commit 3e0a4458fe
20 changed files with 1397 additions and 88 deletions

View file

@ -39,3 +39,16 @@ NEXT_PUBLIC_SITE_URL="https://echoocr.com"
# ─── Environment indicator ────────────────────────────────────
NEXT_PUBLIC_ENV=""
# ─── Integration OAuth credentials ────────────────────────────
# Planning Center Online — create an OAuth app at
# https://api.planningcenteronline.com/oauth/applications
# Redirect URI: {AUTH_URL}/api/integrations/oauth/planning_center/callback
PCO_CLIENT_ID=""
PCO_CLIENT_SECRET=""
# Google Sheets — create OAuth credentials in Google Cloud Console,
# enable the "Google Sheets API", and add the redirect URI:
# {AUTH_URL}/api/integrations/oauth/google_sheets/callback
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""

View file

@ -0,0 +1,163 @@
import Link from "next/link";
import { BookOpen, ExternalLink, Plug } from "lucide-react";
import { Header } from "@/components/layout/header";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { getAllProviders } from "@/lib/integrations/registry";
const CATEGORY_LABELS: Record<string, string> = {
chms: "Church Management",
project_mgmt: "Project Management",
spreadsheet: "Spreadsheet",
export: "Export",
webhook: "Webhook",
};
export const metadata = {
title: "Integration Documentation",
};
export default function IntegrationDocsPage() {
const providers = getAllProviders();
const providersWithGuides = providers.filter((p) => p.setupGuide);
return (
<div className="space-y-8">
<Header
title="Integration Documentation"
description="Step-by-step setup guides for every integration Echo OCR supports."
icon={BookOpen}
>
<Link href="/settings/integrations">
<Button variant="outline" size="sm">
<Plug className="mr-1.5 size-3.5" />
Manage Integrations
</Button>
</Link>
</Header>
{/* Table of contents */}
<Card className="glass-card">
<CardContent className="p-5">
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Jump to an integration
</p>
<nav className="flex flex-wrap gap-2">
{providersWithGuides.map((p) => (
<a
key={p.id}
href={`#${p.id}`}
className="inline-flex items-center gap-1.5 rounded-full border border-border/60 bg-muted/40 px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:border-primary/40 hover:bg-primary/10 hover:text-primary"
>
{p.name}
<Badge
variant="outline"
className="border-border/40 bg-background/60 text-[10px] text-muted-foreground"
>
{CATEGORY_LABELS[p.category] || p.category}
</Badge>
</a>
))}
</nav>
</CardContent>
</Card>
{/* Guides */}
<div className="space-y-10">
{providersWithGuides.map((provider) => {
const guide = provider.setupGuide;
if (!guide) return null;
return (
<section
key={provider.id}
id={provider.id}
className="scroll-mt-24 space-y-4"
>
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border/50 pb-3">
<div>
<h2 className="text-lg font-semibold tracking-tight">
{provider.name}
</h2>
<p className="mt-0.5 text-sm text-muted-foreground">
{provider.description}
</p>
</div>
<div className="flex items-center gap-2">
<Badge
variant="outline"
className="text-xs text-muted-foreground"
>
{CATEGORY_LABELS[provider.category] || provider.category}
</Badge>
{provider.supportsOAuth && (
<Badge
variant="outline"
className="border-primary/30 text-xs text-primary"
>
OAuth
</Badge>
)}
<Link
href={`/settings/integrations/new?provider=${provider.id}`}
>
<Button size="sm" variant="outline">
<Plug className="mr-1.5 size-3.5" />
Connect
</Button>
</Link>
</div>
</div>
<p className="text-sm text-muted-foreground">{guide.summary}</p>
<ol className="space-y-4">
{guide.steps.map((step, idx) => (
<li key={idx} className="flex gap-4">
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/15 text-sm font-bold text-primary">
{idx + 1}
</span>
<div className="min-w-0 flex-1 space-y-1 pt-0.5">
<p className="text-sm font-semibold">{step.title}</p>
<p className="text-sm leading-relaxed text-muted-foreground">
{step.body}
</p>
{step.linkUrl && (
<a
href={step.linkUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
{step.linkLabel || step.linkUrl}
<ExternalLink className="size-3" />
</a>
)}
</div>
</li>
))}
</ol>
{guide.docsUrl && (
<div className="flex items-center justify-between rounded-lg border border-border/40 bg-muted/30 px-4 py-3">
<p className="text-xs text-muted-foreground">
Full official reference
</p>
<a
href={guide.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
{guide.docsUrl}
<ExternalLink className="size-3" />
</a>
</div>
)}
</section>
);
})}
</div>
</div>
);
}

View file

@ -13,6 +13,7 @@ import {
XCircle,
RefreshCw,
Zap,
ArrowLeftRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -26,6 +27,22 @@ import {
CardTitle,
CardDescription,
} from "@/components/ui/card";
import {
FieldMappingEditor,
type SourceField,
type ExternalField,
type FieldMapping,
} from "@/components/integrations/field-mapping-editor";
import {
SetupGuidePanel,
type SetupGuide,
} from "@/components/integrations/setup-guide-panel";
type ProviderInfo = {
id: string;
name: string;
setupGuide?: SetupGuide;
};
type Integration = {
id: string;
@ -65,6 +82,11 @@ export default function IntegrationDetailPage() {
const [deleting, setDeleting] = React.useState(false);
const [confirmDelete, setConfirmDelete] = React.useState(false);
const [sourceFields, setSourceFields] = React.useState<SourceField[]>([]);
const [externalFields, setExternalFields] = React.useState<ExternalField[]>([]);
const [fieldsLoading, setFieldsLoading] = React.useState(true);
const [providerInfo, setProviderInfo] = React.useState<ProviderInfo | null>(null);
React.useEffect(() => {
fetch(`/api/integrations/${id}`)
.then((r) => r.json())
@ -75,6 +97,32 @@ export default function IntegrationDetailPage() {
.catch(() => setLoading(false));
}, [id]);
React.useEffect(() => {
if (!integration?.provider) return;
fetch("/api/integrations")
.then((r) => r.ok ? r.json() : { providers: [] })
.then((data) => {
const match = (data.providers || []).find(
(p: ProviderInfo) => p.id === integration.provider
);
if (match) setProviderInfo(match);
})
.catch(() => {});
}, [integration?.provider]);
React.useEffect(() => {
Promise.all([
fetch("/api/integrations/source-fields").then((r) => r.ok ? r.json() : { fields: [] }),
fetch(`/api/integrations/${id}/fields`).then((r) => r.ok ? r.json() : { fields: [] }),
])
.then(([sourceData, externalData]) => {
setSourceFields(sourceData.fields || []);
setExternalFields(externalData.fields || []);
setFieldsLoading(false);
})
.catch(() => setFieldsLoading(false));
}, [id]);
const handleSave = async () => {
if (!integration) return;
setSaving(true);
@ -87,6 +135,7 @@ export default function IntegrationDetailPage() {
enabled: integration.enabled,
config: integration.config,
triggerEvents: integration.triggerEvents,
fieldMapping: integration.fieldMapping ?? {},
}),
});
if (res.ok) {
@ -174,6 +223,10 @@ export default function IntegrationDetailPage() {
});
};
const handleMappingChange = (mapping: FieldMapping) => {
setIntegration((prev) => (prev ? { ...prev, fieldMapping: mapping } : prev));
};
const toggleTriggerEvent = (event: string) => {
setIntegration((prev) => {
if (!prev) return prev;
@ -266,6 +319,14 @@ export default function IntegrationDetailPage() {
)}
</div>
{/* Setup Guide */}
{providerInfo?.setupGuide && (
<SetupGuidePanel
providerName={providerInfo.name}
guide={providerInfo.setupGuide}
/>
)}
{/* Configuration */}
<Card className="glass-card">
<CardHeader>
@ -309,6 +370,42 @@ export default function IntegrationDetailPage() {
</CardContent>
</Card>
{/* Field Mapping — only if provider returns external fields */}
{(fieldsLoading || externalFields.length > 0) && (
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ArrowLeftRight className="size-4" />
Field Mapping
</CardTitle>
<CardDescription>
Match your card fields to {integration.name} columns. Only mapped
fields will be synced.
</CardDescription>
</CardHeader>
<CardContent>
<FieldMappingEditor
sourceFields={sourceFields}
externalFields={externalFields}
value={(integration.fieldMapping as FieldMapping) || {}}
onChange={handleMappingChange}
externalLabel={
integration.provider === "monday"
? "Monday column"
: integration.provider === "airtable"
? "Airtable field"
: integration.provider === "google_sheets"
? "Sheet column"
: integration.provider === "planning_center"
? "PCO field"
: "External field"
}
loading={fieldsLoading}
/>
</CardContent>
</Card>
)}
{/* Trigger Events */}
<Card className="glass-card">
<CardHeader>

View file

@ -14,6 +14,7 @@ import {
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { SetupGuidePanel, type SetupGuide } from "@/components/integrations/setup-guide-panel";
type ProviderInfo = {
id: string;
@ -21,6 +22,7 @@ type ProviderInfo = {
description: string;
configFields: ConfigField[];
supportsOAuth: boolean;
setupGuide?: SetupGuide;
};
type ConfigField = {
@ -176,6 +178,14 @@ function NewIntegrationForm() {
</div>
</div>
) : (
<div className="space-y-4">
{provider?.setupGuide && (
<SetupGuidePanel
providerName={provider.name}
guide={provider.setupGuide}
defaultOpen
/>
)}
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
@ -289,6 +299,7 @@ function NewIntegrationForm() {
</form>
</CardContent>
</Card>
</div>
)}
</div>
);

View file

@ -11,6 +11,7 @@ import {
XCircle,
Clock,
ExternalLink,
BookOpen,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@ -102,6 +103,25 @@ export default function IntegrationsPage() {
return (
<div className="space-y-6">
{/* Docs banner */}
<Link href="/docs/integrations" className="group block">
<Card className="glass-card border-primary/20 transition-colors group-hover:border-primary/40">
<CardContent className="flex items-center gap-3 p-4">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<BookOpen className="size-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">Integration setup guides</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Step-by-step instructions for connecting every integration
Echo OCR supports.
</p>
</div>
<ExternalLink className="size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</CardContent>
</Card>
</Link>
{/* Connected Integrations */}
{integrations.length > 0 && (
<div className="space-y-3">

View file

@ -30,7 +30,11 @@ export async function GET(
return NextResponse.json({ fields: [] });
}
const fields = await provider.getExternalFields(integration.config);
const configWithId = {
...(integration.config as Record<string, unknown>),
_integrationId: integration.id,
};
const fields = await provider.getExternalFields(configWithId);
return NextResponse.json({ fields });
} catch (error) {
console.error("[integrations/[id]/fields] error:", error);

View file

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import { getProvider } from "@/lib/integrations/registry";
import { flattenCardFieldData } from "@/lib/integrations";
import type { CardData } from "@/lib/integrations/types";
export async function POST(
@ -54,10 +55,16 @@ export async function POST(
let successCount = 0;
let failCount = 0;
const configWithId = {
...(integration.config as Record<string, unknown>),
_integrationId: integration.id,
};
for (const card of cards) {
const flat = flattenCardFieldData(card as unknown as Record<string, unknown>);
const result = await provider.pushCard(
card as unknown as CardData,
integration.config,
flat as unknown as CardData,
configWithId,
integration.fieldMapping
);
if (result.success) {

View file

@ -33,7 +33,11 @@ export async function POST(
);
}
const result = await provider.testConnection(integration.config);
const configWithId = {
...(integration.config as Record<string, unknown>),
_integrationId: integration.id,
};
const result = await provider.testConnection(configWithId);
await prisma.integration.update({
where: { id },

View file

@ -24,6 +24,7 @@ export async function GET() {
supportsOAuth: p.supportsOAuth,
supportsFieldMapping: p.supportsFieldMapping,
configFields: p.configFields,
setupGuide: p.setupGuide,
}));
return NextResponse.json({ integrations, providers });

View file

@ -0,0 +1,79 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
export type SourceField = {
key: string;
label: string;
type: string;
group: "core" | "custom";
};
const CORE_FIELDS: SourceField[] = [
{ key: "firstName", label: "First Name", type: "text", group: "core" },
{ key: "lastName", label: "Last Name", type: "text", group: "core" },
{ key: "name", label: "Full Name", type: "text", group: "core" },
{ key: "email", label: "Email", type: "email", group: "core" },
{ key: "cellPhone", label: "Cell Phone", type: "phone", group: "core" },
{ key: "homePhone", label: "Home Phone", type: "phone", group: "core" },
{ key: "address", label: "Street Address", type: "text", group: "core" },
{ key: "aptNumber", label: "Apt / Suite", type: "text", group: "core" },
{ key: "city", label: "City", type: "text", group: "core" },
{ key: "state", label: "State", type: "text", group: "core" },
{ key: "zip", label: "ZIP Code", type: "text", group: "core" },
{ key: "gender", label: "Gender", type: "text", group: "core" },
{ key: "dateOfBirth", label: "Date of Birth", type: "date", group: "core" },
{ key: "maritalStatus", label: "Marital Status", type: "text", group: "core" },
{ key: "visitType", label: "Visit Type", type: "text", group: "core" },
{ key: "prayerRequests", label: "Prayer Requests", type: "textarea", group: "core" },
{ key: "prayerForTeam", label: "Prayer For Team", type: "checkbox", group: "core" },
{ key: "prayerConfidential", label: "Prayer Confidential", type: "checkbox", group: "core" },
{ key: "messageTopics", label: "Message Topics", type: "multiselect", group: "core" },
{ key: "nextStep", label: "Next Step", type: "multiselect", group: "core" },
{ key: "howHeard", label: "How Heard", type: "multiselect", group: "core" },
{ key: "serviceAttended", label: "Service Attended", type: "text", group: "core" },
{ key: "attendanceDuration", label: "Attendance Duration", type: "text", group: "core" },
{ key: "campusPreference", label: "Campus Preference", type: "text", group: "core" },
{ key: "followUp", label: "Follow Up Notes", type: "textarea", group: "core" },
{ key: "notes", label: "Notes", type: "textarea", group: "core" },
{ key: "firstTimeGuestDate", label: "First Time Guest Date", type: "date", group: "core" },
{ key: "salvationDate", label: "Salvation Date", type: "date", group: "core" },
{ key: "collectionDate", label: "Collection Date", type: "date", group: "core" },
{ key: "submissionSource", label: "Submission Source", type: "text", group: "core" },
{ key: "createdAt", label: "Created At", type: "date", group: "core" },
];
export async function GET() {
try {
const session = await requireApiAuthWithOrg();
const template = await prisma.formTemplate.findFirst({
where: {
organizationId: session.user.orgId!,
isDefault: true,
isActive: true,
},
include: {
fields: {
orderBy: { sortOrder: "asc" },
},
},
});
const coreKeys = new Set(CORE_FIELDS.map((f) => f.key));
const customFields: SourceField[] = (template?.fields ?? [])
.filter((f) => !coreKeys.has(f.key))
.map((f) => ({
key: `fieldData.${f.key}`,
label: f.label,
type: f.type,
group: "custom" as const,
}));
return NextResponse.json({
fields: [...CORE_FIELDS, ...customFields],
});
} catch (error) {
return handleApiError(error);
}
}

View file

@ -0,0 +1,254 @@
"use client";
import * as React from "react";
import { ArrowRight, Plus, Trash2, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export type SourceField = {
key: string;
label: string;
type: string;
group: "core" | "custom";
};
export type ExternalField = {
id: string;
name: string;
type?: string;
};
export type FieldMapping = Record<string, string>;
interface FieldMappingEditorProps {
sourceFields: SourceField[];
externalFields: ExternalField[];
value: FieldMapping;
onChange: (mapping: FieldMapping) => void;
externalLabel?: string;
loading?: boolean;
}
type Row = {
id: string;
sourceKey: string;
externalId: string;
};
function normalize(s: string): string {
return s.toLowerCase().replace(/[^a-z0-9]/g, "");
}
function autoMatch(
sourceFields: SourceField[],
externalFields: ExternalField[]
): FieldMapping {
const mapping: FieldMapping = {};
const usedExternal = new Set<string>();
for (const sf of sourceFields) {
const normalizedSource = normalize(sf.key.replace(/^fieldData\./, ""));
const normalizedLabel = normalize(sf.label);
const match = externalFields.find((ef) => {
if (usedExternal.has(ef.id)) return false;
const nName = normalize(ef.name);
return nName === normalizedSource || nName === normalizedLabel;
});
if (match) {
mapping[sf.key] = match.id;
usedExternal.add(match.id);
}
}
return mapping;
}
export function FieldMappingEditor({
sourceFields,
externalFields,
value,
onChange,
externalLabel = "External Field",
loading = false,
}: FieldMappingEditorProps) {
const rows: Row[] = React.useMemo(() => {
const entries = Object.entries(value).filter(
([k]) => !k.startsWith("_")
);
return entries.map(([sourceKey, externalId], i) => ({
id: `${sourceKey}-${i}`,
sourceKey,
externalId,
}));
}, [value]);
const updateMapping = (newRows: Row[]) => {
const next: FieldMapping = {};
for (const r of newRows) {
if (r.sourceKey && r.externalId) {
next[r.sourceKey] = r.externalId;
}
}
onChange(next);
};
const handleAdd = () => {
updateMapping([...rows, { id: `new-${Date.now()}`, sourceKey: "", externalId: "" }]);
};
const handleRemove = (idx: number) => {
const next = rows.filter((_, i) => i !== idx);
updateMapping(next);
};
const handleChangeRow = (
idx: number,
field: "sourceKey" | "externalId",
val: string | null
) => {
const next = rows.map((r, i) => (i === idx ? { ...r, [field]: val ?? "" } : r));
updateMapping(next);
};
const handleAutoSuggest = () => {
onChange(autoMatch(sourceFields, externalFields));
};
const coreFields = sourceFields.filter((f) => f.group === "core");
const customFields = sourceFields.filter((f) => f.group === "custom");
if (loading) {
return (
<p className="text-sm text-muted-foreground">
Loading available fields
</p>
);
}
if (externalFields.length === 0) {
return (
<div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
Connect the integration first to load available fields from the external
system.
</div>
);
}
return (
<div className="space-y-3">
{rows.length === 0 && (
<div className="rounded-lg border border-dashed p-6 text-center">
<p className="text-sm text-muted-foreground">
No field mappings configured yet.
</p>
<p className="mt-1 text-xs text-muted-foreground">
Add mappings to control which card fields sync to the external
system.
</p>
</div>
)}
{rows.map((row, idx) => (
<div
key={row.id}
className="flex items-center gap-2 rounded-lg border bg-card/40 p-2"
>
<div className="flex-1">
<Select
value={row.sourceKey}
onValueChange={(v) => handleChangeRow(idx, "sourceKey", v)}
>
<SelectTrigger>
<SelectValue placeholder="Card field…" />
</SelectTrigger>
<SelectContent>
{coreFields.length > 0 && (
<SelectGroup>
<SelectLabel>Core Fields</SelectLabel>
{coreFields.map((f) => (
<SelectItem key={f.key} value={f.key}>
{f.label}
</SelectItem>
))}
</SelectGroup>
)}
{customFields.length > 0 && (
<SelectGroup>
<SelectLabel>Custom Fields</SelectLabel>
{customFields.map((f) => (
<SelectItem key={f.key} value={f.key}>
{f.label}
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
</div>
<ArrowRight className="size-4 shrink-0 text-muted-foreground" />
<div className="flex-1">
<Select
value={row.externalId}
onValueChange={(v) => handleChangeRow(idx, "externalId", v)}
>
<SelectTrigger>
<SelectValue placeholder={`${externalLabel}`} />
</SelectTrigger>
<SelectContent>
{externalFields.map((ef) => (
<SelectItem key={ef.id} value={ef.id}>
{ef.name}
{ef.type && (
<span className="ml-2 text-xs text-muted-foreground">
({ef.type})
</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleRemove(idx)}
className="shrink-0 text-muted-foreground hover:text-destructive"
aria-label="Remove mapping"
>
<Trash2 className="size-4" />
</Button>
</div>
))}
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleAdd} className="rounded-xl">
<Plus className="mr-1.5 size-3.5" />
Add Mapping
</Button>
{rows.length === 0 && externalFields.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleAutoSuggest}
className="rounded-xl"
>
<Sparkles className="mr-1.5 size-3.5" />
Auto-match by name
</Button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,113 @@
"use client";
import * as React from "react";
import { ChevronDown, ChevronRight, BookOpen, ExternalLink } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
export type SetupStep = {
title: string;
body: string;
linkUrl?: string;
linkLabel?: string;
};
export type SetupGuide = {
summary: string;
steps: SetupStep[];
docsUrl?: string;
};
type SetupGuidePanelProps = {
providerName: string;
guide: SetupGuide;
defaultOpen?: boolean;
className?: string;
};
export function SetupGuidePanel({
providerName,
guide,
defaultOpen = false,
className,
}: SetupGuidePanelProps) {
const [open, setOpen] = React.useState(defaultOpen);
return (
<Card className={cn("glass-card border-primary/20", className)}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-3 px-5 py-4 text-left transition-colors hover:bg-primary/5"
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<BookOpen className="size-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">
Setup instructions for {providerName}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{guide.steps.length} steps ·{" "}
{open ? "Click to collapse" : "Click to expand"}
</p>
</div>
{open ? (
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
)}
</button>
{open && (
<CardContent className="space-y-4 border-t border-border/50 px-5 pb-5 pt-4">
<p className="text-sm text-muted-foreground">{guide.summary}</p>
<ol className="space-y-4">
{guide.steps.map((step, idx) => (
<li key={idx} className="flex gap-3">
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary/15 text-xs font-bold text-primary">
{idx + 1}
</span>
<div className="min-w-0 flex-1 space-y-1">
<p className="text-sm font-medium">{step.title}</p>
<p className="text-xs leading-relaxed text-muted-foreground">
{step.body}
</p>
{step.linkUrl && (
<a
href={step.linkUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
{step.linkLabel || step.linkUrl}
<ExternalLink className="size-3" />
</a>
)}
</div>
</li>
))}
</ol>
{guide.docsUrl && (
<div className="flex items-center justify-between border-t border-border/50 pt-4">
<p className="text-xs text-muted-foreground">
Need more detail?
</p>
<a
href={guide.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
Official documentation
<ExternalLink className="size-3" />
</a>
</div>
)}
</CardContent>
)}
</Card>
);
}

View file

@ -12,6 +12,24 @@ import { sendWebhook } from "./webhook";
import { getProvider } from "./integrations/registry";
import type { CardData } from "./integrations/types";
/**
* Flattens a card's fieldData JSON column into top-level keys prefixed
* with `fieldData.` so field mappings can reference dynamic template fields
* like any other column. Returns a new object; does not mutate input.
*/
export function flattenCardFieldData(
card: Record<string, unknown>
): Record<string, unknown> {
const flat = { ...card };
const fieldData = card.fieldData;
if (fieldData && typeof fieldData === "object" && !Array.isArray(fieldData)) {
for (const [k, v] of Object.entries(fieldData as Record<string, unknown>)) {
flat[`fieldData.${k}`] = v;
}
}
return flat;
}
export type IntegrationEvent =
| "ocr_complete"
| "ocr_error"
@ -31,6 +49,7 @@ export async function fireIntegrationEvent(
if (!card) return;
const cardData = card as unknown as Record<string, unknown>;
const flatCardData = flattenCardFieldData(cardData);
logActivityForEvent(event, cardId, cardData, extra?.oldCard).catch(() => {});
createNotificationForEvent(event, cardId, cardData).catch(() => {});
@ -51,10 +70,15 @@ export async function fireIntegrationEvent(
const provider = getProvider(integration.provider);
if (!provider) continue;
const configWithId = {
...(integration.config as Record<string, unknown>),
_integrationId: integration.id,
};
provider
.pushCard(
cardData as unknown as CardData,
integration.config,
flatCardData as unknown as CardData,
configWithId,
integration.fieldMapping
)
.then(async (result) => {

View file

@ -77,6 +77,42 @@ export const airtableProvider: IntegrationProvider = {
},
],
setupGuide: {
summary:
"Airtable uses Personal Access Tokens (PATs) with explicit scopes. You'll create a token scoped to the specific base you want Echo OCR to write rows into, then paste that token with the base ID and table name.",
docsUrl: "https://airtable.com/developers/web/guides/personal-access-tokens",
steps: [
{
title: "Create a Personal Access Token",
body: "Go to Airtable's developer hub and click \"Create token\". Give it a descriptive name like \"Echo OCR\".",
linkUrl: "https://airtable.com/create/tokens",
linkLabel: "Create a token",
},
{
title: "Add the required scopes",
body: "Under Scopes, add data.records:read and data.records:write. If you plan to let Echo OCR auto-detect your table schema, also add schema.bases:read.",
},
{
title: "Grant access to your base",
body: "Under \"Access\", add the specific base you want Echo OCR to write to. Avoid granting access to \"All workspaces\" — scope it narrowly.",
},
{
title: "Copy the token",
body: "Click Create token, then copy the token that appears (it starts with pat...). You won't be able to see it again after closing the dialog.",
},
{
title: "Find the base ID",
body: "Open the base in a browser and click Help → API documentation. The base ID is shown at the top of the API docs page and starts with \"app\".",
linkUrl: "https://airtable.com/developers/web/api/introduction",
linkLabel: "Airtable API introduction",
},
{
title: "Enter the table name",
body: "Use the exact table name as it appears in Airtable (case-sensitive) or the table ID. Echo OCR appends a new row for every card pushed.",
},
],
},
async testConnection(config: JsonValue): Promise<TestResult> {
try {
const { personalAccessToken, baseId, tableIdOrName } =

View file

@ -94,6 +94,29 @@ export const csvExportProvider: IntegrationProvider = {
},
],
setupGuide: {
summary:
"CSV / Excel export is always available — no external service or API keys are required. Echo OCR generates CSV or TSV rows from processed cards that you can download or pipe into downstream tools.",
steps: [
{
title: "Choose a format",
body: "CSV is the universal default. TSV (tab-separated) is friendlier for Excel — it opens cleanly without import-wizard prompts and avoids comma-escaping issues with prayer requests or address fields.",
},
{
title: "Decide whether to include headers",
body: "Leave \"Include column headers\" on for most uses. Turn it off only if you're concatenating rows onto an existing file that already has headers.",
},
{
title: "Export from the cards view",
body: "From the main Cards page, select the cards you want and choose Export. Echo OCR generates a single CSV/TSV file with all selected rows in the configured column order.",
},
{
title: "Columns included",
body: "By default Echo OCR includes: name, email, cellPhone, homePhone, address, city, state, zip, gender, dateOfBirth, maritalStatus, visitType, prayerRequests, followUp, notes, serviceAttended, firstTimeGuestDate, salvationDate.",
},
],
},
async testConnection(): Promise<TestResult> {
return {
success: true,

View file

@ -74,6 +74,56 @@ export const googleSheetsProvider: IntegrationProvider = {
},
],
setupGuide: {
summary:
"Google Sheets uses OAuth 2.0. You'll create a project in Google Cloud Console, enable the Sheets API, and register OAuth credentials. Then churches can click Connect to authorize Echo OCR against their own Google account.",
docsUrl: "https://developers.google.com/sheets/api/quickstart/js",
steps: [
{
title: "Create a Google Cloud project",
body: "Go to Google Cloud Console and create a new project (or select an existing one). Projects are free.",
linkUrl: "https://console.cloud.google.com/projectcreate",
linkLabel: "Create a project",
},
{
title: "Enable the Google Sheets API",
body: "Open the API Library and search for \"Google Sheets API\", then click Enable.",
linkUrl: "https://console.cloud.google.com/apis/library/sheets.googleapis.com",
linkLabel: "Enable Sheets API",
},
{
title: "Configure the OAuth consent screen",
body: "Under APIs & Services → OAuth consent screen, choose External, fill in the app name, user support email, and developer email. Add your domain under Authorized Domains.",
},
{
title: "Add scopes",
body: "On the Scopes step, add https://www.googleapis.com/auth/spreadsheets. This lets Echo OCR read sheet headers and append rows.",
},
{
title: "Create OAuth Client ID credentials",
body: "Under APIs & Services → Credentials, click Create Credentials → OAuth client ID. Choose \"Web application\".",
linkUrl: "https://console.cloud.google.com/apis/credentials",
linkLabel: "Open Credentials",
},
{
title: "Set the Authorized Redirect URI",
body: "Add https://YOUR_DOMAIN/api/integrations/oauth/google_sheets/callback (replace YOUR_DOMAIN with the URL where Echo OCR is hosted). For local dev, add http://localhost:3000/api/integrations/oauth/google_sheets/callback.",
},
{
title: "Copy the Client ID and Secret",
body: "After creating the client, copy the Client ID and Client Secret into Echo OCR's environment as GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET, then restart the server.",
},
{
title: "Find the Spreadsheet ID",
body: "Open the target Google Sheet in your browser. The URL looks like https://docs.google.com/spreadsheets/d/ABC123/edit — the ABC123 portion is your Spreadsheet ID.",
},
{
title: "Connect from the Integrations page",
body: "Click Connect next to Google Sheets in Echo OCR and sign in with the Google account that owns (or has edit access to) the spreadsheet.",
},
],
},
async testConnection(config: JsonValue): Promise<TestResult> {
try {
const { accessToken, spreadsheetId } = parseConfig(config);

View file

@ -5,6 +5,7 @@ import {
fetchBoardColumns,
mapCardToColumnValues,
} from "@/lib/monday";
import { prisma } from "@/lib/db";
interface MondayConfig {
apiToken: string;
@ -46,6 +47,36 @@ export const mondayProvider: IntegrationProvider = {
},
],
setupGuide: {
summary:
"Monday.com uses a personal API token for authentication. Generate a token from your Monday account, then paste it here along with the ID of the board you want cards pushed into.",
docsUrl: "https://developer.monday.com/api-reference/docs/authentication",
steps: [
{
title: "Open Monday.com → Developer Tokens",
body: "Click your avatar in the bottom-left corner, select \"Developers\", then open the \"My Access Tokens\" tab.",
linkUrl: "https://monday.com/developers/apps",
linkLabel: "Open developer console",
},
{
title: "Generate a personal API token",
body: "Click \"Show\" next to Personal API Token and copy it. This token has access to everything your Monday account can see — treat it like a password.",
},
{
title: "Find the board ID",
body: "Open the board you want to push cards into. The URL looks like https://yourorg.monday.com/boards/1234567890. The numeric portion at the end is your board ID.",
},
{
title: "Paste the token and board ID into Echo OCR",
body: "Enter both values below and click Test Connection. If the token is valid, Echo OCR will fetch the board's columns so you can map card fields to columns.",
},
{
title: "Configure column mapping (after connecting)",
body: "Once connected, open the integration's detail page to map card fields (name, email, phone, etc.) to the matching columns on your board.",
},
],
},
async testConnection(config: JsonValue): Promise<TestResult> {
try {
const { apiToken, boardId } = parseConfig(config);
@ -82,12 +113,41 @@ export const mondayProvider: IntegrationProvider = {
): Promise<PushResult> {
try {
const { apiToken, boardId, columnMap } = parseConfig(config);
const effectiveMap = (mapping as Record<string, unknown>) || columnMap || {};
const hasMapping =
mapping && typeof mapping === "object" && !Array.isArray(mapping) &&
Object.keys(mapping as Record<string, unknown>).length > 0;
const effectiveMap = hasMapping
? (mapping as Record<string, unknown>)
: (columnMap || {});
const cardData = card as unknown as Record<string, unknown>;
const columnValues = mapCardToColumnValues(cardData, effectiveMap);
const itemName = card.name || "Unnamed Card";
const itemName =
card.name ||
[card.firstName, card.lastName].filter(Boolean).join(" ") ||
"Unnamed Card";
const existingItemId =
typeof cardData.mondayItemId === "string" ? cardData.mondayItemId : null;
const cardId = typeof card.id === "string" ? card.id : null;
if (existingItemId) {
await updateItem(apiToken, boardId, existingItemId, columnValues);
return { success: true, externalId: existingItemId };
}
const itemId = await createItem(apiToken, boardId, itemName, columnValues);
if (cardId) {
try {
await prisma.responseCard.update({
where: { id: cardId },
data: { mondayItemId: itemId },
});
} catch (err) {
console.error("[monday] Failed to persist mondayItemId:", err);
}
}
return { success: true, externalId: itemId };
} catch (err) {
return {

View file

@ -6,8 +6,14 @@ import type {
ExternalField,
JsonValue,
} from "../types";
import { prisma } from "@/lib/db";
const PCO_API_BASE = "https://api.planningcenteronline.com/people/v2";
const PCO_TOKEN_URL = "https://api.planningcenteronline.com/oauth/token";
// PCO requires a descriptive User-Agent or it may return 403
const PCO_USER_AGENT = "EchoOCR/1.0 (+https://echoocr.com)";
// Refresh a token if it is within 5 minutes of expiring
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000;
interface PcoConfig {
accessToken: string;
@ -16,10 +22,12 @@ interface PcoConfig {
defaultListId?: string;
defaultWorkflowId?: string;
matchStrategy?: "email_first" | "name_first" | "manual";
// Injected by dispatch for token persistence — not user-facing
_integrationId?: string;
}
function parseConfig(config: JsonValue): PcoConfig {
const c = config as Record<string, unknown>;
const c = (config as Record<string, unknown>) || {};
return {
accessToken: (c.accessToken as string) || "",
refreshToken: (c.refreshToken as string) || undefined,
@ -28,22 +36,151 @@ function parseConfig(config: JsonValue): PcoConfig {
defaultWorkflowId: (c.defaultWorkflowId as string) || undefined,
matchStrategy:
(c.matchStrategy as PcoConfig["matchStrategy"]) || "email_first",
_integrationId: (c._integrationId as string) || undefined,
};
}
async function refreshAccessToken(
refreshToken: string,
integrationId?: string
): Promise<{ accessToken: string; refreshToken: string; expiresAt: number }> {
const clientId = process.env.PCO_CLIENT_ID;
const clientSecret = process.env.PCO_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw new Error(
"Planning Center OAuth is not configured on the server (missing PCO_CLIENT_ID / PCO_CLIENT_SECRET)"
);
}
const res = await fetch(PCO_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": PCO_USER_AGENT,
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: clientId,
client_secret: clientSecret,
}),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(
`Failed to refresh Planning Center token (${res.status}): ${text}`
);
}
const tokens = await res.json();
const expiresAt = Date.now() + (tokens.expires_in ?? 7200) * 1000;
const newAccess = tokens.access_token as string;
const newRefresh = (tokens.refresh_token as string) || refreshToken;
if (integrationId) {
try {
const integration = await prisma.integration.findUnique({
where: { id: integrationId },
});
if (integration) {
const existing = (integration.config as Record<string, unknown>) || {};
await prisma.integration.update({
where: { id: integrationId },
data: {
config: {
...existing,
accessToken: newAccess,
refreshToken: newRefresh,
tokenExpiresAt: expiresAt,
},
},
});
}
} catch (err) {
console.error("[pco] Failed to persist refreshed token:", err);
}
}
return {
accessToken: newAccess,
refreshToken: newRefresh,
expiresAt,
};
}
async function ensureFreshToken(cfg: PcoConfig): Promise<string> {
const needsRefresh =
cfg.tokenExpiresAt !== undefined &&
cfg.tokenExpiresAt - Date.now() < TOKEN_REFRESH_BUFFER_MS;
if (!needsRefresh) return cfg.accessToken;
if (!cfg.refreshToken) return cfg.accessToken;
try {
const refreshed = await refreshAccessToken(
cfg.refreshToken,
cfg._integrationId
);
cfg.accessToken = refreshed.accessToken;
cfg.refreshToken = refreshed.refreshToken;
cfg.tokenExpiresAt = refreshed.expiresAt;
return refreshed.accessToken;
} catch (err) {
console.error("[pco] Token refresh failed:", err);
return cfg.accessToken;
}
}
async function pcoFetch(
accessToken: string,
cfg: PcoConfig,
path: string,
options: RequestInit = {}
) {
options: RequestInit = {},
retriesLeft = 2
): Promise<{
data?: unknown;
included?: unknown;
meta?: { total_count?: number; [k: string]: unknown };
[k: string]: unknown;
}> {
const accessToken = await ensureFreshToken(cfg);
const res = await fetch(`${PCO_API_BASE}${path}`, {
...options,
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": PCO_USER_AGENT,
...(options.headers || {}),
},
});
// Handle rate limiting (429) with retry
if (res.status === 429 && retriesLeft > 0) {
const retryAfter =
Number(res.headers.get("Retry-After")) ||
Number(res.headers.get("X-PCO-API-Request-Rate-Period")) ||
20;
await new Promise((r) => setTimeout(r, retryAfter * 1000));
return pcoFetch(cfg, path, options, retriesLeft - 1);
}
// Handle expired token (401) by forcing a refresh and retrying once
if (res.status === 401 && retriesLeft > 0 && cfg.refreshToken) {
try {
const refreshed = await refreshAccessToken(
cfg.refreshToken,
cfg._integrationId
);
cfg.accessToken = refreshed.accessToken;
cfg.refreshToken = refreshed.refreshToken;
cfg.tokenExpiresAt = refreshed.expiresAt;
return pcoFetch(cfg, path, options, retriesLeft - 1);
} catch {
// fall through to error
}
}
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`PCO API ${res.status}: ${text}`);
@ -52,32 +189,29 @@ async function pcoFetch(
}
async function findPersonByEmail(
accessToken: string,
cfg: PcoConfig,
email: string
): Promise<string | null> {
const data = await pcoFetch(
accessToken,
cfg,
`/emails?where[address]=${encodeURIComponent(email)}&include=person`
);
const included = data.included;
if (included && included.length > 0) {
return included[0].id;
}
const included = (data.included as Array<{ id: string }> | undefined) || [];
if (included.length > 0) return included[0].id;
return null;
}
async function findPersonByName(
accessToken: string,
cfg: PcoConfig,
firstName: string,
lastName: string
): Promise<string | null> {
const data = await pcoFetch(
accessToken,
cfg,
`/people?where[first_name]=${encodeURIComponent(firstName)}&where[last_name]=${encodeURIComponent(lastName)}`
);
if (data.data && data.data.length > 0) {
return data.data[0].id;
}
const rows = (data.data as Array<{ id: string }> | undefined) || [];
if (rows.length > 0) return rows[0].id;
return null;
}
@ -92,11 +226,28 @@ function splitName(name: string): { firstName: string; lastName: string } {
};
}
// Maps Echo OCR card fields → PCO person attribute keys
const BUILT_IN_PERSON_FIELDS = new Set([
"first_name",
"last_name",
"gender",
"birthdate",
"membership",
"status",
"middle_name",
"nickname",
"given_name",
"anniversary",
"grade",
"graduation_year",
"medical_notes",
"child",
]);
export const planningCenterProvider: IntegrationProvider = {
id: "planning_center",
name: "Planning Center",
description:
"Sync people and response cards to Planning Center Online",
description: "Sync people and response cards to Planning Center Online",
icon: "planning_center",
category: "chms",
supportsFieldMapping: true,
@ -126,17 +277,57 @@ export const planningCenterProvider: IntegrationProvider = {
},
],
setupGuide: {
summary:
"Planning Center uses OAuth 2.0 so every church authorizes Echo OCR against their own PCO account. You'll register one OAuth application in your own Planning Center org; churches then click Connect and sign in.",
docsUrl: "https://developer.planning.center/docs/#/overview/authentication",
steps: [
{
title: "Create a Planning Center account (or use an existing one)",
body: "You need a Planning Center organization owned by you (not the church's). Planning Center organizations are free to create and are used solely to host the OAuth application that all churches will authorize against.",
linkUrl: "https://planningcenter.com/signup",
linkLabel: "Sign up at planningcenter.com",
},
{
title: "Register an OAuth application",
body: "Sign in to the developer console and create a new application. Only Organization Administrators can create OAuth apps.",
linkUrl: "https://api.planningcenteronline.com/oauth/applications",
linkLabel: "Open developer console",
},
{
title: "Configure the redirect URI",
body: "Set the redirect URI to https://YOUR_DOMAIN/api/integrations/oauth/planning_center/callback — replace YOUR_DOMAIN with the domain where Echo OCR is hosted. For local development, use http://localhost:3000/api/integrations/oauth/planning_center/callback.",
},
{
title: "Choose the scopes",
body: "At minimum, request the \"people\" scope. If you also want to add matched people to lists or trigger workflows, the people scope already covers those endpoints.",
},
{
title: "Copy the Client ID and Client Secret",
body: "After creating the application, copy the Application ID and Secret. Add them to Echo OCR's environment as PCO_CLIENT_ID and PCO_CLIENT_SECRET, then restart the server.",
},
{
title: "Connect from the Integrations page",
body: "Back in Echo OCR, click Connect on the Planning Center card and sign in with your church's PCO account. Echo OCR will store the access + refresh tokens and auto-refresh them every 2 hours.",
},
{
title: "(Optional) Set a default List or Workflow",
body: "To auto-add matched people to a PCO List, paste the list's numeric ID (from its URL) into \"Default List ID\". The same applies to workflows.",
},
],
},
async testConnection(config: JsonValue): Promise<TestResult> {
try {
const { accessToken } = parseConfig(config);
if (!accessToken) {
const cfg = parseConfig(config);
if (!cfg.accessToken) {
return {
success: false,
message:
"Not connected — use the Connect button to authorize with Planning Center",
};
}
const data = await pcoFetch(accessToken, "/people?per_page=1");
const data = await pcoFetch(cfg, "/people?per_page=1");
const total = data.meta?.total_count ?? "?";
return {
success: true,
@ -151,25 +342,31 @@ export const planningCenterProvider: IntegrationProvider = {
},
async getExternalFields(config: JsonValue): Promise<ExternalField[]> {
const { accessToken } = parseConfig(config);
const cfg = parseConfig(config);
const builtIn: ExternalField[] = [
{ id: "first_name", name: "First Name", type: "text" },
{ id: "last_name", name: "Last Name", type: "text" },
{ id: "middle_name", name: "Middle Name", type: "text" },
{ id: "nickname", name: "Nickname", type: "text" },
{ id: "gender", name: "Gender", type: "text" },
{ id: "birthdate", name: "Birthdate", type: "date" },
{ id: "anniversary", name: "Anniversary", type: "date" },
{ id: "membership", name: "Membership", type: "text" },
{ id: "status", name: "Status", type: "text" },
{ id: "medical_notes", name: "Medical Notes", type: "text" },
];
try {
const data = await pcoFetch(accessToken, "/field_definitions");
const custom = (data.data || []).map(
(fd: { id: string; attributes: { name: string; data_type: string } }) => ({
const data = await pcoFetch(cfg, "/field_definitions");
const rows =
(data.data as
| Array<{ id: string; attributes: { name: string; data_type: string } }>
| undefined) || [];
const custom = rows.map((fd) => ({
id: `custom_${fd.id}`,
name: fd.attributes.name,
type: fd.attributes.data_type,
})
);
}));
return [...builtIn, ...custom];
} catch {
return builtIn;
@ -179,11 +376,13 @@ export const planningCenterProvider: IntegrationProvider = {
async pushCard(
card: CardData,
config: JsonValue,
mapping: JsonValue
): Promise<PushResult> {
try {
const { accessToken, matchStrategy, defaultListId } =
parseConfig(config);
if (!accessToken) {
const cfg = parseConfig(config);
const fieldMap = (mapping as Record<string, string>) || {};
if (!cfg.accessToken) {
return { success: false, message: "Not connected to Planning Center" };
}
@ -192,30 +391,56 @@ export const planningCenterProvider: IntegrationProvider = {
? splitName(card.name)
: { firstName: "", lastName: "" };
if (matchStrategy === "email_first" || !matchStrategy) {
const strategy = cfg.matchStrategy || "email_first";
if (strategy === "email_first") {
if (card.email) {
personId = await findPersonByEmail(accessToken, card.email);
personId = await findPersonByEmail(cfg, card.email);
}
if (!personId && firstName) {
personId = await findPersonByName(accessToken, firstName, lastName);
personId = await findPersonByName(cfg, firstName, lastName);
}
} else if (matchStrategy === "name_first") {
} else if (strategy === "name_first") {
if (firstName) {
personId = await findPersonByName(accessToken, firstName, lastName);
personId = await findPersonByName(cfg, firstName, lastName);
}
if (!personId && card.email) {
personId = await findPersonByEmail(accessToken, card.email);
personId = await findPersonByEmail(cfg, card.email);
}
}
const personAttrs: Record<string, unknown> = {
first_name: firstName,
last_name: lastName,
};
if (card.gender) personAttrs.gender = card.gender;
// Build person attributes from the field mapping; fall back to defaults
const personAttrs: Record<string, unknown> = {};
const customFieldUpdates: Array<{ fieldDefinitionId: string; value: unknown }> = [];
const hasMapping = fieldMap && Object.keys(fieldMap).length > 0;
if (hasMapping) {
for (const [cardField, externalId] of Object.entries(fieldMap)) {
if (!externalId || cardField.startsWith("_")) continue;
const val = card[cardField];
if (val === null || val === undefined || val === "") continue;
if (externalId.startsWith("custom_")) {
customFieldUpdates.push({
fieldDefinitionId: externalId.replace(/^custom_/, ""),
value: typeof val === "object" ? JSON.stringify(val) : val,
});
} else if (BUILT_IN_PERSON_FIELDS.has(externalId)) {
personAttrs[externalId] = val;
}
}
}
// Always ensure first/last name are set so that created people aren't blank
if (!personAttrs.first_name && firstName) personAttrs.first_name = firstName;
if (!personAttrs.last_name && lastName) personAttrs.last_name = lastName;
if (!personAttrs.gender && card.gender) personAttrs.gender = card.gender;
if (!personAttrs.birthdate && card.dateOfBirth)
personAttrs.birthdate = card.dateOfBirth;
if (personId) {
await pcoFetch(accessToken, `/people/${personId}`, {
await pcoFetch(cfg, `/people/${personId}`, {
method: "PATCH",
body: JSON.stringify({
data: {
@ -226,7 +451,7 @@ export const planningCenterProvider: IntegrationProvider = {
}),
});
} else {
const createRes = await pcoFetch(accessToken, "/people", {
const createRes = await pcoFetch(cfg, "/people", {
method: "POST",
body: JSON.stringify({
data: {
@ -235,12 +460,41 @@ export const planningCenterProvider: IntegrationProvider = {
},
}),
});
personId = createRes.data.id;
personId = ((createRes.data as { id: string }) || {}).id;
}
if (card.email && personId) {
if (!personId) {
return { success: false, message: "Failed to create or match PCO person" };
}
// Write custom field values (if any were mapped)
for (const update of customFieldUpdates) {
try {
await pcoFetch(accessToken, `/people/${personId}/emails`, {
await pcoFetch(cfg, `/people/${personId}/field_data`, {
method: "POST",
body: JSON.stringify({
data: {
type: "FieldDatum",
attributes: { value: String(update.value) },
relationships: {
field_definition: {
data: {
type: "FieldDefinition",
id: update.fieldDefinitionId,
},
},
},
},
}),
});
} catch (err) {
console.error("[pco] custom field write failed:", err);
}
}
if (card.email) {
try {
await pcoFetch(cfg, `/people/${personId}/emails`, {
method: "POST",
body: JSON.stringify({
data: {
@ -258,12 +512,9 @@ export const planningCenterProvider: IntegrationProvider = {
}
}
if (card.cellPhone && personId) {
if (card.cellPhone) {
try {
await pcoFetch(
accessToken,
`/people/${personId}/phone_numbers`,
{
await pcoFetch(cfg, `/people/${personId}/phone_numbers`, {
method: "POST",
body: JSON.stringify({
data: {
@ -275,36 +526,94 @@ export const planningCenterProvider: IntegrationProvider = {
},
},
}),
}
);
});
} catch {
// phone may already exist
}
}
if (defaultListId && personId) {
if (card.homePhone) {
try {
await pcoFetch(
accessToken,
`/lists/${defaultListId}/people`,
{
await pcoFetch(cfg, `/people/${personId}/phone_numbers`, {
method: "POST",
body: JSON.stringify({
data: {
type: "PhoneNumber",
attributes: {
number: card.homePhone,
location: "Home",
primary: !card.cellPhone,
},
},
}),
});
} catch {
// phone may already exist
}
}
// Address sync
if (card.address || card.city || card.state || card.zip) {
try {
const street = [card.address, card.aptNumber]
.filter(Boolean)
.join(" ");
await pcoFetch(cfg, `/people/${personId}/addresses`, {
method: "POST",
body: JSON.stringify({
data: {
type: "Address",
attributes: {
street: street || "",
city: card.city || "",
state: card.state || "",
zip: card.zip || "",
location: "Home",
primary: true,
},
},
}),
});
} catch {
// address may already exist
}
}
if (cfg.defaultListId) {
try {
await pcoFetch(cfg, `/lists/${cfg.defaultListId}/people`, {
method: "POST",
body: JSON.stringify({
data: { type: "Person", id: personId },
}),
}
);
});
} catch {
// may already be on list
}
}
if (cfg.defaultWorkflowId) {
try {
await pcoFetch(cfg, `/workflows/${cfg.defaultWorkflowId}/cards`, {
method: "POST",
body: JSON.stringify({
data: {
type: "WorkflowCard",
relationships: {
person: { data: { type: "Person", id: personId } },
},
},
}),
});
} catch (err) {
console.error("[pco] workflow enqueue failed:", err);
}
}
return {
success: true,
externalId: personId ?? undefined,
message: personId
? `Synced to PCO person ${personId}`
: "Created in PCO",
externalId: personId,
message: `Synced to PCO person ${personId}`,
};
} catch (err) {
return {

View file

@ -38,6 +38,33 @@ export const webhookProvider: IntegrationProvider = {
},
],
setupGuide: {
summary:
"Webhooks let you POST card data to any URL when trigger events fire. Paste a URL that can receive JSON POSTs. Optionally add a signing secret so your endpoint can verify the request came from Echo OCR.",
steps: [
{
title: "Build or choose an endpoint",
body: "Any HTTPS URL that accepts POST requests with a JSON body will work. Common options: Zapier/Make webhooks, a serverless function, n8n, or your own backend route.",
},
{
title: "Expect this payload shape",
body: "Echo OCR POSTs { event: \"card_reviewed\" | \"ocr_complete\" | ..., data: { ...cardFields }, timestamp } as a JSON body. The Content-Type is application/json.",
},
{
title: "(Optional) Add a signing secret",
body: "If you provide a secret, Echo OCR computes an HMAC-SHA256 of the raw request body and sends it in the X-Echo-Signature header. Your endpoint can recompute the HMAC with the same secret to verify authenticity.",
},
{
title: "Test the connection",
body: "Click Test Connection to have Echo OCR POST a small { test: true } payload to your URL. Your endpoint should respond with a 2xx status.",
},
{
title: "Pick trigger events",
body: "On the integration's detail page, choose when webhooks fire — typically \"Card Reviewed\" or \"Card Exported\". Avoid firing on \"OCR Complete\" unless you explicitly want unreviewed data downstream.",
},
],
},
async testConnection(config: JsonValue): Promise<TestResult> {
try {
const { url, secret } = parseConfig(config);

View file

@ -62,6 +62,19 @@ export type ProviderCategory =
| "export"
| "webhook";
export interface SetupStep {
title: string;
body: string;
linkUrl?: string;
linkLabel?: string;
}
export interface SetupGuide {
summary: string;
steps: SetupStep[];
docsUrl?: string;
}
export interface IntegrationProvider {
id: string;
name: string;
@ -71,6 +84,7 @@ export interface IntegrationProvider {
configFields: ConfigField[];
supportsFieldMapping: boolean;
supportsOAuth: boolean;
setupGuide?: SetupGuide;
testConnection(config: JsonValue): Promise<TestResult>;
getExternalFields?(config: JsonValue): Promise<ExternalField[]>;