chore: move miscellaneous small api pages to app dir api (#19699)

* version and verify-booking-token

* username route

* api/me

* logo route

* render email in dev

* link route

* future

* nope and geo location

* referal link and daily

* intercom route and dynamic variables

* intercom route and dynamic variables

* scim 2.0 and helpscout route

* app credentials

* api/book events

* fix daily import path in tests

* fix buildLegacyRequest generation

* fix type errors

* migrate the /teams/ routes

* move cron jobs

* fix daily import path in tests

* fix buildLegacyRequest generation

* fix type errors

* migrate the /teams/ routes

* move cron jobs

* Revert "api/book events"

This reverts commit 607a32fb5b754cad090c2d0cbf64a68f990e220e.

* mock next server NextResponse to fix daily video

* add default responder to teams api

* fix search params

* uses nextUrl.searchParams instead of new url

* uses nextUrl.searchParams instead of new url

* remove outdated api config

* remove app dir version of inbound dynamic variables

* restore pages version of inbound variables

* fix missing code from stupid cursor

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
This commit is contained in:
sean-brydonandBenny Joo authored and GitHub committed 2025-03-09 11:56:56 -04:00
1 parent 1f1862e526
commit d1ce04edc1
41 files changed
+1206 -938

No files matched your search

@@ -0,0 +1,147 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import dayjs from "@calcom/dayjs";
import { sendOrganizerRequestReminderEmail } from "@calcom/emails";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import { BookingStatus, ReminderType } from "@calcom/prisma/enums";
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar";
export async function POST(request: NextRequest) {
const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey");
if (process.env.CRON_API_KEY !== apiKey) {
return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
}
const reminderIntervalMinutes = [48 * 60, 24 * 60, 3 * 60];
let notificationsSent = 0;
for (const interval of reminderIntervalMinutes) {
const bookings = await prisma.booking.findMany({
where: {
status: BookingStatus.PENDING,
createdAt: {
lte: dayjs().add(-interval, "minutes").toDate(),
},
// Only send reminders if the event hasn't finished
endTime: { gte: new Date() },
OR: [
// no payment required
{
payment: { none: {} },
},
// paid but awaiting approval
{
payment: { some: {} },
paid: true,
},
],
},
select: {
...bookingMinimalSelect,
location: true,
user: {
select: {
id: true,
email: true,
name: true,
username: true,
locale: true,
timeZone: true,
destinationCalendar: true,
},
},
eventType: {
select: {
recurringEvent: true,
bookingFields: true,
metadata: true,
},
},
responses: true,
uid: true,
destinationCalendar: true,
},
});
const reminders = await prisma.reminderMail.findMany({
where: {
reminderType: ReminderType.PENDING_BOOKING_CONFIRMATION,
referenceId: {
in: bookings.map((b) => b.id),
},
elapsedMinutes: {
gte: interval,
},
},
});
for (const booking of bookings.filter((b) => !reminders.some((r) => r.referenceId == b.id))) {
const { user } = booking;
const name = user?.name || user?.username;
if (!user || !name || !user.timeZone) {
console.error(`Booking ${booking.id} is missing required properties for booking reminder`, { user });
continue;
}
const tOrganizer = await getTranslation(user.locale ?? "en", "common");
const attendeesListPromises = booking.attendees.map(async (attendee) => {
return {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: {
translate: await getTranslation(attendee.locale ?? "en", "common"),
locale: attendee.locale ?? "en",
},
};
});
const attendeesList = await Promise.all(attendeesListPromises);
const selectedDestinationCalendar = booking.destinationCalendar || user.destinationCalendar;
const evt: CalendarEvent = {
type: booking.title,
title: booking.title,
description: booking.description || undefined,
customInputs: isPrismaObjOrUndefined(booking.customInputs),
...getCalEventResponses({
bookingFields: booking.eventType?.bookingFields ?? null,
booking,
}),
location: booking.location ?? "",
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
organizer: {
id: user.id,
email: booking?.userPrimaryEmail ?? user.email,
name,
timeZone: user.timeZone,
language: { translate: tOrganizer, locale: user.locale ?? "en" },
},
attendees: attendeesList,
uid: booking.uid,
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [],
};
await sendOrganizerRequestReminderEmail(evt, booking?.eventType?.metadata as EventTypeMetadata);
await prisma.reminderMail.create({
data: {
referenceId: booking.id,
reminderType: ReminderType.PENDING_BOOKING_CONFIRMATION,
elapsedMinutes: interval,
},
});
notificationsSent++;
}
}
return NextResponse.json({ notificationsSent });
}
@@ -0,0 +1,23 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import prisma from "@calcom/prisma";
export async function POST(request: NextRequest) {
const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey");
if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) {
return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
}
const deleted = await prisma.calendarCache.deleteMany({
where: {
// Delete all cache entries that expired before now
expiresAt: {
lte: new Date(Date.now()),
},
},
});
return NextResponse.json({ ok: true, count: deleted.count });
}
@@ -0,0 +1,169 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import dayjs from "@calcom/dayjs";
import prisma from "@calcom/prisma";
import { getDefaultScheduleId } from "@calcom/trpc/server/routers/viewer/availability/util";
const travelScheduleSelect = {
id: true,
startDate: true,
endDate: true,
timeZone: true,
prevTimeZone: true,
user: {
select: {
id: true,
timeZone: true,
defaultScheduleId: true,
},
},
};
export async function POST(request: NextRequest) {
const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey");
if (process.env.CRON_API_KEY !== apiKey) {
return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
}
let timeZonesChanged = 0;
const setNewTimeZone = async (timeZone: string, user: { id: number; defaultScheduleId: number | null }) => {
await prisma.user.update({
where: {
id: user.id,
},
data: {
timeZone: timeZone,
},
});
const defaultScheduleId = await getDefaultScheduleId(user.id, prisma);
if (!user.defaultScheduleId) {
// set default schedule if not already set
await prisma.user.update({
where: {
id: user.id,
},
data: {
defaultScheduleId,
},
});
}
await prisma.schedule.updateMany({
where: {
id: defaultScheduleId,
},
data: {
timeZone: timeZone,
},
});
timeZonesChanged++;
};
/* travelSchedules should be deleted automatically when timezone is set back to original tz,
but we do this in case there cron job didn't run for some reason
*/
const schedulesToDelete = await prisma.travelSchedule.findMany({
where: {
OR: [
{
startDate: {
lt: dayjs.utc().subtract(2, "day").toDate(),
},
endDate: null,
},
{
endDate: {
lt: dayjs.utc().subtract(2, "day").toDate(),
},
},
],
},
select: travelScheduleSelect,
});
for (const travelSchedule of schedulesToDelete) {
if (travelSchedule.prevTimeZone) {
await setNewTimeZone(travelSchedule.prevTimeZone, travelSchedule.user);
}
await prisma.travelSchedule.delete({
where: {
id: travelSchedule.id,
},
});
}
const travelSchedulesCloseToCurrentDate = await prisma.travelSchedule.findMany({
where: {
OR: [
{
startDate: {
gte: dayjs.utc().subtract(1, "day").toDate(),
lte: dayjs.utc().add(1, "day").toDate(),
},
},
{
endDate: {
gte: dayjs.utc().subtract(1, "day").toDate(),
lte: dayjs.utc().add(1, "day").toDate(),
},
},
],
},
select: travelScheduleSelect,
});
const travelScheduleIdsToDelete = [];
for (const travelSchedule of travelSchedulesCloseToCurrentDate) {
const userTz = travelSchedule.user.timeZone;
const offset = dayjs().tz(userTz).utcOffset();
// midnight of user's time zone in utc time
const startDateUTC = dayjs(travelSchedule.startDate).subtract(offset, "minute");
// 23:59 of user's time zone in utc time
const endDateUTC = dayjs(travelSchedule.endDate).subtract(offset, "minute");
if (
!dayjs.utc().isBefore(startDateUTC) &&
dayjs.utc().isBefore(endDateUTC) &&
!travelSchedule.prevTimeZone
) {
// if travel schedule has started and prevTimeZone is not yet set, we need to change time zone
await setNewTimeZone(travelSchedule.timeZone, travelSchedule.user);
if (!travelSchedule.endDate) {
travelScheduleIdsToDelete.push(travelSchedule.id);
} else {
await prisma.travelSchedule.update({
where: {
id: travelSchedule.id,
},
data: {
prevTimeZone: travelSchedule.user.timeZone,
},
});
}
}
if (!dayjs.utc().isBefore(endDateUTC)) {
if (travelSchedule.prevTimeZone) {
// travel schedule ended, change back to original timezone
await setNewTimeZone(travelSchedule.prevTimeZone, travelSchedule.user);
}
travelScheduleIdsToDelete.push(travelSchedule.id);
}
}
await prisma.travelSchedule.deleteMany({
where: {
id: {
in: travelScheduleIdsToDelete,
},
},
});
return NextResponse.json({ timeZonesChanged });
}
@@ -0,0 +1,53 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { z } from "zod";
import { TeamBilling } from "@calcom/ee/billing/teams";
import prisma from "@calcom/prisma";
const querySchema = z.object({
page: z.coerce.number().min(0).optional().default(0),
});
export async function POST(request: NextRequest) {
const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey");
if (process.env.CRON_API_KEY !== apiKey) {
return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
}
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const pageSize = 90; // Adjust this value based on the total number of teams and the available processing time
let { page: pageNumber } = querySchema.parse(Object.fromEntries(request.nextUrl.searchParams));
while (true) {
const teams = await prisma.team.findMany({
where: {
slug: {
not: null,
},
},
select: {
id: true,
metadata: true,
isOrganization: true,
parentId: true,
},
skip: pageNumber * pageSize,
take: pageSize,
});
if (teams.length === 0) {
break;
}
const teamsBilling = TeamBilling.initMany(teams);
const teamBillingPromises = teamsBilling.map((teamBilling) => teamBilling.updateQuantity());
await Promise.allSettled(teamBillingPromises);
pageNumber++;
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,315 @@
import type { Prisma } from "@prisma/client";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { sendMonthlyDigestEmails } from "@calcom/emails/email-manager";
import { EventsInsights } from "@calcom/features/insights/server/events";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma";
const querySchema = z.object({
page: z.coerce.number().min(0).optional().default(0),
});
export async function POST(request: NextRequest) {
const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey");
if (process.env.CRON_API_KEY !== apiKey) {
return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
}
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const pageSize = 90; // Adjust this value based on the total number of teams and the available processing time
let { page: pageNumber } = querySchema.parse(Object.fromEntries(request.nextUrl.searchParams));
const firstDateOfMonth = new Date();
firstDateOfMonth.setDate(1);
while (true) {
const teams = await prisma.team.findMany({
where: {
slug: {
not: null,
},
createdAt: {
// created before or on the first day of this month
lte: firstDateOfMonth,
},
},
select: {
id: true,
createdAt: true,
members: true,
name: true,
},
skip: pageNumber * pageSize,
take: pageSize,
});
if (teams.length === 0) {
break;
}
for (const team of teams) {
const EventData: {
Created: number;
Completed: number;
Rescheduled: number;
Cancelled: number;
mostBookedEvents: {
eventTypeId?: number | null;
eventTypeName?: string | null;
count?: number | null;
}[];
membersWithMostBookings: {
userId: number | null;
user: {
id: number;
name: string | null;
email: string;
avatar: string | null;
username: string | null;
};
count: number;
}[];
admin: { email: string; name: string };
team: {
name: string;
id: number;
};
} = {
Created: 0,
Completed: 0,
Rescheduled: 0,
Cancelled: 0,
mostBookedEvents: [],
membersWithMostBookings: [],
admin: { email: "", name: "" },
team: { name: team.name, id: team.id },
};
const userIdsFromTeams = team.members.map((u) => u.userId);
// Booking Events
const whereConditional: Prisma.BookingTimeStatusWhereInput = {
OR: [
{
teamId: team.id,
isTeamBooking: true,
},
{
userId: {
in: userIdsFromTeams,
},
isTeamBooking: false,
},
],
createdAt: {
gte: dayjs(firstDateOfMonth).toISOString(),
lte: dayjs(new Date()).toISOString(),
},
};
const countGroupedByStatus = await EventsInsights.countGroupedByStatus(whereConditional);
EventData["Created"] = countGroupedByStatus["_all"];
EventData["Completed"] = countGroupedByStatus["completed"];
EventData["Rescheduled"] = countGroupedByStatus["rescheduled"];
EventData["Cancelled"] = countGroupedByStatus["cancelled"];
// Most Booked Event Type
const bookingWhere: Prisma.BookingTimeStatusWhereInput = {
createdAt: {
gte: dayjs(firstDateOfMonth).startOf("day").toDate(),
lte: dayjs(new Date()).endOf("day").toDate(),
},
OR: [
{
teamId: team.id,
isTeamBooking: true,
},
{
userId: {
in: userIdsFromTeams,
},
isTeamBooking: false,
},
],
};
const bookingsFromSelected = await prisma.bookingTimeStatus.groupBy({
by: ["eventTypeId"],
where: bookingWhere,
_count: {
id: true,
},
orderBy: {
_count: {
id: "desc",
},
},
take: 10,
});
const eventTypeIds = bookingsFromSelected.reduce((acc: number[], booking) => {
if (typeof booking.eventTypeId !== "number") return acc;
return [...acc, booking.eventTypeId];
}, []);
const eventTypeWhereConditional: Prisma.EventTypeWhereInput = {
id: {
in: eventTypeIds,
},
};
const eventTypesFrom = await prisma.eventType.findMany({
select: {
id: true,
title: true,
teamId: true,
userId: true,
slug: true,
users: {
select: {
username: true,
},
},
team: {
select: {
slug: true,
},
},
},
where: eventTypeWhereConditional,
});
const eventTypeHashMap: Map<
number,
Prisma.EventTypeGetPayload<{
select: {
id: true;
title: true;
teamId: true;
userId: true;
slug: true;
users: {
select: {
username: true;
};
};
team: {
select: {
slug: true;
};
};
};
}>
> = new Map();
eventTypesFrom.forEach((eventType) => {
eventTypeHashMap.set(eventType.id, eventType);
});
EventData["mostBookedEvents"] = bookingsFromSelected.map((booking) => {
const eventTypeSelected = eventTypeHashMap.get(booking.eventTypeId ?? 0);
if (!eventTypeSelected) {
return {};
}
let eventSlug = "";
if (eventTypeSelected.userId) {
eventSlug = `${eventTypeSelected?.users[0]?.username}/${eventTypeSelected?.slug}`;
}
if (eventTypeSelected?.team && eventTypeSelected?.team?.slug) {
eventSlug = `${eventTypeSelected.team.slug}/${eventTypeSelected.slug}`;
}
return {
eventTypeId: booking.eventTypeId,
eventTypeName: eventSlug,
count: booking._count.id,
};
});
// Most booked members
const bookingsFromTeam = await prisma.bookingTimeStatus.groupBy({
by: ["userId"],
where: bookingWhere,
_count: {
id: true,
},
orderBy: {
_count: {
id: "desc",
},
},
take: 10,
});
const userIds = bookingsFromTeam
.filter((booking) => typeof booking.userId === "number")
.map((booking) => booking.userId);
if (userIds.length === 0) {
EventData["membersWithMostBookings"] = [];
} else {
const teamUsers = await prisma.user.findMany({
where: {
id: {
in: userIds as number[],
},
},
select: { id: true, name: true, email: true, avatarUrl: true, username: true },
});
const userHashMap = new Map();
teamUsers.forEach((user) => {
userHashMap.set(user.id, user);
});
EventData["membersWithMostBookings"] = bookingsFromTeam.map((booking) => {
return {
userId: booking.userId,
user: userHashMap.get(booking.userId),
count: booking._count.id,
};
});
}
// Send mail to all Owners and Admins
const mailReceivers = team?.members?.filter(
(member) => member.role === "OWNER" || member.role === "ADMIN"
);
const mailsToSend = mailReceivers.map(async (receiver) => {
const owner = await prisma.user.findUnique({
where: {
id: receiver?.userId,
},
});
if (owner) {
const t = await getTranslation(owner?.locale ?? "en", "common");
// Only send email if user has allowed to receive monthly digest emails
if (owner.receiveMonthlyDigestEmail) {
await sendMonthlyDigestEmails({
...EventData,
admin: { email: owner?.email ?? "", name: owner?.name ?? "" },
language: t,
});
}
}
});
await Promise.all(mailsToSend);
await delay(100); // Adjust the delay as needed to avoid rate limiting
}
pageNumber++;
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,64 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
import logger from "@calcom/lib/logger";
import { prisma } from "@calcom/prisma";
import type { AppCategories, Prisma } from "@calcom/prisma/client";
const isDryRun = process.env.CRON_ENABLE_APP_SYNC !== "true";
const log = logger.getSubLogger({
prefix: ["[api/cron/syncAppMeta]", ...(isDryRun ? ["(dry-run)"] : [])],
});
/**
* syncAppMeta makes sure any app metadata that has been replicated into the database
* remains synchronized with any changes made to the app config files.
*/
export async function POST(request: NextRequest) {
const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey");
if (process.env.CRON_API_KEY !== apiKey) {
return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
}
log.info(`🧐 Checking DB apps are in-sync with app metadata`);
const dbApps = await prisma.app.findMany();
for await (const dbApp of dbApps) {
const app = await getAppWithMetadata(dbApp);
const updates: Prisma.AppUpdateManyMutationInput = {};
if (!app) {
log.warn(`💀 App ${dbApp.slug} (${dbApp.dirName}) no longer exists.`);
continue;
}
// Check for any changes in the app categories (tolerates changes in ordering)
if (
dbApp.categories.length !== app.categories.length ||
!dbApp.categories.every((category) => app.categories.includes(category))
) {
updates["categories"] = app.categories as AppCategories[];
}
if (dbApp.dirName !== (app.dirName ?? app.slug)) {
updates["dirName"] = app.dirName ?? app.slug;
}
if (Object.keys(updates).length > 0) {
log.info(`🔨 Updating app ${dbApp.slug} with ${Object.keys(updates).join(", ")}`);
if (!isDryRun) {
await prisma.app.update({
where: { slug: dbApp.slug },
data: updates,
});
}
} else {
log.info(`✅ App ${dbApp.slug} is up-to-date and correct`);
}
}
return NextResponse.json({ ok: true });
}
+75
View File
@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { renderEmail } from "@calcom/emails";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { getTranslation } from "@calcom/lib/server/i18n";
/**
* This API endpoint is used for development purposes to preview email templates
*/
export async function GET() {
// Only allow in development mode
if (IS_PRODUCTION) {
return new NextResponse("Only for development purposes", {
status: 403,
});
}
const t = await getTranslation("en", "common");
// Render the email template
const emailHtml = await renderEmail("MonthlyDigestEmail", {
language: t,
Created: 12,
Completed: 13,
Rescheduled: 14,
Cancelled: 16,
mostBookedEvents: [
{
eventTypeId: 3,
eventTypeName: "Test1",
count: 3,
},
{
eventTypeId: 4,
eventTypeName: "Test2",
count: 5,
},
],
membersWithMostBookings: [
{
userId: 4,
user: {
id: 4,
name: "User1 name",
email: "email.com",
avatar: "none",
username: "User1",
},
count: 4,
},
{
userId: 6,
user: {
id: 6,
name: "User2 name",
email: "email2.com",
avatar: "none",
username: "User2",
},
count: 8,
},
],
admin: { email: "admin.com", name: "admin" },
team: { name: "Team1", id: 4 },
});
// Create a response with the HTML content
const response = new NextResponse(emailHtml);
// Set appropriate headers
response.headers.set("Content-Type", "text/html");
response.headers.set("Cache-Control", "no-cache, no-store, private, must-revalidate");
return response;
}
+37
View File
@@ -0,0 +1,37 @@
import { cookies, headers } from "next/headers";
import { NextResponse } from "next/server";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { FUTURE_ROUTES_OVERRIDE_COOKIE_NAME as COOKIE_NAME } from "@calcom/lib/constants";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export async function GET() {
const headersList = headers();
const cookiesList = cookies();
const legacyReq = buildLegacyRequest(headersList, cookiesList);
const session = await getServerSession({ req: legacyReq });
if (!session || !session.user || !session.user.email) {
return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
}
let redirectUrl = "/";
// We take you back where you came from if possible
const referer = headersList.get("referer");
if (referer) redirectUrl = referer;
const response = NextResponse.redirect(redirectUrl);
// If has the cookie, Opt-out of V2
if (cookiesList.has(COOKIE_NAME) && cookiesList.get(COOKIE_NAME)?.value === "1") {
response.cookies.set(COOKIE_NAME, "0", { maxAge: 0, path: "/" });
} else {
/* Opt-in to V2 */
response.cookies.set(COOKIE_NAME, "1", { path: "/" });
}
return response;
}
@@ -0,0 +1,53 @@
import { cookies, headers } from "next/headers";
import { NextResponse } from "next/server";
import { dub } from "@calcom/features/auth/lib/dub";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import prisma from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export async function POST() {
const headersList = headers();
const cookiesList = cookies();
const legacyReq = buildLegacyRequest(headersList, cookiesList);
const session = await getServerSession({ req: legacyReq });
if (!session?.user?.username) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
try {
const { shortLink } = await dub.links.get({
externalId: `ext_${session.user.id.toString()}`,
});
return NextResponse.json({ shortLink });
} catch (error) {
console.log("Referral link not found, creating...");
}
const { id: referralLinkId, shortLink } = await dub.links.create({
domain: "refer.cal.com",
key: session.user.username,
url: "https://cal.com",
externalId: session.user.id.toString(), // @see https://d.to/externalId
trackConversion: true, // enable conversion tracking @see https://d.to/conversions
});
/*
Even though we are relying in externalId for retrieving the referral link,
we still save the referralLinkId in the database for future reference.
E.g. we can use this to tell how many users created a referral link.
*/
await prisma.user.update({
where: {
id: session.user.id,
},
data: {
referralLinkId,
},
});
return NextResponse.json({ shortLink });
}
+12
View File
@@ -0,0 +1,12 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server";
export async function GET() {
const headersList = headers();
const country = headersList.get("x-vercel-ip-country") || "Unknown";
const response = NextResponse.json({ country });
response.headers.set("Cache-Control", "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400");
return response;
}
+30
View File
@@ -0,0 +1,30 @@
import { cookies, headers } from "next/headers";
import { NextResponse } from "next/server";
import crypto from "node:crypto";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export async function GET() {
const headersList = headers();
const cookiesList = cookies();
const legacyReq = buildLegacyRequest(headersList, cookiesList);
const session = await getServerSession({ req: legacyReq });
const secret = process.env.INTERCOM_SECRET;
if (!session) {
return NextResponse.json({ message: "user not authenticated" }, { status: 401 });
}
if (!secret) {
return NextResponse.json({ message: "Intercom Identity Verification secret not set" }, { status: 400 });
}
const hmac = crypto.createHmac("sha256", secret);
hmac.update(String(session.user.id));
const hash = hmac.digest("hex");
return NextResponse.json({ hash });
}
+112
View File
@@ -0,0 +1,112 @@
import { cookies, headers } from "next/headers";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { z } from "zod";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import prisma from "@calcom/prisma";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { createContext } from "@calcom/trpc/server/createContext";
import { bookingsRouter } from "@calcom/trpc/server/routers/viewer/bookings/_router";
import type { UserProfile } from "@calcom/types/UserProfile";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { TRPCError } from "@trpc/server";
enum DirectAction {
ACCEPT = "accept",
REJECT = "reject",
}
const querySchema = z.object({
action: z.nativeEnum(DirectAction),
token: z.string(),
reason: z.string().optional(),
});
const decryptedSchema = z.object({
bookingUid: z.string(),
userId: z.number().int(),
});
// Move the sessionGetter function outside the GET function
const createSessionGetter = (userId: number) => async () => {
return {
user: {
id: userId,
username: "" /* Not used in this context */,
role: UserPermissionRole.USER,
/* Not used in this context */
profile: {
id: null,
organizationId: null,
organization: null,
username: "",
upId: "",
} satisfies UserProfile,
},
upId: "",
hasValidLicense: true,
expires: "" /* Not used in this context */,
};
};
export async function GET(request: NextRequest) {
const url = new URL(request.url);
const searchParams = request.nextUrl.searchParams;
try {
const { action, token, reason } = querySchema.parse(Object.fromEntries(searchParams.entries()));
const decryptedData = JSON.parse(
symmetricDecrypt(decodeURIComponent(token), process.env.CALENDSO_ENCRYPTION_KEY || "")
);
const { bookingUid, userId } = decryptedSchema.parse(decryptedData);
const booking = await prisma.booking.findUniqueOrThrow({
where: { uid: bookingUid },
});
const user = await prisma.user.findUniqueOrThrow({
where: { id: userId },
});
// Use the factory function instead of declaring inside the block
const sessionGetter = createSessionGetter(userId);
try {
/** @see https://trpc.io/docs/server-side-calls */
// Create a legacy request object for compatibility
const legacyReq = buildLegacyRequest(headers(), cookies());
const res = {} as any; // Response is still mocked as it's not used in this context
const ctx = await createContext({ req: legacyReq, res }, sessionGetter);
const caller = bookingsRouter.createCaller({
...ctx,
req: legacyReq,
res,
user: { ...user, locale: user?.locale ?? "en" },
});
await caller.confirm({
bookingId: booking.id,
recurringEventId: booking.recurringEventId || undefined,
confirmed: action === DirectAction.ACCEPT,
reason,
});
} catch (e) {
let message = "Error confirming booking";
if (e instanceof TRPCError) message = (e as TRPCError).message;
return NextResponse.redirect(
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent(message)}`
);
}
return NextResponse.redirect(`${url.origin}/booking/${bookingUid}`);
} catch (error) {
console.error("Error processing link request:", error);
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
}
+219
View File
@@ -0,0 +1,219 @@
import { cookies, headers } from "next/headers";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { z } from "zod";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import {
ANDROID_CHROME_ICON_192,
ANDROID_CHROME_ICON_256,
APPLE_TOUCH_ICON,
FAVICON_16,
FAVICON_32,
IS_SELF_HOSTED,
LOGO,
LOGO_ICON,
MSTILE_ICON,
WEBAPP_URL,
} from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
const log = logger.getSubLogger({ prefix: ["[api/logo]"] });
function removePort(url: string) {
return url.replace(/:\d+$/, "");
}
function extractSubdomainAndDomain(hostname: string) {
const hostParts = removePort(hostname).split(".");
const subdomainParts = hostParts.slice(0, hostParts.length - 2);
const domain = hostParts.slice(hostParts.length - 2).join(".");
return [subdomainParts[0], domain];
}
const logoApiSchema = z.object({
type: z.coerce.string().optional(),
});
const SYSTEM_SUBDOMAINS = ["console", "app", "www"];
type LogoType =
| "logo"
| "icon"
| "favicon-16"
| "favicon-32"
| "apple-touch-icon"
| "mstile"
| "android-chrome-192"
| "android-chrome-256";
type LogoTypeDefinition = {
fallback: string;
w?: number;
h?: number;
source: "appLogo" | "appIconLogo";
};
const logoDefinitions: Record<LogoType, LogoTypeDefinition> = {
logo: {
fallback: `${WEBAPP_URL}${LOGO}`,
source: "appLogo",
},
icon: {
fallback: `${WEBAPP_URL}${LOGO_ICON}`,
source: "appIconLogo",
},
"favicon-16": {
fallback: `${WEBAPP_URL}${FAVICON_16}`,
w: 16,
h: 16,
source: "appIconLogo",
},
"favicon-32": {
fallback: `${WEBAPP_URL}${FAVICON_32}`,
w: 32,
h: 32,
source: "appIconLogo",
},
"apple-touch-icon": {
fallback: `${WEBAPP_URL}${APPLE_TOUCH_ICON}`,
w: 180,
h: 180,
source: "appLogo",
},
mstile: {
fallback: `${WEBAPP_URL}${MSTILE_ICON}`,
w: 150,
h: 150,
source: "appLogo",
},
"android-chrome-192": {
fallback: `${WEBAPP_URL}${ANDROID_CHROME_ICON_192}`,
w: 192,
h: 192,
source: "appLogo",
},
"android-chrome-256": {
fallback: `${WEBAPP_URL}${ANDROID_CHROME_ICON_256}`,
w: 256,
h: 256,
source: "appLogo",
},
};
function isValidLogoType(type: string): type is LogoType {
return type in logoDefinitions;
}
async function getTeamLogos(subdomain: string, isValidOrgDomain: boolean) {
try {
if (
// if not cal.com
IS_SELF_HOSTED ||
// missing subdomain (empty string)
!subdomain ||
// in SYSTEM_SUBDOMAINS list
SYSTEM_SUBDOMAINS.includes(subdomain)
) {
throw new Error("No custom logo needed");
}
// load from DB
const { default: prisma } = await import("@calcom/prisma");
const team = await prisma.team.findFirst({
where: {
slug: subdomain,
...(isValidOrgDomain && {
OR: [
{
metadata: {
path: ["isOrganization"],
equals: true,
},
},
{
isOrganization: true,
},
],
}),
},
select: {
appLogo: true,
appIconLogo: true,
},
});
return {
appLogo: team?.appLogo,
appIconLogo: team?.appIconLogo,
};
} catch (error) {
if (error instanceof Error) log.debug(error.message);
return {
appLogo: undefined,
appIconLogo: undefined,
};
}
}
/**
* This API endpoint is used to serve the logo associated with a team if no logo is found we serve our default logo
*/
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const parsedQuery = logoApiSchema.parse(Object.fromEntries(searchParams.entries()));
// Create a legacy request object for compatibility
const legacyReq = buildLegacyRequest(headers(), cookies());
const { isValidOrgDomain } = orgDomainConfig(legacyReq);
const hostname = request.headers.get("host");
if (!hostname) {
return NextResponse.json({ error: "No hostname" }, { status: 400 });
}
const domains = extractSubdomainAndDomain(hostname);
if (!domains) {
return NextResponse.json({ error: "No domains" }, { status: 400 });
}
const [subdomain] = domains;
const teamLogos = await getTeamLogos(subdomain, isValidOrgDomain);
// Resolve all icon types to team logos, falling back to Cal.com defaults.
const type: LogoType = parsedQuery?.type && isValidLogoType(parsedQuery.type) ? parsedQuery.type : "logo";
const logoDefinition = logoDefinitions[type];
const filteredLogo = teamLogos[logoDefinition.source] ?? logoDefinition.fallback;
try {
const response = await fetch(filteredLogo);
const arrayBuffer = await response.arrayBuffer();
let buffer = Buffer.from(arrayBuffer);
// If we need to resize the team logos (via Next.js' built-in image processing)
if (teamLogos[logoDefinition.source] && logoDefinition.w) {
const { detectContentType, optimizeImage } = await import("next/dist/server/image-optimizer");
buffer = await optimizeImage({
buffer,
contentType: detectContentType(buffer) ?? "image/jpeg",
quality: 100,
width: logoDefinition.w,
height: logoDefinition.h, // optional
});
}
// Create a new response with the image buffer
const imageResponse = new NextResponse(buffer);
// Set the appropriate headers
imageResponse.headers.set("Content-Type", response.headers.get("content-type") || "image/png");
imageResponse.headers.set("Cache-Control", "s-maxage=86400, stale-while-revalidate=60");
return imageResponse;
} catch (error) {
return NextResponse.json({ error: "Failed fetching logo" }, { status: 404 });
}
}
+50
View File
@@ -0,0 +1,50 @@
import { cookies, headers } from "next/headers";
import { NextResponse } from "next/server";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { performance } from "@calcom/lib/server/perfObserver";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
let isCold = true;
export async function GET() {
const prePrismaDate = performance.now();
const prisma = (await import("@calcom/prisma")).default;
const preSessionDate = performance.now();
// Create a legacy request object for compatibility
const legacyReq = buildLegacyRequest(headers(), cookies());
const session = await getServerSession({ req: legacyReq });
if (!session) {
return NextResponse.json({ message: "Unauthorized" }, { status: 409 });
}
const preUserDate = performance.now();
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user) {
return NextResponse.json({ message: "No user found" }, { status: 404 });
}
const lastUpdate = performance.now();
// Create response with headers
const response = NextResponse.json({
message: `Hello ${user.name}`,
prePrismaDate,
prismaDuration: `Prisma took ${preSessionDate - prePrismaDate}ms`,
preSessionDate,
sessionDuration: `Session took ${preUserDate - preSessionDate}ms`,
preUserDate,
userDuration: `User took ${lastUpdate - preUserDate}ms`,
lastUpdate,
wasCold: isCold,
});
// Set custom header
response.headers.set("x-is-cold", isCold.toString());
isCold = false;
return response;
}
+9
View File
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ message: "Please don't" }, { status: 400 });
}
export async function POST() {
return NextResponse.json({ message: "Please don't" }, { status: 400 });
}
@@ -0,0 +1,227 @@
import { createHmac } from "crypto";
import { headers } from "next/headers";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getRoomNameFromRecordingId, getBatchProcessorJobAccessLink } from "@calcom/app-store/dailyvideo/lib";
import { sendDailyVideoRecordingEmails } from "@calcom/emails";
import { sendDailyVideoTranscriptEmails } from "@calcom/emails";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import {
getAllTranscriptsAccessLinkFromMeetingId,
getDownloadLinkOfCalVideoByRecordingId,
submitBatchProcessorTranscriptionJob,
} from "@calcom/lib/videoClient";
import prisma from "@calcom/prisma";
import { getBooking } from "@calcom/web/lib/daily-webhook/getBooking";
import { getBookingReference } from "@calcom/web/lib/daily-webhook/getBookingReference";
import { getCalendarEvent } from "@calcom/web/lib/daily-webhook/getCalendarEvent";
import {
meetingEndedSchema,
recordingReadySchema,
batchProcessorJobFinishedSchema,
downloadLinkSchema,
testRequestSchema,
} from "@calcom/web/lib/daily-webhook/schema";
import {
triggerRecordingReadyWebhook,
triggerTranscriptionGeneratedWebhook,
} from "@calcom/web/lib/daily-webhook/triggerWebhooks";
const log = logger.getSubLogger({ prefix: ["daily-video-webhook-handler"] });
const computeSignature = (hmacSecret: string, reqBody: any, webhookTimestampHeader: string | null) => {
const signature = `${webhookTimestampHeader}.${JSON.stringify(reqBody)}`;
const base64DecodedSecret = Buffer.from(hmacSecret, "base64");
const hmac = createHmac("sha256", base64DecodedSecret);
const computed_signature = hmac.update(signature).digest("base64");
return computed_signature;
};
const getDownloadLinkOfCalVideo = async (recordingId: string) => {
const response = await getDownloadLinkOfCalVideoByRecordingId(recordingId);
const downloadLinkResponse = downloadLinkSchema.parse(response);
const downloadLink = downloadLinkResponse.download_link;
return downloadLink;
};
export async function POST(request: NextRequest) {
if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_EMAIL) {
return NextResponse.json({ message: "No SendGrid API key or email" }, { status: 405 });
}
const body = await request.json();
if (testRequestSchema.safeParse(body).success) {
return NextResponse.json({ message: "Test request successful" });
}
const headersList = headers();
const testMode = process.env.NEXT_PUBLIC_IS_E2E || process.env.INTEGRATION_TEST_MODE;
if (!testMode) {
const hmacSecret = process.env.DAILY_WEBHOOK_SECRET;
if (!hmacSecret) {
return NextResponse.json({ message: "No Daily Webhook Secret" }, { status: 405 });
}
const webhookTimestamp = headersList.get("x-webhook-timestamp");
const computed_signature = computeSignature(hmacSecret, body, webhookTimestamp);
if (headersList.get("x-webhook-signature") !== computed_signature) {
return NextResponse.json({ message: "Signature does not match" }, { status: 403 });
}
}
log.debug(
"Daily video webhook Request Body:",
safeStringify({
body,
})
);
try {
if (body?.type === "recording.ready-to-download") {
const recordingReadyResponse = recordingReadySchema.safeParse(body);
if (!recordingReadyResponse.success) {
return NextResponse.json({ message: "Invalid Payload" }, { status: 400 });
}
const { room_name, recording_id, status } = recordingReadyResponse.data.payload;
if (status !== "finished") {
return NextResponse.json({ message: "Recording not finished" }, { status: 400 });
}
const bookingReference = await getBookingReference(room_name);
const booking = await getBooking(bookingReference.bookingId as number);
const evt = await getCalendarEvent(booking);
await prisma.booking.update({
where: {
uid: booking.uid,
},
data: {
isRecorded: true,
},
});
const downloadLink = await getDownloadLinkOfCalVideo(recording_id);
const teamId = await getTeamIdFromEventType({
eventType: {
team: { id: booking?.eventType?.teamId ?? null },
parentId: booking?.eventType?.parentId ?? null,
},
});
await triggerRecordingReadyWebhook({
evt,
downloadLink,
booking: {
userId: booking?.user?.id,
eventTypeId: booking.eventTypeId,
eventTypeParentId: booking.eventType?.parentId,
teamId,
},
});
try {
// Submit Transcription Batch Processor Job
await submitBatchProcessorTranscriptionJob(recording_id);
} catch (err) {
log.error("Failed to Submit Transcription Batch Processor Job:", safeStringify(err));
}
// send emails to all attendees only when user has team plan
await sendDailyVideoRecordingEmails(evt, downloadLink);
return NextResponse.json({ message: "Success" });
} else if (body.type === "meeting.ended") {
const meetingEndedResponse = meetingEndedSchema.safeParse(body);
if (!meetingEndedResponse.success) {
return NextResponse.json({ message: "Invalid Payload" }, { status: 400 });
}
const { room, meeting_id } = meetingEndedResponse.data.payload;
const bookingReference = await getBookingReference(room);
const booking = await getBooking(bookingReference.bookingId as number);
if (!booking.eventType?.canSendCalVideoTranscriptionEmails) {
return NextResponse.json({
message: `Transcription emails are disabled for this event type ${booking.eventTypeId}`,
});
}
const transcripts = await getAllTranscriptsAccessLinkFromMeetingId(meeting_id);
if (!transcripts || !transcripts.length)
return NextResponse.json({
message: `No Transcripts found for room name ${room} and meeting id ${meeting_id}`,
});
const evt = await getCalendarEvent(booking);
await sendDailyVideoTranscriptEmails(evt, transcripts);
return NextResponse.json({ message: "Success" });
} else if (body?.type === "batch-processor.job-finished") {
const batchProcessorJobFinishedResponse = batchProcessorJobFinishedSchema.safeParse(body);
if (!batchProcessorJobFinishedResponse.success) {
return NextResponse.json({ message: "Invalid Payload" }, { status: 400 });
}
const { id, input } = batchProcessorJobFinishedResponse.data.payload;
const roomName = await getRoomNameFromRecordingId(input.recordingId);
const bookingReference = await getBookingReference(roomName);
const booking = await getBooking(bookingReference.bookingId as number);
const teamId = await getTeamIdFromEventType({
eventType: {
team: { id: booking?.eventType?.teamId ?? null },
parentId: booking?.eventType?.parentId ?? null,
},
});
const evt = await getCalendarEvent(booking);
const recording = await getDownloadLinkOfCalVideo(input.recordingId);
const batchProcessorJobAccessLink = await getBatchProcessorJobAccessLink(id);
await triggerTranscriptionGeneratedWebhook({
evt,
downloadLinks: {
transcription: batchProcessorJobAccessLink.transcription,
recording,
},
booking: {
userId: booking?.user?.id,
eventTypeId: booking.eventTypeId,
eventTypeParentId: booking.eventType?.parentId,
teamId,
},
});
return NextResponse.json({ message: "Success" });
} else {
log.error("Invalid type in /recorded-daily-video", body);
return NextResponse.json({ message: "Invalid type in /recorded-daily-video" });
}
} catch (err) {
log.error("Error in /recorded-daily-video", err);
if (err instanceof HttpError) {
return NextResponse.json({ message: err.message }, { status: err.statusCode });
} else {
return NextResponse.json({ message: "something went wrong" }, { status: 500 });
}
}
}
@@ -0,0 +1,141 @@
import type { DirectorySyncEvent, DirectorySyncRequest } from "@boxyhq/saml-jackson";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import handleGroupEvents from "@calcom/features/ee/dsync/lib/handleGroupEvents";
import handleUserEvents from "@calcom/features/ee/dsync/lib/handleUserEvents";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import { DIRECTORY_IDS_TO_LOG } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma from "@calcom/prisma";
const log = logger.getSubLogger({ prefix: ["[scim]"] });
// Extract the auth token from the request headers
const extractAuthToken = (req: NextRequest): string | null => {
const authHeader = req.headers.get("authorization") || null;
return authHeader ? authHeader.split(" ")[1] : null;
};
// Handle the SCIM events
const handleEvents = async (event: DirectorySyncEvent) => {
log.debug("handleEvents", safeStringify(event));
const dSyncData = await prisma.dSyncData.findFirst({
where: {
directoryId: event.directory_id,
},
select: {
id: true,
organizationId: true,
},
});
if (!dSyncData) {
throw new Error("Directory sync data not found");
}
const { organizationId } = dSyncData;
if (!organizationId) {
throw new Error(`Org ID not found for dsync ${dSyncData.id}`);
}
if (event.event.includes("group")) {
handleGroupEvents(event, organizationId);
}
if (event.event === "user.created" || event.event === "user.updated") {
await handleUserEvents(event, organizationId);
}
};
// This is the handler for the SCIM API requests
export async function GET(request: NextRequest, { params }: { params: { directory?: string[] } }) {
return handleScimRequest(request, "GET", params.directory);
}
export async function POST(request: NextRequest, { params }: { params: { directory?: string[] } }) {
return handleScimRequest(request, "POST", params.directory);
}
export async function PUT(request: NextRequest, { params }: { params: { directory?: string[] } }) {
return handleScimRequest(request, "PUT", params.directory);
}
export async function PATCH(request: NextRequest, { params }: { params: { directory?: string[] } }) {
return handleScimRequest(request, "PATCH", params.directory);
}
export async function DELETE(request: NextRequest, { params }: { params: { directory?: string[] } }) {
return handleScimRequest(request, "DELETE", params.directory);
}
async function handleScimRequest(request: NextRequest, method: string, directoryParams?: string[]) {
const { dsyncController } = await jackson();
if (!directoryParams || directoryParams.length === 0) {
return NextResponse.json({ error: "Missing directory parameters" }, { status: 400 });
}
const [directoryId, path, resourceId] = directoryParams;
const shouldLog = DIRECTORY_IDS_TO_LOG.includes(directoryId);
const searchParams = request.nextUrl.searchParams;
if (shouldLog) {
console.log(
"SCIM API request",
safeStringify({
method,
url: request.url,
params: directoryParams,
searchParams: Object.fromEntries(searchParams.entries()),
body: request.body
? await request
.clone()
.json()
.catch(() => ({}))
: undefined,
})
);
}
let body: object | undefined = undefined;
try {
body = await request.json().catch(() => undefined);
} catch (e) {
log.error(`Error parsing SCIM event for directoryId ${directoryId} with error: ${e}`);
}
// Handle the SCIM API requests
const scimRequest: DirectorySyncRequest = {
method,
directoryId,
resourceId,
apiSecret: extractAuthToken(request),
resourceType: path === "Users" ? "users" : "groups",
body,
query: {
count: searchParams.get("count") ? parseInt(searchParams.get("count") as string) : undefined,
startIndex: searchParams.get("startIndex")
? parseInt(searchParams.get("startIndex") as string)
: undefined,
filter: searchParams.get("filter") as string,
},
};
const { status, data } = await dsyncController.requests.handle(scimRequest, handleEvents);
if (shouldLog) {
console.log(
"Response to SCIM",
safeStringify({
status,
data,
})
);
}
return NextResponse.json(data, { status });
}
+1 -1
View File
@@ -39,7 +39,7 @@ const genericSchema = z.object({
});
async function handler(req: NextRequest) {
const { searchParams } = new URL(`${req.url}`);
const { searchParams } = req.nextUrl;
const imageType = searchParams.get("type");
const [calFontData, interFontData, interFontMediumData] = await Promise.all([
+96
View File
@@ -0,0 +1,96 @@
import { createHmac } from "crypto";
import { headers } from "next/headers";
import { cookies } from "next/headers";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import getRawBody from "raw-body";
import z from "zod";
import { emailSchema } from "@calcom/lib/emailSchema";
import { default as webPrisma } from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
const helpscoutRequestBodySchema = z.object({
customer: z.object({
email: emailSchema,
}),
});
/**
* API for Helpscout to retrieve key information about a user from a ticket
* Note: HelpScout expects a JSON with a `html` prop to show its content as HTML
*/
export async function POST(request: NextRequest) {
const hsSignature = request.headers.get("x-helpscout-signature");
if (!hsSignature) return NextResponse.json({ message: "Missing signature" }, { status: 400 });
if (!process.env.CALENDSO_ENCRYPTION_KEY)
return NextResponse.json({ message: "Missing encryption key" }, { status: 500 });
const legacyRequest = buildLegacyRequest(headers(), cookies());
// Get the raw request body
const rawBody = await getRawBody(legacyRequest);
try {
const parsedBody = helpscoutRequestBodySchema.safeParse(JSON.parse(rawBody.toString()));
if (!parsedBody.success) return NextResponse.json({ message: "Invalid request body" }, { status: 400 });
// Verify the signature
const calculatedSig = createHmac("sha1", process.env.CALENDSO_ENCRYPTION_KEY)
.update(rawBody)
.digest("base64");
if (hsSignature !== calculatedSig)
return NextResponse.json({ message: "Invalid signature" }, { status: 400 });
const user = await webPrisma.user.findFirst({
where: {
email: parsedBody.data.customer.email,
},
select: {
username: true,
id: true,
createdDate: true,
},
});
if (!user) return NextResponse.json({ html: "User not found" });
const lastBooking = await webPrisma.attendee.findFirst({
where: {
email: parsedBody.data.customer.email,
},
select: {
booking: {
select: {
createdAt: true,
},
},
},
orderBy: {
booking: {
createdAt: "desc",
},
},
});
return NextResponse.json({
html: `
<ul>
<li><b>Username:</b>&nbsp;${user.username}</li>
<li><b>Last booking:</b>&nbsp;${
lastBooking && lastBooking.booking
? new Date(lastBooking.booking.createdAt).toLocaleDateString("en-US")
: "No info"
}</li>
<li><b>Account created:</b>&nbsp;${new Date(user.createdDate).toLocaleDateString("en-US")}</li>
</ul>
`,
});
} catch (error) {
console.error("Error processing HelpScout request:", error);
return NextResponse.json({ message: "Internal server error" }, { status: 500 });
}
}
+117
View File
@@ -0,0 +1,117 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import type Stripe from "stripe";
import { z } from "zod";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { _MembershipModel as Membership, _TeamModel as Team } from "@calcom/prisma/zod";
const querySchema = z.object({
session_id: z.string().min(1),
});
const checkoutSessionMetadataSchema = z.object({
pendingPaymentTeamId: z.string().transform(Number),
ownerId: z.string().transform(Number),
});
type CheckoutSessionMetadata = z.infer<typeof checkoutSessionMetadataSchema>;
export const schemaTeamReadPublic = Team.omit({});
export const schemaMembershipPublic = Membership.merge(z.object({ team: Team }).partial());
async function handler(request: NextRequest) {
try {
const { session_id } = querySchema.parse(Object.fromEntries(request.nextUrl.searchParams));
const checkoutSession = await getCheckoutSession(session_id);
validateCheckoutSession(checkoutSession);
const checkoutSessionSubscription = getCheckoutSessionSubscription(checkoutSession);
const checkoutSessionMetadata = getCheckoutSessionMetadata(checkoutSession);
const finalizedTeam = await prisma.team.update({
where: { id: checkoutSessionMetadata.pendingPaymentTeamId },
data: {
pendingPayment: false,
members: {
create: {
userId: checkoutSessionMetadata.ownerId as number,
role: MembershipRole.OWNER,
accepted: true,
},
},
metadata: {
paymentId: checkoutSession.id,
subscriptionId: checkoutSessionSubscription.id || null,
subscriptionItemId: checkoutSessionSubscription.items.data[0].id || null,
},
},
include: { members: true },
});
const response = {
message: `Team created successfully. We also made user with ID=${checkoutSessionMetadata.ownerId} the owner of this team.`,
team: schemaTeamReadPublic.parse(finalizedTeam),
owner: schemaMembershipPublic.parse(finalizedTeam.members[0]),
};
return NextResponse.json(response);
} catch (error) {
console.error("Error creating team:", error);
if (error instanceof HttpError) {
return NextResponse.json({ message: error.message }, { status: error.statusCode });
}
return NextResponse.json(
{ message: error instanceof Error ? error.message : "An unexpected error occurred" },
{ status: 500 }
);
}
}
async function getCheckoutSession(sessionId: string) {
const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ["subscription"],
});
if (!checkoutSession) throw new HttpError({ statusCode: 404, message: "Checkout session not found" });
return checkoutSession;
}
function validateCheckoutSession(checkoutSession: Stripe.Response<Stripe.Checkout.Session>) {
if (checkoutSession.payment_status !== "paid")
throw new HttpError({ statusCode: 402, message: "Payment required" });
}
function getCheckoutSessionSubscription(checkoutSession: Stripe.Response<Stripe.Checkout.Session>) {
if (!checkoutSession.subscription) {
throw new HttpError({
statusCode: 400,
message: "Can't publish team/org without subscription",
});
}
return checkoutSession.subscription as Stripe.Subscription;
}
function getCheckoutSessionMetadata(
checkoutSession: Stripe.Response<Stripe.Checkout.Session>
): CheckoutSessionMetadata {
const parseCheckoutSessionMetadata = checkoutSessionMetadataSchema.safeParse(checkoutSession.metadata);
if (!parseCheckoutSessionMetadata.success) {
throw new HttpError({
statusCode: 400,
message: `Incorrect metadata in checkout session. Error: ${parseCheckoutSessionMetadata.error}`,
});
}
return parseCheckoutSessionMetadata.data;
}
export const GET = defaultResponderForAppDir(handler);
@@ -0,0 +1,46 @@
import { OAuth2Client } from "googleapis-common";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug";
import { WEBAPP_URL } from "@calcom/lib/constants";
const scopes = [
"https://www.googleapis.com/auth/admin.directory.user.readonly",
"https://www.googleapis.com/auth/admin.directory.customer.readonly",
];
export async function GET(request: NextRequest) {
try {
// Get appKeys from google-calendar
const { client_id, client_secret } = await getAppKeysFromSlug("google-calendar");
if (!client_id || typeof client_id !== "string")
return NextResponse.json({ message: "Google client_id missing." }, { status: 400 });
if (!client_secret || typeof client_secret !== "string")
return NextResponse.json({ message: "Google client_secret missing." }, { status: 400 });
// Get teamId from query params
const teamId = request.nextUrl.searchParams.get("teamId");
// use different callback to normal calendar connection
const redirect_uri = `${WEBAPP_URL}/api/teams/googleworkspace/callback`;
const oAuth2Client = new OAuth2Client(client_id, client_secret, redirect_uri);
const authUrl = oAuth2Client.generateAuthUrl({
access_type: "offline",
scope: scopes,
prompt: "consent",
state: JSON.stringify({ teamId }),
});
return NextResponse.json({ url: authUrl });
} catch (error) {
console.error("Error generating Google Workspace auth URL:", error);
return NextResponse.json(
{ message: error instanceof Error ? error.message : "An unexpected error occurred" },
{ status: 500 }
);
}
}
@@ -0,0 +1,86 @@
import { OAuth2Client } from "googleapis-common";
import { cookies, headers } from "next/headers";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { z } from "zod";
import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug";
import { throwIfNotHaveAdminAccessToTeam } from "@calcom/app-store/_utils/throwIfNotHaveAdminAccessToTeam";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import prisma from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
const stateSchema = z.object({
teamId: z.string(),
});
export async function GET(request: NextRequest) {
try {
const headersList = headers();
const cookiesList = cookies();
const legacyReq = buildLegacyRequest(headersList, cookiesList);
const session = await getServerSession({ req: legacyReq });
if (!session?.user?.id) {
return NextResponse.json({ message: "You must be logged in to do this" }, { status: 401 });
}
const code = request.nextUrl.searchParams.get("code");
const state = request.nextUrl.searchParams.get("state");
if (!state) {
return NextResponse.json({ message: "No state provided" }, { status: 400 });
}
const parsedState = stateSchema.parse(JSON.parse(state));
const { teamId } = parsedState;
await throwIfNotHaveAdminAccessToTeam({
teamId: Number(teamId) ?? null,
userId: session.user.id,
});
if (!code || typeof code !== "string") {
return NextResponse.json({ message: "`code` must be a string" }, { status: 400 });
}
const { client_id, client_secret } = await getAppKeysFromSlug("google-calendar");
if (!client_id || typeof client_id !== "string")
return NextResponse.json({ message: "Google client_id missing." }, { status: 400 });
if (!client_secret || typeof client_secret !== "string")
return NextResponse.json({ message: "Google client_secret missing." }, { status: 400 });
const redirect_uri = `${WEBAPP_URL}/api/teams/googleworkspace/callback`;
const oAuth2Client = new OAuth2Client(client_id, client_secret, redirect_uri);
const credentials = await oAuth2Client.getToken(code);
await prisma.credential.create({
data: {
type: "google_workspace_directory",
key: credentials.res?.data,
userId: session.user.id,
},
});
let redirectUrl = `${WEBAPP_URL}/settings`;
if (teamId) {
redirectUrl = `${WEBAPP_URL}/settings/teams/${teamId}/members?inviteModal=true&bulk=true`;
}
const safeRedirectUrl = getSafeRedirectUrl(redirectUrl) ?? `${WEBAPP_URL}/teams`;
return NextResponse.redirect(safeRedirectUrl);
} catch (error) {
console.error("Error in Google Workspace callback:", error);
// Redirect to teams page on error
return NextResponse.redirect(`${WEBAPP_URL}/teams`);
}
}
+33
View File
@@ -0,0 +1,33 @@
import { cookies, headers } from "next/headers";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { z } from "zod";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { checkUsername } from "@calcom/lib/server/checkUsername";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
const bodySchema = z.object({
username: z.string(),
orgSlug: z.string().optional(),
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { username, orgSlug } = bodySchema.parse(body);
const legacyReq = buildLegacyRequest(headers(), cookies());
// Get current org domain from request headers
const { currentOrgDomain } = orgDomainConfig(legacyReq);
const result = await checkUsername(username, currentOrgDomain || orgSlug);
return NextResponse.json(result);
} catch (error) {
console.error(error);
return NextResponse.json({ error: "Failed to check username availability" }, { status: 400 });
}
}
@@ -0,0 +1,149 @@
import { headers, cookies } from "next/headers";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { z } from "zod";
import prisma from "@calcom/prisma";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { createContext } from "@calcom/trpc/server/createContext";
import { bookingsRouter } from "@calcom/trpc/server/routers/viewer/bookings/_router";
import { createCallerFactory } from "@calcom/trpc/server/trpc";
import type { UserProfile } from "@calcom/types/UserProfile";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { TRPCError } from "@trpc/server";
enum DirectAction {
ACCEPT = "accept",
REJECT = "reject",
}
const querySchema = z.object({
action: z.nativeEnum(DirectAction),
token: z.string(),
bookingUid: z.string(),
userId: z.string(),
});
export async function GET(request: NextRequest) {
const url = new URL(request.url);
const queryParams = Object.fromEntries(request.nextUrl.searchParams.entries());
try {
const { action, token, bookingUid, userId } = querySchema.parse(queryParams);
if (action === DirectAction.REJECT) {
// Rejections should use POST method
return NextResponse.redirect(
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Rejection requires POST method")}`
);
}
return await handleBookingAction(action, token, bookingUid, userId, undefined, request);
} catch (error) {
const bookingUid = queryParams.bookingUid || "";
return NextResponse.redirect(
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`
);
}
}
export async function POST(request: NextRequest) {
const url = new URL(request.url);
const queryParams = Object.fromEntries(request.nextUrl.searchParams.entries());
try {
const { action, token, bookingUid, userId } = querySchema.parse(queryParams);
const body = await request.json().catch(() => ({}));
const { reason } = z.object({ reason: z.string().optional() }).parse(body || {});
return await handleBookingAction(action, token, bookingUid, userId, reason, request);
} catch (error) {
const bookingUid = queryParams.bookingUid || "";
return NextResponse.redirect(
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`
);
}
}
async function handleBookingAction(
action: DirectAction,
token: string,
bookingUid: string,
userId: string,
reason?: string,
request?: NextRequest
) {
const booking = await prisma.booking.findUnique({
where: { oneTimePassword: token },
});
if (!booking) {
return NextResponse.redirect(
`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`
);
}
const user = await prisma.user.findUniqueOrThrow({
where: { id: Number(userId) },
});
/** We shape the session as required by tRPC router */
async function sessionGetter() {
return {
user: {
id: Number(userId),
username: "" /* Not used in this context */,
role: UserPermissionRole.USER,
/* Not used in this context */
profile: {
id: null,
organizationId: null,
organization: null,
username: "",
upId: "",
} satisfies UserProfile,
},
upId: "",
hasValidLicense: true,
expires: "" /* Not used in this context */,
};
}
try {
/** @see https://trpc.io/docs/server-side-calls */
const createCaller = createCallerFactory(bookingsRouter);
// Use buildLegacyRequest to create a request object compatible with Pages Router
const legacyReq = request ? buildLegacyRequest(headers(), cookies()) : ({} as any);
const res = {} as any;
const ctx = await createContext({ req: legacyReq, res }, sessionGetter);
const caller = createCaller({
...ctx,
req: legacyReq,
res,
user: { ...user, locale: user?.locale ?? "en" },
});
await caller.confirm({
bookingId: booking.id,
recurringEventId: booking.recurringEventId || undefined,
confirmed: action === DirectAction.ACCEPT,
/** Ignored reason input unless we're rejecting */
reason: action === DirectAction.REJECT ? reason : undefined,
});
} catch (e) {
let message = "Error confirming booking";
if (e instanceof TRPCError) message = (e as TRPCError).message;
return NextResponse.redirect(`/booking/${booking.uid}?error=${encodeURIComponent(message)}`);
}
await prisma.booking.update({
where: { id: booking.id },
data: { oneTimePassword: null },
});
return NextResponse.redirect(`/booking/${booking.uid}`);
}
+6
View File
@@ -0,0 +1,6 @@
import { NextResponse } from "next/server";
import * as pjson from "package.json";
export async function GET() {
return NextResponse.json({ version: pjson.version });
}
@@ -0,0 +1,103 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import z from "zod";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { CREDENTIAL_SYNC_SECRET, CREDENTIAL_SYNC_SECRET_HEADER_NAME } from "@calcom/lib/constants";
import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import prisma from "@calcom/prisma";
const appCredentialWebhookRequestBodySchema = z.object({
// UserId of the cal.com user
userId: z.number().int(),
appSlug: z.string(),
// Keys should be AES256 encrypted with the CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY
keys: z.string(),
});
export async function POST(request: NextRequest) {
if (!APP_CREDENTIAL_SHARING_ENABLED) {
return NextResponse.json({ message: "Credential sharing is not enabled" }, { status: 403 });
}
const secretHeader = request.headers.get(CREDENTIAL_SYNC_SECRET_HEADER_NAME);
if (secretHeader !== CREDENTIAL_SYNC_SECRET) {
return NextResponse.json({ message: "Invalid credential sync secret" }, { status: 403 });
}
try {
const body = await request.json();
const reqBodyParsed = appCredentialWebhookRequestBodySchema.safeParse(body);
if (!reqBodyParsed.success) {
return NextResponse.json({ error: reqBodyParsed.error.issues }, { status: 400 });
}
const reqBody = reqBodyParsed.data;
const user = await prisma.user.findUnique({ where: { id: reqBody.userId } });
if (!user) {
return NextResponse.json({ message: "User not found" }, { status: 404 });
}
const app = await prisma.app.findUnique({
where: { slug: reqBody.appSlug },
select: { dirName: true },
});
if (!app) {
return NextResponse.json({ message: "App not found" }, { status: 404 });
}
const appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata];
if (!appMetadata) {
return NextResponse.json(
{ message: "App not found. Ensure that you have the correct app slug" },
{ status: 404 }
);
}
const keys = JSON.parse(
symmetricDecrypt(reqBody.keys, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "")
);
// INFO: Can't use prisma upsert as we don't know the id of the credential
const appCredential = await prisma.credential.findFirst({
where: {
userId: reqBody.userId,
appId: appMetadata.slug,
},
select: {
id: true,
},
});
if (appCredential) {
await prisma.credential.update({
where: {
id: appCredential.id,
},
data: {
key: keys,
},
});
return NextResponse.json({ message: `Credentials updated for userId: ${reqBody.userId}` });
} else {
await prisma.credential.create({
data: {
key: keys,
userId: reqBody.userId,
appId: appMetadata.slug,
type: appMetadata.type,
},
});
return NextResponse.json({ message: `Credentials created for userId: ${reqBody.userId}` });
}
} catch (error) {
console.error("Error processing app credential webhook:", error);
return NextResponse.json({ message: "Internal server error" }, { status: 500 });
}
}