Files
red/.planning/phases/06-signing-flow/06-02-PLAN.md
2026-03-20 11:18:47 -06:00

16 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
06-signing-flow 02 execute 2
06-01
teressa-copeland-homes/src/emails/SigningRequestEmail.tsx
teressa-copeland-homes/src/lib/signing/signing-mailer.tsx
teressa-copeland-homes/src/app/api/documents/[id]/send/route.ts
teressa-copeland-homes/src/app/api/documents/[id]/prepare/route.ts
true
SIGN-01
LEGAL-01
truths artifacts key_links
POST /api/documents/[id]/send creates a signing token, stores jti in signingTokens, sends a branded HTML email with the signing URL, logs email_sent audit event, and updates document status to Sent
The email sender is 'Teressa Copeland <teressa@teressacopelandhomes.com>'
The email body includes document name, expiry date, and a prominent CTA button linking to /sign/[token]
Agent receives notification email when client completes signing (logged on POST /api/sign/[token] completion — wired in plan 04)
POST /api/documents/[id]/prepare now logs a document_prepared audit event
npm run build passes cleanly
path provides exports
teressa-copeland-homes/src/emails/SigningRequestEmail.tsx Branded React Email component for signing request
SigningRequestEmail
path provides exports
teressa-copeland-homes/src/lib/signing/signing-mailer.tsx sendSigningRequestEmail(), sendAgentNotificationEmail()
sendSigningRequestEmail
sendAgentNotificationEmail
path provides
teressa-copeland-homes/src/app/api/documents/[id]/send/route.ts POST handler: create token, send email, log audit event, update status
from to via
send/route.ts createSigningToken() in token.ts import and call to create JWT + DB row
from to via
send/route.ts logAuditEvent() in audit.ts logs email_sent after sendMail resolves
from to via
signing-mailer.tsx SigningRequestEmail.tsx @react-email/render renders component to HTML string
from to via
prepare/route.ts logAuditEvent() logs document_prepared event after successful PDF preparation
Implement the email delivery layer: branded signing request email, agent notification, and the POST /api/documents/[id]/send endpoint that kicks off the signing ceremony.

Purpose: SIGN-01 requires clients receive a unique link via email with no account required. LEGAL-01 requires logging document_prepared and email_sent audit events. Output: Branded HTML email component (React Email), signing-mailer utilities, send API route, and document_prepared audit logging added to existing prepare route.

<execution_context> @/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md @/Users/ccopeland/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/06-signing-flow/06-CONTEXT.md @.planning/phases/06-signing-flow/06-RESEARCH.md @.planning/phases/06-signing-flow/06-01-SUMMARY.md

From teressa-copeland-homes/src/lib/signing/token.ts:

export async function createSigningToken(documentId: string): Promise<{ token: string; jti: string; expiresAt: Date }>

From teressa-copeland-homes/src/lib/signing/audit.ts:

type AuditEventType = 'document_prepared' | 'email_sent' | 'link_opened' | 'document_viewed' | 'signature_submitted' | 'pdf_hash_computed';
export async function logAuditEvent(opts: {
  documentId: string;
  eventType: AuditEventType;
  ipAddress?: string;
  userAgent?: string;
  metadata?: Record<string, unknown>;
}): Promise<void>

From existing prepare route (teressa-copeland-homes/src/app/api/documents/[id]/prepare/route.ts):

  • POST handler that calls preparePdf(), updates preparedFilePath/textFillData/assignedClientId/status to Sent
  • Currently does NOT log any audit events — Task 2 adds document_prepared logging here

From existing contact-mailer.ts (SMTP pattern reference):

import nodemailer from 'nodemailer';
const transporter = nodemailer.createTransport({
  host: process.env.CONTACT_SMTP_HOST,
  port: Number(process.env.CONTACT_SMTP_PORT ?? 587),
  secure: false,
  auth: { user: process.env.CONTACT_EMAIL_USER, pass: process.env.CONTACT_EMAIL_PASS },
});

Reuse the same CONTACT_SMTP_* env vars for the signing email — same SMTP provider.

Task 1: Branded signing request email + mailer teressa-copeland-homes/src/emails/SigningRequestEmail.tsx teressa-copeland-homes/src/lib/signing/signing-mailer.tsx Create directory: teressa-copeland-homes/src/emails/

src/emails/SigningRequestEmail.tsx — React Email component for signing request: The email must match locked decisions from CONTEXT.md:

  • Sender name: "Teressa Copeland"
  • Body: document name, expiry deadline, instruction "No account needed — just click the button below"
  • Prominent "Review & Sign" CTA button

Use @react-email/components primitives (Html, Head, Body, Container, Heading, Text, Button, Hr, Preview). Brand colors: navy #1B2B4B, gold #C9A84C, cream/white background.

// src/emails/SigningRequestEmail.tsx
import {
  Html, Head, Body, Container, Heading, Text, Button, Hr, Preview
} from '@react-email/components';

