* 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>
322 lines
9.4 KiB
TypeScript
322 lines
9.4 KiB
TypeScript
import { Prisma } from "@prisma/client";
|
|
import type { z } from "zod";
|
|
|
|
import { whereClauseForOrgWithSlugOrRequestedSlug } from "@calcom/ee/organizations/lib/orgDomains";
|
|
import { TeamBilling } from "@calcom/features/ee/billing/teams";
|
|
import removeMember from "@calcom/features/ee/teams/lib/removeMember";
|
|
import { deleteDomain } from "@calcom/lib/domainManager/organization";
|
|
import logger from "@calcom/lib/logger";
|
|
import prisma from "@calcom/prisma";
|
|
import { MembershipRole } from "@calcom/prisma/enums";
|
|
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
|
|
|
import { TRPCError } from "@trpc/server";
|
|
|
|
import { WorkflowService } from "../service/workflows";
|
|
import { getParsedTeam } from "./teamUtils";
|
|
|
|
type TeamGetPayloadWithParsedMetadata<TeamSelect extends Prisma.TeamSelect> =
|
|
| (Omit<Prisma.TeamGetPayload<{ select: TeamSelect }>, "metadata" | "isOrganization"> & {
|
|
metadata: z.infer<typeof teamMetadataSchema>;
|
|
isOrganization: boolean;
|
|
})
|
|
| null;
|
|
|
|
type GetTeamOrOrgArg<TeamSelect extends Prisma.TeamSelect> = {
|
|
lookupBy: (
|
|
| {
|
|
id: number;
|
|
}
|
|
| {
|
|
slug: string;
|
|
}
|
|
) & {
|
|
havingMemberWithId?: number;
|
|
};
|
|
/**
|
|
* If we are fetching a team, this is the slug of the organization that the team belongs to.
|
|
*/
|
|
forOrgWithSlug: string | null;
|
|
/**
|
|
* If true, means that we need to fetch an organization with the given slug. Otherwise, we need to fetch a team with the given slug.
|
|
*/
|
|
isOrg: boolean;
|
|
teamSelect: TeamSelect;
|
|
};
|
|
|
|
const log = logger.getSubLogger({ prefix: ["repository", "team"] });
|
|
|
|
/**
|
|
* Gets the team or organization with the given slug or id reliably along with parsed metadata.
|
|
*/
|
|
async function getTeamOrOrg<TeamSelect extends Prisma.TeamSelect>({
|
|
lookupBy,
|
|
forOrgWithSlug: forOrgWithSlug,
|
|
isOrg,
|
|
teamSelect,
|
|
}: GetTeamOrOrgArg<TeamSelect>): Promise<TeamGetPayloadWithParsedMetadata<TeamSelect>> {
|
|
const where: Prisma.TeamFindFirstArgs["where"] = {};
|
|
teamSelect = {
|
|
...teamSelect,
|
|
metadata: true,
|
|
isOrganization: true,
|
|
} satisfies TeamSelect;
|
|
if (lookupBy.havingMemberWithId) where.members = { some: { userId: lookupBy.havingMemberWithId } };
|
|
|
|
if ("id" in lookupBy) {
|
|
where.id = lookupBy.id;
|
|
} else {
|
|
where.slug = lookupBy.slug;
|
|
}
|
|
|
|
if (isOrg) {
|
|
// We must fetch only the organization here.
|
|
// Note that an organization and a team that doesn't belong to an organization, both have parentId null
|
|
// If the organization has null slug(but requestedSlug is 'test') and the team also has slug 'test', we can't distinguish them without explicitly checking the metadata.isOrganization
|
|
// Note that, this isn't possible now to have same requestedSlug as the slug of a team not part of an organization. This is legacy teams handling mostly. But it is still safer to be sure that you are fetching an Organization only in case of isOrgView
|
|
where.isOrganization = true;
|
|
// We must fetch only the team here.
|
|
} else {
|
|
if (forOrgWithSlug) {
|
|
where.parent = whereClauseForOrgWithSlugOrRequestedSlug(forOrgWithSlug);
|
|
}
|
|
}
|
|
|
|
log.debug({
|
|
orgSlug: forOrgWithSlug,
|
|
teamLookupBy: lookupBy,
|
|
isOrgView: isOrg,
|
|
where,
|
|
});
|
|
|
|
const teams = await prisma.team.findMany({
|
|
where,
|
|
select: teamSelect,
|
|
});
|
|
|
|
const teamsWithParsedMetadata = teams
|
|
.map((team) => {
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore ts types are way too complciated for this now
|
|
const parsedMetadata = teamMetadataSchema.parse(team.metadata ?? {});
|
|
return {
|
|
...team,
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore It does exist
|
|
isOrganization: team.isOrganization as boolean,
|
|
metadata: parsedMetadata,
|
|
};
|
|
})
|
|
// In cases where there are many teams with the same slug, we need to find out the one and only one that matches our criteria
|
|
.filter((team) => {
|
|
// We need an org if isOrgView otherwise we need a team
|
|
return isOrg ? team.isOrganization : !team.isOrganization;
|
|
});
|
|
|
|
if (teamsWithParsedMetadata.length > 1) {
|
|
log.error("Found more than one team/Org. We should be doing something wrong.", {
|
|
isOrgView: isOrg,
|
|
where,
|
|
teams: teamsWithParsedMetadata.map((team) => {
|
|
const t = team as unknown as { id: number; slug: string };
|
|
return {
|
|
id: t.id,
|
|
slug: t.slug,
|
|
};
|
|
}),
|
|
});
|
|
}
|
|
|
|
const team = teamsWithParsedMetadata[0];
|
|
if (!team) return null;
|
|
// HACK: I am not sure how to make Prisma in peace with TypeScript with this repository pattern
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
return team as any;
|
|
}
|
|
|
|
export async function getTeam<TeamSelect extends Prisma.TeamSelect>({
|
|
lookupBy,
|
|
forOrgWithSlug: forOrgWithSlug,
|
|
teamSelect,
|
|
}: Omit<GetTeamOrOrgArg<TeamSelect>, "isOrg">): Promise<TeamGetPayloadWithParsedMetadata<TeamSelect>> {
|
|
return getTeamOrOrg({
|
|
lookupBy,
|
|
forOrgWithSlug: forOrgWithSlug,
|
|
isOrg: false,
|
|
teamSelect,
|
|
});
|
|
}
|
|
|
|
export async function getOrg<TeamSelect extends Prisma.TeamSelect>({
|
|
lookupBy,
|
|
forOrgWithSlug: forOrgWithSlug,
|
|
teamSelect,
|
|
}: Omit<GetTeamOrOrgArg<TeamSelect>, "isOrg">): Promise<TeamGetPayloadWithParsedMetadata<TeamSelect>> {
|
|
return getTeamOrOrg({
|
|
lookupBy,
|
|
forOrgWithSlug: forOrgWithSlug,
|
|
isOrg: true,
|
|
teamSelect,
|
|
});
|
|
}
|
|
|
|
const teamSelect = Prisma.validator<Prisma.TeamSelect>()({
|
|
id: true,
|
|
name: true,
|
|
slug: true,
|
|
logoUrl: true,
|
|
parentId: true,
|
|
metadata: true,
|
|
isOrganization: true,
|
|
organizationSettings: true,
|
|
isPlatform: true,
|
|
});
|
|
|
|
export class TeamRepository {
|
|
static async findById({ id }: { id: number }) {
|
|
const team = await prisma.team.findUnique({
|
|
where: {
|
|
id,
|
|
},
|
|
select: teamSelect,
|
|
});
|
|
if (!team) {
|
|
return null;
|
|
}
|
|
return getParsedTeam(team);
|
|
}
|
|
|
|
static async deleteById({ id }: { id: number }) {
|
|
try {
|
|
await WorkflowService.deleteWorkflowRemindersOfRemovedTeam(id);
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
|
|
const deletedTeam = await prisma.$transaction(async (tx) => {
|
|
await tx.eventType.deleteMany({
|
|
where: {
|
|
teamId: id,
|
|
schedulingType: "MANAGED",
|
|
},
|
|
});
|
|
|
|
// delete all memberships
|
|
await tx.membership.deleteMany({
|
|
where: {
|
|
teamId: id,
|
|
},
|
|
});
|
|
|
|
const deletedTeam = await tx.team.delete({
|
|
where: {
|
|
id: id,
|
|
},
|
|
});
|
|
|
|
const teamBilling = await TeamBilling.findAndInit(id);
|
|
await teamBilling.cancel();
|
|
return deletedTeam;
|
|
});
|
|
|
|
if (deletedTeam?.isOrganization && deletedTeam.slug) deleteDomain(deletedTeam.slug);
|
|
|
|
return deletedTeam;
|
|
}
|
|
|
|
// TODO: Move errors away from TRPC error to make it more generic
|
|
static async inviteMemberByToken(token: string, userId: number) {
|
|
const verificationToken = await prisma.verificationToken.findFirst({
|
|
where: {
|
|
token,
|
|
OR: [{ expiresInDays: null }, { expires: { gte: new Date() } }],
|
|
},
|
|
include: {
|
|
team: {
|
|
select: {
|
|
name: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!verificationToken) throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" });
|
|
if (!verificationToken.teamId || !verificationToken.team)
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "Invite token is not associated with any team",
|
|
});
|
|
|
|
try {
|
|
await prisma.membership.create({
|
|
data: {
|
|
createdAt: new Date(),
|
|
teamId: verificationToken.teamId,
|
|
userId: userId,
|
|
role: MembershipRole.MEMBER,
|
|
accepted: false,
|
|
},
|
|
});
|
|
} catch (e) {
|
|
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
|
if (e.code === "P2002") {
|
|
throw new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message: "This user is a member of this team / has a pending invitation.",
|
|
});
|
|
}
|
|
} else throw e;
|
|
}
|
|
|
|
const teamBilling = await TeamBilling.findAndInit(verificationToken.teamId);
|
|
await teamBilling.updateQuantity();
|
|
|
|
return verificationToken.team.name;
|
|
}
|
|
|
|
static async publish(teamId: number) {
|
|
const teamBilling = await TeamBilling.findAndInit(teamId);
|
|
return teamBilling.publish();
|
|
}
|
|
|
|
static async removeMembers(teamIds: number[], memberIds: number[], isOrg = false) {
|
|
const deleteMembershipPromises = [];
|
|
|
|
for (const memberId of memberIds) {
|
|
for (const teamId of teamIds) {
|
|
deleteMembershipPromises.push(
|
|
// This removeMember function is from @calcom/features/ee/teams/lib/removeMember.ts we should probably move it to this repository.
|
|
removeMember({
|
|
teamId,
|
|
memberId,
|
|
isOrg,
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
await Promise.all(deleteMembershipPromises);
|
|
|
|
const teamsBilling = await TeamBilling.findAndInitMany(teamIds);
|
|
const teamBillingPromises = teamsBilling.map((teamBilling) => teamBilling.updateQuantity());
|
|
await Promise.allSettled(teamBillingPromises);
|
|
}
|
|
|
|
static async findTeamWithMembers(teamId: number) {
|
|
return await prisma.team.findUnique({
|
|
where: { id: teamId },
|
|
select: {
|
|
members: {
|
|
select: {
|
|
accepted: true,
|
|
},
|
|
},
|
|
id: true,
|
|
metadata: true,
|
|
parentId: true,
|
|
isOrganization: true,
|
|
},
|
|
});
|
|
}
|
|
}
|