Merge remote-tracking branch 'upstream/main' into improve-multilingualism

This commit is contained in:
maxi 2022-11-14 09:05:06 +01:00
commit ac373a6f94
76 changed files with 1141 additions and 1771 deletions

View File

@ -1,7 +1,7 @@
name: Unit tests
on:
push:
branches: [fixes/e2e-consolidation] # TODO: Remove this after merged in main
branches: [ feat/routing-forms/reporting ] # TODO: Remove this after merged in main
pull_request_target: # So we can test on forks
branches:
- main
@ -21,7 +21,8 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha }} # So we can test on forks
fetch-depth: 2
- run: echo 'NODE_OPTIONS="--max_old_space_size=4096"' >> $GITHUB_ENV
# Should be an 8GB machine as per https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners
- run: echo 'NODE_OPTIONS="--max_old_space_size=6144"' >> $GITHUB_ENV
- name: Use Node 16.x
uses: actions/setup-node@v3
with:

View File

@ -1,4 +1,4 @@
{
"projectId": "cl4u26bwz7962859ply7ibuo43t",
"databaseUrl": "postgresql://postgres@localhost:5450/calendso"
"targetDatabaseUrl": "postgresql://postgres@localhost:5450/calendso"
}

View File

@ -1,3 +1,3 @@
{
"version": "0.3.0"
"version": "0.22.3"
}

View File

@ -598,60 +598,6 @@ paths:
summary: Gets and stores the OAuth token
tags:
- Integrations
/api/user/profile:
patch:
description: Updates a user's profile.
summary: Updates a user's profile
tags:
- User
requestBody:
content:
application/json:
schema:
type: object
properties:
name:
type: string
description:
type: string
avatar:
type: string
timeZone:
type: string
weekStart:
type: string
hideBranding:
type: string
theme:
type: string
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
message:
type: string
"401":
description: Unauthorized
content:
application/json:
schema:
type: object
properties:
message:
type: string
"500":
description: Internal Server Error
content:
application/json:
schema:
type: object
properties:
message:
type: string
/api/me:
get:
description: Gets current user's profile.
@ -686,71 +632,6 @@ paths:
properties:
message:
type: string
/api/user/membership:
get:
description: Get a list of the teams the user has joined.
summary: Get the teams a user is joined to
tags:
- User
requestBody:
content:
application/json:
schema:
type: object
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
message:
type: string
"401":
description: Unauthorized
content:
application/json:
schema:
type: object
properties:
message:
type: string
"500":
description: Internal Server Error
content:
application/json:
schema:
type: object
properties:
message:
type: string
patch:
description: Accept team invitation
summary: Accept team invitation.
tags:
- User
requestBody:
content:
application/json:
schema:
type: object
properties:
teamId:
type: string
delete:
description: Leave team or decline membership invite of current user
summary: Leave team or decline team invite.
tags:
- User
requestBody:
content:
application/json:
schema:
type: object
properties:
teamId:
type: string
"/api/{team}":
delete:
description: Deletes a team

View File

@ -43,9 +43,13 @@ function BookingListItem(booking: BookingItemProps) {
const [rejectionReason, setRejectionReason] = useState<string>("");
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
const mutation = trpc.viewer.bookings.confirm.useMutation({
onSuccess: () => {
setRejectionDialogIsOpen(false);
showToast(t("booking_confirmation_success"), "success");
onSuccess: (data) => {
if (data.status === BookingStatus.REJECTED) {
setRejectionDialogIsOpen(false);
showToast(t("booking_rejection_success"), "success");
} else {
showToast(t("booking_confirmation_success"), "success");
}
utils.viewer.bookings.invalidate();
},
onError: () => {

View File

@ -1,69 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { ButtonBaseProps } from "@calcom/ui/Button";
import { Dialog } from "@calcom/ui/Dialog";
import showToast from "@calcom/ui/v2/core/notifications";
import DeleteStripeDialogContent from "@components/dialog/DeleteStripeDialogContent";
export default function DisconnectStripeIntegration(props: {
/** Integration credential id */
id: number;
render: (renderProps: ButtonBaseProps) => JSX.Element;
onOpenChange: (isOpen: boolean) => unknown | Promise<unknown>;
}) {
const [modalOpen, setModalOpen] = useState(false);
const mutation = useMutation(
async (action: string) => {
const res = await fetch("/api/integrations", {
method: "DELETE",
body: JSON.stringify({ id: props.id, action }),
headers: {
"Content-Type": "application/json",
},
});
if (!res.ok) {
throw new Error("Something went wrong");
}
return res.json();
},
{
async onSettled() {
await props.onOpenChange(modalOpen);
},
onSuccess(data) {
showToast(data.message, "success");
setModalOpen(false);
},
}
);
return (
<>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DeleteStripeDialogContent
variety="warning"
title="Disconnect Stripe Integration"
cancelAllBookingsBtnText="Cancel all bookings"
removeBtnText="Remove payment"
cancelBtnText="Cancel"
onConfirm={() => {
mutation.mutate("cancel");
}}
onRemove={() => {
mutation.mutate("remove");
}}>
If you have unpaid and unconfirmed bookings, you must choose to cancel them or remove the required
payment field.
</DeleteStripeDialogContent>
</Dialog>
{props.render({
onClick() {
setModalOpen(true);
},
disabled: modalOpen,
loading: mutation.isLoading,
})}
</>
);
}

View File

@ -17,9 +17,9 @@ const createRecurringBooking = async (data: ExtendedBookingCreateBody[]) => {
let appsStatus: AppsStatus[] | undefined = undefined;
// Reversing to accumulate results for noEmail instances first, to then lastly, create the
// emailed booking taking into account accumulated results to send app status accurately
for (let key = 0; key < data.length; key++) {
for (let key = data.length - 1; key >= 0; key--) {
const booking = data[key];
if (key === data.length - 1) {
if (key === 0) {
const calcAppsStatus: { [key: string]: AppsStatus } = createdBookings
.flatMap((book) => (book.appsStatus !== undefined ? book.appsStatus : []))
.reduce((prev, curr) => {
@ -39,6 +39,7 @@ const createRecurringBooking = async (data: ExtendedBookingCreateBody[]) => {
appsStatus,
allRecurringDates,
currentRecurringIndex: key,
noEmail: key !== 0,
});
createdBookings.push(response);
}

View File

@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "2.1.4",
"version": "2.1.5",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@ -19,7 +19,7 @@
"check-changed-files": "ts-node scripts/ts-check-changed-files.ts"
},
"engines": {
"node": ">=14.x <17",
"node": ">=16.x",
"yarn": ">=1.19.0 < 2.0.0"
},
"dependencies": {

View File

@ -0,0 +1,7 @@
# Before adding new endpoints here
We're deprecating adding new endpoints in favor of creating tRPC procedures.
You can learn about [tRPC procedures in the docs](https://trpc.io/docs/v10/procedures).
You can see our current tRPC procedures in this file `packages/trpc/server/routers/_app.ts`

View File

@ -1,85 +0,0 @@
import { PrismaClient } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
const prisma = new PrismaClient();
type CalendlyEventType = {
name: string;
slug: string;
duration: number;
description_plain: string;
secret: boolean;
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req });
const authenticatedUser = await prisma.user.findFirstOrThrow({
where: {
id: session?.user.id,
},
select: {
id: true,
},
});
if (req.method == "POST") {
const userResult = await fetch("https://api.calendly.com/users/me", {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + req.body.token,
},
});
if (userResult.status == 200) {
const userData = await userResult.json();
await prisma.user.update({
where: {
id: authenticatedUser.id,
},
data: {
name: userData.resource.name,
},
});
const eventTypesResult = await fetch(
"https://api.calendly.com/event_types?user=" + userData.resource.uri,
{
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + req.body.token,
},
}
);
const eventTypesData = await eventTypesResult.json();
eventTypesData.collection.forEach(async (eventType: CalendlyEventType) => {
await prisma.eventType.create({
data: {
title: eventType.name,
slug: eventType.slug,
length: eventType.duration,
description: eventType.description_plain,
hidden: eventType.secret,
users: {
connect: {
id: authenticatedUser.id,
},
},
userId: authenticatedUser.id,
},
});
});
res.status(201).end();
} else {
res.status(500).end();
}
} else {
res.status(405).end();
}
}

View File

@ -1,85 +0,0 @@
import { PrismaClient } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
const prisma = new PrismaClient();
type SavvyCalEventType = {
name: string;
slug: string;
durations: [number];
description: string;
state: "active";
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req });
const authenticatedUser = await prisma.user.findFirstOrThrow({
where: {
id: session?.user.id,
},
select: {
id: true,
},
});
if (req.method === "POST") {
const userResult = await fetch("https://api.savvycal.com/v1/me", {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + req.body.token,
},
});
if (userResult.status === 200) {
const userData = await userResult.json();
await prisma.user.update({
where: {
id: authenticatedUser.id,
},
data: {
name: userData.display_name,
timeZone: userData.time_zone,
weekStart: userData.first_day_of_week === 0 ? "Sunday" : "Monday",
avatar: userData.avatar_url,
},
});
const eventTypesResult = await fetch("https://api.savvycal.com/v1/links?limit=100", {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + req.body.token,
},
});
const eventTypesData = await eventTypesResult.json();
eventTypesData.entries.forEach(async (eventType: SavvyCalEventType) => {
await prisma.eventType.create({
data: {
title: eventType.name,
slug: eventType.slug,
length: eventType.durations[0],
description: eventType.description.replace(/<[^>]*>?/gm, ""),
hidden: eventType.state === "active",
users: {
connect: {
id: authenticatedUser.id,
},
},
userId: authenticatedUser.id,
},
});
});
res.status(201).end();
} else {
res.status(500).end();
}
} else {
res.status(405).end();
}
}

View File

@ -1,154 +0,0 @@
import { BookingStatus, Prisma } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import { getSession } from "@lib/auth";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!["GET", "DELETE"].includes(req.method || "")) {
return res.status(405).end();
}
// Check that user is authenticated
const session = await getSession({ req });
const userId = session?.user?.id;
if (!userId) {
res.status(401).json({ message: "You must be logged in to do this" });
return;
}
if (req.method === "GET") {
const credentials = await prisma.credential.findMany({
where: {
userId,
},
select: {
type: true,
},
});
res.status(200).json(credentials);
}
if (req.method == "DELETE") {
const id = req.body.id;
const data: Prisma.UserUpdateInput = {
credentials: {
delete: {
id,
},
},
};
const integration = await prisma.credential.findUnique({
where: {
id,
},
});
/* If the user deletes a zapier integration, we delete all his api keys as well. */
if (integration?.appId === "zapier") {
data.apiKeys = {
deleteMany: {
userId,
appId: "zapier",
},
};
/* We also delete all user's zapier wehbooks */
data.webhooks = {
deleteMany: {
userId,
appId: "zapier",
},
};
}
await prisma.user.update({
where: {
id: userId,
},
data,
});
if (req.body?.action === "cancel" || req.body?.action === "remove") {
try {
const bookingIdsWithPayments = await prisma.booking
.findMany({
where: {
userId: session?.user?.id,
paid: false,
NOT: {
payment: {
every: {
booking: null,
},
},
},
},
select: {
id: true,
},
})
.then((bookings) => bookings.map((booking) => booking.id));
const deletePayments = prisma.payment.deleteMany({
where: {
bookingId: {
in: bookingIdsWithPayments,
},
success: false,
},
});
const updateBookings = prisma.booking.updateMany({
where: {
id: {
in: bookingIdsWithPayments,
},
},
data: {
status: BookingStatus.CANCELLED,
rejectionReason: "Payment provider got removed",
},
});
const bookingReferences = await prisma.booking
.findMany({
where: {
status: BookingStatus.ACCEPTED,
},
select: {
id: true,
},
})
.then((bookings) => bookings.map((booking) => booking.id));
const deleteBookingReferences = prisma.bookingReference.deleteMany({
where: {
bookingId: {
in: bookingReferences,
},
},
});
if (req.body?.action === "cancel") {
await prisma.$transaction([deletePayments, updateBookings, deleteBookingReferences]);
} else {
const updateBookings = prisma.booking.updateMany({
where: {
id: {
in: bookingIdsWithPayments,
},
},
data: {
paid: true,
},
});
await prisma.$transaction([deletePayments, updateBookings]);
}
} catch (e) {
console.error(e);
res.status(500).json({ message: "Integration could not be deleted" });
}
}
res.status(200).json({ message: "Integration deleted successfully" });
}
}

View File

@ -65,8 +65,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
await handler(req, res);
} else {
await defaultIntegrationAddHandler({ user: req.session?.user, ...handler });
redirectUrl = handler.redirectUrl || getInstalledAppPath(handler);
res.json({ url: redirectUrl });
redirectUrl = handler.redirect?.url || getInstalledAppPath(handler);
res.json({ url: redirectUrl, newTab: handler.redirect?.newTab });
}
return res.status(200);
} catch (error) {

View File

@ -1,48 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "next-auth/react";
import { defaultHandler } from "@calcom/lib/server";
import { checkUsername } from "@calcom/lib/server/checkUsername";
import prisma from "@calcom/prisma";
import { userMetadata as zodUserMetadata } from "@calcom/prisma/zod-utils";
export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
const { intentUsername } = req.body;
// Check that user is authenticated
try {
const session = await getSession({ req });
const userId = session?.user?.id;
const user = await prisma.user.findFirstOrThrow({
select: {
id: true,
metadata: true,
},
where: { id: userId },
});
const checkPremiumUsernameResult = await checkUsername(intentUsername);
if (userId && user) {
const userMetadata = zodUserMetadata.parse(user.metadata);
await prisma.user.update({
where: {
id: userId,
},
data: {
metadata: {
...userMetadata,
intentUsername,
isIntentPremium: checkPremiumUsernameResult.premium,
},
},
});
}
} catch (error) {
res.status(501).send({ message: "intent-username.save.error" });
}
res.end();
}
export default defaultHandler({
GET: Promise.resolve({ default: getHandler }),
});

