Files
calendar/packages/features/insights/server/trpc-router.ts
T
c28eb90928 chore: Upgrade prisma to 6.7.0 (#21264)
* chore: Upgrade prisma to 6.7.0

* Build fixes

* type fixes

Signed-off-by: Omar López <zomars@me.com>

* Update schema.prisma

* Patching

* Revert "Update schema.prisma"

This reverts commit 47d8618bf89ef4d007b30084df766f17281e21a1.

* Revert "Patching"

This reverts commit a1d2e3040e71690a44d4324db95d73b4d68c6adb.

* Revert schema changes

Signed-off-by: Omar López <zomars@me.com>

* WIP

Signed-off-by: Omar López <zomars@me.com>

* Update getPublicEvent.ts

* Update imports

Signed-off-by: Omar López <zomars@me.com>

* Update gitignore

Signed-off-by: Omar López <zomars@me.com>

* update remaining imports

Signed-off-by: Omar López <zomars@me.com>

* Delete .cursor/config.json

* Discard changes to packages/features/eventtypes/lib/getPublicEvent.ts

* Update _get.ts

* Update user.ts

* Update .gitignore

* update

* Update WorkflowStepContainer.tsx

* Update next-auth-custom-adapter.ts

* Update getPublicEvent.ts

* Update workflow.ts

* Update next-auth-custom-adapter.ts

* Update next-auth-options.ts

* Update bookingScenario.ts

* fix missing imports

* upgrades prismock

Signed-off-by: Omar López <zomars@me.com>

* patches prismock

Signed-off-by: Omar López <zomars@me.com>

* Update reschedule.test.ts

* Update prisma.ts

* patch prismock

Signed-off-by: Omar López <zomars@me.com>

* fix enums imports

Signed-off-by: Omar López <zomars@me.com>

* Revert "Update prisma.ts"

This reverts commit 64edcf8db54171ff4456c209d563b5d431d99619.

* Revert "patch prismock"

This reverts commit e95819113dc9d88e7130947aa120cd42710977c8.

* fix patch

* Fix test that overrun the boundary, it shouldn't test too much

* Move prisma import to changeSMSLockState

* Bring back broken test without illegal imports

* Merge with main and fix filter hosts by same round robin host

* Fixed buildDryRunBooking fn tests

* Fix and move ooo create or update handler test

* Fix packages/features/eventtypes/lib/isCurrentlyAvailable.test.ts

* Fix packages/trpc/server/routers/viewer/organizations/listMembers.handler.test.ts

* Mock @calcom/prisma

* Fix: verify-email.test.ts

* fix: Moved WebhookService test and fixed default import mock

* Fix: Added missing prisma mock, handleNewBooking uses that of course

* We're not testing createContext here

* fix: Prisma mock fix for listMembers.test.ts

* More fixes to broken testcases

* Forgot to remove borked test

* Prevent the need to mock a lot of dependencies by moving out buildBaseWhereCondition to its own file

* Temporarily skip getCalendarEvents, needs a rewrite

* Fix: turns out you can access protected in testcases

* fix further mocks

* Added packages/features/insights/server/buildBaseWhereCondition.ts, types

* Always great to have a mock and then not use it

* And one less again.

* fix: confirm.handler.test, didn't mock prisma

* fix: Address minor nit by @eunjae & fix ImpersonationProvider test

* Updated isPrismaAvailableCheck that doesn't crash on import

* fix: Get Prisma directly from the client, it usually involves the Validator and does not need 'local' inclusion

* Add zod-prisma-types without the generator enabled (commented out)

* Uncomment and see what happens

* Change method of import as imports did not work in Input Schemas

* Remove custom 'zod' booking model, it does not belong with Prisma

* Fix all other global Model imports

* Rewrite most schema includes AND remove barrel file

* Add bookingCreateBodySchema to features/bookings

* Flurry of type fixes for compatibility with new zod gen

* Refactor out the custom prisma type createEventTypeInput

* Work around nullable eventTypeLocations

* HandlePayment type fix

* More fixes, final fix remaining is CompleteEventType

* Should fix a bunch more booking related type errors

* Missed one

* Some props missing from BookingCreateBodySchema

* Fix location type in handleChildrenEventTypes

* Little bit hacky imo but it works

* Final type error \o/

* Forgot to include Prisma

* Do not include zod-utils in booker/types

* Oops, was already including Booker/types

* Fix membership type, also disallow updating createdAt/updatedAt, make part of patch/post

* Fix api v1 type errors

* Fix EventTypeDescription typings

* Remove getParserWithGeneric, use userBodySchema with UserSchema

* use centralized timeZoneSchema

* Implement feedback by @zomars

* Couple of WIP pushes

* Fix tests

* Type fixes in `handleChildrenEventTypes` test

* Try and parse metadata before use

* Change zod-prisma-types configuration for optimal performance

* Fix prisma validator error in `prisma/selects/credential`

* Disable seperate relations model, hits a bug

* Import absolute - this makes rollup work in @platform/libraries

* Attempt at removing resolutions override

* Refactor using `Prisma.validator` to `satisfies`

* Build atoms using @calcom/prisma/client

* Build atoms using @calcom/prisma/client

* fixes

* Update eventTypeSelect.ts

* Adjust `eventTypeMetaDataSchemaWithUntypedApps` from `unknown` to `record(any)`

* `EventTypeDescription` rely on `descriptionAsSafeHTML` instead of `description`

* Add `seatsPerTimeSlot` to event type public select

* Fix typing in `users-public-view` getServerSide props

* Add missing `schedulingType` to prop

* chore: bump platform libraries

* Function return type is illegal, not sure how this passed eslint (#21567)

* Merged with main

* Update updateTokenObject.ts

* Update handleResponse.ts

* Update index.ts

* Update handleChildrenEventTypes.ts

* Update booking-idempotency-key.ts

* Update WebhookService.test.ts

* Update events.test.ts

* Update queued-response.test.ts

* Update events.test.ts

* Update getRoutedUrl.test.ts

* fix: type checks

Signed-off-by: Omar López <zomars@me.com>

* fixes

Signed-off-by: Omar López <zomars@me.com>

* chore: bump platform libraries

* Update yarn.lock

* more fixes

Signed-off-by: Omar López <zomars@me.com>

* fixes

Signed-off-by: Omar López <zomars@me.com>

* biuld fixes

* chore: bump platform libraries

* Update conferencing.repository.ts

* Update conferencing.repository.ts

* Update getCalendarsEvents.test.ts

* Update vite.config.js

* chore: bump platform libraries

* Update users.ts

* Discard changes to docs/api-reference/v2/openapi.json

* Update vite.config.ts

* updated platform libraries

* Update get.handler.test.ts

* Update get.handler.test.ts

* Update schema.prisma

* Discard changes to docs/api-reference/v2/openapi.json

* Update next-auth-custom-adapter.ts

* Update team.ts

* Flurry of type fixes

* Fix majority of insight related type errors

* Type fixes for unlink of account

* Make user nullable again

* Fixed a bunch of unit tests and one type error

* Attempted mock fix

* Attempted fix for Attribute type

* Ensure default import becomes prisma, but not direct usage

* Import default as prisma in prisma.module

* Add attributeOption to attribute type

* Fix calcom/prisma mock

* Refactor Prisma client imports to @calcom/prisma/client

Updated all imports from '@prisma/client' to '@calcom/prisma/client' across tests and repository files for consistency and to use the correct Prisma client package. This change improves maintainability and ensures the correct client is referenced throughout the codebase.

* Undo removal of max-warnings=0 to get main to merge

* Remove unit tests for e2e fixtures, provide new prisma mock

* Mock @calcom/prisma in event manager

* Mock @calcom/prisma in event manager

* Add correct format even with --no-verify

* Mock prisma in CalendarManager

* Add mock for permission-check.service

* Better injection in PrismaApiKeyRepository imports

* More mock fixes :)

* Fix listMembers.handler.test

* Fix User import

* Appropriately adjust all types to be imported as types, there were a lot of types imported as normal deps

* Why was this a thing?

* Strictly speaking; Not using prismock anymore

* Ditched patch file for prismock

* Fix output.service.ts platform type imports, need concrete for plainToClass

* Better typing and tests for unlinkConnectedAccount.handler

* Small type fix

* Disable calendar cache tests as they are dependent on prismock

* chore: bump platform lib

* getRoutedUrl test remove of unused import

* Extract select to external const on getEventTypesFromDB

* Direct select of userSelect from selects/user

* fix type error from merging 23653

* Fixed integration tests by removing hardcoded values that were possible due to mocking, but as its now directly hitting the db no longer

* fix: vite config atoms prisma client type location

* revert: example app prisma client

* revert: example app prisma client

* bump platform libs

* fix: use class instead of type for DI of PlatformBookingsService

* update platform libs

* remove unused variable

* chore: generate prisma client for api v2

* fix: api v2 e2e

* fix: atoms e2e

* fix: atoms e2e

* fix: atoms e2e

* fix: api v2 e2e

* fix: tsconfig apiv2 enums

* publish libraries

* Simplify check for existence teamId

---------

Signed-off-by: Omar López <zomars@me.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>
Co-authored-by: cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-09-11 15:27:50 +01:00

1191 lines
34 KiB
TypeScript

import { z } from "zod";
import dayjs from "@calcom/dayjs";
import {
insightsRoutingServiceInputSchema,
insightsRoutingServicePaginatedInputSchema,
routingRepositoryBaseInputSchema,
routedToPerPeriodInputSchema,
routedToPerPeriodCsvInputSchema,
bookingRepositoryBaseInputSchema,
} from "@calcom/features/insights/server/raw-data.schema";
import { getInsightsBookingService } from "@calcom/lib/di/containers/InsightsBooking";
import { getInsightsRoutingService } from "@calcom/lib/di/containers/InsightsRouting";
import type { PrismaClient } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure";
import { router } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import { hasInsightsPermission } from "./hasInsightsPermission";
import { getTimeView, getDateRanges, type GetDateRangesParams } from "./insightsDateUtils";
import { RoutingEventsInsights } from "./routing-events";
import { VirtualQueuesInsights } from "./virtual-queues";
const UserBelongsToTeamInput = z.object({
teamId: z.coerce.number().optional().nullable(),
isAll: z.boolean().optional(),
});
type BuildBaseWhereConditionCtxType = {
userIsOwnerAdminOfParentTeam: boolean;
userOrganizationId: number | null;
insightsDb: PrismaClient;
};
interface BuildBaseWhereConditionType {
teamId?: number | null;
eventTypeId?: number;
memberUserId?: number;
userId?: number;
isAll?: boolean;
ctx: BuildBaseWhereConditionCtxType;
}
export const buildBaseWhereCondition = async ({
teamId,
eventTypeId,
memberUserId,
userId,
isAll,
ctx,
}: BuildBaseWhereConditionType): Promise<{
whereCondition: Prisma.BookingTimeStatusDenormalizedWhereInput;
}> => {
const conditions: Prisma.BookingTimeStatusDenormalizedWhereInput[] = [];
// EventType Filter
if (eventTypeId) {
conditions.push({
OR: [{ eventTypeId }, { eventParentId: eventTypeId }],
});
}
// User/Member filter
if (memberUserId) {
conditions.push({ userId: memberUserId });
}
if (userId) {
conditions.push({
teamId: null,
userId: userId,
});
}
// organization-wide queries condition
if (isAll && ctx.userIsOwnerAdminOfParentTeam && ctx.userOrganizationId) {
const teamsFromOrg = await ctx.insightsDb.team.findMany({
where: {
parentId: ctx.userOrganizationId,
},
select: {
id: true,
},
});
const teamIds = [ctx.userOrganizationId, ...teamsFromOrg.map((t) => t.id)];
const usersFromOrg =
teamsFromOrg.length > 0
? await ctx.insightsDb.membership.findMany({
where: {
team: {
id: {
in: teamIds,
},
},
accepted: true,
},
select: {
userId: true,
},
})
: [];
conditions.push({
OR: [
{
teamId: {
in: teamIds,
},
isTeamBooking: true,
},
...(usersFromOrg.length > 0
? [
{
userId: {
in: usersFromOrg.map((u) => u.userId),
},
isTeamBooking: false,
},
]
: []),
],
});
}
// Team-specific queries condition
if (!isAll && teamId) {
const usersFromTeam = await ctx.insightsDb.membership.findMany({
where: {
teamId: teamId,
accepted: true,
},
select: {
userId: true,
},
});
const userIdsFromTeam = usersFromTeam.map((u) => u.userId);
conditions.push({
OR: [
{
teamId,
isTeamBooking: true,
},
{
userId: {
in: userIdsFromTeam,
},
isTeamBooking: false,
},
],
});
}
let whereCondition: Prisma.BookingTimeStatusDenormalizedWhereInput = {};
if (conditions.length === 1) {
whereCondition = conditions[0];
} else if (conditions.length > 1) {
whereCondition = { AND: conditions };
}
return {
whereCondition:
Object.keys(whereCondition).length === 0
? { id: -1 } // Ensure no data is returned for invalid parameters
: whereCondition,
};
};
const userBelongsToTeamProcedure = authedProcedure.use(async ({ ctx, next, getRawInput }) => {
const parse = UserBelongsToTeamInput.safeParse(await getRawInput());
if (!parse.success) {
throw new TRPCError({ code: "BAD_REQUEST" });
}
// If teamId is provided, check if user belongs to team
// If teamId is not provided, check if user belongs to any team
const membershipWhereConditional: Prisma.MembershipWhereInput = {
userId: ctx.user.id,
accepted: true,
};
if (parse.data.teamId) {
membershipWhereConditional["teamId"] = parse.data.teamId;
}
const membership = await ctx.insightsDb.membership.findFirst({
where: membershipWhereConditional,
});
let isOwnerAdminOfParentTeam = false;
// Probably we couldn't find a membership because the user is not a direct member of the team
// So that would mean ctx.user.organization is present
if ((parse.data.isAll && ctx.user.organizationId) || (!membership && ctx.user.organizationId)) {
//Look for membership type in organizationId
if (!membership && ctx.user.organizationId && parse.data.teamId) {
const isChildTeamOfOrg = await ctx.insightsDb.team.findFirst({
where: {
id: parse.data.teamId,
parentId: ctx.user.organizationId,
},
});
if (!isChildTeamOfOrg) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
const membershipOrg = await ctx.insightsDb.membership.findFirst({
where: {
userId: ctx.user.id,
teamId: ctx.user.organizationId,
accepted: true,
role: {
in: ["OWNER", "ADMIN"],
},
},
});
if (!membershipOrg) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
isOwnerAdminOfParentTeam = true;
}
return next({
ctx: {
user: {
...ctx.user,
isOwnerAdminOfParentTeam,
},
},
});
});
const insightsPbacProcedure = userBelongsToTeamProcedure.use(async ({ ctx, next }) => {
const hasPermission = await hasInsightsPermission({
userId: ctx.user.id,
organizationId: ctx.user.organizationId,
});
if (!hasPermission) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx,
});
});
const userSelect = {
id: true,
name: true,
email: true,
username: true,
avatarUrl: true,
};
const emptyResponseBookingKPIStats = {
empty: true,
created: {
count: 0,
deltaPrevious: 0,
},
completed: {
count: 0,
deltaPrevious: 0,
},
rescheduled: {
count: 0,
deltaPrevious: 0,
},
cancelled: {
count: 0,
deltaPrevious: 0,
},
rating: {
count: 0,
deltaPrevious: 0,
},
no_show: {
count: 0,
deltaPrevious: 0,
},
no_show_guest: {
count: 0,
deltaPrevious: 0,
},
csat: {
count: 0,
deltaPrevious: 0,
},
previousRange: {
startDate: dayjs().toISOString(),
endDate: dayjs().toISOString(),
},
};
export interface IResultTeamList {
id: number;
slug: string | null;
name: string | null;
logoUrl: string | null;
userId?: number;
isOrg?: boolean;
}
/**
* Helper function to create InsightsBookingService with standardized parameters
*/
function createInsightsBookingService(
ctx: { user: { id: number; organizationId: number | null } },
input: z.infer<typeof bookingRepositoryBaseInputSchema>,
dateTarget: "createdAt" | "startTime" = "createdAt"
) {
const { scope, selectedTeamId, startDate, endDate, columnFilters } = input;
return getInsightsBookingService({
options: {
scope,
userId: ctx.user.id,
orgId: ctx.user.organizationId ?? 0,
...(selectedTeamId && { teamId: selectedTeamId }),
},
filters: {
...(columnFilters && { columnFilters }),
dateRange: {
target: dateTarget,
startDate,
endDate,
},
},
});
}
function createInsightsRoutingService(
ctx: { insightsDb: PrismaClient; user: { id: number; organizationId: number | null } },
input: z.infer<typeof routingRepositoryBaseInputSchema>
) {
return getInsightsRoutingService({
options: {
scope: input.scope,
teamId: input.selectedTeamId,
userId: ctx.user.id,
orgId: ctx.user.organizationId,
},
filters: {
startDate: input.startDate,
endDate: input.endDate,
columnFilters: input.columnFilters,
},
});
}
export const insightsRouter = router({
bookingKPIStats: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ ctx, input }) => {
const currentPeriodService = createInsightsBookingService(ctx, input);
// Get current period stats
const currentStats = await currentPeriodService.getBookingStats();
// Calculate previous period dates and create service for previous period
const previousPeriodDates = currentPeriodService.calculatePreviousPeriodDates();
const previousPeriodService = createInsightsBookingService(ctx, {
...input,
startDate: previousPeriodDates.startDate,
endDate: previousPeriodDates.endDate,
});
// Get previous period stats
const previousStats = await previousPeriodService.getBookingStats();
// Helper function to calculate percentage change
const getPercentage = (current: number, previous: number): number => {
if (previous === 0) return current > 0 ? 100 : 0;
return ((current - previous) / previous) * 100;
};
// Calculate percentages and CSAT
const currentCSAT =
currentStats.total_ratings > 0
? (currentStats.ratings_above_3 / currentStats.total_ratings) * 100
: 0;
const previousCSAT =
previousStats.total_ratings > 0
? (previousStats.ratings_above_3 / previousStats.total_ratings) * 100
: 0;
const currentRating = currentStats.avg_rating ? parseFloat(currentStats.avg_rating.toFixed(1)) : 0;
const previousRating = previousStats.avg_rating ? parseFloat(previousStats.avg_rating.toFixed(1)) : 0;
// Check if all metrics are zero for empty state
const isEmpty =
currentStats.total_bookings === 0 &&
currentStats.completed_bookings === 0 &&
currentStats.rescheduled_bookings === 0 &&
currentStats.cancelled_bookings === 0 &&
currentStats.no_show_host_bookings === 0 &&
currentStats.no_show_guests === 0 &&
currentRating === 0;
if (isEmpty) {
return emptyResponseBookingKPIStats;
}
return {
empty: false,
created: {
count: currentStats.total_bookings,
deltaPrevious: getPercentage(currentStats.total_bookings, previousStats.total_bookings),
},
completed: {
count: currentStats.completed_bookings,
deltaPrevious: getPercentage(
currentStats.total_bookings - currentStats.cancelled_bookings - currentStats.rescheduled_bookings,
previousStats.total_bookings -
previousStats.cancelled_bookings -
previousStats.rescheduled_bookings
),
},
rescheduled: {
count: currentStats.rescheduled_bookings,
deltaPrevious: getPercentage(currentStats.rescheduled_bookings, previousStats.rescheduled_bookings),
},
cancelled: {
count: currentStats.cancelled_bookings,
deltaPrevious: getPercentage(currentStats.cancelled_bookings, previousStats.cancelled_bookings),
},
no_show: {
count: currentStats.no_show_host_bookings,
deltaPrevious: getPercentage(
currentStats.no_show_host_bookings,
previousStats.no_show_host_bookings
),
},
no_show_guest: {
count: currentStats.no_show_guests,
deltaPrevious: getPercentage(currentStats.no_show_guests, previousStats.no_show_guests),
},
rating: {
count: currentRating,
deltaPrevious: getPercentage(currentRating, previousRating),
},
csat: {
count: currentCSAT,
deltaPrevious: getPercentage(currentCSAT, previousCSAT),
},
previousRange: {
startDate: previousPeriodDates.formattedStartDate,
endDate: previousPeriodDates.formattedEndDate,
},
};
}),
eventTrends: insightsPbacProcedure.input(bookingRepositoryBaseInputSchema).query(async ({ ctx, input }) => {
const { startDate, endDate, timeZone } = input;
// Calculate timeView and dateRanges
const timeView = getTimeView(startDate, endDate);
const dateRanges = getDateRanges({
startDate,
endDate,
timeView,
timeZone,
weekStart: ctx.user.weekStart,
});
const insightsBookingService = createInsightsBookingService(ctx, input);
try {
return await insightsBookingService.getEventTrendsStats({
timeZone,
dateRanges,
});
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
popularEvents: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ input, ctx }) => {
const insightsBookingService = createInsightsBookingService(ctx, input);
try {
return await insightsBookingService.getPopularEventsStats();
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
averageEventDuration: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ ctx, input }) => {
const { startDate, endDate, timeZone } = input;
const insightsBookingService = createInsightsBookingService(ctx, input);
try {
const timeView = getTimeView(startDate, endDate);
const dateRanges = getDateRanges({
startDate,
endDate,
timeView,
timeZone,
weekStart: ctx.user.weekStart as GetDateRangesParams["weekStart"],
});
if (!dateRanges.length) {
return [];
}
const startOfEndOf = timeView === "year" ? "year" : timeView === "month" ? "month" : "week";
const allBookings = await insightsBookingService.findAll({
select: {
eventLength: true,
createdAt: true,
},
});
const resultMap = new Map<string, { totalDuration: number; count: number }>();
// Initialize the map with all date ranges
for (const range of dateRanges) {
resultMap.set(dayjs(range.startDate).format("ll"), { totalDuration: 0, count: 0 });
}
for (const booking of allBookings) {
const periodStart = dayjs(booking.createdAt).startOf(startOfEndOf).format("ll");
if (resultMap.has(periodStart)) {
const current = resultMap.get(periodStart);
if (!current) continue;
current.totalDuration += booking.eventLength || 0;
current.count += 1;
}
}
const result = Array.from(resultMap.entries()).map(([date, { totalDuration, count }]) => ({
Date: date,
Average: count > 0 ? totalDuration / count : 0,
}));
return result;
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
membersWithMostCancelledBookings: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ input, ctx }) => {
const insightsBookingService = createInsightsBookingService(ctx, input);
try {
return await insightsBookingService.getMembersStatsWithCount("cancelled", "DESC");
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
membersWithMostCompletedBookings: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ input, ctx }) => {
const insightsBookingService = createInsightsBookingService(ctx, input, "startTime");
try {
return await insightsBookingService.getMembersStatsWithCount("accepted", "DESC");
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
membersWithLeastCompletedBookings: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ input, ctx }) => {
const insightsBookingService = createInsightsBookingService(ctx, input, "startTime");
try {
return await insightsBookingService.getMembersStatsWithCount("accepted", "ASC");
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
membersWithMostBookings: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ input, ctx }) => {
const insightsBookingService = createInsightsBookingService(ctx, input);
try {
return await insightsBookingService.getMembersStatsWithCount("all", "DESC");
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
membersWithLeastBookings: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ input, ctx }) => {
const insightsBookingService = createInsightsBookingService(ctx, input);
try {
return await insightsBookingService.getMembersStatsWithCount("all", "ASC");
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
teamListForUser: authedProcedure.query(async ({ ctx }) => {
const user = ctx.user;
// Fetch user data
const userData = await ctx.insightsDb.user.findUnique({
where: {
id: user.id,
},
select: userSelect,
});
if (!userData) {
return [];
}
// Validate if user belongs to org as admin/owner
if (user.organizationId && user.organization.isOrgAdmin) {
const teamsAndOrg = await ctx.insightsDb.team.findMany({
where: {
OR: [{ parentId: user.organizationId }, { id: user.organizationId }],
},
select: {
id: true,
slug: true,
name: true,
logoUrl: true,
},
});
const teamsFromOrg = teamsAndOrg.filter((team) => team.id !== user.organizationId);
const orgTeam = teamsAndOrg.find((team) => team.id === user.organizationId);
if (!orgTeam) {
return [];
}
const result: IResultTeamList[] = [
{
...orgTeam,
isOrg: true,
},
...teamsFromOrg,
];
return result;
}
// Look if user it's admin/owner in multiple teams
const belongsToTeams = await ctx.insightsDb.membership.findMany({
where: {
team: {
slug: { not: null },
},
accepted: true,
userId: user.id,
OR: [
{
role: "ADMIN",
},
{
role: "OWNER",
},
],
},
include: {
team: {
select: {
id: true,
name: true,
logoUrl: true,
slug: true,
metadata: true,
},
},
},
});
if (belongsToTeams.length === 0) {
return [];
}
const result: IResultTeamList[] = belongsToTeams.map((membership) => {
return { ...membership.team };
});
return result;
}),
userList: authedProcedure
.input(
z.object({
teamId: z.number().optional(),
isAll: z.boolean().nullable(),
})
)
.query(async ({ ctx, input }) => {
const user = ctx.user;
const { teamId, isAll } = input;
if (isAll && user.organizationId && user.organization.isOrgAdmin) {
const usersInTeam = await ctx.insightsDb.membership.findMany({
where: {
team: {
parentId: user.organizationId,
},
},
include: {
user: {
select: userSelect,
},
},
distinct: ["userId"],
});
return usersInTeam.map((membership) => membership.user);
}
if (!teamId) {
return [];
}
const membership = await ctx.insightsDb.membership.findFirst({
where: {
userId: user.id,
teamId,
accepted: true,
},
include: {
user: {
select: userSelect,
},
},
});
if (!membership) {
return [];
}
const isMember = membership && membership.role === "MEMBER";
// If user is not admin, return himself only
if (isMember) {
return [membership.user];
}
const usersInTeam = await ctx.insightsDb.membership.findMany({
where: {
teamId,
accepted: true,
},
include: {
user: {
select: userSelect,
},
},
distinct: ["userId"],
});
return usersInTeam.map((membership) => membership.user);
}),
eventTypeList: insightsPbacProcedure
.input(
z.object({
teamId: z.coerce.number().nullish(),
userId: z.coerce.number().nullish(),
isAll: z.boolean().optional(),
})
)
.query(async ({ ctx, input }) => {
const { prisma, user } = ctx;
const { teamId, userId, isAll } = input;
const eventTypeList = await getEventTypeList({
prisma,
teamId,
userId,
isAll,
user: {
id: user.id,
organizationId: user.organizationId,
isOwnerAdminOfParentTeam: user.isOwnerAdminOfParentTeam,
},
});
return eventTypeList;
}),
recentRatings: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ ctx, input }) => {
const insightsBookingService = createInsightsBookingService(ctx, input);
return await insightsBookingService.getRecentRatingsStats();
}),
membersWithMostNoShow: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ input, ctx }) => {
const insightsBookingService = createInsightsBookingService(ctx, input);
try {
return await insightsBookingService.getMembersStatsWithCount("noShow", "DESC");
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
membersWithHighestRatings: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ ctx, input }) => {
const insightsBookingService = createInsightsBookingService(ctx, input);
return await insightsBookingService.getMembersRatingStats("DESC");
}),
membersWithLowestRatings: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ ctx, input }) => {
const insightsBookingService = createInsightsBookingService(ctx, input);
return await insightsBookingService.getMembersRatingStats("ASC");
}),
rawData: insightsPbacProcedure
.input(
bookingRepositoryBaseInputSchema.extend({
limit: z.number().max(100).optional(),
offset: z.number().optional(),
})
)
.query(async ({ ctx, input }) => {
const { limit, offset } = input;
const insightsBookingService = createInsightsBookingService(ctx, input);
try {
return await insightsBookingService.getCsvData({
limit: limit ?? 100,
offset: offset ?? 0,
});
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
getRoutingFormsForFilters: insightsPbacProcedure
.input(z.object({ userId: z.number().optional(), teamId: z.number().optional(), isAll: z.boolean() }))
.query(async ({ ctx, input }) => {
const { teamId, isAll } = input;
return await RoutingEventsInsights.getRoutingFormsForFilters({
userId: ctx.user.id,
teamId,
isAll,
organizationId: ctx.user.organizationId ?? undefined,
});
}),
routingFormsByStatus: insightsPbacProcedure
.input(insightsRoutingServiceInputSchema)
.query(async ({ ctx, input }) => {
const insightsRoutingService = createInsightsRoutingService(ctx, input);
return await insightsRoutingService.getRoutingFormStats();
}),
routingFormResponses: insightsPbacProcedure
.input(insightsRoutingServicePaginatedInputSchema)
.query(async ({ ctx, input }) => {
const insightsRoutingService = createInsightsRoutingService(ctx, input);
return await insightsRoutingService.getTableData({
sorting: input.sorting,
limit: input.limit,
offset: input.offset,
});
}),
routingFormResponsesForDownload: insightsPbacProcedure
.input(insightsRoutingServicePaginatedInputSchema)
.query(async ({ ctx, input }) => {
const headersPromise = RoutingEventsInsights.getRoutingFormHeaders({
userId: ctx.user.id,
teamId: input.selectedTeamId,
isAll: input.scope === "org",
organizationId: ctx.user.organizationId,
routingFormId: (input.columnFilters || []).find((filter) => filter.id === "formId")?.value?.data as
| string
| undefined,
});
const insightsRoutingService = createInsightsRoutingService(ctx, input);
const dataPromise = insightsRoutingService.getTableData({
sorting: input.sorting,
limit: input.limit,
offset: input.offset,
});
return await RoutingEventsInsights.getRoutingFormPaginatedResponsesForDownload({
headersPromise,
dataPromise,
});
}),
getRoutingFormFieldOptions: insightsPbacProcedure
.input(
z.object({
userId: z.number().optional(),
teamId: z.number().optional(),
isAll: z.boolean(),
routingFormId: z.string().optional(),
})
)
.query(async ({ input, ctx }) => {
const options = await RoutingEventsInsights.getRoutingFormFieldOptions({
...input,
userId: ctx.user.id,
organizationId: ctx.user.organizationId ?? null,
});
return options;
}),
failedBookingsByField: insightsPbacProcedure
.input(insightsRoutingServiceInputSchema)
.query(async ({ ctx, input }) => {
const insightsRoutingService = createInsightsRoutingService(ctx, input);
try {
return await insightsRoutingService.getFailedBookingsByFieldData();
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
routingFormResponsesHeaders: insightsPbacProcedure
.input(
z.object({
userId: z.number().optional(),
teamId: z.number().optional(),
isAll: z.boolean(),
routingFormId: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const headers = await RoutingEventsInsights.getRoutingFormHeaders({
userId: ctx.user.id,
teamId: input.teamId ?? null,
isAll: input.isAll,
organizationId: ctx.user.organizationId ?? null,
routingFormId: input.routingFormId ?? null,
});
return headers || [];
}),
routedToPerPeriod: insightsPbacProcedure
.input(routedToPerPeriodInputSchema)
.query(async ({ ctx, input }) => {
const { period, limit, searchQuery, ...rest } = input;
try {
const insightsRoutingService = createInsightsRoutingService(ctx, rest);
return await insightsRoutingService.getRoutedToPerPeriodData({
period,
limit,
searchQuery,
});
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
routedToPerPeriodCsv: insightsPbacProcedure
.input(routedToPerPeriodCsvInputSchema)
.query(async ({ ctx, input }) => {
const { period, searchQuery, ...rest } = input;
try {
const insightsRoutingService = createInsightsRoutingService(ctx, rest);
const csvData = await insightsRoutingService.getRoutedToPerPeriodCsvData({
period,
searchQuery,
});
const csvString = objectToCsv(csvData);
const downloadAs = `routed-to-${period}-${dayjs(rest.startDate).format("YYYY-MM-DD")}-${dayjs(
rest.endDate
).format("YYYY-MM-DD")}.csv`;
return { data: csvString, filename: downloadAs };
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
getUserRelevantTeamRoutingForms: authedProcedure.query(async ({ ctx }) => {
try {
const routingForms = await VirtualQueuesInsights.getUserRelevantTeamRoutingForms({
userId: ctx.user.id,
});
return routingForms;
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
getRoutingFunnelData: insightsPbacProcedure
.input(routingRepositoryBaseInputSchema)
.query(async ({ ctx, input }) => {
const timeView = getTimeView(input.startDate, input.endDate);
const dateRanges = getDateRanges({
startDate: input.startDate,
endDate: input.endDate,
timeZone: ctx.user.timeZone,
timeView,
weekStart: ctx.user.weekStart,
});
const insightsRoutingService = createInsightsRoutingService(ctx, input);
try {
return await insightsRoutingService.getRoutingFunnelData(dateRanges);
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
bookingsByHourStats: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ ctx, input }) => {
const { timeZone } = input;
const insightsBookingService = createInsightsBookingService(ctx, input, "startTime");
try {
return await insightsBookingService.getBookingsByHourStats({
timeZone,
});
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
recentNoShowGuests: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ ctx, input }) => {
const insightsBookingService = createInsightsBookingService(ctx, input, "startTime");
try {
return await insightsBookingService.getRecentNoShowGuests();
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
});
export async function getEventTypeList({
prisma,
teamId,
userId,
isAll,
user,
}: {
prisma: PrismaClient;
teamId: number | null | undefined;
userId: number | null | undefined;
isAll: boolean | undefined;
user: {
id: number;
organizationId: number | null;
isOwnerAdminOfParentTeam: boolean;
};
}) {
if (!teamId && !userId && !isAll) {
return [];
}
const membershipWhereConditional: Prisma.MembershipWhereInput = {};
let childrenTeamIds: number[] = [];
if (userId && !teamId && !isAll) {
const eventTypeResult = await prisma.eventType.findMany({
select: {
id: true,
slug: true,
title: true,
teamId: true,
userId: true,
team: {
select: {
name: true,
},
},
},
where: {
userId: user.id,
teamId: null,
},
});
return eventTypeResult;
}
if (isAll && user.organizationId && user.isOwnerAdminOfParentTeam) {
const childTeams = await prisma.team.findMany({
where: {
parentId: user.organizationId,
},
select: {
id: true,
},
});
if (childTeams.length > 0) {
childrenTeamIds = childTeams.map((team) => team.id);
}
const eventTypeResult = await prisma.eventType.findMany({
select: {
id: true,
slug: true,
title: true,
teamId: true,
userId: true,
team: {
select: {
name: true,
},
},
},
where: {
OR: [
{
teamId: {
in: [user.organizationId, ...childrenTeamIds],
},
},
{
userId: user.id,
teamId: null,
},
],
},
});
return eventTypeResult;
}
if (teamId && !isAll) {
membershipWhereConditional["teamId"] = teamId;
membershipWhereConditional["userId"] = user.id;
}
// I'm not using unique here since when userId comes from input we should look for every
// event type that user owns
const membership = await prisma.membership.findFirst({
where: membershipWhereConditional,
});
if (!membership && !user.isOwnerAdminOfParentTeam) {
throw new Error("User is not part of a team/org");
}
const eventTypeWhereConditional: Prisma.EventTypeWhereInput = {};
if (teamId && !isAll) {
eventTypeWhereConditional["teamId"] = teamId;
}
let isMember = membership?.role === "MEMBER";
if (user.isOwnerAdminOfParentTeam) {
isMember = false;
}
if (isMember) {
eventTypeWhereConditional["OR"] = [{ userId: user.id }, { users: { some: { id: user.id } } }];
// @TODO this is not working as expected
// hosts: { some: { id: user.id } },
}
const eventTypeResult = await prisma.eventType.findMany({
select: {
id: true,
slug: true,
title: true,
teamId: true,
userId: true,
team: {
select: {
name: true,
},
},
},
where: eventTypeWhereConditional,
});
return eventTypeResult;
}
function objectToCsv(data: Record<string, string>[]) {
if (!data.length) return "";
const headers = Object.keys(data[0]);
const csvRows = [
headers.join(","),
...data.map((row) =>
headers
.map((header) => {
const value = row[header]?.toString() || "";
// Escape quotes and wrap in quotes if contains comma or newline
return value.includes(",") || value.includes("\n") || value.includes('"')
? `"${value.replace(/"/g, '""')}"` // escape double quotes
: value;
})
.join(",")
),
];
return csvRows.join("\n");
}