* fix: use raw query at InsightsBookingService * feat: convert InsightsBookingService to use Prisma.sql raw queries - Convert auth conditions from Prisma object notation to Prisma.sql - Convert filter conditions from Prisma object notation to Prisma.sql - Update return types from Prisma.BookingTimeStatusDenormalizedWhereInput to Prisma.Sql - Fix type error in isOrgOwnerOrAdmin method - Follow same pattern as InsightsRoutingService conversion Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * feat: convert InsightsBookingService to use Prisma.sql raw queries - Convert auth conditions from Prisma object notation to Prisma.sql - Convert filter conditions from Prisma object notation to Prisma.sql - Update return types from Prisma.BookingTimeStatusDenormalizedWhereInput to Prisma.Sql - Fix type error in isOrgOwnerOrAdmin method - Follow same pattern as InsightsRoutingService conversion Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: update InsightsBookingService integration tests for Prisma.sql format - Replace Prisma object notation expectations with Prisma.sql template literals - Add NOTHING_CONDITION constant for consistency with InsightsRoutingService - Update all test cases to use direct Prisma.sql comparisons - Use $queryRaw for actual database integration testing - Follow same testing patterns as InsightsRoutingService Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: exclude intentionally skipped jobs from required CI check failure - Remove 'skipped' from failure condition in pr.yml and all-checks.yml - Allow E2E jobs to be skipped without failing the required check - Only actual failures and cancelled jobs will cause required check to fail Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix tests * Revert "fix: exclude intentionally skipped jobs from required CI check failure" This reverts commit 6ff44fc9a8f14ad657f7bba7c2e454e192b66c8f. * clean up tests * address feedback --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
221 lines
6.7 KiB
TypeScript
221 lines
6.7 KiB
TypeScript
import { Prisma } from "@prisma/client";
|
|
import { z } from "zod";
|
|
|
|
import type { readonlyPrisma } from "@calcom/prisma";
|
|
import { MembershipRole } from "@calcom/prisma/enums";
|
|
|
|
import { MembershipRepository } from "../repository/membership";
|
|
import { TeamRepository } from "../repository/team";
|
|
|
|
export const insightsBookingServiceOptionsSchema = z.discriminatedUnion("scope", [
|
|
z.object({
|
|
scope: z.literal("user"),
|
|
userId: z.number(),
|
|
orgId: z.number(),
|
|
}),
|
|
z.object({
|
|
scope: z.literal("org"),
|
|
userId: z.number(),
|
|
orgId: z.number(),
|
|
}),
|
|
z.object({
|
|
scope: z.literal("team"),
|
|
userId: z.number(),
|
|
orgId: z.number(),
|
|
teamId: z.number(),
|
|
}),
|
|
]);
|
|
|
|
export type InsightsBookingServicePublicOptions = {
|
|
scope: "user" | "org" | "team";
|
|
userId: number;
|
|
orgId: number;
|
|
teamId?: number;
|
|
};
|
|
|
|
export type InsightsBookingServiceOptions = z.infer<typeof insightsBookingServiceOptionsSchema>;
|
|
|
|
export type InsightsBookingServiceFilterOptions = {
|
|
eventTypeId?: number;
|
|
memberUserId?: number;
|
|
};
|
|
|
|
const NOTHING_CONDITION = Prisma.sql`1=0`;
|
|
|
|
export class InsightsBookingService {
|
|
private prisma: typeof readonlyPrisma;
|
|
private options: InsightsBookingServiceOptions | null;
|
|
private filters?: InsightsBookingServiceFilterOptions;
|
|
private cachedAuthConditions?: Prisma.Sql;
|
|
private cachedFilterConditions?: Prisma.Sql | null;
|
|
|
|
constructor({
|
|
prisma,
|
|
options,
|
|
filters,
|
|
}: {
|
|
prisma: typeof readonlyPrisma;
|
|
options: InsightsBookingServicePublicOptions;
|
|
filters?: InsightsBookingServiceFilterOptions;
|
|
}) {
|
|
this.prisma = prisma;
|
|
const validation = insightsBookingServiceOptionsSchema.safeParse(options);
|
|
this.options = validation.success ? validation.data : null;
|
|
|
|
this.filters = filters;
|
|
}
|
|
|
|
async getBaseConditions(): Promise<Prisma.Sql> {
|
|
const authConditions = await this.getAuthorizationConditions();
|
|
const filterConditions = await this.getFilterConditions();
|
|
|
|
if (authConditions && filterConditions) {
|
|
return Prisma.sql`(${authConditions}) AND (${filterConditions})`;
|
|
} else if (authConditions) {
|
|
return authConditions;
|
|
} else if (filterConditions) {
|
|
return filterConditions;
|
|
} else {
|
|
return NOTHING_CONDITION;
|
|
}
|
|
}
|
|
|
|
async getAuthorizationConditions(): Promise<Prisma.Sql> {
|
|
if (this.cachedAuthConditions === undefined) {
|
|
this.cachedAuthConditions = await this.buildAuthorizationConditions();
|
|
}
|
|
return this.cachedAuthConditions;
|
|
}
|
|
|
|
async getFilterConditions(): Promise<Prisma.Sql | null> {
|
|
if (this.cachedFilterConditions === undefined) {
|
|
this.cachedFilterConditions = await this.buildFilterConditions();
|
|
}
|
|
return this.cachedFilterConditions;
|
|
}
|
|
|
|
async buildFilterConditions(): Promise<Prisma.Sql | null> {
|
|
const conditions: Prisma.Sql[] = [];
|
|
|
|
if (!this.filters) {
|
|
return null;
|
|
}
|
|
|
|
if (this.filters.eventTypeId) {
|
|
conditions.push(
|
|
Prisma.sql`("eventTypeId" = ${this.filters.eventTypeId}) OR ("eventParentId" = ${this.filters.eventTypeId})`
|
|
);
|
|
}
|
|
|
|
if (this.filters.memberUserId) {
|
|
conditions.push(Prisma.sql`"userId" = ${this.filters.memberUserId}`);
|
|
}
|
|
|
|
if (conditions.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// Join all conditions with AND
|
|
return conditions.reduce((acc, condition, index) => {
|
|
if (index === 0) return condition;
|
|
return Prisma.sql`(${acc}) AND (${condition})`;
|
|
});
|
|
}
|
|
|
|
async buildAuthorizationConditions(): Promise<Prisma.Sql> {
|
|
if (!this.options) {
|
|
return NOTHING_CONDITION;
|
|
}
|
|
const isOwnerOrAdmin = await this.isOrgOwnerOrAdmin(this.options.userId, this.options.orgId);
|
|
if (!isOwnerOrAdmin) {
|
|
return NOTHING_CONDITION;
|
|
}
|
|
|
|
if (this.options.scope === "user") {
|
|
return Prisma.sql`("userId" = ${this.options.userId}) AND ("teamId" IS NULL)`;
|
|
} else if (this.options.scope === "org") {
|
|
return await this.buildOrgAuthorizationCondition(this.options);
|
|
} else if (this.options.scope === "team") {
|
|
return await this.buildTeamAuthorizationCondition(this.options);
|
|
} else {
|
|
return NOTHING_CONDITION;
|
|
}
|
|
}
|
|
|
|
private async buildOrgAuthorizationCondition(
|
|
options: Extract<InsightsBookingServiceOptions, { scope: "org" }>
|
|
): Promise<Prisma.Sql> {
|
|
// Get all teams from the organization
|
|
const teamRepo = new TeamRepository(this.prisma);
|
|
const teamsFromOrg = await teamRepo.findAllByParentId({
|
|
parentId: options.orgId,
|
|
select: { id: true },
|
|
});
|
|
const teamIds = [options.orgId, ...teamsFromOrg.map((t) => t.id)];
|
|
|
|
// Get all users from the organization
|
|
const userIdsFromOrg =
|
|
teamsFromOrg.length > 0
|
|
? (await MembershipRepository.findAllByTeamIds({ teamIds, select: { userId: true } })).map(
|
|
(m) => m.userId
|
|
)
|
|
: [];
|
|
|
|
const conditions: Prisma.Sql[] = [Prisma.sql`("teamId" = ANY(${teamIds})) AND ("isTeamBooking" = true)`];
|
|
|
|
if (userIdsFromOrg.length > 0) {
|
|
const uniqueUserIds = Array.from(new Set(userIdsFromOrg));
|
|
conditions.push(Prisma.sql`("userId" = ANY(${uniqueUserIds})) AND ("isTeamBooking" = false)`);
|
|
}
|
|
|
|
return conditions.reduce((acc, condition, index) => {
|
|
if (index === 0) return condition;
|
|
return Prisma.sql`(${acc}) OR (${condition})`;
|
|
});
|
|
}
|
|
|
|
private async buildTeamAuthorizationCondition(
|
|
options: Extract<InsightsBookingServiceOptions, { scope: "team" }>
|
|
): Promise<Prisma.Sql> {
|
|
const teamRepo = new TeamRepository(this.prisma);
|
|
const childTeamOfOrg = await teamRepo.findByIdAndParentId({
|
|
id: options.teamId,
|
|
parentId: options.orgId,
|
|
select: { id: true },
|
|
});
|
|
if (!childTeamOfOrg) {
|
|
return NOTHING_CONDITION;
|
|
}
|
|
|
|
const usersFromTeam = await MembershipRepository.findAllByTeamIds({
|
|
teamIds: [options.teamId],
|
|
select: { userId: true },
|
|
});
|
|
const userIdsFromTeam = usersFromTeam.map((u) => u.userId);
|
|
|
|
const conditions: Prisma.Sql[] = [
|
|
Prisma.sql`("teamId" = ${options.teamId}) AND ("isTeamBooking" = true)`,
|
|
];
|
|
|
|
if (userIdsFromTeam.length > 0) {
|
|
conditions.push(Prisma.sql`("userId" = ANY(${userIdsFromTeam})) AND ("isTeamBooking" = false)`);
|
|
}
|
|
|
|
return conditions.reduce((acc, condition, index) => {
|
|
if (index === 0) return condition;
|
|
return Prisma.sql`(${acc}) OR (${condition})`;
|
|
});
|
|
}
|
|
|
|
private async isOrgOwnerOrAdmin(userId: number, orgId: number): Promise<boolean> {
|
|
// Check if the user is an owner or admin of the organization
|
|
const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: orgId });
|
|
return Boolean(
|
|
membership &&
|
|
membership.accepted &&
|
|
membership.role &&
|
|
(membership.role === MembershipRole.OWNER || membership.role === MembershipRole.ADMIN)
|
|
);
|
|
}
|
|
}
|