Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0922c5ee0 | ||
|
|
f8dc7a115a | ||
|
|
a60aa1bf6c | ||
|
|
bc203d7439 | ||
|
|
d82847035f | ||
|
|
6d133c26f8 | ||
|
|
3ae53a4b6e | ||
|
|
63339d8c01 | ||
|
|
44410c87f0 | ||
|
|
c016241004 | ||
|
|
019ef6de2a | ||
|
|
a2b064b269 | ||
|
|
abb4fd307f | ||
|
|
a9ebb0d160 | ||
|
|
1058c0e171 | ||
|
|
021e5f3833 | ||
|
|
5b5a42e72d | ||
|
|
2b43d9a716 | ||
|
|
3a5325f2a8 | ||
|
|
5ad73607f0 | ||
|
|
5067f0dd16 | ||
|
|
a53e697baa | ||
|
|
e7415fe6e2 | ||
|
|
562309d24b | ||
|
|
3900c55204 | ||
|
|
b3be9a93e0 | ||
|
|
d27ccd6f44 | ||
|
|
f86a5db843 | ||
|
|
4cbf9ec9ab | ||
|
|
aa3c91ebd6 | ||
|
|
071c899ea6 | ||
|
|
32b2e83564 | ||
|
|
7ff6d6559a | ||
|
|
2572a5cb93 | ||
|
|
45e0f87e45 | ||
|
|
6e875fccbe | ||
|
|
689e17bcf6 | ||
|
|
584f9c6e93 | ||
|
|
70348c6f64 | ||
|
|
747591fded | ||
|
|
9bfd933699 | ||
|
|
a775e42e85 | ||
|
|
e3e2bc804a | ||
|
|
b84099aec0 | ||
|
|
073d34f893 | ||
|
|
d72900d164 | ||
|
|
e3b478f5b1 | ||
|
|
a5191d1e5a | ||
|
|
ea21cd5826 | ||
|
|
e78c8738fb | ||
|
|
47314bae83 | ||
|
|
fd8afbc418 | ||
|
|
6e7012757f | ||
|
|
933eb0d057 | ||
|
|
fcb79888f3 | ||
|
|
111e2f30c6 | ||
|
|
f2b3e620b4 | ||
|
|
d9133cb395 | ||
|
|
1722edbd36 | ||
|
|
e68ab3b123 | ||
|
|
cf16cd3ed2 | ||
|
|
f4bc7d2ea4 | ||
|
|
f5f7242f02 | ||
|
|
8f5ac26bb1 | ||
|
|
b983adb16a | ||
|
|
8ce018995a | ||
|
|
f11086e9f8 | ||
|
|
91f5d49cd4 | ||
|
|
55e1e0fa3d | ||
|
|
4a59841b02 | ||
|
|
74a9f3c19e | ||
|
|
89250422ea | ||
|
|
2f2f4dba60 | ||
|
|
b292cafe7b | ||
|
|
82053e0c7c | ||
|
|
860d173f0a | ||
|
|
5bc0e1265d | ||
|
|
e362c37ff3 | ||
|
|
4d53f327d9 | ||
|
|
61f436be2a | ||
|
|
d1a0fc6604 | ||
|
|
976013512c | ||
|
|
d9777a3011 | ||
|
|
7725adedc8 | ||
|
|
7269b3ea60 | ||
|
|
c168df4df0 | ||
|
|
be9b0fb9a8 | ||
|
|
c056cabbbd | ||
|
|
97630e8add | ||
|
|
a5443ccfbe | ||
|
|
6f2986e42e | ||
|
|
db345de99e | ||
|
|
bde40dceb3 | ||
|
|
1a86e7c506 | ||
|
|
9ed1624111 | ||
|
|
5dff1c6df0 | ||
|
|
706d7dff42 | ||
|
|
71443ef23d | ||
|
|
17201334b6 | ||
|
|
bc82ff4d21 | ||
|
|
71ae3f157a | ||
|
|
8784be067f | ||
|
|
84a2e55125 | ||
|
|
21e23f32ac | ||
|
|
33aa9420af | ||
|
|
ef0ba48f49 | ||
|
|
6e54c459a4 | ||
|
|
f5c16f0d6b | ||
|
|
105e7d3e28 | ||
|
|
21ef1900e9 | ||
|
|
f07b73dc79 | ||
|
|
d4b0516235 | ||
|
|
7bfad11ff9 | ||
|
|
d2f7a427d0 | ||
|
|
a910eb96ad | ||
|
|
c080458faa | ||
|
|
5c75cfc17f | ||
|
|
4280f556cb | ||
|
|
21368bb130 | ||
|
|
e9076c8e3e | ||
|
|
2471ee801b | ||
|
+6 |
9fc9876b3f | ||
|
|
34ca3bd22d | ||
|
|
bebe1dd7b4 | ||
|
|
fced9a6355 | ||
|
|
6b57dd6794 | ||
|
|
eb4c58186b | ||
|
|
17e438ab9f | ||
|
|
458e6a3abf | ||
|
|
963d22648b | ||
|
|
fabcf2055b | ||
|
|
633697a812 | ||
|
|
2de33fe1bf | ||
|
|
36e79bf69c | ||
|
|
2e36cadce4 | ||
|
|
9ef532019f | ||
|
|
176dd1a7f3 | ||
|
|
e9c71abd6f | ||
|
|
eb472cba36 | ||
|
|
7d77554696 | ||
|
|
31bfa9e32e | ||
|
|
26be88fc56 | ||
|
|
86cf8feb55 | ||
|
|
1edd255846 | ||
|
|
f538877349 | ||
|
|
d759669c1e | ||
|
|
71ce87ca21 | ||
|
|
7eae004aa2 | ||
|
|
6cd427bc78 | ||
|
|
b8d67c5a41 | ||
|
|
42953548b6 | ||
|
|
28c631e108 | ||
|
|
263a96e660 | ||
|
|
d059b1399b | ||
|
|
12a9c74eb6 | ||
|
|
6bb60e36fd | ||
|
|
c1eb2512b6 | ||
|
|
55d75a9786 | ||
|
|
779eb19e7a | ||
|
|
5d8ed130de | ||
|
|
971d9ff0d8 | ||
|
|
b6bcc43655 | ||
|
|
b576fc8c26 | ||
|
|
821843bc6c | ||
|
|
d19e3e53f0 | ||
|
|
40fee7af51 | ||
|
|
f3462776e8 | ||
|
|
57e9ef7320 | ||
|
|
88f46a6b20 |
@@ -334,6 +334,8 @@ AB_TEST_BUCKET_PROBABILITY=50
|
||||
# whether we redirect to the future/event-types from event-types or not
|
||||
APP_ROUTER_EVENT_TYPES_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_ADMIN_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_PLATFORM_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_ORG_ENABLED=0
|
||||
APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED=0
|
||||
APP_ROUTER_APPS_SLUG_ENABLED=0
|
||||
APP_ROUTER_APPS_SLUG_SETUP_ENABLED=0
|
||||
@@ -341,13 +343,28 @@ APP_ROUTER_APPS_SLUG_SETUP_ENABLED=0
|
||||
APP_ROUTER_APPS_CATEGORIES_ENABLED=0
|
||||
# whether we redirect to the future/apps/categories/[category] from /apps/categories/[category] or not
|
||||
APP_ROUTER_APPS_CATEGORIES_CATEGORY_ENABLED=0
|
||||
APP_ROUTER_BOOKING_ENABLED=0
|
||||
APP_ROUTER_BOOKINGS_STATUS_ENABLED=0
|
||||
APP_ROUTER_WORKFLOWS_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_TEAMS_ENABLED=0
|
||||
APP_ROUTER_GETTING_STARTED_STEP_ENABLED=0
|
||||
APP_ROUTER_APPS_ENABLED=0
|
||||
APP_ROUTER_VIDEO_ENABLED=0
|
||||
APP_ROUTER_TEAM_ENABLED=0
|
||||
APP_ROUTER_TEAMS_ENABLED=0
|
||||
APP_ROUTER_AVAILABILITY_ENABLED=0
|
||||
APP_ROUTER_AUTH_FORGOT_PASSWORD_ENABLED=0
|
||||
APP_ROUTER_AUTH_LOGIN_ENABLED=0
|
||||
APP_ROUTER_AUTH_LOGOUT_ENABLED=0
|
||||
APP_ROUTER_AUTH_NEW_ENABLED=0
|
||||
APP_ROUTER_AUTH_SAML_ENABLED=0
|
||||
APP_ROUTER_AUTH_ERROR_ENABLED=0
|
||||
APP_ROUTER_AUTH_PLATFORM_ENABLED=0
|
||||
APP_ROUTER_AUTH_OAUTH2_ENABLED=0
|
||||
APP_ROUTER_MORE_ENABLED=0
|
||||
APP_ROUTER_MAINTENANCE_ENABLED=0
|
||||
APP_ROUTER_UPGRADE_ENABLED=0
|
||||
APP_ROUTER_CONNECT_AND_JOIN_ENABLED=0
|
||||
|
||||
# disable setry server source maps
|
||||
SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1
|
||||
@@ -369,6 +386,11 @@ RETELL_AI_KEY=
|
||||
# Used to disallow emails as being added as guests on bookings
|
||||
BLACKLISTED_GUEST_EMAILS=
|
||||
|
||||
# Used to allow browser push notifications
|
||||
# You can use: 'npx web-push generate-vapid-keys' to generate these keys
|
||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
|
||||
# Custom privacy policy / terms URLs (for self-hosters: change to your privacy policy / terms URLs)
|
||||
NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL=
|
||||
NEXT_PUBLIC_WEBSITE_TERMS_URL=
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report any issues with the platform
|
||||
title: ""
|
||||
labels: ["🐛 bug"]
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
Found a bug? Please fill out the sections below. 👍
|
||||
|
||||
### Issue Summary
|
||||
|
||||
A summary of the issue. This needs to be a clear detailed-rich summary.
|
||||
|
||||
### Steps to Reproduce
|
||||
|
||||
1. (for example) Went to ...
|
||||
2. Clicked on...
|
||||
3. ...
|
||||
|
||||
Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead?
|
||||
|
||||
### Actual Results
|
||||
|
||||
- What's happening right now that is different from what is expected
|
||||
|
||||
### Expected Results
|
||||
|
||||
- This is an ideal result that the system should get after the tests are performed
|
||||
|
||||
### Technical details
|
||||
|
||||
- Browser version, screen recording, console logs, network requests: You can make a recording with [Bird Eats Bug](https://birdeatsbug.com/).
|
||||
- Node.js version
|
||||
- Anything else that you think could be an issue.
|
||||
|
||||
### Evidence
|
||||
|
||||
- How was this tested? This is quite mandatory in terms of bugs. Providing evidence of your testing with screenshots or/and videos is an amazing way to prove the bug and a troubleshooting chance to find the solution.
|
||||
@@ -1,81 +0,0 @@
|
||||
name: Bug report
|
||||
description: "Please make sure you have checked for any duplicate issues."
|
||||
title: "(bug)"
|
||||
labels: ["🐛 bug"]
|
||||
assignees: []
|
||||
body:
|
||||
- type: textarea
|
||||
id: issue-summary
|
||||
attributes:
|
||||
label: Issue Summary
|
||||
description: A summary of the issue. This needs to be a clear detailed-rich summary.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
value: |
|
||||
1. (for example) Went to ...
|
||||
2. Clicked on...
|
||||
3. ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: other-information
|
||||
attributes:
|
||||
label: Other information
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: If applicable, add screenshots to help explain your problem.
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
options:
|
||||
- label: app.cal.com
|
||||
- label: Self-hosted Cal
|
||||
- type: textarea
|
||||
id: desktop-version
|
||||
attributes:
|
||||
label: Desktop (please complete the following information)
|
||||
description: |
|
||||
examples:
|
||||
- **OS**: [e.g. iOS]
|
||||
- **Browser**: [e.g. chrome, safari]
|
||||
- **Version**: [e.g. 22]
|
||||
value: |
|
||||
- OS:
|
||||
- Node:
|
||||
- npm:
|
||||
render: markdown
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
id: nodejs-version
|
||||
attributes:
|
||||
value: |
|
||||
#### Node.JS version
|
||||
|
||||
[e.g. v18.15.0]
|
||||
- type: markdown
|
||||
id: anything-else
|
||||
attributes:
|
||||
value: |
|
||||
#### Anything else?
|
||||
|
||||
- Screen recording, console logs, network requests: You can make a recording with [Loom](https://www.loom.com).
|
||||
- Anything else that you think could be an issue?
|
||||
@@ -1,28 +0,0 @@
|
||||
name: PR Reviewed
|
||||
|
||||
on:
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
label-pr:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Label PR as ready for E2E
|
||||
if: github.event.review.state == 'approved'
|
||||
uses: actions/github-script@v4
|
||||
with:
|
||||
script: |
|
||||
await github.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: process.env.labels.split(', '),
|
||||
})
|
||||
env:
|
||||
github_token: ${{ secrets.READY_FOR_E2E_PAT }}
|
||||
labels: 'ready-for-e2e'
|
||||
File diff suppressed because one or more lines are too long
@@ -168,6 +168,7 @@ export const schemaEventTypeReadPublic = EventType.pick({
|
||||
metadata: jsonSchema.nullable(),
|
||||
customInputs: customInputSchema.array().optional(),
|
||||
link: z.string().optional(),
|
||||
hashedLink: z.object({ link: z.string() }).optional().nullable(),
|
||||
bookingFields: eventTypeBookingFields.optional().nullable(),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -30,40 +30,59 @@ async function authMiddleware(req: NextApiRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
const userWithBookingsAndTeamIds = await prisma.user.findUnique({
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
select: {
|
||||
email: true,
|
||||
bookings: true,
|
||||
teams: {
|
||||
select: {
|
||||
teamId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) throw new HttpError({ statusCode: 404, message: "User not found" });
|
||||
|
||||
const filteredBookings = user?.bookings?.filter((booking) => booking.id === id);
|
||||
const userIsHost = !!filteredBookings?.length;
|
||||
|
||||
const bookingsAsAttendee = prisma.booking.findMany({
|
||||
where: {
|
||||
id,
|
||||
attendees: { some: { email: user.email } },
|
||||
},
|
||||
});
|
||||
|
||||
const bookingsAsEventTypeOwner = prisma.booking.findMany({
|
||||
where: {
|
||||
id,
|
||||
eventType: {
|
||||
owner: { id: userId },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const bookingsAsTeamOwnerOrAdmin = prisma.booking.findMany({
|
||||
where: {
|
||||
id,
|
||||
eventType: {
|
||||
team: {
|
||||
members: {
|
||||
some: { userId, role: { in: ["ADMIN", "OWNER"] }, accepted: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!userWithBookingsAndTeamIds) throw new HttpError({ statusCode: 404, message: "User not found" });
|
||||
const [resultOne, resultTwo, resultThree] = await Promise.all([
|
||||
bookingsAsAttendee,
|
||||
bookingsAsEventTypeOwner,
|
||||
bookingsAsTeamOwnerOrAdmin,
|
||||
]);
|
||||
|
||||
const userBookingIds = userWithBookingsAndTeamIds.bookings.map((booking) => booking.id);
|
||||
const teamBookingsAsOwnerOrAdmin = [...resultOne, ...resultTwo, ...resultThree];
|
||||
const userHasTeamBookings = !!teamBookingsAsOwnerOrAdmin.length;
|
||||
|
||||
if (!userBookingIds.includes(id)) {
|
||||
const teamBookings = await prisma.booking.findUnique({
|
||||
where: {
|
||||
id: id,
|
||||
eventType: {
|
||||
team: {
|
||||
id: {
|
||||
in: userWithBookingsAndTeamIds.teams.map((team) => team.teamId),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!teamBookings) {
|
||||
throw new HttpError({ statusCode: 403, message: "You are not authorized" });
|
||||
}
|
||||
}
|
||||
if (!userIsHost && !userHasTeamBookings)
|
||||
throw new HttpError({ statusCode: 403, message: "You are not authorized" });
|
||||
}
|
||||
|
||||
export default authMiddleware;
|
||||
|
||||
@@ -6,6 +6,9 @@ import prisma from "@calcom/prisma";
|
||||
|
||||
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
||||
|
||||
import checkParentEventOwnership from "../_utils/checkParentEventOwnership";
|
||||
import checkTeamEventEditPermission from "../_utils/checkTeamEventEditPermission";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /event-types/{id}:
|
||||
@@ -49,9 +52,18 @@ async function checkPermissions(req: NextApiRequest) {
|
||||
const { userId, isSystemWideAdmin } = req;
|
||||
const { id } = schemaQueryIdParseInt.parse(req.query);
|
||||
if (isSystemWideAdmin) return;
|
||||
/** Only event type owners can delete it */
|
||||
const eventType = await prisma.eventType.findFirst({ where: { id, userId } });
|
||||
|
||||
const eventType = await prisma.eventType.findFirst({ where: { id } });
|
||||
|
||||
if (!eventType) throw new HttpError({ statusCode: 403, message: "Forbidden" });
|
||||
|
||||
/** Only event type owners or team owners for team events can delete it */
|
||||
if (eventType.teamId) return await checkTeamEventEditPermission(req, { teamId: eventType.teamId });
|
||||
|
||||
if (eventType.parentId) return await checkParentEventOwnership(req);
|
||||
|
||||
if (eventType.userId && eventType.userId !== userId)
|
||||
throw new HttpError({ statusCode: 403, message: "Forbidden" });
|
||||
}
|
||||
|
||||
export default defaultResponder(deleteHandler);
|
||||
|
||||
@@ -51,6 +51,7 @@ export async function getHandler(req: NextApiRequest) {
|
||||
where: { id },
|
||||
include: {
|
||||
customInputs: true,
|
||||
hashedLink: { select: { link: true } },
|
||||
team: { select: { slug: true } },
|
||||
hosts: { select: { userId: true, isFixed: true } },
|
||||
owner: { select: { username: true, id: true } },
|
||||
|
||||
@@ -56,6 +56,7 @@ async function getHandler(req: NextApiRequest) {
|
||||
},
|
||||
include: {
|
||||
customInputs: true,
|
||||
hashedLink: { select: { link: true } },
|
||||
team: { select: { slug: true } },
|
||||
hosts: { select: { userId: true, isFixed: true } },
|
||||
owner: { select: { username: true, id: true } },
|
||||
|
||||
@@ -18,12 +18,8 @@ export default async function checkParentEventOwnership(req: NextApiRequest) {
|
||||
/** These are already parsed upstream, we can assume they're good here. */
|
||||
const parentId = Number(body.parentId);
|
||||
const parentEventType = await prisma.eventType.findUnique({
|
||||
where: {
|
||||
id: parentId,
|
||||
},
|
||||
select: {
|
||||
teamId: true,
|
||||
},
|
||||
where: { id: parentId },
|
||||
select: { teamId: true },
|
||||
});
|
||||
|
||||
if (!parentEventType) {
|
||||
@@ -44,7 +40,8 @@ export default async function checkParentEventOwnership(req: NextApiRequest) {
|
||||
where: {
|
||||
teamId: parentEventType.teamId,
|
||||
userId: userId,
|
||||
OR: [{ role: "OWNER" }, { role: "ADMIN" }],
|
||||
role: { in: ["ADMIN", "OWNER"] },
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -10,21 +10,17 @@ export default async function checkTeamEventEditPermission(
|
||||
req: NextApiRequest,
|
||||
body: Pick<z.infer<typeof schemaEventTypeCreateBodyParams>, "teamId" | "userId">
|
||||
) {
|
||||
const { isSystemWideAdmin } = req;
|
||||
let userId = req.userId;
|
||||
if (isSystemWideAdmin && body.userId) {
|
||||
userId = body.userId;
|
||||
}
|
||||
if (body.teamId) {
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
userId: req.userId,
|
||||
teamId: body.teamId,
|
||||
accepted: true,
|
||||
role: { in: ["ADMIN", "OWNER"] },
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership?.role || !["ADMIN", "OWNER"].includes(membership.role)) {
|
||||
if (!membership) {
|
||||
throw new HttpError({
|
||||
statusCode: 403,
|
||||
message: "No permission to operate on event-type for this team",
|
||||
|
||||
@@ -3,6 +3,7 @@ import utc from "dayjs/plugin/utc";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { isSupportedTimeZone } from "@calcom/lib/date-fns";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
@@ -17,14 +18,22 @@ import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
let isColdStart = true;
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const { usernameList, ...rest } = req.query;
|
||||
if (isColdStart && IS_PRODUCTION) {
|
||||
console.log("Cold start of /v1/slots detected");
|
||||
isColdStart = false;
|
||||
}
|
||||
|
||||
const { usernameList, isTeamEvent, ...rest } = req.query;
|
||||
const parsedIsTeamEvent = String(isTeamEvent).toLowerCase() === "true";
|
||||
let slugs = usernameList;
|
||||
if (!Array.isArray(usernameList)) {
|
||||
slugs = usernameList ? [usernameList] : undefined;
|
||||
}
|
||||
const input = getScheduleSchema.parse({ usernameList: slugs, ...rest });
|
||||
const input = getScheduleSchema.parse({ usernameList: slugs, isTeamEvent: parsedIsTeamEvent, ...rest });
|
||||
const timeZoneSupported = input.timeZone ? isSupportedTimeZone(input.timeZone) : false;
|
||||
const availableSlots = await getAvailableSlots({ ctx: await createContext({ req, res }), input });
|
||||
const slotsInProvidedTimeZone = timeZoneSupported
|
||||
|
||||
@@ -41,7 +41,7 @@ export async function canUserAccessTeamWithRole(
|
||||
/** If not ADMIN then we check if the actual user belongs to team and matches the required role */
|
||||
if (!isSystemWideAdmin) args.where = { ...args.where, members: { some: { userId, role } } };
|
||||
const team = await prisma.team.findFirst(args);
|
||||
if (!team) throw new HttpError({ statusCode: 401, message: `Unauthorized: ${role.toString()} required` });
|
||||
if (!team) throw new HttpError({ statusCode: 401, message: `Unauthorized: OWNER or ADMIN role required` });
|
||||
return team;
|
||||
}
|
||||
|
||||
|
||||
@@ -215,6 +215,7 @@ const generateTeamCheckoutSession = async ({
|
||||
metadata: {
|
||||
pendingPaymentTeamId,
|
||||
ownerId,
|
||||
dubCustomerId: ownerId, // pass the userId during checkout creation for sales conversion tracking: https://d.to/conversions/stripe
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# Set the version of docker compose to use
|
||||
version: "3.9"
|
||||
|
||||
# The containers that compose the project
|
||||
services:
|
||||
db:
|
||||
|
||||
@@ -1,90 +1,332 @@
|
||||
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, test } from "vitest";
|
||||
|
||||
import type { HttpError } from "@calcom/lib/http-error";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import authMiddleware from "../../../pages/api/bookings/[id]/_auth-middleware";
|
||||
|
||||
type CustomNextApiRequest = NextApiRequest & Request;
|
||||
type CustomNextApiResponse = NextApiResponse & Response;
|
||||
|
||||
describe("Bookings auth middleware", () => {
|
||||
it("Returns 403 when user has no permission to the booking", async () => {
|
||||
const trialUser = await prisma.user.findFirstOrThrow({ where: { email: "trial@example.com" } });
|
||||
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
||||
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
|
||||
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
body: {},
|
||||
query: {
|
||||
id: booking.id,
|
||||
describe("Booking ownership and access in Middleware", () => {
|
||||
const adminUserId = 1;
|
||||
const ownerUserId = 2;
|
||||
const memberUserId = 3;
|
||||
const orgOwnerUserId = 4;
|
||||
const adminUserEmail = "admin@example.com";
|
||||
const ownerUserEmail = "owner@example.com";
|
||||
const memberUserEmail = "member@example.com";
|
||||
const orgOwnerUserEmail = "org-owner@example.com";
|
||||
// mock user data
|
||||
function buildMockData() {
|
||||
//Create Users
|
||||
prismock.user.create({
|
||||
data: {
|
||||
id: adminUserId,
|
||||
username: "admin",
|
||||
name: "Admin User",
|
||||
email: adminUserEmail,
|
||||
},
|
||||
});
|
||||
prismock.user.create({
|
||||
data: {
|
||||
id: ownerUserId,
|
||||
username: "owner",
|
||||
name: "Owner User",
|
||||
email: ownerUserEmail,
|
||||
},
|
||||
});
|
||||
prismock.user.create({
|
||||
data: {
|
||||
id: orgOwnerUserId,
|
||||
username: "org-owner",
|
||||
name: "Org Owner",
|
||||
email: orgOwnerUserEmail,
|
||||
},
|
||||
});
|
||||
prismock.user.create({
|
||||
data: {
|
||||
id: memberUserId,
|
||||
username: "member",
|
||||
name: "Member User",
|
||||
email: memberUserEmail,
|
||||
bookings: {
|
||||
create: {
|
||||
id: 2,
|
||||
uid: "2",
|
||||
title: "Booking 2",
|
||||
eventTypeId: 1,
|
||||
startTime: "2024-08-30T06:45:00.000Z",
|
||||
endTime: "2024-08-30T07:45:00.000Z",
|
||||
attendees: {
|
||||
create: {
|
||||
name: "Member User",
|
||||
email: memberUserEmail,
|
||||
timeZone: "UTC",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
//create team
|
||||
prismock.team.create({
|
||||
data: {
|
||||
id: 1,
|
||||
name: "Team 1",
|
||||
slug: "team1",
|
||||
members: {
|
||||
createMany: {
|
||||
data: [
|
||||
{
|
||||
userId: adminUserId,
|
||||
role: MembershipRole.ADMIN,
|
||||
accepted: true,
|
||||
},
|
||||
{
|
||||
userId: ownerUserId,
|
||||
role: MembershipRole.OWNER,
|
||||
accepted: true,
|
||||
},
|
||||
{
|
||||
userId: memberUserId,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
//create Org
|
||||
prismock.team.create({
|
||||
data: {
|
||||
id: 2,
|
||||
name: "Org",
|
||||
slug: "org",
|
||||
isOrganization: true,
|
||||
children: {
|
||||
connect: {
|
||||
id: 1,
|
||||
},
|
||||
},
|
||||
members: {
|
||||
createMany: {
|
||||
data: [
|
||||
{
|
||||
userId: orgOwnerUserId,
|
||||
role: MembershipRole.OWNER,
|
||||
accepted: true,
|
||||
},
|
||||
{
|
||||
userId: memberUserId,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
{
|
||||
userId: ownerUserId,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
{
|
||||
userId: adminUserId,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
//create eventTypes
|
||||
prismock.eventType.create({
|
||||
data: {
|
||||
id: 1,
|
||||
title: "Event 1",
|
||||
slug: "event",
|
||||
length: 60,
|
||||
bookings: {
|
||||
connect: {
|
||||
id: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
prismock.eventType.create({
|
||||
data: {
|
||||
id: 2,
|
||||
title: "Event 2",
|
||||
slug: "event",
|
||||
length: 60,
|
||||
teamId: 1,
|
||||
bookings: {
|
||||
connect: {
|
||||
id: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
//link eventType to teams
|
||||
prismock.eventType.update({
|
||||
where: {
|
||||
id: 1,
|
||||
},
|
||||
data: {
|
||||
owner: {
|
||||
connect: {
|
||||
id: ownerUserId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
prismock.eventType.update({
|
||||
where: {
|
||||
id: 2,
|
||||
},
|
||||
data: {
|
||||
team: {
|
||||
connect: {
|
||||
id: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
//link team to org
|
||||
prismock.team.update({
|
||||
where: {
|
||||
id: 1,
|
||||
},
|
||||
data: {
|
||||
parentId: 2,
|
||||
},
|
||||
});
|
||||
// Call Prisma to create booking with attendees
|
||||
prismock.booking.create({
|
||||
data: {
|
||||
id: 1,
|
||||
uid: "1",
|
||||
title: "Booking 1",
|
||||
userId: 1,
|
||||
startTime: "2024-08-30T06:45:00.000Z",
|
||||
endTime: "2024-08-30T07:45:00.000Z",
|
||||
eventTypeId: 2,
|
||||
attendees: {
|
||||
create: {
|
||||
name: "Admin User",
|
||||
email: adminUserEmail,
|
||||
timeZone: "UTC",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
req.userId = trialUser.id;
|
||||
|
||||
try {
|
||||
await authMiddleware(req);
|
||||
} catch (error) {
|
||||
const httpError = error as HttpError;
|
||||
expect(httpError.statusCode).toBe(403);
|
||||
}
|
||||
test("should not throw error for bookings where user is an attendee", async () => {
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
query: {
|
||||
id: 2,
|
||||
},
|
||||
prisma: prismock,
|
||||
});
|
||||
buildMockData();
|
||||
req.userId = memberUserId;
|
||||
await expect(authMiddleware(req)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("No error is thrown when user is the booking user", async () => {
|
||||
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
||||
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
|
||||
|
||||
test("should throw error for bookings where user is not an attendee", async () => {
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
body: {},
|
||||
query: {
|
||||
id: booking.id,
|
||||
id: 1,
|
||||
},
|
||||
prisma: prismock,
|
||||
});
|
||||
buildMockData();
|
||||
req.userId = memberUserId;
|
||||
|
||||
req.userId = proUser.id;
|
||||
|
||||
await authMiddleware(req);
|
||||
await expect(authMiddleware(req)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("No error is thrown when user is system-wide admin", async () => {
|
||||
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "admin@example.com" } });
|
||||
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
||||
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
|
||||
|
||||
test("should not throw error for booking where user is the event type owner", async () => {
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
body: {},
|
||||
query: {
|
||||
id: booking.id,
|
||||
id: 2,
|
||||
},
|
||||
prisma: prismock,
|
||||
});
|
||||
buildMockData();
|
||||
req.userId = ownerUserId;
|
||||
await expect(authMiddleware(req)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
req.userId = adminUser.id;
|
||||
test("should not throw error for booking where user is team owner or admin", async () => {
|
||||
const { req: req1 } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
query: {
|
||||
id: 1,
|
||||
},
|
||||
prisma: prismock,
|
||||
});
|
||||
const { req: req2 } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
query: {
|
||||
id: 1,
|
||||
},
|
||||
prisma: prismock,
|
||||
});
|
||||
buildMockData();
|
||||
|
||||
req1.userId = adminUserId;
|
||||
req2.userId = ownerUserId;
|
||||
|
||||
await expect(authMiddleware(req1)).resolves.not.toThrow();
|
||||
await expect(authMiddleware(req2)).resolves.not.toThrow();
|
||||
});
|
||||
test("should throw error for booking where user is not team owner or admin", async () => {
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
query: {
|
||||
id: 1,
|
||||
},
|
||||
prisma: prismock,
|
||||
});
|
||||
buildMockData();
|
||||
|
||||
req.userId = memberUserId;
|
||||
|
||||
await expect(authMiddleware(req)).rejects.toThrow();
|
||||
});
|
||||
test("should not throw error when user is system-wide admin", async () => {
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
query: {
|
||||
id: 2,
|
||||
},
|
||||
prisma: prismock,
|
||||
});
|
||||
buildMockData();
|
||||
req.userId = adminUserId;
|
||||
req.isSystemWideAdmin = true;
|
||||
|
||||
await authMiddleware(req);
|
||||
});
|
||||
|
||||
it("No error is thrown when user is org-wide admin", async () => {
|
||||
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
|
||||
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member1-acme@example.com" } });
|
||||
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: memberUser.id } });
|
||||
|
||||
it("should throw error when user is org-wide admin", async () => {
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
body: {},
|
||||
query: {
|
||||
id: booking.id,
|
||||
id: 1,
|
||||
},
|
||||
prisma: prismock,
|
||||
});
|
||||
|
||||
req.userId = adminUser.id;
|
||||
buildMockData();
|
||||
req.userId = orgOwnerUserId;
|
||||
req.isOrganizationOwnerOrAdmin = true;
|
||||
|
||||
await authMiddleware(req);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import prismaMock from "../../../../../../../tests/libs/__mocks__/prismaMock";
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, expect, test, beforeEach, vi } from "vitest";
|
||||
|
||||
import { buildEventType } from "@calcom/lib/test/builder";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import handler from "../../../../pages/api/event-types/[id]/_delete";
|
||||
|
||||
type CustomNextApiRequest = NextApiRequest & Request;
|
||||
type CustomNextApiResponse = NextApiResponse & Response;
|
||||
|
||||
describe("DELETE /api/event-types/[id]", () => {
|
||||
const eventTypeId = 1234567;
|
||||
const teamId = 9999;
|
||||
const adminUser = 1111;
|
||||
const memberUser = 2222;
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// Mocking membership.findFirst
|
||||
prismaMock.membership.findFirst.mockImplementation(({ where }) => {
|
||||
const { userId, teamId, accepted, role } = where;
|
||||
const mockData = [
|
||||
{ userId: 1111, teamId: teamId, accepted: true, role: MembershipRole.ADMIN },
|
||||
{ userId: 2222, teamId: teamId, accepted: true, role: MembershipRole.MEMBER },
|
||||
];
|
||||
// Return the correct user based on the query conditions
|
||||
return mockData.find(
|
||||
(membership) =>
|
||||
membership.userId === userId &&
|
||||
membership.teamId === teamId &&
|
||||
membership.accepted === accepted &&
|
||||
role.in.includes(membership.role)
|
||||
);
|
||||
});
|
||||
|
||||
// Mocking eventType.findFirst
|
||||
prismaMock.eventType.findFirst.mockResolvedValue(
|
||||
buildEventType({
|
||||
id: eventTypeId,
|
||||
teamId,
|
||||
})
|
||||
);
|
||||
|
||||
// Mocking team.findUnique
|
||||
prismaMock.team.findUnique.mockResolvedValue({
|
||||
id: teamId,
|
||||
members: [
|
||||
{ userId: memberUser, role: MembershipRole.MEMBER, teamId: teamId },
|
||||
{ userId: adminUser, role: MembershipRole.ADMIN, teamId: teamId },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error", async () => {
|
||||
test("Fails to remove event type if user is not OWNER/ADMIN of team associated with event type", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "DELETE",
|
||||
body: {},
|
||||
query: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
});
|
||||
|
||||
// Assign userId to the request objects
|
||||
req.userId = memberUser;
|
||||
|
||||
await handler(req, res);
|
||||
expect(res.statusCode).toBe(403); // Check if the deletion was successful
|
||||
});
|
||||
});
|
||||
|
||||
describe("Success", async () => {
|
||||
test("Removes event type if user is owner of team associated with event type", async () => {
|
||||
// Mocks for DELETE request
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "DELETE",
|
||||
body: {},
|
||||
query: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
});
|
||||
|
||||
// Assign userId to the request objects
|
||||
req.userId = adminUser;
|
||||
|
||||
await handler(req, res);
|
||||
expect(res.statusCode).toBe(200); // Check if the deletion was successful
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:latest
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"dev:build:watch": "yarn workspace @calcom/platform-constants build:watch & yarn workspace @calcom/platform-utils build:watch & yarn workspace @calcom/platform-types build:watch",
|
||||
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-enums build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build ",
|
||||
"dev": "yarn dev:build && docker-compose up -d && yarn copy-swagger-module && yarn start --watch",
|
||||
"dev:no-docker": "yarn dev:build && yarn copy-swagger-module && yarn start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node ./dist/apps/api/v2/src/main.js",
|
||||
"test": "yarn dev:build && jest",
|
||||
@@ -19,6 +20,7 @@
|
||||
"test:cov": "yarn dev:build && jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json",
|
||||
"test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json --watch",
|
||||
"prisma": "yarn workspace @calcom/prisma prisma",
|
||||
"generate-schemas": "yarn prisma generate && yarn prisma format",
|
||||
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts"
|
||||
@@ -26,7 +28,7 @@
|
||||
"dependencies": {
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.30",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.36",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
|
||||
@@ -22,7 +22,7 @@ import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { handleNewBooking } from "@calcom/platform-libraries";
|
||||
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
|
||||
import { ApiSuccessResponse, ApiResponse, ApiErrorResponse } from "@calcom/platform-types";
|
||||
|
||||
describe("Bookings Endpoints", () => {
|
||||
describe("User Authenticated", () => {
|
||||
@@ -144,6 +144,49 @@ describe("Bookings Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail to create a booking with no_available_users_found_error", async () => {
|
||||
const bookingStart = "2040-05-21T09:30:00.000Z";
|
||||
const bookingEnd = "2040-05-21T10:30:00.000Z";
|
||||
const bookingEventTypeId = eventTypeId;
|
||||
const bookingTimeZone = "Europe/London";
|
||||
const bookingLanguage = "en";
|
||||
const bookingHashedLink = "";
|
||||
const bookingMetadata = {
|
||||
timeFormat: "12",
|
||||
meetingType: "organizer-phone",
|
||||
};
|
||||
const bookingResponses = {
|
||||
name: "tester",
|
||||
email: "tester@example.com",
|
||||
location: {
|
||||
value: "link",
|
||||
optionValue: "",
|
||||
},
|
||||
notes: "test",
|
||||
guests: [],
|
||||
};
|
||||
|
||||
const body: CreateBookingInput = {
|
||||
start: bookingStart,
|
||||
end: bookingEnd,
|
||||
eventTypeId: bookingEventTypeId,
|
||||
timeZone: bookingTimeZone,
|
||||
language: bookingLanguage,
|
||||
metadata: bookingMetadata,
|
||||
hashedLink: bookingHashedLink,
|
||||
responses: bookingResponses,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.expect(400)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiErrorResponse = response.body;
|
||||
expect(responseBody.error.message).toEqual("no_available_users_found_error");
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a booking with api key to get owner id", async () => {
|
||||
const bookingStart = "2040-05-22T09:30:00.000Z";
|
||||
const bookingEnd = "2040-05-22T10:30:00.000Z";
|
||||
|
||||
@@ -35,6 +35,7 @@ import { ApiQuery, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { User } from "@prisma/client";
|
||||
import { Request } from "express";
|
||||
import { NextApiRequest } from "next/types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
||||
import { BOOKING_READ, SUCCESS_STATUS, BOOKING_WRITE } from "@calcom/platform-constants";
|
||||
@@ -49,6 +50,7 @@ import {
|
||||
getBookingInfo,
|
||||
handleCancelBooking,
|
||||
getBookingForReschedule,
|
||||
ErrorCode,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
@@ -243,8 +245,15 @@ export class BookingsController {
|
||||
): Promise<ApiResponse<BookingResponse[]>> {
|
||||
const oAuthClientId = clientId?.toString();
|
||||
try {
|
||||
const recurringEventId = uuidv4();
|
||||
for (const recurringEvent of req.body) {
|
||||
if (!recurringEvent.recurringEventId) {
|
||||
recurringEvent.recurringEventId = recurringEventId;
|
||||
}
|
||||
}
|
||||
|
||||
const createdBookings: BookingResponse[] = await handleNewRecurringBooking(
|
||||
await this.createNextApiBookingRequest(req, oAuthClientId)
|
||||
await this.createNextApiRecurringBookingRequest(req, oAuthClientId)
|
||||
);
|
||||
|
||||
createdBookings.forEach(async (booking) => {
|
||||
@@ -351,6 +360,19 @@ export class BookingsController {
|
||||
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
||||
}
|
||||
|
||||
private async createNextApiRecurringBookingRequest(
|
||||
req: BookingRequest,
|
||||
oAuthClientId?: string,
|
||||
platformBookingLocation?: string
|
||||
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
|
||||
const userId = (await this.getOwnerId(req)) ?? -1;
|
||||
const oAuthParams = oAuthClientId
|
||||
? await this.getOAuthClientsParams(oAuthClientId)
|
||||
: DEFAULT_PLATFORM_PARAMS;
|
||||
Object.assign(req, { userId, ...oAuthParams, platformBookingLocation });
|
||||
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
||||
}
|
||||
|
||||
private handleBookingErrors(
|
||||
err: Error | HttpError | unknown,
|
||||
type?: "recurring" | `instant` | "no-show"
|
||||
@@ -366,6 +388,9 @@ export class BookingsController {
|
||||
|
||||
if (err instanceof Error) {
|
||||
const error = err as Error;
|
||||
if (Object.values(ErrorCode).includes(error.message as unknown as ErrorCode)) {
|
||||
throw new HttpException(error.message, 400);
|
||||
}
|
||||
throw new InternalServerErrorException(error?.message ?? errMsg);
|
||||
}
|
||||
|
||||
|
||||
@@ -59,10 +59,6 @@ export class CreateBookingInput {
|
||||
@IsOptional()
|
||||
rescheduleUid?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
recurringEventId?: string;
|
||||
|
||||
@IsTimeZone()
|
||||
timeZone!: string;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
||||
import { IsBoolean, IsNumber, IsOptional } from "class-validator";
|
||||
import { IsBoolean, IsString, IsNumber, IsOptional } from "class-validator";
|
||||
|
||||
import type { AppsStatus } from "@calcom/platform-libraries";
|
||||
|
||||
@@ -21,4 +21,8 @@ export class CreateRecurringBookingInput extends CreateBookingInput {
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
currentRecurringIndex?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
recurringEventId?: string;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { SUCCESS_STATUS, APPLE_CALENDAR_TYPE, APPLE_CALENDAR_ID } from "@calcom/platform-constants";
|
||||
import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries";
|
||||
import { symmetricEncrypt, CalendarService, symmetricDecrypt } from "@calcom/platform-libraries";
|
||||
import { Credential } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
@@ -61,26 +62,70 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
if (username.length <= 1 || password.length <= 1)
|
||||
throw new BadRequestException(`Username or password cannot be empty`);
|
||||
|
||||
const data = {
|
||||
type: APPLE_CALENDAR_TYPE,
|
||||
key: symmetricEncrypt(
|
||||
JSON.stringify({ username, password }),
|
||||
process.env.CALENDSO_ENCRYPTION_KEY || ""
|
||||
),
|
||||
userId: userId,
|
||||
teamId: null,
|
||||
appId: APPLE_CALENDAR_ID,
|
||||
invalid: false,
|
||||
};
|
||||
const existingAppleCalendarCredentials = await this.credentialRepository.getAllUserCredentialsByTypeAndId(
|
||||
APPLE_CALENDAR_TYPE,
|
||||
userId
|
||||
);
|
||||
|
||||
let hasMatchingUsernameAndPassword = false;
|
||||
|
||||
if (existingAppleCalendarCredentials.length > 0) {
|
||||
const hasCalendarWithGivenCredentials = existingAppleCalendarCredentials.find(
|
||||
(calendarCredential: Credential) => {
|
||||
const decryptedKey = JSON.parse(
|
||||
symmetricDecrypt(calendarCredential.key as string, process.env.CALENDSO_ENCRYPTION_KEY || "")
|
||||
);
|
||||
|
||||
if (decryptedKey.username === username) {
|
||||
if (decryptedKey.password === password) {
|
||||
hasMatchingUsernameAndPassword = true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!!hasCalendarWithGivenCredentials && hasMatchingUsernameAndPassword) {
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
if (!!hasCalendarWithGivenCredentials && !hasMatchingUsernameAndPassword) {
|
||||
await this.credentialRepository.upsertAppCredential(
|
||||
APPLE_CALENDAR_TYPE,
|
||||
symmetricEncrypt(JSON.stringify({ username, password }), process.env.CALENDSO_ENCRYPTION_KEY || ""),
|
||||
userId,
|
||||
hasCalendarWithGivenCredentials.id
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const data = {
|
||||
type: APPLE_CALENDAR_TYPE,
|
||||
key: symmetricEncrypt(
|
||||
JSON.stringify({ username, password }),
|
||||
process.env.CALENDSO_ENCRYPTION_KEY || ""
|
||||
),
|
||||
userId: userId,
|
||||
teamId: null,
|
||||
appId: APPLE_CALENDAR_ID,
|
||||
invalid: false,
|
||||
};
|
||||
|
||||
const dav = new CalendarService({
|
||||
id: 0,
|
||||
...data,
|
||||
user: { email: userEmail },
|
||||
});
|
||||
await dav?.listCalendars();
|
||||
await this.credentialRepository.createAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
|
||||
await this.credentialRepository.upsertAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
|
||||
} catch (reason) {
|
||||
throw new BadRequestException(`Could not add this apple calendar account: ${reason}`);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@/modules/credentials/credentials.repository";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Injectable,
|
||||
@@ -15,9 +16,11 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { User } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { DateTime } from "luxon";
|
||||
import { z } from "zod";
|
||||
|
||||
import { APPS_TYPE_ID_MAPPING } from "@calcom/platform-constants";
|
||||
import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries";
|
||||
import { Calendar } from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
@@ -33,7 +36,8 @@ export class CalendarsService {
|
||||
private readonly calendarsRepository: CalendarsRepository,
|
||||
private readonly dbRead: PrismaReadService,
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private readonly config: ConfigService
|
||||
private readonly config: ConfigService,
|
||||
private readonly selectedCalendarsRepository: SelectedCalendarsRepository
|
||||
) {}
|
||||
|
||||
async getCalendars(userId: number) {
|
||||
@@ -145,4 +149,32 @@ export class CalendarsService {
|
||||
throw new NotFoundException("Calendar credentials not found");
|
||||
}
|
||||
}
|
||||
|
||||
async createAndLinkCalendarEntry(
|
||||
userId: number,
|
||||
externalId: string,
|
||||
key: Prisma.InputJsonValue,
|
||||
calendarType: keyof typeof APPS_TYPE_ID_MAPPING,
|
||||
credentialId?: number | null
|
||||
) {
|
||||
const credential = await this.credentialsRepository.upsertAppCredential(
|
||||
calendarType,
|
||||
key,
|
||||
userId,
|
||||
credentialId
|
||||
);
|
||||
|
||||
await this.selectedCalendarsRepository.upsertSelectedCalendar(
|
||||
externalId,
|
||||
credential.id,
|
||||
userId,
|
||||
calendarType
|
||||
);
|
||||
}
|
||||
|
||||
async checkCalendarCredentialValidity(userId: number, credentialId: number, type: string) {
|
||||
const credential = await this.credentialsRepository.getUserCredentialById(userId, credentialId, type);
|
||||
|
||||
return !credential?.invalid;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,11 +131,6 @@ export class GoogleCalendarService implements OAuthCalendarApp {
|
||||
const token = await oAuth2Client.getToken(parsedCode);
|
||||
// Google oAuth Credentials are stored in token.tokens
|
||||
const key = token.tokens;
|
||||
const credential = await this.credentialRepository.createAppCredential(
|
||||
GOOGLE_CALENDAR_TYPE,
|
||||
key as Prisma.InputJsonValue,
|
||||
ownerId
|
||||
);
|
||||
|
||||
oAuth2Client.setCredentials(key);
|
||||
|
||||
@@ -149,10 +144,39 @@ export class GoogleCalendarService implements OAuthCalendarApp {
|
||||
const primaryCal = cals.data.items?.find((cal) => cal.primary);
|
||||
|
||||
if (primaryCal?.id) {
|
||||
await this.selectedCalendarsRepository.createSelectedCalendar(
|
||||
primaryCal.id,
|
||||
credential.id,
|
||||
const alreadyExistingSelectedCalendar = await this.selectedCalendarsRepository.getUserSelectedCalendar(
|
||||
ownerId,
|
||||
GOOGLE_CALENDAR_TYPE,
|
||||
primaryCal.id
|
||||
);
|
||||
|
||||
if (alreadyExistingSelectedCalendar) {
|
||||
const isCredentialValid = await this.calendarsService.checkCalendarCredentialValidity(
|
||||
ownerId,
|
||||
alreadyExistingSelectedCalendar.credentialId ?? 0,
|
||||
GOOGLE_CALENDAR_TYPE
|
||||
);
|
||||
|
||||
// user credential probably got expired in this case
|
||||
if (!isCredentialValid) {
|
||||
await this.calendarsService.createAndLinkCalendarEntry(
|
||||
ownerId,
|
||||
alreadyExistingSelectedCalendar.externalId,
|
||||
key as Prisma.InputJsonValue,
|
||||
GOOGLE_CALENDAR_TYPE,
|
||||
alreadyExistingSelectedCalendar.credentialId
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
url: redir || origin,
|
||||
};
|
||||
}
|
||||
|
||||
await this.calendarsService.createAndLinkCalendarEntry(
|
||||
ownerId,
|
||||
primaryCal.id,
|
||||
key as Prisma.InputJsonValue,
|
||||
GOOGLE_CALENDAR_TYPE
|
||||
);
|
||||
}
|
||||
|
||||
@@ -168,16 +168,39 @@ export class OutlookService implements OAuthCalendarApp {
|
||||
const defaultCalendar = await this.getDefaultCalendar(office365OAuthCredentials.access_token);
|
||||
|
||||
if (defaultCalendar?.id) {
|
||||
const credential = await this.credentialRepository.createAppCredential(
|
||||
const alreadyExistingSelectedCalendar = await this.selectedCalendarsRepository.getUserSelectedCalendar(
|
||||
ownerId,
|
||||
OFFICE_365_CALENDAR_TYPE,
|
||||
office365OAuthCredentials,
|
||||
ownerId
|
||||
defaultCalendar.id
|
||||
);
|
||||
|
||||
await this.selectedCalendarsRepository.createSelectedCalendar(
|
||||
defaultCalendar.id,
|
||||
credential.id,
|
||||
if (alreadyExistingSelectedCalendar) {
|
||||
const isCredentialValid = await this.calendarsService.checkCalendarCredentialValidity(
|
||||
ownerId,
|
||||
alreadyExistingSelectedCalendar.credentialId ?? 0,
|
||||
OFFICE_365_CALENDAR_TYPE
|
||||
);
|
||||
|
||||
// user credential probably got expired in this case
|
||||
if (!isCredentialValid) {
|
||||
await this.calendarsService.createAndLinkCalendarEntry(
|
||||
ownerId,
|
||||
alreadyExistingSelectedCalendar.externalId,
|
||||
office365OAuthCredentials,
|
||||
OFFICE_365_CALENDAR_TYPE,
|
||||
alreadyExistingSelectedCalendar.credentialId
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
url: redir || origin,
|
||||
};
|
||||
}
|
||||
|
||||
await this.calendarsService.createAndLinkCalendarEntry(
|
||||
ownerId,
|
||||
defaultCalendar.id,
|
||||
office365OAuthCredentials,
|
||||
OFFICE_365_CALENDAR_TYPE
|
||||
);
|
||||
}
|
||||
|
||||
+2
-2
@@ -36,7 +36,7 @@ import {
|
||||
InternalServerErrorException,
|
||||
ParseIntPipe,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
|
||||
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2";
|
||||
@@ -47,7 +47,7 @@ import { PrismaClient } from "@calcom/prisma";
|
||||
version: [VERSION_2024_04_15, VERSION_2024_06_11],
|
||||
})
|
||||
@UseGuards(PermissionsGuard)
|
||||
@DocsTags("Event types")
|
||||
@DocsExcludeController(true)
|
||||
export class EventTypesController_2024_04_15 {
|
||||
constructor(
|
||||
private readonly eventTypesService: EventTypesService_2024_04_15,
|
||||
|
||||
+11
-1
@@ -15,6 +15,7 @@ import {
|
||||
updateEventType,
|
||||
EventTypesPublic,
|
||||
getEventTypesPublic,
|
||||
systemBeforeFieldEmail,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { EventType } from "@calcom/prisma/client";
|
||||
|
||||
@@ -127,8 +128,17 @@ export class EventTypesService_2024_04_15 {
|
||||
async updateEventType(eventTypeId: number, body: UpdateEventTypeInput_2024_04_15, user: UserWithProfile) {
|
||||
this.checkCanUpdateEventType(user.id, eventTypeId);
|
||||
const eventTypeUser = await this.getUserToUpdateEvent(user);
|
||||
const bookingFields = [...(body.bookingFields || [])];
|
||||
|
||||
if (
|
||||
!bookingFields.find((field) => field.type === "email") &&
|
||||
!bookingFields.find((field) => field.type === "phone")
|
||||
) {
|
||||
bookingFields.push(systemBeforeFieldEmail);
|
||||
}
|
||||
|
||||
await updateEventType({
|
||||
input: { id: eventTypeId, ...body },
|
||||
input: { id: eventTypeId, ...body, bookingFields },
|
||||
ctx: {
|
||||
user: eventTypeUser,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
|
||||
+25
-4
@@ -250,7 +250,6 @@ describe("Event types Endpoints", () => {
|
||||
expect(createdEventType.description).toEqual(body.description);
|
||||
expect(createdEventType.lengthInMinutes).toEqual(body.lengthInMinutes);
|
||||
expect(createdEventType.locations).toEqual(body.locations);
|
||||
expect(createdEventType.bookingFields).toEqual(body.bookingFields);
|
||||
expect(createdEventType.ownerId).toEqual(user.id);
|
||||
expect(createdEventType.scheduleId).toEqual(firstSchedule.id);
|
||||
expect(createdEventType.bookingLimitsCount).toEqual(body.bookingLimitsCount);
|
||||
@@ -259,6 +258,16 @@ describe("Event types Endpoints", () => {
|
||||
expect(createdEventType.offsetStart).toEqual(body.offsetStart);
|
||||
expect(createdEventType.bookingWindow).toEqual(body.bookingWindow);
|
||||
expect(createdEventType.recurrence).toEqual(body.recurrence);
|
||||
|
||||
const responseBookingFields = body.bookingFields || [];
|
||||
const expectedBookingFields = [
|
||||
{ isDefault: true, required: true, slug: "name", type: "name" },
|
||||
{ isDefault: true, required: true, slug: "email", type: "email" },
|
||||
{ isDefault: true, required: false, slug: "rescheduleReason", type: "textarea" },
|
||||
...responseBookingFields.map((field) => ({ isDefault: false, ...field })),
|
||||
];
|
||||
|
||||
expect(createdEventType.bookingFields).toEqual(expectedBookingFields);
|
||||
eventType = responseBody.data;
|
||||
});
|
||||
});
|
||||
@@ -476,6 +485,16 @@ describe("Event types Endpoints", () => {
|
||||
let legacyEventTypeId1: number;
|
||||
let legacyEventTypeId2: number;
|
||||
|
||||
const expectedReturnSystemFields = [
|
||||
{ isDefault: true, required: true, slug: "name", type: "name" },
|
||||
{ isDefault: true, required: true, slug: "email", type: "email" },
|
||||
{ isDefault: true, type: "radioInput", slug: "location", required: false },
|
||||
{ isDefault: true, required: true, slug: "title", type: "text" },
|
||||
{ isDefault: true, required: false, slug: "notes", type: "textarea" },
|
||||
{ isDefault: true, required: false, slug: "guests", type: "multiemail" },
|
||||
{ isDefault: true, required: false, slug: "rescheduleReason", type: "textarea" },
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
@@ -545,7 +564,7 @@ describe("Event types Endpoints", () => {
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should return empty bookingFields if system fields are the only one in database", async () => {
|
||||
it("should return system bookingFields stored in database", async () => {
|
||||
const legacyEventTypeInput = {
|
||||
title: "legacy event type",
|
||||
description: "legacy event type description",
|
||||
@@ -638,11 +657,11 @@ describe("Event types Endpoints", () => {
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const fetchedEventType = responseBody.data;
|
||||
expect(fetchedEventType.bookingFields).toEqual([]);
|
||||
expect(fetchedEventType.bookingFields).toEqual(expectedReturnSystemFields);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return user created bookingFields among system fields in the database", async () => {
|
||||
it("should return user created bookingFields with system fields", async () => {
|
||||
const userDefinedBookingField = {
|
||||
name: "team",
|
||||
type: "textarea",
|
||||
@@ -755,7 +774,9 @@ describe("Event types Endpoints", () => {
|
||||
const fetchedEventType = responseBody.data;
|
||||
|
||||
expect(fetchedEventType.bookingFields).toEqual([
|
||||
...expectedReturnSystemFields,
|
||||
{
|
||||
isDefault: false,
|
||||
type: userDefinedBookingField.type,
|
||||
slug: userDefinedBookingField.name,
|
||||
label: userDefinedBookingField.label,
|
||||
|
||||
+30
-1
@@ -24,7 +24,7 @@ import {
|
||||
Delete,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { ApiHeader, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import {
|
||||
@@ -39,12 +39,23 @@ import {
|
||||
})
|
||||
@UseGuards(PermissionsGuard)
|
||||
@DocsTags("Event types")
|
||||
@ApiHeader({
|
||||
name: "cal-api-version",
|
||||
description: `Must be set to \`2024-06-14\``,
|
||||
required: true,
|
||||
})
|
||||
export class EventTypesController_2024_06_14 {
|
||||
constructor(private readonly eventTypesService: EventTypesService_2024_06_14) {}
|
||||
|
||||
@Post("/")
|
||||
@Permissions([EVENT_TYPE_WRITE])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||
required: true,
|
||||
})
|
||||
async createEventType(
|
||||
@Body() body: CreateEventTypeInput_2024_06_14,
|
||||
@GetUser() user: UserWithProfile
|
||||
@@ -60,6 +71,12 @@ export class EventTypesController_2024_06_14 {
|
||||
@Get("/:eventTypeId")
|
||||
@Permissions([EVENT_TYPE_READ])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||
required: true,
|
||||
})
|
||||
async getEventTypeById(
|
||||
@Param("eventTypeId") eventTypeId: string,
|
||||
@GetUser() user: UserWithProfile
|
||||
@@ -91,6 +108,12 @@ export class EventTypesController_2024_06_14 {
|
||||
@Patch("/:eventTypeId")
|
||||
@Permissions([EVENT_TYPE_WRITE])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||
required: true,
|
||||
})
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async updateEventType(
|
||||
@Param("eventTypeId") eventTypeId: number,
|
||||
@@ -108,6 +131,12 @@ export class EventTypesController_2024_06_14 {
|
||||
@Delete("/:eventTypeId")
|
||||
@Permissions([EVENT_TYPE_WRITE])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||
required: true,
|
||||
})
|
||||
async deleteEventType(
|
||||
@Param("eventTypeId") eventTypeId: number,
|
||||
@GetUser("id") userId: number
|
||||
|
||||
@@ -6,8 +6,8 @@ import { Injectable } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
getEventTypeById,
|
||||
transformApiEventTypeBookingFields,
|
||||
transformApiEventTypeLocations,
|
||||
transformBookingFieldsApiToInternal,
|
||||
transformLocationsApiToInternal,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { CreateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
@@ -30,8 +30,8 @@ type InputEventTransformed = Omit<
|
||||
> & {
|
||||
length: number;
|
||||
slug: string;
|
||||
locations?: ReturnType<typeof transformApiEventTypeLocations>;
|
||||
bookingFields?: ReturnType<typeof transformApiEventTypeBookingFields>;
|
||||
locations?: ReturnType<typeof transformLocationsApiToInternal>;
|
||||
bookingFields?: ReturnType<typeof transformBookingFieldsApiToInternal>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { ApiProperty as DocsProperty } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsString, IsBoolean, IsOptional, IsEnum, Matches } from "class-validator";
|
||||
|
||||
export enum TemplateType {
|
||||
CHECK_IN_APPOINTMENT = "CHECK_IN_APPOINTMENT",
|
||||
CUSTOM_TEMPLATE = "CUSTOM_TEMPLATE",
|
||||
}
|
||||
|
||||
export class CreatePhoneCallInput {
|
||||
@IsString()
|
||||
@Matches(/^\+[1-9]\d{1,14}$/, {
|
||||
message:
|
||||
"Invalid phone number format. Expected format: +<CountryCode><PhoneNumber> with no spaces or separators.",
|
||||
})
|
||||
@DocsProperty({ description: "Your phone number" })
|
||||
yourPhoneNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^\+[1-9]\d{1,14}$/, {
|
||||
message:
|
||||
"Invalid phone number format. Expected format: +<CountryCode><PhoneNumber> with no spaces or separators.",
|
||||
})
|
||||
@DocsProperty({ description: "Number to call" })
|
||||
numberToCall!: string;
|
||||
|
||||
@IsString()
|
||||
@DocsProperty({ description: "CAL API Key" })
|
||||
calApiKey!: string;
|
||||
|
||||
@IsBoolean()
|
||||
@DocsProperty({ description: "Enabled status", default: true })
|
||||
enabled = true;
|
||||
|
||||
@IsEnum(TemplateType)
|
||||
@DocsProperty({ description: "Template type", enum: TemplateType })
|
||||
templateType: TemplateType = TemplateType.CUSTOM_TEMPLATE;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@DocsProperty({ description: "Scheduler name" })
|
||||
schedulerName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(({ value }) => (value ? value : undefined))
|
||||
@DocsProperty({ description: "Guest name" })
|
||||
guestName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(({ value }) => (value ? value : undefined))
|
||||
@DocsProperty({ description: "Guest email" })
|
||||
guestEmail?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(({ value }) => (value ? value : undefined))
|
||||
@DocsProperty({ description: "Guest company" })
|
||||
guestCompany?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@DocsProperty({ description: "Begin message" })
|
||||
beginMessage?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@DocsProperty({ description: "General prompt" })
|
||||
generalPrompt?: string;
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
class Data {
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
callId!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
agentId!: string;
|
||||
}
|
||||
|
||||
export class CreatePhoneCallOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: Data,
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => Data)
|
||||
data!: Data;
|
||||
}
|
||||
+34
-13
@@ -1,12 +1,16 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
transformApiEventTypeBookingFields,
|
||||
transformApiEventTypeLocations,
|
||||
transformApiEventTypeIntervalLimits,
|
||||
transformApiEventTypeFutureBookingLimits,
|
||||
transformApiEventTypeRecurrence,
|
||||
transformBookingFieldsApiToInternal,
|
||||
transformLocationsApiToInternal,
|
||||
transformIntervalLimitsApiToInternal,
|
||||
transformFutureBookingLimitsApiToInternal,
|
||||
transformRecurrenceApiToInternal,
|
||||
systemBeforeFieldName,
|
||||
systemBeforeFieldEmail,
|
||||
systemAfterFieldRescheduleReason,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { systemBeforeFieldLocation } from "@calcom/platform-libraries";
|
||||
import { CreateEventTypeInput_2024_06_14, UpdateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
@@ -30,11 +34,12 @@ export class InputEventTypesService_2024_06_14 {
|
||||
...rest
|
||||
} = inputEventType;
|
||||
|
||||
const hasMultipleLocations = (locations || defaultLocations).length > 1;
|
||||
const eventType = {
|
||||
...rest,
|
||||
length: lengthInMinutes,
|
||||
locations: this.transformInputLocations(locations || defaultLocations),
|
||||
bookingFields: this.transformInputBookingFields(bookingFields),
|
||||
bookingFields: this.transformInputBookingFields(bookingFields, hasMultipleLocations),
|
||||
bookingLimits: bookingLimitsCount ? this.transformInputIntervalLimits(bookingLimitsCount) : undefined,
|
||||
durationLimits: bookingLimitsDuration
|
||||
? this.transformInputIntervalLimits(bookingLimitsDuration)
|
||||
@@ -59,11 +64,15 @@ export class InputEventTypesService_2024_06_14 {
|
||||
...rest
|
||||
} = inputEventType;
|
||||
|
||||
const hasMultipleLocations = !!(locations && locations?.length > 1);
|
||||
|
||||
const eventType = {
|
||||
...rest,
|
||||
length: lengthInMinutes,
|
||||
locations: locations ? this.transformInputLocations(locations) : undefined,
|
||||
bookingFields: bookingFields ? this.transformInputBookingFields(bookingFields) : undefined,
|
||||
bookingFields: bookingFields
|
||||
? this.transformInputBookingFields(bookingFields, hasMultipleLocations)
|
||||
: undefined,
|
||||
schedule: scheduleId,
|
||||
bookingLimits: bookingLimitsCount ? this.transformInputIntervalLimits(bookingLimitsCount) : undefined,
|
||||
durationLimits: bookingLimitsDuration
|
||||
@@ -77,23 +86,35 @@ export class InputEventTypesService_2024_06_14 {
|
||||
}
|
||||
|
||||
transformInputLocations(inputLocations: CreateEventTypeInput_2024_06_14["locations"]) {
|
||||
return transformApiEventTypeLocations(inputLocations);
|
||||
return transformLocationsApiToInternal(inputLocations);
|
||||
}
|
||||
|
||||
transformInputBookingFields(inputBookingFields: CreateEventTypeInput_2024_06_14["bookingFields"]) {
|
||||
return transformApiEventTypeBookingFields(inputBookingFields);
|
||||
transformInputBookingFields(
|
||||
inputBookingFields: CreateEventTypeInput_2024_06_14["bookingFields"],
|
||||
hasMultipleLocations: boolean
|
||||
) {
|
||||
const defaultFieldsBefore = [systemBeforeFieldName, systemBeforeFieldEmail];
|
||||
// note(Lauris): if event type has multiple locations then a radio button booking field has to be displayed to allow booker to pick location
|
||||
if (hasMultipleLocations) {
|
||||
defaultFieldsBefore.push(systemBeforeFieldLocation);
|
||||
}
|
||||
|
||||
const customFields = transformBookingFieldsApiToInternal(inputBookingFields);
|
||||
const defaultFieldsAfter = [systemAfterFieldRescheduleReason];
|
||||
|
||||
return [...defaultFieldsBefore, ...customFields, ...defaultFieldsAfter];
|
||||
}
|
||||
|
||||
transformInputIntervalLimits(inputBookingFields: CreateEventTypeInput_2024_06_14["bookingLimitsCount"]) {
|
||||
return transformApiEventTypeIntervalLimits(inputBookingFields);
|
||||
return transformIntervalLimitsApiToInternal(inputBookingFields);
|
||||
}
|
||||
|
||||
transformInputBookingWindow(inputBookingWindow: CreateEventTypeInput_2024_06_14["bookingWindow"]) {
|
||||
const res = transformApiEventTypeFutureBookingLimits(inputBookingWindow);
|
||||
const res = transformFutureBookingLimitsApiToInternal(inputBookingWindow);
|
||||
return !!res ? res : {};
|
||||
}
|
||||
|
||||
transformInputRecurrignEvent(recurrence: CreateEventTypeInput_2024_06_14["recurrence"]) {
|
||||
return transformApiEventTypeRecurrence(recurrence);
|
||||
return transformRecurrenceApiToInternal(recurrence);
|
||||
}
|
||||
}
|
||||
|
||||
+15
-14
@@ -4,17 +4,17 @@ import type { EventType, User, Schedule } from "@prisma/client";
|
||||
import {
|
||||
EventTypeMetaDataSchema,
|
||||
userMetadata,
|
||||
getResponseEventTypeLocations,
|
||||
getResponseEventTypeBookingFields,
|
||||
transformLocationsInternalToApi,
|
||||
transformBookingFieldsInternalToApi,
|
||||
parseRecurringEvent,
|
||||
TransformedLocationsSchema,
|
||||
BookingFieldsSchema,
|
||||
SystemField,
|
||||
UserField,
|
||||
CustomField,
|
||||
parseBookingLimit,
|
||||
getResponseEventTypeIntervalLimits,
|
||||
getResponseEventTypeFutureBookingLimits,
|
||||
getResponseEventTypeRecurrence,
|
||||
transformIntervalLimitsInternalToApi,
|
||||
transformFutureBookingLimitsInternalToApi,
|
||||
transformRecurrenceInternalToApi,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { TransformFutureBookingsLimitSchema_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
@@ -144,19 +144,20 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
|
||||
transformLocations(locations: any) {
|
||||
if (!locations) return [];
|
||||
return getResponseEventTypeLocations(TransformedLocationsSchema.parse(locations));
|
||||
return transformLocationsInternalToApi(TransformedLocationsSchema.parse(locations));
|
||||
}
|
||||
|
||||
transformBookingFields(inputBookingFields: (SystemField | UserField)[] | null) {
|
||||
if (!inputBookingFields) return [];
|
||||
const userFields = inputBookingFields.filter((field) => field.editable === "user") as UserField[];
|
||||
return getResponseEventTypeBookingFields(userFields);
|
||||
transformBookingFields(bookingFields: (SystemField | CustomField)[] | null) {
|
||||
if (!bookingFields) return [];
|
||||
|
||||
return transformBookingFieldsInternalToApi(bookingFields);
|
||||
}
|
||||
|
||||
transformRecurringEvent(recurringEvent: any) {
|
||||
if (!recurringEvent) return null;
|
||||
const recurringEventParsed = parseRecurringEvent(recurringEvent);
|
||||
return getResponseEventTypeRecurrence(recurringEventParsed);
|
||||
if (!recurringEventParsed) return null;
|
||||
return transformRecurrenceInternalToApi(recurringEventParsed);
|
||||
}
|
||||
|
||||
transformMetadata(metadata: any) {
|
||||
@@ -182,10 +183,10 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
|
||||
transformIntervalLimits(bookingLimits: any) {
|
||||
const bookingLimitsParsed = parseBookingLimit(bookingLimits);
|
||||
return getResponseEventTypeIntervalLimits(bookingLimitsParsed);
|
||||
return transformIntervalLimitsInternalToApi(bookingLimitsParsed);
|
||||
}
|
||||
|
||||
transformBookingWindow(bookingLimits: TransformFutureBookingsLimitSchema_2024_06_14) {
|
||||
return getResponseEventTypeFutureBookingLimits(bookingLimits);
|
||||
return transformFutureBookingLimitsInternalToApi(bookingLimits);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEnum } from "class-validator";
|
||||
import { IsEnum, IsNumber, IsString } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class ProviderVerifyClientData {
|
||||
@IsString()
|
||||
clientId!: string;
|
||||
@IsNumber()
|
||||
organizationId!: number;
|
||||
@IsString()
|
||||
name!: string;
|
||||
}
|
||||
export class ProviderVerifyClientOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
data!: ProviderVerifyClientData;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,11 @@ export class CalProviderController {
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
clientId: oAuthClient.id,
|
||||
organizationId: oAuthClient.organizationId,
|
||||
name: oAuthClient.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@ import {
|
||||
Patch,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { ApiResponse, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
|
||||
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
@@ -36,7 +36,7 @@ import { CreateScheduleInput_2024_04_15 } from "../inputs/create-schedule.input"
|
||||
version: VERSION_2024_04_15_VALUE,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
||||
@DocsTags("Schedules")
|
||||
@DocsExcludeController(true)
|
||||
export class SchedulesController_2024_04_15 {
|
||||
constructor(private readonly schedulesService: SchedulesService_2024_04_15) {}
|
||||
|
||||
|
||||
+32
-2
@@ -17,7 +17,7 @@ import {
|
||||
Patch,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { ApiHeader, ApiOperation, ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
|
||||
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
@@ -38,11 +38,38 @@ import {
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
||||
@DocsTags("Schedules")
|
||||
@ApiHeader({
|
||||
name: "cal-api-version",
|
||||
description: `Must be set to \`2024-06-11\``,
|
||||
required: true,
|
||||
})
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||
required: true,
|
||||
})
|
||||
export class SchedulesController_2024_06_11 {
|
||||
constructor(private readonly schedulesService: SchedulesService_2024_06_11) {}
|
||||
|
||||
@Post("/")
|
||||
@Permissions([SCHEDULE_WRITE])
|
||||
@ApiOperation({
|
||||
summary: "Create a schedule",
|
||||
description: `
|
||||
The point of creating schedules is for event types to be available at specific times.
|
||||
|
||||
First goal of schedules is to have a default schedule. If you are platform customer and created managed users, then it is important to note that each managed user should have a default schedule.
|
||||
1. If you passed \`timeZone\` when creating managed user, then the default schedule from Monday to Friday from 9AM to 5PM will be created with that timezone. Managed user can then change the default schedule via \`AvailabilitySettings\` atom.
|
||||
2. If you did not, then we assume you want that user has specific schedule right away. You should create default schedule by specifying
|
||||
\`"isDefault": true\` in the request body. Until the user has a default schedule that user can't be booked or manage his / her schedule via the AvailabilitySettings atom.
|
||||
|
||||
Second goal is to create other schedules that event types can point to, so that when that event is booked availability is not checked against the default schedule but against that specific schedule.
|
||||
After creating a non default schedule you can update event type to point to that schedule via the PATCH \`event-types/{eventTypeId}\` endpoint.
|
||||
|
||||
When specifying start time and end time for each day use 24 hour format e.g. 08:00, 15:00 etc.
|
||||
`,
|
||||
})
|
||||
async createSchedule(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() bodySchedule: CreateScheduleInput_2024_06_11
|
||||
@@ -59,7 +86,7 @@ export class SchedulesController_2024_06_11 {
|
||||
@Permissions([SCHEDULE_READ])
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Returns the default schedule",
|
||||
description: "Returns the default schedule of the authenticated user",
|
||||
type: GetDefaultScheduleOutput_2024_06_11,
|
||||
})
|
||||
async getDefaultSchedule(@GetUser() user: UserWithProfile): Promise<GetScheduleOutput_2024_06_11> {
|
||||
@@ -88,6 +115,9 @@ export class SchedulesController_2024_06_11 {
|
||||
|
||||
@Get("/")
|
||||
@Permissions([SCHEDULE_READ])
|
||||
@ApiOperation({
|
||||
description: "Returns all schedules of the authenticated user",
|
||||
})
|
||||
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput_2024_06_11> {
|
||||
const schedules = await this.schedulesService.getUserSchedules(user.id);
|
||||
|
||||
|
||||
@@ -9,12 +9,12 @@ import { APPS_TYPE_ID_MAPPING } from "@calcom/platform-constants";
|
||||
export class CredentialsRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async createAppCredential(
|
||||
async upsertAppCredential(
|
||||
type: keyof typeof APPS_TYPE_ID_MAPPING,
|
||||
key: Prisma.InputJsonValue,
|
||||
userId: number
|
||||
userId: number,
|
||||
credentialId?: number | null
|
||||
) {
|
||||
const credential = await this.getByTypeAndUserId(type, userId);
|
||||
return this.dbWrite.prisma.credential.upsert({
|
||||
create: {
|
||||
type,
|
||||
@@ -27,7 +27,7 @@ export class CredentialsRepository {
|
||||
invalid: false,
|
||||
},
|
||||
where: {
|
||||
id: credential?.id ?? 0,
|
||||
id: credentialId ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -36,6 +36,10 @@ export class CredentialsRepository {
|
||||
return this.dbWrite.prisma.credential.findFirst({ where: { type, userId } });
|
||||
}
|
||||
|
||||
getAllUserCredentialsByTypeAndId(type: string, userId: number) {
|
||||
return this.dbRead.prisma.credential.findMany({ where: { type, userId } });
|
||||
}
|
||||
|
||||
getUserCredentialsByIds(userId: number, credentialIds: number[]) {
|
||||
return this.dbRead.prisma.credential.findMany({
|
||||
where: {
|
||||
@@ -60,6 +64,30 @@ export class CredentialsRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getUserCredentialById(userId: number, credentialId: number, type: string) {
|
||||
return await this.dbRead.prisma.credential.findUnique({
|
||||
where: {
|
||||
userId,
|
||||
type,
|
||||
id: credentialId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
key: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
appId: true,
|
||||
invalid: true,
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type CredentialsWithUserEmail = Awaited<
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DestinationCalendarsController } from "@/modules/destination-calendars/
|
||||
import { DestinationCalendarsRepository } from "@/modules/destination-calendars/destination-calendars.repository";
|
||||
import { DestinationCalendarsService } from "@/modules/destination-calendars/services/destination-calendars.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@@ -19,6 +20,7 @@ import { Module } from "@nestjs/common";
|
||||
UsersRepository,
|
||||
CredentialsRepository,
|
||||
AppsRepository,
|
||||
SelectedCalendarsRepository,
|
||||
],
|
||||
controllers: [DestinationCalendarsController],
|
||||
exports: [DestinationCalendarsRepository],
|
||||
|
||||
-1
@@ -79,7 +79,6 @@ export class OAuthClientUsersController {
|
||||
`Creating user with data: ${JSON.stringify(body, null, 2)} for OAuth Client with ID ${oAuthClientId}`
|
||||
);
|
||||
const client = await this.oauthRepository.getOAuthClient(oAuthClientId);
|
||||
console.log("asap createUser client", JSON.stringify(client, null, 2));
|
||||
|
||||
const isPlatformManaged = true;
|
||||
const { user, tokens } = await this.oAuthClientUsersService.createOauthClientUser(
|
||||
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/inputs/attributes/assign/organizations-attributes-options-assign.input";
|
||||
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input";
|
||||
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { AttributeRepositoryFixture } from "test/fixtures/repository/attributes.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { User, Team, Membership } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Attributes Options Endpoints", () => {
|
||||
describe("User lacks required role", () => {
|
||||
let app: INestApplication;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let membershipFixtures: MembershipRepositoryFixture;
|
||||
|
||||
const userEmail = "member@attributes-options-api.com";
|
||||
let user: User;
|
||||
let org: Team;
|
||||
let membership: Membership;
|
||||
let attributeId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: "AttributesOptionsCorp",
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
organization: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
membership = await membershipFixtures.addUserToOrg(user, org, "MEMBER", true);
|
||||
|
||||
// Create an attribute for testing
|
||||
attributeId = "test-attribute-id";
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should not be able to create attribute option", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/attributes/${attributeId}/options`)
|
||||
.send({
|
||||
name: "Option 1",
|
||||
value: "option1",
|
||||
})
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should not be able to delete attribute option", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${org.id}/attributes/${attributeId}/options/1`)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await membershipFixtures.delete(membership.id);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("User has required role", () => {
|
||||
let app: INestApplication;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let membershipFixtures: MembershipRepositoryFixture;
|
||||
let attributeRepositoryFixture: AttributeRepositoryFixture;
|
||||
|
||||
const userEmail = "admin@attributes-options-api.com";
|
||||
let user: User;
|
||||
let org: Team;
|
||||
let membership: Membership;
|
||||
let attributeId: string;
|
||||
let createdOption: any;
|
||||
|
||||
const createOptionInput: CreateOrganizationAttributeOptionInput = {
|
||||
value: "option1",
|
||||
slug: "option1",
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
||||
attributeRepositoryFixture = new AttributeRepositoryFixture(moduleRef);
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: "AttributesOptionsCorp",
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
organization: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
|
||||
|
||||
// Create an attribute for testing
|
||||
const attribute = await attributeRepositoryFixture.create({
|
||||
name: "Test Attribute",
|
||||
team: { connect: { id: org.id } },
|
||||
type: "TEXT",
|
||||
slug: "test-attribute",
|
||||
});
|
||||
attributeId = attribute.id;
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should create attribute option", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/attributes/${attributeId}/options`)
|
||||
.send(createOptionInput)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
createdOption = response.body.data;
|
||||
expect(createdOption.value).toEqual(createOptionInput.value);
|
||||
expect(createdOption.slug).toEqual(createOptionInput.slug);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get attribute options", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/attributes/${attributeId}/options`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
const options = response.body.data;
|
||||
expect(options.length).toEqual(1);
|
||||
expect(options[0].value).toEqual(createOptionInput.value);
|
||||
expect(options[0].slug).toEqual(createOptionInput.slug);
|
||||
});
|
||||
});
|
||||
|
||||
it("should update attribute option", async () => {
|
||||
const updateOptionInput: UpdateOrganizationAttributeOptionInput = {
|
||||
value: "updated-option-value",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/attributes/${attributeId}/options/${createdOption.id}`)
|
||||
.send(updateOptionInput)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
const updatedOption = response.body.data;
|
||||
expect(updatedOption.value).toEqual(updateOptionInput.value);
|
||||
});
|
||||
});
|
||||
|
||||
it("should assign attribute option to user", async () => {
|
||||
const assignInput: AssignOrganizationAttributeOptionToUserInput = {
|
||||
attributeId: attributeId,
|
||||
attributeOptionId: createdOption.id,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/attributes/options/${user.id}`)
|
||||
.send(assignInput)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(response.body.data).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("should get attribute options for user", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/attributes/options/${user.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
const userOptions = response.body.data;
|
||||
expect(userOptions.length).toEqual(1);
|
||||
expect(userOptions[0].id).toEqual(createdOption.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should unassign attribute option from user", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${org.id}/attributes/options/${user.id}/${createdOption.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(response.body.data).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete attribute option", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${org.id}/attributes/${attributeId}/options/${createdOption.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(response.body.data).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await membershipFixtures.delete(membership.id);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/create-organization-attribute.input";
|
||||
import { UpdateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/update-organization-attribute.input";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { User, Team, Membership } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Attributes Endpoints", () => {
|
||||
describe("User lacks required role", () => {
|
||||
let app: INestApplication;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let membershipFixtures: MembershipRepositoryFixture;
|
||||
|
||||
const userEmail = "member@attributes-api.com";
|
||||
let user: User;
|
||||
let org: Team;
|
||||
let membership: Membership;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: "AttributesCorp",
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
organization: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
membership = await membershipFixtures.addUserToOrg(user, org, "MEMBER", true);
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should not be able to create attribute for org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/attributes`)
|
||||
.send({
|
||||
key: "department",
|
||||
value: "engineering",
|
||||
})
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should not be able to delete attribute for org", async () => {
|
||||
return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/attributes/1`).expect(403);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await membershipFixtures.delete(membership.id);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("User has required role", () => {
|
||||
let app: INestApplication;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let membershipFixtures: MembershipRepositoryFixture;
|
||||
|
||||
const userEmail = "admin@attributes-api.com";
|
||||
let user: User;
|
||||
let org: Team;
|
||||
let membership: Membership;
|
||||
|
||||
let createdAttribute: any;
|
||||
|
||||
const createAttributeInput: CreateOrganizationAttributeInput = {
|
||||
name: "department",
|
||||
slug: "department",
|
||||
type: "TEXT",
|
||||
options: [],
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: "AttributesCorp",
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
organization: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should create attribute for org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/attributes`)
|
||||
.send(createAttributeInput)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
createdAttribute = response.body.data;
|
||||
expect(createdAttribute.type).toEqual(createAttributeInput.type);
|
||||
expect(createdAttribute.slug).toEqual(createAttributeInput.slug);
|
||||
expect(createdAttribute.enabled).toEqual(createAttributeInput.enabled);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get org attributes", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/attributes`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
const attributes = response.body.data;
|
||||
expect(attributes.length).toEqual(1);
|
||||
expect(attributes[0].name).toEqual(createAttributeInput.name);
|
||||
expect(attributes[0].slug).toEqual(createAttributeInput.slug);
|
||||
expect(attributes[0].type).toEqual(createAttributeInput.type);
|
||||
expect(attributes[0].enabled).toEqual(createAttributeInput.enabled);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get single org attribute", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/attributes/${createdAttribute.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
const attribute = response.body.data;
|
||||
expect(attribute.name).toEqual(createAttributeInput.name);
|
||||
expect(attribute.slug).toEqual(createAttributeInput.slug);
|
||||
expect(attribute.type).toEqual(createAttributeInput.type);
|
||||
expect(attribute.enabled).toEqual(createAttributeInput.enabled);
|
||||
});
|
||||
});
|
||||
|
||||
it("should update org attribute", async () => {
|
||||
const updateAttributeInput: UpdateOrganizationAttributeInput = {
|
||||
name: "marketing",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/attributes/${createdAttribute.id}`)
|
||||
.send(updateAttributeInput)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
const updatedAttribute = response.body.data;
|
||||
expect(updatedAttribute.name).toEqual(updateAttributeInput.name);
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete org attribute", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${org.id}/attributes/${createdAttribute.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(response.body.data).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await membershipFixtures.delete(membership.id);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/inputs/attributes/assign/organizations-attributes-options-assign.input";
|
||||
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input";
|
||||
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts";
|
||||
import {
|
||||
AssignOptionUserOutput,
|
||||
UnassignOptionUserOutput,
|
||||
} from "@/modules/organizations/outputs/attributes/options/assign-option-user.output";
|
||||
import { CreateAttributeOptionOutput } from "@/modules/organizations/outputs/attributes/options/create-option.output";
|
||||
import { DeleteAttributeOptionOutput } from "@/modules/organizations/outputs/attributes/options/delete-option.output";
|
||||
import { GetOptionUserOutput } from "@/modules/organizations/outputs/attributes/options/get-option-user.output";
|
||||
import { GetAllAttributeOptionOutput } from "@/modules/organizations/outputs/attributes/options/get-option.output";
|
||||
import { UpdateAttributeOptionOutput } from "@/modules/organizations/outputs/attributes/options/update-option.output";
|
||||
import { OrganizationAttributeOptionService } from "@/modules/organizations/services/attributes/organization-attributes-option.service";
|
||||
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from "@nestjs/common";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
export class OrganizationsOptionsAttributesController {
|
||||
constructor(private readonly organizationsAttributesOptionsService: OrganizationAttributeOptionService) {}
|
||||
|
||||
// Creates an attribute option for an attribute
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Post("/attributes/:attributeId/options")
|
||||
async createOrganizationAttributeOption(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("attributeId") attributeId: string,
|
||||
@Body() bodyAttribute: CreateOrganizationAttributeOptionInput
|
||||
): Promise<CreateAttributeOptionOutput> {
|
||||
const attributeOption =
|
||||
await this.organizationsAttributesOptionsService.createOrganizationAttributeOption(
|
||||
orgId,
|
||||
attributeId,
|
||||
bodyAttribute
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attributeOption,
|
||||
};
|
||||
}
|
||||
|
||||
// Deletes an attribute option for an attribute
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Delete("/attributes/:attributeId/options/:optionId")
|
||||
async deleteOrganizationAttributeOption(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("attributeId") attributeId: string,
|
||||
@Param("optionId") optionId: string
|
||||
): Promise<DeleteAttributeOptionOutput> {
|
||||
const attributeOption =
|
||||
await this.organizationsAttributesOptionsService.deleteOrganizationAttributeOption(
|
||||
orgId,
|
||||
attributeId,
|
||||
optionId
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attributeOption,
|
||||
};
|
||||
}
|
||||
|
||||
// Updates an attribute option for an attribute
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Patch("/attributes/:attributeId/options/:optionId")
|
||||
async updateOrganizationAttributeOption(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("attributeId") attributeId: string,
|
||||
@Param("optionId") optionId: string,
|
||||
@Body() bodyAttribute: UpdateOrganizationAttributeOptionInput
|
||||
): Promise<UpdateAttributeOptionOutput> {
|
||||
const attributeOption =
|
||||
await this.organizationsAttributesOptionsService.updateOrganizationAttributeOption(
|
||||
orgId,
|
||||
attributeId,
|
||||
optionId,
|
||||
bodyAttribute
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attributeOption,
|
||||
};
|
||||
}
|
||||
|
||||
// Gets all attribute options for an attribute
|
||||
@Roles("ORG_MEMBER")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Get("/attributes/:attributeId/options")
|
||||
async getOrganizationAttributeOptions(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("attributeId") attributeId: string
|
||||
): Promise<GetAllAttributeOptionOutput> {
|
||||
const attributeOptions = await this.organizationsAttributesOptionsService.getOrganizationAttributeOptions(
|
||||
orgId,
|
||||
attributeId
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attributeOptions,
|
||||
};
|
||||
}
|
||||
|
||||
// Assign attribute option to user
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Post("/attributes/options/:userId")
|
||||
async assignOrganizationAttributeOptionToUser(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("userId", ParseIntPipe) userId: number,
|
||||
@Body() bodyAttribute: AssignOrganizationAttributeOptionToUserInput
|
||||
): Promise<AssignOptionUserOutput> {
|
||||
const attributeOption =
|
||||
await this.organizationsAttributesOptionsService.assignOrganizationAttributeOptionToUser(
|
||||
orgId,
|
||||
userId,
|
||||
bodyAttribute
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attributeOption,
|
||||
};
|
||||
}
|
||||
|
||||
// Unassign attribute option from user
|
||||
@Roles("ORG_MEMBER")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Delete("/attributes/options/:userId/:attributeOptionId")
|
||||
async unassignOrganizationAttributeOptionFromUser(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("userId", ParseIntPipe) userId: number,
|
||||
@Param("attributeOptionId") attributeOptionId: string
|
||||
): Promise<UnassignOptionUserOutput> {
|
||||
const attributeOption =
|
||||
await this.organizationsAttributesOptionsService.unassignOrganizationAttributeOptionFromUser(
|
||||
orgId,
|
||||
userId,
|
||||
attributeOptionId
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attributeOption,
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_MEMBER")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Get("/attributes/options/:userId")
|
||||
async getOrganizationAttributeOptionsForUser(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("userId", ParseIntPipe) userId: number
|
||||
): Promise<GetOptionUserOutput> {
|
||||
const attributeOptions =
|
||||
await this.organizationsAttributesOptionsService.getOrganizationAttributeOptionsForUser(orgId, userId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attributeOptions,
|
||||
};
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { CreateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/create-organization-attribute.input";
|
||||
import { UpdateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/update-organization-attribute.input";
|
||||
import { CreateOrganizationAttributesOutput } from "@/modules/organizations/outputs/attributes/create-organization-attributes.output";
|
||||
import { DeleteOrganizationAttributesOutput } from "@/modules/organizations/outputs/attributes/delete-organization-attributes.output";
|
||||
import {
|
||||
GetOrganizationAttributesOutput,
|
||||
GetSingleAttributeOutput,
|
||||
} from "@/modules/organizations/outputs/attributes/get-organization-attributes.output";
|
||||
import { UpdateOrganizationAttributesOutput } from "@/modules/organizations/outputs/attributes/update-organization-attributes.output";
|
||||
import { OrganizationAttributesService } from "@/modules/organizations/services/attributes/organization-attributes.service";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
export class OrganizationsAttributesController {
|
||||
constructor(private readonly organizationsAttributesService: OrganizationAttributesService) {}
|
||||
// Gets all attributes for an organization
|
||||
@Roles("ORG_MEMBER")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Get("/attributes")
|
||||
async getOrganizationAttributes(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
): Promise<GetOrganizationAttributesOutput> {
|
||||
const { skip, take } = queryParams;
|
||||
const attributes = await this.organizationsAttributesService.getOrganizationAttributes(orgId, skip, take);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attributes,
|
||||
};
|
||||
}
|
||||
|
||||
// Gets a single attribute for an organization
|
||||
@Roles("ORG_MEMBER")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Get("/attributes/:attributeId")
|
||||
async getOrganizationAttribute(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("attributeId") attributeId: string
|
||||
): Promise<GetSingleAttributeOutput> {
|
||||
const attribute = await this.organizationsAttributesService.getOrganizationAttribute(orgId, attributeId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attribute,
|
||||
};
|
||||
}
|
||||
|
||||
// Creates an attribute for an organization
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Post("/attributes")
|
||||
async createOrganizationAttribute(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Body() bodyAttribute: CreateOrganizationAttributeInput
|
||||
): Promise<CreateOrganizationAttributesOutput> {
|
||||
const attribute = await this.organizationsAttributesService.createOrganizationAttribute(
|
||||
orgId,
|
||||
bodyAttribute
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attribute,
|
||||
};
|
||||
}
|
||||
|
||||
// Updates an attribute for an organization
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Patch("/attributes/:attributeId")
|
||||
async updateOrganizationAttribute(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("attributeId") attributeId: string,
|
||||
@Body() bodyAttribute: UpdateOrganizationAttributeInput
|
||||
): Promise<UpdateOrganizationAttributesOutput> {
|
||||
const attribute = await this.organizationsAttributesService.updateOrganizationAttribute(
|
||||
orgId,
|
||||
attributeId,
|
||||
bodyAttribute
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attribute,
|
||||
};
|
||||
}
|
||||
|
||||
// Deletes an attribute for an organization
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Delete("/attributes/:attributeId")
|
||||
async deleteOrganizationAttribute(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("attributeId") attributeId: string
|
||||
): Promise<DeleteOrganizationAttributesOutput> {
|
||||
const attribute = await this.organizationsAttributesService.deleteOrganizationAttribute(
|
||||
orgId,
|
||||
attributeId
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attribute,
|
||||
};
|
||||
}
|
||||
}
|
||||
+28
-1
@@ -1,3 +1,5 @@
|
||||
import { CreatePhoneCallInput } from "@/ee/event-types/event-types_2024_06_14/inputs/create-phone-call.input";
|
||||
import { CreatePhoneCallOutput } from "@/ee/event-types/event-types_2024_06_14/outputs/create-phone-call.output";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
@@ -33,6 +35,7 @@ import {
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { handleCreatePhoneCall } from "@calcom/platform-libraries";
|
||||
import {
|
||||
CreateTeamEventTypeInput_2024_06_14,
|
||||
GetTeamEventTypesQuery_2024_06_14,
|
||||
@@ -91,6 +94,30 @@ export class OrganizationsEventTypesController {
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@Post("/teams/:teamId/event-types/:eventTypeId/create-phone-call")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, IsTeamInOrg, RolesGuard)
|
||||
async createPhoneCall(
|
||||
@Param("eventTypeId") eventTypeId: number,
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Body() body: CreatePhoneCallInput,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<CreatePhoneCallOutput> {
|
||||
const data = await handleCreatePhoneCall({
|
||||
user: {
|
||||
id: user.id,
|
||||
timeZone: user.timeZone,
|
||||
profile: { organization: { id: orgId } },
|
||||
},
|
||||
input: { ...body, eventTypeId },
|
||||
});
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(IsOrgGuard, IsTeamInOrg, IsAdminAPIEnabledGuard)
|
||||
@Get("/teams/:teamId/event-types")
|
||||
async getTeamEventTypes(
|
||||
@@ -138,7 +165,7 @@ export class OrganizationsEventTypesController {
|
||||
@Patch("/teams/:teamId/event-types/:eventTypeId")
|
||||
async updateTeamEventType(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("eventTypeId") eventTypeId: number,
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() bodyEventType: UpdateTeamEventTypeInput_2024_06_14
|
||||
): Promise<UpdateTeamEventTypeOutput> {
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { IsNotEmpty, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class AssignOrganizationAttributeOptionToUserInput {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
readonly value?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
readonly attributeOptionId?: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
readonly attributeId!: string;
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input";
|
||||
import { AttributeType } from "@prisma/client";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
export class CreateOrganizationAttributeInput {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
readonly name!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
readonly slug!: string;
|
||||
|
||||
@IsEnum(AttributeType)
|
||||
@IsNotEmpty()
|
||||
readonly type!: AttributeType;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested()
|
||||
@Type(() => CreateOrganizationAttributeOptionInput)
|
||||
readonly options!: CreateOrganizationAttributeOptionInput[];
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
readonly enabled?: boolean;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { IsNotEmpty, IsString } from "class-validator";
|
||||
|
||||
export class CreateOrganizationAttributeOptionInput {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
readonly value!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
readonly slug!: string;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { IsNotEmpty, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class UpdateOrganizationAttributeOptionInput {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
readonly value?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
readonly slug?: string;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { AttributeType } from "@prisma/client";
|
||||
import { IsBoolean, IsEnum, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class UpdateOrganizationAttributeInput {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
readonly name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
readonly slug?: string;
|
||||
|
||||
@IsEnum(AttributeType)
|
||||
@IsOptional()
|
||||
readonly type?: AttributeType;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
readonly enabled?: boolean;
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/
|
||||
import { EmailModule } from "@/modules/email/email.module";
|
||||
import { EmailService } from "@/modules/email/email.service";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsOptionsAttributesController } from "@/modules/organizations/controllers/attributes/organizations-attributes-options.controller";
|
||||
import { OrganizationsAttributesController } from "@/modules/organizations/controllers/attributes/organizations-attributes.controller";
|
||||
import { OrganizationsEventTypesController } from "@/modules/organizations/controllers/event-types/organizations-event-types.controller";
|
||||
import { OrganizationsMembershipsController } from "@/modules/organizations/controllers/memberships/organizations-membership.controller";
|
||||
import { OrganizationsSchedulesController } from "@/modules/organizations/controllers/schedules/organizations-schedules.controller";
|
||||
@@ -11,6 +13,8 @@ import { OrganizationsTeamsController } from "@/modules/organizations/controller
|
||||
import { OrganizationsUsersController } from "@/modules/organizations/controllers/users/organizations-users.controller";
|
||||
import { OrganizationsWebhooksController } from "@/modules/organizations/controllers/webhooks/organizations-webhooks.controller";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||
import { OrganizationAttributeOptionRepository } from "@/modules/organizations/repositories/attributes/organization-attribute-option.repository";
|
||||
import { OrganizationAttributesRepository } from "@/modules/organizations/repositories/attributes/organization-attribute.repository";
|
||||
import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository";
|
||||
import { OrganizationsMembershipRepository } from "@/modules/organizations/repositories/organizations-membership.repository";
|
||||
import { OrganizationSchedulesRepository } from "@/modules/organizations/repositories/organizations-schedules.repository";
|
||||
@@ -18,6 +22,8 @@ import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
|
||||
import { OrganizationsUsersRepository } from "@/modules/organizations/repositories/organizations-users.repository";
|
||||
import { OrganizationsWebhooksRepository } from "@/modules/organizations/repositories/organizations-webhooks.repository";
|
||||
import { OrganizationAttributeOptionService } from "@/modules/organizations/services/attributes/organization-attributes-option.service";
|
||||
import { OrganizationAttributesService } from "@/modules/organizations/services/attributes/organization-attributes.service";
|
||||
import { InputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/input.service";
|
||||
import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
|
||||
import { OutputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/output.service";
|
||||
@@ -65,6 +71,10 @@ import { Module } from "@nestjs/common";
|
||||
OrganizationsEventTypesRepository,
|
||||
OrganizationsTeamsMembershipsRepository,
|
||||
OrganizationsTeamsMembershipsService,
|
||||
OrganizationAttributesService,
|
||||
OrganizationAttributeOptionService,
|
||||
OrganizationAttributeOptionRepository,
|
||||
OrganizationAttributesRepository,
|
||||
OrganizationsWebhooksRepository,
|
||||
OrganizationsWebhooksService,
|
||||
WebhooksRepository,
|
||||
@@ -80,6 +90,10 @@ import { Module } from "@nestjs/common";
|
||||
OrganizationsMembershipService,
|
||||
OrganizationsTeamsMembershipsRepository,
|
||||
OrganizationsTeamsMembershipsService,
|
||||
OrganizationAttributesService,
|
||||
OrganizationAttributeOptionService,
|
||||
OrganizationAttributeOptionRepository,
|
||||
OrganizationAttributesRepository,
|
||||
OrganizationsWebhooksRepository,
|
||||
OrganizationsWebhooksService,
|
||||
WebhooksRepository,
|
||||
@@ -92,6 +106,8 @@ import { Module } from "@nestjs/common";
|
||||
OrganizationsMembershipsController,
|
||||
OrganizationsEventTypesController,
|
||||
OrganizationsTeamsMembershipsController,
|
||||
OrganizationsAttributesController,
|
||||
OrganizationsOptionsAttributesController,
|
||||
OrganizationsWebhooksController,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsString, IsInt, IsEnum, IsBoolean } from "class-validator";
|
||||
|
||||
export const AttributeType = {
|
||||
TEXT: "TEXT",
|
||||
NUMBER: "NUMBER",
|
||||
SINGLE_SELECT: "SINGLE_SELECT",
|
||||
MULTI_SELECT: "MULTI_SELECT",
|
||||
} as const;
|
||||
|
||||
export type AttributeType = (typeof AttributeType)[keyof typeof AttributeType];
|
||||
|
||||
export class Attribute {
|
||||
@IsString()
|
||||
@ApiProperty({ type: String, required: true, description: "The ID of the attribute", example: "attr_123" })
|
||||
id!: string;
|
||||
|
||||
@IsInt()
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
required: true,
|
||||
description: "The team ID associated with the attribute",
|
||||
example: 1,
|
||||
})
|
||||
teamId!: number;
|
||||
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: true,
|
||||
description: "The type of the attribute",
|
||||
enum: AttributeType,
|
||||
})
|
||||
@IsEnum(AttributeType)
|
||||
type!: AttributeType;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: true,
|
||||
description: "The name of the attribute",
|
||||
example: "Attribute Name",
|
||||
})
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: true,
|
||||
description: "The slug of the attribute",
|
||||
example: "attribute-name",
|
||||
})
|
||||
slug!: string;
|
||||
|
||||
@IsBoolean()
|
||||
@ApiProperty({
|
||||
type: Boolean,
|
||||
required: true,
|
||||
description: "Whether the attribute is enabled and displayed on their profile",
|
||||
example: true,
|
||||
})
|
||||
enabled!: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@ApiProperty({
|
||||
type: Boolean,
|
||||
required: false,
|
||||
description: "Whether users can edit the relation",
|
||||
example: true,
|
||||
})
|
||||
usersCanEditRelation!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEnum } from "class-validator";
|
||||
|
||||
import { ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class BaseOutputDTO {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { Attribute } from "@/modules/organizations/outputs/attributes/attribute.output";
|
||||
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { ValidateNested } from "class-validator";
|
||||
|
||||
export class CreateOrganizationAttributesOutput extends BaseOutputDTO {
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => Attribute)
|
||||
data!: Attribute;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { Attribute } from "@/modules/organizations/outputs/attributes/attribute.output";
|
||||
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { ValidateNested } from "class-validator";
|
||||
|
||||
export class DeleteOrganizationAttributesOutput extends BaseOutputDTO {
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => Attribute)
|
||||
data!: Attribute;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { Attribute } from "@/modules/organizations/outputs/attributes/attribute.output";
|
||||
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsBoolean, IsInt, IsString, IsEnum, ValidateNested, IsOptional } from "class-validator";
|
||||
|
||||
import { AttributeType } from "@calcom/prisma/client";
|
||||
|
||||
export class GetSingleAttributeOutput extends BaseOutputDTO {
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@IsOptional()
|
||||
@Type(() => Attribute)
|
||||
data!: Attribute | null;
|
||||
}
|
||||
|
||||
export class GetOrganizationAttributesOutput extends BaseOutputDTO {
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => Attribute)
|
||||
data!: Attribute[];
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
|
||||
import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsString, ValidateNested } from "class-validator";
|
||||
|
||||
class AssignOptionUserOutputData {
|
||||
@IsString()
|
||||
@ApiProperty({ type: String, required: true, description: "The ID of the option assigned to the user" })
|
||||
id!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({ type: Number, required: true, description: "The ID form the org membership for the user" })
|
||||
memberId!: number;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({ type: String, required: true, description: "The value of the option" })
|
||||
attributeOptionId!: string;
|
||||
}
|
||||
|
||||
export class AssignOptionUserOutput extends BaseOutputDTO {
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => AssignOptionUserOutputData)
|
||||
data!: AssignOptionUserOutputData;
|
||||
}
|
||||
|
||||
export class UnassignOptionUserOutput extends BaseOutputDTO {
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => AssignOptionUserOutputData)
|
||||
data!: AssignOptionUserOutputData;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
|
||||
import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { ValidateNested } from "class-validator";
|
||||
|
||||
export class CreateAttributeOptionOutput extends BaseOutputDTO {
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => OptionOutput)
|
||||
data!: OptionOutput;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
|
||||
import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { ValidateNested } from "class-validator";
|
||||
|
||||
export class DeleteAttributeOptionOutput extends BaseOutputDTO {
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => OptionOutput)
|
||||
data!: OptionOutput;
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsString, ValidateNested } from "class-validator";
|
||||
|
||||
class GetOptionUserOutputData {
|
||||
@IsString()
|
||||
@ApiProperty({ type: String, required: true, description: "The ID of the option assigned to the user" })
|
||||
id!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({ type: String, required: true, description: "The ID of the attribute" })
|
||||
attributeId!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({ type: String, required: true, description: "The value of the option" })
|
||||
value!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({ type: String, required: true, description: "The slug of the option" })
|
||||
slug!: string;
|
||||
}
|
||||
|
||||
export class GetOptionUserOutput extends BaseOutputDTO {
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => GetOptionUserOutputData)
|
||||
data!: GetOptionUserOutputData[];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
|
||||
import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { ValidateNested } from "class-validator";
|
||||
|
||||
export class GetAllAttributeOptionOutput extends BaseOutputDTO {
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => OptionOutput)
|
||||
data!: OptionOutput[];
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsString, IsInt, IsEnum, IsBoolean } from "class-validator";
|
||||
|
||||
export class OptionOutput {
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: true,
|
||||
description: "The ID of the option",
|
||||
example: "attr_option_id",
|
||||
})
|
||||
id!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({ type: String, required: true, description: "The ID of the attribute", example: "attr_id" })
|
||||
attributeId!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: true,
|
||||
description: "The value of the option",
|
||||
example: "option_value",
|
||||
})
|
||||
value!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: true,
|
||||
description: "The slug of the option",
|
||||
example: "option-slug",
|
||||
})
|
||||
slug!: string;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
|
||||
import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { ValidateNested } from "class-validator";
|
||||
|
||||
export class UpdateAttributeOptionOutput extends BaseOutputDTO {
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => OptionOutput)
|
||||
data!: OptionOutput;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { Attribute } from "@/modules/organizations/outputs/attributes/attribute.output";
|
||||
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { ValidateNested } from "class-validator";
|
||||
|
||||
export class UpdateOrganizationAttributesOutput extends BaseOutputDTO {
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => Attribute)
|
||||
data!: Attribute;
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input";
|
||||
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts";
|
||||
import { OrganizationsMembershipService } from "@/modules/organizations/services/organizations-membership.service";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { slugify } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationAttributeOptionRepository {
|
||||
private readonly logger = new Logger("OrganizationAttributeOptionRepository");
|
||||
constructor(
|
||||
private readonly dbRead: PrismaReadService,
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private readonly organizationsMembershipsService: OrganizationsMembershipService
|
||||
) {}
|
||||
|
||||
async createOrganizationAttributeOption(
|
||||
organizationId: number,
|
||||
attributeId: string,
|
||||
data: CreateOrganizationAttributeOptionInput
|
||||
) {
|
||||
return this.dbWrite.prisma.attributeOption.create({
|
||||
data: {
|
||||
...data,
|
||||
attributeId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteOrganizationAttributeOption(organizationId: number, attributeId: string, optionId: string) {
|
||||
try {
|
||||
const deletedAttributeOption = await this.dbWrite.prisma.attributeOption.delete({
|
||||
where: {
|
||||
id: optionId,
|
||||
attributeId,
|
||||
},
|
||||
});
|
||||
return deletedAttributeOption;
|
||||
} catch (error: any) {
|
||||
if (error.code === "P2025") {
|
||||
// P2025 is the Prisma error code for "Record to delete does not exist."
|
||||
this.logger.warn(`Attribute option not found: ${optionId}`);
|
||||
throw new NotFoundException("Attribute option not found");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateOrganizationAttributeOption(
|
||||
organizationId: number,
|
||||
attributeId: string,
|
||||
optionId: string,
|
||||
data: UpdateOrganizationAttributeOptionInput
|
||||
) {
|
||||
return this.dbWrite.prisma.attributeOption.update({
|
||||
where: {
|
||||
id: optionId,
|
||||
attributeId,
|
||||
},
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async getOrganizationAttributeOptions(organizationId: number, attributeId: string) {
|
||||
return this.dbRead.prisma.attributeOption.findMany({
|
||||
where: {
|
||||
attributeId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getOrganizationAttributeOptionsForUser(organizationId: number, userId: number) {
|
||||
const options = this.dbRead.prisma.attributeOption.findMany({
|
||||
where: {
|
||||
attribute: {
|
||||
teamId: organizationId,
|
||||
},
|
||||
assignedUsers: {
|
||||
some: {
|
||||
member: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
async assignOrganizationAttributeOptionToUser({
|
||||
organizationId,
|
||||
membershipId,
|
||||
attributeId,
|
||||
value,
|
||||
attributeOptionId,
|
||||
}: {
|
||||
organizationId: number;
|
||||
membershipId: number;
|
||||
attributeId: string;
|
||||
value?: string;
|
||||
attributeOptionId?: string;
|
||||
}) {
|
||||
let _attributeOptionId = attributeOptionId;
|
||||
|
||||
if (value && !attributeOptionId) {
|
||||
_attributeOptionId = await this.createDynamicAttributeOption(organizationId, attributeId, value);
|
||||
}
|
||||
|
||||
if (!_attributeOptionId) throw new Error("Attribute option not found");
|
||||
|
||||
return this.dbWrite.prisma.attributeToUser.create({
|
||||
data: {
|
||||
attributeOptionId: _attributeOptionId,
|
||||
memberId: membershipId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async createDynamicAttributeOption(
|
||||
organizationId: number,
|
||||
attributeId: string,
|
||||
value: string
|
||||
): Promise<string> {
|
||||
const attributeOption = await this.createOrganizationAttributeOption(organizationId, attributeId, {
|
||||
value: value,
|
||||
slug: slugify(value),
|
||||
});
|
||||
return attributeOption.id;
|
||||
}
|
||||
|
||||
async unassignOrganizationAttributeOptionFromUser(
|
||||
organizationId: number,
|
||||
userId: number,
|
||||
attributeOptionId: string
|
||||
) {
|
||||
const membership = await this.organizationsMembershipsService.getOrgMembershipByUserId(
|
||||
organizationId,
|
||||
userId
|
||||
);
|
||||
|
||||
if (!membership) throw new Error("Membership not found");
|
||||
|
||||
try {
|
||||
const deletedAttributeToUser = await this.dbWrite.prisma.attributeToUser.delete({
|
||||
where: { memberId_attributeOptionId: { memberId: membership.id, attributeOptionId } },
|
||||
});
|
||||
return deletedAttributeToUser;
|
||||
} catch (error: any) {
|
||||
if (error.code === "P2025") {
|
||||
// P2025 is the Prisma error code for "Record to delete does not exist."
|
||||
this.logger.warn(`Attribute option not found: ${attributeOptionId} for user ${userId}`);
|
||||
throw new NotFoundException("Attribute does not belong to this user");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import { CreateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/create-organization-attribute.input";
|
||||
import { UpdateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/update-organization-attribute.input";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationAttributesRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async createOrganizationAttribute(organizationId: number, data: CreateOrganizationAttributeInput) {
|
||||
const { options, ...attributeData } = data;
|
||||
|
||||
const attribute = await this.dbWrite.prisma.attribute.create({
|
||||
data: {
|
||||
...attributeData,
|
||||
teamId: organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (attribute.type === "SINGLE_SELECT" || attribute.type === "MULTI_SELECT") {
|
||||
// TODO: move this to attribute option service
|
||||
await this.dbWrite.prisma.attributeOption.createMany({
|
||||
data: options.map((option) => ({
|
||||
...option,
|
||||
attributeId: attribute.id,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return attribute;
|
||||
}
|
||||
|
||||
async getOrganizationAttribute(organizationId: number, attributeId: string) {
|
||||
const attribute = await this.dbRead.prisma.attribute.findUnique({
|
||||
where: {
|
||||
id: attributeId,
|
||||
teamId: organizationId,
|
||||
},
|
||||
});
|
||||
return attribute;
|
||||
}
|
||||
|
||||
async getOrganizationAttributes(organizationId: number, skip?: number, take?: number) {
|
||||
const attributes = await this.dbRead.prisma.attribute.findMany({
|
||||
where: {
|
||||
teamId: organizationId,
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
return attributes;
|
||||
}
|
||||
async updateOrganizationAttribute(
|
||||
organizationId: number,
|
||||
attributeId: string,
|
||||
data: UpdateOrganizationAttributeInput
|
||||
) {
|
||||
const attribute = await this.dbWrite.prisma.attribute.update({
|
||||
where: {
|
||||
id: attributeId,
|
||||
teamId: organizationId,
|
||||
},
|
||||
data,
|
||||
});
|
||||
return attribute;
|
||||
}
|
||||
|
||||
async deleteOrganizationAttribute(organizationId: number, attributeId: string) {
|
||||
const attribute = await this.dbWrite.prisma.attribute.delete({
|
||||
where: {
|
||||
id: attributeId,
|
||||
teamId: organizationId,
|
||||
},
|
||||
});
|
||||
return attribute;
|
||||
}
|
||||
}
|
||||
+9
@@ -18,6 +18,15 @@ export class OrganizationsMembershipRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async findOrgMembershipByUserId(organizationId: number, userId: number) {
|
||||
return this.dbRead.prisma.membership.findFirst({
|
||||
where: {
|
||||
teamId: organizationId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteOrgMembership(organizationId: number, membershipId: number) {
|
||||
return this.dbWrite.prisma.membership.delete({
|
||||
where: {
|
||||
|
||||
+6
-5
@@ -1,11 +1,12 @@
|
||||
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
|
||||
import { UpdateOrgTeamDto } from "@/modules/organizations/inputs/update-organization-team.input";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsTeamsRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService) {}
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async findOrgTeam(organizationId: number, teamId: number) {
|
||||
return this.dbRead.prisma.team.findUnique({
|
||||
@@ -26,7 +27,7 @@ export class OrganizationsTeamsRepository {
|
||||
}
|
||||
|
||||
async deleteOrgTeam(organizationId: number, teamId: number) {
|
||||
return this.dbRead.prisma.team.delete({
|
||||
return this.dbWrite.prisma.team.delete({
|
||||
where: {
|
||||
id: teamId,
|
||||
isOrganization: false,
|
||||
@@ -36,13 +37,13 @@ export class OrganizationsTeamsRepository {
|
||||
}
|
||||
|
||||
async createOrgTeam(organizationId: number, data: CreateOrgTeamDto) {
|
||||
return this.dbRead.prisma.team.create({
|
||||
return this.dbWrite.prisma.team.create({
|
||||
data: { ...data, parentId: organizationId },
|
||||
});
|
||||
}
|
||||
|
||||
async createPlatformOrgTeam(organizationId: number, oAuthClientId: string, data: CreateOrgTeamDto) {
|
||||
return this.dbRead.prisma.team.create({
|
||||
return this.dbWrite.prisma.team.create({
|
||||
data: {
|
||||
...data,
|
||||
parentId: organizationId,
|
||||
@@ -61,7 +62,7 @@ export class OrganizationsTeamsRepository {
|
||||
}
|
||||
|
||||
async updateOrgTeam(organizationId: number, teamId: number, data: UpdateOrgTeamDto) {
|
||||
return this.dbRead.prisma.team.update({
|
||||
return this.dbWrite.prisma.team.update({
|
||||
data: { ...data },
|
||||
where: { id: teamId, parentId: organizationId, isOrganization: false },
|
||||
});
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/inputs/attributes/assign/organizations-attributes-options-assign.input";
|
||||
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input";
|
||||
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts";
|
||||
import { OrganizationAttributeOptionRepository } from "@/modules/organizations/repositories/attributes/organization-attribute-option.repository";
|
||||
import { OrganizationAttributesService } from "@/modules/organizations/services/attributes/organization-attributes.service";
|
||||
import { OrganizationsMembershipService } from "@/modules/organizations/services/organizations-membership.service";
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
|
||||
const TYPE_SUPPORTS_VALUE = new Set(["TEXT", "NUMBER"]);
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationAttributeOptionService {
|
||||
private readonly logger = new Logger("OrganizationAttributeOptionService");
|
||||
constructor(
|
||||
private readonly organizationAttributeOptionRepository: OrganizationAttributeOptionRepository,
|
||||
private readonly organizationAttributesService: OrganizationAttributesService,
|
||||
private readonly organizationsMembershipsService: OrganizationsMembershipService
|
||||
) {}
|
||||
|
||||
async createOrganizationAttributeOption(
|
||||
organizationId: number,
|
||||
attributeId: string,
|
||||
data: CreateOrganizationAttributeOptionInput
|
||||
) {
|
||||
return this.organizationAttributeOptionRepository.createOrganizationAttributeOption(
|
||||
organizationId,
|
||||
attributeId,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
async deleteOrganizationAttributeOption(organizationId: number, attributeId: string, optionId: string) {
|
||||
return this.organizationAttributeOptionRepository.deleteOrganizationAttributeOption(
|
||||
organizationId,
|
||||
attributeId,
|
||||
optionId
|
||||
);
|
||||
}
|
||||
|
||||
async updateOrganizationAttributeOption(
|
||||
organizationId: number,
|
||||
attributeId: string,
|
||||
optionId: string,
|
||||
data: UpdateOrganizationAttributeOptionInput
|
||||
) {
|
||||
return this.organizationAttributeOptionRepository.updateOrganizationAttributeOption(
|
||||
organizationId,
|
||||
attributeId,
|
||||
optionId,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
async getOrganizationAttributeOptions(organizationId: number, attributeId: string) {
|
||||
return this.organizationAttributeOptionRepository.getOrganizationAttributeOptions(
|
||||
organizationId,
|
||||
attributeId
|
||||
);
|
||||
}
|
||||
|
||||
async assignOrganizationAttributeOptionToUser(
|
||||
organizationId: number,
|
||||
userId: number,
|
||||
data: AssignOrganizationAttributeOptionToUserInput
|
||||
) {
|
||||
const attribute = await this.organizationAttributesService.getOrganizationAttribute(
|
||||
organizationId,
|
||||
data.attributeId
|
||||
);
|
||||
|
||||
if (!attribute) {
|
||||
throw new NotFoundException("Attribute not found");
|
||||
}
|
||||
|
||||
const membership = await this.organizationsMembershipsService.getOrgMembershipByUserId(
|
||||
organizationId,
|
||||
userId
|
||||
);
|
||||
|
||||
if (!membership || !membership.accepted)
|
||||
throw new NotFoundException("User is not a member of the organization");
|
||||
|
||||
if (!TYPE_SUPPORTS_VALUE.has(attribute.type) && data.value) {
|
||||
throw new BadRequestException("Attribute type does not support value");
|
||||
}
|
||||
|
||||
return this.organizationAttributeOptionRepository.assignOrganizationAttributeOptionToUser({
|
||||
organizationId,
|
||||
membershipId: membership.id,
|
||||
value: data.value,
|
||||
attributeId: data.attributeId,
|
||||
attributeOptionId: data.attributeOptionId,
|
||||
});
|
||||
}
|
||||
|
||||
async unassignOrganizationAttributeOptionFromUser(
|
||||
organizationId: number,
|
||||
userId: number,
|
||||
attributeOptionId: string
|
||||
) {
|
||||
return this.organizationAttributeOptionRepository.unassignOrganizationAttributeOptionFromUser(
|
||||
organizationId,
|
||||
userId,
|
||||
attributeOptionId
|
||||
);
|
||||
}
|
||||
|
||||
async getOrganizationAttributeOptionsForUser(organizationId: number, userId: number) {
|
||||
return this.organizationAttributeOptionRepository.getOrganizationAttributeOptionsForUser(
|
||||
organizationId,
|
||||
userId
|
||||
);
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { CreateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/create-organization-attribute.input";
|
||||
import { UpdateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/update-organization-attribute.input";
|
||||
import { OrganizationAttributesRepository } from "@/modules/organizations/repositories/attributes/organization-attribute.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationAttributesService {
|
||||
constructor(private readonly organizationAttributesRepository: OrganizationAttributesRepository) {}
|
||||
|
||||
async createOrganizationAttribute(organizationId: number, data: CreateOrganizationAttributeInput) {
|
||||
const attribute = await this.organizationAttributesRepository.createOrganizationAttribute(
|
||||
organizationId,
|
||||
data
|
||||
);
|
||||
return attribute;
|
||||
}
|
||||
|
||||
async getOrganizationAttribute(organizationId: number, attributeId: string) {
|
||||
const attribute = await this.organizationAttributesRepository.getOrganizationAttribute(
|
||||
organizationId,
|
||||
attributeId
|
||||
);
|
||||
return attribute;
|
||||
}
|
||||
|
||||
async getOrganizationAttributes(organizationId: number, skip?: number, take?: number) {
|
||||
const attributes = await this.organizationAttributesRepository.getOrganizationAttributes(
|
||||
organizationId,
|
||||
skip,
|
||||
take
|
||||
);
|
||||
return attributes;
|
||||
}
|
||||
|
||||
async updateOrganizationAttribute(
|
||||
organizationId: number,
|
||||
attributeId: string,
|
||||
data: UpdateOrganizationAttributeInput
|
||||
) {
|
||||
const attribute = await this.organizationAttributesRepository.updateOrganizationAttribute(
|
||||
organizationId,
|
||||
attributeId,
|
||||
data
|
||||
);
|
||||
return attribute;
|
||||
}
|
||||
|
||||
async deleteOrganizationAttribute(organizationId: number, attributeId: string) {
|
||||
const attribute = await this.organizationAttributesRepository.deleteOrganizationAttribute(
|
||||
organizationId,
|
||||
attributeId
|
||||
);
|
||||
return attribute;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,14 @@ export class OrganizationsMembershipService {
|
||||
return membership;
|
||||
}
|
||||
|
||||
async getOrgMembershipByUserId(organizationId: number, userId: number) {
|
||||
const membership = await this.organizationsMembershipRepository.findOrgMembershipByUserId(
|
||||
organizationId,
|
||||
userId
|
||||
);
|
||||
return membership;
|
||||
}
|
||||
|
||||
async getPaginatedOrgMemberships(organizationId: number, skip = 0, take = 250) {
|
||||
const memberships = await this.organizationsMembershipRepository.findOrgMembershipsPaginated(
|
||||
organizationId,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Injectable } from "@nestjs/common";
|
||||
export class SelectedCalendarsRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
createSelectedCalendar(externalId: string, credentialId: number, userId: number, integration: string) {
|
||||
upsertSelectedCalendar(externalId: string, credentialId: number, userId: number, integration: string) {
|
||||
return this.dbWrite.prisma.selectedCalendar.upsert({
|
||||
create: {
|
||||
userId,
|
||||
@@ -38,6 +38,18 @@ export class SelectedCalendarsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
getUserSelectedCalendar(userId: number, integration: string, externalId: string) {
|
||||
return this.dbRead.prisma.selectedCalendar.findUnique({
|
||||
where: {
|
||||
userId_integration_externalId: {
|
||||
userId,
|
||||
externalId,
|
||||
integration,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async addUserSelectedCalendar(
|
||||
userId: number,
|
||||
integration: string,
|
||||
|
||||
@@ -29,7 +29,11 @@ export class CreateManagedUserInput {
|
||||
@IsTimeZone()
|
||||
@IsOptional()
|
||||
@CapitalizeTimeZone()
|
||||
@ApiProperty({ example: "America/New_York" })
|
||||
@ApiProperty({
|
||||
example: "America/New_York",
|
||||
description: `Timezone is used to create user's default schedule from Monday to Friday from 9AM to 5PM. If it is not passed then user does not have
|
||||
a default schedule and it must be created manually via the /schedules endpoint. Until the schedule is created, the user can't access availability atom to set his / her availability nor booked.`,
|
||||
})
|
||||
timeZone?: string;
|
||||
|
||||
@IsEnum(Locales)
|
||||
|
||||
@@ -68,7 +68,6 @@ export class UsersRepository {
|
||||
}
|
||||
|
||||
async findByIdWithProfile(userId: number): Promise<UserWithProfile | null> {
|
||||
console.log("findByIdWithProfile");
|
||||
return this.dbRead.prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
|
||||
+4130
-2020
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
import { Prisma, User } from "@prisma/client";
|
||||
|
||||
export class AttributeRepositoryFixture {
|
||||
private prismaReadClient: PrismaReadService["prisma"];
|
||||
private prismaWriteClient: PrismaWriteService["prisma"];
|
||||
|
||||
constructor(private readonly module: TestingModule) {
|
||||
this.prismaReadClient = module.get(PrismaReadService).prisma;
|
||||
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
|
||||
}
|
||||
|
||||
async create(data: Prisma.AttributeCreateInput) {
|
||||
return this.prismaWriteClient.attribute.create({ data });
|
||||
}
|
||||
|
||||
async createOption(data: Prisma.AttributeOptionCreateInput) {
|
||||
return this.prismaWriteClient.attributeOption.create({ data });
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
return this.prismaWriteClient.attribute.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async deleteOption(id: string) {
|
||||
return this.prismaWriteClient.attributeOption.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@@ -22,4 +22,7 @@ const env: Partial<Omit<Environment, "NODE_ENV">> = {
|
||||
process.env = {
|
||||
...env,
|
||||
...process.env,
|
||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY:
|
||||
"BIds0AQJ96xGBjTSMHTOqLBLutQE7Lu32KKdgSdy7A2cS4mKI2cgb3iGkhDJa5Siy-stezyuPm8qpbhmNxdNHMw",
|
||||
VAPID_PRIVATE_KEY: "6cJtkASCar5sZWguIAW7OjvyixpBw9p8zL8WDDwk9Jk",
|
||||
};
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# @calcom/web
|
||||
|
||||
## 4.5.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @calcom/embed-core@1.5.1
|
||||
- @calcom/embed-react@1.5.1
|
||||
- @calcom/embed-snippet@1.3.1
|
||||
|
||||
## 4.0.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -8,18 +8,35 @@ import { FUTURE_ROUTES_ENABLED_COOKIE_NAME, FUTURE_ROUTES_OVERRIDE_COOKIE_NAME }
|
||||
const ROUTES: [URLPattern, boolean][] = [
|
||||
["/event-types", process.env.APP_ROUTER_EVENT_TYPES_ENABLED === "1"] as const,
|
||||
["/settings/admin/:path*", process.env.APP_ROUTER_SETTINGS_ADMIN_ENABLED === "1"] as const,
|
||||
["/settings/platform/:path*", process.env.APP_ROUTER_SETTINGS_PLATFORM_ENABLED === "1"] as const,
|
||||
["/settings/organizations/:path*", process.env.APP_ROUTER_SETTINGS_ORG_ENABLED === "1"] as const,
|
||||
["/apps/installed/:category", process.env.APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED === "1"] as const,
|
||||
["/apps/:slug", process.env.APP_ROUTER_APPS_SLUG_ENABLED === "1"] as const,
|
||||
["/apps/:slug/setup", process.env.APP_ROUTER_APPS_SLUG_SETUP_ENABLED === "1"] as const,
|
||||
["/apps/categories", process.env.APP_ROUTER_APPS_CATEGORIES_ENABLED === "1"] as const,
|
||||
["/apps/categories/:category", process.env.APP_ROUTER_APPS_CATEGORIES_CATEGORY_ENABLED === "1"] as const,
|
||||
["/auth/forgot-password/:path*", process.env.APP_ROUTER_AUTH_FORGOT_PASSWORD_ENABLED === "1"] as const,
|
||||
["/auth/login", process.env.APP_ROUTER_AUTH_LOGIN_ENABLED === "1"] as const,
|
||||
["/auth/logout", process.env.APP_ROUTER_AUTH_LOGOUT_ENABLED === "1"] as const,
|
||||
["/auth/new", process.env.APP_ROUTER_AUTH_NEW_ENABLED === "1"] as const,
|
||||
["/auth/saml-idp", process.env.APP_ROUTER_AUTH_SAML_ENABLED === "1"] as const,
|
||||
["/auth/error", process.env.APP_ROUTER_AUTH_ERROR_ENABLED === "1"] as const,
|
||||
["/auth/platform/:path*", process.env.APP_ROUTER_AUTH_PLATFORM_ENABLED === "1"] as const,
|
||||
["/auth/oauth2/:path*", process.env.APP_ROUTER_AUTH_OAUTH2_ENABLED === "1"] as const,
|
||||
["/workflows/:path*", process.env.APP_ROUTER_WORKFLOWS_ENABLED === "1"] as const,
|
||||
["/settings/teams/:path*", process.env.APP_ROUTER_SETTINGS_TEAMS_ENABLED === "1"] as const,
|
||||
["/getting-started/:step", process.env.APP_ROUTER_GETTING_STARTED_STEP_ENABLED === "1"] as const,
|
||||
["/apps", process.env.APP_ROUTER_APPS_ENABLED === "1"] as const,
|
||||
["/bookings/:status", process.env.APP_ROUTER_BOOKINGS_STATUS_ENABLED === "1"] as const,
|
||||
["/booking/:path*", process.env.APP_ROUTER_BOOKING_ENABLED === "1"] as const,
|
||||
["/video/:path*", process.env.APP_ROUTER_VIDEO_ENABLED === "1"] as const,
|
||||
["/team", process.env.APP_ROUTER_TEAM_ENABLED === "1"] as const,
|
||||
["/teams", process.env.APP_ROUTER_TEAMS_ENABLED === "1"] as const,
|
||||
["/more", process.env.APP_ROUTER_MORE_ENABLED === "1"] as const,
|
||||
["/maintenance", process.env.APP_ROUTER_MAINTENANCE_ENABLED === "1"] as const,
|
||||
["/upgrade", process.env.APP_ROUTER_UPGRADE_ENABLED === "1"] as const,
|
||||
["/connect-and-join", process.env.APP_ROUTER_CONNECT_AND_JOIN_ENABLED === "1"] as const,
|
||||
["/availability", process.env.APP_ROUTER_AVAILABILITY_ENABLED === "1"] as const,
|
||||
].map(([pathname, enabled]) => [
|
||||
new URLPattern({
|
||||
pathname,
|
||||
|
||||
@@ -14,10 +14,6 @@ export default function withEmbedSsrAppDir<T extends Record<string, any>>(
|
||||
) {
|
||||
return async (context: GetServerSidePropsContext): Promise<T> => {
|
||||
const { embed, layout } = context.query;
|
||||
const isCOEPEnabled = context.query["flag.coep"] === "true";
|
||||
if (isCOEPEnabled) {
|
||||
context.res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
|
||||
}
|
||||
try {
|
||||
const props = await getData(context);
|
||||
|
||||
|
||||
+22
-10
@@ -5,11 +5,10 @@ import { headers } from "next/headers";
|
||||
|
||||
import { constructGenericImage } from "@calcom/lib/OgImages";
|
||||
import { IS_CALCOM, WEBAPP_URL, APP_NAME, SEO_IMG_OGIMG } from "@calcom/lib/constants";
|
||||
import { truncateOnWord } from "@calcom/lib/text";
|
||||
//@ts-expect-error no type definitions
|
||||
import config from "@calcom/web/next-i18next.config";
|
||||
|
||||
import { preparePageMetadata } from "@lib/metadata";
|
||||
|
||||
const create = async (locale: string, ns: string) => {
|
||||
const { _nextI18Next } = await serverSideTranslations(locale, [ns], config);
|
||||
|
||||
@@ -22,7 +21,7 @@ const create = async (locale: string, ns: string) => {
|
||||
return _i18n;
|
||||
};
|
||||
|
||||
const getFixedT = async (locale: string, ns: string) => {
|
||||
export const getFixedT = async (locale: string, ns = "common") => {
|
||||
const i18n = await create(locale, ns);
|
||||
|
||||
return i18n.getFixedT(locale, ns);
|
||||
@@ -30,7 +29,8 @@ const getFixedT = async (locale: string, ns: string) => {
|
||||
|
||||
export const _generateMetadata = async (
|
||||
getTitle: (t: TFunction<string, undefined>) => string,
|
||||
getDescription: (t: TFunction<string, undefined>) => string
|
||||
getDescription: (t: TFunction<string, undefined>) => string,
|
||||
excludeAppNameFromTitle?: boolean
|
||||
) => {
|
||||
const h = headers();
|
||||
const canonical = h.get("x-pathname") ?? "";
|
||||
@@ -50,12 +50,24 @@ export const _generateMetadata = async (
|
||||
description,
|
||||
});
|
||||
|
||||
return preparePageMetadata({
|
||||
title,
|
||||
canonical,
|
||||
image,
|
||||
const titleSuffix = `| ${APP_NAME}`;
|
||||
const displayedTitle =
|
||||
title.includes(titleSuffix) || excludeAppNameFromTitle ? title : `${title} ${titleSuffix}`;
|
||||
|
||||
return {
|
||||
title: title.length === 0 ? APP_NAME : displayedTitle,
|
||||
description,
|
||||
siteName: APP_NAME,
|
||||
alternates: {
|
||||
canonical,
|
||||
},
|
||||
openGraph: {
|
||||
description: truncateOnWord(description, 158),
|
||||
url: canonical,
|
||||
type: "website",
|
||||
siteName: APP_NAME,
|
||||
title,
|
||||
images: [image],
|
||||
},
|
||||
metadataBase,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,8 +2,11 @@ import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import withEmbedSsrAppDir from "app/WithEmbedSSR";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import LegacyPage from "~/users/views/users-type-public-view";
|
||||
import { getServerSideProps, type PageProps } from "~/users/views/users-type-public-view.getServerSideProps";
|
||||
import { getServerSideProps } from "@server/lib/[user]/[type]/getServerSideProps";
|
||||
|
||||
import LegacyPage, { type PageProps } from "~/users/views/users-type-public-view";
|
||||
|
||||
export { generateMetadata } from "../page";
|
||||
|
||||
const getData = withAppDirSsr<PageProps>(getServerSideProps);
|
||||
|
||||
|
||||
@@ -4,30 +4,31 @@ import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
import { headers, cookies } from "next/headers";
|
||||
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { EventRepository } from "@calcom/lib/server/repository/event";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
import { getServerSideProps } from "@server/lib/[user]/[type]/getServerSideProps";
|
||||
|
||||
import type { PageProps as LegacyPageProps } from "~/users/views/users-type-public-view";
|
||||
import LegacyPage from "~/users/views/users-type-public-view";
|
||||
import {
|
||||
getServerSideProps,
|
||||
type PageProps as LegacyPageProps,
|
||||
} from "~/users/views/users-type-public-view.getServerSideProps";
|
||||
|
||||
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
||||
const props = await getData(buildLegacyCtx(headers(), cookies(), params, searchParams));
|
||||
const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams);
|
||||
const props = await getData(legacyCtx);
|
||||
|
||||
const { eventData, booking, user, slug } = props;
|
||||
const { booking, user: username, slug: eventSlug } = props;
|
||||
const rescheduleUid = booking?.uid;
|
||||
const { trpc } = await import("@calcom/trpc");
|
||||
const { data: event } = trpc.viewer.public.event.useQuery(
|
||||
{
|
||||
username: user,
|
||||
eventSlug: slug,
|
||||
isTeamEvent: false,
|
||||
org: eventData.entity.orgSlug ?? null,
|
||||
fromRedirectOfNonOrgLink: eventData.entity.fromRedirectOfNonOrgLink,
|
||||
},
|
||||
{ refetchOnWindowFocus: false }
|
||||
);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(legacyCtx.req, legacyCtx.params?.orgSlug);
|
||||
|
||||
const event = await EventRepository.getPublicEvent({
|
||||
username,
|
||||
eventSlug,
|
||||
isTeamEvent: false,
|
||||
org: isValidOrgDomain ? currentOrgDomain : null,
|
||||
fromRedirectOfNonOrgLink: legacyCtx.query.orgRedirection === "true",
|
||||
});
|
||||
|
||||
const profileName = event?.profile?.name ?? "";
|
||||
const title = event?.title ?? "";
|
||||
|
||||
@@ -2,8 +2,12 @@ import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import withEmbedSsrAppDir from "app/WithEmbedSSR";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import { getServerSideProps } from "@server/lib/[user]/getServerSideProps";
|
||||
|
||||
import type { PageProps as UserPageProps } from "~/users/views/users-public-view";
|
||||
import LegacyPage from "~/users/views/users-public-view";
|
||||
import { getServerSideProps, type UserPageProps } from "~/users/views/users-public-view.getServerSideProps";
|
||||
|
||||
export { generateMetadata } from "../page";
|
||||
|
||||
const getData = withAppDirSsr<UserPageProps>(getServerSideProps);
|
||||
|
||||
|
||||
@@ -8,8 +8,10 @@ import { getLayout } from "@calcom/features/MainLayoutAppDir";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
import { getServerSideProps } from "@server/lib/[user]/getServerSideProps";
|
||||
|
||||
import type { PageProps as LegacyPageProps } from "~/users/views/users-public-view";
|
||||
import LegacyPage from "~/users/views/users-public-view";
|
||||
import { getServerSideProps, type UserPageProps } from "~/users/views/users-public-view.getServerSideProps";
|
||||
|
||||
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
||||
const props = await getData(buildLegacyCtx(headers(), cookies(), params, searchParams));
|
||||
@@ -21,5 +23,5 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const getData = withAppDirSsr<UserPageProps>(getServerSideProps);
|
||||
const getData = withAppDirSsr<LegacyPageProps>(getServerSideProps);
|
||||
export default WithLayout({ getLayout, getData, Page: LegacyPage })<"P">;
|
||||
|
||||
@@ -1,186 +1,45 @@
|
||||
import LegacyPage, { getLayout } from "@pages/apps/[slug]/[...pages]";
|
||||
import type { PageProps, SearchParams } from "app/_types";
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { Params } from "next/dist/shared/lib/router/utils/route-matcher";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
import type { GetServerSidePropsResult } from "next";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { notFound } from "next/navigation";
|
||||
import z from "zod";
|
||||
|
||||
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
|
||||
import RoutingFormsRoutingConfig, {
|
||||
serverSidePropsConfig,
|
||||
} from "@calcom/app-store/routing-forms/pages/app-routing.config";
|
||||
import TypeformRoutingConfig from "@calcom/app-store/typeform/pages/app-routing.config";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { AppGetServerSideProps } from "@calcom/types/AppGetServerSideProps";
|
||||
|
||||
import type { AppProps } from "@lib/app-providers";
|
||||
import { getServerSideProps } from "@lib/apps/[slug]/[...pages]/getServerSideProps";
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
import PageWrapper from "@components/PageWrapperAppDir";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
type AppPageType = {
|
||||
getServerSideProps: AppGetServerSideProps;
|
||||
// A component than can accept any properties
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: ((props: any) => JSX.Element) &
|
||||
Pick<AppProps["Component"], "isBookingPage" | "getLayout" | "PageWrapper">;
|
||||
};
|
||||
|
||||
type Found = {
|
||||
notFound: false;
|
||||
Component: AppPageType["default"];
|
||||
getServerSideProps: AppPageType["getServerSideProps"];
|
||||
};
|
||||
|
||||
const AppsRouting = {
|
||||
"routing-forms": RoutingFormsRoutingConfig,
|
||||
typeform: TypeformRoutingConfig,
|
||||
};
|
||||
import LegacyPage, { getLayout } from "~/apps/[slug]/[...pages]/pages-view";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
slug: z.string(),
|
||||
pages: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const generateMetadata = async ({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Params;
|
||||
searchParams: SearchParams;
|
||||
}) => {
|
||||
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
||||
const p = paramsSchema.safeParse(params);
|
||||
|
||||
if (!p.success) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const mainPage = p.data.pages[0];
|
||||
|
||||
if (mainPage === "forms") {
|
||||
return await _generateMetadata(
|
||||
() => `Forms`,
|
||||
() => ""
|
||||
);
|
||||
}
|
||||
|
||||
const legacyContext = buildLegacyCtx(headers(), cookies(), params, searchParams);
|
||||
const { form } = await getPageProps(legacyContext);
|
||||
const data = await getData(legacyContext);
|
||||
const form = "form" in data ? (data.form as { name?: string; description?: string }) : null;
|
||||
const formName = form?.name ?? "Routing Forms";
|
||||
const formDescription = form?.description ?? "";
|
||||
|
||||
return await _generateMetadata(
|
||||
() => `${form.name}`,
|
||||
() => form.description
|
||||
() => formName,
|
||||
() => formDescription
|
||||
);
|
||||
};
|
||||
|
||||
function getRoute(appName: string, pages: string[]) {
|
||||
const routingConfig = AppsRouting[appName as keyof typeof AppsRouting] as Record<string, AppPageType>;
|
||||
const getData = withAppDirSsr<GetServerSidePropsResult<any>>(getServerSideProps);
|
||||
|
||||
if (!routingConfig) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const mainPage = pages[0];
|
||||
const appPage = routingConfig.layoutHandler || (routingConfig[mainPage] as AppPageType);
|
||||
|
||||
const getServerSidePropsHandler = serverSidePropsConfig[mainPage];
|
||||
|
||||
if (!appPage) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return {
|
||||
notFound: false,
|
||||
Component: appPage.default,
|
||||
...appPage,
|
||||
getServerSideProps: getServerSidePropsHandler,
|
||||
} as Found;
|
||||
}
|
||||
|
||||
const getPageProps = async ({ params, query, req }: GetServerSidePropsContext) => {
|
||||
const p = paramsSchema.safeParse(params);
|
||||
|
||||
if (!p.success) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const { slug: appName, pages } = p.data;
|
||||
|
||||
const route = getRoute(appName, pages);
|
||||
|
||||
if (route.notFound) {
|
||||
return route;
|
||||
}
|
||||
|
||||
if (route.getServerSideProps) {
|
||||
// TODO: Document somewhere that right now it is just a convention that filename should have appPages in it's name.
|
||||
// appPages is actually hardcoded here and no matter the fileName the same variable would be used.
|
||||
// We can write some validation logic later on that ensures that [...appPages].tsx file exists
|
||||
params!.appPages = pages.slice(1);
|
||||
|
||||
const ctx = { req, params, query };
|
||||
|
||||
const session = await getServerSession({ req });
|
||||
const user = session?.user;
|
||||
const app = await getAppWithMetadata({ slug: appName });
|
||||
|
||||
if (!app) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const result = await route.getServerSideProps(
|
||||
{
|
||||
...ctx,
|
||||
params: {
|
||||
...ctx.params,
|
||||
appPages: pages.slice(1),
|
||||
},
|
||||
} as GetServerSidePropsContext<{
|
||||
slug: string;
|
||||
pages: string[];
|
||||
appPages: string[];
|
||||
}>,
|
||||
prisma,
|
||||
user,
|
||||
ssrInit
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
|
||||
if (result.notFound) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (result.redirect) {
|
||||
redirect(result.redirect.destination);
|
||||
}
|
||||
|
||||
return {
|
||||
appName,
|
||||
appUrl: app.simplePath || `/apps/${appName}`,
|
||||
...result.props,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
appName,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export default async function Page({ params, searchParams }: PageProps) {
|
||||
const h = headers();
|
||||
const nonce = h.get("x-nonce") ?? undefined;
|
||||
|
||||
const legacyContext = buildLegacyCtx(h, cookies(), params, searchParams);
|
||||
const props = await getPageProps(legacyContext);
|
||||
return (
|
||||
<PageWrapper getLayout={getLayout} requiresLicense={false} nonce={nonce} themeBasis={null} {...props}>
|
||||
<LegacyPage {...props} />
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
export default WithLayout({
|
||||
getLayout,
|
||||
getData,
|
||||
Page: LegacyPage,
|
||||
});
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import Page from "@pages/apps/[slug]/index";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { withAppDirSsg } from "app/WithAppDirSsg";
|
||||
import type { PageProps } from "app/_types";
|
||||
import type { PageProps as _PageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
import type { InferGetStaticPropsType } from "next";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import { AppRepository } from "@calcom/lib/server/repository/app";
|
||||
|
||||
import { getStaticProps } from "@lib/apps/[slug]/getStaticProps";
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
type Y = InferGetStaticPropsType<typeof getStaticProps>;
|
||||
const getData = withAppDirSsg<Y>(getStaticProps);
|
||||
import type { PageProps } from "~/apps/[slug]/slug-view";
|
||||
import Page from "~/apps/[slug]/slug-view";
|
||||
|
||||
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
||||
const getData = withAppDirSsg<PageProps>(getStaticProps);
|
||||
|
||||
export const generateMetadata = async ({ params, searchParams }: _PageProps) => {
|
||||
const legacyContext = buildLegacyCtx(headers(), cookies(), params, searchParams);
|
||||
const res = await getData(legacyContext);
|
||||
|
||||
@@ -27,7 +27,7 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
||||
|
||||
export const generateStaticParams = async () => {
|
||||
try {
|
||||
const appStore = await prisma.app.findMany({ select: { slug: true } });
|
||||
const appStore = await AppRepository.findAppStore();
|
||||
return appStore.map(({ slug }) => ({ slug }));
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Prisma.PrismaClientInitializationError) {
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import Page from "@pages/apps/[slug]/setup";
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import type { PageProps as _PageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
import type { InferGetServerSidePropsType } from "next";
|
||||
|
||||
import { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps";
|
||||
|
||||
export const generateMetadata = async ({ params }: { params: Record<string, string | string[]> }) => {
|
||||
import Page, { type PageProps } from "~/apps/[slug]/setup/setup-view";
|
||||
|
||||
export const generateMetadata = async ({ params }: _PageProps) => {
|
||||
return await _generateMetadata(
|
||||
() => `${params.slug}`,
|
||||
() => ""
|
||||
);
|
||||
};
|
||||
|
||||
type T = InferGetServerSidePropsType<typeof getServerSideProps>;
|
||||
|
||||
const getData = withAppDirSsr<T>(getServerSideProps);
|
||||
const getData = withAppDirSsr<PageProps>(getServerSideProps);
|
||||
|
||||
export default WithLayout({ getLayout: null, Page, getData });
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import CategoryPage, { type PageProps } from "@pages/apps/categories/[category]";
|
||||
import { withAppDirSsg } from "app/WithAppDirSsg";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { AppCategories } from "@calcom/prisma/enums";
|
||||
import { isPrismaAvailableCheck } from "@calcom/prisma/is-prisma-available-check";
|
||||
|
||||
import { getStaticProps } from "@lib/apps/categories/[category]/getStaticProps";
|
||||
|
||||
import CategoryPage, { type PageProps } from "~/apps/categories/[category]/category-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
() => `${APP_NAME}`,
|
||||
() => ""
|
||||
() => "Apps Store",
|
||||
() => "Connecting people, technology and the workplace."
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import Page from "@pages/apps/categories/index";
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import { getServerSideProps } from "@lib/apps/categories/getServerSideProps";
|
||||
|
||||
import Page from "~/apps/categories/categories-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
() => `Categories`,
|
||||
() => ""
|
||||
() => "Apps Store",
|
||||
() => "Connecting people, technology and the workplace."
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { getServerSideProps } from "@lib/apps/installation/[[...step]]/getServerSideProps";
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
import type { OnboardingPageProps } from "~/apps/installation/[[...step]]/step-view";
|
||||
import Page from "~/apps/installation/[[...step]]/step-view";
|
||||
|
||||
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
||||
const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams);
|
||||
|
||||
const { appMetadata } = await getData(legacyCtx);
|
||||
return await _generateMetadata(
|
||||
(t) => `${t("install")} ${appMetadata?.name ?? ""}`,
|
||||
() => ""
|
||||
);
|
||||
};
|
||||
|
||||
const getData = withAppDirSsr<OnboardingPageProps>(getServerSideProps);
|
||||
|
||||
export default WithLayout({ getLayout: null, getData, Page });
|
||||
@@ -1,17 +1,18 @@
|
||||
import Page from "@pages/apps/installed/[category]";
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import { getServerSideProps } from "@lib/apps/installed/[category]/getServerSideProps";
|
||||
import { getServerSidePropsAppDir } from "@lib/apps/installed/[category]/getServerSideProps";
|
||||
|
||||
import Page from "~/apps/installed/[category]/installed-category-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${t("installed_apps")}`,
|
||||
(t) => t("installed_apps"),
|
||||
(t) => t("manage_your_connected_apps")
|
||||
);
|
||||
};
|
||||
|
||||
const getData = withAppDirSsr(getServerSideProps);
|
||||
const getData = withAppDirSsr(getServerSidePropsAppDir);
|
||||
|
||||
export default WithLayout({ getLayout: null, getData, Page });
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
const Page = () => {
|
||||
redirect("/apps/installed/calendar");
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,18 +1,20 @@
|
||||
import AppsPage from "@pages/apps";
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import { getLayout } from "@calcom/features/MainLayoutAppDir";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import { getServerSideProps } from "@lib/apps/getServerSideProps";
|
||||
|
||||
import AppsPage, { LayoutWrapper } from "~/apps/apps-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
() => `Apps | ${APP_NAME}`,
|
||||
() => ""
|
||||
(t) => t("app_store"),
|
||||
(t) => t("app_store_description")
|
||||
);
|
||||
};
|
||||
|
||||
export default WithLayout({ getLayout, getData: withAppDirSsr(getServerSideProps), Page: AppsPage });
|
||||
export default WithLayout({
|
||||
getLayout: LayoutWrapper,
|
||||
getData: withAppDirSsr(getServerSideProps),
|
||||
Page: AppsPage,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { withAppDirSsg } from "app/WithAppDirSsg";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import { getStaticProps } from "@server/lib/auth/error/getStaticProps";
|
||||
|
||||
import Page from "~/auth/error/error-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
() => "Error",
|
||||
() => ""
|
||||
);
|
||||
};
|
||||
|
||||
const getData = withAppDirSsg(getStaticProps);
|
||||
|
||||
export default WithLayout({ getData, Page, getLayout: null })<"P">;
|
||||
export const dynamic = "force-static";
|
||||
@@ -1,9 +1,11 @@
|
||||
import SetNewUserPassword from "@pages/auth/forgot-password/[id]";
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import { getServerSideProps } from "@server/lib/forgot-password/[id]/getServerSideProps";
|
||||
import { getServerSideProps } from "@server/lib/auth/forgot-password/[id]/getServerSideProps";
|
||||
|
||||
import type { PageProps } from "~/auth/forgot-password/[id]/forgot-password-single-view";
|
||||
import SetNewUserPassword from "~/auth/forgot-password/[id]/forgot-password-single-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
@@ -15,5 +17,5 @@ export const generateMetadata = async () => {
|
||||
export default WithLayout({
|
||||
getLayout: null,
|
||||
Page: SetNewUserPassword,
|
||||
getData: withAppDirSsr(getServerSideProps),
|
||||
getData: withAppDirSsr<PageProps>(getServerSideProps),
|
||||
})<"P">;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user