View File

@ -1,45 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import { getSession } from "@lib/auth";
import { defaultAvatarSrc } from "@lib/profile";
/**
* @deprecated Use TRCP's viewer.me query
*/
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req });
if (!session) {
res.status(401).json({ message: "Not authenticated" });
return;
}
const user = await prisma.user.findUniqueOrThrow({
where: {
id: session.user.id,
},
select: {
id: true,
username: true,
name: true,
email: true,
bio: true,
timeZone: true,
weekStart: true,
startTime: true,
endTime: true,
bufferTime: true,
theme: true,
createdDate: true,
hideBranding: true,
avatar: true,
},
});
user.avatar = user.avatar || defaultAvatarSrc({ email: user.email });
res.status(200).json({
user,
});
}

View File

@ -1,55 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import { getSession } from "@lib/auth";
import { getAvailabilityFromSchedule } from "@lib/availability";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req });
const userId = session?.user?.id;
if (!userId) {
res.status(401).json({ message: "Not authenticated" });
return;
}
if (!req.body.schedule || req.body.schedule.length !== 7) {
return res.status(400).json({ message: "Bad Request." });
}
const availability = getAvailabilityFromSchedule(req.body.schedule);
if (req.method === "POST") {
try {
await prisma.user.update({
where: {
id: userId,
},
data: {
availability: {
/* We delete user availabilty */
deleteMany: {
userId: {
equals: userId,
},
},
/* So we can replace it */
createMany: {
data: availability.map((schedule) => ({
days: schedule.days,
startTime: schedule.startTime,
endTime: schedule.endTime,
})),
},
},
},
});
return res.status(200).json({
message: "created",
});
} catch (error) {
console.error(error);
return res.status(500).json({ message: "Unable to create schedule." });
}
}
}

View File

@ -1,60 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
import { closeComDeleteTeam } from "@calcom/lib/sync/SyncServiceManager";
import prisma from "@calcom/prisma";
import { getSession } from "@lib/auth";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req: req });
if (!session) {
return res.status(401).json({ message: "Not authenticated" });
}
if (!session.user?.id) {
console.log("Received session token without a user id.");
return res.status(500).json({ message: "Something went wrong." });
}
if (!req.query.team) {
console.log("Missing team query param.");
return res.status(500).json({ message: "Something went wrong." });
}
const teamId = parseInt(req.query.team as string);
// GET /api/teams/{team}
if (req.method === "GET") {
const team = await getTeamWithMembers(teamId);
return res.status(200).json({ team });
}
// DELETE /api/teams/{team}
if (req.method === "DELETE") {
const membership = await prisma.membership.findFirst({
where: {
userId: session.user.id,
teamId,
},
});
if (!membership || membership.role !== "OWNER") {
console.log(`User ${session.user.id} tried deleting an organization they don't own.`);
return res.status(403).json({ message: "Forbidden." });
}
await prisma.membership.delete({
where: {
userId_teamId: { userId: session.user.id, teamId },
},
});
const deletedTeam = await prisma.team.delete({
where: {
id: teamId,
},
});
// Sync Services: Close.com
closeComDeleteTeam(deletedTeam);
return res.status(204).send(null);
}
}

View File

@ -1,132 +0,0 @@
import { MembershipRole } from "@prisma/client";
import { randomBytes } from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import { sendTeamInviteEmail } from "@calcom/emails";
import prisma from "@calcom/prisma";
import { getSession } from "@lib/auth";
import { BASE_URL } from "@lib/config/constants";
import slugify from "@lib/slugify";
import { getTranslation } from "@server/lib/i18n";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const t = await getTranslation(req.body.language ?? "en", "common");
if (req.method !== "POST") {
return res.status(400).json({ message: "Bad request" });
}
const session = await getSession({ req });
if (!session) {
return res.status(401).json({ message: "Not authenticated" });
}
const team = await prisma.team.findFirst({
where: {
id: parseInt(req.query.team as string),
},
});
if (!team) {
return res.status(404).json({ message: "Invalid team" });
}
const reqBody = req.body as {
usernameOrEmail: string;
role: MembershipRole;
sendEmailInvitation: boolean;
};
const { role, sendEmailInvitation } = reqBody;
// liberal email match
const isEmail = (str: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str);
const usernameOrEmail = isEmail(reqBody.usernameOrEmail)
? reqBody.usernameOrEmail.toLowerCase()
: slugify(reqBody.usernameOrEmail);
const invitee = await prisma.user.findFirst({
where: {
OR: [{ username: usernameOrEmail }, { email: usernameOrEmail }],
},
});
if (!invitee) {
const email = isEmail(usernameOrEmail) ? usernameOrEmail : undefined;
if (!email) {
return res.status(400).json({
message: `Invite failed because there is no corresponding user for ${usernameOrEmail}`,
});
}
await prisma.user.create({
data: {
email,
teams: {
create: {
team: {
connect: {
id: parseInt(req.query.team as string),
},
},
role,
},
},
},
});
const token: string = randomBytes(32).toString("hex");
await prisma.verificationToken.create({
data: {
identifier: usernameOrEmail,
token,
expires: new Date(new Date().setHours(168)), // +1 week
},
});
if (session?.user?.name && team?.name) {
await sendTeamInviteEmail({
language: t,
from: session.user.name,
to: usernameOrEmail,
teamName: team.name,
joinLink: `${BASE_URL}/auth/signup?token=${token}&callbackUrl=/settings/teams}`,
});
}
return res.status(201).json({});
}
// create provisional membership
try {
await prisma.membership.create({
data: {
teamId: parseInt(req.query.team as string),
userId: invitee.id,
role,
},
});
} catch (err: any) {
if (err.code === "P2002") {
// unique constraint violation
return res.status(409).json({
message: "This user is a member of this team / has a pending invitation.",
});
} else {
throw err; // rethrow
}
}
// inform user of membership by email
if (sendEmailInvitation && session?.user?.name && team?.name) {
await sendTeamInviteEmail({
language: t,
from: session.user.name,
to: usernameOrEmail,
teamName: team.name,
joinLink: BASE_URL + "/settings/teams",
});
}
res.status(201).json({});
}

View File

@ -1,86 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { closeComDeleteTeamMembership } from "@calcom/lib/sync/SyncServiceManager";
import prisma from "@calcom/prisma";
import { getSession } from "@lib/auth";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req });
if (!session) {
res.status(401).json({ message: "Not authenticated" });
return;
}
const isTeamOwner = !!(await prisma.membership.findFirst({
where: {
userId: session.user?.id,
teamId: parseInt(req.query.team as string),
role: "OWNER",
},
}));
if (!isTeamOwner) {
res.status(403).json({ message: "You are not authorized to manage this team" });
return;
}
// List members
if (req.method === "GET") {
const memberships = await prisma.membership.findMany({
where: {
teamId: parseInt(req.query.team as string),
},
});
let members = await prisma.user.findMany({
where: {
id: {
in: memberships.map((membership) => membership.userId),
},
},
select: {
id: true,
username: true,
name: true,
email: true,
bio: true,
avatar: true,
timeZone: true,
},
});
members = members.map((member) => {
const membership = memberships.find((membership) => member.id === membership.userId);
return {
...member,
role: membership?.accepted ? membership?.role : "INVITEE",
};
});
return res.status(200).json({ members: members });
}
// Cancel a membership (invite)
if (req.method === "DELETE") {
const user = await prisma.user.findFirst({
where: {
id: req.body.userId,
},
});
await prisma.membership.delete({
where: {
userId_teamId: { userId: req.body.userId, teamId: parseInt(req.query.team as string) },
},
});
// Sync Services: Close.com
closeComDeleteTeamMembership(user);
return res.status(204).send(null);
}
// Promote or demote a member of the team
res.status(200).json({});
}

View File

@ -1,71 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import { getSession } from "@lib/auth";
// @deprecated - USE TRPC
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req });
if (!session?.user?.id) {
return res.status(401).json({ message: "Not authenticated" });
}
const isTeamOwner = !!(await prisma.membership.findFirst({
where: {
userId: session.user.id,
teamId: parseInt(req.query.team as string),
role: "OWNER",
},
}));
if (!isTeamOwner) {
res.status(403).json({ message: "You are not authorized to manage this team" });
return;
}
// PATCH /api/teams/profile/{team}
if (req.method === "PATCH") {
const team = await prisma.team.findFirst({
where: {
id: parseInt(req.query.team as string),
},
});
if (!team) {
return res.status(404).json({ message: "Invalid team" });
}
const username = req.body.username;
const userConflict = await prisma.team.findMany({
where: {
slug: username,
},
});
const teamId = Number(req.query.team);
if (userConflict.some((team) => team.id !== teamId)) {
return res.status(409).json({ message: "Team username already taken" });
}
const name = req.body.name;
const slug = req.body.username;
const bio = req.body.description;
const logo = req.body.logo;
const hideBranding = req.body.hideBranding;
await prisma.team.update({
where: {
id: team.id,
},
data: {
name,
slug,
logo,
bio,
hideBranding,
},
});
return res.status(200).json({ message: "Team updated successfully" });
}
}

View File

@ -1,54 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getStripeCustomerId } from "@calcom/app-store/stripepayment/lib/customer";
import prisma from "@calcom/prisma";
import { getSession } from "@lib/auth";
import { WEBSITE_URL } from "@lib/config/constants";
import { HttpError as HttpCode } from "@lib/core/http/error";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req });
if (!session?.user?.id) {
return res.status(401).json({ message: "Not authenticated" });
}
if (!["GET", "POST"].includes(req.method || "")) {
throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
}
const user = await prisma.user.findUniqueOrThrow({
where: {
id: session.user.id,
},
select: {
email: true,
metadata: true,
},
});
const stripeCustomerId = await getStripeCustomerId(user);
try {
const response = await fetch(`${WEBSITE_URL}/api/upgrade`, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
stripeCustomerId,
email: user.email,
fromApp: true,
}),
});
const data = await response.json();
if (!data.url) throw new HttpCode({ statusCode: 401, message: data.message });
res.redirect(303, data.url);
} catch (error) {
console.error(`error`, error);
res.redirect(303, req.headers.origin || "/");
}
}

View File

@ -1,86 +0,0 @@
import { pick } from "lodash";
import type { NextApiRequest, NextApiResponse } from "next";
import z from "zod";
import prisma from "@calcom/prisma";
import { getSession } from "@lib/auth";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req });
if (!session?.user.id) {
return res.status(401).json({ message: "Not authenticated" });
}
const querySchema = z.object({
id: z.string().transform((val) => parseInt(val)),
});
const parsedQuery = querySchema.safeParse(req.query);
const userId = parsedQuery.success ? parsedQuery.data.id : null;
if (!userId) {
return res.status(400).json({ message: "No user id provided" });
}
const authenticatedUser = await prisma.user.findFirstOrThrow({
where: {
id: session.user.id,
},
select: {
id: true,
},
});
if (userId !== authenticatedUser.id) {
return res.status(401).json({ message: "Unauthorized" });
}
if (req.method === "GET") {
return res.status(405).json({ message: "Method Not Allowed" });
}
if (req.method === "PATCH") {
const updatedUser = await prisma.user.update({
where: {
id: authenticatedUser.id,
},
data: {
...pick(req.body.data, [
"username",
"name",
"avatar",
"timeZone",
"timeFormat",
"weekStart",
"hideBranding",
"theme",
"completedOnboarding",
]),
bio: req.body.description ?? req.body.data?.bio,
},
select: {
id: true,
username: true,
name: true,
email: true,
emailVerified: true,
bio: true,
avatar: true,
timeZone: true,
timeFormat: true,
weekStart: true,
startTime: true,
endTime: true,
bufferTime: true,
hideBranding: true,
theme: true,
createdDate: true,
plan: true,
completedOnboarding: true,
},
});
return res.status(200).json({ message: "User Updated", data: updatedUser });
}
}

