EmberClone/scripts/phase19_features.py

172 lines
7.0 KiB
Python

#!/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<ip, {count, resetAt}>. 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()))