#!/usr/bin/env python3 """Phase-18: invoice-pdf-real, api-key-management, audit-log-filters, idle-detection, time-entry-comments.""" 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 PHASE18_STATE = ROOT / ".phase18-state.json" FEATURES: list[Feature] = [ Feature( name="api-key-management", description="API-Keys für users (für REST-Programmzugriff)", files=[ FileGen( path="apps/api/src/db/schema.ts", purpose=( "WICHTIG: behalte alle bestehenden Tabellen — füge nur `apiKeys` neu hinzu. " "Behalte vor allem `passwordResetTokens`. " "Neue Tabelle: apiKeys (id, userId references users, name, keyHash, createdAt, lastUsedAt nullable, revokedAt nullable)." ), refs=["apps/api/src/db/schema.ts"], ), FileGen( path="apps/api/src/routes/api-keys.ts", purpose=( "Fastify-Plugin /api/api-keys. Auth required. " "GET / (list user's keys, ohne keyHash), POST / (generate random key, return plaintext einmalig, store hash), " "DELETE /:id (revoke = set revokedAt). Use FastifyInstance." ), refs=["apps/api/src/routes/users.ts"], ), FileGen( path="apps/web/src/pages/ApiKeys.tsx", purpose=( "ApiKeys-Page. Liste der eigenen Keys (name, lastUsed, status). " "Create-Form mit name. Bei create: Modal zeigt key PLAINTEXT einmal (Copy-Button)." ), refs=["apps/web/src/pages/Customers.tsx"], ), ], ), Feature( name="audit-log-filters", description="Audit-Log mit Filter (user, action, date-range)", files=[ FileGen( path="apps/api/src/routes/audit-log.ts", purpose=( "ERWEITERT — behalte GET /. Füge Query-Params ?userId=, ?action=, ?from=, ?to=. " "WHERE clauses mit drizzle and(). Behalte admin-only-Logik." ), refs=["apps/api/src/routes/audit-log.ts"], ), FileGen( path="apps/web/src/pages/AuditLog.tsx", purpose=( "ERWEITERT — behalte Tabelle. Füge Filter-Bar: User-Select, Action-Search, Date-Range. " "Refetch bei Filter-Change." ), refs=["apps/web/src/pages/AuditLog.tsx"], ), ], ), Feature( name="idle-detection", description="Idle-Detection: nach 5min Inaktivität Active-Timer pausieren-prompt", files=[ FileGen( path="apps/web/src/components/IdleDetector.tsx", purpose=( "IdleDetector-Component. Listens auf mousemove, keypress. Resettet Timer. " "Nach 5min ohne Activity UND wenn ActiveTimer läuft: zeigt Modal 'Du warst 5min inaktiv. " "Timer pausieren oder weiterlaufen lassen?'. Bei pause: api.stopTimeEntry." ), refs=["apps/web/src/components/ActiveTimer.tsx"], ), FileGen( path="apps/web/src/App.tsx", purpose="ERWEITERT — mount global im Root-Route. Behalte alles.", refs=["apps/web/src/App.tsx"], ), ], ), Feature( name="time-entry-comments", description="Kommentare/Notes pro TimeEntry als Thread", files=[ FileGen( path="apps/api/src/db/schema.ts", purpose=( "WICHTIG: behalte ALLE bestehenden Tabellen (vor allem passwordResetTokens, apiKeys). " "Füge `timeEntryComments` Tabelle: id, entryId references timeEntries, userId references users, " "body text, createdAt." ), refs=["apps/api/src/db/schema.ts"], ), FileGen( path="apps/api/src/routes/time-entry-comments.ts", purpose=( "Fastify-Plugin /api/time-entry-comments. Auth required. " "GET /entries/:entryId/comments (list), POST /entries/:entryId/comments (body: {body}), DELETE /:id. " "User kann nur eigene löschen (außer admin)." ), ), ], ), Feature( name="api-client-phase18", description="API um apikeys + audit-filters + comments erweitern", files=[ FileGen( path="apps/web/src/lib/api.ts", purpose=( "ERWEITERT — behalte ALLES. Füge: listApiKeys, createApiKey(name), revokeApiKey(id), " "listAuditLog(filters), listEntryComments(entryId), createEntryComment(entryId, body), deleteEntryComment(id)." ), refs=["apps/web/src/lib/api.ts"], ), ], ), Feature( name="router-phase18", description="Mount + UI-Routen für api-keys, comments", files=[ FileGen( path="apps/api/src/routes/index.ts", purpose="ERWEITERT — füge apiKeyRoutes ('/api/api-keys') + timeEntryCommentRoutes ('/api/time-entry-comments').", refs=["apps/api/src/routes/index.ts"], ), FileGen( path="apps/web/src/App.tsx", purpose="ERWEITERT — füge /api-keys Route. Behalte alles.", refs=["apps/web/src/App.tsx"], ), ], ), ] def load_state() -> dict: if PHASE18_STATE.exists(): return json.loads(PHASE18_STATE.read_text()) return {"completed_features": [], "current_feature": None, "started_at": datetime.datetime.now().isoformat()} def save_state(state: dict) -> None: PHASE18_STATE.write_text(json.dumps(state, indent=2)) async def main() -> int: log_section("🚀 Phase-18 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-18 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()))