feat(documents-upload): File-Upload via @fastify/multipart + Documents-Page [tsc:fail]

This commit is contained in:
Dennis (via Claude+Gemma) 2026-05-23 05:41:42 +02:00
parent c99263ad4e
commit 26805dca90
8 changed files with 539 additions and 1 deletions

5
.phase7-state.json Normal file
View File

@ -0,0 +1,5 @@
{
"completed_features": [],
"current_feature": "documents-upload",
"started_at": "2026-05-23T05:40:09.997191"
}

View File

@ -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<RouteGenericInterface, RawServerDefault, IncomingMessage, FastifySchema, FastifyTypeProviderDefault, unknown, FastifyBaseLogger, ResolveFastifyRequestType<...>>'.
src/routes/documents.ts(56,9): error TS2769: No overload matches this call.
Overload 1 of 2, '(value: { filename: string | SQL<unknown> | Placeholder<string, any>; contentType: string | SQL<unknown> | Placeholder<string, any>; sizeBytes: number | SQL<...> | Placeholder<...>; id?: string | ... 2 more ... | undefined; createdAt?: SQL<...> | ... 2 more ... | undefined; userId?: string | ... 3 more ... | undefined; c

View File

@ -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",

View File

@ -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()
})

View File

@ -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()
})
}

View File

@ -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<HTMLInputElement>) => {
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 <div className="p-6 text-red-500">Error loading documents.</div>
return (
<div className="p-6 max-w-6xl mx-auto space-y-8">
<header>
<h1 className="text-2xl font-bold text-gray-900">Documents</h1>
<p className="text-gray-500">Store and manage your project files</p>
</header>
<section className="bg-white p-8 rounded-lg border-2 border-dashed border-gray-300 flex flex-col items-center justify-center space-y-4 hover:border-blue-400 transition-colors">
<div className="text-center">
<p className="text-lg font-medium text-gray-700">Upload a new document</p>
<p className="text-sm text-gray-500 mb-4">PDF, Images, or Text files</p>
</div>
<label className="relative cursor-pointer bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 transition-colors font-medium">
{uploading ? "Uploading..." : "Select File"}
<input
type="file"
className="hidden"
onChange={handleFileUpload}
disabled={uploading}
/>
</label>
</section>
<section className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
<table className="w-full text-left border-collapse">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-xs font-semibold text-gray-600 uppercase">Filename</th>
<th className="px-6 py-3 text-xs font-semibold text-gray-600 uppercase">Size</th>
<th className="px-6 py-3 text-xs font-semibold text-gray-600 uppercase">Date</th>
<th className="px-6 py-3 text-xs font-semibold text-gray-600 uppercase text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{isLoading ? (
<tr>
<td colSpan={4} className="px-6 py-10 text-center">
<LoadingSpinner />
</td>
</tr>
) : documents && documents.length > 0 ? (
documents.map((doc) => (
<tr key={doc.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 text-sm font-medium text-gray-900 truncate max-w-xs">
{doc.filename}
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{formatSize(doc.size)}
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{new Date(doc.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4 text-sm text-right space-x-3">
<button
onClick={() => window.open(`/api/documents/${doc.id}/file`)}
className="text-blue-600 hover:text-blue-800 font-medium"
>
Download
</button>
<button
onClick={() => {
if (confirm("Delete this document?")) {
deleteMutation.mutate(doc.id)
}
}}
className="text-red-600 hover:text-red-800 font-medium"
>
Delete
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={4} className="px-6 py-10">
<EmptyState message="No documents uploaded yet." />
</td>
</tr>
)}
</tbody>
</table>
</section>
</div>
)
}

33
pnpm-lock.yaml generated
View File

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

210
scripts/phase7_features.py Normal file
View File

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