refactor: event trends chart on /insights to use InsightsBookingService (#22741)
* refactor: event trends chart on /insights to use InsightsBookingService * extract service instantiation * remove unused code * rename from eventsTimeline to eventTrends * clean up --------- Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
This commit is contained in:
co-authored by
Anik Dhabal Babu
parent
e98024b08e
commit
e6a0a0fc69
@@ -10,7 +10,7 @@ import {
|
||||
import {
|
||||
AverageEventDurationChart,
|
||||
BookingKPICards,
|
||||
BookingStatusLineChart,
|
||||
EventTrendsChart,
|
||||
HighestNoShowHostTable,
|
||||
HighestRatedMembersTable,
|
||||
BookingsByHourChart,
|
||||
@@ -70,7 +70,7 @@ function InsightsPageContent() {
|
||||
<div className="my-4 space-y-4">
|
||||
<BookingKPICards />
|
||||
|
||||
<BookingStatusLineChart />
|
||||
<EventTrendsChart />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
||||
<div className="sm:col-span-2">
|
||||
|
||||
+12
-8
@@ -1,4 +1,6 @@
|
||||
import { useDataTable } from "@calcom/features/data-table";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useInsightsParameters } from "../hooks/useInsightsParameters";
|
||||
@@ -7,22 +9,24 @@ import { ChartCard } from "./ChartCard";
|
||||
import { LineChart } from "./LineChart";
|
||||
import { LoadingInsight } from "./LoadingInsights";
|
||||
|
||||
export const BookingStatusLineChart = () => {
|
||||
export const EventTrendsChart = () => {
|
||||
const { t } = useLocale();
|
||||
const { isAll, teamId, userId, startDate, endDate, eventTypeId } = useInsightsParameters();
|
||||
const { scope, selectedTeamId, memberUserId, startDate, endDate, eventTypeId } = useInsightsParameters();
|
||||
const { timeZone } = useDataTable();
|
||||
|
||||
const {
|
||||
data: eventsTimeLine,
|
||||
data: eventTrends,
|
||||
isSuccess,
|
||||
isPending,
|
||||
} = trpc.viewer.insights.eventsTimeline.useQuery(
|
||||
} = trpc.viewer.insights.eventTrends.useQuery(
|
||||
{
|
||||
scope,
|
||||
selectedTeamId,
|
||||
startDate,
|
||||
endDate,
|
||||
teamId,
|
||||
timeZone: timeZone || CURRENT_TIMEZONE,
|
||||
eventTypeId,
|
||||
userId,
|
||||
isAll,
|
||||
memberUserId,
|
||||
},
|
||||
{
|
||||
staleTime: 30000,
|
||||
@@ -40,7 +44,7 @@ export const BookingStatusLineChart = () => {
|
||||
<ChartCard title={t("event_trends")}>
|
||||
<LineChart
|
||||
className="linechart ml-4 mt-4 h-80 sm:ml-0"
|
||||
data={eventsTimeLine ?? []}
|
||||
data={eventTrends ?? []}
|
||||
categories={["Created", "Completed", "Rescheduled", "Cancelled", "No-Show (Host)", "No-Show (Guest)"]}
|
||||
index="Month"
|
||||
colors={["purple", "green", "blue", "red", "slate", "orange"]}
|
||||
@@ -2,7 +2,7 @@ export { AverageEventDurationChart } from "./AverageEventDurationChart";
|
||||
export { BookingKPICards } from "./BookingKPICards";
|
||||
export { BookingsByHourChart } from "./BookingsByHourChart";
|
||||
|
||||
export { BookingStatusLineChart } from "./BookingStatusLineChart";
|
||||
export { EventTrendsChart } from "./EventTrendsChart";
|
||||
export { FailedBookingsByField } from "./FailedBookingsByField";
|
||||
export { HighestNoShowHostTable } from "./HighestNoShowHostTable";
|
||||
export { HighestRatedMembersTable } from "./HighestRatedMembersTable";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { readonlyPrisma as prisma } from "@calcom/prisma";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
type TimeViewType = "week" | "month" | "year" | "day";
|
||||
|
||||
@@ -75,99 +74,6 @@ export interface GetDateRangesParams {
|
||||
}
|
||||
|
||||
class EventsInsights {
|
||||
static countGroupedByStatusForRanges = async (
|
||||
whereConditional: Prisma.BookingTimeStatusDenormalizedWhereInput,
|
||||
startDate: Dayjs,
|
||||
endDate: Dayjs,
|
||||
dateRanges: DateRange[],
|
||||
timeZone: string
|
||||
): Promise<AggregateResult> => {
|
||||
const formattedStartDate = dayjs(startDate).format("YYYY-MM-DD HH:mm:ss");
|
||||
const formattedEndDate = dayjs(endDate).format("YYYY-MM-DD HH:mm:ss");
|
||||
const whereClause = buildSqlCondition(whereConditional);
|
||||
|
||||
const data = await 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
|
||||
"createdAt" BETWEEN ${formattedStartDate}::timestamp AND ${formattedEndDate}::timestamp
|
||||
AND ${Prisma.raw(whereClause)}
|
||||
) AS bookings
|
||||
GROUP BY
|
||||
"date",
|
||||
"timeStatus",
|
||||
"noShowHost"
|
||||
ORDER BY
|
||||
"date";
|
||||
`;
|
||||
|
||||
const aggregate: AggregateResult = {};
|
||||
|
||||
// Initialize all date ranges with zero counts
|
||||
dateRanges.forEach(({ formattedDate }) => {
|
||||
aggregate[formattedDate] = {
|
||||
completed: 0,
|
||||
rescheduled: 0,
|
||||
cancelled: 0,
|
||||
noShowHost: 0,
|
||||
noShowGuests: 0,
|
||||
_all: 0,
|
||||
uncompleted: 0,
|
||||
};
|
||||
});
|
||||
|
||||
// Process the raw data
|
||||
data.forEach(({ date, bookingsCount, timeStatus, noShowHost, noShowGuests }) => {
|
||||
// Find which date range this date belongs to
|
||||
const dateRange = dateRanges.find((range) =>
|
||||
dayjs(date).isBetween(range.startDate, range.endDate, null, "[]")
|
||||
);
|
||||
|
||||
if (!dateRange) return;
|
||||
|
||||
const formattedDate = dateRange.formattedDate;
|
||||
const statusKey = timeStatus as keyof StatusAggregate;
|
||||
|
||||
// Add to the specific status count
|
||||
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;
|
||||
});
|
||||
|
||||
return aggregate;
|
||||
};
|
||||
|
||||
static getTotalNoShowGuests = async (where: Prisma.BookingTimeStatusDenormalizedWhereInput) => {
|
||||
const bookings = await prisma.bookingTimeStatusDenormalized.findMany({
|
||||
where,
|
||||
|
||||
@@ -313,6 +313,35 @@ export interface IResultTeamList {
|
||||
|
||||
const BATCH_SIZE = 1000; // Adjust based on your needs
|
||||
|
||||
/**
|
||||
* Helper function to create InsightsBookingService with standardized parameters
|
||||
*/
|
||||
function createInsightsBookingService(
|
||||
ctx: { insightsDb: typeof readonlyPrisma; user: { id: number; organizationId: number | null } },
|
||||
input: z.infer<typeof bookingRepositoryBaseInputSchema>,
|
||||
dateTarget: "createdAt" | "startTime" = "createdAt"
|
||||
) {
|
||||
const { scope, selectedTeamId, eventTypeId, memberUserId, startDate, endDate } = input;
|
||||
|
||||
return new InsightsBookingService({
|
||||
prisma: ctx.insightsDb,
|
||||
options: {
|
||||
scope,
|
||||
userId: ctx.user.id,
|
||||
orgId: ctx.user.organizationId ?? 0,
|
||||
...(selectedTeamId && { teamId: selectedTeamId }),
|
||||
},
|
||||
filters: {
|
||||
...(eventTypeId && { eventTypeId }),
|
||||
...(memberUserId && { memberUserId }),
|
||||
dateRange: {
|
||||
target: dateTarget,
|
||||
startDate,
|
||||
endDate,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
export const insightsRouter = router({
|
||||
eventsByStatus: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => {
|
||||
const { teamId, startDate, endDate, eventTypeId, memberUserId, userId, isAll } = input;
|
||||
@@ -451,78 +480,31 @@ export const insightsRouter = router({
|
||||
|
||||
return result;
|
||||
}),
|
||||
eventsTimeline: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => {
|
||||
const { teamId, eventTypeId, memberUserId, isAll, startDate, endDate, userId: selfUserId } = input;
|
||||
if (selfUserId && ctx.user?.id !== selfUserId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
eventTrends: userBelongsToTeamProcedure
|
||||
.input(bookingRepositoryBaseInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { startDate, endDate, timeZone } = input;
|
||||
|
||||
if (!teamId && !selfUserId) {
|
||||
return [];
|
||||
}
|
||||
// Calculate timeView and dateRanges
|
||||
const timeView = EventsInsights.getTimeView(startDate, endDate);
|
||||
const dateRanges = EventsInsights.getDateRanges({
|
||||
startDate,
|
||||
endDate,
|
||||
timeView,
|
||||
timeZone,
|
||||
weekStart: ctx.user.weekStart,
|
||||
});
|
||||
|
||||
const timeView = EventsInsights.getTimeView(startDate, endDate);
|
||||
const r = await buildBaseWhereCondition({
|
||||
teamId,
|
||||
eventTypeId: eventTypeId ?? undefined,
|
||||
memberUserId: memberUserId ?? undefined,
|
||||
userId: selfUserId ?? undefined,
|
||||
isAll: isAll ?? false,
|
||||
ctx: {
|
||||
userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam,
|
||||
userOrganizationId: ctx.user.organizationId,
|
||||
insightsDb: ctx.insightsDb,
|
||||
},
|
||||
});
|
||||
|
||||
const { whereCondition: whereConditional } = r;
|
||||
|
||||
const dateRanges = EventsInsights.getDateRanges({
|
||||
startDate,
|
||||
endDate,
|
||||
timeView,
|
||||
timeZone: ctx.user.timeZone,
|
||||
weekStart: ctx.user.weekStart as GetDateRangesParams["weekStart"],
|
||||
});
|
||||
if (!dateRanges.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch counts grouped by status for the entire range
|
||||
const countsByStatus = await EventsInsights.countGroupedByStatusForRanges(
|
||||
whereConditional,
|
||||
dayjs(startDate),
|
||||
dayjs(endDate),
|
||||
dateRanges,
|
||||
ctx.user.timeZone
|
||||
);
|
||||
|
||||
const result = dateRanges.map(({ formattedDate }) => {
|
||||
const EventData = {
|
||||
Month: formattedDate,
|
||||
Created: 0,
|
||||
Completed: 0,
|
||||
Rescheduled: 0,
|
||||
Cancelled: 0,
|
||||
"No-Show (Host)": 0,
|
||||
"No-Show (Guest)": 0,
|
||||
};
|
||||
|
||||
const countsForDateRange = countsByStatus[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;
|
||||
const insightsBookingService = createInsightsBookingService(ctx, input);
|
||||
try {
|
||||
return await insightsBookingService.getEventTrendsStats({
|
||||
timeZone,
|
||||
dateRanges,
|
||||
});
|
||||
} catch (e) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
|
||||
}
|
||||
return EventData;
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
}),
|
||||
popularEventTypes: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => {
|
||||
const { teamId, startDate, endDate, memberUserId, userId, isAll, eventTypeId } = input;
|
||||
|
||||
@@ -655,26 +637,9 @@ export const insightsRouter = router({
|
||||
averageEventDuration: userBelongsToTeamProcedure
|
||||
.input(bookingRepositoryBaseInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { scope, selectedTeamId, startDate, endDate, eventTypeId, memberUserId, timeZone } = input;
|
||||
const { startDate, endDate, timeZone } = input;
|
||||
|
||||
const insightsBookingService = new InsightsBookingService({
|
||||
prisma: ctx.insightsDb,
|
||||
options: {
|
||||
scope,
|
||||
userId: ctx.user.id,
|
||||
orgId: ctx.user.organizationId ?? 0,
|
||||
...(selectedTeamId && { teamId: selectedTeamId }),
|
||||
},
|
||||
filters: {
|
||||
...(eventTypeId && { eventTypeId }),
|
||||
...(memberUserId && { memberUserId }),
|
||||
dateRange: {
|
||||
target: "createdAt",
|
||||
startDate,
|
||||
endDate,
|
||||
},
|
||||
},
|
||||
});
|
||||
const insightsBookingService = createInsightsBookingService(ctx, input);
|
||||
|
||||
try {
|
||||
const timeView = EventsInsights.getTimeView(startDate, endDate);
|
||||
@@ -1504,26 +1469,9 @@ export const insightsRouter = router({
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { scope, selectedTeamId, startDate, endDate, eventTypeId, memberUserId, limit, offset } = input;
|
||||
const { limit, offset } = input;
|
||||
|
||||
const insightsBookingService = new InsightsBookingService({
|
||||
prisma: ctx.insightsDb,
|
||||
options: {
|
||||
scope,
|
||||
userId: ctx.user.id,
|
||||
orgId: ctx.user.organizationId ?? 0,
|
||||
...(selectedTeamId && { teamId: selectedTeamId }),
|
||||
},
|
||||
filters: {
|
||||
...(eventTypeId && { eventTypeId }),
|
||||
...(memberUserId && { memberUserId }),
|
||||
dateRange: {
|
||||
target: "createdAt",
|
||||
startDate,
|
||||
endDate,
|
||||
},
|
||||
},
|
||||
});
|
||||
const insightsBookingService = createInsightsBookingService(ctx, input);
|
||||
|
||||
try {
|
||||
return await insightsBookingService.getCsvData({
|
||||
@@ -1763,26 +1711,8 @@ export const insightsRouter = router({
|
||||
bookingsByHourStats: userBelongsToTeamProcedure
|
||||
.input(bookingRepositoryBaseInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { scope, selectedTeamId, startDate, endDate, eventTypeId, memberUserId, timeZone } = input;
|
||||
|
||||
const insightsBookingService = new InsightsBookingService({
|
||||
prisma: ctx.insightsDb,
|
||||
options: {
|
||||
scope,
|
||||
userId: ctx.user.id,
|
||||
orgId: ctx.user.organizationId ?? 0,
|
||||
...(selectedTeamId && { teamId: selectedTeamId }),
|
||||
},
|
||||
filters: {
|
||||
...(eventTypeId && { eventTypeId }),
|
||||
...(memberUserId && { memberUserId }),
|
||||
dateRange: {
|
||||
target: "startTime",
|
||||
startDate,
|
||||
endDate,
|
||||
},
|
||||
},
|
||||
});
|
||||
const { timeZone } = input;
|
||||
const insightsBookingService = createInsightsBookingService(ctx, input, "startTime");
|
||||
|
||||
try {
|
||||
return await insightsBookingService.getBookingsByHourStats({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { DateRange } from "@calcom/features/insights/server/events";
|
||||
import type { readonlyPrisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
@@ -529,6 +530,135 @@ export class InsightsBookingService {
|
||||
|
||||
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 }) => {
|
||||
const eventData = {
|
||||
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;
|
||||
}
|
||||
|
||||
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 });
|
||||
|
||||
Reference in New Issue
Block a user