interface SigningRequestEmailProps {
  documentName: string;
  signingUrl: string;
  expiryDate: string; // e.g. "March 25, 2026"
  clientName?: string;
}

export function SigningRequestEmail({ documentName, signingUrl, expiryDate, clientName }: SigningRequestEmailProps) {
  return (
    <Html>
      <Head />
      <Preview>Please review and sign: {documentName}</Preview>
      <Body style={{ backgroundColor: '#ffffff', fontFamily: 'Georgia, serif' }}>
        <Container style={{ maxWidth: '560px', margin: '40px auto', padding: '0 20px' }}>
          <Heading style={{ color: '#1B2B4B', fontSize: '22px', marginBottom: '8px' }}>
            Teressa Copeland Homes
          </Heading>
          <Hr style={{ borderColor: '#C9A84C', marginBottom: '24px' }} />
          {clientName && (
            <Text style={{ color: '#333', fontSize: '16px' }}>Hello {clientName},</Text>
          )}
          <Text style={{ color: '#333', fontSize: '16px', lineHeight: '1.6' }}>
            You have a document ready for your review and signature:
          </Text>
          <Text style={{ color: '#1B2B4B', fontSize: '18px', fontWeight: 'bold', margin: '16px 0' }}>
            {documentName}
          </Text>
          <Text style={{ color: '#555', fontSize: '15px' }}>
            No account needed  just click the button below.
          </Text>
          <Button
            href={signingUrl}
            style={{
              backgroundColor: '#C9A84C',
              color: '#ffffff',
              padding: '14px 32px',
              borderRadius: '4px',
              fontSize: '16px',
              fontWeight: 'bold',
              textDecoration: 'none',
              display: 'inline-block',
              margin: '20px 0',
            }}
          >
            Review &amp; Sign
          </Button>
          <Text style={{ color: '#888', fontSize: '13px' }}>
            This link expires on {expiryDate}. If you did not expect this document, you can safely ignore this email.
          </Text>
          <Hr style={{ borderColor: '#e0e0e0', marginTop: '32px' }} />
          <Text style={{ color: '#aaa', fontSize: '12px' }}>
            Teressa Copeland Homes · Utah Licensed Real Estate Agent
          </Text>
        </Container>
      </Body>
    </Html>
  );
}

Also create a minimal agent notification component inline in signing-mailer.tsx (no separate file needed — it is simpler).

src/lib/signing/signing-mailer.tsx — mailer functions. IMPORTANT: Must be .tsx extension (not .ts) because it calls render() with JSX:

import { render } from '@react-email/render';
import nodemailer from 'nodemailer';
import { SigningRequestEmail } from '@/emails/SigningRequestEmail';
import React from 'react';

function createTransporter() {
  return nodemailer.createTransport({
    host: process.env.CONTACT_SMTP_HOST!,
    port: Number(process.env.CONTACT_SMTP_PORT ?? 587),
    secure: false,
    auth: {
      user: process.env.CONTACT_EMAIL_USER!,
      pass: process.env.CONTACT_EMAIL_PASS!,
    },
  });
}

export async function sendSigningRequestEmail(opts: {
  to: string;
  clientName?: string;
  documentName: string;
  signingUrl: string;
  expiresAt: Date;
}): Promise<void> {
  const expiryDate = opts.expiresAt.toLocaleDateString('en-US', {
    month: 'long', day: 'numeric', year: 'numeric',
  });
  const html = await render(
    React.createElement(SigningRequestEmail, {
      documentName: opts.documentName,
      signingUrl: opts.signingUrl,
      expiryDate,
      clientName: opts.clientName,
    })
  );
  const transporter = createTransporter();
  await transporter.sendMail({
    from: '"Teressa Copeland" <teressa@teressacopelandhomes.com>',
    to: opts.to,
    subject: `Please sign: ${opts.documentName}`,
    html,
  });
}

export async function sendAgentNotificationEmail(opts: {
  clientName: string;
  documentName: string;
  signedAt: Date;
}): Promise<void> {
  const formattedTime = opts.signedAt.toLocaleString('en-US', {
    month: 'long', day: 'numeric', year: 'numeric',
    hour: 'numeric', minute: '2-digit', timeZoneName: 'short',
  });
  const transporter = createTransporter();
  await transporter.sendMail({
    from: '"Teressa Copeland Homes" <teressa@teressacopelandhomes.com>',
    to: 'teressa@teressacopelandhomes.com',
    subject: `Signed: ${opts.documentName}`,
    text: `${opts.clientName} has signed "${opts.documentName}" on ${formattedTime}.`,
  });
}
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | tail -5 SigningRequestEmail.tsx and signing-mailer.tsx exist and compile; npm run build passes Task 2: Send API route + document_prepared audit logging teressa-copeland-homes/src/app/api/documents/[id]/send/route.ts teressa-copeland-homes/src/app/api/documents/[id]/prepare/route.ts **Create src/app/api/documents/[id]/send/route.ts** — POST handler (agent-authenticated):

