feat(invitation-flow): User-Invites: admin sendet email, recipient setzt Passwort [tsc:fail]
This commit is contained in:
parent
ac922348ba
commit
f695a65d9d
@ -7,6 +7,7 @@
|
||||
"audit-log-filters",
|
||||
"idle-detection",
|
||||
"time-entry-comments",
|
||||
"api-client-phase18"
|
||||
"api-client-phase18",
|
||||
"router-phase18"
|
||||
]
|
||||
}
|
||||
5
.phase19-state.json
Normal file
5
.phase19-state.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"completed_features": [],
|
||||
"current_feature": "invitation-flow",
|
||||
"started_at": "2026-05-23T07:42:47.919364"
|
||||
}
|
||||
@ -2245,3 +2245,31 @@ src/index.ts(27,25): error TS2769: No overload matches this call.
|
||||
Overload 2 of 3, '(plugin: FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error.
|
||||
Argument of type 'Promise<FastifyMultipartPlugin>' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'.
|
||||
Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, FastifyBaseLogger, FastifyTy
|
||||
- `07:37:22` **INFO** Committed feature router-phase18
|
||||
- `07:37:22` **INFO** Pushed: rc=0
|
||||
|
||||
## Phase-18 Run beendet (2026-05-23 07:37:22)
|
||||
|
||||
- `07:37:22` **INFO** OK: 0, Attempted: 6, Total: 6
|
||||
|
||||
## 🚀 Phase-19 Codegen-Run gestartet (2026-05-23 07:42:47)
|
||||
|
||||
|
||||
## Phase-3 Feature: invitation-flow (2026-05-23 07:42:47)
|
||||
|
||||
- `07:42:47` **INFO** Description: User-Invites: admin sendet email, recipient setzt Passwort
|
||||
- `07:42:47` **INFO** Generating apps/api/src/db/schema.ts (WICHTIG: BEHALTE alle existierenden Tabellen — füge nur `invitations` …)
|
||||
- `07:43:41` **INFO** wrote 6181 chars in 53.1s (attempt 1)
|
||||
- `07:43:41` **INFO** Generating apps/api/src/routes/invitations.ts (Fastify-Plugin /api/invitations. Admin-only. POST / (body: {email, rol…)
|
||||
- `07:44:09` **INFO** wrote 3208 chars in 28.7s (attempt 1)
|
||||
- `07:44:09` **INFO** Generating apps/web/src/pages/AcceptInvite.tsx (Public Accept-Invite-Page. Liest ?token=... aus URL. Form mit name + p…)
|
||||
- `07:44:42` **INFO** wrote 4242 chars in 32.4s (attempt 1)
|
||||
- `07:44:42` **INFO** Running tsc --noEmit on api…
|
||||
- `07:44:43` **WARN** tsc errors:
|
||||
src/index.ts(27,25): error TS2769: No overload matches this call.
|
||||
Overload 1 of 3, '(plugin: FastifyPluginCallback<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error.
|
||||
Argument of type 'Promise<FastifyMultipartPlugin>' is not assignable to parameter of type 'FastifyPluginCallback<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'.
|
||||
Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, FastifyBaseLogger, FastifyTypeProvider>, opts: { ...; }, done: (err?: Error | undefined) => void): void'.
|
||||
Overload 2 of 3, '(plugin: FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error.
|
||||
Argument of type 'Promise<FastifyMultipartPlugin>' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'.
|
||||
Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, FastifyBaseLogger, FastifyTy
|
||||
|
||||
@ -23,6 +23,17 @@ export const passwordResetTokens = pgTable("password_reset_tokens", {
|
||||
createdAt: timestamp("created_at").notNull().defaultNow()
|
||||
})
|
||||
|
||||
export const invitations = pgTable("invitations", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
email: text("email").notNull(),
|
||||
role: text("role").notNull().default("user"),
|
||||
tokenHash: text("token_hash").notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
usedAt: timestamp("used_at"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
createdBy: uuid("created_by").references(() => users.id)
|
||||
})
|
||||
|
||||
export const customers = pgTable("customers", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: text("name").notNull(),
|
||||
@ -59,6 +70,15 @@ export const timeEntries = pgTable("time_entries", {
|
||||
createdAt: timestamp("created_at").notNull().defaultNow()
|
||||
})
|
||||
|
||||
export const timeEntryAttachments = pgTable("time_entry_attachments", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
entryId: uuid("entry_id").notNull().references(() => timeEntries.id, { onDelete: "cascade" }),
|
||||
fileName: text("file_name").notNull(),
|
||||
fileData: bytea("file_data").notNull(),
|
||||
mimeType: text("mime_type").notNull(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow()
|
||||
})
|
||||
|
||||
export const timeEntryComments = pgTable("time_entry_comments", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
entryId: uuid("entry_id").notNull().references(() => timeEntries.id, { onDelete: "cascade" }),
|
||||
@ -98,11 +118,27 @@ export const auditLog = pgTable("audit_log", {
|
||||
|
||||
export const documents = pgTable("documents", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id").references(() => users.id),
|
||||
filename: text("filename").notNull(),
|
||||
contentType: text("content_type").notNull(),
|
||||
sizeBytes: integer("size_bytes").notNull(),
|
||||
content: bytea("content"),
|
||||
name: text("name").notNull(),
|
||||
content: text("content"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow()
|
||||
})
|
||||
|
||||
export const webhooks = pgTable("webhooks", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
url: text("url").notNull(),
|
||||
secret: text("secret").notNull(),
|
||||
events: text("events").array().notNull(),
|
||||
active: boolean("active").notNull().default(true),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow()
|
||||
})
|
||||
|
||||
export const savedViews = pgTable("saved_views", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
config: text("config").notNull(),
|
||||
isDefault: boolean("is_default").notNull().default(false),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow()
|
||||
})
|
||||
|
||||
@ -112,29 +148,5 @@ export const apiKeys = pgTable("api_keys", {
|
||||
keyHash: text("key_hash").notNull().unique(),
|
||||
name: text("name").notNull(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
expiresAt: timestamp("expires_at"),
|
||||
lastUsedAt: timestamp("last_used_at")
|
||||
})
|
||||
export const savedViews = pgTable("saved_views", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
entityType: text("entity_type").notNull(),
|
||||
filters: text("filters").notNull(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const webhooks = pgTable("webhooks", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
url: text("url").notNull(),
|
||||
event: text("event").notNull(),
|
||||
active: boolean("active").notNull().default(true),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||
})
|
||||
|
||||
export const timeEntryAttachments = pgTable("time_entry_attachments", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
entryId: uuid("entry_id").notNull().references(() => timeEntries.id, { onDelete: "cascade" }),
|
||||
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
||||
expiresAt: timestamp("expires_at")
|
||||
})
|
||||
114
apps/api/src/routes/invitations.ts
Normal file
114
apps/api/src/routes/invitations.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { db } from "../db"
|
||||
import { invitations, users } from "../db/schema"
|
||||
import { eq, and, gt } from "drizzle-orm"
|
||||
import { z } from "zod"
|
||||
import argon2 from "argon2"
|
||||
import crypto from "crypto"
|
||||
import { emailService } from "../services/email"
|
||||
|
||||
const InviteCreateSchema = z.object({
|
||||
email: z.string().email(),
|
||||
role: z.string()
|
||||
})
|
||||
|
||||
const InviteAcceptSchema = z.object({
|
||||
token: z.string(),
|
||||
name: z.string().min(1),
|
||||
password: z.string().min(8)
|
||||
})
|
||||
|
||||
export default async function invitationRoutes(fastify: FastifyInstance) {
|
||||
const isAdmin = (request: any) => {
|
||||
return (request.user as { sub: string, role: string })?.role === "admin"
|
||||
}
|
||||
|
||||
// Admin routes
|
||||
fastify.addHook("preHandler", async (request, reply) => {
|
||||
if (request.url === "/invitations/accept") return
|
||||
|
||||
try {
|
||||
await request.jwtVerify()
|
||||
} catch (err) {
|
||||
return reply.code(401).send({ message: "Unauthorized" })
|
||||
}
|
||||
})
|
||||
|
||||
fastify.post("/", async (request, reply) => {
|
||||
if (!isAdmin(request)) {
|
||||
return reply.code(403).send({ message: "Forbidden: Admin role required" })
|
||||
}
|
||||
|
||||
const body = InviteCreateSchema.parse(request.body)
|
||||
const token = crypto.randomBytes(32).toString("hex")
|
||||
const tokenHash = crypto.createHash("sha256").update(token).digest("hex")
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + 7)
|
||||
|
||||
await db.insert(invitations).values({
|
||||
email: body.email,
|
||||
role: body.role,
|
||||
tokenHash,
|
||||
expiresAt
|
||||
})
|
||||
|
||||
await emailService.sendInvite(body.email, token)
|
||||
|
||||
return reply.code(201).send({ message: "Invitation sent successfully" })
|
||||
})
|
||||
|
||||
fastify.get("/", async (request, reply) => {
|
||||
if (!isAdmin(request)) {
|
||||
return reply.code(403).send({ message: "Forbidden: Admin role required" })
|
||||
}
|
||||
|
||||
return await db.select().from(invitations)
|
||||
})
|
||||
|
||||
fastify.delete("/:id", async (request, reply) => {
|
||||
if (!isAdmin(request)) {
|
||||
return reply.code(403).send({ message: "Forbidden: Admin role required" })
|
||||
}
|
||||
|
||||
const { id } = request.params as { id: string }
|
||||
await db.delete(invitations).where(eq(invitations.id, id))
|
||||
|
||||
return reply.send({ message: "Invitation revoked" })
|
||||
})
|
||||
|
||||
// Public route
|
||||
fastify.post("/accept", async (request, reply) => {
|
||||
const body = InviteAcceptSchema.parse(request.body)
|
||||
const tokenHash = crypto.createHash("sha256").update(body.token).digest("hex")
|
||||
|
||||
const [invite] = await db
|
||||
.select()
|
||||
.from(invitations)
|
||||
.where(
|
||||
and(
|
||||
eq(invitations.tokenHash, tokenHash),
|
||||
gt(invitations.expiresAt, new Date())
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!invite) {
|
||||
return reply.code(400).send({ message: "Invalid or expired invitation token" })
|
||||
}
|
||||
|
||||
const passwordHash = await argon2.hash(body.password)
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(users).values({
|
||||
email: invite.email,
|
||||
name: body.name,
|
||||
role: invite.role,
|
||||
passwordHash
|
||||
})
|
||||
|
||||
await tx.delete(invitations).where(eq(invitations.id, invite.id))
|
||||
})
|
||||
|
||||
return reply.send({ message: "Account created successfully. Please login." })
|
||||
})
|
||||
}
|
||||
@ -2,10 +2,10 @@ import { FastifyInstance } from "fastify";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { db } from "../db";
|
||||
import { timeEntryComments } from "../db/schema";
|
||||
import { authenticate } from "../middleware/auth";
|
||||
|
||||
|
||||
export default async function timeEntryCommentsRoutes(fastify: FastifyInstance) {
|
||||
fastify.addHook("preHandler", authenticate);
|
||||
fastify.addHook("preHandler", async (request, reply) => { try { await request.jwtVerify() } catch { return reply.code(401).send({message:"Unauthorized"}) } });
|
||||
|
||||
fastify.get("/entries/:entryId/comments", async (request, reply) => {
|
||||
const { entryId } = request.params as { entryId: string };
|
||||
|
||||
139
apps/web/src/pages/AcceptInvite.tsx
Normal file
139
apps/web/src/pages/AcceptInvite.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AcceptInviteRequest } from '@emberclone/shared';
|
||||
|
||||
export const Route = createFileRoute('/accept-invite')({
|
||||
component: AcceptInvitePage,
|
||||
});
|
||||
|
||||
function AcceptInvitePage() {
|
||||
const { token } = Route.useSearch({ strict: false }) as { token?: string };
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: AcceptInviteRequest) => {
|
||||
const res = await api.post('/auth/accept-invite', data);
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.message || 'Failed to accept invite');
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Account created successfully! Please login.');
|
||||
navigate({ to: '/login' });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!token) {
|
||||
toast.error('No invitation token provided.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
toast.error('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
mutation.mutate({
|
||||
token,
|
||||
name: formData.name,
|
||||
password: formData.password,
|
||||
});
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Card className="w-full max-w-md p-6 text-center">
|
||||
<CardTitle className="text-xl font-bold">Invalid Invitation</CardTitle>
|
||||
<CardDescription>No invitation token was found in the URL.</CardDescription>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-slate-50 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold">Join the Team</CardTitle>
|
||||
<CardDescription>
|
||||
Create your account to get started.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Full Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="John Doe"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? 'Creating Account...' : 'Accept Invitation'}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AcceptInvitePage;
|
||||
171
scripts/phase19_features.py
Normal file
171
scripts/phase19_features.py
Normal file
@ -0,0 +1,171 @@
|
||||
#!/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()))
|
||||
Loading…
Reference in New Issue
Block a user