feat: add uuid plumbing from API v2 to packages/features (#25721)

## What does this PR do?

This PR adds user UUID plumbing from API v2 controllers through to packages/features functions. This is preparatory work extracted from PR #25125 to support future audit logging functionality.

**Key changes:**
- API v2 controllers (2024-04-15, 2024-08-13) now extract and pass `userUuid` to downstream functions
- `handleMarkNoShow` and `CancelBookingInput` types now accept optional `userUuid` parameter
- `UserRepository.findUnlockedUserForSession` now selects `uuid` field
- Session middleware now includes `uuid` in the returned user object
- Fixed lint warning: changed `PromiseSettledResult<any>` to `PromiseSettledResult<unknown>`

**Refactoring (optimization):**
- Renamed `getOwnerId` → `getOwner` and `getOwnerIdRescheduledBooking` → `getOwnerRescheduledBooking`
- These methods now return `{ id: number; uuid: string } | null` instead of just `number | undefined`
- This eliminates redundant database calls by fetching user id and uuid in a single query

**Important:** This is plumbing-only - packages/features receives the `userUuid` but does not use it directly (note the `_userUuid` prefix). The actual audit logging usage will come in a follow-up PR.

Requested by: @hariombalhara (hariom@cal.com)

Link to Devin run: https://app.devin.ai/sessions/545209189f6347cd807bf1b336f9ac40

## 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. N/A - plumbing only, existing tests cover the functionality.

## How should this be tested?

1. Run type checks: `yarn type-check:ci --force`
2. Verify the changes compile without new type errors related to uuid
3. The `userUuid` parameters are optional, so existing functionality should work unchanged

## Checklist

- [x] My code follows the style guidelines of this project
- [x] I have checked if my changes generate no new warnings

## Human Review Checklist

- [ ] Verify the `userUuid` parameter is intentionally unused (prefixed with `_userUuid`) - this is plumbing for future audit logging
- [ ] Verify all callers of `getOwner` and `getOwnerRescheduledBooking` correctly handle the new `{ id, uuid } | null` return type
- [ ] Confirm the change from `undefined` to `null` as the "not found" return value is handled consistently
- [ ] Verify the `uuid` field exists in the User model schema
This commit is contained in:
Hariom Balhara authored and GitHub committed 2025-12-09 17:29:55 +05:30
1 parent f350542d0d
commit e20cc960f1
8 files changed
+136 -97

No files matched your search

@@ -125,7 +125,7 @@ export class BookingsController_2024_04_15 {
private readonly instantBookingCreateService: InstantBookingCreateService,
private readonly eventTypeRepository: PrismaEventTypeRepository,
private readonly teamRepository: PrismaTeamRepository
) {}
) { }
@Get("/")
@UseGuards(ApiAuthGuard)
@@ -270,6 +270,7 @@ export class BookingsController_2024_04_15 {
const res = await handleCancelBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
userUuid: bookingRequest.userUuid,
arePlatformEmailsEnabled: bookingRequest.arePlatformEmailsEnabled,
platformClientId: bookingRequest.platformClientId,
platformCancelUrl: bookingRequest.platformCancelUrl,
@@ -300,7 +301,7 @@ export class BookingsController_2024_04_15 {
@Permissions([BOOKING_WRITE])
@UseGuards(ApiAuthGuard)
async markNoShow(
@GetUser("id") userId: number,
@GetUser() user: UserWithProfile,
@Body() body: MarkNoShowInput_2024_04_15,
@Param("bookingUid") bookingUid: string
): Promise<MarkNoShowOutput_2024_04_15> {
@@ -309,7 +310,8 @@ export class BookingsController_2024_04_15 {
bookingUid: bookingUid,
attendees: body.attendees,
noShowHost: body.noShowHost,
userId,
userId: user.id,
userUuid: user.uuid,
});
return { status: SUCCESS_STATUS, data: markNoShowResponse };
@@ -378,7 +380,7 @@ export class BookingsController_2024_04_15 {
): Promise<ApiResponse<InstantBookingCreateResult>> {
const oAuthClientId =
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId));
req.userId = (await this.getOwnerId(req)) ?? -1;
req.userId = (await this.getOwner(req))?.id ?? -1;
try {
const bookingReq = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
const instantMeeting = await this.instantBookingCreateService.createBooking({
@@ -405,30 +407,45 @@ export class BookingsController_2024_04_15 {
throw new InternalServerErrorException("Could not create instant booking.");
}
private async getOwnerId(req: Request): Promise<number | undefined> {
private async getOwner(req: Request): Promise<{ id: number; uuid: string } | null> {
try {
const bearerToken = req.get("Authorization")?.replace("Bearer ", "");
if (bearerToken) {
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
const apiKeyHash = sha256Hash(strippedApiKey);
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
return keyData?.userId;
} else {
// Access Token
const ownerId = await this.oAuthFlowService.getOwnerId(bearerToken);
return ownerId;
}
if (!bearerToken) {
return null;
}
let ownerId: number | null = null;
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
const apiKeyHash = sha256Hash(strippedApiKey);
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
ownerId = keyData?.userId ?? null;
} else {
// Access Token
ownerId = await this.oAuthFlowService.getOwnerId(bearerToken);
}
if (!ownerId) {
return null;
}
const user = await this.usersRepository.findById(ownerId);
if (!user) {
return null;
}
return { id: user.id, uuid: user.uuid };
} catch (err) {
this.logger.error(err);
return null;
}
}
private async getOwnerIdRescheduledBooking(
private async getOwnerRescheduledBooking(
request: Request,
platformClientId?: string
): Promise<number | undefined> {
): Promise<{ id: number; uuid: string } | null> {
if (
platformClientId &&
request.body.rescheduledBy &&
@@ -440,13 +457,15 @@ export class BookingsController_2024_04_15 {
);
}
if (request.body.rescheduledBy) {
if (request.body.rescheduledBy !== request.body.responses.email) {
return (await this.usersRepository.findByEmail(request.body.rescheduledBy))?.id;
if (request.body.rescheduledBy && request.body.rescheduledBy !== request.body.responses.email) {
const user = await this.usersRepository.findByEmail(request.body.rescheduledBy);
if (!user) {
return null;
}
return { id: user.id, uuid: user.uuid };
}
return undefined;
return null;
}
private async getOAuthClientIdFromEventType(eventTypeId: number): Promise<string | undefined> {
@@ -498,7 +517,8 @@ export class BookingsController_2024_04_15 {
}
}
const userId = await this.getOwnerId(req);
const owner = await this.getOwner(req);
const userId = owner?.id;
if (!userId) {
throw new UnauthorizedException(
@@ -561,12 +581,16 @@ export class BookingsController_2024_04_15 {
oAuthClientId?: string,
platformBookingLocation?: string,
isEmbed?: string
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
): Promise<NextApiRequest & { userId?: number; userUuid?: string } & OAuthRequestParams> {
const requestId = req.get("X-Request-Id");
const clone = { ...req };
const userId = clone.body.rescheduleUid
? await this.getOwnerIdRescheduledBooking(req, oAuthClientId)
: await this.getOwnerId(req);
const owner = clone.body.rescheduleUid
? await this.getOwnerRescheduledBooking(req, oAuthClientId)
: await this.getOwner(req);
const userId = owner?.id;
const userUuid = owner?.uuid;
const oAuthParams = oAuthClientId
? await this.getOAuthClientsParams(oAuthClientId, this.transformToBoolean(isEmbed))
: DEFAULT_PLATFORM_PARAMS;
@@ -577,7 +601,7 @@ export class BookingsController_2024_04_15 {
oAuthClientId,
...oAuthParams,
});
Object.assign(clone, { userId, ...oAuthParams, platformBookingLocation });
Object.assign(clone, { userId, userUuid, ...oAuthParams, platformBookingLocation });
clone.body = {
...clone.body,
noEmail: !oAuthParams.arePlatformEmailsEnabled,
@@ -586,7 +610,7 @@ export class BookingsController_2024_04_15 {
if (oAuthClientId) {
await this.setPlatformAttendeesEmails(clone.body, oAuthClientId);
}
return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
return clone as unknown as NextApiRequest & { userId?: number; userUuid?: string } & OAuthRequestParams;
}
async setPlatformAttendeesEmails(
@@ -612,9 +636,12 @@ export class BookingsController_2024_04_15 {
oAuthClientId?: string,
platformBookingLocation?: string,
isEmbed?: string
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
): Promise<NextApiRequest & { userId?: number; userUuid?: string } & OAuthRequestParams> {
const clone = { ...req };
const userId = (await this.getOwnerId(req)) ?? -1;
const owner = await this.getOwner(req);
const userId = owner?.id ?? -1;
const userUuid = owner?.uuid;
const oAuthParams = oAuthClientId
? await this.getOAuthClientsParams(oAuthClientId, this.transformToBoolean(isEmbed))
: DEFAULT_PLATFORM_PARAMS;
@@ -628,6 +655,7 @@ export class BookingsController_2024_04_15 {
});
Object.assign(clone, {
userId,
userUuid,
...oAuthParams,
platformBookingLocation,
noEmail: !oAuthParams.arePlatformEmailsEnabled,
@@ -636,7 +664,7 @@ export class BookingsController_2024_04_15 {
if (oAuthClientId) {
await this.setPlatformAttendeesEmails(clone.body, oAuthClientId);
}
return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
return clone as unknown as NextApiRequest & { userId?: number; userUuid?: string } & OAuthRequestParams;
}
private handleBookingErrors(
@@ -397,18 +397,18 @@ export class BookingsController_2024_08_13 {
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
async markNoShow(
@Param("bookingUid") bookingUid: string,
@Body() body: MarkAbsentBookingInput_2024_08_13,
@GetUser("id") ownerId: number
): Promise<MarkAbsentBookingOutput_2024_08_13> {
const booking = await this.bookingsService.markAbsent(bookingUid, ownerId, body);
async markNoShow(
@Param("bookingUid") bookingUid: string,
@Body() body: MarkAbsentBookingInput_2024_08_13,
@GetUser() user: ApiAuthGuardUser
): Promise<MarkAbsentBookingOutput_2024_08_13> {
const booking = await this.bookingsService.markAbsent(bookingUid, user.id, body, user.uuid);
return {
status: SUCCESS_STATUS,
data: booking,
};
}
return {
status: SUCCESS_STATUS,
data: booking,
};
}
@Post("/:bookingUid/reassign")
@HttpCode(HttpStatus.OK)
@@ -919,34 +919,40 @@ export class BookingsService_2024_08_13 {
return await this.getBooking(recurringBookingUid, authUser);
}
async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) {
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
const bookingBefore = await this.bookingsRepository.getByUid(bookingUid);
async markAbsent(
bookingUid: string,
bookingOwnerId: number,
body: MarkAbsentBookingInput_2024_08_13,
userUuid?: string
) {
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
const bookingBefore = await this.bookingsRepository.getByUid(bookingUid);
if (!bookingBefore) {
throw new NotFoundException(`Booking with uid=${bookingUid} not found.`);
}
if (!bookingBefore) {
throw new NotFoundException(`Booking with uid=${bookingUid} not found.`);
}
const nowUtc = DateTime.utc();
const bookingStartTimeUtc = DateTime.fromJSDate(bookingBefore.startTime, { zone: "utc" });
const nowUtc = DateTime.utc();
const bookingStartTimeUtc = DateTime.fromJSDate(bookingBefore.startTime, { zone: "utc" });
if (nowUtc < bookingStartTimeUtc) {
throw new BadRequestException(
`Bookings can only be marked as absent after their scheduled start time. Current time in UTC+0: ${nowUtc.toISO()}, Booking start time in UTC+0: ${bookingStartTimeUtc.toISO()}`
);
}
if (nowUtc < bookingStartTimeUtc) {
throw new BadRequestException(
`Bookings can only be marked as absent after their scheduled start time. Current time in UTC+0: ${nowUtc.toISO()}, Booking start time in UTC+0: ${bookingStartTimeUtc.toISO()}`
);
}
const platformClientParams = bookingBefore?.eventTypeId
? await this.platformBookingsService.getOAuthClientParams(bookingBefore.eventTypeId)
: undefined;
const platformClientParams = bookingBefore?.eventTypeId
? await this.platformBookingsService.getOAuthClientParams(bookingBefore.eventTypeId)
: undefined;
await handleMarkNoShow({
bookingUid,
attendees: bodyTransformed.attendees,
noShowHost: bodyTransformed.noShowHost,
userId: bookingOwnerId,
platformClientParams,
});
await handleMarkNoShow({
bookingUid,
attendees: bodyTransformed.attendees,
noShowHost: bodyTransformed.noShowHost,
userId: bookingOwnerId,
userUuid,
platformClientParams,
});
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
@@ -66,6 +66,7 @@ export type BookingToDelete = Awaited<ReturnType<typeof getBookingToDelete>>;
export type CancelBookingInput = {
userId?: number;
userUuid?: string;
bookingData: z.infer<typeof bookingCancelInput>;
} & PlatformParams;
+3 -1
View File
@@ -45,7 +45,7 @@ const buildResultPayload = async (
};
};
const logFailedResults = (results: PromiseSettledResult<any>[]) => {
const logFailedResults = (results: PromiseSettledResult<unknown>[]) => {
const failed = results.filter((x) => x.status === "rejected") as PromiseRejectedResult[];
if (failed.length < 1) return;
const failedMessage = failed.map((r) => r.reason);
@@ -89,10 +89,12 @@ const handleMarkNoShow = async ({
attendees,
noShowHost,
userId,
userUuid: _userUuid,
locale,
platformClientParams,
}: TNoShowInputSchema & {
userId?: number;
userUuid?: string;
locale?: string;
platformClientParams?: PlatformClientParams;
}) => {
@@ -1014,18 +1014,19 @@ export class UserRepository {
};
}
async findUnlockedUserForSession({ userId }: { userId: number }) {
const user = await this.prismaClient.user.findUnique({
where: {
id: userId,
// Locked users can't login
locked: false,
},
select: {
id: true,
username: true,
name: true,
email: true,
async findUnlockedUserForSession({ userId }: { userId: number }) {
const user = await this.prismaClient.user.findUnique({
where: {
id: userId,
// Locked users can't login
locked: false,
},
select: {
id: true,
uuid: true,
username: true,
name: true,
email: true,
emailVerified: true,
bio: true,
avatarUrl: true,
@@ -45,10 +45,10 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
safeStringify({ user, userFromDb, upId })
);
const { email, username, id } = user;
if (!email || !id) {
return null; // should we return null here?
}
const { email, username, id, uuid } = user;
if (!email || !id) {
return null; // should we return null here?
}
const userMetaData = userMetadata.parse(user.metadata || {});
const orgMetadata = teamMetadataSchema.parse(user.profile?.organization?.metadata || {});
@@ -71,19 +71,20 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
requestedSlug: orgMetadata?.requestedSlug ?? null,
};
return {
...user,
avatar: `${WEBAPP_URL}/${user.username}/avatar.png${organization.id ? `?orgId=${organization.id}` : ""}`,
// TODO: OrgNewSchema - later - We could consolidate the props in user.profile?.organization as organization is a profile thing now.
organization,
organizationId: organization.id,
id,
email,
username,
locale,
defaultBookerLayouts: userMetaData?.defaultBookerLayouts || null,
requiresBookerEmailVerification: user.requiresBookerEmailVerification,
};
return {
...user,
avatar: `${WEBAPP_URL}/${user.username}/avatar.png${organization.id ? `?orgId=${organization.id}` : ""}`,
// TODO: OrgNewSchema - later - We could consolidate the props in user.profile?.organization as organization is a profile thing now.
organization,
organizationId: organization.id,
id,
uuid,
email,
username,
locale,
defaultBookerLayouts: userMetaData?.defaultBookerLayouts || null,
requiresBookerEmailVerification: user.requiresBookerEmailVerification,
};
}
export type UserFromSession = Awaited<ReturnType<typeof getUserFromSession>>;
@@ -40,7 +40,7 @@ import type { TConfirmInputSchema } from "./confirm.schema";
type ConfirmOptions = {
ctx: {
user: Pick<NonNullable<TrpcSessionUser>, "id" | "email" | "username" | "role" | "destinationCalendar">;
user: Pick<NonNullable<TrpcSessionUser>, "id" | "uuid" | "email" | "username" | "role" | "destinationCalendar">;
};
input: TConfirmInputSchema;
};