Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1aa2f68e73 | ||
|
|
ad707b0ee4 | ||
|
|
49a2f4d4f7 | ||
|
|
f576979345 | ||
|
|
b45399ab78 | ||
|
|
306dc5e4fb | ||
|
|
605304b93b | ||
|
|
d6a17b2b1e | ||
|
|
4bee875a8c | ||
|
|
fe4ae3ae62 | ||
|
|
a245578ff2 | ||
|
|
f5ec696e15 | ||
|
|
ec9384858b | ||
|
|
47613f796e | ||
|
|
446c780d85 | ||
|
|
615968c640 | ||
|
|
fc89436b6a | ||
|
|
3c1015cb11 | ||
|
|
46c513330c | ||
|
|
ef1598faeb | ||
|
|
42a6903057 | ||
|
|
0f0c2b8b92 | ||
|
|
c400c36306 | ||
|
|
6410bd2eaf | ||
|
|
2ddc5317b8 | ||
|
|
6fffe3377d | ||
|
|
eedef35d02 | ||
|
|
08d232a816 | ||
|
|
d9b3f8657f | ||
|
|
99dc5837f5 | ||
|
|
5a35540522 | ||
|
|
c01fc08f67 | ||
|
|
7397722eb3 | ||
|
|
d51e03b14b | ||
|
|
c4af180a5b | ||
|
|
ee5f1dc879 | ||
|
|
15b43176ff | ||
|
|
03240709dc | ||
|
|
7ee451ca02 | ||
|
|
55b066c475 | ||
|
|
c52bbc4be3 | ||
|
|
31b1f31d82 | ||
|
|
ab1c1bb015 | ||
|
|
45219f4736 | ||
|
|
3296255f80 | ||
|
|
81b4651d6e | ||
|
|
e3bd90c4f2 | ||
|
|
17f1932467 | ||
|
|
1b04fe82a7 | ||
|
|
ffb36509ff | ||
|
|
dc361f7879 | ||
|
|
0b59a5d184 | ||
|
|
54c17eb827 | ||
|
|
bd1142ea7a | ||
|
|
26051833cf | ||
|
|
1d8bc6ca49 | ||
|
|
fbe8cf8223 | ||
|
|
ef8b3e83ac | ||
|
|
9d228a1d96 | ||
|
|
57df25505d | ||
|
|
31cbea0e23 | ||
|
|
edd80c056c | ||
|
|
6ebf0db389 | ||
|
|
6f4e51b859 | ||
|
|
4f5d0db792 | ||
|
|
8733bcaf7a | ||
|
|
5aaca2b7aa | ||
|
|
eacd05bf3b | ||
|
|
d51e0182b5 | ||
|
|
220897ba86 | ||
|
|
78c0203a45 | ||
|
|
720fd90066 | ||
|
|
ca8ee749a2 | ||
|
|
ce2d3a4b00 | ||
|
|
2923684c7c | ||
|
|
879ae597be | ||
|
|
d21f7ccbc8 | ||
|
|
4cc1bc6746 | ||
|
|
49a2c60212 | ||
|
|
7dfbb42ced | ||
|
|
41fafb9e5e | ||
|
|
6815f2f9c7 | ||
|
|
91ab27e070 | ||
|
|
396ccb1cd2 | ||
|
|
4e8d5cebca | ||
|
|
80f2e61189 | ||
|
|
d844fd6ef7 | ||
|
|
513f54f4fe | ||
|
|
2e6d71f493 | ||
|
|
55488c39f4 | ||
|
|
c0e123a917 | ||
|
|
ec88f16f1f | ||
|
|
a957c15062 | ||
|
|
b32b31ccdc | ||
|
|
e29f6af540 | ||
|
|
c6cb3423fa | ||
|
|
45904c07bc | ||
|
|
0623887ff8 | ||
|
|
f31c45a153 | ||
|
|
f5217caef2 |
+5
-1
@@ -443,4 +443,8 @@ NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT=10
|
||||
NEXT_PUBLIC_HEAD_SCRIPTS=
|
||||
NEXT_PUBLIC_BODY_SCRIPTS=
|
||||
|
||||
DATABASE_CHUNK_SIZE=
|
||||
DATABASE_CHUNK_SIZE=
|
||||
|
||||
# Service Account Encryption Key for encrypting/decrypting service account keys
|
||||
# You can use: `openssl rand -base64 24` to generate one
|
||||
CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY=
|
||||
|
||||
+7
-5
@@ -1,9 +1,9 @@
|
||||
/**/package.json @calcom/Foundation
|
||||
/apps/api/**/* @calcom/Platform @calcom/Foundation
|
||||
/apps/ui-playground/**/* @calcom/Consumer
|
||||
/apps/web/components/**/* @calcom/Consumer
|
||||
/apps/web/modules/**/* @calcom/Consumer
|
||||
/apps/web/**/layout.tsx @calcom/Foundation
|
||||
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
|
||||
/apps/web/components/**/* @calcom/Consumer @calcom/Foundation
|
||||
/apps/web/modules/**/* @calcom/Consumer @calcom/Foundation
|
||||
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
|
||||
/apps/web/lib/**/* @calcom/Foundation
|
||||
/apps/web/middleware.ts @calcom/Foundation
|
||||
/deploy/**/* @calcom/Foundation
|
||||
@@ -26,5 +26,7 @@
|
||||
/packages/lib/slots.ts @calcom/Foundation
|
||||
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
||||
/packages/prisma/**/* @calcom/Foundation
|
||||
/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @calcom/Foundation
|
||||
/packages/trpc/server/routers/viewer/bookings/get.handler.ts @calcom/Foundation
|
||||
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
|
||||
/packages/ui/**/* @calcom/Consumer
|
||||
/packages/ui/**/* @calcom/Consumer @calcom/Foundation
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
on:
|
||||
workflow_call:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
jobs:
|
||||
cleanup-report:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout GitHub Pages Branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: calcom/test-results
|
||||
ref: gh-pages
|
||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
|
||||
- name: Set Git User
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Cleanup HTML reports on PR close/merge
|
||||
env:
|
||||
BRANCH_REPORTS_DIR: reports/${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
if [ -d "$BRANCH_REPORTS_DIR" ]; then
|
||||
rm -rf "$BRANCH_REPORTS_DIR"
|
||||
git add .
|
||||
git commit -m "workflow: remove all reports for branch ${{ github.event.pull_request.head.ref }} (PR closed/merged)"
|
||||
git push
|
||||
else
|
||||
echo "No reports found for branch ${{ github.event.pull_request.head.ref }}. Nothing to clean up."
|
||||
fi
|
||||
@@ -219,6 +219,17 @@ jobs:
|
||||
uses: ./.github/workflows/publish-report.yml
|
||||
secrets: inherit
|
||||
|
||||
cleanup-report:
|
||||
name: Cleanup HTML report
|
||||
if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' && (github.event.pull_request.merged == true || github.event.pull_request.state == 'closed') }}
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
needs: [changes, check-label, publish-report]
|
||||
uses: ./.github/workflows/cleanup-report.yml
|
||||
secrets: inherit
|
||||
|
||||
required:
|
||||
needs:
|
||||
[
|
||||
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@calcom/prisma"
|
||||
Vendored
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@calcom/prisma"
|
||||
Vendored
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@calcom/prisma"
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
Possible issue:
|
||||
- We won't be able to use already built calendar-cache when Delegation Credential is enabled because existing CalendarCache entries don't have userId set.
|
||||
|
||||
## Approach of always using SelectedCalendar.credentialId for CalendarCache even for DelegationCredentials
|
||||
- Delete Credentials when DelegationCredential is disabled
|
||||
|
||||
|
||||
TO Test
|
||||
- New members beyond the batch size are processed
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -185,7 +185,12 @@ async function verifyCredentialsAndGetId({
|
||||
currentCredentialId: number | null;
|
||||
}) {
|
||||
if (parsedBody.integration && parsedBody.externalId) {
|
||||
const calendarCredentials = getCalendarCredentialsWithoutDelegation(userCredentials);
|
||||
const calendarCredentials = getCalendarCredentialsWithoutDelegation(
|
||||
userCredentials.map((cred) => ({
|
||||
...cred,
|
||||
delegationCredentialId: null,
|
||||
}))
|
||||
);
|
||||
|
||||
const { connectedCalendars } = await getConnectedCalendars(
|
||||
calendarCredentials,
|
||||
|
||||
@@ -24,7 +24,12 @@ import { membershipCreateBodySchema, schemaMembershipPublic } from "~/lib/valida
|
||||
*/
|
||||
async function postHandler(req: NextApiRequest) {
|
||||
const data = membershipCreateBodySchema.parse(req.body);
|
||||
const args: Prisma.MembershipCreateArgs = { data };
|
||||
const args: Prisma.MembershipCreateArgs = {
|
||||
data: {
|
||||
createdAt: new Date(),
|
||||
...data,
|
||||
},
|
||||
};
|
||||
|
||||
await checkPermissions(req);
|
||||
|
||||
|
||||
@@ -112,6 +112,8 @@ $ yarn run test:cov
|
||||
### Guards
|
||||
1. In case a guard would return "false" for "canActivate" instead throw ForbiddenException with an error message containing guard name and the error.
|
||||
2. In case a guard would return "false" for "canActivate" DO NOT cache the result in redis, because we don't want that someone is forbidden, updates whatever was the problem, and then has to wait for cache to expire. We only cache in redis guard results where "canAccess" is "true".
|
||||
3. If you use ApiAuthGuard but want that only specific auth method is allowed, for example, api key, then you also need to add `@ApiAuthGuardOnlyAllow(["API_KEY"])` under the `@UseGuards(ApiAuthGuard)`. Shortly, use `ApiAuthGuardOnlyAllow` to specify which auth methods are allowed by `ApiAuthGuard`. If `ApiAuthGuardOnlyAllow` is not used or nothing is passed to it or empty array it means that
|
||||
all auth methods are allowed.
|
||||
|
||||
## Support
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/test/jest.setup-e2e.ts"],
|
||||
"reporters": ["default", "jest-summarizing-reporter"],
|
||||
"workerIdleMemoryLimit": "512MB",
|
||||
"maxWorkers": 8
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.174",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.185",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
|
||||
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||
@@ -67,6 +68,7 @@ import { Module } from "@nestjs/common";
|
||||
SelectedCalendarsRepository,
|
||||
OrganizationsTeamsRepository,
|
||||
OrganizationsRepository,
|
||||
ErrorsBookingsService_2024_08_13,
|
||||
],
|
||||
controllers: [BookingsController_2024_08_13],
|
||||
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
|
||||
|
||||
+4
-4
@@ -385,7 +385,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
|
||||
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -430,7 +430,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
|
||||
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -459,7 +459,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
|
||||
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -502,7 +502,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
|
||||
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -583,7 +583,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(data.rating).toEqual(bookingInThePast.rating);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -642,7 +642,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
await bookingsRepositoryFixture.deleteById(mockBooking.id);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { CalendarLink } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
|
||||
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
|
||||
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||
@@ -85,34 +86,19 @@ export class BookingsService_2024_08_13 {
|
||||
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository,
|
||||
private readonly organizationsRepository: OrganizationsRepository,
|
||||
private readonly teamsRepository: TeamsRepository,
|
||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository
|
||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
|
||||
private readonly errorsBookingsService: ErrorsBookingsService_2024_08_13
|
||||
) {}
|
||||
|
||||
async createBooking(request: Request, body: CreateBookingInput) {
|
||||
let bookingTeamEventType = false;
|
||||
try {
|
||||
const eventType = await this.getBookedEventType(body);
|
||||
if (eventType?.team) {
|
||||
bookingTeamEventType = true;
|
||||
}
|
||||
if (!eventType) {
|
||||
if (body.username && body.eventTypeSlug && !body.organizationSlug) {
|
||||
throw new NotFoundException(
|
||||
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} not found.`
|
||||
);
|
||||
}
|
||||
if (body.username && body.eventTypeSlug && body.organizationSlug) {
|
||||
throw new NotFoundException(
|
||||
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} within organization ${body.organizationSlug} not found.`
|
||||
);
|
||||
}
|
||||
if (body.teamSlug && body.eventTypeSlug && !body.organizationSlug) {
|
||||
throw new NotFoundException(
|
||||
`Event type with slug ${body.eventTypeSlug} belonging to team ${body.teamSlug} not found.`
|
||||
);
|
||||
}
|
||||
if (body.teamSlug && body.eventTypeSlug && body.organizationSlug) {
|
||||
throw new NotFoundException(
|
||||
`Event type with slug ${body.eventTypeSlug} belonging to team ${body.teamSlug} within organization ${body.organizationSlug} not found.`
|
||||
);
|
||||
}
|
||||
throw new NotFoundException(`Event type with id ${body.eventTypeId} not found.`);
|
||||
this.errorsBookingsService.handleEventTypeToBeBookedNotFound(body);
|
||||
}
|
||||
|
||||
body.eventTypeId = eventType.id;
|
||||
@@ -138,18 +124,7 @@ export class BookingsService_2024_08_13 {
|
||||
|
||||
return await this.createRegularBooking(request, body, eventType);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "no_available_users_found_error") {
|
||||
throw new BadRequestException("User either already has booking at this time or is not available");
|
||||
} else if (error.message === "booking_time_out_of_bounds_error") {
|
||||
throw new BadRequestException(
|
||||
`The event type can't be booked at selected time ${body.start}. This could be because it's too soon (violating the minimum booking notice) or too far in the future (outside the event's scheduling window). Try fetching available slots first using the GET /v2/slots endpoint and then make a booking with "start" time equal to one of the available slots: https://cal.com/docs/api-reference/v2/slots`
|
||||
);
|
||||
} else if (error.message === "Attempting to book a meeting in the past.") {
|
||||
throw new BadRequestException("Attempting to book a meeting in the past.");
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
this.errorsBookingsService.handleBookingError(error, bookingTeamEventType);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Logger } from "@nestjs/common";
|
||||
|
||||
import { CreateBookingInput } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class ErrorsBookingsService_2024_08_13 {
|
||||
private readonly logger = new Logger("ErrorsBookingsService_2024_08_13");
|
||||
|
||||
handleEventTypeToBeBookedNotFound(body: CreateBookingInput): never {
|
||||
if (body.username && body.eventTypeSlug && !body.organizationSlug) {
|
||||
throw new NotFoundException(
|
||||
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} not found.`
|
||||
);
|
||||
}
|
||||
if (body.username && body.eventTypeSlug && body.organizationSlug) {
|
||||
throw new NotFoundException(
|
||||
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} within organization ${body.organizationSlug} not found.`
|
||||
);
|
||||
}
|
||||
if (body.teamSlug && body.eventTypeSlug && !body.organizationSlug) {
|
||||
throw new NotFoundException(
|
||||
`Event type with slug ${body.eventTypeSlug} belonging to team ${body.teamSlug} not found.`
|
||||
);
|
||||
}
|
||||
if (body.teamSlug && body.eventTypeSlug && body.organizationSlug) {
|
||||
throw new NotFoundException(
|
||||
`Event type with slug ${body.eventTypeSlug} belonging to team ${body.teamSlug} within organization ${body.organizationSlug} not found.`
|
||||
);
|
||||
}
|
||||
throw new NotFoundException(`Event type with id ${body.eventTypeId} not found.`);
|
||||
}
|
||||
|
||||
handleBookingError(error: unknown, bookingTeamEventType: boolean): never {
|
||||
const hostsUnavaile = "One of the hosts either already has booking at this time or is not available";
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "no_available_users_found_error") {
|
||||
if (bookingTeamEventType) {
|
||||
throw new BadRequestException(hostsUnavaile);
|
||||
}
|
||||
throw new BadRequestException("User either already has booking at this time or is not available");
|
||||
} else if (error.message === "booking_time_out_of_bounds_error") {
|
||||
throw new BadRequestException(
|
||||
`The event type can't be booked at the "start" time provided. This could be because it's too soon (violating the minimum booking notice) or too far in the future (outside the event's scheduling window). Try fetching available slots first using the GET /v2/slots endpoint and then make a booking with "start" time equal to one of the available slots: https://cal.com/docs/api-reference/v2/slots`
|
||||
);
|
||||
} else if (error.message === "Attempting to book a meeting in the past.") {
|
||||
throw new BadRequestException("Attempting to book a meeting in the past.");
|
||||
} else if (error.message === "hosts_unavailable_for_booking") {
|
||||
throw new BadRequestException(hostsUnavaile);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { IcsFeedService } from "@/ee/calendars/services/ics-feed.service";
|
||||
import { OutlookService } from "@/ee/calendars/services/outlook.service";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { ApiAuthGuardOnlyAllow } from "@/modules/auth/decorators/api-auth-guard-only-allow.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
@@ -51,7 +52,7 @@ import {
|
||||
APPLE_CALENDAR,
|
||||
CREDENTIAL_CALENDARS,
|
||||
} from "@calcom/platform-constants";
|
||||
import { ApiResponse, CalendarBusyTimesInput } from "@calcom/platform-types";
|
||||
import { ApiResponse, CalendarBusyTimesInput, CreateCalendarCredentialsInput } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/calendars",
|
||||
@@ -135,6 +136,7 @@ export class CalendarsController {
|
||||
name: "calendar",
|
||||
})
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiAuthGuardOnlyAllow(["API_KEY", "ACCESS_TOKEN"])
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Get("/:calendar/connect")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -173,7 +175,7 @@ export class CalendarsController {
|
||||
@Get("/:calendar/save")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Redirect(undefined, 301)
|
||||
@ApiOperation({ summary: "Save an oAuth calendar credentials" })
|
||||
@ApiOperation({ summary: "Save Google or Outlook calendar credentials" })
|
||||
async save(
|
||||
@Query("state") state: string,
|
||||
@Query("code") code: string,
|
||||
@@ -221,11 +223,11 @@ export class CalendarsController {
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Post("/:calendar/credentials")
|
||||
@ApiOperation({ summary: "Sync credentials" })
|
||||
@ApiOperation({ summary: "Save Apple calendar credentials" })
|
||||
async syncCredentials(
|
||||
@GetUser() user: User,
|
||||
@Param("calendar") calendar: string,
|
||||
@Body() body: { username: string; password: string }
|
||||
@Body() body: CreateCalendarCredentialsInput
|
||||
): Promise<{ status: string }> {
|
||||
const { username, password } = body;
|
||||
|
||||
|
||||
@@ -60,8 +60,9 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
}
|
||||
|
||||
async saveCalendarCredentials(userId: number, userEmail: string, username: string, password: string) {
|
||||
if (username.length <= 1 || password.length <= 1)
|
||||
if (!username || !password || username.length <= 1 || password.length <= 1) {
|
||||
throw new BadRequestException(`Username or password cannot be empty`);
|
||||
}
|
||||
|
||||
const existingAppleCalendarCredentials = await this.credentialRepository.getAllUserCredentialsByTypeAndId(
|
||||
APPLE_CALENDAR_TYPE,
|
||||
@@ -118,6 +119,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
teamId: null,
|
||||
appId: APPLE_CALENDAR_ID,
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
};
|
||||
|
||||
const dav = new CalendarService({
|
||||
|
||||
@@ -35,6 +35,7 @@ export class IcsFeedService implements ICSFeedCalendarApp {
|
||||
teamId: null,
|
||||
appId: ICS_CALENDAR,
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
+8
@@ -490,6 +490,7 @@ describe("Event types Endpoints", () => {
|
||||
requiresBookerEmailVerification: false,
|
||||
hideCalendarNotes: false,
|
||||
hideCalendarEventDetails: false,
|
||||
hideOrganizerEmail: false,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
color: {
|
||||
darkThemeHex: "#292929",
|
||||
@@ -529,6 +530,7 @@ describe("Event types Endpoints", () => {
|
||||
|
||||
expect(createdEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||
expect(createdEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||
expect(createdEventType.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
|
||||
expect(createdEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||
body.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
@@ -610,6 +612,7 @@ describe("Event types Endpoints", () => {
|
||||
);
|
||||
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
||||
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
||||
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
|
||||
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||
eventType.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
@@ -1009,6 +1012,7 @@ describe("Event types Endpoints", () => {
|
||||
requiresBookerEmailVerification: true,
|
||||
hideCalendarNotes: true,
|
||||
hideCalendarEventDetails: true,
|
||||
hideOrganizerEmail: true,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
color: {
|
||||
darkThemeHex: "#292929",
|
||||
@@ -1073,6 +1077,7 @@ describe("Event types Endpoints", () => {
|
||||
);
|
||||
expect(updatedEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||
expect(updatedEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||
expect(updatedEventType.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
|
||||
expect(updatedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||
body.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
@@ -1093,6 +1098,7 @@ describe("Event types Endpoints", () => {
|
||||
eventType.requiresBookerEmailVerification = updatedEventType.requiresBookerEmailVerification;
|
||||
eventType.hideCalendarNotes = updatedEventType.hideCalendarNotes;
|
||||
eventType.hideCalendarEventDetails = updatedEventType.hideCalendarEventDetails;
|
||||
eventType.hideOrganizerEmail = updatedEventType.hideOrganizerEmail;
|
||||
eventType.lockTimeZoneToggleOnBookingPage = updatedEventType.lockTimeZoneToggleOnBookingPage;
|
||||
eventType.color = updatedEventType.color;
|
||||
eventType.bookingFields = updatedEventType.bookingFields;
|
||||
@@ -1163,6 +1169,7 @@ describe("Event types Endpoints", () => {
|
||||
);
|
||||
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
||||
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
||||
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
|
||||
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||
eventType.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
@@ -1204,6 +1211,7 @@ describe("Event types Endpoints", () => {
|
||||
);
|
||||
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
||||
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
||||
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
|
||||
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||
eventType.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
|
||||
+3
@@ -87,6 +87,7 @@ type Input = Pick<
|
||||
| "destinationCalendar"
|
||||
| "useEventTypeDestinationCalendarEmail"
|
||||
| "hideCalendarEventDetails"
|
||||
| "hideOrganizerEmail"
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
@@ -123,6 +124,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
seatsShowAttendees,
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
hideCalendarEventDetails,
|
||||
hideOrganizerEmail,
|
||||
} = databaseEventType;
|
||||
|
||||
const locations = this.transformLocations(databaseEventType.locations);
|
||||
@@ -197,6 +199,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
destinationCalendar,
|
||||
useDestinationCalendarEmail: useEventTypeDestinationCalendarEmail,
|
||||
hideCalendarEventDetails,
|
||||
hideOrganizerEmail,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@ import { Permissions } from "@/modules/auth/decorators/permissions/permissions.d
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
@@ -48,8 +46,6 @@ export class GcalController {
|
||||
|
||||
constructor(
|
||||
private readonly credentialRepository: CredentialsRepository,
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
|
||||
private readonly config: ConfigService,
|
||||
private readonly gcalService: GCalService,
|
||||
private readonly calendarsService: CalendarsService
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { RateLimitType } from "@/lib/throttler-guard";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
|
||||
export const Throttle = Reflector.createDecorator<RateLimitType>();
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getEnv } from "@/env";
|
||||
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||
import { Throttle } from "@/lib/endpoint-throttler-decorator";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
|
||||
import { Inject, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
|
||||
@@ -22,7 +23,7 @@ const rateLimitSchema = z.object({
|
||||
ttl: z.number(),
|
||||
blockDuration: z.number(),
|
||||
});
|
||||
type RateLimitType = z.infer<typeof rateLimitSchema>;
|
||||
export type RateLimitType = z.infer<typeof rateLimitSchema>;
|
||||
const rateLimitsSchema = z.array(rateLimitSchema);
|
||||
|
||||
const sixtySecondsMs = 60 * 1000;
|
||||
@@ -52,7 +53,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
|
||||
protected async handleRequest(requestProps: ThrottlerRequest): Promise<boolean> {
|
||||
const { context } = requestProps;
|
||||
|
||||
const throttleOptions = this.reflector.get(Throttle, context.getHandler());
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
|
||||
const response = context.switchToHttp().getResponse<Response>();
|
||||
@@ -63,6 +64,10 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${IP}"`
|
||||
);
|
||||
|
||||
if (throttleOptions) {
|
||||
return this.handleApiEndpointThrottle(tracker, throttleOptions, response);
|
||||
}
|
||||
|
||||
if (tracker.startsWith("api_key_")) {
|
||||
return this.handleApiKeyRequest(tracker, response);
|
||||
} else {
|
||||
@@ -70,6 +75,15 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleApiEndpointThrottle(tracker: string, options: RateLimitType, response: Response) {
|
||||
const { isBlocked } = await this.incrementRateLimit(`${tracker}_${options.name}`, options, response);
|
||||
if (isBlocked) {
|
||||
throw new ThrottlerException("CustomThrottlerGuard - Too many requests. Please try again later.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async handleApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
|
||||
const rateLimits = await this.getRateLimitsForApiKeyTracker(tracker);
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Reflector } from "@nestjs/core";
|
||||
|
||||
export type AllowedAuthMethod = "OAUTH_CLIENT_CREDENTIALS" | "API_KEY" | "ACCESS_TOKEN" | "NEXT_AUTH";
|
||||
|
||||
export const ApiAuthGuardOnlyAllow = Reflector.createDecorator<AllowedAuthMethod[]>();
|
||||
@@ -1,7 +1,20 @@
|
||||
import { ExecutionContext, Inject } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
|
||||
import { ApiAuthGuardOnlyAllow } from "../../decorators/api-auth-guard-only-allow.decorator";
|
||||
|
||||
export class ApiAuthGuard extends AuthGuard("api-auth") {
|
||||
constructor() {
|
||||
constructor(@Inject(Reflector) private readonly reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
getRequest(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
const allowedMethods = this.reflector.get(ApiAuthGuardOnlyAllow, context.getHandler());
|
||||
request.allowedAuthMethods = allowedMethods;
|
||||
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,14 @@ import { getToken } from "next-auth/jwt";
|
||||
|
||||
import { INVALID_ACCESS_TOKEN, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
|
||||
import type { AllowedAuthMethod } from "../../decorators/api-auth-guard-only-allow.decorator";
|
||||
|
||||
export type ApiAuthGuardUser = UserWithProfile & { isSystemAdmin: boolean };
|
||||
export type ApiAuthGuardRequest = Request & {
|
||||
authMethod: AuthMethods;
|
||||
organizationId: number | null;
|
||||
user: ApiAuthGuardUser;
|
||||
allowedAuthMethods?: AllowedAuthMethod[];
|
||||
};
|
||||
export const NO_AUTH_PROVIDED_MESSAGE =
|
||||
"No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers";
|
||||
@@ -49,12 +52,19 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
const oAuthClientId = params.clientId || request.get(X_CAL_CLIENT_ID);
|
||||
const bearerToken = request.get("Authorization")?.replace("Bearer ", "");
|
||||
|
||||
if (oAuthClientId && oAuthClientSecret) {
|
||||
const allowedMethods = request.allowedAuthMethods;
|
||||
const noSpecificAuthExpected = !allowedMethods || !allowedMethods.length;
|
||||
const oAuthAllowed = noSpecificAuthExpected || allowedMethods.includes("OAUTH_CLIENT_CREDENTIALS");
|
||||
const apiKeyAllowed = noSpecificAuthExpected || allowedMethods.includes("API_KEY");
|
||||
const accessTokenAllowed = noSpecificAuthExpected || allowedMethods.includes("ACCESS_TOKEN");
|
||||
const nextAuthAllowed = noSpecificAuthExpected || allowedMethods.includes("NEXT_AUTH");
|
||||
|
||||
if (oAuthClientId && oAuthClientSecret && oAuthAllowed) {
|
||||
request.authMethod = AuthMethods["OAUTH_CLIENT"];
|
||||
return await this.authenticateOAuthClient(oAuthClientId, oAuthClientSecret, request);
|
||||
}
|
||||
|
||||
if (bearerToken) {
|
||||
if (bearerToken && (apiKeyAllowed || accessTokenAllowed)) {
|
||||
const requestOrigin = request.get("Origin");
|
||||
request.authMethod = isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
|
||||
? AuthMethods["API_KEY"]
|
||||
@@ -64,13 +74,18 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
|
||||
const nextAuthSecret = this.config.get("next.authSecret", { infer: true });
|
||||
const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret });
|
||||
|
||||
if (nextAuthToken) {
|
||||
if (nextAuthToken && nextAuthAllowed) {
|
||||
request.authMethod = AuthMethods["NEXT_AUTH"];
|
||||
return await this.authenticateNextAuth(nextAuthToken, request);
|
||||
}
|
||||
|
||||
throw new UnauthorizedException(`ApiAuthStrategy - ${NO_AUTH_PROVIDED_MESSAGE}`);
|
||||
const noAuthProvided = !oAuthClientId && !oAuthClientSecret && !bearerToken && !nextAuthToken;
|
||||
if (noAuthProvided) {
|
||||
throw new UnauthorizedException(`ApiAuthStrategy - ${NO_AUTH_PROVIDED_MESSAGE}`);
|
||||
}
|
||||
throw new UnauthorizedException(
|
||||
`ApiAuthStrategy - Invalid authentication method. Please provide one of the allowed methods: ${allowedMethods}`
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
return this.error(err);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
Delete,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ApiExcludeController } from "@nestjs/swagger";
|
||||
@@ -96,6 +97,19 @@ export class BillingController {
|
||||
};
|
||||
}
|
||||
|
||||
@Delete("/:organizationId/unsubscribe")
|
||||
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
|
||||
@MembershipRoles(["OWNER", "ADMIN"])
|
||||
async cancelTeamSubscriptionInStripe(
|
||||
@Param("organizationId") organizationId: number
|
||||
): Promise<ApiResponse> {
|
||||
await this.billingService.cancelTeamSubscription(organizationId);
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
@Post("/webhook")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async stripeWebhook(
|
||||
|
||||
@@ -7,6 +7,7 @@ import { OrganizationsRepository } from "@/modules/organizations/index/organizat
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { InjectQueue } from "@nestjs/bull";
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
@@ -313,6 +314,28 @@ export class BillingService implements OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async cancelTeamSubscription(teamId: number) {
|
||||
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||
const customerId = teamWithBilling?.platformBilling?.customerId;
|
||||
|
||||
if (!customerId) {
|
||||
throw new NotFoundException("No customer id found for team in Stripe");
|
||||
}
|
||||
|
||||
if (!teamWithBilling?.platformBilling || !teamWithBilling?.platformBilling.subscriptionId) {
|
||||
throw new NotFoundException("Team plan not found");
|
||||
}
|
||||
|
||||
try {
|
||||
await this.stripeService
|
||||
.getStripe()
|
||||
.subscriptions.cancel(teamWithBilling?.platformBilling?.subscriptionId);
|
||||
} catch (error) {
|
||||
this.logger.log(error, "error while cancelling team subscription in stripe");
|
||||
throw new BadRequestException("Failed to cancel team subscription");
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
try {
|
||||
await this.billingQueue.close();
|
||||
|
||||
@@ -11,6 +11,7 @@ import { OrganizationsUsersBookingsModule } from "@/modules/organizations/users/
|
||||
import { RouterModule } from "@/modules/router/router.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { TimezoneModule } from "@/modules/timezones/timezones.module";
|
||||
import { VerifiedResourcesModule } from "@/modules/verified-resources/verified-resources.module";
|
||||
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@@ -33,6 +34,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
|
||||
OrganizationsUsersBookingsModule,
|
||||
OrganizationsBookingsModule,
|
||||
OrganizationsRoutingFormsModule,
|
||||
VerifiedResourcesModule,
|
||||
RouterModule,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -63,6 +63,7 @@ export class MembershipsRepository {
|
||||
async createMembership(teamId: number, userId: number, role: MembershipRole, accepted: boolean) {
|
||||
const membership = await this.dbRead.prisma.membership.create({
|
||||
data: {
|
||||
createdAt: new Date(),
|
||||
role,
|
||||
teamId,
|
||||
userId,
|
||||
|
||||
+4
@@ -340,6 +340,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
requiresBookerEmailVerification: true,
|
||||
hideCalendarNotes: true,
|
||||
hideCalendarEventDetails: true,
|
||||
hideOrganizerEmail: true,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
color: {
|
||||
darkThemeHex: "#292929",
|
||||
@@ -371,6 +372,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
expect(data.requiresBookerEmailVerification).toEqual(body.requiresBookerEmailVerification);
|
||||
expect(data.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||
expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||
expect(data.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
|
||||
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
|
||||
expect(data.color).toEqual(body.color);
|
||||
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
|
||||
@@ -1099,6 +1101,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
requiresBookerEmailVerification: true,
|
||||
hideCalendarNotes: true,
|
||||
hideCalendarEventDetails: true,
|
||||
hideOrganizerEmail: true,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
color: {
|
||||
darkThemeHex: "#292929",
|
||||
@@ -1130,6 +1133,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
expect(data.requiresBookerEmailVerification).toEqual(body.requiresBookerEmailVerification);
|
||||
expect(data.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||
expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||
expect(data.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
|
||||
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
|
||||
expect(data.color).toEqual(body.color);
|
||||
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
|
||||
|
||||
@@ -70,6 +70,7 @@ type Input = Pick<
|
||||
| "eventName"
|
||||
| "useEventTypeDestinationCalendarEmail"
|
||||
| "hideCalendarEventDetails"
|
||||
| "hideOrganizerEmail"
|
||||
| "team"
|
||||
>;
|
||||
|
||||
|
||||
+10
@@ -10,6 +10,16 @@ export class CreateOrganizationInput extends RefreshApiKeyInput {
|
||||
@ApiProperty({ description: "Name of the organization", example: "CalTeam" })
|
||||
readonly name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description:
|
||||
"Organization slug in kebab-case - if not provided will be generated automatically based on name.",
|
||||
example: "cal-tel",
|
||||
})
|
||||
readonly slug?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Object,
|
||||
description: METADATA_DOCS,
|
||||
|
||||
+2
@@ -46,6 +46,7 @@ import {
|
||||
X_CAL_SECRET_KEY,
|
||||
} from "@calcom/platform-constants";
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { slugify } from "@calcom/platform-libraries";
|
||||
import { ApiSuccessResponse, CreateOAuthClientInput } from "@calcom/platform-types";
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
@@ -176,6 +177,7 @@ describe("Organizations Organizations Endpoints", () => {
|
||||
managedOrg = responseBody.data;
|
||||
expect(managedOrg?.id).toBeDefined();
|
||||
expect(managedOrg?.name).toEqual(body.name);
|
||||
expect(managedOrg?.slug).toEqual(slugify(body.name));
|
||||
expect(managedOrg?.metadata).toEqual(body.metadata);
|
||||
expect(managedOrg?.apiKey).toBeDefined();
|
||||
|
||||
|
||||
+6
@@ -16,6 +16,12 @@ export class ManagedOrganizationOutput {
|
||||
@ApiProperty()
|
||||
readonly name!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Expose()
|
||||
@ApiPropertyOptional()
|
||||
readonly slug?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Object,
|
||||
example: { key: "value" },
|
||||
|
||||
+6
@@ -10,6 +10,8 @@ import { ManagedOrganizationsOutputService } from "@/modules/organizations/organ
|
||||
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
|
||||
import { ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { slugify } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class ManagedOrganizationsService {
|
||||
constructor(
|
||||
@@ -36,6 +38,10 @@ export class ManagedOrganizationsService {
|
||||
|
||||
const { apiKeyDaysValid, apiKeyNeverExpires, ...organizationData } = organizationInput;
|
||||
|
||||
if (!organizationData.slug) {
|
||||
organizationData.slug = slugify(organizationData.name);
|
||||
}
|
||||
|
||||
const organization = await this.managedOrganizationsRepository.createManagedOrganization(
|
||||
managerOrganizationId,
|
||||
{
|
||||
|
||||
+28
@@ -36,6 +36,7 @@ describe("Organizations Team Endpoints", () => {
|
||||
let team2: Team;
|
||||
let teamCreatedViaApi: Team;
|
||||
let teamCreatedViaApi2: Team;
|
||||
let teamCreatedViaApi3: Team;
|
||||
|
||||
const userEmail = `organizations-teams-admin-${randomString()}@api.com`;
|
||||
let user: User;
|
||||
@@ -245,11 +246,38 @@ describe("Organizations Team Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should create the team of the org with automatically set slug", async () => {
|
||||
const teamName = `Organizations Teams Automatic Slug`;
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams`)
|
||||
.send({
|
||||
name: teamName,
|
||||
bio: "This is our test team created via API",
|
||||
} satisfies CreateOrgTeamDto)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<Team> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
teamCreatedViaApi3 = responseBody.data;
|
||||
expect(teamCreatedViaApi3.name).toEqual(teamName);
|
||||
expect(teamCreatedViaApi3.slug).toEqual("organizations-teams-automatic-slug");
|
||||
expect(teamCreatedViaApi3.bio).toEqual("This is our test team created via API");
|
||||
expect(teamCreatedViaApi3.parentId).toEqual(org.id);
|
||||
const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(
|
||||
user.id,
|
||||
teamCreatedViaApi3.id
|
||||
);
|
||||
expect(membership?.role ?? "").toEqual("OWNER");
|
||||
expect(membership?.accepted).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await teamsRepositoryFixture.delete(team.id);
|
||||
await teamsRepositoryFixture.delete(team2.id);
|
||||
await teamsRepositoryFixture.delete(teamCreatedViaApi2.id);
|
||||
await teamsRepositoryFixture.delete(teamCreatedViaApi3.id);
|
||||
await teamsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
+10
@@ -5,6 +5,8 @@ import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/inde
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { slugify } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsTeamsService {
|
||||
constructor(
|
||||
@@ -40,6 +42,10 @@ export class OrganizationsTeamsService {
|
||||
async createOrgTeam(organizationId: number, data: CreateOrgTeamDto, user: UserWithProfile) {
|
||||
const { autoAcceptCreator, ...rest } = data;
|
||||
|
||||
if (!rest.slug) {
|
||||
rest.slug = slugify(rest.name);
|
||||
}
|
||||
|
||||
const team = await this.organizationsTeamRepository.createOrgTeam(organizationId, rest);
|
||||
|
||||
if (user.role !== "ADMIN") {
|
||||
@@ -56,6 +62,10 @@ export class OrganizationsTeamsService {
|
||||
) {
|
||||
const { autoAcceptCreator, ...rest } = data;
|
||||
|
||||
if (!rest.slug) {
|
||||
rest.slug = slugify(rest.name);
|
||||
}
|
||||
|
||||
const team = await this.organizationsTeamRepository.createPlatformOrgTeam(
|
||||
organizationId,
|
||||
oAuthClientId,
|
||||
|
||||
+5
-1
@@ -69,7 +69,11 @@ export class OrganizationsTeamsMembershipsRepository {
|
||||
|
||||
async createOrgTeamMembership(teamId: number, data: CreateOrgTeamMembershipDto) {
|
||||
return this.dbWrite.prisma.membership.create({
|
||||
data: { ...data, teamId: teamId },
|
||||
data: {
|
||||
createdAt: new Date(),
|
||||
...data,
|
||||
teamId: teamId,
|
||||
},
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
});
|
||||
}
|
||||
|
||||
+2
@@ -6,6 +6,7 @@ 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 { IsRoutingFormInTeam } from "@/modules/auth/guards/routing-forms/is-routing-form-in-team.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { GetRoutingFormResponsesParams } from "@/modules/organizations/routing-forms/inputs/get-routing-form-responses-params.input";
|
||||
@@ -29,6 +30,7 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
IsTeamInOrg,
|
||||
IsRoutingFormInTeam,
|
||||
PlatformPlanGuard,
|
||||
RolesGuard,
|
||||
IsAdminAPIEnabledGuard
|
||||
)
|
||||
@ApiHeader(API_KEY_HEADER)
|
||||
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { RequestEmailVerificationInput } from "@/modules/verified-resources/inputs/request-email-verification.input";
|
||||
import { VerifyEmailInput } from "@/modules/verified-resources/inputs/verify-email.input";
|
||||
import {
|
||||
TeamVerifiedEmailOutput,
|
||||
TeamVerifiedEmailsOutput,
|
||||
} from "@/modules/verified-resources/outputs/verified-email.output";
|
||||
import {
|
||||
TeamVerifiedPhoneOutput,
|
||||
TeamVerifiedPhonesOutput,
|
||||
} from "@/modules/verified-resources/outputs/verified-phone.output";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { TOTP as TOTPtoMock } from "@otplib/core";
|
||||
import { User } from "@prisma/client";
|
||||
import { totp } from "otplib";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { VerifiedResourcesRepositoryFixtures } from "test/fixtures/repository/verified-resources.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
|
||||
import { AttendeeVerifyEmail } from "@calcom/platform-libraries/emails";
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
jest.spyOn(totp, "generate").mockImplementation(function () {
|
||||
return "1234";
|
||||
});
|
||||
|
||||
jest.spyOn(TOTPtoMock.prototype, "check").mockImplementation(function () {
|
||||
return true;
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(AttendeeVerifyEmail.prototype as any, "getNodeMailerPayload")
|
||||
.mockImplementation(async function () {
|
||||
return {
|
||||
to: `testnotrealemail@notreal.com`,
|
||||
from: `testnotrealemail@notreal.com`,
|
||||
subject: "No Subject",
|
||||
html: "<html><body>Mocked Email Content</body></html>",
|
||||
text: "body",
|
||||
};
|
||||
});
|
||||
|
||||
describe("Organizations Teams Verified Resources", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let verifiedResourcesRepositoryFixtures: VerifiedResourcesRepositoryFixtures;
|
||||
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
|
||||
let org: Team;
|
||||
let orgTeam: Team;
|
||||
const emailToVerify = `org-team-e2e-verified-resources-${randomString()}@example.com`;
|
||||
const phoneToVerify = "+37255556666";
|
||||
const authEmail = `organizations-verified-resources-responses-user-${randomString()}@api.com`;
|
||||
let user: User;
|
||||
let apiKeyString: string;
|
||||
let verifiedEmailId: number;
|
||||
let verifiedPhoneId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
}).compile();
|
||||
|
||||
verifiedResourcesRepositoryFixtures = new VerifiedResourcesRepositoryFixtures(moduleRef);
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: `organizations-verified-resources-responses-organization-${randomString()}`,
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: authEmail,
|
||||
username: authEmail,
|
||||
});
|
||||
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||
apiKeyString = keyString;
|
||||
|
||||
orgTeam = await teamsRepositoryFixture.create({
|
||||
name: `organizations-verified-resources-responses-team-${randomString()}`,
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: orgTeam.id } },
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${user.id}`,
|
||||
username: authEmail,
|
||||
organization: {
|
||||
connect: {
|
||||
id: org.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
verifiedPhoneId = (
|
||||
await verifiedResourcesRepositoryFixtures.createPhone({
|
||||
phoneNumber: phoneToVerify,
|
||||
user: { connect: { id: user.id } },
|
||||
team: {
|
||||
connect: {
|
||||
id: orgTeam.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
).id;
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should trigger email verification code", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/emails/verification-code/request`
|
||||
)
|
||||
.send({ email: emailToVerify } satisfies RequestEmailVerificationInput)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it("should verify email", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/emails/verification-code/verify`
|
||||
)
|
||||
.send({ email: emailToVerify, code: "1234" } satisfies VerifyEmailInput)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
const response = res.body as TeamVerifiedEmailOutput;
|
||||
verifiedEmailId = response.data.id;
|
||||
expect(response.data.email).toEqual(emailToVerify);
|
||||
expect(response.data.teamId).toEqual(orgTeam.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fetch verified email by id", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/emails/${verifiedEmailId}`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
const response = res.body as TeamVerifiedEmailOutput;
|
||||
expect(response.data.email).toEqual(emailToVerify);
|
||||
expect(response.data.teamId).toEqual(orgTeam.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fetch verified number by id", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/phones/${verifiedPhoneId}`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
const response = res.body as TeamVerifiedPhoneOutput;
|
||||
expect(response.data.phoneNumber).toEqual(phoneToVerify);
|
||||
expect(response.data.teamId).toEqual(orgTeam.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fetch verified emails", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/emails`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
const response = res.body as TeamVerifiedEmailsOutput;
|
||||
expect(response.data.find((verifiedEmail) => verifiedEmail.email === emailToVerify)).toBeDefined();
|
||||
expect(response.data.find((verifiedEmail) => verifiedEmail.teamId === orgTeam.id)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should fetch verified phones", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/phones`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
const response = res.body as TeamVerifiedPhonesOutput;
|
||||
expect(
|
||||
response.data.find((verifiedPhone) => verifiedPhone.phoneNumber === phoneToVerify)
|
||||
).toBeDefined();
|
||||
expect(response.data.find((verifiedPhone) => verifiedPhone.teamId === orgTeam.id)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await verifiedResourcesRepositoryFixtures.deleteEmailById(verifiedEmailId);
|
||||
await verifiedResourcesRepositoryFixtures.deletePhoneById(verifiedPhoneId);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { Throttle } from "@/lib/endpoint-throttler-decorator";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.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 { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { RequestEmailVerificationInput } from "@/modules/verified-resources/inputs/request-email-verification.input";
|
||||
import { RequestPhoneVerificationInput } from "@/modules/verified-resources/inputs/request-phone-verification.input";
|
||||
import { VerifyEmailInput } from "@/modules/verified-resources/inputs/verify-email.input";
|
||||
import { VerifyPhoneInput } from "@/modules/verified-resources/inputs/verify-phone.input";
|
||||
import { RequestEmailVerificationOutput } from "@/modules/verified-resources/outputs/request-email-verification-output";
|
||||
import { RequestPhoneVerificationOutput } from "@/modules/verified-resources/outputs/request-phone-verification-output";
|
||||
import {
|
||||
TeamVerifiedEmailOutput,
|
||||
TeamVerifiedEmailOutputData,
|
||||
TeamVerifiedEmailsOutput,
|
||||
} from "@/modules/verified-resources/outputs/verified-email.output";
|
||||
import {
|
||||
TeamVerifiedPhoneOutput,
|
||||
TeamVerifiedPhoneOutputData,
|
||||
TeamVerifiedPhonesOutput,
|
||||
} from "@/modules/verified-resources/outputs/verified-phone.output";
|
||||
import { VerifiedResourcesService } from "@/modules/verified-resources/services/verified-resources.service";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/teams/:teamId/verified-resources",
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@ApiTags("Organization Team Verified Resources")
|
||||
export class OrgTeamsVerifiedResourcesController {
|
||||
constructor(private readonly verifiedResourcesService: VerifiedResourcesService) {}
|
||||
@ApiOperation({
|
||||
summary: "Request Email Verification Code",
|
||||
description: `Sends a verification code to the email.`,
|
||||
})
|
||||
@Roles("TEAM_ADMIN")
|
||||
@Throttle({
|
||||
limit: 3,
|
||||
ttl: 60000,
|
||||
blockDuration: 60000,
|
||||
name: "org_teams_verified_resources_emails_requests",
|
||||
})
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Post("/emails/verification-code/request")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async requestEmailVerificationCode(
|
||||
@Body() body: RequestEmailVerificationInput,
|
||||
@GetUser("username") username: string,
|
||||
@GetUser("locale") locale: string
|
||||
): Promise<RequestEmailVerificationOutput> {
|
||||
const verificationCodeRequest = await this.verifiedResourcesService.requestEmailVerificationCode(
|
||||
{ username, locale },
|
||||
body.email
|
||||
);
|
||||
|
||||
return {
|
||||
status: verificationCodeRequest ? SUCCESS_STATUS : ERROR_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Request Phone Number Verification Code",
|
||||
description: `Sends a verification code to the phone number.`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@Throttle({
|
||||
limit: 3,
|
||||
ttl: 60000,
|
||||
blockDuration: 60000,
|
||||
name: "org_teams_verified_resources_phones_requests",
|
||||
})
|
||||
@Post("/phones/verification-code/request")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async requestPhoneVerificationCode(
|
||||
@Body() body: RequestPhoneVerificationInput
|
||||
): Promise<RequestPhoneVerificationOutput> {
|
||||
const verificationCodeRequest = await this.verifiedResourcesService.requestPhoneVerificationCode(
|
||||
body.phone
|
||||
);
|
||||
|
||||
return {
|
||||
status: verificationCodeRequest ? SUCCESS_STATUS : ERROR_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Verify an email for an org team.",
|
||||
description: `Use code to verify an email`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Post("/emails/verification-code/verify")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async verifyEmail(
|
||||
@Body() body: VerifyEmailInput,
|
||||
@GetUser("id") userId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<TeamVerifiedEmailOutput> {
|
||||
const verifiedEmail = await this.verifiedResourcesService.verifyEmail(
|
||||
userId,
|
||||
body.email,
|
||||
body.code,
|
||||
teamId
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(TeamVerifiedEmailOutputData, verifiedEmail),
|
||||
};
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Verify a phone number for an org team.",
|
||||
description: `Use code to verify a phone number`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Post("/phones/verification-code/verify")
|
||||
@Throttle({
|
||||
limit: 3,
|
||||
ttl: 60000,
|
||||
blockDuration: 60000,
|
||||
name: "org_teams_verified_resources_phones_verify",
|
||||
})
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async verifyPhoneNumber(
|
||||
@Body() body: VerifyPhoneInput,
|
||||
@GetUser("id") userId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<TeamVerifiedPhoneOutput> {
|
||||
const verifiedPhone = await this.verifiedResourcesService.verifyPhone(
|
||||
userId,
|
||||
body.phone,
|
||||
body.code,
|
||||
teamId
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(TeamVerifiedPhoneOutputData, verifiedPhone),
|
||||
};
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get list of verified emails of an org team.",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Get("/emails")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getVerifiedEmails(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() pagination: SkipTakePagination
|
||||
): Promise<TeamVerifiedEmailsOutput> {
|
||||
const verifiedEmails = await this.verifiedResourcesService.getTeamVerifiedEmails(
|
||||
teamId,
|
||||
pagination?.skip ?? 0,
|
||||
pagination?.take ?? 250
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: verifiedEmails.map((verifiedEmail) => plainToClass(TeamVerifiedEmailOutputData, verifiedEmail)),
|
||||
};
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get list of verified phone numbers of an org team.",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Get("/phones")
|
||||
@Roles("TEAM_ADMIN")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getVerifiedPhoneNumbers(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() pagination: SkipTakePagination
|
||||
): Promise<TeamVerifiedPhonesOutput> {
|
||||
const verifiedPhoneNumbers = await this.verifiedResourcesService.getTeamVerifiedPhoneNumbers(
|
||||
teamId,
|
||||
pagination?.skip ?? 0,
|
||||
pagination?.take ?? 250
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: verifiedPhoneNumbers.map((verifiedPhoneNumber) =>
|
||||
plainToClass(TeamVerifiedPhoneOutputData, verifiedPhoneNumber)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get verified email of an org team by id.",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Get("/emails/:id")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getVerifiedEmailById(
|
||||
@Param("id") id: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<TeamVerifiedEmailOutput> {
|
||||
const verifiedEmail = await this.verifiedResourcesService.getTeamVerifiedEmailById(teamId, id);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(TeamVerifiedEmailOutputData, verifiedEmail),
|
||||
};
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get verified phone number of an org team by id.",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Get("/phones/:id")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getVerifiedPhoneById(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("id") id: number
|
||||
): Promise<TeamVerifiedPhoneOutput> {
|
||||
const verifiedPhoneNumber = await this.verifiedResourcesService.getTeamVerifiedPhoneNumberById(
|
||||
teamId,
|
||||
id
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(TeamVerifiedPhoneOutputData, verifiedPhoneNumber),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export class OrganizationsUsersRepository {
|
||||
member: {
|
||||
teamId: orgId,
|
||||
...(teamIds && { user: { teams: { some: { teamId: { in: teamIds } } } } }),
|
||||
// Filter to only get users which have ALL of the assigned attribue options
|
||||
// Filter to only get users which have ALL of the assigned attribute options
|
||||
...(attributeQueryOperator === "AND" && {
|
||||
AND: assignedOptionIds.map((optionId) => ({
|
||||
AttributeToUser: { some: { attributeOptionId: optionId } },
|
||||
@@ -46,11 +46,11 @@ export class OrganizationsUsersRepository {
|
||||
}),
|
||||
},
|
||||
...(emailArray && emailArray.length ? { email: { in: emailArray } } : {}),
|
||||
// Filter to get users which have AT LEAST ONE of the assigned attribue options
|
||||
// Filter to get users which have AT LEAST ONE of the assigned attribute options
|
||||
...(attributeQueryOperator === "OR" && {
|
||||
attributeOption: { id: { in: assignedOptionIds } },
|
||||
}),
|
||||
// Filter to get users that have NONE the assigned attribue options
|
||||
// Filter to get users that have NONE the assigned attribute options
|
||||
...(attributeQueryOperator === "NONE" && {
|
||||
NOT: {
|
||||
attributeOption: { id: { in: assignedOptionIds } },
|
||||
|
||||
@@ -7,6 +7,9 @@ const ensureUserLevelWhere = {
|
||||
eventTypeId: null,
|
||||
};
|
||||
|
||||
export const NO_SELECTED_CALENDAR_FOUND = "No SelectedCalendar found.";
|
||||
export const MULTIPLE_SELECTED_CALENDARS_FOUND = "Multiple SelecteCalendars found. Skipping deletion";
|
||||
|
||||
@Injectable()
|
||||
export class SelectedCalendarsRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
@@ -113,11 +116,11 @@ export class SelectedCalendarsRepository {
|
||||
|
||||
// Make the behaviour same as .delete which throws error if no record is found
|
||||
if (records.length === 0) {
|
||||
throw new Error("No SelectedCalendar found.");
|
||||
throw new Error(NO_SELECTED_CALENDAR_FOUND);
|
||||
}
|
||||
|
||||
if (records.length > 1) {
|
||||
throw new Error("Multiple SelecteCalendars found. Skipping deletion");
|
||||
throw new Error(MULTIPLE_SELECTED_CALENDARS_FOUND);
|
||||
}
|
||||
|
||||
return await this.dbWrite.prisma.selectedCalendar.delete({
|
||||
|
||||
@@ -5,9 +5,13 @@ import {
|
||||
SelectedCalendarsInputDto,
|
||||
SelectedCalendarsQueryParamsInputDto,
|
||||
} from "@/modules/selected-calendars/inputs/selected-calendars.input";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import {
|
||||
MULTIPLE_SELECTED_CALENDARS_FOUND,
|
||||
NO_SELECTED_CALENDAR_FOUND,
|
||||
SelectedCalendarsRepository,
|
||||
} from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { SelectedCalendarRepository } from "@calcom/platform-libraries";
|
||||
|
||||
@@ -111,13 +115,23 @@ export class SelectedCalendarsService {
|
||||
}
|
||||
}
|
||||
|
||||
const removedCalendarEntry = await this.selectedCalendarsRepository.removeUserSelectedCalendar(
|
||||
user.id,
|
||||
integration,
|
||||
externalId,
|
||||
delegationCredentialId
|
||||
);
|
||||
try {
|
||||
const removedCalendarEntry = await this.selectedCalendarsRepository.removeUserSelectedCalendar(
|
||||
user.id,
|
||||
integration,
|
||||
externalId,
|
||||
delegationCredentialId
|
||||
);
|
||||
|
||||
return removedCalendarEntry;
|
||||
return removedCalendarEntry;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === NO_SELECTED_CALENDAR_FOUND) {
|
||||
throw new NotFoundException(NO_SELECTED_CALENDAR_FOUND);
|
||||
} else if (error.message === MULTIPLE_SELECTED_CALENDARS_FOUND) {
|
||||
throw new BadRequestException(MULTIPLE_SELECTED_CALENDARS_FOUND);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export class SlotsController_2024_09_04 {
|
||||
summary: "Find out when is an event type ready to be booked.",
|
||||
description: `
|
||||
There are 4 ways to get available slots for event type of an individual user:
|
||||
|
||||
|
||||
1. By event type id. Event type id can be of user and team event types. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'
|
||||
|
||||
2. By event type slug + username. Example '/v2/slots?eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'
|
||||
@@ -72,7 +72,7 @@ export class SlotsController_2024_09_04 {
|
||||
|
||||
And 3 ways to get available slots for team event type:
|
||||
|
||||
1. By team event type id. Example '/v2/slots?teamEventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'
|
||||
1. By team event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'
|
||||
|
||||
2. By team event type slug + team slug. Example '/v2/slots?eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'
|
||||
|
||||
@@ -109,9 +109,9 @@ export class SlotsController_2024_09_04 {
|
||||
name: "usernames",
|
||||
required: false,
|
||||
description: `The usernames for which available slots should be checked separated by a comma.
|
||||
|
||||
|
||||
Checking slots by usernames is used mainly for dynamic events where there is no specific event but we just want to know when 2 or more people are available.
|
||||
|
||||
|
||||
Must contain at least 2 usernames.`,
|
||||
example: "alice,bob",
|
||||
})
|
||||
@@ -154,9 +154,9 @@ export class SlotsController_2024_09_04 {
|
||||
required: true,
|
||||
description: `
|
||||
Time until which available slots should be checked.
|
||||
|
||||
|
||||
Must be in UTC timezone as ISO 8601 datestring.
|
||||
|
||||
|
||||
You can pass date without hours which defaults to end of day or specify hours:
|
||||
2024-08-20 (will have hours 23:59:59 aka at the very end of the date) or you can specify hours manually like 2024-08-20T18:00:00Z.`,
|
||||
example: "2050-09-06",
|
||||
@@ -166,9 +166,9 @@ export class SlotsController_2024_09_04 {
|
||||
required: true,
|
||||
description: `
|
||||
Time starting from which available slots should be checked.
|
||||
|
||||
|
||||
Must be in UTC timezone as ISO 8601 datestring.
|
||||
|
||||
|
||||
You can pass date without hours which defaults to start of day or specify hours:
|
||||
2024-08-13 (will have hours 00:00:00 aka at very beginning of the date) or you can specify hours manually like 2024-08-13T09:00:00Z.`,
|
||||
example: "2050-09-05",
|
||||
@@ -300,6 +300,9 @@ export class SlotsController_2024_09_04 {
|
||||
}
|
||||
|
||||
@Delete("/reservations/:uid")
|
||||
@ApiOperation({
|
||||
summary: "Delete a reserved slot",
|
||||
})
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@DocsResponse({
|
||||
status: 200,
|
||||
|
||||
+2
@@ -312,6 +312,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
requiresBookerEmailVerification: true,
|
||||
hideCalendarNotes: true,
|
||||
hideCalendarEventDetails: true,
|
||||
hideOrganizerEmail: true,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
color: {
|
||||
darkThemeHex: "#292929",
|
||||
@@ -343,6 +344,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
expect(data.requiresBookerEmailVerification).toEqual(body.requiresBookerEmailVerification);
|
||||
expect(data.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||
expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||
expect(data.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
|
||||
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
|
||||
expect(data.color).toEqual(body.color);
|
||||
|
||||
|
||||
@@ -21,7 +21,11 @@ export class TeamsMembershipsRepository {
|
||||
|
||||
async createTeamMembership(teamId: number, data: CreateTeamMembershipInput) {
|
||||
return this.dbWrite.prisma.membership.create({
|
||||
data: { ...data, teamId: teamId },
|
||||
data: {
|
||||
createdAt: new Date(),
|
||||
...data,
|
||||
teamId: teamId,
|
||||
},
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
|
||||
import { randomString } from "test/utils/randomString";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { slugify } from "@calcom/platform-libraries";
|
||||
import { TeamOutputDto } from "@calcom/platform-types";
|
||||
|
||||
describe("Teams endpoint", () => {
|
||||
@@ -75,7 +76,7 @@ describe("Teams endpoint", () => {
|
||||
describe("User has membership in created team", () => {
|
||||
it("should create first team", async () => {
|
||||
const body: CreateTeamInput = {
|
||||
name: `teams-dog-${randomString()}`,
|
||||
name: `teams dog ${randomString()}`,
|
||||
metadata: {
|
||||
teamKey: "teamValue",
|
||||
},
|
||||
@@ -93,6 +94,7 @@ describe("Teams endpoint", () => {
|
||||
expect(responseData).toBeDefined();
|
||||
expect(responseData.id).toBeDefined();
|
||||
expect(responseData.name).toEqual(body.name);
|
||||
expect(responseData.slug).toEqual(slugify(body.name));
|
||||
expect(responseData.metadata).toEqual(body.metadata);
|
||||
team1 = responseData;
|
||||
});
|
||||
|
||||
@@ -11,7 +11,11 @@ export class CreateTeamInput {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiPropertyOptional({ type: String, description: "Team slug", example: "caltel" })
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "Team slug in kebab-case - if not provided will be generated automatically based on name.",
|
||||
example: "caltel",
|
||||
})
|
||||
readonly slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -6,6 +6,8 @@ import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { BadRequestException, Injectable, InternalServerErrorException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
import { slugify } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class TeamsService {
|
||||
private isTeamBillingEnabled = this.configService.get("stripe.isTeamBillingEnabled");
|
||||
@@ -19,6 +21,9 @@ export class TeamsService {
|
||||
|
||||
async createTeam(input: CreateTeamInput, ownerId: number) {
|
||||
const { autoAcceptCreator, ...teamData } = input;
|
||||
if (!teamData.slug) {
|
||||
teamData.slug = slugify(teamData.name);
|
||||
}
|
||||
|
||||
const existingTeam = await this.teamsMembershipsRepository.findTeamMembershipsByNameAndUser(
|
||||
input.name,
|
||||
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { Throttle } from "@/lib/endpoint-throttler-decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { RequestEmailVerificationInput } from "@/modules/verified-resources/inputs/request-email-verification.input";
|
||||
import { RequestPhoneVerificationInput } from "@/modules/verified-resources/inputs/request-phone-verification.input";
|
||||
import { VerifyEmailInput } from "@/modules/verified-resources/inputs/verify-email.input";
|
||||
import { VerifyPhoneInput } from "@/modules/verified-resources/inputs/verify-phone.input";
|
||||
import { RequestEmailVerificationOutput } from "@/modules/verified-resources/outputs/request-email-verification-output";
|
||||
import { RequestPhoneVerificationOutput } from "@/modules/verified-resources/outputs/request-phone-verification-output";
|
||||
import {
|
||||
TeamVerifiedEmailOutput,
|
||||
TeamVerifiedEmailOutputData,
|
||||
TeamVerifiedEmailsOutput,
|
||||
} from "@/modules/verified-resources/outputs/verified-email.output";
|
||||
import {
|
||||
TeamVerifiedPhoneOutput,
|
||||
TeamVerifiedPhoneOutputData,
|
||||
TeamVerifiedPhonesOutput,
|
||||
} from "@/modules/verified-resources/outputs/verified-phone.output";
|
||||
import { VerifiedResourcesService } from "@/modules/verified-resources/services/verified-resources.service";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/teams/:teamId/verified-resources",
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, RolesGuard)
|
||||
@ApiTags("Teams Verified Resources")
|
||||
export class TeamsVerifiedResourcesController {
|
||||
constructor(private readonly verifiedResourcesService: VerifiedResourcesService) {}
|
||||
@ApiOperation({
|
||||
summary: "Request Email Verification Code",
|
||||
description: `Sends a verification code to the Email.`,
|
||||
})
|
||||
@Roles("TEAM_ADMIN")
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Post("/emails/verification-code/request")
|
||||
@Throttle({ limit: 5, ttl: 60000, blockDuration: 60000, name: "teams_verified_resources_emails_requests" })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async requestEmailVerificationCode(
|
||||
@Body() body: RequestEmailVerificationInput,
|
||||
@GetUser("username") username: string,
|
||||
@GetUser("locale") locale: string
|
||||
): Promise<RequestEmailVerificationOutput> {
|
||||
const verificationCodeRequest = await this.verifiedResourcesService.requestEmailVerificationCode(
|
||||
{ username, locale },
|
||||
body.email
|
||||
);
|
||||
|
||||
return {
|
||||
status: verificationCodeRequest ? SUCCESS_STATUS : ERROR_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Request Phone Number Verification Code",
|
||||
description: `Sends a verification code to the phone number.`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@Post("/phones/verification-code/request")
|
||||
@Throttle({ limit: 3, ttl: 60000, blockDuration: 60000, name: "teams_verified_resources_phones_requests" })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async requestPhoneVerificationCode(
|
||||
@Body() body: RequestPhoneVerificationInput
|
||||
): Promise<RequestPhoneVerificationOutput> {
|
||||
const verificationCodeRequest = await this.verifiedResourcesService.requestPhoneVerificationCode(
|
||||
body.phone
|
||||
);
|
||||
|
||||
return {
|
||||
status: verificationCodeRequest ? SUCCESS_STATUS : ERROR_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Verify an email for a team.",
|
||||
description: `Use code to verify an email`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@Post("/emails/verification-code/verify")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async verifyEmail(
|
||||
@Body() body: VerifyEmailInput,
|
||||
@GetUser("id") userId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<TeamVerifiedEmailOutput> {
|
||||
const verifiedEmail = await this.verifiedResourcesService.verifyEmail(
|
||||
userId,
|
||||
body.email,
|
||||
body.code,
|
||||
teamId
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(TeamVerifiedEmailOutputData, verifiedEmail),
|
||||
};
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Verify a phone number for an org team.",
|
||||
description: `Use code to verify a phone number`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@Post("/phones/verification-code/verify")
|
||||
@Throttle({ limit: 3, ttl: 60000, blockDuration: 60000, name: "teams_verified_resources_phones_verify" })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async verifyPhoneNumber(
|
||||
@Body() body: VerifyPhoneInput,
|
||||
@GetUser("id") userId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<TeamVerifiedPhoneOutput> {
|
||||
const verifiedPhone = await this.verifiedResourcesService.verifyPhone(
|
||||
userId,
|
||||
body.phone,
|
||||
body.code,
|
||||
teamId
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(TeamVerifiedPhoneOutputData, verifiedPhone),
|
||||
};
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get list of verified emails of a team.",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@Get("/emails")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getVerifiedEmails(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() pagination: SkipTakePagination
|
||||
): Promise<TeamVerifiedEmailsOutput> {
|
||||
const verifiedEmails = await this.verifiedResourcesService.getTeamVerifiedEmails(
|
||||
teamId,
|
||||
pagination?.skip ?? 0,
|
||||
pagination?.take ?? 250
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: verifiedEmails.map((verifiedEmail) => plainToClass(TeamVerifiedEmailOutputData, verifiedEmail)),
|
||||
};
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get list of verified phone numbers of a team.",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Get("/phones")
|
||||
@Roles("TEAM_ADMIN")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getVerifiedPhoneNumbers(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() pagination: SkipTakePagination
|
||||
): Promise<TeamVerifiedPhonesOutput> {
|
||||
const verifiedPhoneNumbers = await this.verifiedResourcesService.getTeamVerifiedPhoneNumbers(
|
||||
teamId,
|
||||
pagination?.skip ?? 0,
|
||||
pagination?.take ?? 250
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: verifiedPhoneNumbers.map((verifiedPhoneNumber) =>
|
||||
plainToClass(TeamVerifiedPhoneOutputData, verifiedPhoneNumber)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get verified email of a team by id.",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@Get("/emails/:id")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getVerifiedEmailById(
|
||||
@Param("id") id: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<TeamVerifiedEmailOutput> {
|
||||
const verifiedEmail = await this.verifiedResourcesService.getTeamVerifiedEmailById(teamId, id);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(TeamVerifiedEmailOutputData, verifiedEmail),
|
||||
};
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get verified phone number of a team by id.",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@Get("/phones/:id")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getVerifiedPhoneById(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("id") id: number
|
||||
): Promise<TeamVerifiedPhoneOutput> {
|
||||
const verifiedPhoneNumber = await this.verifiedResourcesService.getTeamVerifiedPhoneNumberById(
|
||||
teamId,
|
||||
id
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(TeamVerifiedPhoneOutputData, verifiedPhoneNumber),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -149,7 +149,7 @@ export class TokensRepository {
|
||||
this.dbWrite.prisma.refreshToken.create({
|
||||
data: {
|
||||
// note(Lauris): I am leaving userId because it was before adding ownerId to standardize payload like in the createOAuthTokens function for
|
||||
// backwards compatability.
|
||||
// backwards compatibility.
|
||||
secret: this.jwtService.signRefreshToken({
|
||||
clientId,
|
||||
ownerId: tokenUserId,
|
||||
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { Throttle } from "@/lib/endpoint-throttler-decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { RequestEmailVerificationInput } from "@/modules/verified-resources/inputs/request-email-verification.input";
|
||||
import { RequestPhoneVerificationInput } from "@/modules/verified-resources/inputs/request-phone-verification.input";
|
||||
import { VerifyEmailInput } from "@/modules/verified-resources/inputs/verify-email.input";
|
||||
import { VerifyPhoneInput } from "@/modules/verified-resources/inputs/verify-phone.input";
|
||||
import { RequestEmailVerificationOutput } from "@/modules/verified-resources/outputs/request-email-verification-output";
|
||||
import { RequestPhoneVerificationOutput } from "@/modules/verified-resources/outputs/request-phone-verification-output";
|
||||
import {
|
||||
UserVerifiedEmailOutput,
|
||||
UserVerifiedEmailOutputData,
|
||||
UserVerifiedEmailsOutput,
|
||||
} from "@/modules/verified-resources/outputs/verified-email.output";
|
||||
import {
|
||||
UserVerifiedPhoneOutput,
|
||||
UserVerifiedPhoneOutputData,
|
||||
UserVerifiedPhonesOutput,
|
||||
} from "@/modules/verified-resources/outputs/verified-phone.output";
|
||||
import { VerifiedResourcesService } from "@/modules/verified-resources/services/verified-resources.service";
|
||||
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Query, UseGuards } from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/verified-resources",
|
||||
})
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiTags("Verified Resources")
|
||||
export class UserVerifiedResourcesController {
|
||||
constructor(private readonly verifiedResourcesService: VerifiedResourcesService) {}
|
||||
@ApiOperation({
|
||||
summary: "Request Email Verification Code",
|
||||
description: `Sends a verification code to the email.`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Post("/emails/verification-code/request")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Throttle({ limit: 5, ttl: 60000, blockDuration: 60000, name: "users_verified_resources_emails_requests" })
|
||||
async requestEmailVerificationCode(
|
||||
@Body() body: RequestEmailVerificationInput,
|
||||
@GetUser("username") username: string,
|
||||
@GetUser("locale") locale: string
|
||||
): Promise<RequestEmailVerificationOutput> {
|
||||
const verificationCodeRequest = await this.verifiedResourcesService.requestEmailVerificationCode(
|
||||
{ username, locale },
|
||||
body.email
|
||||
);
|
||||
|
||||
return {
|
||||
status: verificationCodeRequest ? SUCCESS_STATUS : ERROR_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Request Phone Number Verification Code",
|
||||
description: `Sends a verification code to the phone number.`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Post("/phones/verification-code/request")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Throttle({ limit: 3, ttl: 60000, blockDuration: 60000, name: "users_verified_resources_phones_requests" })
|
||||
async requestPhoneVerificationCode(
|
||||
@Body() body: RequestPhoneVerificationInput
|
||||
): Promise<RequestPhoneVerificationOutput> {
|
||||
const verificationCodeRequest = await this.verifiedResourcesService.requestPhoneVerificationCode(
|
||||
body.phone
|
||||
);
|
||||
|
||||
return {
|
||||
status: verificationCodeRequest ? SUCCESS_STATUS : ERROR_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Verify an email.",
|
||||
description: `Use code to verify an email`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Post("/emails/verification-code/verify")
|
||||
@Throttle({ limit: 3, ttl: 60000, blockDuration: 60000, name: "users_verified_resources_phones_verify" })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async verifyEmail(
|
||||
@Body() body: VerifyEmailInput,
|
||||
@GetUser("id") userId: number
|
||||
): Promise<UserVerifiedEmailOutput> {
|
||||
const verifiedEmail = await this.verifiedResourcesService.verifyEmail(userId, body.email, body.code);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(UserVerifiedEmailOutputData, verifiedEmail),
|
||||
};
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Verify a phone number.",
|
||||
description: `Use code to verify a phone number`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Post("/phones/verification-code/verify")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async verifyPhoneNumber(
|
||||
@Body() body: VerifyPhoneInput,
|
||||
@GetUser("id") userId: number
|
||||
): Promise<UserVerifiedPhoneOutput> {
|
||||
const verifiedPhone = await this.verifiedResourcesService.verifyPhone(userId, body.phone, body.code);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(UserVerifiedPhoneOutputData, verifiedPhone),
|
||||
};
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get list of verified emails.",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Get("/emails")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getVerifiedEmails(
|
||||
@GetUser("id") userId: number,
|
||||
@Query() pagination: SkipTakePagination
|
||||
): Promise<UserVerifiedEmailsOutput> {
|
||||
const verifiedEmails = await this.verifiedResourcesService.getUserVerifiedEmails(
|
||||
userId,
|
||||
pagination?.skip ?? 0,
|
||||
pagination?.take ?? 250
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: verifiedEmails.map((verifiedEmail) => plainToClass(UserVerifiedEmailOutputData, verifiedEmail)),
|
||||
};
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get list of verified phone numbers.",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Get("/phones")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getVerifiedPhoneNumbers(
|
||||
@GetUser("id") userId: number,
|
||||
@Query() pagination: SkipTakePagination
|
||||
): Promise<UserVerifiedPhonesOutput> {
|
||||
const verifiedPhoneNumbers = await this.verifiedResourcesService.getUserVerifiedPhoneNumbers(
|
||||
userId,
|
||||
pagination?.skip ?? 0,
|
||||
pagination?.take ?? 250
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: verifiedPhoneNumbers.map((verifiedPhoneNumber) =>
|
||||
plainToClass(UserVerifiedPhoneOutputData, verifiedPhoneNumber)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get verified email by id.",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Get("/emails/:id")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getVerifiedEmailById(
|
||||
@GetUser("id") userId: number,
|
||||
@Param("id") id: number
|
||||
): Promise<UserVerifiedEmailOutput> {
|
||||
const verifiedEmail = await this.verifiedResourcesService.getUserVerifiedEmailById(userId, id);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(UserVerifiedEmailOutputData, verifiedEmail),
|
||||
};
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get verified phone number by id.",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Get("/phones/:id")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getVerifiedPhoneById(
|
||||
@GetUser("id") userId: number,
|
||||
@Param("id") id: number
|
||||
): Promise<UserVerifiedPhoneOutput> {
|
||||
const verifiedPhoneNumber = await this.verifiedResourcesService.getUserVerifiedPhoneNumberById(
|
||||
userId,
|
||||
id
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(UserVerifiedPhoneOutputData, verifiedPhoneNumber),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose } from "class-transformer";
|
||||
import { IsEmail } from "class-validator";
|
||||
|
||||
export class RequestEmailVerificationInput {
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
description: "Email to verify.",
|
||||
example: "acme@example.com",
|
||||
})
|
||||
@IsEmail()
|
||||
@Expose()
|
||||
email!: string;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose } from "class-transformer";
|
||||
import { IsPhoneNumber } from "class-validator";
|
||||
|
||||
export class RequestPhoneVerificationInput {
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
description: "Phone number to verify.",
|
||||
example: "+372 5555 6666",
|
||||
})
|
||||
@IsPhoneNumber()
|
||||
@Expose()
|
||||
phone!: string;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose } from "class-transformer";
|
||||
import { IsEmail, IsString } from "class-validator";
|
||||
|
||||
export class VerifyEmailInput {
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
description: "Email to verify.",
|
||||
example: "example@acme.com",
|
||||
})
|
||||
@IsEmail()
|
||||
@Expose()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
description: "verification code sent to the email to verify",
|
||||
example: "1ABG2C",
|
||||
})
|
||||
@Expose()
|
||||
@IsString()
|
||||
code!: string;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose } from "class-transformer";
|
||||
import { IsEmail, IsPhoneNumber, IsString } from "class-validator";
|
||||
|
||||
export class VerifyPhoneInput {
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
description: "phone number to verify.",
|
||||
example: "+37255556666",
|
||||
})
|
||||
@IsPhoneNumber()
|
||||
@Expose()
|
||||
phone!: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
description: "verification code sent to the phone number to verify",
|
||||
example: "1ABG2C",
|
||||
})
|
||||
@Expose()
|
||||
@IsString()
|
||||
code!: string;
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEnum } from "class-validator";
|
||||
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class RequestEmailVerificationOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEnum } from "class-validator";
|
||||
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class RequestPhoneVerificationOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsNotEmptyObject,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class UserVerifiedEmailOutputData {
|
||||
@Expose()
|
||||
@IsNumber()
|
||||
@ApiProperty({
|
||||
description: "The unique identifier for the verified email.",
|
||||
example: 789,
|
||||
})
|
||||
id!: number;
|
||||
|
||||
@Expose()
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
description: "The verified email address.",
|
||||
example: "user@example.com",
|
||||
format: "email",
|
||||
})
|
||||
email!: string;
|
||||
|
||||
@Expose()
|
||||
@IsNumber()
|
||||
@ApiProperty({
|
||||
description: "The ID of the associated user, if applicable.",
|
||||
example: 45,
|
||||
})
|
||||
userId!: number;
|
||||
}
|
||||
|
||||
export class TeamVerifiedEmailOutputData {
|
||||
@Expose()
|
||||
@IsNumber()
|
||||
@ApiProperty({
|
||||
description: "The unique identifier for the verified email.",
|
||||
example: 789,
|
||||
})
|
||||
id!: number;
|
||||
|
||||
@Expose()
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
description: "The verified email address.",
|
||||
example: "user@example.com",
|
||||
format: "email",
|
||||
})
|
||||
email!: string;
|
||||
|
||||
@Expose()
|
||||
@IsNumber()
|
||||
@ApiProperty({
|
||||
description: "The ID of the associated team, if applicable.",
|
||||
example: 89,
|
||||
})
|
||||
teamId!: number;
|
||||
|
||||
@Expose()
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@ApiProperty({
|
||||
description: "The ID of the associated user, if applicable.",
|
||||
example: 45,
|
||||
nullable: true,
|
||||
required: false,
|
||||
})
|
||||
userId?: number | null;
|
||||
}
|
||||
|
||||
export class TeamVerifiedEmailOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: ScheduleOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@Type(() => TeamVerifiedEmailOutputData)
|
||||
data!: TeamVerifiedEmailOutputData;
|
||||
}
|
||||
|
||||
export class UserVerifiedEmailOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: ScheduleOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@Type(() => UserVerifiedEmailOutputData)
|
||||
data!: UserVerifiedEmailOutputData;
|
||||
}
|
||||
|
||||
export class TeamVerifiedEmailsOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: ScheduleOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TeamVerifiedEmailOutputData)
|
||||
data!: TeamVerifiedEmailOutputData[];
|
||||
}
|
||||
|
||||
export class UserVerifiedEmailsOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: ScheduleOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => UserVerifiedEmailOutputData)
|
||||
data!: UserVerifiedEmailOutputData[];
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsNotEmptyObject,
|
||||
IsNumber,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
IsOptional,
|
||||
} from "class-validator";
|
||||
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class UserVerifiedPhoneOutputData {
|
||||
@Expose()
|
||||
@IsNumber()
|
||||
@ApiProperty({
|
||||
description: "The unique identifier for the verified email.",
|
||||
example: 789,
|
||||
})
|
||||
id!: number;
|
||||
|
||||
@Expose()
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
description: "The verified phone number.",
|
||||
example: "+37255556666",
|
||||
format: "phone",
|
||||
})
|
||||
phoneNumber!: string;
|
||||
|
||||
@Expose()
|
||||
@IsNumber()
|
||||
@ApiProperty({
|
||||
description: "The ID of the associated user, if applicable.",
|
||||
example: 45,
|
||||
})
|
||||
userId!: number;
|
||||
}
|
||||
|
||||
export class TeamVerifiedPhoneOutputData {
|
||||
@Expose()
|
||||
@IsNumber()
|
||||
@ApiProperty({
|
||||
description: "The unique identifier for the verified email.",
|
||||
example: 789,
|
||||
})
|
||||
id!: number;
|
||||
|
||||
@Expose()
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
description: "The verified phone number.",
|
||||
example: "+37255556666",
|
||||
format: "phone",
|
||||
})
|
||||
phoneNumber!: string;
|
||||
|
||||
@Expose()
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@ApiProperty({
|
||||
description: "The ID of the associated user, if applicable.",
|
||||
example: 45,
|
||||
nullable: true,
|
||||
required: false,
|
||||
})
|
||||
userId?: number | null;
|
||||
|
||||
@Expose()
|
||||
@IsNumber()
|
||||
@ApiProperty({
|
||||
description: "The ID of the associated team, if applicable.",
|
||||
example: 89,
|
||||
})
|
||||
teamId!: number;
|
||||
}
|
||||
|
||||
export class UserVerifiedPhoneOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: ScheduleOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@Type(() => UserVerifiedPhoneOutputData)
|
||||
data!: UserVerifiedPhoneOutputData;
|
||||
}
|
||||
|
||||
export class TeamVerifiedPhoneOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: ScheduleOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@Type(() => TeamVerifiedPhoneOutputData)
|
||||
data!: TeamVerifiedPhoneOutputData;
|
||||
}
|
||||
|
||||
export class UserVerifiedPhonesOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: ScheduleOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => UserVerifiedPhoneOutputData)
|
||||
data!: UserVerifiedPhoneOutputData[];
|
||||
}
|
||||
|
||||
export class TeamVerifiedPhonesOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: ScheduleOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TeamVerifiedPhoneOutputData)
|
||||
data!: TeamVerifiedPhoneOutputData[];
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { TeamsVerifiedResourcesRepository } from "@/modules/verified-resources/teams-verified-resources.repository";
|
||||
import { UsersVerifiedResourcesRepository } from "@/modules/verified-resources/users-verified-resources.repository";
|
||||
import { BadRequestException, ConflictException, Injectable } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
verifyPhoneNumber,
|
||||
sendVerificationCode as sendPhoneVerificationCode,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { sendEmailVerificationByCode, verifyEmailCodeHandler } from "@calcom/platform-libraries/emails";
|
||||
|
||||
@Injectable()
|
||||
export class VerifiedResourcesService {
|
||||
constructor(
|
||||
private readonly usersVerifiedResourcesRepository: UsersVerifiedResourcesRepository,
|
||||
private readonly teamsVerifiedResourcesRepository: TeamsVerifiedResourcesRepository
|
||||
) {}
|
||||
|
||||
async requestEmailVerificationCode(user: { username: string; locale: string }, email: string) {
|
||||
const res = await sendEmailVerificationByCode({
|
||||
email: email,
|
||||
language: user.locale,
|
||||
username: user.username,
|
||||
isVerifyingEmail: true,
|
||||
});
|
||||
|
||||
if (res.ok && !res.skipped) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new BadRequestException("Email could not be verified.");
|
||||
}
|
||||
|
||||
if (res.skipped) {
|
||||
throw new ConflictException("Email is already verified.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async requestPhoneVerificationCode(phone: string) {
|
||||
try {
|
||||
await sendPhoneVerificationCode(phone);
|
||||
} catch (err) {
|
||||
throw new BadRequestException("Could not send verification code to this phone number.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async verifyPhone(userId: number, phone: string, code: string, teamId?: number) {
|
||||
const result = await verifyPhoneNumber(phone, code, userId, teamId);
|
||||
|
||||
if (result) {
|
||||
return teamId
|
||||
? await this.teamsVerifiedResourcesRepository.getTeamVerifiedPhoneNumber(userId, phone, teamId)
|
||||
: await this.usersVerifiedResourcesRepository.getUserVerifiedPhoneNumber(userId, phone);
|
||||
}
|
||||
|
||||
throw new BadRequestException("Could not verify phone number.");
|
||||
}
|
||||
|
||||
async verifyEmail(userId: number, email: string, code: string, teamId?: number) {
|
||||
const isValidToken = await verifyEmailCodeHandler({
|
||||
ctx: { user: { id: userId } },
|
||||
input: { email, code, teamId },
|
||||
});
|
||||
if (isValidToken) {
|
||||
const verifiedEmail = teamId
|
||||
? await this.teamsVerifiedResourcesRepository.getTeamVerifiedEmail(userId, email, teamId)
|
||||
: await this.usersVerifiedResourcesRepository.getUserVerifiedEmail(userId, email);
|
||||
return verifiedEmail;
|
||||
}
|
||||
throw new BadRequestException("Invalid Verification Code");
|
||||
}
|
||||
|
||||
async getUserVerifiedEmailById(userId: number, id: number) {
|
||||
return this.usersVerifiedResourcesRepository.getUserVerifiedEmailById(userId, id);
|
||||
}
|
||||
|
||||
async getTeamVerifiedEmailById(teamId: number, id: number) {
|
||||
return this.teamsVerifiedResourcesRepository.getTeamVerifiedEmailById(id, teamId);
|
||||
}
|
||||
|
||||
async getVerifiedEmail(userId: number, email: string) {
|
||||
return this.usersVerifiedResourcesRepository.getUserVerifiedEmail(userId, email);
|
||||
}
|
||||
|
||||
async getUserVerifiedEmails(userId: number, skip = 0, take = 250) {
|
||||
return this.usersVerifiedResourcesRepository.getUserVerifiedEmails(userId, skip, take);
|
||||
}
|
||||
|
||||
async getTeamVerifiedEmails(teamId: number, skip = 0, take = 250) {
|
||||
return this.teamsVerifiedResourcesRepository.getTeamVerifiedEmails(teamId, skip, take);
|
||||
}
|
||||
|
||||
async getUserVerifiedPhoneNumberById(userId: number, id: number) {
|
||||
return this.usersVerifiedResourcesRepository.getUserVerifiedPhoneNumberById(userId, id);
|
||||
}
|
||||
|
||||
async getTeamVerifiedPhoneNumberById(teamId: number, id: number) {
|
||||
return this.teamsVerifiedResourcesRepository.getTeamVerifiedPhoneNumberById(id, teamId);
|
||||
}
|
||||
|
||||
async getUserVerifiedPhoneNumber(userId: number, phoneNumber: string) {
|
||||
return this.usersVerifiedResourcesRepository.getUserVerifiedEmail(userId, phoneNumber);
|
||||
}
|
||||
|
||||
async getTeamVerifiedPhoneNumber(userId: number, phoneNumber: string, teamId: number) {
|
||||
return this.teamsVerifiedResourcesRepository.getTeamVerifiedPhoneNumber(userId, phoneNumber, teamId);
|
||||
}
|
||||
|
||||
async getUserVerifiedPhoneNumbers(userId: number, skip = 0, take = 250) {
|
||||
return this.usersVerifiedResourcesRepository.getUserVerifiedPhoneNumbers(userId, skip, take);
|
||||
}
|
||||
|
||||
async getTeamVerifiedPhoneNumbers(teamId: number, skip = 0, take = 250) {
|
||||
return this.teamsVerifiedResourcesRepository.getTeamVerifiedPhoneNumbers(teamId, skip, take);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class TeamsVerifiedResourcesRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async getTeamVerifiedEmails(teamId: number, skip = 0, take = 250) {
|
||||
return this.dbRead.prisma.verifiedEmail.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
}
|
||||
|
||||
async getTeamVerifiedPhoneNumbers(teamId: number, skip = 0, take = 250) {
|
||||
return this.dbRead.prisma.verifiedNumber.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
}
|
||||
|
||||
async getTeamVerifiedEmail(userId: number, email: string, teamId: number) {
|
||||
return this.dbRead.prisma.verifiedEmail.findFirstOrThrow({
|
||||
where: {
|
||||
userId,
|
||||
email,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getTeamVerifiedPhoneNumber(userId: number, phone: string, teamId: number) {
|
||||
return this.dbRead.prisma.verifiedNumber.findFirstOrThrow({
|
||||
where: {
|
||||
userId,
|
||||
phoneNumber: phone,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getTeamVerifiedEmailById(id: number, teamId: number) {
|
||||
return this.dbRead.prisma.verifiedEmail.findFirst({
|
||||
where: {
|
||||
id,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getTeamVerifiedPhoneNumberById(id: number, teamId: number) {
|
||||
return this.dbRead.prisma.verifiedNumber.findFirst({
|
||||
where: {
|
||||
id,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class UsersVerifiedResourcesRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async getUserVerifiedEmails(userId: number, skip = 0, take = 250) {
|
||||
return this.dbRead.prisma.verifiedEmail.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
}
|
||||
|
||||
async getUserVerifiedPhoneNumber(userId: number, phone: string) {
|
||||
return this.dbRead.prisma.verifiedNumber.findFirstOrThrow({
|
||||
where: {
|
||||
userId,
|
||||
phoneNumber: phone,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getUserVerifiedPhoneNumbers(userId: number, skip = 0, take = 250) {
|
||||
return this.dbRead.prisma.verifiedNumber.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
}
|
||||
|
||||
async getUserVerifiedEmail(userId: number, email: string) {
|
||||
return this.dbRead.prisma.verifiedEmail.findFirstOrThrow({
|
||||
where: {
|
||||
userId,
|
||||
email,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getUserVerifiedEmailById(userId: number, id: number) {
|
||||
return this.dbRead.prisma.verifiedEmail.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getUserVerifiedPhoneNumberById(userId: number, id: number) {
|
||||
return this.dbRead.prisma.verifiedNumber.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { OrgTeamsVerifiedResourcesController } from "@/modules/organizations/teams/verified-resources/org-teams-verified-resources.controller";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { TeamsVerifiedResourcesController } from "@/modules/teams/verified-resources/teams-verified-resources.controller";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { UserVerifiedResourcesController } from "@/modules/verified-resources/controllers/users-verified-resources.controller";
|
||||
import { VerifiedResourcesService } from "@/modules/verified-resources/services/verified-resources.service";
|
||||
import { TeamsVerifiedResourcesRepository } from "@/modules/verified-resources/teams-verified-resources.repository";
|
||||
import { UsersVerifiedResourcesRepository } from "@/modules/verified-resources/users-verified-resources.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, RedisModule],
|
||||
controllers: [
|
||||
UserVerifiedResourcesController,
|
||||
TeamsVerifiedResourcesController,
|
||||
OrgTeamsVerifiedResourcesController,
|
||||
],
|
||||
providers: [
|
||||
VerifiedResourcesService,
|
||||
UsersVerifiedResourcesRepository,
|
||||
TeamsVerifiedResourcesRepository,
|
||||
MembershipsRepository,
|
||||
OrganizationsTeamsRepository,
|
||||
OrganizationsRepository,
|
||||
StripeService,
|
||||
AppsRepository,
|
||||
CredentialsRepository,
|
||||
UsersRepository,
|
||||
],
|
||||
exports: [VerifiedResourcesService],
|
||||
})
|
||||
export class VerifiedResourcesModule {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ export class MembershipRepositoryFixture {
|
||||
}
|
||||
|
||||
async create(data: Prisma.MembershipCreateInput) {
|
||||
return this.prismaWriteClient.membership.create({ data });
|
||||
return this.prismaWriteClient.membership.create({ data: { createdAt: new Date(), ...data } });
|
||||
}
|
||||
|
||||
async delete(membershipId: Membership["id"]) {
|
||||
@@ -30,7 +30,7 @@ export class MembershipRepositoryFixture {
|
||||
|
||||
async addUserToOrg(user: User, org: Team, role: MembershipRole, accepted: boolean) {
|
||||
const membership = await this.prismaWriteClient.membership.create({
|
||||
data: { teamId: org.id, userId: user.id, role, accepted },
|
||||
data: { createdAt: new Date(), teamId: org.id, userId: user.id, role, accepted },
|
||||
});
|
||||
await this.prismaWriteClient.user.update({ where: { id: user.id }, data: { organizationId: org.id } });
|
||||
return membership;
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export class VerifiedResourcesRepositoryFixtures {
|
||||
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 createPhone(data: Prisma.VerifiedNumberCreateInput) {
|
||||
return this.prismaWriteClient.verifiedNumber.create({ data });
|
||||
}
|
||||
|
||||
async deleteEmailById(id: number) {
|
||||
return this.prismaWriteClient.verifiedEmail.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async deletePhoneById(id: number) {
|
||||
return this.prismaWriteClient.verifiedNumber.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
jest.retryTimes(process.env.CI ? 2 : 0); // Global retry for all tests
|
||||
@@ -2,9 +2,9 @@ export default function Page() {
|
||||
return (
|
||||
<div className="font-cal -mt-16 flex min-h-screen w-screen items-center justify-center border pt-16 text-center ">
|
||||
<div>
|
||||
<h1 className="text-7xl font-bold uppercase leading-none tracking-tight md:text-8xl">
|
||||
<h1 className="text-7xl leading-none md:text-8xl">
|
||||
Open UI for absolutely
|
||||
<br /> Everyone
|
||||
<br /> everyone
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -65,3 +65,34 @@ Buttons can be grouped together using the ButtonGroup component. The ButtonGroup
|
||||
#### Combined
|
||||
|
||||
<ButtonGroupSizesCombined/>
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
// Primary button (default)
|
||||
<Button>Primary Button</Button>
|
||||
|
||||
// Secondary button
|
||||
<Button color="secondary">Secondary Button</Button>
|
||||
|
||||
// Minimal button
|
||||
<Button color="minimal">Minimal Button</Button>
|
||||
|
||||
// Destructive button
|
||||
<Button color="destructive">Destructive Button</Button>
|
||||
|
||||
// With start icon
|
||||
<Button StartIcon="plus">Add Item</Button>
|
||||
|
||||
// With end icon
|
||||
<Button EndIcon="chevronRight">View More</Button>
|
||||
|
||||
// With link
|
||||
<Button href="/some-path">Navigate</Button>
|
||||
|
||||
// Different sizes
|
||||
<Button size="xs">Extra Small</Button>
|
||||
<Button size="sm">Small</Button>
|
||||
<Button size="base">Base (Default)</Button>
|
||||
<Button size="lg">Large</Button>
|
||||
```
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function revalidateAvailabilityList() {
|
||||
revalidatePath("/availability");
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
|
||||
export default function Error() {
|
||||
const { t } = useLocale();
|
||||
return <Alert severity="error" title={t("something_went_wrong")} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import AvailabilityLoader from "app/(use-page-wrapper)/(main-nav)/availability/skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <AvailabilityLoader />;
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
// import { cookies, headers } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
// import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
// import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
// import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
|
||||
import AvailabilityPage, { AvailabilityCTA } from "~/availability/availability-view";
|
||||
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
|
||||
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
|
||||
import { availabilityRouter } from "@calcom/trpc/server/routers/viewer/availability/_router";
|
||||
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
|
||||
|
||||
import { AvailabilityList, AvailabilityCTA } from "~/availability/availability-view";
|
||||
|
||||
import { ShellMainAppDir } from "../ShellMainAppDir";
|
||||
|
||||
@@ -19,31 +21,43 @@ export const generateMetadata = async () => {
|
||||
);
|
||||
};
|
||||
|
||||
const Page = async () => {
|
||||
// const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
// const userId = session?.user?.id;
|
||||
// const orgId = session?.user?.org?.id;
|
||||
// if (!userId || !orgId) {
|
||||
// notFound();
|
||||
// }
|
||||
const Page = async ({ searchParams: _searchParams }: PageProps) => {
|
||||
const searchParams = await _searchParams;
|
||||
const t = await getTranslate();
|
||||
|
||||
try {
|
||||
// const currentOrg = await OrganizationRepository.findCurrentOrg({
|
||||
// orgId,
|
||||
// userId,
|
||||
// });
|
||||
const t = await getTranslate();
|
||||
return (
|
||||
<ShellMainAppDir
|
||||
heading={t("availability")}
|
||||
subtitle={t("configure_availability")}
|
||||
CTA={<AvailabilityCTA />}>
|
||||
<AvailabilityPage />
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
const [meCaller, availabilityCaller] = await Promise.all([
|
||||
createRouterCaller(meRouter),
|
||||
createRouterCaller(availabilityRouter),
|
||||
]);
|
||||
|
||||
const [me, availabilities] = await Promise.all([meCaller.get(), availabilityCaller.list()]);
|
||||
const organizationId = me.organization?.id ?? me.profiles[0]?.organizationId;
|
||||
const isOrgPrivate = organizationId
|
||||
? await OrganizationRepository.checkIfPrivate({
|
||||
orgId: organizationId,
|
||||
})
|
||||
: false;
|
||||
const canViewTeamAvailability = me.organization?.isOrgAdmin || !isOrgPrivate;
|
||||
|
||||
return (
|
||||
<ShellMainAppDir
|
||||
heading={t("availability")}
|
||||
subtitle={t("configure_availability")}
|
||||
CTA={
|
||||
<AvailabilityCTA
|
||||
toggleGroupOptions={[
|
||||
{ value: "mine", label: t("my_availability") },
|
||||
...(canViewTeamAvailability ? [{ value: "team", label: t("team_availability") }] : []),
|
||||
]}
|
||||
/>
|
||||
}>
|
||||
{searchParams?.type === "team" && canViewTeamAvailability ? (
|
||||
<AvailabilitySliderTable userTimeFormat={me?.timeFormat ?? null} isOrg={!!organizationId} />
|
||||
) : (
|
||||
<AvailabilityList availabilities={availabilities ?? { schedules: [] }} me={me} />
|
||||
)}
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
|
||||
|
||||
import SkeletonLoader from "@calcom/features/availability/components/SkeletonLoader";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import { AvailabilityCTA } from "~/availability/availability-view";
|
||||
|
||||
export default function AvailabilityLoader() {
|
||||
const { t } = useLocale();
|
||||
const toggleGroupOptions = [
|
||||
{ value: "mine", label: t("my_availability") },
|
||||
{ value: "team", label: t("team_availability") },
|
||||
];
|
||||
|
||||
return (
|
||||
<ShellMainAppDir
|
||||
heading={t("availability")}
|
||||
subtitle={t("configure_availability")}
|
||||
CTA={<AvailabilityCTA toggleGroupOptions={toggleGroupOptions} />}>
|
||||
<SkeletonLoader />
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function revalidateEventTypesList() {
|
||||
revalidatePath("/event-types");
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
|
||||
export default function Error() {
|
||||
const { t } = useLocale();
|
||||
return <Alert severity="error" title={t("something_went_wrong")} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { EventTypesSkeleton } from "./skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <EventTypesSkeleton />;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
|
||||
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
|
||||
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -32,19 +33,27 @@ const Page = async ({ params, searchParams }: PageProps) => {
|
||||
}
|
||||
|
||||
const t = await getTranslate();
|
||||
|
||||
const caller = await createRouterCaller(eventTypesRouter);
|
||||
const filters = getTeamsFiltersFromQuery(_searchParams);
|
||||
const initialData = await caller.getUserEventGroups({ filters });
|
||||
|
||||
const [meCaller, eventTypesCaller] = await Promise.all([
|
||||
createRouterCaller(meRouter),
|
||||
createRouterCaller(eventTypesRouter),
|
||||
]);
|
||||
|
||||
const [me, userEventGroupsData] = await Promise.all([
|
||||
meCaller.get(),
|
||||
eventTypesCaller.getUserEventGroups({ filters }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<ShellMainAppDir
|
||||
heading={t("event_types_page_title")}
|
||||
subtitle={t("event_types_page_subtitle")}
|
||||
CTA={<EventTypesCTA initialData={initialData} filters={filters} />}>
|
||||
<EventTypes initialData={initialData} filters={filters} />
|
||||
CTA={<EventTypesCTA userEventGroupsData={userEventGroupsData} />}>
|
||||
<EventTypes userEventGroupsData={userEventGroupsData} user={me} />
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
export const revalidate = 3600; // 1 hour in seconds
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
|
||||
|
||||
import { EventTypesSkeletonLoader } from "@calcom/features/eventtypes/components/SkeletonLoader";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
export function EventTypesSkeleton() {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<ShellMainAppDir heading={t("event_types_page_title")} subtitle={t("event_types_page_subtitle")}>
|
||||
<EventTypesSkeletonLoader />
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
}
|
||||
+2
-11
@@ -1,15 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { TeamsListing } from "@calcom/features/ee/teams/components";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
export const TeamsCTA = () => {
|
||||
const { t } = useLocale();
|
||||
const [user] = trpc.viewer.me.get.useSuspenseQuery();
|
||||
return !user.organizationId || user.organization.isOrgAdmin ? (
|
||||
return (
|
||||
<Button
|
||||
data-testid="new-team-btn"
|
||||
variant="fab"
|
||||
@@ -18,11 +15,5 @@ export const TeamsCTA = () => {
|
||||
href={`${WEBAPP_URL}/settings/teams/new?returnTo=${WEBAPP_URL}/teams`}>
|
||||
{t("new")}
|
||||
</Button>
|
||||
) : null;
|
||||
);
|
||||
};
|
||||
|
||||
function Teams() {
|
||||
return <TeamsListing />;
|
||||
}
|
||||
|
||||
export default Teams;
|
||||
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function revalidateTeamsList() {
|
||||
revalidatePath("/teams");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TeamsListSkeleton } from "./skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <TeamsListSkeleton />;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import TeamsView, { TeamsCTA } from "~/teams/teams-view";
|
||||
import { ServerTeamsListing } from "./server-page";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -30,13 +30,11 @@ const ServerPage = async ({ searchParams: _searchParams }: ServerPageProps) => {
|
||||
}
|
||||
|
||||
const t = await getTranslate();
|
||||
const { Main, CTA } = await ServerTeamsListing({ searchParams });
|
||||
|
||||
return (
|
||||
<ShellMainAppDir
|
||||
CTA={<TeamsCTA />}
|
||||
heading={t("teams")}
|
||||
subtitle={t("create_manage_teams_collaborative")}>
|
||||
<TeamsView />
|
||||
<ShellMainAppDir CTA={CTA} heading={t("teams")} subtitle={t("create_manage_teams_collaborative")}>
|
||||
{Main}
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import type { SearchParams } from "app/_types";
|
||||
|
||||
import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing";
|
||||
import { CreationSource } from "@calcom/prisma/enums";
|
||||
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
|
||||
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { TeamsCTA } from "./CTA";
|
||||
|
||||
export const ServerTeamsListing = async ({ searchParams }: { searchParams: SearchParams }) => {
|
||||
const token = Array.isArray(searchParams?.token) ? searchParams.token[0] : searchParams?.token;
|
||||
|
||||
const [teamsCaller, meCaller] = await Promise.all([
|
||||
createRouterCaller(viewerTeamsRouter),
|
||||
createRouterCaller(meRouter),
|
||||
]);
|
||||
|
||||
let teamNameFromInvite,
|
||||
errorMsgFromInvite = null;
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
teamNameFromInvite = await teamsCaller.inviteMemberByToken({
|
||||
token,
|
||||
creationSource: CreationSource.WEBAPP,
|
||||
});
|
||||
} catch (e) {
|
||||
errorMsgFromInvite = "Error while fetching teams";
|
||||
if (e instanceof TRPCError) errorMsgFromInvite = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
const [user, teams] = await Promise.all([
|
||||
meCaller.get(),
|
||||
teamsCaller.list({
|
||||
includeOrgs: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
Main: (
|
||||
<TeamsListing
|
||||
teams={teams}
|
||||
user={user}
|
||||
teamNameFromInvite={teamNameFromInvite ?? null}
|
||||
errorMsgFromInvite={errorMsgFromInvite}
|
||||
/>
|
||||
),
|
||||
CTA: !user.organizationId || user.organization.isOrgAdmin ? <TeamsCTA /> : null,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
|
||||
import { TeamsCTA } from "app/(use-page-wrapper)/(main-nav)/teams/CTA";
|
||||
|
||||
import SkeletonLoaderTeamList from "@calcom/features/ee/teams/components/SkeletonloaderTeamList";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
export const TeamsListSkeleton = () => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<ShellMainAppDir
|
||||
heading={t("teams")}
|
||||
subtitle={t("create_manage_teams_collaborative")}
|
||||
CTA={<TeamsCTA />}>
|
||||
<SkeletonLoaderTeamList />
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AppHomePageSkeleton } from "app/(use-page-wrapper)/apps/(homepage)/skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <AppHomePageSkeleton />;
|
||||
}
|
||||
+32
-17
@@ -1,15 +1,28 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import type { AppCategories } from "@calcom/prisma/enums";
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { req } = context;
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import AppsPage from "~/apps/apps-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => t("app_store"),
|
||||
(t) => t("app_store_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/apps"
|
||||
);
|
||||
};
|
||||
|
||||
const ServerPage = async () => {
|
||||
const req = buildLegacyRequest(await headers(), await cookies());
|
||||
const session = await getServerSession({ req });
|
||||
|
||||
let appStore, userAdminTeamsIds: number[];
|
||||
if (session?.user?.id) {
|
||||
const userAdminTeams = await UserRepository.getUserAdminTeams(session.user.id);
|
||||
@@ -30,18 +43,20 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
return c;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
return {
|
||||
props: {
|
||||
categories: Object.entries(categories)
|
||||
.map(([name, count]): { name: AppCategories; count: number } => ({
|
||||
name: name as AppCategories,
|
||||
count,
|
||||
}))
|
||||
.sort(function (a, b) {
|
||||
return b.count - a.count;
|
||||
}),
|
||||
appStore,
|
||||
userAdminTeams: userAdminTeamsIds,
|
||||
},
|
||||
const props = {
|
||||
categories: Object.entries(categories)
|
||||
.map(([name, count]): { name: AppCategories; count: number } => ({
|
||||
name: name as AppCategories,
|
||||
count,
|
||||
}))
|
||||
.sort(function (a, b) {
|
||||
return b.count - a.count;
|
||||
}),
|
||||
appStore,
|
||||
userAdminTeams: userAdminTeamsIds,
|
||||
};
|
||||
|
||||
return <AppsPage {...props} isAdmin={session?.user?.role === "ADMIN"} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { SkeletonText, SkeletonContainer, SkeletonButton } from "@calcom/ui/components/skeleton";
|
||||
|
||||
import AppsLayout from "@components/apps/layouts/AppsLayout";
|
||||
|
||||
export function AppHomePageSkeleton() {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<AppsLayout
|
||||
isPublic
|
||||
heading={t("app_store")}
|
||||
subtitle={t("app_store_description")}
|
||||
headerClassName="sm:hidden lg:block hidden"
|
||||
isAdmin={false}>
|
||||
<SkeletonContainer>
|
||||
<div className="flex flex-col gap-y-8">
|
||||
{/* Featured Categories section */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<SkeletonText className="h-6 w-40 font-semibold" />
|
||||
<div className="flex">
|
||||
<SkeletonButton className="mx-1 h-8 w-8 rounded-md" /> {/* Left arrow */}
|
||||
<SkeletonButton className="mx-1 h-8 w-8 rounded-md" /> {/* Right arrow */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
|
||||
{/* Category cards - 5 items based on screenshot */}
|
||||
{Array(5)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<div
|
||||
key={`category-${i}`}
|
||||
className="bg-subtle flex h-48 flex-col items-center justify-center rounded-md p-4">
|
||||
<div className="mb-3 h-14 w-14 rounded-full bg-gray-200" /> {/* Icon placeholder */}
|
||||
<SkeletonText className="h-5 w-24 text-center" /> {/* Category name */}
|
||||
<SkeletonText className="mt-1 h-4 w-16 text-center" /> {/* Apps count */}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Most Popular section */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<SkeletonText className="h-6 w-40 font-semibold" />
|
||||
<div className="flex">
|
||||
<SkeletonButton className="mx-1 h-8 w-8 rounded-md" /> {/* Left arrow */}
|
||||
<SkeletonButton className="mx-1 h-8 w-8 rounded-md" /> {/* Right arrow */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
|
||||
{/* App cards - 3 items */}
|
||||
{Array(3)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<div key={`app-${i}`} className="border-subtle flex flex-col rounded-md border p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="mr-3 h-10 w-10 rounded-md bg-gray-200" /> {/* App icon */}
|
||||
<div className="flex-1">
|
||||
<SkeletonText className="h-5 w-32" /> {/* App name */}
|
||||
<SkeletonText className="mt-1 h-4 w-24" /> {/* Developer name */}
|
||||
</div>
|
||||
</div>
|
||||
<SkeletonText className="mt-3 h-4 w-full" /> {/* Description line 1 */}
|
||||
<SkeletonText className="mt-2 h-4 w-3/4" /> {/* Description line 2 */}
|
||||
<div className="mt-4 flex justify-between">
|
||||
<SkeletonButton className="h-9 w-24 rounded-md" /> {/* Details button */}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* All apps section */}
|
||||
<div className="mt-4">
|
||||
<SkeletonText className="h-6 w-32 font-semibold" />
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
|
||||
{/* App cards - 6 items */}
|
||||
{Array(6)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<div key={`all-app-${i}`} className="border-subtle flex flex-col rounded-md border p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="mr-3 h-10 w-10 rounded-md bg-gray-200" /> {/* App icon */}
|
||||
<div className="flex-1">
|
||||
<SkeletonText className="h-5 w-32" /> {/* App name */}
|
||||
<SkeletonText className="mt-1 h-4 w-24" /> {/* Developer name */}
|
||||
</div>
|
||||
</div>
|
||||
<SkeletonText className="mt-3 h-4 w-full" /> {/* Description line 1 */}
|
||||
<SkeletonText className="mt-2 h-4 w-3/4" /> {/* Description line 2 */}
|
||||
<div className="mt-4 flex justify-between">
|
||||
<SkeletonButton className="h-9 w-24 rounded-md" /> {/* Details button */}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
</AppsLayout>
|
||||
);
|
||||
}
|
||||
@@ -9,14 +9,11 @@ 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(await headers(), await cookies(), await params, await searchParams);
|
||||
|
||||
const { appMetadata } = await getData(legacyCtx);
|
||||
export const generateMetadata = async ({ params }: PageProps) => {
|
||||
const stepParam = (await params).step;
|
||||
const step = stepParam && Array.isArray(stepParam) ? stepParam.join("/") : "";
|
||||
return await _generateMetadata(
|
||||
(t) => `${t("install")} ${appMetadata?.name ?? ""}`,
|
||||
(t) => t("app_installation"),
|
||||
() => "",
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { getServerSideProps } from "@lib/apps/getServerSideProps";
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
import AppsPage from "~/apps/apps-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => t("app_store"),
|
||||
(t) => t("app_store_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/apps"
|
||||
);
|
||||
};
|
||||
|
||||
const getData = withAppDirSsr(getServerSideProps);
|
||||
|
||||
const ServerPage = async ({ params, searchParams }: PageProps) => {
|
||||
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
|
||||
const props = await getData(context);
|
||||
return <AppsPage {...props} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -23,7 +23,6 @@ const normalizePages = (pages: string[] | string | undefined) => {
|
||||
export const generateMetadata = async ({ params }: { params: Promise<{ pages: string[] }> }) => {
|
||||
const { mainPage } = normalizePages((await params).pages);
|
||||
return await _generateMetadata(
|
||||
// TODO: Need to show the actual form name instead of "Form"
|
||||
(t) => (mainPage === "routing-link" ? `Form | Cal.com Forms` : `${t("routing_forms")} | Cal.com Forms`),
|
||||
() => "",
|
||||
undefined,
|
||||
@@ -48,15 +47,16 @@ const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
|
||||
</FormProvider>
|
||||
);
|
||||
|
||||
if (mainPage === "routing-link") {
|
||||
return <FinalComponent />;
|
||||
// TODO: we should really be using layout for this.
|
||||
if (mainPage === "forms") {
|
||||
return (
|
||||
<Shell withoutMain>
|
||||
<FinalComponent />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell withoutMain>
|
||||
<FinalComponent />
|
||||
</Shell>
|
||||
);
|
||||
return <FinalComponent />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function revalidateSchedulePage(scheduleId: number) {
|
||||
revalidatePath(`/availability/${scheduleId}`);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user