Skip to content

Commit

Permalink
feat(server): support selfhost licenses
Browse files Browse the repository at this point in the history
  • Loading branch information
forehalo committed Dec 4, 2024
1 parent a29fb66 commit d2b66cf
Show file tree
Hide file tree
Showing 17 changed files with 873 additions and 41 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- CreateTable
CREATE TABLE "licenses" (
"key" VARCHAR NOT NULL,
"installed_at" TIMESTAMPTZ(3) DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "licenses_pkey" PRIMARY KEY ("key")
);

-- CreateTable
CREATE TABLE "installed_licenses" (
"key" VARCHAR NOT NULL,
"workspace_id" VARCHAR NOT NULL,
"installed_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revalidated_at" TIMESTAMPTZ(3),

CONSTRAINT "installed_licenses_pkey" PRIMARY KEY ("key")
);

-- CreateIndex
CREATE UNIQUE INDEX "installed_licenses_workspace_id_key" ON "installed_licenses"("workspace_id");
16 changes: 16 additions & 0 deletions packages/backend/server/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -551,3 +551,19 @@ model Invoice {
@@index([targetId])
@@map("invoices")
}

model License {
key String @id @map("key") @db.VarChar
installedAt DateTime? @default(now()) @map("installed_at") @db.Timestamptz(3)
@@map("licenses")
}

model InstalledLicense {
key String @id @map("key") @db.VarChar
workspaceId String @unique @map("workspace_id") @db.VarChar
installedAt DateTime @default(now()) @map("installed_at") @db.Timestamptz(3)
revalidatedAt DateTime? @map("revalidated_at") @db.Timestamptz(3)
@@map("installed_licenses")
}
13 changes: 13 additions & 0 deletions packages/backend/server/src/core/quota/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,19 @@ export class QuotaService {
});
}

async switchWorkspaceQuota(
workspaceId: string,
quota: QuotaType,
reason?: string,
expiredAt?: Date
) {
// TODO(@darksky): implement
}

async switchWorkspaceQuotaToDefault(workspaceId: string, reason?: string) {
// TODO(@darksky): implement
}

async hasQuota(userId: string, quota: QuotaType, tx?: PrismaTransaction) {
const executor = tx ?? this.prisma;

Expand Down
19 changes: 19 additions & 0 deletions packages/backend/server/src/fundamentals/error/def.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,25 @@ export const USER_FRIENDLY_ERRORS = {
type: 'invalid_input',
message: 'Workspace id is required to update team subscription.',
},
license_not_found: {
type: 'resource_not_found',
message: 'License not found.',
},
invalid_license_to_activate: {
type: 'bad_request',
message: 'Invalid license to activate.',
},
invalid_license_update_params: {
type: 'invalid_input',
args: { reason: 'string' },
message: ({ reason }) => `Invalid license update params. ${reason}`,
},
workspace_members_exceed_limit_to_downgrade: {
type: 'bad_request',
args: { limit: 'number' },
message: ({ limit }) =>
`You cannot downgrade the workspace from team workspace because there are more than ${limit} members that are currently active.`,
},

// Copilot errors
copilot_session_not_found: {
Expand Down
38 changes: 37 additions & 1 deletion packages/backend/server/src/fundamentals/error/errors.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,38 @@ export class WorkspaceIdRequiredToUpdateTeamSubscription extends UserFriendlyErr
}
}

export class LicenseNotFound extends UserFriendlyError {
constructor(message?: string) {
super('resource_not_found', 'license_not_found', message);
}
}

export class InvalidLicenseToActivate extends UserFriendlyError {
constructor(message?: string) {
super('bad_request', 'invalid_license_to_activate', message);
}
}
@ObjectType()
class InvalidLicenseUpdateParamsDataType {
@Field() reason!: string
}

export class InvalidLicenseUpdateParams extends UserFriendlyError {
constructor(args: InvalidLicenseUpdateParamsDataType, message?: string | ((args: InvalidLicenseUpdateParamsDataType) => string)) {
super('invalid_input', 'invalid_license_update_params', message, args);
}
}
@ObjectType()
class WorkspaceMembersExceedLimitToDowngradeDataType {
@Field() limit!: number
}

export class WorkspaceMembersExceedLimitToDowngrade extends UserFriendlyError {
constructor(args: WorkspaceMembersExceedLimitToDowngradeDataType, message?: string | ((args: WorkspaceMembersExceedLimitToDowngradeDataType) => string)) {
super('bad_request', 'workspace_members_exceed_limit_to_downgrade', message, args);
}
}

