diff --git a/.phase5-state.json b/.phase5-state.json index e1fa6cc..2ba0ada 100644 --- a/.phase5-state.json +++ b/.phase5-state.json @@ -8,6 +8,7 @@ "started_at": "2026-05-23T05:20:11.407108", "attempted_features": [ "settings-page", - "api-client-phase5" + "api-client-phase5", + "router-phase5" ] } \ No newline at end of file diff --git a/GENERATION_LOG.md b/GENERATION_LOG.md index ef0dfd9..630d5ec 100644 --- a/GENERATION_LOG.md +++ b/GENERATION_LOG.md @@ -621,3 +621,29 @@ src/routes/settings.ts(3,10): error TS2305: Module '"../db/schema"' has no expor undefined /home/dark/Developer/EmberClone/apps/api:  ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL  Command failed with exit code 2: tsc --noEmit -p tsconfig.json +- `05:26:14` **INFO** Committed feature router-phase5 +- `05:26:14` **INFO** Pushed: rc=0 + +## Phase-5 Run beendet (2026-05-23 05:26:14) + +- `05:26:14` **INFO** OK: 3, Attempted: 3, Total: 6 +- `05:26:14` **INFO** Running db:generate + db:migrate for schema changes… +- `05:26:15` **INFO** db:generate rc=0: /EmberClone/apps/api/drizzle.config.ts' +5 tables +app_settings 5 columns 0 indexes 0 fks +customers 4 columns 0 indexes 0 fks +projects 5 columns 0 indexes 1 fks +time_entries 7 columns 0 indexes 2 fks +users 6 columns 0 indexes 0 fks + +[✓] Your SQL migration file ➜ drizzle/0001_minor_fabian_cortez.sql 🚀 + +- `05:26:16` **INFO** db:migrate rc=0: +> @emberclone/api@0.0.1 db:migrate /home/dark/Developer/EmberClone/apps/api +> tsx src/db/migrate.ts + +Running migrations... +Migrations completed successfully +Checking for admin user... +Admin user already exists + diff --git a/apps/api/drizzle/0001_minor_fabian_cortez.sql b/apps/api/drizzle/0001_minor_fabian_cortez.sql new file mode 100644 index 0000000..e0fd335 --- /dev/null +++ b/apps/api/drizzle/0001_minor_fabian_cortez.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS "app_settings" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_name" text DEFAULT 'EmberClone' NOT NULL, + "default_billable" boolean DEFAULT true NOT NULL, + "week_start" integer DEFAULT 1 NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); diff --git a/apps/api/drizzle/meta/0001_snapshot.json b/apps/api/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..4b3b840 --- /dev/null +++ b/apps/api/drizzle/meta/0001_snapshot.json @@ -0,0 +1,310 @@ +{ + "id": "fda40693-a479-48ca-b84b-678a33a4d995", + "prevId": "cf31898f-9a96-4dfd-ac9e-23326daf55fb", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app_settings": { + "name": "app_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_name": { + "name": "workspace_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'EmberClone'" + }, + "default_billable": { + "name": "default_billable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "week_start": { + "name": "week_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customers": { + "name": "customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_customer_id_customers_id_fk": { + "name": "projects_customer_id_customers_id_fk", + "tableFrom": "projects", + "tableTo": "customers", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.time_entries": { + "name": "time_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_time": { + "name": "start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "time_entries_user_id_users_id_fk": { + "name": "time_entries_user_id_users_id_fk", + "tableFrom": "time_entries", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "time_entries_project_id_projects_id_fk": { + "name": "time_entries_project_id_projects_id_fk", + "tableFrom": "time_entries", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 40694be..ef35286 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1779503479059, "tag": "0000_empty_talon", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1779506775428, + "tag": "0001_minor_fabian_cortez", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index 8222d1f..ee56fc1 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -1,95 +1,45 @@ import { FastifyInstance } from "fastify" -import { db } from "../db" -import { settings } from "../db/schema" import { eq } from "drizzle-orm" import { z } from "zod" +import { db } from "../db" +import { appSettings } from "../db/schema" const SettingsUpdateSchema = z.object({ - siteName: z.string().min(1).optional(), - maintenanceMode: z.boolean().optional(), - contactEmail: z.string().email().optional(), - allowRegistration: z.boolean().optional() + workspaceName: z.string().min(1).optional(), + defaultBillable: z.boolean().optional(), + weekStart: z.number().int().min(0).max(6).optional(), }) export default async function settingsRoutes(fastify: FastifyInstance) { fastify.addHook("preHandler", async (request, reply) => { try { await request.jwtVerify() - } catch (err) { + } catch { return reply.code(401).send({ message: "Unauthorized" }) } }) - const isAdmin = (request: any) => { - return (request.user as { sub: string, role: string })?.role === "admin" + async function getOrCreate() { + const rows = await db.select().from(appSettings).limit(1) + if (rows.length > 0) return rows[0] + const [created] = await db.insert(appSettings).values({}).returning() + return created } - fastify.get("/", async (request, reply) => { - const [currentSettings] = await db - .select() - .from(settings) - .limit(1) - - if (!currentSettings) { - // Lazy-init default settings if none exist - const [defaults] = await db - .insert(settings) - .values({ - siteName: "EmberClone", - maintenanceMode: false, - allowRegistration: true, - contactEmail: "admin@example.com" - }) - .returning() - - return defaults - } - - return currentSettings - }) + fastify.get("/", async () => getOrCreate()) fastify.patch("/", async (request, reply) => { - if (!isAdmin(request)) { - return reply.code(403).send({ message: "Forbidden: Admin role required" }) + const payload = request.user as { sub: string; role: string } + if (payload.role !== "admin") { + return reply.code(403).send({ message: "Admin only" }) } - const body = SettingsUpdateSchema.parse(request.body) - - // We assume there is only one settings row (singleton pattern) + const current = await getOrCreate() const [updated] = await db - .update(settings) - .set(body) - .where(eq(settings.id, "global")) // Assuming a fixed ID for global settings + .update(appSettings) + .set({ ...body, updatedAt: new Date() }) + .where(eq(appSettings.id, current.id)) .returning() - - if (!updated) { - // If no row with ID 'global' exists, we create it or update the first available - const [existing] = await db.select().from(settings).limit(1) - - if (!existing) { - const [created] = await db - .insert(settings) - .values({ - id: "global", - ...body, - siteName: body.siteName ?? "EmberClone", - maintenanceMode: body.maintenanceMode ?? false, - allowRegistration: body.allowRegistration ?? true, - contactEmail: body.contactEmail ?? "admin@example.com" - }) - .returning() - return created - } - - const [finalUpdate] = await db - .update(settings) - .set(body) - .where(eq(settings.id, existing.id)) - .returning() - - return finalUpdate - } - return updated }) -} \ No newline at end of file +} diff --git a/apps/web/src/components/Nav.tsx b/apps/web/src/components/Nav.tsx index 3135bfc..f8c5103 100644 --- a/apps/web/src/components/Nav.tsx +++ b/apps/web/src/components/Nav.tsx @@ -14,7 +14,7 @@ import { } from "lucide-react" import { useQuery } from "@tanstack/react-query" import { api } from "../lib/api" -import { useTheme } from "../hooks/useTheme" +import { useTheme } from "../lib/theme" export default function Nav() { const location = useLocation()