View File

@ -1,50 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { deleteStripeCustomer } from "@calcom/app-store/stripepayment/lib/customer";
import { deleteWebUser as syncServicesDeleteWebUser } from "@calcom/lib/sync/SyncServiceManager";
import prisma from "@calcom/prisma";
import { getSession } from "@lib/auth";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req });
if (!session?.user.id) {
return res.status(401).json({ message: "Not authenticated" });
}
if (req.method !== "DELETE") {
return res.status(405).json({ message: "Method Not Allowed" });
}
if (req.method === "DELETE") {
// Get user
const user = await prisma.user.findUniqueOrThrow({
where: {
id: session.user?.id,
},
select: {
id: true,
email: true,
metadata: true,
username: true,
createdDate: true,
name: true,
plan: true,
},
});
// Delete from stripe
await deleteStripeCustomer(user).catch(console.warn);
// Delete from Cal
await prisma.user.delete({
where: {
id: session?.user.id,
},
});
// Sync Services
syncServicesDeleteWebUser(user);
return res.status(204).end();
}
}

View File

@ -1,63 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import { getSession } from "@lib/auth";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req: req });
if (!session || !session.user?.id) {
return res.status(401).json({ message: "Not authenticated" });
}
if (req.method === "GET") {
const memberships = await prisma.membership.findMany({
where: {
userId: session.user.id,
},
});
const teams = await prisma.team.findMany({
where: {
id: {
in: memberships.map((membership) => membership.teamId),
},
},
});
return res.status(200).json({
membership: memberships.map((membership) => ({
role: membership.accepted ? membership.role : "INVITEE",
...teams.find((team) => team.id === membership.teamId),
})),
});
}
if (!req.body.teamId) {
return res.status(400).json({ message: "Bad request" });
}
// Leave team or decline membership invite of current user
if (req.method === "DELETE") {
await prisma.membership.delete({
where: {
userId_teamId: { userId: session.user.id, teamId: req.body.teamId },
},
});
return res.status(204).send(null);
}
// Accept team invitation
if (req.method === "PATCH") {
await prisma.membership.update({
where: {
userId_teamId: { userId: session.user.id, teamId: req.body.teamId },
},
data: {
accepted: true,
},
});
return res.status(204).send(null);
}
}

View File

@ -1,50 +0,0 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
import { pick } from "lodash";
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import { getSession } from "@lib/auth";
import { resizeBase64Image } from "@server/lib/resizeBase64Image";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req: req });
if (!session) {
res.status(401).json({ message: "Not authenticated" });
return;
}
try {
const avatar = req.body.avatar ? await resizeBase64Image(req.body.avatar) : undefined;
await prisma.user.update({
where: {
id: session.user.id,
},
data: {
...pick(req.body, [
"username",
"name",
"timeZone",
"weekStart",
"hideBranding",
"theme",
"completedOnboarding",
"locale",
]),
avatar,
bio: req.body.description,
},
});
} catch (e) {
if (e instanceof PrismaClientKnownRequestError) {
if (e.code === "P2002") {
return res.status(409).json({ message: "Username already taken" });
}
}
throw e;
}
return res.status(200).json({ message: "Profile updated successfully" });
}

View File

@ -1,28 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { ensureSession } from "@calcom/lib/auth";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
async function handler(req: NextApiRequest, res: NextApiResponse<Response>): Promise<void> {
// Only logged in users can opt-in/out
await ensureSession({ req });
// If has the cookie, Opt-out of V2
if ("calcom-v2-early-access" in req.cookies && req.cookies["calcom-v2-early-access"] === "1") {
res.setHeader("Set-Cookie", `calcom-v2-early-access=0; Max-Age=0; Path=/`);
} else {
/* Opt-int to V2 */
res.setHeader("Set-Cookie", "calcom-v2-early-access=1; Path=/");
}
let redirectUrl = "/";
// We take you back where you came from if possible
if (typeof req.headers["referer"] === "string") redirectUrl = req.headers["referer"];
res.redirect(redirectUrl);
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(handler) }),
});

View File

