Skip to content

[db][protocol] Implement TeamSubscription2 DB shapes and migration #9655

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 9, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions components/dashboard/src/settings/Plans.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Subscription,
UserPaidSubscription,
AssignedTeamSubscription,
AssignedTeamSubscription2,
CreditDescription,
} from "@gitpod/gitpod-protocol/lib/accounting-protocol";
import { PlanCoupon, GithubUpgradeURL } from "@gitpod/gitpod-protocol/lib/payment-protocol";
Expand Down Expand Up @@ -80,16 +81,18 @@ export default function () {
const paidSubscription = activeSubscriptions.find((s) => UserPaidSubscription.is(s));
const paidPlan = paidSubscription && Plans.getById(paidSubscription.planId);

const assignedTeamSubscriptions = activeSubscriptions.filter((s) => AssignedTeamSubscription.is(s));
const assignedTeamSubscriptions = activeSubscriptions.filter(
(s) => AssignedTeamSubscription.is(s) || AssignedTeamSubscription2.is(s),
);
const getAssignedTs = (type: PlanType) =>
assignedTeamSubscriptions.find((s) => {
const p = Plans.getById(s.planId);
return !!p && p.type === type;
});
const assignedProfessionalTs = getAssignedTs("professional-new");
const assignedUnleashedTs = getAssignedTs("professional");
const assignedStudentUnleashedTs = getAssignedTs("student");
const assignedTs = assignedProfessionalTs || assignedUnleashedTs || assignedStudentUnleashedTs;
const assignedProfessionalTs = getAssignedTs("professional-new");
const assignedTs = assignedUnleashedTs || assignedStudentUnleashedTs || assignedProfessionalTs;

const claimedTeamSubscriptionId = new URL(window.location.href).searchParams.get("teamid");
if (
Expand Down Expand Up @@ -674,7 +677,7 @@ export default function () {
)}
<p className="text-sm">
<a
className={`text-blue-light hover:underline" ${isChargebeeCustomer ? "" : "invisible"}`}
className={`gp-link ${isChargebeeCustomer ? "" : "invisible"}`}
href="javascript:void(0)"
onClick={() => {
ChargebeeClient.getOrCreate().then((chargebeeClient) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import { orderByEndDateDescThenStartDateDesc, orderByStartDateAscEndDateAsc } fr

/**
* This class maintains the following invariant on a given set of Subscriptions and over the offered operations:
* - Whenever a users paid (non-FREE) subscription starts: End his FREE subscription
* - For every period a user has non paid subscription: Grant him a FREE subscription
* - Whenever a users paid (non-FREE) subscription starts: End their FREE subscription
* - For every period a user has non paid subscription: Grant them a FREE subscription
*/
export class SubscriptionModel {
protected readonly result: SubscriptionModel.Result = SubscriptionModel.Result.create();
Expand Down Expand Up @@ -61,6 +61,14 @@ export class SubscriptionModel {
return subscriptionsForSlot.sort(orderByEndDateDescThenStartDateDesc)[0];
}

findSubscriptionByTeamMembershipId(teamMembershipId: string): Subscription | undefined {
const subscriptionsForMembership = this.subscriptions.filter(s => s.teamMembershipId === teamMembershipId);
if (subscriptionsForMembership.length === 0) {
return undefined;
}
return subscriptionsForMembership.sort(orderByEndDateDescThenStartDateDesc)[0];
}

getResult(): SubscriptionModel.Result {
return SubscriptionModel.Result.copy(this.result);
}
Expand Down
3 changes: 3 additions & 0 deletions components/gitpod-db/src/container-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ import { OssAllowListDB } from "./oss-allowlist-db";
import { OssAllowListDBImpl } from "./typeorm/oss-allowlist-db-impl";
import { TypeORMInstallationAdminImpl } from "./typeorm/installation-admin-db-impl";
import { InstallationAdminDB } from "./installation-admin-db";
import { TeamSubscription2DB } from "./team-subscription-2-db";
import { TeamSubscription2DBImpl } from "./typeorm/team-subscription-2-db-impl";

// THE DB container module that contains all DB implementations
export const dbContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => {
Expand Down Expand Up @@ -143,6 +145,7 @@ export const dbContainerModule = new ContainerModule((bind, unbind, isBound, reb
};
});
bind(TeamSubscriptionDB).to(TeamSubscriptionDBImpl).inSingletonScope();
bind(TeamSubscription2DB).to(TeamSubscription2DBImpl).inSingletonScope();
bind(EmailDomainFilterDB).to(EmailDomainFilterDBImpl).inSingletonScope();
bind(EduEmailDomainDB).to(EduEmailDomainDBImpl).inSingletonScope();
bind(EMailDB).to(TypeORMEMailDBImpl).inSingletonScope();
Expand Down
1 change: 1 addition & 0 deletions components/gitpod-db/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export * from "./pending-github-event-db";
export * from "./typeorm/typeorm";
export * from "./accounting-db";
export * from "./team-subscription-db";
export * from "./team-subscription-2-db";
export * from "./edu-email-domain-db";
export * from "./email-domain-filter-db";
export * from "./typeorm/entity/db-account-entry";
Expand Down
6 changes: 6 additions & 0 deletions components/gitpod-db/src/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,12 @@ export class GitpodTableDescriptionProvider implements TableDescriptionProvider
deletionColumn: "deleted",
timeColumn: "_lastModified",
},
{
name: "d_b_team_subscription2;",
primaryKeys: ["id"],
deletionColumn: "deleted",
timeColumn: "_lastModified",
},
/**
* BEWARE
*
Expand Down
3 changes: 3 additions & 0 deletions components/gitpod-db/src/team-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import { Team, TeamMemberInfo, TeamMemberRole, TeamMembershipInvite } from "@gitpod/gitpod-protocol";
import { DBTeamMembership } from "./typeorm/entity/db-team-membership";

export const TeamDB = Symbol("TeamDB");
export interface TeamDB {
Expand All @@ -17,11 +18,13 @@ export interface TeamDB {
): Promise<{ total: number; rows: Team[] }>;
findTeamById(teamId: string): Promise<Team | undefined>;
findMembersByTeam(teamId: string): Promise<TeamMemberInfo[]>;
findTeamMembership(userId: string, teamId: string): Promise<DBTeamMembership | undefined>;
findTeamsByUser(userId: string): Promise<Team[]>;
findTeamsByUserAsSoleOwner(userId: string): Promise<Team[]>;
createTeam(userId: string, name: string): Promise<Team>;
addMemberToTeam(userId: string, teamId: string): Promise<void>;
setTeamMemberRole(userId: string, teamId: string, role: TeamMemberRole): Promise<void>;
setTeamMemberSubscription(userId: string, teamId: string, subscriptionId: string): Promise<void>;
removeMemberFromTeam(userId: string, teamId: string): Promise<void>;
findTeamMembershipInviteById(inviteId: string): Promise<TeamMembershipInvite>;
findGenericInviteByTeamId(teamId: string): Promise<TeamMembershipInvite | undefined>;
Expand Down
17 changes: 17 additions & 0 deletions components/gitpod-db/src/team-subscription-2-db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
* Licensed under the Gitpod Enterprise Source Code License,
* See License.enterprise.txt in the project root folder.
*/

import { TeamSubscription2 } from "@gitpod/gitpod-protocol/lib/team-subscription-protocol";

export const TeamSubscription2DB = Symbol("TeamSubscription2DB");
export interface TeamSubscription2DB {
storeEntry(ts: TeamSubscription2): Promise<void>;
findById(id: string): Promise<TeamSubscription2 | undefined>;
findByPaymentRef(teamId: string, paymentReference: string): Promise<TeamSubscription2 | undefined>;
findForTeam(teamId: string, date: string): Promise<TeamSubscription2 | undefined>;

transaction<T>(code: (db: TeamSubscription2DB) => Promise<T>): Promise<T>;
}
1 change: 1 addition & 0 deletions components/gitpod-db/src/typeorm/deleted-entry-gc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const tables: TableWithDeletion[] = [
{ deletionColumn: "deleted", name: "d_b_project_env_var" },
{ deletionColumn: "deleted", name: "d_b_project_info" },
{ deletionColumn: "deleted", name: "d_b_project_usage" },
{ deletionColumn: "deleted", name: "d_b_team_subscription2" },
];

interface TableWithDeletion {
Expand Down
6 changes: 6 additions & 0 deletions components/gitpod-db/src/typeorm/entity/db-subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ export class DBSubscription implements Subscription {
@Index("ind_teamSubscriptionSlotId")
teamSubscriptionSlotId?: string;

@Column({
default: "",
transformer: Transformer.MAP_EMPTY_STR_TO_UNDEFINED,
})
teamMembershipId?: string;

@Column({
default: false,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import { TeamMemberRole } from "@gitpod/gitpod-protocol";
import { Entity, Column, PrimaryColumn, Index } from "typeorm";
import { Transformer } from "../transformer";
import { TypeORM } from "../typeorm";

@Entity()
Expand All @@ -28,6 +29,13 @@ export class DBTeamMembership {
@Column("varchar")
creationTime: string;

@Column({
...TypeORM.UUID_COLUMN_TYPE,
default: "",
transformer: Transformer.MAP_EMPTY_STR_TO_UNDEFINED,
})
subscriptionId?: string;

// This column triggers the db-sync deletion mechanism. It's not intended for public consumption.
@Column()
deleted: boolean;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
* Licensed under the Gitpod Enterprise Source Code License,
* See License.enterprise.txt in the project root folder.
*/

import { Entity, Column, PrimaryColumn, Index } from "typeorm";

import { TeamSubscription2 } from "@gitpod/gitpod-protocol/lib/team-subscription-protocol";

import { TypeORM } from "../../typeorm/typeorm";
import { Transformer } from "../../typeorm/transformer";

@Entity()
@Index("ind_team_paymentReference", ["teamId", "paymentReference"])
@Index("ind_team_startdate", ["teamId", "startDate"])
// on DB but not Typeorm: @Index("ind_lastModified", ["_lastModified"]) // DBSync
export class DBTeamSubscription2 implements TeamSubscription2 {
@PrimaryColumn("uuid")
id: string;

@Column(TypeORM.UUID_COLUMN_TYPE)
teamId: string;

@Column()
paymentReference: string;

@Column()
startDate: string;

@Column({
default: "",
transformer: Transformer.MAP_EMPTY_STR_TO_UNDEFINED,
})
endDate?: string;

@Column()
planId: string;

@Column("int")
quantity: number;

@Column({
default: "",
transformer: Transformer.MAP_EMPTY_STR_TO_UNDEFINED,
})
cancellationDate?: string;

// This column triggers the db-sync deletion mechanism. It's not intended for public consumption.
@Column()
deleted: boolean;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License-AGPL.txt in the project root for license information.
*/

import { MigrationInterface, QueryRunner } from "typeorm";
import { columnExists, tableExists } from "./helper/helper";

export class TeamSubscrition21650526577994 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"CREATE TABLE IF NOT EXISTS `d_b_team_subscription2` (`id` char(36) NOT NULL, `teamId` char(36) NOT NULL, `paymentReference` varchar(255) NOT NULL, `startDate` varchar(255) NOT NULL, `endDate` varchar(255) NOT NULL DEFAULT '', `planId` varchar(255) NOT NULL, `quantity` int(11) NOT NULL, `cancellationDate` varchar(255) NOT NULL DEFAULT '', `deleted` tinyint(4) NOT NULL DEFAULT '0', `_lastModified` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`), KEY `ind_team_paymentReference` (`teamId`, `paymentReference`), KEY `ind_team_startDate` (`teamId`, `startDate`), KEY `ind_dbsync` (`_lastModified`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
);
if (!(await columnExists(queryRunner, "d_b_subscription", "teamMembershipId"))) {
await queryRunner.query(
"ALTER TABLE `d_b_subscription` ADD COLUMN `teamMembershipId` char(36) NOT NULL DEFAULT ''",
);
}
if (!(await columnExists(queryRunner, "d_b_team_membership", "subscriptionId"))) {
await queryRunner.query(
"ALTER TABLE `d_b_team_membership` ADD COLUMN `subscriptionId` char(36) NOT NULL DEFAULT ''",
);
}
}

public async down(queryRunner: QueryRunner): Promise<void> {
if (await tableExists(queryRunner, "d_b_team_subscription2")) {
await queryRunner.query("DROP TABLE `d_b_team_subscription2`");
}
if (await columnExists(queryRunner, "d_b_subscription", "teamMembershipId")) {
await queryRunner.query("ALTER TABLE `d_b_subscription` DROP COLUMN `teamMembershipId`");
}
if (await columnExists(queryRunner, "d_b_team_membership", "subscriptionId")) {
await queryRunner.query("ALTER TABLE `d_b_team_membership` DROP COLUMN `subscriptionId`");
}
}
}
20 changes: 20 additions & 0 deletions components/gitpod-db/src/typeorm/team-db-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ export class TeamDBImpl implements TeamDB {
return infos.sort((a, b) => (a.memberSince < b.memberSince ? 1 : a.memberSince === b.memberSince ? 0 : -1));
}

public async findTeamMembership(userId: string, teamId: string): Promise<DBTeamMembership | undefined> {
const membershipRepo = await this.getMembershipRepo();
return membershipRepo.findOne({ userId, teamId, deleted: false });
}

public async findTeamsByUser(userId: string): Promise<Team[]> {
const teamRepo = await this.getTeamRepo();
const membershipRepo = await this.getMembershipRepo();
Expand Down Expand Up @@ -192,6 +197,21 @@ export class TeamDBImpl implements TeamDB {
await membershipRepo.save(membership);
}

public async setTeamMemberSubscription(userId: string, teamId: string, subscriptionId: string): Promise<void> {
const teamRepo = await this.getTeamRepo();
const team = await teamRepo.findOne(teamId);
if (!team || !!team.deleted) {
throw new Error("A team with this ID could not be found");
}
const membershipRepo = await this.getMembershipRepo();
const membership = await membershipRepo.findOne({ teamId, userId, deleted: false });
if (!membership) {
throw new Error("The user is not currently a member of this team");
}
membership.subscriptionId = subscriptionId;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from #9655 I can read that the subscription already knows the membership. why storing this link in both directions?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Let's use this as a thread in here.)

For example, when you leave a team, the membership is automatically deleted (leaving no trace), but the subscription is merely cancelled at end of term. This leaves a trace (e.g. for users wondering why they have unlimited hours without a paid subscription, we could see that the still-active subscription came from a team membership that has since been deleted).

So, what is the DBTeamMembership.subscriptionId need for then? I couldn't spot any usage.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure! So, while this direction of the link is indeed not used in the current code, it was requested in the RFC that this link be materialized in both directions:

  • We add a 0..1 reference from d_b_team_member[ship] to d_b_subscription and the opposite reference in analogy to the reference to d_b_team_subscription_slot.

Source (internal)

And, this made sense to me at the time, because the link between d_b_team_subscription_slot and d_b_subscription also went both ways.

As mentioned above, this slight duplication has helped/saved us a few times, for example in customer requests, or to investigate recover from bugs.

Are you proposing to drop the reference from d_b_team_membership to d_b_subscription now?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's keep this model and quickly move on to Usage-Based / Pay-As-You-Go (which will hopefully deprecate all these subscriptions anyway)

Decision (internal Slack)

await membershipRepo.save(membership);
}

public async removeMemberFromTeam(userId: string, teamId: string): Promise<void> {
const teamRepo = await this.getTeamRepo();
const team = await teamRepo.findOne(teamId);
Expand Down
73 changes: 73 additions & 0 deletions components/gitpod-db/src/typeorm/team-subscription-2-db-impl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
* Licensed under the Gitpod Enterprise Source Code License,
* See License.enterprise.txt in the project root folder.
*/

import { injectable, inject } from "inversify";
import { EntityManager, Repository } from "typeorm";

import { TeamSubscription2 } from "@gitpod/gitpod-protocol/lib/team-subscription-protocol";

import { TeamSubscription2DB } from "../team-subscription-2-db";
import { DBTeamSubscription2 } from "./entity/db-team-subscription-2";
import { TypeORM } from "./typeorm";

@injectable()
export class TeamSubscription2DBImpl implements TeamSubscription2DB {
@inject(TypeORM) protected readonly typeORM: TypeORM;

async transaction<T>(code: (db: TeamSubscription2DB) => Promise<T>): Promise<T> {
const manager = await this.getEntityManager();
return await manager.transaction(async (manager) => {
return await code(new TransactionalTeamSubscription2DBImpl(manager));
});
}

protected async getEntityManager() {
return (await this.typeORM.getConnection()).manager;
}

protected async getRepo(): Promise<Repository<DBTeamSubscription2>> {
return (await this.getEntityManager()).getRepository(DBTeamSubscription2);
}

/**
* Team Subscriptions 2
*/

async storeEntry(ts: TeamSubscription2): Promise<void> {
const repo = await this.getRepo();
await repo.save(ts);
}

async findById(id: string): Promise<TeamSubscription2 | undefined> {
const repo = await this.getRepo();
return repo.findOne(id);
}

async findByPaymentRef(teamId: string, paymentReference: string): Promise<TeamSubscription2 | undefined> {
const repo = await this.getRepo();
return repo.findOne({ teamId, paymentReference });
}

async findForTeam(teamId: string, date: string): Promise<TeamSubscription2 | undefined> {
const repo = await this.getRepo();
const query = repo
.createQueryBuilder("ts2")
.where("ts2.teamId = :teamId", { teamId })
.andWhere("ts2.startDate <= :date", { date })
.andWhere('ts2.endDate = "" OR ts2.endDate > :date', { date });
return query.getOne();
}
}

export class TransactionalTeamSubscription2DBImpl extends TeamSubscription2DBImpl {
constructor(protected readonly manager: EntityManager) {
super();
}

async getEntityManager(): Promise<EntityManager> {
return this.manager;
}
}
Loading