Logic:

  1. Auth guard (same pattern as all portal API routes: const session = await auth(); if (!session) return new Response('Unauthorized', { status: 401 }))
  2. Resolve params: const { id } = await params (Next.js 16 — params is a Promise)
  3. Fetch document from DB — if not found return 404; if no preparedFilePath return 422 ("Document not yet prepared"); if status is 'Signed' return 409 ("Already signed")
  4. Fetch client email from assignedClientId (fall back to doc.clientId for the email lookup)
  5. Call createSigningToken(doc.id) — returns { token, jti, expiresAt }
  6. Build signing URL: ${process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000'}/sign/${token}
  7. Call sendSigningRequestEmail({ to: clientEmail, clientName, documentName: doc.name, signingUrl, expiresAt })
  8. Log audit event: await logAuditEvent({ documentId: doc.id, eventType: 'email_sent' })
  9. Update document status to 'Sent', set sentAt = now() (only if not already Sent — use db.update with WHERE status != 'Signed')
  10. Return 200 JSON: { ok: true, expiresAt: expiresAt.toISOString() }

Error handling: wrap entire body in try/catch; if sendMail throws, return 502 with error message (do NOT update DB status if email failed).

// src/app/api/documents/[id]/send/route.ts
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { documents, clients } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { createSigningToken } from '@/lib/signing/token';
import { sendSigningRequestEmail } from '@/lib/signing/signing-mailer';
import { logAuditEvent } from '@/lib/signing/audit';

export async function POST(
  _req: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const session = await auth();
  if (!session) return new Response('Unauthorized', { status: 401 });

  const { id } = await params;

  try {
    const doc = await db.query.documents.findFirst({
      where: eq(documents.id, id),
    });
    if (!doc) return NextResponse.json({ error: 'Document not found' }, { status: 404 });
    if (!doc.preparedFilePath) return NextResponse.json({ error: 'Document not yet prepared' }, { status: 422 });
    if (doc.status === 'Signed') return NextResponse.json({ error: 'Already signed' }, { status: 409 });

    // Resolve recipient: prefer assignedClientId, fall back to clientId
    const clientId = doc.assignedClientId ?? doc.clientId;
    const client = await db.query.clients.findFirst({ where: eq(clients.id, clientId) });
    if (!client) return NextResponse.json({ error: 'Client not found' }, { status: 422 });

    const { token, expiresAt } = await createSigningToken(doc.id);
    const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000';
    const signingUrl = `${baseUrl}/sign/${token}`;

    await sendSigningRequestEmail({
      to: client.email,
      clientName: client.name,
      documentName: doc.name,
      signingUrl,
      expiresAt,
    });

    await logAuditEvent({ documentId: doc.id, eventType: 'email_sent' });

    // Update status to Sent (skip if already Sent or Signed to avoid downgrade)
    if (doc.status === 'Draft') {
      await db.update(documents)
        .set({ status: 'Sent', sentAt: new Date() })
        .where(eq(documents.id, id));
    }

    return NextResponse.json({ ok: true, expiresAt: expiresAt.toISOString() });
  } catch (err) {
    console.error('[send] error:', err);
    return NextResponse.json({ error: 'Failed to send signing email' }, { status: 502 });
  }
}

Modify src/app/api/documents/[id]/prepare/route.ts — add document_prepared audit log after the existing preparePdf() + db.update() success path. Find the line where status is updated to 'Sent' and add immediately after:

await logAuditEvent({ documentId: id, eventType: 'document_prepared' });

Import logAuditEvent at the top of prepare/route.ts:

import { logAuditEvent } from '@/lib/signing/audit';

IMPORTANT: The prepare route already marks status 'Sent'. In the new signing flow, PreparePanel will call prepare (fills text + places rects) and then a separate "Send" button calls POST /api/documents/[id]/send. The document_prepared event must be logged in prepare/route.ts where the PDF is actually prepared — not in send/route.ts. cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "error|Error|✓" | tail -10 send/route.ts exists; prepare/route.ts imports and calls logAuditEvent; npm run build passes with no TypeScript errors; curl POST /api/documents/{id}/send on an unauthenticated request returns 401

- Build passes: `npm run build` exits 0 - send route file exists: `ls teressa-copeland-homes/src/app/api/documents/*/send/route.ts` - Email template: `ls teressa-copeland-homes/src/emails/SigningRequestEmail.tsx` - prepare/route.ts references logAuditEvent: `grep logAuditEvent teressa-copeland-homes/src/app/api/documents/*/prepare/route.ts`

<success_criteria> Email delivery layer complete when: SigningRequestEmail.tsx renders without error, signing-mailer.tsx exports both functions, send/route.ts exists and returns 401 unauthenticated, prepare/route.ts logs document_prepared, npm run build passes. </success_criteria>

After completion, create `.planning/phases/06-signing-flow/06-02-SUMMARY.md`