cd576f5378
* refactor: use dependency injection for InsightsBookingService - Add DI tokens for InsightsBookingService and module - Rename InsightsBookingService to InsightsBookingBaseService - Create InsightsBookingService DI interface with create method - Add DI module and container for InsightsBookingService - Update tRPC router to use getInsightsBookingService DI container - Update test file to use InsightsBookingBaseService - Update documentation to reflect new DI pattern Follows the same dependency injection pattern established for InsightsRoutingService in PR #22677 Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: restore createInsightsBookingService helper using DI internally - Keep createInsightsBookingService helper function for cleaner API - Use getInsightsBookingService DI container internally - Maintain same function signature and behavior - All existing calls continue to work unchanged Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: rename service files with proper capitalization - Rename insightsBookingBase.ts to InsightsBookingBaseService.ts - Rename insightsBookingDI.ts to InsightsBookingDIService.ts - Update all import statements to use new file names - Maintain existing handler functionality using createInsightsBookingService(ctx, input) Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * revert some changes * rename * update doc --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1114 lines
33 KiB
TypeScript
1114 lines
33 KiB
TypeScript
import { Prisma } from "@prisma/client";
|
|
import md5 from "md5";
|
|
import { z } from "zod";
|
|
|
|
import dayjs from "@calcom/dayjs";
|
|
import { ZColumnFilter } from "@calcom/features/data-table/lib/types";
|
|
import type { ColumnFilter } from "@calcom/features/data-table/lib/types";
|
|
import { isSingleSelectFilterValue, isMultiSelectFilterValue } from "@calcom/features/data-table/lib/utils";
|
|
import type { DateRange } from "@calcom/features/insights/server/insightsDateUtils";
|
|
import type { readonlyPrisma } from "@calcom/prisma";
|
|
import { MembershipRole } from "@calcom/prisma/enums";
|
|
|
|
import { MembershipRepository } from "../repository/membership";
|
|
import { TeamRepository } from "../repository/team";
|
|
|
|
// Utility function to build user hash map with avatar URL fallback
|
|
export const buildHashMapForUsers = <
|
|
T extends { avatarUrl: string | null; id: number; username: string | null; [key: string]: unknown }
|
|
>(
|
|
usersFromTeam: T[]
|
|
) => {
|
|
const userHashMap = new Map<number | null, Omit<T, "avatarUrl"> & { avatarUrl: string }>();
|
|
usersFromTeam.forEach((user) => {
|
|
userHashMap.set(user.id, {
|
|
...user,
|
|
// TODO: Use AVATAR_FALLBACK when avatar.png endpoint is fased out
|
|
avatarUrl: user.avatarUrl || `/${user.username}/avatar.png`,
|
|
});
|
|
});
|
|
return userHashMap;
|
|
};
|
|
|
|
// Type definition for BookingTimeStatusDenormalized view
|
|
export type BookingTimeStatusDenormalized = z.infer<typeof bookingDataSchema>;
|
|
|
|
// Helper type for select parameter
|
|
export type BookingSelect = {
|
|
[K in keyof BookingTimeStatusDenormalized]?: boolean;
|
|
};
|
|
|
|
// Helper type for selected fields
|
|
export type SelectedFields<T> = T extends undefined
|
|
? BookingTimeStatusDenormalized
|
|
: {
|
|
[K in keyof T as T[K] extends true ? K : never]: K extends keyof BookingTimeStatusDenormalized
|
|
? BookingTimeStatusDenormalized[K]
|
|
: never;
|
|
};
|
|
|
|
type UserStatsData = {
|
|
userId: number;
|
|
user: {
|
|
id: number;
|
|
username: string | null;
|
|
name: string | null;
|
|
email: string;
|
|
avatarUrl: string;
|
|
};
|
|
emailMd5: string;
|
|
count: number;
|
|
}[];
|
|
|
|
export const bookingDataSchema = z
|
|
.object({
|
|
id: z.number(),
|
|
uid: z.string(),
|
|
eventTypeId: z.number().nullable(),
|
|
title: z.string(),
|
|
description: z.string().nullable(),
|
|
startTime: z.date(),
|
|
endTime: z.date(),
|
|
createdAt: z.date(),
|
|
updatedAt: z.date().nullable(),
|
|
location: z.string().nullable(),
|
|
paid: z.boolean(),
|
|
status: z.string(), // BookingStatus enum
|
|
rescheduled: z.boolean().nullable(),
|
|
userId: z.number().nullable(),
|
|
teamId: z.number().nullable(),
|
|
eventLength: z.number().nullable(),
|
|
eventParentId: z.number().nullable(),
|
|
userEmail: z.string().nullable(),
|
|
userName: z.string().nullable(),
|
|
userUsername: z.string().nullable(),
|
|
ratingFeedback: z.string().nullable(),
|
|
rating: z.number().nullable(),
|
|
noShowHost: z.boolean().nullable(),
|
|
isTeamBooking: z.boolean(),
|
|
timeStatus: z.string().nullable(),
|
|
})
|
|
.strict();
|
|
|
|
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 = z.infer<typeof insightsBookingServiceFilterOptionsSchema>;
|
|
|
|
export const insightsBookingServiceFilterOptionsSchema = z.object({
|
|
dateRange: z
|
|
.object({
|
|
target: z.enum(["createdAt", "startTime"]),
|
|
startDate: z.string(),
|
|
endDate: z.string(),
|
|
})
|
|
.optional(),
|
|
columnFilters: z.array(ZColumnFilter).optional(),
|
|
});
|
|
|
|
const NOTHING_CONDITION = Prisma.sql`1=0`;
|
|
|
|
const bookingDataKeys = new Set(Object.keys(bookingDataSchema.shape));
|
|
|
|
export class InsightsBookingBaseService {
|
|
private prisma: typeof readonlyPrisma;
|
|
private options: InsightsBookingServiceOptions | null;
|
|
private filters: InsightsBookingServiceFilterOptions | null;
|
|
private cachedAuthConditions?: Prisma.Sql;
|
|
private cachedFilterConditions?: Prisma.Sql | null;
|
|
|
|
constructor({
|
|
prisma,
|
|
options,
|
|
filters,
|
|
}: {
|
|
prisma: typeof readonlyPrisma;
|
|
options: InsightsBookingServicePublicOptions;
|
|
filters?: InsightsBookingServiceFilterOptions;
|
|
}) {
|
|
this.prisma = prisma;
|
|
const optionsValidated = insightsBookingServiceOptionsSchema.safeParse(options);
|
|
this.options = optionsValidated.success ? optionsValidated.data : null;
|
|
|
|
const filtersValidated = insightsBookingServiceFilterOptionsSchema.safeParse(filters);
|
|
this.filters = filtersValidated.success ? filtersValidated.data : null;
|
|
}
|
|
|
|
async getBookingsByHourStats({ timeZone }: { timeZone: string }) {
|
|
const baseConditions = await this.getBaseConditions();
|
|
|
|
const results = await this.prisma.$queryRaw<
|
|
Array<{
|
|
hour: string;
|
|
count: number;
|
|
}>
|
|
>`
|
|
SELECT
|
|
EXTRACT(HOUR FROM ("startTime" AT TIME ZONE 'UTC' AT TIME ZONE ${timeZone}))::int as "hour",
|
|
COUNT(*)::int as "count"
|
|
FROM "BookingTimeStatusDenormalized"
|
|
WHERE ${baseConditions}
|
|
AND "status" = 'accepted'
|
|
GROUP BY 1
|
|
ORDER BY 1
|
|
`;
|
|
|
|
// Create a map of results by hour for easy lookup
|
|
const resultsMap = new Map(results.map((row) => [Number(row.hour), row.count]));
|
|
|
|
// Return all 24 hours (0-23), filling with 0 values for missing data
|
|
return Array.from({ length: 24 }, (_, hour) => ({
|
|
hour,
|
|
count: resultsMap.get(hour) || 0,
|
|
}));
|
|
}
|
|
|
|
async findAll<TSelect extends BookingSelect | undefined = undefined>({
|
|
select,
|
|
}: {
|
|
select?: TSelect;
|
|
} = {}): Promise<Array<SelectedFields<TSelect>>> {
|
|
const baseConditions = await this.getBaseConditions();
|
|
|
|
// Build the select clause with validated fields
|
|
let selectFields = Prisma.sql`*`;
|
|
if (select) {
|
|
const keys = Object.keys(select);
|
|
if (keys.some((key) => !bookingDataKeys.has(key))) {
|
|
throw new Error("Invalid select keys provided");
|
|
}
|
|
|
|
if (keys.length > 0) {
|
|
// Use Prisma.sql for each field to ensure proper escaping
|
|
const sqlFields = keys.map((field) => Prisma.sql`"${Prisma.raw(field)}"`);
|
|
selectFields = Prisma.join(sqlFields, ", ");
|
|
}
|
|
}
|
|
|
|
return await this.prisma.$queryRaw<Array<SelectedFields<TSelect>>>`
|
|
SELECT ${selectFields}
|
|
FROM "BookingTimeStatusDenormalized"
|
|
WHERE ${baseConditions}
|
|
`;
|
|
}
|
|
|
|
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 Prisma.sql`(${authConditions})`;
|
|
} else if (filterConditions) {
|
|
return Prisma.sql`(${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;
|
|
}
|
|
|
|
// Process columnFilters using type-safe utility functions
|
|
if (this.filters.columnFilters) {
|
|
for (const filter of this.filters.columnFilters) {
|
|
const condition = this.buildColumnFilterCondition(filter);
|
|
if (condition) {
|
|
conditions.push(condition);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Use dateRange object for date filtering
|
|
if (this.filters.dateRange) {
|
|
const { target, startDate, endDate } = this.filters.dateRange;
|
|
if (startDate) {
|
|
if (isNaN(Date.parse(startDate))) {
|
|
throw new Error(`Invalid date format: ${startDate}`);
|
|
}
|
|
conditions.push(Prisma.sql`"${Prisma.raw(target)}" >= ${startDate}::timestamp`);
|
|
}
|
|
if (endDate) {
|
|
if (isNaN(Date.parse(endDate))) {
|
|
throw new Error(`Invalid date format: ${endDate}`);
|
|
}
|
|
conditions.push(Prisma.sql`"${Prisma.raw(target)}" <= ${endDate}::timestamp`);
|
|
}
|
|
}
|
|
|
|
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})`;
|
|
});
|
|
}
|
|
|
|
private buildColumnFilterCondition(filter: ColumnFilter): Prisma.Sql | null {
|
|
const { id, value } = filter;
|
|
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
|
|
if (id === "eventTypeId" && isSingleSelectFilterValue(value) && typeof value.data === "number") {
|
|
return Prisma.sql`("eventTypeId" = ${value.data}) OR ("eventParentId" = ${value.data})`;
|
|
}
|
|
|
|
if (id === "userId" && isSingleSelectFilterValue(value) && typeof value.data === "number") {
|
|
return Prisma.sql`"userId" = ${value.data}`;
|
|
}
|
|
|
|
if (id === "status" && isMultiSelectFilterValue(value)) {
|
|
const statusValues = value.data.map((status) => Prisma.sql`${status}::"BookingStatus"`);
|
|
return Prisma.sql`"status" IN (${Prisma.join(statusValues)})`;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async buildAuthorizationConditions(): Promise<Prisma.Sql> {
|
|
if (!this.options) {
|
|
return NOTHING_CONDITION;
|
|
}
|
|
const scope = this.options.scope;
|
|
const targetId =
|
|
scope === "org" ? this.options.orgId : scope === "team" ? this.options.teamId : undefined;
|
|
|
|
if (targetId && scope !== "user") {
|
|
const isOwnerOrAdmin = await this.isOwnerOrAdmin(this.options.userId, targetId);
|
|
if (!isOwnerOrAdmin) {
|
|
return NOTHING_CONDITION;
|
|
}
|
|
}
|
|
|
|
if (scope === "user") {
|
|
return Prisma.sql`("userId" = ${this.options.userId}) AND ("teamId" IS NULL)`;
|
|
} else if (scope === "org") {
|
|
return await this.buildOrgAuthorizationCondition(this.options);
|
|
} else if (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 (options.orgId && !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})`;
|
|
});
|
|
}
|
|
|
|
async getCsvData({ limit = 100, offset = 0 }: { limit?: number; offset?: number }) {
|
|
const baseConditions = await this.getBaseConditions();
|
|
|
|
// Get total count first
|
|
const totalCountResult = await this.prisma.$queryRaw<[{ count: number }]>`
|
|
SELECT COUNT(*)::int as count
|
|
FROM "BookingTimeStatusDenormalized"
|
|
WHERE ${baseConditions}
|
|
`;
|
|
const totalCount = totalCountResult[0]?.count || 0;
|
|
|
|
// 1. Get booking data from BookingTimeStatusDenormalized
|
|
const csvData = await this.prisma.$queryRaw<
|
|
Array<{
|
|
id: number;
|
|
uid: string | null;
|
|
title: string;
|
|
createdAt: Date;
|
|
timeStatus: string;
|
|
eventTypeId: number | null;
|
|
eventLength: number;
|
|
startTime: Date;
|
|
endTime: Date;
|
|
paid: boolean;
|
|
userEmail: string;
|
|
userUsername: string;
|
|
rating: number | null;
|
|
ratingFeedback: string | null;
|
|
noShowHost: boolean;
|
|
}>
|
|
>`
|
|
SELECT
|
|
"id",
|
|
"uid",
|
|
"title",
|
|
"createdAt",
|
|
"timeStatus",
|
|
"eventTypeId",
|
|
"eventLength",
|
|
"startTime",
|
|
"endTime",
|
|
"paid",
|
|
"userEmail",
|
|
"userUsername",
|
|
"rating",
|
|
"ratingFeedback",
|
|
"noShowHost"
|
|
FROM "BookingTimeStatusDenormalized"
|
|
WHERE ${baseConditions}
|
|
ORDER BY "createdAt" DESC
|
|
LIMIT ${limit}
|
|
OFFSET ${offset}
|
|
`;
|
|
|
|
if (csvData.length === 0) {
|
|
return { data: csvData, total: totalCount };
|
|
}
|
|
|
|
const uids = csvData.filter((b) => b.uid !== null).map((b) => b.uid as string);
|
|
|
|
if (uids.length === 0) {
|
|
return { data: csvData, total: totalCount };
|
|
}
|
|
|
|
// 2. Get all bookings with their attendees and seat references
|
|
const bookings = await this.prisma.booking.findMany({
|
|
where: {
|
|
uid: {
|
|
in: uids,
|
|
},
|
|
},
|
|
select: {
|
|
uid: true,
|
|
attendees: {
|
|
select: {
|
|
name: true,
|
|
email: true,
|
|
noShow: true,
|
|
},
|
|
},
|
|
seatsReferences: {
|
|
select: {
|
|
attendee: {
|
|
select: {
|
|
name: true,
|
|
email: true,
|
|
noShow: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// 3. Create booking map with attendee data (matching original logic)
|
|
const bookingMap = new Map(
|
|
bookings.map((booking) => {
|
|
const attendeeList =
|
|
booking.seatsReferences.length > 0
|
|
? booking.seatsReferences.map((ref) => ref.attendee)
|
|
: booking.attendees;
|
|
|
|
// List all no-show guests (name and email)
|
|
const noShowGuests =
|
|
attendeeList
|
|
.filter((attendee) => attendee?.noShow)
|
|
.map((attendee) => (attendee ? `${attendee.name} (${attendee.email})` : null))
|
|
.filter(Boolean) // remove null values
|
|
.join("; ") || null;
|
|
const noShowGuestsCount = attendeeList.filter((attendee) => attendee?.noShow).length;
|
|
|
|
const formattedAttendees = attendeeList
|
|
.map((attendee) => (attendee ? `${attendee.name} (${attendee.email})` : null))
|
|
.filter(Boolean);
|
|
|
|
return [booking.uid, { attendeeList: formattedAttendees, noShowGuests, noShowGuestsCount }];
|
|
})
|
|
);
|
|
|
|
// 4. Calculate max attendees for dynamic columns
|
|
const maxAttendees = Math.max(
|
|
...Array.from(bookingMap.values()).map((data) => data.attendeeList.length),
|
|
0
|
|
);
|
|
|
|
// 5. Create final booking map with attendee fields
|
|
const finalBookingMap = new Map(
|
|
Array.from(bookingMap.entries()).map(([uid, data]) => {
|
|
const attendeeFields: Record<string, string | null> = {};
|
|
|
|
for (let i = 1; i <= maxAttendees; i++) {
|
|
attendeeFields[`attendee${i}`] = data.attendeeList[i - 1] || null;
|
|
}
|
|
|
|
return [
|
|
uid,
|
|
{
|
|
noShowGuests: data.noShowGuests,
|
|
noShowGuestsCount: data.noShowGuestsCount,
|
|
...attendeeFields,
|
|
},
|
|
];
|
|
})
|
|
);
|
|
|
|
// 6. Combine booking data with attendee data
|
|
const data = csvData.map((bookingTimeStatus) => {
|
|
if (!bookingTimeStatus.uid) {
|
|
// should not be reached because we filtered above
|
|
const nullAttendeeFields: Record<string, null> = {};
|
|
for (let i = 1; i <= maxAttendees; i++) {
|
|
nullAttendeeFields[`attendee${i}`] = null;
|
|
}
|
|
|
|
return {
|
|
...bookingTimeStatus,
|
|
noShowGuests: null,
|
|
noShowGuestsCount: 0,
|
|
...nullAttendeeFields,
|
|
};
|
|
}
|
|
|
|
const attendeeData = finalBookingMap.get(bookingTimeStatus.uid);
|
|
|
|
if (!attendeeData) {
|
|
const nullAttendeeFields: Record<string, null> = {};
|
|
for (let i = 1; i <= maxAttendees; i++) {
|
|
nullAttendeeFields[`attendee${i}`] = null;
|
|
}
|
|
|
|
return {
|
|
...bookingTimeStatus,
|
|
noShowGuests: null,
|
|
noShowGuestsCount: 0,
|
|
...nullAttendeeFields,
|
|
};
|
|
}
|
|
|
|
return {
|
|
...bookingTimeStatus,
|
|
noShowGuests: attendeeData.noShowGuests,
|
|
noShowGuestsCount: attendeeData.noShowGuestsCount,
|
|
...Object.fromEntries(Object.entries(attendeeData).filter(([key]) => key.startsWith("attendee"))),
|
|
};
|
|
});
|
|
|
|
return { data, total: totalCount };
|
|
}
|
|
|
|
async getEventTrendsStats({ timeZone, dateRanges }: { timeZone: string; dateRanges: DateRange[] }) {
|
|
if (!dateRanges.length) {
|
|
return [];
|
|
}
|
|
|
|
const baseConditions = await this.getBaseConditions();
|
|
|
|
const data = await this.prisma.$queryRaw<
|
|
{
|
|
date: Date;
|
|
bookingsCount: number;
|
|
timeStatus: string;
|
|
noShowHost: boolean;
|
|
noShowGuests: number;
|
|
}[]
|
|
>`
|
|
SELECT
|
|
"date",
|
|
CAST(COUNT(*) AS INTEGER) AS "bookingsCount",
|
|
CAST(COUNT(CASE WHEN "isNoShowGuest" = true THEN 1 END) AS INTEGER) AS "noShowGuests",
|
|
"timeStatus",
|
|
"noShowHost"
|
|
FROM (
|
|
SELECT
|
|
DATE("createdAt" AT TIME ZONE ${timeZone}) as "date",
|
|
"a"."noShow" AS "isNoShowGuest",
|
|
"timeStatus",
|
|
"noShowHost"
|
|
FROM
|
|
"BookingTimeStatusDenormalized"
|
|
JOIN
|
|
"Attendee" "a" ON "a"."bookingId" = "BookingTimeStatusDenormalized"."id"
|
|
WHERE
|
|
${baseConditions}
|
|
) AS bookings
|
|
GROUP BY
|
|
"date",
|
|
"timeStatus",
|
|
"noShowHost"
|
|
ORDER BY
|
|
"date"
|
|
`;
|
|
|
|
// Initialize aggregate object with zero counts for all date ranges
|
|
const aggregate: {
|
|
[date: string]: {
|
|
completed: number;
|
|
rescheduled: number;
|
|
cancelled: number;
|
|
noShowHost: number;
|
|
noShowGuests: number;
|
|
_all: number;
|
|
uncompleted: number;
|
|
};
|
|
} = {};
|
|
|
|
dateRanges.forEach(({ formattedDate }) => {
|
|
aggregate[formattedDate] = {
|
|
completed: 0,
|
|
rescheduled: 0,
|
|
cancelled: 0,
|
|
noShowHost: 0,
|
|
noShowGuests: 0,
|
|
_all: 0,
|
|
uncompleted: 0,
|
|
};
|
|
});
|
|
|
|
// Process the raw data and aggregate by date ranges
|
|
data.forEach(({ date, bookingsCount, timeStatus, noShowHost, noShowGuests }) => {
|
|
// Find which date range this date belongs to using native Date comparison
|
|
const dateRange = dateRanges.find((range) => {
|
|
const bookingDate = new Date(date);
|
|
const rangeStart = new Date(range.startDate);
|
|
const rangeEnd = new Date(range.endDate);
|
|
return bookingDate >= rangeStart && bookingDate <= rangeEnd;
|
|
});
|
|
|
|
if (!dateRange) return;
|
|
|
|
const formattedDate = dateRange.formattedDate;
|
|
const statusKey = timeStatus as keyof (typeof aggregate)[string];
|
|
|
|
// Add to the specific status count
|
|
if (statusKey in aggregate[formattedDate]) {
|
|
aggregate[formattedDate][statusKey] += Number(bookingsCount);
|
|
}
|
|
|
|
// Add to the total count (_all)
|
|
aggregate[formattedDate]["_all"] += Number(bookingsCount);
|
|
|
|
// Track no-show host counts separately
|
|
if (noShowHost) {
|
|
aggregate[formattedDate]["noShowHost"] += Number(bookingsCount);
|
|
}
|
|
|
|
// Track no-show guests explicitly
|
|
aggregate[formattedDate]["noShowGuests"] += noShowGuests;
|
|
});
|
|
|
|
// Transform aggregate data into the expected format
|
|
const result = dateRanges.map(({ formattedDate, formattedDateFull }) => {
|
|
const eventData = {
|
|
formattedDateFull: formattedDateFull,
|
|
Month: formattedDate,
|
|
Created: 0,
|
|
Completed: 0,
|
|
Rescheduled: 0,
|
|
Cancelled: 0,
|
|
"No-Show (Host)": 0,
|
|
"No-Show (Guest)": 0,
|
|
};
|
|
|
|
const countsForDateRange = aggregate[formattedDate];
|
|
|
|
if (countsForDateRange) {
|
|
eventData["Created"] = countsForDateRange["_all"] || 0;
|
|
eventData["Completed"] = countsForDateRange["completed"] || 0;
|
|
eventData["Rescheduled"] = countsForDateRange["rescheduled"] || 0;
|
|
eventData["Cancelled"] = countsForDateRange["cancelled"] || 0;
|
|
eventData["No-Show (Host)"] = countsForDateRange["noShowHost"] || 0;
|
|
eventData["No-Show (Guest)"] = countsForDateRange["noShowGuests"] || 0;
|
|
}
|
|
return eventData;
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
async getPopularEventsStats() {
|
|
const baseConditions = await this.getBaseConditions();
|
|
|
|
const bookingsFromSelected = await this.prisma.$queryRaw<
|
|
Array<{
|
|
eventTypeId: number;
|
|
count: number;
|
|
}>
|
|
>`
|
|
SELECT
|
|
"eventTypeId",
|
|
COUNT(id)::int as count
|
|
FROM "BookingTimeStatusDenormalized"
|
|
WHERE ${baseConditions} AND "eventTypeId" IS NOT NULL
|
|
GROUP BY "eventTypeId"
|
|
ORDER BY count DESC
|
|
LIMIT 10
|
|
`;
|
|
|
|
const eventTypeIds = bookingsFromSelected.map((booking) => booking.eventTypeId);
|
|
|
|
if (eventTypeIds.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const eventTypesFrom = await this.prisma.eventType.findMany({
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
teamId: true,
|
|
userId: true,
|
|
slug: true,
|
|
users: {
|
|
select: {
|
|
username: true,
|
|
},
|
|
},
|
|
team: {
|
|
select: {
|
|
slug: true,
|
|
},
|
|
},
|
|
},
|
|
where: {
|
|
id: {
|
|
in: eventTypeIds,
|
|
},
|
|
},
|
|
});
|
|
|
|
const eventTypeHashMap = new Map(eventTypesFrom.map((eventType) => [eventType.id, eventType]));
|
|
|
|
const result = bookingsFromSelected
|
|
.map((booking) => {
|
|
const eventTypeSelected = eventTypeHashMap.get(booking.eventTypeId);
|
|
if (!eventTypeSelected) {
|
|
return null;
|
|
}
|
|
|
|
let eventSlug = "";
|
|
if (eventTypeSelected.userId) {
|
|
eventSlug = `${eventTypeSelected?.users[0]?.username}/${eventTypeSelected?.slug}`;
|
|
}
|
|
if (eventTypeSelected?.team && eventTypeSelected?.team?.slug) {
|
|
eventSlug = `${eventTypeSelected.team.slug}/${eventTypeSelected.slug}`;
|
|
}
|
|
return {
|
|
eventTypeId: booking.eventTypeId,
|
|
eventTypeName: eventSlug,
|
|
count: booking.count,
|
|
};
|
|
})
|
|
.filter((item): item is NonNullable<typeof item> => item !== null);
|
|
|
|
return result;
|
|
}
|
|
|
|
async getMembersStatsWithCount(
|
|
type: "all" | "cancelled" | "noShow" = "all",
|
|
sortOrder: "ASC" | "DESC" = "DESC"
|
|
): Promise<UserStatsData> {
|
|
const baseConditions = await this.getBaseConditions();
|
|
|
|
let additionalCondition = Prisma.sql``;
|
|
if (type === "cancelled") {
|
|
additionalCondition = Prisma.sql`AND status = 'cancelled'`;
|
|
} else if (type === "noShow") {
|
|
additionalCondition = Prisma.sql`AND "noShowHost" = true`;
|
|
}
|
|
|
|
const bookingsFromTeam = await this.prisma.$queryRaw<
|
|
Array<{
|
|
userId: number;
|
|
count: number;
|
|
}>
|
|
>`
|
|
SELECT
|
|
"userId",
|
|
COUNT(id)::int as count
|
|
FROM "BookingTimeStatusDenormalized"
|
|
WHERE ${baseConditions} AND "userId" IS NOT NULL ${additionalCondition}
|
|
GROUP BY "userId"
|
|
ORDER BY count ${sortOrder === "ASC" ? Prisma.sql`ASC` : Prisma.sql`DESC`}
|
|
LIMIT 10
|
|
`;
|
|
|
|
if (bookingsFromTeam.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const userIds = bookingsFromTeam.map((booking) => booking.userId);
|
|
|
|
const usersFromTeam = await this.prisma.user.findMany({
|
|
where: {
|
|
id: {
|
|
in: userIds,
|
|
},
|
|
},
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
username: true,
|
|
avatarUrl: true,
|
|
},
|
|
});
|
|
|
|
const userHashMap = buildHashMapForUsers(usersFromTeam);
|
|
|
|
const result = bookingsFromTeam
|
|
.map((booking) => {
|
|
const user = userHashMap.get(booking.userId);
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
userId: booking.userId,
|
|
user,
|
|
emailMd5: md5(user.email || ""),
|
|
count: booking.count,
|
|
};
|
|
})
|
|
.filter((item): item is NonNullable<typeof item> => item !== null);
|
|
|
|
return result;
|
|
}
|
|
|
|
async getMembersRatingStats(sortOrder: "ASC" | "DESC" = "DESC"): Promise<UserStatsData> {
|
|
const baseConditions = await this.getBaseConditions();
|
|
|
|
const bookingsFromTeam = await this.prisma.$queryRaw<
|
|
Array<{
|
|
userId: number;
|
|
count: number;
|
|
}>
|
|
>`
|
|
SELECT
|
|
"userId",
|
|
AVG("rating")::float as "count"
|
|
FROM "BookingTimeStatusDenormalized"
|
|
WHERE ${baseConditions} AND "userId" IS NOT NULL AND "rating" IS NOT NULL
|
|
GROUP BY "userId"
|
|
ORDER BY "count" ${sortOrder === "ASC" ? Prisma.sql`ASC` : Prisma.sql`DESC`}
|
|
LIMIT 10
|
|
`;
|
|
|
|
if (bookingsFromTeam.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const userIds = bookingsFromTeam.map((booking) => booking.userId);
|
|
|
|
const usersFromTeam = await this.prisma.user.findMany({
|
|
where: {
|
|
id: {
|
|
in: userIds,
|
|
},
|
|
},
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
username: true,
|
|
avatarUrl: true,
|
|
},
|
|
});
|
|
|
|
const userHashMap = buildHashMapForUsers(usersFromTeam);
|
|
|
|
const result = bookingsFromTeam
|
|
.map((booking) => {
|
|
const user = userHashMap.get(booking.userId);
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
userId: booking.userId,
|
|
user,
|
|
emailMd5: md5(user.email),
|
|
count: booking.count,
|
|
};
|
|
})
|
|
.filter((item): item is NonNullable<typeof item> => item !== null);
|
|
|
|
return result;
|
|
}
|
|
|
|
async getRecentRatingsStats() {
|
|
const baseConditions = await this.getBaseConditions();
|
|
|
|
const bookingsFromTeam = await this.prisma.$queryRaw<
|
|
Array<{
|
|
userId: number | null;
|
|
rating: number | null;
|
|
ratingFeedback: string | null;
|
|
}>
|
|
>`
|
|
SELECT
|
|
"userId",
|
|
"rating",
|
|
"ratingFeedback"
|
|
FROM "BookingTimeStatusDenormalized"
|
|
WHERE ${baseConditions} AND "ratingFeedback" IS NOT NULL
|
|
ORDER BY "endTime" DESC
|
|
LIMIT 10
|
|
`;
|
|
|
|
if (bookingsFromTeam.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const userIds = bookingsFromTeam
|
|
.filter((booking) => booking.userId !== null)
|
|
.map((booking) => booking.userId as number)
|
|
.filter((userId, index, array) => array.indexOf(userId) === index);
|
|
|
|
if (userIds.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const usersFromTeam = await this.prisma.user.findMany({
|
|
where: {
|
|
id: {
|
|
in: userIds,
|
|
},
|
|
},
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
username: true,
|
|
avatarUrl: true,
|
|
},
|
|
});
|
|
|
|
const userHashMap = buildHashMapForUsers(usersFromTeam);
|
|
|
|
const result = bookingsFromTeam
|
|
.map((booking) => {
|
|
if (!booking.userId) {
|
|
return null;
|
|
}
|
|
|
|
const user = userHashMap.get(booking.userId);
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
userId: booking.userId,
|
|
user,
|
|
emailMd5: md5(user.email),
|
|
rating: booking.rating,
|
|
feedback: booking.ratingFeedback,
|
|
};
|
|
})
|
|
.filter((item): item is NonNullable<typeof item> => item !== null);
|
|
|
|
return result;
|
|
}
|
|
|
|
async getBookingStats() {
|
|
const baseConditions = await this.getBaseConditions();
|
|
|
|
const stats = await this.prisma.$queryRaw<
|
|
Array<{
|
|
total_bookings: bigint;
|
|
completed_bookings: bigint;
|
|
rescheduled_bookings: bigint;
|
|
cancelled_bookings: bigint;
|
|
no_show_host_bookings: bigint;
|
|
avg_rating: number | null;
|
|
total_ratings: bigint;
|
|
ratings_above_3: bigint;
|
|
no_show_guests: bigint;
|
|
}>
|
|
>`
|
|
WITH booking_stats AS (
|
|
SELECT
|
|
COUNT(*) as total_bookings,
|
|
COUNT(CASE WHEN "timeStatus" = 'completed' THEN 1 END) as completed_bookings,
|
|
COUNT(CASE WHEN "timeStatus" = 'rescheduled' THEN 1 END) as rescheduled_bookings,
|
|
COUNT(CASE WHEN "timeStatus" = 'cancelled' THEN 1 END) as cancelled_bookings,
|
|
COUNT(CASE WHEN "noShowHost" = true THEN 1 END) as no_show_host_bookings,
|
|
AVG(CASE WHEN "rating" IS NOT NULL THEN "rating" END) as avg_rating,
|
|
COUNT(CASE WHEN "rating" IS NOT NULL THEN 1 END) as total_ratings,
|
|
COUNT(CASE WHEN "rating" > 3 THEN 1 END) as ratings_above_3
|
|
FROM "BookingTimeStatusDenormalized"
|
|
WHERE ${baseConditions}
|
|
),
|
|
guest_stats AS (
|
|
SELECT COUNT(*) as no_show_guests
|
|
FROM "Attendee" a
|
|
INNER JOIN "BookingTimeStatusDenormalized" b ON a."bookingId" = b.id
|
|
WHERE ${baseConditions} AND a."noShow" = true
|
|
)
|
|
SELECT
|
|
bs.total_bookings,
|
|
bs.completed_bookings,
|
|
bs.rescheduled_bookings,
|
|
bs.cancelled_bookings,
|
|
bs.no_show_host_bookings,
|
|
bs.avg_rating,
|
|
bs.total_ratings,
|
|
bs.ratings_above_3,
|
|
gs.no_show_guests
|
|
FROM booking_stats bs, guest_stats gs
|
|
`;
|
|
|
|
const rawStats = stats[0];
|
|
return rawStats
|
|
? {
|
|
total_bookings: Number(rawStats.total_bookings),
|
|
completed_bookings: Number(rawStats.completed_bookings),
|
|
rescheduled_bookings: Number(rawStats.rescheduled_bookings),
|
|
cancelled_bookings: Number(rawStats.cancelled_bookings),
|
|
no_show_host_bookings: Number(rawStats.no_show_host_bookings),
|
|
avg_rating: rawStats.avg_rating,
|
|
total_ratings: Number(rawStats.total_ratings),
|
|
ratings_above_3: Number(rawStats.ratings_above_3),
|
|
no_show_guests: Number(rawStats.no_show_guests),
|
|
}
|
|
: {
|
|
total_bookings: 0,
|
|
completed_bookings: 0,
|
|
rescheduled_bookings: 0,
|
|
cancelled_bookings: 0,
|
|
no_show_host_bookings: 0,
|
|
avg_rating: 0,
|
|
total_ratings: 0,
|
|
ratings_above_3: 0,
|
|
no_show_guests: 0,
|
|
};
|
|
}
|
|
|
|
calculatePreviousPeriodDates() {
|
|
if (!this.filters?.dateRange) {
|
|
throw new Error("Date range is required for calculating previous period");
|
|
}
|
|
|
|
const startDate = dayjs(this.filters.dateRange.startDate);
|
|
const endDate = dayjs(this.filters.dateRange.endDate);
|
|
const startTimeEndTimeDiff = endDate.diff(startDate, "day");
|
|
|
|
const lastPeriodStartDate = startDate.subtract(startTimeEndTimeDiff, "day");
|
|
const lastPeriodEndDate = endDate.subtract(startTimeEndTimeDiff, "day");
|
|
|
|
return {
|
|
startDate: lastPeriodStartDate.toISOString(),
|
|
endDate: lastPeriodEndDate.toISOString(),
|
|
formattedStartDate: lastPeriodStartDate.format("YYYY-MM-DD"),
|
|
formattedEndDate: lastPeriodEndDate.format("YYYY-MM-DD"),
|
|
};
|
|
}
|
|
|
|
private async isOwnerOrAdmin(userId: number, targetId: number): Promise<boolean> {
|
|
// Check if the user is an owner or admin of the organization or team
|
|
const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: targetId });
|
|
return Boolean(
|
|
membership &&
|
|
membership.accepted &&
|
|
membership.role &&
|
|
(membership.role === MembershipRole.OWNER || membership.role === MembershipRole.ADMIN)
|
|
);
|
|
}
|
|
}
|