docs/superpowers/plans/2026-09-05-cover-letter-library.md
For agentic workers: Use executing-plans to implement this plan task by task. Backend ownership belongs to audit_builder; apps/web ownership belongs to root. Leave changes uncommitted for root review.
Goal: Persist cover letters independently, edit the same document from the library and builder, and export immutable application attachments.
Architecture: A user-owned cover_letter row stores rich text plus copied resume styling and sender information. Source resume/application links supply context, never synchronized content. Existing embedded letters remain independent and support explicit copying into the library.
Tech Stack: TypeScript, Zod, Drizzle/PostgreSQL, oRPC, React PDF, TanStack Query.
Spec: The contract and lifecycle below implement approved issue #3255 hybrid behavior.
type CoverLetterStyle = {
basics: ResumeData["basics"];
picture: ResumeData["picture"];
metadata: Omit<ResumeData["metadata"], "notes" | "layout">;
sectionId: string;
itemId: string;
};
type CoverLetter = {
id: string; name: string; recipient: string; content: string;
style: CoverLetterStyle;
sourceResumeId: string | null; sourceApplicationId: string | null;
revision: number; createdAt: Date; updatedAt: Date;
};
type CoverLetterDocument = {
format: "reactive-resume-cover-letter"; version: 1;
name: string; recipient: string; content: string; style: CoverLetterStyle;
};
Exported types/schema: @reactive-resume/schema/cover-letter/data.
Pure helpers: @reactive-resume/resume/cover-letter exposes createCoverLetterResumeData(letter: Pick<CoverLetter, "name" | "recipient" | "content" | "style">): ResumeData, copyCoverLetterStyle(data: ResumeData, sectionId?: string, itemId?: string): CoverLetterStyle, and coverLetterTextToHtml(text: string): string.
oRPC namespace coverLetters:
| Procedure | Input | Output |
|---|---|---|
| list | { search?, resumeId?, applicationId?, limit?: number, offset?: number } | { items: CoverLetter[], total: number } |
| getById | { id } | CoverLetter |
| create | { name, recipient?, content?, resumeId?, applicationId? } | CoverLetter |
| update | { id, expectedRevision, name?, recipient?, content? } | CoverLetter |
| refreshStyle | { id, expectedRevision, resumeId } | CoverLetter |
| duplicate | { id, name? } | CoverLetter |
| delete | { id, expectedRevision } | void |
| copyEmbedded | { resumeId, sectionId, itemId, name? } | CoverLetter |
| export | { id } | CoverLetterDocument |
| import | { document: CoverLetterDocument } | CoverLetter |
List defaults limit=20, offset=0; limit max100; literal case-insensitive name search. Stable ordering updatedAt descending, id descending. IDs and dates are server-owned. Every read/write enforces user ownership; inaccessible contexts return NOT_FOUND. Atomic revision predicates return CONFLICT for stale same-user mutations, NOT_FOUND for absent/foreign documents. Import always creates a new owned ID and clears provenance.
AI applications.ai.draftMessage retains { text } and adds optional coverLetterId; cover-letter generation persists before returning. Follow-up generation stays transient. Client opens saved document by ID rather than making another copy.
Library and builder select the same row. Editor uses RichInput and explicit Save with revision; CONFLICT offers reload without discarding current edits automatically. Name/content views render text or existing trusted rich-editor/PDF pathways, never raw HTML insertion. Empty document creation uses default resume styling; chosen resume copies basics/picture and metadata except private notes and resume layout. Composer includes only one full-width cover-letter section with empty standard sections and notes.
Refresh explicitly replaces copied styling/sender only, preserves letter text/name and identity. Embedded copy preserves original section/item IDs so existing targeted style rules continue to apply. Original embedded letter remains unchanged and independently editable. Account export includes coverLetters; standalone versioned JSON has content and styling needed to render without original context IDs. Images remain URL references, not embedded bytes. Resume deletion removes screenshots/PDFs only and keeps picture assets; deleting the entire account removes its owned assets and documents.
PDF uses createResumePdfBlob(createCoverLetterResumeData(letter)). Application action uploads the resulting File via existing applications.attachDocument({id,kind:"cover-letter",file}). Later edits/deletion of source letters never mutate uploaded attachment snapshots. PR #3395's escaping and composition move into pure domain helper; its attachment endpoint is reused.
Files: packages/schema/src/cover-letter/data.ts; packages/resume/src/cover-letter.ts; their tests and package export maps.
<script> plain text; absence of resume sections/notes in composed data; round-trip document shape.rtk proxy pnpm --filter @reactive-resume/resume exec vitest run src/cover-letter.test.ts and capture RED.expect(result.basics).toEqual(source.basics) and expect(result.metadata.notes).toBe("").Files: packages/db/src/schema/cover-letter.ts, schema/index.ts, generated migrations; packages/api/src/dto/cover-letter.ts; features/cover-letters/{service,router,html}.ts; routers/index.ts.
rtk proxy pnpm --filter @reactive-resume/api exec vitest run src/features/cover-letters and capture RED.{userId,id,revision} update/delete conditions with revision increment.Files: packages/api/src/features/applications/ai.ts and tests; packages/api/src/features/auth/service.ts and tests.
coverLetterId beside original text.