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:
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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
@@ -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> ${user.username}</li>
|
||||
<li><b>Last booking:</b> ${
|
||||
lastBooking && lastBooking.booking
|
||||
? new Date(lastBooking.booking.createdAt).toLocaleDateString("en-US")
|
||||
: "No info"
|
||||
}</li>
|
||||
<li><b>Account created:</b> ${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 });
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user