Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20efb566b2 | ||
|
|
d9ebf26e21 | ||
|
|
cb13874574 | ||
|
|
1fa02d957e | ||
|
|
529ca159f7 | ||
|
|
be12ac75b4 | ||
|
|
68cdfd42b1 | ||
|
|
3ebac0194e | ||
|
|
6bdf320640 | ||
|
|
cdfdce533e | ||
|
|
5e29a7fa2a | ||
|
|
97377c4554 | ||
|
|
acc82f808d | ||
|
|
3a59cd41cc | ||
|
|
6ae2ce8ed9 | ||
|
|
476460dad8 | ||
|
|
19cadd18e3 | ||
|
|
e20cc960f1 | ||
|
|
f350542d0d | ||
|
|
257a49ce88 | ||
|
|
75b93cebdb | ||
|
|
161ebdbbec | ||
|
|
978c5bdb95 | ||
|
|
895d039efc | ||
|
|
9f566bc8fc | ||
|
|
518e541177 | ||
|
|
c1be922ee1 | ||
|
|
967bba1e80 | ||
|
|
0fc26f782f | ||
|
|
fadd824d12 | ||
|
|
c1b7b6e83d | ||
|
|
e82a53a442 | ||
|
|
a6578fdcd2 | ||
|
|
4e68bcb021 | ||
|
|
1e673f1df4 | ||
|
|
b91f27dc80 | ||
|
|
becae27b45 | ||
|
|
584f5581fa | ||
|
|
e10c4c1cb5 | ||
|
|
c78552ce60 | ||
|
|
9631d0a64b | ||
|
|
1182460d5c | ||
|
|
aedc0f5a03 | ||
|
|
ff88ec0eae | ||
|
|
110b5ceaf6 | ||
|
|
7c53d2c9ce | ||
|
|
eccebff4c6 | ||
|
|
0a36dd7f61 | ||
|
|
5143083efb | ||
|
|
b874e0d55a | ||
|
|
3ee24586a7 | ||
|
|
b014ded5fd |
@@ -1,187 +0,0 @@
|
||||
language: en-US
|
||||
tone_instructions: ''
|
||||
early_access: false
|
||||
enable_free_tier: true
|
||||
reviews:
|
||||
profile: chill
|
||||
request_changes_workflow: false
|
||||
high_level_summary: false
|
||||
high_level_summary_placeholder: '@coderabbitai summary'
|
||||
high_level_summary_in_walkthrough: false
|
||||
auto_title_placeholder: '@coderabbitai'
|
||||
auto_title_instructions: ''
|
||||
review_status: true
|
||||
commit_status: true
|
||||
fail_commit_status: false
|
||||
collapse_walkthrough: false
|
||||
changed_files_summary: false
|
||||
sequence_diagrams: false
|
||||
estimate_code_review_effort: false
|
||||
assess_linked_issues: true
|
||||
related_issues: false
|
||||
related_prs: true
|
||||
suggested_labels: false
|
||||
auto_apply_labels: false
|
||||
suggested_reviewers: false
|
||||
auto_assign_reviewers: false
|
||||
poem: false
|
||||
labeling_instructions: []
|
||||
path_filters:
|
||||
- '!**/node_modules/**'
|
||||
- '!**/dist/**'
|
||||
- '!**/build/**'
|
||||
- '!**/.next/**'
|
||||
path_instructions:
|
||||
- path: "**/*.{ts,tsx,js,jsx}"
|
||||
instructions: "Flag default exports and encourage named exports. Named exports provide better tree-shaking, easier refactoring, and clearer imports. Exempt main components like pages, layouts, and components that serve as the primary export of a module."
|
||||
abort_on_close: true
|
||||
disable_cache: false
|
||||
auto_review:
|
||||
enabled: true
|
||||
auto_incremental_review: true
|
||||
ignore_title_keywords: []
|
||||
labels: []
|
||||
drafts: false
|
||||
base_branches: []
|
||||
finishing_touches:
|
||||
docstrings:
|
||||
enabled: true
|
||||
unit_tests:
|
||||
enabled: true
|
||||
pre_merge_checks:
|
||||
docstrings:
|
||||
mode: warning
|
||||
threshold: 80
|
||||
title:
|
||||
mode: warning
|
||||
requirements: ''
|
||||
description:
|
||||
mode: warning
|
||||
issue_assessment:
|
||||
mode: warning
|
||||
tools:
|
||||
ast-grep:
|
||||
rule_dirs: []
|
||||
util_dirs: []
|
||||
essential_rules: true
|
||||
packages: []
|
||||
shellcheck:
|
||||
enabled: true
|
||||
ruff:
|
||||
enabled: true
|
||||
markdownlint:
|
||||
enabled: true
|
||||
github-checks:
|
||||
enabled: true
|
||||
timeout_ms: 180000
|
||||
languagetool:
|
||||
enabled: true
|
||||
enabled_rules: []
|
||||
disabled_rules: []
|
||||
enabled_categories: []
|
||||
disabled_categories: []
|
||||
enabled_only: false
|
||||
level: default
|
||||
biome:
|
||||
enabled: true
|
||||
hadolint:
|
||||
enabled: true
|
||||
swiftlint:
|
||||
enabled: true
|
||||
phpstan:
|
||||
enabled: true
|
||||
level: default
|
||||
phpmd:
|
||||
enabled: true
|
||||
phpcs:
|
||||
enabled: true
|
||||
golangci-lint:
|
||||
enabled: true
|
||||
yamllint:
|
||||
enabled: true
|
||||
gitleaks:
|
||||
enabled: true
|
||||
checkov:
|
||||
enabled: true
|
||||
detekt:
|
||||
enabled: true
|
||||
eslint:
|
||||
enabled: true
|
||||
flake8:
|
||||
enabled: true
|
||||
rubocop:
|
||||
enabled: true
|
||||
buf:
|
||||
enabled: true
|
||||
regal:
|
||||
enabled: true
|
||||
actionlint:
|
||||
enabled: true
|
||||
pmd:
|
||||
enabled: true
|
||||
cppcheck:
|
||||
enabled: true
|
||||
semgrep:
|
||||
enabled: true
|
||||
circleci:
|
||||
enabled: true
|
||||
clippy:
|
||||
enabled: true
|
||||
sqlfluff:
|
||||
enabled: true
|
||||
prismaLint:
|
||||
enabled: true
|
||||
pylint:
|
||||
enabled: true
|
||||
oxc:
|
||||
enabled: true
|
||||
shopifyThemeCheck:
|
||||
enabled: true
|
||||
luacheck:
|
||||
enabled: true
|
||||
brakeman:
|
||||
enabled: true
|
||||
dotenvLint:
|
||||
enabled: true
|
||||
htmlhint:
|
||||
enabled: true
|
||||
checkmake:
|
||||
enabled: true
|
||||
osvScanner:
|
||||
enabled: true
|
||||
chat:
|
||||
art: true
|
||||
auto_reply: true
|
||||
integrations:
|
||||
jira:
|
||||
usage: auto
|
||||
linear:
|
||||
usage: auto
|
||||
knowledge_base:
|
||||
opt_out: false
|
||||
web_search:
|
||||
enabled: true
|
||||
code_guidelines:
|
||||
enabled: true
|
||||
filePatterns: []
|
||||
learnings:
|
||||
scope: auto
|
||||
issues:
|
||||
scope: auto
|
||||
jira:
|
||||
usage: auto
|
||||
project_keys: []
|
||||
linear:
|
||||
usage: auto
|
||||
team_keys: []
|
||||
pull_requests:
|
||||
scope: auto
|
||||
mcp:
|
||||
usage: auto
|
||||
disabled_servers: []
|
||||
code_generation:
|
||||
docstrings:
|
||||
language: en-US
|
||||
path_instructions: []
|
||||
unit_tests:
|
||||
path_instructions: []
|
||||
@@ -0,0 +1,4 @@
|
||||
/.yarn/** linguist-vendored
|
||||
/.yarn/releases/* binary
|
||||
/.yarn/plugins/**/* binary
|
||||
/.pnp.* binary linguist-generated
|
||||
@@ -1,6 +1,3 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
yarn lint-staged
|
||||
|
||||
yarn app-store:build && git add packages/app-store/*.generated.*
|
||||
|
||||
@@ -1,30 +1,205 @@
|
||||
# Cal.com Development Guide for AI Agents
|
||||
|
||||
**📖 Complete documentation is in the [.agents/](.agents/) directory.**
|
||||
You are a senior Cal.com engineer working in a Yarn/Turbo monorepo. You prioritize type safety, security, and small, reviewable diffs.
|
||||
|
||||
## Quick Reference
|
||||
## Do
|
||||
|
||||
### Essential Commands
|
||||
- Use `select` instead of `include` in Prisma queries for performance and security
|
||||
- Use `import type { X }` for TypeScript type imports
|
||||
- Use early returns to reduce nesting: `if (!booking) return null;`
|
||||
- Use TRPCError with proper error codes for API errors
|
||||
- Use conventional commits: `feat:`, `fix:`, `refactor:`
|
||||
- Create PRs in draft mode by default
|
||||
- Run `yarn type-check:ci --force` before concluding CI failures are unrelated to your changes
|
||||
- Import directly from source files, not barrel files (e.g., `@calcom/ui/components/button` not `@calcom/ui`)
|
||||
- Add translations to `apps/web/public/static/locales/en/common.json` for all UI strings
|
||||
- Use `date-fns` or native `Date` instead of Day.js when timezone awareness isn't needed
|
||||
- Put permission checks in `page.tsx`, never in `layout.tsx`
|
||||
- Use `ast-grep` for searching if available; otherwise use `rg` (ripgrep), then fall back to `grep`
|
||||
|
||||
- `yarn dev` - Start development server
|
||||
- `yarn build` - Build all packages
|
||||
- `yarn lint:fix` - Lint and fix code
|
||||
- `yarn type-check` - TypeScript checking
|
||||
- `yarn test <filename> -- --integrationTestsOnly` - Run integration tests
|
||||
- `yarn e2e <filename> --grep "<testName>"` - Run specific E2E test
|
||||
## Don't
|
||||
|
||||
## Tool Preferences
|
||||
- Never use `as any` - use proper type-safe solutions instead
|
||||
- Never expose `credential.key` field in API responses or queries
|
||||
- Never commit secrets or API keys
|
||||
- Never modify `*.generated.ts` files directly - they're created by app-store-cli
|
||||
- Never put business logic in repositories - that belongs in Services
|
||||
- Never use barrel imports from index.ts files
|
||||
- Never skip running type checks before pushing
|
||||
- Never create large PRs (>500 lines or >10 files) - split them instead
|
||||
|
||||
### Search Tools Priority
|
||||
## Commands
|
||||
|
||||
Use tools in this order of preference:
|
||||
### File-scoped (preferred for speed)
|
||||
|
||||
1. **ast-grep** - For AST-based code searches (if available)
|
||||
2. **rg (ripgrep)** - For fast text searches
|
||||
3. **grep** - As fallback for text searches
|
||||
```bash
|
||||
# Type check - always run on changed files
|
||||
yarn type-check:ci --force
|
||||
|
||||
## 📚 Detailed Documentation
|
||||
# Lint single file
|
||||
yarn eslint --fix path/to/file.tsx
|
||||
|
||||
- **[.agents/README.md](.agents/README.md)** - Complete development guide
|
||||
- **[.agents/commands.md](.agents/commands.md)** - All build, test & dev commands
|
||||
- **[.agents/knowledge-base.md](.agents/knowledge-base.md)** - Knowledge base & best practices
|
||||
# Format single file
|
||||
yarn prettier --write path/to/file.tsx
|
||||
|
||||
# Unit test specific file
|
||||
yarn vitest run path/to/file.test.ts
|
||||
|
||||
# Integration test specific file
|
||||
yarn test path/to/file.integration-test.ts -- --integrationTestsOnly
|
||||
|
||||
# E2E test specific file
|
||||
PLAYWRIGHT_HEADLESS=1 yarn e2e path/to/file.e2e.ts
|
||||
```
|
||||
|
||||
### Project-wide (use sparingly)
|
||||
|
||||
```bash
|
||||
# Development
|
||||
yarn dev # Start dev server
|
||||
yarn dx # Dev with database setup
|
||||
|
||||
# Build & check
|
||||
yarn build # Build all packages
|
||||
yarn lint:fix # Lint and fix all
|
||||
yarn type-check # Type check all
|
||||
|
||||
# Tests (use TZ=UTC for consistency)
|
||||
TZ=UTC yarn test # All unit tests
|
||||
yarn e2e # All E2E tests
|
||||
|
||||
# Database
|
||||
yarn prisma generate # Regenerate types after schema changes
|
||||
yarn workspace @calcom/prisma db-migrate # Run migrations
|
||||
```
|
||||
|
||||
## Boundaries
|
||||
|
||||
### Always do
|
||||
- Run type check on changed files before committing
|
||||
- Run relevant tests before pushing
|
||||
- Use `select` in Prisma queries
|
||||
- Follow conventional commits for PR titles
|
||||
|
||||
### Ask first
|
||||
- Adding new dependencies
|
||||
- Schema changes to `packages/prisma/schema.prisma`
|
||||
- Changes affecting multiple packages
|
||||
- Deleting files
|
||||
- Running full build or E2E suites
|
||||
|
||||
### Never do
|
||||
- Commit secrets, API keys, or `.env` files
|
||||
- Expose `credential.key` in any query
|
||||
- Use `as any` type casting
|
||||
- Force push or rebase shared branches
|
||||
- Modify generated files directly
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/web/ # Main Next.js application
|
||||
packages/prisma/ # Database schema (schema.prisma) and migrations
|
||||
packages/trpc/ # tRPC API layer (routers in server/routers/)
|
||||
packages/ui/ # Shared UI components
|
||||
packages/features/ # Feature-specific code
|
||||
packages/app-store/ # Third-party integrations
|
||||
packages/lib/ # Shared utilities
|
||||
```
|
||||
|
||||
### Key files
|
||||
- Routes: `apps/web/app/` (App Router)
|
||||
- Database schema: `packages/prisma/schema.prisma`
|
||||
- tRPC routers: `packages/trpc/server/routers/`
|
||||
- Translations: `apps/web/public/static/locales/en/common.json`
|
||||
- Workflow constants: `packages/features/ee/workflows/lib/constants.ts`
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: Next.js 13+ (App Router in some areas)
|
||||
- **Language**: TypeScript (strict)
|
||||
- **Database**: PostgreSQL with Prisma ORM
|
||||
- **API**: tRPC for type-safe APIs
|
||||
- **Auth**: NextAuth.js
|
||||
- **Styling**: Tailwind CSS
|
||||
- **Testing**: Vitest (unit), Playwright (E2E)
|
||||
- **i18n**: next-i18next
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Good error handling
|
||||
|
||||
```typescript
|
||||
// Good - Descriptive error with context
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Unable to create booking: User ${userId} has no available time slots for ${date}`,
|
||||
});
|
||||
|
||||
// Bad - Generic error
|
||||
throw new Error("Booking failed");
|
||||
```
|
||||
|
||||
### Good Prisma query
|
||||
|
||||
```typescript
|
||||
// Good - Use select for performance and security
|
||||
const booking = await prisma.booking.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Bad - Include fetches all fields including sensitive ones
|
||||
const booking = await prisma.booking.findFirst({
|
||||
include: { user: true }
|
||||
});
|
||||
```
|
||||
|
||||
### Good imports
|
||||
|
||||
```typescript
|
||||
// Good - Type imports and direct paths
|
||||
import type { User } from "@prisma/client";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
// Bad - Regular import for types, barrel imports
|
||||
import { User } from "@prisma/client";
|
||||
import { Button } from "@calcom/ui";
|
||||
```
|
||||
|
||||
## PR Checklist
|
||||
|
||||
- [ ] Title follows conventional commits: `feat(scope): description`
|
||||
- [ ] Type check passes: `yarn type-check:ci --force`
|
||||
- [ ] Lint passes: `yarn lint:fix`
|
||||
- [ ] Relevant tests pass
|
||||
- [ ] Diff is small and focused (<500 lines, <10 files)
|
||||
- [ ] No secrets or API keys committed
|
||||
- [ ] UI strings added to translation files
|
||||
- [ ] Created as draft PR
|
||||
|
||||
## When Stuck
|
||||
|
||||
- Ask a clarifying question before making large speculative changes
|
||||
- Propose a short plan for complex tasks
|
||||
- Open a draft PR with notes if unsure about approach
|
||||
- Fix type errors before test failures - they're often the root cause
|
||||
- Run `yarn prisma generate` if you see missing enum/type errors
|
||||
|
||||
## Extended Documentation
|
||||
|
||||
For detailed information, see the `agents/` directory:
|
||||
|
||||
- **[agents/README.md](agents/README.md)** - Architecture overview and patterns
|
||||
- **[agents/commands.md](agents/commands.md)** - Complete command reference
|
||||
- **[agents/knowledge-base.md](agents/knowledge-base.md)** - Domain knowledge and best practices
|
||||
- **[agents/coding-standards.md](agents/coding-standards.md)** - Coding standards with examples
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ ARG CALCOM_TELEMETRY_DISABLED
|
||||
ARG DATABASE_URL
|
||||
ARG NEXTAUTH_SECRET=secret
|
||||
ARG CALENDSO_ENCRYPTION_KEY=secret
|
||||
ARG MAX_OLD_SPACE_SIZE=4096
|
||||
ARG MAX_OLD_SPACE_SIZE=6144
|
||||
ARG NEXT_PUBLIC_API_V2_URL
|
||||
ARG CSP_POLICY
|
||||
|
||||
|
||||
@@ -836,12 +836,12 @@ following
|
||||
|
||||
1. Open [HubSpot Developer](https://developer.hubspot.com/) and sign into your account, or create a new one.
|
||||
2. From within the home of the Developer account page, go to "Manage apps".
|
||||
3. Click "Create app" button top right.
|
||||
3. Click "Create legacy app" button top right and select public app.
|
||||
4. Fill in any information you want in the "App info" tab
|
||||
5. Go to tab "Auth"
|
||||
6. Now copy the Client ID and Client Secret to your `.env` file into the `HUBSPOT_CLIENT_ID` and `HUBSPOT_CLIENT_SECRET` fields.
|
||||
7. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/hubspot/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||
8. In the "Scopes" section at the bottom of the page, make sure you select "Read" and "Write" for scope called `crm.objects.contacts`
|
||||
8. In the "Scopes" section at the bottom of the page, make sure you select "Read" and "Write" for scopes called `crm.objects.contacts` and `crm.lists`.
|
||||
9. Click the "Save" button at the bottom footer.
|
||||
10. You're good to go. Now you can see any booking in Cal.com created as a meeting in HubSpot for your contacts.
|
||||
|
||||
|
||||
@@ -27,6 +27,12 @@ import { User } from "@prisma/client";
|
||||
|
||||
- Use React children and context instead of passing props through multiple components
|
||||
|
||||
### ORM and Types
|
||||
|
||||
- Never import `@calcom/prisma/client` in features, services, UI, or handlers.
|
||||
- Use repository DTOs or domain types instead.
|
||||
- See "Repository + DTO Pattern and Method Conventions" in the knowledge base for details and examples.
|
||||
|
||||
### Security Rules
|
||||
|
||||
```typescript
|
||||
@@ -306,9 +306,54 @@ import { Button } from "@calcom/ui/components/button";
|
||||
- **Tests**: Same as source file + `.test.ts` or `.spec.ts`
|
||||
- **Avoid**: Dot-suffixes like `.service.ts`, `.repository.ts` (except for tests, types, specs)
|
||||
|
||||
## Repository Method Conventions
|
||||
## Repository + DTO Pattern and Method Conventions
|
||||
|
||||
Repositories should follow consistent naming and design patterns to promote reusability and maintainability.
|
||||
We use a Repository + DTO pattern to isolate Prisma and the database from business logic. Repositories are the only layer that talks to Prisma and database models. All services, tRPC handlers, API controllers, workflows, and UI should depend on DTOs or domain types, not Prisma types.
|
||||
|
||||
DTOs (Data Transfer Objects) are simple TypeScript types or interfaces that represent our domain data in an ORM-agnostic way and are returned from repositories.
|
||||
|
||||
### Why this pattern?
|
||||
|
||||
- **Prevent type leaks**: Prisma type changes stay inside repositories instead of breaking services, handlers, and UI across dozens of files.
|
||||
- **Enable safe refactors**: Business logic works against stable DTOs, so you can change storage details without touching higher layers.
|
||||
- **Keep ORM swappable**: If we ever change ORM, only repository implementations change; services and handlers stay the same.
|
||||
|
||||
### What not to do: tight Prisma coupling in business logic
|
||||
|
||||
Do not import Prisma types in business logic. Prisma types belong only in the data access layer.
|
||||
|
||||
```typescript
|
||||
// ❌ Bad – business logic depends on Prisma
|
||||
import type { Webhook } from "@calcom/prisma/client";
|
||||
export function sendPayload(webhook: Webhook) { /* ... */ }
|
||||
|
||||
// ✅ Good – business logic depends on DTO + repository
|
||||
export interface WebhookDTO { id: string; url: string; }
|
||||
export interface IWebhookRepository { findById(id: string): Promise<WebhookDTO | null>; }
|
||||
export async function sendPayload(repo: IWebhookRepository, id: string) {
|
||||
const webhook = await repo.findById(id);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### DTOs in repositories
|
||||
|
||||
Repositories should expose DTOs from their public methods and keep Prisma types internal. Inside the repository, use `Prisma.*GetPayload` and map rows to DTOs. The public methods should only return DTOs, never Prisma models.
|
||||
|
||||
```typescript
|
||||
// DTO – ORM-agnostic type exported from the repository module
|
||||
export interface BookingDTO {
|
||||
id: string;
|
||||
userId: number;
|
||||
startTime: Date;
|
||||
status: "PENDING" | "CONFIRMED" | "CANCELLED";
|
||||
}
|
||||
```
|
||||
|
||||
### Prisma boundaries
|
||||
|
||||
- **Allowed**: `packages/prisma`, repository implementations (`packages/features/**/repositories/*Repository.ts`), and low-level data access infrastructure.
|
||||
- **Not allowed**: `packages/features/**` business logic (non-repository), `packages/trpc/**` handlers, `apps/web/**`, `apps/api/v2/**` services/controllers, and workflow/webhook/service layers.
|
||||
|
||||
### Method Naming Rules
|
||||
|
||||
@@ -125,7 +125,7 @@ export class BookingsController_2024_04_15 {
|
||||
private readonly instantBookingCreateService: InstantBookingCreateService,
|
||||
private readonly eventTypeRepository: PrismaEventTypeRepository,
|
||||
private readonly teamRepository: PrismaTeamRepository
|
||||
) {}
|
||||
) { }
|
||||
|
||||
@Get("/")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@@ -270,6 +270,7 @@ export class BookingsController_2024_04_15 {
|
||||
const res = await handleCancelBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
userUuid: bookingRequest.userUuid,
|
||||
arePlatformEmailsEnabled: bookingRequest.arePlatformEmailsEnabled,
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
@@ -300,7 +301,7 @@ export class BookingsController_2024_04_15 {
|
||||
@Permissions([BOOKING_WRITE])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async markNoShow(
|
||||
@GetUser("id") userId: number,
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() body: MarkNoShowInput_2024_04_15,
|
||||
@Param("bookingUid") bookingUid: string
|
||||
): Promise<MarkNoShowOutput_2024_04_15> {
|
||||
@@ -309,7 +310,8 @@ export class BookingsController_2024_04_15 {
|
||||
bookingUid: bookingUid,
|
||||
attendees: body.attendees,
|
||||
noShowHost: body.noShowHost,
|
||||
userId,
|
||||
userId: user.id,
|
||||
userUuid: user.uuid,
|
||||
});
|
||||
|
||||
return { status: SUCCESS_STATUS, data: markNoShowResponse };
|
||||
@@ -378,7 +380,7 @@ export class BookingsController_2024_04_15 {
|
||||
): Promise<ApiResponse<InstantBookingCreateResult>> {
|
||||
const oAuthClientId =
|
||||
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId));
|
||||
req.userId = (await this.getOwnerId(req)) ?? -1;
|
||||
req.userId = (await this.getOwner(req))?.id ?? -1;
|
||||
try {
|
||||
const bookingReq = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
|
||||
const instantMeeting = await this.instantBookingCreateService.createBooking({
|
||||
@@ -405,30 +407,45 @@ export class BookingsController_2024_04_15 {
|
||||
throw new InternalServerErrorException("Could not create instant booking.");
|
||||
}
|
||||
|
||||
private async getOwnerId(req: Request): Promise<number | undefined> {
|
||||
private async getOwner(req: Request): Promise<{ id: number; uuid: string } | null> {
|
||||
try {
|
||||
const bearerToken = req.get("Authorization")?.replace("Bearer ", "");
|
||||
if (bearerToken) {
|
||||
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
|
||||
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
|
||||
const apiKeyHash = sha256Hash(strippedApiKey);
|
||||
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
||||
return keyData?.userId;
|
||||
} else {
|
||||
// Access Token
|
||||
const ownerId = await this.oAuthFlowService.getOwnerId(bearerToken);
|
||||
return ownerId;
|
||||
}
|
||||
if (!bearerToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let ownerId: number | null = null;
|
||||
|
||||
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
|
||||
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
|
||||
const apiKeyHash = sha256Hash(strippedApiKey);
|
||||
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
||||
ownerId = keyData?.userId ?? null;
|
||||
} else {
|
||||
// Access Token
|
||||
ownerId = await this.oAuthFlowService.getOwnerId(bearerToken);
|
||||
}
|
||||
|
||||
if (!ownerId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await this.usersRepository.findById(ownerId);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { id: user.id, uuid: user.uuid };
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async getOwnerIdRescheduledBooking(
|
||||
private async getOwnerRescheduledBooking(
|
||||
request: Request,
|
||||
platformClientId?: string
|
||||
): Promise<number | undefined> {
|
||||
): Promise<{ id: number; uuid: string } | null> {
|
||||
if (
|
||||
platformClientId &&
|
||||
request.body.rescheduledBy &&
|
||||
@@ -440,13 +457,15 @@ export class BookingsController_2024_04_15 {
|
||||
);
|
||||
}
|
||||
|
||||
if (request.body.rescheduledBy) {
|
||||
if (request.body.rescheduledBy !== request.body.responses.email) {
|
||||
return (await this.usersRepository.findByEmail(request.body.rescheduledBy))?.id;
|
||||
if (request.body.rescheduledBy && request.body.rescheduledBy !== request.body.responses.email) {
|
||||
const user = await this.usersRepository.findByEmail(request.body.rescheduledBy);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
return { id: user.id, uuid: user.uuid };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return null;
|
||||
}
|
||||
|
||||
private async getOAuthClientIdFromEventType(eventTypeId: number): Promise<string | undefined> {
|
||||
@@ -498,7 +517,8 @@ export class BookingsController_2024_04_15 {
|
||||
}
|
||||
}
|
||||
|
||||
const userId = await this.getOwnerId(req);
|
||||
const owner = await this.getOwner(req);
|
||||
const userId = owner?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException(
|
||||
@@ -561,12 +581,16 @@ export class BookingsController_2024_04_15 {
|
||||
oAuthClientId?: string,
|
||||
platformBookingLocation?: string,
|
||||
isEmbed?: string
|
||||
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
|
||||
): Promise<NextApiRequest & { userId?: number; userUuid?: string } & OAuthRequestParams> {
|
||||
const requestId = req.get("X-Request-Id");
|
||||
const clone = { ...req };
|
||||
const userId = clone.body.rescheduleUid
|
||||
? await this.getOwnerIdRescheduledBooking(req, oAuthClientId)
|
||||
: await this.getOwnerId(req);
|
||||
const owner = clone.body.rescheduleUid
|
||||
? await this.getOwnerRescheduledBooking(req, oAuthClientId)
|
||||
: await this.getOwner(req);
|
||||
|
||||
const userId = owner?.id;
|
||||
const userUuid = owner?.uuid;
|
||||
|
||||
const oAuthParams = oAuthClientId
|
||||
? await this.getOAuthClientsParams(oAuthClientId, this.transformToBoolean(isEmbed))
|
||||
: DEFAULT_PLATFORM_PARAMS;
|
||||
@@ -577,7 +601,7 @@ export class BookingsController_2024_04_15 {
|
||||
oAuthClientId,
|
||||
...oAuthParams,
|
||||
});
|
||||
Object.assign(clone, { userId, ...oAuthParams, platformBookingLocation });
|
||||
Object.assign(clone, { userId, userUuid, ...oAuthParams, platformBookingLocation });
|
||||
clone.body = {
|
||||
...clone.body,
|
||||
noEmail: !oAuthParams.arePlatformEmailsEnabled,
|
||||
@@ -586,7 +610,7 @@ export class BookingsController_2024_04_15 {
|
||||
if (oAuthClientId) {
|
||||
await this.setPlatformAttendeesEmails(clone.body, oAuthClientId);
|
||||
}
|
||||
return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
||||
return clone as unknown as NextApiRequest & { userId?: number; userUuid?: string } & OAuthRequestParams;
|
||||
}
|
||||
|
||||
async setPlatformAttendeesEmails(
|
||||
@@ -612,9 +636,12 @@ export class BookingsController_2024_04_15 {
|
||||
oAuthClientId?: string,
|
||||
platformBookingLocation?: string,
|
||||
isEmbed?: string
|
||||
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
|
||||
): Promise<NextApiRequest & { userId?: number; userUuid?: string } & OAuthRequestParams> {
|
||||
const clone = { ...req };
|
||||
const userId = (await this.getOwnerId(req)) ?? -1;
|
||||
const owner = await this.getOwner(req);
|
||||
const userId = owner?.id ?? -1;
|
||||
const userUuid = owner?.uuid;
|
||||
|
||||
const oAuthParams = oAuthClientId
|
||||
? await this.getOAuthClientsParams(oAuthClientId, this.transformToBoolean(isEmbed))
|
||||
: DEFAULT_PLATFORM_PARAMS;
|
||||
@@ -628,6 +655,7 @@ export class BookingsController_2024_04_15 {
|
||||
});
|
||||
Object.assign(clone, {
|
||||
userId,
|
||||
userUuid,
|
||||
...oAuthParams,
|
||||
platformBookingLocation,
|
||||
noEmail: !oAuthParams.arePlatformEmailsEnabled,
|
||||
@@ -636,7 +664,7 @@ export class BookingsController_2024_04_15 {
|
||||
if (oAuthClientId) {
|
||||
await this.setPlatformAttendeesEmails(clone.body, oAuthClientId);
|
||||
}
|
||||
return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
||||
return clone as unknown as NextApiRequest & { userId?: number; userUuid?: string } & OAuthRequestParams;
|
||||
}
|
||||
|
||||
private handleBookingErrors(
|
||||
|
||||
@@ -397,18 +397,18 @@ export class BookingsController_2024_08_13 {
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
async markNoShow(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@Body() body: MarkAbsentBookingInput_2024_08_13,
|
||||
@GetUser("id") ownerId: number
|
||||
): Promise<MarkAbsentBookingOutput_2024_08_13> {
|
||||
const booking = await this.bookingsService.markAbsent(bookingUid, ownerId, body);
|
||||
async markNoShow(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@Body() body: MarkAbsentBookingInput_2024_08_13,
|
||||
@GetUser() user: ApiAuthGuardUser
|
||||
): Promise<MarkAbsentBookingOutput_2024_08_13> {
|
||||
const booking = await this.bookingsService.markAbsent(bookingUid, user.id, body, user.uuid);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: booking,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: booking,
|
||||
};
|
||||
}
|
||||
|
||||
@Post("/:bookingUid/reassign")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -919,34 +919,40 @@ export class BookingsService_2024_08_13 {
|
||||
return await this.getBooking(recurringBookingUid, authUser);
|
||||
}
|
||||
|
||||
async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) {
|
||||
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
|
||||
const bookingBefore = await this.bookingsRepository.getByUid(bookingUid);
|
||||
async markAbsent(
|
||||
bookingUid: string,
|
||||
bookingOwnerId: number,
|
||||
body: MarkAbsentBookingInput_2024_08_13,
|
||||
userUuid?: string
|
||||
) {
|
||||
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
|
||||
const bookingBefore = await this.bookingsRepository.getByUid(bookingUid);
|
||||
|
||||
if (!bookingBefore) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} not found.`);
|
||||
}
|
||||
if (!bookingBefore) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} not found.`);
|
||||
}
|
||||
|
||||
const nowUtc = DateTime.utc();
|
||||
const bookingStartTimeUtc = DateTime.fromJSDate(bookingBefore.startTime, { zone: "utc" });
|
||||
const nowUtc = DateTime.utc();
|
||||
const bookingStartTimeUtc = DateTime.fromJSDate(bookingBefore.startTime, { zone: "utc" });
|
||||
|
||||
if (nowUtc < bookingStartTimeUtc) {
|
||||
throw new BadRequestException(
|
||||
`Bookings can only be marked as absent after their scheduled start time. Current time in UTC+0: ${nowUtc.toISO()}, Booking start time in UTC+0: ${bookingStartTimeUtc.toISO()}`
|
||||
);
|
||||
}
|
||||
if (nowUtc < bookingStartTimeUtc) {
|
||||
throw new BadRequestException(
|
||||
`Bookings can only be marked as absent after their scheduled start time. Current time in UTC+0: ${nowUtc.toISO()}, Booking start time in UTC+0: ${bookingStartTimeUtc.toISO()}`
|
||||
);
|
||||
}
|
||||
|
||||
const platformClientParams = bookingBefore?.eventTypeId
|
||||
? await this.platformBookingsService.getOAuthClientParams(bookingBefore.eventTypeId)
|
||||
: undefined;
|
||||
const platformClientParams = bookingBefore?.eventTypeId
|
||||
? await this.platformBookingsService.getOAuthClientParams(bookingBefore.eventTypeId)
|
||||
: undefined;
|
||||
|
||||
await handleMarkNoShow({
|
||||
bookingUid,
|
||||
attendees: bodyTransformed.attendees,
|
||||
noShowHost: bodyTransformed.noShowHost,
|
||||
userId: bookingOwnerId,
|
||||
platformClientParams,
|
||||
});
|
||||
await handleMarkNoShow({
|
||||
bookingUid,
|
||||
attendees: bodyTransformed.attendees,
|
||||
noShowHost: bodyTransformed.noShowHost,
|
||||
userId: bookingOwnerId,
|
||||
userUuid,
|
||||
platformClientParams,
|
||||
});
|
||||
|
||||
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
|
||||
|
||||
|
||||
+162
-2
@@ -34,6 +34,7 @@ import { SchedulingType } from "@calcom/platform-libraries";
|
||||
import {
|
||||
BaseConfirmationPolicy_2024_06_14,
|
||||
TeamEventTypeOutput_2024_06_14,
|
||||
supportedIntegrations,
|
||||
type ApiSuccessResponse,
|
||||
type CreateEventTypeInput_2024_06_14,
|
||||
type EventTypeOutput_2024_06_14,
|
||||
@@ -640,9 +641,9 @@ describe("Event types Endpoints", () => {
|
||||
hiddenEventType = responseBody.data;
|
||||
});
|
||||
|
||||
it(`/GET/event-types by username`, async () => {
|
||||
it(`/GET/event-types by username with sortCreatedAt=desc`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${user.username}`)
|
||||
.get(`/api/v2/event-types?username=${user.username}&sortCreatedAt=desc`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(200);
|
||||
@@ -653,6 +654,12 @@ describe("Event types Endpoints", () => {
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.length).toEqual(2);
|
||||
|
||||
// Verify ordering: event types are returned newest to oldest when sortCreatedAt=desc
|
||||
// hiddenEventType was created after eventType, so it should have a higher ID and appear first
|
||||
expect(responseBody.data[0].id).toBeGreaterThan(responseBody.data[1].id);
|
||||
expect(responseBody.data[0].id).toEqual(hiddenEventType.id);
|
||||
expect(responseBody.data[1].id).toEqual(eventType.id);
|
||||
|
||||
const fetchedEventType = responseBody.data?.find((et) => et.id === eventType.id);
|
||||
const fetchedHiddenEventType = responseBody.data?.find((et) => et.id === hiddenEventType.id);
|
||||
|
||||
@@ -711,6 +718,46 @@ describe("Event types Endpoints", () => {
|
||||
expect(fetchedEventType?.hidden).toEqual(false);
|
||||
});
|
||||
|
||||
it(`/GET/event-types by username with sortCreatedAt=asc`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${user.username}&sortCreatedAt=asc`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.length).toEqual(2);
|
||||
|
||||
// Verify ordering: event types are returned oldest to newest when sortCreatedAt=asc
|
||||
// eventType was created before hiddenEventType, so it should have a lower ID and appear first
|
||||
expect(responseBody.data[0].id).toBeLessThan(responseBody.data[1].id);
|
||||
expect(responseBody.data[0].id).toEqual(eventType.id);
|
||||
expect(responseBody.data[1].id).toEqual(hiddenEventType.id);
|
||||
});
|
||||
|
||||
it(`/GET/event-types by username without sortCreatedAt parameter`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${user.username}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.length).toEqual(2);
|
||||
|
||||
// Without sortCreatedAt, no specific order is guaranteed
|
||||
// Just verify both event types are present
|
||||
const ids = responseBody.data.map((et) => et.id);
|
||||
expect(ids).toContain(eventType.id);
|
||||
expect(ids).toContain(hiddenEventType.id);
|
||||
});
|
||||
|
||||
it(`/GET/event-types by username should not return hidden event type if no auth provided`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${user.username}`)
|
||||
@@ -2855,6 +2902,119 @@ describe("Event types Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should accept newly supported integration locations in input validation", async () => {
|
||||
// Test that the API accepts various newly supported integrations by creating event types
|
||||
// with these location types directly in the database and then retrieving them
|
||||
// Note: Database uses underscores in integration types, API returns hyphens
|
||||
const integrationTests = [
|
||||
{ internal: "integrations:jitsi", api: "jitsi" },
|
||||
{ internal: "integrations:whereby_video", api: "whereby-video" },
|
||||
{ internal: "integrations:huddle01", api: "huddle" },
|
||||
{ internal: "integrations:tandem", api: "tandem" },
|
||||
{ internal: "integrations:element-call_video", api: "element-call-video" },
|
||||
];
|
||||
|
||||
for (const { internal, api } of integrationTests) {
|
||||
const eventTypeInput = {
|
||||
title: `event type ${api}`,
|
||||
description: "event type description",
|
||||
length: 30,
|
||||
hidden: false,
|
||||
slug: `event-type-${api}`,
|
||||
locations: [
|
||||
{
|
||||
type: internal,
|
||||
link: `https://example.com/${api}/meeting`,
|
||||
},
|
||||
],
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
bookingFields: [],
|
||||
};
|
||||
|
||||
const legacyEventType = await eventTypesRepositoryFixture.create(eventTypeInput, user.id);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/${legacyEventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const fetchedEventType = responseBody.data;
|
||||
|
||||
// Verify the integration location is returned correctly
|
||||
expect(fetchedEventType.locations).toHaveLength(1);
|
||||
expect(fetchedEventType.locations[0]).toMatchObject({
|
||||
type: "integration",
|
||||
integration: api,
|
||||
});
|
||||
|
||||
// Clean up
|
||||
await eventTypesRepositoryFixture.delete(legacyEventType.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject with 400 if creating event type with unsupported integration location", async () => {
|
||||
const createPayload = {
|
||||
title: "Event with unsupported integration",
|
||||
slug: "event-with-unsupported-integration",
|
||||
lengthInMinutes: 30,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "unsupported-integration",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(createPayload);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
`Validation failed for integration location: integration must be one of the following values: ${supportedIntegrations.join(
|
||||
", "
|
||||
)}`
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject with 400 if patching event type with integration that user has not connected", async () => {
|
||||
const createPayload = {
|
||||
title: "Event for patch test",
|
||||
slug: "event-patch-test-unconnected-integration",
|
||||
lengthInMinutes: 30,
|
||||
};
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(createPayload)
|
||||
.expect(201);
|
||||
|
||||
const createdEventType = createResponse.body.data;
|
||||
|
||||
const patchPayload = {
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "jitsi",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const patchResponse = await request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${createdEventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(patchPayload);
|
||||
|
||||
expect(patchResponse.status).toBe(400);
|
||||
expect(patchResponse.body.error.message).toBe("jitsi not connected.");
|
||||
|
||||
// Cleanup
|
||||
await eventTypesRepositoryFixture.delete(createdEventType.id);
|
||||
});
|
||||
|
||||
describe("EventType Hidden Property", () => {
|
||||
let createdEventTypeId: number;
|
||||
|
||||
|
||||
+2
@@ -155,6 +155,8 @@ export class EventTypesController_2024_06_14 {
|
||||
summary: "Get all event types",
|
||||
description: `Hidden event types are returned only if authentication is provided and it belongs to the event type owner.
|
||||
|
||||
Use the optional \`sortCreatedAt\` query parameter to order results by creation date (by ID). Accepts "asc" (oldest first) or "desc" (newest first). When not provided, no explicit ordering is applied.
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import type { SortOrderType } from "@calcom/platform-types";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
@@ -67,21 +68,23 @@ export class EventTypesRepository_2024_06_14 {
|
||||
});
|
||||
}
|
||||
|
||||
async getUserEventTypes(userId: number) {
|
||||
async getUserEventTypes(userId: number, sortCreatedAt?: SortOrderType) {
|
||||
return this.dbRead.prisma.eventType.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
...(sortCreatedAt && { orderBy: { id: sortCreatedAt } }),
|
||||
include: { users: true, schedule: true, destinationCalendar: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getUserEventTypesPublic(userId: number) {
|
||||
async getUserEventTypesPublic(userId: number, sortCreatedAt?: SortOrderType) {
|
||||
return this.dbRead.prisma.eventType.findMany({
|
||||
where: {
|
||||
userId,
|
||||
hidden: false,
|
||||
},
|
||||
...(sortCreatedAt && { orderBy: { id: sortCreatedAt } }),
|
||||
include: { users: true, schedule: true, destinationCalendar: true },
|
||||
});
|
||||
}
|
||||
|
||||
+11
-9
@@ -22,7 +22,7 @@ import {
|
||||
getEventTypesPublic,
|
||||
EventTypesPublic,
|
||||
} from "@calcom/platform-libraries/event-types";
|
||||
import type { GetEventTypesQuery_2024_06_14 } from "@calcom/platform-types";
|
||||
import type { GetEventTypesQuery_2024_06_14, SortOrderType } from "@calcom/platform-types";
|
||||
import type { EventType } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
@@ -160,15 +160,16 @@ export class EventTypesService_2024_06_14 {
|
||||
orgSlug?: string;
|
||||
orgId?: number;
|
||||
authUser?: AuthOptionalUser;
|
||||
sortCreatedAt?: SortOrderType;
|
||||
}) {
|
||||
const user = await this.usersRepository.findByUsername(params.username, params.orgSlug, params.orgId);
|
||||
if (!user) {
|
||||
return [];
|
||||
}
|
||||
if (params.authUser?.id !== user.id) {
|
||||
return await this.getUserEventTypesPublic(user.id);
|
||||
return await this.getUserEventTypesPublic(user.id, params.sortCreatedAt);
|
||||
}
|
||||
return await this.getUserEventTypes(user.id);
|
||||
return await this.getUserEventTypes(user.id, params.sortCreatedAt);
|
||||
}
|
||||
|
||||
async getUserToCreateEvent(user: UserWithProfile) {
|
||||
@@ -211,16 +212,16 @@ export class EventTypesService_2024_06_14 {
|
||||
};
|
||||
}
|
||||
|
||||
async getUserEventTypes(userId: number) {
|
||||
const eventTypes = await this.eventTypesRepository.getUserEventTypes(userId);
|
||||
async getUserEventTypes(userId: number, sortCreatedAt?: SortOrderType) {
|
||||
const eventTypes = await this.eventTypesRepository.getUserEventTypes(userId, sortCreatedAt);
|
||||
|
||||
return eventTypes.map((eventType) => {
|
||||
return { ownerId: userId, ...eventType };
|
||||
});
|
||||
}
|
||||
|
||||
async getUserEventTypesPublic(userId: number) {
|
||||
const eventTypes = await this.eventTypesRepository.getUserEventTypesPublic(userId);
|
||||
async getUserEventTypesPublic(userId: number, sortCreatedAt?: SortOrderType) {
|
||||
const eventTypes = await this.eventTypesRepository.getUserEventTypesPublic(userId, sortCreatedAt);
|
||||
|
||||
return eventTypes.map((eventType) => {
|
||||
return { ownerId: userId, ...eventType };
|
||||
@@ -237,7 +238,7 @@ export class EventTypesService_2024_06_14 {
|
||||
}
|
||||
|
||||
async getEventTypes(queryParams: GetEventTypesQuery_2024_06_14, authUser?: AuthOptionalUser) {
|
||||
const { username, eventSlug, usernames, orgSlug, orgId } = queryParams;
|
||||
const { username, eventSlug, usernames, orgSlug, orgId, sortCreatedAt } = queryParams;
|
||||
if (username && eventSlug) {
|
||||
const eventType = await this.getEventTypeByUsernameAndSlug({
|
||||
username,
|
||||
@@ -255,6 +256,7 @@ export class EventTypesService_2024_06_14 {
|
||||
orgSlug,
|
||||
orgId,
|
||||
authUser,
|
||||
sortCreatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -264,7 +266,7 @@ export class EventTypesService_2024_06_14 {
|
||||
}
|
||||
|
||||
if (authUser?.id) {
|
||||
return await this.getUserEventTypes(authUser.id);
|
||||
return await this.getUserEventTypes(authUser.id, sortCreatedAt);
|
||||
}
|
||||
|
||||
return [];
|
||||
|
||||
+31
-4
@@ -39,6 +39,7 @@ import {
|
||||
InputBookingField_2024_06_14,
|
||||
OutputUnknownLocation_2024_06_14,
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
supportedIntegrations,
|
||||
} from "@calcom/platform-types";
|
||||
import { BookerLayouts } from "@calcom/prisma/zod-utils";
|
||||
|
||||
@@ -542,14 +543,40 @@ export class InputEventTypesService_2024_06_14 {
|
||||
}
|
||||
|
||||
async checkAppIsValidAndConnected(user: UserWithProfile, appSlug: string) {
|
||||
const conferencingApps = ["google-meet", "office365-video", "zoom"];
|
||||
const conferencingApps = supportedIntegrations as readonly string[];
|
||||
if (!conferencingApps.includes(appSlug)) {
|
||||
throw new BadRequestException("Invalid app, available apps are: ", conferencingApps.join(", "));
|
||||
}
|
||||
|
||||
if (appSlug === "office365-video") {
|
||||
appSlug = "msteams";
|
||||
}
|
||||
// Map API integration names to actual app slugs
|
||||
const slugMap: Record<string, string> = {
|
||||
"office365-video": "msteams",
|
||||
"facetime-video": "facetime",
|
||||
"whereby-video": "whereby",
|
||||
"whatsapp-video": "whatsapp",
|
||||
"webex-video": "webex",
|
||||
"telegram-video": "telegram",
|
||||
"sylaps-video": "sylapsvideo",
|
||||
"skype-video": "skype",
|
||||
"sirius-video": "sirius_video",
|
||||
"signal-video": "signal",
|
||||
"shimmer-video": "shimmervideo",
|
||||
"salesroom-video": "salesroom",
|
||||
"roam-video": "roam",
|
||||
"riverside-video": "riverside",
|
||||
"ping-video": "ping",
|
||||
"mirotalk-video": "mirotalk",
|
||||
"jelly-video": "jelly",
|
||||
"jelly-conferencing": "jelly",
|
||||
"huddle": "huddle01",
|
||||
"element-call-video": "element-call",
|
||||
"eightxeight-video": "eightxeight",
|
||||
"discord-video": "discord",
|
||||
"demodesk-video": "demodesk",
|
||||
"campfire-video": "campfire",
|
||||
};
|
||||
|
||||
appSlug = slugMap[appSlug] || appSlug;
|
||||
|
||||
const credentials = await getUsersCredentialsIncludeServiceAccountKey(user);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.rep
|
||||
import { PrismaHostRepository } from "@/lib/repositories/prisma-host.repository";
|
||||
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||
import { BookingAuditProducerService } from "@/lib/services/booking-audit-producer.service";
|
||||
import { BookingEmailSmsService } from "@/lib/services/booking-emails-sms-service";
|
||||
import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service";
|
||||
import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service";
|
||||
@@ -16,6 +17,7 @@ import { BookingEmailAndSmsSyncTaskerService } from "@/lib/services/tasker/booki
|
||||
import { BookingEmailAndSmsTaskService } from "@/lib/services/tasker/booking-emails-sms-task.service";
|
||||
import { BookingEmailAndSmsTasker } from "@/lib/services/tasker/booking-emails-sms-tasker.service";
|
||||
import { BookingEmailAndSmsTriggerTaskerService } from "@/lib/services/tasker/booking-emails-sms-trigger-tasker.service";
|
||||
import { TaskerService } from "@/lib/services/tasker.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { Module, Scope } from "@nestjs/common";
|
||||
|
||||
@@ -35,6 +37,7 @@ import { Module, Scope } from "@nestjs/common";
|
||||
},
|
||||
scope: Scope.TRANSIENT,
|
||||
},
|
||||
BookingAuditProducerService,
|
||||
BookingEventHandlerService,
|
||||
CheckBookingAndDurationLimitsService,
|
||||
CheckBookingLimitsService,
|
||||
@@ -45,6 +48,7 @@ import { Module, Scope } from "@nestjs/common";
|
||||
BookingEmailAndSmsSyncTaskerService,
|
||||
BookingEmailAndSmsTriggerTaskerService,
|
||||
BookingEmailAndSmsTasker,
|
||||
TaskerService,
|
||||
RegularBookingService,
|
||||
],
|
||||
exports: [RegularBookingService],
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { TaskerService } from "@/lib/services/tasker.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { BookingAuditTaskerProducerService } from "@calcom/platform-libraries/bookings";
|
||||
|
||||
@Injectable()
|
||||
export class BookingAuditProducerService extends BookingAuditTaskerProducerService {
|
||||
constructor(taskerService: TaskerService) {
|
||||
super({
|
||||
tasker: taskerService.getTasker(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,20 @@ import { BookingEventHandlerService as BaseBookingEventHandlerService } from "@c
|
||||
|
||||
import { Logger } from "@/lib/logger.bridge";
|
||||
|
||||
import { BookingAuditProducerService } from "./booking-audit-producer.service";
|
||||
import { HashedLinkService } from "./hashed-link.service";
|
||||
|
||||
@Injectable()
|
||||
export class BookingEventHandlerService extends BaseBookingEventHandlerService {
|
||||
constructor(hashedLinkService: HashedLinkService, bridgeLogger: Logger) {
|
||||
constructor(
|
||||
hashedLinkService: HashedLinkService,
|
||||
bridgeLogger: Logger,
|
||||
bookingAuditProducerService: BookingAuditProducerService
|
||||
) {
|
||||
super({
|
||||
log: bridgeLogger,
|
||||
hashedLinkService,
|
||||
bookingAuditProducerService,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { getTasker, type Tasker } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class TaskerService {
|
||||
private readonly tasker: Tasker;
|
||||
|
||||
constructor() {
|
||||
this.tasker = getTasker();
|
||||
}
|
||||
|
||||
getTasker(): Tasker {
|
||||
return this.tasker;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-8
@@ -47,8 +47,8 @@ import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { handleCreatePhoneCall } from "@calcom/platform-libraries";
|
||||
import {
|
||||
CreateTeamEventTypeInput_2024_06_14,
|
||||
GetOrganizationEventTypesQuery_2024_06_14,
|
||||
GetTeamEventTypesQuery_2024_06_14,
|
||||
SkipTakePagination,
|
||||
TeamEventTypeOutput_2024_06_14,
|
||||
UpdateTeamEventTypeInput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
@@ -159,12 +159,16 @@ export class OrganizationsEventTypesController {
|
||||
|
||||
@UseGuards(IsOrgGuard, IsTeamInOrg, IsAdminAPIEnabledGuard)
|
||||
@Get("/teams/:teamId/event-types")
|
||||
@ApiOperation({ summary: "Get team event types" })
|
||||
@ApiOperation({
|
||||
summary: "Get team event types",
|
||||
description:
|
||||
'Use the optional `sortCreatedAt` query parameter to order results by creation date (by ID). Accepts "asc" (oldest first) or "desc" (newest first). When not provided, no explicit ordering is applied.',
|
||||
})
|
||||
async getTeamEventTypes(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() queryParams: GetTeamEventTypesQuery_2024_06_14
|
||||
): Promise<GetTeamEventTypesOutput> {
|
||||
const { eventSlug, hostsLimit } = queryParams;
|
||||
const { eventSlug, hostsLimit, sortCreatedAt } = queryParams;
|
||||
|
||||
if (eventSlug) {
|
||||
const eventType = await this.organizationsEventTypesService.getTeamEventTypeBySlug(
|
||||
@@ -179,7 +183,7 @@ export class OrganizationsEventTypesController {
|
||||
};
|
||||
}
|
||||
|
||||
const eventTypes = await this.organizationsEventTypesService.getTeamEventTypes(teamId);
|
||||
const eventTypes = await this.organizationsEventTypesService.getTeamEventTypes(teamId, sortCreatedAt);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
@@ -191,16 +195,21 @@ export class OrganizationsEventTypesController {
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/teams/event-types")
|
||||
@ApiOperation({ summary: "Get all team event types" })
|
||||
@ApiOperation({
|
||||
summary: "Get all team event types",
|
||||
description:
|
||||
'Use the optional `sortCreatedAt` query parameter to order results by creation date (by ID). Accepts "asc" (oldest first) or "desc" (newest first). When not provided, no explicit ordering is applied.',
|
||||
})
|
||||
async getTeamsEventTypes(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
@Query() queryParams: GetOrganizationEventTypesQuery_2024_06_14
|
||||
): Promise<GetTeamEventTypesOutput> {
|
||||
const { skip, take } = queryParams;
|
||||
const { skip, take, sortCreatedAt } = queryParams;
|
||||
const eventTypes = await this.organizationsEventTypesService.getOrganizationsTeamsEventTypes(
|
||||
orgId,
|
||||
skip,
|
||||
take
|
||||
take,
|
||||
sortCreatedAt
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
+9
-1
@@ -1,16 +1,24 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import type { SortOrderType } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsEventTypesRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService) {}
|
||||
async getOrganizationTeamsEventTypes(orgId: number, skip: number, take: number) {
|
||||
async getOrganizationTeamsEventTypes(
|
||||
orgId: number,
|
||||
skip: number,
|
||||
take: number,
|
||||
sortCreatedAt?: SortOrderType
|
||||
) {
|
||||
return this.dbRead.prisma.eventType.findMany({
|
||||
where: {
|
||||
team: {
|
||||
parentId: orgId,
|
||||
},
|
||||
},
|
||||
...(sortCreatedAt && { orderBy: { id: sortCreatedAt } }),
|
||||
skip,
|
||||
take,
|
||||
include: { users: true, schedule: true, hosts: true, destinationCalendar: true },
|
||||
|
||||
+12
-4
@@ -1,4 +1,6 @@
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
|
||||
import type { SortOrderType } from "@calcom/platform-types";
|
||||
import { OrganizationsEventTypesRepository } from "@/modules/organizations/event-types/organizations-event-types.repository";
|
||||
import {
|
||||
TransformedCreateTeamEventTypeInput,
|
||||
@@ -83,16 +85,22 @@ export class OrganizationsEventTypesService {
|
||||
return this.teamsEventTypesService.getTeamEventTypeBySlug(teamId, eventTypeSlug, hostsLimit);
|
||||
}
|
||||
|
||||
async getTeamEventTypes(teamId: number): Promise<DatabaseTeamEventType[]> {
|
||||
return await this.teamsEventTypesService.getTeamEventTypes(teamId);
|
||||
async getTeamEventTypes(teamId: number, sortCreatedAt?: SortOrderType): Promise<DatabaseTeamEventType[]> {
|
||||
return await this.teamsEventTypesService.getTeamEventTypes(teamId, sortCreatedAt);
|
||||
}
|
||||
|
||||
async getOrganizationsTeamsEventTypes(
|
||||
orgId: number,
|
||||
skip = 0,
|
||||
take = 250
|
||||
take = 250,
|
||||
sortCreatedAt?: SortOrderType
|
||||
): Promise<DatabaseTeamEventType[]> {
|
||||
return await this.organizationEventTypesRepository.getOrganizationTeamsEventTypes(orgId, skip, take);
|
||||
return await this.organizationEventTypesRepository.getOrganizationTeamsEventTypes(
|
||||
orgId,
|
||||
skip,
|
||||
take,
|
||||
sortCreatedAt
|
||||
);
|
||||
}
|
||||
|
||||
async updateOrganizationTeamEventType(
|
||||
|
||||
+7
-3
@@ -136,12 +136,16 @@ export class TeamsEventTypesController {
|
||||
}
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({ summary: "Get a team event type" })
|
||||
@ApiOperation({
|
||||
summary: "Get team event types",
|
||||
description:
|
||||
'Use the optional `sortCreatedAt` query parameter to order results by creation date (by ID). Accepts "asc" (oldest first) or "desc" (newest first). When not provided, no explicit ordering is applied.',
|
||||
})
|
||||
async getTeamEventTypes(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() queryParams: GetTeamEventTypesQuery_2024_06_14
|
||||
): Promise<GetTeamEventTypesOutput> {
|
||||
const { eventSlug, hostsLimit } = queryParams;
|
||||
const { eventSlug, hostsLimit, sortCreatedAt } = queryParams;
|
||||
|
||||
if (eventSlug) {
|
||||
const eventType = await this.teamsEventTypesService.getTeamEventTypeBySlug(
|
||||
@@ -156,7 +160,7 @@ export class TeamsEventTypesController {
|
||||
};
|
||||
}
|
||||
|
||||
const eventTypes = await this.teamsEventTypesService.getTeamEventTypes(teamId);
|
||||
const eventTypes = await this.teamsEventTypesService.getTeamEventTypes(teamId, sortCreatedAt);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
|
||||
@@ -11,6 +11,8 @@ import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, NotFoundException, Logger } from "@nestjs/common";
|
||||
|
||||
import type { SortOrderType } from "@calcom/platform-types";
|
||||
|
||||
import { createEventType, updateEventType } from "@calcom/platform-libraries/event-types";
|
||||
|
||||
@Injectable()
|
||||
@@ -101,8 +103,8 @@ export class TeamsEventTypesService {
|
||||
return eventType;
|
||||
}
|
||||
|
||||
async getTeamEventTypes(teamId: number): Promise<DatabaseTeamEventType[]> {
|
||||
return await this.teamsEventTypesRepository.getTeamEventTypes(teamId);
|
||||
async getTeamEventTypes(teamId: number, sortCreatedAt?: SortOrderType): Promise<DatabaseTeamEventType[]> {
|
||||
return await this.teamsEventTypesRepository.getTeamEventTypes(teamId, sortCreatedAt);
|
||||
}
|
||||
|
||||
async updateTeamEventType(
|
||||
|
||||
@@ -2,6 +2,8 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import type { SortOrderType } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class TeamsEventTypesRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
@@ -79,11 +81,12 @@ export class TeamsEventTypesRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async getTeamEventTypes(teamId: number) {
|
||||
async getTeamEventTypes(teamId: number, sortCreatedAt?: SortOrderType) {
|
||||
return this.dbRead.prisma.eventType.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
...(sortCreatedAt && { orderBy: { id: sortCreatedAt } }),
|
||||
include: {
|
||||
users: true,
|
||||
schedule: true,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Added as a separate route for now to ease the testing of the audit logs feature
|
||||
// It partially matches the figma design - https://www.figma.com/design/wleA2SR6rn60EK7ORxAfMy/Cal.com-New-Features?node-id=5641-6732&p=f
|
||||
// TOOD: Move it to the booking page side bar later
|
||||
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import BookingLogsView from "~/booking/logs/views/booking-logs-view";
|
||||
|
||||
export const generateMetadata = async ({ params }: { params: Promise<{ uid: string }> }) =>
|
||||
await _generateMetadata(
|
||||
(t) => t("booking_history"),
|
||||
(t) => t("booking_history_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
`/booking/${(await params).uid}/logs`
|
||||
);
|
||||
|
||||
const Page = async ({ params }: PageProps) => {
|
||||
const resolvedParams = await params;
|
||||
const bookingUid = resolvedParams.uid;
|
||||
|
||||
if (!bookingUid || typeof bookingUid !== "string") {
|
||||
redirect("/bookings/upcoming");
|
||||
}
|
||||
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<ShellMainAppDir heading={t("booking_history")} subtitle={t("booking_history_description")}>
|
||||
<BookingLogsView bookingUid={bookingUid} />
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
@@ -19,7 +19,7 @@ export const generateMetadata = async () => {
|
||||
);
|
||||
};
|
||||
|
||||
const ServerPage = async () => {
|
||||
const ServerPage = async (props: { searchParams: Promise<{ fromTeamOnboarding?: string }> }) => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
@@ -28,8 +28,10 @@ const ServerPage = async () => {
|
||||
|
||||
const userEmail = session.user.email || "";
|
||||
const userName = session.user.name || "";
|
||||
const searchParams = await props.searchParams;
|
||||
const fromTeamOnboarding = searchParams?.fromTeamOnboarding === "true";
|
||||
|
||||
return <PersonalSettingsView userEmail={userEmail} userName={userName} />;
|
||||
return <PersonalSettingsView userEmail={userEmail} userName={userName} fromTeamOnboarding={fromTeamOnboarding} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
@@ -29,7 +29,7 @@ async function handler(req: NextRequest) {
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: piiHasher.hash(ip),
|
||||
identifier: `forgotPassword:${piiHasher.hash(ip)}`,
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -43,6 +43,7 @@ async function getHandler(req: NextRequest) {
|
||||
|
||||
// If onboarding-v3 is enabled AND organizationOnboardingId exists, redirect to onboarding flow
|
||||
if (isOnboardingV3Enabled && organizationOnboardingId) {
|
||||
params.append("fromTeamOnboarding", "true");
|
||||
const redirectUrl = new URL(
|
||||
`/onboarding/personal/settings?${params.toString()}`,
|
||||
WEBAPP_URL
|
||||
|
||||
@@ -64,7 +64,8 @@ async function postHandler(request: NextRequest) {
|
||||
} catch (error) {
|
||||
const bookingUid = queryParams.bookingUid || "";
|
||||
return NextResponse.redirect(
|
||||
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`
|
||||
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`,
|
||||
{ status: 303 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -84,7 +85,8 @@ async function handleBookingAction(
|
||||
|
||||
if (!booking) {
|
||||
return NextResponse.redirect(
|
||||
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`
|
||||
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`,
|
||||
{ status: 303 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,7 +142,10 @@ async function handleBookingAction(
|
||||
} catch (e) {
|
||||
let message = "Error confirming booking";
|
||||
if (e instanceof TRPCError) message = (e as TRPCError).message;
|
||||
return NextResponse.redirect(`${url.origin}/booking/${booking.uid}?error=${encodeURIComponent(message)}`);
|
||||
return NextResponse.redirect(
|
||||
`${url.origin}/booking/${booking.uid}?error=${encodeURIComponent(message)}`,
|
||||
{ status: 303 }
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.booking.update({
|
||||
@@ -148,7 +153,7 @@ async function handleBookingAction(
|
||||
data: { oneTimePassword: null },
|
||||
});
|
||||
|
||||
return NextResponse.redirect(`${url.origin}/booking/${booking.uid}`);
|
||||
return NextResponse.redirect(`${url.origin}/booking/${booking.uid}`, { status: 303 });
|
||||
}
|
||||
|
||||
export const GET = defaultResponderForAppDir(getHandler);
|
||||
|
||||
@@ -184,8 +184,7 @@ export const AppPage = ({
|
||||
enabled: !!dependencies,
|
||||
});
|
||||
|
||||
const disableInstall =
|
||||
dependencyData.data && dependencyData.data.some((dependency) => !dependency.installed);
|
||||
const disableInstall = dependencyData.data ? dependencyData.data.some((dependency) => !dependency.installed) : false;
|
||||
|
||||
// const disableInstall = requiresGCal && !gCalInstalled.data;
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { InstallAppButtonChild } from "./InstallAppButtonChild";
|
||||
|
||||
// Mock credential type
|
||||
type MockCredential = {
|
||||
id: number;
|
||||
delegatedToId: string;
|
||||
userId: number;
|
||||
user: { email: string };
|
||||
key: { access_token: string };
|
||||
invalid: boolean;
|
||||
teamId: null;
|
||||
team: null;
|
||||
delegationCredentialId: string;
|
||||
type:
|
||||
| `${string}_calendar`
|
||||
| `${string}_messaging`
|
||||
| `${string}_payment`
|
||||
| `${string}_video`
|
||||
| `${string}_other`
|
||||
| `${string}_automation`
|
||||
| `${string}_analytics`
|
||||
| `${string}_crm`
|
||||
| `${string}_other_calendar`;
|
||||
appId: string;
|
||||
};
|
||||
|
||||
// Factory function to create mock credentials
|
||||
const createMockCredential = (overrides: Partial<MockCredential> = {}): MockCredential => ({
|
||||
id: 1,
|
||||
type: "google_calendar" as const,
|
||||
userId: 1,
|
||||
delegatedToId: "delegation-123",
|
||||
teamId: null,
|
||||
team: null,
|
||||
user: { email: "test@example.com" },
|
||||
key: { access_token: "mock_token_123" },
|
||||
delegationCredentialId: "delegation-123",
|
||||
appId: "google-calendar",
|
||||
invalid: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Mock the useLocale hook
|
||||
vi.mock("@calcom/lib/hooks/useLocale", () => ({
|
||||
useLocale: () => ({
|
||||
t: (key: string) => {
|
||||
const translations = {
|
||||
install_app: "Install App",
|
||||
install_another: "Install Another",
|
||||
start_paid_trial: "Start Free Trial",
|
||||
subscribe: "Subscribe",
|
||||
};
|
||||
return translations[key as keyof typeof translations] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("InstallAppButtonChild", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("is disabled when credentials exist and multiInstall is false", () => {
|
||||
const mockCredentials = [createMockCredential()];
|
||||
render(<InstallAppButtonChild multiInstall={false} credentials={mockCredentials} />);
|
||||
|
||||
const button = screen.getByTestId("install-app-button");
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it("is enabled when credentials exist and multiInstall is true", () => {
|
||||
const mockCredentials = [createMockCredential()];
|
||||
render(<InstallAppButtonChild multiInstall={true} credentials={mockCredentials} />);
|
||||
|
||||
const button = screen.getByTestId("install-app-button");
|
||||
expect(button).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("is disabled when disabled prop is true regardless of credentials", () => {
|
||||
render(<InstallAppButtonChild disabled={true} multiInstall={true} credentials={[]} />);
|
||||
|
||||
const button = screen.getByTestId("install-app-button");
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it("combines disabled prop with credential logic correctly", () => {
|
||||
const mockCredentials = [createMockCredential()];
|
||||
render(<InstallAppButtonChild disabled={true} multiInstall={false} credentials={mockCredentials} />);
|
||||
|
||||
const button = screen.getByTestId("install-app-button");
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it("is enabled when no blocking conditions exist", () => {
|
||||
render(<InstallAppButtonChild multiInstall={false} credentials={[]} disabled={false} />);
|
||||
|
||||
const button = screen.getByTestId("install-app-button");
|
||||
expect(button).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ export const InstallAppButtonChild = ({
|
||||
const { t } = useLocale();
|
||||
|
||||
const shouldDisableInstallation = !multiInstall ? !!(credentials && credentials.length) : false;
|
||||
const isDisabled = shouldDisableInstallation || props.disabled;
|
||||
|
||||
// Paid apps don't support team installs at the moment
|
||||
// Also, cal.ai(the only paid app at the moment) doesn't support team install either
|
||||
@@ -25,7 +26,7 @@ export const InstallAppButtonChild = ({
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
{...props}
|
||||
disabled={shouldDisableInstallation}
|
||||
disabled={isDisabled}
|
||||
color="primary"
|
||||
size="base">
|
||||
{paid.trial ? t("start_paid_trial") : t("subscribe")}
|
||||
@@ -37,7 +38,7 @@ export const InstallAppButtonChild = ({
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
{...props}
|
||||
disabled={shouldDisableInstallation}
|
||||
disabled={isDisabled}
|
||||
color="primary"
|
||||
size="base">
|
||||
{multiInstall ? t("install_another") : t("install_app")}
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* TODO: Move it to features/booking-audit
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
import { FilterSearchField, Select } from "@calcom/ui/components/form";
|
||||
|
||||
interface BookingLogsViewProps {
|
||||
bookingUid: string;
|
||||
}
|
||||
|
||||
type AuditLog = {
|
||||
id: string;
|
||||
action: string;
|
||||
type: string;
|
||||
timestamp: string;
|
||||
data: Record<string, unknown> | null;
|
||||
actor: {
|
||||
type: string;
|
||||
displayName: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
interface BookingLogsFiltersProps {
|
||||
searchTerm: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
typeFilter: string | null;
|
||||
onTypeFilterChange: (value: string | null) => void;
|
||||
actorFilter: string | null;
|
||||
onActorFilterChange: (value: string | null) => void;
|
||||
typeOptions: Array<{ label: string; value: string }>;
|
||||
actorOptions: Array<{ label: string; value: string }>;
|
||||
}
|
||||
|
||||
interface BookingLogsTimelineProps {
|
||||
logs: AuditLog[];
|
||||
}
|
||||
|
||||
const getActionIcon = (action: string) => {
|
||||
switch (action) {
|
||||
case "CREATED":
|
||||
return "calendar";
|
||||
case "CANCELLED":
|
||||
case "REJECTED":
|
||||
return "ban";
|
||||
case "ACCEPTED":
|
||||
return "check";
|
||||
case "RESCHEDULED":
|
||||
case "RESCHEDULE_REQUESTED":
|
||||
return "pencil";
|
||||
case "REASSIGNMENT":
|
||||
case "ATTENDEE_ADDED":
|
||||
case "ATTENDEE_REMOVED":
|
||||
return "user-check";
|
||||
case "LOCATION_CHANGED":
|
||||
return "map-pin";
|
||||
case "HOST_NO_SHOW_UPDATED":
|
||||
case "ATTENDEE_NO_SHOW_UPDATED":
|
||||
return "ban";
|
||||
default:
|
||||
return "sparkles";
|
||||
}
|
||||
};
|
||||
|
||||
function BookingLogsFilters({
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
typeFilter,
|
||||
onTypeFilterChange,
|
||||
actorFilter,
|
||||
onActorFilterChange,
|
||||
typeOptions,
|
||||
actorOptions,
|
||||
}: BookingLogsFiltersProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 items-start">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<FilterSearchField
|
||||
size="sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
containerClassName=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-[140px]">
|
||||
<Select
|
||||
size="sm"
|
||||
value={typeFilter ? { label: `${t("type")}: ${t(`audit_action.${typeFilter.toLowerCase()}`)}`, value: typeFilter } : { label: `${t("type")}: ${t("all")}`, value: "" }}
|
||||
onChange={(option) => {
|
||||
if (!option) return;
|
||||
onTypeFilterChange(option.value || null);
|
||||
}}
|
||||
options={[{ label: `${t("type")}: ${t("all")}`, value: "" }, ...typeOptions.map(opt => ({ ...opt, label: `${t("type")}: ${opt.label}` }))]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-[140px]">
|
||||
<Select
|
||||
size="sm"
|
||||
value={actorFilter ? { label: `${t("actor")}: ${actorFilter}`, value: actorFilter } : { label: `${t("actor")}: ${t("all")}`, value: "" }}
|
||||
onChange={(option) => {
|
||||
if (!option) return;
|
||||
onActorFilterChange(option.value || null);
|
||||
}}
|
||||
options={[{ label: `${t("actor")}: ${t("all")}`, value: "" }, ...actorOptions.map(opt => ({ ...opt, label: `${t("actor")}: ${opt.label}` }))]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
|
||||
const { t } = useLocale();
|
||||
const [expandedLogIds, setExpandedLogIds] = useState<Set<string>>(new Set());
|
||||
const [showJsonMap, setShowJsonMap] = useState<Record<string, boolean>>({});
|
||||
|
||||
const toggleExpand = (logId: string) => {
|
||||
setExpandedLogIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(logId)) {
|
||||
newSet.delete(logId);
|
||||
} else {
|
||||
newSet.add(logId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleJson = (logId: string) => {
|
||||
setShowJsonMap((prev) => ({
|
||||
...prev,
|
||||
[logId]: !prev[logId],
|
||||
}));
|
||||
};
|
||||
|
||||
if (logs.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted">{t("no_audit_logs_found")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{logs.map((log, index) => {
|
||||
const isLast = index === logs.length - 1;
|
||||
const isExpanded = expandedLogIds.has(log.id);
|
||||
const actionDisplay = t(`audit_action.${log.action.toLowerCase()}`);
|
||||
const showJson = showJsonMap[log.id] || false;
|
||||
|
||||
return (
|
||||
<div key={log.id} className="flex gap-1">
|
||||
<div className="flex flex-col items-center self-stretch">
|
||||
<div className="pt-2 shrink-0">
|
||||
<div className="bg-subtle rounded-[3.556px] p-1 flex items-center justify-center w-4 h-4">
|
||||
<Icon name={getActionIcon(log.action)} className="h-3 w-3 text-subtle" />
|
||||
</div>
|
||||
</div>
|
||||
{!isLast && (
|
||||
<div className="w-px bg-subtle flex-1 min-h-0" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 rounded-lg py-2">
|
||||
<div className="px-3 mb-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-emphasis leading-4">
|
||||
{actionDisplay}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1 mt-1 text-xs text-subtle">
|
||||
<span>{log.actor.displayName}</span>
|
||||
<span>•</span>
|
||||
<span>{dayjs(log.timestamp).fromNow()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
variant="icon"
|
||||
StartIcon="ellipsis"
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3">
|
||||
<div className="bg-muted rounded-[10px] py-1">
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
onClick={() => toggleExpand(log.id)}
|
||||
StartIcon={isExpanded ? "chevron-down" : "chevron-right"}
|
||||
className="w-full justify-start text-xs font-medium text-subtle h-6">
|
||||
{isExpanded ? t("hide_details") : t("show_details")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 px-3">
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="font-medium text-emphasis min-w-[80px]">{t("type")}</span>
|
||||
<span className="text-default">{log.type}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="font-medium text-emphasis min-w-[80px]">{t("actor")}</span>
|
||||
<span className="text-default">{log.actor.type}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="font-medium text-emphasis min-w-[80px]">
|
||||
{t("timestamp")}
|
||||
</span>
|
||||
<span className="text-default">
|
||||
{dayjs(log.timestamp).format("YYYY-MM-DD HH:mm:ss")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{log.data && Object.keys(log.data).length > 0 && (
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
onClick={() => toggleJson(log.id)}
|
||||
StartIcon={showJson ? "chevron-down" : "chevron-right"}
|
||||
className="mb-1 h-6 px-0 font-medium">
|
||||
{t("json")}
|
||||
</Button>
|
||||
{showJson && (
|
||||
<pre className="bg-subtle p-3 rounded-md text-[10px] overflow-x-auto text-default font-mono border border-subtle">
|
||||
{JSON.stringify(log.data, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BookingLogsView({ bookingUid }: BookingLogsViewProps) {
|
||||
const router = useRouter();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<string | null>(null);
|
||||
const [actorFilter, setActorFilter] = useState<string | null>(null);
|
||||
const { t } = useLocale();
|
||||
const { data, isLoading, error } = trpc.viewer.bookings.getAuditLogs.useQuery({
|
||||
bookingUid,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<p className="text-red-600 font-medium">{t("error_loading_booking_logs")}</p>
|
||||
<p className="text-sm text-gray-500 mt-2">{error.message}</p>
|
||||
<Button className="mt-4" onClick={() => router.back()}>
|
||||
{t("go_back")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SkeletonText className="h-12 w-full" />
|
||||
<SkeletonText className="h-24 w-full" />
|
||||
<SkeletonText className="h-24 w-full" />
|
||||
<SkeletonText className="h-24 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const auditLogs = data?.auditLogs || [];
|
||||
|
||||
// Apply filters
|
||||
const filteredLogs = auditLogs.filter((log) => {
|
||||
const matchesSearch =
|
||||
!searchTerm ||
|
||||
log.action.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
log.actor.displayName?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesType = !typeFilter || log.action === typeFilter;
|
||||
const matchesActor = !actorFilter || log.actor.type === actorFilter;
|
||||
|
||||
return matchesSearch && matchesType && matchesActor;
|
||||
});
|
||||
|
||||
const uniqueTypes = Array.from(new Set(auditLogs.map((log) => log.action)));
|
||||
const uniqueActorTypes = Array.from(new Set(auditLogs.map((log) => log.actor.type)));
|
||||
|
||||
const typeOptions = uniqueTypes.map((type) => ({
|
||||
label: t(`audit_action.${type.toLowerCase()}`),
|
||||
value: type,
|
||||
}));
|
||||
|
||||
const actorOptions = uniqueActorTypes.map((actorType) => ({
|
||||
label: actorType,
|
||||
value: actorType,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<BookingLogsFilters
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
typeFilter={typeFilter}
|
||||
onTypeFilterChange={setTypeFilter}
|
||||
actorFilter={actorFilter}
|
||||
onActorFilterChange={setActorFilter}
|
||||
typeOptions={typeOptions}
|
||||
actorOptions={actorOptions}
|
||||
/>
|
||||
|
||||
<BookingLogsTimeline logs={filteredLogs} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -312,14 +312,12 @@ function CallHistoryContent({ org: _org }: CallHistoryProps) {
|
||||
</>
|
||||
}
|
||||
EmptyView={
|
||||
<div className="px-6 py-8">
|
||||
<EmptyScreen
|
||||
Icon="phone"
|
||||
headline={searchTerm ? t("no_result_found_for", { searchTerm }) : t("no_call_history")}
|
||||
description={t("no_call_history_description")}
|
||||
className="mb-16"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -105,9 +105,9 @@ export function useCreateTeam() {
|
||||
|
||||
// Redirect to personal settings after successful invite
|
||||
const gettingStartedPath = flags["onboarding-v3"]
|
||||
? "/onboarding/personal/settings"
|
||||
? "/onboarding/personal/settings?fromTeamOnboarding=true"
|
||||
: "/getting-started";
|
||||
router.push(gettingStartedPath);
|
||||
router.replace(gettingStartedPath);
|
||||
} catch (error) {
|
||||
console.error("Failed to invite members:", error);
|
||||
// Extract error message from TRPC error
|
||||
|
||||
@@ -81,6 +81,11 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
|
||||
router.push("/onboarding/organization/teams");
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
// Skip brand customization and go to teams
|
||||
router.push("/onboarding/organization/teams");
|
||||
};
|
||||
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={2} totalSteps={4}>
|
||||
{/* Left column - Main content */}
|
||||
@@ -95,9 +100,14 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
|
||||
onClick={() => router.push("/onboarding/organization/details")}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button color="minimal" className="rounded-[10px]" onClick={handleSkip}>
|
||||
{t("onboarding_skip_for_now")}
|
||||
</Button>
|
||||
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
{/* Form */}
|
||||
|
||||
@@ -37,9 +37,7 @@ export const InstallableAppCard = ({
|
||||
</span>
|
||||
)}
|
||||
{app.logo && <img src={app.logo} alt={app.name} className="h-9 w-9 rounded-md" />}
|
||||
<p
|
||||
className="text-default line-clamp-1 break-words text-left text-sm font-medium leading-none"
|
||||
title={app.name}>
|
||||
<p className="text-default line-clamp-1 break-words text-left text-sm font-medium" title={app.name}>
|
||||
{app.name}
|
||||
</p>
|
||||
<InstallAppButtonWithoutPlanCheck
|
||||
|
||||
@@ -26,9 +26,14 @@ import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
type PersonalSettingsViewProps = {
|
||||
userEmail: string;
|
||||
userName?: string;
|
||||
fromTeamOnboarding?: boolean;
|
||||
};
|
||||
|
||||
export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsViewProps) => {
|
||||
export const PersonalSettingsView = ({
|
||||
userEmail,
|
||||
userName,
|
||||
fromTeamOnboarding = false,
|
||||
}: PersonalSettingsViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { data: user } = trpc.viewer.me.get.useQuery();
|
||||
@@ -117,12 +122,14 @@ export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsVi
|
||||
subtitle={t("personal_details_subtitle")}
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-end gap-4">
|
||||
<Button
|
||||
color="minimal"
|
||||
className="rounded-[10px]"
|
||||
onClick={() => router.push("/onboarding/getting-started")}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
{!fromTeamOnboarding && (
|
||||
<Button
|
||||
color="minimal"
|
||||
className="rounded-[10px]"
|
||||
onClick={() => router.push("/onboarding/getting-started")}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form="personal-settings-form"
|
||||
|
||||
@@ -113,16 +113,16 @@ export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) =>
|
||||
}
|
||||
} else {
|
||||
// No invites, skip to personal settings
|
||||
const gettingStartedPath = "/onboarding/personal/settings";
|
||||
router.push(gettingStartedPath);
|
||||
const gettingStartedPath = "/onboarding/personal/settings?fromTeamOnboarding=true";
|
||||
router.replace(gettingStartedPath);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = async () => {
|
||||
setTeamInvites([]);
|
||||
// Skip inviting members and go to personal settings
|
||||
const gettingStartedPath = "/onboarding/personal/settings";
|
||||
router.push(gettingStartedPath);
|
||||
const gettingStartedPath = "/onboarding/personal/settings?fromTeamOnboarding=true";
|
||||
router.replace(gettingStartedPath);
|
||||
};
|
||||
|
||||
const hasValidInvites = fields.some((_, index) => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useRouter } from "next/navigation";
|
||||
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { PERMISSION_MAP } from "@calcom/platform-constants";
|
||||
import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants/permissions";
|
||||
@@ -18,10 +17,8 @@ import type { FormValues } from "@components/settings/platform/oauth-clients/oau
|
||||
import { OAuthClientForm } from "@components/settings/platform/oauth-clients/oauth-client-form";
|
||||
|
||||
export default function CreateOAuthClient() {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const clientId = searchParams?.get("clientId") || "";
|
||||
|
||||
const { isUserLoading, isPlatformUser, isPaidUser } = useGetUserAttributes();
|
||||
|
||||
@@ -77,24 +74,7 @@ export default function CreateOAuthClient() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<OAuthClientForm
|
||||
isPending={isSaving}
|
||||
onSubmit={onSubmit}
|
||||
defaultValues={{
|
||||
areCalendarEventsEnabled: true,
|
||||
areEmailsEnabled: true,
|
||||
eventTypeRead: true,
|
||||
eventTypeWrite: true,
|
||||
bookingRead: true,
|
||||
bookingWrite: true,
|
||||
scheduleRead: true,
|
||||
scheduleWrite: true,
|
||||
appsRead: true,
|
||||
appsWrite: true,
|
||||
profileRead: true,
|
||||
profileWrite: true,
|
||||
}}
|
||||
/>
|
||||
<OAuthClientForm isPending={isSaving} onSubmit={onSubmit} />
|
||||
</div>
|
||||
</Shell>
|
||||
</div>
|
||||
|
||||
@@ -115,7 +115,7 @@ export function UserPage(props: PageProps) {
|
||||
eventType: type,
|
||||
});
|
||||
}}
|
||||
className="bg-default border-subtle dark:bg-cal-muted dark:hover:bg-emphasis hover:bg-cal-muted group relative border-b transition first:rounded-t-md last:rounded-b-md last:border-b-0"
|
||||
className="bg-default border-subtle dark:bg-cal-muted dark:hover:bg-subtle hover:bg-cal-muted group relative border-b transition first:rounded-t-md last:rounded-b-md last:border-b-0"
|
||||
data-testid="event-type-link">
|
||||
<Icon
|
||||
name="arrow-right"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.9.9",
|
||||
"version": "5.9.12",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -124,11 +124,11 @@
|
||||
"posthog-node": "^5.9.5",
|
||||
"qrcode": "^1.5.1",
|
||||
"raw-body": "^2.5.1",
|
||||
"react": "^18.2.0",
|
||||
"react": "18.2.0",
|
||||
"react-colorful": "^5.6.0",
|
||||
"react-date-picker": "^8.3.6",
|
||||
"react-digit-input": "^2.1.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-easy-crop": "^3.5.2",
|
||||
"react-hook-form": "^7.43.3",
|
||||
"react-live-chat-loader": "^2.8.1",
|
||||
|
||||
@@ -36,7 +36,7 @@ async function handler(req: NextApiRequest & { userId?: number; traceContext: Tr
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: piiHasher.hash(userIp),
|
||||
identifier: `createBooking:${piiHasher.hash(userIp)}`,
|
||||
});
|
||||
|
||||
const session = await getServerSession({ req });
|
||||
|
||||
@@ -38,7 +38,7 @@ async function handler(req: NextApiRequest & RequestMeta) {
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: piiHasher.hash(userIp),
|
||||
identifier: `createRecurringBooking:${piiHasher.hash(userIp)}`,
|
||||
});
|
||||
const session = await getServerSession({ req });
|
||||
/* To mimic API behavior and comply with types */
|
||||
|
||||
@@ -68,7 +68,7 @@ test.describe("Booking Confirmation and Rejection via API", () => {
|
||||
maxRedirects: 0,
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(307);
|
||||
expect(response.status()).toBe(303);
|
||||
const location = response.headers()["location"];
|
||||
expect(location).toContain(`/booking/${booking.uid}`);
|
||||
|
||||
@@ -138,7 +138,7 @@ test.describe("Booking Confirmation and Rejection via API", () => {
|
||||
maxRedirects: 0,
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(307);
|
||||
expect(response.status()).toBe(303);
|
||||
const location = response.headers()["location"];
|
||||
expect(location).toContain(`/booking/${booking.uid}`);
|
||||
|
||||
|
||||
@@ -3261,7 +3261,11 @@
|
||||
"filter_operator_is_empty": "Is empty",
|
||||
"filter_operator_not_empty": "Not empty",
|
||||
"translate_description_button": "Auto translate title and description",
|
||||
"translate_description_button_description": "Automatically translate titles and descriptions to the visitor’s browser language using AI.",
|
||||
"translate_description_button_description": "Automatically translate titles and descriptions to the visitor's browser language using AI.",
|
||||
"auto_translate_booking_title": "Auto translate booking title",
|
||||
"auto_translate_booking_title_description": "Automatically translate the booking title to the visitor's browser language. Disable this to keep the original title.",
|
||||
"auto_translate_instant_meeting_title": "Auto translate instant meeting title",
|
||||
"auto_translate_instant_meeting_title_description": "Automatically translate the instant meeting booking title to the visitor's browser language. Disable this to keep the original English title.",
|
||||
"rr_distribution_method": "Distribution",
|
||||
"rr_distribution_method_description": "Allows for optimising distribution for maximum availability or to aim for a more balanced assignment.",
|
||||
"rr_distribution_method_availability_title": "Maximize availability",
|
||||
@@ -4093,5 +4097,18 @@
|
||||
"booking_response": "Booking Response",
|
||||
"list_view": "List view",
|
||||
"calendar_view": "Calendar view",
|
||||
"booking_history": "Booking History",
|
||||
"booking_history_description": "View the history of actions performed on this booking",
|
||||
"audit_action": {
|
||||
"created": "Created"
|
||||
},
|
||||
"error_loading_booking_logs": "Error loading booking logs",
|
||||
"no_audit_logs_found": "No audit logs found",
|
||||
"hide_details": "Hide details",
|
||||
"show_details": "Show details",
|
||||
"actor": "Actor",
|
||||
"timestamp": "Timestamp",
|
||||
"json": "JSON",
|
||||
"hubspot_ignore_guests": "Do not create new records for guests added to the booking",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
secondaryEmailId,
|
||||
autoTranslateDescriptionEnabled,
|
||||
autoTranslateInstantMeetingTitleEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
...evType
|
||||
@@ -148,8 +149,24 @@ describe("handleChildrenEventTypes", () => {
|
||||
// Setup transaction mock to execute the callback
|
||||
setupTransactionMock();
|
||||
|
||||
// Mock findMany to return empty array (no workflows to link)
|
||||
prismaMock.eventType.findMany.mockResolvedValue([]);
|
||||
// Mock createManyAndReturn to return the created event type (full EventType shape required for type safety)
|
||||
const createdEventType = {
|
||||
...evType,
|
||||
id: 123,
|
||||
userId: 4,
|
||||
schedulingType,
|
||||
teamId,
|
||||
timeZone,
|
||||
requiresBookerEmailVerification,
|
||||
lockTimeZoneToggleOnBookingPage,
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
secondaryEmailId,
|
||||
autoTranslateDescriptionEnabled,
|
||||
autoTranslateInstantMeetingTitleEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
};
|
||||
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
|
||||
|
||||
const result = await updateChildrenEventTypes({
|
||||
eventTypeId: 1,
|
||||
@@ -162,7 +179,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
updatedValues: {},
|
||||
});
|
||||
const { createdAt, updatedAt, ...expectedEvType } = evType;
|
||||
expect(prismaMock.eventType.createMany).toHaveBeenCalledWith({
|
||||
expect(prismaMock.eventType.createManyAndReturn).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{
|
||||
...expectedEvType,
|
||||
@@ -183,6 +200,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
},
|
||||
],
|
||||
skipDuplicates: true,
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
expect(result.newUserIds).toEqual([4]);
|
||||
expect(result.oldUserIds).toEqual([]);
|
||||
@@ -207,6 +225,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
secondaryEmailId,
|
||||
assignRRMembersUsingSegment,
|
||||
autoTranslateInstantMeetingTitleEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
...evType
|
||||
@@ -316,6 +335,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
secondaryEmailId,
|
||||
autoTranslateDescriptionEnabled,
|
||||
autoTranslateInstantMeetingTitleEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
@@ -329,8 +349,25 @@ describe("handleChildrenEventTypes", () => {
|
||||
// Setup transaction mock to execute the callback
|
||||
setupTransactionMock();
|
||||
|
||||
// Mock findMany to return empty array (no workflows to link)
|
||||
prismaMock.eventType.findMany.mockResolvedValue([]);
|
||||
// Mock createManyAndReturn to return the created event type (full EventType shape required for type safety)
|
||||
const createdEventType = {
|
||||
...evType,
|
||||
id: 123,
|
||||
userId: 4,
|
||||
schedulingType,
|
||||
teamId,
|
||||
timeZone,
|
||||
requiresBookerEmailVerification,
|
||||
lockTimeZoneToggleOnBookingPage,
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
secondaryEmailId,
|
||||
autoTranslateDescriptionEnabled,
|
||||
autoTranslateInstantMeetingTitleEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
};
|
||||
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
|
||||
|
||||
prismaMock.eventType.deleteMany.mockResolvedValue([123] as unknown as Prisma.BatchPayload);
|
||||
const result = await updateChildrenEventTypes({
|
||||
@@ -344,7 +381,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
updatedValues: {},
|
||||
});
|
||||
const { createdAt, updatedAt, ...expectedEvType } = evType;
|
||||
expect(prismaMock.eventType.createMany).toHaveBeenCalledWith({
|
||||
expect(prismaMock.eventType.createManyAndReturn).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{
|
||||
...expectedEvType,
|
||||
@@ -366,6 +403,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
},
|
||||
],
|
||||
skipDuplicates: true,
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
expect(result.newUserIds).toEqual([4]);
|
||||
expect(result.oldUserIds).toEqual([]);
|
||||
@@ -387,6 +425,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
lockTimeZoneToggleOnBookingPage,
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
secondaryEmailId,
|
||||
autoTranslateInstantMeetingTitleEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
@@ -454,6 +493,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
secondaryEmailId,
|
||||
autoTranslateDescriptionEnabled,
|
||||
autoTranslateInstantMeetingTitleEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
@@ -471,92 +511,28 @@ describe("handleChildrenEventTypes", () => {
|
||||
// Setup transaction mock to execute the callback
|
||||
setupTransactionMock();
|
||||
|
||||
// Mock findMany to return the newly created event type for workflow linking
|
||||
// This simulates the event type created for user 5
|
||||
prismaMock.eventType.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 3,
|
||||
title: "test",
|
||||
slug: "test",
|
||||
description: null,
|
||||
position: 0,
|
||||
locations: [],
|
||||
length: 30,
|
||||
offsetStart: 0,
|
||||
hidden: false,
|
||||
userId: 5,
|
||||
profileId: null,
|
||||
teamId: null,
|
||||
eventName: null,
|
||||
parentId: 1,
|
||||
bookingFields: null,
|
||||
timeZone: null,
|
||||
periodType: "UNLIMITED",
|
||||
periodStartDate: null,
|
||||
periodEndDate: null,
|
||||
periodDays: null,
|
||||
periodCountCalendarDays: null,
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
lockedTimeZone: null,
|
||||
requiresConfirmation: false,
|
||||
requiresConfirmationWillBlockSlot: false,
|
||||
requiresConfirmationForFreeEmail: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
canSendCalVideoTranscriptionEmails: true,
|
||||
autoTranslateDescriptionEnabled: false,
|
||||
recurringEvent: null,
|
||||
disableGuests: false,
|
||||
hideCalendarNotes: false,
|
||||
hideCalendarEventDetails: false,
|
||||
minimumBookingNotice: 120,
|
||||
beforeEventBuffer: 0,
|
||||
afterEventBuffer: 0,
|
||||
seatsPerTimeSlot: null,
|
||||
onlyShowFirstAvailableSlot: false,
|
||||
showOptimizedSlots: false,
|
||||
disableCancelling: false,
|
||||
disableRescheduling: false,
|
||||
seatsShowAttendees: false,
|
||||
seatsShowAvailabilityCount: true,
|
||||
schedulingType: null,
|
||||
scheduleId: null,
|
||||
price: 0,
|
||||
currency: "usd",
|
||||
slotInterval: null,
|
||||
metadata: {},
|
||||
successRedirectUrl: null,
|
||||
forwardParamsSuccessRedirect: true,
|
||||
bookingLimits: null,
|
||||
durationLimits: null,
|
||||
isInstantEvent: false,
|
||||
instantMeetingExpiryTimeOffsetInSeconds: 90,
|
||||
instantMeetingScheduleId: null,
|
||||
assignAllTeamMembers: false,
|
||||
useEventTypeDestinationCalendarEmail: false,
|
||||
secondaryEmailId: null,
|
||||
eventTypeColor: null,
|
||||
rescheduleWithSameRoundRobinHost: false,
|
||||
rrSegmentQueryValue: null,
|
||||
assignRRMembersUsingSegment: false,
|
||||
useEventLevelSelectedCalendars: false,
|
||||
restrictionScheduleId: null,
|
||||
useBookerTimezone: false,
|
||||
allowReschedulingCancelledBookings: false,
|
||||
includeNoShowInRRCalculation: false,
|
||||
interfaceLanguage: null,
|
||||
customReplyToEmail: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
instantMeetingParameters: [],
|
||||
isRRWeightsEnabled: false,
|
||||
maxLeadThreshold: null,
|
||||
allowReschedulingPastBookings: false,
|
||||
hideOrganizerEmail: false,
|
||||
maxActiveBookingsPerBooker: null,
|
||||
maxActiveBookingPerBookerOfferReschedule: false,
|
||||
bookingRequiresAuthentication: false,
|
||||
},
|
||||
]);
|
||||
// Mock createManyAndReturn to return the newly created event type for workflow linking
|
||||
// This simulates the event type created for user 5 (full EventType shape required for type safety)
|
||||
const createdEventType = {
|
||||
...evType,
|
||||
id: 3,
|
||||
userId: 5,
|
||||
schedulingType: _schedulingType,
|
||||
teamId: _teamId,
|
||||
locations: _locations,
|
||||
timeZone: _timeZone,
|
||||
parentId: _parentId,
|
||||
requiresBookerEmailVerification,
|
||||
lockTimeZoneToggleOnBookingPage,
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
secondaryEmailId,
|
||||
autoTranslateDescriptionEnabled,
|
||||
autoTranslateInstantMeetingTitleEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
};
|
||||
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
|
||||
|
||||
// Mock the event type that will be returned for existing users
|
||||
const mockUpdatedEventType = {
|
||||
@@ -565,6 +541,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
timeZone: "UTC",
|
||||
teamId: 1,
|
||||
autoTranslateDescriptionEnabled: false,
|
||||
autoTranslateInstantMeetingTitleEnabled: true,
|
||||
secondaryEmailId: null,
|
||||
schedulingType: SchedulingType.MANAGED,
|
||||
requiresBookerEmailVerification: false,
|
||||
@@ -585,6 +562,10 @@ describe("handleChildrenEventTypes", () => {
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
// Mock workflowsOnEventTypes.findMany to return empty array (no existing workflow links for old event types)
|
||||
// This is needed because the new implementation uses findMany + createMany instead of upsert
|
||||
prismaMock.workflowsOnEventTypes.findMany.mockResolvedValue([]);
|
||||
|
||||
await updateChildrenEventTypes({
|
||||
eventTypeId: 1,
|
||||
oldEventType: { children: [{ userId: 4 }], team: { name: "" } },
|
||||
@@ -601,9 +582,9 @@ describe("handleChildrenEventTypes", () => {
|
||||
|
||||
const { createdAt, updatedAt, ...expectedEvType } = evType;
|
||||
if ("workflows" in expectedEvType) delete expectedEvType.workflows;
|
||||
// Verify createMany was called for new users (user 5)
|
||||
// Note: createMany doesn't support nested relations like workflows, so they're handled separately
|
||||
expect(prismaMock.eventType.createMany).toHaveBeenCalledWith({
|
||||
// Verify createManyAndReturn was called for new users (user 5)
|
||||
// Note: createManyAndReturn doesn't support nested relations like workflows, so they're handled separately
|
||||
expect(prismaMock.eventType.createManyAndReturn).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{
|
||||
...expectedEvType,
|
||||
@@ -627,6 +608,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
},
|
||||
],
|
||||
skipDuplicates: true,
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
|
||||
// Verify workflowsOnEventTypes.createMany was called for new users' workflows
|
||||
@@ -658,19 +640,23 @@ describe("handleChildrenEventTypes", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
// Verify workflowsOnEventTypes.upsert was called for existing users' workflows
|
||||
expect(prismaMock.workflowsOnEventTypes.upsert).toHaveBeenCalledWith({
|
||||
create: {
|
||||
eventTypeId: 2,
|
||||
workflowId: 11,
|
||||
},
|
||||
update: {},
|
||||
// Verify workflowsOnEventTypes.findMany was called to check existing relationships
|
||||
expect(prismaMock.workflowsOnEventTypes.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workflowId_eventTypeId: {
|
||||
eventTypeId: 2,
|
||||
workflowId: 11,
|
||||
},
|
||||
workflowId: { in: [11] },
|
||||
eventTypeId: { in: [2] },
|
||||
},
|
||||
select: {
|
||||
workflowId: true,
|
||||
eventTypeId: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Verify workflowsOnEventTypes.createMany was called for existing users' workflows
|
||||
// Since findMany returned empty array, all workflow-eventType pairs should be created
|
||||
expect(prismaMock.workflowsOnEventTypes.createMany).toHaveBeenCalledWith({
|
||||
data: [{ eventTypeId: 2, workflowId: 11 }],
|
||||
skipDuplicates: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,2 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
cd companion
|
||||
npx lint-staged
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { createReadStream, existsSync } from "fs";
|
||||
import { promises as fs } from "fs";
|
||||
import { IncomingMessage, ServerResponse } from "http";
|
||||
import { extname, join, normalize } from "path";
|
||||
|
||||
const distDir = join(process.cwd(), "dist");
|
||||
|
||||
const mimeTypes: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".map": "application/json; charset=utf-8",
|
||||
};
|
||||
|
||||
const getSafePath = (urlPath: string): string => {
|
||||
const decodedPath = decodeURIComponent(urlPath.split("?")[0]);
|
||||
const normalized = normalize(decodedPath).replace(/^(\.\.(\/|\\|$))+/, "");
|
||||
return normalized.startsWith("/") ? normalized : `/${normalized}`;
|
||||
};
|
||||
|
||||
const resolveFilePath = async (safePath: string): Promise<string> => {
|
||||
let candidate = join(distDir, safePath);
|
||||
try {
|
||||
const stats = await fs.stat(candidate);
|
||||
if (stats.isDirectory()) {
|
||||
candidate = join(candidate, "index.html");
|
||||
}
|
||||
return candidate;
|
||||
} catch {
|
||||
const fallback = join(distDir, "index.html");
|
||||
if (existsSync(fallback)) {
|
||||
return fallback;
|
||||
}
|
||||
throw new Error("Dist folder missing. Run `npm run build` before deploying.");
|
||||
}
|
||||
};
|
||||
|
||||
const sendFile = (res: ServerResponse, filePath: string) => {
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
const contentType = mimeTypes[ext] || "application/octet-stream";
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
ext === ".html" ? "no-cache, no-store, must-revalidate" : "public, max-age=31536000, immutable"
|
||||
);
|
||||
|
||||
const stream = createReadStream(filePath);
|
||||
stream.on("error", (error) => {
|
||||
console.error("Failed to read file:", error);
|
||||
res.statusCode = 500;
|
||||
res.end("Internal Server Error");
|
||||
});
|
||||
stream.pipe(res);
|
||||
};
|
||||
|
||||
export default async function handler(req: IncomingMessage, res: ServerResponse) {
|
||||
if (req.method && !["GET", "HEAD"].includes(req.method)) {
|
||||
res.statusCode = 405;
|
||||
res.setHeader("Allow", "GET, HEAD");
|
||||
res.end("Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const urlPath = req.url ?? "/";
|
||||
const safePath = getSafePath(urlPath);
|
||||
const filePath = await resolveFilePath(safePath);
|
||||
sendFile(res, filePath);
|
||||
} catch (error) {
|
||||
console.error("Server render error:", error);
|
||||
res.statusCode = 500;
|
||||
res.end("Internal Server Error");
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export default defineContentScript({
|
||||
|
||||
// Create iframe
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.src = "http://localhost:8081";
|
||||
iframe.src = "https://companion.cal.com";
|
||||
iframe.style.width = "400px";
|
||||
iframe.style.height = "100%";
|
||||
iframe.style.border = "none";
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"name": "Cal.com Companion",
|
||||
"version": "1.7.0",
|
||||
"description": "Your calendar companion for quick booking and scheduling",
|
||||
"permissions": ["activeTab", "http://localhost:8081/*", "identity"],
|
||||
"permissions": ["activeTab", "https://companion.cal.com/*", "identity"],
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self'; object-src 'self'; frame-src 'self' http://localhost:8081;"
|
||||
"extension_pages": "script-src 'self'; object-src 'self'; frame-src 'self' https://companion.cal.com;"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "Cal.com Companion"
|
||||
|
||||
@@ -290,14 +290,37 @@ export class CalComOAuthService {
|
||||
}
|
||||
|
||||
private async getDiscoveryEndpoints(): Promise<AuthSession.DiscoveryDocument> {
|
||||
const fallbackDiscovery: AuthSession.DiscoveryDocument = {
|
||||
authorizationEndpoint: `${this.config.calcomBaseUrl}/auth/oauth2/authorize`,
|
||||
tokenEndpoint: `${this.config.calcomBaseUrl}/api/auth/oauth/token`,
|
||||
revocationEndpoint: `${this.config.calcomBaseUrl}/api/auth/oauth/revoke`,
|
||||
};
|
||||
|
||||
const isCrossOriginWeb =
|
||||
Platform.OS === "web" &&
|
||||
typeof window !== "undefined" &&
|
||||
(() => {
|
||||
try {
|
||||
return new URL(this.config.calcomBaseUrl).origin !== window.location.origin;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
})();
|
||||
|
||||
// Skip discovery fetch when we know CORS will block it (e.g. companion.cal.com -> app.cal.com).
|
||||
if (isCrossOriginWeb) {
|
||||
return fallbackDiscovery;
|
||||
}
|
||||
|
||||
try {
|
||||
const discovery = await AuthSession.fetchDiscoveryAsync(this.config.calcomBaseUrl);
|
||||
return discovery;
|
||||
} catch {
|
||||
return {
|
||||
authorizationEndpoint: `${this.config.calcomBaseUrl}/auth/oauth2/authorize`,
|
||||
tokenEndpoint: `${this.config.calcomBaseUrl}/api/auth/oauth/token`,
|
||||
} as AuthSession.DiscoveryDocument;
|
||||
...fallbackDiscovery,
|
||||
...discovery,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("Failed to load discovery document, using fallback endpoints", error);
|
||||
return fallbackDiscovery;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"routes": [
|
||||
{
|
||||
"handle": "filesystem"
|
||||
},
|
||||
{
|
||||
"src": "/.*",
|
||||
"dest": "/index.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,14 +11,14 @@ export default defineConfig({
|
||||
description: "Your calendar companion for quick booking and scheduling",
|
||||
permissions: ["activeTab", "storage", "identity"],
|
||||
host_permissions: [
|
||||
"http://localhost:8081/*",
|
||||
"https://companion.cal.com/*",
|
||||
"https://api.cal.com/*",
|
||||
"https://app.cal.com/*",
|
||||
"https://mail.google.com/*",
|
||||
],
|
||||
content_security_policy: {
|
||||
extension_pages:
|
||||
"script-src 'self'; object-src 'self'; frame-src 'self' http://localhost:8081;",
|
||||
"script-src 'self'; object-src 'self'; frame-src 'self' https://companion.cal.com",
|
||||
},
|
||||
action: {
|
||||
default_title: "Cal.com Companion",
|
||||
|
||||
@@ -2819,6 +2819,7 @@
|
||||
"organization.attributes.update",
|
||||
"organization.attributes.delete",
|
||||
"organization.attributes.create",
|
||||
"organization.attributes.editUsers",
|
||||
"routingForm.create",
|
||||
"routingForm.read",
|
||||
"routingForm.update",
|
||||
@@ -4470,6 +4471,7 @@
|
||||
"get": {
|
||||
"operationId": "OrganizationsEventTypesController_getTeamEventTypes",
|
||||
"summary": "Get team event types",
|
||||
"description": "Use the optional `sortCreatedAt` query parameter to order results by creation date (by ID). Accepts \"asc\" (oldest first) or \"desc\" (newest first). When not provided, no explicit ordering is applied.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
@@ -4523,6 +4525,16 @@
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sortCreatedAt",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Sort event types by creation date. When not provided, no explicit ordering is applied.",
|
||||
"schema": {
|
||||
"enum": ["asc", "desc"],
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -4816,6 +4828,7 @@
|
||||
"get": {
|
||||
"operationId": "OrganizationsEventTypesController_getTeamsEventTypes",
|
||||
"summary": "Get all team event types",
|
||||
"description": "Use the optional `sortCreatedAt` query parameter to order results by creation date (by ID). Accepts \"asc\" (oldest first) or \"desc\" (newest first). When not provided, no explicit ordering is applied.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
@@ -4876,6 +4889,16 @@
|
||||
"default": 0,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sortCreatedAt",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Sort event types by creation date. When not provided, no explicit ordering is applied.",
|
||||
"schema": {
|
||||
"enum": ["asc", "desc"],
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -11552,7 +11575,7 @@
|
||||
"get": {
|
||||
"operationId": "EventTypesController_2024_06_14_getEventTypes",
|
||||
"summary": "Get all event types",
|
||||
"description": "Hidden event types are returned only if authentication is provided and it belongs to the event type owner.\n \n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
|
||||
"description": "Hidden event types are returned only if authentication is provided and it belongs to the event type owner.\n \n Use the optional `sortCreatedAt` query parameter to order results by creation date (by ID). Accepts \"asc\" (oldest first) or \"desc\" (newest first). When not provided, no explicit ordering is applied.\n \n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -11609,6 +11632,16 @@
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sortCreatedAt",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Sort event types by creation date. When not provided, no explicit ordering is applied.",
|
||||
"schema": {
|
||||
"enum": ["asc", "desc"],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
@@ -14420,6 +14453,7 @@
|
||||
"get": {
|
||||
"operationId": "TeamsEventTypesController_getTeamEventTypes",
|
||||
"summary": "Get a team event type",
|
||||
"description": "Use the optional `sortCreatedAt` query parameter to order results by creation date (by ID). Accepts \"asc\" (oldest first) or \"desc\" (newest first). When not provided, no explicit ordering is applied.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "teamId",
|
||||
@@ -14446,6 +14480,16 @@
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sortCreatedAt",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Sort event types by creation date. When not provided, no explicit ordering is applied.",
|
||||
"schema": {
|
||||
"enum": ["asc", "desc"],
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -16781,7 +16825,37 @@
|
||||
"integration": {
|
||||
"type": "string",
|
||||
"example": "cal-video",
|
||||
"enum": ["cal-video", "google-meet", "office365-video", "zoom"]
|
||||
"enum": [
|
||||
"cal-video",
|
||||
"google-meet",
|
||||
"zoom",
|
||||
"whereby-video",
|
||||
"whatsapp-video",
|
||||
"webex-video",
|
||||
"telegram-video",
|
||||
"tandem",
|
||||
"sylaps-video",
|
||||
"skype-video",
|
||||
"sirius-video",
|
||||
"signal-video",
|
||||
"shimmer-video",
|
||||
"salesroom-video",
|
||||
"roam-video",
|
||||
"riverside-video",
|
||||
"ping-video",
|
||||
"office365-video",
|
||||
"mirotalk-video",
|
||||
"jitsi",
|
||||
"jelly-video",
|
||||
"jelly-conferencing",
|
||||
"huddle",
|
||||
"facetime-video",
|
||||
"element-call-video",
|
||||
"eightxeight-video",
|
||||
"discord-video",
|
||||
"demodesk-video",
|
||||
"campfire-video"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["type", "integration"]
|
||||
@@ -17989,7 +18063,7 @@
|
||||
},
|
||||
"locations": {
|
||||
"type": "array",
|
||||
"description": "Locations where the event will take place. If not provided, cal video link will be used as the location.",
|
||||
"description": "Locations where the event will take place. If not provided, cal video link will be used as the location. Note: Setting a location to a conferencing app does not install the app - the app must already be installed. Via API, only Google Meet (google-meet), Microsoft Teams (office365-video), and Zoom (zoom) can be installed. Cal Video (cal-video) is installed by default. All other conferencing apps must be connected via the Cal.com web app and are not available for Platform plan customers. You can only set an event type location to an app that has already been installed or connected.",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -19920,7 +19994,7 @@
|
||||
},
|
||||
"locations": {
|
||||
"type": "array",
|
||||
"description": "Locations where the event will take place. If not provided, cal video link will be used as the location.",
|
||||
"description": "Locations where the event will take place. If not provided, cal video link will be used as the location. Note: Setting a location to a conferencing app does not install the app - the app must already be installed. Via API, only Google Meet (google-meet), Microsoft Teams (office365-video), and Zoom (zoom) can be installed. Cal Video (cal-video) is installed by default. All other conferencing apps must be connected via the Cal.com web app and are not available for Platform plan customers. You can only set an event type location to an app that has already been installed or connected.",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -21561,7 +21635,7 @@
|
||||
},
|
||||
"locations": {
|
||||
"type": "array",
|
||||
"description": "Locations where the event will take place. If not provided, cal video link will be used as the location.",
|
||||
"description": "Locations where the event will take place. If not provided, cal video link will be used as the location. Note: Setting a location to a conferencing app does not install the app - the app must already be installed. Via API, only Google Meet (google-meet), Microsoft Teams (office365-video), and Zoom (zoom) can be installed. Cal Video (cal-video) is installed by default. All other conferencing apps must be connected via the Cal.com web app and are not available for Platform plan customers. You can only set an event type location to an app that has already been installed or connected.",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -22024,7 +22098,7 @@
|
||||
},
|
||||
"locations": {
|
||||
"type": "array",
|
||||
"description": "Locations where the event will take place. If not provided, cal video link will be used as the location.",
|
||||
"description": "Locations where the event will take place. If not provided, cal video link will be used as the location. Note: Setting a location to a conferencing app does not install the app - the app must already be installed. Via API, only Google Meet (google-meet), Microsoft Teams (office365-video), and Zoom (zoom) can be installed. Cal Video (cal-video) is installed by default. All other conferencing apps must be connected via the Cal.com web app and are not available for Platform plan customers. You can only set an event type location to an app that has already been installed or connected.",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -26294,6 +26368,7 @@
|
||||
"organization.attributes.update",
|
||||
"organization.attributes.delete",
|
||||
"organization.attributes.create",
|
||||
"organization.attributes.editUsers",
|
||||
"routingForm.create",
|
||||
"routingForm.read",
|
||||
"routingForm.update",
|
||||
@@ -26397,6 +26472,7 @@
|
||||
"organization.attributes.update",
|
||||
"organization.attributes.delete",
|
||||
"organization.attributes.create",
|
||||
"organization.attributes.editUsers",
|
||||
"routingForm.create",
|
||||
"routingForm.read",
|
||||
"routingForm.update",
|
||||
@@ -26528,6 +26604,7 @@
|
||||
"organization.attributes.update",
|
||||
"organization.attributes.delete",
|
||||
"organization.attributes.create",
|
||||
"organization.attributes.editUsers",
|
||||
"routingForm.create",
|
||||
"routingForm.read",
|
||||
"routingForm.update",
|
||||
@@ -26630,6 +26707,7 @@
|
||||
"organization.attributes.update",
|
||||
"organization.attributes.delete",
|
||||
"organization.attributes.create",
|
||||
"organization.attributes.editUsers",
|
||||
"routingForm.create",
|
||||
"routingForm.read",
|
||||
"routingForm.update",
|
||||
@@ -27467,6 +27545,14 @@
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"avatarUrl": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"bio": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"timeFormat": {
|
||||
"type": "number"
|
||||
},
|
||||
@@ -27493,6 +27579,8 @@
|
||||
"username",
|
||||
"email",
|
||||
"name",
|
||||
"avatarUrl",
|
||||
"bio",
|
||||
"timeFormat",
|
||||
"defaultScheduleId",
|
||||
"weekStart",
|
||||
|
||||
@@ -248,6 +248,7 @@
|
||||
"platform/guides/replacing-toasts",
|
||||
"platform/guides/booking-fields",
|
||||
"platform/guides/booking-redirects",
|
||||
"platform/guides/managed-orgs",
|
||||
"platform/guides/teams-setup",
|
||||
"platform/guides/global-event-types",
|
||||
"platform/guides/custom-booking-flow"
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
title: Managed organizations
|
||||
description: Find out how to create managed organizations and their OAuth clients via the api.
|
||||
---
|
||||
|
||||
## **1. How to create a managed organizations**
|
||||
|
||||
First, manually create an OAuth client in [https://app.cal.com/settings/platform](https://app.cal.com/settings/platform).
|
||||
|
||||
Second, make a POST request to `https://api.cal.com/v2/organizations/:organizationId/organizations` to create a managed organization and receive its api key:
|
||||
|
||||
1. Replace `:organizationId` in the URL with your organization Id that you can copy from OAuth client settings.
|
||||
2. Add authentication headers `x-cal-client-id` equal to OAuth client id and `x-cal-secret-key` equal to the OAuth client secret.
|
||||
3. Add request body
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "some organization",
|
||||
"metadata": {
|
||||
"some": "metadata"
|
||||
},
|
||||
"apiKeyDaysValid": 60
|
||||
}
|
||||
```
|
||||
|
||||
Required fields: "name" - name of the managed organization.
|
||||
|
||||
Optional fields: "metadata", "apiKeyDaysValid", "apiKeyNeverExpires". By default the api key of a managed organization is valid for 30 days which means that you have to rotate them (will explain how below). If you want that api key is valid, for example, 60 days then specify that using "apiKeyDaysValid: 60" or if you want that api key never expires, set "apiKeyNeverExpires: true" in the request body.
|
||||
|
||||
Here is response:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"id": 148,
|
||||
"name": "some organization",
|
||||
"metadata": {
|
||||
"some": "metadata"
|
||||
},
|
||||
"apiKey": "cal_xxx168ceb89117cc3a1b1f1777511506"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notably, you will only have access to the `apiKey` only now, so make sure to create a table in your database where you store managed organization's `id` and `apiKey` . The `apiKey` will be used to create an OAuth client that is used to create platform users.
|
||||
|
||||
Using same `x-cal-client-id` and `x-cal-secret-key` headers you can make requests to:
|
||||
|
||||
1. Fetch managed organization by making GET request to `https://api.cal.com/v2/organizations/:organizationId/organizations/:managedOrganizationId`
|
||||
2. Fetch all managed organizations by making GET request to `https://api.cal.com/v2/organizations/:organizationId/organizations`
|
||||
3. Update managed organization by making PATCH request to `https://api.cal.com/v2/organizations/:organizationId/organizations/:managedOrganizationId` with body where you can specify either "name" or "metadata"
|
||||
4. Delete managed organization by making DELETE request to `https://api.cal.com/v2/organizations/:organizationId/organizations/:managedOrganizationId`
|
||||
|
||||
Shortly, the flow is that using OAuth credentials of manager organization you create a managed organization and using its api key you can create and managed OAuth clients of the managed organization, and for the rest of the things like creating "managed users" of managed organization you will use OAuth client credentials.
|
||||
|
||||
## **2. How to create OAuth client for a managed organization**
|
||||
|
||||
Now that you have created a managed organization and have its `apiKey` you can create OAuth clients for the managed organization.
|
||||
|
||||
Make POST request to `https://api.cal.com/v2/oauth-clients` by:
|
||||
|
||||
1. Setting `Authorization: Bearer <apiKey>` header where `<apiKey>` is api key returned when creating a managed organization.
|
||||
2. Setting request body:
|
||||
1. name is name of the OAuth client
|
||||
2. permissions - an array that can receive following permissions: `"EVENT_TYPE_READ", "EVENT_TYPE_WRITE", "BOOKING_READ", "BOOKING_WRITE", "SCHEDULE_READ", "SCHEDULE_WRITE", "APPS_READ", "APPS_WRITE", "PROFILE_READ", "PROFILE_WRITE"` or if you want all of them enabled set `*` . You most probably want to use `*` . See docs here [https://cal.com/docs/platform/quickstart#2-setting-up-an-oauth-client](https://cal.com/docs/platform/quickstart#2-setting-up-an-oauth-client)
|
||||
3. redirectUris - point to (as example) `your-domain.com*` where users end up when connecting google calendar etc and what is the origin of the requests aka what origins are allowed to make requests to our endpoints. Having `your-domain.com*` would mean that we will accept requests from cal UI components that make requests from `your-domain.com, your-domain.com/page` etc.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "OAuth client for managed organization",
|
||||
"redirectUris": ["https://your-domain.com*"],
|
||||
"permissions": ["*"]
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"clientId": "xxxepq3bg0000khghtq4bxxx",
|
||||
"clientSecret": "xxxhbGciOiJIUzI1NiIsInR5cCI6IlpXVCJ9.eyJuYW1lIjoiT0F1dGggY2xpZW50IGZvciBtYW5hZ2VkIG9yZ2FuaXphdGlvbiIsInJlZGlyZWN0VXJpcyI6wyJodHRwOi8vbG9jYWxob3N0OjQzMjEiXSwicGVybWlzc2lvbnMiOjY0LCJpYXQiOjE3NDAxMzg4MjB9.3nzplyvqf-ZlMUuhX9mYfpxtneH62iJPB6WpsYBWxxx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To fetch all OAuth clients of a managed organization make a GET request to `https://api.cal.com/v2/oauth-clients` by:
|
||||
|
||||
1. Setting `Authorization: Bearer <apiKey>` header where `<apiKey>` is the api key returned when creating a managed organization.
|
||||
|
||||
To fetch a specific OAuth client of a managed organization make a GET request to `https://api.cal.com/v2/oauth-clients/:oAuthClientId` by:
|
||||
|
||||
1. Setting `Authorization: Bearer <apiKey>` header where `<apiKey>` is the api key returned when creating a managed organization.
|
||||
|
||||
## **3. How to refresh api key of a managed organization**
|
||||
|
||||
If you did not specify `apiKeyNeverExpires` when creating a managed organization then you will have to refresh the api key after `apiKeyDaysValid` (if you specified it) or by default after 30 days.
|
||||
|
||||
Make a POST request to `https://api.cal.com/v2/api-keys/refresh` by:
|
||||
|
||||
1. Setting `Authorization: Bearer <apiKey>` header where `<apiKey>` is api key returned when creating a managed organization.
|
||||
2. Add a request body `{"apiKeyDaysValid": 30}` or whatever number of days you want the new api key to be valid or `{"apiKeyNeverExpires": true}` if you want that it never expires.
|
||||
|
||||
The response will contain the new api key for the managed organization:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"apiKey": "cal_xxx467709fffebe8c6559211cb5f178f"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
and now you have to update in your database api key of the managed organization.
|
||||
+3
-2
@@ -100,7 +100,7 @@
|
||||
"checkly": "latest",
|
||||
"dotenv-checker": "^1.1.5",
|
||||
"eslint": "9.36.0",
|
||||
"husky": "^8.0.0",
|
||||
"husky": "9.1.7",
|
||||
"i18n-unused": "^0.13.0",
|
||||
"jest-diff": "^29.5.0",
|
||||
"jest-summarizing-reporter": "^1.1.4",
|
||||
@@ -139,7 +139,8 @@
|
||||
"dayjs@1.11.2": "patch:dayjs@npm%3A1.11.4#./.yarn/patches/dayjs-npm-1.11.4-97921cd375.patch",
|
||||
"dayjs@^1": "patch:dayjs@npm%3A1.11.4#./.yarn/patches/dayjs-npm-1.11.4-97921cd375.patch",
|
||||
"dayjs@^1.8.29": "patch:dayjs@npm%3A1.11.4#./.yarn/patches/dayjs-npm-1.11.4-97921cd375.patch",
|
||||
"import-in-the-middle": "1.13.1"
|
||||
"import-in-the-middle": "1.13.1",
|
||||
"react@19.2.0": "19.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=7.0.0",
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { AppDependencyComponent } from "./AppDependencyComponent";
|
||||
|
||||
// Type for dependency data
|
||||
type MockDependency = {
|
||||
name: string;
|
||||
slug: string;
|
||||
installed: boolean;
|
||||
};
|
||||
|
||||
// Mock the useLocale hook
|
||||
vi.mock("@calcom/lib/hooks/useLocale", () => ({
|
||||
useLocale: () => ({
|
||||
t: (key: string, values?: Record<string, unknown>) => {
|
||||
if (key === "app_is_connected") {
|
||||
return `${values?.dependencyName} is connected`;
|
||||
}
|
||||
if (key === "this_app_requires_connected_account") {
|
||||
return `${values?.appName} requires a connected ${values?.dependencyName} account`;
|
||||
}
|
||||
if (key === "connect_app") {
|
||||
return `Connect ${values?.dependencyName}`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock constants and UI components
|
||||
vi.mock("@calcom/lib/constants", () => ({
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/ui/components/icon", () => ({
|
||||
Icon: ({ name, className }: { name: string; className?: string }) => (
|
||||
<svg data-testid={`${name}-icon`} className={className}>
|
||||
{name}
|
||||
</svg>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("AppDependencyComponent", () => {
|
||||
// Factory function to reduce duplication
|
||||
const createMockDependency = (overrides: Partial<MockDependency> = {}): MockDependency => ({
|
||||
name: "Google Calendar",
|
||||
slug: "google-calendar",
|
||||
installed: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows success indicators when dependencies are met", () => {
|
||||
const dependency = createMockDependency({ installed: true });
|
||||
const { container } = render(
|
||||
<AppDependencyComponent
|
||||
appName="Google Meet"
|
||||
dependencyData={[dependency]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass("bg-subtle");
|
||||
expect(screen.getByTestId("check-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("Google Calendar is connected")).toBeInTheDocument();
|
||||
expect(screen.getByText("Google Meet requires a connected Google Calendar account"))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error indicators and connect link when dependencies are not met", () => {
|
||||
const dependency = createMockDependency({ installed: false });
|
||||
const { container } = render(
|
||||
<AppDependencyComponent
|
||||
appName="Google Meet"
|
||||
dependencyData={[dependency]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass("bg-error");
|
||||
expect(screen.getByTestId("circle-x-icon")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /connect google calendar/i }))
|
||||
.toHaveAttribute("href", expect.stringContaining("/apps/google-calendar"));
|
||||
expect(screen.getByText("Google Meet requires a connected Google Calendar account"))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows mixed states when some dependencies are unmet", () => {
|
||||
const dependencies = [
|
||||
createMockDependency({ installed: true }),
|
||||
createMockDependency({ installed: false }),
|
||||
];
|
||||
|
||||
const { container } = render(
|
||||
<AppDependencyComponent
|
||||
appName="Google Meet"
|
||||
dependencyData={dependencies}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass("bg-error");
|
||||
expect(screen.getByTestId("check-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("circle-x-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows only success indicators when all dependencies are met", () => {
|
||||
const dependencies = [
|
||||
createMockDependency({ installed: true }),
|
||||
createMockDependency({ name: "Zoom", slug: "zoom", installed: true }),
|
||||
];
|
||||
|
||||
const { container } = render(
|
||||
<AppDependencyComponent
|
||||
appName="Google Meet"
|
||||
dependencyData={dependencies}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass("bg-subtle");
|
||||
const checkIcons = screen.getAllByTestId("check-icon");
|
||||
expect(checkIcons).toHaveLength(2);
|
||||
expect(screen.queryByTestId("circle-x-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles empty dependency data gracefully", () => {
|
||||
const { container } = render(
|
||||
<AppDependencyComponent appName="Google Meet" dependencyData={undefined} />
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass("bg-subtle");
|
||||
expect(screen.queryByTestId("check-icon")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("circle-x-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles empty dependency array gracefully", () => {
|
||||
const { container } = render(
|
||||
<AppDependencyComponent appName="Google Meet" dependencyData={[]} />
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass("bg-subtle");
|
||||
expect(screen.queryByTestId("check-icon")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("circle-x-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("treats truthy non-boolean values as installed", () => {
|
||||
const dependency = createMockDependency({
|
||||
installed: "yes" as unknown as boolean
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<AppDependencyComponent
|
||||
appName="Google Meet"
|
||||
dependencyData={[dependency]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass("bg-subtle");
|
||||
expect(screen.getByTestId("check-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("Google Calendar is connected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays requirement message consistently", () => {
|
||||
const dependency = createMockDependency({ installed: true });
|
||||
render(
|
||||
<AppDependencyComponent
|
||||
appName="Google Meet"
|
||||
dependencyData={[dependency]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("Google Meet requires a connected Google Calendar account")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -16,11 +16,13 @@ export const AppDependencyComponent = ({
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const hasUnmetDependencies = dependencyData ? dependencyData.some((dep) => !dep.installed) : false;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-md px-4 py-3",
|
||||
dependencyData && dependencyData.some((dependency) => !dependency.installed) ? "bg-cal-info" : "bg-subtle"
|
||||
hasUnmetDependencies ? "bg-error" : "bg-subtle"
|
||||
)}>
|
||||
{dependencyData &&
|
||||
dependencyData.map((dependency) => {
|
||||
@@ -50,9 +52,9 @@ export const AppDependencyComponent = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="items-start space-x-2.5">
|
||||
<div className="text-info flex items-start">
|
||||
<div className="text-error flex items-start">
|
||||
<div>
|
||||
<Icon name="circle-alert" className="mr-2 mt-1 font-semibold" />
|
||||
<Icon name="circle-x" className="mr-2 mt-1 font-semibold" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">
|
||||
@@ -62,13 +64,12 @@ export const AppDependencyComponent = ({
|
||||
interpolation: { escapeValue: false },
|
||||
})}
|
||||
</span>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<>
|
||||
<Link
|
||||
href={`${WEBAPP_URL}/apps/${dependency.slug}`}
|
||||
className="text-info flex items-center underline">
|
||||
className="text-error flex items-center underline">
|
||||
<span className="mr-1">
|
||||
{t("connect_app", { dependencyName: dependency.name })}
|
||||
</span>
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
|
||||
import AppCard from "@calcom/app-store/_components/AppCard";
|
||||
import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled";
|
||||
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Section } from "@calcom/ui/components/section";
|
||||
import { Switch } from "@calcom/ui/components/form";
|
||||
|
||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType, onAppInstallSuccess }) {
|
||||
import type { appDataSchema } from "../zod";
|
||||
|
||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
app,
|
||||
eventType,
|
||||
onAppInstallSuccess,
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const { t } = useLocale();
|
||||
const { getAppData, setAppData } = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
||||
|
||||
const ignoreGuests = getAppData("ignoreGuests") ?? false;
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
onAppInstallSuccess={onAppInstallSuccess}
|
||||
@@ -20,9 +33,26 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
updateEnabled(e);
|
||||
}}
|
||||
switchChecked={enabled}
|
||||
hideAppCardOptions
|
||||
/>
|
||||
hideSettingsIcon>
|
||||
<Section.Content>
|
||||
<Section.SubSection>
|
||||
<Section.SubSectionHeader
|
||||
icon="user-plus"
|
||||
title={t("hubspot_ignore_guests")}
|
||||
labelFor="ignore-guests">
|
||||
<Switch
|
||||
size="sm"
|
||||
labelOnLeading
|
||||
checked={ignoreGuests}
|
||||
onCheckedChange={(checked) => {
|
||||
setAppData("ignoreGuests", checked);
|
||||
}}
|
||||
/>
|
||||
</Section.SubSectionHeader>
|
||||
</Section.SubSection>
|
||||
</Section.Content>
|
||||
</AppCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventTypeAppCard;
|
||||
export default EventTypeAppCard;
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
SimplePublicObject,
|
||||
SimplePublicObjectInput,
|
||||
} from "@hubspot/api-client/lib/codegen/crm/objects/meetings";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { getLocation } from "@calcom/lib/CalEventParser";
|
||||
import getLabelValueMapFromResponses from "@calcom/lib/bookings/getLabelValueMapFromResponses";
|
||||
@@ -20,6 +21,7 @@ import type { CRM, ContactCreateInput, Contact, CrmEvent } from "@calcom/types/C
|
||||
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
|
||||
import type { HubspotToken } from "../api/callback";
|
||||
import type { appDataSchema } from "../zod";
|
||||
|
||||
export default class HubspotCalendarService implements CRM {
|
||||
private url = "";
|
||||
@@ -29,8 +31,9 @@ export default class HubspotCalendarService implements CRM {
|
||||
private client_id = "";
|
||||
private client_secret = "";
|
||||
private hubspotClient: hubspot.Client;
|
||||
private appOptions: z.infer<typeof appDataSchema>;
|
||||
|
||||
constructor(credential: CredentialPayload) {
|
||||
constructor(credential: CredentialPayload, appOptions?: z.infer<typeof appDataSchema>) {
|
||||
this.hubspotClient = new hubspot.Client();
|
||||
|
||||
this.integrationName = "hubspot_other_calendar";
|
||||
@@ -38,6 +41,8 @@ export default class HubspotCalendarService implements CRM {
|
||||
this.auth = this.hubspotAuth(credential).then((r) => r);
|
||||
|
||||
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
|
||||
|
||||
this.appOptions = appOptions || {};
|
||||
}
|
||||
|
||||
private getHubspotMeetingBody = (event: CalendarEvent): string => {
|
||||
@@ -300,7 +305,7 @@ export default class HubspotCalendarService implements CRM {
|
||||
}
|
||||
|
||||
getAppOptions() {
|
||||
console.log("No options implemented");
|
||||
return this.appOptions;
|
||||
}
|
||||
|
||||
async handleAttendeeNoShow() {
|
||||
|
||||
@@ -2,7 +2,9 @@ import { z } from "zod";
|
||||
|
||||
import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
|
||||
|
||||
export const appDataSchema = eventTypeAppCardZod;
|
||||
export const appDataSchema = eventTypeAppCardZod.extend({
|
||||
ignoreGuests: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const appKeysSchema = z.object({
|
||||
client_id: z.string().min(1),
|
||||
|
||||
@@ -56,7 +56,7 @@ export const sendEmailVerification = async ({
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: hashEmail(email),
|
||||
identifier: `sendEmailVerification:${hashEmail(email)}`,
|
||||
});
|
||||
|
||||
await prisma.verificationToken.create({
|
||||
|
||||
@@ -361,20 +361,20 @@ Used when a booking status changes to accepted.
|
||||
#### ATTENDEE_ADDED
|
||||
```typescript
|
||||
{
|
||||
addedAttendees // { old: null, new: ["email@example.com", ...] }
|
||||
attendees // { old: ["email1@example.com", ...], new: ["email1@example.com", "email2@example.com", ...] }
|
||||
}
|
||||
```
|
||||
|
||||
Tracks attendee(s) that were added in this action. Old value is null since we're tracking the delta, not full state.
|
||||
Tracks attendee(s) that were added in this action. The field stores the state change: `old` contains attendees before addition, `new` contains attendees after addition. The actual added attendees are computed as the difference (new - old).
|
||||
|
||||
#### ATTENDEE_REMOVED
|
||||
```typescript
|
||||
{
|
||||
removedAttendees // { old: null, new: ["email@example.com", ...] }
|
||||
attendees // { old: ["email1@example.com", ...], new: ["email2@example.com", ...] }
|
||||
}
|
||||
```
|
||||
|
||||
Tracks attendee(s) that were removed in this action. Old value is null since we're tracking the delta, not full state.
|
||||
Tracks attendee(s) that were removed in this action. The field stores the state change: `old` contains attendees before removal, `new` contains remaining attendees after removal. The actual removed attendees are computed as the difference (old - new).
|
||||
|
||||
---
|
||||
|
||||
@@ -671,14 +671,19 @@ The audit system is designed to capture what actually happened, not to enforce b
|
||||
// ❌ BAD: Enforcing expected values
|
||||
if (booking.status === 'ACCEPTED' || booking.status === 'PENDING' || booking.status === 'AWAITING_HOST') {
|
||||
// Only audit if status is "expected"
|
||||
await auditService.onBookingCreated(...);
|
||||
await auditTaskConsumer.onBookingAction(...);
|
||||
}
|
||||
|
||||
// ✅ GOOD: Recording actual state
|
||||
await auditService.onBookingCreated(bookingId, userUuid, {
|
||||
startTime: booking.startTime.toISOString(),
|
||||
endTime: booking.endTime.toISOString(),
|
||||
status: booking.status // Whatever it actually is
|
||||
await bookingAuditProducerService.queueAudit({
|
||||
bookingUid,
|
||||
actor,
|
||||
action: "CREATED",
|
||||
data: {
|
||||
startTime: booking.startTime.toISOString(),
|
||||
endTime: booking.endTime.toISOString(),
|
||||
status: booking.status // Whatever it actually is
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
@@ -728,40 +733,47 @@ await prisma.auditActor.update({
|
||||
`BookingEventHandlerService` is the primary entry point for tracking any booking changes. It acts as a coordinator that:
|
||||
|
||||
1. **Receives booking events** from various parts of the application (booking creation, status changes, updates, etc.)
|
||||
2. **Relays to BookingAuditService** to create audit records
|
||||
2. **Queues audit tasks** via BookingAuditProducerService for asynchronous processing
|
||||
3. **Handles other side effects** such as webhooks, notifications, and workflow triggers
|
||||
|
||||
|
||||
### BookingAuditService
|
||||
### BookingAuditTaskConsumer
|
||||
|
||||
The audit system is accessed through `BookingAuditService`, which provides:
|
||||
The audit system processes audit records through `BookingAuditTaskConsumer`, which provides:
|
||||
|
||||
### Convenience Methods
|
||||
### Core Method
|
||||
|
||||
- `onBookingCreated()` - Track booking creation
|
||||
- `onBookingAccepted()` - Track acceptance
|
||||
- `onBookingRejected()` - Track rejection
|
||||
- `onBookingCancelled()` - Track cancellation
|
||||
- `onBookingRescheduled()` - Track reschedule
|
||||
- `onAttendeeAdded()` - Track attendee addition
|
||||
- `onAttendeeRemoved()` - Track attendee removal
|
||||
- `onLocationChanged()` - Track location changes
|
||||
- `onHostNoShowUpdated()` - Track host no-show
|
||||
- `onAttendeeNoShowUpdated()` - Track attendee no-show
|
||||
- `onReassignment()` - Track booking reassignment
|
||||
- `onRescheduleRequested()` - Track reschedule requests
|
||||
- `onBookingAction(params)` - Generic method that handles any booking action
|
||||
- Accepts a single object parameter with `bookingUid`, `actor`, `action`, and `data`
|
||||
- Derives the record type (RECORD_CREATED, RECORD_UPDATED, RECORD_DELETED) from the action
|
||||
- Routes to appropriate action service for data validation and formatting
|
||||
|
||||
### Record Type Mapping
|
||||
|
||||
- `getRecordType({ action })` - Maps booking actions to audit record types:
|
||||
- `CREATED` → `RECORD_CREATED`
|
||||
- Future actions like `CANCELLED`, `RESCHEDULED` → `RECORD_UPDATED`
|
||||
- Future actions like `DELETED` → `RECORD_DELETED`
|
||||
|
||||
### Actor Management
|
||||
|
||||
- `getOrCreateUserActor()` - Ensures User actors exist before creating audits
|
||||
- Automatic AuditActor creation/lookup for registered users
|
||||
- `resolveActorId()` - Resolves Actor objects to actor IDs in the AuditActor table
|
||||
- Automatic AuditActor creation/lookup for registered users, guests, and attendees
|
||||
- System actor for automated actions
|
||||
|
||||
### Future: Trigger.dev Task Orchestration
|
||||
|
||||
**Current Flow (Synchronous):**
|
||||
**Current Flow (Asynchronous with Task Queue):**
|
||||
```
|
||||
Booking Endpoint → BookingEventHandler.onBookingCreated() → await auditService, linkService, webhookService
|
||||
Booking Endpoint
|
||||
↓
|
||||
BookingEventHandler.onBookingCreated() [orchestrator]
|
||||
├─ bookingAuditProducerService.queueAudit({ bookingUid, actor, action, data })
|
||||
├─ hashedLinkService.validateAndIncrementUsage(hashedLink)
|
||||
└─ Other side effects...
|
||||
|
||||
Task Queue
|
||||
└─ Task: Booking Audit → BookingAuditTaskConsumer.onBookingAction({ bookingUid, actor, action, data })
|
||||
```
|
||||
|
||||
**Future Flow (Async with Trigger.dev):**
|
||||
@@ -769,14 +781,14 @@ Booking Endpoint → BookingEventHandler.onBookingCreated() → await auditServi
|
||||
Booking Endpoint
|
||||
↓
|
||||
BookingEventHandler.onBookingCreated() [orchestrator]
|
||||
├─ tasks.trigger('bookingAudit', { bookingId, userUuid, data })
|
||||
├─ tasks.trigger('bookingAudit', { bookingUid, actor, action, data })
|
||||
├─ tasks.trigger('invalidateHashedLink', { bookingId, hashedLink })
|
||||
├─ tasks.trigger('sendNotifications', { bookingId, email, sms })
|
||||
├─ tasks.trigger('triggerWorkflows', { bookingId, event: 'NEW_EVENT' })
|
||||
└─ Immediately returns to user (non-blocking)
|
||||
|
||||
Trigger.dev Queue
|
||||
├─ Task: Booking Audit (with retries, monitoring)
|
||||
├─ Task: Booking Audit → BookingAuditTaskConsumer.onBookingAction({ bookingUid, actor, action, data }) (with retries, monitoring)
|
||||
├─ Task: Hashed Link Invalidation (independent)
|
||||
├─ Task: Email & SMS Notifications (independent)
|
||||
└─ Task: Workflow Triggers (independent)
|
||||
|
||||
@@ -5,8 +5,8 @@ import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/
|
||||
import { createModule } from "../../di/di";
|
||||
|
||||
export const actorRepositoryModule = createModule();
|
||||
const token = BOOKING_AUDIT_DI_TOKENS.ACTOR_REPOSITORY;
|
||||
const moduleToken = BOOKING_AUDIT_DI_TOKENS.ACTOR_REPOSITORY_MODULE;
|
||||
const token = BOOKING_AUDIT_DI_TOKENS.AUDIT_ACTOR_REPOSITORY;
|
||||
const moduleToken = BOOKING_AUDIT_DI_TOKENS.AUDIT_ACTOR_REPOSITORY_MODULE;
|
||||
const loadModule = bindModuleToClassOnToken({
|
||||
module: actorRepositoryModule,
|
||||
moduleToken,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { PrismaAuditActorRepository } from "@calcom/features/booking-audit/lib/repository/PrismaAuditActorRepository";
|
||||
import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens";
|
||||
import { bindModuleToClassOnToken } from "@calcom/features/di/di";
|
||||
import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma";
|
||||
import { createModule } from "../../di/di";
|
||||
|
||||
export const auditActorRepositoryModule = createModule();
|
||||
const token = BOOKING_AUDIT_DI_TOKENS.AUDIT_ACTOR_REPOSITORY;
|
||||
const moduleToken = BOOKING_AUDIT_DI_TOKENS.AUDIT_ACTOR_REPOSITORY_MODULE;
|
||||
const loadModule = bindModuleToClassOnToken({
|
||||
module: auditActorRepositoryModule,
|
||||
moduleToken,
|
||||
token,
|
||||
classs: PrismaAuditActorRepository,
|
||||
depsMap: {
|
||||
prismaClient: prismaModuleLoader,
|
||||
},
|
||||
});
|
||||
|
||||
export const moduleLoader = {
|
||||
token,
|
||||
loadModule,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createContainer } from "@calcom/features/di/di";
|
||||
import type { BookingAuditProducerService } from "@calcom/features/booking-audit/lib/service/BookingAuditProducerService.interface";
|
||||
|
||||
import {
|
||||
moduleLoader as bookingAuditTaskerProducerServiceModule,
|
||||
} from "./BookingAuditTaskerProducerService.module";
|
||||
|
||||
const container = createContainer();
|
||||
|
||||
export function getBookingAuditProducerService() {
|
||||
bookingAuditTaskerProducerServiceModule.loadModule(container);
|
||||
|
||||
return container.get<BookingAuditProducerService>(bookingAuditTaskerProducerServiceModule.token);
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { BookingAuditService } from "@calcom/features/booking-audit/lib/service/BookingAuditService";
|
||||
import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens";
|
||||
import { moduleLoader as bookingAuditRepositoryModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditRepository.module";
|
||||
import { moduleLoader as actorRepositoryModuleLoader } from "@calcom/features/booking-audit/di/ActorRepository.module";
|
||||
|
||||
import { createModule, bindModuleToClassOnToken } from "../../di/di";
|
||||
|
||||
export const bookingAuditServiceModule = createModule();
|
||||
const token = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_SERVICE;
|
||||
const moduleToken = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_SERVICE_MODULE;
|
||||
|
||||
const loadModule = bindModuleToClassOnToken({
|
||||
module: bookingAuditServiceModule,
|
||||
moduleToken,
|
||||
token,
|
||||
classs: BookingAuditService,
|
||||
depsMap: {
|
||||
bookingAuditRepository: bookingAuditRepositoryModuleLoader,
|
||||
actorRepository: actorRepositoryModuleLoader,
|
||||
},
|
||||
});
|
||||
|
||||
export const moduleLoader = {
|
||||
token,
|
||||
loadModule
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createContainer } from "@calcom/features/di/di";
|
||||
import type { BookingAuditTaskConsumer } from "@calcom/features/booking-audit/lib/service/BookingAuditTaskConsumer";
|
||||
|
||||
import {
|
||||
moduleLoader as bookingAuditTaskConsumerModule,
|
||||
} from "./BookingAuditTaskConsumer.module";
|
||||
|
||||
const container = createContainer();
|
||||
|
||||
export function getBookingAuditTaskConsumer() {
|
||||
bookingAuditTaskConsumerModule.loadModule(container);
|
||||
|
||||
return container.get<BookingAuditTaskConsumer>(bookingAuditTaskConsumerModule.token);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { BookingAuditTaskConsumer } from "@calcom/features/booking-audit/lib/service/BookingAuditTaskConsumer";
|
||||
import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens";
|
||||
import { moduleLoader as bookingAuditRepositoryModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditRepository.module";
|
||||
import { moduleLoader as auditActorRepositoryModuleLoader } from "@calcom/features/booking-audit/di/AuditActorRepository.module";
|
||||
import { moduleLoader as featuresRepositoryModuleLoader } from "@calcom/features/di/modules/Features";
|
||||
|
||||
import { createModule, bindModuleToClassOnToken } from "../../di/di";
|
||||
|
||||
export const bookingAuditTaskConsumerModule = createModule();
|
||||
const token = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_TASK_CONSUMER;
|
||||
const moduleToken = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_TASK_CONSUMER_MODULE;
|
||||
|
||||
const loadModule = bindModuleToClassOnToken({
|
||||
module: bookingAuditTaskConsumerModule,
|
||||
moduleToken,
|
||||
token,
|
||||
classs: BookingAuditTaskConsumer,
|
||||
depsMap: {
|
||||
bookingAuditRepository: bookingAuditRepositoryModuleLoader,
|
||||
auditActorRepository: auditActorRepositoryModuleLoader,
|
||||
featuresRepository: featuresRepositoryModuleLoader,
|
||||
},
|
||||
});
|
||||
|
||||
export const moduleLoader = {
|
||||
token,
|
||||
loadModule
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { BookingAuditTaskerProducerService } from "@calcom/features/booking-audit/lib/service/BookingAuditTaskerProducerService";
|
||||
import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens";
|
||||
import { moduleLoader as taskerModuleLoader } from "@calcom/features/di/shared/services/tasker.service";
|
||||
|
||||
import { createModule, bindModuleToClassOnToken } from "../../di/di";
|
||||
|
||||
export const bookingAuditProducerServiceModule = createModule();
|
||||
const token = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_PRODUCER_SERVICE;
|
||||
const moduleToken = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_PRODUCER_SERVICE_MODULE;
|
||||
|
||||
const loadModule = bindModuleToClassOnToken({
|
||||
module: bookingAuditProducerServiceModule,
|
||||
moduleToken,
|
||||
token,
|
||||
classs: BookingAuditTaskerProducerService,
|
||||
depsMap: {
|
||||
tasker: taskerModuleLoader,
|
||||
},
|
||||
});
|
||||
|
||||
export const moduleLoader = {
|
||||
token,
|
||||
loadModule
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createContainer } from "@calcom/features/di/di";
|
||||
|
||||
import {
|
||||
type BookingAuditViewerService,
|
||||
moduleLoader as bookingAuditViewerServiceModule,
|
||||
} from "./BookingAuditViewerService.module";
|
||||
|
||||
const container = createContainer();
|
||||
|
||||
export function getBookingAuditViewerService() {
|
||||
bookingAuditViewerServiceModule.loadModule(container);
|
||||
|
||||
return container.get<BookingAuditViewerService>(bookingAuditViewerServiceModule.token);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { BookingAuditViewerService } from "@calcom/features/booking-audit/lib/service/BookingAuditViewerService";
|
||||
import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens";
|
||||
import { moduleLoader as bookingAuditRepositoryModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditRepository.module";
|
||||
import { moduleLoader as userRepositoryModuleLoader } from "@calcom/features/di/modules/User";
|
||||
|
||||
import { createModule, bindModuleToClassOnToken } from "../../di/di";
|
||||
|
||||
export const bookingAuditViewerServiceModule = createModule();
|
||||
const token = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_VIEWER_SERVICE;
|
||||
const moduleToken = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_VIEWER_SERVICE_MODULE;
|
||||
|
||||
export { BookingAuditViewerService }
|
||||
|
||||
const loadModule = bindModuleToClassOnToken({
|
||||
module: bookingAuditViewerServiceModule,
|
||||
moduleToken,
|
||||
token,
|
||||
classs: BookingAuditViewerService,
|
||||
depsMap: {
|
||||
bookingAuditRepository: bookingAuditRepositoryModuleLoader,
|
||||
userRepository: userRepositoryModuleLoader,
|
||||
},
|
||||
});
|
||||
|
||||
export const moduleLoader = {
|
||||
token,
|
||||
loadModule
|
||||
};
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
export const BOOKING_AUDIT_DI_TOKENS = {
|
||||
BOOKING_AUDIT_SERVICE: Symbol("BookingAuditService"),
|
||||
BOOKING_AUDIT_SERVICE_MODULE: Symbol("BookingAuditServiceModule"),
|
||||
BOOKING_AUDIT_VIEWER_SERVICE: Symbol("BookingAuditViewerService"),
|
||||
BOOKING_AUDIT_VIEWER_SERVICE_MODULE: Symbol("BookingAuditViewerServiceModule"),
|
||||
BOOKING_AUDIT_PRODUCER_SERVICE: Symbol("BookingAuditProducerService"),
|
||||
BOOKING_AUDIT_PRODUCER_SERVICE_MODULE: Symbol("BookingAuditProducerServiceModule"),
|
||||
BOOKING_AUDIT_TASK_CONSUMER: Symbol("BookingAuditTaskConsumer"),
|
||||
BOOKING_AUDIT_TASK_CONSUMER_MODULE: Symbol("BookingAuditTaskConsumerModule"),
|
||||
BOOKING_AUDIT_REPOSITORY: Symbol("BookingAuditRepository"),
|
||||
BOOKING_AUDIT_REPOSITORY_MODULE: Symbol("BookingAuditRepositoryModule"),
|
||||
ACTOR_REPOSITORY: Symbol("ActorRepository"),
|
||||
ACTOR_REPOSITORY_MODULE: Symbol("ActorRepositoryModule"),
|
||||
AUDIT_ACTOR_REPOSITORY: Symbol("AuditActorRepository"),
|
||||
AUDIT_ACTOR_REPOSITORY_MODULE: Symbol("AuditActorRepositoryModule"),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { StringChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
|
||||
|
||||
/**
|
||||
* Accepted Audit Action Service
|
||||
* Handles ACCEPTED action with per-action versioning
|
||||
*/
|
||||
|
||||
// Module-level because it is passed to IAuditActionService type outside the class scope
|
||||
const fieldsSchemaV1 = z.object({
|
||||
status: StringChangeSchema,
|
||||
});
|
||||
|
||||
export class AcceptedAuditActionService
|
||||
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "ACCEPTED" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = AcceptedAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = AcceptedAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<
|
||||
typeof AcceptedAuditActionService.latestFieldsSchema,
|
||||
typeof AcceptedAuditActionService.storedDataSchema
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: AcceptedAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: AcceptedAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(): Promise<TranslationWithParams> {
|
||||
return { key: "booking_audit_action.accepted" };
|
||||
}
|
||||
|
||||
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): AcceptedAuditDisplayData {
|
||||
const { fields } = storedData;
|
||||
return {
|
||||
previousStatus: fields.status.old ?? null,
|
||||
newStatus: fields.status.new ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type AcceptedAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
|
||||
export type AcceptedAuditDisplayData = {
|
||||
previousStatus: string | null;
|
||||
newStatus: string | null;
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { StringArrayChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
|
||||
|
||||
/**
|
||||
* Attendee Added Audit Action Service
|
||||
* Handles ATTENDEE_ADDED action with per-action versioning
|
||||
*/
|
||||
|
||||
// Module-level because it is passed to IAuditActionService type outside the class scope
|
||||
const fieldsSchemaV1 = z.object({
|
||||
attendees: StringArrayChangeSchema,
|
||||
});
|
||||
|
||||
export class AttendeeAddedAuditActionService
|
||||
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "ATTENDEE_ADDED" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = AttendeeAddedAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = AttendeeAddedAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<
|
||||
typeof AttendeeAddedAuditActionService.latestFieldsSchema,
|
||||
typeof AttendeeAddedAuditActionService.storedDataSchema
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: AttendeeAddedAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: AttendeeAddedAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(): Promise<TranslationWithParams> {
|
||||
return { key: "booking_audit_action.attendee_added" };
|
||||
}
|
||||
|
||||
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): AttendeeAddedAuditDisplayData {
|
||||
const { fields } = storedData;
|
||||
const previousAttendeesSet = new Set(fields.attendees.old ?? []);
|
||||
const addedAttendees = fields.attendees.new.filter(
|
||||
(email) => !previousAttendeesSet.has(email)
|
||||
);
|
||||
return {
|
||||
addedAttendees,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type AttendeeAddedAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
|
||||
export type AttendeeAddedAuditDisplayData = {
|
||||
addedAttendees: string[];
|
||||
};
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { BooleanChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
|
||||
|
||||
/**
|
||||
* Attendee No-Show Updated Audit Action Service
|
||||
* Handles ATTENDEE_NO_SHOW_UPDATED action with per-action versioning
|
||||
*/
|
||||
|
||||
// Module-level because it is passed to IAuditActionService type outside the class scope
|
||||
const fieldsSchemaV1 = z.object({
|
||||
noShowAttendee: BooleanChangeSchema,
|
||||
});
|
||||
|
||||
export class AttendeeNoShowUpdatedAuditActionService
|
||||
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "ATTENDEE_NO_SHOW_UPDATED" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = AttendeeNoShowUpdatedAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = AttendeeNoShowUpdatedAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<
|
||||
typeof AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema,
|
||||
typeof AttendeeNoShowUpdatedAuditActionService.storedDataSchema
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: AttendeeNoShowUpdatedAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(): Promise<TranslationWithParams> {
|
||||
return { key: "booking_audit_action.attendee_no_show_updated" };
|
||||
}
|
||||
|
||||
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): AttendeeNoShowUpdatedAuditDisplayData {
|
||||
const { fields } = storedData;
|
||||
return {
|
||||
noShowAttendee: fields.noShowAttendee.new,
|
||||
previousNoShowAttendee: fields.noShowAttendee.old ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type AttendeeNoShowUpdatedAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
|
||||
export type AttendeeNoShowUpdatedAuditDisplayData = {
|
||||
noShowAttendee: boolean;
|
||||
previousNoShowAttendee: boolean | null;
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { StringArrayChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
|
||||
|
||||
/**
|
||||
* Attendee Removed Audit Action Service
|
||||
* Handles ATTENDEE_REMOVED action with per-action versioning
|
||||
*/
|
||||
|
||||
// Module-level because it is passed to IAuditActionService type outside the class scope
|
||||
const fieldsSchemaV1 = z.object({
|
||||
attendees: StringArrayChangeSchema,
|
||||
});
|
||||
|
||||
export class AttendeeRemovedAuditActionService
|
||||
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "ATTENDEE_REMOVED" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = AttendeeRemovedAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = AttendeeRemovedAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<
|
||||
typeof AttendeeRemovedAuditActionService.latestFieldsSchema,
|
||||
typeof AttendeeRemovedAuditActionService.storedDataSchema
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: AttendeeRemovedAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: AttendeeRemovedAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(): Promise<TranslationWithParams> {
|
||||
return { key: "booking_audit_action.attendee_removed" };
|
||||
}
|
||||
|
||||
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): AttendeeRemovedAuditDisplayData {
|
||||
const { fields } = storedData;
|
||||
// Note: fields.attendees stores the state change (old -> new), not the removed attendees directly
|
||||
// old = attendees before removal, new = remaining attendees after removal
|
||||
const remainingAttendeesSet = new Set(fields.attendees.new ?? []);
|
||||
// Compute removed attendees: those in old but not in new (remaining)
|
||||
const removedAttendees = (fields.attendees.old ?? []).filter(
|
||||
(email) => !remainingAttendeesSet.has(email)
|
||||
);
|
||||
return {
|
||||
removedAttendees,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type AttendeeRemovedAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
|
||||
export type AttendeeRemovedAuditDisplayData = {
|
||||
removedAttendees: string[];
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Audit Action Service Helper
|
||||
*
|
||||
* Provides reusable utility methods for audit action services via composition.
|
||||
*
|
||||
* We use composition instead of inheritance for Action services so that services can evolve to v2, v3 independently without polluting a shared base class
|
||||
*/
|
||||
export class AuditActionServiceHelper<
|
||||
TLatestFieldsSchema extends z.ZodTypeAny,
|
||||
TStoredDataSchema extends z.ZodTypeAny
|
||||
> {
|
||||
private readonly latestFieldsSchema: TLatestFieldsSchema;
|
||||
private readonly latestVersion: number;
|
||||
private readonly storedDataSchema: TStoredDataSchema;
|
||||
|
||||
constructor({
|
||||
/**
|
||||
* The schema to validate against latest version
|
||||
*/
|
||||
latestFieldsSchema,
|
||||
latestVersion,
|
||||
/**
|
||||
* The schema to validate the stored data that could be of any version
|
||||
*/
|
||||
storedDataSchema,
|
||||
}: {
|
||||
latestFieldsSchema: TLatestFieldsSchema;
|
||||
latestVersion: number;
|
||||
storedDataSchema: TStoredDataSchema;
|
||||
}) {
|
||||
this.latestFieldsSchema = latestFieldsSchema;
|
||||
this.latestVersion = latestVersion;
|
||||
this.storedDataSchema = storedDataSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse input fields with the latest fields schema and wrap with version
|
||||
*/
|
||||
getVersionedData(fields: unknown): { version: number; fields: z.infer<TLatestFieldsSchema> } {
|
||||
const parsed = this.latestFieldsSchema.parse(fields);
|
||||
return {
|
||||
version: this.latestVersion,
|
||||
fields: parsed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse stored audit record (includes version wrapper)
|
||||
* Accepts any version defined in allVersionsDataSchema (for backward compatibility)
|
||||
*/
|
||||
parseStored(data: unknown): z.infer<TStoredDataSchema> {
|
||||
return this.storedDataSchema.parse(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract version from stored data
|
||||
*/
|
||||
getVersion(data: unknown): number {
|
||||
const parsed = z.object({ version: z.number() }).parse(data);
|
||||
return parsed.version;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { StringChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
|
||||
|
||||
/**
|
||||
* Cancelled Audit Action Service
|
||||
* Handles CANCELLED action with per-action versioning
|
||||
*/
|
||||
|
||||
// Module-level because it is passed to IAuditActionService type outside the class scope
|
||||
const fieldsSchemaV1 = z.object({
|
||||
cancellationReason: StringChangeSchema,
|
||||
cancelledBy: StringChangeSchema,
|
||||
status: StringChangeSchema,
|
||||
});
|
||||
|
||||
export class CancelledAuditActionService
|
||||
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "CANCELLED" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = CancelledAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = CancelledAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<
|
||||
typeof CancelledAuditActionService.latestFieldsSchema,
|
||||
typeof CancelledAuditActionService.storedDataSchema
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: CancelledAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: CancelledAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(): Promise<TranslationWithParams> {
|
||||
return { key: "booking_audit_action.cancelled" };
|
||||
}
|
||||
|
||||
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): CancelledAuditDisplayData {
|
||||
const { fields } = storedData;
|
||||
return {
|
||||
cancellationReason: fields.cancellationReason.new ?? null,
|
||||
previousReason: fields.cancellationReason.old ?? null,
|
||||
cancelledBy: fields.cancelledBy.new ?? null,
|
||||
previousCancelledBy: fields.cancelledBy.old ?? null,
|
||||
previousStatus: fields.status.old ?? null,
|
||||
newStatus: fields.status.new ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type CancelledAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
|
||||
export type CancelledAuditDisplayData = {
|
||||
cancellationReason: string | null;
|
||||
previousReason: string | null;
|
||||
cancelledBy: string | null;
|
||||
previousCancelledBy: string | null;
|
||||
previousStatus: string | null;
|
||||
newStatus: string | null;
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { z } from "zod";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
|
||||
|
||||
/**
|
||||
* Created Audit Action Service
|
||||
*
|
||||
* Note: CREATED action captures initial state, so it doesn't use { old, new } pattern
|
||||
*/
|
||||
|
||||
// Module-level because it is passed to IAuditActionService type outside the class scope
|
||||
const fieldsSchemaV1 = z.object({
|
||||
startTime: z.number(),
|
||||
endTime: z.number(),
|
||||
status: z.nativeEnum(BookingStatus),
|
||||
});
|
||||
|
||||
export class CreatedAuditActionService implements IAuditActionService<
|
||||
typeof fieldsSchemaV1,
|
||||
typeof fieldsSchemaV1
|
||||
> {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "CREATED" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = CreatedAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = CreatedAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<typeof CreatedAuditActionService.latestFieldsSchema, typeof CreatedAuditActionService.storedDataSchema>;
|
||||
|
||||
constructor() {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: CreatedAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: CreatedAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(): Promise<TranslationWithParams> {
|
||||
return { key: "booking_audit_action.created" };
|
||||
}
|
||||
|
||||
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): CreatedAuditDisplayData {
|
||||
const { fields } = storedData;
|
||||
return {
|
||||
startTime: new Date(fields.startTime).toISOString(),
|
||||
endTime: new Date(fields.endTime).toISOString(),
|
||||
status: fields.status,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type CreatedAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
|
||||
export type CreatedAuditDisplayData = {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
status: BookingStatus;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { BooleanChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
|
||||
|
||||
/**
|
||||
* Host No-Show Updated Audit Action Service
|
||||
* Handles HOST_NO_SHOW_UPDATED action with per-action versioning
|
||||
*/
|
||||
|
||||
// Module-level because it is passed to IAuditActionService type outside the class scope
|
||||
const fieldsSchemaV1 = z.object({
|
||||
noShowHost: BooleanChangeSchema,
|
||||
});
|
||||
|
||||
export class HostNoShowUpdatedAuditActionService
|
||||
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "HOST_NO_SHOW_UPDATED" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = HostNoShowUpdatedAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = HostNoShowUpdatedAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<
|
||||
typeof HostNoShowUpdatedAuditActionService.latestFieldsSchema,
|
||||
typeof HostNoShowUpdatedAuditActionService.storedDataSchema
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: HostNoShowUpdatedAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: HostNoShowUpdatedAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(): Promise<TranslationWithParams> {
|
||||
return { key: "booking_audit_action.host_no_show_updated" };
|
||||
}
|
||||
|
||||
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): HostNoShowUpdatedAuditDisplayData {
|
||||
const { fields } = storedData;
|
||||
return {
|
||||
noShowHost: fields.noShowHost.new,
|
||||
previousNoShowHost: fields.noShowHost.old ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type HostNoShowUpdatedAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
|
||||
export type HostNoShowUpdatedAuditDisplayData = {
|
||||
noShowHost: boolean;
|
||||
previousNoShowHost: boolean | null;
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Represents a component that can be interpolated into translations
|
||||
* Used with react-i18next Trans component for proper i18n support (RTL, word order, etc.)
|
||||
*/
|
||||
export type TranslationComponent = {
|
||||
type: "link";
|
||||
href: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a translation key with optional interpolation params and components
|
||||
* Used for dynamic display titles that need to be translated with context
|
||||
* Components are used for clickable links within translations (e.g., "Rescheduled to <1>New Booking</1>")
|
||||
*/
|
||||
export type TranslationWithParams = {
|
||||
key: string;
|
||||
params?: Record<string, string | number>;
|
||||
components?: TranslationComponent[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Interface for Audit Action Services
|
||||
*
|
||||
* Defines the contract that all audit action services must implement.
|
||||
* Uses composition with AuditActionServiceHelper to provide common functionality
|
||||
* while maintaining type safety and flexibility for versioned schemas.
|
||||
*
|
||||
* @template TLatestFieldsSchema - The Zod schema type for the latest version's audit fields (write operations)
|
||||
* @template TStoredFieldsSchema - The Zod schema type for all supported versions' audit fields (read operations, union type)
|
||||
*/
|
||||
export interface IAuditActionService<
|
||||
TLatestFieldsSchema extends z.ZodTypeAny,
|
||||
TStoredFieldsSchema extends z.ZodTypeAny
|
||||
> {
|
||||
/**
|
||||
* Current version number for this action type
|
||||
*/
|
||||
readonly VERSION: number;
|
||||
|
||||
/**
|
||||
* Parse given fields against latest schema and wrap with version
|
||||
* @param fields - Raw input fields (just the audit fields)
|
||||
* @returns Parsed data with version wrapper { version, fields }
|
||||
*/
|
||||
getVersionedData(fields: unknown): { version: number; fields: z.infer<TLatestFieldsSchema> };
|
||||
|
||||
/**
|
||||
* Parse stored audit record (includes version wrapper)
|
||||
* Accepts data from ANY supported version (not just latest) for backward compatibility
|
||||
* @param data - Stored data from database (can be any version)
|
||||
* @returns Parsed stored data { version, fields } - version may differ from current VERSION
|
||||
*/
|
||||
parseStored(data: unknown): { version: number; fields: z.infer<TStoredFieldsSchema> };
|
||||
|
||||
/**
|
||||
* Extract version number from stored data
|
||||
* @param data - Stored data from database
|
||||
* @returns Version number
|
||||
*/
|
||||
getVersion(data: unknown): number;
|
||||
|
||||
/**
|
||||
* Get flattened JSON data for display (fields only, no version wrapper)
|
||||
* Optional - implement only if custom display formatting is needed
|
||||
* @param storedData - Parsed stored data { version, fields }
|
||||
* @returns The fields object without version wrapper and we decide what fields to show to the client
|
||||
*/
|
||||
getDisplayJson?(storedData: { version: number; fields: z.infer<TStoredFieldsSchema> }): unknown;
|
||||
|
||||
/**
|
||||
* Get the display title for the audit action
|
||||
* Returns a translation key with optional interpolation params for dynamic titles
|
||||
* (e.g., "Booking reassigned to John Doe" instead of just "Reassignment")
|
||||
* @param storedData - Parsed stored data { version, fields }
|
||||
* @returns Translation key with optional interpolation params
|
||||
*/
|
||||
getDisplayTitle(storedData: { version: number; fields: z.infer<TStoredFieldsSchema> }): Promise<TranslationWithParams>;
|
||||
|
||||
/**
|
||||
* Migrate old version data to latest version
|
||||
*
|
||||
* Required method that validates and migrates data to latest schema version.
|
||||
* For V1-only actions, simply validates and returns with isMigrated=false.
|
||||
* For multi-version actions, checks version and transforms if needed.
|
||||
*
|
||||
* @param data - Data from task payload (any supported version)
|
||||
* @returns Migration result with status and latest data
|
||||
*/
|
||||
migrateToLatest(data: unknown): {
|
||||
/**
|
||||
* True if migration was performed, false if already latest
|
||||
*/
|
||||
isMigrated: boolean;
|
||||
/**
|
||||
* Always set, either migrated or original
|
||||
*/
|
||||
latestData: z.infer<TLatestFieldsSchema>;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { getHumanReadableLocationValue } from "@calcom/app-store/locations";
|
||||
import { StringChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
/**
|
||||
* Location Changed Audit Action Service
|
||||
* Handles LOCATION_CHANGED action with per-action versioning
|
||||
*/
|
||||
|
||||
// Module-level because it is passed to IAuditActionService type outside the class scope
|
||||
const fieldsSchemaV1 = z.object({
|
||||
location: StringChangeSchema,
|
||||
});
|
||||
|
||||
export class LocationChangedAuditActionService
|
||||
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "LOCATION_CHANGED" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = LocationChangedAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = LocationChangedAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<
|
||||
typeof LocationChangedAuditActionService.latestFieldsSchema,
|
||||
typeof LocationChangedAuditActionService.storedDataSchema
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: LocationChangedAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: LocationChangedAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): Promise<TranslationWithParams> {
|
||||
const { fields } = storedData;
|
||||
// TODO: Ideally we want to translate the location label to the user's locale
|
||||
// We currently don't accept requesting user's translate fn here, fix it later.
|
||||
const t = await getTranslation("en", "common");
|
||||
|
||||
const fromLocation = getHumanReadableLocationValue(fields.location.old, t);
|
||||
const toLocation = getHumanReadableLocationValue(fields.location.new, t);
|
||||
|
||||
return {
|
||||
key: "booking_audit_action.location_changed_from_to",
|
||||
params: { fromLocation, toLocation },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type LocationChangedAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
@@ -0,0 +1,91 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { StringChangeSchema, NumberChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
|
||||
|
||||
/**
|
||||
* Reassignment Audit Action Service
|
||||
* Handles REASSIGNMENT action with per-action versioning
|
||||
*/
|
||||
|
||||
// Module-level because it is passed to IAuditActionService type outside the class scope
|
||||
const fieldsSchemaV1 = z.object({
|
||||
assignedToId: NumberChangeSchema,
|
||||
assignedById: NumberChangeSchema,
|
||||
reassignmentReason: StringChangeSchema,
|
||||
userPrimaryEmail: StringChangeSchema.optional(),
|
||||
title: StringChangeSchema.optional(),
|
||||
});
|
||||
|
||||
export class ReassignmentAuditActionService
|
||||
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "REASSIGNMENT" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = ReassignmentAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = ReassignmentAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<
|
||||
typeof ReassignmentAuditActionService.latestFieldsSchema,
|
||||
typeof ReassignmentAuditActionService.storedDataSchema
|
||||
>;
|
||||
|
||||
constructor(private userRepository: UserRepository) {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: ReassignmentAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: ReassignmentAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): Promise<TranslationWithParams> {
|
||||
const { fields } = storedData;
|
||||
const user = await this.userRepository.findById({ id: fields.assignedToId.new });
|
||||
const reassignedToName = user?.name || "Unknown";
|
||||
return {
|
||||
key: "booking_audit_action.booking_reassigned_to_host",
|
||||
params: { host: reassignedToName },
|
||||
};
|
||||
}
|
||||
|
||||
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): ReassignmentAuditDisplayData {
|
||||
const { fields } = storedData;
|
||||
return {
|
||||
newAssignedToId: fields.assignedToId.new,
|
||||
reassignmentReason: fields.reassignmentReason.new ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type ReassignmentAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
|
||||
export type ReassignmentAuditDisplayData = {
|
||||
newAssignedToId: number;
|
||||
reassignmentReason: string | null;
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { StringChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
|
||||
|
||||
/**
|
||||
* Rejected Audit Action Service
|
||||
* Handles REJECTED action with per-action versioning
|
||||
*/
|
||||
|
||||
// Module-level because it is passed to IAuditActionService type outside the class scope
|
||||
const fieldsSchemaV1 = z.object({
|
||||
rejectionReason: StringChangeSchema,
|
||||
status: StringChangeSchema,
|
||||
});
|
||||
|
||||
export class RejectedAuditActionService
|
||||
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "REJECTED" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = RejectedAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = RejectedAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<
|
||||
typeof RejectedAuditActionService.latestFieldsSchema,
|
||||
typeof RejectedAuditActionService.storedDataSchema
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: RejectedAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: RejectedAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(): Promise<TranslationWithParams> {
|
||||
return { key: "booking_audit_action.rejected" };
|
||||
}
|
||||
|
||||
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): RejectedAuditDisplayData {
|
||||
const { fields } = storedData;
|
||||
return {
|
||||
rejectionReason: fields.rejectionReason.new ?? null,
|
||||
previousReason: fields.rejectionReason.old ?? null,
|
||||
previousStatus: fields.status.old ?? null,
|
||||
newStatus: fields.status.new ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type RejectedAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
|
||||
export type RejectedAuditDisplayData = {
|
||||
rejectionReason: string | null;
|
||||
previousReason: string | null;
|
||||
previousStatus: string | null;
|
||||
newStatus: string | null;
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { StringChangeSchema, BooleanChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
|
||||
|
||||
/**
|
||||
* Reschedule Requested Audit Action Service
|
||||
* Handles RESCHEDULE_REQUESTED action with per-action versioning
|
||||
*/
|
||||
|
||||
// Module-level because it is passed to IAuditActionService type outside the class scope
|
||||
const fieldsSchemaV1 = z.object({
|
||||
cancellationReason: StringChangeSchema,
|
||||
cancelledBy: StringChangeSchema,
|
||||
rescheduled: BooleanChangeSchema.optional(),
|
||||
});
|
||||
|
||||
export class RescheduleRequestedAuditActionService
|
||||
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "RESCHEDULE_REQUESTED" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = RescheduleRequestedAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = RescheduleRequestedAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<
|
||||
typeof RescheduleRequestedAuditActionService.latestFieldsSchema,
|
||||
typeof RescheduleRequestedAuditActionService.storedDataSchema
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: RescheduleRequestedAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: RescheduleRequestedAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(): Promise<TranslationWithParams> {
|
||||
return { key: "booking_audit_action.reschedule_requested" };
|
||||
}
|
||||
|
||||
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): RescheduleRequestedAuditDisplayData {
|
||||
const { fields } = storedData;
|
||||
return {
|
||||
reason: fields.cancellationReason.new ?? null,
|
||||
requestedBy: fields.cancelledBy.new ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type RescheduleRequestedAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
|
||||
export type RescheduleRequestedAuditDisplayData = {
|
||||
reason: string | null;
|
||||
requestedBy: string | null;
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { StringChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
|
||||
|
||||
/**
|
||||
* Rescheduled Audit Action Service
|
||||
* Handles RESCHEDULED action with per-action versioning
|
||||
*/
|
||||
|
||||
// Module-level because it is passed to IAuditActionService type outside the class scope
|
||||
const fieldsSchemaV1 = z.object({
|
||||
startTime: StringChangeSchema,
|
||||
endTime: StringChangeSchema,
|
||||
rescheduledToUid: StringChangeSchema,
|
||||
});
|
||||
|
||||
export class RescheduledAuditActionService
|
||||
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "RESCHEDULED" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = RescheduledAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = RescheduledAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<
|
||||
typeof RescheduledAuditActionService.latestFieldsSchema,
|
||||
typeof RescheduledAuditActionService.storedDataSchema
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: RescheduledAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: RescheduledAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): Promise<TranslationWithParams> {
|
||||
const rescheduledToUid = storedData.fields.rescheduledToUid.new;
|
||||
return {
|
||||
key: "booking_audit_action.rescheduled",
|
||||
components: rescheduledToUid ? [{ type: "link", href: `/booking/${rescheduledToUid}` }] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): RescheduledAuditDisplayData {
|
||||
const { fields } = storedData;
|
||||
return {
|
||||
previousStartTime: fields.startTime.old ?? null,
|
||||
newStartTime: fields.startTime.new ?? null,
|
||||
previousEndTime: fields.endTime.old ?? null,
|
||||
newEndTime: fields.endTime.new ?? null,
|
||||
rescheduledToUid: fields.rescheduledToUid.new ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the rescheduled log that created a specific booking
|
||||
* by matching the rescheduledToUid field with the target booking UID
|
||||
* @param rescheduledLogs - Array of rescheduled audit logs to search through
|
||||
* @param rescheduledToBookingUid - The UID of the booking that was created from the reschedule
|
||||
* @returns The matching log or null if not found
|
||||
*/
|
||||
getMatchingLog<T extends { data: unknown }>({
|
||||
rescheduledLogs,
|
||||
rescheduledToBookingUid,
|
||||
}: {
|
||||
rescheduledLogs: T[];
|
||||
rescheduledToBookingUid: string;
|
||||
}): T | null {
|
||||
return rescheduledLogs.find((log) => {
|
||||
const parsedData = this.parseStored(log.data);
|
||||
return parsedData.fields.rescheduledToUid.new === rescheduledToBookingUid;
|
||||
}) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
export type RescheduledAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
|
||||
export type RescheduledAuditDisplayData = {
|
||||
previousStartTime: string | null;
|
||||
newStartTime: string | null;
|
||||
previousEndTime: string | null;
|
||||
newEndTime: string | null;
|
||||
rescheduledToUid: string | null;
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Common change schemas for audit data
|
||||
* These represent old -> new value transitions
|
||||
*/
|
||||
|
||||
export const StringChangeSchema = z.object({
|
||||
old: z.string().nullable(),
|
||||
new: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const BooleanChangeSchema = z.object({
|
||||
old: z.boolean().nullable(),
|
||||
new: z.boolean(),
|
||||
});
|
||||
|
||||
export const StringArrayChangeSchema = z.object({
|
||||
old: z.array(z.string()).nullable(),
|
||||
new: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const NumberChangeSchema = z.object({
|
||||
old: z.number().nullable(),
|
||||
new: z.number(),
|
||||
});
|
||||
|
||||
@@ -13,9 +13,8 @@ type AuditActor = {
|
||||
export interface IAuditActorRepository {
|
||||
findByUserUuid(userUuid: string): Promise<AuditActor | null>;
|
||||
findSystemActorOrThrow(): Promise<AuditActor>;
|
||||
// TODO: To be implemented in followup PR
|
||||
// upsertUserActor(userUuid: string): Promise<AuditActor>;
|
||||
// upsertGuestActor(email: string, name?: string, phone?: string): Promise<AuditActor>;
|
||||
// findByAttendeeId(attendeeId: number): Promise<AuditActor | null>;
|
||||
createIfNotExistsUserActor(params: { userUuid: string }): Promise<AuditActor>;
|
||||
createIfNotExistsGuestActor(email: string | null, name: string | null, phone: string | null): Promise<AuditActor>;
|
||||
findByAttendeeId(attendeeId: number): Promise<AuditActor | null>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { JsonValue } from "@calcom/types/Json";
|
||||
import type { AuditActorType } from "./IAuditActorRepository";
|
||||
|
||||
export type BookingAuditType = "RECORD_CREATED" | "RECORD_UPDATED" | "RECORD_DELETED"
|
||||
|
||||
/**
|
||||
* Booking audit actions track changes to bookings throughout their lifecycle.
|
||||
*
|
||||
* Note: PENDING and AWAITING_HOST represent initial booking states, not transitions.
|
||||
* They are reserved in the enum for potential future use but should not appear in audit logs.
|
||||
* Use the CREATED action to capture initial booking status instead.
|
||||
*/
|
||||
export type BookingAuditAction = "CREATED" | "CANCELLED" | "ACCEPTED" | "REJECTED" | "PENDING" | "AWAITING_HOST" | "RESCHEDULED" | "ATTENDEE_ADDED" | "ATTENDEE_REMOVED" | "REASSIGNMENT" | "LOCATION_CHANGED" | "HOST_NO_SHOW_UPDATED" | "ATTENDEE_NO_SHOW_UPDATED" | "RESCHEDULE_REQUESTED"
|
||||
export type BookingAuditCreateInput = {
|
||||
bookingUid: string;
|
||||
@@ -23,10 +32,27 @@ type BookingAudit = {
|
||||
data: JsonValue;
|
||||
}
|
||||
|
||||
export type BookingAuditWithActor = BookingAudit & {
|
||||
actor: {
|
||||
id: string;
|
||||
type: AuditActorType;
|
||||
userUuid: string | null;
|
||||
attendeeId: number | null;
|
||||
name: string | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IBookingAuditRepository {
|
||||
/**
|
||||
* Creates a new booking audit record
|
||||
*/
|
||||
create(bookingAudit: BookingAuditCreateInput): Promise<BookingAudit>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all audit logs for a specific booking
|
||||
* @param bookingUid - The unique identifier of the booking
|
||||
* @returns Array of audit logs with actor information, ordered by timestamp DESC
|
||||
*/
|
||||
findAllForBooking(bookingUid: string): Promise<BookingAuditWithActor[]>;
|
||||
}
|
||||
|
||||
@@ -25,5 +25,87 @@ export class PrismaAuditActorRepository implements IAuditActorRepository {
|
||||
|
||||
return actor;
|
||||
}
|
||||
|
||||
async createIfNotExistsUserActor(params: { userUuid: string }) {
|
||||
return this.deps.prismaClient.auditActor.upsert({
|
||||
where: { userUuid: params.userUuid },
|
||||
create: {
|
||||
type: "USER",
|
||||
userUuid: params.userUuid,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
async createIfNotExistsGuestActor(email: string | null, name: string | null, phone: string | null) {
|
||||
const normalizedEmail = email && email.trim() !== "" ? email : null;
|
||||
const normalizedName = name && name.trim() !== "" ? name : null;
|
||||
const normalizedPhone = phone && phone.trim() !== "" ? phone : null;
|
||||
|
||||
// If all fields are null, we can't use upsert (no unique constraint), so just create a new record
|
||||
if (!normalizedEmail && !normalizedPhone) {
|
||||
return this.deps.prismaClient.auditActor.create({
|
||||
data: {
|
||||
type: "GUEST",
|
||||
email: null,
|
||||
name: normalizedName,
|
||||
phone: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// First try to find by email if email exists
|
||||
if (normalizedEmail) {
|
||||
const existingByEmail = await this.deps.prismaClient.auditActor.findUnique({
|
||||
where: { email: normalizedEmail },
|
||||
});
|
||||
|
||||
if (existingByEmail) {
|
||||
// Update existing record found by email
|
||||
return this.deps.prismaClient.auditActor.update({
|
||||
where: { email: normalizedEmail },
|
||||
data: {
|
||||
name: normalizedName ?? undefined,
|
||||
phone: normalizedPhone ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If not found by email and phone exists, try to find by phone
|
||||
if (normalizedPhone) {
|
||||
const existingByPhone = await this.deps.prismaClient.auditActor.findUnique({
|
||||
where: { phone: normalizedPhone },
|
||||
});
|
||||
|
||||
if (existingByPhone) {
|
||||
// Update existing record found by phone
|
||||
return this.deps.prismaClient.auditActor.update({
|
||||
where: { phone: normalizedPhone },
|
||||
data: {
|
||||
email: normalizedEmail ?? undefined,
|
||||
name: normalizedName ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Not found by either email or phone, create new record
|
||||
return this.deps.prismaClient.auditActor.create({
|
||||
data: {
|
||||
type: "GUEST",
|
||||
email: normalizedEmail,
|
||||
name: normalizedName,
|
||||
phone: normalizedPhone,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findByAttendeeId(attendeeId: number) {
|
||||
return this.deps.prismaClient.auditActor.findUnique({
|
||||
where: { attendeeId },
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
import type { IBookingAuditRepository, BookingAuditCreateInput } from "./IBookingAuditRepository";
|
||||
import type { IBookingAuditRepository, BookingAuditCreateInput, BookingAuditWithActor } from "./IBookingAuditRepository";
|
||||
|
||||
type Dependencies = {
|
||||
prismaClient: PrismaClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe actor fields to expose in audit logs
|
||||
* Excludes PII fields like email and phone that aren't needed for display
|
||||
*/
|
||||
const safeActorSelect = {
|
||||
id: true,
|
||||
type: true,
|
||||
userUuid: true,
|
||||
attendeeId: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
} as const;
|
||||
|
||||
export class PrismaBookingAuditRepository implements IBookingAuditRepository {
|
||||
constructor(private readonly deps: Dependencies) { }
|
||||
|
||||
@@ -20,5 +34,21 @@ export class PrismaBookingAuditRepository implements IBookingAuditRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findAllForBooking(bookingUid: string): Promise<BookingAuditWithActor[]> {
|
||||
return this.deps.prismaClient.bookingAudit.findMany({
|
||||
where: {
|
||||
bookingUid,
|
||||
},
|
||||
include: {
|
||||
actor: {
|
||||
select: safeActorSelect,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
timestamp: "desc",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Actor } from "../../../bookings/lib/types/actor";
|
||||
import type { BookingAuditTaskProducerActionData } from "../types/bookingAuditTask";
|
||||
|
||||
/**
|
||||
* BookingAuditProducerService Interface
|
||||
*
|
||||
* Producer abstraction for queueing booking audit tasks.
|
||||
* Allows for multiple implementations (e.g., Tasker, Trigger.dev).
|
||||
*
|
||||
* Implementations:
|
||||
* - BookingAuditTaskerProducerService: Uses Tasker for local/background jobs
|
||||
* - BookingAuditTriggerProducerService: (Future) Uses Trigger.dev for cloud jobs
|
||||
*/
|
||||
export interface BookingAuditProducerService {
|
||||
/**
|
||||
* Queue Audit - Push audit task for async processing
|
||||
*
|
||||
* Single method that handles all audit types using discriminated union.
|
||||
* TypeScript ensures type safety between action and data.
|
||||
*
|
||||
* @param bookingUid - The booking UID
|
||||
* @param actor - The actor performing the action
|
||||
* @param organizationId - The organization ID (null for non-org bookings)
|
||||
* @param actionData - Discriminated union of action type and its data
|
||||
*
|
||||
* @example
|
||||
* await bookingAuditProducerService.queueAudit(bookingUid, actor, organizationId, {
|
||||
* action: "CREATED",
|
||||
* data: { startTime, endTime, status }
|
||||
* });
|
||||
*/
|
||||
queueAudit(bookingUid: string, actor: Actor, organizationId: number | null, actionData: BookingAuditTaskProducerActionData): Promise<void>;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user