Files
calendar/packages/lib/server/repository/PrismaAgentRepository.ts
T
d4bff9d6b1 feat: Cal.ai Self Serve #2 (#22995)
* feat: Cal.ai Self Serve #2

* chore: fix import and remove logs

* fix: update checkout session

* fix: type errors and test

* fix: imports

* fix: type err

* fix: type error

* fix: tests

* chore: save progress

* fix: workflow flow

* fix: workflow update bug

* tests: add unit tests for retell ai webhoo

* fix: status code

* fix: test and delete bug

* fix: add dynamic variables

* fix: type err

* chore: update unit test

* fix: type error

* chore: update default prompt

* fix: type errors

* fix: workflow permissions

* fix: workflow page

* fix: translations

* feat: add call duration

* chore: add booking uid

* fix: button positioning

* chore: update tests

* chore: improvements

* chore: some more improvements

* refactor: improvements

* refactor: code feedback

* refactor: improvements

* feat: enable credits for orgs (#23077)

* Show credits UI for orgs

* fix stripe callback url when buying credits

* give orgs 20% credits

* add test for calulating credits

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>

* fix: types

* fix: types

* chore: error

* fix: type error

* fix: type error

* chore: mock env

* feat: add idempotency key to prevent double charging

* chore: add userId and teamId

* fix: skip inbound calls

* chore: update tests

* feat: add feature flag for voice agent

* feat: finish test call and other improvements

* chore: add alert

* chore: update .env.example

* chore: improvements

* fix: update tests

* refactor: remove un necessary

* feat: add setup badge

* chore: improvements

* fix: use referene id

* chore: improvements

* fix: type error

* fix: type

* refactor: change pricing logic

* refactor: update tests

* fix: conflicts

* fix: billing link for orgs

* fix: types

* refactor: move feature flag up

* fix: alert and test call credit check

* fix: update unit tests

* fix: feedback

* refactor: improvements

* refactor: move handlers to separate files

* fix: types

* fix: missing import

* fix: type

* refactor: change general tools functions handling

* refactor: use repository

* refactor: improvements

* fix: types

* fix: type errorr

* fix: auth check

* feat: add creditFor

* fix: update defualt prompt

* fix: throw error on frontend

* fix: update unit tests

* fix: use deleteAllWorkflowReminders

* refactor: add connect phone number

* refactor: improvements

* chore: translation

* chore: update message

* chore: translation

* design improvements buy number dialog

* add translation for error message

* use translation key in error message

* refactor: improve connect phone number tab

* feat: support un saved workflow to tests

* chore: remove un used

* fix: remove un used

* fix: remove un used

* refactor: similify billing

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-08-29 05:04:05 +01:00

563 lines
15 KiB
TypeScript

import { Prisma } from "@prisma/client";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
interface _AgentRawResult {
id: string;
name: string;
providerAgentId: string;
enabled: boolean;
userId: number;
teamId: number | null;
createdAt: Date;
updatedAt: Date;
user_id?: number;
user_name?: string;
user_email?: string;
team_id?: number;
team_name?: string;
team_slug?: string;
team_logo_url?: string;
}
interface _PhoneNumberRawResult {
id: number;
phoneNumber: string;
subscriptionStatus: string;
provider: string;
outboundAgentId?: string;
}
export class PrismaAgentRepository {
private static async getUserAccessibleTeamIds(userId: number): Promise<number[]> {
const memberships = await prisma.membership.findMany({
where: {
userId,
accepted: true,
},
select: {
teamId: true,
},
});
return memberships.map((membership) => membership.teamId);
}
private static async getUserAdminTeamIds(userId: number): Promise<number[]> {
const memberships = await prisma.membership.findMany({
where: {
userId,
accepted: true,
role: {
in: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
select: {
teamId: true,
},
});
return memberships.map((membership) => membership.teamId);
}
static async findByIdWithUserAccess({
agentId,
userId,
teamId,
}: {
agentId: string;
userId: number;
teamId?: number;
}) {
const accessibleTeamIds = await this.getUserAccessibleTeamIds(userId);
let whereCondition: Prisma.Sql;
if (teamId) {
// If teamId is provided, check that the user has access to that specific team
if (accessibleTeamIds.includes(teamId)) {
whereCondition = Prisma.sql`id = ${agentId} AND "teamId" = ${teamId}`;
} else {
// If user doesn't have access to the team, only check for personal agents
whereCondition = Prisma.sql`id = ${agentId} AND "userId" = ${userId}`;
}
} else if (accessibleTeamIds.length > 0) {
// No specific teamId provided, check both personal and team agents
whereCondition = Prisma.sql`id = ${agentId} AND ("userId" = ${userId} OR "teamId" IN (${Prisma.join(
accessibleTeamIds
)}))`;
} else {
// User has no team access, only check personal agents
whereCondition = Prisma.sql`id = ${agentId} AND "userId" = ${userId}`;
}
const agents = await prisma.$queryRaw<_AgentRawResult[]>`
SELECT
id,
name,
"providerAgentId",
enabled,
"userId",
"teamId",
"createdAt",
"updatedAt"
FROM "Agent"
WHERE ${whereCondition}
LIMIT 1
`;
return agents.length > 0 ? agents[0] : null;
}
static async findByProviderAgentIdWithUserAccess({
providerAgentId,
userId,
}: {
providerAgentId: string;
userId: number;
}) {
const accessibleTeamIds = await this.getUserAccessibleTeamIds(userId);
let whereCondition: Prisma.Sql;
if (accessibleTeamIds.length > 0) {
whereCondition = Prisma.sql`"providerAgentId" = ${providerAgentId} AND ("userId" = ${userId} OR "teamId" IN (${Prisma.join(
accessibleTeamIds
)}))`;
} else {
whereCondition = Prisma.sql`"providerAgentId" = ${providerAgentId} AND "userId" = ${userId}`;
}
const agents = await prisma.$queryRaw<_AgentRawResult[]>`
SELECT
id,
name,
"providerAgentId",
enabled,
"userId",
"teamId",
"createdAt",
"updatedAt"
FROM "Agent"
WHERE ${whereCondition}
LIMIT 1
`;
return agents.length > 0 ? agents[0] : null;
}
static async findById({ id }: { id: string }) {
return await prisma.agent.findUnique({
select: {
id: true,
name: true,
providerAgentId: true,
enabled: true,
userId: true,
teamId: true,
createdAt: true,
updatedAt: true,
},
where: {
id,
},
});
}
static async findByProviderAgentId({ providerAgentId }: { providerAgentId: string }) {
return await prisma.agent.findUnique({
select: {
id: true,
name: true,
providerAgentId: true,
enabled: true,
userId: true,
teamId: true,
createdAt: true,
updatedAt: true,
},
where: {
providerAgentId,
},
});
}
static async findManyWithUserAccess({
userId,
teamId,
scope = "all",
}: {
userId: number;
teamId?: number;
scope?: "personal" | "team" | "all";
}) {
let whereCondition: Prisma.Sql;
if (scope === "personal") {
whereCondition = Prisma.sql`a."userId" = ${userId}`;
} else if (scope === "team") {
const accessibleTeamIds = await this.getUserAccessibleTeamIds(userId);
if (accessibleTeamIds.length === 0) {
return [];
}
if (teamId) {
// Check if user has access to the specific team
if (!accessibleTeamIds.includes(teamId)) {
return [];
}
whereCondition = Prisma.sql`a."teamId" = ${teamId}`;
} else {
whereCondition = Prisma.sql`a."teamId" IN (${Prisma.join(accessibleTeamIds)})`;
}
} else {
const accessibleTeamIds = await this.getUserAccessibleTeamIds(userId);
if (teamId) {
if (accessibleTeamIds.includes(teamId)) {
whereCondition = Prisma.sql`(a."userId" = ${userId} OR a."teamId" = ${teamId})`;
} else {
whereCondition = Prisma.sql`a."userId" = ${userId}`;
}
} else if (accessibleTeamIds.length > 0) {
whereCondition = Prisma.sql`(a."userId" = ${userId} OR a."teamId" IN (${Prisma.join(
accessibleTeamIds
)}))`;
} else {
whereCondition = Prisma.sql`a."userId" = ${userId}`;
}
}
const agents = await prisma.$queryRaw<_AgentRawResult[]>`
SELECT
a.id,
a.name,
a."providerAgentId",
a.enabled,
a."userId",
a."teamId",
a."createdAt",
a."updatedAt",
u.id as user_id,
u.name as user_name,
u.email as user_email,
t.id as team_id,
t.name as team_name,
t.slug as team_slug,
t."logoUrl" as team_logo_url
FROM "Agent" a
LEFT JOIN "users" u ON a."userId" = u.id
LEFT JOIN "Team" t ON a."teamId" = t.id
WHERE ${whereCondition}
ORDER BY a."teamId" ASC, a."createdAt" DESC
`;
// Get phone numbers for each agent in a separate query to avoid N+1
const agentIds = agents.map((agent) => agent.id);
const phoneNumbers =
agentIds.length > 0
? await prisma.$queryRaw<_PhoneNumberRawResult[]>`
SELECT
pn.id,
pn."phoneNumber",
pn."subscriptionStatus",
pn.provider,
pn."outboundAgentId"
FROM "CalAiPhoneNumber" pn
WHERE pn."outboundAgentId" IN (${Prisma.join(agentIds)})
`
: [];
// Map phone numbers to agents
const phoneNumbersByAgent = phoneNumbers.reduce((acc, pn) => {
const agentId = pn.outboundAgentId;
if (agentId) {
if (!acc[agentId]) {
acc[agentId] = [];
}
acc[agentId].push({
id: pn.id,
phoneNumber: pn.phoneNumber,
subscriptionStatus: pn.subscriptionStatus,
provider: pn.provider,
});
}
return acc;
}, {} as Record<string, _PhoneNumberRawResult[]>);
// Transform results to match expected format
return agents.map((agent) => ({
id: agent.id,
name: agent.name,
providerAgentId: agent.providerAgentId,
enabled: agent.enabled,
userId: agent.userId,
teamId: agent.teamId,
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
user: agent.user_id
? {
id: agent.user_id,
name: agent.user_name ?? null,
email: agent.user_email ?? null,
}
: null,
team: agent.team_id
? {
id: agent.team_id,
name: agent.team_name ?? null,
slug: agent.team_slug ?? null,
logoUrl: agent.team_logo_url ?? null,
}
: null,
outboundPhoneNumbers: phoneNumbersByAgent[agent.id] || [],
}));
}
static async findByIdWithUserAccessAndDetails({
id,
userId,
teamId,
}: {
id: string;
userId: number;
teamId?: number;
}) {
const accessibleTeamIds = await this.getUserAccessibleTeamIds(userId);
let whereCondition: Prisma.Sql;
if (teamId) {
// If teamId is provided, check that the user has access to that specific team
if (accessibleTeamIds.includes(teamId)) {
whereCondition = Prisma.sql`a.id = ${id} AND a."teamId" = ${teamId}`;
} else {
// If user doesn't have access to the team, only check for personal agents
whereCondition = Prisma.sql`a.id = ${id} AND a."userId" = ${userId}`;
}
} else if (accessibleTeamIds.length > 0) {
// No specific teamId provided, check both personal and team agents
whereCondition = Prisma.sql`a.id = ${id} AND (a."userId" = ${userId} OR a."teamId" IN (${Prisma.join(
accessibleTeamIds
)}))`;
} else {
// User has no team access, only check personal agents
whereCondition = Prisma.sql`a.id = ${id} AND a."userId" = ${userId}`;
}
const agents = await prisma.$queryRaw<_AgentRawResult[]>`
SELECT
a.id,
a.name,
a."providerAgentId",
a.enabled,
a."userId",
a."teamId",
a."createdAt",
a."updatedAt",
u.id as user_id,
u.name as user_name,
u.email as user_email,
t.id as team_id,
t.name as team_name,
t.slug as team_slug
FROM "Agent" a
LEFT JOIN "users" u ON a."userId" = u.id
LEFT JOIN "Team" t ON a."teamId" = t.id
WHERE ${whereCondition}
LIMIT 1
`;
if (agents.length === 0) {
return null;
}
const agent = agents[0];
const phoneNumbers = await prisma.$queryRaw<_PhoneNumberRawResult[]>`
SELECT
pn.id,
pn."phoneNumber",
pn."subscriptionStatus",
pn.provider
FROM "CalAiPhoneNumber" pn
WHERE pn."outboundAgentId" = ${agent.id}
`;
// Transform result to match expected format
return {
id: agent.id,
name: agent.name,
providerAgentId: agent.providerAgentId,
enabled: agent.enabled,
userId: agent.userId,
teamId: agent.teamId,
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
user: agent.user_id
? {
id: agent.user_id,
name: agent.user_name ?? null,
email: agent.user_email ?? null,
}
: null,
team: agent.team_id
? {
id: agent.team_id,
name: agent.team_name ?? null,
slug: agent.team_slug ?? null,
}
: null,
outboundPhoneNumbers: phoneNumbers.map((pn) => ({
id: pn.id,
phoneNumber: pn.phoneNumber,
subscriptionStatus: pn.subscriptionStatus,
provider: pn.provider,
})),
};
}
static async create({
name,
providerAgentId,
userId,
teamId,
}: {
name: string;
providerAgentId: string;
userId: number;
teamId?: number;
}) {
return await prisma.agent.create({
data: {
name,
providerAgentId,
userId,
teamId,
},
});
}
static async findByIdWithAdminAccess({
id,
userId,
teamId,
}: {
id: string;
userId: number;
teamId?: number;
}) {
const adminTeamIds = await this.getUserAdminTeamIds(userId);
let whereCondition: Prisma.Sql;
if (teamId) {
// If teamId is specified, check that user has admin access to that specific team
if (adminTeamIds.includes(teamId)) {
whereCondition = Prisma.sql`id = ${id} AND "teamId" = ${teamId}`;
} else {
// If user doesn't have admin access to the team, only check for personal agents
whereCondition = Prisma.sql`id = ${id} AND "userId" = ${userId}`;
}
} else if (adminTeamIds.length > 0) {
whereCondition = Prisma.sql`id = ${id} AND ("userId" = ${userId} OR "teamId" IN (${Prisma.join(
adminTeamIds
)}))`;
} else {
whereCondition = Prisma.sql`id = ${id} AND "userId" = ${userId}`;
}
const agents = await prisma.$queryRaw<_AgentRawResult[]>`
SELECT
id,
name,
"providerAgentId",
enabled,
"userId",
"teamId",
"createdAt",
"updatedAt"
FROM "Agent"
WHERE ${whereCondition}
LIMIT 1
`;
return agents.length > 0 ? agents[0] : null;
}
static async findByIdWithCallAccess({ id, userId }: { id: string; userId: number }) {
const accessibleTeamIds = await this.getUserAccessibleTeamIds(userId);
let whereCondition: Prisma.Sql;
if (accessibleTeamIds.length > 0) {
whereCondition = Prisma.sql`a.id = ${id} AND (a."userId" = ${userId} OR a."teamId" IN (${Prisma.join(
accessibleTeamIds
)}))`;
} else {
whereCondition = Prisma.sql`a.id = ${id} AND a."userId" = ${userId}`;
}
const agents = await prisma.$queryRaw<_AgentRawResult[]>`
SELECT
a.id,
a.name,
a."providerAgentId",
a.enabled,
a."userId",
a."teamId",
a."createdAt",
a."updatedAt"
FROM "Agent" a
WHERE ${whereCondition}
LIMIT 1
`;
if (agents.length === 0) {
return null;
}
const agent = agents[0];
const phoneNumbers = await prisma.$queryRaw<{ phoneNumber: string }[]>`
SELECT "phoneNumber"
FROM "CalAiPhoneNumber"
WHERE "outboundAgentId" = ${agent.id}
`;
return {
...agent,
outboundPhoneNumbers: phoneNumbers,
};
}
static async delete({ id }: { id: string }) {
return await prisma.agent.delete({
where: { id },
});
}
static async linkToWorkflowStep({ workflowStepId, agentId }: { workflowStepId: number; agentId: string }) {
return await prisma.workflowStep.update({
where: { id: workflowStepId },
data: { agentId },
});
}
static async canManageTeamResources({
userId,
teamId,
}: {
userId: number;
teamId: number;
}): Promise<boolean> {
const result = await prisma.$queryRaw<{ count: bigint }[]>`
SELECT COUNT(*) as count
FROM "Membership"
WHERE "userId" = ${userId}
AND "teamId" = ${teamId}
AND accepted = true
AND role IN ('ADMIN', 'OWNER')
`;
return Number(result[0].count) > 0;
}
}