Skip to content

Commit ac58a8b

Browse files
committed
[db][protocol] Implement TeamSubscription2 DB shapes and migration
1 parent f1c142b commit ac58a8b

16 files changed

+278
-6
lines changed

components/dashboard/src/settings/Plans.tsx

+7-4
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
Subscription,
1111
UserPaidSubscription,
1212
AssignedTeamSubscription,
13+
AssignedTeamSubscription2,
1314
CreditDescription,
1415
} from "@gitpod/gitpod-protocol/lib/accounting-protocol";
1516
import { PlanCoupon, GithubUpgradeURL } from "@gitpod/gitpod-protocol/lib/payment-protocol";
@@ -80,16 +81,18 @@ export default function () {
8081
const paidSubscription = activeSubscriptions.find((s) => UserPaidSubscription.is(s));
8182
const paidPlan = paidSubscription && Plans.getById(paidSubscription.planId);
8283

83-
const assignedTeamSubscriptions = activeSubscriptions.filter((s) => AssignedTeamSubscription.is(s));
84+
const assignedTeamSubscriptions = activeSubscriptions.filter(
85+
(s) => AssignedTeamSubscription.is(s) || AssignedTeamSubscription2.is(s),
86+
);
8487
const getAssignedTs = (type: PlanType) =>
8588
assignedTeamSubscriptions.find((s) => {
8689
const p = Plans.getById(s.planId);
8790
return !!p && p.type === type;
8891
});
89-
const assignedProfessionalTs = getAssignedTs("professional-new");
9092
const assignedUnleashedTs = getAssignedTs("professional");
9193
const assignedStudentUnleashedTs = getAssignedTs("student");
92-
const assignedTs = assignedProfessionalTs || assignedUnleashedTs || assignedStudentUnleashedTs;
94+
const assignedProfessionalTs = getAssignedTs("professional-new");
95+
const assignedTs = assignedUnleashedTs || assignedStudentUnleashedTs || assignedProfessionalTs;
9396

9497
const claimedTeamSubscriptionId = new URL(window.location.href).searchParams.get("teamid");
9598
if (
@@ -674,7 +677,7 @@ export default function () {
674677
)}
675678
<p className="text-sm">
676679
<a
677-
className={`text-blue-light hover:underline" ${isChargebeeCustomer ? "" : "invisible"}`}
680+
className={`gp-link ${isChargebeeCustomer ? "" : "invisible"}`}
678681
href="javascript:void(0)"
679682
onClick={() => {
680683
ChargebeeClient.getOrCreate().then((chargebeeClient) =>

components/ee/payment-endpoint/src/accounting/subscription-model.ts

+10-2
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import { orderByEndDateDescThenStartDateDesc, orderByStartDateAscEndDateAsc } fr
1010

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

64+
findSubscriptionByTeamMembershipId(teamMembershipId: string): Subscription | undefined {
65+
const subscriptionsForMembership = this.subscriptions.filter(s => s.teamMembershipId === teamMembershipId);
66+
if (subscriptionsForMembership.length === 0) {
67+
return undefined;
68+
}
69+
return subscriptionsForMembership.sort(orderByEndDateDescThenStartDateDesc)[0];
70+
}
71+
6472
getResult(): SubscriptionModel.Result {
6573
return SubscriptionModel.Result.copy(this.result);
6674
}

components/gitpod-db/src/container-module.ts

+3
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ import { OssAllowListDB } from "./oss-allowlist-db";
6262
import { OssAllowListDBImpl } from "./typeorm/oss-allowlist-db-impl";
6363
import { TypeORMInstallationAdminImpl } from "./typeorm/installation-admin-db-impl";
6464
import { InstallationAdminDB } from "./installation-admin-db";
65+
import { TeamSubscription2DB } from "./team-subscription-2-db";
66+
import { TeamSubscription2DBImpl } from "./typeorm/team-subscription-2-db-impl";
6567

6668
// THE DB container module that contains all DB implementations
6769
export const dbContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => {
@@ -143,6 +145,7 @@ export const dbContainerModule = new ContainerModule((bind, unbind, isBound, reb
143145
};
144146
});
145147
bind(TeamSubscriptionDB).to(TeamSubscriptionDBImpl).inSingletonScope();
148+
bind(TeamSubscription2DB).to(TeamSubscription2DBImpl).inSingletonScope();
146149
bind(EmailDomainFilterDB).to(EmailDomainFilterDBImpl).inSingletonScope();
147150
bind(EduEmailDomainDB).to(EduEmailDomainDBImpl).inSingletonScope();
148151
bind(EMailDB).to(TypeORMEMailDBImpl).inSingletonScope();

components/gitpod-db/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export * from "./pending-github-event-db";
3232
export * from "./typeorm/typeorm";
3333
export * from "./accounting-db";
3434
export * from "./team-subscription-db";
35+
export * from "./team-subscription-2-db";
3536
export * from "./edu-email-domain-db";
3637
export * from "./email-domain-filter-db";
3738
export * from "./typeorm/entity/db-account-entry";

components/gitpod-db/src/tables.ts

+6
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,12 @@ export class GitpodTableDescriptionProvider implements TableDescriptionProvider
262262
deletionColumn: "deleted",
263263
timeColumn: "_lastModified",
264264
},
265+
{
266+
name: "d_b_team_subscription2;",
267+
primaryKeys: ["id"],
268+
deletionColumn: "deleted",
269+
timeColumn: "_lastModified",
270+
},
265271
/**
266272
* BEWARE
267273
*

components/gitpod-db/src/team-db.ts

+3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

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

910
export const TeamDB = Symbol("TeamDB");
1011
export interface TeamDB {
@@ -17,11 +18,13 @@ export interface TeamDB {
1718
): Promise<{ total: number; rows: Team[] }>;
1819
findTeamById(teamId: string): Promise<Team | undefined>;
1920
findMembersByTeam(teamId: string): Promise<TeamMemberInfo[]>;
21+
findTeamMembership(userId: string, teamId: string): Promise<DBTeamMembership | undefined>;
2022
findTeamsByUser(userId: string): Promise<Team[]>;
2123
findTeamsByUserAsSoleOwner(userId: string): Promise<Team[]>;
2224
createTeam(userId: string, name: string): Promise<Team>;
2325
addMemberToTeam(userId: string, teamId: string): Promise<void>;
2426
setTeamMemberRole(userId: string, teamId: string, role: TeamMemberRole): Promise<void>;
27+
setTeamMemberSubscription(userId: string, teamId: string, subscriptionId: string): Promise<void>;
2528
removeMemberFromTeam(userId: string, teamId: string): Promise<void>;
2629
findTeamMembershipInviteById(inviteId: string): Promise<TeamMembershipInvite>;
2730
findGenericInviteByTeamId(teamId: string): Promise<TeamMembershipInvite | undefined>;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
3+
* Licensed under the Gitpod Enterprise Source Code License,
4+
* See License.enterprise.txt in the project root folder.
5+
*/
6+
7+
import { TeamSubscription2 } from "@gitpod/gitpod-protocol/lib/team-subscription-protocol";
8+
9+
export const TeamSubscription2DB = Symbol("TeamSubscription2DB");
10+
export interface TeamSubscription2DB {
11+
storeEntry(ts: TeamSubscription2): Promise<void>;
12+
findById(id: string): Promise<TeamSubscription2 | undefined>;
13+
findByPaymentRef(teamId: string, paymentReference: string): Promise<TeamSubscription2 | undefined>;
14+
findForTeam(teamId: string, date: string): Promise<TeamSubscription2 | undefined>;
15+
16+
transaction<T>(code: (db: TeamSubscription2DB) => Promise<T>): Promise<T>;
17+
}

components/gitpod-db/src/typeorm/deleted-entry-gc.ts

+1
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ const tables: TableWithDeletion[] = [
6262
{ deletionColumn: "deleted", name: "d_b_project_env_var" },
6363
{ deletionColumn: "deleted", name: "d_b_project_info" },
6464
{ deletionColumn: "deleted", name: "d_b_project_usage" },
65+
{ deletionColumn: "deleted", name: "d_b_team_subscription2" },
6566
];
6667

6768
interface TableWithDeletion {

components/gitpod-db/src/typeorm/entity/db-subscription.ts

+6
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ export class DBSubscription implements Subscription {
6060
@Index("ind_teamSubscriptionSlotId")
6161
teamSubscriptionSlotId?: string;
6262

63+
@Column({
64+
default: "",
65+
transformer: Transformer.MAP_EMPTY_STR_TO_UNDEFINED,
66+
})
67+
teamMembershipId?: string;
68+
6369
@Column({
6470
default: false,
6571
})

components/gitpod-db/src/typeorm/entity/db-team-membership.ts

+8
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import { TeamMemberRole } from "@gitpod/gitpod-protocol";
88
import { Entity, Column, PrimaryColumn, Index } from "typeorm";
9+
import { Transformer } from "../transformer";
910
import { TypeORM } from "../typeorm";
1011

1112
@Entity()
@@ -28,6 +29,13 @@ export class DBTeamMembership {
2829
@Column("varchar")
2930
creationTime: string;
3031

32+
@Column({
33+
...TypeORM.UUID_COLUMN_TYPE,
34+
default: "",
35+
transformer: Transformer.MAP_EMPTY_STR_TO_UNDEFINED,
36+
})
37+
subscriptionId?: string;
38+
3139
// This column triggers the db-sync deletion mechanism. It's not intended for public consumption.
3240
@Column()
3341
deleted: boolean;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
3+
* Licensed under the Gitpod Enterprise Source Code License,
4+
* See License.enterprise.txt in the project root folder.
5+
*/
6+
7+
import { Entity, Column, PrimaryColumn, Index } from "typeorm";
8+
9+
import { TeamSubscription2 } from "@gitpod/gitpod-protocol/lib/team-subscription-protocol";
10+
11+
import { TypeORM } from "../../typeorm/typeorm";
12+
import { Transformer } from "../../typeorm/transformer";
13+
14+
@Entity()
15+
@Index("ind_team_paymentReference", ["teamId", "paymentReference"])
16+
@Index("ind_team_startdate", ["teamId", "startDate"])
17+
// on DB but not Typeorm: @Index("ind_lastModified", ["_lastModified"]) // DBSync
18+
export class DBTeamSubscription2 implements TeamSubscription2 {
19+
@PrimaryColumn("uuid")
20+
id: string;
21+
22+
@Column(TypeORM.UUID_COLUMN_TYPE)
23+
teamId: string;
24+
25+
@Column()
26+
paymentReference: string;
27+
28+
@Column()
29+
startDate: string;
30+
31+
@Column({
32+
default: "",
33+
transformer: Transformer.MAP_EMPTY_STR_TO_UNDEFINED,
34+
})
35+
endDate?: string;
36+
37+
@Column()
38+
planId: string;
39+
40+
@Column("int")
41+
quantity: number;
42+
43+
@Column({
44+
default: "",
45+
transformer: Transformer.MAP_EMPTY_STR_TO_UNDEFINED,
46+
})
47+
cancellationDate?: string;
48+
49+
// This column triggers the db-sync deletion mechanism. It's not intended for public consumption.
50+
@Column()
51+
deleted: boolean;
52+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License-AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { MigrationInterface, QueryRunner } from "typeorm";
8+
import { columnExists, tableExists } from "./helper/helper";
9+
10+
export class TeamSubscrition21650526577994 implements MigrationInterface {
11+
public async up(queryRunner: QueryRunner): Promise<void> {
12+
await queryRunner.query(
13+
"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;",
14+
);
15+
if (!(await columnExists(queryRunner, "d_b_subscription", "teamMembershipId"))) {
16+
await queryRunner.query(
17+
"ALTER TABLE `d_b_subscription` ADD COLUMN `teamMembershipId` char(36) NOT NULL DEFAULT ''",
18+
);
19+
}
20+
if (!(await columnExists(queryRunner, "d_b_team_membership", "subscriptionId"))) {
21+
await queryRunner.query(
22+
"ALTER TABLE `d_b_team_membership` ADD COLUMN `subscriptionId` char(36) NOT NULL DEFAULT ''",
23+
);
24+
}
25+
}
26+
27+
public async down(queryRunner: QueryRunner): Promise<void> {
28+
if (await tableExists(queryRunner, "d_b_team_subscription2")) {
29+
await queryRunner.query("DROP TABLE `d_b_team_subscription2`");
30+
}
31+
if (await columnExists(queryRunner, "d_b_subscription", "teamMembershipId")) {
32+
await queryRunner.query("ALTER TABLE `d_b_subscription` DROP COLUMN `teamMembershipId`");
33+
}
34+
if (await columnExists(queryRunner, "d_b_team_membership", "subscriptionId")) {
35+
await queryRunner.query("ALTER TABLE `d_b_team_membership` DROP COLUMN `subscriptionId`");
36+
}
37+
}
38+
}

components/gitpod-db/src/typeorm/team-db-impl.ts

+20
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ export class TeamDBImpl implements TeamDB {
8383
return infos.sort((a, b) => (a.memberSince < b.memberSince ? 1 : a.memberSince === b.memberSince ? 0 : -1));
8484
}
8585

86+
public async findTeamMembership(userId: string, teamId: string): Promise<DBTeamMembership | undefined> {
87+
const membershipRepo = await this.getMembershipRepo();
88+
return membershipRepo.findOne({ userId, teamId, deleted: false });
89+
}
90+
8691
public async findTeamsByUser(userId: string): Promise<Team[]> {
8792
const teamRepo = await this.getTeamRepo();
8893
const membershipRepo = await this.getMembershipRepo();
@@ -192,6 +197,21 @@ export class TeamDBImpl implements TeamDB {
192197
await membershipRepo.save(membership);
193198
}
194199

200+
public async setTeamMemberSubscription(userId: string, teamId: string, subscriptionId: string): Promise<void> {
201+
const teamRepo = await this.getTeamRepo();
202+
const team = await teamRepo.findOne(teamId);
203+
if (!team || !!team.deleted) {
204+
throw new Error("A team with this ID could not be found");
205+
}
206+
const membershipRepo = await this.getMembershipRepo();
207+
const membership = await membershipRepo.findOne({ teamId, userId, deleted: false });
208+
if (!membership) {
209+
throw new Error("The user is not currently a member of this team");
210+
}
211+
membership.subscriptionId = subscriptionId;
212+
await membershipRepo.save(membership);
213+
}
214+
195215
public async removeMemberFromTeam(userId: string, teamId: string): Promise<void> {
196216
const teamRepo = await this.getTeamRepo();
197217
const team = await teamRepo.findOne(teamId);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
3+
* Licensed under the Gitpod Enterprise Source Code License,
4+
* See License.enterprise.txt in the project root folder.
5+
*/
6+
7+
import { injectable, inject } from "inversify";
8+
import { EntityManager, Repository } from "typeorm";
9+
10+
import { TeamSubscription2 } from "@gitpod/gitpod-protocol/lib/team-subscription-protocol";
11+
12+
import { TeamSubscription2DB } from "../team-subscription-2-db";
13+
import { DBTeamSubscription2 } from "./entity/db-team-subscription-2";
14+
import { TypeORM } from "./typeorm";
15+
16+
@injectable()
17+
export class TeamSubscription2DBImpl implements TeamSubscription2DB {
18+
@inject(TypeORM) protected readonly typeORM: TypeORM;
19+
20+
async transaction<T>(code: (db: TeamSubscription2DB) => Promise<T>): Promise<T> {
21+
const manager = await this.getEntityManager();
22+
return await manager.transaction(async (manager) => {
23+
return await code(new TransactionalTeamSubscription2DBImpl(manager));
24+
});
25+
}
26+
27+
protected async getEntityManager() {
28+
return (await this.typeORM.getConnection()).manager;
29+
}
30+
31+
protected async getRepo(): Promise<Repository<DBTeamSubscription2>> {
32+
return (await this.getEntityManager()).getRepository(DBTeamSubscription2);
33+
}
34+
35+
/**
36+
* Team Subscriptions 2
37+
*/
38+
39+
async storeEntry(ts: TeamSubscription2): Promise<void> {
40+
const repo = await this.getRepo();
41+
await repo.save(ts);
42+
}
43+
44+
async findById(id: string): Promise<TeamSubscription2 | undefined> {
45+
const repo = await this.getRepo();
46+
return repo.findOne(id);
47+
}
48+
49+
async findByPaymentRef(teamId: string, paymentReference: string): Promise<TeamSubscription2 | undefined> {
50+
const repo = await this.getRepo();
51+
return repo.findOne({ teamId, paymentReference });
52+
}
53+
54+
async findForTeam(teamId: string, date: string): Promise<TeamSubscription2 | undefined> {
55+
const repo = await this.getRepo();
56+
const query = repo
57+
.createQueryBuilder("ts2")
58+
.where("ts2.teamId = :teamId", { teamId })
59+
.andWhere("ts2.startDate <= :date", { date })
60+
.andWhere('ts2.endDate = "" OR ts2.endDate > :date', { date });
61+
return query.getOne();
62+
}
63+
}
64+
65+
export class TransactionalTeamSubscription2DBImpl extends TeamSubscription2DBImpl {
66+
constructor(protected readonly manager: EntityManager) {
67+
super();
68+
}
69+
70+
async getEntityManager(): Promise<EntityManager> {
71+
return this.manager;
72+
}
73+
}

0 commit comments

Comments
 (0)