From 89dbe69137c60431bca00a9d36b9c1308a3eb941 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 1 Aug 2026 01:47:28 +0000 Subject: [PATCH] T4/Phase 2C-2: port calendar routes to Hono (5 routes) --- apps/api/src/routes/calendar.ts | 241 ++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 apps/api/src/routes/calendar.ts diff --git a/apps/api/src/routes/calendar.ts b/apps/api/src/routes/calendar.ts new file mode 100644 index 0000000..add4f56 --- /dev/null +++ b/apps/api/src/routes/calendar.ts @@ -0,0 +1,241 @@ +import { Hono } from "hono"; +import { db, calendarEvents } from "@project-e/db"; +import { and, asc, desc, eq, gte, lte, isNull } from "drizzle-orm"; +import { requireAuth, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth"; +import { recordActivity } from "../middleware/activity"; +import { z } from "zod"; + +export const calendarRoutes = new Hono(); + +const createEventSchema = z.object({ + title: z.string().min(1, "Title is required"), + description: z.string().optional().nullable(), + startTime: z.string().datetime(), + endTime: z.string().datetime().optional().nullable(), + allDay: z.boolean().optional().default(false), + color: z.string().optional().nullable(), + domain: z.string().min(1, "Domain is required"), + entityType: z.string().optional().nullable(), + entityId: z.string().uuid().optional().nullable(), + recurrenceRule: z.string().optional().nullable(), + customFields: z.record(z.string(), z.unknown()).optional(), +}); + +const updateEventSchema = z.object({ + title: z.string().min(1).optional(), + description: z.string().optional().nullable(), + startTime: z.string().datetime().optional(), + endTime: z.string().datetime().optional().nullable(), + allDay: z.boolean().optional(), + color: z.string().optional().nullable(), + entityType: z.string().optional().nullable(), + entityId: z.string().uuid().optional().nullable(), + recurrenceRule: z.string().optional().nullable(), + customFields: z.record(z.string(), z.unknown()).optional(), +}); + +// GET /api/calendar/events?from=...&to=... — List events in range +calendarRoutes.get("/events", async (c) => { + try { + const user = await requireAuth(c); + const url = new URL(c.req.url); + const from = url.searchParams.get("from"); + const to = url.searchParams.get("to"); + let domainId = url.searchParams.get("domain") || undefined; + if (!domainId) { + const active = await resolveActiveDomain(user); + domainId = active.id; + } + + const conditions: any[] = [eq(calendarEvents.domainId, domainId)]; + if (from) conditions.push(gte(calendarEvents.startTime, new Date(from))); + if (to) conditions.push(lte(calendarEvents.endTime, new Date(to))); + + const items = await db.select() + .from(calendarEvents) + .where(and(...conditions)) + .orderBy(asc(calendarEvents.startTime)); + + return c.json({ items, totalItems: items.length }); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[calendar] GET /events error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list events" } }, 500); + } +}); + +// POST /api/calendar/events — Create +calendarRoutes.post("/events", async (c) => { + try { + const user = await requireAuth(c); + const body = await c.req.json(); + const data = createEventSchema.parse({ + ...body, + domain: body.domain || (await resolveActiveDomain(user)).id, + }); + + const [event] = await db.insert(calendarEvents).values({ + title: data.title, + description: data.description ?? null, + startTime: new Date(data.startTime), + endTime: data.endTime ? new Date(data.endTime) : null, + allDay: data.allDay, + color: data.color ?? null, + domainId: data.domain, + entityType: data.entityType ?? null, + entityId: data.entityId ?? null, + recurrenceRule: data.recurrenceRule ?? null, + customFields: data.customFields ?? {}, + }).returning(); + + await recordActivity({ + actor: user.name, + action: "created", + entityType: "calendar_event", + entityId: event.id, + changes: { title: event.title }, + workspaceId: data.domain, + }); + + return c.json(event, 201); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + if (error instanceof z.ZodError) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400); + } + console.error("[calendar] POST /events error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create event" } }, 500); + } +}); + +// PATCH /api/calendar/events/:id — Update +calendarRoutes.patch("/events/:id", async (c) => { + try { + const user = await requireAuth(c); + const id = c.req.param("id"); + const body = await c.req.json(); + const data = updateEventSchema.parse(body); + + const [existing] = await db.select() + .from(calendarEvents) + .where(eq(calendarEvents.id, id)) + .limit(1); + + if (!existing) { + return c.json({ error: { code: "NOT_FOUND", message: "Event not found" } }, 404); + } + + const updateValues: Record = {}; + if (data.title !== undefined) updateValues.title = data.title; + if (data.description !== undefined) updateValues.description = data.description; + if (data.startTime !== undefined) updateValues.startTime = new Date(data.startTime); + if (data.endTime !== undefined) updateValues.endTime = data.endTime ? new Date(data.endTime) : null; + if (data.allDay !== undefined) updateValues.allDay = data.allDay; + if (data.color !== undefined) updateValues.color = data.color; + if (data.entityType !== undefined) updateValues.entityType = data.entityType; + if (data.entityId !== undefined) updateValues.entityId = data.entityId; + if (data.recurrenceRule !== undefined) updateValues.recurrenceRule = data.recurrenceRule; + if (data.customFields !== undefined) updateValues.customFields = data.customFields; + updateValues.updatedAt = new Date(); + + const [updated] = await db.update(calendarEvents) + .set(updateValues) + .where(eq(calendarEvents.id, id)) + .returning(); + + await recordActivity({ + actor: user.name, + action: "updated", + entityType: "calendar_event", + entityId: id, + changes: { title: updated.title }, + workspaceId: existing.domainId, + }); + + return c.json(updated); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + if (error instanceof z.ZodError) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400); + } + console.error("[calendar] PATCH /events/:id error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update event" } }, 500); + } +}); + +// DELETE /api/calendar/events/:id — Delete +calendarRoutes.delete("/events/:id", async (c) => { + try { + const user = await requireAuth(c); + const id = c.req.param("id"); + + const [existing] = await db.select() + .from(calendarEvents) + .where(eq(calendarEvents.id, id)) + .limit(1); + + if (!existing) { + return c.json({ error: { code: "NOT_FOUND", message: "Event not found" } }, 404); + } + + await db.delete(calendarEvents).where(eq(calendarEvents.id, id)); + + await recordActivity({ + actor: user.name, + action: "deleted", + entityType: "calendar_event", + entityId: id, + changes: { title: existing.title }, + workspaceId: existing.domainId, + }); + + return c.body(null, 204); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[calendar] DELETE /events/:id error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete event" } }, 500); + } +}); + +// GET /api/calendar/upcoming?days=7 — Next N days +calendarRoutes.get("/upcoming", async (c) => { + try { + const user = await requireAuth(c); + const url = new URL(c.req.url); + const days = Math.max(1, Math.min(365, parseInt(url.searchParams.get("days") || "7"))); + let domainId = url.searchParams.get("domain") || undefined; + if (!domainId) { + const active = await resolveActiveDomain(user); + domainId = active.id; + } + + const now = new Date(); + const end = new Date(); + end.setDate(end.getDate() + days); + + const items = await db.select() + .from(calendarEvents) + .where(and( + eq(calendarEvents.domainId, domainId), + gte(calendarEvents.startTime, now), + lte(calendarEvents.startTime, end), + )) + .orderBy(asc(calendarEvents.startTime)); + + return c.json({ items, totalItems: items.length }); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[calendar] GET /upcoming error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get upcoming events" } }, 500); + } +});