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)
|
||||
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 | - |
|
||||
|
||||
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