Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/block-pending-issue.yml
Original file line number Diff line number Diff line change
@@ -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."
7 changes: 7 additions & 0 deletions checkin-app/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions checkin-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions checkin-app/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ model MembershipProcess {
/// @sensitivity:internal
zohoEnvelopeId String?
/// @sensitivity:internal
zohoActionId String?
/// @sensitivity:internal
contractSignedAt DateTime?
/// @sensitivity:internal
bgConsentAt DateTime?
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof SIGN>[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';
});
});
36 changes: 36 additions & 0 deletions checkin-app/src/app/api/membership/contract/sign/route.ts
Original file line number Diff line number Diff line change
@@ -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<ExternalErrorCode, number> = {
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 });
}
});
38 changes: 33 additions & 5 deletions checkin-app/src/app/membership/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ interface PersonPrefill {

interface ExternalStatus {
contractSigned: boolean;
contractStarted: boolean;
bgConsented: boolean;
deepLinkUrl: string | null;
}
Expand Down Expand Up @@ -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("");
Expand Down Expand Up @@ -476,11 +499,16 @@ export default function MembershipPage() {
</Text>
</div>

<ExternalTask done={!!state.external?.contractSigned} title="Sign your membership contract" doneText="Contract signed — thank you!">
<Text c="dimmed">
We&apos;ve sent your membership contract via Zoho Sign. Please check your email
and sign it. This page updates automatically once it&apos;s signed.
</Text>
<ExternalTask done={!!state.external?.contractSigned} title="Sign your membership agreement" doneText="Agreement signed — thank you!">
<Stack gap="xs" align="flex-start">
<Text c="dimmed">
Sign your personalized membership agreement online. This page updates
automatically once it&apos;s signed.
</Text>
<Button color="green" disabled={saving} loading={saving} onClick={startSigning}>
{state.external?.contractStarted ? "Resume signing →" : "Sign your membership agreement →"}
</Button>
</Stack>
</ExternalTask>

<ExternalTask done={!!state.external?.bgConsented} title="Consent to a background check" doneText="Background-check consent received.">
Expand Down
12 changes: 12 additions & 0 deletions checkin-app/src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading