From 791e6069d6d0da2a357a52b757c63545655e0d3c Mon Sep 17 00:00:00 2001 From: "Dennis (via Claude+Gemma)" Date: Sat, 23 May 2026 06:04:08 +0200 Subject: [PATCH] feat(webhooks-config): Outgoing-Webhooks Tabelle + CRUD + UI [tsc:fail] --- .phase9-state.json | 5 + GENERATION_LOG.md | 22 ++++ apps/api/src/db/schema.ts | 9 ++ apps/api/src/routes/webhooks.ts | 128 ++++++++++++++++++++++ apps/web/src/pages/Webhooks.tsx | 146 +++++++++++++++++++++++++ scripts/phase9_features.py | 183 ++++++++++++++++++++++++++++++++ 6 files changed, 493 insertions(+) create mode 100644 .phase9-state.json create mode 100644 apps/api/src/routes/webhooks.ts create mode 100644 apps/web/src/pages/Webhooks.tsx create mode 100644 scripts/phase9_features.py diff --git a/.phase9-state.json b/.phase9-state.json new file mode 100644 index 0000000..2b3e6a4 --- /dev/null +++ b/.phase9-state.json @@ -0,0 +1,5 @@ +{ + "completed_features": [], + "current_feature": "webhooks-config", + "started_at": "2026-05-23T06:02:21.166704" +} \ No newline at end of file diff --git a/GENERATION_LOG.md b/GENERATION_LOG.md index a60ae16..5eebfdb 100644 --- a/GENERATION_LOG.md +++ b/GENERATION_LOG.md @@ -1017,3 +1017,25 @@ Migrations completed successfully Checking for admin user... Admin user already exists + +## 🚀 Phase-9 Codegen-Run gestartet (2026-05-23 06:02:21) + + +## Phase-3 Feature: webhooks-config (2026-05-23 06:02:21) + +- `06:02:21` **INFO** Description: Outgoing-Webhooks Tabelle + CRUD + UI +- `06:02:21` **INFO** Generating apps/api/src/db/schema.ts (ERWEITERT — füge `webhooks` (pgTable 'webhooks'): id (uuid pk default …) +- `06:02:46` **INFO** wrote 2976 chars in 25.6s (attempt 1) +- `06:02:46` **INFO** Generating apps/api/src/routes/webhooks.ts (Fastify-Plugin /api/webhooks. Admin-only (preHandler checks role). CRU…) +- `06:03:18` **INFO** wrote 3402 chars in 31.4s (attempt 1) +- `06:03:18` **INFO** Generating apps/web/src/pages/Webhooks.tsx (Webhooks-Page (admin-only). Liste + Create-Form (url, event-select). T…) +- `06:04:06` **INFO** wrote 5951 chars in 48.8s (attempt 1) +- `06:04:06` **INFO** Running tsc --noEmit on api… +- `06:04:08` **WARN** tsc errors: +src/index.ts(27,25): error TS2769: No overload matches this call. + Overload 1 of 3, '(plugin: FastifyPluginCallback<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error. + Argument of type 'Promise' is not assignable to parameter of type 'FastifyPluginCallback<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'. + Type 'Promise' provides no match for the signature '(instance: FastifyInstance, FastifyBaseLogger, FastifyTypeProvider>, opts: { ...; }, done: (err?: Error | undefined) => void): void'. + Overload 2 of 3, '(plugin: FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error. + Argument of type 'Promise' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'. + Type 'Promise' provides no match for the signature '(instance: FastifyInstance, FastifyBaseLogger, FastifyTy diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 9df405d..e961c20 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -66,4 +66,13 @@ export const documents = pgTable("documents", { sizeBytes: integer("size_bytes").notNull(), content: bytea("content"), createdAt: timestamp("created_at").notNull().defaultNow() +}) + +export const webhooks = pgTable("webhooks", { + id: uuid("id").primaryKey().defaultRandom(), + url: text("url").notNull(), + event: text("event").notNull(), + active: boolean("active").notNull().default(true), + createdAt: timestamp("created_at").notNull().defaultNow(), + createdBy: uuid("created_by").references(() => users.id) }) \ No newline at end of file diff --git a/apps/api/src/routes/webhooks.ts b/apps/api/src/routes/webhooks.ts new file mode 100644 index 0000000..d209144 --- /dev/null +++ b/apps/api/src/routes/webhooks.ts @@ -0,0 +1,128 @@ +import { FastifyInstance } from "fastify" +import { db } from "../db" +import { webhooks } from "../db/schema" +import { eq } from "drizzle-orm" +import { z } from "zod" + +const WebhookCreateSchema = z.object({ + url: z.string().url(), + event: z.string(), + secret: z.string().min(1).optional(), + active: z.boolean().optional() +}) + +const WebhookUpdateSchema = z.object({ + url: z.string().url().optional(), + event: z.string().optional(), + secret: z.string().optional(), + active: z.boolean().optional() +}) + +export default async function webhookRoutes(fastify: FastifyInstance) { + fastify.addHook("preHandler", async (request, reply) => { + try { + await request.jwtVerify() + } catch (err) { + return reply.code(401).send({ message: "Unauthorized" }) + } + }) + + const isAdmin = (request: any) => { + return (request.user as { sub: string, role: string })?.role === "admin" + } + + fastify.get("/", async (request, reply) => { + if (!isAdmin(request)) { + return reply.code(403).send({ message: "Forbidden: Admin role required" }) + } + + return await db.select().from(webhooks) + }) + + fastify.post("/", async (request, reply) => { + if (!isAdmin(request)) { + return reply.code(403).send({ message: "Forbidden: Admin role required" }) + } + + const body = WebhookCreateSchema.parse(request.body) + + const [webhook] = await db + .insert(webhooks) + .values(body) + .returning() + + return reply.code(201).send(webhook) + }) + + fastify.patch("/:id", async (request, reply) => { + if (!isAdmin(request)) { + return reply.code(403).send({ message: "Forbidden: Admin role required" }) + } + + const { id } = request.params as { id: string } + const body = WebhookUpdateSchema.parse(request.body) + + const [webhook] = await db + .update(webhooks) + .set(body) + .where(eq(webhooks.id, id)) + .returning() + + if (!webhook) { + return reply.code(404).send({ message: "Webhook not found" }) + } + + return webhook + }) + + fastify.delete("/:id", async (request, reply) => { + if (!isAdmin(request)) { + return reply.code(403).send({ message: "Forbidden: Admin role required" }) + } + + const { id } = request.params as { id: string } + + const result = await db + .delete(webhooks) + .where(eq(webhooks.id, id)) + + if (result.rowCount === 0) { + return reply.code(404).send({ message: "Webhook not found" }) + } + + return reply.send({ message: "Webhook deleted successfully" }) + }) + + fastify.post("/:id/test", async (request, reply) => { + if (!isAdmin(request)) { + return reply.code(403).send({ message: "Forbidden: Admin role required" }) + } + + const { id } = request.params as { id: string } + + const [webhook] = await db + .select() + .from(webhooks) + .where(eq(webhooks.id, id)) + .limit(1) + + if (!webhook) { + return reply.code(404).send({ message: "Webhook not found" }) + } + + try { + await fastify.inject({ + method: 'POST', + url: webhook.url, + payload: { + event: "webhook.test", + timestamp: new Date().toISOString(), + data: { message: "This is a test payload from EmberClone" } + } + }) + return reply.send({ message: "Test payload sent successfully" }) + } catch (error) { + return reply.code(500).send({ message: "Failed to send test payload", error }) + } + }) +} \ No newline at end of file diff --git a/apps/web/src/pages/Webhooks.tsx b/apps/web/src/pages/Webhooks.tsx new file mode 100644 index 0000000..fcb5a36 --- /dev/null +++ b/apps/web/src/pages/Webhooks.tsx @@ -0,0 +1,146 @@ +import { useState } from "react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { api } from "../lib/api" +import type { Webhook } from "@emberclone/shared" + +export default function Webhooks() { + const queryClient = useQueryClient() + const [url, setUrl] = useState("") + const [event, setEvent] = useState("customer.created") + + const { data: webhooks, isLoading, isError } = useQuery({ + queryKey: ["webhooks"], + queryFn: () => api.listWebhooks() + }) + + const createMutation = useMutation({ + mutationFn: (payload: { url: string; event: string }) => api.createWebhook(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["webhooks"] }) + setUrl("") + } + }) + + const deleteMutation = useMutation({ + mutationFn: (id: string) => api.deleteWebhook(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["webhooks"] }) + } + }) + + const testMutation = useMutation({ + mutationFn: (id: string) => api.testWebhook(id), + onSuccess: () => { + alert("Test webhook sent successfully") + }, + onError: (error: any) => { + alert(`Test failed: ${error.message || "Unknown error"}`) + } + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!url.trim()) return + createMutation.mutate({ url, event }) + } + + if (isLoading) return
Loading webhooks...
+ if (isError) return
Error loading webhooks.
+ + return ( +
+
+

Webhooks

+

Configure external notifications for system events

+
+ +
+

Create New Webhook

+
+
+ + setUrl(e.target.value)} + placeholder="https://your-api.com/webhook" + /> +
+
+ + +
+
+ +
+
+
+ +
+ + + + + + + + + + {webhooks?.length === 0 && ( + + + + )} + {webhooks?.map((webhook: Webhook) => ( + + + + + + ))} + +
EventURLActions
No webhooks configured.
+ + {webhook.event} + + + {webhook.url} + + + +
+
+
+ ) +} \ No newline at end of file diff --git a/scripts/phase9_features.py b/scripts/phase9_features.py new file mode 100644 index 0000000..a142313 --- /dev/null +++ b/scripts/phase9_features.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Phase-9: webhooks, scheduled-reports, 2fa-stub, billing-stub, integrations.""" + +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 + +PHASE9_STATE = ROOT / ".phase9-state.json" + +FEATURES: list[Feature] = [ + Feature( + name="webhooks-config", + description="Outgoing-Webhooks Tabelle + CRUD + UI", + files=[ + FileGen( + path="apps/api/src/db/schema.ts", + purpose=( + "ERWEITERT — füge `webhooks` (pgTable 'webhooks'): " + "id (uuid pk default random), url (text notnull), event (text notnull, e.g. 'time_entry.created'), " + "active (boolean default true), createdAt (timestamp default now), createdBy (uuid references users id)." + ), + refs=["apps/api/src/db/schema.ts"], + ), + FileGen( + path="apps/api/src/routes/webhooks.ts", + purpose=( + "Fastify-Plugin /api/webhooks. Admin-only (preHandler checks role). " + "CRUD: GET / (list), POST /, PATCH /:id, DELETE /:id, POST /:id/test (sendet test-payload)." + ), + refs=["apps/api/src/routes/users.ts"], + ), + FileGen( + path="apps/web/src/pages/Webhooks.tsx", + purpose=( + "Webhooks-Page (admin-only). Liste + Create-Form (url, event-select). " + "Test-Button pro Webhook. Verwende api.listWebhooks, api.createWebhook, api.deleteWebhook, api.testWebhook." + ), + refs=["apps/web/src/pages/Customers.tsx"], + ), + ], + ), + Feature( + name="two-factor-auth-stub", + description="2FA-Setup-Page (TOTP-Stub, kein realer verify yet)", + files=[ + FileGen( + path="apps/web/src/pages/TwoFactorAuth.tsx", + purpose=( + "2FA-Setup-Page. Zeigt fake QR-Code-Placeholder (SVG-Box mit 'QR-Code here'), " + "Secret-String (random base32, useState), Input für 6-stelligen Code. " + "Submit: stub, zeigt Toast 'TOTP-Setup-MVP — Verifikation kommt in v2'. " + "Tailwind, max-w-md." + ), + refs=["apps/web/src/pages/Profile.tsx"], + ), + ], + ), + Feature( + name="billing-stub", + description="Plans-Page mit Pricing-Tiers (UI only, kein Stripe)", + files=[ + FileGen( + path="apps/web/src/pages/Billing.tsx", + purpose=( + "Billing/Plans-Page. 3 Pricing-Cards: 'Free' (0€, 1 User, 100 entries/Monat), " + "'Team' (12€/User/Monat, unbegrenzte entries, Customers + Projects), " + "'Enterprise' (custom, SSO, audit-log, priority support). " + "Aktueller Plan: Free badge oben. Upgrade-Button → Toast 'Stripe-Integration kommt in v2'. " + "Tailwind: grid-cols-3, hover-scale auf Cards." + ), + refs=["apps/web/src/pages/AdminUsers.tsx"], + ), + ], + ), + Feature( + name="integrations-page", + description="Integrations-Page mit Slack/Discord/Webhook-Cards", + files=[ + FileGen( + path="apps/web/src/pages/Integrations.tsx", + purpose=( + "Integrations-Page. Grid mit Karten für: Slack, Discord, Webhooks, Calendar (Google), Email. " + "Pro Karte: Icon (lucide-react), Name, kurze Beschreibung, 'Configure'-Button. " + "Slack/Discord/Calendar: 'Coming Soon' Badge + disabled Button. " + "Webhooks: links zu /webhooks. Email: links zu /settings. " + "Tailwind cards, hover-shadow." + ), + ), + ], + ), + Feature( + name="api-client-phase9", + description="API um Webhooks endpoints erweitert", + files=[ + FileGen( + path="apps/web/src/lib/api.ts", + purpose=( + "ERWEITERT — behalte ALLES. Füge: " + "listWebhooks(), createWebhook({url, event}), updateWebhook(id, data), deleteWebhook(id), testWebhook(id)." + ), + refs=["apps/web/src/lib/api.ts"], + ), + ], + ), + Feature( + name="router-phase9", + description="App + routes/index für phase9 Routes", + files=[ + FileGen( + path="apps/api/src/routes/index.ts", + purpose="ERWEITERT — füge webhookRoutes ('/api/webhooks'). Behalte alle bestehenden.", + refs=["apps/api/src/routes/index.ts"], + ), + FileGen( + path="apps/web/src/App.tsx", + purpose=( + "ERWEITERT — füge /webhooks (admin), /2fa, /billing, /integrations Routes. Behalte alles." + ), + refs=["apps/web/src/App.tsx"], + ), + FileGen( + path="apps/web/src/components/Nav.tsx", + purpose=( + "ERWEITERT — füge Integrations-Link (alle Users) + Webhooks-Link (admin) + Billing-Link (alle). " + "2FA-Link nur in Profile-Page (separate Section). Behalte alle bestehenden Links." + ), + refs=["apps/web/src/components/Nav.tsx"], + ), + ], + ), +] + + +def load_state() -> dict: + if PHASE9_STATE.exists(): + return json.loads(PHASE9_STATE.read_text()) + return {"completed_features": [], "current_feature": None, "started_at": datetime.datetime.now().isoformat()} + + +def save_state(state: dict) -> None: + PHASE9_STATE.write_text(json.dumps(state, indent=2)) + + +async def main() -> int: + log_section("🚀 Phase-9 Codegen-Run gestartet") + 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-9 Run beendet") + log(f"OK: {len(state.get('completed_features', []))}, Attempted: {len(state.get('attempted_features', []))}, Total: {len(FEATURES)}") + + import subprocess + 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()))