diff --git a/apps/scheduler/app/api/scheduler/availability/route.ts b/apps/scheduler/app/api/scheduler/availability/route.ts new file mode 100644 index 0000000000..b1ef388380 --- /dev/null +++ b/apps/scheduler/app/api/scheduler/availability/route.ts @@ -0,0 +1,51 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { z, ZodError } from "zod"; + +import { requireSchedulerSession } from "@scheduler/lib/scheduler/auth"; +import { getAvailability } from "@scheduler/lib/scheduler/service"; + +export const dynamic = "force-dynamic"; + +const querySchema = z.object({ + attendeeIds: z.array(z.number().int().positive()).min(1), + rangeStart: z.string().datetime(), + rangeEnd: z.string().datetime(), + durationMinutes: z.number().int().positive(), +}); + +function parseAttendeeIds(raw: string | null): number[] { + if (!raw) return []; + return raw + .split(",") + .map((value) => Number(value.trim())) + .filter((value) => Number.isInteger(value) && value > 0); +} + +export async function GET(request: NextRequest) { + const auth = await requireSchedulerSession(request); + if ("response" in auth) return auth.response; + + const params = request.nextUrl.searchParams; + try { + const input = querySchema.parse({ + attendeeIds: parseAttendeeIds(params.get("attendeeIds")), + rangeStart: params.get("rangeStart") ?? undefined, + rangeEnd: params.get("rangeEnd") ?? undefined, + durationMinutes: Number(params.get("durationMinutes")), + }); + + const result = await getAvailability( + Number(auth.session.user.id), + input.attendeeIds, + input.rangeStart, + input.rangeEnd, + input.durationMinutes + ); + return NextResponse.json(result); + } catch (error) { + if (error instanceof ZodError) { + return NextResponse.json({ error: "VALIDATION_FAILED", issues: error.issues }, { status: 400 }); + } + throw error; + } +} diff --git a/apps/scheduler/app/api/scheduler/connections/route.ts b/apps/scheduler/app/api/scheduler/connections/route.ts new file mode 100644 index 0000000000..c4be65ab34 --- /dev/null +++ b/apps/scheduler/app/api/scheduler/connections/route.ts @@ -0,0 +1,12 @@ +import { NextResponse, type NextRequest } from "next/server"; + +import { requireSchedulerSession } from "@scheduler/lib/scheduler/auth"; +import { getConnections } from "@scheduler/lib/scheduler/service"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + const auth = await requireSchedulerSession(request); + if ("response" in auth) return auth.response; + return NextResponse.json(await getConnections(Number(auth.session.user.id))); +} diff --git a/apps/scheduler/app/api/scheduler/meetings/route.ts b/apps/scheduler/app/api/scheduler/meetings/route.ts new file mode 100644 index 0000000000..801425d71a --- /dev/null +++ b/apps/scheduler/app/api/scheduler/meetings/route.ts @@ -0,0 +1,41 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { ZodError } from "zod"; + +import { requireSchedulerSession } from "@scheduler/lib/scheduler/auth"; +import { createMeeting, listMeetings } from "@scheduler/lib/scheduler/service"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + const auth = await requireSchedulerSession(request); + if ("response" in auth) return auth.response; + return NextResponse.json(await listMeetings(Number(auth.session.user.id))); +} + +export async function POST(request: NextRequest) { + const auth = await requireSchedulerSession(request); + if ("response" in auth) return auth.response; + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "INVALID_JSON" }, { status: 400 }); + } + + try { + const result = await createMeeting(Number(auth.session.user.id), body); + if (result.status === "not_implemented") { + return NextResponse.json(result, { status: 501 }); + } + return NextResponse.json(result, { status: 201 }); + } catch (error) { + if (error instanceof ZodError) { + return NextResponse.json({ error: "VALIDATION_FAILED", issues: error.issues }, { status: 400 }); + } + if (error instanceof Error && error.message === "ORGANIZER_NOT_IN_ATTENDEES") { + return NextResponse.json({ error: "ORGANIZER_NOT_IN_ATTENDEES" }, { status: 400 }); + } + throw error; + } +} diff --git a/apps/scheduler/app/api/scheduler/schedule/route.ts b/apps/scheduler/app/api/scheduler/schedule/route.ts new file mode 100644 index 0000000000..91c6bc3c34 --- /dev/null +++ b/apps/scheduler/app/api/scheduler/schedule/route.ts @@ -0,0 +1,35 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { ZodError } from "zod"; + +import { requireSchedulerSession } from "@scheduler/lib/scheduler/auth"; +import { getSchedule, updateSchedule } from "@scheduler/lib/scheduler/service"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + const auth = await requireSchedulerSession(request); + if ("response" in auth) return auth.response; + return NextResponse.json(await getSchedule(Number(auth.session.user.id))); +} + +export async function PUT(request: NextRequest) { + const auth = await requireSchedulerSession(request); + if ("response" in auth) return auth.response; + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "INVALID_JSON" }, { status: 400 }); + } + + try { + const schedule = await updateSchedule(Number(auth.session.user.id), body); + return NextResponse.json(schedule); + } catch (error) { + if (error instanceof ZodError) { + return NextResponse.json({ error: "VALIDATION_FAILED", issues: error.issues }, { status: 400 }); + } + throw error; + } +} diff --git a/apps/scheduler/app/api/scheduler/team/route.ts b/apps/scheduler/app/api/scheduler/team/route.ts new file mode 100644 index 0000000000..848374ec43 --- /dev/null +++ b/apps/scheduler/app/api/scheduler/team/route.ts @@ -0,0 +1,12 @@ +import { NextResponse, type NextRequest } from "next/server"; + +import { requireSchedulerSession } from "@scheduler/lib/scheduler/auth"; +import { getTeamContext } from "@scheduler/lib/scheduler/service"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + const auth = await requireSchedulerSession(request); + if ("response" in auth) return auth.response; + return NextResponse.json(await getTeamContext(Number(auth.session.user.id))); +} diff --git a/apps/scheduler/lib/scheduler/auth.ts b/apps/scheduler/lib/scheduler/auth.ts new file mode 100644 index 0000000000..476f434c31 --- /dev/null +++ b/apps/scheduler/lib/scheduler/auth.ts @@ -0,0 +1,23 @@ +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +import { buildLegacyRequest } from "./legacy-request"; + +export async function getSchedulerSession(request: NextRequest) { + return getServerSession({ req: buildLegacyRequest(request) }); +} + +/** + * Returns either `{ session }` for an authenticated viewer, or `{ response }` + * carrying a 401 the route handler should return directly. + */ +export async function requireSchedulerSession(request: NextRequest) { + const session = await getSchedulerSession(request); + if (!session?.user?.id) { + return { + response: NextResponse.json({ error: "AUTHENTIK_LOGIN_REQUIRED" }, { status: 401 }), + }; + } + return { session }; +} diff --git a/apps/scheduler/lib/scheduler/demo-data.ts b/apps/scheduler/lib/scheduler/demo-data.ts new file mode 100644 index 0000000000..5686f61673 --- /dev/null +++ b/apps/scheduler/lib/scheduler/demo-data.ts @@ -0,0 +1,114 @@ +import type { + BusyBlock, + MeetingDraft, + SchedulerSchedule, + SchedulerUser, + WeeklyRange, +} from "./types"; + +/** + * Deterministic fallback fixtures used ONLY when `SCHEDULER_DEMO_MODE === "1"`. + * No DB access is performed in demo mode. Day numbers follow `Date.getUTCDay()` + * (Sun=0 .. Sat=6), so Mon–Fri == 1..5. + */ + +export const demoUsers: SchedulerUser[] = [ + { id: 1, name: "Zach Sharma", email: "zach@vynte.local", timeZone: "America/Denver" }, + { id: 2, name: "Maya Chen", email: "maya@vynte.local", timeZone: "America/Denver" }, + { id: 3, name: "Jordan Lee", email: "jordan@vynte.local", timeZone: "America/Denver" }, +]; + +const WORKDAY_RANGES: WeeklyRange[] = [1, 2, 3, 4, 5].map((day) => ({ + day, + enabled: true, + startTime: "09:00", + endTime: "17:00", +})); + +const WEEKEND_RANGES: WeeklyRange[] = [0, 6].map((day) => ({ + day, + enabled: false, + startTime: "09:00", + endTime: "17:00", +})); + +function demoWeekly(): WeeklyRange[] { + return [...WEEKEND_RANGES.slice(0, 1), ...WORKDAY_RANGES, ...WEEKEND_RANGES.slice(1)]; +} + +export const demoSchedules: SchedulerSchedule[] = demoUsers.map((user, index) => ({ + id: index + 1, + userId: user.id, + timeZone: user.timeZone, + weekly: demoWeekly(), + overrides: [], +})); + +export const demoBusyBlocks: BusyBlock[] = [ + { + id: "demo-busy-1", + userId: 1, + start: "2026-06-15T16:00:00.000Z", + end: "2026-06-15T17:00:00.000Z", + title: "Zach 1:1", + source: "internal", + }, + { + id: "demo-busy-2", + userId: 2, + start: "2026-06-15T18:00:00.000Z", + end: "2026-06-15T19:00:00.000Z", + title: "Maya focus block", + source: "internal", + }, + { + id: "demo-busy-3", + userId: 3, + start: "2026-06-16T15:30:00.000Z", + end: "2026-06-16T16:30:00.000Z", + title: "Jordan external", + source: "external", + }, +]; + +export type DemoConnection = { + type: string; + appId: string; + category: string; + connected: boolean; +}; + +export const demoConnectionsByUser: Record = { + 1: [ + { type: "google_calendar", appId: "google-calendar", category: "calendar", connected: true }, + { type: "google_video", appId: "google-meet", category: "conferencing", connected: true }, + ], + 2: [{ type: "office365_calendar", appId: "office365-calendar", category: "calendar", connected: true }], + 3: [], +}; + +export type DemoMeeting = { + id: number; + uid: string; + title: string; + organizerId: number; + attendeeIds: number[]; + start: string; + end: string; + location: string | null; +}; + +export const demoMeetings: DemoMeeting[] = [ + { + id: 9001, + uid: "demo-meeting-9001", + title: "Weekly sync", + organizerId: 1, + attendeeIds: [1, 2, 3], + start: "2026-06-15T17:00:00.000Z", + end: "2026-06-15T17:30:00.000Z", + location: "integrations:google:meet", + }, +]; + +export { type MeetingDraft }; diff --git a/apps/scheduler/lib/scheduler/legacy-request.ts b/apps/scheduler/lib/scheduler/legacy-request.ts new file mode 100644 index 0000000000..5a5fc8ea8e --- /dev/null +++ b/apps/scheduler/lib/scheduler/legacy-request.ts @@ -0,0 +1,20 @@ +import type { NextApiRequest } from "next"; +import type { NextRequest } from "next/server"; + +/** + * Adapts an App Router `NextRequest` into the `NextApiRequest`-shaped object that + * Cal's `getServerSession` (via `next-auth/jwt` `getToken`) expects: a plain cookies + * map, a plain headers map, plus `method` and `url`. + */ +export function buildLegacyRequest(request: NextRequest): NextApiRequest { + const cookies = Object.fromEntries( + request.cookies.getAll().map((cookie) => [cookie.name, cookie.value]) + ); + const headers = Object.fromEntries(request.headers.entries()); + return { + cookies, + headers, + method: request.method, + url: request.nextUrl.pathname + request.nextUrl.search, + } as unknown as NextApiRequest; +} diff --git a/apps/scheduler/lib/scheduler/service.ts b/apps/scheduler/lib/scheduler/service.ts new file mode 100644 index 0000000000..6e1c19e2e5 --- /dev/null +++ b/apps/scheduler/lib/scheduler/service.ts @@ -0,0 +1,468 @@ +import prisma from "@calcom/prisma"; + +import { computeMutualSlots } from "./availability"; +import { + demoBusyBlocks, + demoConnectionsByUser, + demoMeetings, + demoSchedules, + demoUsers, + type DemoConnection, +} from "./demo-data"; +import { filterBusyBlocksForViewer } from "./privacy"; +import type { + BusyBlock, + MutualSlot, + PublicBusyBlock, + SchedulerSchedule, + SchedulerUser, + WeeklyRange, +} from "./types"; +import { z } from "zod"; + +const DEFAULT_TIME_ZONE = "America/Denver"; + +function isDemoMode(): boolean { + return process.env.SCHEDULER_DEMO_MODE === "1"; +} + +// --------------------------------------------------------------------------- +// Mappers +// --------------------------------------------------------------------------- + +type DbUser = { + id: number; + name: string | null; + email: string; + timeZone: string; +}; + +function mapUser(user: DbUser): SchedulerUser { + return { + id: user.id, + name: user.name ?? user.email, + email: user.email, + timeZone: user.timeZone ?? DEFAULT_TIME_ZONE, + }; +} + +/** Cal stores Availability start/end as a Time-only value; we read the UTC clock time. */ +function timeToHhMm(value: Date): string { + const hours = String(value.getUTCHours()).padStart(2, "0"); + const minutes = String(value.getUTCMinutes()).padStart(2, "0"); + return `${hours}:${minutes}`; +} + +type DbAvailability = { + days: number[]; + startTime: Date; + endTime: Date; + date: Date | null; +}; + +/** + * Builds a SchedulerSchedule from a user's Schedule + Availability rows. + * Weekly rows have `date === null` and one-or-more `days`; override rows have a `date`. + */ +function buildSchedule( + userId: number, + schedule: { id: number; timeZone: string | null; availability: DbAvailability[] } | null, + fallbackTimeZone: string +): SchedulerSchedule { + const weekly: WeeklyRange[] = []; + const overrides: SchedulerSchedule["overrides"] = []; + + for (const row of schedule?.availability ?? []) { + if (row.date) { + overrides.push({ + date: row.date.toISOString().slice(0, 10), + unavailable: false, + startTime: timeToHhMm(row.startTime), + endTime: timeToHhMm(row.endTime), + }); + continue; + } + for (const day of row.days) { + weekly.push({ + day, + enabled: true, + startTime: timeToHhMm(row.startTime), + endTime: timeToHhMm(row.endTime), + }); + } + } + + return { + id: schedule?.id ?? null, + userId, + timeZone: schedule?.timeZone ?? fallbackTimeZone, + weekly, + overrides, + }; +} + +const USER_SELECT = { id: true, name: true, email: true, timeZone: true } as const; + +const SCHEDULE_INCLUDE = { + availability: { + select: { days: true, startTime: true, endTime: true, date: true }, + }, +} as const; + +// --------------------------------------------------------------------------- +// Team +// --------------------------------------------------------------------------- + +export async function getTeamContext( + viewerId: number +): Promise<{ viewer: SchedulerUser; team: SchedulerUser[] }> { + if (isDemoMode()) { + const viewer = demoUsers.find((user) => user.id === viewerId) ?? demoUsers[0]; + return { viewer, team: demoUsers }; + } + + // Single-team model: every active (unlocked) user is a teammate. + const users = await prisma.user.findMany({ + where: { locked: false }, + select: USER_SELECT, + orderBy: { id: "asc" }, + }); + + const team = users.map(mapUser); + const viewer = team.find((user) => user.id === viewerId) ?? mapUser(await requireUser(viewerId)); + return { viewer, team }; +} + +async function requireUser(userId: number): Promise { + const user = await prisma.user.findUnique({ where: { id: userId }, select: USER_SELECT }); + if (!user) { + throw new Error(`Scheduler user not found: ${userId}`); + } + return user; +} + +// --------------------------------------------------------------------------- +// Schedule (read/write) +// --------------------------------------------------------------------------- + +async function loadSchedule(userId: number): Promise { + const user = await requireUser(userId); + const schedule = await prisma.schedule.findFirst({ + where: { userId }, + orderBy: { id: "asc" }, + select: { id: true, timeZone: true, ...SCHEDULE_INCLUDE }, + }); + return buildSchedule(userId, schedule, user.timeZone ?? DEFAULT_TIME_ZONE); +} + +export async function getSchedule(userId: number): Promise { + if (isDemoMode()) { + return demoSchedules.find((schedule) => schedule.userId === userId) ?? demoSchedules[0]; + } + return loadSchedule(userId); +} + +const hhMm = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Expected HH:MM time"); + +const weeklyRangeSchema = z.object({ + day: z.number().int().min(0).max(6), + enabled: z.boolean(), + startTime: hhMm, + endTime: hhMm, +}); + +const dateOverrideSchema = z.object({ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD date"), + unavailable: z.boolean(), + startTime: hhMm.optional(), + endTime: hhMm.optional(), +}); + +const updateScheduleSchema = z.object({ + timeZone: z.string().min(1).optional(), + weekly: z.array(weeklyRangeSchema), + overrides: z.array(dateOverrideSchema).default([]), +}); + +/** Parses an "HH:MM" string into a 1970-01-01 UTC Date for the Time column. */ +function hhMmToTime(value: string): Date { + const [hours, minutes] = value.split(":").map(Number); + return new Date(Date.UTC(1970, 0, 1, hours, minutes, 0, 0)); +} + +export async function updateSchedule(userId: number, body: unknown): Promise { + const parsed = updateScheduleSchema.parse(body); + + if (isDemoMode()) { + // Demo mode is read-only; echo the parsed schedule back without persisting. + return { + id: null, + userId, + timeZone: parsed.timeZone ?? DEFAULT_TIME_ZONE, + weekly: parsed.weekly, + overrides: parsed.overrides, + }; + } + + const user = await requireUser(userId); + const timeZone = parsed.timeZone ?? user.timeZone ?? DEFAULT_TIME_ZONE; + + const existing = await prisma.schedule.findFirst({ + where: { userId }, + orderBy: { id: "asc" }, + select: { id: true }, + }); + + const availabilityRows = [ + ...parsed.weekly + .filter((range) => range.enabled) + .map((range) => ({ + days: [range.day], + startTime: hhMmToTime(range.startTime), + endTime: hhMmToTime(range.endTime), + date: null as Date | null, + userId, + })), + ...parsed.overrides + .filter((override) => !override.unavailable && override.startTime && override.endTime) + .map((override) => ({ + days: [] as number[], + startTime: hhMmToTime(override.startTime as string), + endTime: hhMmToTime(override.endTime as string), + date: new Date(`${override.date}T00:00:00.000Z`), + userId, + })), + ]; + + // Replace the schedule's availability atomically; immutable from the caller's view. + const scheduleId = await prisma.$transaction(async (tx) => { + const schedule = existing + ? await tx.schedule.update({ + where: { id: existing.id }, + data: { timeZone }, + select: { id: true }, + }) + : await tx.schedule.create({ + data: { userId, name: "Working Hours", timeZone }, + select: { id: true }, + }); + + await tx.availability.deleteMany({ where: { scheduleId: schedule.id } }); + if (availabilityRows.length > 0) { + await tx.availability.createMany({ + data: availabilityRows.map((row) => ({ ...row, scheduleId: schedule.id })), + }); + } + return schedule.id; + }); + + void scheduleId; + return loadSchedule(userId); +} + +// --------------------------------------------------------------------------- +// Connections (no secrets exposed) +// --------------------------------------------------------------------------- + +export type SchedulerConnection = { + type: string; + appId: string | null; + category: string; + connected: boolean; +}; + +export async function getConnections( + userId: number +): Promise<{ connections: SchedulerConnection[] }> { + if (isDemoMode()) { + const demo: DemoConnection[] = demoConnectionsByUser[userId] ?? []; + return { + connections: demo.map((connection) => ({ + type: connection.type, + appId: connection.appId, + category: connection.category, + connected: connection.connected, + })), + }; + } + + // Only non-secret metadata is selected: `key`/`encryptedKey` are never read. + const credentials = await prisma.credential.findMany({ + where: { userId }, + select: { + type: true, + appId: true, + invalid: true, + app: { select: { categories: true } }, + }, + orderBy: { id: "asc" }, + }); + + const connections: SchedulerConnection[] = credentials.map((credential) => ({ + type: credential.type, + appId: credential.appId, + category: credential.app?.categories?.[0] ?? "other", + connected: credential.invalid !== true, + })); + + return { connections }; +} + +// --------------------------------------------------------------------------- +// Availability +// --------------------------------------------------------------------------- + +async function bookingsToBusyBlocks( + attendeeIds: number[], + rangeStart: string, + rangeEnd: string +): Promise { + const bookings = await prisma.booking.findMany({ + where: { + userId: { in: attendeeIds }, + status: { in: ["ACCEPTED", "PENDING"] }, + startTime: { lt: new Date(rangeEnd) }, + endTime: { gt: new Date(rangeStart) }, + }, + select: { id: true, userId: true, title: true, startTime: true, endTime: true }, + orderBy: { startTime: "asc" }, + }); + + return bookings + .filter((booking): booking is typeof booking & { userId: number } => booking.userId !== null) + .map((booking) => ({ + id: `booking-${booking.id}`, + userId: booking.userId, + start: booking.startTime.toISOString(), + end: booking.endTime.toISOString(), + title: booking.title, + source: "internal" as const, + })); +} + +export async function getAvailability( + viewerId: number, + attendeeIds: number[], + rangeStart: string, + rangeEnd: string, + durationMinutes: number +): Promise<{ busy: PublicBusyBlock[]; mutualSlots: MutualSlot[] }> { + if (isDemoMode()) { + const relevant = demoBusyBlocks.filter((block) => attendeeIds.includes(block.userId)); + const schedules = demoSchedules.filter((schedule) => attendeeIds.includes(schedule.userId)); + const mutualSlots = computeMutualSlots({ + schedules, + busy: relevant, + rangeStart, + rangeEnd, + durationMinutes, + }); + return { busy: filterBusyBlocksForViewer(relevant, viewerId), mutualSlots }; + } + + const busyBlocks = await bookingsToBusyBlocks(attendeeIds, rangeStart, rangeEnd); + const schedules = await Promise.all(attendeeIds.map((id) => loadSchedule(id))); + + const mutualSlots = computeMutualSlots({ + schedules, + busy: busyBlocks, + rangeStart, + rangeEnd, + durationMinutes, + }); + + return { busy: filterBusyBlocksForViewer(busyBlocks, viewerId), mutualSlots }; +} + +// --------------------------------------------------------------------------- +// Meetings +// --------------------------------------------------------------------------- + +export type SchedulerMeeting = { + id: number; + uid: string; + title: string; + organizerId: number | null; + attendeeIds: number[]; + start: string; + end: string; + location: string | null; +}; + +export async function listMeetings( + viewerId: number +): Promise<{ meetings: SchedulerMeeting[] }> { + if (isDemoMode()) { + const meetings = demoMeetings + .filter((meeting) => meeting.attendeeIds.includes(viewerId)) + .map((meeting) => ({ + id: meeting.id, + uid: meeting.uid, + title: meeting.title, + organizerId: meeting.organizerId, + attendeeIds: meeting.attendeeIds, + start: meeting.start, + end: meeting.end, + location: meeting.location, + })); + return { meetings }; + } + + const bookings = await prisma.booking.findMany({ + where: { userId: viewerId, status: { in: ["ACCEPTED", "PENDING"] } }, + select: { id: true, uid: true, title: true, userId: true, startTime: true, endTime: true, location: true }, + orderBy: { startTime: "desc" }, + take: 100, + }); + + const meetings: SchedulerMeeting[] = bookings.map((booking) => ({ + id: booking.id, + uid: booking.uid, + title: booking.title, + organizerId: booking.userId, + attendeeIds: booking.userId !== null ? [booking.userId] : [], + start: booking.startTime.toISOString(), + end: booking.endTime.toISOString(), + location: booking.location, + })); + + return { meetings }; +} + +const createMeetingSchema = z.object({ + title: z.string().min(1), + attendeeIds: z.array(z.number().int().positive()).min(1), + start: z.string().datetime(), + durationMinutes: z.number().int().positive(), + location: z.string().optional(), + conferencing: z.enum(["none", "provider"]).optional(), +}); + +export type CreateMeetingResult = + | { status: "created"; meeting: SchedulerMeeting } + | { status: "not_implemented"; reason: string }; + +/** + * v1 decision: we DO NOT create a real Cal `Booking` here. + * + * Although the `Booking` model only marks `uid`/`title`/`startTime`/`endTime` as + * NOT NULL, a booking that Cal's own UI, calendar sync, and notification pipeline + * can actually consume requires an `eventTypeId`, `Attendee` rows, and structured + * `responses` JSON. Synthesizing those from the table-free data we have would mean + * guessing values, producing fragile/half-valid bookings. Per the plan, we instead + * return the explicit `CAL_BOOKING_SERVICE_BOUNDARY` not_implemented boundary, which + * the route surfaces as HTTP 501. + */ +export async function createMeeting( + viewerId: number, + body: unknown +): Promise { + const draft = createMeetingSchema.parse(body); + + if (!draft.attendeeIds.includes(viewerId)) { + throw new Error("ORGANIZER_NOT_IN_ATTENDEES"); + } + + // See function doc: intentionally a v1 boundary rather than a fragile booking. + return { status: "not_implemented", reason: "CAL_BOOKING_SERVICE_BOUNDARY" }; +}