@ -52,16 +52,6 @@ const BillingView = () => {
<>
<Meta title={t("billing")} description={t("manage_billing_description")} />
<div className="space-y-6 text-sm sm:space-y-8">
{!isPro && (
<CtaRow title={t("billing_freeplan_title")} description={t("billing_freeplan_description")}>
<form target="_blank" method="POST" action="/api/upgrade">
<Button type="submit" EndIcon={Icon.FiExternalLink}>
{t("billing_freeplan_cta")}
</Button>
</form>
</CtaRow>
)}
<CtaRow
className={classNames(!isPro && "pointer-events-none opacity-30")}
title={t("billing_manage_details_title")}

View File

@ -155,8 +155,7 @@ const ProfileView = () => {
const {
reset,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
formState: { dirtyFields },
formState: { isSubmitting, isDirty },
} = formMethods;
useEffect(() => {
@ -197,6 +196,7 @@ const ProfileView = () => {
};
if (isLoading || !user) return <SkeletonLoader />;
const isDisabled = isSubmitting || !isDirty;
return (
<>
@ -261,7 +261,12 @@ const ProfileView = () => {
<TextField label={t("about")} hint={t("bio_hint")} {...formMethods.register("bio")} />
</div>
<Button color="primary" className="mt-8" type="submit" loading={mutation.isLoading}>
<Button
disabled={isDisabled}
color="primary"
className="mt-8"
type="submit"
loading={mutation.isLoading}>
{t("update")}
</Button>

View File

@ -1297,6 +1297,7 @@
"fetching_calendars_error": "There was a problem fetching your calendars. Please <1>try again</1> or reach out to customer support.",
"calendar_connection_fail": "Calendar connection failed",
"booking_confirmation_success": "Booking confirmation succeeded",
"booking_rejection_success": "Booking rejection succeeded",
"booking_confirmation_fail": "Booking confirmation failed",
"we_wont_show_again": "We won't show this again",
"couldnt_update_timezone": "We couldn't update the timezone",
@ -1366,6 +1367,8 @@
"invalid_credential": "Oh no! Looks like permission expired or was revoked. Please reinstall again.",
"choose_common_schedule_team_event": "Choose a common schedule",
"choose_common_schedule_team_event_description": "Enable this if you want to use a common schedule between hosts. When disabled, each host will be booked based on their default schedule.",
"sender_id": "Sender ID",
"sender_id_error_message":"Only letters, numbers and spaces allowed (max. 11 characters)",
"test_routing_form": "Test Routing Form",
"test_preview": "Test Preview",
"route_to": "Route to",

View File

@ -697,7 +697,7 @@
"add_new_event_type": "新しいイベントタイプを追加する",
"new_event_type_to_book_description": "ユーザーが時間を予約する際に使う新しいイベントタイプを作成する。",
"length": "長さ",
"minimum_booking_notice": "最低頻度の通知",
"minimum_booking_notice": "予約通知の最小時間",
"slot_interval": "時間帯の間隔",
"slot_interval_default": "イベントの長さを使用する (デフォルト)",
"delete_event_type_description": "このイベントタイプを削除してもよろしいですか? あなたがこのリンクを共有した人は、それを使って予約することができなくなります。",

View File

@ -50,6 +50,16 @@ const config: Config = {
testEnvironment: "jsdom",
setupFiles: ["<rootDir>/packages/app-store/closecomothercalendar/test/globals.ts"],
},
{
displayName: "@calcom/routing-forms",
roots: ["<rootDir>/packages/app-store/ee/routing-forms"],
testMatch: ["**/test/lib/**/*.(spec|test).(ts|tsx|js)"],
transform: {
"^.+\\.ts?$": "ts-jest",
},
transformIgnorePatterns: ["/node_modules/", "^.+\\.module\\.(css|sass|scss)$"],
testEnvironment: "jsdom",
},
{
displayName: "@calcom/api",
roots: ["<rootDir>/apps/api"],

View File

@ -92,7 +92,7 @@
]
},
"engines": {
"node": "16.x",
"node": ">=16.x",
"npm": ">=7.0.0",
"yarn": ">=1.19.0 < 2.0.0"
},

View File

@ -26,35 +26,42 @@ export default function AppCard({
const [animationRef] = useAutoAnimate<HTMLDivElement>();
return (
<div ref={animationRef} className="mb-4 mt-2 rounded-md border border-gray-200 p-4 text-sm sm:p-8">
<div className="flex w-full flex-col gap-2 sm:flex-row sm:gap-0">
{/* Don't know why but w-[42px] isn't working, started happening when I started using next/dynamic */}
<Link href={"/apps/" + app.slug}>
<a className="mr-3 h-auto w-10 rounded-sm">
<img src={app?.logo} alt={app?.name} />
</a>
</Link>
<div className="flex flex-col">
<span className="font-semibold leading-none text-black">{app?.name}</span>
<p className="pt-2 text-sm font-normal text-gray-600">{description || app?.description}</p>
</div>
{app?.isInstalled ? (
<div className="ml-auto flex items-center">
<Switch
onCheckedChange={(enabled) => {
if (switchOnClick) {
switchOnClick(enabled);
}
setAppData("enabled", enabled);
}}
checked={switchChecked}
/>
<div className="mb-4 mt-2 rounded-md border border-gray-200">
<div className="p-4 text-sm sm:p-8">
<div className="flex w-full flex-col gap-2 sm:flex-row sm:gap-0">
{/* Don't know why but w-[42px] isn't working, started happening when I started using next/dynamic */}
<Link href={"/apps/" + app.slug}>
<a className="mr-3 h-auto w-10 rounded-sm">
<img className="w-full" src={app?.logo} alt={app?.name} />
</a>
</Link>
<div className="flex flex-col">
<span className="text-base font-semibold leading-4 text-black">{app?.name}</span>
<p className="pt-2 text-sm font-normal leading-4 text-gray-600">
{description || app?.description}
</p>
</div>
) : (
<OmniInstallAppButton className="ml-auto flex items-center" appId={app?.slug} />
)}
{app?.isInstalled ? (
<div className="ml-auto flex items-center">
<Switch
onCheckedChange={(enabled) => {
if (switchOnClick) {
switchOnClick(enabled);
}
setAppData("enabled", enabled);
}}
checked={switchChecked}
/>
</div>
) : (
<OmniInstallAppButton className="ml-auto flex items-center" appId={app?.slug} />
)}
</div>
</div>
<div ref={animationRef}>
{app?.isInstalled && switchChecked && <hr />}
{app?.isInstalled && switchChecked ? <div className="p-4 text-sm sm:px-8">{children}</div> : null}
</div>
{app?.isInstalled && switchChecked ? <div className="mt-4">{children}</div> : null}
</div>
);
}

View File

@ -6,6 +6,14 @@ import { App } from "@calcom/types/App";
import getInstalledAppPath from "./getInstalledAppPath";
function gotoUrl(url: string, newTab?: boolean) {
if (newTab) {
window.open(url, "_blank");
return;
}
window.location.href = url;
}
function useAddAppMutation(_type: App["type"] | null, options?: Parameters<typeof useMutation>[2]) {
const mutation = useMutation<
unknown,
@ -41,7 +49,7 @@ function useAddAppMutation(_type: App["type"] | null, options?: Parameters<typeo
const json = await res.json();
if (!isOmniInstall) {
window.location.href = json.url;
gotoUrl(json.url, json.newTab);
return;
}
@ -51,7 +59,7 @@ function useAddAppMutation(_type: App["type"] | null, options?: Parameters<typeo
// Check first that the URL is absolute, then check that it is of different origin from the current.
if (/https?:\/\//.test(json.url) && !json.url.startsWith(window.location.origin)) {
// TODO: For Omni installation to authenticate and come back to the page where installation was initiated, some changes need to be done in all apps' add callbacks
window.location.href = json.url;
gotoUrl(json.url, json.newTab);
}
}, options);

View File

@ -20,7 +20,9 @@ const handler: AppDeclarativeHandler = {
},
});
},
redirectUrl: "/apps/routing-forms/forms",
redirect: {
url: "/apps/routing-forms/forms",
},
};
export default handler;

View File

@ -19,6 +19,11 @@ export default function RoutingNavBar({
href: `${appUrl}/route-builder/${form?.id}`,
className: "pointer-events-none opacity-30 lg:pointer-events-auto lg:opacity-100",
},
{
name: "Reporting",
href: `${appUrl}/reporting/${form?.id}`,
className: "pointer-events-none opacity-30 lg:pointer-events-auto lg:opacity-100",
},
];
return (
<div className="mb-4">

View File

@ -5,6 +5,12 @@ import { useForm, UseFormReturn, Controller } from "react-hook-form";
import useApp from "@calcom/lib/hooks/useApp";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import {
AppGetServerSidePropsContext,
AppPrisma,
AppUser,
AppSsrInit,
} from "@calcom/types/AppGetServerSideProps";
import { Icon } from "@calcom/ui";
import { Dialog, DialogContent, DialogClose, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
import { Button, ButtonGroup } from "@calcom/ui/components";
@ -15,6 +21,7 @@ import SettingsToggle from "@calcom/ui/v2/core/SettingsToggle";
import { ShellMain } from "@calcom/ui/v2/core/Shell";
import Banner from "@calcom/ui/v2/core/banner";
import { getSerializableForm } from "../lib/getSerializableForm";
import { processRoute } from "../lib/processRoute";
import { RoutingPages } from "../pages/route-builder/[...appPages]";
import { SerializableForm } from "../types/types";
@ -392,3 +399,65 @@ export default function SingleFormWrapper({ form: _form, ...props }: SingleFormC
}
return <SingleForm form={form} {...props} />;
}
export const getServerSidePropsForSingleFormView = async function getServerSidePropsForSingleFormView(
context: AppGetServerSidePropsContext,
prisma: AppPrisma,
user: AppUser,
ssrInit: AppSsrInit
) {
const ssr = await ssrInit(context);
if (!user) {
return {
redirect: {
permanent: false,
destination: "/auth/login",
},
};
}
const { params } = context;
if (!params) {
return {
notFound: true,
};
}
const formId = params.appPages[0];
if (!formId || params.appPages.length > 1) {
return {
notFound: true,
};
}
const isAllowed = (await import("../lib/isAllowed")).isAllowed;
if (!(await isAllowed({ userId: user.id, formId }))) {
return {
notFound: true,
};
}
const form = await prisma.app_RoutingForms_Form.findUnique({
where: {
id: formId,
},
include: {
_count: {
select: {
responses: true,
},
},
},
});
if (!form) {
return {
notFound: true,
};
}
return {
props: {
trpcState: ssr.dehydrate(),
form: getSerializableForm(form),
},
};
};

View File

@ -150,7 +150,7 @@ function SelectWidget({
);
}
function Button({ type, label, onClick, readonly }: ButtonProps) {
function Button({ config, type, label, onClick, readonly }: ButtonProps) {
if (type === "delRule" || type == "delGroup") {
return (
<button className="ml-5">
@ -160,7 +160,7 @@ function Button({ type, label, onClick, readonly }: ButtonProps) {
}
let dataTestId = "";
if (type === "addRule") {
label = "Add rule";
label = config?.operators.__calReporting ? "Add Filter" : "Add rule";
dataTestId = "add-rule";
} else if (type == "addGroup") {
label = "Add rule group";
@ -185,11 +185,15 @@ function ButtonGroup({ children }: ButtonGroupProps) {
}
return (
<>
{children.map((button) => {
{children.map((button, key) => {
if (!button) {
return null;
}
return button;
return (
<div key={key} className="mb-2">
{button}
</div>
);
})}
</>
);
@ -222,10 +226,10 @@ function Conjs({ not, setNot, config, conjunctionOptions, setConjunction, disabl
value = value == "any" ? "none" : "all";
}
const selectValue = options.find((option) => option.value === value);
const summary = !config.operators.__calReporting ? "Rule group when" : "Query where";
return (
<div className="flex items-center text-sm">
<span>Rule group when</span>
<span>{summary}</span>
<Select
className="flex px-2"
defaultValue={selectValue}
@ -267,7 +271,7 @@ const FieldSelect = function FieldSelect(props: FieldProps) {
return (
<Select
className="data-testid-field-select"
className="data-testid-field-select mb-2"
menuPosition="fixed"
onChange={(item) => {
if (!item) {

View File

@ -0,0 +1,168 @@
// It can have many shapes, so just use any and we rely on unit tests to test all those scenarios.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type LogicData = Partial<Record<keyof typeof OPERATOR_MAP, any>>;
type NegatedLogicData = {
"!": LogicData;
};
export type JsonLogicQuery = {
logic: {
and?: LogicData[];
or?: LogicData[];
"!"?: {
and?: LogicData[];
or?: LogicData[];
};
} | null;
};
type PrismaWhere = {
AND?: ReturnType<typeof convertQueriesToPrismaWhereClause>[];
OR?: ReturnType<typeof convertQueriesToPrismaWhereClause>[];
NOT?: PrismaWhere;
};
const OPERATOR_MAP = {
"==": {
operator: "equals",
secondaryOperand: null,
},
in: {
operator: "string_contains",
secondaryOperand: null,
},
"!=": {
operator: "NOT.equals",
secondaryOperand: null,
},
"!": {
operator: "equals",
secondaryOperand: "",
},
"!!": {
operator: "NOT.equals",
secondaryOperand: "",
},
all: {
operator: "array_contains",
secondaryOperand: null,
},
};
/**
* Operators supported on array of basic queries
*/
const GROUP_OPERATOR_MAP = {
and: "AND",
or: "OR",
"!": "NOT",
} as const;
const convertSingleQueryToPrismaWhereClause = (
operatorName: keyof typeof OPERATOR_MAP,
logicData: LogicData,
isNegation: boolean
) => {
const mappedOperator = OPERATOR_MAP[operatorName].operator;
const staticSecondaryOperand = OPERATOR_MAP[operatorName].secondaryOperand;
isNegation = isNegation || mappedOperator.startsWith("NOT.");
const prismaOperator = mappedOperator.replace("NOT.", "");
const operands =
logicData[operatorName] instanceof Array ? logicData[operatorName] : [logicData[operatorName]];
const mainOperand = operatorName !== "in" ? operands[0].var : operands[1].var;
let secondaryOperand = staticSecondaryOperand || (operatorName !== "in" ? operands[1] : operands[0]) || "";
if (operatorName === "all") {
secondaryOperand = secondaryOperand.in[1];
}
const prismaWhere = {
response: { path: [mainOperand, "value"], [`${prismaOperator}`]: secondaryOperand },
};
if (isNegation) {
return {
NOT: {
...prismaWhere,
},
};
}
return prismaWhere;
};
const isNegation = (logicData: LogicData | NegatedLogicData) => {
if ("!" in logicData) {
const negatedLogicData = logicData["!"];
for (const [operatorName] of Object.entries(OPERATOR_MAP)) {
if (negatedLogicData[operatorName]) {
return true;
}
}
}
return false;
};
const convertQueriesToPrismaWhereClause = (logicData: LogicData) => {
const _isNegation = isNegation(logicData);
if (_isNegation) {
logicData = logicData["!"];
}
for (const [key] of Object.entries(OPERATOR_MAP)) {
const operatorName = key as keyof typeof OPERATOR_MAP;
if (logicData[operatorName]) {
return convertSingleQueryToPrismaWhereClause(operatorName, logicData, _isNegation);
}
}
};
export const jsonLogicToPrisma = (query: JsonLogicQuery) => {
try {
let logic = query.logic;
if (!logic) {
return {};
}
let prismaWhere: PrismaWhere = {};
let negateLogic = false;
// Case: Negation of "Any of these"
// Example: {"logic":{"!":{"or":[{"==":[{"var":"505d3c3c-aa71-4220-93a9-6fd1e1087939"},"1"]},{"==":[{"var":"505d3c3c-aa71-4220-93a9-6fd1e1087939"},"1"]}]}}}
if (logic["!"]) {
logic = logic["!"];
negateLogic = true;
}
// Case: All of these
if (logic.and) {
const where: PrismaWhere["AND"] = (prismaWhere[GROUP_OPERATOR_MAP["and"]] = []);
logic.and.forEach((and) => {
const res = convertQueriesToPrismaWhereClause(and);
if (!res) {
return;
}
where.push(res);
});
}
// Case: Any of these
else if (logic.or) {
const where: PrismaWhere["OR"] = (prismaWhere[GROUP_OPERATOR_MAP["or"]] = []);
logic.or.forEach((or) => {
const res = convertQueriesToPrismaWhereClause(or);
if (!res) {
return;
}
where.push(res);
});
}
if (negateLogic) {
prismaWhere = { NOT: { ...prismaWhere } };
}
return prismaWhere;
} catch (e) {
console.log("Error converting to prisma `where`", JSON.stringify(query), "Error is ", e);
return {};
}
};

View File

@ -1,6 +1,7 @@
//TODO: Generate this file automatically so that like in Next.js file based routing can work automatically
import * as formEdit from "./form-edit/[...appPages]";
import * as forms from "./forms/[...appPages]";
import * as Reporting from "./reporting/[...appPages]";
import * as RouteBuilder from "./route-builder/[...appPages]";
import * as Router from "./router/[...appPages]";
import * as RoutingLink from "./routing-link/[...appPages]";
@ -11,6 +12,7 @@ const routingConfig = {
forms: forms,
"routing-link": RoutingLink,
router: Router,
reporting: Reporting,
};
export default routingConfig;

View File

@ -6,12 +6,6 @@ import { UseFormReturn } from "react-hook-form";
import { v4 as uuidv4 } from "uuid";
import classNames from "@calcom/lib/classNames";
import {
AppGetServerSidePropsContext,
AppPrisma,
AppUser,
AppSsrInit,
} from "@calcom/types/AppGetServerSideProps";
import { Icon } from "@calcom/ui";
import { Button, TextAreaField, TextField } from "@calcom/ui/components";
import { EmptyScreen, SelectField, Shell } from "@calcom/ui/v2";
@ -20,10 +14,11 @@ import FormCard from "@calcom/ui/v2/core/form/FormCard";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import { getServerSidePropsForSingleFormView as getServerSideProps } from "../../components/SingleForm";
import SingleForm from "../../components/SingleForm";
import { getSerializableForm } from "../../lib/getSerializableForm";
import { SerializableForm } from "../../types/types";
export { getServerSideProps };
type RoutingForm = SerializableForm<App_RoutingForms_Form>;
type RoutingFormWithResponseCount = RoutingForm & {
_count: {
@ -302,64 +297,3 @@ FormEditPage.getLayout = (page: React.ReactElement) => {
</Shell>
);
};
export const getServerSideProps = async function getServerSideProps(
context: AppGetServerSidePropsContext,
prisma: AppPrisma,
user: AppUser,
ssrInit: AppSsrInit
) {
const ssr = await ssrInit(context);
if (!user) {
return {
redirect: {
permanent: false,
destination: "/auth/login",
},
};
}
const { params } = context;
if (!params) {
return {
notFound: true,
};
}
const formId = params.appPages[0];
if (!formId || params.appPages.length > 1) {
return {
notFound: true,
};
}
const isAllowed = (await import("../../lib/isAllowed")).isAllowed;
if (!(await isAllowed({ userId: user.id, formId }))) {
return {
notFound: true,
};
}
const form = await prisma.app_RoutingForms_Form.findUnique({
where: {
id: formId,
},
include: {
_count: {
select: {
responses: true,
},
},
},
});
if (!form) {
return {
notFound: true,
};
}
return {
props: {
trpcState: ssr.dehydrate(),
form: getSerializableForm(form),
},
};
};

View File

@ -0,0 +1,195 @@
import React, { useRef, useState, useCallback } from "react";
import { Query, Config, Builder, Utils as QbUtils, JsonLogicResult } from "react-awesome-query-builder";
import { JsonTree, ImmutableTree, BuilderProps } from "react-awesome-query-builder";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Button } from "@calcom/ui";
import { Shell } from "@calcom/ui/v2";
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
import SingleForm from "../../components/SingleForm";
import { getServerSidePropsForSingleFormView as getServerSideProps } from "../../components/SingleForm";
import QueryBuilderInitialConfig from "../../components/react-awesome-query-builder/config/config";
import "../../components/react-awesome-query-builder/styles.css";
import { JsonLogicQuery } from "../../jsonLogicToPrisma";
import { getQueryBuilderConfig } from "../route-builder/[...appPages]";
export { getServerSideProps };
type QueryBuilderUpdatedConfig = typeof QueryBuilderInitialConfig & { fields: Config["fields"] };
const Result = ({ formId, jsonLogicQuery }: { formId: string; jsonLogicQuery: JsonLogicQuery | null }) => {
const { t } = useLocale();
const { isLoading, status, data, isFetching, error, isFetchingNextPage, hasNextPage, fetchNextPage } =
trpc.viewer.appRoutingForms.report.useInfiniteQuery(
{
formId: formId,
// Send jsonLogicQuery only if it's a valid logic, otherwise send a logic with no query.
jsonLogicQuery: jsonLogicQuery?.logic
? jsonLogicQuery
: {
logic: {},
},
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
const buttonInView = useInViewObserver(() => {
if (!isFetching && hasNextPage && status === "success") {
fetchNextPage();
}
});
const headers = useRef<string[] | null>(null);
if (!isLoading && !data) {
return <div>Error loading report {error?.message} </div>;
}
headers.current = (data?.pages && data?.pages[0]?.headers) || headers.current;
return (
<div className="w-full max-w-[2000px] overflow-x-scroll">
<table
data-testid="reporting-table"
className="table-fixed border-separate border-spacing-0 rounded-md border border-gray-300 bg-gray-100">
<tr data-testid="reporting-header" className="border-b border-gray-300 bg-gray-200">
{headers.current?.map((header, index) => (
<th
className={classNames(
"border-b border-gray-300 py-3 px-2 text-left text-base font-medium",
index !== (headers.current?.length || 0) - 1 ? "border-r" : ""
)}
key={index}>
{header}
</th>
))}
</tr>
{!isLoading &&
data?.pages.map((page) => {
return page.responses?.map((responses, rowIndex) => {
const isLastRow = page.responses.length - 1 === rowIndex;
return (
<tr
key={rowIndex}
data-testid="reporting-row"
className={classNames(
"text-center text-sm",
rowIndex % 2 ? "" : "bg-white",
isLastRow ? "" : "border-b"
)}>
{responses.map((r, columnIndex) => {
const isLastColumn = columnIndex === responses.length - 1;
return (
<td
className={classNames(
"overflow-x-hidden border-gray-300 py-3 px-2 text-left",
isLastRow ? "" : "border-b",
isLastColumn ? "" : "border-r"
)}
key={columnIndex}>
{r}
</td>
);
})}
</tr>
);
});
})}
</table>
{isLoading ? <div className="p-2">Report is loading</div> : ""}
<Button
type="button"
color="minimal"
ref={buttonInView.ref}
loading={isFetchingNextPage}
disabled={!hasNextPage}
onClick={() => fetchNextPage()}>
{hasNextPage ? t("load_more_results") : t("no_more_results")}
</Button>
</div>
);
};
const getInitialQuery = (config: ReturnType<typeof getQueryBuilderConfig>) => {
const uuid = QbUtils.uuid();
const queryValue: JsonTree = { id: uuid, type: "group" } as JsonTree;
const tree = QbUtils.checkTree(QbUtils.loadTree(queryValue), config);
return {
state: { tree, config },
queryValue,
};
};
const Reporter = ({ form }: { form: inferSSRProps<typeof getServerSideProps>["form"] }) => {
const config = getQueryBuilderConfig(form, true);
const [query, setQuery] = useState(getInitialQuery(config));
const [jsonLogicQuery, setJsonLogicQuery] = useState<JsonLogicResult | null>(null);
const onChange = (immutableTree: ImmutableTree, config: QueryBuilderUpdatedConfig) => {
const jsonTree = QbUtils.getTree(immutableTree);
setQuery(() => {
const newValue = {
state: { tree: immutableTree, config: config },
queryValue: jsonTree,
};
setJsonLogicQuery(QbUtils.jsonLogicFormat(newValue.state.tree, config));
return newValue;
});
};
const renderBuilder = useCallback(
(props: BuilderProps) => (
<div className="query-builder-container">
<div className="query-builder qb-lite">
<Builder {...props} />
</div>
</div>
),
[]
);
return (
<div className="flex flex-col-reverse md:flex-row">
<div className="cal-query-builder w-full ltr:mr-2 rtl:ml-2">
<Query
{...config}
value={query.state.tree}
onChange={(immutableTree, config) => {
onChange(immutableTree, config as QueryBuilderUpdatedConfig);
}}
renderBuilder={renderBuilder}
/>
<Result formId={form.id} jsonLogicQuery={jsonLogicQuery as JsonLogicQuery} />
</div>
</div>
);
};
export default function ReporterWrapper({
form,
appUrl,
}: inferSSRProps<typeof getServerSideProps> & { appUrl: string }) {
return (
<SingleForm
form={form}
appUrl={appUrl}
Page={({ form }) => (
<div className="route-config">
<Reporter form={form} />
</div>
)}
/>
);
}
ReporterWrapper.getLayout = (page: React.ReactElement) => {
return (
<Shell backPath="/apps/routing-forms/forms" withoutMain={true}>
{page}
</Shell>
);
};

View File

@ -6,32 +6,28 @@ import { Query, Config, Builder, Utils as QbUtils } from "react-awesome-query-bu
import { JsonTree, ImmutableTree, BuilderProps } from "react-awesome-query-builder";
import { trpc } from "@calcom/trpc/react";
import {
AppGetServerSidePropsContext,
AppPrisma,
AppUser,
AppSsrInit,
} from "@calcom/types/AppGetServerSideProps";
import { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Icon } from "@calcom/ui";
import { Button, TextField, TextArea } from "@calcom/ui/components";
import { SelectWithValidation as Select, Shell } from "@calcom/ui/v2";
import FormCard from "@calcom/ui/v2/core/form/FormCard";
import { getServerSidePropsForSingleFormView as getServerSideProps } from "../../components/SingleForm";
import SingleForm from "../../components/SingleForm";
import QueryBuilderInitialConfig from "../../components/react-awesome-query-builder/config/config";
import "../../components/react-awesome-query-builder/styles.css";
import { getSerializableForm } from "../../lib/getSerializableForm";
import { SerializableForm } from "../../types/types";
import { FieldTypes } from "../form-edit/[...appPages]";
export { getServerSideProps };
type RoutingForm = SerializableForm<App_RoutingForms_Form>;
const InitialConfig = QueryBuilderInitialConfig;
const hasRules = (route: Route) =>
route.queryValue.children1 && Object.keys(route.queryValue.children1).length;
type QueryBuilderUpdatedConfig = typeof QueryBuilderInitialConfig & { fields: Config["fields"] };
export function getQueryBuilderConfig(form: RoutingForm) {
export function getQueryBuilderConfig(form: RoutingForm, forReporting = false) {
const fields: Record<
string,
{
@ -74,9 +70,15 @@ export function getQueryBuilderConfig(form: RoutingForm) {
}
});
const initialConfigCopy = { ...InitialConfig };
if (forReporting) {
delete initialConfigCopy.operators.is_empty;
delete initialConfigCopy.operators.is_not_empty;
initialConfigCopy.operators.__calReporting = true;
}
// You need to provide your own config. See below 'Config format'
const config: QueryBuilderUpdatedConfig = {
...InitialConfig,
...initialConfigCopy,
fields: fields,
};
return config;
@ -467,65 +469,3 @@ RouteBuilder.getLayout = (page: React.ReactElement) => {
</Shell>
);
};
export const getServerSideProps = async function getServerSideProps(
context: AppGetServerSidePropsContext,
prisma: AppPrisma,
user: AppUser,
ssrInit: AppSsrInit
) {
const ssr = await ssrInit(context);
if (!user) {
return {
redirect: {
permanent: false,
destination: "/auth/login",
},
};
}
const { params } = context;
if (!params) {
return {
notFound: true,
};
}
const formId = params.appPages[0];
if (!formId || params.appPages.length > 1) {
return {
notFound: true,
};
}
const isAllowed = (await import("../../lib/isAllowed")).isAllowed;
if (!(await isAllowed({ userId: user.id, formId }))) {
return {
notFound: true,
};
}
const form = await prisma.app_RoutingForms_Form.findUnique({
where: {
id: formId,
},
include: {
_count: {
select: {
responses: true,
},
},
},
});
if (!form) {
return {
notFound: true,
};
}
return {
props: {
trpcState: ssr.dehydrate(),
form: getSerializableForm(form),
},
};
};

View File

@ -162,42 +162,54 @@ test.describe("Routing Forms", () => {
return user;
};
test("Routing Link should accept submission while routing works and responses can be downloaded", async ({
page,
users,
}) => {
test("Routing Link - Reporting and CSV Download ", async ({ page, users }) => {
const user = await createUserAndLoginAndInstallApp({ users, page });
const routingForm = user.routingForms[0];
test.setTimeout(120000);
// Fill form when you are logged out
await users.logout();
await gotoRoutingLink(page, routingForm.id);
await page.fill('[data-testid="field"]', "event-routing");
page.click('button[type="submit"]');
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/pro/30min");
},
});
await gotoRoutingLink(page, routingForm.id);
await page.fill('[data-testid="field"]', "external-redirect");
page.click('button[type="submit"]');
await page.waitForNavigation({
url(url) {
return url.hostname.includes("google.com");
},
});
await gotoRoutingLink(page, routingForm.id);
await page.fill('[data-testid="field"]', "custom-page");
await page.click('button[type="submit"]');
await page.isVisible("text=Custom Page Result");
await fillSeededForm(page, routingForm.id);
// Log back in to view form responses.
await user.login();
await page.goto(`/apps/routing-forms/route-builder/${routingForm.id}`);
await page.goto(`/apps/routing-forms/reporting/${routingForm.id}`);
// Can't keep waiting forever. So, added a timeout of 5000ms
await page.waitForResponse((response) => response.url().includes("viewer.appRoutingForms.report"), {
timeout: 5000,
});
const headerEls = page.locator("[data-testid='reporting-header'] th");
// Once the response is there, React would soon render it, so 500ms is enough
await headerEls.first().waitFor({
timeout: 500,
});
const numHeaderEls = await headerEls.count();
const headers = [];
for (let i = 0; i < numHeaderEls; i++) {
headers.push(await headerEls.nth(i).innerText());
}
const responses = [];
const responseRows = page.locator("[data-testid='reporting-row']");
const numResponseRows = await responseRows.count();
for (let i = 0; i < numResponseRows; i++) {
const rowLocator = responseRows.nth(i).locator("td");
const numRowEls = await rowLocator.count();
const rowResponses = [];
for (let j = 0; j < numRowEls; j++) {
rowResponses.push(await rowLocator.nth(j).innerText());
}
responses.push(rowResponses);
}
expect(headers).toEqual(["Test field", "Multi Select"]);
expect(responses).toEqual([
["event-routing", ""],
["external-redirect", ""],
["custom-page", ""],
]);
const [download] = await Promise.all([
// Start waiting for the download
page.waitForEvent("download"),
@ -273,3 +285,27 @@ test.describe("Routing Forms", () => {
});
});
});
async function fillSeededForm(page: Page, routingFormId: string) {
await gotoRoutingLink(page, routingFormId);
await page.fill('[data-testid="field"]', "event-routing");
page.click('button[type="submit"]');
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/pro/30min");
},
});
await gotoRoutingLink(page, routingFormId);
await page.fill('[data-testid="field"]', "external-redirect");
page.click('button[type="submit"]');
await page.waitForNavigation({
url(url) {
return url.hostname.includes("google.com");
},
});
await gotoRoutingLink(page, routingFormId);
await page.fill('[data-testid="field"]', "custom-page");
await page.click('button[type="submit"]');
await page.isVisible("text=Custom Page Result");
}

View File

@ -0,0 +1,167 @@
import { expect, it, describe } from "@jest/globals";
import { jsonLogicToPrisma } from "../../jsonLogicToPrisma";
afterEach(() => {
jest.resetAllMocks();
});
describe("jsonLogicToPrisma - Single Query", () => {
it("should support Short 'Equals' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: { and: [{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "A"] }] },
});
expect(prismaWhere).toEqual({
AND: [
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "A",
},
},
],
});
});
it("should support Short 'Not Equals' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: { and: [{ "!=": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "abc"] }] },
});
expect(prismaWhere).toEqual({
AND: [
{
NOT: {
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "abc",
},
},
},
],
});
});
it("should support Short 'Contains' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: { and: [{ in: ["A", { var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }] }] },
});
expect(prismaWhere).toEqual({
AND: [
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
string_contains: "A",
},
},
],
});
});
it("should support Short 'Not Contains' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: { and: [{ "!": { in: ["a", { var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }] } }] },
});
expect(prismaWhere).toEqual({
AND: [
{
NOT: {
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
string_contains: "a",
},
},
},
],
});
});
it("should support 'MultiSelect' 'Equals' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
and: [{ all: [{ var: "267c7817-81a5-4bef-9d5b-d0faa4cd0d71" }, { in: [{ var: "" }, ["C", "D"]] }] }],
},
});
expect(prismaWhere).toEqual({
AND: [
{ response: { path: ["267c7817-81a5-4bef-9d5b-d0faa4cd0d71", "value"], array_contains: ["C", "D"] } },
],
});
});
});
describe("jsonLogicToPrisma - Single Query", () => {
it("should support where All Match ['Equals', 'Equals'] operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
and: [
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "a"] },
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "b"] },
],
},
});
expect(prismaWhere).toEqual({
AND: [
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "a",
},
},
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "b",
},
},
],
});
});
it("should support where Any Match ['Equals', 'Equals'] operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
or: [
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "a"] },
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "b"] },
],
},
});
expect(prismaWhere).toEqual({
OR: [
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "a",
},
},
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "b",
},
},
],
});
});
it("should support where None Match ['Equals', 'Equals'] operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
"!": {
or: [
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "abc"] },
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "abcd"] },
],
},
},
});
expect(prismaWhere).toEqual({
NOT: {
OR: [
{ response: { path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"], equals: "abc" } },
{ response: { path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"], equals: "abcd" } },
],
},
});
});
});

