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..cc0596f4 100644 --- a/checkin-app/next.config.ts +++ b/checkin-app/next.config.ts @@ -8,6 +8,8 @@ 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 fetched from S3 at runtime (not read from + // disk or imported), so there's nothing to bundle into the standalone image. }; export default nextConfig; diff --git a/checkin-app/package.json b/checkin-app/package.json index 320314d5..b8a2209c 100644 --- a/checkin-app/package.json +++ b/checkin-app/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@auth/prisma-adapter": "^2.11.1", + "@aws-sdk/client-s3": "^3.1075.0", "@mantine/core": "^7.17.8", "@mantine/form": "^7.17.8", "@mantine/hooks": "^7.17.8", @@ -33,6 +34,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..28dc2ded --- /dev/null +++ b/checkin-app/src/app/__tests__/membershipContractSignAPI.integration.test.ts @@ -0,0 +1,173 @@ +/** + * @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('lets a RENEWAL process in the EXTERNAL phase sign (renewals re-sign fresh)', async () => { + const hh = await prisma.household.create({ data: { name: `HH renewal ${TAG}` } }); + const rLead = await prisma.participant.create({ data: { email: `rlead-${TAG}@example.com`, name: 'Renewing Lead', householdId: hh.id } }); + await prisma.householdLead.create({ data: { householdId: hh.id, participantId: rLead.id } }); + const m = await prisma.membership.create({ data: { householdId: hh.id, status: 'ACTIVE' } }); + await prisma.membershipProcess.create({ data: { membershipId: m.id, kind: 'RENEWAL', status: 'PENDING_EXTERNAL_ACTION' } }); + + asUser(rLead.id); + const res = await SIGN(signReq()); + expect(res.status).toBe(200); + expect(zoho.createRequest).toHaveBeenCalledTimes(1); + }); + + it('survives two concurrent clicks: stores one request and embeds the same id for both', async () => { + // Distinct ids per create so a double-create would be detectable. + let n = 0; + (zoho.createRequest as jest.Mock).mockImplementation(async () => { + n += 1; + return { requestId: `REQ-C${n}`, actionId: `ACT-C${n}`, documentId: `DOC-C${n}` }; + }); + await prisma.membershipProcess.update({ where: { id: processId }, data: { zohoEnvelopeId: null, zohoActionId: null } }); + asUser(leadId); + + const [r1, r2] = await Promise.all([SIGN(signReq()), SIGN(signReq())]); + expect(r1.status).toBe(200); + expect(r2.status).toBe(200); + + // Exactly one canonical request persisted, and every embed URL was minted + // against THAT id — no split brain even if both calls created at Zoho. + const p = await prisma.membershipProcess.findUnique({ where: { id: processId } }); + expect(p?.zohoEnvelopeId).toBeTruthy(); + expect(p?.zohoActionId).toBeTruthy(); + for (const call of (zoho.getEmbeddedSignUrl as jest.Mock).mock.calls) { + expect(call[0].requestId).toBe(p?.zohoEnvelopeId); + expect(call[0].actionId).toBe(p?.zohoActionId); + } + + // Restore the default create mock + a stored request for later assertions. + (zoho.createRequest as jest.Mock).mockResolvedValue({ requestId: 'REQ-1', actionId: 'ACT-1', documentId: 'DOC-1' }); + await prisma.membershipProcess.update({ where: { id: processId }, data: { zohoEnvelopeId: 'REQ-1', zohoActionId: 'ACT-1' } }); + }); + + 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..4a0f8d90 100644 --- a/checkin-app/src/lib/config.ts +++ b/checkin-app/src/lib/config.ts @@ -51,6 +51,30 @@ 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, + // AWS — region for SDK clients. Set on the ECS task def (AWS_REGION); the + // default covers local dev. Credentials come from the task role / local profile. + awsRegion: (): string => process.env.AWS_REGION || 'us-east-2', + + // Membership-agreement PDF — stored in a private S3 bucket (uploaded + // out-of-band, never committed) and fetched at runtime by the task role. + // Bucket null when unset → the sign endpoint reports the agreement + // unavailable (503), the same failure as a missing object. The key has a + // default so only the bucket must be wired per-env. + agreementPdfBucket: (): string | null => process.env.AGREEMENT_PDF_S3_BUCKET || null, + agreementPdfKey: (): string => process.env.AGREEMENT_PDF_S3_KEY || 'membership-agreement.pdf', + + // 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__/agreementDocument.test.ts b/checkin-app/src/lib/membership/contract/__tests__/agreementDocument.test.ts new file mode 100644 index 00000000..a2e508f0 --- /dev/null +++ b/checkin-app/src/lib/membership/contract/__tests__/agreementDocument.test.ts @@ -0,0 +1,76 @@ +/** + * @jest-environment node + */ +import { PDFDocument } from "pdf-lib"; + +// Mock the S3 SDK so loadAgreementPdf() can be exercised without real AWS. +const sendMock = jest.fn(); +jest.mock("@aws-sdk/client-s3", () => { + class NoSuchKey extends Error { + constructor() { + super("NoSuchKey"); + this.name = "NoSuchKey"; + } + } + return { + S3Client: jest.fn().mockImplementation(() => ({ send: sendMock })), + GetObjectCommand: jest.fn().mockImplementation((input) => ({ input })), + NoSuchKey, + }; +}); + +import { loadAgreementPdf, AgreementUnavailableError } from "@/lib/membership/contract/agreementDocument"; + +/** Mimic an AWS SDK v3 service error — matched by `.name` (as in production). */ +function noSuchKeyError(): Error { + const err = new Error("The specified key does not exist."); + err.name = "NoSuchKey"; + return err; +} + +async function samplePdf(): Promise { + const doc = await PDFDocument.create(); + doc.addPage([612, 792]); + doc.addPage([612, 792]); // two pages — fields sit on the last + return Buffer.from(await doc.save()); +} + +describe("loadAgreementPdf", () => { + const prevBucket = process.env.AGREEMENT_PDF_S3_BUCKET; + afterEach(() => sendMock.mockReset()); + afterAll(() => { + process.env.AGREEMENT_PDF_S3_BUCKET = prevBucket; + }); + + it("throws AgreementUnavailableError when no bucket is configured", async () => { + delete process.env.AGREEMENT_PDF_S3_BUCKET; + await expect(loadAgreementPdf()).rejects.toBeInstanceOf(AgreementUnavailableError); + expect(sendMock).not.toHaveBeenCalled(); + }); + + it("throws AgreementUnavailableError when the object is missing (NoSuchKey)", async () => { + process.env.AGREEMENT_PDF_S3_BUCKET = "checkin-assets-test"; + sendMock.mockRejectedValueOnce(noSuchKeyError()); + await expect(loadAgreementPdf()).rejects.toBeInstanceOf(AgreementUnavailableError); + }); + + it("re-throws unexpected S3 errors (not swallowed as 'unavailable')", async () => { + process.env.AGREEMENT_PDF_S3_BUCKET = "checkin-assets-test"; + sendMock.mockRejectedValueOnce(new Error("AccessDenied")); + await expect(loadAgreementPdf()).rejects.toThrow("AccessDenied"); + }); + + it("returns the PDF bytes + last-page geometry when present", async () => { + process.env.AGREEMENT_PDF_S3_BUCKET = "checkin-assets-test"; + const bytes = await samplePdf(); + sendMock.mockResolvedValueOnce({ + Body: { transformToByteArray: async () => new Uint8Array(bytes) }, + }); + + const res = await loadAgreementPdf(); + expect(Buffer.isBuffer(res.pdf)).toBe(true); + expect(res.lastPageNo).toBe(1); + expect(res.pageWidth).toBe(612); + expect(res.pageHeight).toBe(792); + }); +}); 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__/watermark.test.ts b/checkin-app/src/lib/membership/contract/__tests__/watermark.test.ts new file mode 100644 index 00000000..37c12368 --- /dev/null +++ b/checkin-app/src/lib/membership/contract/__tests__/watermark.test.ts @@ -0,0 +1,31 @@ +/** + * @jest-environment node + */ +import { PDFDocument } from "pdf-lib"; +import { stampWatermark } from "@/lib/membership/contract/agreementDocument"; + +async function makePdf(pages = 2): Promise { + const doc = await PDFDocument.create(); + for (let i = 0; i < pages; i++) doc.addPage([612, 792]); + return Buffer.from(await doc.save()); +} + +describe("stampWatermark", () => { + it("returns a valid PDF with the same page count and dimensions", async () => { + const original = await makePdf(2); + const stamped = await stampWatermark(original, "DEV TEST — NOT A LEGAL AGREEMENT"); + + const doc = await PDFDocument.load(stamped); + expect(doc.getPageCount()).toBe(2); + // Geometry is unchanged so signature-field placement still lines up. + const { width, height } = doc.getPage(1).getSize(); + expect(width).toBe(612); + expect(height).toBe(792); + }); + + it("modifies the document (watermark content is added)", async () => { + const original = await makePdf(1); + const stamped = await stampWatermark(original, "DEV TEST — NOT A LEGAL AGREEMENT"); + expect(stamped.length).toBeGreaterThan(original.length); + }); +}); 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..f2e19cbb --- /dev/null +++ b/checkin-app/src/lib/membership/contract/agreementDocument.ts @@ -0,0 +1,104 @@ +import { GetObjectCommand, NoSuchKey, S3Client } from "@aws-sdk/client-s3"; +import { PDFDocument, StandardFonts, rgb, degrees } from "pdf-lib"; +import { config } from "@/lib/config"; + +/** + * 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). + * + * The PDF is NOT committed to the repo: it lives in a private S3 bucket and is + * fetched here at runtime with the ECS task role (region + credentials come from + * the environment — no static creds in the image). One bucket/key serves every + * env; non-prod signatures are watermarked at upload time (see stampWatermark). + * + * TODO(#289): upload the real agreement to s3://$AGREEMENT_PDF_S3_BUCKET/ + * $AGREEMENT_PDF_S3_KEY once https://github.com/innovationtreehouse/checkin/ + * issues/289 closes. Until the bucket is configured / the object exists, + * loadAgreementPdf() throws AgreementUnavailableError (the sign endpoint then + * reports a clean 503) rather than uploading a placeholder. + */ + +/** 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"; + } +} + +// One client per process; region (and task-role credentials) resolve from the +// environment the container runs in (AWS_REGION is set on the ECS task def). +let s3: S3Client | null = null; +function s3Client(): S3Client { + if (!s3) s3 = new S3Client({ region: config.awsRegion() }); + return s3; +} + +/** Read the agreement PDF from S3 and the geometry needed to place the signature fields. */ +export async function loadAgreementPdf(): Promise { + const bucket = config.agreementPdfBucket(); + if (!bucket) { + throw new AgreementUnavailableError( + "Membership agreement bucket is not configured (AGREEMENT_PDF_S3_BUCKET unset).", + ); + } + const key = config.agreementPdfKey(); + + let pdf: Buffer; + try { + const res = await s3Client().send(new GetObjectCommand({ Bucket: bucket, Key: key })); + pdf = Buffer.from(await res.Body!.transformToByteArray()); + } catch (e) { + // Missing object (not yet uploaded) → unavailable, not a hard error. The + // task role has s3:ListBucket so this surfaces as NoSuchKey, not a 403. + if (e instanceof NoSuchKey || (e as { name?: string })?.name === "NoSuchKey") { + throw new AgreementUnavailableError( + `Membership agreement PDF not found at s3://${bucket}/${key} (pending issue #289).`, + ); + } + throw e; + } + + 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 }; +} + +/** + * Stamp a diagonal watermark across every page — used on NON-prod instances so a + * test signature is unmistakably marked as such, both in the signing ceremony and + * on the final signed PDF. Server-controlled (driven by CHECKIN_ENV), so the + * applicant can't remove or edit it. Page geometry is unchanged, so signature + * field placement still matches loadAgreementPdf()'s dimensions. Never applied in + * prod, which must produce a clean, binding agreement. + */ +export async function stampWatermark(pdf: Buffer, text: string): Promise { + const doc = await PDFDocument.load(pdf); + const font = await doc.embedFont(StandardFonts.HelveticaBold); + for (const page of doc.getPages()) { + const { width, height } = page.getSize(); + page.drawText(text, { + x: width * 0.1, + y: height * 0.42, + size: 26, + font, + color: rgb(0.85, 0.1, 0.1), + opacity: 0.22, + rotate: degrees(45), + }); + } + return Buffer.from(await doc.save()); +} 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..ddcfa0b0 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, stampWatermark, 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,130 @@ 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."); + } + + // Any kind in the EXTERNAL phase — INITIAL applications AND renewals, which + // re-sign the agreement fresh each cycle. Gating on status alone keeps this in + // step with getIntakeState/getExternalStatus, which surface the button for any + // non-ACTIVE process (a kind filter here would render the button then 409). + const process = (user.household?.membership?.processes ?? []) + .filter((p) => 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; + } + + // On non-prod instances, mark the request + document as a DEV test so a + // signature can never be mistaken for a binding one — baked in server-side + // (CHECKIN_ENV), not editable by the applicant. Prod stays clean. The + // create/submit/embed flow is otherwise identical across envs. + const isProd = config.isProd(); + const pdf = isProd ? agreement.pdf : await stampWatermark(agreement.pdf, "DEV TEST — NOT A LEGAL AGREEMENT"); + const requestName = `${isProd ? "" : "[DEV TEST — NOT BINDING] "}Membership Agreement — ${recipientName}`; + + const created = await createRequest({ + token, + pdf, + filename: AGREEMENT_FILENAME, + recipientEmail, + recipientName, + requestName, + 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 }, + }); + + // Atomically claim the process for THIS request's ids. Two concurrent + // POSTs can both pass the null check above and both create a Zoho request; + // the conditional update (zohoEnvelopeId still null) lets only the first + // writer win. If we lost the race (count 0), discard our just-created + // request and reuse the winner's stored ids so the process keeps a single + // canonical signing request — our orphaned Zoho request simply expires. + const claim = await prisma.membershipProcess.updateMany({ + where: { id: process.id, zohoEnvelopeId: null }, + data: { zohoEnvelopeId: created.requestId, zohoActionId: created.actionId }, + }); + if (claim.count === 0) { + const winner = await prisma.membershipProcess.findUnique({ where: { id: process.id } }); + requestId = winner?.zohoEnvelopeId ?? null; + actionId = winner?.zohoActionId ?? null; + if (!requestId || !actionId) { + throw new ExternalError("wrong_phase", "Your signing request is still being prepared. Please try again in a moment."); + } + logger.info(`Concurrent signing request for membership process ${process.id}; reusing stored ${requestId}.`); + } else { + requestId = created.requestId; + actionId = created.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..4c9131bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "version": "0.1.0", "dependencies": { "@auth/prisma-adapter": "^2.11.1", + "@aws-sdk/client-s3": "^3.1075.0", "@mantine/core": "^7.17.8", "@mantine/form": "^7.17.8", "@mantine/hooks": "^7.17.8", @@ -39,6 +40,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", @@ -221,6 +223,31 @@ "node": ">=16.0.0" } }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", @@ -270,6 +297,50 @@ "tslib": "^2.6.2" } }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.8.tgz", + "integrity": "sha512-v0U9S7gBIme3OTgt1LdbAF4RpvavCc+4GK1+1xqAcqtbrHsEhjQo6R45LKcjhs/+WrRJij1Y0Gztw7QPAIeUfA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1075.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1075.0.tgz", + "integrity": "sha512-h1A6nIl1YX6Y45enGsTK7ef3ZrOnBiQJ1qF5R2K/nMWfsu6A9mc2Y5T66nxerABzyjjyyvign3MrzafnFoQKmA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/credential-provider-node": "^3.972.58", + "@aws-sdk/middleware-flexible-checksums": "^3.974.33", + "@aws-sdk/middleware-sdk-s3": "^3.972.54", + "@aws-sdk/signature-v4-multi-region": "^3.996.35", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-sns": { "version": "3.1068.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sns/-/client-sns-3.1068.0.tgz", @@ -292,13 +363,13 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.20", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.20.tgz", - "integrity": "sha512-7sDi2B2N3mc3nf1nz6FyEx/FCrJ1N1QnBmraHHQNabFaeAh2IaOOLml48/rHOD1bICHgTRkbBgNTvUzEr5Z35g==", + "version": "3.974.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.23.tgz", + "integrity": "sha512-MiWR/uWjxjFXGzrE0Ghc5lWxUxzHsUWFhV+OX7M4cR9SrmrnZs6TXavnCWnzzdwJeFri34xQo81rvGNzK3c4BQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.12", - "@aws-sdk/xml-builder": "^3.972.29", + "@aws-sdk/types": "^3.973.13", + "@aws-sdk/xml-builder": "^3.972.31", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.6", "@smithy/signature-v4": "^5.4.6", @@ -311,13 +382,13 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.46", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.46.tgz", - "integrity": "sha512-+GPXVS2srMOlH74S+SmC1gVuP2TvUZ0siuC0onKO93q+udP+M72dmY8wJfVQ5CX9z/9X5A1HHwz5yRIGBtskvQ==", + "version": "3.972.49", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.49.tgz", + "integrity": "sha512-liB3yQNHCM9k/gu/w36XHMKPluT7HTlnGUhRbBGSISDQkcr/Sy1zsZabiuvQj8WG5yW573u9RehrBvvnIQ9OEQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/types": "^3.973.12", + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" @@ -327,13 +398,13 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.48", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.48.tgz", - "integrity": "sha512-fA5loSdlocacRxyUXtpoHSMuk5rsIKRDzQYVMnMxjcmFeZshaJlJ8lymy/hYKji6sne/UmNGj5pxuEs6kq/Qcg==", + "version": "3.972.51", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.51.tgz", + "integrity": "sha512-XET0H2oofciJ5lMRWNIvRjAP7Q3wv2XT+JtJJEdhPWUMwe3TvQ9qcxonpu7vXmNngncvFpi4E2It+Tamas/naA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/types": "^3.973.12", + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", @@ -345,20 +416,20 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.53", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.53.tgz", - "integrity": "sha512-ZfdhIOR41q8TcWEnUac+gCOb+O2LBWdHLmjedXpXz4IEFW2ppNuFcm6p0sMTavpM+zD5TYfpH5Gp7guRyqSgsQ==", + "version": "3.972.56", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.56.tgz", + "integrity": "sha512-IAmc61hbgQiHht9U3x0tnRwz0lzdwOwD/i9voRgdJrKamF+JtmrBOsW9GwB7mfFonNWOWL4qARWYrF8veEMe3w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/credential-provider-env": "^3.972.46", - "@aws-sdk/credential-provider-http": "^3.972.48", - "@aws-sdk/credential-provider-login": "^3.972.52", - "@aws-sdk/credential-provider-process": "^3.972.46", - "@aws-sdk/credential-provider-sso": "^3.972.52", - "@aws-sdk/credential-provider-web-identity": "^3.972.52", - "@aws-sdk/nested-clients": "^3.997.20", - "@aws-sdk/types": "^3.973.12", + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/credential-provider-env": "^3.972.49", + "@aws-sdk/credential-provider-http": "^3.972.51", + "@aws-sdk/credential-provider-login": "^3.972.55", + "@aws-sdk/credential-provider-process": "^3.972.49", + "@aws-sdk/credential-provider-sso": "^3.972.55", + "@aws-sdk/credential-provider-web-identity": "^3.972.55", + "@aws-sdk/nested-clients": "^3.997.23", + "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", @@ -369,14 +440,14 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.52", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.52.tgz", - "integrity": "sha512-9hu2oR0qH7Fst5Tzdx+UWxm+w5zCXtErTLtOOW5hwwQc170CLwOeniRxyFY6s9mHfGEfC5zFukNBdKBwJR8mhQ==", + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.55.tgz", + "integrity": "sha512-hBBkANo3cDn+h2qxxzER4a+J8JCO9o9Z/YYmU7iky6AcaarX5RRdRcHNC6SLdwY0vAXQygn6soUbDqPn3GghaA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/nested-clients": "^3.997.20", - "@aws-sdk/types": "^3.973.12", + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/nested-clients": "^3.997.23", + "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" @@ -386,18 +457,18 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.55", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.55.tgz", - "integrity": "sha512-zMGLa/dhESVqmCD7mmIFFKSwSFrJGScvCXcjvBZEVOOMauFS5JRQvLTMukFpMEFWiV6dTAlsen2ATDBulLPtbg==", + "version": "3.972.58", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.58.tgz", + "integrity": "sha512-OyCLVmSI7pZO8hxwNVX6pXhTVlJqRBTp+ijdEfJSUj0RyjHnF602OfAarOzGq6wkGodeFkYBt8MmJ6A6ycRgWw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.46", - "@aws-sdk/credential-provider-http": "^3.972.48", - "@aws-sdk/credential-provider-ini": "^3.972.53", - "@aws-sdk/credential-provider-process": "^3.972.46", - "@aws-sdk/credential-provider-sso": "^3.972.52", - "@aws-sdk/credential-provider-web-identity": "^3.972.52", - "@aws-sdk/types": "^3.973.12", + "@aws-sdk/credential-provider-env": "^3.972.49", + "@aws-sdk/credential-provider-http": "^3.972.51", + "@aws-sdk/credential-provider-ini": "^3.972.56", + "@aws-sdk/credential-provider-process": "^3.972.49", + "@aws-sdk/credential-provider-sso": "^3.972.55", + "@aws-sdk/credential-provider-web-identity": "^3.972.55", + "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", @@ -408,13 +479,13 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.46", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.46.tgz", - "integrity": "sha512-VUoNFBIjWrUN8NbFiQiuxQEgFjvziAlBRPK+ddh27aj65gk0BYu6bLZnrdrNZwpW6vAihtSUtEMQ1PUJ32QRPA==", + "version": "3.972.49", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.49.tgz", + "integrity": "sha512-C8h36lBuC/RnBSsjlO+dn6xZm3KbAl5vpJaVPAfQnMmz2/OISmKOc8XZcqMQgO2ADwBYNRMM6Kf3vz9G/TulMQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/types": "^3.973.12", + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" @@ -424,15 +495,15 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.52", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.52.tgz", - "integrity": "sha512-nb2/n4o/HQf+FVpVbZe9vCTFngmuDoIsltMgLAtjixaKzvzhB4J8WSDFyWgnErgLHk55ctWH+I4PU+LIHhyffg==", + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.55.tgz", + "integrity": "sha512-1FkOz74Ea5QGS9jtIoXp55T/IkSS3spv+nLTT07fRY/+T5xmEOqaYBVIaEmX4zTNvbV6g2lrtlaVKWEoNyJt3w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/nested-clients": "^3.997.20", - "@aws-sdk/token-providers": "3.1066.0", - "@aws-sdk/types": "^3.973.12", + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/nested-clients": "^3.997.23", + "@aws-sdk/token-providers": "3.1074.0", + "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" @@ -442,14 +513,44 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.52", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.52.tgz", - "integrity": "sha512-lKj6aRSGbqLmpYmM24bY7a1Xmfcq2vkE3hv8CSPYfc1yCu0BPu/XEJ1L4Fm61MsU6ULLNSG8UGsffNoFUBjESA==", + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.55.tgz", + "integrity": "sha512-g2BoECD1q01kTPByi56+VLVvdWDzMkKIcr77qixpqH0okw2t0U5CoPv+6S8v/D1Y2Wa6QKKtn6XAtDzP+Kfpvg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/nested-clients": "^3.997.20", - "@aws-sdk/types": "^3.973.12", + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/nested-clients": "^3.997.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.974.33", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.33.tgz", + "integrity": "sha512-qMgQSPemQq2/eW/e/0+SpY4kYR5L7dUgBiVdEc5bd+ztHNv07ZMYiI+sTiir3TgKndFfglSw/VFi7oZJ6bZ63g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1000.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.54", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.54.tgz", + "integrity": "sha512-GDfDQ0gwLFRKN9gWIKcmVrHJ3e7XagnY7N1LLzMVNgnOnuY7f/ALgmy3CuBjosWD95T/Z6e+gs1IeWmLPkyLKQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/signature-v4-multi-region": "^3.996.35", + "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" @@ -459,16 +560,16 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.20", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.20.tgz", - "integrity": "sha512-IYJuLpXp2DEILVQpQOy0PMpkftv0AHEOCn52o0atyOaumA0CdWQ3klPyXdViGYLbNpESsVFMVybvHUeZAuiGxA==", + "version": "3.997.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.23.tgz", + "integrity": "sha512-gO93ZPsI2bxeFZD42f1/qjDw6FAZkNZcKRO94LIiT03fzOmcJ9e/tunxjVjA1Rl69ClmVJzz8H3G9CdKef10PA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/signature-v4-multi-region": "^3.996.34", - "@aws-sdk/types": "^3.973.12", + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/signature-v4-multi-region": "^3.996.35", + "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", @@ -480,12 +581,12 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.34", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.34.tgz", - "integrity": "sha512-mx1L5qlumSOt/nKM3BFaHE2HVkWwz0i4Bw0pyYO42FfX/FeLlo8YI6csC0gSPprEk6fTIqI+CZN9RwUwKd5krQ==", + "version": "3.996.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.35.tgz", + "integrity": "sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.12", + "@aws-sdk/types": "^3.973.13", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" @@ -495,14 +596,14 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1066.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1066.0.tgz", - "integrity": "sha512-UqEUJq7dqa44hneLDUcX7UJy95cg8YqEWyakRpvIPnrNS3Mq+UlQHgCDGu5pvwAPtlIW4qcYbvW6reG6++FyvA==", + "version": "3.1074.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1074.0.tgz", + "integrity": "sha512-pv80IzgGW4RnXWtft692chZOM9i6PhebVsLCcnaM4dBEPZva2fE6FXAHs76G7Rc7s3yGyX/68G0nZMrUy+Vmpg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/nested-clients": "^3.997.20", - "@aws-sdk/types": "^3.973.12", + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/nested-clients": "^3.997.23", + "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" @@ -512,9 +613,9 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.12.tgz", - "integrity": "sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA==", + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz", + "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.14.3", @@ -537,13 +638,12 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.29.tgz", - "integrity": "sha512-fk0niuGFxfi8yIJuMVM4mhwObkiQSuwZFj3tAPrLVx64Pk3BkrEIpqjzHKY4hKoEBUD6Jg/S74Zj9jy+5F3DnQ==", + "version": "3.972.31", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.31.tgz", + "integrity": "sha512-SzE4Pgyl+hDF+BuyuzxUSpwnuUu9lJuO1YGgteG89/4Qv0+2IQiVQqdbPV32IozLvXWQChPQcdkk/sKvb1QHiQ==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.14.3", - "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" }, "engines": { @@ -3116,18 +3216,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@nodable/entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/nodable" - } - ], - "license": "MIT" - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3205,6 +3293,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", @@ -4165,13 +4271,13 @@ } }, "node_modules/@smithy/core": { - "version": "3.24.7", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.7.tgz", - "integrity": "sha512-KoUi4M1f3BG6kzN1FnCwL7oyFptTbyBJKjR6yhSib+JHRdUmM1o+VwsFtJ66NZCkCzVfJMWRHJNo0R0jznp0Pg==", + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.26.0.tgz", + "integrity": "sha512-mLUktFAn+Pa2agl1J7VgtYNFWCX8/b4GMJSK1hCu4YCvtBfM6F8Os3EP4ry+DFFlXOf3wyvlgXhuUdFoy52D3g==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.14.4", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -4179,13 +4285,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.3.9", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.9.tgz", - "integrity": "sha512-ZlfJ/4Fa3jYb+3eaohPfG9utX9HmdhFNcFtpoGAhUhdynAOmGXtmigbi7eEiONKM+ykHw8RwKuDEb85Lx7t7fA==", + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.2.tgz", + "integrity": "sha512-18UMDMyrAbDcpmL1gLUA7ww0fRTcdCrSjSJOi2Sbld+tVjwD/pW+OAwjlScFLR7vvBnhZrIPQ7kVuTf1mnJLug==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.24.7", - "@smithy/types": "^4.14.4", + "@smithy/core": "^3.26.0", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -4247,9 +4353,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.14.4", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.4.tgz", - "integrity": "sha512-B2S9+UGm1+/pHkcx3ZoLVX1a+pmSk8rqxRR+ZsNqZaJ5q9FWX9AFGQVM4qG5+OBeQUZVy99HY8HqW8gK/wgXzQ==", + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.0.tgz", + "integrity": "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -5523,18 +5629,6 @@ "node": ">= 8" } }, - "node_modules/anynum": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.0.tgz", - "integrity": "sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, "node_modules/archiver": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", @@ -8678,43 +8772,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fast-xml-builder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.5.0", - "xml-naming": "^0.1.0" - } - }, - "node_modules/fast-xml-parser": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", - "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "@nodable/entities": "^2.1.0", - "fast-xml-builder": "^1.1.7", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.2.3" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -14230,21 +14287,6 @@ "node": ">=8" } }, - "node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -14300,6 +14342,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", @@ -16542,21 +16602,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strnum": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.0.tgz", - "integrity": "sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "anynum": "^1.0.0" - } - }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -18285,21 +18330,6 @@ "node": ">=18" } }, - "node_modules/xml-naming": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",