export class CopilotSessionNotFound extends UserFriendlyError {
constructor(message?: string) {
super('resource_not_found', 'copilot_session_not_found', message);
Expand Down Expand Up @@ -642,6 +674,10 @@ export enum ErrorNames {
CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION,
WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION,
WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION,
LICENSE_NOT_FOUND,
INVALID_LICENSE_TO_ACTIVATE,
INVALID_LICENSE_UPDATE_PARAMS,
WORKSPACE_MEMBERS_EXCEED_LIMIT_TO_DOWNGRADE,
COPILOT_SESSION_NOT_FOUND,
COPILOT_SESSION_DELETED,
NO_COPILOT_PROVIDER_AVAILABLE,
Expand Down Expand Up @@ -670,5 +706,5 @@ registerEnumType(ErrorNames, {
export const ErrorDataUnionType = createUnionType({
name: 'ErrorDataUnion',
types: () =>
[UnknownOauthProviderDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, SpaceNotFoundDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, DocNotFoundDataType, DocAccessDeniedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType] as const,
[UnknownOauthProviderDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, SpaceNotFoundDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, DocNotFoundDataType, DocAccessDeniedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, InvalidLicenseUpdateParamsDataType, WorkspaceMembersExceedLimitToDowngradeDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType] as const,
});
2 changes: 2 additions & 0 deletions packages/backend/server/src/plugins/payment/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Plugin } from '../registry';
import { StripeWebhookController } from './controller';
import { SubscriptionCronJobs } from './cron';
import {
SelfhostTeamSubscriptionManager,
UserSubscriptionManager,
WorkspaceSubscriptionManager,
} from './manager';
Expand All @@ -31,6 +32,7 @@ import { StripeWebhook } from './webhook';
StripeWebhook,
UserSubscriptionManager,
WorkspaceSubscriptionManager,
SelfhostTeamSubscriptionManager,
SubscriptionCronJobs,
WorkspaceSubscriptionResolver,
],
Expand Down
164 changes: 164 additions & 0 deletions packages/backend/server/src/plugins/payment/license/controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { Body, Controller, Param, Post } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { z } from 'zod';

import {
InvalidLicenseToActivate,
InvalidLicenseUpdateParams,
LicenseNotFound,
Mutex,
} from '../../../fundamentals';
import { SelfhostTeamSubscriptionManager } from '../manager/selfhost';
import { SubscriptionService } from '../service';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
} from '../types';

const UpdateSeatsParams = z.object({
seats: z.number().min(1),
});

const UpdateRecurringParams = z.object({
recurring: z.enum([
SubscriptionRecurring.Monthly,
SubscriptionRecurring.Yearly,
]),
});

@Controller('/api/team/licenses')
export class LicenseController {
constructor(
private readonly db: PrismaClient,
private readonly mutex: Mutex,
private readonly subscription: SubscriptionService,
private readonly manager: SelfhostTeamSubscriptionManager
) {}

@Post('/:license/activate')
async activate(@Param('license') key: string) {
await using lock = await this.mutex.lock(`license-activation:${key}`);

if (!lock) {
throw new InvalidLicenseToActivate();
}

const license = await this.db.license.findUnique({
where: {
key,
},
});

if (!license) {
throw new InvalidLicenseToActivate();
}

const subscription = await this.manager.getSubscription({
key: license.key,
plan: SubscriptionPlan.SelfHostedTeam,
});

if (
!subscription ||
license.installedAt ||
subscription.status !== SubscriptionStatus.Active
) {
throw new InvalidLicenseToActivate();
}

await this.db.license.update({
where: {
key,
},
data: {
installedAt: new Date(),
},
});

return {
quota: {},
endAt: subscription.end?.getTime(),
};
}

@Post('/:license/deactivate')
async deactivate(@Param('license') key: string) {
await this.db.license.update({
where: {
key,
},
data: {
installedAt: null,
},
});

return {
success: true,
};
}

@Post('/:license/seats')
async updateSeats(
@Param('license') key: string,
@Body() body: z.infer<typeof UpdateSeatsParams>
) {
const parseResult = UpdateSeatsParams.safeParse(body);

if (parseResult.error) {
throw new InvalidLicenseUpdateParams({
reason: parseResult.error.message,
});
}

const license = await this.db.license.findUnique({
where: {
key,
},
});

if (!license) {
throw new LicenseNotFound();
}

await this.subscription.updateSubscriptionQuantity(
{
key: license.key,
plan: SubscriptionPlan.SelfHostedTeam,
},
parseResult.data.seats
);
}

@Post('/:license/recurring')
async updateRecurring(
@Param('license') key: string,
@Body() body: z.infer<typeof UpdateRecurringParams>
) {
const parseResult = UpdateRecurringParams.safeParse(body);

if (parseResult.error) {
throw new InvalidLicenseUpdateParams({
reason: parseResult.error.message,
});
}

const license = await this.db.license.findUnique({
where: {
key,
},
});

if (!license) {
throw new LicenseNotFound();
}

await this.subscription.updateSubscriptionRecurring(
{
key: license.key,
plan: SubscriptionPlan.SelfHostedTeam,
},
parseResult.data.recurring
);
}
}
Loading

0 comments on commit d2b66cf

Please sign in to comment.