212 lines
8.7 KiB
Python
212 lines
8.7 KiB
Python
#!/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()))
|