diff --git a/.github/workflows/block-pending-issue.yml b/.github/workflows/block-pending-issue.yml new file mode 100644 index 00000000..88d22db2 --- /dev/null +++ b/.github/workflows/block-pending-issue.yml @@ -0,0 +1,31 @@ +name: Block merge (pending #289) + +# DO NOT SUBMIT guard for membership-agreement e-signing. The feature cannot ship +# until the real agreement PDF lands (issue #289) and the Zoho Sign OAuth secrets +# (zoho-client-id / -client-secret / -refresh-token) are populated in AWS Secrets +# Manager. This job fails any PR that touches the signing code while #289 is still +# OPEN, and clears automatically the moment #289 is closed — no marker file to +# remember to remove. Delete this workflow once the feature has shipped. +on: + pull_request: + paths: + - 'checkin-app/src/lib/membership/contract/**' + - 'checkin-app/src/app/api/membership/contract/**' + +jobs: + pending-issue: + name: Block until #289 closed + runs-on: ubuntu-latest + steps: + - name: Fail while issue #289 is open + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + state="$(gh issue view 289 --repo "${{ github.repository }}" --json state --jq .state)" + echo "Issue #289 state: $state" + if [ "$state" != "CLOSED" ]; then + echo "::error::DO NOT SUBMIT — agreement signing is blocked until issue #289 is closed (real agreement PDF + Zoho AWS secrets). This check clears automatically when #289 closes." + exit 1 + fi + echo "Issue #289 is closed — agreement signing may ship." diff --git a/checkin-app/next.config.ts b/checkin-app/next.config.ts index 05bda145..122483ff 100644 --- a/checkin-app/next.config.ts +++ b/checkin-app/next.config.ts @@ -8,6 +8,13 @@ const nextConfig: NextConfig = { // those symlinks and bundles the linked code into the image (otherwise Next // infers the app dir as the trace root and ships dangling symlinks). outputFileTracingRoot: path.join(__dirname, '..'), + // The membership-agreement PDF is read from disk at runtime (fs.readFile), not + // imported, so Next's tracer won't bundle it on its own. Explicitly include the + // assets dir for the sign route so the file ships in the standalone image. + // (Dir is empty until the real agreement lands — issue #289.) + outputFileTracingIncludes: { + '/api/membership/contract/sign': ['./src/lib/membership/contract/assets/**'], + }, }; export default nextConfig; diff --git a/checkin-app/package.json b/checkin-app/package.json index 320314d5..b8c49101 100644 --- a/checkin-app/package.json +++ b/checkin-app/package.json @@ -33,6 +33,7 @@ "date-fns-tz": "^3.2.0", "next": "^16.1.6", "next-auth": "^4.24.13", + "pdf-lib": "^1.17.1", "pg": "^8.18.0", "qrcode": "^1.5.4", "qrcode.react": "^4.2.0", diff --git a/checkin-app/prisma/migrations/20260621000000_add_zoho_action_id/migration.sql b/checkin-app/prisma/migrations/20260621000000_add_zoho_action_id/migration.sql new file mode 100644 index 00000000..b0ac88ba --- /dev/null +++ b/checkin-app/prisma/migrations/20260621000000_add_zoho_action_id/migration.sql @@ -0,0 +1,5 @@ +-- Embedded Zoho Sign signing needs the recipient's action id (alongside the +-- existing request id in zohoEnvelopeId) to mint per-session embed tokens. The +-- signing request/document is created once and these two ids are stored so it is +-- never re-created. Additive, nullable column — no backfill. +ALTER TABLE "MembershipProcess" ADD COLUMN "zohoActionId" TEXT; diff --git a/checkin-app/prisma/schema.prisma b/checkin-app/prisma/schema.prisma index 823d2216..dfce9aca 100644 --- a/checkin-app/prisma/schema.prisma +++ b/checkin-app/prisma/schema.prisma @@ -305,6 +305,8 @@ model MembershipProcess { /// @sensitivity:internal zohoEnvelopeId String? /// @sensitivity:internal + zohoActionId String? + /// @sensitivity:internal contractSignedAt DateTime? /// @sensitivity:internal bgConsentAt DateTime? diff --git a/checkin-app/src/app/__tests__/membershipContractSignAPI.integration.test.ts b/checkin-app/src/app/__tests__/membershipContractSignAPI.integration.test.ts new file mode 100644 index 00000000..2bc0bbe9 --- /dev/null +++ b/checkin-app/src/app/__tests__/membershipContractSignAPI.integration.test.ts @@ -0,0 +1,131 @@ +/** + * @jest-environment node + */ +/** + * Integration tests for POST /api/membership/contract/sign — the applicant-facing + * "Sign your membership agreement" action. The Zoho client and the agreement-PDF + * loader are mocked (no real Zoho calls / no PDF on disk); the DB and the + * idempotent create-and-store logic are exercised for real. + */ + +import prisma from '@/lib/prisma'; +import { getServerSession } from 'next-auth/next'; +import * as zoho from '@/lib/membership/contract/zohoClient'; + +jest.mock('next-auth/next', () => ({ getServerSession: jest.fn() })); +jest.mock('@/lib/membership/contract/agreementDocument', () => ({ + ...jest.requireActual('@/lib/membership/contract/agreementDocument'), + loadAgreementPdf: jest.fn().mockResolvedValue({ pdf: Buffer.from('%PDF-1.4'), lastPageNo: 0, pageWidth: 612, pageHeight: 792 }), +})); +jest.mock('@/lib/membership/contract/zohoClient', () => ({ + ZohoError: class ZohoError extends Error {}, + getAccessToken: jest.fn().mockResolvedValue('tok'), + createRequest: jest.fn().mockResolvedValue({ requestId: 'REQ-1', actionId: 'ACT-1', documentId: 'DOC-1' }), + submitRequest: jest.fn().mockResolvedValue(undefined), + getEmbeddedSignUrl: jest.fn().mockResolvedValue('https://sign.zoho.com/embed/xyz'), +})); + +// Imported AFTER the mocks so the route picks up the mocked client. +import { POST as SIGN } from '@/app/api/membership/contract/sign/route'; + +const TAG = 'contract-sign-test'; + +function asUser(id: number) { + (getServerSession as jest.Mock).mockResolvedValue({ user: { id, sysadmin: false, boardMember: false } }); +} +function signReq() { + return new Request('http://localhost:4000/api/membership/contract/sign', { method: 'POST' }) as unknown as Parameters[0]; +} + +describe('POST /api/membership/contract/sign', () => { + let leadId: number; + let nonLeadId: number; + let processId: number; + const prevEnv = { id: process.env.ZOHO_CLIENT_ID, secret: process.env.ZOHO_CLIENT_SECRET, refresh: process.env.ZOHO_REFRESH_TOKEN }; + + async function wipe() { + const hhs = await prisma.household.findMany({ where: { name: { contains: TAG } }, select: { id: true } }); + const ids = hhs.map((h) => h.id); + if (ids.length) { + await prisma.auditLog.deleteMany({ where: { tableName: 'MembershipProcess', affectedEntityId: { in: ids } } }).catch(() => {}); + await prisma.membershipProcess.deleteMany({ where: { membership: { householdId: { in: ids } } } }); + await prisma.membership.deleteMany({ where: { householdId: { in: ids } } }); + await prisma.householdLead.deleteMany({ where: { householdId: { in: ids } } }); + await prisma.participant.deleteMany({ where: { householdId: { in: ids } } }); + await prisma.household.deleteMany({ where: { id: { in: ids } } }); + } + } + + beforeAll(async () => { + process.env.ZOHO_CLIENT_ID = 'cid'; + process.env.ZOHO_CLIENT_SECRET = 'csecret'; + process.env.ZOHO_REFRESH_TOKEN = 'rtoken'; + await wipe(); + + const hh = await prisma.household.create({ data: { name: `HH ${TAG}` } }); + const lead = await prisma.participant.create({ data: { email: `lead-${TAG}@example.com`, name: 'Lead Parent', householdId: hh.id } }); + const nonLead = await prisma.participant.create({ data: { email: `member-${TAG}@example.com`, name: 'Member', householdId: hh.id } }); + await prisma.householdLead.create({ data: { householdId: hh.id, participantId: lead.id } }); + const m = await prisma.membership.create({ data: { householdId: hh.id, status: 'NONE' } }); + const proc = await prisma.membershipProcess.create({ data: { membershipId: m.id, kind: 'INITIAL', status: 'PENDING_EXTERNAL_ACTION' } }); + leadId = lead.id; + nonLeadId = nonLead.id; + processId = proc.id; + }); + + afterAll(async () => { + await wipe(); + process.env.ZOHO_CLIENT_ID = prevEnv.id; + process.env.ZOHO_CLIENT_SECRET = prevEnv.secret; + process.env.ZOHO_REFRESH_TOKEN = prevEnv.refresh; + await prisma.$disconnect(); + }); + + beforeEach(() => jest.clearAllMocks()); + + it('rejects a non-lead household member', async () => { + asUser(nonLeadId); + const res = await SIGN(signReq()); + expect(res.status).toBe(403); + expect(zoho.createRequest).not.toHaveBeenCalled(); + }); + + it('creates the Zoho request once, stores the ids, and returns the embed url', async () => { + asUser(leadId); + const res = await SIGN(signReq()); + expect(res.status).toBe(200); + expect((await res.json()).url).toBe('https://sign.zoho.com/embed/xyz'); + expect(zoho.createRequest).toHaveBeenCalledTimes(1); + // PrintedName is prefilled from the applicant's name. + expect((zoho.submitRequest as jest.Mock).mock.calls[0][0].prefill).toEqual({ PrintedName: 'Lead Parent' }); + const p = await prisma.membershipProcess.findUnique({ where: { id: processId } }); + expect(p?.zohoEnvelopeId).toBe('REQ-1'); + expect(p?.zohoActionId).toBe('ACT-1'); + }); + + it('is idempotent: a second click reuses the stored request, only minting a fresh url', async () => { + asUser(leadId); + const res = await SIGN(signReq()); + expect(res.status).toBe(200); + expect(zoho.createRequest).not.toHaveBeenCalled(); // already created last test + expect(zoho.getEmbeddedSignUrl).toHaveBeenCalledTimes(1); + const p = await prisma.membershipProcess.findUnique({ where: { id: processId } }); + expect(p?.zohoEnvelopeId).toBe('REQ-1'); // unchanged + }); + + it('409s when the application is not in the EXTERNAL phase', async () => { + await prisma.membershipProcess.update({ where: { id: processId }, data: { status: 'INTAKE' } }); + asUser(leadId); + const res = await SIGN(signReq()); + expect(res.status).toBe(409); + await prisma.membershipProcess.update({ where: { id: processId }, data: { status: 'PENDING_EXTERNAL_ACTION' } }); + }); + + it('503s when Zoho is not configured', async () => { + delete process.env.ZOHO_CLIENT_ID; + asUser(leadId); + const res = await SIGN(signReq()); + expect(res.status).toBe(503); + process.env.ZOHO_CLIENT_ID = 'cid'; + }); +}); diff --git a/checkin-app/src/app/api/membership/contract/sign/route.ts b/checkin-app/src/app/api/membership/contract/sign/route.ts new file mode 100644 index 00000000..79f5ae0a --- /dev/null +++ b/checkin-app/src/app/api/membership/contract/sign/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { withAuth } from "@/lib/auth"; +import { logger } from "@/lib/logger"; +import { getOrCreateContractSigningUrl, ExternalError, type ExternalErrorCode } from "@/lib/membership/external"; + +export const dynamic = "force-dynamic"; + +// HTTP status per ExternalError code. 503 = integration not ready yet (secrets or +// the agreement PDF — issue #289); the applicant sees a "check back soon" message. +const STATUS_BY_CODE: Record = { + not_configured: 503, + agreement_unavailable: 503, + not_found: 404, + no_household: 404, + not_lead: 403, + wrong_phase: 409, +}; + +/** + * POST /api/membership/contract/sign — applicant-facing "Sign your membership + * agreement" action. Creates the Zoho signing request once (stored on the + * process) and returns a fresh embedded sign URL to redirect the applicant into. + */ +export const POST = withAuth({}, async (_req, auth) => { + if (auth.type !== "session") return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + try { + const url = await getOrCreateContractSigningUrl(auth.user.id); + return NextResponse.json({ url }); + } catch (error) { + if (error instanceof ExternalError) { + return NextResponse.json({ error: error.message, code: error.code }, { status: STATUS_BY_CODE[error.code] }); + } + logger.error(`Membership contract sign error: ${error instanceof Error ? error.message : String(error)}`); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } +}); diff --git a/checkin-app/src/app/membership/page.tsx b/checkin-app/src/app/membership/page.tsx index 44af571b..d1dcb866 100644 --- a/checkin-app/src/app/membership/page.tsx +++ b/checkin-app/src/app/membership/page.tsx @@ -21,6 +21,7 @@ interface PersonPrefill { interface ExternalStatus { contractSigned: boolean; + contractStarted: boolean; bgConsented: boolean; deepLinkUrl: string | null; } @@ -289,6 +290,28 @@ export default function MembershipPage() { } }; + // "Sign your membership agreement" — create/reuse the Zoho request and go + // straight into the embedded signing ceremony. The contract task flips to ✓ + // automatically (Zoho webhook) when the applicant returns signed. + const startSigning = async () => { + setSaving(true); + flash(""); + try { + const res = await fetch("/api/membership/contract/sign", { method: "POST" }); + const data = await res.json(); + if (res.ok && data.url) { + window.location.href = data.url; + } else { + flash(apiError(data, "Couldn't open the signing form. Please try again."), true); + setSaving(false); + } + } catch { + flash("Network error.", true); + setSaving(false); + } + // On success we navigate away, so we intentionally leave `saving` true. + }; + const renew = async () => { setSaving(true); flash(""); @@ -476,11 +499,16 @@ export default function MembershipPage() { - - - We've sent your membership contract via Zoho Sign. Please check your email - and sign it. This page updates automatically once it's signed. - + + + + Sign your personalized membership agreement online. This page updates + automatically once it's signed. + + + diff --git a/checkin-app/src/lib/config.ts b/checkin-app/src/lib/config.ts index d8ccf7ae..593d6f60 100644 --- a/checkin-app/src/lib/config.ts +++ b/checkin-app/src/lib/config.ts @@ -51,6 +51,18 @@ export const config = { // static hosted URL provided out-of-band, so it lives in config, not BoardSettings. averityConsentUrl: (): string | null => process.env.AVERITY_CONSENT_URL || null, + // Zoho Sign — membership-agreement e-signing. The three OAuth secrets are + // null when unset (integration "off"); the two endpoints default to the .com + // data center and only need overriding for .eu/.in/etc. See zohoConfigured(). + zohoClientId: (): string | null => process.env.ZOHO_CLIENT_ID || null, + zohoClientSecret: (): string | null => process.env.ZOHO_CLIENT_SECRET || null, + zohoRefreshToken: (): string | null => process.env.ZOHO_REFRESH_TOKEN || null, + zohoAccountsUrl: (): string => process.env.ZOHO_ACCOUNTS_URL || 'https://accounts.zoho.com', + zohoSignApi: (): string => process.env.ZOHO_SIGN_API || 'https://sign.zoho.com/api/v1', + // True only when all three OAuth secrets are present — gates the sign endpoint. + zohoConfigured: (): boolean => + !!(process.env.ZOHO_CLIENT_ID && process.env.ZOHO_CLIENT_SECRET && process.env.ZOHO_REFRESH_TOKEN), + // App checkinEnv: (): CheckinEnv => readCheckinEnv(), // Production (default when unset). Consumers should call this rather than diff --git a/checkin-app/src/lib/membership/contract/__tests__/signFields.test.ts b/checkin-app/src/lib/membership/contract/__tests__/signFields.test.ts new file mode 100644 index 00000000..28ee1018 --- /dev/null +++ b/checkin-app/src/lib/membership/contract/__tests__/signFields.test.ts @@ -0,0 +1,37 @@ +import { SIGN_FIELDS, toZohoFields } from "@/lib/membership/contract/signFields"; + +describe("signFields", () => { + it("ports the five fields from the source-of-truth script", () => { + expect(SIGN_FIELDS.map((f) => f.field_name)).toEqual([ + "Signature", + "DateSigned", + "PrintedName", + "InsuranceCo", + "PolicyNumber", + ]); + }); + + it("converts percentage coords to absolute (pct/100 × page size, rounded)", () => { + const fields = toZohoFields("doc-1", 2, 612, 792); + const sig = fields.find((f) => f.field_name === "Signature")!; + // x 2% of 612 = 12.24 → 12 ; y 76% of 792 = 601.92 → 602 + expect(sig.x_coord).toBe(12); + expect(sig.y_coord).toBe(602); + expect(sig.abs_width).toBe(Math.round(0.54 * 612)); + expect(sig.abs_height).toBe(Math.round(0.08 * 792)); + expect(sig.document_id).toBe("doc-1"); + expect(sig.page_no).toBe(2); + }); + + it("tags text fields with field_category and leaves sign/date untagged", () => { + const fields = toZohoFields("d", 0, 600, 800); + expect(fields.find((f) => f.field_name === "PrintedName")!.field_category).toBe("Textfield"); + expect(fields.find((f) => f.field_name === "Signature")!.field_category).toBeUndefined(); + }); + + it("applies prefill as a field default_value only where provided", () => { + const fields = toZohoFields("d", 0, 600, 800, { PrintedName: "Ada Lovelace" }); + expect(fields.find((f) => f.field_name === "PrintedName")!.default_value).toBe("Ada Lovelace"); + expect(fields.find((f) => f.field_name === "InsuranceCo")!.default_value).toBeUndefined(); + }); +}); diff --git a/checkin-app/src/lib/membership/contract/__tests__/zohoClient.test.ts b/checkin-app/src/lib/membership/contract/__tests__/zohoClient.test.ts new file mode 100644 index 00000000..c594f8bf --- /dev/null +++ b/checkin-app/src/lib/membership/contract/__tests__/zohoClient.test.ts @@ -0,0 +1,96 @@ +/** + * @jest-environment node + */ +import { + getAccessToken, + createRequest, + getEmbeddedSignUrl, + ZohoError, + _resetTokenCache, +} from "@/lib/membership/contract/zohoClient"; + +const ORIGINAL_ENV = { ...process.env }; + +function mockFetchOnce(body: unknown, ok = true, status = 200) { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok, + status, + json: async () => body, + text: async () => JSON.stringify(body), + }); +} + +describe("zohoClient", () => { + beforeEach(() => { + process.env.ZOHO_CLIENT_ID = "cid"; + process.env.ZOHO_CLIENT_SECRET = "csecret"; + process.env.ZOHO_REFRESH_TOKEN = "rtoken"; + delete process.env.ZOHO_ACCOUNTS_URL; + delete process.env.ZOHO_SIGN_API; + _resetTokenCache(); + global.fetch = jest.fn(); + }); + afterAll(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it("caches the access token across calls (one token exchange)", async () => { + mockFetchOnce({ access_token: "tok-123", expires_in: 3600 }); + const a = await getAccessToken(); + const b = await getAccessToken(); + expect(a).toBe("tok-123"); + expect(b).toBe("tok-123"); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it("throws ZohoError when OAuth secrets are missing", async () => { + delete process.env.ZOHO_REFRESH_TOKEN; + await expect(getAccessToken()).rejects.toBeInstanceOf(ZohoError); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("createRequest returns request/action/document ids on success", async () => { + mockFetchOnce({ + status: "success", + requests: { + request_id: "req-1", + actions: [{ action_id: "act-1" }], + document_ids: [{ document_id: "doc-1" }], + }, + }); + const result = await createRequest({ + token: "t", + pdf: Buffer.from("%PDF-1.4 fake"), + filename: "a.pdf", + recipientEmail: "x@example.com", + recipientName: "X", + requestName: "Agreement", + expirationDays: 15, + }); + expect(result).toEqual({ requestId: "req-1", actionId: "act-1", documentId: "doc-1" }); + }); + + it("createRequest throws when Zoho reports a non-success status", async () => { + mockFetchOnce({ status: "failure", message: "nope" }); + await expect( + createRequest({ + token: "t", + pdf: Buffer.from("x"), + filename: "a.pdf", + recipientEmail: "x@example.com", + recipientName: "X", + requestName: "Agreement", + expirationDays: 15, + }), + ).rejects.toBeInstanceOf(ZohoError); + }); + + it("getEmbeddedSignUrl returns the sign_url and passes host", async () => { + mockFetchOnce({ sign_url: "https://sign.zoho.com/embed/abc" }); + const url = await getEmbeddedSignUrl({ token: "t", requestId: "req-1", actionId: "act-1", host: "https://app.example.com" }); + expect(url).toBe("https://sign.zoho.com/embed/abc"); + const calledUrl = (global.fetch as jest.Mock).mock.calls[0][0] as URL; + expect(calledUrl.toString()).toContain("/requests/req-1/actions/act-1/embedtoken"); + expect(calledUrl.searchParams.get("host")).toBe("https://app.example.com"); + }); +}); diff --git a/checkin-app/src/lib/membership/contract/agreementDocument.ts b/checkin-app/src/lib/membership/contract/agreementDocument.ts new file mode 100644 index 00000000..edec66a7 --- /dev/null +++ b/checkin-app/src/lib/membership/contract/agreementDocument.ts @@ -0,0 +1,55 @@ +import { readFile } from "fs/promises"; +import path from "path"; +import { PDFDocument } from "pdf-lib"; + +/** + * Loader for the static membership-agreement PDF that gets uploaded to Zoho Sign. + * We do NOT generate the PDF — Zoho overlays the signature fields onto this file + * (Printed Name prefilled). Reads the last-page index + dimensions the way the + * source-of-truth script used pymupdf, but via pdf-lib (pure JS). + * + * TODO(#289): drop in the real agreement at AGREEMENT_PDF_PATH once + * https://github.com/innovationtreehouse/checkin/issues/289 closes. Until then the + * file is absent and loadAgreementPdf() throws a clear error (and the DO_NOT_SUBMIT + * marker blocks merge), so the sign endpoint fails loudly rather than uploading a + * placeholder. + */ +export const AGREEMENT_PDF_PATH = path.join( + process.cwd(), + "src/lib/membership/contract/assets/membership-agreement.pdf", +); + +/** Filename shown in Zoho for the uploaded document. */ +export const AGREEMENT_FILENAME = "membership-agreement.pdf"; + +export interface LoadedAgreement { + pdf: Buffer; + /** Zero-based last-page index (fields sit on the last page, per the script). */ + lastPageNo: number; + pageWidth: number; + pageHeight: number; +} + +export class AgreementUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = "AgreementUnavailableError"; + } +} + +/** Read the agreement PDF and the geometry needed to place the signature fields. */ +export async function loadAgreementPdf(): Promise { + let pdf: Buffer; + try { + pdf = await readFile(AGREEMENT_PDF_PATH); + } catch { + throw new AgreementUnavailableError( + `Membership agreement PDF not found at ${AGREEMENT_PDF_PATH} (pending issue #289).`, + ); + } + + const doc = await PDFDocument.load(pdf); + const lastPageNo = doc.getPageCount() - 1; + const { width, height } = doc.getPage(lastPageNo).getSize(); + return { pdf, lastPageNo, pageWidth: width, pageHeight: height }; +} diff --git a/checkin-app/src/lib/membership/contract/signFields.ts b/checkin-app/src/lib/membership/contract/signFields.ts new file mode 100644 index 00000000..502201bb --- /dev/null +++ b/checkin-app/src/lib/membership/contract/signFields.ts @@ -0,0 +1,86 @@ +/** + * Signature-field layout for the membership agreement — a direct port of + * `signature_fields()` in innovationtreehouse/sign-script (send_for_signature.py), + * the source of truth for how Zoho Sign requests are generated. + * + * Coordinates are PERCENTAGES of the page (0–100); Zoho needs absolutes, so + * `toZohoFields()` multiplies by the actual page width/height (read from the PDF). + * All five fields sit at the bottom of the LAST page: + * + * [ Signature ] [ Date Signed ] y≈76% + * [ Printed Name ] [ Insurance Company ] [ Policy # (N/A) ] y≈87% + */ + +/** Zoho field_type_name values used by the agreement. */ +export type ZohoFieldType = "Signature" | "Date" | "Textfield"; + +export interface SignFieldSpec { + field_type_name: ZohoFieldType; + field_name: string; + field_label: string; + is_mandatory: boolean; + x_pct: number; + y_pct: number; + w_pct: number; + h_pct: number; +} + +/** The five fields, in the same order/coords as the Python source of truth. */ +export const SIGN_FIELDS: SignFieldSpec[] = [ + { field_type_name: "Signature", field_name: "Signature", field_label: "Signature", is_mandatory: true, x_pct: 2, y_pct: 76, w_pct: 54, h_pct: 8 }, + { field_type_name: "Date", field_name: "DateSigned", field_label: "Date", is_mandatory: true, x_pct: 59, y_pct: 76, w_pct: 39, h_pct: 8 }, + { field_type_name: "Textfield", field_name: "PrintedName", field_label: "Printed Name", is_mandatory: true, x_pct: 2, y_pct: 87, w_pct: 29, h_pct: 5 }, + { field_type_name: "Textfield", field_name: "InsuranceCo", field_label: "Insurance Company", is_mandatory: true, x_pct: 33, y_pct: 87, w_pct: 33, h_pct: 5 }, + { field_type_name: "Textfield", field_name: "PolicyNumber", field_label: "Policy # (N/A if none)", is_mandatory: true, x_pct: 68, y_pct: 87, w_pct: 30, h_pct: 5 }, +]; + +/** A field as Zoho's `submit` endpoint expects it (absolute coords on one document). */ +export interface ZohoSubmitField { + field_name: string; + field_type_name: ZohoFieldType; + field_label: string; + field_category?: string; + document_id: string; + page_no: number; + x_coord: number; + y_coord: number; + abs_width: number; + abs_height: number; + is_mandatory: boolean; + default_value?: string; +} + +/** Optional per-field prefill values, keyed by field_name (e.g. PrintedName). */ +export type FieldPrefill = Partial>; + +/** + * Convert the percentage layout to Zoho's absolute submit fields for a given + * document/page, mirroring the script's coordinate math (pct/100 × page size, + * rounded). Textfields carry `field_category: "Textfield"` as in the source. + */ +export function toZohoFields( + documentId: string, + pageNo: number, + pageWidth: number, + pageHeight: number, + prefill: FieldPrefill = {}, +): ZohoSubmitField[] { + return SIGN_FIELDS.map((f) => { + const entry: ZohoSubmitField = { + field_name: f.field_name, + field_type_name: f.field_type_name, + field_label: f.field_label, + document_id: documentId, + page_no: pageNo, + x_coord: Math.round((f.x_pct / 100) * pageWidth), + y_coord: Math.round((f.y_pct / 100) * pageHeight), + abs_width: Math.round((f.w_pct / 100) * pageWidth), + abs_height: Math.round((f.h_pct / 100) * pageHeight), + is_mandatory: f.is_mandatory, + }; + if (f.field_type_name === "Textfield") entry.field_category = "Textfield"; + const value = prefill[f.field_name]; + if (value) entry.default_value = value; + return entry; + }); +} diff --git a/checkin-app/src/lib/membership/contract/zohoClient.ts b/checkin-app/src/lib/membership/contract/zohoClient.ts new file mode 100644 index 00000000..03247aa1 --- /dev/null +++ b/checkin-app/src/lib/membership/contract/zohoClient.ts @@ -0,0 +1,200 @@ +import { config } from "@/lib/config"; +import { toZohoFields, type FieldPrefill } from "@/lib/membership/contract/signFields"; + +/** + * Zoho Sign API client — the "send for signature" half of the integration, a + * TypeScript port of innovationtreehouse/sign-script (send_for_signature.py). + * + * Flow: getAccessToken (OAuth refresh) → createRequest (upload PDF + register the + * single SIGN recipient) → submitRequest (attach the agreement's signature fields) + * → getEmbeddedSignUrl (mint a short-lived in-app signing session). The webhook + * half lives in contract/zoho.ts + external.ts and is unchanged. + * + * Server-only: reads OAuth secrets via config and is never imported client-side. + */ + +export class ZohoError extends Error { + constructor(message: string) { + super(message); + this.name = "ZohoError"; + } +} + +function requireSecrets(): { clientId: string; clientSecret: string; refreshToken: string } { + const clientId = config.zohoClientId(); + const clientSecret = config.zohoClientSecret(); + const refreshToken = config.zohoRefreshToken(); + if (!clientId || !clientSecret || !refreshToken) { + throw new ZohoError("Zoho Sign is not configured (missing ZOHO_CLIENT_ID/SECRET/REFRESH_TOKEN)."); + } + return { clientId, clientSecret, refreshToken }; +} + +// In-process access-token cache. Zoho access tokens last ~1h; we refresh a minute +// early. Module-scoped so it survives across requests in the same server instance. +let cachedToken: { token: string; expiresAt: number } | null = null; + +/** Exchange the refresh token for an access token (cached until ~1 min before expiry). */ +export async function getAccessToken(): Promise { + if (cachedToken && cachedToken.expiresAt > Date.now()) return cachedToken.token; + + const { clientId, clientSecret, refreshToken } = requireSecrets(); + const url = new URL(`${config.zohoAccountsUrl()}/oauth/v2/token`); + url.searchParams.set("grant_type", "refresh_token"); + url.searchParams.set("client_id", clientId); + url.searchParams.set("client_secret", clientSecret); + url.searchParams.set("refresh_token", refreshToken); + + const resp = await fetch(url, { method: "POST" }); + if (!resp.ok) throw new ZohoError(`Token exchange failed (${resp.status}): ${await resp.text()}`); + const data = (await resp.json()) as { access_token?: string; expires_in?: number }; + if (!data.access_token) throw new ZohoError(`Token exchange returned no access_token: ${JSON.stringify(data)}`); + + const ttlMs = (data.expires_in ?? 3600) * 1000; + cachedToken = { token: data.access_token, expiresAt: Date.now() + ttlMs - 60_000 }; + return data.access_token; +} + +/** Reset the cached token (used by tests). */ +export function _resetTokenCache(): void { + cachedToken = null; +} + +function authHeader(token: string): Record { + return { Authorization: `Zoho-oauthtoken ${token}` }; +} + +export interface CreateRequestResult { + requestId: string; + actionId: string; + documentId: string; +} + +/** + * Upload the agreement PDF and register the single SIGN recipient. Mirrors the + * script's create_request: is_sequential, one SIGN action, verify_recipient false. + */ +export async function createRequest(params: { + token: string; + pdf: Buffer; + filename: string; + recipientEmail: string; + recipientName: string; + requestName: string; + expirationDays: number; +}): Promise { + const payload = { + requests: { + request_name: params.requestName, + expiration_days: params.expirationDays, + is_sequential: true, + actions: [ + { + action_type: "SIGN", + recipient_email: params.recipientEmail, + recipient_name: params.recipientName, + signing_order: 0, + verify_recipient: false, + }, + ], + }, + }; + + const form = new FormData(); + form.append("file", new Blob([new Uint8Array(params.pdf)], { type: "application/pdf" }), params.filename); + form.append("data", JSON.stringify(payload)); + + const resp = await fetch(`${config.zohoSignApi()}/requests`, { + method: "POST", + headers: authHeader(params.token), + body: form, + }); + if (!resp.ok) throw new ZohoError(`Failed to create request (${resp.status}): ${await resp.text()}`); + const result = (await resp.json()) as { + status?: string; + requests?: { + request_id?: string; + actions?: { action_id?: string }[]; + document_ids?: { document_id?: string }[]; + }; + }; + if (result.status !== "success" || !result.requests) { + throw new ZohoError(`Failed to create request: ${JSON.stringify(result)}`); + } + const req = result.requests; + const requestId = req.request_id; + const actionId = req.actions?.[0]?.action_id; + const documentId = req.document_ids?.[0]?.document_id; + if (!requestId || !actionId || !documentId) { + throw new ZohoError(`Create request missing ids: ${JSON.stringify(result)}`); + } + return { requestId, actionId, documentId }; +} + +/** + * Attach the agreement's signature fields and submit the request. Mirrors the + * script's submit_request; `prefill` seeds field default_values (e.g. PrintedName). + */ +export async function submitRequest(params: { + token: string; + requestId: string; + actionId: string; + documentId: string; + recipientEmail: string; + recipientName: string; + lastPageNo: number; + pageWidth: number; + pageHeight: number; + prefill?: FieldPrefill; +}): Promise { + const fields = toZohoFields(params.documentId, params.lastPageNo, params.pageWidth, params.pageHeight, params.prefill); + const payload = { + requests: { + actions: [ + { + action_id: params.actionId, + recipient_name: params.recipientName, + recipient_email: params.recipientEmail, + action_type: "SIGN", + fields, + }, + ], + }, + }; + + const form = new FormData(); + form.append("data", JSON.stringify(payload)); + + const resp = await fetch(`${config.zohoSignApi()}/requests/${params.requestId}/submit`, { + method: "POST", + headers: authHeader(params.token), + body: form, + }); + if (!resp.ok) throw new ZohoError(`Failed to submit request (${resp.status}): ${await resp.text()}`); + const result = (await resp.json()) as { status?: string }; + if (result.status !== "success") throw new ZohoError(`Failed to submit request: ${JSON.stringify(result)}`); +} + +/** + * Mint a short-lived embedded-signing URL for the recipient action so the + * applicant can sign in-app (no email). Re-fetchable per click — the request + * itself is created once and stored, only this session URL is ephemeral. + * + * `host` must be allow-listed in the Zoho Sign console for embedded signing. + */ +export async function getEmbeddedSignUrl(params: { + token: string; + requestId: string; + actionId: string; + host: string; +}): Promise { + const url = new URL(`${config.zohoSignApi()}/requests/${params.requestId}/actions/${params.actionId}/embedtoken`); + url.searchParams.set("host", params.host); + + const resp = await fetch(url, { method: "POST", headers: authHeader(params.token) }); + if (!resp.ok) throw new ZohoError(`Failed to get embed token (${resp.status}): ${await resp.text()}`); + const result = (await resp.json()) as { status?: string; sign_url?: string; requests?: { sign_url?: string } }; + const signUrl = result.sign_url ?? result.requests?.sign_url; + if (!signUrl) throw new ZohoError(`Embed token response missing sign_url: ${JSON.stringify(result)}`); + return signUrl; +} diff --git a/checkin-app/src/lib/membership/external.ts b/checkin-app/src/lib/membership/external.ts index 6fc66f71..54b164e4 100644 --- a/checkin-app/src/lib/membership/external.ts +++ b/checkin-app/src/lib/membership/external.ts @@ -1,6 +1,15 @@ import prisma from "@/lib/prisma"; +import { config } from "@/lib/config"; +import { logger } from "@/lib/logger"; import { backgroundCheckProvider } from "@/lib/membership/background-check/manual-adapter"; import { notifyReviewers } from "@/lib/membership/review"; +import { + createRequest, + submitRequest, + getAccessToken, + getEmbeddedSignUrl, +} from "@/lib/membership/contract/zohoClient"; +import { loadAgreementPdf, AGREEMENT_FILENAME, AgreementUnavailableError } from "@/lib/membership/contract/agreementDocument"; /** * EXTERNAL-phase service — the two parallel actions an applicant completes after @@ -15,8 +24,16 @@ import { notifyReviewers } from "@/lib/membership/review"; */ const SYSTEM_ACTOR = 0; +export type ExternalErrorCode = + | "not_found" + | "wrong_phase" + | "not_lead" + | "no_household" + | "not_configured" + | "agreement_unavailable"; + export class ExternalError extends Error { - constructor(public readonly code: "not_found" | "wrong_phase", message: string) { + constructor(public readonly code: ExternalErrorCode, message: string) { super(message); this.name = "ExternalError"; } @@ -24,14 +41,21 @@ export class ExternalError extends Error { export interface ExternalStatus { contractSigned: boolean; + /** True once a Zoho signing request exists — lets the UI say "Resume signing". */ + contractStarted: boolean; bgConsented: boolean; deepLinkUrl: string | null; } /** Applicant-facing status of the two external actions (+ the consent deep link). */ -export async function getExternalStatus(process: { contractSignedAt: Date | null; bgConsentAt: Date | null }): Promise { +export async function getExternalStatus(process: { + contractSignedAt: Date | null; + bgConsentAt: Date | null; + zohoEnvelopeId: string | null; +}): Promise { return { contractSigned: !!process.contractSignedAt, + contractStarted: !!process.zohoEnvelopeId, bgConsented: !!process.bgConsentAt, deepLinkUrl: await backgroundCheckProvider.getConsentDeepLink(), }; @@ -103,3 +127,102 @@ export async function setZohoEnvelope(processId: number, requestId: string, acto export async function findProcessByEnvelope(requestId: string) { return prisma.membershipProcess.findFirst({ where: { zohoEnvelopeId: requestId } }); } + +/** Days an applicant has to sign before the Zoho request expires (mirrors the script's default). */ +const CONTRACT_EXPIRATION_DAYS = 15; + +/** + * Applicant-facing "Sign your membership agreement" action. Idempotent: the Zoho + * signing request/document is created at most once (its request + action ids are + * stored on the process), then every call mints a fresh short-lived EMBEDDED sign + * URL so the applicant goes straight into the signing ceremony in-app. + * + * Returns the embedded sign URL. Throws ExternalError for the caller to map to HTTP. + */ +export async function getOrCreateContractSigningUrl(userId: number): Promise { + if (!config.zohoConfigured()) { + throw new ExternalError("not_configured", "Agreement signing isn't available yet. Please check back soon."); + } + + const user = await prisma.participant.findUnique({ + where: { id: userId }, + include: { + householdLeads: true, + household: { include: { membership: { include: { processes: true } } } }, + }, + }); + if (!user) throw new ExternalError("not_found", "Application not found."); + if (!user.householdId) throw new ExternalError("no_household", "You must create a household first."); + const isLead = user.householdLeads.some((l) => l.householdId === user.householdId); + if (!isLead && !user.sysadmin) { + throw new ExternalError("not_lead", "Only a household lead can sign the membership agreement."); + } + + const process = (user.household?.membership?.processes ?? []) + .filter((p) => p.kind === "INITIAL" && p.status === "PENDING_EXTERNAL_ACTION") + .sort((a, b) => b.id - a.id)[0]; + if (!process) throw new ExternalError("wrong_phase", "No application is awaiting your signature."); + + const recipientEmail = user.email; + const recipientName = user.name?.trim() || user.email || "Applicant"; + if (!recipientEmail) throw new ExternalError("not_found", "Your account has no email on file to sign with."); + + const token = await getAccessToken(); + + // Create the request once; reuse the stored ids on every later click so the + // document is never re-generated (only the embed session below is ephemeral). + let requestId = process.zohoEnvelopeId; + let actionId = process.zohoActionId; + if (!requestId || !actionId) { + let agreement; + try { + agreement = await loadAgreementPdf(); + } catch (e) { + if (e instanceof AgreementUnavailableError) { + throw new ExternalError("agreement_unavailable", "The membership agreement isn't ready yet. Please check back soon."); + } + throw e; + } + + const created = await createRequest({ + token, + pdf: agreement.pdf, + filename: AGREEMENT_FILENAME, + recipientEmail, + recipientName, + requestName: `Membership Agreement — ${recipientName}`, + expirationDays: CONTRACT_EXPIRATION_DAYS, + }); + await submitRequest({ + token, + requestId: created.requestId, + actionId: created.actionId, + documentId: created.documentId, + recipientEmail, + recipientName, + lastPageNo: agreement.lastPageNo, + pageWidth: agreement.pageWidth, + pageHeight: agreement.pageHeight, + prefill: { PrintedName: recipientName }, + }); + + requestId = created.requestId; + actionId = created.actionId; + await prisma.membershipProcess.update({ + where: { id: process.id }, + data: { zohoEnvelopeId: requestId, zohoActionId: actionId }, + }); + await prisma.auditLog.create({ + data: { + actorId: userId, + action: "EDIT", + tableName: "MembershipProcess", + affectedEntityId: process.id, + newData: JSON.stringify({ zohoEnvelopeId: requestId, zohoActionId: actionId }), + }, + }); + logger.info(`Created Zoho signing request ${requestId} for membership process ${process.id}.`); + } + + return getEmbeddedSignUrl({ token, requestId, actionId, host: config.baseUrl() }); +} diff --git a/checkin-app/src/security/generated/classifications.ts b/checkin-app/src/security/generated/classifications.ts index 710a7ccf..45663abb 100644 --- a/checkin-app/src/security/generated/classifications.ts +++ b/checkin-app/src/security/generated/classifications.ts @@ -71,6 +71,7 @@ export const classifications = { stageEnteredAt: 'internal', createdAt: 'public', zohoEnvelopeId: 'internal', + zohoActionId: 'internal', contractSignedAt: 'internal', bgConsentAt: 'internal', shopifyDraftOrderId: 'internal', diff --git a/package-lock.json b/package-lock.json index e0a7701e..18b0217b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "date-fns-tz": "^3.2.0", "next": "^16.1.6", "next-auth": "^4.24.13", + "pdf-lib": "^1.17.1", "pg": "^8.18.0", "qrcode": "^1.5.4", "qrcode.react": "^4.2.0", @@ -3205,6 +3206,24 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -14300,6 +14319,24 @@ "devOptional": true, "license": "MIT" }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/perfect-debounce": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",