From 26805dca90c350b555d2803e36c458bb4466868e Mon Sep 17 00:00:00 2001 From: "Dennis (via Claude+Gemma)" Date: Sat, 23 May 2026 05:41:42 +0200 Subject: [PATCH] feat(documents-upload): File-Upload via @fastify/multipart + Documents-Page [tsc:fail] --- .phase7-state.json | 5 + GENERATION_LOG.md | 26 ++++ apps/api/package.json | 1 + apps/api/src/db/schema.ts | 18 ++- apps/api/src/routes/documents.ts | 109 ++++++++++++++++ apps/web/src/pages/Documents.tsx | 138 ++++++++++++++++++++ pnpm-lock.yaml | 33 +++++ scripts/phase7_features.py | 210 +++++++++++++++++++++++++++++++ 8 files changed, 539 insertions(+), 1 deletion(-) create mode 100644 .phase7-state.json create mode 100644 apps/api/src/routes/documents.ts create mode 100644 apps/web/src/pages/Documents.tsx create mode 100644 scripts/phase7_features.py diff --git a/.phase7-state.json b/.phase7-state.json new file mode 100644 index 0000000..d7e23bc --- /dev/null +++ b/.phase7-state.json @@ -0,0 +1,5 @@ +{ + "completed_features": [], + "current_feature": "documents-upload", + "started_at": "2026-05-23T05:40:09.997191" +} \ No newline at end of file diff --git a/GENERATION_LOG.md b/GENERATION_LOG.md index be8af80..e5df2ee 100644 --- a/GENERATION_LOG.md +++ b/GENERATION_LOG.md @@ -756,3 +756,29 @@ Migrations completed successfully Checking for admin user... Admin user already exists + +## 🚀 Phase-7 Codegen-Run gestartet (2026-05-23 05:40:07) + +- `05:40:07` **INFO** Installing @fastify/multipart… +- `05:40:09` **INFO** rc=0: cies found: @esbuild-kit/core-utils@3.3.2, @esbuild-kit/esm-loader@2.6.5 +. | +6 + +Progress: resolved 447, reused 327, downloaded 0, added 6, done +Done in 1.8s + + +## Phase-3 Feature: documents-upload (2026-05-23 05:40:09) + +- `05:40:09` **INFO** Description: File-Upload via @fastify/multipart + Documents-Page +- `05:40:09` **INFO** Generating apps/api/src/db/schema.ts (ERWEITERT — behalte alle Tabellen. Füge `documents` (pgTable 'document…) +- `05:40:32` **INFO** wrote 2642 chars in 22.6s (attempt 1) +- `05:40:32` **INFO** Generating apps/api/src/routes/documents.ts (Fastify-Plugin /api/documents. Auth required. GET / (list user's docs,…) +- `05:40:58` **INFO** wrote 2753 chars in 25.7s (attempt 1) +- `05:40:58` **INFO** Generating apps/web/src/pages/Documents.tsx (Documents-Page. Drag-and-drop oder File-Input zum Upload. Liste aller …) +- `05:41:41` **INFO** wrote 5304 chars in 43.1s (attempt 1) +- `05:41:41` **INFO** Running tsc --noEmit on api… +- `05:41:42` **WARN** tsc errors: +src/routes/documents.ts(34,25): error TS2339: Property 'name' does not exist on type 'PgTableWithColumns<{ name: "documents"; schema: undefined; columns: { id: PgColumn<{ name: "id"; tableName: "documents"; dataType: "string"; columnType: "PgUUID"; data: string; driverParam: string; notNull: true; hasDefault: true; ... 6 more ...; generated: undefined; }, {}, {}>; ... 5 more ...; createdAt: PgColumn<...'. +src/routes/documents.ts(36,25): error TS2339: Property 'size' does not exist on type 'PgTableWithColumns<{ name: "documents"; schema: undefined; columns: { id: PgColumn<{ name: "id"; tableName: "documents"; dataType: "string"; columnType: "PgUUID"; data: string; driverParam: string; notNull: true; hasDefault: true; ... 6 more ...; generated: undefined; }, {}, {}>; ... 5 more ...; createdAt: PgColumn<...'. +src/routes/documents.ts(46,32): error TS2339: Property 'file' does not exist on type 'FastifyRequest>'. +src/routes/documents.ts(56,9): error TS2769: No overload matches this call. + Overload 1 of 2, '(value: { filename: string | SQL | Placeholder; contentType: string | SQL | Placeholder; sizeBytes: number | SQL<...> | Placeholder<...>; id?: string | ... 2 more ... | undefined; createdAt?: SQL<...> | ... 2 more ... | undefined; userId?: string | ... 3 more ... | undefined; c diff --git a/apps/api/package.json b/apps/api/package.json index 7460605..352cb9a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -16,6 +16,7 @@ "@fastify/cookie": "^9.3.1", "@fastify/cors": "^9.0.1", "@fastify/jwt": "^8.0.1", + "@fastify/multipart": "^10.0.0", "argon2": "^0.40.3", "drizzle-orm": "^0.36.0", "fastify": "^4.28.1", diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 7eb7071..9df405d 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -1,4 +1,10 @@ -import { pgTable, text, timestamp, boolean, uuid, integer } from "drizzle-orm/pg-core" +import { pgTable, text, timestamp, boolean, uuid, integer, customType } from "drizzle-orm/pg-core" + +const bytea = customType<{ data: Buffer }>({ + dataType() { + return "bytea" + }, +}) export const users = pgTable("users", { id: uuid("id").primaryKey().defaultRandom(), @@ -50,4 +56,14 @@ export const auditLog = pgTable("audit_log", { resourceId: text("resource_id"), metadata: text("metadata"), createdAt: timestamp("created_at").notNull().defaultNow() +}) + +export const documents = pgTable("documents", { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id").references(() => users.id), + filename: text("filename").notNull(), + contentType: text("content_type").notNull(), + sizeBytes: integer("size_bytes").notNull(), + content: bytea("content"), + createdAt: timestamp("created_at").notNull().defaultNow() }) \ No newline at end of file diff --git a/apps/api/src/routes/documents.ts b/apps/api/src/routes/documents.ts new file mode 100644 index 0000000..2f9f445 --- /dev/null +++ b/apps/api/src/routes/documents.ts @@ -0,0 +1,109 @@ +import { FastifyInstance } from "fastify" +import { db } from "../db" +import { documents } from "../db/schema" +import { eq, and } from "drizzle-orm" +import { z } from "zod" + +const DocumentSchema = z.object({ + name: z.string().min(1), + contentType: z.string(), + size: z.number(), + content: z.instanceof(Buffer) +}) + +export default async function documentRoutes(fastify: FastifyInstance) { + fastify.addHook("preHandler", async (request, reply) => { + try { + await request.jwtVerify() + } catch (err) { + return reply.code(401).send({ message: "Unauthorized" }) + } + }) + + fastify.get("/", async (request, reply) => { + const user = request.user as any + + const conds = [] + if (user.role !== "admin") { + conds.push(eq(documents.userId, user.id)) + } + + const results = await db + .select({ + id: documents.id, + name: documents.name, + contentType: documents.contentType, + size: documents.size, + createdAt: documents.createdAt + }) + .from(documents) + .where(conds.length ? and(...conds) : undefined as any) + + return results + }) + + fastify.post("/", async (request, reply) => { + const data = await request.file() + if (!data) { + return reply.code(400).send({ message: "No file uploaded" }) + } + + const user = request.user as any + + const [doc] = await db + .insert(documents) + .values({ + name: data.filename, + contentType: data.mimetype, + size: data.filesize, + content: data.data, + userId: user.id + }) + .returning({ id: documents.id, name: documents.name }) + + return reply.code(201).send(doc) + }) + + fastify.get("/:id/file", async (request, reply) => { + const { id } = request.params as { id: string } + const user = request.user as any + + const [doc] = await db + .select() + .from(documents) + .where( + user.role === "admin" + ? eq(documents.id, id) + : and(eq(documents.id, id), eq(documents.userId, user.id)) + ) + .limit(1) + + if (!doc) { + return reply.code(404).send({ message: "Document not found" }) + } + + reply.header("Content-Type", doc.contentType) + return reply.send(doc.content) + }) + + fastify.delete("/:id", async (request, reply) => { + const { id } = request.params as { id: string } + const user = request.user as any + + const conds = [eq(documents.id, id)] + if (user.role !== "admin") { + conds.push(eq(documents.userId, user.id)) + } + + const [doc] = await db + .delete(documents) + .where(and(...conds)) + .returning() + + if (!doc) { + return reply.code(404).send({ message: "Document not found or unauthorized" }) + } + + return reply.code(204).send() + }) +} \ No newline at end of file diff --git a/apps/web/src/pages/Documents.tsx b/apps/web/src/pages/Documents.tsx new file mode 100644 index 0000000..0b9f942 --- /dev/null +++ b/apps/web/src/pages/Documents.tsx @@ -0,0 +1,138 @@ +import { useState } from "react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { api } from "../lib/api" +import { EmptyState } from "../components/EmptyState" +import { LoadingSpinner } from "../components/LoadingSpinner" + +export default function Documents() { + const queryClient = useQueryClient() + const [uploading, setUploading] = useState(false) + + const { data: documents, isLoading, isError } = useQuery({ + queryKey: ["documents"], + queryFn: () => api.listDocuments() + }) + + const uploadMutation = useMutation({ + mutationFn: (file: File) => api.uploadDocument(file), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["documents"] }) + setUploading(false) + }, + onError: () => { + setUploading(false) + alert("Upload failed") + } + }) + + const deleteMutation = useMutation({ + mutationFn: (id: string) => api.deleteDocument(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["documents"] }) + } + }) + + const handleFileUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + + setUploading(true) + uploadMutation.mutate(file) + } + + const formatSize = (bytes: number) => { + if (bytes === 0) return "0 Bytes" + const k = 1024 + const sizes = ["Bytes", "KB", "MB", "GB"] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i] + } + + if (isError) return
Error loading documents.
+ + return ( +
+
+

Documents

+

Store and manage your project files

+
+ +
+
+

Upload a new document

+

PDF, Images, or Text files

+
+ + +
+ +
+ + + + + + + + + + + {isLoading ? ( + + + + ) : documents && documents.length > 0 ? ( + documents.map((doc) => ( + + + + + + + )) + ) : ( + + + + )} + +
FilenameSizeDateActions
+ +
+ {doc.filename} + + {formatSize(doc.size)} + + {new Date(doc.createdAt).toLocaleDateString()} + + + +
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a190f1..05519b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@fastify/jwt': specifier: ^8.0.1 version: 8.0.1 + '@fastify/multipart': + specifier: ^10.0.0 + version: 10.0.0 argon2: specifier: ^0.40.3 version: 0.40.3 @@ -1263,6 +1266,10 @@ packages: fast-uri: 2.4.0 dev: false + /@fastify/busboy@3.2.0: + resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} + dev: false + /@fastify/cookie@9.4.0: resolution: {integrity: sha512-Th+pt3kEkh4MQD/Q2q1bMuJIB5NX/D5SwSpOKu3G/tjoGbwfpurIMJsWSPS0SJJ4eyjtmQ8OipDQspf8RbUOlg==} dependencies: @@ -1277,10 +1284,18 @@ packages: mnemonist: 0.39.6 dev: false + /@fastify/deepmerge@3.2.1: + resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==} + dev: false + /@fastify/error@3.4.1: resolution: {integrity: sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==} dev: false + /@fastify/error@4.2.0: + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + dev: false + /@fastify/fast-json-stringify-compiler@4.3.0: resolution: {integrity: sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==} dependencies: @@ -1303,6 +1318,16 @@ packages: fast-deep-equal: 3.1.3 dev: false + /@fastify/multipart@10.0.0: + resolution: {integrity: sha512-pUx3Z1QStY7E7kwvDTIvB6P+rF5lzP+iqPgZyJyG3yBJVPvQaZxzDHYbQD89rbY0ciXrMOyGi8ezHDVexLvJDA==} + dependencies: + '@fastify/busboy': 3.2.0 + '@fastify/deepmerge': 3.2.1 + '@fastify/error': 4.2.0 + fastify-plugin: 5.1.0 + secure-json-parse: 4.1.0 + dev: false + /@jridgewell/gen-mapping@0.3.13: resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} dependencies: @@ -2480,6 +2505,10 @@ packages: resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} dev: false + /fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + dev: false + /fastify@4.29.1: resolution: {integrity: sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==} dependencies: @@ -3280,6 +3309,10 @@ packages: resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} dev: false + /secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + dev: false + /semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true diff --git a/scripts/phase7_features.py b/scripts/phase7_features.py new file mode 100644 index 0000000..3f60a24 --- /dev/null +++ b/scripts/phase7_features.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Phase-7: file-upload (docs), search, email-stub, dashboard-widgets, mobile-polish.""" + +from __future__ import annotations + +import asyncio +import datetime +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from phase2_features import Feature, FileGen, ROOT, log, log_section # noqa: E402 +from phase3_features import run_feature_v2 # noqa: E402 + +PHASE7_STATE = ROOT / ".phase7-state.json" + +FEATURES: list[Feature] = [ + Feature( + name="documents-upload", + description="File-Upload via @fastify/multipart + Documents-Page", + files=[ + FileGen( + path="apps/api/src/db/schema.ts", + purpose=( + "ERWEITERT — behalte alle Tabellen. Füge `documents` (pgTable 'documents'): " + "id (uuid pk default random), userId (uuid references users id), " + "filename (text notnull), contentType (text notnull), sizeBytes (integer notnull), " + "content (bytea via customType), createdAt (timestamp default now)." + ), + refs=["apps/api/src/db/schema.ts"], + ), + FileGen( + path="apps/api/src/routes/documents.ts", + purpose=( + "Fastify-Plugin /api/documents. Auth required. " + "GET / (list user's docs, metadata only — kein content), " + "POST / (multipart-Upload via request.file(), speichert filename+contentType+size+content), " + "GET /:id/file (returnt content mit Content-Type header), DELETE /:id. " + "User sieht nur eigene (außer admin)." + ), + refs=["apps/api/src/routes/customers.ts"], + ), + FileGen( + path="apps/web/src/pages/Documents.tsx", + purpose=( + "Documents-Page. Drag-and-drop oder File-Input zum Upload. " + "Liste aller Dokumente: filename, Größe (formatiert MB), datum, Download-Link + Delete-Button. " + "Verwende api.listDocuments(), api.uploadDocument(file), api.deleteDocument(id). " + "Download via window.open(`/api/documents/${id}/file`)." + ), + refs=["apps/web/src/pages/TimeEntries.tsx"], + ), + ], + ), + Feature( + name="search-everywhere", + description="Global Search API + Search-Bar component", + files=[ + FileGen( + path="apps/api/src/routes/search.ts", + purpose=( + "Fastify-Plugin /api/search?q=... Auth required. " + "Sucht in time-entries (description), customers (name), projects (name), users (email/name für admin). " + "Returns: { timeEntries, customers, projects, users? } arrays mit max 10 pro Kategorie. " + "Verwende drizzle ilike() für case-insensitive." + ), + refs=["apps/api/src/routes/customers.ts"], + ), + FileGen( + path="apps/web/src/components/SearchBar.tsx", + purpose=( + "Global Search-Component. Input rechts in Nav-Bar. Debounced (300ms). " + "Bei Input ≥2 chars: useQuery api.search(q). Dropdown unterhalb zeigt Resultate gruppiert. " + "Klick → navigate zur Detail-Page (z.B. /customers/$id)." + ), + refs=["apps/web/src/components/CommandPalette.tsx", "apps/web/src/lib/api.ts"], + ), + ], + ), + Feature( + name="email-notification-stub", + description="Email-Service-Stub für Notifications (console-log only, kein realer SMTP)", + files=[ + FileGen( + path="apps/api/src/services/email.ts", + purpose=( + "EmailService class. Methoden: sendWelcome(user), sendPasswordReset(email, token), sendDailyReminder(user). " + "MVP: nur console.log mit formatiertem Output (subject, to, body) — kein realer SMTP, das kommt später. " + "Export const emailService = new EmailService()." + ), + ), + FileGen( + path="apps/api/src/routes/users.ts", + purpose=( + "ERWEITERT — behalte alles. Füge in POST / (create user, admin-only): nach Insert ruf " + "emailService.sendWelcome(newUser) auf. Import service oben." + ), + refs=["apps/api/src/routes/users.ts"], + ), + ], + ), + Feature( + name="mobile-responsive-polish", + description="Nav + Pages mobile-friendly (Hamburger, stacking)", + files=[ + FileGen( + path="apps/web/src/components/Nav.tsx", + purpose=( + "ERWEITERT — Mobile-Hamburger (Menu-Icon) bei md:hidden, full Nav-Links als overlay-drawer beim Klick. " + "Desktop: bestehender flex Layout. Tailwind: md:flex vs Mobile-Menü-State (useState)." + ), + refs=["apps/web/src/components/Nav.tsx"], + ), + ], + ), + Feature( + name="api-client-phase7", + description="API um docs + search erweitert", + files=[ + FileGen( + path="apps/web/src/lib/api.ts", + purpose=( + "ERWEITERT — behalte ALLES. Füge: " + "listDocuments(), uploadDocument(file: File), deleteDocument(id), " + "search(q: string)." + ), + refs=["apps/web/src/lib/api.ts"], + ), + ], + ), + Feature( + name="router-phase7", + description="App + routes/index für phase7 Routes", + files=[ + FileGen( + path="apps/api/src/routes/index.ts", + purpose=( + "ERWEITERT — behalte alle. Füge documentsRoutes ('/api/documents'), searchRoutes ('/api/search')." + ), + refs=["apps/api/src/routes/index.ts"], + ), + FileGen( + path="apps/api/src/index.ts", + purpose=( + "ERWEITERT — behalte alles. Registriere @fastify/multipart Plugin: " + "`await server.register(import('@fastify/multipart').then(m => m.default), { limits: { fileSize: 20*1024*1024 } })`." + ), + refs=["apps/api/src/index.ts"], + ), + FileGen( + path="apps/web/src/App.tsx", + purpose="ERWEITERT — füge /documents (Documents Page) Route hinzu. Auth-Check.", + refs=["apps/web/src/App.tsx"], + ), + ], + ), +] + + +def load_state() -> dict: + if PHASE7_STATE.exists(): + return json.loads(PHASE7_STATE.read_text()) + return {"completed_features": [], "current_feature": None, "started_at": datetime.datetime.now().isoformat()} + + +def save_state(state: dict) -> None: + PHASE7_STATE.write_text(json.dumps(state, indent=2)) + + +async def main() -> int: + log_section("🚀 Phase-7 Codegen-Run gestartet") + + # ensure @fastify/multipart is installed + import subprocess + log("Installing @fastify/multipart…") + r = subprocess.run(["pnpm", "--filter", "api", "add", "@fastify/multipart"], + cwd=ROOT, capture_output=True, text=True, timeout=120) + log(f" rc={r.returncode}: {r.stdout[-200:]}") + + state = load_state() + for feature in FEATURES: + if feature.name in state.get("completed_features", []): + continue + state["current_feature"] = feature.name; save_state(state) + try: + success = await run_feature_v2(feature) + if success: + state.setdefault("completed_features", []).append(feature.name) + else: + state.setdefault("attempted_features", []).append(feature.name) + save_state(state) + except Exception as e: + log(f"❌ {feature.name} crashed: {e}", level="ERROR") + state.setdefault("attempted_features", []).append(feature.name); save_state(state) + + log_section("Phase-7 Run beendet") + log(f"OK: {len(state.get('completed_features', []))}, Attempted: {len(state.get('attempted_features', []))}, Total: {len(FEATURES)}") + + # auto db:generate + migrate + log("Running db:generate + db:migrate…") + r = subprocess.run(["pnpm", "--filter", "api", "db:generate"], cwd=ROOT, capture_output=True, text=True, timeout=60) + log(f" db:generate rc={r.returncode}: {r.stdout[-200:]}") + r = subprocess.run(["pnpm", "--filter", "api", "db:migrate"], cwd=ROOT, capture_output=True, text=True, timeout=60) + log(f" db:migrate rc={r.returncode}: {r.stdout[-200:]}") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main()))