Add scheduler API service layer

This commit is contained in:
Zachariah K. Sharma
2026-06-14 12:37:15 -06:00
parent e11e9d6393
commit 00f177ecdc
9 changed files with 776 additions and 0 deletions
@@ -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;
}
}
@@ -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)));
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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)));
}
+23
View File
@@ -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 };
}
+114
View File
@@ -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 MonFri == 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<number, DemoConnection[]> = {
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 };
@@ -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;
}
+468
View File
@@ -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<DbUser> {
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<SchedulerSchedule> {
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<SchedulerSchedule> {
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<SchedulerSchedule> {
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<BusyBlock[]> {
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<CreateMeetingResult> {
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" };
}