## 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) -->
239 lines
6.1 KiB
TypeScript
239 lines
6.1 KiB
TypeScript
import { prisma } from "@calcom/prisma";
|
|
import type { PrismaClient } from "@calcom/prisma";
|
|
import { Prisma } from "@calcom/prisma/client";
|
|
|
|
import { type TaskTypes } from "./tasker";
|
|
import { scanWorkflowBodySchema } from "./tasks/scanWorkflowBody";
|
|
|
|
const whereSucceeded: Prisma.TaskWhereInput = {
|
|
succeededAt: { not: null },
|
|
};
|
|
|
|
const whereMaxAttemptsReached: Prisma.TaskWhereInput = {
|
|
attempts: {
|
|
equals: {
|
|
// @ts-expect-error prisma is tripping: '_ref' does not exist in type 'FieldRef<"Task", "Int">'
|
|
_ref: "maxAttempts",
|
|
_container: "Task",
|
|
},
|
|
},
|
|
};
|
|
|
|
/** This is a function to ensure new Date is always fresh */
|
|
const makeWhereUpcomingTasks = (): Prisma.TaskWhereInput => ({
|
|
// Get only tasks that have not succeeded yet
|
|
succeededAt: null,
|
|
// Get only tasks that are scheduled to run now or in the past
|
|
scheduledAt: {
|
|
lt: new Date(),
|
|
},
|
|
// Get only tasks where maxAttemps has not been reached
|
|
attempts: {
|
|
lt: {
|
|
// @ts-expect-error prisma is tripping: '_ref' does not exist in type 'FieldRef<"Task", "Int">'
|
|
_ref: "maxAttempts",
|
|
_container: "Task",
|
|
},
|
|
},
|
|
});
|
|
|
|
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 this.deps.prismaClient.task.create({
|
|
data: {
|
|
payload,
|
|
type,
|
|
scheduledAt,
|
|
maxAttempts,
|
|
referenceUid,
|
|
},
|
|
});
|
|
return newTask.id;
|
|
}
|
|
|
|
async getNextBatch() {
|
|
console.info("Getting next batch of tasks", makeWhereUpcomingTasks());
|
|
return this.deps.prismaClient.task.findMany({
|
|
where: makeWhereUpcomingTasks(),
|
|
orderBy: {
|
|
scheduledAt: "asc",
|
|
},
|
|
take: 1000,
|
|
});
|
|
}
|
|
|
|
async getFailed() {
|
|
return this.deps.prismaClient.task.findMany({
|
|
where: whereMaxAttemptsReached,
|
|
});
|
|
}
|
|
|
|
async getSucceeded() {
|
|
return this.deps.prismaClient.task.findMany({
|
|
where: whereSucceeded,
|
|
});
|
|
}
|
|
|
|
async count() {
|
|
return this.deps.prismaClient.task.count();
|
|
}
|
|
|
|
async countUpcoming() {
|
|
return this.deps.prismaClient.task.count({
|
|
where: makeWhereUpcomingTasks(),
|
|
});
|
|
}
|
|
|
|
async countFailed() {
|
|
return this.deps.prismaClient.task.count({
|
|
where: whereMaxAttemptsReached,
|
|
});
|
|
}
|
|
|
|
async countSucceeded() {
|
|
return this.deps.prismaClient.task.count({
|
|
where: whereSucceeded,
|
|
});
|
|
}
|
|
|
|
async retry({
|
|
taskId,
|
|
lastError,
|
|
minRetryIntervalMins,
|
|
}: {
|
|
taskId: string;
|
|
lastError?: string;
|
|
minRetryIntervalMins?: number | null;
|
|
}) {
|
|
const failedAttemptTime = new Date();
|
|
const updatedScheduledAt = minRetryIntervalMins
|
|
? new Date(failedAttemptTime.getTime() + 1000 * 60 * minRetryIntervalMins)
|
|
: undefined;
|
|
|
|
return this.deps.prismaClient.task.update({
|
|
where: {
|
|
id: taskId,
|
|
},
|
|
data: {
|
|
attempts: { increment: 1 },
|
|
lastError,
|
|
lastFailedAttemptAt: failedAttemptTime,
|
|
...(updatedScheduledAt && {
|
|
scheduledAt: updatedScheduledAt,
|
|
}),
|
|
},
|
|
});
|
|
}
|
|
|
|
async succeed(taskId: string) {
|
|
return this.deps.prismaClient.task.update({
|
|
where: {
|
|
id: taskId,
|
|
},
|
|
data: {
|
|
attempts: { increment: 1 },
|
|
succeededAt: new Date(),
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
},
|
|
});
|
|
}
|
|
|
|
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 this.deps.prismaClient.task.delete({
|
|
where: {
|
|
referenceUid_type: {
|
|
referenceUid,
|
|
type,
|
|
},
|
|
},
|
|
select: {
|
|
id: true,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {
|
|
// P2025 is the error code for "Record to delete does not exist"
|
|
console.warn(`Task with reference ${referenceUid} and type ${type} does not exist. No action taken.`);
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async cleanup() {
|
|
// TODO: Uncomment this later
|
|
// return this.deps.prismaClient.task.deleteMany({
|
|
// where: {
|
|
// OR: [
|
|
// // Get tasks that have succeeded
|
|
// whereSucceeded,
|
|
// // Get tasks where maxAttemps has been reached
|
|
// whereMaxAttemptsReached,
|
|
// ],
|
|
// },
|
|
// });
|
|
}
|
|
|
|
async hasNewerScanTaskForStepId(workflowStepId: number, createdAt: string) {
|
|
const tasks = await this.deps.prismaClient.$queryRaw<{ payload: string }[]>`
|
|
SELECT "payload"
|
|
FROM "Task"
|
|
WHERE "type" = 'scanWorkflowBody'
|
|
AND "succeededAt" IS NULL
|
|
AND (payload::jsonb ->> 'workflowStepId')::int = ${workflowStepId}
|
|
`;
|
|
|
|
return tasks.some((task) => {
|
|
try {
|
|
const parsed = scanWorkflowBodySchema.parse(JSON.parse(task.payload));
|
|
if (!parsed.createdAt) return false;
|
|
return new Date(parsed.createdAt) > new Date(createdAt);
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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 });
|