Phase9
This commit is contained in:
509
apps/api/src/integrations/whmcs/whmcs-integration.service.ts
Normal file
509
apps/api/src/integrations/whmcs/whmcs-integration.service.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user