Enhance API with OIDC support, including login and callback endpoints. Update environment variables for OIDC configuration in .env.example. Add new features to the catalog service for listing software families, Minecraft versions, and deployment regions. Implement server management actions such as kill, delete, and update in the servers module. Integrate feature flags for maintenance mode in server operations. Update pnpm-lock.yaml with new dependencies and versions.
This commit is contained in:
@@ -21,16 +21,22 @@
|
||||
"@hexahost/contracts": "workspace:*",
|
||||
"@hexahost/database": "workspace:*",
|
||||
"@hexahost/dns": "workspace:*",
|
||||
"@hexahost/feature-flags": "workspace:*",
|
||||
"@hexahost/otel": "workspace:*",
|
||||
"@hexahost/integration-auth": "workspace:*",
|
||||
"@hexahost/metering": "workspace:*",
|
||||
"@hexahost/permissions": "workspace:*",
|
||||
"@hexahost/scheduler": "workspace:*",
|
||||
"@hexahost/server-state": "workspace:*",
|
||||
"@hexahost/storage": "workspace:*",
|
||||
"@aws-sdk/client-s3": "^3.750.0",
|
||||
"@nestjs/common": "^11.0.7",
|
||||
"@nestjs/core": "^11.0.7",
|
||||
"@nestjs/platform-fastify": "^11.0.7",
|
||||
"@nestjs/swagger": "^11.0.3",
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"bullmq": "^5.34.8",
|
||||
"cron-parser": "^5.0.4",
|
||||
"fastify": "^5.2.1",
|
||||
"ioredis": "^5.4.2",
|
||||
"nestjs-pino": "^4.3.0",
|
||||
|
||||
29
apps/api/src/abuse/abuse.controller.ts
Normal file
29
apps/api/src/abuse/abuse.controller.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { createAbuseReportSchema } from '@hexahost/contracts';
|
||||
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { SessionGuard } from '../auth/guards/session.guard';
|
||||
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
|
||||
|
||||
import { AbuseService } from './abuse.service';
|
||||
|
||||
@ApiTags('abuse')
|
||||
@Controller('abuse/reports')
|
||||
@UseGuards(SessionGuard)
|
||||
export class AbuseController {
|
||||
constructor(private readonly abuseService: AbuseService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Submit an abuse report' })
|
||||
create(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Body(new ZodValidationPipe(createAbuseReportSchema)) body: unknown,
|
||||
) {
|
||||
return this.abuseService.create(
|
||||
user.id,
|
||||
body as Parameters<AbuseService['create']>[1],
|
||||
);
|
||||
}
|
||||
}
|
||||
15
apps/api/src/abuse/abuse.module.ts
Normal file
15
apps/api/src/abuse/abuse.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
import { AbuseController } from './abuse.controller';
|
||||
import { AbuseService } from './abuse.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AuthModule],
|
||||
controllers: [AbuseController],
|
||||
providers: [AbuseService],
|
||||
exports: [AbuseService],
|
||||
})
|
||||
export class AbuseModule {}
|
||||
78
apps/api/src/abuse/abuse.service.ts
Normal file
78
apps/api/src/abuse/abuse.service.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type {
|
||||
AbuseReport,
|
||||
AbuseReportListResponse,
|
||||
CreateAbuseReport,
|
||||
UpdateAbuseReport,
|
||||
} from '@hexahost/contracts';
|
||||
import type { AbuseReport as AbuseReportRow } from '@hexahost/database';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
function mapReport(report: AbuseReportRow): AbuseReport {
|
||||
return {
|
||||
id: report.id,
|
||||
reporterId: report.reporterId,
|
||||
targetUserId: report.targetUserId,
|
||||
serverId: report.serverId,
|
||||
status: report.status,
|
||||
reason: report.reason,
|
||||
details: report.details,
|
||||
resolution: report.resolution,
|
||||
createdAt: report.createdAt.toISOString(),
|
||||
updatedAt: report.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AbuseService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async create(reporterId: string, input: CreateAbuseReport): Promise<AbuseReport> {
|
||||
const report = await this.prisma.abuseReport.create({
|
||||
data: {
|
||||
reporterId,
|
||||
targetUserId: input.targetUserId,
|
||||
serverId: input.serverId,
|
||||
reason: input.reason.trim(),
|
||||
details: input.details?.trim(),
|
||||
},
|
||||
});
|
||||
|
||||
return mapReport(report);
|
||||
}
|
||||
|
||||
async listAdmin(options?: {
|
||||
status?: AbuseReport['status'];
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}): Promise<AbuseReportListResponse> {
|
||||
const limit = Math.min(Math.max(options?.limit ?? 50, 1), 100);
|
||||
|
||||
const reports = await this.prisma.abuseReport.findMany({
|
||||
where: options?.status ? { status: options.status } : undefined,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit + 1,
|
||||
...(options?.cursor
|
||||
? { cursor: { id: options.cursor }, skip: 1 }
|
||||
: {}),
|
||||
});
|
||||
|
||||
return {
|
||||
reports: reports.slice(0, limit).map(mapReport),
|
||||
};
|
||||
}
|
||||
|
||||
async updateAdmin(reportId: string, input: UpdateAbuseReport): Promise<AbuseReport> {
|
||||
const report = await this.prisma.abuseReport.update({
|
||||
where: { id: reportId },
|
||||
data: {
|
||||
status: input.status,
|
||||
resolution: input.resolution,
|
||||
},
|
||||
});
|
||||
|
||||
return mapReport(report);
|
||||
}
|
||||
}
|
||||
58
apps/api/src/admin/admin-abuse.controller.ts
Normal file
58
apps/api/src/admin/admin-abuse.controller.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { updateAbuseReportSchema } from '@hexahost/contracts';
|
||||
import type { AbuseReport } from '@hexahost/contracts';
|
||||
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { SessionGuard } from '../auth/guards/session.guard';
|
||||
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
|
||||
import { AbuseService } from '../abuse/abuse.service';
|
||||
|
||||
import { assertSuperAdmin } from './admin-auth.util';
|
||||
|
||||
@ApiTags('admin')
|
||||
@Controller('admin/abuse')
|
||||
@UseGuards(SessionGuard)
|
||||
export class AdminAbuseController {
|
||||
constructor(private readonly abuseService: AbuseService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List abuse reports' })
|
||||
list(
|
||||
@CurrentUser() user: { roles: string[] },
|
||||
@Query('status') status?: AbuseReport['status'],
|
||||
@Query('limit') limit?: string,
|
||||
@Query('cursor') cursor?: string,
|
||||
) {
|
||||
assertSuperAdmin(user.roles);
|
||||
return this.abuseService.listAdmin({
|
||||
status,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
cursor,
|
||||
});
|
||||
}
|
||||
|
||||
@Patch(':reportId')
|
||||
@ApiOperation({ summary: 'Update an abuse report' })
|
||||
update(
|
||||
@CurrentUser() user: { roles: string[] },
|
||||
@Param('reportId', ParseUUIDPipe) reportId: string,
|
||||
@Body(new ZodValidationPipe(updateAbuseReportSchema)) body: unknown,
|
||||
) {
|
||||
assertSuperAdmin(user.roles);
|
||||
return this.abuseService.updateAdmin(
|
||||
reportId,
|
||||
body as Parameters<AbuseService['updateAdmin']>[1],
|
||||
);
|
||||
}
|
||||
}
|
||||
31
apps/api/src/admin/admin-audit.controller.ts
Normal file
31
apps/api/src/admin/admin-audit.controller.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { SessionGuard } from '../auth/guards/session.guard';
|
||||
|
||||
import { assertSuperAdmin } from './admin-auth.util';
|
||||
import { AdminAuditService } from './admin-audit.service';
|
||||
|
||||
@ApiTags('admin')
|
||||
@Controller('admin/audit')
|
||||
@UseGuards(SessionGuard)
|
||||
export class AdminAuditController {
|
||||
constructor(private readonly audit: AdminAuditService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List audit events' })
|
||||
list(
|
||||
@CurrentUser() user: { roles: string[] },
|
||||
@Query('limit') limit?: string,
|
||||
@Query('cursor') cursor?: string,
|
||||
@Query('action') action?: string,
|
||||
) {
|
||||
assertSuperAdmin(user.roles);
|
||||
return this.audit.listAuditEvents({
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
cursor,
|
||||
action,
|
||||
});
|
||||
}
|
||||
}
|
||||
62
apps/api/src/admin/admin-audit.service.ts
Normal file
62
apps/api/src/admin/admin-audit.service.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AdminAuditService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listAuditEvents(query: {
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
action?: string;
|
||||
}): Promise<{
|
||||
items: Array<{
|
||||
id: string;
|
||||
action: string;
|
||||
entityType: string | null;
|
||||
entityId: string | null;
|
||||
metadata: unknown;
|
||||
ipAddress: string | null;
|
||||
createdAt: string;
|
||||
user: { id: string; email: string; username: string } | null;
|
||||
}>;
|
||||
nextCursor: string | null;
|
||||
}> {
|
||||
const limit = Math.min(query.limit ?? 50, 100);
|
||||
|
||||
const events = await this.prisma.auditEvent.findMany({
|
||||
where: query.action ? { action: query.action } : undefined,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit + 1,
|
||||
...(query.cursor
|
||||
? {
|
||||
cursor: { id: query.cursor },
|
||||
skip: 1,
|
||||
}
|
||||
: {}),
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, email: true, username: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const hasMore = events.length > limit;
|
||||
const items = hasMore ? events.slice(0, limit) : events;
|
||||
|
||||
return {
|
||||
items: items.map((event) => ({
|
||||
id: event.id,
|
||||
action: event.action,
|
||||
entityType: event.entityType,
|
||||
entityId: event.entityId,
|
||||
metadata: event.metadata,
|
||||
ipAddress: event.ipAddress,
|
||||
createdAt: event.createdAt.toISOString(),
|
||||
user: event.user,
|
||||
})),
|
||||
nextCursor: hasMore ? items[items.length - 1]?.id ?? null : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
18
apps/api/src/admin/admin-auth.util.ts
Normal file
18
apps/api/src/admin/admin-auth.util.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
|
||||
import { UserRole } from '@hexahost/auth';
|
||||
|
||||
export function assertSuperAdmin(roles: string[]): void {
|
||||
if (!roles.includes(UserRole.SUPER_ADMIN)) {
|
||||
throw new ForbiddenException('Super administrator access required');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertPlatformRole(
|
||||
roles: string[],
|
||||
allowed: UserRole[],
|
||||
): void {
|
||||
if (!allowed.some((role) => roles.includes(role))) {
|
||||
throw new ForbiddenException('Insufficient platform permissions');
|
||||
}
|
||||
}
|
||||
22
apps/api/src/admin/admin-dashboard.controller.ts
Normal file
22
apps/api/src/admin/admin-dashboard.controller.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { SessionGuard } from '../auth/guards/session.guard';
|
||||
|
||||
import { assertSuperAdmin } from './admin-auth.util';
|
||||
import { AdminDashboardService } from './admin-dashboard.service';
|
||||
|
||||
@ApiTags('admin')
|
||||
@Controller('admin/dashboard')
|
||||
@UseGuards(SessionGuard)
|
||||
export class AdminDashboardController {
|
||||
constructor(private readonly dashboard: AdminDashboardService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Admin dashboard statistics' })
|
||||
stats(@CurrentUser() user: { roles: string[] }) {
|
||||
assertSuperAdmin(user.roles);
|
||||
return this.dashboard.getStats();
|
||||
}
|
||||
}
|
||||
51
apps/api/src/admin/admin-dashboard.service.ts
Normal file
51
apps/api/src/admin/admin-dashboard.service.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AdminDashboardService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getStats() {
|
||||
const [
|
||||
activeUsers,
|
||||
totalServers,
|
||||
runningServers,
|
||||
queuedServers,
|
||||
onlineNodes,
|
||||
failedJobs24h,
|
||||
openAbuseReports,
|
||||
openTickets,
|
||||
] = await Promise.all([
|
||||
this.prisma.user.count({ where: { status: 'ACTIVE' } }),
|
||||
this.prisma.gameServer.count({
|
||||
where: { status: { not: 'DELETED' } },
|
||||
}),
|
||||
this.prisma.gameServer.count({ where: { status: 'RUNNING' } }),
|
||||
this.prisma.serverStartQueueEntry.count({ where: { status: 'QUEUED' } }),
|
||||
this.prisma.gameNode.count({ where: { status: 'ONLINE' } }),
|
||||
this.prisma.jobRecord.count({
|
||||
where: {
|
||||
status: 'FAILED',
|
||||
createdAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) },
|
||||
},
|
||||
}),
|
||||
this.prisma.abuseReport.count({ where: { status: 'OPEN' } }),
|
||||
this.prisma.supportTicket.count({
|
||||
where: { status: { in: ['OPEN', 'IN_PROGRESS', 'WAITING'] } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
activeUsers,
|
||||
totalServers,
|
||||
runningServers,
|
||||
queueLength: queuedServers,
|
||||
onlineNodes,
|
||||
failedJobs24h,
|
||||
openAbuseReports,
|
||||
openTickets,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
31
apps/api/src/admin/admin-jobs.controller.ts
Normal file
31
apps/api/src/admin/admin-jobs.controller.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { SessionGuard } from '../auth/guards/session.guard';
|
||||
|
||||
import { assertSuperAdmin } from './admin-auth.util';
|
||||
import { AdminJobsService } from './admin-jobs.service';
|
||||
|
||||
@ApiTags('admin')
|
||||
@Controller('admin/jobs')
|
||||
@UseGuards(SessionGuard)
|
||||
export class AdminJobsController {
|
||||
constructor(private readonly jobs: AdminJobsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List background jobs' })
|
||||
list(
|
||||
@CurrentUser() user: { roles: string[] },
|
||||
@Query('limit') limit?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('queue') queue?: string,
|
||||
) {
|
||||
assertSuperAdmin(user.roles);
|
||||
return this.jobs.listJobs({
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
status,
|
||||
queue,
|
||||
});
|
||||
}
|
||||
}
|
||||
47
apps/api/src/admin/admin-jobs.service.ts
Normal file
47
apps/api/src/admin/admin-jobs.service.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AdminJobsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listJobs(query: { limit?: number; status?: string; queue?: string }): Promise<{
|
||||
jobs: Array<{
|
||||
id: string;
|
||||
queue: string;
|
||||
jobName: string;
|
||||
status: string;
|
||||
attempts: number;
|
||||
error: string | null;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
createdAt: string;
|
||||
}>;
|
||||
}> {
|
||||
const limit = Math.min(query.limit ?? 50, 100);
|
||||
|
||||
const jobs = await this.prisma.jobRecord.findMany({
|
||||
where: {
|
||||
...(query.status ? { status: query.status as never } : {}),
|
||||
...(query.queue ? { queue: query.queue } : {}),
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return {
|
||||
jobs: jobs.map((job) => ({
|
||||
id: job.id,
|
||||
queue: job.queue,
|
||||
jobName: job.jobName,
|
||||
status: job.status,
|
||||
attempts: job.attempts,
|
||||
error: job.error,
|
||||
startedAt: job.startedAt?.toISOString() ?? null,
|
||||
completedAt: job.completedAt?.toISOString() ?? null,
|
||||
createdAt: job.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
57
apps/api/src/admin/admin-users.controller.ts
Normal file
57
apps/api/src/admin/admin-users.controller.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { SessionGuard } from '../auth/guards/session.guard';
|
||||
|
||||
import { assertSuperAdmin } from './admin-auth.util';
|
||||
import { AdminUsersService } from './admin-users.service';
|
||||
|
||||
@ApiTags('admin')
|
||||
@Controller('admin/users')
|
||||
@UseGuards(SessionGuard)
|
||||
export class AdminUsersController {
|
||||
constructor(private readonly users: AdminUsersService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Search users' })
|
||||
search(
|
||||
@CurrentUser() user: { roles: string[] },
|
||||
@Query('q') q?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
assertSuperAdmin(user.roles);
|
||||
return this.users.searchUsers({
|
||||
q,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Post(':id/suspend')
|
||||
@ApiOperation({ summary: 'Suspend a user account' })
|
||||
suspend(
|
||||
@CurrentUser() user: { roles: string[] },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
assertSuperAdmin(user.roles);
|
||||
return this.users.suspendUser(id);
|
||||
}
|
||||
|
||||
@Post(':id/unsuspend')
|
||||
@ApiOperation({ summary: 'Unsuspend a user account' })
|
||||
unsuspend(
|
||||
@CurrentUser() user: { roles: string[] },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
assertSuperAdmin(user.roles);
|
||||
return this.users.unsuspendUser(id);
|
||||
}
|
||||
}
|
||||
77
apps/api/src/admin/admin-users.service.ts
Normal file
77
apps/api/src/admin/admin-users.service.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AdminUsersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async searchUsers(query: { q?: string; limit?: number }): Promise<{
|
||||
users: Array<{
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
status: string;
|
||||
roles: string[];
|
||||
twoFactorEnabled: boolean;
|
||||
sessionCount: number;
|
||||
serverCount: number;
|
||||
createdAt: string;
|
||||
}>;
|
||||
}> {
|
||||
const limit = Math.min(query.limit ?? 25, 100);
|
||||
const q = query.q?.trim();
|
||||
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: q
|
||||
? {
|
||||
OR: [
|
||||
{ email: { contains: q, mode: 'insensitive' } },
|
||||
{ username: { contains: q, mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
include: {
|
||||
platformRoles: {
|
||||
include: { role: true },
|
||||
},
|
||||
totpCredential: { select: { enabledAt: true } },
|
||||
_count: { select: { sessions: true, gameServers: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
users: users.map((user) => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
status: user.status,
|
||||
roles: user.platformRoles.map((entry) => entry.role.name),
|
||||
twoFactorEnabled: Boolean(user.totpCredential?.enabledAt),
|
||||
sessionCount: user._count.sessions,
|
||||
serverCount: user._count.gameServers,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async suspendUser(userId: string, reason?: string): Promise<{ id: string; status: string }> {
|
||||
return this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { status: 'SUSPENDED' },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
}
|
||||
|
||||
async unsuspendUser(userId: string): Promise<{ id: string; status: string }> {
|
||||
return this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { status: 'ACTIVE', failedLoginAttempts: 0, lockedUntil: null },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,36 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AbuseModule } from '../abuse/abuse.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
import { AdminAbuseController } from './admin-abuse.controller';
|
||||
import { AdminAuditController } from './admin-audit.controller';
|
||||
import { AdminAuditService } from './admin-audit.service';
|
||||
import { AdminDashboardController } from './admin-dashboard.controller';
|
||||
import { AdminDashboardService } from './admin-dashboard.service';
|
||||
import { AdminJobsController } from './admin-jobs.controller';
|
||||
import { AdminJobsService } from './admin-jobs.service';
|
||||
import { AdminNodesController } from './admin-nodes.controller';
|
||||
import { AdminNodesService } from './admin-nodes.service';
|
||||
import { AdminUsersController } from './admin-users.controller';
|
||||
import { AdminUsersService } from './admin-users.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [AdminNodesController],
|
||||
providers: [AdminNodesService],
|
||||
imports: [AuthModule, AbuseModule],
|
||||
controllers: [
|
||||
AdminNodesController,
|
||||
AdminDashboardController,
|
||||
AdminAuditController,
|
||||
AdminJobsController,
|
||||
AdminUsersController,
|
||||
AdminAbuseController,
|
||||
],
|
||||
providers: [
|
||||
AdminNodesService,
|
||||
AdminDashboardService,
|
||||
AdminAuditService,
|
||||
AdminJobsService,
|
||||
AdminUsersService,
|
||||
],
|
||||
})
|
||||
export class AdminModule {}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
|
||||
import { AbuseModule } from './abuse/abuse.module';
|
||||
import { ComplianceModule } from './compliance/compliance.module';
|
||||
import { NotificationsModule } from './notifications/notifications.module';
|
||||
import { FeatureFlagsModule } from './feature-flags/feature-flags.module';
|
||||
import { OrganizationsModule } from './organizations/organizations.module';
|
||||
import { SupportModule } from './support/support.module';
|
||||
import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
@@ -13,10 +19,14 @@ import { EdgeModule } from './edge/edge.module';
|
||||
import { ServersModule } from './servers/servers.module';
|
||||
import { AppConfigModule } from './config/app-config.module';
|
||||
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
|
||||
import { CsrfModule } from './common/csrf/csrf.module';
|
||||
import { CsrfGuard } from './common/csrf/csrf.guard';
|
||||
import { IdempotencyInterceptor } from './common/interceptors/idempotency.interceptor';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
LoggerModule.forRoot({
|
||||
@@ -51,12 +61,27 @@ import { LoggerModule } from 'nestjs-pino';
|
||||
ServersModule,
|
||||
NodesModule,
|
||||
AdminModule,
|
||||
NotificationsModule,
|
||||
ComplianceModule,
|
||||
FeatureFlagsModule,
|
||||
OrganizationsModule,
|
||||
SupportModule,
|
||||
AbuseModule,
|
||||
CsrfModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ThrottlerGuard,
|
||||
},
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: CsrfGuard,
|
||||
},
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: IdempotencyInterceptor,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
@@ -221,4 +222,35 @@ export class AuthController {
|
||||
request.ip,
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('oidc/login')
|
||||
@ApiOperation({ summary: 'Redirect to OIDC provider (optional)' })
|
||||
oidcLogin(@Res() reply: FastifyReply) {
|
||||
const issuer = process.env['OIDC_ISSUER'];
|
||||
const clientId = process.env['OIDC_CLIENT_ID'];
|
||||
const redirectUri = process.env['OIDC_REDIRECT_URI'];
|
||||
if (!issuer || !clientId || !redirectUri) {
|
||||
throw new BadRequestException('OIDC is not configured');
|
||||
}
|
||||
const url = new URL(`${issuer.replace(/\/$/, '')}/authorize`);
|
||||
url.searchParams.set('client_id', clientId);
|
||||
url.searchParams.set('redirect_uri', redirectUri);
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('scope', 'openid email profile');
|
||||
return reply.redirect(url.toString(), 302);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('oidc/callback')
|
||||
@ApiOperation({ summary: 'OIDC callback (optional)' })
|
||||
async oidcCallback(
|
||||
@Query('code') code: string | undefined,
|
||||
@Res() reply: FastifyReply,
|
||||
) {
|
||||
if (!code) {
|
||||
throw new BadRequestException('Missing authorization code');
|
||||
}
|
||||
return this.authService.completeOidcLogin(code, reply);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,13 @@ import { SsoService } from './sso.service';
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [AuthService, SessionService, SessionGuard, AuditService, SsoService],
|
||||
exports: [
|
||||
AuthService,
|
||||
SessionService,
|
||||
SessionGuard,
|
||||
AuditService,
|
||||
SsoService,
|
||||
NOTIFICATIONS_QUEUE,
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -712,4 +712,77 @@ export class AuthService {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async completeOidcLogin(code: string, reply: FastifyReply): Promise<void> {
|
||||
const issuer = process.env['OIDC_ISSUER'];
|
||||
const clientId = process.env['OIDC_CLIENT_ID'];
|
||||
const clientSecret = process.env['OIDC_CLIENT_SECRET'];
|
||||
const redirectUri = process.env['OIDC_REDIRECT_URI'];
|
||||
if (!issuer || !clientId || !clientSecret || !redirectUri) {
|
||||
throw new BadRequestException('OIDC is not configured');
|
||||
}
|
||||
|
||||
const tokenResponse = await fetch(`${issuer.replace(/\/$/, '')}/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
throw new UnauthorizedException('OIDC token exchange failed');
|
||||
}
|
||||
|
||||
const tokenJson = (await tokenResponse.json()) as { access_token?: string };
|
||||
if (!tokenJson.access_token) {
|
||||
throw new UnauthorizedException('OIDC token missing');
|
||||
}
|
||||
|
||||
const userInfoResponse = await fetch(`${issuer.replace(/\/$/, '')}/userinfo`, {
|
||||
headers: { Authorization: `Bearer ${tokenJson.access_token}` },
|
||||
});
|
||||
if (!userInfoResponse.ok) {
|
||||
throw new UnauthorizedException('OIDC userinfo failed');
|
||||
}
|
||||
|
||||
const profile = (await userInfoResponse.json()) as {
|
||||
email?: string;
|
||||
sub?: string;
|
||||
name?: string;
|
||||
};
|
||||
if (!profile.email) {
|
||||
throw new BadRequestException('OIDC profile has no email');
|
||||
}
|
||||
|
||||
let user = await this.prisma.user.findUnique({
|
||||
where: { email: profile.email },
|
||||
});
|
||||
if (!user) {
|
||||
const username = generateUsernameFromEmail(profile.email);
|
||||
const passwordHash = await hashPassword(generateSecureToken(32));
|
||||
user = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.user.create({
|
||||
data: {
|
||||
email: profile.email!,
|
||||
username,
|
||||
displayName: profile.name ?? username,
|
||||
emailVerifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await tx.userCredential.create({
|
||||
data: { userId: created.id, passwordHash },
|
||||
});
|
||||
return created;
|
||||
});
|
||||
}
|
||||
|
||||
const { token, expiresAt } = await this.sessionService.createSession(user.id);
|
||||
this.sessionService.setSessionCookie(reply, token, expiresAt);
|
||||
reply.redirect(`${this.config.APP_URL}/dashboard`, 302);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,19 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
import { CatalogService } from './catalog.service';
|
||||
|
||||
const SOFTWARE_FAMILIES = [
|
||||
'VANILLA',
|
||||
'PAPER',
|
||||
'PURPUR',
|
||||
'FABRIC',
|
||||
'FORGE',
|
||||
'NEOFORGE',
|
||||
'QUILT',
|
||||
'BEDROCK_DEDICATED',
|
||||
] as const;
|
||||
|
||||
const MINECRAFT_VERSIONS = ['1.21.1', '1.21', '1.20.6', '1.20.4'];
|
||||
|
||||
@ApiTags('catalog')
|
||||
@Controller('catalog')
|
||||
@UseGuards(SessionGuard)
|
||||
@@ -17,6 +30,55 @@ export class CatalogController {
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Get('software')
|
||||
@ApiOperation({ summary: 'List supported server software families' })
|
||||
listSoftware() {
|
||||
return {
|
||||
families: SOFTWARE_FAMILIES.map((family) => ({ id: family, name: family })),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('versions')
|
||||
@ApiOperation({ summary: 'List supported Minecraft versions' })
|
||||
async listVersions(@Query('edition') edition?: string) {
|
||||
const dbVersions = await this.prisma.minecraftVersion.findMany({
|
||||
where: edition ? { edition: edition as 'JAVA' | 'BEDROCK', isActive: true } : { isActive: true },
|
||||
orderBy: { version: 'desc' },
|
||||
});
|
||||
|
||||
if (dbVersions.length > 0) {
|
||||
return { versions: dbVersions.map((entry) => entry.version) };
|
||||
}
|
||||
|
||||
return { versions: MINECRAFT_VERSIONS };
|
||||
}
|
||||
|
||||
@Get('regions')
|
||||
@ApiOperation({ summary: 'List available deployment regions' })
|
||||
async listRegions(): Promise<{
|
||||
regions: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
hostname: string;
|
||||
status: string;
|
||||
}>;
|
||||
}> {
|
||||
const nodes = await this.prisma.gameNode.findMany({
|
||||
where: { status: { in: ['ONLINE', 'OFFLINE'] } },
|
||||
select: { id: true, name: true, hostname: true, status: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
return {
|
||||
regions: nodes.map((node) => ({
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
hostname: node.hostname,
|
||||
status: node.status,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('projects')
|
||||
@ApiOperation({ summary: 'Search Modrinth projects' })
|
||||
search(@Query() query: Record<string, string | undefined>) {
|
||||
@@ -64,4 +126,17 @@ export class CatalogController {
|
||||
resolvedSoftwareFamily as 'PAPER' | 'FABRIC' | 'PURPUR' | 'FORGE' | 'NEOFORGE' | 'QUILT',
|
||||
);
|
||||
}
|
||||
|
||||
@Get('curseforge/projects')
|
||||
@ApiOperation({ summary: 'Search CurseForge mods (requires API key)' })
|
||||
searchCurseForge(@Query('q') q?: string, @Query('limit') limit?: string) {
|
||||
if (!q?.trim()) {
|
||||
throw new BadRequestException('q is required');
|
||||
}
|
||||
const parsedLimit = limit ? Number.parseInt(limit, 10) : 20;
|
||||
return this.catalogService.searchCurseForge({
|
||||
query: q.trim(),
|
||||
limit: Number.isFinite(parsedLimit) ? parsedLimit : 20,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
isVersionCompatible,
|
||||
resolveModrinthLoader,
|
||||
resolveProjectType,
|
||||
searchCurseForgeMods,
|
||||
searchModrinthProjects,
|
||||
type SoftwareFamily,
|
||||
} from '@hexahost/catalog';
|
||||
@@ -78,4 +79,26 @@ export class CatalogService {
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async searchCurseForge(input: { query: string; limit?: number }) {
|
||||
try {
|
||||
const result = await searchCurseForgeMods({
|
||||
searchFilter: input.query,
|
||||
pageSize: input.limit ?? 20,
|
||||
});
|
||||
return {
|
||||
provider: 'curseforge' as const,
|
||||
projects: result.data.map((project) => ({
|
||||
id: String(project.id),
|
||||
slug: project.slug,
|
||||
name: project.name,
|
||||
description: project.summary,
|
||||
downloads: project.downloadCount,
|
||||
iconUrl: project.logo?.thumbnailUrl ?? null,
|
||||
})),
|
||||
};
|
||||
} catch {
|
||||
throw new BadRequestException('CurseForge integration is not configured');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
20
apps/api/src/common/csrf/csrf.controller.ts
Normal file
20
apps/api/src/common/csrf/csrf.controller.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Controller, Get, Res, UseGuards } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
|
||||
import { CsrfService } from './csrf.service';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
@SkipThrottle()
|
||||
export class CsrfController {
|
||||
constructor(private readonly csrf: CsrfService) {}
|
||||
|
||||
@Get('csrf')
|
||||
@ApiOperation({ summary: 'Issue a CSRF token for browser clients' })
|
||||
issue(@Res({ passthrough: true }) reply: FastifyReply): { token: string } {
|
||||
const token = this.csrf.issueToken(reply);
|
||||
return { token };
|
||||
}
|
||||
}
|
||||
19
apps/api/src/common/csrf/csrf.guard.ts
Normal file
19
apps/api/src/common/csrf/csrf.guard.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
import { CsrfService } from './csrf.service';
|
||||
|
||||
@Injectable()
|
||||
export class CsrfGuard implements CanActivate {
|
||||
constructor(private readonly csrf: CsrfService) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
||||
this.csrf.validate(request);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
15
apps/api/src/common/csrf/csrf.module.ts
Normal file
15
apps/api/src/common/csrf/csrf.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AppConfigModule } from '../../config/app-config.module';
|
||||
|
||||
import { CsrfController } from './csrf.controller';
|
||||
import { CsrfGuard } from './csrf.guard';
|
||||
import { CsrfService } from './csrf.service';
|
||||
|
||||
@Module({
|
||||
imports: [AppConfigModule],
|
||||
controllers: [CsrfController],
|
||||
providers: [CsrfService, CsrfGuard],
|
||||
exports: [CsrfService, CsrfGuard],
|
||||
})
|
||||
export class CsrfModule {}
|
||||
77
apps/api/src/common/csrf/csrf.service.ts
Normal file
77
apps/api/src/common/csrf/csrf.service.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
import type { AppConfig } from '@hexahost/config';
|
||||
|
||||
import { APP_CONFIG } from '../../config/config.constants';
|
||||
|
||||
const CSRF_COOKIE = 'hgc_csrf';
|
||||
const CSRF_HEADER = 'x-csrf-token';
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
@Injectable()
|
||||
export class CsrfService {
|
||||
constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {}
|
||||
|
||||
issueToken(reply: FastifyReply): string {
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
const signed = this.sign(token);
|
||||
|
||||
reply.setCookie(CSRF_COOKIE, signed, {
|
||||
httpOnly: false,
|
||||
sameSite: 'lax',
|
||||
secure: this.config.APP_ENV === 'production',
|
||||
path: '/',
|
||||
});
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
validate(request: FastifyRequest): void {
|
||||
const method = request.method.toUpperCase();
|
||||
|
||||
if (SAFE_METHODS.has(method)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = request.url.split('?')[0] ?? '';
|
||||
|
||||
if (
|
||||
path.startsWith('/api/v1/integrations/') ||
|
||||
path.startsWith('/api/v1/internal/') ||
|
||||
path.startsWith('/api/v1/webhooks/') ||
|
||||
path.startsWith('/api/v1/health/')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cookieToken = this.readSignedCookie(request);
|
||||
const headerToken = request.headers[CSRF_HEADER];
|
||||
|
||||
if (typeof headerToken !== 'string' || !cookieToken) {
|
||||
throw new UnauthorizedException('CSRF token missing');
|
||||
}
|
||||
|
||||
const expected = this.sign(headerToken);
|
||||
const actual = cookieToken;
|
||||
|
||||
if (
|
||||
expected.length !== actual.length ||
|
||||
!timingSafeEqual(Buffer.from(expected), Buffer.from(actual))
|
||||
) {
|
||||
throw new UnauthorizedException('CSRF token invalid');
|
||||
}
|
||||
}
|
||||
|
||||
private readSignedCookie(request: FastifyRequest): string | null {
|
||||
const cookies = request.cookies as Record<string, string | undefined>;
|
||||
return cookies[CSRF_COOKIE] ?? null;
|
||||
}
|
||||
|
||||
private sign(token: string): string {
|
||||
return createHmac('sha256', this.config.SESSION_SECRET)
|
||||
.update(token)
|
||||
.digest('base64url');
|
||||
}
|
||||
}
|
||||
71
apps/api/src/common/interceptors/idempotency.interceptor.ts
Normal file
71
apps/api/src/common/interceptors/idempotency.interceptor.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Inject,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
import { REDIS_CLIENT } from '../../servers/servers.service';
|
||||
|
||||
const IDEMPOTENCY_HEADER = 'idempotency-key';
|
||||
const TTL_SECONDS = 86_400;
|
||||
|
||||
@Injectable()
|
||||
export class IdempotencyInterceptor implements NestInterceptor {
|
||||
constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {}
|
||||
|
||||
async intercept(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler,
|
||||
): Promise<Observable<unknown>> {
|
||||
const request = context.switchToHttp().getRequest<{
|
||||
method?: string;
|
||||
headers?: Record<string, string | string[] | undefined>;
|
||||
user?: { id?: string };
|
||||
url?: string;
|
||||
}>();
|
||||
|
||||
if (!request.method || !['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method)) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const rawKey = request.headers?.[IDEMPOTENCY_HEADER];
|
||||
const key = Array.isArray(rawKey) ? rawKey[0] : rawKey;
|
||||
if (!key || key.length < 8) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const userId = request.user?.id ?? 'anonymous';
|
||||
const cacheKey = `idempotency:panel:${userId}:${key}:${request.method}:${request.url ?? ''}`;
|
||||
const cached = await this.redis.get(cacheKey);
|
||||
if (cached) {
|
||||
return of(JSON.parse(cached) as unknown);
|
||||
}
|
||||
|
||||
return new Observable((subscriber) => {
|
||||
next.handle().subscribe({
|
||||
next: (value) => {
|
||||
void this.redis
|
||||
.set(cacheKey, JSON.stringify(value), 'EX', TTL_SECONDS)
|
||||
.catch(() => undefined);
|
||||
subscriber.next(value);
|
||||
subscriber.complete();
|
||||
},
|
||||
error: (error: unknown) => subscriber.error(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function assertIdempotencyKey(headers: Record<string, unknown>): string {
|
||||
const raw = headers[IDEMPOTENCY_HEADER];
|
||||
const key = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (typeof key !== 'string' || key.length < 8) {
|
||||
throw new BadRequestException('Idempotency-Key header is required');
|
||||
}
|
||||
return key;
|
||||
}
|
||||
63
apps/api/src/compliance/compliance.controller.ts
Normal file
63
apps/api/src/compliance/compliance.controller.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { requestAccountDeletionSchema } from '@hexahost/contracts';
|
||||
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { SessionGuard } from '../auth/guards/session.guard';
|
||||
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
|
||||
|
||||
import { ComplianceService } from './compliance.service';
|
||||
|
||||
@ApiTags('account')
|
||||
@Controller('account/compliance')
|
||||
@UseGuards(SessionGuard)
|
||||
export class ComplianceController {
|
||||
constructor(private readonly complianceService: ComplianceService) {}
|
||||
|
||||
@Post('export')
|
||||
requestExport(@CurrentUser() user: { id: string }) {
|
||||
return this.complianceService.requestDataExport(user.id);
|
||||
}
|
||||
|
||||
@Get('export')
|
||||
listExports(@CurrentUser() user: { id: string }) {
|
||||
return this.complianceService.listDataExports(user.id);
|
||||
}
|
||||
|
||||
@Get('export/:exportId/download')
|
||||
downloadExport(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('exportId', ParseUUIDPipe) exportId: string,
|
||||
) {
|
||||
return this.complianceService.getExportDownload(user.id, exportId);
|
||||
}
|
||||
|
||||
@Post('deletion')
|
||||
requestDeletion(
|
||||
@CurrentUser() user: { id: string; email: string },
|
||||
@Body(new ZodValidationPipe(requestAccountDeletionSchema)) body: unknown,
|
||||
) {
|
||||
const parsed = body as { confirmEmail: string };
|
||||
return this.complianceService.requestAccountDeletion(user.id, parsed.confirmEmail);
|
||||
}
|
||||
|
||||
@Get('deletion')
|
||||
deletionStatus(@CurrentUser() user: { id: string }) {
|
||||
return this.complianceService.getAccountDeletionStatus(user.id);
|
||||
}
|
||||
|
||||
@Delete('deletion')
|
||||
cancelDeletion(@CurrentUser() user: { id: string }) {
|
||||
return this.complianceService.cancelAccountDeletion(user.id);
|
||||
}
|
||||
}
|
||||
16
apps/api/src/compliance/compliance.module.ts
Normal file
16
apps/api/src/compliance/compliance.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { ServersModule } from '../servers/servers.module';
|
||||
|
||||
import { ComplianceController } from './compliance.controller';
|
||||
import { ComplianceService } from './compliance.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AuthModule, ServersModule],
|
||||
controllers: [ComplianceController],
|
||||
providers: [ComplianceService],
|
||||
exports: [ComplianceService],
|
||||
})
|
||||
export class ComplianceModule {}
|
||||
400
apps/api/src/compliance/compliance.service.ts
Normal file
400
apps/api/src/compliance/compliance.service.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import type Redis from 'ioredis';
|
||||
|
||||
import { getConfig } from '@hexahost/config';
|
||||
import type { GameServerStatus } from '@hexahost/server-state';
|
||||
import { ALLOWED_TRANSITIONS } from '@hexahost/server-state';
|
||||
import {
|
||||
createPresignedGetUrl,
|
||||
createS3Client,
|
||||
loadStorageConfig,
|
||||
} from '@hexahost/storage';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
REDIS_CLIENT,
|
||||
SERVER_LIFECYCLE_QUEUE,
|
||||
} from '../servers/servers.service';
|
||||
|
||||
const DELETION_GRACE_DAYS = 14;
|
||||
const EXPORT_TTL_DAYS = 7;
|
||||
const EXPORT_BLOB_PREFIX = 'export:blob:';
|
||||
|
||||
function isStorageConfigured(): boolean {
|
||||
return Boolean(process.env['S3_ENDPOINT']?.trim());
|
||||
}
|
||||
|
||||
function exportObjectKey(userId: string, exportId: string): string {
|
||||
return `users/${userId}/exports/${exportId}.json`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ComplianceService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||
@Inject(SERVER_LIFECYCLE_QUEUE)
|
||||
private readonly lifecycleQueue: Queue,
|
||||
) {}
|
||||
|
||||
async requestDataExport(userId: string) {
|
||||
const existing = await this.prisma.dataExportRequest.findFirst({
|
||||
where: { userId, status: { in: ['PENDING', 'PROCESSING', 'READY'] } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (existing) {
|
||||
return this.mapExport(existing);
|
||||
}
|
||||
|
||||
const created = await this.prisma.dataExportRequest.create({
|
||||
data: { userId, status: 'PENDING' },
|
||||
});
|
||||
|
||||
void this.processDataExport(created.id).catch(() => undefined);
|
||||
|
||||
return this.mapExport(created);
|
||||
}
|
||||
|
||||
async listDataExports(userId: string) {
|
||||
const rows = await this.prisma.dataExportRequest.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
});
|
||||
return { exports: rows.map((row) => this.mapExport(row)) };
|
||||
}
|
||||
|
||||
async getExportDownload(userId: string, exportId: string) {
|
||||
const row = await this.prisma.dataExportRequest.findFirst({
|
||||
where: { id: exportId, userId },
|
||||
});
|
||||
if (!row || row.status !== 'READY') {
|
||||
throw new NotFoundException('Export not available');
|
||||
}
|
||||
if (row.expiresAt && row.expiresAt <= new Date()) {
|
||||
throw new NotFoundException('Export has expired');
|
||||
}
|
||||
|
||||
if (row.downloadUrl?.startsWith('s3://')) {
|
||||
const [, bucket, ...keyParts] = row.downloadUrl.split('/');
|
||||
const key = keyParts.join('/');
|
||||
const config = loadStorageConfig();
|
||||
const client = createS3Client(config);
|
||||
const url = await createPresignedGetUrl(client, bucket!, key, 3600);
|
||||
return { url, expiresInSeconds: 3600 };
|
||||
}
|
||||
|
||||
const blob = await this.redis.get(`${EXPORT_BLOB_PREFIX}${exportId}`);
|
||||
if (!blob) {
|
||||
throw new NotFoundException('Export data not found');
|
||||
}
|
||||
|
||||
return {
|
||||
contentType: 'application/json',
|
||||
data: JSON.parse(blob) as unknown,
|
||||
};
|
||||
}
|
||||
|
||||
async requestAccountDeletion(userId: string, confirmEmail: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user || user.email !== confirmEmail) {
|
||||
throw new BadRequestException('Email confirmation does not match');
|
||||
}
|
||||
|
||||
const scheduledFor = new Date();
|
||||
scheduledFor.setDate(scheduledFor.getDate() + DELETION_GRACE_DAYS);
|
||||
|
||||
const existing = await this.prisma.accountDeletionRequest.findFirst({
|
||||
where: { userId, status: { in: ['PENDING', 'SCHEDULED'] } },
|
||||
});
|
||||
if (existing) {
|
||||
return this.mapDeletion(existing);
|
||||
}
|
||||
|
||||
const created = await this.prisma.accountDeletionRequest.create({
|
||||
data: {
|
||||
userId,
|
||||
status: 'SCHEDULED',
|
||||
scheduledFor,
|
||||
},
|
||||
});
|
||||
return this.mapDeletion(created);
|
||||
}
|
||||
|
||||
async cancelAccountDeletion(userId: string) {
|
||||
const pending = await this.prisma.accountDeletionRequest.findFirst({
|
||||
where: { userId, status: { in: ['PENDING', 'SCHEDULED'] } },
|
||||
});
|
||||
if (!pending) {
|
||||
throw new NotFoundException('No pending deletion request');
|
||||
}
|
||||
const updated = await this.prisma.accountDeletionRequest.update({
|
||||
where: { id: pending.id },
|
||||
data: { status: 'CANCELLED' },
|
||||
});
|
||||
return this.mapDeletion(updated);
|
||||
}
|
||||
|
||||
async getAccountDeletionStatus(userId: string) {
|
||||
const pending = await this.prisma.accountDeletionRequest.findFirst({
|
||||
where: { userId, status: { in: ['PENDING', 'SCHEDULED'] } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return { request: pending ? this.mapDeletion(pending) : null };
|
||||
}
|
||||
|
||||
async processPendingExports(limit = 10): Promise<number> {
|
||||
const pending = await this.prisma.dataExportRequest.findMany({
|
||||
where: { status: 'PENDING' },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
for (const row of pending) {
|
||||
await this.processDataExport(row.id);
|
||||
}
|
||||
|
||||
return pending.length;
|
||||
}
|
||||
|
||||
async processDueDeletions(limit = 10): Promise<number> {
|
||||
const due = await this.prisma.accountDeletionRequest.findMany({
|
||||
where: { status: 'SCHEDULED', scheduledFor: { lte: new Date() } },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
for (const row of due) {
|
||||
await this.executeAccountDeletion(row.id);
|
||||
}
|
||||
|
||||
return due.length;
|
||||
}
|
||||
|
||||
async processDataExport(exportId: string): Promise<void> {
|
||||
const row = await this.prisma.dataExportRequest.findUnique({
|
||||
where: { id: exportId },
|
||||
});
|
||||
if (!row || !['PENDING', 'PROCESSING'].includes(row.status)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.prisma.dataExportRequest.update({
|
||||
where: { id: exportId },
|
||||
data: { status: 'PROCESSING' },
|
||||
});
|
||||
|
||||
try {
|
||||
const payload = await this.collectUserExportData(row.userId);
|
||||
const json = JSON.stringify(payload, null, 2);
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + EXPORT_TTL_DAYS);
|
||||
|
||||
let downloadUrl: string;
|
||||
|
||||
if (isStorageConfigured()) {
|
||||
const config = loadStorageConfig();
|
||||
const client = createS3Client(config);
|
||||
const key = exportObjectKey(row.userId, exportId);
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: config.S3_BUCKET,
|
||||
Key: key,
|
||||
Body: json,
|
||||
ContentType: 'application/json',
|
||||
}),
|
||||
);
|
||||
downloadUrl = `s3://${config.S3_BUCKET}/${key}`;
|
||||
} else {
|
||||
await this.redis.set(
|
||||
`${EXPORT_BLOB_PREFIX}${exportId}`,
|
||||
json,
|
||||
'EX',
|
||||
EXPORT_TTL_DAYS * 24 * 60 * 60,
|
||||
);
|
||||
const config = getConfig();
|
||||
downloadUrl = `${config.API_URL}/account/compliance/export/${exportId}/download`;
|
||||
}
|
||||
|
||||
await this.prisma.dataExportRequest.update({
|
||||
where: { id: exportId },
|
||||
data: {
|
||||
status: 'READY',
|
||||
downloadUrl,
|
||||
expiresAt,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
await this.prisma.dataExportRequest.update({
|
||||
where: { id: exportId },
|
||||
data: { status: 'FAILED' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async executeAccountDeletion(requestId: string): Promise<void> {
|
||||
const request = await this.prisma.accountDeletionRequest.findUnique({
|
||||
where: { id: requestId },
|
||||
});
|
||||
if (!request || request.status !== 'SCHEDULED') {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = request.userId;
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user || user.status === 'DELETED') {
|
||||
await this.prisma.accountDeletionRequest.update({
|
||||
where: { id: requestId },
|
||||
data: { status: 'COMPLETED', completedAt: new Date() },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const servers = await this.prisma.gameServer.findMany({
|
||||
where: { userId, status: { not: 'DELETED' } },
|
||||
});
|
||||
|
||||
for (const server of servers) {
|
||||
if (server.status === 'RUNNING' || server.status === 'STARTING') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ALLOWED_TRANSITIONS[server.status as GameServerStatus]?.includes('DELETING')) {
|
||||
const correlationId = randomUUID();
|
||||
await this.prisma.gameServer.update({
|
||||
where: { id: server.id },
|
||||
data: { status: 'DELETING' },
|
||||
});
|
||||
await this.lifecycleQueue.add(
|
||||
'delete-server',
|
||||
{ serverId: server.id, action: 'delete', correlationId },
|
||||
{ jobId: correlationId },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const anonymizedId = randomUUID().slice(0, 8);
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.userSession.updateMany({
|
||||
where: { userId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
}),
|
||||
this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
email: `deleted-${anonymizedId}@deleted.invalid`,
|
||||
username: `deleted-${anonymizedId}`,
|
||||
displayName: null,
|
||||
status: 'DELETED',
|
||||
},
|
||||
}),
|
||||
this.prisma.accountDeletionRequest.update({
|
||||
where: { id: requestId },
|
||||
data: { status: 'COMPLETED', completedAt: new Date() },
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
private async collectUserExportData(userId: string) {
|
||||
const [user, servers, auditEvents] = await Promise.all([
|
||||
this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
status: true,
|
||||
emailVerifiedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.gameServer.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
edition: true,
|
||||
softwareFamily: true,
|
||||
minecraftVersion: true,
|
||||
ramMb: true,
|
||||
hostPort: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
this.prisma.auditEvent.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
action: true,
|
||||
entityType: true,
|
||||
entityId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 500,
|
||||
}),
|
||||
]);
|
||||
|
||||
const actionCounts = auditEvents.reduce<Record<string, number>>((acc, event) => {
|
||||
acc[event.action] = (acc[event.action] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
exportedAt: new Date().toISOString(),
|
||||
profile: user,
|
||||
servers,
|
||||
auditSummary: {
|
||||
totalEvents: auditEvents.length,
|
||||
actionCounts,
|
||||
recentEvents: auditEvents.slice(0, 50),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private mapExport(row: {
|
||||
id: string;
|
||||
status: string;
|
||||
downloadUrl: string | null;
|
||||
expiresAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
status: row.status as 'PENDING' | 'PROCESSING' | 'READY' | 'FAILED' | 'EXPIRED',
|
||||
downloadUrl: row.downloadUrl,
|
||||
expiresAt: row.expiresAt?.toISOString() ?? null,
|
||||
completedAt: row.completedAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private mapDeletion(row: {
|
||||
id: string;
|
||||
status: string;
|
||||
scheduledFor: Date;
|
||||
completedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
status: row.status as 'PENDING' | 'SCHEDULED' | 'COMPLETED' | 'CANCELLED',
|
||||
scheduledFor: row.scheduledFor.toISOString(),
|
||||
completedAt: row.completedAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
37
apps/api/src/feature-flags/feature-flags.controller.ts
Normal file
37
apps/api/src/feature-flags/feature-flags.controller.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { FeatureFlagService } from '@hexahost/feature-flags';
|
||||
|
||||
import { Public } from '../auth/decorators/current-user.decorator';
|
||||
import { SessionGuard } from '../auth/guards/session.guard';
|
||||
|
||||
import { FEATURE_FLAG_SERVICE } from './feature-flags.module';
|
||||
|
||||
@ApiTags('system')
|
||||
@Controller('feature-flags')
|
||||
export class FeatureFlagsController {
|
||||
constructor(
|
||||
@Inject(FEATURE_FLAG_SERVICE) private readonly flags: FeatureFlagService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List public feature flags' })
|
||||
async list() {
|
||||
const flags = await this.flags.listFlags();
|
||||
return {
|
||||
flags: flags.map((flag) => ({
|
||||
key: flag.key,
|
||||
enabled: flag.enabled,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('admin')
|
||||
@UseGuards(SessionGuard)
|
||||
@ApiOperation({ summary: 'List feature flags with metadata (authenticated)' })
|
||||
async listDetailed() {
|
||||
return { flags: await this.flags.listFlags() };
|
||||
}
|
||||
}
|
||||
25
apps/api/src/feature-flags/feature-flags.module.ts
Normal file
25
apps/api/src/feature-flags/feature-flags.module.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { FeatureFlagService } from '@hexahost/feature-flags';
|
||||
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
import { FeatureFlagsController } from './feature-flags.controller';
|
||||
|
||||
export const FEATURE_FLAG_SERVICE = Symbol('FEATURE_FLAG_SERVICE');
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AuthModule],
|
||||
controllers: [FeatureFlagsController],
|
||||
providers: [
|
||||
{
|
||||
provide: FEATURE_FLAG_SERVICE,
|
||||
useFactory: (prisma: PrismaService) => new FeatureFlagService(prisma),
|
||||
inject: [PrismaService],
|
||||
},
|
||||
],
|
||||
exports: [FEATURE_FLAG_SERVICE],
|
||||
})
|
||||
export class FeatureFlagsModule {}
|
||||
@@ -109,6 +109,21 @@ export class WhmcsIntegrationController {
|
||||
);
|
||||
}
|
||||
|
||||
@Put('users/:externalUserId')
|
||||
upsertUser(
|
||||
@Req() request: WhmcsAuthenticatedRequest,
|
||||
@Param('externalUserId') externalUserId: string,
|
||||
@Body() body: unknown,
|
||||
) {
|
||||
const parsed = whmcsUpsertClientRequestSchema.parse(body);
|
||||
return this.whmcs.upsertClient(
|
||||
request.whmcsInstallation!,
|
||||
externalUserId,
|
||||
parsed,
|
||||
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('services')
|
||||
provisionService(@Req() request: WhmcsAuthenticatedRequest, @Body() body: unknown) {
|
||||
const parsed = whmcsProvisionServiceRequestSchema.parse(body);
|
||||
@@ -181,6 +196,21 @@ export class WhmcsIntegrationController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post('services/:externalServiceId/renew')
|
||||
renewService(
|
||||
@Req() request: WhmcsAuthenticatedRequest,
|
||||
@Param('externalServiceId') externalServiceId: string,
|
||||
@Body() body: unknown,
|
||||
) {
|
||||
const parsed = (body ?? {}) as { invoiceId?: string; monthlyCreditGrant?: number };
|
||||
return this.whmcs.renewService(
|
||||
request.whmcsInstallation!,
|
||||
externalServiceId,
|
||||
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
|
||||
parsed,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('services/:externalServiceId/sso')
|
||||
createServiceSso(
|
||||
@Req() request: WhmcsAuthenticatedRequest,
|
||||
@@ -284,6 +314,54 @@ export class WhmcsIntegrationController {
|
||||
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('services/:externalServiceId/actions/start')
|
||||
startService(
|
||||
@Req() request: WhmcsAuthenticatedRequest,
|
||||
@Param('externalServiceId') externalServiceId: string,
|
||||
) {
|
||||
return this.whmcs.startServiceAction(
|
||||
request.whmcsInstallation!,
|
||||
externalServiceId,
|
||||
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('services/:externalServiceId/actions/stop')
|
||||
stopService(
|
||||
@Req() request: WhmcsAuthenticatedRequest,
|
||||
@Param('externalServiceId') externalServiceId: string,
|
||||
) {
|
||||
return this.whmcs.stopServiceAction(
|
||||
request.whmcsInstallation!,
|
||||
externalServiceId,
|
||||
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('services/:externalServiceId/actions/restart')
|
||||
restartService(
|
||||
@Req() request: WhmcsAuthenticatedRequest,
|
||||
@Param('externalServiceId') externalServiceId: string,
|
||||
) {
|
||||
return this.whmcs.restartServiceAction(
|
||||
request.whmcsInstallation!,
|
||||
externalServiceId,
|
||||
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('services/:externalServiceId/actions/backup')
|
||||
backupService(
|
||||
@Req() request: WhmcsAuthenticatedRequest,
|
||||
@Param('externalServiceId') externalServiceId: string,
|
||||
) {
|
||||
return this.whmcs.backupServiceAction(
|
||||
request.whmcsInstallation!,
|
||||
externalServiceId,
|
||||
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function headerValue(request: WhmcsAuthenticatedRequest, header: string): string | undefined {
|
||||
|
||||
@@ -3,6 +3,7 @@ import Redis from 'ioredis';
|
||||
|
||||
import { getConfig } from '@hexahost/config';
|
||||
|
||||
import { BackupsModule } from '../../servers/backups/backups.module';
|
||||
import { BillingModule } from '../../billing/billing.module';
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { ServersModule } from '../../servers/servers.module';
|
||||
@@ -20,7 +21,7 @@ import { WhmcsUsageService } from './whmcs-usage.service';
|
||||
export { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
|
||||
|
||||
@Module({
|
||||
imports: [ServersModule, BillingModule, AuthModule],
|
||||
imports: [ServersModule, BackupsModule, BillingModule, AuthModule],
|
||||
controllers: [WhmcsIntegrationController],
|
||||
providers: [
|
||||
WhmcsIntegrationService,
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { GameServer } from '@hexahost/database';
|
||||
import { BillingService } from '../../billing/billing.service';
|
||||
import { getBillingProvider } from '../../billing/billing-provider';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { BackupsService } from '../../servers/backups/backups.service';
|
||||
import { ServersService } from '../../servers/servers.service';
|
||||
|
||||
import type { WhmcsInstallationContext } from './whmcs-integration.guard';
|
||||
@@ -34,6 +35,7 @@ export class WhmcsIntegrationService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly serversService: ServersService,
|
||||
private readonly backupsService: BackupsService,
|
||||
private readonly billing: BillingService,
|
||||
) {}
|
||||
|
||||
@@ -356,6 +358,67 @@ export class WhmcsIntegrationService {
|
||||
return response;
|
||||
}
|
||||
|
||||
async renewService(
|
||||
installation: InstallationRef,
|
||||
externalServiceId: string,
|
||||
idempotencyKey: string,
|
||||
body: { invoiceId?: string; monthlyCreditGrant?: number },
|
||||
): 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 (body.monthlyCreditGrant && body.monthlyCreditGrant > 0) {
|
||||
const wallet = await this.prisma.creditWallet.findUnique({
|
||||
where: { userId: server.userId },
|
||||
});
|
||||
|
||||
if (wallet) {
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.creditWallet.update({
|
||||
where: { id: wallet.id },
|
||||
data: { balance: { increment: body.monthlyCreditGrant } },
|
||||
}),
|
||||
this.prisma.creditTransaction.create({
|
||||
data: {
|
||||
walletId: wallet.id,
|
||||
amount: body.monthlyCreditGrant,
|
||||
type: 'GRANT',
|
||||
referenceType: 'whmcs_renew',
|
||||
referenceId: body.invoiceId ?? externalServiceId,
|
||||
idempotencyKey,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedLink = await this.prisma.whmcsServiceLink.update({
|
||||
where: { id: link.id },
|
||||
data: { status: 'ACTIVE', suspendedAt: null },
|
||||
});
|
||||
|
||||
const response = this.toServiceResponse(
|
||||
externalServiceId,
|
||||
updatedLink.status,
|
||||
server,
|
||||
);
|
||||
|
||||
await this.recordOperation(
|
||||
installation.id,
|
||||
idempotencyKey,
|
||||
'service:renew',
|
||||
response,
|
||||
externalServiceId,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async terminateService(
|
||||
installation: InstallationRef,
|
||||
externalServiceId: string,
|
||||
@@ -530,4 +593,93 @@ export class WhmcsIntegrationService {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async startServiceAction(
|
||||
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 result = await this.serversService.startServerInternal(
|
||||
link.server.id,
|
||||
`whmcs:start:${externalServiceId}`,
|
||||
);
|
||||
|
||||
const response = this.toServiceResponse(
|
||||
externalServiceId,
|
||||
link.status,
|
||||
result.server as unknown as GameServer,
|
||||
);
|
||||
await this.recordOperation(installation.id, idempotencyKey, 'service:start', response, externalServiceId);
|
||||
return response;
|
||||
}
|
||||
|
||||
async stopServiceAction(
|
||||
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 result = await this.serversService.stopServer(link.server.userId, link.server.id);
|
||||
const response = this.toServiceResponse(
|
||||
externalServiceId,
|
||||
link.status,
|
||||
result.server as unknown as GameServer,
|
||||
);
|
||||
await this.recordOperation(installation.id, idempotencyKey, 'service:stop', response, externalServiceId);
|
||||
return response;
|
||||
}
|
||||
|
||||
async restartServiceAction(
|
||||
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 result = await this.serversService.restartServer(link.server.userId, link.server.id);
|
||||
const response = this.toServiceResponse(
|
||||
externalServiceId,
|
||||
link.status,
|
||||
result.server as unknown as GameServer,
|
||||
);
|
||||
await this.recordOperation(installation.id, idempotencyKey, 'service:restart', response, externalServiceId);
|
||||
return response;
|
||||
}
|
||||
|
||||
async backupServiceAction(
|
||||
installation: InstallationRef,
|
||||
externalServiceId: string,
|
||||
idempotencyKey: string,
|
||||
): Promise<{ externalServiceId: string; jobId: string }> {
|
||||
const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey);
|
||||
if (existingOp?.result) {
|
||||
return existingOp.result as { externalServiceId: string; jobId: string };
|
||||
}
|
||||
|
||||
const link = await this.findServiceLink(installation.id, externalServiceId);
|
||||
const backup = await this.backupsService.createBackup(link.server.userId, link.server.id, {
|
||||
label: 'WHMCS backup',
|
||||
});
|
||||
const response = {
|
||||
externalServiceId,
|
||||
jobId: backup.jobId ?? idempotencyKey,
|
||||
};
|
||||
await this.recordOperation(installation.id, idempotencyKey, 'service:backup', response, externalServiceId);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,14 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
|
||||
import { validateConfig } from '@hexahost/config';
|
||||
import { initOpenTelemetry, shutdownOpenTelemetry } from '@hexahost/otel';
|
||||
|
||||
import { AppModule } from './app.module';
|
||||
import { Rfc7807ExceptionFilter } from './common/filters/rfc7807-exception.filter';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const config = validateConfig();
|
||||
initOpenTelemetry('hexahost-api');
|
||||
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
AppModule,
|
||||
|
||||
@@ -167,6 +167,9 @@ export class NodesService {
|
||||
case 'server.files.list.result':
|
||||
case 'server.files.read.result':
|
||||
case 'server.files.write.result':
|
||||
case 'server.files.delete.result':
|
||||
case 'server.files.archive.result':
|
||||
case 'server.files.unarchive.result':
|
||||
case 'server.command.result':
|
||||
await this.handleDirectResult(envelope);
|
||||
break;
|
||||
|
||||
61
apps/api/src/notifications/notifications.controller.ts
Normal file
61
apps/api/src/notifications/notifications.controller.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { updateNotificationPreferencesSchema } from '@hexahost/contracts';
|
||||
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { SessionGuard } from '../auth/guards/session.guard';
|
||||
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
|
||||
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
@ApiTags('account')
|
||||
@Controller('account/notifications')
|
||||
@UseGuards(SessionGuard)
|
||||
export class NotificationsController {
|
||||
constructor(private readonly notificationsService: NotificationsService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: { id: string }) {
|
||||
return this.notificationsService.list(user.id);
|
||||
}
|
||||
|
||||
@Post(':id/read')
|
||||
markRead(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.notificationsService.markRead(user.id, id);
|
||||
}
|
||||
|
||||
@Post('read-all')
|
||||
markAllRead(@CurrentUser() user: { id: string }) {
|
||||
return this.notificationsService.markAllRead(user.id);
|
||||
}
|
||||
|
||||
@Get('preferences')
|
||||
getPreferences(@CurrentUser() user: { id: string }) {
|
||||
return this.notificationsService.getPreferences(user.id);
|
||||
}
|
||||
|
||||
@Patch('preferences')
|
||||
@ApiOperation({ summary: 'Update notification channel preferences' })
|
||||
updatePreferences(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Body(new ZodValidationPipe(updateNotificationPreferencesSchema)) body: unknown,
|
||||
) {
|
||||
return this.notificationsService.updatePreferences(
|
||||
user.id,
|
||||
body as Parameters<NotificationsService['updatePreferences']>[1],
|
||||
);
|
||||
}
|
||||
}
|
||||
15
apps/api/src/notifications/notifications.module.ts
Normal file
15
apps/api/src/notifications/notifications.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
import { NotificationsController } from './notifications.controller';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AuthModule],
|
||||
controllers: [NotificationsController],
|
||||
providers: [NotificationsService],
|
||||
exports: [NotificationsService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
166
apps/api/src/notifications/notifications.service.ts
Normal file
166
apps/api/src/notifications/notifications.service.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
|
||||
import type {
|
||||
NotificationListResponse,
|
||||
UpdateNotificationPreferences,
|
||||
} from '@hexahost/contracts';
|
||||
|
||||
import { NOTIFICATIONS_QUEUE } from '../auth/auth.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(NOTIFICATIONS_QUEUE) private readonly notificationsQueue: Queue,
|
||||
) {}
|
||||
|
||||
async list(userId: string): Promise<NotificationListResponse> {
|
||||
const [notifications, unreadCount] = await Promise.all([
|
||||
this.prisma.notification.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
}),
|
||||
this.prisma.notification.count({
|
||||
where: { userId, readAt: null },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
unreadCount,
|
||||
notifications: notifications.map((n) => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
channel: n.channel,
|
||||
title: n.title,
|
||||
body: n.body,
|
||||
readAt: n.readAt?.toISOString() ?? null,
|
||||
createdAt: n.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async markRead(userId: string, notificationId: string) {
|
||||
await this.prisma.notification.updateMany({
|
||||
where: { id: notificationId, userId },
|
||||
data: { readAt: new Date() },
|
||||
});
|
||||
return { read: true };
|
||||
}
|
||||
|
||||
async markAllRead(userId: string) {
|
||||
await this.prisma.notification.updateMany({
|
||||
where: { userId, readAt: null },
|
||||
data: { readAt: new Date() },
|
||||
});
|
||||
return { read: true };
|
||||
}
|
||||
|
||||
async getPreferences(userId: string): Promise<{
|
||||
preferences: Array<{ type: string; channel: string; enabled: boolean }>;
|
||||
}> {
|
||||
const prefs = await this.prisma.notificationPreference.findMany({
|
||||
where: { userId },
|
||||
});
|
||||
return {
|
||||
preferences: prefs.map((p) => ({
|
||||
type: p.type,
|
||||
channel: p.channel,
|
||||
enabled: p.enabled,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async updatePreferences(
|
||||
userId: string,
|
||||
input: UpdateNotificationPreferences,
|
||||
): Promise<{ preferences: Array<{ type: string; channel: string; enabled: boolean }> }> {
|
||||
await this.prisma.$transaction(
|
||||
input.preferences.map((pref) =>
|
||||
this.prisma.notificationPreference.upsert({
|
||||
where: {
|
||||
userId_type_channel: {
|
||||
userId,
|
||||
type: pref.type,
|
||||
channel: pref.channel,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
type: pref.type,
|
||||
channel: pref.channel,
|
||||
enabled: pref.enabled,
|
||||
},
|
||||
update: { enabled: pref.enabled },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return this.getPreferences(userId);
|
||||
}
|
||||
|
||||
async createInApp(
|
||||
userId: string,
|
||||
input: { type: NotificationListResponse['notifications'][number]['type']; title: string; body: string },
|
||||
): Promise<{ id: string }> {
|
||||
const created = await this.prisma.notification.create({
|
||||
data: {
|
||||
userId,
|
||||
type: input.type,
|
||||
channel: 'IN_APP',
|
||||
title: input.title,
|
||||
body: input.body,
|
||||
},
|
||||
});
|
||||
return { id: created.id };
|
||||
}
|
||||
|
||||
async dispatch(
|
||||
userId: string,
|
||||
input: {
|
||||
type: NotificationListResponse['notifications'][number]['type'];
|
||||
title: string;
|
||||
body: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<void> {
|
||||
await this.createInApp(userId, input);
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { email: true },
|
||||
});
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prefs = await this.prisma.notificationPreference.findMany({
|
||||
where: { userId, type: input.type, enabled: true },
|
||||
});
|
||||
const enabledChannels = new Set(prefs.map((p) => p.channel));
|
||||
|
||||
const channels = ['EMAIL', 'WEBHOOK', 'DISCORD'] as const;
|
||||
for (const channel of channels) {
|
||||
if (!enabledChannels.has(channel) && prefs.length > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.notificationsQueue.add(
|
||||
'dispatch-notification',
|
||||
{
|
||||
userId,
|
||||
type: input.type,
|
||||
channel,
|
||||
title: input.title,
|
||||
body: input.body,
|
||||
metadata: {
|
||||
...input.metadata,
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
{ removeOnComplete: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
69
apps/api/src/organizations/organizations.controller.ts
Normal file
69
apps/api/src/organizations/organizations.controller.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
createOrganizationSchema,
|
||||
inviteOrganizationMemberSchema,
|
||||
} from '@hexahost/contracts';
|
||||
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { SessionGuard } from '../auth/guards/session.guard';
|
||||
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
|
||||
|
||||
import { OrganizationsService } from './organizations.service';
|
||||
|
||||
@ApiTags('organizations')
|
||||
@Controller('organizations')
|
||||
@UseGuards(SessionGuard)
|
||||
export class OrganizationsController {
|
||||
constructor(private readonly organizationsService: OrganizationsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List current user's organizations" })
|
||||
list(@CurrentUser() user: { id: string }) {
|
||||
return this.organizationsService.list(user.id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a new organization' })
|
||||
create(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Body(new ZodValidationPipe(createOrganizationSchema)) body: unknown,
|
||||
) {
|
||||
return this.organizationsService.create(
|
||||
user.id,
|
||||
body as Parameters<OrganizationsService['create']>[1],
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':organizationId/members')
|
||||
@ApiOperation({ summary: 'List organization members' })
|
||||
listMembers(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('organizationId', ParseUUIDPipe) organizationId: string,
|
||||
) {
|
||||
return this.organizationsService.listMembers(user.id, organizationId);
|
||||
}
|
||||
|
||||
@Post(':organizationId/members')
|
||||
@ApiOperation({ summary: 'Invite a member to the organization' })
|
||||
inviteMember(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('organizationId', ParseUUIDPipe) organizationId: string,
|
||||
@Body(new ZodValidationPipe(inviteOrganizationMemberSchema)) body: unknown,
|
||||
) {
|
||||
return this.organizationsService.inviteMember(
|
||||
user.id,
|
||||
organizationId,
|
||||
body as Parameters<OrganizationsService['inviteMember']>[2],
|
||||
);
|
||||
}
|
||||
}
|
||||
15
apps/api/src/organizations/organizations.module.ts
Normal file
15
apps/api/src/organizations/organizations.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
import { OrganizationsController } from './organizations.controller';
|
||||
import { OrganizationsService } from './organizations.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AuthModule],
|
||||
controllers: [OrganizationsController],
|
||||
providers: [OrganizationsService],
|
||||
exports: [OrganizationsService],
|
||||
})
|
||||
export class OrganizationsModule {}
|
||||
217
apps/api/src/organizations/organizations.service.ts
Normal file
217
apps/api/src/organizations/organizations.service.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { generateSecureToken, hashToken } from '@hexahost/auth';
|
||||
import type {
|
||||
CreateOrganization,
|
||||
InviteOrganizationMember,
|
||||
Organization,
|
||||
OrganizationInviteResponse,
|
||||
OrganizationListResponse,
|
||||
OrganizationMemberListResponse,
|
||||
} from '@hexahost/contracts';
|
||||
import type {
|
||||
Organization as OrganizationRow,
|
||||
OrganizationMember,
|
||||
OrganizationMemberRole,
|
||||
} from '@hexahost/database';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const INVITE_TTL_DAYS = 7;
|
||||
|
||||
function slugifyName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 48);
|
||||
}
|
||||
|
||||
function mapOrganization(
|
||||
org: OrganizationRow,
|
||||
role: OrganizationMemberRole,
|
||||
): Organization {
|
||||
return {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
slug: org.slug,
|
||||
role,
|
||||
createdAt: org.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(userId: string): Promise<OrganizationListResponse> {
|
||||
const memberships = await this.prisma.organizationMember.findMany({
|
||||
where: { userId },
|
||||
include: { organization: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
return {
|
||||
organizations: memberships.map((membership) =>
|
||||
mapOrganization(membership.organization, membership.role),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async create(userId: string, input: CreateOrganization): Promise<Organization> {
|
||||
const baseSlug = input.slug ?? slugifyName(input.name);
|
||||
if (!baseSlug) {
|
||||
throw new ConflictException('Unable to derive organization slug');
|
||||
}
|
||||
|
||||
let slug = baseSlug;
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
const existing = await this.prisma.organization.findUnique({
|
||||
where: { slug },
|
||||
});
|
||||
if (!existing) {
|
||||
break;
|
||||
}
|
||||
slug = `${baseSlug}-${randomUUID().slice(0, 6)}`;
|
||||
}
|
||||
|
||||
const organization = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.organization.create({
|
||||
data: {
|
||||
name: input.name.trim(),
|
||||
slug,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.organizationMember.create({
|
||||
data: {
|
||||
organizationId: created.id,
|
||||
userId,
|
||||
role: 'OWNER',
|
||||
},
|
||||
});
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
return mapOrganization(organization, 'OWNER');
|
||||
}
|
||||
|
||||
async listMembers(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
): Promise<OrganizationMemberListResponse> {
|
||||
await this.requireMember(organizationId, userId);
|
||||
|
||||
const members = await this.prisma.organizationMember.findMany({
|
||||
where: { organizationId },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
return {
|
||||
members: members.map((member) => ({
|
||||
id: member.id,
|
||||
userId: member.userId,
|
||||
role: member.role,
|
||||
email: member.user.email,
|
||||
username: member.user.username,
|
||||
displayName: member.user.displayName,
|
||||
createdAt: member.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async inviteMember(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
input: InviteOrganizationMember,
|
||||
): Promise<OrganizationInviteResponse | { memberId: string; userId: string; role: string }> {
|
||||
const membership = await this.requireMember(organizationId, userId);
|
||||
if (!['OWNER', 'ADMIN'].includes(membership.role)) {
|
||||
throw new ForbiddenException('Only organization admins can invite members');
|
||||
}
|
||||
|
||||
const email = input.email.toLowerCase();
|
||||
const existingUser = await this.prisma.user.findUnique({ where: { email } });
|
||||
|
||||
if (existingUser) {
|
||||
const member = await this.prisma.organizationMember.upsert({
|
||||
where: {
|
||||
organizationId_userId: {
|
||||
organizationId,
|
||||
userId: existingUser.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
organizationId,
|
||||
userId: existingUser.id,
|
||||
role: input.role,
|
||||
},
|
||||
update: { role: input.role },
|
||||
});
|
||||
|
||||
return {
|
||||
memberId: member.id,
|
||||
userId: member.userId,
|
||||
role: member.role,
|
||||
};
|
||||
}
|
||||
|
||||
const token = generateSecureToken();
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + INVITE_TTL_DAYS);
|
||||
|
||||
const invite = await this.prisma.organizationInvite.create({
|
||||
data: {
|
||||
organizationId,
|
||||
email,
|
||||
role: input.role,
|
||||
tokenHash: hashToken(token),
|
||||
invitedById: userId,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: invite.id,
|
||||
email: invite.email,
|
||||
role: invite.role,
|
||||
expiresAt: invite.expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private async requireMember(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
): Promise<OrganizationMember> {
|
||||
const membership = await this.prisma.organizationMember.findUnique({
|
||||
where: {
|
||||
organizationId_userId: { organizationId, userId },
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFoundException('Organization not found');
|
||||
}
|
||||
|
||||
return membership;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
@@ -11,9 +15,13 @@ import {
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
archiveFilesSchema,
|
||||
filePathSchema,
|
||||
listFilesQuerySchema,
|
||||
readFileContentQuerySchema,
|
||||
unarchiveFileSchema,
|
||||
updateFileContentSchema,
|
||||
uploadFileSchema,
|
||||
} from '@hexahost/contracts';
|
||||
|
||||
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
|
||||
@@ -30,7 +38,6 @@ export class FilesController {
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List files in the server data directory' })
|
||||
@ApiResponse({ status: 200, description: 'Directory listing' })
|
||||
list(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@@ -41,7 +48,6 @@ export class FilesController {
|
||||
|
||||
@Get('content')
|
||||
@ApiOperation({ summary: 'Read a text file from the server' })
|
||||
@ApiResponse({ status: 200, description: 'File content' })
|
||||
read(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@@ -53,7 +59,6 @@ export class FilesController {
|
||||
|
||||
@Put('content')
|
||||
@ApiOperation({ summary: 'Write a text file on the server' })
|
||||
@ApiResponse({ status: 200, description: 'Updated file content' })
|
||||
write(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@@ -65,4 +70,59 @@ export class FilesController {
|
||||
body as Parameters<FilesService['writeFile']>[2],
|
||||
);
|
||||
}
|
||||
|
||||
@Post('upload')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Upload a file (base64) to the server' })
|
||||
upload(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Body(new ZodValidationPipe(uploadFileSchema)) body: unknown,
|
||||
) {
|
||||
return this.filesService.uploadFile(
|
||||
user.id,
|
||||
serverId,
|
||||
body as Parameters<FilesService['uploadFile']>[2],
|
||||
);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@ApiOperation({ summary: 'Delete a file or directory' })
|
||||
delete(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Query(new ZodValidationPipe(filePathSchema)) query: { path: string },
|
||||
) {
|
||||
return this.filesService.deleteFile(user.id, serverId, query.path);
|
||||
}
|
||||
|
||||
@Post('archive')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Create a tar.gz archive from selected paths' })
|
||||
archive(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Body(new ZodValidationPipe(archiveFilesSchema)) body: unknown,
|
||||
) {
|
||||
return this.filesService.archiveFiles(
|
||||
user.id,
|
||||
serverId,
|
||||
body as Parameters<FilesService['archiveFiles']>[2],
|
||||
);
|
||||
}
|
||||
|
||||
@Post('unarchive')
|
||||
@HttpCode(HttpStatus.ACCEPTED)
|
||||
@ApiOperation({ summary: 'Extract a tar.gz archive on the server' })
|
||||
unarchive(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Body(new ZodValidationPipe(unarchiveFileSchema)) body: unknown,
|
||||
) {
|
||||
return this.filesService.unarchiveFile(
|
||||
user.id,
|
||||
serverId,
|
||||
body as Parameters<FilesService['unarchiveFile']>[2],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,12 @@ import {
|
||||
} from '@nestjs/common';
|
||||
|
||||
import type {
|
||||
ArchiveFiles,
|
||||
FileContent,
|
||||
FileListResponse,
|
||||
UnarchiveFile,
|
||||
UpdateFileContent,
|
||||
UploadFile,
|
||||
} from '@hexahost/contracts';
|
||||
import type { GameServer } from '@hexahost/database';
|
||||
|
||||
@@ -19,6 +22,15 @@ import { assertSafeServerPath } from '../shared/server-path.util';
|
||||
|
||||
const FILE_ACCESS_STATUSES = new Set<GameServer['status']>(['RUNNING', 'STOPPED']);
|
||||
|
||||
interface AgentFileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
isDir?: boolean;
|
||||
type?: 'file' | 'directory';
|
||||
size?: number;
|
||||
modifiedAt?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FilesService {
|
||||
constructor(
|
||||
@@ -51,7 +63,18 @@ export class FilesService {
|
||||
);
|
||||
}
|
||||
|
||||
return response.result as FileListResponse;
|
||||
const raw = response.result as { entries?: AgentFileEntry[] };
|
||||
const entries = (raw.entries ?? []).map((entry) => ({
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
type: (entry.isDir ?? entry.type === 'directory' ? 'directory' : 'file') as
|
||||
| 'file'
|
||||
| 'directory',
|
||||
size: entry.size,
|
||||
modifiedAt: entry.modifiedAt,
|
||||
}));
|
||||
|
||||
return { path: safePath, entries };
|
||||
}
|
||||
|
||||
async readFile(
|
||||
@@ -78,7 +101,12 @@ export class FilesService {
|
||||
);
|
||||
}
|
||||
|
||||
return response.result as FileContent;
|
||||
const raw = response.result as { path: string; content: string };
|
||||
return {
|
||||
path: raw.path,
|
||||
content: raw.content,
|
||||
encoding: 'utf-8',
|
||||
};
|
||||
}
|
||||
|
||||
async writeFile(
|
||||
@@ -98,6 +126,7 @@ export class FilesService {
|
||||
path: safePath,
|
||||
content: input.content,
|
||||
encoding: input.encoding,
|
||||
create: true,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -115,7 +144,140 @@ export class FilesService {
|
||||
metadata: { path: safePath },
|
||||
});
|
||||
|
||||
return response.result as FileContent;
|
||||
return {
|
||||
path: safePath,
|
||||
content: input.content,
|
||||
encoding: input.encoding ?? 'utf-8',
|
||||
};
|
||||
}
|
||||
|
||||
async uploadFile(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
input: UploadFile,
|
||||
): Promise<FileContent> {
|
||||
const content =
|
||||
input.encoding === 'base64'
|
||||
? Buffer.from(input.content, 'base64').toString('utf-8')
|
||||
: input.content;
|
||||
|
||||
return this.writeFile(userId, serverId, {
|
||||
path: input.path,
|
||||
content,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
}
|
||||
|
||||
async deleteFile(userId: string, serverId: string, path: string): Promise<{ path: string }> {
|
||||
const server = await this.requireFileAccess(userId, serverId);
|
||||
const safePath = assertSafeServerPath(path);
|
||||
|
||||
const response = await this.nodeBridge.sendAgentRequest(
|
||||
server.nodeId!,
|
||||
'server.files.delete',
|
||||
{
|
||||
serverId,
|
||||
generation: server.version,
|
||||
path: safePath,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
throw new ServiceUnavailableException(
|
||||
response.errorMessage ?? 'Failed to delete file',
|
||||
);
|
||||
}
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'server.file.delete',
|
||||
userId,
|
||||
entityType: 'game_server',
|
||||
entityId: serverId,
|
||||
metadata: { path: safePath },
|
||||
});
|
||||
|
||||
return { path: safePath };
|
||||
}
|
||||
|
||||
async archiveFiles(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
input: ArchiveFiles,
|
||||
): Promise<{ archivePath: string; sha256?: string; size?: number }> {
|
||||
const server = await this.requireFileAccess(userId, serverId);
|
||||
const paths = input.paths.map((p) => assertSafeServerPath(p));
|
||||
|
||||
const response = await this.nodeBridge.sendAgentRequest(
|
||||
server.nodeId!,
|
||||
'server.files.archive',
|
||||
{
|
||||
serverId,
|
||||
generation: server.version,
|
||||
paths,
|
||||
archiveName: input.archiveName,
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
throw new ServiceUnavailableException(
|
||||
response.errorMessage ?? 'Failed to create archive',
|
||||
);
|
||||
}
|
||||
|
||||
const result = response.result as {
|
||||
archivePath: string;
|
||||
sha256?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'server.file.archive',
|
||||
userId,
|
||||
entityType: 'game_server',
|
||||
entityId: serverId,
|
||||
metadata: { paths, archivePath: result.archivePath },
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async unarchiveFile(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
input: UnarchiveFile,
|
||||
): Promise<{ destinationPath: string }> {
|
||||
const server = await this.requireFileAccess(userId, serverId);
|
||||
const archivePath = assertSafeServerPath(input.archivePath);
|
||||
const destinationPath = assertSafeServerPath(input.destinationPath);
|
||||
|
||||
const response = await this.nodeBridge.sendAgentRequest(
|
||||
server.nodeId!,
|
||||
'server.files.unarchive',
|
||||
{
|
||||
serverId,
|
||||
generation: server.version,
|
||||
archivePath,
|
||||
destinationPath,
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
throw new ServiceUnavailableException(
|
||||
response.errorMessage ?? 'Failed to extract archive',
|
||||
);
|
||||
}
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'server.file.unarchive',
|
||||
userId,
|
||||
entityType: 'game_server',
|
||||
entityId: serverId,
|
||||
metadata: { archivePath, destinationPath },
|
||||
});
|
||||
|
||||
return { destinationPath };
|
||||
}
|
||||
|
||||
private async requireFileAccess(
|
||||
|
||||
119
apps/api/src/servers/members/members.controller.ts
Normal file
119
apps/api/src/servers/members/members.controller.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
|
||||
import { SessionGuard } from '../../auth/guards/session.guard';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { findAccessibleServer } from '../shared/server-ownership.util';
|
||||
|
||||
const inviteSchema = z.object({
|
||||
email: z.string().email(),
|
||||
role: z.enum(['ADMIN', 'OPERATOR', 'DEVELOPER', 'VIEWER']).default('VIEWER'),
|
||||
});
|
||||
|
||||
@ApiTags('servers')
|
||||
@Controller('servers/:serverId/members')
|
||||
@UseGuards(SessionGuard)
|
||||
export class MembersController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List server members' })
|
||||
async list(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
): Promise<{
|
||||
members: Array<{
|
||||
id: string;
|
||||
userId: string;
|
||||
role: string;
|
||||
email: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
acceptedAt: string | null;
|
||||
}>;
|
||||
}> {
|
||||
await findAccessibleServer(this.prisma, user.id, serverId, 'server.view');
|
||||
|
||||
const members = await this.prisma.gameServerMember.findMany({
|
||||
where: { serverId },
|
||||
include: {
|
||||
user: { select: { id: true, email: true, username: true, displayName: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
return {
|
||||
members: members.map((member) => ({
|
||||
id: member.id,
|
||||
userId: member.userId,
|
||||
role: member.role,
|
||||
email: member.user.email,
|
||||
username: member.user.username,
|
||||
displayName: member.user.displayName,
|
||||
acceptedAt: member.acceptedAt?.toISOString() ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Invite a user to the server' })
|
||||
async invite(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Body(new ZodValidationPipe(inviteSchema)) body: unknown,
|
||||
): Promise<{ status?: string; message?: string; id?: string; userId?: string; role?: string }> {
|
||||
await findAccessibleServer(this.prisma, user.id, serverId, 'server.members.manage');
|
||||
const input = body as z.infer<typeof inviteSchema>;
|
||||
|
||||
const invitedUser = await this.prisma.user.findUnique({
|
||||
where: { email: input.email.toLowerCase() },
|
||||
});
|
||||
|
||||
if (!invitedUser) {
|
||||
return { status: 'pending', message: 'User must register before accepting invite' };
|
||||
}
|
||||
|
||||
const member = await this.prisma.gameServerMember.upsert({
|
||||
where: {
|
||||
serverId_userId: { serverId, userId: invitedUser.id },
|
||||
},
|
||||
create: {
|
||||
serverId,
|
||||
userId: invitedUser.id,
|
||||
role: input.role,
|
||||
acceptedAt: new Date(),
|
||||
},
|
||||
update: { role: input.role },
|
||||
});
|
||||
|
||||
return { id: member.id, userId: member.userId, role: member.role };
|
||||
}
|
||||
|
||||
@Delete(':memberId')
|
||||
@ApiOperation({ summary: 'Remove a server member' })
|
||||
async remove(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Param('memberId', ParseUUIDPipe) memberId: string,
|
||||
) {
|
||||
await findAccessibleServer(this.prisma, user.id, serverId, 'server.members.manage');
|
||||
|
||||
await this.prisma.gameServerMember.deleteMany({
|
||||
where: { id: memberId, serverId },
|
||||
});
|
||||
|
||||
return { removed: true };
|
||||
}
|
||||
}
|
||||
8
apps/api/src/servers/members/members.module.ts
Normal file
8
apps/api/src/servers/members/members.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { MembersController } from './members.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [MembersController],
|
||||
})
|
||||
export class MembersModule {}
|
||||
@@ -1,14 +1,27 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
addBanSchema,
|
||||
addOperatorSchema,
|
||||
addWhitelistSchema,
|
||||
removePlayerEntrySchema,
|
||||
} from '@hexahost/contracts';
|
||||
|
||||
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
|
||||
import { SessionGuard } from '../../auth/guards/session.guard';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
|
||||
import { PlayersService } from './players.service';
|
||||
|
||||
@@ -19,12 +32,105 @@ export class PlayersController {
|
||||
constructor(private readonly playersService: PlayersService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List online players, whitelist, and operators' })
|
||||
@ApiResponse({ status: 200, description: 'Player overview' })
|
||||
@ApiOperation({ summary: 'List online players, whitelist, operators, and bans' })
|
||||
list(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
) {
|
||||
return this.playersService.listPlayers(user.id, serverId);
|
||||
}
|
||||
|
||||
@Post('whitelist')
|
||||
addWhitelist(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Body(new ZodValidationPipe(addWhitelistSchema)) body: unknown,
|
||||
) {
|
||||
return this.playersService.addWhitelist(
|
||||
user.id,
|
||||
serverId,
|
||||
body as Parameters<PlayersService['addWhitelist']>[2],
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('whitelist')
|
||||
removeWhitelist(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Body(new ZodValidationPipe(removePlayerEntrySchema)) body: unknown,
|
||||
) {
|
||||
return this.playersService.removeWhitelist(
|
||||
user.id,
|
||||
serverId,
|
||||
body as Parameters<PlayersService['removeWhitelist']>[2],
|
||||
);
|
||||
}
|
||||
|
||||
@Post('operators')
|
||||
addOperator(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Body(new ZodValidationPipe(addOperatorSchema)) body: unknown,
|
||||
) {
|
||||
return this.playersService.addOperator(
|
||||
user.id,
|
||||
serverId,
|
||||
body as Parameters<PlayersService['addOperator']>[2],
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('operators')
|
||||
removeOperator(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Body(new ZodValidationPipe(removePlayerEntrySchema)) body: unknown,
|
||||
) {
|
||||
return this.playersService.removeOperator(
|
||||
user.id,
|
||||
serverId,
|
||||
body as Parameters<PlayersService['removeOperator']>[2],
|
||||
);
|
||||
}
|
||||
|
||||
@Post('bans')
|
||||
addBan(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Body(new ZodValidationPipe(addBanSchema)) body: unknown,
|
||||
) {
|
||||
return this.playersService.addBan(
|
||||
user.id,
|
||||
serverId,
|
||||
body as Parameters<PlayersService['addBan']>[2],
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('bans')
|
||||
removeBan(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Body(new ZodValidationPipe(addBanSchema.pick({ name: true }))) body: unknown,
|
||||
) {
|
||||
return this.playersService.removeBan(
|
||||
user.id,
|
||||
serverId,
|
||||
body as { name: string },
|
||||
);
|
||||
}
|
||||
|
||||
@Post('kick')
|
||||
@HttpCode(HttpStatus.ACCEPTED)
|
||||
kick(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Body(new ZodValidationPipe(addBanSchema.pick({ name: true }).extend({
|
||||
reason: addBanSchema.shape.reason,
|
||||
}))) body: unknown,
|
||||
) {
|
||||
return this.playersService.kickPlayer(
|
||||
user.id,
|
||||
serverId,
|
||||
body as { name: string; reason?: string },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,13 @@ import {
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import type { PlayerListResponse } from '@hexahost/contracts';
|
||||
import type {
|
||||
AddBan,
|
||||
AddOperator,
|
||||
AddWhitelist,
|
||||
PlayerListResponse,
|
||||
RemovePlayerEntry,
|
||||
} from '@hexahost/contracts';
|
||||
import type { GameServer } from '@hexahost/database';
|
||||
|
||||
import { NodeBridgeService } from '../../nodes/node-bridge.service';
|
||||
@@ -13,6 +19,7 @@ import { findOwnedServer } from '../shared/server-ownership.util';
|
||||
|
||||
const WHITELIST_FILE = 'whitelist.json';
|
||||
const OPS_FILE = 'ops.json';
|
||||
const BANS_FILE = 'banned-players.json';
|
||||
|
||||
@Injectable()
|
||||
export class PlayersService {
|
||||
@@ -32,17 +39,114 @@ export class PlayersService {
|
||||
throw new BadRequestException('Server is not assigned to a node');
|
||||
}
|
||||
|
||||
const [online, whitelist, operators] = await Promise.all([
|
||||
const [online, whitelist, operators, bans] = await Promise.all([
|
||||
this.fetchOnlinePlayers(server),
|
||||
this.readJsonFile(userId, serverId, WHITELIST_FILE, []),
|
||||
this.readJsonFile(userId, serverId, OPS_FILE, []),
|
||||
this.readJsonFile(userId, serverId, BANS_FILE, []),
|
||||
]);
|
||||
|
||||
return {
|
||||
online,
|
||||
whitelist,
|
||||
operators,
|
||||
};
|
||||
return { online, whitelist, operators, bans };
|
||||
}
|
||||
|
||||
async addWhitelist(userId: string, serverId: string, input: AddWhitelist) {
|
||||
const list = await this.readJsonFile<Array<{ uuid: string; name: string }>>(
|
||||
userId,
|
||||
serverId,
|
||||
WHITELIST_FILE,
|
||||
[],
|
||||
);
|
||||
const next = list.filter((e) => e.uuid !== input.uuid);
|
||||
next.push({ uuid: input.uuid, name: input.name });
|
||||
await this.writeJsonFile(userId, serverId, WHITELIST_FILE, next);
|
||||
await this.runCommandIfRunning(userId, serverId, `whitelist add ${input.name}`);
|
||||
return { whitelist: next };
|
||||
}
|
||||
|
||||
async removeWhitelist(userId: string, serverId: string, input: RemovePlayerEntry) {
|
||||
const list = await this.readJsonFile<Array<{ uuid: string; name: string }>>(
|
||||
userId,
|
||||
serverId,
|
||||
WHITELIST_FILE,
|
||||
[],
|
||||
);
|
||||
const entry = list.find((e) => e.uuid === input.uuid);
|
||||
const next = list.filter((e) => e.uuid !== input.uuid);
|
||||
await this.writeJsonFile(userId, serverId, WHITELIST_FILE, next);
|
||||
if (entry) {
|
||||
await this.runCommandIfRunning(userId, serverId, `whitelist remove ${entry.name}`);
|
||||
}
|
||||
return { whitelist: next };
|
||||
}
|
||||
|
||||
async addOperator(userId: string, serverId: string, input: AddOperator) {
|
||||
const list = await this.readJsonFile<
|
||||
Array<{ uuid: string; name: string; level?: number; bypassesPlayerLimit?: boolean }>
|
||||
>(userId, serverId, OPS_FILE, []);
|
||||
const next = list.filter((e) => e.uuid !== input.uuid);
|
||||
next.push({
|
||||
uuid: input.uuid,
|
||||
name: input.name,
|
||||
level: input.level,
|
||||
bypassesPlayerLimit: input.bypassesPlayerLimit,
|
||||
});
|
||||
await this.writeJsonFile(userId, serverId, OPS_FILE, next);
|
||||
await this.runCommandIfRunning(
|
||||
userId,
|
||||
serverId,
|
||||
`op ${input.name}`,
|
||||
);
|
||||
return { operators: next };
|
||||
}
|
||||
|
||||
async removeOperator(userId: string, serverId: string, input: RemovePlayerEntry) {
|
||||
const list = await this.readJsonFile<Array<{ uuid: string; name: string }>>(
|
||||
userId,
|
||||
serverId,
|
||||
OPS_FILE,
|
||||
[],
|
||||
);
|
||||
const entry = list.find((e) => e.uuid === input.uuid);
|
||||
const next = list.filter((e) => e.uuid !== input.uuid);
|
||||
await this.writeJsonFile(userId, serverId, OPS_FILE, next);
|
||||
if (entry) {
|
||||
await this.runCommandIfRunning(userId, serverId, `deop ${entry.name}`);
|
||||
}
|
||||
return { operators: next };
|
||||
}
|
||||
|
||||
async addBan(userId: string, serverId: string, input: AddBan) {
|
||||
const list = await this.readJsonFile<
|
||||
Array<{ uuid?: string; name: string; reason?: string; source?: string }>
|
||||
>(userId, serverId, BANS_FILE, []);
|
||||
const next = list.filter((e) => e.name.toLowerCase() !== input.name.toLowerCase());
|
||||
next.push({
|
||||
name: input.name,
|
||||
reason: input.reason ?? 'Banned by panel',
|
||||
source: 'HexaHost Panel',
|
||||
});
|
||||
await this.writeJsonFile(userId, serverId, BANS_FILE, next);
|
||||
await this.runCommandIfRunning(userId, serverId, `ban ${input.name}`);
|
||||
return { bans: next };
|
||||
}
|
||||
|
||||
async removeBan(userId: string, serverId: string, input: { name: string }) {
|
||||
const list = await this.readJsonFile<Array<{ name: string }>>(
|
||||
userId,
|
||||
serverId,
|
||||
BANS_FILE,
|
||||
[],
|
||||
);
|
||||
const next = list.filter((e) => e.name.toLowerCase() !== input.name.toLowerCase());
|
||||
await this.writeJsonFile(userId, serverId, BANS_FILE, next);
|
||||
await this.runCommandIfRunning(userId, serverId, `pardon ${input.name}`);
|
||||
return { bans: next };
|
||||
}
|
||||
|
||||
async kickPlayer(userId: string, serverId: string, input: { name: string; reason?: string }) {
|
||||
const reason = input.reason ? ` ${input.reason}` : '';
|
||||
await this.runCommandIfRunning(userId, serverId, `kick ${input.name}${reason}`);
|
||||
return { kicked: input.name };
|
||||
}
|
||||
|
||||
private async fetchOnlinePlayers(
|
||||
@@ -106,4 +210,39 @@ export class PlayersService {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private async writeJsonFile<T>(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
path: string,
|
||||
data: T,
|
||||
): Promise<void> {
|
||||
await this.filesService.writeFile(userId, serverId, {
|
||||
path,
|
||||
content: `${JSON.stringify(data, null, 2)}\n`,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
}
|
||||
|
||||
private async runCommandIfRunning(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
command: string,
|
||||
): Promise<void> {
|
||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||
if (server.status !== 'RUNNING' || !server.nodeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.nodeBridge.sendAgentRequest(
|
||||
server.nodeId,
|
||||
'server.command',
|
||||
{
|
||||
serverId,
|
||||
generation: server.version,
|
||||
command,
|
||||
},
|
||||
10_000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
77
apps/api/src/servers/schedules/schedules.controller.ts
Normal file
77
apps/api/src/servers/schedules/schedules.controller.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { createScheduleSchema, updateScheduleSchema } from '@hexahost/contracts';
|
||||
|
||||
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
|
||||
import { SessionGuard } from '../../auth/guards/session.guard';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
|
||||
import { SchedulesService } from './schedules.service';
|
||||
|
||||
@ApiTags('servers')
|
||||
@Controller('servers/:serverId/schedules')
|
||||
@UseGuards(SessionGuard)
|
||||
export class SchedulesController {
|
||||
constructor(private readonly schedulesService: SchedulesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List server schedules' })
|
||||
list(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
) {
|
||||
return this.schedulesService.list(user.id, serverId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
create(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Body(new ZodValidationPipe(createScheduleSchema)) body: unknown,
|
||||
) {
|
||||
return this.schedulesService.create(
|
||||
user.id,
|
||||
serverId,
|
||||
body as Parameters<SchedulesService['create']>[2],
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':scheduleId')
|
||||
update(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
|
||||
@Body(new ZodValidationPipe(updateScheduleSchema)) body: unknown,
|
||||
) {
|
||||
return this.schedulesService.update(
|
||||
user.id,
|
||||
serverId,
|
||||
scheduleId,
|
||||
body as Parameters<SchedulesService['update']>[3],
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':scheduleId')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
|
||||
) {
|
||||
await this.schedulesService.remove(user.id, serverId, scheduleId);
|
||||
}
|
||||
}
|
||||
15
apps/api/src/servers/schedules/schedules.module.ts
Normal file
15
apps/api/src/servers/schedules/schedules.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
|
||||
import { SchedulesController } from './schedules.controller';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AuthModule],
|
||||
controllers: [SchedulesController],
|
||||
providers: [SchedulesService],
|
||||
exports: [SchedulesService],
|
||||
})
|
||||
export class SchedulesModule {}
|
||||
150
apps/api/src/servers/schedules/schedules.service.ts
Normal file
150
apps/api/src/servers/schedules/schedules.service.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
|
||||
import type {
|
||||
CreateSchedule,
|
||||
Schedule as ScheduleDto,
|
||||
ScheduleListResponse,
|
||||
UpdateSchedule,
|
||||
} from '@hexahost/contracts';
|
||||
import type { Prisma, Schedule, ScheduledTask } from '@hexahost/database';
|
||||
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { findOwnedServer } from '../shared/server-ownership.util';
|
||||
|
||||
function mapSchedule(
|
||||
schedule: Schedule & { tasks: ScheduledTask[] },
|
||||
): ScheduleDto {
|
||||
return {
|
||||
id: schedule.id,
|
||||
name: schedule.name,
|
||||
timezone: schedule.timezone,
|
||||
cronExpr: schedule.cronExpr,
|
||||
enabled: schedule.enabled,
|
||||
nextRunAt: schedule.nextRunAt?.toISOString() ?? null,
|
||||
lastRunAt: schedule.lastRunAt?.toISOString() ?? null,
|
||||
tasks: schedule.tasks.map((task) => ({
|
||||
id: task.id,
|
||||
taskType: task.taskType,
|
||||
payload: (task.payload as Record<string, unknown> | null) ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(userId: string, serverId: string): Promise<ScheduleListResponse> {
|
||||
await findOwnedServer(this.prisma, userId, serverId);
|
||||
const schedules = await this.prisma.schedule.findMany({
|
||||
where: { serverId },
|
||||
include: { tasks: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return { schedules: schedules.map(mapSchedule) };
|
||||
}
|
||||
|
||||
async create(userId: string, serverId: string, input: CreateSchedule) {
|
||||
await findOwnedServer(this.prisma, userId, serverId);
|
||||
const schedule = await this.prisma.schedule.create({
|
||||
data: {
|
||||
serverId,
|
||||
name: input.name,
|
||||
timezone: input.timezone,
|
||||
cronExpr: input.cronExpr,
|
||||
enabled: input.enabled,
|
||||
nextRunAt: this.computeNextRun(input.cronExpr, input.timezone),
|
||||
tasks: {
|
||||
create: input.tasks.map((task) => ({
|
||||
taskType: task.taskType,
|
||||
payload: (task.payload ?? undefined) as Prisma.InputJsonValue | undefined,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { tasks: true },
|
||||
});
|
||||
return mapSchedule(schedule);
|
||||
}
|
||||
|
||||
async update(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
scheduleId: string,
|
||||
input: UpdateSchedule,
|
||||
) {
|
||||
await this.requireSchedule(userId, serverId, scheduleId);
|
||||
const existing = await this.prisma.schedule.findUniqueOrThrow({
|
||||
where: { id: scheduleId },
|
||||
});
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
if (input.tasks) {
|
||||
await tx.scheduledTask.deleteMany({ where: { scheduleId } });
|
||||
await tx.scheduledTask.createMany({
|
||||
data: input.tasks.map((task) => ({
|
||||
scheduleId,
|
||||
taskType: task.taskType,
|
||||
payload: (task.payload ?? undefined) as Prisma.InputJsonValue | undefined,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
await tx.schedule.update({
|
||||
where: { id: scheduleId },
|
||||
data: {
|
||||
name: input.name,
|
||||
timezone: input.timezone,
|
||||
cronExpr: input.cronExpr,
|
||||
enabled: input.enabled,
|
||||
nextRunAt:
|
||||
input.cronExpr !== undefined || input.timezone !== undefined
|
||||
? this.computeNextRun(
|
||||
input.cronExpr ?? existing.cronExpr,
|
||||
input.timezone ?? existing.timezone,
|
||||
)
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const schedule = await this.prisma.schedule.findUniqueOrThrow({
|
||||
where: { id: scheduleId },
|
||||
include: { tasks: true },
|
||||
});
|
||||
return mapSchedule(schedule);
|
||||
}
|
||||
|
||||
async remove(userId: string, serverId: string, scheduleId: string) {
|
||||
await this.requireSchedule(userId, serverId, scheduleId);
|
||||
await this.prisma.schedule.delete({ where: { id: scheduleId } });
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
private async requireSchedule(userId: string, serverId: string, scheduleId: string) {
|
||||
await findOwnedServer(this.prisma, userId, serverId);
|
||||
const schedule = await this.prisma.schedule.findFirst({
|
||||
where: { id: scheduleId, serverId },
|
||||
});
|
||||
if (!schedule) {
|
||||
throw new NotFoundException('Schedule not found');
|
||||
}
|
||||
return schedule;
|
||||
}
|
||||
|
||||
private computeNextRun(cronExpr: string, timezone = 'UTC'): Date {
|
||||
try {
|
||||
const interval = CronExpressionParser.parse(cronExpr, {
|
||||
tz: timezone,
|
||||
currentDate: new Date(),
|
||||
});
|
||||
return interval.next().toDate();
|
||||
} catch {
|
||||
throw new BadRequestException('Invalid cron expression or timezone');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,15 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import type { GameServer } from '@hexahost/database';
|
||||
|
||||
type GameServerStatus = GameServer['status'];
|
||||
|
||||
const ALLOWED_TRANSITIONS: Record<GameServerStatus, GameServerStatus[]> = {
|
||||
DRAFT: ['PROVISIONING', 'DELETING'],
|
||||
PROVISIONING: ['INSTALLING', 'ERROR', 'DELETING'],
|
||||
INSTALLING: ['STOPPED', 'ERROR', 'DELETING'],
|
||||
STOPPED: ['STARTING', 'QUEUED', 'PROVISIONING', 'BACKING_UP', 'RESTORING', 'DELETING'],
|
||||
QUEUED: ['STARTING', 'STOPPED', 'DELETING'],
|
||||
STARTING: ['RUNNING', 'STOPPED', 'ERROR', 'UNKNOWN', 'DELETING'],
|
||||
RUNNING: ['STOPPING', 'BACKING_UP', 'ERROR', 'UNKNOWN', 'DELETING'],
|
||||
STOPPING: ['STOPPED', 'ERROR', 'UNKNOWN', 'DELETING'],
|
||||
BACKING_UP: ['RUNNING', 'STOPPED', 'ERROR'],
|
||||
RESTORING: ['STOPPED', 'ERROR'],
|
||||
UNKNOWN: ['STOPPED', 'STARTING', 'ERROR', 'DELETING'],
|
||||
ERROR: ['STOPPED', 'STARTING', 'QUEUED', 'PROVISIONING', 'DELETING'],
|
||||
DELETING: ['DELETED'],
|
||||
DELETED: [],
|
||||
};
|
||||
|
||||
const IN_PROGRESS_STATUSES: GameServerStatus[] = [
|
||||
'PROVISIONING',
|
||||
'INSTALLING',
|
||||
'STARTING',
|
||||
'STOPPING',
|
||||
'BACKING_UP',
|
||||
'RESTORING',
|
||||
'DELETING',
|
||||
];
|
||||
import {
|
||||
ALLOWED_TRANSITIONS,
|
||||
IN_PROGRESS_STATUSES,
|
||||
assertTransition,
|
||||
isInProgress,
|
||||
resolveKillTarget,
|
||||
resolveStartTarget,
|
||||
resolveStopTarget,
|
||||
type GameServerStatus,
|
||||
} from '@hexahost/server-state';
|
||||
|
||||
@Injectable()
|
||||
export class ServerStateService {
|
||||
@@ -36,44 +17,56 @@ export class ServerStateService {
|
||||
fromStatus: GameServerStatus,
|
||||
toStatus: GameServerStatus,
|
||||
): void {
|
||||
const allowed = ALLOWED_TRANSITIONS[fromStatus];
|
||||
|
||||
if (!allowed.includes(toStatus)) {
|
||||
try {
|
||||
assertTransition(fromStatus, toStatus);
|
||||
} catch (error) {
|
||||
throw new BadRequestException(
|
||||
`Invalid state transition from ${fromStatus} to ${toStatus}`,
|
||||
error instanceof Error ? error.message : 'Invalid state transition',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assertNotInProgress(status: GameServerStatus): void {
|
||||
if (IN_PROGRESS_STATUSES.includes(status)) {
|
||||
if (isInProgress(status)) {
|
||||
throw new BadRequestException('Server operation already in progress');
|
||||
}
|
||||
}
|
||||
|
||||
resolveStartTarget(status: GameServerStatus): GameServerStatus {
|
||||
switch (status) {
|
||||
case 'DRAFT':
|
||||
case 'ERROR':
|
||||
return 'PROVISIONING';
|
||||
case 'STOPPED':
|
||||
case 'UNKNOWN':
|
||||
case 'QUEUED':
|
||||
return 'STARTING';
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
`Cannot start server while in status ${status}`,
|
||||
);
|
||||
try {
|
||||
return resolveStartTarget(status);
|
||||
} catch (error) {
|
||||
throw new BadRequestException(
|
||||
error instanceof Error ? error.message : 'Cannot start server',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
resolveStopTarget(status: GameServerStatus): GameServerStatus {
|
||||
if (status !== 'RUNNING') {
|
||||
try {
|
||||
return resolveStopTarget(status);
|
||||
} catch (error) {
|
||||
throw new BadRequestException(
|
||||
`Cannot stop server while in status ${status}`,
|
||||
error instanceof Error ? error.message : 'Cannot stop server',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return 'STOPPING';
|
||||
resolveKillTarget(status: GameServerStatus): GameServerStatus {
|
||||
try {
|
||||
return resolveKillTarget(status);
|
||||
} catch (error) {
|
||||
throw new BadRequestException(
|
||||
error instanceof Error ? error.message : 'Cannot kill server',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
allowedTransitions(): typeof ALLOWED_TRANSITIONS {
|
||||
return ALLOWED_TRANSITIONS;
|
||||
}
|
||||
|
||||
inProgressStatuses(): GameServerStatus[] {
|
||||
return IN_PROGRESS_STATUSES;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { createServerRequestSchema } from '@hexahost/contracts';
|
||||
import { createServerRequestSchema, updateServerRequestSchema } from '@hexahost/contracts';
|
||||
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { SessionGuard } from '../auth/guards/session.guard';
|
||||
@@ -90,6 +92,40 @@ export class ServersController {
|
||||
return this.serversService.restartServer(user.id, id, skip === 'true');
|
||||
}
|
||||
|
||||
@Post(':id/kill')
|
||||
@HttpCode(HttpStatus.ACCEPTED)
|
||||
@ApiOperation({ summary: 'Force-kill a running game server' })
|
||||
kill(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.serversService.killServer(user.id, id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.ACCEPTED)
|
||||
@ApiOperation({ summary: 'Delete a game server' })
|
||||
delete(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.serversService.deleteServer(user.id, id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update server metadata' })
|
||||
patch(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(updateServerRequestSchema)) body: unknown,
|
||||
) {
|
||||
return this.serversService.updateServer(
|
||||
user.id,
|
||||
id,
|
||||
body as Parameters<ServersService['updateServer']>[2],
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id/start-queue')
|
||||
@ApiOperation({ summary: 'Get start queue status for a server' })
|
||||
startQueue(
|
||||
|
||||
@@ -9,8 +9,12 @@ import { NodeBridgeModule } from '../nodes/node-bridge.module';
|
||||
import { AppConfigModule } from '../config/app-config.module';
|
||||
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { FeatureFlagsModule } from '../feature-flags/feature-flags.module';
|
||||
import { SchedulingModule } from '../scheduling/scheduling.module';
|
||||
|
||||
import { MembersModule } from './members/members.module';
|
||||
import { SchedulesModule } from './schedules/schedules.module';
|
||||
import { SoftwareModule } from './software/software.module';
|
||||
import { ConsoleModule } from './console/console.module';
|
||||
import { AddonsModule } from './addons/addons.module';
|
||||
import { BackupsModule } from './backups/backups.module';
|
||||
@@ -34,6 +38,7 @@ import { PlansService } from './plans.service';
|
||||
imports: [
|
||||
AppConfigModule,
|
||||
AuthModule,
|
||||
FeatureFlagsModule,
|
||||
SchedulingModule,
|
||||
BillingModule,
|
||||
IdleModule,
|
||||
@@ -44,6 +49,9 @@ import { PlansService } from './plans.service';
|
||||
forwardRef(() => FilesModule),
|
||||
forwardRef(() => PropertiesModule),
|
||||
forwardRef(() => PlayersModule),
|
||||
forwardRef(() => MembersModule),
|
||||
forwardRef(() => SchedulesModule),
|
||||
forwardRef(() => SoftwareModule),
|
||||
forwardRef(() => NodeBridgeModule),
|
||||
],
|
||||
controllers: [ServersController, PlansController],
|
||||
@@ -88,6 +96,7 @@ import { PlansService } from './plans.service';
|
||||
exports: [
|
||||
ServersService,
|
||||
REDIS_CLIENT,
|
||||
SERVER_LIFECYCLE_QUEUE,
|
||||
ConsoleModule,
|
||||
NodeBridgeModule,
|
||||
],
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import type Redis from 'ioredis';
|
||||
@@ -16,10 +17,12 @@ import type {
|
||||
ServerResponse,
|
||||
} from '@hexahost/contracts';
|
||||
import type { GameServer } from '@hexahost/database';
|
||||
import { FeatureFlagService } from '@hexahost/feature-flags';
|
||||
import { formatJoinAddress } from '@hexahost/dns';
|
||||
|
||||
type GameServerStatus = GameServer['status'];
|
||||
|
||||
import { FEATURE_FLAG_SERVICE } from '../feature-flags/feature-flags.module';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { SchedulingService } from '../scheduling/scheduling.service';
|
||||
@@ -45,12 +48,14 @@ export class ServersService {
|
||||
private readonly lifecycleQueue: Queue,
|
||||
@Inject(SERVER_PROVISIONING_QUEUE)
|
||||
private readonly provisioningQueue: Queue,
|
||||
@Inject(FEATURE_FLAG_SERVICE) private readonly featureFlags: FeatureFlagService,
|
||||
) {}
|
||||
|
||||
async createServer(
|
||||
userId: string,
|
||||
input: CreateServerRequest,
|
||||
): Promise<ServerResponse> {
|
||||
await this.assertMaintenanceAllowed('create');
|
||||
await this.billing.assertCanCreateServer(userId, input.planId);
|
||||
|
||||
const server = await this.prisma.gameServer.create({
|
||||
@@ -108,6 +113,7 @@ export class ServersService {
|
||||
userId: string,
|
||||
serverId: string,
|
||||
): Promise<ServerActionResponse> {
|
||||
await this.assertMaintenanceAllowed('start');
|
||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||
this.assertServerNotBillingSuspended(server);
|
||||
this.serverState.assertNotInProgress(server.status);
|
||||
@@ -323,6 +329,180 @@ export class ServersService {
|
||||
};
|
||||
}
|
||||
|
||||
async killServer(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
): Promise<ServerActionResponse> {
|
||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||
this.assertServerNotBillingSuspended(server);
|
||||
|
||||
const targetStatus = this.serverState.resolveKillTarget(server.status);
|
||||
this.serverState.assertTransition(server.status, targetStatus);
|
||||
|
||||
const correlationId = randomUUID();
|
||||
const updated = await this.transitionServer(
|
||||
server,
|
||||
targetStatus,
|
||||
'kill requested',
|
||||
userId,
|
||||
correlationId,
|
||||
);
|
||||
|
||||
const job = await this.lifecycleQueue.add(
|
||||
'kill-server',
|
||||
{ serverId, action: 'kill', correlationId },
|
||||
{ jobId: correlationId },
|
||||
);
|
||||
|
||||
if (updated.nodeId) {
|
||||
await this.publishNodeCommand(updated.nodeId, {
|
||||
protocolVersion: 1,
|
||||
messageId: randomUUID(),
|
||||
correlationId,
|
||||
type: 'server.kill',
|
||||
timestamp: new Date().toISOString(),
|
||||
payload: {
|
||||
serverId,
|
||||
generation: updated.version,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
server: this.toResponse(updated),
|
||||
jobId: job.id,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteServer(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
): Promise<ServerActionResponse> {
|
||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||
this.serverState.assertNotInProgress(server.status);
|
||||
|
||||
if (server.status === 'RUNNING' || server.status === 'STARTING') {
|
||||
throw new ConflictException('Stop the server before deleting it');
|
||||
}
|
||||
|
||||
this.serverState.assertTransition(server.status, 'DELETING');
|
||||
|
||||
const correlationId = randomUUID();
|
||||
const updated = await this.transitionServer(
|
||||
server,
|
||||
'DELETING',
|
||||
'delete requested',
|
||||
userId,
|
||||
correlationId,
|
||||
);
|
||||
|
||||
const job = await this.lifecycleQueue.add(
|
||||
'delete-server',
|
||||
{ serverId, action: 'delete', correlationId },
|
||||
{ jobId: correlationId },
|
||||
);
|
||||
|
||||
if (updated.nodeId) {
|
||||
await this.publishNodeCommand(updated.nodeId, {
|
||||
protocolVersion: 1,
|
||||
messageId: randomUUID(),
|
||||
correlationId,
|
||||
type: 'server.delete',
|
||||
timestamp: new Date().toISOString(),
|
||||
payload: {
|
||||
serverId,
|
||||
generation: updated.version,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
server: this.toResponse(updated),
|
||||
jobId: job.id,
|
||||
};
|
||||
}
|
||||
|
||||
async updateServer(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
input: { name?: string; idleShutdownEnabled?: boolean },
|
||||
): Promise<ServerResponse> {
|
||||
await findOwnedServer(this.prisma, userId, serverId);
|
||||
|
||||
const updated = await this.prisma.gameServer.update({
|
||||
where: { id: serverId },
|
||||
data: {
|
||||
...(input.name !== undefined ? { name: input.name } : {}),
|
||||
...(input.idleShutdownEnabled !== undefined
|
||||
? { idleShutdownEnabled: input.idleShutdownEnabled }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
return this.toResponse(updated);
|
||||
}
|
||||
|
||||
async reinstallServer(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
input: {
|
||||
softwareFamily: string;
|
||||
minecraftVersion: string;
|
||||
createBackup?: boolean;
|
||||
},
|
||||
): Promise<{ serverId: string; jobId: string; status: string }> {
|
||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||
this.serverState.assertNotInProgress(server.status);
|
||||
|
||||
if (input.createBackup) {
|
||||
await this.lifecycleQueue.add('backup-server', {
|
||||
serverId,
|
||||
type: 'MANUAL',
|
||||
reason: 'pre-reinstall',
|
||||
});
|
||||
}
|
||||
|
||||
const correlationId = randomUUID();
|
||||
const updated = await this.prisma.gameServer.update({
|
||||
where: { id: serverId },
|
||||
data: {
|
||||
softwareFamily: input.softwareFamily as GameServer['softwareFamily'],
|
||||
minecraftVersion: input.minecraftVersion,
|
||||
status: 'PROVISIONING',
|
||||
version: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.gameServerStateTransition.create({
|
||||
data: {
|
||||
gameServerId: serverId,
|
||||
fromStatus: server.status,
|
||||
toStatus: 'PROVISIONING',
|
||||
reason: 'Software reinstall requested',
|
||||
actorId: userId,
|
||||
correlationId,
|
||||
},
|
||||
});
|
||||
|
||||
const job = await this.provisioningQueue.add(
|
||||
'provision-server',
|
||||
{
|
||||
serverId,
|
||||
correlationId,
|
||||
reinstall: true,
|
||||
softwareFamily: input.softwareFamily,
|
||||
minecraftVersion: input.minecraftVersion,
|
||||
},
|
||||
{ jobId: correlationId },
|
||||
);
|
||||
|
||||
return {
|
||||
serverId,
|
||||
jobId: job.id ?? correlationId,
|
||||
status: updated.status,
|
||||
};
|
||||
}
|
||||
|
||||
async publishNodeCommand(
|
||||
nodeId: string,
|
||||
envelope: Record<string, unknown>,
|
||||
@@ -422,6 +602,15 @@ export class ServersService {
|
||||
}
|
||||
}
|
||||
|
||||
private async assertMaintenanceAllowed(action: string): Promise<void> {
|
||||
const maintenance = await this.featureFlags.isEnabled('maintenance_mode');
|
||||
if (maintenance) {
|
||||
throw new ServiceUnavailableException(
|
||||
`Platform maintenance mode is active; cannot ${action} servers`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async recordTransition(
|
||||
gameServerId: string,
|
||||
fromStatus: GameServerStatus | null,
|
||||
|
||||
@@ -3,25 +3,75 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { GameServer } from '@hexahost/database';
|
||||
import {
|
||||
assertPermission,
|
||||
type ServerMemberRole,
|
||||
type ServerPermission,
|
||||
} from '@hexahost/permissions';
|
||||
|
||||
import type { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
export interface ServerAccessContext {
|
||||
server: GameServer;
|
||||
role: ServerMemberRole;
|
||||
permissionOverrides: ServerPermission[] | null;
|
||||
}
|
||||
|
||||
export async function findOwnedServer(
|
||||
prisma: PrismaService,
|
||||
userId: string,
|
||||
serverId: string,
|
||||
): Promise<GameServer> {
|
||||
const access = await findAccessibleServer(prisma, userId, serverId, 'server.view');
|
||||
return access.server;
|
||||
}
|
||||
|
||||
export async function findAccessibleServer(
|
||||
prisma: PrismaService,
|
||||
userId: string,
|
||||
serverId: string,
|
||||
permission: ServerPermission,
|
||||
): Promise<ServerAccessContext> {
|
||||
const server = await prisma.gameServer.findUnique({
|
||||
where: { id: serverId },
|
||||
include: {
|
||||
members: {
|
||||
where: { userId },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!server) {
|
||||
throw new NotFoundException('Server not found');
|
||||
}
|
||||
|
||||
if (server.userId !== userId) {
|
||||
throw new ForbiddenException('You do not own this server');
|
||||
let role: ServerMemberRole;
|
||||
let overrides: ServerPermission[] | null = null;
|
||||
|
||||
if (server.userId === userId) {
|
||||
role = 'OWNER';
|
||||
} else {
|
||||
const membership = server.members[0];
|
||||
|
||||
if (!membership) {
|
||||
throw new ForbiddenException('You do not have access to this server');
|
||||
}
|
||||
|
||||
role = membership.role as ServerMemberRole;
|
||||
overrides = Array.isArray(membership.permissions)
|
||||
? (membership.permissions as ServerPermission[])
|
||||
: null;
|
||||
}
|
||||
|
||||
return server;
|
||||
try {
|
||||
assertPermission(role, permission, overrides);
|
||||
} catch {
|
||||
throw new ForbiddenException(`Missing permission: ${permission}`);
|
||||
}
|
||||
|
||||
return {
|
||||
server,
|
||||
role,
|
||||
permissionOverrides: overrides,
|
||||
};
|
||||
}
|
||||
|
||||
40
apps/api/src/servers/software/software.controller.ts
Normal file
40
apps/api/src/servers/software/software.controller.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { reinstallServerSchema } from '@hexahost/contracts';
|
||||
|
||||
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
|
||||
import { SessionGuard } from '../../auth/guards/session.guard';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import { ServersService } from '../servers.service';
|
||||
|
||||
@ApiTags('servers')
|
||||
@Controller('servers/:serverId/software')
|
||||
@UseGuards(SessionGuard)
|
||||
export class SoftwareController {
|
||||
constructor(private readonly serversService: ServersService) {}
|
||||
|
||||
@Post('reinstall')
|
||||
@HttpCode(HttpStatus.ACCEPTED)
|
||||
@ApiOperation({ summary: 'Reinstall server software' })
|
||||
reinstall(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Body(new ZodValidationPipe(reinstallServerSchema)) body: unknown,
|
||||
) {
|
||||
return this.serversService.reinstallServer(
|
||||
user.id,
|
||||
serverId,
|
||||
body as Parameters<ServersService['reinstallServer']>[2],
|
||||
);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/servers/software/software.module.ts
Normal file
12
apps/api/src/servers/software/software.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { ServersModule } from '../servers.module';
|
||||
|
||||
import { SoftwareController } from './software.controller';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, forwardRef(() => ServersModule)],
|
||||
controllers: [SoftwareController],
|
||||
})
|
||||
export class SoftwareModule {}
|
||||
69
apps/api/src/support/support.controller.ts
Normal file
69
apps/api/src/support/support.controller.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
addSupportTicketMessageSchema,
|
||||
createSupportTicketSchema,
|
||||
} from '@hexahost/contracts';
|
||||
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { SessionGuard } from '../auth/guards/session.guard';
|
||||
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
|
||||
|
||||
import { SupportService } from './support.service';
|
||||
|
||||
@ApiTags('support')
|
||||
@Controller('support/tickets')
|
||||
@UseGuards(SessionGuard)
|
||||
export class SupportController {
|
||||
constructor(private readonly supportService: SupportService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a support ticket' })
|
||||
create(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Body(new ZodValidationPipe(createSupportTicketSchema)) body: unknown,
|
||||
) {
|
||||
return this.supportService.create(
|
||||
user.id,
|
||||
body as Parameters<SupportService['create']>[1],
|
||||
);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List current user's support tickets" })
|
||||
list(@CurrentUser() user: { id: string }) {
|
||||
return this.supportService.list(user.id);
|
||||
}
|
||||
|
||||
@Get(':ticketId')
|
||||
@ApiOperation({ summary: 'Get a support ticket with messages' })
|
||||
get(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('ticketId', ParseUUIDPipe) ticketId: string,
|
||||
) {
|
||||
return this.supportService.get(user.id, ticketId);
|
||||
}
|
||||
|
||||
@Post(':ticketId/messages')
|
||||
@ApiOperation({ summary: 'Add a message to a support ticket' })
|
||||
addMessage(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('ticketId', ParseUUIDPipe) ticketId: string,
|
||||
@Body(new ZodValidationPipe(addSupportTicketMessageSchema)) body: unknown,
|
||||
) {
|
||||
return this.supportService.addMessage(
|
||||
user.id,
|
||||
ticketId,
|
||||
body as Parameters<SupportService['addMessage']>[2],
|
||||
);
|
||||
}
|
||||
}
|
||||
15
apps/api/src/support/support.module.ts
Normal file
15
apps/api/src/support/support.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
import { SupportController } from './support.controller';
|
||||
import { SupportService } from './support.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AuthModule],
|
||||
controllers: [SupportController],
|
||||
providers: [SupportService],
|
||||
exports: [SupportService],
|
||||
})
|
||||
export class SupportModule {}
|
||||
143
apps/api/src/support/support.service.ts
Normal file
143
apps/api/src/support/support.service.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import type {
|
||||
AddSupportTicketMessage,
|
||||
CreateSupportTicket,
|
||||
SupportTicket,
|
||||
SupportTicketListResponse,
|
||||
} from '@hexahost/contracts';
|
||||
import type {
|
||||
SupportTicket as SupportTicketRow,
|
||||
SupportTicketMessage,
|
||||
} from '@hexahost/database';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
function mapTicket(
|
||||
ticket: SupportTicketRow & { messages?: SupportTicketMessage[] },
|
||||
includeMessages = false,
|
||||
): SupportTicket {
|
||||
return {
|
||||
id: ticket.id,
|
||||
subject: ticket.subject,
|
||||
status: ticket.status,
|
||||
priority: ticket.priority,
|
||||
serverId: ticket.serverId,
|
||||
createdAt: ticket.createdAt.toISOString(),
|
||||
updatedAt: ticket.updatedAt.toISOString(),
|
||||
...(includeMessages && ticket.messages
|
||||
? {
|
||||
messages: ticket.messages.map((message) => ({
|
||||
id: message.id,
|
||||
authorId: message.authorId,
|
||||
isInternal: message.isInternal,
|
||||
body: message.body,
|
||||
createdAt: message.createdAt.toISOString(),
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SupportService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async create(userId: string, input: CreateSupportTicket): Promise<SupportTicket> {
|
||||
if (input.serverId) {
|
||||
const server = await this.prisma.gameServer.findFirst({
|
||||
where: { id: input.serverId, userId },
|
||||
});
|
||||
if (!server) {
|
||||
throw new ForbiddenException('Server not found or not owned by user');
|
||||
}
|
||||
}
|
||||
|
||||
const ticket = await this.prisma.supportTicket.create({
|
||||
data: {
|
||||
userId,
|
||||
subject: input.subject.trim(),
|
||||
priority: input.priority,
|
||||
serverId: input.serverId,
|
||||
messages: {
|
||||
create: {
|
||||
authorId: userId,
|
||||
body: input.body.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { messages: true },
|
||||
});
|
||||
|
||||
return mapTicket(ticket, true);
|
||||
}
|
||||
|
||||
async list(userId: string): Promise<SupportTicketListResponse> {
|
||||
const tickets = await this.prisma.supportTicket.findMany({
|
||||
where: { userId },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
|
||||
return { tickets: tickets.map((ticket) => mapTicket(ticket)) };
|
||||
}
|
||||
|
||||
async get(userId: string, ticketId: string): Promise<SupportTicket> {
|
||||
const ticket = await this.prisma.supportTicket.findFirst({
|
||||
where: { id: ticketId, userId },
|
||||
include: {
|
||||
messages: {
|
||||
where: { isInternal: false },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
throw new NotFoundException('Support ticket not found');
|
||||
}
|
||||
|
||||
return mapTicket(ticket, true);
|
||||
}
|
||||
|
||||
async addMessage(
|
||||
userId: string,
|
||||
ticketId: string,
|
||||
input: AddSupportTicketMessage,
|
||||
): Promise<SupportTicket> {
|
||||
const ticket = await this.prisma.supportTicket.findFirst({
|
||||
where: { id: ticketId, userId },
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
throw new NotFoundException('Support ticket not found');
|
||||
}
|
||||
|
||||
if (ticket.status === 'CLOSED' || ticket.status === 'RESOLVED') {
|
||||
throw new ForbiddenException('Cannot add messages to a closed ticket');
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.supportTicketMessage.create({
|
||||
data: {
|
||||
ticketId,
|
||||
authorId: userId,
|
||||
body: input.body.trim(),
|
||||
},
|
||||
}),
|
||||
this.prisma.supportTicket.update({
|
||||
where: { id: ticketId },
|
||||
data: {
|
||||
status: ticket.status === 'WAITING' ? 'OPEN' : ticket.status,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return this.get(userId, ticketId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user