#!/usr/bin/env python3 """Phase-15: saved-views, webhooks-trigger, email-verification, password-reset, weekly-summary.""" 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 PHASE15_STATE = ROOT / ".phase15-state.json" FEATURES: list[Feature] = [ Feature( name="saved-views", description="Saved-Filter-Views für TimeEntries (named presets)", files=[ FileGen( path="apps/api/src/db/schema.ts", purpose=( "ERWEITERT — füge `savedViews` pgTable: id (uuid pk), userId (uuid references users), " "name (text), entityType (text, e.g. 'time-entries'), filters (text, JSON), createdAt. " "Behalte alles." ), refs=["apps/api/src/db/schema.ts"], ), FileGen( path="apps/api/src/routes/saved-views.ts", purpose=( "Fastify-Plugin /api/saved-views. CRUD: GET (list user's views, optional ?entityType=...), POST, DELETE /:id. " "Use FastifyInstance type." ), refs=["apps/api/src/routes/customers.ts"], ), FileGen( path="apps/web/src/pages/TimeEntries.tsx", purpose=( "ERWEITERT — füge Saved-Views-Dropdown ins Filter-Bar: " "Auswahl lädt Filter-Settings; 'Save current' Button speichert aktuelle Filter mit name-prompt. " "Verwende api.listSavedViews({entityType:'time-entries'}), api.createSavedView, api.deleteSavedView." ), refs=["apps/web/src/pages/TimeEntries.tsx"], ), ], ), Feature( name="webhook-trigger-events", description="Echter Webhook-Send bei TimeEntry-Create/Update/Delete", files=[ FileGen( path="apps/api/src/services/webhooks.ts", purpose=( "WebhookDispatcher class. Methode triggerEvent(event: string, payload: any): " "queried alle webhooks wo event matched UND active=true, dann fetch-POST mit payload als JSON. " "Fire-and-forget (kein await). Errors loggen. Export const webhookDispatcher = new WebhookDispatcher()." ), ), FileGen( path="apps/api/src/routes/time-entries.ts", purpose=( "ERWEITERT — behalte alles. Nach insert/update/delete jeweils webhookDispatcher.triggerEvent(" "'time_entry.created'/'time_entry.updated'/'time_entry.deleted', entryObject). " "Import webhookDispatcher oben." ), refs=["apps/api/src/routes/time-entries.ts"], ), ], ), Feature( name="password-reset", description="Password-Reset-Flow (Request + Set new via token)", files=[ FileGen( path="apps/api/src/db/schema.ts", purpose=( "ERWEITERT — füge `passwordResetTokens` pgTable: id (uuid pk), userId (uuid references users), " "tokenHash (text notnull), expiresAt (timestamp), usedAt (timestamp nullable), createdAt. " "Behalte alles." ), refs=["apps/api/src/db/schema.ts"], ), FileGen( path="apps/api/src/routes/auth.ts", purpose=( "ERWEITERT — behalte alles. Füge POST /forgot-password (body: {email}): generate random-token, " "save hash + expires in 1h, ruf emailService.sendPasswordReset(email, token) auf. Always 200 (no-leak). " "POST /reset-password (body: {token, newPassword}): verify token + not expired + not used, " "hash newPassword via argon2, update user, mark used." ), refs=["apps/api/src/routes/auth.ts"], ), FileGen( path="apps/web/src/pages/ForgotPassword.tsx", purpose=( "ForgotPassword-Page. Form mit email-Input → api.forgotPassword(email), Toast 'Email gesendet falls Account existiert'." ), ), FileGen( path="apps/web/src/pages/ResetPassword.tsx", purpose=( "ResetPassword-Page. Liest ?token=... aus URL. Form mit newPassword + Bestätigung. " "Submit → api.resetPassword(token, newPassword), redirect /login." ), ), ], ), Feature( name="weekly-summary-email-stub", description="Cron-stub für weekly-summary-email (Endpoint manuell triggerbar)", files=[ FileGen( path="apps/api/src/routes/notifications.ts", purpose=( "Fastify-Plugin /api/notifications. POST /send-weekly-summary (admin-only): " "iteriert alle users, ruft emailService.sendWeeklySummary(user) für jeden auf. " "Return {sent: count}. In v2 wird das als cron-job laufen." ), refs=["apps/api/src/routes/users.ts"], ), FileGen( path="apps/api/src/services/email.ts", purpose=( "ERWEITERT — füge sendWeeklySummary(user) Methode. Fetched user's time-entries last week, " "console.logs formatted summary (subject, body)." ), refs=["apps/api/src/services/email.ts"], ), ], ), Feature( name="api-client-phase15", description="API um phase15 endpoints erweitert", files=[ FileGen( path="apps/web/src/lib/api.ts", purpose=( "ERWEITERT — behalte ALLES. Füge: listSavedViews(opts?), createSavedView(data), deleteSavedView(id), " "forgotPassword(email), resetPassword(token, password)." ), refs=["apps/web/src/lib/api.ts"], ), ], ), Feature( name="router-phase15", description="Mount neue routes", files=[ FileGen( path="apps/api/src/routes/index.ts", purpose=( "ERWEITERT — füge savedViewRoutes ('/api/saved-views') + notificationRoutes ('/api/notifications'). Behalte alles." ), refs=["apps/api/src/routes/index.ts"], ), FileGen( path="apps/web/src/App.tsx", purpose="ERWEITERT — füge /forgot-password und /reset-password Routes (public, no auth). Behalte alles.", refs=["apps/web/src/App.tsx"], ), FileGen( path="apps/web/src/pages/Login.tsx", purpose=( "ERWEITERT — füge 'Passwort vergessen?'-Link unten zur /forgot-password. Behalte alles." ), refs=["apps/web/src/pages/Login.tsx"], ), ], ), ] def load_state() -> dict: if PHASE15_STATE.exists(): return json.loads(PHASE15_STATE.read_text()) return {"completed_features": [], "current_feature": None, "started_at": datetime.datetime.now().isoformat()} def save_state(state: dict) -> None: PHASE15_STATE.write_text(json.dumps(state, indent=2)) async def main() -> int: log_section("🚀 Phase-15 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-15 Run beendet") log(f"OK: {len(state.get('completed_features', []))}, Attempted: {len(state.get('attempted_features', []))}, Total: {len(FEATURES)}") return 0 if __name__ == "__main__": sys.exit(asyncio.run(main()))