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:
@@ -137,9 +137,12 @@ Plans:
|
|||||||
1. Agent can download the signed PDF from the dashboard via an authenticated presigned URL (5-minute TTL)
|
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
|
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
|
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
|
## 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 |
|
| 4. PDF Ingest | 4/4 | Complete | 2026-03-20 |
|
||||||
| 5. PDF Fill and Field Mapping | 3/4 | In Progress| |
|
| 5. PDF Fill and Field Mapping | 3/4 | In Progress| |
|
||||||
| 6. Signing Flow | 6/6 | Complete | 2026-03-21 |
|
| 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 | - |
|
||||||
|
|||||||
284
.planning/phases/07-audit-trail-and-download/07-01-PLAN.md
Normal file
284
.planning/phases/07-audit-trail-and-download/07-01-PLAN.md
Normal 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>
|
||||||
396
.planning/phases/07-audit-trail-and-download/07-02-PLAN.md
Normal file
396
.planning/phases/07-audit-trail-and-download/07-02-PLAN.md
Normal 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>
|
||||||
108
.planning/phases/07-audit-trail-and-download/07-03-PLAN.md
Normal file
108
.planning/phases/07-audit-trail-and-download/07-03-PLAN.md
Normal 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>
|
||||||
Reference in New Issue
Block a user