Compare commits

..
7 Commits
Author SHA1 Message Date
GitHub Actions 2b5ea649d4 chore: release v5.3.0
Post release / release (push) Canceled after 0s
2025-05-15 20:43:11 +00:00
Benny Joo 0802ebb17b refactor: check insights feature flag in higher stack (#21341) 2025-05-15 12:41:22 -07:00
Anik Dhabal Babu 29b8d68f45 fix: unable to save workflow with verified secondary email (#21338)
* fix: unable to save workflow

* Update util.ts
2025-05-15 19:40:45 +00:00
Lauris Skraucis 6994cac5e0 fix: v2 token secret conflicts (#21279)
* fix: accessToken.create unique constraint failed on the secret field

* fix: force refresh tokens within single transaction
2025-05-15 16:04:50 +00:00
Carina Wollendorfer df9b72583d add learn more help link (#21333) 2025-05-15 11:02:57 +00:00
Carina WollendorferandCarinaWolli 553eab59e6 fix: sms credits follow up (#21304)
* address mrge review comments

* code clean up

* fix type error

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-05-15 08:33:38 +00:00
Joe Au-Yeung 8d675fd8b6 feat: Add AssignmentReason to BOOKING_CREATED webhook (#21315)
* AssignmentREasonRecorder methods return the reason generated

* Add assignmentReason to `EventPayloadType`

* Pass `assignmentReason` to webhook payload data

* Undo constants commit
2025-05-14 23:33:25 +00:00
21 changed files with 180 additions and 79 deletions
View File
@@ -177,7 +177,7 @@ export class OAuthClientUsersController {
const { id } = await this.validateManagedUserOwnership(oAuthClientId, userId);
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } =
await this.tokensRepository.createOAuthTokens(oAuthClientId, id, true);
await this.tokensRepository.forceRefreshOAuthTokens(oAuthClientId, id);
return {
status: SUCCESS_STATUS,
@@ -5,6 +5,7 @@ import { Injectable } from "@nestjs/common";
import { Logger } from "@nestjs/common";
import { PlatformAuthorizationToken } from "@prisma/client";
import { DateTime } from "luxon";
import { v4 as uuidv4 } from "uuid";
@Injectable()
export class TokensRepository {
@@ -50,31 +51,18 @@ export class TokensRepository {
});
}
async createOAuthTokens(clientId: string, ownerId: number, deleteOld?: boolean) {
if (deleteOld) {
try {
await this.dbWrite.prisma.$transaction([
this.dbWrite.prisma.accessToken.deleteMany({
where: { client: { id: clientId }, userId: ownerId },
}),
this.dbWrite.prisma.refreshToken.deleteMany({
where: {
client: { id: clientId },
userId: ownerId,
},
}),
]);
} catch (err) {
this.logger.error("createOAuthTokens - Failed to delete old tokens", err);
}
}
async createOAuthTokens(clientId: string, ownerId: number) {
const accessExpiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate();
const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate();
const [accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([
this.dbWrite.prisma.accessToken.create({
data: {
secret: this.jwtService.signAccessToken({ clientId, ownerId, expiresAt: accessExpiry.valueOf() }),
secret: this.jwtService.signAccessToken({
clientId,
ownerId,
expiresAt: accessExpiry.valueOf(),
jti: uuidv4(),
}),
expiresAt: accessExpiry,
client: { connect: { id: clientId } },
owner: { connect: { id: ownerId } },
@@ -82,7 +70,12 @@ export class TokensRepository {
}),
this.dbWrite.prisma.refreshToken.create({
data: {
secret: this.jwtService.signRefreshToken({ clientId, ownerId, expiresAt: refreshExpiry.valueOf() }),
secret: this.jwtService.signRefreshToken({
clientId,
ownerId,
expiresAt: refreshExpiry.valueOf(),
jti: uuidv4(),
}),
expiresAt: refreshExpiry,
client: { connect: { id: clientId } },
owner: { connect: { id: ownerId } },
@@ -98,6 +91,57 @@ export class TokensRepository {
};
}
async forceRefreshOAuthTokens(clientId: string, ownerId: number) {
const accessExpiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate();
const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate();
const [_deletedAccessToken, _deletedRefreshToken, accessToken, refreshToken] =
await this.dbWrite.prisma.$transaction([
this.dbWrite.prisma.accessToken.deleteMany({
where: { client: { id: clientId }, userId: ownerId },
}),
this.dbWrite.prisma.refreshToken.deleteMany({
where: {
client: { id: clientId },
userId: ownerId,
},
}),
this.dbWrite.prisma.accessToken.create({
data: {
secret: this.jwtService.signAccessToken({
clientId,
ownerId,
expiresAt: accessExpiry.valueOf(),
jti: uuidv4(),
}),
expiresAt: accessExpiry,
client: { connect: { id: clientId } },
owner: { connect: { id: ownerId } },
},
}),
this.dbWrite.prisma.refreshToken.create({
data: {
secret: this.jwtService.signRefreshToken({
clientId,
ownerId,
expiresAt: refreshExpiry.valueOf(),
jti: uuidv4(),
}),
expiresAt: refreshExpiry,
client: { connect: { id: clientId } },
owner: { connect: { id: ownerId } },
},
}),
]);
return {
accessToken: accessToken.secret,
accessTokenExpiresAt: accessToken.expiresAt,
refreshToken: refreshToken.secret,
refreshTokenExpiresAt: refreshToken.expiresAt,
};
}
async getAccessTokenExpiryDate(accessTokenSecret: string) {
const accessToken = await this.dbRead.prisma.accessToken.findFirst({
where: {
@@ -140,6 +184,7 @@ export class TokensRepository {
ownerId: tokenUserId,
expiresAt: accessExpiry.valueOf(),
userId: tokenUserId,
jti: uuidv4(),
}),
expiresAt: accessExpiry,
client: { connect: { id: clientId } },
@@ -155,6 +200,7 @@ export class TokensRepository {
ownerId: tokenUserId,
expiresAt: refreshExpiry.valueOf(),
userId: tokenUserId,
jti: uuidv4(),
}),
expiresAt: refreshExpiry,
client: { connect: { id: clientId } },
@@ -1,12 +1,22 @@
import { getTranslate } from "app/_utils";
import { notFound } from "next/navigation";
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import Shell from "@calcom/features/shell/Shell";
import UpgradeTipWrapper from "./UpgradeTipWrapper";
export default async function InsightsLayout({ children }: { children: React.ReactNode }) {
const featuresRepository = new FeaturesRepository();
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
if (!insightsEnabled) {
return notFound();
}
const t = await getTranslate();
return (
<>
<div>
@@ -1,7 +1,4 @@
import { _generateMetadata } from "app/_utils";
import { notFound } from "next/navigation";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import InsightsPage from "~/insights/insights-view";
@@ -15,12 +12,5 @@ export const generateMetadata = async () =>
);
export default async function Page() {
const featuresRepository = new FeaturesRepository();
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
if (!insightsEnabled) {
return notFound();
}
return <InsightsPage />;
}
@@ -1,7 +1,4 @@
import { _generateMetadata } from "app/_utils";
import { notFound } from "next/navigation";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import InsightsVirtualQueuesPage from "~/insights/insights-virtual-queues-view";
@@ -15,12 +12,5 @@ export const generateMetadata = async () =>
);
export default async function Page() {
const featuresRepository = new FeaturesRepository();
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
if (!insightsEnabled) {
return notFound();
}
return <InsightsVirtualQueuesPage />;
}
@@ -1,7 +1,4 @@
import { _generateMetadata } from "app/_utils";
import { notFound } from "next/navigation";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import InsightsRoutingPage from "~/insights/insights-routing-view";
@@ -15,12 +12,5 @@ export const generateMetadata = async () =>
);
export default async function Page() {
const featuresRepository = new FeaturesRepository();
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
if (!insightsEnabled) {
return notFound();
}
return <InsightsRoutingPage />;
}
@@ -1,9 +1,11 @@
"use client";
import { ProgressBar } from "@tremor/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import ServerTrans from "@calcom/lib/components/ServerTrans";
import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
@@ -61,7 +63,19 @@ export default function BillingCredits() {
<div className="border-subtle mt-8 space-y-6 rounded-lg border px-6 py-6 pb-6 text-sm sm:space-y-8">
<div>
<h2 className="text-base font-semibold">{t("credits")}</h2>
<p>{t("view_and_manage_credits_description")}</p>
<ServerTrans
t={t}
i18nKey="view_and_manage_credits_description"
components={[
<Link
key="Credit System"
className="underline underline-offset-2"
target="_blank"
href="https://cal.com/help/billing-and-usage/messaging-credits">
Learn more
</Link>,
]}
/>
<div className="-mx-6 mt-6">
<hr className="border-subtle" />
</div>
@@ -90,7 +104,6 @@ export default function BillingCredits() {
) : (
<></>
)}
<Label>
{creditsData.credits.totalMonthlyCredits ? t("additional_credits") : t("available_credits")}
</Label>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.2.17",
"version": "5.3.0",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+2 -2
View File
@@ -76,7 +76,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const creditService = new CreditService();
if (countryCode === "US" || countryCode === "CA") {
// SMS to US and CA are free on a team plan
// SMS to US and CA are free
let teamIdToCharge = parsedTeamId;
if (!teamIdToCharge && parsedUserId) {
@@ -100,7 +100,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
credits: 0,
});
return res.status(200).send(`SMS to US and CA are free on a team plan. Credits set to 0`);
return res.status(200).send(`SMS to US and CA are free. Credits set to 0`);
}
let orgId;
@@ -3024,7 +3024,7 @@
"buy_credits": "Buy Credits",
"credits": "Credits",
"view_and_manage_credits": "View and manage credits",
"view_and_manage_credits_description": "View and manage credits for sending SMS messages. One credit is worth 1¢ (USD)",
"view_and_manage_credits_description": "View and manage credits for sending SMS messages. One credit is worth 1¢ (USD). <0>Learn more</0>",
"buy_additional_credits": "Buy additional credits ($0.01 per credit)",
"overview": "Overview",
"organization_slug_taken": "Organization slug is already taken",
@@ -52,7 +52,7 @@ export const CreditBalanceLimitReachedEmail = (
<div style={{ textAlign: "center", marginTop: "24px" }}>
<CallToAction
label={user.t("buy_credits")}
href={`${WEBAPP_URL}/settings/teams/${user.id}/credits`}
href={`${WEBAPP_URL}/settings/billing`}
endIconName="linkIcon"
/>
</div>
@@ -61,7 +61,7 @@ export const CreditBalanceLowWarningEmail = (
<div style={{ textAlign: "center", marginTop: "24px" }}>
<CallToAction
label={user.t("buy_credits")}
href={`${WEBAPP_URL}/settings/teams/${user.id}/billing`}
href={`${WEBAPP_URL}/settings/billing`}
endIconName="linkIcon"
/>
</div>
@@ -68,6 +68,7 @@ import { getTranslation } from "@calcom/lib/server/i18n";
import { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma";
import type { AssignmentReasonEnum } from "@calcom/prisma/enums";
import { BookingStatus, SchedulingType, WebhookTriggerEvents } from "@calcom/prisma/enums";
import { CreationSource } from "@calcom/prisma/enums";
import {
@@ -1301,6 +1302,8 @@ async function handler(
})
);
let assignmentReason: { reasonEnum: AssignmentReasonEnum; reasonString: string } | undefined;
try {
if (!isDryRun) {
booking = await createBooking({
@@ -1341,7 +1344,7 @@ async function handler(
// If it's a round robin event, record the reason for the host assignment
if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
if (reqBody.crmOwnerRecordType && reqBody.crmAppSlug && contactOwnerEmail && routingFormResponseId) {
await AssignmentReasonRecorder.CRMOwnership({
assignmentReason = await AssignmentReasonRecorder.CRMOwnership({
bookingId: booking.id,
crmAppSlug: reqBody.crmAppSlug,
teamMemberEmail: contactOwnerEmail,
@@ -1349,7 +1352,7 @@ async function handler(
routingFormResponseId,
});
} else if (routingFormResponseId && teamId) {
await AssignmentReasonRecorder.routingFormRoute({
assignmentReason = await AssignmentReasonRecorder.routingFormRoute({
bookingId: booking.id,
routingFormResponseId,
organizerId: organizerUser.id,
@@ -1885,6 +1888,7 @@ async function handler(
status: "ACCEPTED",
smsReminderNumber: booking?.smsReminderNumber || undefined,
rescheduledBy: reqBody.rescheduledBy,
...(assignmentReason ? { assignmentReason: [assignmentReason] } : {}),
};
if (bookingRequiresPayment) {
@@ -14,7 +14,7 @@ const handler = async (data: SWHMap["checkout.session.completed"]["data"]) => {
const userId = session.metadata?.userId ? Number(session.metadata.userId) : undefined;
if (!teamId && !userId) {
throw new HttpCode(400, "Team id and user id, but at least one is required");
throw new HttpCode(400, "Team id and user id are missing, but at least one is required");
}
const lineItems = await stripe.checkout.sessions.listLineItems(session.id);
@@ -395,7 +395,6 @@ describe("CreditService", () => {
vi.spyOn(CreditService.prototype, "getMonthlyCredits").mockResolvedValue(500);
const result = await creditService.getAllCreditsForTeam(1);
console.log(`tesult ${JSON.stringify(result)}`);
expect(result).toEqual({
totalMonthlyCredits: 500,
totalRemainingMonthlyCredits: 420, // 500 - (50 + 30)
@@ -113,13 +113,21 @@ export default class AssignmentReasonRecorder {
}
}
const reasonEnum = AssignmentReasonEnum.ROUTING_FORM_ROUTING;
const reasonString = attributeValues.join(", ");
await prisma.assignmentReason.create({
data: {
bookingId: bookingId,
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING,
reasonString: attributeValues.join(", "),
reasonEnum,
reasonString,
},
});
return {
reasonEnum,
reasonString,
};
}
// Separate method to handle rerouting
@@ -148,13 +156,20 @@ export default class AssignmentReasonRecorder {
if (!crmRoutingReason || !crmRoutingReason.assignmentReason) return;
const { reasonEnum, assignmentReason } = crmRoutingReason;
await prisma.assignmentReason.create({
data: {
bookingId,
reasonEnum: crmRoutingReason.reasonEnum,
reasonString: crmRoutingReason.assignmentReason,
reasonEnum,
reasonString: assignmentReason,
},
});
return {
reasonEnum,
reasonString: assignmentReason,
};
}
static roundRobinReassignment = withReporting(
AssignmentReasonRecorder._roundRobinReassignment,
@@ -180,6 +195,11 @@ export default class AssignmentReasonRecorder {
},
});
const reasonEnum =
reassignmentType === RRReassignmentType.MANUAL
? AssignmentReasonEnum.REASSIGNED
: AssignmentReasonEnum.RR_REASSIGNED;
const reasonString = `Reassigned by: ${reassignedBy?.username || "team member"}. ${
reassignReason ? `Reason: ${reassignReason}` : ""
}`;
@@ -187,12 +207,14 @@ export default class AssignmentReasonRecorder {
await prisma.assignmentReason.create({
data: {
bookingId: bookingId,
reasonEnum:
reassignmentType === RRReassignmentType.MANUAL
? AssignmentReasonEnum.REASSIGNED
: AssignmentReasonEnum.RR_REASSIGNED,
reasonEnum,
reasonString,
},
});
return {
reasonEnum,
reasonString,
};
}
}
+1 -1
View File
@@ -85,7 +85,7 @@ export class CreditsRepository {
}
}
static async findCreditBalanceWithExpenseLogs({ teamId }: { teamId?: number }) {
static async findCreditBalanceWithExpenseLogs({ teamId }: { teamId: number }) {
return await prisma.creditBalance.findUnique({
where: {
teamId,
@@ -34,9 +34,9 @@ export const buyCreditsHandler = async ({ ctx, input }: BuyCreditsOptions) => {
}
} else {
// if user id is part of a team, user can't buy credits for themselves
const membership = await MembershipRepository.findAllAcceptedMemberships(ctx.user.id);
const memberships = await MembershipRepository.findAllAcceptedMemberships(ctx.user.id);
if (membership.length > 0) {
if (memberships.length > 0) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
@@ -25,9 +25,9 @@ export const getAllCreditsHandler = async ({ ctx, input }: GetAllCreditsOptions)
}
} else {
//if user is part of team, don't return any credits if teamId is not given
const membership = await MembershipRepository.findAllAcceptedMemberships(ctx.user.id);
const memberships = await MembershipRepository.findAllAcceptedMemberships(ctx.user.id);
if (membership.length > 0) {
if (memberships.length > 0) {
return null;
}
}
@@ -137,6 +137,28 @@ export const verifyEmailSender = async (email: string, userId: number, teamId: n
return;
}
// Check if it's a verified secondary email of the user
const secondaryEmail = await prisma.secondaryEmail.findFirst({
where: {
userId,
email,
emailVerified: {
not: null,
},
},
});
if (secondaryEmail) {
await prisma.verifiedEmail.create({
data: {
email,
userId,
teamId,
},
});
return;
}
if (teamId) {
const team = await prisma.team.findFirst({
where: {
@@ -149,6 +171,12 @@ export const verifyEmailSender = async (email: string, userId: number, teamId: n
select: {
id: true,
email: true,
secondaryEmails: {
select: {
email: true,
emailVerified: true,
},
},
},
},
},
@@ -166,9 +194,18 @@ export const verifyEmailSender = async (email: string, userId: number, teamId: n
throw new TRPCError({ code: "FORBIDDEN", message: "You are not a member of this team" });
}
const teamMemberEmail = team.members.filter((member) => member.user.email === email);
let foundTeamMember = team.members.find((member) => member.user.email === email);
if (teamMemberEmail) {
// Only check secondary emails if no match was found with primary email
if (!foundTeamMember) {
foundTeamMember = team.members.find((member) =>
member.user.secondaryEmails.some(
(secondary) => secondary.email === email && !!secondary.emailVerified
)
);
}
if (foundTeamMember) {
await prisma.verifiedEmail.create({
data: {
email,