docs(06-signing-flow): create phase plan
This commit is contained in:
384
.planning/phases/06-signing-flow/06-02-PLAN.md
Normal file
384
.planning/phases/06-signing-flow/06-02-PLAN.md
Normal file
@@ -0,0 +1,384 @@
|
||||
---
|
||||
phase: 06-signing-flow
|
||||
plan: "02"
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- "06-01"
|
||||
files_modified:
|
||||
- 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
|
||||
autonomous: true
|
||||
requirements:
|
||||
- SIGN-01
|
||||
- LEGAL-01
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "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"
|
||||
artifacts:
|
||||
- path: "teressa-copeland-homes/src/emails/SigningRequestEmail.tsx"
|
||||
provides: "Branded React Email component for signing request"
|
||||
exports: ["SigningRequestEmail"]
|
||||
- path: "teressa-copeland-homes/src/lib/signing/signing-mailer.tsx"
|
||||
provides: "sendSigningRequestEmail(), sendAgentNotificationEmail()"
|
||||
exports: ["sendSigningRequestEmail", "sendAgentNotificationEmail"]
|
||||
- path: "teressa-copeland-homes/src/app/api/documents/[id]/send/route.ts"
|
||||
provides: "POST handler: create token, send email, log audit event, update status"
|
||||
key_links:
|
||||
- from: "send/route.ts"
|
||||
to: "createSigningToken() in token.ts"
|
||||
via: "import and call to create JWT + DB row"
|
||||
- from: "send/route.ts"
|
||||
to: "logAuditEvent() in audit.ts"
|
||||
via: "logs email_sent after sendMail resolves"
|
||||
- from: "signing-mailer.tsx"
|
||||
to: "SigningRequestEmail.tsx"
|
||||
via: "@react-email/render renders component to HTML string"
|
||||
- from: "prepare/route.ts"
|
||||
to: "logAuditEvent()"
|
||||
via: "logs document_prepared event after successful PDF preparation"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/ccopeland/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<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
|
||||
|
||||
<interfaces>
|
||||
<!-- From Plan 06-01 — signing utilities now available -->
|
||||
|
||||
From teressa-copeland-homes/src/lib/signing/token.ts:
|
||||
```typescript
|
||||
export async function createSigningToken(documentId: string): Promise<{ token: string; jti: string; expiresAt: Date }>
|
||||
```
|
||||
|
||||
From teressa-copeland-homes/src/lib/signing/audit.ts:
|
||||
```typescript
|
||||
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):
|
||||
```typescript
|
||||
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.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Branded signing request email + mailer</name>
|
||||
<files>
|
||||
teressa-copeland-homes/src/emails/SigningRequestEmail.tsx
|
||||
teressa-copeland-homes/src/lib/signing/signing-mailer.tsx
|
||||
</files>
|
||||
<action>
|
||||
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.
|
||||
|
||||
```typescript
|
||||
// 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 & 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:
|
||||
```typescript
|
||||
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}.`,
|
||||
});
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | tail -5</automated>
|
||||
</verify>
|
||||
<done>SigningRequestEmail.tsx and signing-mailer.tsx exist and compile; npm run build passes</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Send API route + document_prepared audit logging</name>
|
||||
<files>
|
||||
teressa-copeland-homes/src/app/api/documents/[id]/send/route.ts
|
||||
teressa-copeland-homes/src/app/api/documents/[id]/prepare/route.ts
|
||||
</files>
|
||||
<action>
|
||||
**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).
|
||||
|
||||
```typescript
|
||||
// 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:
|
||||
|
||||
```typescript
|
||||
await logAuditEvent({ documentId: id, eventType: 'document_prepared' });
|
||||
```
|
||||
|
||||
Import logAuditEvent at the top of prepare/route.ts:
|
||||
```typescript
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "error|Error|✓" | tail -10</automated>
|
||||
</verify>
|
||||
<done>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</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- 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`
|
||||
</verification>
|
||||
|
||||
<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>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-signing-flow/06-02-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user