510 lines
13 KiB
TypeScript
510 lines
13 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
|
|
import {
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
|
|
import type {
|
|
WhmcsChangePackageRequest,
|
|
WhmcsPlanCatalogResponse,
|
|
WhmcsProvisionServiceRequest,
|
|
WhmcsServiceResponse,
|
|
WhmcsUpsertClientRequest,
|
|
WhmcsUpsertClientResponse,
|
|
} from '@hexahost/contracts';
|
|
import { formatJoinAddress } from '@hexahost/dns';
|
|
import type { GameServer } from '@hexahost/database';
|
|
|
|
import { BillingService } from '../../billing/billing.service';
|
|
import { PrismaService } from '../../prisma/prisma.service';
|
|
import { ServersService } from '../../servers/servers.service';
|
|
|
|
import type { WhmcsInstallationContext } from './whmcs-integration.guard';
|
|
|
|
type InstallationRef = {
|
|
id: string;
|
|
integrationId: string;
|
|
};
|
|
|
|
@Injectable()
|
|
export class WhmcsIntegrationService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly serversService: ServersService,
|
|
private readonly billing: BillingService,
|
|
) {}
|
|
|
|
getHealth(installation: WhmcsInstallationContext) {
|
|
return {
|
|
status: 'ok' as const,
|
|
integrationId: installation.integrationId,
|
|
billingProvider: process.env['BILLING_PROVIDER'] ?? 'internal',
|
|
};
|
|
}
|
|
|
|
async listPlans(): Promise<WhmcsPlanCatalogResponse> {
|
|
const plans = await this.prisma.plan.findMany({
|
|
where: { isActive: true },
|
|
orderBy: { name: 'asc' },
|
|
});
|
|
|
|
return {
|
|
plans: plans.map((plan) => ({
|
|
id: plan.id,
|
|
slug: plan.slug,
|
|
name: plan.name,
|
|
maxRamMb: plan.maxRamMb,
|
|
isFree: plan.isFree,
|
|
})),
|
|
};
|
|
}
|
|
|
|
async upsertClient(
|
|
installation: InstallationRef,
|
|
externalClientId: string,
|
|
input: WhmcsUpsertClientRequest,
|
|
idempotencyKey: string,
|
|
): Promise<WhmcsUpsertClientResponse> {
|
|
const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey);
|
|
if (existingOp?.result) {
|
|
return existingOp.result as WhmcsUpsertClientResponse;
|
|
}
|
|
|
|
const existingLink = await this.prisma.whmcsClientLink.findUnique({
|
|
where: {
|
|
installationId_externalClientId: {
|
|
installationId: installation.id,
|
|
externalClientId,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (existingLink) {
|
|
const user = await this.prisma.user.update({
|
|
where: { id: existingLink.userId },
|
|
data: {
|
|
displayName: input.displayName,
|
|
},
|
|
});
|
|
|
|
const result = {
|
|
userId: user.id,
|
|
externalClientId,
|
|
created: false,
|
|
};
|
|
|
|
await this.recordOperation(installation.id, idempotencyKey, 'client:upsert', result);
|
|
return result;
|
|
}
|
|
|
|
const user = await this.prisma.user.create({
|
|
data: {
|
|
email: input.email,
|
|
username: input.username,
|
|
displayName: input.displayName,
|
|
status: 'ACTIVE',
|
|
},
|
|
});
|
|
|
|
await this.billing.ensureWalletForUser(user.id);
|
|
|
|
await this.prisma.whmcsClientLink.create({
|
|
data: {
|
|
installationId: installation.id,
|
|
externalClientId,
|
|
userId: user.id,
|
|
},
|
|
});
|
|
|
|
const result = {
|
|
userId: user.id,
|
|
externalClientId,
|
|
created: true,
|
|
};
|
|
|
|
await this.recordOperation(installation.id, idempotencyKey, 'client:upsert', result);
|
|
return result;
|
|
}
|
|
|
|
async provisionService(
|
|
installation: InstallationRef,
|
|
input: WhmcsProvisionServiceRequest,
|
|
idempotencyKey: string,
|
|
): Promise<WhmcsServiceResponse> {
|
|
const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey);
|
|
if (existingOp?.result) {
|
|
return existingOp.result as WhmcsServiceResponse;
|
|
}
|
|
|
|
const existing = await this.prisma.whmcsServiceLink.findUnique({
|
|
where: {
|
|
installationId_externalServiceId: {
|
|
installationId: installation.id,
|
|
externalServiceId: input.externalServiceId,
|
|
},
|
|
},
|
|
include: { server: true },
|
|
});
|
|
|
|
if (existing) {
|
|
return this.toServiceResponse(existing.externalServiceId, existing.status, existing.server);
|
|
}
|
|
|
|
const clientLink = await this.prisma.whmcsClientLink.findUnique({
|
|
where: {
|
|
installationId_externalClientId: {
|
|
installationId: installation.id,
|
|
externalClientId: input.externalClientId,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!clientLink) {
|
|
throw new NotFoundException(`Unknown WHMCS client ${input.externalClientId}`);
|
|
}
|
|
|
|
const plan = await this.prisma.plan.findFirst({
|
|
where: { slug: input.planSlug, isActive: true },
|
|
});
|
|
|
|
if (!plan) {
|
|
throw new NotFoundException(`Unknown plan slug ${input.planSlug}`);
|
|
}
|
|
|
|
await this.billing.assertCanCreateServer(clientLink.userId, plan.id);
|
|
|
|
const server = await this.serversService.createServer(clientLink.userId, {
|
|
name: input.serverName,
|
|
edition: input.edition,
|
|
softwareFamily: input.softwareFamily,
|
|
minecraftVersion: input.minecraftVersion,
|
|
eulaAccepted: true,
|
|
planId: plan.id,
|
|
ramMb: plan.maxRamMb,
|
|
});
|
|
|
|
await this.prisma.whmcsServiceLink.create({
|
|
data: {
|
|
installationId: installation.id,
|
|
externalServiceId: input.externalServiceId,
|
|
externalProductId: input.externalProductId,
|
|
serverId: server.id,
|
|
status: 'PENDING',
|
|
},
|
|
});
|
|
|
|
const started = await this.serversService.startServerInternal(
|
|
server.id,
|
|
`whmcs:provision:${input.externalServiceId}`,
|
|
);
|
|
|
|
await this.prisma.whmcsServiceLink.update({
|
|
where: {
|
|
installationId_externalServiceId: {
|
|
installationId: installation.id,
|
|
externalServiceId: input.externalServiceId,
|
|
},
|
|
},
|
|
data: { status: 'ACTIVE' },
|
|
});
|
|
|
|
const response = this.toServiceResponse(
|
|
input.externalServiceId,
|
|
'ACTIVE',
|
|
started.server as unknown as GameServer,
|
|
);
|
|
|
|
await this.recordOperation(
|
|
installation.id,
|
|
idempotencyKey,
|
|
'service:provision',
|
|
response,
|
|
input.externalServiceId,
|
|
);
|
|
|
|
return response;
|
|
}
|
|
|
|
async getService(
|
|
installation: InstallationRef,
|
|
externalServiceId: string,
|
|
): Promise<WhmcsServiceResponse> {
|
|
const link = await this.findServiceLink(installation.id, externalServiceId);
|
|
return this.toServiceResponse(externalServiceId, link.status, link.server);
|
|
}
|
|
|
|
async suspendService(
|
|
installation: InstallationRef,
|
|
externalServiceId: string,
|
|
reason: string | undefined,
|
|
idempotencyKey: string,
|
|
): Promise<WhmcsServiceResponse> {
|
|
const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey);
|
|
if (existingOp?.result) {
|
|
return existingOp.result as WhmcsServiceResponse;
|
|
}
|
|
|
|
const link = await this.findServiceLink(installation.id, externalServiceId);
|
|
const server = link.server;
|
|
|
|
if (server.status === 'RUNNING') {
|
|
await this.serversService.stopServer(server.userId, server.id);
|
|
} else if (server.status === 'QUEUED') {
|
|
await this.serversService.cancelQueuedStart(server.userId, server.id);
|
|
}
|
|
|
|
const updatedServer = await this.prisma.gameServer.update({
|
|
where: { id: server.id },
|
|
data: {
|
|
billingSuspendedAt: new Date(),
|
|
billingSuspendReason: reason ?? 'whmcs-suspend',
|
|
},
|
|
});
|
|
|
|
const updatedLink = await this.prisma.whmcsServiceLink.update({
|
|
where: { id: link.id },
|
|
data: {
|
|
status: 'SUSPENDED',
|
|
suspendedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
const response = this.toServiceResponse(
|
|
externalServiceId,
|
|
updatedLink.status,
|
|
updatedServer,
|
|
);
|
|
|
|
await this.recordOperation(
|
|
installation.id,
|
|
idempotencyKey,
|
|
'service:suspend',
|
|
response,
|
|
externalServiceId,
|
|
);
|
|
|
|
return response;
|
|
}
|
|
|
|
async unsuspendService(
|
|
installation: InstallationRef,
|
|
externalServiceId: string,
|
|
idempotencyKey: string,
|
|
): Promise<WhmcsServiceResponse> {
|
|
const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey);
|
|
if (existingOp?.result) {
|
|
return existingOp.result as WhmcsServiceResponse;
|
|
}
|
|
|
|
const link = await this.findServiceLink(installation.id, externalServiceId);
|
|
|
|
const updatedServer = await this.prisma.gameServer.update({
|
|
where: { id: link.serverId },
|
|
data: {
|
|
billingSuspendedAt: null,
|
|
billingSuspendReason: null,
|
|
},
|
|
});
|
|
|
|
const updatedLink = await this.prisma.whmcsServiceLink.update({
|
|
where: { id: link.id },
|
|
data: {
|
|
status: 'ACTIVE',
|
|
suspendedAt: null,
|
|
},
|
|
});
|
|
|
|
const response = this.toServiceResponse(
|
|
externalServiceId,
|
|
updatedLink.status,
|
|
updatedServer,
|
|
);
|
|
|
|
await this.recordOperation(
|
|
installation.id,
|
|
idempotencyKey,
|
|
'service:unsuspend',
|
|
response,
|
|
externalServiceId,
|
|
);
|
|
|
|
return response;
|
|
}
|
|
|
|
async terminateService(
|
|
installation: InstallationRef,
|
|
externalServiceId: string,
|
|
idempotencyKey: string,
|
|
): Promise<WhmcsServiceResponse> {
|
|
const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey);
|
|
if (existingOp?.result) {
|
|
return existingOp.result as WhmcsServiceResponse;
|
|
}
|
|
|
|
const link = await this.findServiceLink(installation.id, externalServiceId);
|
|
const server = link.server;
|
|
|
|
if (server.status === 'RUNNING') {
|
|
await this.serversService.stopServer(server.userId, server.id);
|
|
}
|
|
|
|
const updatedServer = await this.prisma.gameServer.update({
|
|
where: { id: server.id },
|
|
data: {
|
|
status: 'DELETED',
|
|
billingSuspendedAt: new Date(),
|
|
billingSuspendReason: 'whmcs-terminate',
|
|
},
|
|
});
|
|
|
|
const updatedLink = await this.prisma.whmcsServiceLink.update({
|
|
where: { id: link.id },
|
|
data: {
|
|
status: 'TERMINATED',
|
|
terminatedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
const response = this.toServiceResponse(
|
|
externalServiceId,
|
|
updatedLink.status,
|
|
updatedServer,
|
|
);
|
|
|
|
await this.recordOperation(
|
|
installation.id,
|
|
idempotencyKey,
|
|
'service:terminate',
|
|
response,
|
|
externalServiceId,
|
|
);
|
|
|
|
return response;
|
|
}
|
|
|
|
async changePackage(
|
|
installation: InstallationRef,
|
|
externalServiceId: string,
|
|
input: WhmcsChangePackageRequest,
|
|
idempotencyKey: string,
|
|
): Promise<WhmcsServiceResponse> {
|
|
const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey);
|
|
if (existingOp?.result) {
|
|
return existingOp.result as WhmcsServiceResponse;
|
|
}
|
|
|
|
const link = await this.findServiceLink(installation.id, externalServiceId);
|
|
const plan = await this.prisma.plan.findFirst({
|
|
where: { slug: input.planSlug, isActive: true },
|
|
});
|
|
|
|
if (!plan) {
|
|
throw new NotFoundException(`Unknown plan slug ${input.planSlug}`);
|
|
}
|
|
|
|
const updatedServer = await this.prisma.gameServer.update({
|
|
where: { id: link.serverId },
|
|
data: {
|
|
planId: plan.id,
|
|
ramMb: input.ramMb ?? plan.maxRamMb,
|
|
},
|
|
});
|
|
|
|
const response = this.toServiceResponse(
|
|
externalServiceId,
|
|
link.status,
|
|
updatedServer,
|
|
);
|
|
|
|
await this.recordOperation(
|
|
installation.id,
|
|
idempotencyKey,
|
|
'service:change-package',
|
|
response,
|
|
externalServiceId,
|
|
);
|
|
|
|
return response;
|
|
}
|
|
|
|
private async findServiceLink(installationId: string, externalServiceId: string) {
|
|
const link = await this.prisma.whmcsServiceLink.findUnique({
|
|
where: {
|
|
installationId_externalServiceId: {
|
|
installationId,
|
|
externalServiceId,
|
|
},
|
|
},
|
|
include: { server: true },
|
|
});
|
|
|
|
if (!link) {
|
|
throw new NotFoundException(`Unknown WHMCS service ${externalServiceId}`);
|
|
}
|
|
|
|
return link;
|
|
}
|
|
|
|
private async findCompletedOperation(installationId: string, idempotencyKey: string) {
|
|
return this.prisma.integrationOperation.findUnique({
|
|
where: { idempotencyKey },
|
|
});
|
|
}
|
|
|
|
private async recordOperation(
|
|
installationId: string,
|
|
idempotencyKey: string,
|
|
operation: string,
|
|
result: unknown,
|
|
externalRef?: string,
|
|
): Promise<void> {
|
|
await this.prisma.integrationOperation.create({
|
|
data: {
|
|
installationId,
|
|
idempotencyKey,
|
|
operation,
|
|
externalRef,
|
|
status: 'COMPLETED',
|
|
result: result as object,
|
|
},
|
|
});
|
|
}
|
|
|
|
private toServiceResponse(
|
|
externalServiceId: string,
|
|
status: 'PENDING' | 'ACTIVE' | 'SUSPENDED' | 'TERMINATED',
|
|
server: GameServer,
|
|
): WhmcsServiceResponse {
|
|
return {
|
|
externalServiceId,
|
|
status,
|
|
server: {
|
|
id: server.id,
|
|
userId: server.userId,
|
|
planId: server.planId,
|
|
nodeId: server.nodeId,
|
|
name: server.name,
|
|
status: server.status,
|
|
version: server.version,
|
|
edition: server.edition,
|
|
softwareFamily: server.softwareFamily,
|
|
minecraftVersion: server.minecraftVersion,
|
|
eulaAccepted: server.eulaAccepted,
|
|
hostPort: server.hostPort,
|
|
containerId: server.containerId,
|
|
dataPath: server.dataPath,
|
|
activeWorldName: server.activeWorldName,
|
|
ramMb: server.ramMb,
|
|
idleShutdownEnabled: server.idleShutdownEnabled,
|
|
idleStopAt: server.idleStopAt?.toISOString() ?? null,
|
|
joinSlug: server.joinSlug,
|
|
joinHostname: formatJoinAddress(server.joinSlug, server.edition),
|
|
joinToStartEnabled: server.joinToStartEnabled,
|
|
createdAt: server.createdAt.toISOString(),
|
|
updatedAt: server.updatedAt.toISOString(),
|
|
},
|
|
};
|
|
}
|
|
}
|