docs(07-audit-trail-and-download): create phase 7 plan

3 plans in 3 sequential waves: agent download token + API route (01),
UI wiring for download button + signedAt column (02), human verification
checkpoint (03). Covers SIGN-07 and LEGAL-03.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chandler Copeland
2026-03-21 10:30:05 -06:00
parent 45f49ce498
commit 9fe7936304
4 changed files with 794 additions and 3 deletions

View File

@@ -137,9 +137,12 @@ Plans:
1. Agent can download the signed PDF from the dashboard via an authenticated presigned URL (5-minute TTL)
2. Signed PDFs are stored in a private local directory (not publicly accessible) — a direct or guessable URL returns an access error, not the file
3. Document status in the dashboard updates correctly to "Signed" after a signing ceremony completes
**Plans**: TBD
**Plans**: 3 plans
Plans: none yet
Plans:
- [ ] 07-01-PLAN.md — Agent download token utilities (createAgentDownloadToken/verifyAgentDownloadToken in token.ts) + GET /api/documents/[id]/download route with 5-min presigned JWT and path traversal guard
- [ ] 07-02-PLAN.md — PreparePanel Signed-state panel with Download button, document detail page server-side token generation, DocumentsTable Date Signed column, dashboard signedAt select
- [ ] 07-03-PLAN.md — Full Phase 7 human verification checkpoint (SIGN-07 + LEGAL-03)
## Progress
@@ -154,4 +157,4 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7
| 4. PDF Ingest | 4/4 | Complete | 2026-03-20 |
| 5. PDF Fill and Field Mapping | 3/4 | In Progress| |
| 6. Signing Flow | 6/6 | Complete | 2026-03-21 |
| 7. Audit Trail and Download | 0/? | Not started | - |
| 7. Audit Trail and Download | 0/3 | Not started | - |

View File

