Add scheduler API service layer
This commit is contained in:
@@ -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)));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<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;
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
Reference in New Issue
Block a user