T4/Phase 2C-2: port calendar routes to Hono (5 routes)

This commit is contained in:
Hermes
2026-08-01 01:47:28 +00:00
parent c1c95f727a
commit 89dbe69137
+241
View File
@@ -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<string, unknown> = {};
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);
}
});