@@ -0,0 +1,284 @@
---
phase: 07-audit-trail-and-download
plan: "01"
type: execute
wave: 1
depends_on: []
files_modified:
- teressa-copeland-homes/src/lib/signing/token.ts
- teressa-copeland-homes/src/app/api/documents/[id]/download/route.ts
autonomous: true
requirements:
- SIGN-07
- LEGAL-03
must_haves:
truths:
- "GET /api/documents/[id]/download?adt=[token] streams the signed PDF when the agent-download JWT is valid"
- "A missing or expired adt token returns 401 — no file served"
- "An adt token for document A cannot download document B (route ID vs token documentId mismatch returns 403)"
- "A signedFilePath containing path traversal characters returns 403"
- "A document with no signedFilePath (unsigned) returns 404"
artifacts:
- path: "teressa-copeland-homes/src/lib/signing/token.ts"
provides: "createAgentDownloadToken and verifyAgentDownloadToken exports"
contains: "purpose: 'agent-download'"
- path: "teressa-copeland-homes/src/app/api/documents/[id]/download/route.ts"
provides: "GET handler streaming signed PDF for authenticated agent download"
exports: ["GET"]
key_links:
- from: "teressa-copeland-homes/src/app/api/documents/[id]/download/route.ts"
to: "teressa-copeland-homes/src/lib/signing/token.ts"
via: "verifyAgentDownloadToken import"
pattern: "verifyAgentDownloadToken"
- from: "route.ts download handler"
to: "uploads/ directory on disk"
via: "readFile + path.join(UPLOADS_DIR, signedFilePath) + startsWith guard"
pattern: "absPath.startsWith\\(UPLOADS_DIR\\)"
---
<objective>
Add agent-authenticated PDF download: extend token.ts with agent-download JWT utilities and create GET /api/documents/[id]/download route that streams signed PDFs behind a 5-min presigned token.
Purpose: Satisfy LEGAL-03 (signed PDFs never accessible via guessable public URLs; agent downloads via authenticated presigned URLs only) and provide the API surface SIGN-07 requires.
Output: Two files — updated token.ts with agent download token functions, new download API 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/STATE.md
@.planning/phases/07-audit-trail-and-download/07-RESEARCH.md
<interfaces>
<!-- Existing token.ts exports — extend this file, do not replace -->
<!-- Source: teressa-copeland-homes/src/lib/signing/token.ts -->
```typescript
import { SignJWT, jwtVerify } from 'jose';
import { db } from '@/lib/db';
import { signingTokens } from '@/lib/db/schema';
const getSecret = () => new TextEncoder().encode(process.env.SIGNING_JWT_SECRET!);
// Existing exports (keep all of these):
export async function createSigningToken(documentId: string): Promise<{ token: string; jti: string; expiresAt: Date }>
export async function verifySigningToken(token: string): Promise<{ documentId: string; jti: string; exp: number }>
// Client download token — purpose: 'download', 15-min TTL, no DB record
export async function createDownloadToken(documentId: string): Promise<string>
export async function verifyDownloadToken(token: string): Promise<{ documentId: string }>
// Phase 7 adds agent download token — purpose: 'agent-download', 5-min TTL, no DB record
// ADD: createAgentDownloadToken and verifyAgentDownloadToken
```
<!-- Existing client download route — reference implementation to mirror -->
<!-- Source: teressa-copeland-homes/src/app/api/sign/[token]/download/route.ts -->
```typescript
// Pattern: query param token (dt=) → verify → path traversal guard → readFile → Response
// Agent route uses same pattern with adt= query param and purpose: 'agent-download'
const UPLOADS_DIR = path.join(process.cwd(), 'uploads');
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const dt = url.searchParams.get('dt');
// ... verifyDownloadToken(dt) → doc.signedFilePath → absPath traversal guard → readFile → Response
const absPath = path.join(UPLOADS_DIR, doc.signedFilePath);
if (!absPath.startsWith(UPLOADS_DIR)) return 403;
// new Response(new Uint8Array(fileBuffer), { 'Content-Disposition': 'attachment; filename=...' })
}
```
<!-- documents table columns relevant to download -->
<!-- Source: teressa-copeland-homes/src/lib/db/schema.ts lines 49-62 -->
```typescript
status: documentStatusEnum("status").notNull().default("Draft"),
signedFilePath: text("signed_file_path"), // null until signed
pdfHash: text("pdf_hash"),
signedAt: timestamp("signed_at"), // null until signed
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add agent download token functions to token.ts</name>
<files>teressa-copeland-homes/src/lib/signing/token.ts</files>
<action>
Append two new exported functions to the end of the existing token.ts. Do NOT modify or remove any existing functions.
Add:
```typescript
// Agent download token — purpose: 'agent-download', 5-min TTL, no DB record
// Generated server-side only (server component or API route). Never in a client component.
export async function createAgentDownloadToken(documentId: string): Promise<string> {
return await new SignJWT({ documentId, purpose: 'agent-download' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('5m')
.sign(getSecret());
}
export async function verifyAgentDownloadToken(token: string): Promise<{ documentId: string }> {
const { payload } = await jwtVerify(token, getSecret());
if (payload['purpose'] !== 'agent-download') throw new Error('Not an agent download token');
return { documentId: payload['documentId'] as string };
}
```
This is consistent with the existing createDownloadToken/verifyDownloadToken pattern (purpose: 'download', 15m TTL) — same signing secret, different purpose string, shorter TTL per success criterion.
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -20</automated>
</verify>
<done>token.ts exports createAgentDownloadToken and verifyAgentDownloadToken; tsc --noEmit passes; existing exports are untouched</done>
</task>
<task type="auto">
<name>Task 2: Create GET /api/documents/[id]/download route</name>
<files>teressa-copeland-homes/src/app/api/documents/[id]/download/route.ts</files>
<action>
Create the directory and file `teressa-copeland-homes/src/app/api/documents/[id]/download/route.ts`.
This is the agent-facing download endpoint. It mirrors the existing `/api/sign/[token]/download/route.ts` pattern exactly, substituting `verifyAgentDownloadToken` for `verifyDownloadToken` and adding a document ID cross-check.
Implementation:
```typescript
import { NextRequest } from 'next/server';
import { verifyAgentDownloadToken } from '@/lib/signing/token';
import { db } from '@/lib/db';
import { documents } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import path from 'node:path';
import { readFile } from 'node:fs/promises';
const UPLOADS_DIR = path.join(process.cwd(), 'uploads');
// GET /api/documents/[id]/download?adt=[agentDownloadToken]
// Requires: valid agent-download JWT in adt query param (generated server-side in document detail page)
// No Auth.js session check at this route — the short-lived JWT IS the credential (same as client download pattern)
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const url = new URL(req.url);
const adt = url.searchParams.get('adt');
if (!adt) {
return new Response(JSON.stringify({ error: 'Missing download token' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
let documentId: string;
try {
const verified = await verifyAgentDownloadToken(adt);
documentId = verified.documentId;
} catch {
return new Response(JSON.stringify({ error: 'Download link expired or invalid' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
// Defense in depth: token documentId must match route [id] param
if (documentId !== id) {
return new Response(JSON.stringify({ error: 'Forbidden' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
const doc = await db.query.documents.findFirst({
where: eq(documents.id, documentId),
columns: { id: true, name: true, signedFilePath: true },
});
if (!doc || !doc.signedFilePath) {
return new Response(JSON.stringify({ error: 'Signed PDF not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
// Path traversal guard — required on every file read from uploads/
const absPath = path.join(UPLOADS_DIR, doc.signedFilePath);
if (!absPath.startsWith(UPLOADS_DIR)) {
return new Response(JSON.stringify({ error: 'Forbidden' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
let fileBuffer: Buffer;
try {
fileBuffer = await readFile(absPath);
} catch {
return new Response(JSON.stringify({ error: 'File not found on disk' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
const safeName = doc.name.replace(/[^a-zA-Z0-9-_ ]/g, '');
return new Response(new Uint8Array(fileBuffer), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${safeName}_signed.pdf"`,
},
});
}
```
Key invariants:
- `new Uint8Array(fileBuffer)` not `fileBuffer` directly — required for Next.js 16 TypeScript strict mode (Buffer is not assignable to BodyInit; established in Phase 6)
- `absPath.startsWith(UPLOADS_DIR)` traversal guard before every readFile — never skip
- Token `documentId` === route `id` cross-check — prevents token for doc A downloading doc B
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -20 && npm run build 2>&1 | tail -10</automated>
</verify>
<done>
Route file exists at correct path; tsc --noEmit passes; npm run build passes;
GET /api/documents/[id]/download without adt returns 401;
GET /api/documents/[id]/download with expired/invalid token returns 401;
GET /api/documents/[id]/download with valid token for a non-existent docId returns 404
</done>
</task>
</tasks>
<verification>
1. `npx tsc --noEmit` passes with no errors
2. `npm run build` completes successfully
3. `src/lib/signing/token.ts` exports: createSigningToken, verifySigningToken, createDownloadToken, verifyDownloadToken, createAgentDownloadToken, verifyAgentDownloadToken (6 total)
4. Route file exists at `src/app/api/documents/[id]/download/route.ts`
5. Route contains `absPath.startsWith(UPLOADS_DIR)` guard
6. Route verifies `documentId !== id` mismatch returns 403
</verification>
<success_criteria>
- createAgentDownloadToken creates a JWT with purpose:'agent-download' and 5-min expiry
- verifyAgentDownloadToken throws if purpose is not 'agent-download'
- GET /api/documents/[id]/download streams signed PDF when adt JWT is valid
- Route returns 401 for missing/expired token, 403 for ID mismatch, 403 for path traversal, 404 for unsigned document
- No new npm packages required; no existing exports modified
</success_criteria>
<output>
After completion, create `.planning/phases/07-audit-trail-and-download/07-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,396 @@
---
phase: 07-audit-trail-and-download
plan: "02"
type: execute
wave: 2
depends_on:
- "07-01"
files_modified:
- teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/page.tsx
- teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx
- teressa-copeland-homes/src/app/portal/_components/DocumentsTable.tsx
- teressa-copeland-homes/src/app/portal/(protected)/dashboard/page.tsx
autonomous: true
requirements:
- SIGN-07
- LEGAL-03
must_haves:
truths:
- "Agent sees a Download button on the document detail page when document status is Signed"
- "Clicking the Download button triggers browser PDF download dialog (no login prompt, no 404)"
- "Download button is absent when document status is Draft, Sent, or Viewed"
- "Dashboard table shows a Date Signed column populated for Signed documents"
- "Dashboard StatusBadge shows Signed for documents that have completed signing"
artifacts:
- path: "teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/page.tsx"
provides: "Server component that generates agentDownloadUrl and passes it to PreparePanel"
contains: "createAgentDownloadToken"
- path: "teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx"
provides: "Download button rendered only when currentStatus === Signed and agentDownloadUrl is non-null"
contains: "agentDownloadUrl"
- path: "teressa-copeland-homes/src/app/portal/_components/DocumentsTable.tsx"
provides: "DocumentRow type with signedAt field + Date Signed column in table"
contains: "signedAt"
- path: "teressa-copeland-homes/src/app/portal/(protected)/dashboard/page.tsx"
provides: "Select includes signedAt from documents table"
contains: "signedAt: documents.signedAt"
key_links:
- from: "teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/page.tsx"
to: "teressa-copeland-homes/src/lib/signing/token.ts"
via: "import { createAgentDownloadToken } from '@/lib/signing/token'"
pattern: "createAgentDownloadToken"
- from: "teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/page.tsx"
to: "PreparePanel"
via: "agentDownloadUrl prop"
pattern: "agentDownloadUrl"
- from: "teressa-copeland-homes/src/app/portal/(protected)/dashboard/page.tsx"
to: "DocumentsTable"
via: "rows prop including signedAt field"
pattern: "signedAt"
---
<objective>
Wire the agent-facing download UI: generate a presigned download URL in the document detail server component and pass it to PreparePanel for Signed documents; add signedAt to the dashboard table.
Purpose: Complete SIGN-07 (agent can download signed PDF) by surfacing the API from Plan 01 in the portal UI. Satisfies LEGAL-03 by ensuring the only download path is the presigned token route — no direct file URLs anywhere in the UI.
Output: Four modified files — document detail page (token generation), PreparePanel (Download button), DocumentsTable (signedAt type + column), dashboard page (signedAt select).
</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/STATE.md
@.planning/phases/07-audit-trail-and-download/07-RESEARCH.md
@.planning/phases/07-audit-trail-and-download/07-01-SUMMARY.md
<interfaces>
<!-- Current document detail page server component -->
<!-- Source: teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/page.tsx -->
```typescript
// Current: fetches doc + docClient, renders PdfViewerWrapper + PreparePanel
// Phase 7 change: generate agentDownloadUrl server-side for Signed docs and pass as prop
export default async function DocumentPage({ params }: { params: Promise<{ docId: string }> }) {
// ...existing auth, db fetch...
// ADD after fetching doc:
const agentDownloadUrl = doc.signedFilePath
? `/api/documents/${docId}/download?adt=${await createAgentDownloadToken(docId)}`
: null;
// MODIFY PreparePanel call to pass new props:
<PreparePanel
docId={docId}
defaultEmail={docClient?.email ?? ''}
clientName={docClient?.name ?? ''}
currentStatus={doc.status}
agentDownloadUrl={agentDownloadUrl} // ADD
signedAt={doc.signedAt ?? null} // ADD
/>
}
```
<!-- Current PreparePanel — 'use client' component -->
<!-- Source: teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx -->
```typescript
// Current interface:
interface PreparePanelProps {
docId: string;
defaultEmail: string;
clientName: string;
currentStatus: string;
}
// Phase 7 change: extend interface, add Download button section for Signed status
// CRITICAL: Token generation must NOT happen in PreparePanel — PreparePanel is 'use client'
// PreparePanel only renders an <a href={agentDownloadUrl}> anchor — it does not call createAgentDownloadToken
// Current non-Draft status return (replace with status-aware rendering):
if (currentStatus !== 'Draft') {
return (
<div ...>
Document status is <strong>{currentStatus}</strong> preparation is only available for Draft documents.
</div>
);
}
// Phase 7: When currentStatus === 'Signed' and agentDownloadUrl !== null, show download section instead
// When currentStatus === 'Sent' or 'Viewed', keep the read-only message (no download button)
```
<!-- Current DocumentsTable row type -->
<!-- Source: teressa-copeland-homes/src/app/portal/_components/DocumentsTable.tsx -->
```typescript
type DocumentRow = {
id: string;
name: string;
clientName: string | null;
status: "Draft" | "Sent" | "Viewed" | "Signed";
sentAt: Date | null;
clientId: string;
// ADD: signedAt: Date | null;
};
```
<!-- Current dashboard page select -->
<!-- Source: teressa-copeland-homes/src/app/portal/(protected)/dashboard/page.tsx -->
```typescript
const allRows = await db
.select({
id: documents.id,
name: documents.name,
status: documents.status,
sentAt: documents.sentAt,
clientName: clients.name,
clientId: documents.clientId,
// ADD: signedAt: documents.signedAt,
})
.from(documents)
// ...
```
<!-- documents table columns (schema) -->
```typescript
status: documentStatusEnum("status").notNull().default("Draft"),
signedFilePath: text("signed_file_path"), // null until signed
signedAt: timestamp("signed_at"), // null until signed
```
<!-- Token function created in Plan 01 -->
```typescript
// From teressa-copeland-homes/src/lib/signing/token.ts (after Plan 01):
export async function createAgentDownloadToken(documentId: string): Promise<string>
// Returns a JWT with purpose:'agent-download', 5-min TTL
// Used in server component only — import forbidden in 'use client' files
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Update PreparePanel props interface and add Download button for Signed status</name>
<files>teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx</files>
<action>
Two changes to PreparePanel.tsx:
**1. Extend PreparePanelProps interface:**
```typescript
interface PreparePanelProps {
docId: string;
defaultEmail: string;
clientName: string;
currentStatus: string;
agentDownloadUrl?: string | null; // ADD
signedAt?: Date | null; // ADD
}
```
**2. Replace the non-Draft early return block with status-aware rendering.** Currently the function returns a generic message for any non-Draft status. Replace with:
```typescript
// For Signed status: show download section with optional date
if (currentStatus === 'Signed') {
return (
<div style={{ borderRadius: '0.5rem', border: '1px solid #D1FAE5', padding: '1rem', backgroundColor: '#F0FDF4' }}>
<p style={{ fontSize: '0.875rem', color: '#065F46', fontWeight: 600, marginBottom: '0.5rem' }}>
Document Signed
</p>
{signedAt && (
<p style={{ fontSize: '0.75rem', color: '#6B7280', marginBottom: '0.75rem' }}>
Signed on{' '}
{new Date(signedAt).toLocaleString('en-US', {
timeZone: 'America/Denver',
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
})}
</p>
)}
{agentDownloadUrl ? (
<a
href={agentDownloadUrl}
style={{
display: 'inline-block',
padding: '0.5rem 1rem',
backgroundColor: '#1B2B4B',
color: '#FFFFFF',
borderRadius: '0.375rem',
fontSize: '0.875rem',
fontWeight: 500,
textDecoration: 'none',
}}
>
Download Signed PDF
</a>
) : (
<p style={{ fontSize: '0.75rem', color: '#9CA3AF' }}>Signed PDF not available.</p>
)}
</div>
);
}
// For Sent/Viewed: keep existing read-only message
if (currentStatus !== 'Draft') {
return (
<div style={{ borderRadius: '0.5rem', border: '1px solid #E5E7EB', padding: '1rem', backgroundColor: '#F9FAFB', fontSize: '0.875rem', color: '#6B7280' }}>
Document status is <strong>{currentStatus}</strong> preparation is only available for Draft documents.
</div>
);
}
```
Important: The Download button is a plain `<a href={agentDownloadUrl}>` anchor — no fetch(), no onClick handler. The browser follows the link directly, which triggers the Content-Disposition: attachment response from the API route.
Do not destructure `agentDownloadUrl` or `signedAt` from props in the function signature until you have updated the interface. Update interface first.
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -20</automated>
</verify>
<done>PreparePanel accepts agentDownloadUrl and signedAt props; TypeScript passes; Signed status renders download section; Sent/Viewed status renders read-only message; Draft status renders full prepare form unchanged</done>
</task>
<task type="auto">
<name>Task 2: Wire document detail page, update dashboard table for signedAt</name>
<files>
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/page.tsx
teressa-copeland-homes/src/app/portal/_components/DocumentsTable.tsx
teressa-copeland-homes/src/app/portal/(protected)/dashboard/page.tsx
</files>
<action>
**File 1: documents/[docId]/page.tsx**
Add import for createAgentDownloadToken at the top:
```typescript
import { createAgentDownloadToken } from '@/lib/signing/token';
```
After the existing `const [doc, docClient] = await Promise.all([...])` block, add:
```typescript
// Generate agent download URL server-side for Signed documents
// Must be done here (server component) — PreparePanel is 'use client' and cannot call createAgentDownloadToken
const agentDownloadUrl = doc.signedFilePath
? `/api/documents/${docId}/download?adt=${await createAgentDownloadToken(docId)}`
: null;
```
Pass the two new props to PreparePanel:
```tsx
<PreparePanel
docId={docId}
defaultEmail={docClient?.email ?? ''}
clientName={docClient?.name ?? ''}
currentStatus={doc.status}
agentDownloadUrl={agentDownloadUrl}
signedAt={doc.signedAt ?? null}
/>
```
Note: `doc.signedAt` is available on the document object — it's a column in the documents table (timestamp("signed_at")).
---
**File 2: DocumentsTable.tsx**
Add `signedAt: Date | null` to the DocumentRow type:
```typescript
type DocumentRow = {
id: string;
name: string;
clientName: string | null;
status: "Draft" | "Sent" | "Viewed" | "Signed";
sentAt: Date | null;
signedAt: Date | null; // ADD
clientId: string;
};
```
Add a "Date Signed" column header after the "Date Sent" `<th>`:
```tsx
<th style={{ textAlign: "left", fontSize: "0.75rem", fontWeight: 600, color: "#6B7280", textTransform: "uppercase", letterSpacing: "0.05em", padding: "0.75rem 1.5rem" }}>
Date Signed
</th>
```
Add a "Date Signed" `<td>` in the row map after the "Date Sent" cell:
```tsx
<td style={{ padding: "0.875rem 1.5rem", color: "#6B7280" }}>
{row.signedAt
? new Date(row.signedAt).toLocaleDateString("en-US", {
timeZone: "America/Denver",
month: "short",
day: "numeric",
year: "numeric",
})
: "—"}
</td>
```
---
**File 3: dashboard/page.tsx**
Add `signedAt: documents.signedAt` to the select object:
```typescript
const allRows = await db
.select({
id: documents.id,
name: documents.name,
status: documents.status,
sentAt: documents.sentAt,
signedAt: documents.signedAt, // ADD
clientName: clients.name,
clientId: documents.clientId,
})
.from(documents)
.leftJoin(clients, eq(documents.clientId, clients.id))
.orderBy(desc(documents.createdAt));
```
The `allRows` inferred type will now include `signedAt: Date | null`, which matches the updated DocumentRow type in DocumentsTable.tsx.
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -20 && npm run build 2>&1 | tail -10</automated>
</verify>
<done>
tsc --noEmit passes with no errors;
npm run build passes cleanly;
DocumentsTable accepts rows with signedAt field;
Dashboard query selects signedAt;
Document detail page imports and calls createAgentDownloadToken for signed docs;
PreparePanel receives agentDownloadUrl and signedAt props
</done>
</task>
</tasks>
<verification>
1. `npx tsc --noEmit` passes — no type errors across all four modified files
2. `npm run build` completes successfully
3. PreparePanel renders three distinct states: Draft (prepare form), Sent/Viewed (read-only message), Signed (green panel with download link)
4. agentDownloadUrl is generated in the server component (page.tsx), not in PreparePanel
5. DocumentsTable has Date Signed column
6. Dashboard query includes signedAt in select
</verification>
<success_criteria>
- Agent can navigate to a Signed document detail page and see a green "Document Signed" panel with signed timestamp and "Download Signed PDF" anchor link
- Download button is absent for Draft/Sent/Viewed documents
- Dashboard table shows "Date Signed" column with date for Signed documents, "—" for others
- Build passes with no TypeScript errors
</success_criteria>
<output>
After completion, create `.planning/phases/07-audit-trail-and-download/07-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,108 @@
---
phase: 07-audit-trail-and-download
plan: "03"
type: execute
wave: 3
depends_on:
- "07-02"
files_modified: []
autonomous: false
requirements:
- SIGN-07
- LEGAL-03
must_haves:
truths:
- "Agent downloads the signed PDF from the document detail page and receives the actual file"
- "Dashboard shows Signed status badge for the signed document"
- "Dashboard shows a non-empty Date Signed value for the signed document"
- "Accessing uploads/ directory or file directly via guessable URL returns an error, not the file"
artifacts: []
key_links: []
---
<objective>
Human verification checkpoint: confirm that the agent-facing download, Signed status badge, private storage guard, and download-only-for-Signed rule all pass the Phase 7 success criteria.
Purpose: Satisfy the observable success criteria for SIGN-07 and LEGAL-03 through direct browser verification.
Output: Human confirmation that all Phase 7 success criteria are met (or issue report for gap closure).
</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/ROADMAP.md
@.planning/phases/07-audit-trail-and-download/07-02-SUMMARY.md
</context>
<tasks>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 1: Full Phase 7 browser verification</name>
<action>Start the dev server and walk through the Phase 7 verification checklist below. No code changes needed — this task is observation only.</action>
<files>none</files>
<verify>Human approval of all 4 verification criteria below.</verify>
<done>Agent confirms: download button works, Signed badge shows, Date Signed populates, guessable URLs return 404.</done>
<what-built>
Plan 01: Agent-authenticated download API at GET /api/documents/[id]/download — 5-min presigned JWT (adt query param), path traversal guard, streams signedFilePath PDF.
Plan 02:
- Document detail page: generates agentDownloadUrl server-side for Signed docs, passes to PreparePanel
- PreparePanel: green "Document Signed" panel with signed timestamp + "Download Signed PDF" anchor for Signed status; unchanged prepare form for Draft; read-only message for Sent/Viewed
- DocumentsTable: Date Signed column added to table
- Dashboard page: signedAt included in DB select
</what-built>
<how-to-verify>
Start the dev server: `cd teressa-copeland-homes && npm run dev`
Prerequisite: You need a document with status "Signed" and signedFilePath populated in the DB. Use an existing signed document from Phase 6 testing, or run through a quick signing ceremony now.
**Criterion 1 — Agent download (SIGN-07):**
1. Log in to the portal at http://localhost:3000/portal/dashboard
2. Find a document with status "Signed" — confirm the "Date Signed" column shows a formatted date (not "—")
3. Click the document name to navigate to the document detail page (/portal/documents/[id])
4. Confirm the right sidebar shows a green panel labeled "Document Signed" with the signed date/time and a "Download Signed PDF" button
5. Click "Download Signed PDF" — confirm browser PDF download dialog appears and the file saves successfully
6. Open the downloaded file — confirm it is a PDF containing the drawn signature
**Criterion 2 — Status badge (SIGN-07 success criterion 3):**
7. Return to http://localhost:3000/portal/dashboard
8. Confirm the signed document's Status column shows "Signed" badge (green/teal styling)
**Criterion 3 — Private storage (LEGAL-03):**
9. In the browser address bar, visit: http://localhost:3000/uploads/
10. Confirm it returns 404 — NOT a directory listing or file contents
11. Also try: http://localhost:3000/uploads/clients/ — confirm 404
**Criterion 4 — Download button absent for non-Signed documents:**
12. Navigate to any document with status "Draft", "Sent", or "Viewed"
13. Confirm the right sidebar does NOT show a "Download Signed PDF" button (Draft shows prepare form; Sent/Viewed shows read-only status message)
</how-to-verify>
<resume-signal>
Type "approved" if all 4 criteria pass.
Or describe which criterion failed and what you observed — Claude will diagnose and create a gap closure plan.
</resume-signal>
</task>
</tasks>
<verification>
All three Phase 7 roadmap success criteria verified by human:
1. Agent can download the signed PDF from the document detail page via authenticated presigned URL (5-minute TTL)
2. Signed PDFs are stored in a private local directory — a direct or guessable URL returns 404, not the file
3. Document status in the dashboard updates correctly to "Signed" after a signing ceremony completes
</verification>
<success_criteria>
- Human types "approved" after verifying all 4 browser checks
- Phase 7 is marked complete in ROADMAP.md and STATE.md
- REQUIREMENTS.md checkboxes for SIGN-07 and LEGAL-03 updated to [x]
</success_criteria>
<output>
After completion, create `.planning/phases/07-audit-trail-and-download/07-03-SUMMARY.md`
</output>