docs(06-signing-flow): create phase plan
This commit is contained in:
305
.planning/phases/06-signing-flow/06-05-PLAN.md
Normal file
305
.planning/phases/06-signing-flow/06-05-PLAN.md
Normal file
@@ -0,0 +1,305 @@
|
||||
---
|
||||
phase: 06-signing-flow
|
||||
plan: "05"
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on:
|
||||
- "06-04"
|
||||
files_modified:
|
||||
- teressa-copeland-homes/src/app/sign/[token]/confirmed/page.tsx
|
||||
- teressa-copeland-homes/src/app/api/sign/[token]/download/route.ts
|
||||
- teressa-copeland-homes/src/app/sign/[token]/_components/SigningPageClient.tsx
|
||||
autonomous: true
|
||||
requirements:
|
||||
- SIGN-06
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "After POST /api/sign/[token] returns ok:true, the client is redirected to /sign/[token]/confirmed"
|
||||
- "Confirmation page shows: success checkmark, 'You've signed [Document Name]', timestamp of signing, and a 'Download your copy' button"
|
||||
- "The 'Download your copy' button downloads the signed PDF for the client via a short-lived download token (15-min TTL)"
|
||||
- "Revisiting an already-used /sign/[token] shows the 'Already Signed' state with signed date and a 'Download your copy' link"
|
||||
- "GET /api/sign/[token]/download validates a download JWT token (not the signing token) and streams the signedFilePath PDF"
|
||||
- "npm run build passes cleanly"
|
||||
artifacts:
|
||||
- path: "teressa-copeland-homes/src/app/sign/[token]/confirmed/page.tsx"
|
||||
provides: "Post-signing confirmation page with signed date + download button"
|
||||
- path: "teressa-copeland-homes/src/app/api/sign/[token]/download/route.ts"
|
||||
provides: "GET: validate download token, stream signedFilePath as PDF"
|
||||
key_links:
|
||||
- from: "confirmed/page.tsx"
|
||||
to: "/api/sign/[token]/download?dt=[downloadToken]"
|
||||
via: "download button href with short-lived token query param"
|
||||
- from: "download/route.ts"
|
||||
to: "documents.signedFilePath"
|
||||
via: "reads file from uploads/ and streams as application/pdf — never from public directory"
|
||||
- from: "SigningPageClient.tsx"
|
||||
to: "/sign/[token]/confirmed"
|
||||
via: "router.push after POST /api/sign/[token] returns ok:true"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the post-signing confirmation screen and the client's ability to download a copy of their signed document via a short-lived token.
|
||||
|
||||
Purpose: SIGN-06 — client must see confirmation after signing. The confirmation page is the final touch in the signing ceremony UX (locked decision: success checkmark, document name, timestamp, download button).
|
||||
Output: Confirmation page, already-signed download link, and /api/sign/[token]/download route for secure client PDF access.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/ccopeland/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/06-signing-flow/06-CONTEXT.md
|
||||
@.planning/phases/06-signing-flow/06-RESEARCH.md
|
||||
@.planning/phases/06-signing-flow/06-04-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- From Plan 06-01 -->
|
||||
From teressa-copeland-homes/src/lib/signing/token.ts:
|
||||
```typescript
|
||||
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 }>
|
||||
```
|
||||
|
||||
Note: For the download token, use the same createSigningToken pattern but with a different purpose. The research recommends a second short-lived JWT with `purpose: 'download'` claim and 15-min TTL. Extend token.ts to add:
|
||||
```typescript
|
||||
export async function createDownloadToken(documentId: string): Promise<string>
|
||||
// SignJWT with { documentId, purpose: 'download' }, setExpirationTime('15m'), no DB record needed
|
||||
export async function verifyDownloadToken(token: string): Promise<{ documentId: string }>
|
||||
// jwtVerify + check payload.purpose === 'download'
|
||||
```
|
||||
|
||||
From teressa-copeland-homes/src/lib/db/schema.ts:
|
||||
```typescript
|
||||
// documents table — added in Plan 06-01:
|
||||
// signedFilePath: text — absolute path to signed PDF
|
||||
// signedAt: timestamp
|
||||
// pdfHash: text
|
||||
//
|
||||
// signingTokens table:
|
||||
// usedAt: timestamp — set when signing was completed
|
||||
```
|
||||
|
||||
From Plan 06-04: POST /api/sign/[token] returns { ok: true } on success.
|
||||
SigningPageClient.tsx submits via fetch POST and receives this response — needs router.push to /sign/[token]/confirmed after ok:true.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Download token utilities + download API route</name>
|
||||
<files>
|
||||
teressa-copeland-homes/src/lib/signing/token.ts
|
||||
teressa-copeland-homes/src/app/api/sign/[token]/download/route.ts
|
||||
</files>
|
||||
<action>
|
||||
**Extend src/lib/signing/token.ts** — add two new exports for the client download token.
|
||||
|
||||
Append to the end of the existing token.ts file:
|
||||
|
||||
```typescript
|
||||
// Short-lived download token for client copy download (15-min TTL, no DB record)
|
||||
// purpose: 'download' claim distinguishes from signing tokens
|
||||
export async function createDownloadToken(documentId: string): Promise<string> {
|
||||
return await new SignJWT({ documentId, purpose: 'download' })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('15m')
|
||||
.sign(getSecret());
|
||||
}
|
||||
|
||||
export async function verifyDownloadToken(token: string): Promise<{ documentId: string }> {
|
||||
const { payload } = await jwtVerify(token, getSecret());
|
||||
if (payload['purpose'] !== 'download') throw new Error('Not a download token');
|
||||
return { documentId: payload['documentId'] as string };
|
||||
}
|
||||
```
|
||||
|
||||
**Create src/app/api/sign/[token]/download/route.ts** — GET handler:
|
||||
|
||||
The `[token]` in this path is the SIGNING token (used to identify the document via context). The actual download authorization uses a `dt` query parameter (the short-lived download token).
|
||||
|
||||
Logic:
|
||||
1. Resolve signing token from params, verify it (allows expired JWT here — signed docs should remain downloadable briefly after link expiry)
|
||||
- Actually: do NOT use the signing token for auth here. Instead, use ONLY the `dt` query param for authorization.
|
||||
2. Get `dt` from URL search params: `const url = new URL(req.url); const dt = url.searchParams.get('dt')`
|
||||
3. If no `dt`, return 401
|
||||
4. Call `verifyDownloadToken(dt)` — if throws, return 401 "Download link expired or invalid"
|
||||
5. Fetch document by `documentId` from the verified download token payload
|
||||
6. Guard: if `doc.signedFilePath` is null, return 404 "Signed PDF not found"
|
||||
7. Path traversal guard: ensure `doc.signedFilePath` starts with expected uploads/ prefix (same guard pattern used in Phase 4)
|
||||
8. Read file: `const fileBuffer = await readFile(doc.signedFilePath)`
|
||||
9. Return as streaming PDF response:
|
||||
```typescript
|
||||
return new Response(fileBuffer, {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${doc.name.replace(/[^a-zA-Z0-9-_ ]/g, '')}_signed.pdf"`,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
No agent auth required — download token is the authorization mechanism.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "sign/\[token\]/download" | head -5</automated>
|
||||
</verify>
|
||||
<done>createDownloadToken and verifyDownloadToken exported from token.ts; download/route.ts exists; build passes</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Confirmation page + redirect from signing client</name>
|
||||
<files>
|
||||
teressa-copeland-homes/src/app/sign/[token]/confirmed/page.tsx
|
||||
teressa-copeland-homes/src/app/sign/[token]/_components/SigningPageClient.tsx
|
||||
</files>
|
||||
<action>
|
||||
**Create src/app/sign/[token]/confirmed/page.tsx** — server component showing signing confirmation (locked UX decisions from CONTEXT.md):
|
||||
|
||||
- Success checkmark
|
||||
- "You've signed [Document Name]"
|
||||
- Timestamp of signing
|
||||
- "Download your copy" button (generates a download token, passes as `dt` query param)
|
||||
- Clean thank-you only — no agent contact info on the page (locked decision)
|
||||
|
||||
```typescript
|
||||
import { verifySigningToken } from '@/lib/signing/token';
|
||||
import { createDownloadToken } from '@/lib/signing/token';
|
||||
import { db } from '@/lib/db';
|
||||
import { signingTokens, documents } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ token: string }>;
|
||||
}
|
||||
|
||||
export default async function ConfirmedPage({ params }: Props) {
|
||||
const { token } = await params;
|
||||
|
||||
// Verify signing token to get documentId (allow expired — confirmation page visited after signing)
|
||||
let documentId: string | null = null;
|
||||
let jti: string | null = null;
|
||||
try {
|
||||
const payload = await verifySigningToken(token);
|
||||
documentId = payload.documentId;
|
||||
jti = payload.jti;
|
||||
} catch {
|
||||
// Token expired is OK here — signing may have happened right before expiry
|
||||
// Try to look up by jti if token body is still parseable
|
||||
}
|
||||
|
||||
if (!documentId) {
|
||||
return <div style={{ padding: '40px', textAlign: 'center', fontFamily: 'Georgia, serif', color: '#1B2B4B' }}>Document not found.</div>;
|
||||
}
|
||||
|
||||
const doc = await db.query.documents.findFirst({
|
||||
where: eq(documents.id, documentId),
|
||||
});
|
||||
|
||||
// Get signed timestamp from signingTokens.usedAt
|
||||
let signedAt: Date | null = null;
|
||||
if (jti) {
|
||||
const tokenRow = await db.query.signingTokens.findFirst({ where: eq(signingTokens.jti, jti) });
|
||||
signedAt = tokenRow?.usedAt ?? doc?.signedAt ?? null;
|
||||
}
|
||||
|
||||
if (!doc || !doc.signedFilePath) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: '#FAF9F7', fontFamily: 'Georgia, serif' }}>
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<h1 style={{ color: '#1B2B4B' }}>Document not yet available</h1>
|
||||
<p style={{ color: '#555' }}>Please check back shortly.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Generate 15-minute download token for client copy
|
||||
const downloadToken = await createDownloadToken(doc.id);
|
||||
const downloadUrl = `/api/sign/${token}/download?dt=${downloadToken}`;
|
||||
|
||||
const formattedDate = signedAt
|
||||
? signedAt.toLocaleString('en-US', { month: 'long', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })
|
||||
: 'Just now';
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', backgroundColor: '#FAF9F7', fontFamily: 'Georgia, serif', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ textAlign: 'center', maxWidth: '480px', padding: '48px 32px' }}>
|
||||
{/* Success checkmark */}
|
||||
<div style={{ width: '72px', height: '72px', borderRadius: '50%', backgroundColor: '#1B2B4B', color: '#C9A84C', fontSize: '36px', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 24px' }}>
|
||||
✓
|
||||
</div>
|
||||
<h1 style={{ color: '#1B2B4B', fontSize: '26px', marginBottom: '8px' }}>
|
||||
You've signed
|
||||
</h1>
|
||||
<p style={{ color: '#1B2B4B', fontSize: '20px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||
{doc.name}
|
||||
</p>
|
||||
<p style={{ color: '#888', fontSize: '14px', marginBottom: '32px' }}>
|
||||
Signed on {formattedDate}
|
||||
</p>
|
||||
<a
|
||||
href={downloadUrl}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
backgroundColor: '#C9A84C',
|
||||
color: '#fff',
|
||||
padding: '12px 28px',
|
||||
borderRadius: '4px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '15px',
|
||||
}}
|
||||
>
|
||||
Download your copy
|
||||
</a>
|
||||
<p style={{ color: '#aaa', fontSize: '12px', marginTop: '16px' }}>
|
||||
Download link valid for 15 minutes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Update SigningPageClient.tsx** — add navigation to confirmed page after successful submission.
|
||||
|
||||
Find the `handleSubmit` function (or where the POST response is handled). After receiving `{ ok: true }`:
|
||||
```typescript
|
||||
import { useRouter } from 'next/navigation';
|
||||
// ...
|
||||
const router = useRouter();
|
||||
// After POST returns ok:true:
|
||||
router.push(`/sign/${token}/confirmed`);
|
||||
```
|
||||
|
||||
The `token` prop is already passed from page.tsx to SigningPageClient. Use it for the redirect URL.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "confirmed|error|Error" | grep -v "^$" | head -10</automated>
|
||||
</verify>
|
||||
<done>confirmed/page.tsx exists; SigningPageClient.tsx uses router.push to confirmed after successful submit; download route streams PDF; npm run build passes</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- Build passes: `npm run build` exits 0
|
||||
- Confirmation page: `ls teressa-copeland-homes/src/app/sign/*/confirmed/page.tsx`
|
||||
- Download route: `ls teressa-copeland-homes/src/app/api/sign/*/download/route.ts`
|
||||
- Download token functions: `grep -n "createDownloadToken\|verifyDownloadToken" teressa-copeland-homes/src/lib/signing/token.ts`
|
||||
- Client redirect: `grep -n "router.push\|confirmed" teressa-copeland-homes/src/app/sign/*/\_components/SigningPageClient.tsx`
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
Post-signing flow complete when: confirmed page renders success checkmark + document name + signed timestamp + download button, download route streams the signedFilePath PDF using a 15-min token, SigningPageClient navigates to confirmed after successful POST, and npm run build passes.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-signing-flow/06-05-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user