View File

@ -11,6 +11,7 @@ import { authedProcedure, publicProcedure, router } from "@calcom/trpc/server/tr
import { Ensure } from "@calcom/types/utils";
import ResponseEmail from "./emails/templates/response-email";
import { jsonLogicToPrisma } from "./jsonLogicToPrisma";
import { getSerializableForm } from "./lib/getSerializableForm";
import { isAllowed } from "./lib/isAllowed";
import { Response, SerializableForm } from "./types/types";
@ -377,6 +378,80 @@ const appRoutingForms = router({
},
});
}),
report: authedProcedure
.input(
z.object({
formId: z.string(),
jsonLogicQuery: z.object({
logic: z.union([z.record(z.any()), z.null()]),
}),
cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type
})
)
.query(async ({ ctx: { prisma }, input }) => {
// Can be any prisma `where` clause
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const prismaWhere: Record<string, any> = input.jsonLogicQuery
? jsonLogicToPrisma(input.jsonLogicQuery)
: {};
const skip = input.cursor ?? 0;
const take = 50;
logger.debug(
`Built Prisma where ${JSON.stringify(prismaWhere)} from jsonLogicQuery ${JSON.stringify(
input.jsonLogicQuery
)}`
);
const form = await prisma.app_RoutingForms_Form.findUnique({
where: {
id: input.formId,
},
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found",
});
}
// TODO: Second argument is required to return deleted operators.
const serializedForm = getSerializableForm(form, true);
const rows = await prisma.app_RoutingForms_FormResponse.findMany({
where: {
formId: input.formId,
...prismaWhere,
},
take,
skip,
});
const fields = serializedForm?.fields || [];
const headers = fields.map((f) => f.label + (f.deleted ? "(Deleted)" : ""));
const responses: string[][] = [];
rows.forEach((r) => {
const rowResponses: string[] = [];
responses.push(rowResponses);
fields.forEach((field) => {
if (!r.response) {
return;
}
const response = r.response as Response;
const value = response[field.id]?.value || "";
let stringValue = "";
if (value instanceof Array) {
stringValue = value.join(", ");
} else {
stringValue = value;
}
rowResponses.push(stringValue);
});
});
const areThereNoResultsOrLessThanAskedFor = !rows.length || rows.length < take;
return {
headers,
responses,
nextCursor: areThereNoResultsOrLessThanAskedFor ? null : skip + rows.length,
};
}),
});
export default appRoutingForms;

