From 61b170a89d82b520dab19d5cf57519f685246022 Mon Sep 17 00:00:00 2001 From: Parag Ghatage Date: Thu, 17 Jul 2025 11:04:54 +0530 Subject: [PATCH 1/3] add initial AuditLogService and /audit-logs API endpoint --- apps/api/v1/lib/audit-log.service.ts | 12 ++++++++ apps/api/v1/lib/helpers/audit-log.ts | 6 ++++ apps/api/v1/lib/types.ts | 12 ++++++++ apps/api/v1/pages/api/audit-logs/_get.ts | 29 ++++++++++++++++++ apps/api/v1/pages/api/audit-logs/_post.ts | 37 +++++++++++++++++++++++ apps/api/v1/pages/api/audit-logs/index.ts | 10 ++++++ 6 files changed, 106 insertions(+) create mode 100644 apps/api/v1/lib/audit-log.service.ts create mode 100644 apps/api/v1/lib/helpers/audit-log.ts create mode 100644 apps/api/v1/pages/api/audit-logs/_get.ts create mode 100644 apps/api/v1/pages/api/audit-logs/_post.ts create mode 100644 apps/api/v1/pages/api/audit-logs/index.ts diff --git a/apps/api/v1/lib/audit-log.service.ts b/apps/api/v1/lib/audit-log.service.ts new file mode 100644 index 00000000000000..d2bd718687c826 --- /dev/null +++ b/apps/api/v1/lib/audit-log.service.ts @@ -0,0 +1,12 @@ +import type { AuditLogEvent } from "~/lib/types"; + +export const AuditLogService = { + async logEvent(event: AuditLogEvent): Promise { + console.log("Audit Log Event:", event); + }, + + async getEvents(filter?: any): Promise { + // Returning Expty array for now + return []; + }, +}; diff --git a/apps/api/v1/lib/helpers/audit-log.ts b/apps/api/v1/lib/helpers/audit-log.ts new file mode 100644 index 00000000000000..49093096339467 --- /dev/null +++ b/apps/api/v1/lib/helpers/audit-log.ts @@ -0,0 +1,6 @@ +import { AuditLogService } from "~/lib/audit-log.service"; +import type { AuditLogEvent } from "~/lib/types"; + +export async function EmitAuditLogEvent(event: AuditLogEvent) { + await AuditLogService.logEvent(event); +} diff --git a/apps/api/v1/lib/types.ts b/apps/api/v1/lib/types.ts index 3022c8c0c808c1..bcf74b2177ec34 100644 --- a/apps/api/v1/lib/types.ts +++ b/apps/api/v1/lib/types.ts @@ -146,6 +146,18 @@ interface EventTypeExtended extends Omit; +} + // EventType export type EventTypeResponse = BaseResponse & { event_type?: Partial; diff --git a/apps/api/v1/pages/api/audit-logs/_get.ts b/apps/api/v1/pages/api/audit-logs/_get.ts new file mode 100644 index 00000000000000..aa9e9f2cd8218d --- /dev/null +++ b/apps/api/v1/pages/api/audit-logs/_get.ts @@ -0,0 +1,29 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server/defaultResponder"; + +import { AuditLogService } from "~/lib/audit-log.service"; +import type { AuditLogEvent } from "~/lib/types"; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { orgId, userId, action } = req.query; + + const filter: Record = { + orgId: typeof orgId === "string" ? orgId : undefined, + userId: typeof userId === "string" ? userId : undefined, + action: typeof action === "string" ? action : undefined, + }; + + try { + const events: AuditLogEvent[] = await AuditLogService.getEvents?.(filter); + return res.status(200).json({ events }); + } catch (error) { + throw new HttpError({ + statusCode: 500, + message: "Failed to fetch audit logs", + }); + } +} + +export default defaultResponder(handler); diff --git a/apps/api/v1/pages/api/audit-logs/_post.ts b/apps/api/v1/pages/api/audit-logs/_post.ts new file mode 100644 index 00000000000000..d03fcca14656c7 --- /dev/null +++ b/apps/api/v1/pages/api/audit-logs/_post.ts @@ -0,0 +1,37 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server/defaultResponder"; + +import { AuditLogService } from "~/lib/audit-log.service"; +import type { AuditLogEvent } from "~/lib/types"; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const body = req.body as Partial; + if (!body || typeof body !== "object") { + throw new HttpError({ + statusCode: 400, + message: "Invalid/Missing JSON body", + }); + } + + // Required audit event fields + const requiredFields = ["timestamp", "userId", "orgId", "action", "resource", "details"]; + for (const field of requiredFields) { + if (!body[field as keyof AuditLogEvent]) { + throw new HttpError({ + statusCode: 400, + message: `Missing required field: ${field}`, + }); + } + } + + try { + await AuditLogService.logEvent(body as AuditLogEvent); + return res.status(201).json({ ok: true }); + } catch (error: unknown) { + throw error; + } +} + +export default defaultResponder(handler); diff --git a/apps/api/v1/pages/api/audit-logs/index.ts b/apps/api/v1/pages/api/audit-logs/index.ts new file mode 100644 index 00000000000000..ecdb5ed46a70f9 --- /dev/null +++ b/apps/api/v1/pages/api/audit-logs/index.ts @@ -0,0 +1,10 @@ +import { defaultHandler } from "@calcom/lib/server/defaultHandler"; + +import { withMiddleware } from "~/lib/helpers/withMiddleware"; + +export default withMiddleware()( + defaultHandler({ + POST: import("./_post"), + GET: import("./_get"), + }) +); From df5db1694463f53ca372c5b7210d333cf3f32b2a Mon Sep 17 00:00:00 2001 From: Parag Ghatage Date: Fri, 18 Jul 2025 16:10:38 +0530 Subject: [PATCH 2/3] added AuditLog table in prisma --- .../src/ee/audit-logs/lib/audit-log.events.ts | 10 +++++++ .../ee/audit-logs/lib/audit-log.service.ts | 29 +++++++++++++++++++ .../migration.sql | 28 ++++++++++++++++++ packages/prisma/schema.prisma | 29 +++++++++++++++++++ 4 files changed, 96 insertions(+) create mode 100644 apps/api/v2/src/ee/audit-logs/lib/audit-log.events.ts create mode 100644 apps/api/v2/src/ee/audit-logs/lib/audit-log.service.ts create mode 100644 packages/prisma/migrations/20250718092001_add_audit_log/migration.sql diff --git a/apps/api/v2/src/ee/audit-logs/lib/audit-log.events.ts b/apps/api/v2/src/ee/audit-logs/lib/audit-log.events.ts new file mode 100644 index 00000000000000..e53925bf64a752 --- /dev/null +++ b/apps/api/v2/src/ee/audit-logs/lib/audit-log.events.ts @@ -0,0 +1,10 @@ +export const AUDIT_LOG_EVENT = "audit.log"; + +export type AuditLogPayload = { + teamId: number; + actorId: number; + action: string; + targetType: string; + targetId: string; + metadata?: Record; +}; diff --git a/apps/api/v2/src/ee/audit-logs/lib/audit-log.service.ts b/apps/api/v2/src/ee/audit-logs/lib/audit-log.service.ts new file mode 100644 index 00000000000000..2d5162989e6f76 --- /dev/null +++ b/apps/api/v2/src/ee/audit-logs/lib/audit-log.service.ts @@ -0,0 +1,29 @@ +import { AuditLogPayload } from "@/ee/audit-logs/lib/audit-log.events"; +import { Injectable } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; +import { PrismaWriteService } from "src/modules/prisma/prisma-write.service"; + +export abstract class AuditLogService { + abstract log(payload: AuditLogPayload): Promise; +} + +@Injectable() +export class PrismaAuditLogService extends AuditLogService { + // Injecting the PrismaWriteService class + constructor(private readonly prismaWriteService: PrismaWriteService) { + super(); + } + + async log(payload: AuditLogPayload): Promise { + await this.prismaWriteService.prisma.auditLog.create({ + data: { + teamId: payload.teamId, + actorId: payload.actorId, + action: payload.action, + targetType: payload.targetType, + targetId: payload.targetId, + metadata: (payload.metadata || {}) as Prisma.InputJsonValue, + }, + }); + } +} diff --git a/packages/prisma/migrations/20250718092001_add_audit_log/migration.sql b/packages/prisma/migrations/20250718092001_add_audit_log/migration.sql new file mode 100644 index 00000000000000..e0e556ce269356 --- /dev/null +++ b/packages/prisma/migrations/20250718092001_add_audit_log/migration.sql @@ -0,0 +1,28 @@ +-- CreateTable +CREATE TABLE "AuditLog" ( + "id" SERIAL NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "teamId" INTEGER NOT NULL, + "actorId" INTEGER NOT NULL, + "action" TEXT NOT NULL, + "targetType" TEXT NOT NULL, + "targetId" TEXT NOT NULL, + "metadata" JSONB NOT NULL, + + CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "AuditLog_teamId_idx" ON "AuditLog"("teamId"); + +-- CreateIndex +CREATE INDEX "AuditLog_actorId_idx" ON "AuditLog"("actorId"); + +-- CreateIndex +CREATE INDEX "AuditLog_targetType_targetId_idx" ON "AuditLog"("targetType", "targetId"); + +-- AddForeignKey +ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 8298a97a7985aa..5082aed84dde69 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -421,6 +421,9 @@ model User { creditBalance CreditBalance? whitelistWorkflows Boolean @default(false) + // Audit Logs + auditLogs AuditLog[] @relation("UserAuditLogs") + @@unique([email]) @@unique([email, username]) @@unique([username, organizationId]) @@ -546,6 +549,9 @@ model Team { managedOrganizations ManagedOrganization[] @relation("ManagerOrganization") filterSegments FilterSegment[] + // Audit logs + auditLogs AuditLog[] + @@unique([slug, parentId]) @@index([parentId]) } @@ -2411,3 +2417,26 @@ model RolePermission { // TODO: come back to this with indexs. @@index([action]) } + +model AuditLog { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + + // The organization/team this log belongs to. + teamId Int + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + + // Who performed the action. + actorId Int + actor User @relation("UserAuditLogs", fields: [actorId], references: [id], onDelete: Cascade) + + // What happened. + action String // e.g., "event_type.created", "booking.cancelled" + targetType String // e.g., "EventType", "Booking", "User" + targetId String + metadata Json // To store details like old/new values. + + @@index([teamId]) + @@index([actorId]) + @@index([targetType, targetId]) +} From 7f2d8b9debe6b0169953513f1185af5cb4e3c257 Mon Sep 17 00:00:00 2001 From: Parag Ghatage Date: Sat, 26 Jul 2025 21:44:44 +0530 Subject: [PATCH 3/3] Added emitAuditEvent method in bookings.services.ts and called it from createInstantBooking method --- apps/api/v2/package.json | 1 + apps/api/v2/src/app.module.ts | 2 + .../2024-08-13/services/bookings.service.ts | 47 ++++++++++++++++++- yarn.lock | 20 ++++++++ 4 files changed, 68 insertions(+), 2 deletions(-) diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index 87c2b98682e4c0..014a2251138c45 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -50,6 +50,7 @@ "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.1.1", "@nestjs/core": "^10.0.0", + "@nestjs/event-emitter": "^3.0.1", "@nestjs/jwt": "^10.2.0", "@nestjs/passport": "^10.0.2", "@nestjs/platform-express": "^10.0.0", diff --git a/apps/api/v2/src/app.module.ts b/apps/api/v2/src/app.module.ts index 6ec489bcba8830..7e6dc3690e2bc6 100644 --- a/apps/api/v2/src/app.module.ts +++ b/apps/api/v2/src/app.module.ts @@ -18,6 +18,7 @@ import { BullModule } from "@nestjs/bull"; import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; import { APP_GUARD, APP_INTERCEPTOR, APP_FILTER } from "@nestjs/core"; +import { EventEmitterModule } from "@nestjs/event-emitter"; import { seconds, ThrottlerModule } from "@nestjs/throttler"; import { SentryModule, SentryGlobalFilter } from "@sentry/nestjs/setup"; @@ -25,6 +26,7 @@ import { AppController } from "./app.controller"; @Module({ imports: [ + EventEmitterModule.forRoot(), SentryModule.forRoot(), ConfigModule.forRoot({ ignoreEnvFile: true, diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts index 6ec04b417a8136..a9a3d7a9935986 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts @@ -1,3 +1,4 @@ +import { AUDIT_LOG_EVENT, AuditLogPayload } from "@/ee/audit-logs/lib/audit-log.events"; import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository"; import { CalendarLink } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output"; import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service"; @@ -20,6 +21,7 @@ import { UsersService } from "@/modules/users/services/users.service"; import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository"; import { ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common"; import { BadRequestException } from "@nestjs/common"; +import { EventEmitter2 } from "@nestjs/event-emitter"; import { Request } from "express"; import { DateTime } from "luxon"; import { z } from "zod"; @@ -53,7 +55,7 @@ import { CancelBookingInput, } from "@calcom/platform-types"; import { PrismaClient } from "@calcom/prisma"; -import { EventType, User, Team } from "@calcom/prisma/client"; +import { Booking, EventType, User, Team } from "@calcom/prisma/client"; type CreatedBooking = { hosts: { id: number }[]; @@ -95,7 +97,8 @@ export class BookingsService_2024_08_13 { private readonly organizationsRepository: OrganizationsRepository, private readonly teamsRepository: TeamsRepository, private readonly teamsEventTypesRepository: TeamsEventTypesRepository, - private readonly errorsBookingsService: ErrorsBookingsService_2024_08_13 + private readonly errorsBookingsService: ErrorsBookingsService_2024_08_13, + private readonly eventEmitter: EventEmitter2 ) {} async createBooking(request: Request, body: CreateBookingInput) { @@ -385,6 +388,20 @@ export class BookingsService_2024_08_13 { throw new Error(`Booking with id=${booking.bookingId} was not found in the database`); } + // AUDIT LOG: Only emit an event if the event type belongs to a team. + if (eventType.teamId) { + this._emitAuditEvent({ + action: "booking.created", + actorId: bookingRequest.userId, + teamId: eventType.teamId, // This is now guaranteed to be a number + booking: databaseBooking, + metadata: { + instant: true, + bookerEmail: body.attendee.email, + }, + }); + } + return this.outputService.getOutputBooking(databaseBooking); } @@ -1018,4 +1035,30 @@ export class BookingsService_2024_08_13 { t: await getTranslation("en", "common"), }); } + + private _emitAuditEvent(data: { + action: string; + actorId: number; + teamId: number; + booking: Partial; + metadata?: Record; + }) { + if (!data.teamId) { + return; // Don't log if there's no associated team/org + } + + const payload: AuditLogPayload = { + action: data.action, + actorId: data.actorId, + teamId: data.teamId, + targetType: "Booking", + targetId: String(data.booking.id), + metadata: { + bookingUid: data.booking.uid, + bookingTitle: data.booking.title, + ...data.metadata, + }, + }; + this.eventEmitter.emit(AUDIT_LOG_EVENT, payload); + } } diff --git a/yarn.lock b/yarn.lock index 7a6dbca9284ba1..ea224a2fde9d32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2532,6 +2532,7 @@ __metadata: "@nestjs/common": ^10.0.0 "@nestjs/config": ^3.1.1 "@nestjs/core": ^10.0.0 + "@nestjs/event-emitter": ^3.0.1 "@nestjs/jwt": ^10.2.0 "@nestjs/passport": ^10.0.2 "@nestjs/platform-express": ^10.0.0 @@ -8805,6 +8806,18 @@ __metadata: languageName: node linkType: hard +"@nestjs/event-emitter@npm:^3.0.1": + version: 3.0.1 + resolution: "@nestjs/event-emitter@npm:3.0.1" + dependencies: + eventemitter2: 6.4.9 + peerDependencies: + "@nestjs/common": ^10.0.0 || ^11.0.0 + "@nestjs/core": ^10.0.0 || ^11.0.0 + checksum: 9e916a3f983f37088d1b3cba3167b5b16032085b6949763cb14db604b259468c0aefe379d8911f4c8e1158c308fb761bd0ee8d08845f56d547cd649252637602 + languageName: node + linkType: hard + "@nestjs/jwt@npm:^10.2.0": version: 10.2.0 resolution: "@nestjs/jwt@npm:10.2.0" @@ -26863,6 +26876,13 @@ __metadata: languageName: node linkType: hard +"eventemitter2@npm:6.4.9": + version: 6.4.9 + resolution: "eventemitter2@npm:6.4.9" + checksum: be59577c1e1c35509c7ba0e2624335c35bbcfd9485b8a977384c6cc6759341ea1a98d3cb9dbaa5cea4fff9b687e504504e3f9c2cc1674cf3bd8a43a7c74ea3eb + languageName: node + linkType: hard + "eventemitter3@npm:^4.0.0, eventemitter3@npm:^4.0.1, eventemitter3@npm:^4.0.4": version: 4.0.7 resolution: "eventemitter3@npm:4.0.7"