feat(documents-upload): File-Upload via @fastify/multipart + Documents-Page [tsc:fail]
This commit is contained in:
parent
c99263ad4e
commit
26805dca90
5
.phase7-state.json
Normal file
5
.phase7-state.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"completed_features": [],
|
||||||
|
"current_feature": "documents-upload",
|
||||||
|
"started_at": "2026-05-23T05:40:09.997191"
|
||||||
|
}
|
||||||
@ -756,3 +756,29 @@ Migrations completed successfully
|
|||||||
Checking for admin user...
|
Checking for admin user...
|
||||||
Admin user already exists
|
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
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
"@fastify/cookie": "^9.3.1",
|
"@fastify/cookie": "^9.3.1",
|
||||||
"@fastify/cors": "^9.0.1",
|
"@fastify/cors": "^9.0.1",
|
||||||
"@fastify/jwt": "^8.0.1",
|
"@fastify/jwt": "^8.0.1",
|
||||||
|
"@fastify/multipart": "^10.0.0",
|
||||||
"argon2": "^0.40.3",
|
"argon2": "^0.40.3",
|
||||||
"drizzle-orm": "^0.36.0",
|
"drizzle-orm": "^0.36.0",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
|
|||||||
@ -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", {
|
export const users = pgTable("users", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
@ -51,3 +57,13 @@ export const auditLog = pgTable("audit_log", {
|
|||||||
metadata: text("metadata"),
|
metadata: text("metadata"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow()
|
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()
|
||||||
|
})
|
||||||
109
apps/api/src/routes/documents.ts
Normal file
109
apps/api/src/routes/documents.ts
Normal 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()
|
||||||
|
})
|
||||||
|
}
|
||||||
138
apps/web/src/pages/Documents.tsx
Normal file
138
apps/web/src/pages/Documents.tsx
Normal 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
33
pnpm-lock.yaml
generated
@ -29,6 +29,9 @@ importers:
|
|||||||
'@fastify/jwt':
|
'@fastify/jwt':
|
||||||
specifier: ^8.0.1
|
specifier: ^8.0.1
|
||||||
version: 8.0.1
|
version: 8.0.1
|
||||||
|
'@fastify/multipart':
|
||||||
|
specifier: ^10.0.0
|
||||||
|
version: 10.0.0
|
||||||
argon2:
|
argon2:
|
||||||
specifier: ^0.40.3
|
specifier: ^0.40.3
|
||||||
version: 0.40.3
|
version: 0.40.3
|
||||||
@ -1263,6 +1266,10 @@ packages:
|
|||||||
fast-uri: 2.4.0
|
fast-uri: 2.4.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@fastify/busboy@3.2.0:
|
||||||
|
resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@fastify/cookie@9.4.0:
|
/@fastify/cookie@9.4.0:
|
||||||
resolution: {integrity: sha512-Th+pt3kEkh4MQD/Q2q1bMuJIB5NX/D5SwSpOKu3G/tjoGbwfpurIMJsWSPS0SJJ4eyjtmQ8OipDQspf8RbUOlg==}
|
resolution: {integrity: sha512-Th+pt3kEkh4MQD/Q2q1bMuJIB5NX/D5SwSpOKu3G/tjoGbwfpurIMJsWSPS0SJJ4eyjtmQ8OipDQspf8RbUOlg==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -1277,10 +1284,18 @@ packages:
|
|||||||
mnemonist: 0.39.6
|
mnemonist: 0.39.6
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@fastify/deepmerge@3.2.1:
|
||||||
|
resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@fastify/error@3.4.1:
|
/@fastify/error@3.4.1:
|
||||||
resolution: {integrity: sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==}
|
resolution: {integrity: sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@fastify/error@4.2.0:
|
||||||
|
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@fastify/fast-json-stringify-compiler@4.3.0:
|
/@fastify/fast-json-stringify-compiler@4.3.0:
|
||||||
resolution: {integrity: sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==}
|
resolution: {integrity: sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -1303,6 +1318,16 @@ packages:
|
|||||||
fast-deep-equal: 3.1.3
|
fast-deep-equal: 3.1.3
|
||||||
dev: false
|
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:
|
/@jridgewell/gen-mapping@0.3.13:
|
||||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -2480,6 +2505,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==}
|
resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/fastify-plugin@5.1.0:
|
||||||
|
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/fastify@4.29.1:
|
/fastify@4.29.1:
|
||||||
resolution: {integrity: sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==}
|
resolution: {integrity: sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -3280,6 +3309,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==}
|
resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/secure-json-parse@4.1.0:
|
||||||
|
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/semver@6.3.1:
|
/semver@6.3.1:
|
||||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|||||||
210
scripts/phase7_features.py
Normal file
210
scripts/phase7_features.py
Normal 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()))
|
||||||
Loading…
Reference in New Issue
Block a user