diff --git a/apps/api/eslint.config.js b/apps/api/eslint.config.js new file mode 100644 index 0000000..e3961f7 --- /dev/null +++ b/apps/api/eslint.config.js @@ -0,0 +1,11 @@ +import tseslint from "typescript-eslint"; + +export default tseslint.config( + ...tseslint.configs.recommended, + { + rules: { + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + }, + } +); diff --git a/apps/api/package.json b/apps/api/package.json index 071a656..dd128d2 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -15,7 +15,9 @@ "@groombook/db": "workspace:*", "@groombook/types": "workspace:*", "@hono/node-server": "^1.13.7", + "@hono/zod-validator": "^0.4.3", "hono": "^4.6.17", + "jose": "^5.9.6", "openid-client": "^6.1.7", "zod": "^3.24.1" }, diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 93e317d..903a8f0 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -6,6 +6,7 @@ import { clientsRouter } from "./routes/clients.js"; import { petsRouter } from "./routes/pets.js"; import { servicesRouter } from "./routes/services.js"; import { appointmentsRouter } from "./routes/appointments.js"; +import { staffRouter } from "./routes/staff.js"; import { authMiddleware } from "./middleware/auth.js"; const app = new Hono(); @@ -31,6 +32,7 @@ api.route("/clients", clientsRouter); api.route("/pets", petsRouter); api.route("/services", servicesRouter); api.route("/appointments", appointmentsRouter); +api.route("/staff", staffRouter); const port = Number(process.env.PORT ?? 3000); console.log(`API server listening on port ${port}`); diff --git a/apps/api/src/routes/appointments.ts b/apps/api/src/routes/appointments.ts index ef55b83..05f87cc 100644 --- a/apps/api/src/routes/appointments.ts +++ b/apps/api/src/routes/appointments.ts @@ -1,8 +1,7 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod"; -import { and, eq, gte, lte } from "drizzle-orm"; -import { getDb, appointments } from "@groombook/db"; +import { and, eq, getDb, gte, lt, lte, ne, appointments } from "@groombook/db"; export const appointmentsRouter = new Hono(); @@ -35,15 +34,43 @@ const updateAppointmentSchema = z.object({ priceCents: z.number().int().positive().nullable().optional(), }); -// List appointments, optionally filtered by date range +/** Returns true if a staff member has a non-cancelled appointment overlapping [start, end). */ +async function hasConflict( + staffId: string, + start: Date, + end: Date, + excludeId?: string +): Promise { + const db = getDb(); + const conditions = [ + eq(appointments.staffId, staffId), + // Overlap: existing.start < end AND existing.end > start + lt(appointments.startTime, end), + gte(appointments.endTime, start), + // Ignore cancelled/no_show + ne(appointments.status, "cancelled"), + ne(appointments.status, "no_show"), + ]; + if (excludeId) conditions.push(ne(appointments.id, excludeId)); + const rows = await db + .select({ id: appointments.id }) + .from(appointments) + .where(and(...conditions)) + .limit(1); + return rows.length > 0; +} + +// List appointments, optionally filtered by date range or staffId appointmentsRouter.get("/", async (c) => { const db = getDb(); const from = c.req.query("from"); const to = c.req.query("to"); + const staffId = c.req.query("staffId"); const conditions = []; if (from) conditions.push(gte(appointments.startTime, new Date(from))); if (to) conditions.push(lte(appointments.startTime, new Date(to))); + if (staffId) conditions.push(eq(appointments.staffId, staffId)); const rows = conditions.length > 0 @@ -76,13 +103,26 @@ appointmentsRouter.post( async (c) => { const db = getDb(); const body = c.req.valid("json"); + const start = new Date(body.startTime); + const end = new Date(body.endTime); + + if (end <= start) { + return c.json({ error: "endTime must be after startTime" }, 422); + } + + if (body.staffId) { + const conflict = await hasConflict(body.staffId, start, end); + if (conflict) { + return c.json( + { error: "Staff member has a conflicting appointment at this time" }, + 409 + ); + } + } + const [row] = await db .insert(appointments) - .values({ - ...body, - startTime: new Date(body.startTime), - endTime: new Date(body.endTime), - }) + .values({ ...body, startTime: start, endTime: end }) .returning(); return c.json(row, 201); } @@ -93,16 +133,57 @@ appointmentsRouter.patch( zValidator("json", updateAppointmentSchema), async (c) => { const db = getDb(); + const id = c.req.param("id"); const body = c.req.valid("json"); + + // If rescheduling, check for conflicts + if ((body.startTime || body.endTime || body.staffId !== undefined) && body.staffId) { + const existing = await db + .select() + .from(appointments) + .where(eq(appointments.id, id)) + .limit(1); + const current = existing[0]; + if (!current) return c.json({ error: "Not found" }, 404); + + const start = body.startTime ? new Date(body.startTime) : current.startTime; + const end = body.endTime ? new Date(body.endTime) : current.endTime; + const staffId = body.staffId ?? current.staffId; + + if (end <= start) { + return c.json({ error: "endTime must be after startTime" }, 422); + } + + if (staffId) { + const conflict = await hasConflict(staffId, start, end, id); + if (conflict) { + return c.json( + { error: "Staff member has a conflicting appointment at this time" }, + 409 + ); + } + } + } + const update: Record = { ...body, updatedAt: new Date() }; if (body.startTime) update.startTime = new Date(body.startTime); if (body.endTime) update.endTime = new Date(body.endTime); const [row] = await db .update(appointments) .set(update) - .where(eq(appointments.id, c.req.param("id"))) + .where(eq(appointments.id, id)) .returning(); if (!row) return c.json({ error: "Not found" }, 404); return c.json(row); } ); + +appointmentsRouter.delete("/:id", async (c) => { + const db = getDb(); + const [row] = await db + .delete(appointments) + .where(eq(appointments.id, c.req.param("id"))) + .returning(); + if (!row) return c.json({ error: "Not found" }, 404); + return c.json({ ok: true }); +}); diff --git a/apps/api/src/routes/clients.ts b/apps/api/src/routes/clients.ts index b1d9f7c..e560393 100644 --- a/apps/api/src/routes/clients.ts +++ b/apps/api/src/routes/clients.ts @@ -1,8 +1,7 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod"; -import { eq } from "drizzle-orm"; -import { getDb, clients } from "@groombook/db"; +import { eq, getDb, clients } from "@groombook/db"; export const clientsRouter = new Hono(); diff --git a/apps/api/src/routes/pets.ts b/apps/api/src/routes/pets.ts index 73cb42a..5dc5869 100644 --- a/apps/api/src/routes/pets.ts +++ b/apps/api/src/routes/pets.ts @@ -1,8 +1,7 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod"; -import { eq } from "drizzle-orm"; -import { getDb, pets } from "@groombook/db"; +import { eq, getDb, pets } from "@groombook/db"; export const petsRouter = new Hono(); @@ -42,8 +41,15 @@ petsRouter.get("/:id", async (c) => { petsRouter.post("/", zValidator("json", createPetSchema), async (c) => { const db = getDb(); - const body = c.req.valid("json"); - const [row] = await db.insert(pets).values(body).returning(); + const { weightKg, dateOfBirth, ...rest } = c.req.valid("json"); + const [row] = await db + .insert(pets) + .values({ + ...rest, + weightKg: weightKg?.toString(), + dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined, + }) + .returning(); return c.json(row, 201); }); @@ -52,10 +58,15 @@ petsRouter.patch( zValidator("json", updatePetSchema), async (c) => { const db = getDb(); - const body = c.req.valid("json"); + const { weightKg, dateOfBirth, ...rest } = c.req.valid("json"); const [row] = await db .update(pets) - .set({ ...body, updatedAt: new Date() }) + .set({ + ...rest, + weightKg: weightKg?.toString(), + dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined, + updatedAt: new Date(), + }) .where(eq(pets.id, c.req.param("id"))) .returning(); if (!row) return c.json({ error: "Not found" }, 404); diff --git a/apps/api/src/routes/services.ts b/apps/api/src/routes/services.ts index 1f9315a..621a797 100644 --- a/apps/api/src/routes/services.ts +++ b/apps/api/src/routes/services.ts @@ -1,8 +1,7 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod"; -import { eq } from "drizzle-orm"; -import { getDb, services } from "@groombook/db"; +import { eq, getDb, services } from "@groombook/db"; export const servicesRouter = new Hono(); diff --git a/apps/api/src/routes/staff.ts b/apps/api/src/routes/staff.ts new file mode 100644 index 0000000..b305735 --- /dev/null +++ b/apps/api/src/routes/staff.ts @@ -0,0 +1,64 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; +import { eq, getDb, staff } from "@groombook/db"; + +export const staffRouter = new Hono(); + +const createStaffSchema = z.object({ + name: z.string().min(1).max(200), + email: z.string().email(), + role: z.enum(["groomer", "receptionist", "manager"]).default("groomer"), + oidcSub: z.string().optional(), + active: z.boolean().default(true), +}); + +const updateStaffSchema = createStaffSchema.partial().omit({ email: true }); + +staffRouter.get("/", async (c) => { + const db = getDb(); + const includeInactive = c.req.query("includeInactive") === "true"; + const rows = includeInactive + ? await db.select().from(staff).orderBy(staff.name) + : await db.select().from(staff).where(eq(staff.active, true)).orderBy(staff.name); + return c.json(rows); +}); + +staffRouter.get("/:id", async (c) => { + const db = getDb(); + const [row] = await db + .select() + .from(staff) + .where(eq(staff.id, c.req.param("id"))); + if (!row) return c.json({ error: "Not found" }, 404); + return c.json(row); +}); + +staffRouter.post("/", zValidator("json", createStaffSchema), async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + const [row] = await db.insert(staff).values(body).returning(); + return c.json(row, 201); +}); + +staffRouter.patch("/:id", zValidator("json", updateStaffSchema), async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + const [row] = await db + .update(staff) + .set({ ...body, updatedAt: new Date() }) + .where(eq(staff.id, c.req.param("id"))) + .returning(); + if (!row) return c.json({ error: "Not found" }, 404); + return c.json(row); +}); + +staffRouter.delete("/:id", async (c) => { + const db = getDb(); + const [row] = await db + .delete(staff) + .where(eq(staff.id, c.req.param("id"))) + .returning(); + if (!row) return c.json({ error: "Not found" }, 404); + return c.json({ ok: true }); +}); diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 0000000..d913ed3 --- /dev/null +++ b/apps/api/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + passWithNoTests: true, + }, +}); diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js new file mode 100644 index 0000000..e3961f7 --- /dev/null +++ b/apps/web/eslint.config.js @@ -0,0 +1,11 @@ +import tseslint from "typescript-eslint"; + +export default tseslint.config( + ...tseslint.configs.recommended, + { + rules: { + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + }, + } +); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 90cc38b..af0f877 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,26 +1,59 @@ -import { Routes, Route, Link } from "react-router-dom"; +import { Routes, Route, Link, useLocation } from "react-router-dom"; import { AppointmentsPage } from "./pages/Appointments.js"; import { ClientsPage } from "./pages/Clients.js"; import { ServicesPage } from "./pages/Services.js"; +import { StaffPage } from "./pages/Staff.js"; + +const NAV_LINKS = [ + { to: "/", label: "Appointments" }, + { to: "/clients", label: "Clients" }, + { to: "/services", label: "Services" }, + { to: "/staff", label: "Staff" }, +]; export function App() { + const location = useLocation(); return ( -
-