View File

@ -56,17 +56,8 @@ export default class LarkCalendarService implements Calendar {
const refreshExpireDate = larkAuthCredentials.refresh_expires_date;
const refreshToken = larkAuthCredentials.refresh_token;
if (isExpired(refreshExpireDate) || !refreshToken) {
const res = await fetch("/api/integrations", {
method: "DELETE",
body: JSON.stringify({ id: credential.id }),
headers: {
"Content-Type": "application/json",
},
});
if (!res.ok) {
throw new Error("disconnection wrong");
}
throw new Error("refresh token expires");
await prisma.credential.delete({ where: { id: credential.id } });
throw new Error("Lark Calendar refresh token expired");
}
try {
const appAccessToken = await getAppAccessToken();

View File

@ -10,7 +10,10 @@ const handler: AppDeclarativeHandler = {
slug: appConfig.slug,
supportsMultipleInstalls: false,
handlerType: "add",
redirectUrl: "https://n8n.io/integrations/cal-trigger/",
redirect: {
url: "https://n8n.io/integrations/cal-trigger/",
newTab: true,
},
createCredential: ({ appType, user, slug }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {} }),
};

View File

@ -10,7 +10,10 @@ const handler: AppDeclarativeHandler = {
slug: appConfig.slug,
supportsMultipleInstalls: false,
handlerType: "add",
redirectUrl: "https://pipedream.com/apps/cal-com",
redirect: {
newTab: true,
url: "https://pipedream.com/apps/cal-com",
},
createCredential: ({ appType, user, slug }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {} }),
};

View File

@ -10,7 +10,9 @@ const handler: AppDeclarativeHandler = {
variant: appConfig.variant,
supportsMultipleInstalls: false,
handlerType: "add",
redirectUrl: "raycast://extensions/eluce2/cal-com-share-meeting-links?source=webstore",
redirect: {
url: "raycast://extensions/eluce2/cal-com-share-meeting-links?source=webstore",
},
createCredential: ({ appType, user, slug }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {} }),
};

View File

@ -10,7 +10,9 @@ const handler: AppDeclarativeHandler = {
variant: appConfig.variant,
supportsMultipleInstalls: false,
handlerType: "add",
redirectUrl: "/apps/typeform/how-to-use",
redirect: {
url: "/apps/typeform/how-to-use",
},
createCredential: ({ appType, user, slug }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {} }),
};

View File

@ -1,39 +0,0 @@
import dayjs from "@calcom/dayjs";
import { TRIAL_LIMIT_DAYS } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import Button from "@calcom/ui/Button";
const TrialBanner = () => {
const { t } = useLocale();
const query = useMeQuery();
const user = query.data;
if (!user || user.plan !== "TRIAL") return null;
const trialDaysLeft = user.trialEndsAt
? dayjs(user.trialEndsAt).add(1, "day").diff(dayjs(), "day")
: dayjs(user.createdDate)
.add(TRIAL_LIMIT_DAYS + 1, "day")
.diff(dayjs(), "day");
return (
<div
className="m-4 hidden rounded-md bg-yellow-200 p-4 text-center text-sm font-medium text-gray-600 lg:block"
data-testid="trial-banner">
<div className="mb-2 text-left">
{trialDaysLeft > 0 ? t("trial_days_left", { days: trialDaysLeft }) : t("trial_expired")}
</div>
<Button
href="/api/upgrade"
color="minimal"
prefetch={false}
className="w-full justify-center border-2 border-gray-600 hover:bg-yellow-100">
{t("upgrade_now")}
</Button>
</div>
);
};
export default TrialBanner;

View File

