Files
cal.diy/packages/trpc/server/routers/viewer/workflows/update.handler.ts
T
ead42c1a9c feat: SMS credits for free users (#21245)
* Add credits section to billing

* create seperate router for credits

* add stripe checkout session

* schema changes + code improvements

* rename to creditBalance

* custom quantify input with error message

* add checkout session completed webhook endpoint

* fix typo

* UI fixes

* add payCredits handler

* add error toast message

* allow scheduling sms up as close to 15 minutes in the future

* schedule at most 2 hours in advance

* webhook to pay for sent sms

* continued work on twilio callback

* code clean up

* further implementation for credit handling

* add migration

* object as param for scheduleSMS

* object as param for sendSMS

* fix TrpcSessionUser imports

* fix imports

* add db changes

* add cron job for price setting

* twilio status callback to create expense log

* remove unused code

* set up low credit balance email

* fixes for buying credits

* fixes in api/twilio/webhook

* add test to save credits to credits balance

* fix typos

* add new helper function chargeCredits

* expand twilioProvider

* fix type errors

* adjust tests

* type errors

* clean up

* clean up

* fix subscription active check

* remove some user/org related code

* more changes to remove user/org support

* send emails seperatly to admins

* fixes for team billing page

* fix stripe success url

* fixes to creating expense log

* email imrovements and more

* get monthly team price from stripe

* fix import

* fix monthly credits calculation

* finsih low credit balance warning email

* credit balance limit reached email

* create CreditService

* cancel SMS and send as email instead

* add messageDispatcher

* fix type error

* fix type error

* fix type error

* fix import

* fix unit test

* clean up twilioProvider

* clean up chckSmsPrices/route

* add missing translations

* add skeleton loader

* add admin check to get handler

* code clean up + fixes

* improve scheduling with fallback

* fix type error

* add bookingUid to handleSendingSMS

* add unit tests for creditService

* add more tests to credit-service.test.ts

* add test for cancelScheduledMessagesAndScheduleEmails

* fix test and type error

* add back resolve

* fix empty resolve

* adjust limitReachedAt logic

* address mrge comment on styling

* add getAdminMembership to repository

* twilio/webhook clean up (feedback)

* feedback - clean up

* remove todo comment

* clean up twilio/webhook

* code clean up

* add use client

* add createOneTimeCheckout to stripe service

* refactor repository pattern

* small fixes + clean up

* fix type error

* add missing import

* fix hasAvailableCredits for user

* force-dynamic

* rename credits to creditBalance

* fix stripe import

* remove not needed code

* fix e2e tests

* improve low balance warning email

* dynamic-import CreditService

* index.ts

* add user logic checkSmsPrices endpoint

* fix e2e tests

* remove dynamic import CreditService

* Revert "remove dynamic import CreditService"

This reverts commit e272978a7ff3fc5a04139e656c9f8d2c84a40dda.

* no need to dynamic-import credit service

* Revert "no need to dynamic-import credit service"

This reverts commit ba5ae488d08979a65fb47b5d0722cda9f45d6ea0.

* fix twilio webhook

* add userId support in checkout.session.completed

* clean up code

* only select id in getAdminMembership

* revert billing/package.json

* fix type checks

* fix type checks

* adjust hasAvailableCredits function

* fixes for checkout sessioned completed

* add UI for user

* fix type errors

* adds requires credits badge

* remove team check from update.handler

* clean up inlcude statements

* fix credit-service tests

* add tests

* fix type errors

* fix type errors

* fix and add tests

* imrove badge

* code clean up

* add reminderScheduler test

* add additional credits as title

* fixes for warningSentAt and limitReachedAt

* mock stripe

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: hbjORbj <sldisek783@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-05-13 15:55:19 +02:00

575 lines
16 KiB
TypeScript
Executable File

import { isEmailAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import tasker from "@calcom/features/tasker";
import { IS_SELF_HOSTED, SCANNING_WORKFLOW_STEPS } from "@calcom/lib/constants";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
import type { PrismaClient } from "@calcom/prisma";
import { WorkflowActions, WorkflowTemplates } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import hasActiveTeamPlanHandler from "../teams/hasActiveTeamPlan.handler";
import type { TUpdateInputSchema } from "./update.schema";
import {
getSender,
isAuthorized,
upsertSmsReminderFieldForEventTypes,
deleteRemindersOfActiveOnIds,
isAuthorizedToAddActiveOnIds,
scheduleWorkflowNotifications,
verifyEmailSender,
removeSmsReminderFieldForEventTypes,
isStepEdited,
getEmailTemplateText,
} from "./util";
type UpdateOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
prisma: PrismaClient;
};
input: TUpdateInputSchema;
};
export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
const { user } = ctx;
const { id, name, activeOn, steps, trigger, time, timeUnit, isActiveOnAll } = input;
const userWorkflow = await ctx.prisma.workflow.findUnique({
where: {
id,
},
select: {
id: true,
userId: true,
isActiveOnAll: true,
trigger: true,
time: true,
timeUnit: true,
team: {
select: {
isOrganization: true,
},
},
teamId: true,
user: {
select: {
teams: true,
},
},
steps: true,
activeOn: true,
activeOnTeams: true,
},
});
const isOrg = !!userWorkflow?.team?.isOrganization;
const isUserAuthorized = await isAuthorized(userWorkflow, ctx.user.id, true);
if (!isUserAuthorized || !userWorkflow) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (steps.find((step) => step.workflowId != id)) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const isCurrentUsernamePremium = hasKeyInMetadata(user, "isPremium") ? !!user.metadata.isPremium : false;
let teamsPlan = { isActive: false, isTrial: false };
if (!isCurrentUsernamePremium) {
teamsPlan = await hasActiveTeamPlanHandler({ ctx });
}
const hasPaidPlan = IS_SELF_HOSTED || isCurrentUsernamePremium || teamsPlan.isActive;
let newActiveOn: number[] = [];
let removedActiveOnIds: number[] = [];
let activeOnWithChildren: number[] = activeOn;
let oldActiveOnIds: number[] = [];
if (!isOrg) {
// activeOn are event types ids
const activeOnEventTypes = await ctx.prisma.eventType.findMany({
where: {
id: {
in: activeOn,
},
...(userWorkflow.teamId && { parentId: null }), //all children managed event types are added after
},
select: {
id: true,
children: {
select: {
id: true,
},
},
},
});
activeOnWithChildren = activeOnEventTypes
.map((eventType) => [eventType.id].concat(eventType.children.map((child) => child.id)))
.flat();
let oldActiveOnEventTypes: { id: number; children: { id: number }[] }[];
if (userWorkflow.isActiveOnAll) {
oldActiveOnEventTypes = await ctx.prisma.eventType.findMany({
where: {
...(userWorkflow.teamId ? { teamId: userWorkflow.teamId } : { userId: userWorkflow.userId }),
},
select: {
id: true,
children: {
select: {
id: true,
},
},
},
});
} else {
oldActiveOnEventTypes = (
await ctx.prisma.workflowsOnEventTypes.findMany({
where: {
workflowId: id,
},
select: {
eventTypeId: true,
eventType: {
select: {
children: {
select: {
id: true,
},
},
},
},
},
})
).map((eventTypeRel) => {
return { id: eventTypeRel.eventTypeId, children: eventTypeRel.eventType.children };
});
}
oldActiveOnIds = oldActiveOnEventTypes.flatMap((eventType) => [
eventType.id,
...eventType.children.map((child) => child.id),
]);
newActiveOn = activeOn.filter((eventTypeId) => !oldActiveOnIds.includes(eventTypeId));
const isAuthorizedToAddIds = await isAuthorizedToAddActiveOnIds(
newActiveOn,
isOrg,
userWorkflow?.teamId,
userWorkflow?.userId
);
if (!isAuthorizedToAddIds) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
//remove all scheduled Email and SMS reminders for eventTypes that are not active any more
removedActiveOnIds = oldActiveOnIds.filter((eventTypeId) => !activeOnWithChildren.includes(eventTypeId));
await deleteRemindersOfActiveOnIds({ removedActiveOnIds, workflowSteps: userWorkflow.steps, isOrg });
//update active on
await ctx.prisma.workflowsOnEventTypes.deleteMany({
where: {
workflowId: id,
},
});
//create all workflow - eventtypes relationships
await ctx.prisma.workflowsOnEventTypes.createMany({
data: activeOnWithChildren.map((eventTypeId) => ({
workflowId: id,
eventTypeId,
})),
});
} else {
// activeOn are team ids
if (userWorkflow.isActiveOnAll) {
oldActiveOnIds = (
await ctx.prisma.team.findMany({
where: {
parent: {
id: userWorkflow.teamId ?? 0,
},
},
select: {
id: true,
},
})
).map((team) => team.id);
} else {
oldActiveOnIds = (
await ctx.prisma.workflowsOnTeams.findMany({
where: {
workflowId: id,
},
select: {
teamId: true,
},
})
).map((teamRel) => teamRel.teamId);
}
newActiveOn = activeOn.filter((teamId) => !oldActiveOnIds.includes(teamId));
const isAuthorizedToAddIds = await isAuthorizedToAddActiveOnIds(
newActiveOn,
isOrg,
userWorkflow?.teamId,
userWorkflow?.userId
);
if (!isAuthorizedToAddIds) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
removedActiveOnIds = oldActiveOnIds.filter((teamId) => !activeOn.includes(teamId));
await deleteRemindersOfActiveOnIds({
removedActiveOnIds,
workflowSteps: userWorkflow.steps,
isOrg,
activeOnIds: activeOn.filter((activeOn) => !newActiveOn.includes(activeOn)),
});
//update active on
await ctx.prisma.workflowsOnTeams.deleteMany({
where: {
workflowId: id,
},
});
await ctx.prisma.workflowsOnTeams.createMany({
data: activeOn.map((teamId) => ({
workflowId: id,
teamId,
})),
});
}
if (userWorkflow.trigger !== trigger || userWorkflow.time !== time || userWorkflow.timeUnit !== timeUnit) {
//if trigger changed, delete all reminders from steps before change
await deleteRemindersOfActiveOnIds({
removedActiveOnIds: oldActiveOnIds,
workflowSteps: userWorkflow.steps,
isOrg,
});
await scheduleWorkflowNotifications({
activeOn, // schedule for activeOn that stayed the same + new active on (old reminders were deleted)
isOrg,
workflowSteps: userWorkflow.steps, // use old steps here, edited and deleted steps are handled below
time,
timeUnit,
trigger,
userId: user.id,
teamId: userWorkflow.teamId,
});
} else {
// if trigger didn't change, only schedule reminders for all new activeOn
await scheduleWorkflowNotifications({
activeOn: newActiveOn,
isOrg,
workflowSteps: userWorkflow.steps, // use old steps here, edited and deleted steps are handled below
time,
timeUnit,
trigger,
userId: user.id,
teamId: userWorkflow.teamId,
alreadyScheduledActiveOnIds: activeOn.filter((activeOn) => !newActiveOn.includes(activeOn)), // alreadyScheduledActiveOnIds
});
}
// handle deleted and edited workflow steps
userWorkflow.steps.map(async (oldStep) => {
const foundStep = steps.find((s) => s.id === oldStep.id);
let newStep;
if (foundStep) {
const { senderName, ...rest } = {
...foundStep,
numberVerificationPending: false,
sender: getSender({
action: foundStep.action,
sender: foundStep.sender || null,
senderName: foundStep.senderName,
}),
};
newStep = rest;
}
const remindersFromStep = await ctx.prisma.workflowReminder.findMany({
where: {
workflowStepId: oldStep.id,
},
select: {
id: true,
referenceId: true,
method: true,
booking: {
select: {
eventTypeId: true,
},
},
},
});
//step was deleted
if (!newStep) {
// cancel all workflow reminders from deleted steps
await WorkflowRepository.deleteAllWorkflowReminders(remindersFromStep);
await ctx.prisma.workflowStep.delete({
where: {
id: oldStep.id,
},
});
} else if (isStepEdited(oldStep, { ...newStep, verifiedAt: oldStep.verifiedAt })) {
// check if step that require team plan already existed before
if (!hasPaidPlan) {
const isChangingToCustomTemplate =
newStep.template === WorkflowTemplates.CUSTOM && oldStep.template !== WorkflowTemplates.CUSTOM;
if (isChangingToCustomTemplate) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Not available on free plan" });
}
//if email body or subject was changed, change to predefined template
if (newStep.emailSubject !== oldStep.emailSubject || newStep.reminderBody !== oldStep.reminderBody) {
// already existing custom templates can't be updated
if (newStep.template === WorkflowTemplates.CUSTOM) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Not available on free plan" });
}
if (isEmailAction(newStep.action)) {
// on free plans always use predefined templates
const { emailBody, emailSubject } = await getEmailTemplateText(newStep.template, {
locale: ctx.user.locale,
action: newStep.action,
timeFormat: ctx.user.timeFormat,
});
newStep = { ...newStep, reminderBody: emailBody, emailSubject };
}
}
}
// update step
const requiresSender =
newStep.action === WorkflowActions.SMS_NUMBER ||
newStep.action === WorkflowActions.WHATSAPP_NUMBER ||
newStep.action === WorkflowActions.EMAIL_ADDRESS;
if (newStep.action === WorkflowActions.EMAIL_ADDRESS) {
await verifyEmailSender(newStep.sendTo || "", user.id, userWorkflow.teamId);
}
const didBodyChange = newStep.reminderBody !== oldStep.reminderBody;
await ctx.prisma.workflowStep.update({
where: {
id: oldStep.id,
},
data: {
action: newStep.action,
sendTo: requiresSender ? newStep.sendTo : null,
stepNumber: newStep.stepNumber,
workflowId: newStep.workflowId,
reminderBody: newStep.reminderBody,
emailSubject: newStep.emailSubject,
template: newStep.template,
numberRequired: newStep.numberRequired,
sender: newStep.sender,
numberVerificationPending: false,
includeCalendarEvent: newStep.includeCalendarEvent,
verifiedAt: !SCANNING_WORKFLOW_STEPS ? new Date() : didBodyChange ? null : oldStep.verifiedAt,
},
});
if (SCANNING_WORKFLOW_STEPS && didBodyChange) {
await tasker.create("scanWorkflowBody", { workflowStepIds: [oldStep.id], userId: ctx.user.id });
} else {
// schedule notifications for edited steps
await scheduleWorkflowNotifications({
activeOn,
isOrg,
workflowSteps: [newStep],
time,
timeUnit,
trigger,
userId: user.id,
teamId: userWorkflow.teamId,
});
}
// cancel all notifications of edited step
await WorkflowRepository.deleteAllWorkflowReminders(remindersFromStep);
}
});
// handle added workflow steps
const addedSteps = await Promise.all(
steps
.filter((step) => step.id <= 0)
.map(async (newStep) => {
if (!hasPaidPlan) {
if (newStep.template === WorkflowTemplates.CUSTOM) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Not available on free plan" });
}
// on free plans always use predefined templates
const { emailBody, emailSubject } = await getEmailTemplateText(newStep.template, {
locale: ctx.user.locale,
action: newStep.action,
timeFormat: ctx.user.timeFormat,
});
newStep = { ...newStep, reminderBody: emailBody, emailSubject };
}
if (newStep.action === WorkflowActions.EMAIL_ADDRESS) {
await verifyEmailSender(newStep.sendTo || "", user.id, userWorkflow.teamId);
}
const {
id: _stepId,
senderName,
...stepToAdd
} = {
...newStep,
sender: getSender({
action: newStep.action,
sender: newStep.sender || null,
senderName: newStep.senderName,
}),
};
return stepToAdd;
})
);
if (addedSteps.length) {
//create new steps
const createdSteps = await Promise.all(
addedSteps.map((step) =>
ctx.prisma.workflowStep.create({
data: {
...step,
numberVerificationPending: false,
...(!SCANNING_WORKFLOW_STEPS ? { verifiedAt: new Date() } : {}),
},
})
)
);
if (SCANNING_WORKFLOW_STEPS) {
// workflows are scanned then scheduled in the task
await tasker.create("scanWorkflowBody", {
workflowStepIds: createdSteps.map((step) => step.id),
userId: ctx.user.id,
});
} else {
// schedule notification for new step
await scheduleWorkflowNotifications({
activeOn,
isOrg,
workflowSteps: createdSteps,
time,
timeUnit,
trigger,
userId: user.id,
teamId: userWorkflow.teamId,
});
}
}
//update trigger, name, time, timeUnit
await ctx.prisma.workflow.update({
where: {
id,
},
data: {
name,
trigger,
time,
timeUnit,
isActiveOnAll,
},
});
const workflow = await ctx.prisma.workflow.findFirst({
where: {
id,
},
include: {
activeOn: {
select: {
eventType: true,
},
},
activeOnTeams: {
select: {
team: true,
},
},
team: {
select: {
id: true,
slug: true,
members: true,
name: true,
isOrganization: true,
},
},
steps: {
orderBy: {
stepNumber: "asc",
},
},
},
});
// Remove or add booking field for sms reminder number
const smsReminderNumberNeeded =
activeOn.length &&
steps.some(
(step) =>
step.action === WorkflowActions.SMS_ATTENDEE || step.action === WorkflowActions.WHATSAPP_ATTENDEE
);
await removeSmsReminderFieldForEventTypes({
activeOnToRemove: removedActiveOnIds,
workflowId: id,
isOrg,
activeOn,
});
if (!smsReminderNumberNeeded) {
await removeSmsReminderFieldForEventTypes({
activeOnToRemove: activeOnWithChildren,
workflowId: id,
isOrg,
});
} else {
await upsertSmsReminderFieldForEventTypes({
activeOn: activeOnWithChildren,
workflowId: id,
isSmsReminderNumberRequired: steps.some(
(s) =>
(s.action === WorkflowActions.SMS_ATTENDEE || s.action === WorkflowActions.WHATSAPP_ATTENDEE) &&
s.numberRequired
),
isOrg,
});
}
return {
workflow,
};
};