feat: Add support to audit and view audit log with Booking CREATED action (#25468)

## What does this PR do?

This PR introduces the booking audit infrastructure with a viewer service to fetch, enrich, and format audit logs. This is the first iteration that includes only the CREATED action with versioned schema

Note: Additional audit actions, UI improvements to match with Figma will be taken care of in followup PRs, to ensure the PR size remains as small as possible(as it is large enough already)

Demo - Loom - https://www.loom.com/share/22c2f38b052b480b8be3716e1608eaf6

### Key Changes

**New Booking Audit Package** (`packages/features/booking-audit/`):

- `BookingAuditViewerService` - Fetches, enriches, and formats audit logs for display
- `BookingAuditTaskConsumer` - Processes audit tasks asynchronously via Tasker
- `CreatedAuditActionService` (v1) - Handles RECORD_CREATED action with versioned schema

**Repository Layer**:

- `BookingAuditRepository` - CRUD operations for audit records
- `AuditActorRepository` - Actor management (users, guests, system)
- `UserRepository.findByUuid()` - User lookup for actor enrichment

**UI Components**:

- New page at `/booking/logs/[bookinguid]` for viewing audit history
- Filterable audit log list with search, type, and actor filters
- Expandable log entries showing detailed change information

**Infrastructure**:

- `bookingAudit` Tasker task type for async processing
- `booking-audit` feature flag (disabled by default)
- tRPC endpoint `viewer.bookings.getAuditLogs`

### Updates since last revision

- **Refactored `Task` class to `TaskRepository`**: Converted all static methods to instance methods following the repository pattern. A singleton `Task` is exported for backward compatibility, so existing call sites continue to work unchanged.

### Important Notes for Reviewers

- **Permission check is TODO**: `checkPermissions()` in `BookingAuditViewerService` is not yet implemented
- **Only CREATED action supported**: Other actions will throw an error - additional actions will be added iteratively in follow-up PRs
- **Feature flag controlled**: System is behind `booking-audit` flag, disabled by default
- **UI strings need i18n**: Some UI text in `booking-logs-view.tsx` may need translation

### Human Review Checklist

- [ ] Verify permission enforcement strategy for audit log access
- [ ] Review DI wiring in module files
- [ ] Confirm error handling in `BookingAuditTaskConsumer` is appropriate for retry scenarios
- [ ] Check if UI strings need to be added to translation files
- [ ] Verify `TaskRepository` refactoring maintains backward compatibility (singleton export `Task` should work for all existing call sites)

## Mandatory Tasks (DO NOT REMOVE)

- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected).
- [x] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). N/A - internal infrastructure
- [x] I confirm automated tests are in place that prove my fix is effective or that my feature works.

## How should this be tested?

Run the integration tests:

```bash
TZ=UTC yarn test packages/features/booking-audit/lib/service/BookingAuditViewerService.integration-test.ts
TZ=UTC yarn test packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.integration-test.ts
```

To test the UI:

1. Enable the `booking-audit` feature flag in the database
2. Create a booking (only CREATED action is supported in this PR)
3. Navigate to `/booking/logs/{bookingUid}` to view the audit trail

## Checklist

- [x] I have read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- [x] My code follows the style guidelines of this project
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have checked if my changes generate no new warnings

<!-- Link to Devin run: https://app.devin.ai/sessions/a8ae61c253b549429401c9651dcbcf44 -->
<!-- Requested by: hariom@cal.com (@hariombalhara) -->
This commit is contained in:
Hariom Balhara authored and GitHub committed 2025-12-08 17:38:58 +05:30
1 parent fadd824d12
commit 0fc26f782f
53 files changed
+2302 -337

No files matched your search

