Merge remote-tracking branch 'upstream/main' into improve-multilingualism
This commit is contained in:
commit
ac373a6f94
|
@ -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:
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
"projectId": "cl4u26bwz7962859ply7ibuo43t",
|
||||
"databaseUrl": "postgresql://postgres@localhost:5450/calendso"
|
||||
"targetDatabaseUrl": "postgresql://postgres@localhost:5450/calendso"
|
||||
}
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"version": "0.3.0"
|
||||
"version": "0.22.3"
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: () => {
|
||||
|
|
|
@ -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,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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`
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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" });
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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 }),
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
}
|
|
@ -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." });
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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({});
|
||||
}
|
|
@ -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({});
|
||||
}
|
|
@ -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" });
|
||||
}
|
||||
}
|
|
@ -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 || "/");
|
||||
}
|
||||
}
|
|
@ -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 });
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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" });
|
||||
}
|
|
@ -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) }),
|
||||
});
|
|
@ -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")}
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "このイベントタイプを削除してもよろしいですか? あなたがこのリンクを共有した人は、それを使って予約することができなくなります。",
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -92,7 +92,7 @@
|
|||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": "16.x",
|
||||
"node": ">=16.x",
|
||||
"npm": ">=7.0.0",
|
||||
"yarn": ">=1.19.0 < 2.0.0"
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -20,7 +20,9 @@ const handler: AppDeclarativeHandler = {
|
|||
},
|
||||
});
|
||||
},
|
||||
redirectUrl: "/apps/routing-forms/forms",
|
||||
redirect: {
|
||||
url: "/apps/routing-forms/forms",
|
||||
},
|
||||
};
|
||||
|
||||
export default handler;
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 {};
|
||||
}
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -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" } },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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: {} }),
|
||||
};
|
||||
|
|
|
@ -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: {} }),
|
||||
};
|
||||
|
|
|
@ -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: {} }),
|
||||
};
|
||||
|
|
|
@ -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: {} }),
|
||||
};
|
||||
|
|
|
@ -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;
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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 ||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "WorkflowStep" ADD COLUMN "sender" TEXT;
|
|
@ -573,6 +573,7 @@ model WorkflowStep {
|
|||
template WorkflowTemplates @default(REMINDER)
|
||||
workflowReminders WorkflowReminder[]
|
||||
numberRequired Boolean?
|
||||
sender String?
|
||||
}
|
||||
|
||||
model Workflow {
|
||||
|
|
|
@ -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 };
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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" };
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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}`
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in New Issue
Block a user