From e61e66ec3408e534c9ac8cc81ccfe7265cdb4e32 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Mon, 5 Jan 2026 17:24:30 +0530 Subject: [PATCH] chore: Ensure that uuid is available in server session[Booking Audit Prerequisite] (#25963) ## What does this PR do? Similar to #25721, adds uuid in session so that BookingAudit has it readily available Adds the user's UUID to the booking metadata by: 1. Extending the NextAuth `User` interface to include an optional `uuid` property from PrismaUser 2. Making `uuid` required on `Session.user` via intersection type (`User & { uuid: PrismaUser["uuid"] }`) 3. Adding `uuid` to the session user object in `getServerSession.ts` 4. Adding `uuid` to the `AdapterUser` transformation in `next-auth-custom-adapter.ts` 5. Passing `userUuid` from the session to the booking creation flow 6. Updating the API key verification flow to include `uuid` in the user data (repository, service, and type definitions) 7. Adding `req.userUuid` as a required field on the request object (like `req.userId`) 8. Adding `uuid` to mock session objects in web app routes and test context 9. Adding `uuid` to the `findByEmailAndIncludeProfilesAndPassword` query in UserRepository Also removes commented-out code that was placeholder for future work and fixes lint warnings for unused variables. ## Mandatory Tasks (DO NOT REMOVE) - [x] 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 - no documentation changes needed. - [x] I confirm automated tests are in place that prove my fix is effective or that my feature works. ## How should this be tested? 1. Verify that the NextAuth session callback is already populating the `uuid` field on the user object 2. Create a booking and confirm `userUuid` is included in the booking metadata 3. Test API v1 endpoints (`/api/invites` POST and `/api/teams/[teamId]/publish`) to verify they receive the uuid from the authenticated user 4. Check that the booking flow works correctly with the new parameter 5. Test SAML login flow to verify session still works correctly (uuid is resolved from database after authentication) ## Human Review Checklist - [ ] Verify the NextAuth session callback populates `uuid` - if not, this change will pass `undefined` at runtime - [ ] Confirm `userUuid` is consumed downstream in the booking service - [ ] Verify that `PrismaApiKeyRepository.findByHashedKey()` correctly fetches the user's uuid from the database - [ ] Verify the discriminated union type in `ApiKeyService.ts` ensures `result.user` is always defined when `result.valid` is true - [ ] Confirm all API v1 endpoints using `req.userUuid` go through the `verifyApiKey` middleware - [ ] Verify `UserRepository.findByEmailAndIncludeProfilesAndPassword()` includes `uuid` in the select clause - [ ] Verify SAML login still works correctly - uuid is now optional on User interface so SAML providers don't need to supply it at profile stage ## Updates since last revision - **Made uuid optional on User, required on Session**: Changed the type strategy so `uuid` is optional on the NextAuth `User` interface but required on `Session.user` via intersection type. This allows SAML providers to not supply uuid at the profile stage while ensuring uuid is always present on the session after the user is resolved from the database. - **Removed uuid from SAML functions**: Since uuid is now optional on User, the SAML profile and authorize functions no longer need to include it. --- Link to Devin run: https://app.devin.ai/sessions/97e5603b719a420b9b35041252c9db26 Requested by: hariom@cal.com (@hariombalhara) --- apps/api/v1/lib/helpers/verifyApiKey.test.ts | 3 +++ apps/api/v1/lib/helpers/verifyApiKey.ts | 7 ++++--- apps/api/v1/next.d.ts | 1 + apps/api/v1/pages/api/invites/_post.ts | 1 + apps/api/v1/pages/api/teams/[teamId]/publish.ts | 1 + .../actions/validateUserHasOrg.test.ts | 4 ++++ .../actions/validateUserHasOrgAdmin.test.ts | 8 ++++++++ apps/web/app/api/link/route.ts | 8 +++++--- apps/web/app/api/verify-booking-token/route.ts | 3 +++ packages/features/auth/lib/getServerSession.ts | 1 + .../features/auth/lib/next-auth-custom-adapter.ts | 1 + .../repositories/PrismaApiKeyRepository.ts | 1 + .../ee/api-keys/services/ApiKeyService.ts | 15 +++++++++------ .../features/users/repositories/UserRepository.ts | 1 + packages/types/next-auth.d.ts | 3 ++- tests/libs/mockTRPCContext.ts | 1 + 16 files changed, 46 insertions(+), 13 deletions(-) diff --git a/apps/api/v1/lib/helpers/verifyApiKey.test.ts b/apps/api/v1/lib/helpers/verifyApiKey.test.ts index 1ec41081b8..bd8c404a04 100644 --- a/apps/api/v1/lib/helpers/verifyApiKey.test.ts +++ b/apps/api/v1/lib/helpers/verifyApiKey.test.ts @@ -139,6 +139,7 @@ describe("Verify API key - Unit Tests", () => { valid: true, userId: 1, user: { + uuid: "test-uuid-1", role: UserPermissionRole.ADMIN, locked: false, email: "admin@example.com", @@ -183,6 +184,7 @@ describe("Verify API key - Unit Tests", () => { valid: true, userId: 2, user: { + uuid: "test-uuid-2", role: UserPermissionRole.USER, locked: false, email: "org-admin@acme.com", @@ -227,6 +229,7 @@ describe("Verify API key - Unit Tests", () => { valid: true, userId: 3, user: { + uuid: "test-uuid-3", role: UserPermissionRole.USER, locked: true, email: "locked@example.com", diff --git a/apps/api/v1/lib/helpers/verifyApiKey.ts b/apps/api/v1/lib/helpers/verifyApiKey.ts index 804b21bce3..b71cdcab31 100644 --- a/apps/api/v1/lib/helpers/verifyApiKey.ts +++ b/apps/api/v1/lib/helpers/verifyApiKey.ts @@ -36,9 +36,10 @@ export const verifyApiKey: NextMiddleware = async (req, res, next) => { return res.status(401).json({ error: result.error }); } - // save the user id in the request for later use - req.userId = result.userId!; - req.user = result.user!; + // save the user id and uuid in the request for later use + req.userId = result.userId; + req.userUuid = result.user.uuid; + req.user = result.user; const { isAdmin, scope } = await isAdminGuard(req); const userIsLockedOrBlocked = await isLockedOrBlocked(req); diff --git a/apps/api/v1/next.d.ts b/apps/api/v1/next.d.ts index 6816e97e4f..3c4f4ac536 100644 --- a/apps/api/v1/next.d.ts +++ b/apps/api/v1/next.d.ts @@ -8,6 +8,7 @@ export declare module "next" { session?: Session | null; userId: number; + userUuid: string; user?: { role: string; locked: boolean; email: string } | null; method: string; // session: { user: { id: number } }; diff --git a/apps/api/v1/pages/api/invites/_post.ts b/apps/api/v1/pages/api/invites/_post.ts index e23595939b..67340c8e68 100644 --- a/apps/api/v1/pages/api/invites/_post.ts +++ b/apps/api/v1/pages/api/invites/_post.ts @@ -22,6 +22,7 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) { return { user: { id: req.userId, + uuid: req.userUuid, username: "", profile: { id: null, diff --git a/apps/api/v1/pages/api/teams/[teamId]/publish.ts b/apps/api/v1/pages/api/teams/[teamId]/publish.ts index bf3ea71df6..665d5bd391 100644 --- a/apps/api/v1/pages/api/teams/[teamId]/publish.ts +++ b/apps/api/v1/pages/api/teams/[teamId]/publish.ts @@ -22,6 +22,7 @@ const patchHandler = async (req: NextApiRequest, res: NextApiResponse) => { return { user: { id: req.userId, + uuid: req.userUuid, username: "" /* Not used in this context */, role: req.isSystemWideAdmin ? UserPermissionRole.ADMIN : UserPermissionRole.USER, profile: { diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/actions/validateUserHasOrg.test.ts b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/actions/validateUserHasOrg.test.ts index ecc2f9eece..2fc5361f77 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/actions/validateUserHasOrg.test.ts +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/actions/validateUserHasOrg.test.ts @@ -38,6 +38,7 @@ describe("validateUserHasOrg", () => { hasValidLicense: true, user: { id: 123, + uuid: "test-uuid-123", email: "test@example.com", name: "Test User", org: { @@ -87,6 +88,7 @@ describe("validateUserHasOrg", () => { const mockSession = createMockSession({ user: { id: 123, + uuid: "test-uuid-123", email: "test@example.com", name: "Test User", org: undefined, @@ -132,6 +134,7 @@ describe("validateUserHasOrg", () => { const mockSession = createMockSession({ user: { id: 123, + uuid: "test-uuid-123", email: "test@example.com", name: "Test User", org: undefined, @@ -159,6 +162,7 @@ describe("validateUserHasOrg", () => { const mockSession = createMockSession({ user: { id: undefined as unknown as number, + uuid: "test-uuid-123", email: "test@example.com", name: "Test User", org: { diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/actions/validateUserHasOrgAdmin.test.ts b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/actions/validateUserHasOrgAdmin.test.ts index 664fc02fac..65e523ff03 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/actions/validateUserHasOrgAdmin.test.ts +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/actions/validateUserHasOrgAdmin.test.ts @@ -44,6 +44,7 @@ describe("validateUserHasOrgAdmin", () => { hasValidLicense: true, user: { id: 123, + uuid: "test-uuid-123", email: "test@example.com", name: "Test User", org: { @@ -95,6 +96,7 @@ describe("validateUserHasOrgAdmin", () => { const mockSession = createMockSession({ user: { id: 123, + uuid: "test-uuid-123", email: "test@example.com", name: "Test User", org: { @@ -136,6 +138,7 @@ describe("validateUserHasOrgAdmin", () => { const mockSession = createMockSession({ user: { id: 123, + uuid: "test-uuid-123", email: "test@example.com", name: "Test User", org: undefined, @@ -187,6 +190,7 @@ describe("validateUserHasOrgAdmin", () => { const mockSession = createMockSession({ user: { id: 123, + uuid: "test-uuid-123", email: "test@example.com", name: "Test User", org: undefined, @@ -216,6 +220,7 @@ describe("validateUserHasOrgAdmin", () => { const mockSession = createMockSession({ user: { id: 123, + uuid: "test-uuid-123", email: "test@example.com", name: "Test User", org: { @@ -260,6 +265,7 @@ describe("validateUserHasOrgAdmin", () => { const mockSession = createMockSession({ user: { id: 123, + uuid: "test-uuid-123", email: "test@example.com", name: "Test User", org: undefined, @@ -324,6 +330,7 @@ describe("validateUserHasOrgAdmin", () => { const mockSession = createMockSession({ user: { id: 123, + uuid: "test-uuid-123", email: "test@example.com", name: "Test User", org: { @@ -366,6 +373,7 @@ describe("validateUserHasOrgAdmin", () => { const mockSession = createMockSession({ user: { id: 123, + uuid: "test-uuid-123", email: "test@example.com", name: "Test User", org: undefined, diff --git a/apps/web/app/api/link/route.ts b/apps/web/app/api/link/route.ts index d105ee3b28..36113b72bc 100644 --- a/apps/web/app/api/link/route.ts +++ b/apps/web/app/api/link/route.ts @@ -37,10 +37,11 @@ const decryptedSchema = z.object({ }); // Move the sessionGetter function outside the GET function -const createSessionGetter = (userId: number) => async () => { +const createSessionGetter = (userId: number, userUuid: string) => async () => { return { user: { id: userId, + uuid: userUuid, username: "" /* Not used in this context */, role: UserPermissionRole.USER, /* Not used in this context */ @@ -85,13 +86,14 @@ async function handler(request: NextRequest) { }); // Use the factory function instead of declaring inside the block - const sessionGetter = createSessionGetter(userId); + const sessionGetter = createSessionGetter(userId, user.uuid); try { /** @see https://trpc.io/docs/server-side-calls */ // Create a legacy request object for compatibility const legacyReq = buildLegacyRequest(await headers(), await cookies()); - const res = {} as any; // Response is still mocked as it's not used in this context + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Response is mocked as it's not used in this context + const res = {} as any; const ctx = await createContext({ req: legacyReq, res }, sessionGetter); const createCaller = createCallerFactory(bookingsRouter); diff --git a/apps/web/app/api/verify-booking-token/route.ts b/apps/web/app/api/verify-booking-token/route.ts index 642b4fc00e..c0401168b2 100644 --- a/apps/web/app/api/verify-booking-token/route.ts +++ b/apps/web/app/api/verify-booking-token/route.ts @@ -96,6 +96,7 @@ async function handleBookingAction( return { user: { id: Number(userId), + uuid: user.uuid, username: "" /* Not used in this context */, role: UserPermissionRole.USER, /* Not used in this context */ @@ -118,7 +119,9 @@ async function handleBookingAction( const createCaller = createCallerFactory(bookingsRouter); // Use buildLegacyRequest to create a request object compatible with Pages Router + const legacyReq = request ? buildLegacyRequest(await headers(), await cookies()) : ({} as any); + const res = {} as any; const ctx = await createContext({ req: legacyReq, res }, sessionGetter); diff --git a/packages/features/auth/lib/getServerSession.ts b/packages/features/auth/lib/getServerSession.ts index 1fab94ef10..69fd6b063b 100644 --- a/packages/features/auth/lib/getServerSession.ts +++ b/packages/features/auth/lib/getServerSession.ts @@ -96,6 +96,7 @@ export async function getServerSession(options: { expires: new Date(typeof token.exp === "number" ? token.exp * 1000 : Date.now()).toISOString(), user: { id: user.id, + uuid: user.uuid, name: user.name, username: user.username, email: user.email, diff --git a/packages/features/auth/lib/next-auth-custom-adapter.ts b/packages/features/auth/lib/next-auth-custom-adapter.ts index 757349eaf9..7b75622e29 100644 --- a/packages/features/auth/lib/next-auth-custom-adapter.ts +++ b/packages/features/auth/lib/next-auth-custom-adapter.ts @@ -14,6 +14,7 @@ const parseIntSafe = (id: string | number): number => { // Simple utility functions for transformations const toAdapterUser = (user: User): AdapterUser => ({ id: user.id.toString(), + uuid: user.uuid, name: user.name, email: user.email, emailVerified: user.emailVerified, diff --git a/packages/features/ee/api-keys/repositories/PrismaApiKeyRepository.ts b/packages/features/ee/api-keys/repositories/PrismaApiKeyRepository.ts index 9482ab59d3..35fa5c024a 100644 --- a/packages/features/ee/api-keys/repositories/PrismaApiKeyRepository.ts +++ b/packages/features/ee/api-keys/repositories/PrismaApiKeyRepository.ts @@ -21,6 +21,7 @@ export class PrismaApiKeyRepository { expiresAt: true, user: { select: { + uuid: true, role: true, locked: true, email: true, diff --git a/packages/features/ee/api-keys/services/ApiKeyService.ts b/packages/features/ee/api-keys/services/ApiKeyService.ts index c1d175443c..2853359ce0 100644 --- a/packages/features/ee/api-keys/services/ApiKeyService.ts +++ b/packages/features/ee/api-keys/services/ApiKeyService.ts @@ -4,19 +4,22 @@ type Deps = { apiKeyRepo: PrismaApiKeyRepository; }; -interface VerifyKeyResult { - valid: boolean; - error?: string; - userId?: number; - user?: { +type VerifyKeyResult = { + valid: true; + userId: number; + user: { + uuid: string; role: string; locked: boolean; email: string; }; +} | { + valid: false; + error: string; } export class ApiKeyService { - constructor(private readonly deps: Deps) {} + constructor(private readonly deps: Deps) { } async verifyKeyByHashedKey(hashedKey: string): Promise { const apiKey = await this.deps.apiKeyRepo.findByHashedKey(hashedKey); diff --git a/packages/features/users/repositories/UserRepository.ts b/packages/features/users/repositories/UserRepository.ts index 073b85b53d..6af638fa99 100644 --- a/packages/features/users/repositories/UserRepository.ts +++ b/packages/features/users/repositories/UserRepository.ts @@ -339,6 +339,7 @@ export class UserRepository { locked: true, role: true, id: true, + uuid: true, username: true, name: true, email: true, diff --git a/packages/types/next-auth.d.ts b/packages/types/next-auth.d.ts index 4dd1cc4d76..769858fea7 100644 --- a/packages/types/next-auth.d.ts +++ b/packages/types/next-auth.d.ts @@ -13,11 +13,12 @@ declare module "next-auth" { hasValidLicense: boolean; profileId?: number | null; upId: string; - user: User; + user: User & { uuid: PrismaUser["uuid"] }; } interface User extends Omit { id: PrismaUser["id"]; + uuid?: PrismaUser["uuid"]; emailVerified?: PrismaUser["emailVerified"]; email_verified?: boolean; completedOnboarding?: boolean; diff --git a/tests/libs/mockTRPCContext.ts b/tests/libs/mockTRPCContext.ts index 840bd64fbe..e9c2359510 100644 --- a/tests/libs/mockTRPCContext.ts +++ b/tests/libs/mockTRPCContext.ts @@ -12,6 +12,7 @@ const sessionGetter: GetSessionFn = () => email: "", image: "", id: 1, + uuid: "test-uuid-mock-context", username: "" /* Not used in this context */, role: UserPermissionRole.USER, profile: {