#!/usr/bin/env python3 """Phase-19: multi-tenancy, invitation-flow, rate-limiting, presence, search-history.""" 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 PHASE19_STATE = ROOT / ".phase19-state.json" FEATURES: list[Feature] = [ Feature( name="invitation-flow", description="User-Invites: admin sendet email, recipient setzt Passwort", files=[ FileGen( path="apps/api/src/db/schema.ts", purpose=( "WICHTIG: BEHALTE alle existierenden Tabellen — füge nur `invitations` neu hinzu. " "BEHALTE explizit: users, customers, projects, projectTemplates, timeEntries, timeEntryAttachments, " "timeEntryComments, appSettings, auditLog, documents, webhooks, savedViews, apiKeys, passwordResetTokens. " "Neue Tabelle: invitations (id, email text, role text default 'user', tokenHash, expiresAt, usedAt nullable, createdAt, createdBy references users)." ), refs=["apps/api/src/db/schema.ts"], ), FileGen( path="apps/api/src/routes/invitations.ts", purpose=( "Fastify-Plugin /api/invitations. Admin-only. " "POST / (body: {email, role}): generate token, store hash + expires in 7d, emailService.sendInvite(email, token). " "GET / (list all pending). DELETE /:id (revoke). " "Plus PUBLIC POST /accept (body: {token, name, password}): verify token, create user, redirect /login." ), refs=["apps/api/src/routes/users.ts"], ), FileGen( path="apps/web/src/pages/AcceptInvite.tsx", purpose=( "Public Accept-Invite-Page. Liest ?token=... aus URL. Form mit name + password + confirm. " "Submit → api.acceptInvite(token, name, password), Toast + redirect /login." ), ), ], ), Feature( name="rate-limiting-stub", description="In-Memory Rate-Limiter pro IP (Stub für /api/auth/*)", files=[ FileGen( path="apps/api/src/services/rate-limit.ts", purpose=( "RateLimiter class. Map. Methode check(ip, limit, windowMs): " "returns {allowed: boolean, remaining: number}. Auto-reset wenn now > resetAt. " "Export const rateLimiter = new RateLimiter()." ), ), FileGen( path="apps/api/src/routes/auth.ts", purpose=( "ERWEITERT — behalte alles. Füge preHandler-check für POST /login: " "rateLimiter.check(request.ip, 10, 60_000) — wenn nicht allowed: 429 'Too many requests'. " "Import rateLimiter oben." ), refs=["apps/api/src/routes/auth.ts"], ), ], ), Feature( name="search-history", description="Letzte 10 Sucheinträge des Users persistieren (localStorage)", files=[ FileGen( path="apps/web/src/components/SearchBar.tsx", purpose=( "ERWEITERT — behalte bestehende SearchBar. Persistiere bei jedem Search in localStorage 'search_history' (max 10 strings, dedupe). " "Wenn Input leer + focused: zeige History als Dropdown." ), refs=["apps/web/src/components/SearchBar.tsx"], ), ], ), Feature( name="presence-stub", description="User-Presence-Stub (online/offline-Status basierend auf last-activity-API-call)", files=[ FileGen( path="apps/api/src/routes/users.ts", purpose=( "ERWEITERT — behalte alles. Füge GET /presence (admin oder eigener User): " "Liste user.id → lastActiveAt (errechnet aus dem letzten audit-log-Eintrag). " "Plus: pro authenticated request automatisch usersService.touchLastActive(userId) (kann auch in preHandler)." ), refs=["apps/api/src/routes/users.ts"], ), ], ), Feature( name="api-client-phase19", description="API erweitern um invitations, accept, presence", files=[ FileGen( path="apps/web/src/lib/api.ts", purpose=( "ERWEITERT — behalte ALLES. Füge: createInvitation({email, role}), listInvitations(), deleteInvitation(id), " "acceptInvite(token, name, password), getPresence()." ), refs=["apps/web/src/lib/api.ts"], ), ], ), Feature( name="router-phase19", description="Mount invitations + accept-invite public route", files=[ FileGen( path="apps/api/src/routes/index.ts", purpose="ERWEITERT — füge invitationRoutes ('/api/invitations'). Behalte alles.", refs=["apps/api/src/routes/index.ts"], ), FileGen( path="apps/web/src/App.tsx", purpose="ERWEITERT — füge /accept-invite Route (public). Behalte alles.", refs=["apps/web/src/App.tsx"], ), ], ), ] def load_state() -> dict: if PHASE19_STATE.exists(): return json.loads(PHASE19_STATE.read_text()) return {"completed_features": [], "current_feature": None, "started_at": datetime.datetime.now().isoformat()} def save_state(state: dict) -> None: PHASE19_STATE.write_text(json.dumps(state, indent=2)) async def main() -> int: log_section("🚀 Phase-19 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-19 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()))