Files
calendar/packages/lib/server/repository/PrismaAgentRepository.ts
T
9e1e50e90b feat: cal.ai form triggers #4 (#23587)
* add trigger

* small fixes

* add missing workflow DTOs

* small fixes

* use activeOnWithChildren

* fix active on when switching trigger type

* remove add variable dropdown

* feat: lang support

* fix: type errors

* feat: select voice agent

* refactor: address feedback

* refactor: address feedback

* refactor: missing import

* fix: types

* add getAllWorkflowsFromRoutingForm to WorkflowService

* fix error caused by undefined evt

* fix type error

* fix type error

* fix tests

* feat: add inbound calls

* chore: formatting

* chore

* feat: finish inbound call

* chore: formatting

* fix: update bug

* fix: types

* code clean up

* final fixes and clean up

* remove console.log

* remove template text form from triggers

* add routing form repoditory function

* refactor: Agent Configuration Sheet (#23930)

* refactor: agent configuration sheet

* chore: use default phone numbre

* refactor: improvements

* refactor: improvements

* fix: types

* fix: feedback

* fix bug with key

* chore:

* fix: feedback

* fix: prompt

* add comments

* fix: review

* fix: review

* refactor: class

* refactor: class

* fix test

* allow cal ai action on form triggers

* move any reusable code to scheduleAIPhoneCall

* add missing await

* use predefined FormSubmissionData type

* add .trim() to sms message

* pass contextData instead

* finish base setup

* add missing trigger in update-workflow.input.ts

* allow cal.ai action for form triggers in handler

* chore: add support for form workflows on api v2

* fixup! chore: add support for form workflows on api v2

* ai phone call on form submissions (WIP)

* use existing type for Option array

* pass chosen event type id

* refactor: rename

* Update apps/web/public/static/locales/en/common.json

* Update apps/web/public/static/locales/en/common.json

* add missing imports

* chore: update set value

* fix: remove index

* fix: type error

* fix: update tetss

* use only repository functions in update handler

* move all prisma queries from list.handler

* review suggestions

* fix: use logger

* chore: handle workflows api v2

* chore: handle workflows api v2, split in 2 endpoints

* fix workflow step creation

* remove connect agent and fixes types

* add type to workflow

* chore: use workflow type in apiv2 WorkflowsOutputService

* update worklfow type on update

* chore: use workflow type in apiv2 WorkflowsOutputService

* fix template body for torm trigger

* some UI fixes for email subject/body

* resetting email body when changing form triggers

* use type field to query workflows

* clean up all old active on values

* remove responseId from all funciton calls

* remove undefined from updateTemplate

* refactor: don't use static

* fix: type

* refactor: split routing form and event-type workflows code

* refactor: split routing form and event-type workflows code

* fix template  text when adding action

* chore: don't rename WorkflowActivationDto to avoid ci blocking

* refine update schedule to use only allowed actions

* fix type error

* don't allow whatsapp action with form trigger

* fix type error

* return early if activeOn array is empty

* fix: from step type in BaseFormWorkflowStepDto

* fixup! fix: from step type in BaseFormWorkflowStepDto

* api v2 updates

* move all prisma calls to repository (service/workflows.ts)

* use FORM_TRIGGER_WORKFLOW_EVENTS for form queries

* use userRepository

* use FORM_TRIGGER_WORKFLOW_EVENTS in isFormTrigger

* code clean up

* code clean up

* use repository functions in formSubmissionValidation.ts

* fix: schema

* refactor:

* remove action check in update handler

* add event type selection

* event type selector improvements

* adjust update.handler

* set outboundEventTypeId

* add back trpc  import

* fix agent repository functions

* clean up

* fix bugs caused by merge

* pass eventTypeId to updateToolsFromAgentId

* add migration for outboundEventTypeId

* add SMS actions to allowed form action constants

* add cal ai to allowed form actions

* pick correct event type for web call

* pass correct routed event type id

* remove unsued import

* fixes for offset api v2

* add missing responseId

* fix failing test

* fix failing test

* improve error message

* remove unused imports

* chore: handle sms step action for form worklfow in dtos

* fix typo

* missing missing newStep

* minor fixes

* remove changes

* add routedEventTypeId

* fix type error

* fix type error

* fix typ error in executAPIPhoneCall.tsx

* add back inboundEventTypeId

* remove console.log

* remove outdated code

* small fixes

* don't throw error for missing phone number

* add  back filtered triggerOptions

* fix eventTypeId in testCall handler

* fix type error

* update migration

* fix trigger is not defined

* convert eventTypeId to string

* only use outboundEventTypeId for FORM_SUBMITTED trigger

* show toast when no event type selected

* fix type errors

* add missing translation

* fix type error

* remove callType

* fix tests

* small fixes

* clean up AgentConfigurationSheet

* remove EventTypeSelector file

* code clean up

* clean up

* clean up

* use resusable function for TestPhoneCallDialog and WebCallDialog

* rename result

* fix types for event type id

* use repository runction in workflowReminder.ts

* fix type error

* pass eventTypeIds correctly

* fix typo

* Update apps/web/public/static/locales/en/common.json

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

* use watch instead of getValues

* change to  z.record(z.unknown()) instead of any()

* fix type of eventTypeId

* check permissinon for outBoundEventTypeId

* add isNaN check

* improve function name

* update tools when outbound agent event type id changes

* pass missing outboundEventTypeId

* update migration

* fix test

* remove cal-ai step from test

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-11-11 15:24:31 +02:00

651 lines
17 KiB
TypeScript

import { Prisma, PrismaClient } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
interface _AgentRawResult {
id: string;
name: string;
providerAgentId: string;
enabled: boolean;
userId: number;
teamId: number | null;
inboundEventTypeId?: number | null;
outboundEventTypeId?: 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 {
constructor(private prismaClient: PrismaClient) {}
private async getUserAccessibleTeamIds(userId: number): Promise<number[]> {
const memberships = await this.prismaClient.membership.findMany({
where: {
userId,
accepted: true,
},
select: {
teamId: true,
},
});
return memberships.map((membership) => membership.teamId);
}
private async getUserAdminTeamIds(userId: number): Promise<number[]> {
const memberships = await this.prismaClient.membership.findMany({
where: {
userId,
accepted: true,
role: {
in: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
select: {
teamId: true,
},
});
return memberships.map((membership) => membership.teamId);
}
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 query = Prisma.sql`
SELECT
id,
name,
"providerAgentId",
enabled,
"userId",
"teamId",
"inboundEventTypeId",
"outboundEventTypeId",
"createdAt",
"updatedAt"
FROM "Agent"
WHERE ${whereCondition}
LIMIT 1
`;
const agents = await this.prismaClient.$queryRaw<_AgentRawResult[]>(query);
return agents.length > 0 ? agents[0] : null;
}
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 query = Prisma.sql`
SELECT
id,
name,
"providerAgentId",
enabled,
"userId",
"teamId",
"inboundEventTypeId",
"outboundEventTypeId",
"createdAt",
"updatedAt"
FROM "Agent"
WHERE ${whereCondition}
LIMIT 1
`;
const agents = await this.prismaClient.$queryRaw<_AgentRawResult[]>(query);
return agents.length > 0 ? agents[0] : null;
}
async findById({ id }: { id: string }) {
return await this.prismaClient.agent.findUnique({
select: {
id: true,
name: true,
providerAgentId: true,
enabled: true,
userId: true,
teamId: true,
inboundEventTypeId: true,
outboundEventTypeId: true,
createdAt: true,
updatedAt: true,
},
where: {
id,
},
});
}
async findByProviderAgentId({ providerAgentId }: { providerAgentId: string }) {
return await this.prismaClient.agent.findUnique({
select: {
id: true,
name: true,
providerAgentId: true,
enabled: true,
userId: true,
teamId: true,
inboundEventTypeId: true,
outboundEventTypeId: true,
createdAt: true,
updatedAt: true,
team: {
select: {
id: true,
parentId: true,
},
},
},
where: {
providerAgentId,
},
});
}
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 query = Prisma.sql`
SELECT
a.id,
a.name,
a."providerAgentId",
a.enabled,
a."userId",
a."teamId",
a."inboundEventTypeId",
a."outboundEventTypeId",
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
`;
const agents = await this.prismaClient.$queryRaw<_AgentRawResult[]>(query);
// 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 this.prismaClient.$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,
inboundEventTypeId: agent.inboundEventTypeId,
outboundEventTypeId: agent.outboundEventTypeId,
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] || [],
}));
}
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 query = Prisma.sql`
SELECT
a.id,
a.name,
a."providerAgentId",
a.enabled,
a."userId",
a."teamId",
a."inboundEventTypeId",
a."outboundEventTypeId",
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
`;
const agents = await this.prismaClient.$queryRaw<_AgentRawResult[]>(query);
if (agents.length === 0) {
return null;
}
const agent = agents[0];
const phoneNumbers = await this.prismaClient.$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,
inboundEventTypeId: agent.inboundEventTypeId,
outboundEventTypeId: agent.outboundEventTypeId,
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,
})),
};
}
async create({
name,
providerAgentId,
userId,
teamId,
}: {
name: string;
providerAgentId: string;
userId: number;
teamId?: number;
}) {
return await this.prismaClient.agent.create({
data: {
name,
providerAgentId,
userId,
teamId,
},
});
}
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 query = Prisma.sql`
SELECT
id,
name,
"providerAgentId",
enabled,
"userId",
"teamId",
"createdAt",
"updatedAt",
"inboundEventTypeId",
"outboundEventTypeId"
FROM "Agent"
WHERE ${whereCondition}
LIMIT 1
`;
const agents = await this.prismaClient.$queryRaw<_AgentRawResult[]>(query);
return agents.length > 0 ? agents[0] : null;
}
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 query = Prisma.sql`
SELECT
a.id,
a.name,
a."providerAgentId",
a.enabled,
a."userId",
a."teamId",
a."inboundEventTypeId",
a."outboundEventTypeId",
a."createdAt",
a."updatedAt"
FROM "Agent" a
WHERE ${whereCondition}
LIMIT 1
`;
const agents = await this.prismaClient.$queryRaw<_AgentRawResult[]>(query);
if (agents.length === 0) {
return null;
}
const agent = agents[0];
const phoneNumbers = await this.prismaClient.$queryRaw<{ phoneNumber: string }[]>`
SELECT "phoneNumber"
FROM "CalAiPhoneNumber"
WHERE "outboundAgentId" = ${agent.id}
`;
return {
...agent,
outboundPhoneNumbers: phoneNumbers,
};
}
async delete({ id }: { id: string }) {
return await this.prismaClient.agent.delete({
where: { id },
});
}
async linkOutboundAgentToWorkflow({
workflowStepId,
agentId,
}: {
workflowStepId: number;
agentId: string;
}) {
return await this.prismaClient.workflowStep.update({
where: { id: workflowStepId },
data: { agentId },
});
}
async linkInboundAgentToWorkflow({ workflowStepId, agentId }: { workflowStepId: number; agentId: string }) {
return await this.prismaClient.workflowStep.update({
where: {
id: workflowStepId,
},
data: {
inboundAgentId: agentId,
},
});
}
async updateEventTypeId({ agentId, eventTypeId }: { agentId: string; eventTypeId: number }) {
return await this.prismaClient.agent.update({
where: {
id: agentId,
},
data: {
inboundEventTypeId: eventTypeId,
},
});
}
async updateOutboundEventTypeId({ agentId, eventTypeId }: { agentId: string; eventTypeId: number }) {
return await this.prismaClient.agent.update({
where: {
id: agentId,
},
data: {
outboundEventTypeId: eventTypeId,
},
});
}
async canManageTeamResources({ userId, teamId }: { userId: number; teamId: number }): Promise<boolean> {
const result = await this.prismaClient.$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;
}
async findAgentWithPhoneNumbers(agentId: string) {
return await this.prismaClient.agent.findUnique({
where: { id: agentId },
select: {
id: true,
outboundPhoneNumbers: {
select: {
id: true,
phoneNumber: true,
subscriptionStatus: true,
},
},
},
});
}
async findProviderAgentIdById(agentId: string) {
return await this.prismaClient.agent.findUnique({
where: { id: agentId },
select: { providerAgentId: true },
});
}
}