@@ -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;
}
}
@@ -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;
@@ -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>
);
}
@@ -4093,5 +4093,17 @@
"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",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+39 -27
View File
@@ -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
};
+8 -4
View File
@@ -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,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,80 @@
import { z } from "zod";
import { BookingStatus } from "@calcom/prisma/enums";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService } 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";
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 };
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): CreatedAuditDisplayData {
return {
startTime: new Date(storedData.fields.startTime).toISOString(),
endTime: new Date(storedData.fields.endTime).toISOString(),
status: storedData.fields.status,
};
}
}
export type CreatedAuditData = z.infer<typeof fieldsSchemaV1>;
export type CreatedAuditDisplayData = {
startTime: string;
endTime: string;
status: BookingStatus;
};
@@ -0,0 +1,71 @@
import { z } from "zod";
/**
* 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)
* @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;
/**
* 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>;
};
}
@@ -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>;
}
@@ -1,67 +0,0 @@
import type { JsonValue } from "@calcom/types/Json";
import logger from "@calcom/lib/logger";
import type { IBookingAuditRepository, BookingAuditType, BookingAuditAction } from "../repository/IBookingAuditRepository";
import type { IAuditActorRepository } from "../repository/IAuditActorRepository";
import { safeStringify } from "@calcom/lib/safeStringify";
interface BookingAuditServiceDeps {
bookingAuditRepository: IBookingAuditRepository;
actorRepository: IAuditActorRepository;
}
type CreateBookingAuditInput = {
bookingUid: string;
actorId: string;
type: BookingAuditType;
action: BookingAuditAction;
data: JsonValue;
timestamp: Date; // Required: actual time of the booking change (business event)
};
type BookingAudit = {
id: string;
bookingUid: string;
actorId: string;
type: BookingAuditType;
action: BookingAuditAction;
timestamp: Date;
createdAt: Date;
updatedAt: Date;
data: JsonValue;
};
/**
* BookingAuditService - Central service for all booking audit operations
* Handles both write (audit creation) and read (display) operations
* Each action service manages its own schema versioning
*/
export class BookingAuditService {
private readonly bookingAuditRepository: IBookingAuditRepository;
constructor(private readonly deps: BookingAuditServiceDeps) {
this.bookingAuditRepository = deps.bookingAuditRepository;
}
/**
* TODO: TO be integrated with public methods in followup PR
*/
async createAuditRecord(input: CreateBookingAuditInput): Promise<BookingAudit> {
// Log only non-sensitive metadata, excluding the data field which may contain PII
logger.info("Creating audit record", safeStringify({
bookingUid: input.bookingUid,
actorId: input.actorId,
type: input.type,
action: input.action,
timestamp: input.timestamp,
}));
return this.bookingAuditRepository.create({
bookingUid: input.bookingUid,
actorId: input.actorId,
type: input.type,
action: input.action,
timestamp: input.timestamp,
data: input.data,
});
}
}
@@ -0,0 +1,300 @@
import type { JsonValue } from "@calcom/types/Json";
import logger from "@calcom/lib/logger";
import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface";
import type { Actor } from "../../../bookings/lib/types/actor";
import type { BookingAuditTaskConsumerPayload } from "../types/bookingAuditTask";
import { BookingAuditTaskConsumerPayloadSchema } from "../types/bookingAuditTask";
import { CreatedAuditActionService, type CreatedAuditData } from "../actions/CreatedAuditActionService";
import type { IBookingAuditRepository, BookingAuditType, BookingAuditAction } from "../repository/IBookingAuditRepository";
import type { IAuditActorRepository } from "../repository/IAuditActorRepository";
import { safeStringify } from "@calcom/lib/safeStringify";
interface BookingAuditTaskConsumerDeps {
bookingAuditRepository: IBookingAuditRepository;
auditActorRepository: IAuditActorRepository;
featuresRepository: IFeaturesRepository;
}
type CreateBookingAuditInput = {
bookingUid: string;
actorId: string;
type: BookingAuditType;
action: BookingAuditAction;
data: JsonValue;
timestamp: Date; // Required: actual time of the booking change (business event)
};
type BookingAudit = {
id: string;
bookingUid: string;
actorId: string;
type: BookingAuditType;
action: BookingAuditAction;
timestamp: Date;
createdAt: Date;
updatedAt: Date;
data: JsonValue;
};
/**
* BookingAuditTaskConsumer - Task consumer for processing booking audit tasks
* Handles all audit processing logic including feature flag checks and routing to action handlers
* Designed to be deployed separately (e.g., as trigger.dev job) with minimal dependencies
*
* Note: PENDING and AWAITING_HOST actions are intentionally not implemented.
* These represent initial booking states captured by the CREATED action.
*/
export class BookingAuditTaskConsumer {
private readonly createdActionService: CreatedAuditActionService;
private readonly bookingAuditRepository: IBookingAuditRepository;
private readonly auditActorRepository: IAuditActorRepository;
private readonly featuresRepository: IFeaturesRepository;
constructor(private readonly deps: BookingAuditTaskConsumerDeps) {
this.bookingAuditRepository = deps.bookingAuditRepository;
this.auditActorRepository = deps.auditActorRepository;
this.featuresRepository = deps.featuresRepository;
// Each service instantiates its own helper with its specific schema
this.createdActionService = new CreatedAuditActionService();
}
/**
* Process Audit Task - Entry point for task handler
*
* This method handles:
* 1. Schema validation (accepts all supported versions)
* 2. Feature flag checks
* 3. Schema migration to latest version if needed
* 4. Routing to appropriate action handlers
*
* Schema Migration:
* - Validates payload against all supported schema versions
* - Migrates old versions to latest at task boundary
* - Updates task payload in DB if migration occurs
* - Ensures retries always use latest schema version
*
* @param payload - The booking audit task payload (unknown type, will be validated)
* @param taskId - Optional task ID for updating migrated payload in DB
* @returns Promise that resolves when processing is complete
*/
async processAuditTask(payload: unknown, taskId: string): Promise<void> {
// Validate payload schema (accepts all supported versions)
const parseResult = BookingAuditTaskConsumerPayloadSchema.safeParse(payload);
if (!parseResult.success) {
const errorMsg = `Invalid booking audit payload: ${safeStringify(parseResult.error.errors)}`;
logger.error(errorMsg);
throw new Error(errorMsg);
}
const validatedPayload = parseResult.data;
const { action, bookingUid, actor, organizationId, data, timestamp } = validatedPayload;
// Skip processing for non-organization bookings
if (organizationId === null) {
logger.info(
`Skipping audit for non-organization booking: action=${action}, bookingUid=${bookingUid}`
);
return;
}
const isFeatureEnabled = await this.featuresRepository.checkIfTeamHasFeature(organizationId, "booking-audit");
if (!isFeatureEnabled) {
logger.info(
`booking-audit feature is disabled for organization, skipping audit processing: action=${action}, bookingUid=${bookingUid}, organizationId=${organizationId}`
);
return;
}
const dataInLatestFormat = await this.migrateIfNeeded({ action, data, payload: validatedPayload, taskId });
if (action !== "CREATED") {
throw new Error(`Unsupported audit action: ${action}`);
}
await this.onBookingAction({ bookingUid, actor, action, data: dataInLatestFormat, timestamp });
}
/**
* Migrate If Needed - Migrates data to latest version
*
* Calls the action service's migrateToLatest method which:
* - Validates the data against all supported versions
* - Migrates to latest version if needed
* - Returns validated data with migration status
*
* If migration occurred, updates the task payload in DB for retries.
*/
private async migrateIfNeeded(params: {
action: BookingAuditTaskConsumerPayload["action"];
data: unknown;
payload: BookingAuditTaskConsumerPayload;
taskId: string;
}) {
const { action, data, payload, taskId } = params;
const actionService = this.getActionService(action);
// migrateToLatest is now required - validates and migrates if needed
const migrationResult = actionService.migrateToLatest(data);
// If migrated, update task payload in DB
if (migrationResult.isMigrated) {
logger.info(
`Schema migration performed: action=${action}`
);
await this.updateTaskPayload(payload, migrationResult.latestData, taskId);
}
return migrationResult.latestData;
}
/**
* Get Action Service - Returns the appropriate action service for the given action type
*
* @param action - The booking audit action type
* @returns The corresponding action service instance
*/
private getActionService(action: BookingAuditAction) {
if (action !== "CREATED") {
throw new Error(`Unsupported audit action: ${action}`);
}
return this.createdActionService;
}
/**
* Update Task Payload - Updates the task payload in DB with migrated data
*
* This ensures that task retries use the latest schema version.
* When a task fails and retries, it will use the already-migrated payload.
*
* @param payload - Original task payload
* @param latestData - Migrated data in latest schema version
* @param taskId - Task ID (required for DB update)
*/
private async updateTaskPayload(
payload: BookingAuditTaskConsumerPayload,
latestData: unknown,
taskId: string
): Promise<void> {
try {
const { Task } = await import("@calcom/features/tasker/repository");
const updatedPayload = { ...payload, data: latestData };
await Task.updatePayload(taskId, JSON.stringify(updatedPayload));
logger.info(
`Successfully updated task payload in DB: taskId=${taskId}, action=${payload.action}`
);
} catch (error) {
// Log error but don't fail the task - migration happened in memory
logger.error(
`Failed to update task payload in DB: taskId=${taskId}, error=${safeStringify(error)}`
);
}
}
/**
* Resolves an Actor to an actor ID in the AuditActor table
* Handles different actor types appropriately (upsert, lookup, or direct ID)
*/
private async resolveActorId(actor: Actor): Promise<string> {
switch (actor.identifiedBy) {
case "id":
return actor.id;
case "user": {
const userActor = await this.auditActorRepository.createIfNotExistsUserActor({ userUuid: actor.userUuid });
return userActor.id;
}
case "attendee": {
const attendeeActor = await this.auditActorRepository.findByAttendeeId(actor.attendeeId);
if (!attendeeActor) {
throw new Error(`Attendee actor not found for attendeeId: ${actor.attendeeId}`);
}
return attendeeActor.id;
}
case "guest": {
const guestActor = await this.auditActorRepository.createIfNotExistsGuestActor(
actor.email ?? null,
actor.name ?? null,
actor.phone ?? null
);
return guestActor.id;
}
}
}
/**
* Creates a booking audit record
* Action services handle their own version wrapping
*/
private async createAuditRecord(input: CreateBookingAuditInput): Promise<BookingAudit> {
logger.info("Creating audit record", safeStringify({
bookingUid: input.bookingUid,
actorId: input.actorId,
type: input.type,
action: input.action,
timestamp: input.timestamp,
}));
return this.bookingAuditRepository.create({
bookingUid: input.bookingUid,
actorId: input.actorId,
type: input.type,
action: input.action,
timestamp: input.timestamp,
data: input.data ?? null,
});
}
/**
* Get Record Type - Derives the record type from the action
*
* Maps booking actions to their corresponding audit record types:
* - CREATED → RECORD_CREATED
* - Future actions like CANCELLED, RESCHEDULED → RECORD_UPDATED
* - Future actions like DELETED → RECORD_DELETED
*
* @param action - The booking audit action
* @returns The corresponding record type
*/
private getRecordType(params: { action: BookingAuditAction }): BookingAuditType {
const { action } = params;
switch (action) {
case "CREATED":
return "RECORD_CREATED";
// Future actions will map to RECORD_UPDATED or RECORD_DELETED
default:
throw new Error(`Unsupported action for record type mapping: ${action}`);
}
}
async onBookingAction(params: {
bookingUid: string;
actor: Actor;
action: BookingAuditAction;
data: CreatedAuditData;
timestamp: number;
}): Promise<BookingAudit> {
const { bookingUid, actor, action, data, timestamp } = params;
const actionService = this.getActionService(action);
const versionedData = actionService.getVersionedData(data);
const actorId = await this.resolveActorId(actor);
const recordType = this.getRecordType({ action });
return this.createAuditRecord({
bookingUid,
actorId,
type: recordType,
action,
data: versionedData,
timestamp: new Date(timestamp),
});
}
}
@@ -0,0 +1,59 @@
import type { Tasker } from "@calcom/features/tasker/tasker";
import type { Actor } from "../../../bookings/lib/types/actor";
import type { BookingAuditTaskProducerActionData } from "../types/bookingAuditTask";
import type { BookingAuditProducerService } from "./BookingAuditProducerService.interface";
interface BookingAuditTaskerProducerServiceDeps {
tasker: Tasker;
}
/**
* BookingAuditTaskerProducerService - Tasker-based implementation of BookingAuditProducerService
*
* Producer that uses Tasker for local/background job processing.
* Task processing is handled by BookingAuditTaskConsumer.
*
* For future migration to trigger.dev, create BookingAuditTriggerProducerService
* that implements the same BookingAuditProducerService interface.
*/
export class BookingAuditTaskerProducerService implements BookingAuditProducerService {
private readonly tasker: Tasker;
constructor(private readonly deps: BookingAuditTaskerProducerServiceDeps) {
this.tasker = deps.tasker;
}
/**
* Queue Audit - Push audit task to Tasker for async processing
*
* Single method that handles all audit types using discriminated union.
* TypeScript ensures type safety between action and data.
* Timestamp is automatically captured when the task is queued.
*
* @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 }
* });
*/
async queueAudit(bookingUid: string, actor: Actor, organizationId: number | null, actionData: BookingAuditTaskProducerActionData): Promise<void> {
// Skip queueing for non-organization bookings
if (organizationId === null) {
return;
}
await this.tasker.create("bookingAudit", {
bookingUid,
actor,
organizationId,
timestamp: Date.now(),
...actionData,
});
}
}
@@ -0,0 +1,179 @@
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { CreatedAuditActionService, type CreatedAuditDisplayData } from "../actions/CreatedAuditActionService";
import type { IBookingAuditRepository, BookingAuditWithActor, BookingAuditAction, BookingAuditType } from "../repository/IBookingAuditRepository";
import { IS_PRODUCTION } from "@calcom/lib/constants";
type AuditDisplayData = CreatedAuditDisplayData;
interface BookingAuditViewerServiceDeps {
bookingAuditRepository: IBookingAuditRepository;
userRepository: UserRepository;
}
type EnrichedAuditLog = {
id: string;
bookingUid: string;
type: BookingAuditType;
action: BookingAuditAction;
timestamp: string;
createdAt: string;
data: AuditDisplayData;
actor: {
id: string;
type: string;
userUuid: string | null;
attendeeId: number | null;
name: string | null;
createdAt: Date;
displayName: string;
displayEmail: string | null;
displayAvatar: string | null;
};
};
/**
* BookingAuditViewerService - Service for viewing and formatting booking audit logs
*/
export class BookingAuditViewerService {
private readonly createdActionService: CreatedAuditActionService;
private readonly bookingAuditRepository: IBookingAuditRepository;
private readonly userRepository: UserRepository;
constructor(private readonly deps: BookingAuditViewerServiceDeps) {
this.bookingAuditRepository = deps.bookingAuditRepository;
this.userRepository = deps.userRepository;
this.createdActionService = new CreatedAuditActionService();
}
/**
* Get audit logs for a booking with full enrichment and formatting
* Handles permission checks, fetches logs, enriches actors, and formats display
*/
async getAuditLogsForBooking(
bookingUid: string,
_userId: number,
_userEmail: string
): Promise<{ bookingUid: string; auditLogs: EnrichedAuditLog[] }> {
// Check permissions
await this.checkPermissions();
// Fetch audit logs
const auditLogs = await this.bookingAuditRepository.findAllForBooking(bookingUid);
// Enrich and format audit logs
const enrichedAuditLogs = await Promise.all(
auditLogs.map(async (log) => {
const enrichedActor = await this.enrichActorInformation(log.actor);
const actionService = this.getActionService(log.action);
const parsedData = actionService.parseStored(log.data);
return {
id: log.id,
bookingUid: log.bookingUid,
type: log.type,
action: log.action,
timestamp: log.timestamp.toISOString(),
createdAt: log.createdAt.toISOString(),
data: actionService.getDisplayJson(parsedData),
actor: {
...log.actor,
displayName: enrichedActor.displayName,
displayEmail: enrichedActor.displayEmail,
displayAvatar: enrichedActor.displayAvatar,
},
};
})
);
return {
bookingUid,
auditLogs: enrichedAuditLogs,
};
}
/**
* Check if user has permission to view audit logs for a booking
*/
private async checkPermissions(): Promise<void> {
// TODO: Implement permission check
if (IS_PRODUCTION) {
throw new Error("Permission check is not implemented for production environments");
}
}
/**
* Get Action Service - Returns the appropriate action service for the given action type
*
* @param action - The booking audit action type
* @returns The corresponding action service instance
*/
private getActionService(action: BookingAuditAction) {
if (action !== "CREATED") {
throw new Error(`Unsupported audit action: ${action}`);
}
return this.createdActionService;
}
/**
* Enrich actor information with user details if userUuid exists
*/
private async enrichActorInformation(actor: BookingAuditWithActor["actor"]): Promise<{
displayName: string;
displayEmail: string | null;
displayAvatar: string | null;
}> {
// SYSTEM actor
if (actor.type === "SYSTEM") {
return {
displayName: "Cal.com",
displayEmail: null,
displayAvatar: null,
};
}
// GUEST actor - use name or default
if (actor.type === "GUEST") {
return {
displayName: actor.name || "Guest",
displayEmail: null,
displayAvatar: null,
};
}
// ATTENDEE actor - use name or default
if (actor.type === "ATTENDEE") {
return {
displayName: actor.name || "Attendee",
displayEmail: null,
displayAvatar: null,
};
}
// USER actor - lookup from User table and include avatar
if (actor.type === "USER") {
if (!actor.userUuid) {
throw new Error("User UUID is required for USER actor");
}
const actorUser = await this.userRepository.findByUuid({ uuid: actor.userUuid });
if (actorUser) {
return {
displayName: actorUser.name || actorUser.email,
displayEmail: actorUser.email,
displayAvatar: actorUser.avatarUrl || null,
};
}
return {
displayName: "Deleted User",
displayEmail: null,
displayAvatar: null,
}
}
// Satisfying Typescript
throw new Error(`Unknown actor type: ${actor.type}`);
}
}
@@ -0,0 +1,284 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { prisma } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import type { BookingAuditTaskConsumer } from "./BookingAuditTaskConsumer";
import type { BookingAuditViewerService } from "./BookingAuditViewerService";
import { makeUserActor } from "../../../bookings/lib/types/actor";
import { getBookingAuditTaskConsumer } from "../../di/BookingAuditTaskConsumer.container";
import { getBookingAuditViewerService } from "../../di/BookingAuditViewerService.container";
/**
* Integration tests for booking audit system
* Tests the complete write-read cycle using real database
*/
describe("Booking Audit Integration", () => {
let bookingAuditTaskConsumer: BookingAuditTaskConsumer;
let bookingAuditViewerService: BookingAuditViewerService;
// Test data holders
let testUserId: number;
let testUserUuid: string;
let testUserEmail: string;
let testBookingUid: string;
let testEventTypeId: number;
let testAttendeeEmail: string;
let testAttendeeUserId: number;
beforeEach(async () => {
// Initialize services from DI containers
bookingAuditTaskConsumer = getBookingAuditTaskConsumer();
bookingAuditViewerService = getBookingAuditViewerService();
// Create test user
const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(7);
const testUser = await prisma.user.create({
data: {
email: `test-audit-user-${timestamp}-${randomSuffix}@example.com`,
username: `testaudituser-${timestamp}-${randomSuffix}`,
name: "Test Audit User",
},
});
testUserId = testUser.id;
testUserUuid = testUser.uuid;
testUserEmail = testUser.email;
// Create test event type
const eventType = await prisma.eventType.create({
data: {
title: "Test Event Type",
slug: `test-event-${timestamp}-${randomSuffix}`,
length: 60,
userId: testUserId,
},
});
testEventTypeId = eventType.id;
// Create test booking
const startTime = new Date();
const endTime = new Date(startTime.getTime() + 60 * 60 * 1000);
testBookingUid = `test-booking-${timestamp}-${randomSuffix}`;
const testBooking = await prisma.booking.create({
data: {
uid: testBookingUid,
title: "Test Booking",
startTime,
endTime,
userId: testUserId,
eventTypeId: testEventTypeId,
status: BookingStatus.ACCEPTED,
},
});
// Create test attendee user for permission tests
const attendeeUser = await prisma.user.create({
data: {
email: `test-attendee-${timestamp}-${randomSuffix}@example.com`,
username: `testattendee-${timestamp}-${randomSuffix}`,
name: "Test Attendee",
},
});
testAttendeeUserId = attendeeUser.id;
testAttendeeEmail = attendeeUser.email;
// Add attendee to booking
await prisma.attendee.create({
data: {
email: testAttendeeEmail,
name: "Test Attendee",
timeZone: "UTC",
bookingId: testBooking.id,
},
});
});
afterEach(async () => {
// Clean up test data in correct order to respect foreign key constraints
// Delete audit logs first (references actor)
await prisma.bookingAudit.deleteMany({
where: { bookingUid: testBookingUid },
});
// Delete actors
await prisma.auditActor.deleteMany({
where: {
OR: [
{ userUuid: testUserUuid },
{ email: testAttendeeEmail },
],
},
});
// Delete attendees
await prisma.attendee.deleteMany({
where: { email: testAttendeeEmail },
});
// Delete booking
await prisma.booking.deleteMany({
where: { uid: testBookingUid },
});
// Delete event type
await prisma.eventType.deleteMany({
where: { id: testEventTypeId },
});
// Delete users
await prisma.user.deleteMany({
where: {
id: {
in: [testUserId, testAttendeeUserId],
},
},
});
});
describe("CREATED action end-to-end flow", () => {
it("should create audit record and retrieve it with correct formatting", async () => {
// Arrange: Get booking details
const booking = await prisma.booking.findUnique({
where: { uid: testBookingUid },
select: {
startTime: true,
endTime: true,
status: true,
},
});
expect(booking).toBeDefined();
// Create actor using helper
const actor = makeUserActor(testUserUuid);
// Act: Create audit record using TaskConsumer
await bookingAuditTaskConsumer.onBookingAction({
bookingUid: testBookingUid,
actor,
action: "CREATED",
data: {
startTime: booking!.startTime.getTime(),
endTime: booking!.endTime.getTime(),
status: booking!.status,
},
timestamp: Date.now(),
});
// Retrieve audit logs
const result = await bookingAuditViewerService.getAuditLogsForBooking(
testBookingUid,
testUserId,
testUserEmail
);
// Assert: Verify audit log exists and has correct data
expect(result.bookingUid).toBe(testBookingUid);
expect(result.auditLogs).toHaveLength(1);
const auditLog = result.auditLogs[0];
expect(auditLog.bookingUid).toBe(testBookingUid);
expect(auditLog.action).toBe("CREATED");
expect(auditLog.type).toBe("RECORD_CREATED");
// Verify audit data matches (getDisplayJson returns fields only, not versioned wrapper)
const displayData = auditLog.data
expect(displayData.startTime).toBe(booking!.startTime.toISOString());
expect(displayData.endTime).toBe(booking!.endTime.toISOString());
expect(displayData.status).toBe(booking!.status);
});
it("should enrich actor information with user details", async () => {
// Arrange: Get booking details
const booking = await prisma.booking.findUnique({
where: { uid: testBookingUid },
select: { startTime: true, endTime: true, status: true },
});
// Create actor using helper
const actor = makeUserActor(testUserUuid);
// Create audit record using TaskConsumer
await bookingAuditTaskConsumer.onBookingAction({
bookingUid: testBookingUid,
actor,
action: "CREATED",
data: {
startTime: booking!.startTime.getTime(),
endTime: booking!.endTime.getTime(),
status: booking!.status,
},
timestamp: Date.now(),
});
// Act: Retrieve audit logs
const result = await bookingAuditViewerService.getAuditLogsForBooking(
testBookingUid,
testUserId,
testUserEmail
);
// Assert: Verify actor enrichment
const auditLog = result.auditLogs[0];
expect(auditLog.actor.displayName).toBe("Test Audit User");
expect(auditLog.actor.displayEmail).toBe(testUserEmail);
expect(auditLog.actor.userUuid).toBe(testUserUuid);
});
it.skip("should enforce permission checks correctly", async () => {
// Arrange: Get booking details
const booking = await prisma.booking.findUnique({
where: { uid: testBookingUid },
select: { startTime: true, endTime: true, status: true },
});
// Create actor using helper
const actor = makeUserActor(testUserUuid);
// Create audit record using TaskConsumer
await bookingAuditTaskConsumer.onBookingAction({
bookingUid: testBookingUid,
actor,
action: "CREATED",
data: {
startTime: booking!.startTime.getTime(),
endTime: booking!.endTime.getTime(),
status: booking!.status,
},
timestamp: Date.now(),
});
// Act & Assert: Booking owner can view
const ownerResult = await bookingAuditViewerService.getAuditLogsForBooking(
testBookingUid,
testUserId,
testUserEmail
);
expect(ownerResult.auditLogs).toHaveLength(1);
// Act & Assert: Attendee can view
const attendeeResult = await bookingAuditViewerService.getAuditLogsForBooking(
testBookingUid,
testAttendeeUserId,
testAttendeeEmail
);
expect(attendeeResult.auditLogs).toHaveLength(1);
// Act & Assert: Unauthorized user cannot view
const unauthorizedUserId = 999999;
const unauthorizedEmail = "unauthorized@example.com";
await expect(
bookingAuditViewerService.getAuditLogsForBooking(
testBookingUid,
unauthorizedUserId,
unauthorizedEmail
)
).rejects.toThrow("You do not have permission to view audit logs for this booking");
});
});
});
@@ -0,0 +1,29 @@
import { z } from "zod";
import { ActorSchema } from "@calcom/features/bookings/lib/types/actor";
import {
CreatedAuditActionService,
} from "../actions/CreatedAuditActionService";
const baseSchema = z.object({
bookingUid: z.string(),
actor: ActorSchema,
organizationId: z.number().nullable(),
timestamp: z.number(),
});
export const BookingAuditTaskConsumerPayloadSchema = z.discriminatedUnion("action", [
baseSchema.merge(z.object({
action: z.literal(CreatedAuditActionService.TYPE),
// Payload in Task record could have any version of the data schema
data: CreatedAuditActionService.storedFieldsSchema,
})),
// ... more actions here
]);
export type BookingAuditTaskProducerActionData =
| { action: typeof CreatedAuditActionService.TYPE; data: z.infer<typeof CreatedAuditActionService.latestFieldsSchema> }
// ... more actions here
export type BookingAuditTaskConsumerPayload = z.infer<typeof BookingAuditTaskConsumerPayloadSchema>;
@@ -2,7 +2,7 @@ import { BookingEventHandlerService } from "@calcom/features/bookings/lib/onBook
import { bindModuleToClassOnToken, createModule } from "@calcom/features/di/di";
import { DI_TOKENS } from "@calcom/features/di/tokens";
import { moduleLoader as hashedLinkServiceModuleLoader } from "@calcom/features/hashedLink/di/HashedLinkService.module";
import { moduleLoader as bookingAuditServiceModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditService.module";
import { moduleLoader as bookingAuditProducerServiceModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditTaskerProducerService.module";
import { moduleLoader as loggerModuleLoader } from "@calcom/features/di/shared/services/logger.service";
const thisModule = createModule();
@@ -16,7 +16,7 @@ const loadModule = bindModuleToClassOnToken({
classs: BookingEventHandlerService,
depsMap: {
hashedLinkService: hashedLinkServiceModuleLoader,
bookingAuditService: bookingAuditServiceModuleLoader,
bookingAuditProducerService: bookingAuditProducerServiceModuleLoader,
log: loggerModuleLoader,
},
});
@@ -27,4 +27,3 @@ export const moduleLoader = {
};
export type { BookingEventHandlerService };
@@ -1,4 +1,3 @@
import { moduleLoader as bookingAuditServiceModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditService.module";
import { moduleLoader as bookingEventHandlerModuleLoader } from "@calcom/features/bookings/di/BookingEventHandlerService.module";
import { RegularBookingService } from "@calcom/features/bookings/lib/service/RegularBookingService";
import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di";
@@ -1,32 +1,47 @@
import type { BookingAuditService } from "@calcom/features/booking-audit/lib/service/BookingAuditService";
import type { BookingAuditProducerService } from "@calcom/features/booking-audit/lib/service/BookingAuditProducerService.interface";
import type { HashedLinkService } from "@calcom/features/hashedLink/lib/service/HashedLinkService";
import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { Actor } from "../types/actor";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import type { BookingCreatedPayload, BookingRescheduledPayload } from "./types";
interface BookingEventHandlerDeps {
log: ISimpleLogger;
hashedLinkService: HashedLinkService;
//TODO: To be made required in followup PR
bookingAuditService?: BookingAuditService;
bookingAuditProducerService: BookingAuditProducerService;
}
export class BookingEventHandlerService {
private readonly log: BookingEventHandlerDeps["log"];
private readonly hashedLinkService: BookingEventHandlerDeps["hashedLinkService"];
constructor(private readonly deps: BookingEventHandlerDeps) {
this.log = deps.log;
this.hashedLinkService = deps.hashedLinkService;
}
async onBookingCreated(payload: BookingCreatedPayload) {
async onBookingCreated(payload: BookingCreatedPayload, actor: Actor) {
this.log.debug("onBookingCreated", safeStringify(payload));
if (payload.config.isDryRun) {
return;
}
await this.onBookingCreatedOrRescheduled(payload);
if (IS_PRODUCTION) {
// Skip queueing audit for production environments till we are absolutely sure that the payload schema is correct for CREATED action
// We might get more clarity as we implement more actions and test them
return;
}
try {
await this.deps.bookingAuditProducerService.queueAudit(payload.booking.uid, actor, payload.organizationId, {
action: "CREATED",
data: {
startTime: payload.booking.startTime.getTime(),
endTime: payload.booking.endTime.getTime(),
status: payload.booking.status,
},
});
} catch (error) {
this.log.error("Error while queueing booking audit", safeStringify(error));
}
}
async onBookingRescheduled(payload: BookingRescheduledPayload) {
+19 -4
View File
@@ -1,11 +1,26 @@
import type { BookingFlowConfig } from "./dto/types";
import type { BookingFlowConfig } from "../dto/types";
import type { BookingStatus } from "@calcom/prisma/enums";
export interface BookingCreatedPayload {
config: BookingFlowConfig;
bookingFormData: {
hashedLink: string | null;
};
booking: {
uid: string;
startTime: Date;
endTime: Date;
status: BookingStatus;
userId: number | null;
user?: {
id: number;
};
};
organizationId: number | null;
}
// Add more fields here when needed
type BookingRescheduledPayload = BookingCreatedPayload;
export interface BookingRescheduledPayload extends BookingCreatedPayload {
oldBooking?: {
startTime: Date;
endTime: Date;
};
}
@@ -36,6 +36,7 @@ import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhoo
import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled";
import { BookingEventHandlerService } from "@calcom/features/bookings/lib/onBookingEvents/BookingEventHandlerService";
import { BookingEmailAndSmsTasker } from "@calcom/features/bookings/lib/tasker/BookingEmailAndSmsTasker";
import type { BookingRescheduledPayload } from "@calcom/features/bookings/lib/onBookingEvents/types.d";
import { getSpamCheckService } from "@calcom/features/di/watchlist/containers/SpamCheckService.container";
import { CreditService } from "@calcom/features/ee/billing/credit-service";
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
@@ -126,6 +127,7 @@ import { validateBookingTimeIsNotOutOfBounds } from "../handleNewBooking/validat
import { validateEventLength } from "../handleNewBooking/validateEventLength";
import handleSeats from "../handleSeats/handleSeats";
import type { IBookingService } from "../interfaces/IBookingService";
import { makeGuestActor } from "../types/actor";
const translator = short();
@@ -412,6 +414,49 @@ function formatAvailabilitySnapshot(data: {
};
}
function buildBookingCreatedPayload({
booking,
organizerUserId,
hashedLink,
isDryRun,
organizationId,
}: {
booking: {
id: number;
uid: string;
startTime: Date;
endTime: Date;
status: BookingStatus;
userId: number | null;
};
organizerUserId: number;
hashedLink: string | null;
isDryRun: boolean;
organizationId: number | null;
}) {
return {
config: {
isDryRun,
},
bookingFormData: {
hashedLink,
},
booking: {
id: booking.id,
uid: booking.uid,
startTime: booking.startTime,
endTime: booking.endTime,
status: booking.status,
userId: booking.userId,
user: {
id: organizerUserId,
},
},
organizationId,
};
}
export interface IBookingServiceDependencies {
checkBookingAndDurationLimitsService: CheckBookingAndDurationLimitsService;
prismaClient: PrismaClient;
@@ -1265,9 +1310,9 @@ async function handler(
// This ensures that createMeeting isn't called for static video apps as bookingLocation becomes just a regular value for them.
const { bookingLocation, conferenceCredentialId } = organizerOrFirstDynamicGroupMemberDefaultLocationUrl
? {
bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl,
conferenceCredentialId: undefined,
}
bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl,
conferenceCredentialId: undefined,
}
: getLocationValueForDB(locationBodyString, eventType.locations);
tracingLogger.info("locationBodyString", locationBodyString);
@@ -1313,8 +1358,8 @@ async function handler(
const destinationCalendar = eventType.destinationCalendar
? [eventType.destinationCalendar]
: organizerUser.destinationCalendar
? [organizerUser.destinationCalendar]
: null;
? [organizerUser.destinationCalendar]
: null;
let organizerEmail = organizerUser.email || "Email-less";
if (eventType.useEventTypeDestinationCalendarEmail && destinationCalendar?.[0]?.primaryEmail) {
@@ -1935,14 +1980,14 @@ async function handler(
}
const updateManager = !skipCalendarSyncTaskCreation
? await eventManager.reschedule(
evt,
originalRescheduledBooking.uid,
undefined,
changedOrganizer,
previousHostDestinationCalendar,
isBookingRequestedReschedule,
skipDeleteEventsAndMeetings
)
evt,
originalRescheduledBooking.uid,
undefined,
changedOrganizer,
previousHostDestinationCalendar,
isBookingRequestedReschedule,
skipDeleteEventsAndMeetings
)
: placeholderCreatedEvent;
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
// to the default description when we are sending the emails.
@@ -2243,32 +2288,36 @@ async function handler(
const metadata = videoCallUrl
? {
videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl,
}
videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl,
}
: undefined;
const bookingFlowConfig = {
const bookingCreatedPayload = buildBookingCreatedPayload({
booking,
organizerUserId: organizerUser.id,
// FIXME: It looks like hasHashedBookingLink is set to true based on the value of hashedLink when sending the request. So, technically we could remove hasHashedBookingLink usage completely
hashedLink: hasHashedBookingLink ? reqBody.hashedLink ?? null : null,
isDryRun,
};
organizationId: eventOrganizationId,
});
const bookingCreatedPayload = {
config: bookingFlowConfig,
bookingFormData: {
// FIXME: It looks like hasHashedBookingLink is set to true based on the value of hashedLink when sending the request. So, technically we could remove hasHashedBookingLink usage completely
hashedLink: hasHashedBookingLink ? reqBody.hashedLink ?? null : null,
},
const bookingRescheduledPayload: BookingRescheduledPayload = {
...bookingCreatedPayload,
oldBooking: originalRescheduledBooking ? {
startTime: originalRescheduledBooking.startTime,
endTime: originalRescheduledBooking.endTime,
} : undefined,
};
// Add more fields here when needed
const bookingRescheduledPayload = bookingCreatedPayload;
const bookingEventHandler = deps.bookingEventHandler;
// TODO: Incrementally move all stuff that happens after a booking is created to these handlers
if (originalRescheduledBooking) {
await bookingEventHandler.onBookingRescheduled(bookingRescheduledPayload);
} else {
await bookingEventHandler.onBookingCreated(bookingCreatedPayload);
// TODO: We need to check session in booking flow and accordingly create USER actor if applicable.
const auditActor = makeGuestActor({ email: bookerEmail, name: fullName });
await bookingEventHandler.onBookingCreated(bookingCreatedPayload, auditActor);
}
const webhookData: EventPayloadType = {
@@ -2333,9 +2382,9 @@ async function handler(
...eventType,
metadata: eventType.metadata
? {
...eventType.metadata,
apps: eventType.metadata?.apps as Prisma.JsonValue,
}
...eventType.metadata,
apps: eventType.metadata?.apps as Prisma.JsonValue,
}
: {},
},
paymentAppCredentials: eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
@@ -2669,7 +2718,7 @@ async function handler(
* We are open to renaming it to something more descriptive.
*/
export class RegularBookingService implements IBookingService {
constructor(private readonly deps: IBookingServiceDependencies) {}
constructor(private readonly deps: IBookingServiceDependencies) { }
async createBooking(input: { bookingData: CreateRegularBookingData; bookingMeta?: CreateBookingMeta }) {
return handler({ bookingData: input.bookingData, ...input.bookingMeta }, this.deps);
+42 -46
View File
@@ -1,49 +1,38 @@
/**
* Represents the entity that performed a booking action
* Uses discriminating union for type-safe actor identification
*/
export type Actor = ActorById | UserActor | AttendeeActor | GuestActor;
import { z } from "zod";
/**
* Actor referenced by existing ID in AuditActor table
* Use this for System actors or when you already have an actor ID
*/
type ActorById = {
identifiedBy: "id";
id: string; // UUID of existing actor (including System actor)
};
const UserActorSchema = z.object({
identifiedBy: z.literal("user"),
userUuid: z.string(),
});
/**
* Actor identified by user UUID
* Will be upserted in AuditActor table
*/
type UserActor = {
identifiedBy: "user";
userUuid: string;
};
const GuestActorSchema = z.object({
identifiedBy: z.literal("guest"),
email: z.string().optional(),
name: z.string().optional(),
phone: z.string().optional(),
});
/**
* Actor identified by attendee ID
* Must already exist in AuditActor table
*/
type AttendeeActor = {
identifiedBy: "attendee";
attendeeId: number;
};
const AttendeeActorSchema = z.object({
identifiedBy: z.literal("attendee"),
attendeeId: z.number(),
});
/**
* Actor identified by guest data (email, name, phone)
* Will be upserted in AuditActor table
*/
type GuestActor = {
identifiedBy: "guest";
email?: string;
name?: string;
phone?: string;
};
const ActorByIdSchema = z.object({
identifiedBy: z.literal("id"),
id: z.string(),
});
// System actor ID constant
export const SYSTEM_ACTOR_ID = "00000000-0000-0000-0000-000000000000";
export const ActorSchema = z.discriminatedUnion("identifiedBy", [
ActorByIdSchema,
UserActorSchema,
GuestActorSchema,
AttendeeActorSchema,
]);
export type Actor = z.infer<typeof ActorSchema>;
type UserActor = z.infer<typeof UserActorSchema>;
type GuestActor = z.infer<typeof GuestActorSchema>;
type ActorById = z.infer<typeof ActorByIdSchema>;
/**
* Creates an Actor representing a User by UUID
@@ -69,12 +58,16 @@ export function makeSystemActor(): ActorById {
/**
* Creates an Actor representing a Guest (non-registered attendee)
*/
export function makeGuestActor(email: string, name?: string, phone?: string): GuestActor {
export function makeGuestActor(params: {
email: string;
name?: string;
phone?: string;
}): GuestActor {
return {
identifiedBy: "guest",
email,
name,
phone,
email: params.email,
name: params.name,
phone: params.phone,
};
}
@@ -86,4 +79,7 @@ export function makeActorById(id: string): ActorById {
identifiedBy: "id",
id,
};
}
}
// System actor ID constant
export const SYSTEM_ACTOR_ID = "00000000-0000-0000-0000-000000000000";
@@ -1,13 +1,23 @@
import { createModule } from "@evyweb/ioctopus";
import type { Container } from "@evyweb/ioctopus";
import tasker from "@calcom/features/tasker";
import type { ITasker } from "@calcom/features/webhooks/lib/interface/infrastructure";
import { SHARED_TOKENS } from "../shared.tokens";
export const taskerServiceModule = createModule();
const token = SHARED_TOKENS.TASKER;
// Bind tasker with proper async factory that respects IoC
taskerServiceModule.bind(SHARED_TOKENS.TASKER).toFactory(async (): Promise<ITasker> => {
const taskerModule = await import("@calcom/features/tasker");
return taskerModule.default;
taskerServiceModule.bind(SHARED_TOKENS.TASKER).toFactory((): ITasker => {
return tasker;
});
const loadModule = (container: Container) => {
container.load(SHARED_TOKENS.TASKER, taskerServiceModule);
};
export const moduleLoader = {
token,
loadModule,
};
+1
View File
@@ -33,6 +33,7 @@ export type AppFlags = {
"booking-calendar-view": boolean;
"booking-email-sms-tasker": boolean;
"bookings-v3": boolean;
"booking-audit": boolean;
};
export type TeamFeatures = Record<keyof AppFlags, boolean>;
+1
View File
@@ -32,6 +32,7 @@ const initialData: AppFlags = {
"booking-calendar-view": false,
"booking-email-sms-tasker": false,
"bookings-v3": false,
"booking-audit": false,
};
if (process.env.NEXT_PUBLIC_IS_E2E) {
+59 -31
View File
@@ -1,4 +1,5 @@
import db from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import type { PrismaClient } from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
import { type TaskTypes } from "./tasker";
@@ -36,15 +37,21 @@ const makeWhereUpcomingTasks = (): Prisma.TaskWhereInput => ({
},
});
export class Task {
static async create(
type Dependencies = {
prismaClient: PrismaClient;
};
export class TaskRepository {
constructor(private readonly deps: Dependencies) { }
async create(
type: TaskTypes,
payload: string,
options: { scheduledAt?: Date; maxAttempts?: number; referenceUid?: string } = {}
) {
const { scheduledAt, maxAttempts, referenceUid } = options;
console.info("Creating task", { type, payload, scheduledAt, maxAttempts });
const newTask = await db.task.create({
const newTask = await this.deps.prismaClient.task.create({
data: {
payload,
type,
@@ -56,9 +63,9 @@ export class Task {
return newTask.id;
}
static async getNextBatch() {
async getNextBatch() {
console.info("Getting next batch of tasks", makeWhereUpcomingTasks());
return db.task.findMany({
return this.deps.prismaClient.task.findMany({
where: makeWhereUpcomingTasks(),
orderBy: {
scheduledAt: "asc",
@@ -67,41 +74,41 @@ export class Task {
});
}
static async getFailed() {
return db.task.findMany({
async getFailed() {
return this.deps.prismaClient.task.findMany({
where: whereMaxAttemptsReached,
});
}
static async getSucceeded() {
return db.task.findMany({
async getSucceeded() {
return this.deps.prismaClient.task.findMany({
where: whereSucceeded,
});
}
static async count() {
return db.task.count();
async count() {
return this.deps.prismaClient.task.count();
}
static async countUpcoming() {
return db.task.count({
async countUpcoming() {
return this.deps.prismaClient.task.count({
where: makeWhereUpcomingTasks(),
});
}
static async countFailed() {
return db.task.count({
async countFailed() {
return this.deps.prismaClient.task.count({
where: whereMaxAttemptsReached,
});
}
static async countSucceeded() {
return db.task.count({
async countSucceeded() {
return this.deps.prismaClient.task.count({
where: whereSucceeded,
});
}
static async retry({
async retry({
taskId,
lastError,
minRetryIntervalMins,
@@ -115,7 +122,7 @@ export class Task {
? new Date(failedAttemptTime.getTime() + 1000 * 60 * minRetryIntervalMins)
: undefined;
return db.task.update({
return this.deps.prismaClient.task.update({
where: {
id: taskId,
},
@@ -130,8 +137,8 @@ export class Task {
});
}
static async succeed(taskId: string) {
return db.task.update({
async succeed(taskId: string) {
return this.deps.prismaClient.task.update({
where: {
id: taskId,
},
@@ -142,18 +149,35 @@ export class Task {
});
}
static async cancel(taskId: string) {
return db.task.delete({
/**
* Update the payload of a task
*
* @param taskId - The ID of the task to update
* @param newPayload - The new payload string
*/
async updatePayload(taskId: string, newPayload: string) {
return this.deps.prismaClient.task.update({
where: {
id: taskId,
},
data: {
payload: newPayload,
},
});
}
async cancel(taskId: string) {
return this.deps.prismaClient.task.delete({
where: {
id: taskId,
},
});
}
static async cancelWithReference(referenceUid: string, type: TaskTypes): Promise<{ id: string } | null> {
// db.task.delete throws an error if the task does not exist, so we catch it and return null
async cancelWithReference(referenceUid: string, type: TaskTypes): Promise<{ id: string } | null> {
// prismaClient.task.delete throws an error if the task does not exist, so we catch it and return null
try {
return await db.task.delete({
return await this.deps.prismaClient.task.delete({
where: {
referenceUid_type: {
referenceUid,
@@ -174,9 +198,9 @@ export class Task {
}
}
static async cleanup() {
async cleanup() {
// TODO: Uncomment this later
// return db.task.deleteMany({
// return this.deps.prismaClient.task.deleteMany({
// where: {
// OR: [
// // Get tasks that have succeeded
@@ -188,8 +212,8 @@ export class Task {
// });
}
static async hasNewerScanTaskForStepId(workflowStepId: number, createdAt: string) {
const tasks = await db.$queryRaw<{ payload: string }[]>`
async hasNewerScanTaskForStepId(workflowStepId: number, createdAt: string) {
const tasks = await this.deps.prismaClient.$queryRaw<{ payload: string }[]>`
SELECT "payload"
FROM "Task"
WHERE "type" = 'scanWorkflowBody'
@@ -208,3 +232,7 @@ export class Task {
});
}
}
// Export singleton instance for backward compatibility
// This allows existing code using Task.create(), Task.succeed(), etc. to continue working
export const Task = new TaskRepository({ prismaClient: prisma });
+1 -1
View File
@@ -20,7 +20,7 @@ export class TaskProcessor {
if (!taskHandlerGetter) throw new Error(`Task handler not found for type ${task.type}`);
const taskConfig = tasksConfig[task.type as keyof typeof tasksConfig];
const taskHandler = await taskHandlerGetter();
return taskHandler(task.payload)
return taskHandler(task.payload, task.id)
.then(async () => {
await Task.succeed(task.id);
})
+3 -2
View File
@@ -1,7 +1,7 @@
import type { z } from "zod";
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
import type { BookingAuditTaskConsumerPayload } from "@calcom/features/booking-audit/lib/types/bookingAuditTask";
export type TaskerTypes = "internal" | "redis";
type TaskPayloads = {
sendWebhook: string;
@@ -37,9 +37,10 @@ type TaskPayloads = {
responses?: FORM_SUBMITTED_WEBHOOK_RESPONSES | null;
routedEventTypeId?: number | null;
};
bookingAudit: BookingAuditTaskConsumerPayload;
};
export type TaskTypes = keyof TaskPayloads;
export type TaskHandler = (payload: string) => Promise<void>;
export type TaskHandler = (payload: string, taskId?: string) => Promise<void>;
export type TaskerCreate = <TaskKey extends keyof TaskPayloads>(
type: TaskKey,
payload: TaskPayloads[TaskKey],
@@ -0,0 +1,35 @@
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getBookingAuditTaskConsumer } from "@calcom/features/booking-audit/di/BookingAuditTaskConsumer.container";
const log = logger.getSubLogger({ prefix: ["[tasker] bookingAudit"] });
/**
* Booking Audit Task Handler
*
* Thin wrapper that parses JSON and delegates to BookingAuditTaskConsumer.
* Schema validation happens in processAuditTask for better separation of concerns.
*
*/
export async function bookingAudit(payload: string, taskId?: string): Promise<void> {
try {
if (!taskId) {
throw new Error("Task ID is required for booking audit consumer");
}
const parsedPayload: unknown = JSON.parse(payload);
log.info(`Processing booking audit: taskId=${taskId}`);
const bookingAuditTaskConsumer = getBookingAuditTaskConsumer();
await bookingAuditTaskConsumer.processAuditTask(parsedPayload, taskId);
log.info(`Successfully processed booking audit: taskId=${taskId}`);
} catch (error) {
const errorMsg = `Error processing booking audit: ${safeStringify(error)} | TaskId: ${taskId}`;
log.error(errorMsg);
// Rethrow to trigger retry via Tasker
throw error;
}
}
+1
View File
@@ -30,6 +30,7 @@ const tasks: Record<TaskTypes, () => Promise<TaskHandler>> = {
sendAnalyticsEvent: () =>
import("./analytics/sendAnalyticsEvent").then((module) => module.sendAnalyticsEvent),
executeAIPhoneCall: () => import("./executeAIPhoneCall").then((module) => module.executeAIPhoneCall),
bookingAudit: () => import("./bookingAudit").then((module) => module.bookingAudit),
};
export const tasksConfig = {
@@ -123,7 +123,7 @@ const userSelect = {
} satisfies Prisma.UserSelect;
export class UserRepository {
constructor(private prismaClient: PrismaClient) {}
constructor(private prismaClient: PrismaClient) { }
async findTeamsByUserId({ userId }: { userId: UserType["id"] }) {
const teamMemberships = await this.prismaClient.membership.findMany({
@@ -236,35 +236,35 @@ export class UserRepository {
// Lookup in profiles because that's where the organization usernames exist
const profiles = orgSlug
? (
await ProfileRepository.findManyByOrgSlugOrRequestedSlug({
orgSlug: orgSlug,
usernames: usernameList,
})
).map((profile) => ({
...profile,
organization: getParsedTeam(profile.organization),
}))
await ProfileRepository.findManyByOrgSlugOrRequestedSlug({
orgSlug: orgSlug,
usernames: usernameList,
})
).map((profile) => ({
...profile,
organization: getParsedTeam(profile.organization),
}))
: null;
const where =
profiles && profiles.length > 0
? {
// Get UserIds from profiles
id: {
in: profiles.map((profile) => profile.user.id),
},
}
// Get UserIds from profiles
id: {
in: profiles.map((profile) => profile.user.id),
},
}
: {
username: {
in: usernameList,
},
...(orgSlug
? {
organization: whereClauseForOrgWithSlugOrRequestedSlug(orgSlug),
}
: {
organization: null,
}),
};
username: {
in: usernameList,
},
...(orgSlug
? {
organization: whereClauseForOrgWithSlugOrRequestedSlug(orgSlug),
}
: {
organization: null,
}),
};
return { where, profiles };
}
@@ -388,6 +388,19 @@ export class UserRepository {
};
}
async findByUuid({ uuid }: { uuid: string }) {
return this.prismaClient.user.findUnique({
where: {
uuid,
},
select: {
name: true,
email: true,
avatarUrl: true,
},
});
}
async findByIds({ ids }: { ids: number[] }) {
return this.prismaClient.user.findMany({
where: {
@@ -668,27 +681,27 @@ export class UserRepository {
async enrichEntityWithProfile<
T extends
| {
profile: {
id: number;
username: string | null;
organizationId: number | null;
organization?: {
id: number;
name: string;
calVideoLogo?: string | null;
bannerUrl: string | null;
slug: string | null;
metadata: Prisma.JsonValue;
};
};
}
| {
user: {
username: string | null;
id: number;
};
}
| {
profile: {
id: number;
username: string | null;
organizationId: number | null;
organization?: {
id: number;
name: string;
calVideoLogo?: string | null;
bannerUrl: string | null;
slug: string | null;
metadata: Prisma.JsonValue;
};
};
}
| {
user: {
username: string | null;
id: number;
};
}
>(entity: T) {
if ("profile" in entity) {
const { profile, ...entityWithoutProfile } = entity;
@@ -701,11 +714,11 @@ export class UserRepository {
...profileWithoutOrganization,
...(parsedOrg
? {
organization: parsedOrg,
}
organization: parsedOrg,
}
: {
organization: null,
}),
organization: null,
}),
},
};
return ret;
@@ -741,10 +754,10 @@ export class UserRepository {
data: {
movedToProfile: data.movedToProfileId
? {
connect: {
id: data.movedToProfileId,
},
}
connect: {
id: data.movedToProfileId,
},
}
: undefined,
},
});
@@ -790,15 +803,15 @@ export class UserRepository {
locked,
...(organizationIdValue
? {
organizationId: organizationIdValue,
profiles: {
create: {
username,
organizationId: organizationIdValue,
uid: ProfileRepository.generateProfileUid(),
},
organizationId: organizationIdValue,
profiles: {
create: {
username,
organizationId: organizationIdValue,
uid: ProfileRepository.generateProfileUid(),
},
}
},
}
: {}),
...rest,
},
+1
View File
@@ -17,3 +17,4 @@ export { BookingEmailAndSmsSyncTasker } from "@calcom/features/bookings/lib/task
export { BookingEmailAndSmsTriggerDevTasker } from "@calcom/features/bookings/lib/tasker/BookingEmailAndSmsTriggerTasker";
export { BookingEmailAndSmsTasker } from "@calcom/features/bookings/lib/tasker/BookingEmailAndSmsTasker";
export { BookingEmailSmsHandler } from "@calcom/features/bookings/lib/BookingEmailSmsHandler";
export { BookingAuditTaskerProducerService } from "@calcom/features/booking-audit/lib/service/BookingAuditTaskerProducerService";
+2
View File
@@ -130,3 +130,5 @@ export { checkEmailVerificationRequired } from "@calcom/trpc/server/routers/publ
export { TeamService } from "@calcom/features/ee/teams/services/teamService";
export { BookingAccessService } from "@calcom/features/bookings/services/BookingAccessService";
export { getTasker } from "@calcom/features/tasker/tasker-factory";
export type { Tasker } from "@calcom/features/tasker/tasker";
@@ -0,0 +1,10 @@
INSERT INTO
"Feature" (slug, enabled, description, "type")
VALUES
(
'booking-audit',
false,
'Enable booking audit trails - Track all booking actions and changes for organizations',
'OPERATIONAL'
) ON CONFLICT (slug) DO NOTHING;
@@ -8,22 +8,11 @@ import { ZFindInputSchema } from "./find.schema";
import { ZGetInputSchema } from "./get.schema";
import { ZGetBookingAttendeesInputSchema } from "./getBookingAttendees.schema";
import { ZInstantBookingInputSchema } from "./getInstantBookingLocation.schema";
import { ZGetAuditLogsInputSchema } from "./getAuditLogs.schema";
import { ZReportBookingInputSchema } from "./reportBooking.schema";
import { ZRequestRescheduleInputSchema } from "./requestReschedule.schema";
import { bookingsProcedure } from "./util";
type BookingsRouterHandlerCache = {
get?: typeof import("./get.handler").getHandler;
requestReschedule?: typeof import("./requestReschedule.handler").requestRescheduleHandler;
editLocation?: typeof import("./editLocation.handler").editLocationHandler;
addGuests?: typeof import("./addGuests.handler").addGuestsHandler;
confirm?: typeof import("./confirm.handler").confirmHandler;
getBookingAttendees?: typeof import("./getBookingAttendees.handler").getBookingAttendeesHandler;
find?: typeof import("./find.handler").getHandler;
getInstantBookingLocation?: typeof import("./getInstantBookingLocation.handler").getHandler;
reportBooking?: typeof import("./reportBooking.handler").reportBookingHandler;
};
export const bookingsRouter = router({
get: authedProcedure.input(ZGetInputSchema).query(async ({ input, ctx }) => {
const { getHandler } = await import("./get.handler");
@@ -109,4 +98,12 @@ export const bookingsRouter = router({
input,
});
}),
getAuditLogs: authedProcedure.input(ZGetAuditLogsInputSchema).query(async ({ input, ctx }) => {
const { getAuditLogsHandler } = await import("./getAuditLogs.handler");
return getAuditLogsHandler({
ctx,
input,
});
}),
});
@@ -0,0 +1,30 @@
import type { PrismaClient } from "@calcom/prisma/client";
import { getBookingAuditViewerService } from "@calcom/features/booking-audit/di/BookingAuditViewerService.container";
import type { TrpcSessionUser } from "../../../types";
import type { TGetAuditLogsInputSchema } from "./getAuditLogs.schema";
type GetAuditLogsOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
prisma: PrismaClient;
};
input: TGetAuditLogsInputSchema;
};
export const getAuditLogsHandler = async ({ ctx, input }: GetAuditLogsOptions) => {
const { user } = ctx;
const { bookingUid } = input;
const bookingAuditViewerService = getBookingAuditViewerService();
const result = await bookingAuditViewerService.getAuditLogsForBooking(
bookingUid,
user.id,
user.email
);
return result;
};
@@ -0,0 +1,8 @@
import { z } from "zod";
export const ZGetAuditLogsInputSchema = z.object({
bookingUid: z.string(),
});
export type TGetAuditLogsInputSchema = z.infer<typeof ZGetAuditLogsInputSchema>;