@ -38,7 +38,11 @@ export const CreateANewTeamForm = () => {
return (
<>
<Form form={newTeamFormMethods} handleSubmit={(v) => createTeamMutation.mutate(v)}>
<Form
form={newTeamFormMethods}
handleSubmit={(v) => {
if (!createTeamMutation.isLoading) createTeamMutation.mutate(v);
}}>
<div className="mb-8">
<Controller
name="name"
@ -113,10 +117,19 @@ export const CreateANewTeamForm = () => {
</div>
<div className="flex space-x-2">
<Button color="secondary" href="/settings" className="w-full justify-center">
<Button
disabled={createTeamMutation.isLoading}
color="secondary"
href="/settings"
className="w-full justify-center">
{t("cancel")}
</Button>
<Button color="primary" type="submit" EndIcon={Icon.FiArrowRight} className="w-full justify-center">
<Button
disabled={createTeamMutation.isLoading}
color="primary"
type="submit"
EndIcon={Icon.FiArrowRight}
className="w-full justify-center">
{t("continue")}
</Button>
</div>

View File

@ -1,10 +1,10 @@
import { MembershipRole } from "@prisma/client";
import { MembershipRole, Prisma } from "@prisma/client";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/router";
import { Controller, useForm } from "react-hook-form";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import objectKeys from "@calcom/lib/objectKeys";
@ -56,6 +56,9 @@ const ProfileView = () => {
form.setValue("url", team.slug || "");
form.setValue("logo", team.logo || "");
form.setValue("bio", team.bio || "");
if (team.slug === null && (team?.metadata as Prisma.JsonObject)?.requestedSlug) {
form.setValue("url", ((team?.metadata as Prisma.JsonObject)?.requestedSlug as string) || "");
}
}
},
}
@ -86,6 +89,17 @@ const ProfileView = () => {
},
});
const publishMutation = trpc.viewer.teams.publish.useMutation({
async onSuccess(data: { url?: string }) {
if (data.url) {
router.push(data.url);
}
},
async onError(err) {
showToast(err.message, "error");
},
});
function deleteTeam() {
if (team?.id) deleteTeamMutation.mutate({ teamId: team.id });
}
@ -199,6 +213,19 @@ const ProfileView = () => {
<Button color="primary" className="mt-8" type="submit" loading={mutation.isLoading}>
{t("update")}
</Button>
{IS_TEAM_BILLING_ENABLED &&
team.slug === null &&
(team.metadata as Prisma.JsonObject)?.requestedSlug && (
<Button
color="secondary"
className="ml-2 mt-8"
type="button"
onClick={() => {
publishMutation.mutate({ teamId: team.id });
}}>
Publish
</Button>
)}
</Form>
) : (
<div className="flex">

View File

@ -105,7 +105,12 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
break;
}
if (message?.length && message?.length > 0 && sendTo) {
const scheduledSMS = await twilio.scheduleSMS(sendTo, message, reminder.scheduledDate);
const scheduledSMS = await twilio.scheduleSMS(
sendTo,
message,
reminder.scheduledDate,
reminder.workflowStep.sender || "Cal"
);
await prisma.workflowReminder.update({
where: {

View File

@ -6,17 +6,18 @@ import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Checkbox, EmailField, Form, Label } from "@calcom/ui/components";
import { Button, Checkbox, EmailField, Form, Label, TextField } from "@calcom/ui/components";
import PhoneInput from "@calcom/ui/form/PhoneInputLazy";
import { Dialog, DialogClose, DialogContent, DialogFooter, Select } from "@calcom/ui/v2";
import { WORKFLOW_ACTIONS } from "../../lib/constants";
import { getWorkflowActionOptions } from "../../lib/getOptions";
import { onlyLettersNumbersSpaces } from "../../pages/v2/workflow";
interface IAddActionDialog {
isOpenDialog: boolean;
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
addAction: (action: WorkflowActions, sendTo?: string, numberRequired?: boolean) => void;
addAction: (action: WorkflowActions, sendTo?: string, numberRequired?: boolean, sender?: string) => void;
isFreeUser: boolean;
}
@ -29,6 +30,7 @@ type AddActionFormValues = {
action: WorkflowActions;
sendTo?: string;
numberRequired?: boolean;
sender?: string;
};
const cleanUpActionsForFreeUser = (actions: ISelectActionOption[]) => {
@ -41,6 +43,7 @@ export const AddActionDialog = (props: IAddActionDialog) => {
const { t } = useLocale();
const { isOpenDialog, setIsOpenDialog, addAction, isFreeUser } = props;
const [isPhoneNumberNeeded, setIsPhoneNumberNeeded] = useState(false);
const [isSenderIdNeeded, setIsSenderIdNeeded] = useState(false);
const [isEmailAddressNeeded, setIsEmailAddressNeeded] = useState(false);
const workflowActions = getWorkflowActionOptions(t);
const actionOptions = isFreeUser ? cleanUpActionsForFreeUser(workflowActions) : workflowActions;
@ -52,12 +55,17 @@ export const AddActionDialog = (props: IAddActionDialog) => {
.refine((val) => isValidPhoneNumber(val) || val.includes("@"))
.optional(),
numberRequired: z.boolean().optional(),
sender: z
.string()
.refine((val) => onlyLettersNumbersSpaces(val))
.nullable(),
});
const form = useForm<AddActionFormValues>({
mode: "onSubmit",
defaultValues: {
action: WorkflowActions.EMAIL_HOST,
sender: "Cal",
},
resolver: zodResolver(formSchema),
});
@ -67,11 +75,18 @@ export const AddActionDialog = (props: IAddActionDialog) => {
form.setValue("action", newValue.value);
if (newValue.value === WorkflowActions.SMS_NUMBER) {
setIsPhoneNumberNeeded(true);
setIsSenderIdNeeded(true);
setIsEmailAddressNeeded(false);
} else if (newValue.value === WorkflowActions.EMAIL_ADDRESS) {
setIsEmailAddressNeeded(true);
setIsSenderIdNeeded(false);
setIsPhoneNumberNeeded(false);
} else if (newValue.value === WorkflowActions.SMS_ATTENDEE) {
setIsSenderIdNeeded(true);
setIsEmailAddressNeeded(false);
setIsPhoneNumberNeeded(false);
} else {
setIsSenderIdNeeded(false);
setIsEmailAddressNeeded(false);
setIsPhoneNumberNeeded(false);
}
@ -90,13 +105,14 @@ export const AddActionDialog = (props: IAddActionDialog) => {
<Form
form={form}
handleSubmit={(values) => {
addAction(values.action, values.sendTo, values.numberRequired);
addAction(values.action, values.sendTo, values.numberRequired, values.sender);
form.unregister("sendTo");
form.unregister("action");
form.unregister("numberRequired");
setIsOpenDialog(false);
setIsPhoneNumberNeeded(false);
setIsEmailAddressNeeded(false);
setIsSenderIdNeeded(false);
}}>
<div className="mt-5 space-y-1">
<Label htmlFor="label">{t("action")}:</Label>
@ -119,25 +135,10 @@ export const AddActionDialog = (props: IAddActionDialog) => {
<p className="mt-1 text-sm text-red-500">{form.formState.errors.action.message}</p>
)}
</div>
{form.getValues("action") === WorkflowActions.SMS_ATTENDEE && (
<div className="mt-5">
<Controller
name="numberRequired"
control={form.control}
render={() => (
<Checkbox
defaultChecked={form.getValues("numberRequired") || false}
description={t("make_phone_number_required")}
onChange={(e) => form.setValue("numberRequired", e.target.checked)}
/>
)}
/>
</div>
)}
{isPhoneNumberNeeded && (
<div className="mt-5 space-y-1">
<Label htmlFor="sendTo">{t("phone_number")}</Label>
<div className="mt-1">
<div className="mt-1 mb-5">
<PhoneInput<AddActionFormValues>
control={form.control}
name="sendTo"
@ -157,6 +158,32 @@ export const AddActionDialog = (props: IAddActionDialog) => {
<EmailField required label={t("email_address")} {...form.register("sendTo")} />
</div>
)}
{isSenderIdNeeded && (
<div className="mt-5">
<TextField
label={t("sender_id")}
type="text"
placeholder="Cal"
maxLength={11}
{...form.register(`sender`)}
/>
</div>
)}
{form.getValues("action") === WorkflowActions.SMS_ATTENDEE && (
<div className="mt-5">
<Controller
name="numberRequired"
control={form.control}
render={() => (
<Checkbox
defaultChecked={form.getValues("numberRequired") || false}
description={t("make_phone_number_required")}
onChange={(e) => form.setValue("numberRequired", e.target.checked)}
/>
)}
/>
</div>
)}
<DialogFooter>
<DialogClose asChild>
<Button
@ -168,6 +195,7 @@ export const AddActionDialog = (props: IAddActionDialog) => {
form.unregister("numberRequired");
setIsPhoneNumberNeeded(false);
setIsEmailAddressNeeded(false);
setIsSenderIdNeeded(false);
}}>
{t("cancel")}
</Button>

View File

@ -51,7 +51,7 @@ export default function WorkflowDetailsPage(props: Props) {
[data]
);
const addAction = (action: WorkflowActions, sendTo?: string, numberRequired?: boolean) => {
const addAction = (action: WorkflowActions, sendTo?: string, numberRequired?: boolean, sender?: string) => {
const steps = form.getValues("steps");
const id =
steps?.length > 0
@ -75,6 +75,7 @@ export default function WorkflowDetailsPage(props: Props) {
emailSubject: null,
template: WorkflowTemplates.CUSTOM,
numberRequired: numberRequired || false,
sender: sender || "Cal",
};
steps?.push(step);
form.setValue("steps", steps);

View File

@ -17,7 +17,7 @@ import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger }
import { Icon } from "@calcom/ui/Icon";
import { Button } from "@calcom/ui/components";
import { Checkbox } from "@calcom/ui/components";
import { EmailField, Label, TextArea } from "@calcom/ui/components/form";
import { EmailField, Label, TextArea, TextField } from "@calcom/ui/components/form";
import PhoneInput from "@calcom/ui/form/PhoneInputLazy";
import { DialogClose, DialogContent } from "@calcom/ui/v2";
import ConfirmationDialogContent from "@calcom/ui/v2/core/ConfirmationDialogContent";
@ -52,6 +52,12 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
step?.action === WorkflowActions.SMS_NUMBER ? true : false
);
const [isSenderIdNeeded, setIsSenderIdNeeded] = useState(
step?.action === WorkflowActions.SMS_NUMBER || step?.action === WorkflowActions.SMS_ATTENDEE
? true
: false
);
const [isEmailAddressNeeded, setIsEmailAddressNeeded] = useState(
step?.action === WorkflowActions.EMAIL_ADDRESS ? true : false
);
@ -279,13 +285,20 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
if (val) {
if (val.value === WorkflowActions.SMS_NUMBER) {
setIsPhoneNumberNeeded(true);
setIsSenderIdNeeded(true);
setIsEmailAddressNeeded(false);
} else if (val.value === WorkflowActions.EMAIL_ADDRESS) {
setIsEmailAddressNeeded(true);
setIsPhoneNumberNeeded(false);
setIsSenderIdNeeded(false);
} else if (val.value === WorkflowActions.SMS_ATTENDEE) {
setIsSenderIdNeeded(true);
setIsEmailAddressNeeded(false);
setIsPhoneNumberNeeded(false);
} else {
setIsEmailAddressNeeded(false);
setIsPhoneNumberNeeded(false);
setIsSenderIdNeeded(false);
}
form.unregister(`steps.${step.stepNumber - 1}.sendTo`);
form.clearErrors(`steps.${step.stepNumber - 1}.sendTo`);
@ -315,43 +328,64 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
);
}}
/>
{form.getValues(`steps.${step.stepNumber - 1}.action`) === WorkflowActions.SMS_ATTENDEE && (
<div className="mt-5">
<Controller
name={`steps.${step.stepNumber - 1}.numberRequired`}
control={form.control}
render={() => (
<Checkbox
defaultChecked={
form.getValues(`steps.${step.stepNumber - 1}.numberRequired`) || false
}
description={t("make_phone_number_required")}
onChange={(e) =>
form.setValue(`steps.${step.stepNumber - 1}.numberRequired`, e.target.checked)
}
/>
)}
/>
</div>
)}
</div>
{isPhoneNumberNeeded && (
<div className="mt-5 rounded-md bg-gray-50 p-4">
<Label>{t("custom_phone_number")}</Label>
<PhoneInput<FormValues>
{(isPhoneNumberNeeded || isSenderIdNeeded) && (
<div className="mt-2 rounded-md bg-gray-50 p-4 pt-0">
{isPhoneNumberNeeded && (
<>
<Label className="pt-4">{t("custom_phone_number")}</Label>
<PhoneInput<FormValues>
control={form.control}
name={`steps.${step.stepNumber - 1}.sendTo`}
placeholder={t("phone_number")}
id={`steps.${step.stepNumber - 1}.sendTo`}
className="w-full rounded-md"
required
/>
{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && (
<p className="mt-1 text-xs text-red-500">
{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}
</p>
)}
</>
)}
{isSenderIdNeeded && (
<>
<div className="pt-4">
<TextField
label={t("sender_id")}
type="text"
placeholder="Cal"
maxLength={11}
{...form.register(`steps.${step.stepNumber - 1}.sender`)}
/>
</div>
{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.sender && (
<p className="mt-1 text-xs text-red-500">{t("sender_id_error_message")}</p>
)}
</>
)}
</div>
)}
{form.getValues(`steps.${step.stepNumber - 1}.action`) === WorkflowActions.SMS_ATTENDEE && (
<div className="mt-2">
<Controller
name={`steps.${step.stepNumber - 1}.numberRequired`}
control={form.control}
name={`steps.${step.stepNumber - 1}.sendTo`}
placeholder={t("phone_number")}
id={`steps.${step.stepNumber - 1}.sendTo`}
className="w-full rounded-md"
required
/>
{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && (
<p className="mt-1 text-sm text-red-500">
{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}
</p>
render={() => (
<Checkbox
defaultChecked={
form.getValues(`steps.${step.stepNumber - 1}.numberRequired`) || false
}
description={t("make_phone_number_required")}
onChange={(e) =>
form.setValue(`steps.${step.stepNumber - 1}.numberRequired`, e.target.checked)
}
/>
)}
/>
</div>
)}
{isEmailAddressNeeded && (
@ -408,7 +442,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
/>
{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.emailSubject && (
<p className="mt-1 text-sm text-red-500">
<p className="mt-1 text-xs text-red-500">
{form.formState?.errors?.steps[step.stepNumber - 1]?.emailSubject?.message || ""}
</p>
)}
@ -433,7 +467,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
/>
{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.reminderBody && (
<p className="mt-1 text-sm text-red-500">
<p className="mt-1 text-xs text-red-500">
{form.formState?.errors?.steps[step.stepNumber - 1]?.reminderBody?.message || ""}
</p>
)}
@ -497,6 +531,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
emailSubject,
reminderBody,
template: step.template,
sender: step.sender || "Cal",
});
} else {
const isNumberValid =

View File

@ -47,7 +47,8 @@ export const scheduleWorkflowReminders = async (
},
step.reminderBody || "",
step.id,
step.template
step.template,
step.sender || "Cal"
);
} else if (
step.action === WorkflowActions.EMAIL_ATTENDEE ||
@ -115,7 +116,8 @@ export const sendCancelledReminders = async (
},
step.reminderBody || "",
step.id,
step.template
step.template,
step.sender || "Cal"
);
} else if (
step.action === WorkflowActions.EMAIL_ATTENDEE ||

View File

@ -19,18 +19,19 @@ function assertTwilio(twilio: TwilioClient.Twilio | undefined): asserts twilio i
if (!twilio) throw new Error("Twilio credentials are missing from the .env file");
}
export const sendSMS = async (phoneNumber: string, body: string) => {
export const sendSMS = async (phoneNumber: string, body: string, sender: string) => {
assertTwilio(twilio);
const response = await twilio.messages.create({
body: body,
messagingServiceSid: process.env.TWILIO_MESSAGING_SID,
to: phoneNumber,
from: sender,
});
return response;
};
export const scheduleSMS = async (phoneNumber: string, body: string, scheduledDate: Date) => {
export const scheduleSMS = async (phoneNumber: string, body: string, scheduledDate: Date, sender: string) => {
assertTwilio(twilio);
const response = await twilio.messages.create({
body: body,
@ -38,6 +39,7 @@ export const scheduleSMS = async (phoneNumber: string, body: string, scheduledDa
to: phoneNumber,
scheduleType: "fixed",
sendAt: scheduledDate,
from: sender,
});
return response;

View File

@ -48,7 +48,8 @@ export const scheduleSMSReminder = async (
},
message: string,
workflowStepId: number,
template: WorkflowTemplates
template: WorkflowTemplates,
sender: string
) => {
const { startTime, endTime } = evt;
const uid = evt.uid as string;
@ -97,7 +98,7 @@ export const scheduleSMSReminder = async (
triggerEvent === WorkflowTriggerEvents.RESCHEDULE_EVENT
) {
try {
await twilio.sendSMS(reminderPhone, message);
await twilio.sendSMS(reminderPhone, message, sender);
} catch (error) {
console.log(`Error sending SMS with error ${error}`);
}
@ -112,7 +113,12 @@ export const scheduleSMSReminder = async (
!scheduledDate.isAfter(currentDate.add(7, "day"))
) {
try {
const scheduledSMS = await twilio.scheduleSMS(reminderPhone, message, scheduledDate.toDate());
const scheduledSMS = await twilio.scheduleSMS(
reminderPhone,
message,
scheduledDate.toDate(),
sender
);
await prisma.workflowReminder.create({
data: {

View File

@ -38,6 +38,13 @@ export type FormValues = {
timeUnit?: TimeUnit;
};
export function onlyLettersNumbersSpaces(str: string) {
if (str.length <= 11 && /^[A-Za-z0-9\s]*$/.test(str)) {
return true;
}
return false;
}
const formSchema = z.object({
name: z.string(),
activeOn: z.object({ value: z.string(), label: z.string() }).array(),
@ -58,6 +65,11 @@ const formSchema = z.object({
.string()
.refine((val) => isValidPhoneNumber(val) || val.includes("@"))
.nullable(),
sender: z
.string()
.refine((val) => onlyLettersNumbersSpaces(val))
.optional()
.nullable(),
})
.array(),
});

View File

@ -22,6 +22,7 @@ export async function getTeamWithMembers(id?: number, slug?: string, userId?: nu
logo: true,
bio: true,
hideBranding: true,
metadata: true,
members: {
select: {
accepted: true,

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "WorkflowStep" ADD COLUMN "sender" TEXT;

View File

@ -573,6 +573,7 @@ model WorkflowStep {
template WorkflowTemplates @default(REMINDER)
workflowReminders WorkflowReminder[]
numberRequired Boolean?
sender String?
}
model Workflow {

View File

@ -731,7 +731,7 @@ export const bookingsRouter = router({
},
});
return { message: "Booking confirmed" };
return { message: "Booking confirmed", status: BookingStatus.ACCEPTED };
}
const attendeesListPromises = booking.attendees.map(async (attendee) => {
return {
@ -985,6 +985,10 @@ export const bookingsRouter = router({
await sendDeclinedEmails(evt);
}
return { message: "Booking " + confirmed ? "confirmed" : "rejected" };
const message = "Booking " + confirmed ? "confirmed" : "rejected";
const status = confirmed ? BookingStatus.ACCEPTED : BookingStatus.REJECTED;
return { message, status };
}),
});

View File

@ -154,14 +154,24 @@ export const viewerTeamsRouter = router({
if (
input.slug &&
IS_TEAM_BILLING_ENABLED &&
/** If the team doesn't have a slug we can assume that it hasn't been published yet. */ !prevTeam.slug
/** If the team doesn't have a slug we can assume that it hasn't been published yet. */
!prevTeam.slug
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You cannot change the slug until you publish your team",
});
// Save it on the metadata so we can use it later
data.metadata = {
requestedSlug: input.slug,
};
} else {
data.slug = input.slug;
// If we save slug, we don't need the requestedSlug anymore
const metadataParse = teamMetadataSchema.safeParse(prevTeam.metadata);
if (metadataParse.success) {
const { requestedSlug, ...cleanMetadata } = metadataParse.data || {};
data.metadata = {
...cleanMetadata,
};
}
}
const updatedTeam = await ctx.prisma.team.update({
@ -582,7 +592,7 @@ export const viewerTeamsRouter = router({
if (!metadata.success || !metadata.data?.requestedSlug)
throw new TRPCError({ code: "BAD_REQUEST", message: "Can't publish team without `requestedSlug`" });
// if payment needed, responed with checkout url
// if payment needed, respond with checkout url
if (IS_TEAM_BILLING_ENABLED) {
const checkoutSession = await purchaseTeamSubscription({
teamId: prevTeam.id,

View File

@ -160,6 +160,7 @@ export const workflowsRouter = router({
action: WorkflowActions.EMAIL_HOST,
template: WorkflowTemplates.REMINDER,
workflowId: workflow.id,
sender: "Cal",
},
});
return { workflow };
@ -235,6 +236,7 @@ export const workflowsRouter = router({
emailSubject: z.string().optional().nullable(),
template: z.enum(WORKFLOW_TEMPLATES),
numberRequired: z.boolean().nullable(),
sender: z.string().optional().nullable(),
})
.array(),
trigger: z.enum(WORKFLOW_TRIGGER_EVENTS),
@ -472,7 +474,8 @@ export const workflowsRouter = router({
},
step.reminderBody || "",
step.id,
step.template
step.template,
step.sender || "Cal"
);
}
});
@ -541,6 +544,7 @@ export const workflowsRouter = router({
emailSubject: newStep.template === WorkflowTemplates.CUSTOM ? newStep.emailSubject : null,
template: newStep.template,
numberRequired: newStep.numberRequired,
sender: newStep.sender || "Cal",
},
});
//cancel all reminders of step and create new ones (not for newEventTypes)
@ -651,7 +655,8 @@ export const workflowsRouter = router({
},
newStep.reminderBody || "",
newStep.id || 0,
newStep.template
newStep.template,
newStep.sender || "Cal"
);
}
});
@ -677,6 +682,8 @@ export const workflowsRouter = router({
});
addedSteps.forEach(async (step) => {
if (step) {
const newStep = step;
newStep.sender = step.sender || "Cal";
const createdStep = await ctx.prisma.workflowStep.create({
data: step,
});
@ -764,7 +771,8 @@ export const workflowsRouter = router({
},
step.reminderBody || "",
createdStep.id,
step.template
step.template,
step.sender || "Cal"
);
}
});
@ -812,10 +820,11 @@ export const workflowsRouter = router({
reminderBody: z.string(),
template: z.enum(WORKFLOW_TEMPLATES),
sendTo: z.string().optional(),
sender: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { action, emailSubject, reminderBody, template, sendTo } = input;
const { action, emailSubject, reminderBody, template, sendTo, sender } = input;
try {
const booking = await ctx.prisma.booking.findFirst({
orderBy: {
@ -899,7 +908,8 @@ export const workflowsRouter = router({
{ time: null, timeUnit: null },
reminderBody,
0,
template
template,
sender || "Cal"
);
return { message: "Notification sent" };
}

View File

@ -11,6 +11,9 @@ export type AppDeclarativeHandler = {
handlerType: "add";
createCredential: (arg: { user: Session["user"]; appType: string; slug: string }) => Promise<Credential>;
supportsMultipleInstalls: boolean;
redirectUrl?: string;
redirect?: {
newTab?: boolean;
url: string;
};
};
export type AppHandler = AppDeclarativeHandler | NextApiHandler;

View File

@ -53,7 +53,7 @@ type DropdownMenuItemProps = ComponentProps<typeof DropdownMenuPrimitive["Checkb
export const DropdownMenuItem = forwardRef<HTMLDivElement, DropdownMenuItemProps>(
({ className = "", ...props }, forwardedRef) => (
<DropdownMenuPrimitive.Item
className={`text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 ${className}`}
className={`text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-none ${className}`}
{...props}
ref={forwardedRef}
/>

View File

@ -1,54 +0,0 @@
import { Trans } from "next-i18next";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import Button from "./Button";
import { Dialog, DialogClose, DialogContent } from "./Dialog";
import { Icon } from "./Icon";
export function UpgradeToProDialog({
modalOpen,
setModalOpen,
children,
}: {
modalOpen: boolean;
setModalOpen: (open: boolean) => void;
children: React.ReactNode;
}) {
const { t } = useLocale();
return (
<Dialog open={modalOpen}>
<DialogContent>
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-yellow-100">
<Icon.FiAlertTriangle className="h-6 w-6 text-yellow-400" aria-hidden="true" />
</div>
<div className="mb-4 sm:flex sm:items-start">
<div className="mt-3 sm:mt-0 sm:text-left">
<h3 className="font-cal text-lg font-bold leading-6 text-gray-900" id="modal-title">
{t("only_available_on_pro_plan")}
</h3>
</div>
</div>
<div className="flex flex-col space-y-3">
<p>{children}</p>
<p>
<Trans i18nKey="plan_upgrade_instructions">
You can
<a href="/api/upgrade" className="underline">
upgrade here
</a>
.
</Trans>
</p>
</div>
<div className="mt-5 gap-x-2 sm:mt-4 sm:flex sm:flex-row-reverse">
<DialogClose asChild>
<Button className="table-cell w-full text-center" onClick={() => setModalOpen(false)}>
{t("dismiss")}
</Button>
</DialogClose>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -240,10 +240,10 @@ function UserDropdown({ small }: { small?: boolean }) {
return (
<Dropdown open={menuOpen}>
<DropdownMenuTrigger asChild onClick={() => setMenuOpen((menuOpen) => !menuOpen)}>
<button className="group flex w-full cursor-pointer appearance-none items-center rounded-full p-2 text-left outline-none hover:bg-gray-100 sm:pl-3 md:rounded-none lg:pl-2">
<button className="group flex w-full cursor-pointer appearance-none items-center rounded-full p-2 text-left outline-none hover:bg-gray-200 sm:pl-3 md:rounded lg:pl-2">
<span
className={classNames(
small ? "h-8 w-8" : "h-9 w-9 ltr:mr-2 rtl:ml-3",
small ? "h-6 w-6" : "h-8 w-8 ltr:mr-2 rtl:ml-3",
"relative flex-shrink-0 rounded-full bg-gray-300 "
)}>
{
@ -267,7 +267,7 @@ function UserDropdown({ small }: { small?: boolean }) {
<span className="block truncate font-medium text-gray-900">
{user.name || "Nameless User"}
</span>
<span className="block truncate font-normal text-neutral-500">
<span className="block truncate font-normal text-gray-900">
{user.username
? process.env.NEXT_PUBLIC_WEBSITE_URL === "https://cal.com"
? `cal.com/${user.username}`

View File

@ -1,4 +1,5 @@
import type { Credential } from "@prisma/client";
import { useRouter } from "next/router";
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import { InstallAppButton } from "@calcom/app-store/components";
@ -15,8 +16,11 @@ interface AppCardProps {
export default function AppCard({ app, credentials }: AppCardProps) {
const { t } = useLocale();
const router = useRouter();
const mutation = useAddAppMutation(null, {
onSuccess: () => {
// Refresh SSR page content without actual reload
router.replace(router.asPath);
showToast(t("app_successfully_installed"), "success");
},
onError: (error) => {

View File

@ -47,7 +47,7 @@ const VerticalTabItem = function ({
target={props.isExternalLink ? "_blank" : "_self"}
className={classNames(
props.textClassNames || "text-sm font-medium leading-none text-gray-600",
"min-h-9 group flex w-64 flex-row items-center rounded-md px-3 py-[10px] hover:bg-gray-100 group-hover:text-gray-700 [&[aria-current='page']]:bg-gray-200 [&[aria-current='page']]:text-gray-900",
"min-h-9 group flex w-64 flex-row rounded-md px-3 py-[10px] hover:bg-gray-100 group-hover:text-gray-700 [&[aria-current='page']]:bg-gray-200 [&[aria-current='page']]:text-gray-900",
props.disabled && "pointer-events-none !opacity-30",
(isChild || !props.icon) && "ml-7 mr-5 w-auto",
!info ? "h-6" : "h-14",
@ -56,9 +56,9 @@ const VerticalTabItem = function ({
data-testid={`vertical-tab-${name}`}
aria-current={isCurrent ? "page" : undefined}>
{props.icon && <props.icon className="mr-[10px] h-[16px] w-[16px] stroke-[2px] md:mt-0" />}
<div>
<div className="h-fit">
<span className="flex items-center space-x-2">
<Skeleton title={t(name)} as="p" className="max-w-36 truncate">
<Skeleton title={t(name)} as="p" className="max-w-36 min-h-4 truncate">
{t(name)}
</Skeleton>
{props.isExternalLink ? <Icon.FiExternalLink /> : null}

View File

@ -35,7 +35,7 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
<>
<div className={classNames("dark:text-darkgray-800 text-neutral-500", className)}>
{eventType.description && (
<h2 className="dark:text-darkgray-800 max-w-[280px] overflow-hidden text-ellipsis py-2 text-sm text-gray-600 opacity-60 sm:max-w-[500px]">
<h2 className="dark:text-darkgray-800 max-w-[200px] overflow-hidden text-ellipsis py-2 text-sm text-gray-600 opacity-60 sm:max-w-[500px]">
{eventType.description.substring(0, 100)}
{eventType.description.length > 100 && "..."}
</h2>

View File

@ -183,6 +183,9 @@
"$CLOSECOM_API_KEY",
"$SENDGRID_API_KEY",
"$SENDGRID_EMAIL",
"$TWILIO_TOKEN",
"$TWILIO_SID",
"$TWILIO_MESSAGING_SID",
"$CRON_API_KEY",
"$DAILY_API_KEY",
"$DAILY_SCALE_PLAN",