From 2cb58cbe02b02e20963e61008d2c048409c0570f Mon Sep 17 00:00:00 2001 From: Cole Mackenzie Date: Sat, 7 Feb 2026 21:35:41 -0800 Subject: [PATCH] fix: add proper exports and bundling build to catch errors --- .github/workflows/workers.yml | 14 + workers/paperless/package.json | 1 + .../paperless/src/db/repositories/index.ts | 1 + .../src/db/repositories/suggestions.ts | 34 +- workers/paperless/src/db/schema/documents.ts | 2 + workers/paperless/src/db/schema/index.ts | 1 + .../paperless/src/routes/documents.$id.tsx | 312 +++++++++++++++++- 7 files changed, 355 insertions(+), 10 deletions(-) diff --git a/.github/workflows/workers.yml b/.github/workflows/workers.yml index 755300e..d9b8de0 100644 --- a/.github/workflows/workers.yml +++ b/.github/workflows/workers.yml @@ -35,6 +35,20 @@ jobs: - name: Run Biome format check run: pnpm exec biome format ./ + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 + with: + node-version: lts/* + cache: pnpm + - run: pnpm install + - name: Build and dry-run deploy + run: pnpm --filter paperless run build:verify + test: name: Test runs-on: ubuntu-latest diff --git a/workers/paperless/package.json b/workers/paperless/package.json index 6ff95bd..bf81ba1 100644 --- a/workers/paperless/package.json +++ b/workers/paperless/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "vite dev --port 3000", "build": "vite build", + "build:verify": "pnpm run build && wrangler deploy --dry-run", "serve": "vite preview", "test": "vitest run", "deploy": "pnpm run build && VERSION_ID=$(wrangler versions upload --tag $(git rev-parse --short HEAD) --message \"$(git log -1 --pretty=%s)\" 2>&1 | grep 'Worker Version ID:' | awk '{print $4}') && wrangler versions deploy $VERSION_ID@100 --yes", diff --git a/workers/paperless/src/db/repositories/index.ts b/workers/paperless/src/db/repositories/index.ts index f6c2f92..dcec468 100644 --- a/workers/paperless/src/db/repositories/index.ts +++ b/workers/paperless/src/db/repositories/index.ts @@ -1,3 +1,4 @@ +export * from "./comments"; export * from "./correspondents"; export * from "./documents"; export * from "./files"; diff --git a/workers/paperless/src/db/repositories/suggestions.ts b/workers/paperless/src/db/repositories/suggestions.ts index 9a0a4a3..e24605d 100644 --- a/workers/paperless/src/db/repositories/suggestions.ts +++ b/workers/paperless/src/db/repositories/suggestions.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, isNull } from "drizzle-orm"; +import { and, desc, eq, isNotNull, isNull } from "drizzle-orm"; import type { Database } from "../index"; import { documentSuggestions, @@ -91,6 +91,38 @@ export async function dismissSuggestion( return !!updated; } +export interface ActedSuggestionData { + id: string; + type: "tag" | "correspondent" | "title" | "date"; + name: string; + accepted: boolean; + actedAt: string; +} + +export async function listActedSuggestionsByDocument( + db: Database, + documentId: bigint, +): Promise { + const results = await db + .select() + .from(documentSuggestions) + .where( + and( + eq(documentSuggestions.documentId, documentId), + isNotNull(documentSuggestions.accepted), + ), + ) + .orderBy(desc(documentSuggestions.updatedAt)); + + return results.map((s) => ({ + id: s.id.toString(), + type: s.type, + name: s.name, + accepted: s.accepted as boolean, + actedAt: s.updatedAt.toISOString(), + })); +} + export async function deletePendingSuggestionsForDocument( db: Database, documentId: bigint, diff --git a/workers/paperless/src/db/schema/documents.ts b/workers/paperless/src/db/schema/documents.ts index 72689a6..71d1de6 100644 --- a/workers/paperless/src/db/schema/documents.ts +++ b/workers/paperless/src/db/schema/documents.ts @@ -11,6 +11,7 @@ import { varchar, } from "drizzle-orm/pg-core"; import { correspondents } from "./correspondents"; +import { documentComments } from "./document-comments"; import { documentSuggestions } from "./document-suggestions"; import { files } from "./files"; import { documentTags } from "./tags"; @@ -63,6 +64,7 @@ export const documentsRelations = relations(documents, ({ one, many }) => ({ files: many(files), documentTags: many(documentTags), suggestions: many(documentSuggestions), + comments: many(documentComments), })); export type Document = typeof documents.$inferSelect; diff --git a/workers/paperless/src/db/schema/index.ts b/workers/paperless/src/db/schema/index.ts index ef4bdb7..e470262 100644 --- a/workers/paperless/src/db/schema/index.ts +++ b/workers/paperless/src/db/schema/index.ts @@ -1,4 +1,5 @@ export * from "./correspondents"; +export * from "./document-comments"; export * from "./document-suggestions"; export * from "./documents"; export * from "./files"; diff --git a/workers/paperless/src/routes/documents.$id.tsx b/workers/paperless/src/routes/documents.$id.tsx index b7d0dd4..cefc9bc 100644 --- a/workers/paperless/src/routes/documents.$id.tsx +++ b/workers/paperless/src/routes/documents.$id.tsx @@ -24,9 +24,11 @@ import { Home, Image, Loader2, + MessageSquare, Plus, RefreshCw, Search, + Send, Sparkles, Tag, Trash2, @@ -38,6 +40,8 @@ import { createDbFromHyperdrive, getDocumentById, getNextASN, + listActedSuggestionsByDocument, + listCommentsByDocument, listCorrespondents, listSuggestionsByDocument, listTags, @@ -170,19 +174,103 @@ const getDocumentSuggestions = createServerFn({ method: "GET" }) return listSuggestionsByDocument(db, BigInt(data.id)); }); +type TimelineEvent = + | { type: "document_created"; timestamp: string } + | { + type: "comment_added"; + timestamp: string; + commentId: string; + content: string; + } + | { + type: "suggestion_accepted"; + timestamp: string; + suggestionType: string; + suggestionName: string; + } + | { + type: "suggestion_dismissed"; + timestamp: string; + suggestionType: string; + suggestionName: string; + }; + +const getDocumentTimeline = createServerFn({ method: "GET" }) + .middleware([documentIdMiddleware]) + .handler(async ({ data }): Promise => { + const db = createDbFromHyperdrive(env.HYPERDRIVE); + const docId = BigInt(data.id); + + const [comments, actedSuggestions, doc] = await Promise.all([ + listCommentsByDocument(db, docId), + listActedSuggestionsByDocument(db, docId), + getDocumentById(db, docId), + ]); + + const events: TimelineEvent[] = []; + + if (doc) { + events.push({ + type: "document_created", + timestamp: doc.createdAt, + }); + } + + for (const comment of comments) { + events.push({ + type: "comment_added", + timestamp: comment.createdAt, + commentId: comment.id, + content: comment.content, + }); + } + + for (const suggestion of actedSuggestions) { + events.push({ + type: suggestion.accepted + ? "suggestion_accepted" + : "suggestion_dismissed", + timestamp: suggestion.actedAt, + suggestionType: suggestion.type, + suggestionName: suggestion.name, + }); + } + + events.sort( + (a, b) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + ); + + return events; + }); + export const Route = createFileRoute("/documents/$id")({ component: DocumentView, errorComponent: DocumentErrorPage, loader: async ({ params }) => { - const [document, allTags, allCorrespondents, nextASN, suggestions] = - await Promise.all([ - getDocument({ data: { id: params.id } }), - getAllTags(), - getAllCorrespondents(), - fetchNextASN(), - getDocumentSuggestions({ data: { id: params.id } }), - ]); - return { document, allTags, allCorrespondents, nextASN, suggestions }; + const [ + document, + allTags, + allCorrespondents, + nextASN, + suggestions, + timeline, + ] = await Promise.all([ + getDocument({ data: { id: params.id } }), + getAllTags(), + getAllCorrespondents(), + fetchNextASN(), + getDocumentSuggestions({ data: { id: params.id } }), + getDocumentTimeline({ data: { id: params.id } }), + ]); + return { + document, + allTags, + allCorrespondents, + nextASN, + suggestions, + timeline, + }; }, }); @@ -260,6 +348,7 @@ function DocumentView() { allCorrespondents = [], nextASN = 1, suggestions: initialSuggestions = [], + timeline: initialTimeline = [], } = Route.useLoaderData() ?? {}; const router = useRouter(); const navigate = useNavigate(); @@ -289,6 +378,12 @@ function DocumentView() { const [actioningSuggestion, setActioningSuggestion] = useState( null, ); + const [timeline, setTimeline] = useState(initialTimeline); + const [newComment, setNewComment] = useState(""); + const [isSubmittingComment, setIsSubmittingComment] = useState(false); + const [deletingCommentId, setDeletingCommentId] = useState( + null, + ); const primaryFile = doc.files[0]; const MAX_VISIBLE_TAGS = 5; @@ -313,6 +408,56 @@ function DocumentView() { setSuggestions(initialSuggestions); }, [initialSuggestions]); + // Sync timeline when loader data changes + useEffect(() => { + setTimeline(initialTimeline); + }, [initialTimeline]); + + const handleAddComment = async () => { + const content = newComment.trim(); + if (!content) return; + + setIsSubmittingComment(true); + try { + const response = await fetch(`/api/documents/${doc.id}/comments`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }); + + if (response.ok) { + setNewComment(""); + router.invalidate(); + } + } catch (error) { + console.error("Failed to add comment:", error); + } finally { + setIsSubmittingComment(false); + } + }; + + const handleDeleteComment = async (commentId: string) => { + setDeletingCommentId(commentId); + // Optimistic remove + setTimeline((prev) => + prev.filter( + (e) => !(e.type === "comment_added" && e.commentId === commentId), + ), + ); + try { + await fetch(`/api/documents/${doc.id}/comments/${commentId}`, { + method: "DELETE", + }); + router.invalidate(); + } catch (error) { + console.error("Failed to delete comment:", error); + // Revert on error + setTimeline(initialTimeline); + } finally { + setDeletingCommentId(null); + } + }; + const handleSaveTitle = async () => { if (!editTitle.trim() || editTitle.trim() === doc.title) { setIsEditingTitle(false); @@ -1202,6 +1347,69 @@ function DocumentView() { )} + + {/* Activity Card */} +
+
+

+ + Activity + + {timeline.length} + +

+
+
+ {timeline.length > 0 ? ( +
+ {timeline.map((event, idx) => ( + + ))} +
+ ) : ( +
+

No activity yet

+
+ )} +
+
+
+