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.
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 12s
CI / Go — node-agent tests (push) Failing after 9s
CI / Go — edge-gateway build (push) Successful in 17s

This commit is contained in:
TheOnlyMace
2026-07-05 18:39:53 +02:00
parent bf36cb3159
commit 50cd4b3ffd
225 changed files with 17824 additions and 436 deletions

View File

@@ -0,0 +1,74 @@
export interface CurseForgeProject {
id: number;
name: string;
slug: string;
summary: string;
downloadCount: number;
logo?: { thumbnailUrl?: string };
}
export interface CurseForgeSearchResponse {
data: CurseForgeProject[];
pagination: { totalCount: number };
}
export interface CurseForgeFile {
id: number;
displayName: string;
fileName: string;
downloadUrl: string;
fileLength: number;
gameVersions: string[];
modLoader?: number;
}
const CURSEFORGE_API = 'https://api.curseforge.com/v1';
function apiKey(): string {
const key = process.env['CURSEFORGE_API_KEY'];
if (!key) {
throw new Error('CURSEFORGE_API_KEY is not configured');
}
return key;
}
async function curseforgeFetch<T>(path: string): Promise<T> {
const response = await fetch(`${CURSEFORGE_API}${path}`, {
headers: {
'x-api-key': apiKey(),
Accept: 'application/json',
},
});
if (!response.ok) {
throw new Error(`CurseForge API error: ${response.status}`);
}
return (await response.json()) as T;
}
export async function searchCurseForgeMods(input: {
gameId?: number;
searchFilter: string;
pageSize?: number;
classId?: number;
}): Promise<CurseForgeSearchResponse> {
const params = new URLSearchParams({
gameId: String(input.gameId ?? 432),
searchFilter: input.searchFilter,
pageSize: String(input.pageSize ?? 20),
});
if (input.classId !== undefined) {
params.set('classId', String(input.classId));
}
return curseforgeFetch<CurseForgeSearchResponse>(`/mods/search?${params.toString()}`);
}
export async function getCurseForgeModFiles(modId: number): Promise<{ data: CurseForgeFile[] }> {
return curseforgeFetch<{ data: CurseForgeFile[] }>(`/mods/${modId}/files`);
}
export async function getCurseForgeMod(modId: number): Promise<{ data: CurseForgeProject }> {
return curseforgeFetch<{ data: CurseForgeProject }>(`/mods/${modId}`);
}

View File

@@ -11,6 +11,15 @@ export {
type SoftwareFamily,
} from './modrinth';
export {
searchCurseForgeMods,
getCurseForgeModFiles,
getCurseForgeMod,
type CurseForgeProject,
type CurseForgeSearchResponse,
type CurseForgeFile,
} from './curseforge';
export {
resolveModrinthLoader,
resolveProjectType,

View File

@@ -0,0 +1,42 @@
import { z } from 'zod';
export const abuseReportStatusSchema = z.enum([
'OPEN',
'INVESTIGATING',
'RESOLVED',
'DISMISSED',
]);
export const createAbuseReportSchema = z.object({
reason: z.string().min(1).max(256),
details: z.string().max(4096).optional(),
targetUserId: z.string().uuid().optional(),
serverId: z.string().uuid().optional(),
});
export const abuseReportSchema = z.object({
id: z.string().uuid(),
reporterId: z.string().uuid().nullable(),
targetUserId: z.string().uuid().nullable(),
serverId: z.string().uuid().nullable(),
status: abuseReportStatusSchema,
reason: z.string(),
details: z.string().nullable(),
resolution: z.string().nullable(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
export const abuseReportListResponseSchema = z.object({
reports: z.array(abuseReportSchema),
});
export const updateAbuseReportSchema = z.object({
status: abuseReportStatusSchema.optional(),
resolution: z.string().max(4096).optional(),
});
export type CreateAbuseReport = z.infer<typeof createAbuseReportSchema>;
export type AbuseReport = z.infer<typeof abuseReportSchema>;
export type AbuseReportListResponse = z.infer<typeof abuseReportListResponseSchema>;
export type UpdateAbuseReport = z.infer<typeof updateAbuseReportSchema>;

View File

@@ -0,0 +1,25 @@
import { z } from 'zod';
export const dataExportRequestSchema = z.object({
id: z.string().uuid(),
status: z.enum(['PENDING', 'PROCESSING', 'READY', 'FAILED', 'EXPIRED']),
downloadUrl: z.string().url().nullable(),
expiresAt: z.string().datetime().nullable(),
completedAt: z.string().datetime().nullable(),
createdAt: z.string().datetime(),
});
export const accountDeletionRequestSchema = z.object({
id: z.string().uuid(),
status: z.enum(['PENDING', 'SCHEDULED', 'COMPLETED', 'CANCELLED']),
scheduledFor: z.string().datetime(),
completedAt: z.string().datetime().nullable(),
createdAt: z.string().datetime(),
});
export const requestAccountDeletionSchema = z.object({
confirmEmail: z.string().email(),
});
export type DataExportRequest = z.infer<typeof dataExportRequestSchema>;
export type AccountDeletionRequest = z.infer<typeof accountDeletionRequestSchema>;

View File

@@ -21,6 +21,7 @@ export const fileContentSchema = z.object({
path: z.string(),
content: z.string(),
encoding: z.enum(['utf-8', 'base64']).default('utf-8'),
version: z.number().int().optional(),
});
export const readFileContentQuerySchema = z.object({
@@ -31,6 +32,27 @@ export const updateFileContentSchema = z.object({
path: z.string().min(1),
content: z.string(),
encoding: z.enum(['utf-8', 'base64']).default('utf-8'),
version: z.number().int().optional(),
});
export const uploadFileSchema = z.object({
path: z.string().min(1),
content: z.string(),
encoding: z.enum(['utf-8', 'base64']).default('base64'),
});
export const filePathSchema = z.object({
path: z.string().min(1),
});
export const archiveFilesSchema = z.object({
paths: z.array(z.string().min(1)).min(1).max(100),
archiveName: z.string().min(1).max(128).optional(),
});
export const unarchiveFileSchema = z.object({
archivePath: z.string().min(1),
destinationPath: z.string().default('.'),
});
export type ListFilesQuery = z.infer<typeof listFilesQuerySchema>;
@@ -39,3 +61,6 @@ export type FileListResponse = z.infer<typeof fileListResponseSchema>;
export type FileContent = z.infer<typeof fileContentSchema>;
export type ReadFileContentQuery = z.infer<typeof readFileContentQuerySchema>;
export type UpdateFileContent = z.infer<typeof updateFileContentSchema>;
export type UploadFile = z.infer<typeof uploadFileSchema>;
export type ArchiveFiles = z.infer<typeof archiveFilesSchema>;
export type UnarchiveFile = z.infer<typeof unarchiveFileSchema>;

View File

@@ -61,6 +61,7 @@ export {
serverEditionSchema,
softwareFamilySchema,
createServerRequestSchema,
updateServerRequestSchema,
serverResponseSchema,
serverListResponseSchema,
serverActionResponseSchema,
@@ -68,6 +69,7 @@ export {
type ServerEdition,
type SoftwareFamily,
type CreateServerRequest,
type UpdateServerRequest,
type ServerResponse,
type ServerListResponse,
type ServerActionResponse,
@@ -80,12 +82,19 @@ export {
fileContentSchema,
readFileContentQuerySchema,
updateFileContentSchema,
uploadFileSchema,
filePathSchema,
archiveFilesSchema,
unarchiveFileSchema,
type ListFilesQuery,
type FileEntry,
type FileListResponse,
type FileContent,
type ReadFileContentQuery,
type UpdateFileContent,
type UploadFile,
type ArchiveFiles,
type UnarchiveFile,
} from './files';
export {
@@ -108,13 +117,54 @@ export {
onlinePlayerSchema,
whitelistEntrySchema,
operatorEntrySchema,
banEntrySchema,
playerListResponseSchema,
addWhitelistSchema,
addOperatorSchema,
removePlayerEntrySchema,
addBanSchema,
type OnlinePlayer,
type WhitelistEntry,
type OperatorEntry,
type BanEntry,
type PlayerListResponse,
type AddWhitelist,
type AddOperator,
type RemovePlayerEntry,
type AddBan,
} from './players';
export {
scheduleTaskTypeSchema,
scheduleSchema,
createScheduleSchema,
updateScheduleSchema,
scheduleListResponseSchema,
type Schedule,
type CreateSchedule,
type UpdateSchedule,
type ScheduleListResponse,
} from './schedules';
export {
notificationTypeSchema,
notificationChannelSchema,
notificationSchema,
notificationListResponseSchema,
notificationPreferenceSchema,
updateNotificationPreferencesSchema,
type Notification,
type NotificationListResponse,
type UpdateNotificationPreferences,
} from './notifications';
export {
reinstallServerSchema,
reinstallServerResponseSchema,
type ReinstallServer,
type ReinstallServerResponse,
} from './software';
export {
backupStatusSchema,
backupTypeSchema,
@@ -273,3 +323,62 @@ export {
type WhmcsMtlsStatusResponse,
type WhmcsValidatePlanResponse,
} from './whmcs';
export {
dataExportRequestSchema,
accountDeletionRequestSchema,
requestAccountDeletionSchema,
type DataExportRequest,
type AccountDeletionRequest,
} from './compliance';
export {
whmcsServiceActionRequestSchema,
whmcsServiceActionResponseSchema,
type WhmcsServiceActionRequest,
type WhmcsServiceActionResponse,
} from './whmcs-actions';
export {
organizationMemberRoleSchema,
organizationSchema,
organizationListResponseSchema,
createOrganizationSchema,
organizationMemberSchema,
organizationMemberListResponseSchema,
inviteOrganizationMemberSchema,
organizationInviteResponseSchema,
type Organization,
type OrganizationListResponse,
type CreateOrganization,
type OrganizationMember,
type OrganizationMemberListResponse,
type InviteOrganizationMember,
type OrganizationInviteResponse,
} from './organizations';
export {
supportTicketStatusSchema,
supportTicketPrioritySchema,
supportTicketMessageSchema,
supportTicketSchema,
supportTicketListResponseSchema,
createSupportTicketSchema,
addSupportTicketMessageSchema,
type SupportTicket,
type SupportTicketListResponse,
type CreateSupportTicket,
type AddSupportTicketMessage,
} from './support';
export {
abuseReportStatusSchema,
createAbuseReportSchema,
abuseReportSchema,
abuseReportListResponseSchema,
updateAbuseReportSchema,
type CreateAbuseReport,
type AbuseReport,
type AbuseReportListResponse,
type UpdateAbuseReport,
} from './abuse';

View File

@@ -0,0 +1,51 @@
import { z } from 'zod';
export const notificationTypeSchema = z.enum([
'SERVER_READY',
'SERVER_START_FAILED',
'SERVER_STOPPED',
'IDLE_STOP_WARNING',
'BACKUP_SUCCESS',
'BACKUP_FAILED',
'RESTORE_SUCCESS',
'RESTORE_FAILED',
'CREDITS_LOW',
'BILLING_ISSUE',
'INVITE',
'NODE_INCIDENT',
'MAINTENANCE',
'SECURITY',
'NEW_LOGIN',
'TWO_FA_CHANGED',
]);
export const notificationChannelSchema = z.enum(['IN_APP', 'EMAIL', 'WEBHOOK', 'DISCORD']);
export const notificationSchema = z.object({
id: z.string().uuid(),
type: notificationTypeSchema,
channel: notificationChannelSchema,
title: z.string(),
body: z.string(),
readAt: z.string().datetime().nullable(),
createdAt: z.string().datetime(),
});
export const notificationListResponseSchema = z.object({
notifications: z.array(notificationSchema),
unreadCount: z.number().int(),
});
export const notificationPreferenceSchema = z.object({
type: notificationTypeSchema,
channel: notificationChannelSchema,
enabled: z.boolean(),
});
export const updateNotificationPreferencesSchema = z.object({
preferences: z.array(notificationPreferenceSchema),
});
export type Notification = z.infer<typeof notificationSchema>;
export type NotificationListResponse = z.infer<typeof notificationListResponseSchema>;
export type UpdateNotificationPreferences = z.infer<typeof updateNotificationPreferencesSchema>;

View File

@@ -0,0 +1,56 @@
import { z } from 'zod';
export const organizationMemberRoleSchema = z.enum(['OWNER', 'ADMIN', 'MEMBER']);
export const organizationSchema = z.object({
id: z.string().uuid(),
name: z.string(),
slug: z.string(),
role: organizationMemberRoleSchema,
createdAt: z.string().datetime(),
});
export const organizationListResponseSchema = z.object({
organizations: z.array(organizationSchema),
});
export const createOrganizationSchema = z.object({
name: z.string().min(1).max(128),
slug: z.string().min(2).max(64).regex(/^[a-z0-9-]+$/).optional(),
});
export const organizationMemberSchema = z.object({
id: z.string().uuid(),
userId: z.string().uuid(),
role: organizationMemberRoleSchema,
email: z.string().email(),
username: z.string(),
displayName: z.string().nullable(),
createdAt: z.string().datetime(),
});
export const organizationMemberListResponseSchema = z.object({
members: z.array(organizationMemberSchema),
});
export const inviteOrganizationMemberSchema = z.object({
email: z.string().email(),
role: organizationMemberRoleSchema.default('MEMBER'),
});
export const organizationInviteResponseSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
role: organizationMemberRoleSchema,
expiresAt: z.string().datetime(),
});
export type Organization = z.infer<typeof organizationSchema>;
export type OrganizationListResponse = z.infer<typeof organizationListResponseSchema>;
export type CreateOrganization = z.infer<typeof createOrganizationSchema>;
export type OrganizationMember = z.infer<typeof organizationMemberSchema>;
export type OrganizationMemberListResponse = z.infer<
typeof organizationMemberListResponseSchema
>;
export type InviteOrganizationMember = z.infer<typeof inviteOrganizationMemberSchema>;
export type OrganizationInviteResponse = z.infer<typeof organizationInviteResponseSchema>;

View File

@@ -17,13 +17,48 @@ export const operatorEntrySchema = z.object({
bypassesPlayerLimit: z.boolean().optional(),
});
export const banEntrySchema = z.object({
uuid: z.string().uuid().optional(),
name: z.string(),
reason: z.string().optional(),
source: z.string().optional(),
expires: z.string().optional(),
});
export const playerListResponseSchema = z.object({
online: z.array(onlinePlayerSchema),
whitelist: z.array(whitelistEntrySchema),
operators: z.array(operatorEntrySchema),
bans: z.array(banEntrySchema).optional(),
});
export const addWhitelistSchema = z.object({
uuid: z.string().uuid(),
name: z.string().min(1).max(16),
});
export const addOperatorSchema = z.object({
uuid: z.string().uuid(),
name: z.string().min(1).max(16),
level: z.number().int().min(0).max(4).default(4),
bypassesPlayerLimit: z.boolean().default(false),
});
export const removePlayerEntrySchema = z.object({
uuid: z.string().uuid(),
});
export const addBanSchema = z.object({
name: z.string().min(1).max(16),
reason: z.string().max(256).optional(),
});
export type OnlinePlayer = z.infer<typeof onlinePlayerSchema>;
export type WhitelistEntry = z.infer<typeof whitelistEntrySchema>;
export type OperatorEntry = z.infer<typeof operatorEntrySchema>;
export type BanEntry = z.infer<typeof banEntrySchema>;
export type PlayerListResponse = z.infer<typeof playerListResponseSchema>;
export type AddWhitelist = z.infer<typeof addWhitelistSchema>;
export type AddOperator = z.infer<typeof addOperatorSchema>;
export type RemovePlayerEntry = z.infer<typeof removePlayerEntrySchema>;
export type AddBan = z.infer<typeof addBanSchema>;

View File

@@ -0,0 +1,54 @@
import { z } from 'zod';
export const scheduleTaskTypeSchema = z.enum([
'START',
'STOP',
'RESTART',
'BACKUP',
'CONSOLE_COMMAND',
'UPDATE_CHECK',
'MAINTENANCE',
]);
export const scheduledTaskSchema = z.object({
id: z.string().uuid(),
taskType: scheduleTaskTypeSchema,
payload: z.record(z.unknown()).nullable(),
});
export const scheduleSchema = z.object({
id: z.string().uuid(),
name: z.string(),
timezone: z.string(),
cronExpr: z.string(),
enabled: z.boolean(),
nextRunAt: z.string().datetime().nullable(),
lastRunAt: z.string().datetime().nullable(),
tasks: z.array(scheduledTaskSchema),
});
export const createScheduleSchema = z.object({
name: z.string().min(1).max(64),
timezone: z.string().default('UTC'),
cronExpr: z.string().min(5).max(128),
enabled: z.boolean().default(true),
tasks: z
.array(
z.object({
taskType: scheduleTaskTypeSchema,
payload: z.record(z.unknown()).optional(),
}),
)
.min(1),
});
export const updateScheduleSchema = createScheduleSchema.partial();
export const scheduleListResponseSchema = z.object({
schedules: z.array(scheduleSchema),
});
export type Schedule = z.infer<typeof scheduleSchema>;
export type CreateSchedule = z.infer<typeof createScheduleSchema>;
export type UpdateSchedule = z.infer<typeof updateScheduleSchema>;
export type ScheduleListResponse = z.infer<typeof scheduleListResponseSchema>;

View File

@@ -2,6 +2,7 @@ import { z } from 'zod';
export const gameServerStatusSchema = z.enum([
'DRAFT',
'ALLOCATING',
'PROVISIONING',
'INSTALLING',
'STOPPED',
@@ -11,6 +12,9 @@ export const gameServerStatusSchema = z.enum([
'STOPPING',
'BACKING_UP',
'RESTORING',
'MIGRATING',
'SUSPENDED',
'MAINTENANCE',
'UNKNOWN',
'ERROR',
'DELETING',
@@ -42,6 +46,11 @@ export const createServerRequestSchema = z.object({
planId: z.string().uuid().optional(),
});
export const updateServerRequestSchema = z.object({
name: z.string().min(1).max(64).optional(),
idleShutdownEnabled: z.boolean().optional(),
});
export const serverResponseSchema = z.object({
id: z.string().uuid(),
userId: z.string().uuid(),
@@ -82,6 +91,7 @@ export type GameServerStatus = z.infer<typeof gameServerStatusSchema>;
export type ServerEdition = z.infer<typeof serverEditionSchema>;
export type SoftwareFamily = z.infer<typeof softwareFamilySchema>;
export type CreateServerRequest = z.infer<typeof createServerRequestSchema>;
export type UpdateServerRequest = z.infer<typeof updateServerRequestSchema>;
export type ServerResponse = z.infer<typeof serverResponseSchema>;
export type ServerListResponse = z.infer<typeof serverListResponseSchema>;
export type ServerActionResponse = z.infer<typeof serverActionResponseSchema>;

View File

@@ -0,0 +1,18 @@
import { z } from 'zod';
import { softwareFamilySchema } from './servers';
export const reinstallServerSchema = z.object({
softwareFamily: softwareFamilySchema,
minecraftVersion: z.string().min(1).max(32),
createBackup: z.boolean().default(true),
});
export const reinstallServerResponseSchema = z.object({
serverId: z.string().uuid(),
jobId: z.string(),
status: z.string(),
});
export type ReinstallServer = z.infer<typeof reinstallServerSchema>;
export type ReinstallServerResponse = z.infer<typeof reinstallServerResponseSchema>;

View File

@@ -0,0 +1,50 @@
import { z } from 'zod';
export const supportTicketStatusSchema = z.enum([
'OPEN',
'IN_PROGRESS',
'WAITING',
'RESOLVED',
'CLOSED',
]);
export const supportTicketPrioritySchema = z.enum(['LOW', 'NORMAL', 'HIGH', 'URGENT']);
export const supportTicketMessageSchema = z.object({
id: z.string().uuid(),
authorId: z.string().uuid().nullable(),
isInternal: z.boolean(),
body: z.string(),
createdAt: z.string().datetime(),
});
export const supportTicketSchema = z.object({
id: z.string().uuid(),
subject: z.string(),
status: supportTicketStatusSchema,
priority: supportTicketPrioritySchema,
serverId: z.string().uuid().nullable(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
messages: z.array(supportTicketMessageSchema).optional(),
});
export const supportTicketListResponseSchema = z.object({
tickets: z.array(supportTicketSchema),
});
export const createSupportTicketSchema = z.object({
subject: z.string().min(1).max(256),
body: z.string().min(1).max(8192),
priority: supportTicketPrioritySchema.default('NORMAL'),
serverId: z.string().uuid().optional(),
});
export const addSupportTicketMessageSchema = z.object({
body: z.string().min(1).max(8192),
});
export type SupportTicket = z.infer<typeof supportTicketSchema>;
export type SupportTicketListResponse = z.infer<typeof supportTicketListResponseSchema>;
export type CreateSupportTicket = z.infer<typeof createSupportTicketSchema>;
export type AddSupportTicketMessage = z.infer<typeof addSupportTicketMessageSchema>;

View File

@@ -0,0 +1,12 @@
import { z } from 'zod';
import { serverActionResponseSchema } from './servers';
export const whmcsServiceActionRequestSchema = z.object({
reason: z.string().max(256).optional(),
});
export const whmcsServiceActionResponseSchema = serverActionResponseSchema;
export type WhmcsServiceActionRequest = z.infer<typeof whmcsServiceActionRequestSchema>;
export type WhmcsServiceActionResponse = z.infer<typeof whmcsServiceActionResponseSchema>;

View File

@@ -0,0 +1,18 @@
-- Gap closure migration: permissions, catalog, compliance, WHMCS mappings, server states
-- AlterEnum
ALTER TYPE "GameServerStatus" ADD VALUE IF NOT EXISTS 'ALLOCATING';
ALTER TYPE "GameServerStatus" ADD VALUE IF NOT EXISTS 'MIGRATING';
ALTER TYPE "GameServerStatus" ADD VALUE IF NOT EXISTS 'SUSPENDED';
ALTER TYPE "GameServerStatus" ADD VALUE IF NOT EXISTS 'MAINTENANCE';
-- CreateEnum
CREATE TYPE "GameServerMemberRole" AS ENUM ('OWNER', 'ADMIN', 'OPERATOR', 'DEVELOPER', 'VIEWER');
CREATE TYPE "OrganizationMemberRole" AS ENUM ('OWNER', 'ADMIN', 'MEMBER');
CREATE TYPE "NotificationChannel" AS ENUM ('IN_APP', 'EMAIL', 'WEBHOOK', 'DISCORD');
CREATE TYPE "NotificationType" AS ENUM ('SERVER_READY', 'SERVER_START_FAILED', 'SERVER_STOPPED', 'IDLE_STOP_WARNING', 'BACKUP_SUCCESS', 'BACKUP_FAILED', 'RESTORE_SUCCESS', 'RESTORE_FAILED', 'CREDITS_LOW', 'BILLING_ISSUE', 'INVITE', 'NODE_INCIDENT', 'MAINTENANCE', 'SECURITY', 'NEW_LOGIN', 'TWO_FA_CHANGED');
CREATE TYPE "AbuseReportStatus" AS ENUM ('OPEN', 'INVESTIGATING', 'RESOLVED', 'DISMISSED');
CREATE TYPE "SupportTicketStatus" AS ENUM ('OPEN', 'IN_PROGRESS', 'WAITING', 'RESOLVED', 'CLOSED');
CREATE TYPE "SupportTicketPriority" AS ENUM ('LOW', 'NORMAL', 'HIGH', 'URGENT');
CREATE TYPE "ScheduleTaskType" AS ENUM ('START', 'STOP', 'RESTART', 'BACKUP', 'CONSOLE_COMMAND', 'UPDATE_CHECK', 'MAINTENANCE');
CREATE TYPE "CatalogProviderKind" AS ENUM ('MODRINTH', 'CURSEFORGE', 'MOJANG', 'PAPERMC', 'CUSTOM');

View File

@@ -0,0 +1,768 @@
-- Gap closure tables: organizations, catalog, schedules, notifications, compliance, billing, admin
-- CreateEnum
DO $$ BEGIN
CREATE TYPE "JobStatus" AS ENUM ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED', 'CANCELLED');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- CreateTable
CREATE TABLE IF NOT EXISTS "organizations" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "organizations_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "organization_members" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" "OrganizationMemberRole" NOT NULL DEFAULT 'MEMBER',
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "organization_members_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "organization_invites" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"role" "OrganizationMemberRole" NOT NULL DEFAULT 'MEMBER',
"tokenHash" TEXT NOT NULL,
"invitedById" TEXT NOT NULL,
"expiresAt" TIMESTAMPTZ(3) NOT NULL,
"acceptedAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "organization_invites_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "game_server_members" (
"id" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" "GameServerMemberRole" NOT NULL DEFAULT 'VIEWER',
"permissions" JSONB,
"invitedAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"acceptedAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "game_server_members_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "game_server_properties" (
"id" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "game_server_properties_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "game_server_environment_variables" (
"id" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"isSecret" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "game_server_environment_variables_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "game_server_secrets" (
"id" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"valueCiphertext" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "game_server_secrets_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "game_node_capabilities" (
"id" TEXT NOT NULL,
"nodeId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" TEXT,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "game_node_capabilities_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "game_node_maintenance_windows" (
"id" TEXT NOT NULL,
"nodeId" TEXT NOT NULL,
"startsAt" TIMESTAMPTZ(3) NOT NULL,
"endsAt" TIMESTAMPTZ(3) NOT NULL,
"drainFirst" BOOLEAN NOT NULL DEFAULT true,
"message" TEXT,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "game_node_maintenance_windows_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "minecraft_versions" (
"id" TEXT NOT NULL,
"version" TEXT NOT NULL,
"edition" "ServerEdition" NOT NULL DEFAULT 'JAVA',
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "minecraft_versions_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "software_builds" (
"id" TEXT NOT NULL,
"family" "SoftwareFamily" NOT NULL,
"minecraftVersionId" TEXT NOT NULL,
"downloadUrl" TEXT,
"sha256" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "software_builds_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "runtime_images" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"imageRef" TEXT NOT NULL,
"digest" TEXT,
"javaVersion" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "runtime_images_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "catalog_providers" (
"id" TEXT NOT NULL,
"kind" "CatalogProviderKind" NOT NULL,
"name" TEXT NOT NULL,
"isEnabled" BOOLEAN NOT NULL DEFAULT false,
"config" JSONB,
"lastSyncAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "catalog_providers_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "catalog_projects" (
"id" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"externalId" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"name" TEXT NOT NULL,
"projectType" TEXT NOT NULL,
"metadata" JSONB,
"syncedAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "catalog_projects_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "catalog_versions" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"externalId" TEXT NOT NULL,
"versionNumber" TEXT NOT NULL,
"gameVersions" JSONB,
"loaders" JSONB,
"sha512" TEXT,
"downloadUrl" TEXT,
"syncedAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "catalog_versions_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "catalog_dependencies" (
"id" TEXT NOT NULL,
"versionId" TEXT NOT NULL,
"dependencyId" TEXT NOT NULL,
"dependencyType" TEXT NOT NULL,
CONSTRAINT "catalog_dependencies_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "worlds" (
"id" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT false,
"sizeBytes" BIGINT,
"hasLevelDat" BOOLEAN NOT NULL DEFAULT false,
"seed" TEXT,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "worlds_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "world_archives" (
"id" TEXT NOT NULL,
"worldId" TEXT NOT NULL,
"s3Key" TEXT NOT NULL,
"sha256" TEXT,
"sizeBytes" BIGINT,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "world_archives_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "schedules" (
"id" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"timezone" TEXT NOT NULL DEFAULT 'UTC',
"cronExpr" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"nextRunAt" TIMESTAMPTZ(3),
"lastRunAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "schedules_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "scheduled_tasks" (
"id" TEXT NOT NULL,
"scheduleId" TEXT NOT NULL,
"taskType" "ScheduleTaskType" NOT NULL,
"payload" JSONB,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "scheduled_tasks_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "notifications" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" "NotificationType" NOT NULL,
"channel" "NotificationChannel" NOT NULL DEFAULT 'IN_APP',
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"metadata" JSONB,
"readAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "notification_preferences" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" "NotificationType" NOT NULL,
"channel" "NotificationChannel" NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "notification_preferences_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "abuse_reports" (
"id" TEXT NOT NULL,
"reporterId" TEXT,
"targetUserId" TEXT,
"serverId" TEXT,
"status" "AbuseReportStatus" NOT NULL DEFAULT 'OPEN',
"reason" TEXT NOT NULL,
"details" TEXT,
"resolution" TEXT,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "abuse_reports_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "support_tickets" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"serverId" TEXT,
"subject" TEXT NOT NULL,
"status" "SupportTicketStatus" NOT NULL DEFAULT 'OPEN',
"priority" "SupportTicketPriority" NOT NULL DEFAULT 'NORMAL',
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "support_tickets_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "support_ticket_messages" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"authorId" TEXT,
"isInternal" BOOLEAN NOT NULL DEFAULT false,
"body" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "support_ticket_messages_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "subscriptions" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"planId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"startsAt" TIMESTAMPTZ(3) NOT NULL,
"endsAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "subscriptions_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "invoices" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"subscriptionId" TEXT,
"amountCents" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'EUR',
"status" TEXT NOT NULL,
"externalRef" TEXT,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "invoices_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "payments" (
"id" TEXT NOT NULL,
"invoiceId" TEXT NOT NULL,
"amountCents" INTEGER NOT NULL,
"provider" TEXT NOT NULL,
"externalRef" TEXT,
"status" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "payments_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "resource_quotas" (
"id" TEXT NOT NULL,
"userId" TEXT,
"planId" TEXT,
"key" TEXT NOT NULL,
"limit" INTEGER NOT NULL,
"used" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "resource_quotas_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "security_change_logs" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"metadata" JSONB,
"ipAddress" TEXT,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "security_change_logs_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "data_export_requests" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"downloadUrl" TEXT,
"expiresAt" TIMESTAMPTZ(3),
"completedAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "data_export_requests_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "account_deletion_requests" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"scheduledFor" TIMESTAMPTZ(3) NOT NULL,
"completedAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "account_deletion_requests_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "whmcs_user_links" (
"id" TEXT NOT NULL,
"installationId" TEXT NOT NULL,
"externalUserId" TEXT NOT NULL,
"externalClientId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "whmcs_user_links_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "whmcs_product_mappings" (
"id" TEXT NOT NULL,
"installationId" TEXT NOT NULL,
"externalProductId" TEXT NOT NULL,
"planSlug" TEXT NOT NULL,
"defaultEdition" "ServerEdition" NOT NULL DEFAULT 'JAVA',
"defaultSoftware" "SoftwareFamily" NOT NULL DEFAULT 'VANILLA',
"defaultVersion" TEXT NOT NULL DEFAULT '1.21.1',
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "whmcs_product_mappings_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "whmcs_option_mappings" (
"id" TEXT NOT NULL,
"installationId" TEXT NOT NULL,
"externalOptionId" TEXT NOT NULL,
"externalValueId" TEXT NOT NULL,
"resourceKey" TEXT NOT NULL,
"normalizedValue" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "whmcs_option_mappings_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "whmcs_addon_mappings" (
"id" TEXT NOT NULL,
"installationId" TEXT NOT NULL,
"externalAddonId" TEXT NOT NULL,
"featureKey" TEXT NOT NULL,
"deltaValue" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "whmcs_addon_mappings_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "feature_flags" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"description" TEXT,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"metadata" JSONB,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "feature_flags_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "system_settings" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" JSONB NOT NULL,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "system_settings_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "job_records" (
"id" TEXT NOT NULL,
"queue" TEXT NOT NULL,
"jobName" TEXT NOT NULL,
"status" "JobStatus" NOT NULL DEFAULT 'PENDING',
"payload" JSONB,
"result" JSONB,
"error" TEXT,
"attempts" INTEGER NOT NULL DEFAULT 0,
"startedAt" TIMESTAMPTZ(3),
"completedAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "job_records_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "audit_events" (
"id" TEXT NOT NULL,
"userId" TEXT,
"action" TEXT NOT NULL,
"entityType" TEXT,
"entityId" TEXT,
"metadata" JSONB,
"ipAddress" TEXT,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_events_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "organizations_slug_key" ON "organizations"("slug");
CREATE UNIQUE INDEX IF NOT EXISTS "organization_members_organizationId_userId_key" ON "organization_members"("organizationId", "userId");
CREATE INDEX IF NOT EXISTS "organization_members_userId_idx" ON "organization_members"("userId");
CREATE UNIQUE INDEX IF NOT EXISTS "organization_invites_tokenHash_key" ON "organization_invites"("tokenHash");
CREATE INDEX IF NOT EXISTS "organization_invites_organizationId_idx" ON "organization_invites"("organizationId");
CREATE INDEX IF NOT EXISTS "organization_invites_email_idx" ON "organization_invites"("email");
CREATE UNIQUE INDEX IF NOT EXISTS "game_server_members_serverId_userId_key" ON "game_server_members"("serverId", "userId");
CREATE INDEX IF NOT EXISTS "game_server_members_userId_idx" ON "game_server_members"("userId");
CREATE UNIQUE INDEX IF NOT EXISTS "game_server_properties_serverId_key_key" ON "game_server_properties"("serverId", "key");
CREATE UNIQUE INDEX IF NOT EXISTS "game_server_environment_variables_serverId_key_key" ON "game_server_environment_variables"("serverId", "key");
CREATE UNIQUE INDEX IF NOT EXISTS "game_server_secrets_serverId_name_key" ON "game_server_secrets"("serverId", "name");
CREATE UNIQUE INDEX IF NOT EXISTS "game_node_capabilities_nodeId_key_key" ON "game_node_capabilities"("nodeId", "key");
CREATE INDEX IF NOT EXISTS "game_node_maintenance_windows_nodeId_startsAt_idx" ON "game_node_maintenance_windows"("nodeId", "startsAt");
CREATE UNIQUE INDEX IF NOT EXISTS "minecraft_versions_version_key" ON "minecraft_versions"("version");
CREATE UNIQUE INDEX IF NOT EXISTS "software_builds_family_minecraftVersionId_key" ON "software_builds"("family", "minecraftVersionId");
CREATE UNIQUE INDEX IF NOT EXISTS "runtime_images_name_key" ON "runtime_images"("name");
CREATE UNIQUE INDEX IF NOT EXISTS "catalog_providers_kind_key" ON "catalog_providers"("kind");
CREATE UNIQUE INDEX IF NOT EXISTS "catalog_projects_providerId_externalId_key" ON "catalog_projects"("providerId", "externalId");
CREATE INDEX IF NOT EXISTS "catalog_projects_slug_idx" ON "catalog_projects"("slug");
CREATE UNIQUE INDEX IF NOT EXISTS "catalog_versions_projectId_externalId_key" ON "catalog_versions"("projectId", "externalId");
CREATE INDEX IF NOT EXISTS "catalog_dependencies_versionId_idx" ON "catalog_dependencies"("versionId");
CREATE UNIQUE INDEX IF NOT EXISTS "worlds_serverId_name_key" ON "worlds"("serverId", "name");
CREATE INDEX IF NOT EXISTS "world_archives_worldId_idx" ON "world_archives"("worldId");
CREATE INDEX IF NOT EXISTS "schedules_serverId_idx" ON "schedules"("serverId");
CREATE INDEX IF NOT EXISTS "scheduled_tasks_scheduleId_idx" ON "scheduled_tasks"("scheduleId");
CREATE INDEX IF NOT EXISTS "notifications_userId_readAt_idx" ON "notifications"("userId", "readAt");
CREATE INDEX IF NOT EXISTS "notifications_createdAt_idx" ON "notifications"("createdAt");
CREATE UNIQUE INDEX IF NOT EXISTS "notification_preferences_userId_type_channel_key" ON "notification_preferences"("userId", "type", "channel");
CREATE INDEX IF NOT EXISTS "abuse_reports_status_idx" ON "abuse_reports"("status");
CREATE INDEX IF NOT EXISTS "abuse_reports_serverId_idx" ON "abuse_reports"("serverId");
CREATE INDEX IF NOT EXISTS "support_tickets_userId_idx" ON "support_tickets"("userId");
CREATE INDEX IF NOT EXISTS "support_tickets_status_idx" ON "support_tickets"("status");
CREATE INDEX IF NOT EXISTS "support_ticket_messages_ticketId_idx" ON "support_ticket_messages"("ticketId");
CREATE INDEX IF NOT EXISTS "subscriptions_userId_idx" ON "subscriptions"("userId");
CREATE INDEX IF NOT EXISTS "invoices_userId_idx" ON "invoices"("userId");
CREATE INDEX IF NOT EXISTS "payments_invoiceId_idx" ON "payments"("invoiceId");
CREATE UNIQUE INDEX IF NOT EXISTS "resource_quotas_userId_planId_key_key" ON "resource_quotas"("userId", "planId", "key");
CREATE INDEX IF NOT EXISTS "security_change_logs_userId_createdAt_idx" ON "security_change_logs"("userId", "createdAt");
CREATE INDEX IF NOT EXISTS "data_export_requests_userId_idx" ON "data_export_requests"("userId");
CREATE INDEX IF NOT EXISTS "account_deletion_requests_userId_idx" ON "account_deletion_requests"("userId");
CREATE UNIQUE INDEX IF NOT EXISTS "whmcs_user_links_installationId_externalUserId_key" ON "whmcs_user_links"("installationId", "externalUserId");
CREATE INDEX IF NOT EXISTS "whmcs_user_links_userId_idx" ON "whmcs_user_links"("userId");
CREATE UNIQUE INDEX IF NOT EXISTS "whmcs_product_mappings_installationId_externalProductId_key" ON "whmcs_product_mappings"("installationId", "externalProductId");
CREATE UNIQUE INDEX IF NOT EXISTS "whmcs_option_mappings_installationId_externalOptionId_externalValueId_key" ON "whmcs_option_mappings"("installationId", "externalOptionId", "externalValueId");
CREATE UNIQUE INDEX IF NOT EXISTS "whmcs_addon_mappings_installationId_externalAddonId_key" ON "whmcs_addon_mappings"("installationId", "externalAddonId");
CREATE UNIQUE INDEX IF NOT EXISTS "feature_flags_key_key" ON "feature_flags"("key");
CREATE UNIQUE INDEX IF NOT EXISTS "system_settings_key_key" ON "system_settings"("key");
CREATE INDEX IF NOT EXISTS "job_records_queue_status_idx" ON "job_records"("queue", "status");
CREATE INDEX IF NOT EXISTS "job_records_createdAt_idx" ON "job_records"("createdAt");
CREATE INDEX IF NOT EXISTS "audit_events_userId_idx" ON "audit_events"("userId");
CREATE INDEX IF NOT EXISTS "audit_events_action_idx" ON "audit_events"("action");
CREATE INDEX IF NOT EXISTS "audit_events_entityType_entityId_idx" ON "audit_events"("entityType", "entityId");
CREATE INDEX IF NOT EXISTS "audit_events_createdAt_idx" ON "audit_events"("createdAt");
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "organization_members" ADD CONSTRAINT "organization_members_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "organization_members" ADD CONSTRAINT "organization_members_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "organization_invites" ADD CONSTRAINT "organization_invites_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "organization_invites" ADD CONSTRAINT "organization_invites_invitedById_fkey" FOREIGN KEY ("invitedById") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "game_server_members" ADD CONSTRAINT "game_server_members_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "game_server_members" ADD CONSTRAINT "game_server_members_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "game_server_properties" ADD CONSTRAINT "game_server_properties_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "game_server_environment_variables" ADD CONSTRAINT "game_server_environment_variables_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "game_server_secrets" ADD CONSTRAINT "game_server_secrets_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "game_node_capabilities" ADD CONSTRAINT "game_node_capabilities_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "game_nodes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "game_node_maintenance_windows" ADD CONSTRAINT "game_node_maintenance_windows_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "game_nodes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "software_builds" ADD CONSTRAINT "software_builds_minecraftVersionId_fkey" FOREIGN KEY ("minecraftVersionId") REFERENCES "minecraft_versions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "catalog_projects" ADD CONSTRAINT "catalog_projects_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "catalog_providers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "catalog_versions" ADD CONSTRAINT "catalog_versions_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "catalog_projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "catalog_dependencies" ADD CONSTRAINT "catalog_dependencies_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "catalog_versions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "worlds" ADD CONSTRAINT "worlds_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "world_archives" ADD CONSTRAINT "world_archives_worldId_fkey" FOREIGN KEY ("worldId") REFERENCES "worlds"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "schedules" ADD CONSTRAINT "schedules_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "scheduled_tasks" ADD CONSTRAINT "scheduled_tasks_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "schedules"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "notification_preferences" ADD CONSTRAINT "notification_preferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "abuse_reports" ADD CONSTRAINT "abuse_reports_reporterId_fkey" FOREIGN KEY ("reporterId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "support_tickets" ADD CONSTRAINT "support_tickets_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "support_ticket_messages" ADD CONSTRAINT "support_ticket_messages_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "support_tickets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "payments" ADD CONSTRAINT "payments_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "security_change_logs" ADD CONSTRAINT "security_change_logs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "data_export_requests" ADD CONSTRAINT "data_export_requests_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "account_deletion_requests" ADD CONSTRAINT "account_deletion_requests_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "whmcs_user_links" ADD CONSTRAINT "whmcs_user_links_installationId_fkey" FOREIGN KEY ("installationId") REFERENCES "whmcs_installations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "whmcs_user_links" ADD CONSTRAINT "whmcs_user_links_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "whmcs_product_mappings" ADD CONSTRAINT "whmcs_product_mappings_installationId_fkey" FOREIGN KEY ("installationId") REFERENCES "whmcs_installations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "whmcs_option_mappings" ADD CONSTRAINT "whmcs_option_mappings_installationId_fkey" FOREIGN KEY ("installationId") REFERENCES "whmcs_installations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "whmcs_addon_mappings" ADD CONSTRAINT "whmcs_addon_mappings_installationId_fkey" FOREIGN KEY ("installationId") REFERENCES "whmcs_installations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "audit_events" ADD CONSTRAINT "audit_events_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;

View File

@@ -31,6 +31,7 @@ enum SoftwareFamily {
enum GameServerStatus {
DRAFT
ALLOCATING
PROVISIONING
INSTALLING
STOPPED
@@ -40,12 +41,95 @@ enum GameServerStatus {
STOPPING
BACKING_UP
RESTORING
MIGRATING
SUSPENDED
MAINTENANCE
UNKNOWN
ERROR
DELETING
DELETED
}
enum GameServerMemberRole {
OWNER
ADMIN
OPERATOR
DEVELOPER
VIEWER
}
enum OrganizationMemberRole {
OWNER
ADMIN
MEMBER
}
enum NotificationChannel {
IN_APP
EMAIL
WEBHOOK
DISCORD
}
enum NotificationType {
SERVER_READY
SERVER_START_FAILED
SERVER_STOPPED
IDLE_STOP_WARNING
BACKUP_SUCCESS
BACKUP_FAILED
RESTORE_SUCCESS
RESTORE_FAILED
CREDITS_LOW
BILLING_ISSUE
INVITE
NODE_INCIDENT
MAINTENANCE
SECURITY
NEW_LOGIN
TWO_FA_CHANGED
}
enum AbuseReportStatus {
OPEN
INVESTIGATING
RESOLVED
DISMISSED
}
enum SupportTicketStatus {
OPEN
IN_PROGRESS
WAITING
RESOLVED
CLOSED
}
enum SupportTicketPriority {
LOW
NORMAL
HIGH
URGENT
}
enum ScheduleTaskType {
START
STOP
RESTART
BACKUP
CONSOLE_COMMAND
UPDATE_CHECK
MAINTENANCE
}
enum CatalogProviderKind {
MODRINTH
CURSEFORGE
MOJANG
PAPERMC
CUSTOM
}
enum GameNodeStatus {
ONLINE
OFFLINE
@@ -194,7 +278,18 @@ model User {
totpCredential TotpCredential?
creditWallet CreditWallet?
whmcsClientLink WhmcsClientLink?
whmcsUserLinks WhmcsUserLink[]
ssoTickets SsoTicket[]
organizationMembers OrganizationMember[]
organizationInvites OrganizationInvite[] @relation("OrganizationInviteInviter")
gameServerMembers GameServerMember[]
notifications Notification[]
notificationPreferences NotificationPreference[]
abuseReports AbuseReport[]
supportTickets SupportTicket[]
securityChangeLogs SecurityChangeLog[]
dataExportRequests DataExportRequest[]
accountDeletionRequests AccountDeletionRequest[]
@@map("users")
}
@@ -361,6 +456,12 @@ model GameServer {
dnsRecords ServerDnsRecord[]
whmcsServiceLink WhmcsServiceLink?
ssoTickets SsoTicket[]
members GameServerMember[]
worlds World[]
schedules Schedule[]
properties GameServerProperty[]
environmentVars GameServerEnvironmentVariable[]
secrets GameServerSecret[]
@@index([userId])
@@index([nodeId])
@@ -430,6 +531,8 @@ model GameNode {
heartbeats GameNodeHeartbeat[]
allocations GameServerAllocation[]
startQueueEntries ServerStartQueueEntry[]
capabilities GameNodeCapability[]
maintenanceWindows GameNodeMaintenanceWindow[]
@@map("game_nodes")
}
@@ -743,7 +846,11 @@ model WhmcsInstallation {
updatedAt DateTime @updatedAt @db.Timestamptz(3)
clientLinks WhmcsClientLink[]
userLinks WhmcsUserLink[]
serviceLinks WhmcsServiceLink[]
productMappings WhmcsProductMapping[]
optionMappings WhmcsOptionMapping[]
addonMappings WhmcsAddonMapping[]
operations IntegrationOperation[]
ssoTickets SsoTicket[]
events IntegrationEvent[]
@@ -907,3 +1014,562 @@ model WhmcsUsageExport {
@@index([installationId, periodEnd])
@@map("whmcs_usage_exports")
}
model Organization {
id String @id @default(uuid())
name String
slug String @unique
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
members OrganizationMember[]
invites OrganizationInvite[]
@@map("organizations")
}
model OrganizationMember {
id String @id @default(uuid())
organizationId String
userId String
role OrganizationMemberRole @default(MEMBER)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([organizationId, userId])
@@index([userId])
@@map("organization_members")
}
model OrganizationInvite {
id String @id @default(uuid())
organizationId String
email String
role OrganizationMemberRole @default(MEMBER)
tokenHash String @unique
invitedById String
expiresAt DateTime @db.Timestamptz(3)
acceptedAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
invitedBy User @relation("OrganizationInviteInviter", fields: [invitedById], references: [id], onDelete: Cascade)
@@index([organizationId])
@@index([email])
@@map("organization_invites")
}
model GameServerMember {
id String @id @default(uuid())
serverId String
userId String
role GameServerMemberRole @default(VIEWER)
permissions Json?
invitedAt DateTime @default(now()) @db.Timestamptz(3)
acceptedAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([serverId, userId])
@@index([userId])
@@map("game_server_members")
}
model GameServerProperty {
id String @id @default(uuid())
serverId String
key String
value String
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
@@unique([serverId, key])
@@map("game_server_properties")
}
model GameServerEnvironmentVariable {
id String @id @default(uuid())
serverId String
key String
value String
isSecret Boolean @default(false)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
@@unique([serverId, key])
@@map("game_server_environment_variables")
}
model GameServerSecret {
id String @id @default(uuid())
serverId String
name String
valueCiphertext String
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
@@unique([serverId, name])
@@map("game_server_secrets")
}
model GameNodeCapability {
id String @id @default(uuid())
nodeId String
key String
value String?
createdAt DateTime @default(now()) @db.Timestamptz(3)
node GameNode @relation(fields: [nodeId], references: [id], onDelete: Cascade)
@@unique([nodeId, key])
@@map("game_node_capabilities")
}
model GameNodeMaintenanceWindow {
id String @id @default(uuid())
nodeId String
startsAt DateTime @db.Timestamptz(3)
endsAt DateTime @db.Timestamptz(3)
drainFirst Boolean @default(true)
message String?
createdAt DateTime @default(now()) @db.Timestamptz(3)
node GameNode @relation(fields: [nodeId], references: [id], onDelete: Cascade)
@@index([nodeId, startsAt])
@@map("game_node_maintenance_windows")
}
model MinecraftVersion {
id String @id @default(uuid())
version String @unique
edition ServerEdition @default(JAVA)
isActive Boolean @default(true)
createdAt DateTime @default(now()) @db.Timestamptz(3)
softwareBuilds SoftwareBuild[]
@@map("minecraft_versions")
}
model SoftwareBuild {
id String @id @default(uuid())
family SoftwareFamily
minecraftVersionId String
downloadUrl String?
sha256 String?
isActive Boolean @default(true)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
minecraftVersion MinecraftVersion @relation(fields: [minecraftVersionId], references: [id], onDelete: Cascade)
@@unique([family, minecraftVersionId])
@@map("software_builds")
}
model RuntimeImage {
id String @id @default(uuid())
name String @unique
imageRef String
digest String?
javaVersion String?
isActive Boolean @default(true)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
@@map("runtime_images")
}
model CatalogProvider {
id String @id @default(uuid())
kind CatalogProviderKind @unique
name String
isEnabled Boolean @default(false)
config Json?
lastSyncAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
projects CatalogProject[]
@@map("catalog_providers")
}
model CatalogProject {
id String @id @default(uuid())
providerId String
externalId String
slug String
name String
projectType String
metadata Json?
syncedAt DateTime @default(now()) @db.Timestamptz(3)
provider CatalogProvider @relation(fields: [providerId], references: [id], onDelete: Cascade)
versions CatalogVersion[]
@@unique([providerId, externalId])
@@index([slug])
@@map("catalog_projects")
}
model CatalogVersion {
id String @id @default(uuid())
projectId String
externalId String
versionNumber String
gameVersions Json?
loaders Json?
sha512 String?
downloadUrl String?
syncedAt DateTime @default(now()) @db.Timestamptz(3)
project CatalogProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
dependencies CatalogDependency[] @relation("VersionDependencies")
@@unique([projectId, externalId])
@@map("catalog_versions")
}
model CatalogDependency {
id String @id @default(uuid())
versionId String
dependencyId String
dependencyType String
version CatalogVersion @relation("VersionDependencies", fields: [versionId], references: [id], onDelete: Cascade)
@@index([versionId])
@@map("catalog_dependencies")
}
model World {
id String @id @default(uuid())
serverId String
name String
isActive Boolean @default(false)
sizeBytes BigInt?
hasLevelDat Boolean @default(false)
seed String?
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
archives WorldArchive[]
@@unique([serverId, name])
@@map("worlds")
}
model WorldArchive {
id String @id @default(uuid())
worldId String
s3Key String
sha256 String?
sizeBytes BigInt?
createdAt DateTime @default(now()) @db.Timestamptz(3)
world World @relation(fields: [worldId], references: [id], onDelete: Cascade)
@@index([worldId])
@@map("world_archives")
}
model Schedule {
id String @id @default(uuid())
serverId String
name String
timezone String @default("UTC")
cronExpr String
enabled Boolean @default(true)
nextRunAt DateTime? @db.Timestamptz(3)
lastRunAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
tasks ScheduledTask[]
@@index([serverId])
@@map("schedules")
}
model ScheduledTask {
id String @id @default(uuid())
scheduleId String
taskType ScheduleTaskType
payload Json?
createdAt DateTime @default(now()) @db.Timestamptz(3)
schedule Schedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
@@index([scheduleId])
@@map("scheduled_tasks")
}
model Notification {
id String @id @default(uuid())
userId String
type NotificationType
channel NotificationChannel @default(IN_APP)
title String
body String
metadata Json?
readAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, readAt])
@@index([createdAt])
@@map("notifications")
}
model NotificationPreference {
id String @id @default(uuid())
userId String
type NotificationType
channel NotificationChannel
enabled Boolean @default(true)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, type, channel])
@@map("notification_preferences")
}
model AbuseReport {
id String @id @default(uuid())
reporterId String?
targetUserId String?
serverId String?
status AbuseReportStatus @default(OPEN)
reason String
details String?
resolution String?
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
reporter User? @relation(fields: [reporterId], references: [id], onDelete: SetNull)
@@index([status])
@@index([serverId])
@@map("abuse_reports")
}
model SupportTicket {
id String @id @default(uuid())
userId String
serverId String?
subject String
status SupportTicketStatus @default(OPEN)
priority SupportTicketPriority @default(NORMAL)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
messages SupportTicketMessage[]
@@index([userId])
@@index([status])
@@map("support_tickets")
}
model SupportTicketMessage {
id String @id @default(uuid())
ticketId String
authorId String?
isInternal Boolean @default(false)
body String
createdAt DateTime @default(now()) @db.Timestamptz(3)
ticket SupportTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
@@index([ticketId])
@@map("support_ticket_messages")
}
model Subscription {
id String @id @default(uuid())
userId String
planId String
status String
startsAt DateTime @db.Timestamptz(3)
endsAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
@@index([userId])
@@map("subscriptions")
}
model Invoice {
id String @id @default(uuid())
userId String
subscriptionId String?
amountCents Int
currency String @default("EUR")
status String
externalRef String?
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
payments Payment[]
@@index([userId])
@@map("invoices")
}
model Payment {
id String @id @default(uuid())
invoiceId String
amountCents Int
provider String
externalRef String?
status String
createdAt DateTime @default(now()) @db.Timestamptz(3)
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
@@index([invoiceId])
@@map("payments")
}
model ResourceQuota {
id String @id @default(uuid())
userId String?
planId String?
key String
limit Int
used Int @default(0)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
@@unique([userId, planId, key])
@@map("resource_quotas")
}
model SecurityChangeLog {
id String @id @default(uuid())
userId String
action String
metadata Json?
ipAddress String?
createdAt DateTime @default(now()) @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt])
@@map("security_change_logs")
}
model DataExportRequest {
id String @id @default(uuid())
userId String
status String @default("PENDING")
downloadUrl String?
expiresAt DateTime? @db.Timestamptz(3)
completedAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("data_export_requests")
}
model AccountDeletionRequest {
id String @id @default(uuid())
userId String
status String @default("PENDING")
scheduledFor DateTime @db.Timestamptz(3)
completedAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("account_deletion_requests")
}
model WhmcsUserLink {
id String @id @default(uuid())
installationId String
externalUserId String
externalClientId String
userId String
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
installation WhmcsInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([installationId, externalUserId])
@@index([userId])
@@map("whmcs_user_links")
}
model WhmcsProductMapping {
id String @id @default(uuid())
installationId String
externalProductId String
planSlug String
defaultEdition ServerEdition @default(JAVA)
defaultSoftware SoftwareFamily @default(VANILLA)
defaultVersion String @default("1.21.1")
isActive Boolean @default(true)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
installation WhmcsInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade)
@@unique([installationId, externalProductId])
@@map("whmcs_product_mappings")
}
model WhmcsOptionMapping {
id String @id @default(uuid())
installationId String
externalOptionId String
externalValueId String
resourceKey String
normalizedValue String
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
installation WhmcsInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade)
@@unique([installationId, externalOptionId, externalValueId])
@@map("whmcs_option_mappings")
}
model WhmcsAddonMapping {
id String @id @default(uuid())
installationId String
externalAddonId String
featureKey String
deltaValue String
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
installation WhmcsInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade)
@@unique([installationId, externalAddonId])
@@map("whmcs_addon_mappings")
}

View File

@@ -38,6 +38,19 @@ export type {
SystemSetting,
AuditEvent,
JobRecord,
Schedule,
ScheduledTask,
Notification,
NotificationPreference,
DataExportRequest,
AccountDeletionRequest,
Organization,
OrganizationMember,
OrganizationMemberRole,
OrganizationInvite,
SupportTicket,
SupportTicketMessage,
AbuseReport,
} from '@prisma/client';
import { PrismaClient, Prisma } from '@prisma/client';

View File

@@ -0,0 +1,17 @@
{
"name": "@hexahost/email-templates",
"version": "0.0.1",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,102 @@
export type EmailLocale = 'de' | 'en';
export interface EmailTemplateInput {
appName: string;
locale?: EmailLocale;
}
export interface LinkEmailInput extends EmailTemplateInput {
url: string;
}
const copy = {
de: {
verifySubject: (app: string) => `E-Mail bestätigen ${app}`,
verifyHtml: (app: string, url: string) =>
`<p>Willkommen bei ${app}!</p><p><a href="${url}">E-Mail-Adresse bestätigen</a></p><p>Der Link ist 24 Stunden gültig.</p>`,
resetSubject: (app: string) => `Passwort zurücksetzen ${app}`,
resetHtml: (app: string, url: string) =>
`<p>Du hast ein Passwort-Reset für ${app} angefordert.</p><p><a href="${url}">Neues Passwort setzen</a></p><p>Falls du das nicht warst, ignoriere diese E-Mail.</p>`,
serverReadySubject: (app: string, name: string) => `Server bereit ${name} ${app}`,
serverReadyHtml: (app: string, name: string) =>
`<p>Dein Server <strong>${name}</strong> ist bereit.</p><p>Öffne das Panel in ${app}, um zu starten.</p>`,
backupSuccessSubject: (app: string, name: string) => `Backup erfolgreich ${name}`,
backupSuccessHtml: (app: string, name: string) =>
`<p>Das Backup für <strong>${name}</strong> wurde erfolgreich erstellt.</p>`,
backupFailedSubject: (app: string, name: string) => `Backup fehlgeschlagen ${name}`,
backupFailedHtml: (app: string, name: string) =>
`<p>Das Backup für <strong>${name}</strong> ist fehlgeschlagen. Bitte prüfe das Panel in ${app}.</p>`,
},
en: {
verifySubject: (app: string) => `Verify your email ${app}`,
verifyHtml: (app: string, url: string) =>
`<p>Welcome to ${app}!</p><p><a href="${url}">Verify your email address</a></p><p>This link expires in 24 hours.</p>`,
resetSubject: (app: string) => `Reset your password ${app}`,
resetHtml: (app: string, url: string) =>
`<p>You requested a password reset for ${app}.</p><p><a href="${url}">Set a new password</a></p><p>If you did not request this, ignore this email.</p>`,
serverReadySubject: (app: string, name: string) => `Server ready ${name} ${app}`,
serverReadyHtml: (app: string, name: string) =>
`<p>Your server <strong>${name}</strong> is ready.</p><p>Open the panel in ${app} to get started.</p>`,
backupSuccessSubject: (app: string, name: string) => `Backup successful ${name}`,
backupSuccessHtml: (app: string, name: string) =>
`<p>The backup for <strong>${name}</strong> completed successfully.</p>`,
backupFailedSubject: (app: string, name: string) => `Backup failed ${name}`,
backupFailedHtml: (app: string, name: string) =>
`<p>The backup for <strong>${name}</strong> failed. Please check the panel in ${app}.</p>`,
},
} as const;
function localeOf(input: EmailTemplateInput): EmailLocale {
return input.locale === 'de' ? 'de' : 'en';
}
export function renderVerifyEmail(input: LinkEmailInput): { subject: string; html: string } {
const locale = localeOf(input);
const t = copy[locale];
return {
subject: t.verifySubject(input.appName),
html: t.verifyHtml(input.appName, input.url),
};
}
export function renderResetEmail(input: LinkEmailInput): { subject: string; html: string } {
const locale = localeOf(input);
const t = copy[locale];
return {
subject: t.resetSubject(input.appName),
html: t.resetHtml(input.appName, input.url),
};
}
export function renderServerReadyEmail(
input: EmailTemplateInput & { serverName: string },
): { subject: string; html: string } {
const locale = localeOf(input);
const t = copy[locale];
return {
subject: t.serverReadySubject(input.appName, input.serverName),
html: t.serverReadyHtml(input.appName, input.serverName),
};
}
export function renderBackupSuccessEmail(
input: EmailTemplateInput & { serverName: string },
): { subject: string; html: string } {
const locale = localeOf(input);
const t = copy[locale];
return {
subject: t.backupSuccessSubject(input.appName, input.serverName),
html: t.backupSuccessHtml(input.appName, input.serverName),
};
}
export function renderBackupFailedEmail(
input: EmailTemplateInput & { serverName: string },
): { subject: string; html: string } {
const locale = localeOf(input);
const t = copy[locale];
return {
subject: t.backupFailedSubject(input.appName, input.serverName),
html: t.backupFailedHtml(input.appName, input.serverName),
};
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}

View File

@@ -0,0 +1,22 @@
{
"name": "@hexahost/feature-flags",
"version": "0.0.1",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run",
"lint": "tsc --noEmit"
},
"dependencies": {
"@hexahost/database": "workspace:*"
},
"devDependencies": {
"typescript": "^5.7.3",
"vitest": "^3.0.5"
}
}

View File

@@ -0,0 +1,57 @@
import type { PrismaClient } from '@hexahost/database';
export interface FeatureFlagSnapshot {
key: string;
enabled: boolean;
metadata: Record<string, unknown> | null;
}
export class FeatureFlagService {
private cache = new Map<string, FeatureFlagSnapshot>();
private loadedAt = 0;
private readonly ttlMs: number;
constructor(
private readonly prisma: PrismaClient,
ttlMs = 30_000,
) {
this.ttlMs = ttlMs;
}
async refresh(): Promise<void> {
const flags = await this.prisma.featureFlag.findMany();
this.cache.clear();
for (const flag of flags) {
this.cache.set(flag.key, {
key: flag.key,
enabled: flag.enabled,
metadata:
flag.metadata && typeof flag.metadata === 'object' && !Array.isArray(flag.metadata)
? (flag.metadata as Record<string, unknown>)
: null,
});
}
this.loadedAt = Date.now();
}
private async ensureLoaded(): Promise<void> {
if (Date.now() - this.loadedAt > this.ttlMs || this.cache.size === 0) {
await this.refresh();
}
}
async isEnabled(key: string, defaultValue = false): Promise<boolean> {
await this.ensureLoaded();
return this.cache.get(key)?.enabled ?? defaultValue;
}
async getFlag(key: string): Promise<FeatureFlagSnapshot | null> {
await this.ensureLoaded();
return this.cache.get(key) ?? null;
}
async listFlags(): Promise<FeatureFlagSnapshot[]> {
await this.ensureLoaded();
return [...this.cache.values()];
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}

View File

@@ -0,0 +1,24 @@
{
"name": "@hexahost/otel",
"version": "0.0.1",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit"
},
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.57.1",
"@opentelemetry/resources": "^1.30.1",
"@opentelemetry/sdk-node": "^0.57.1",
"@opentelemetry/semantic-conventions": "^1.30.0"
},
"devDependencies": {
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,37 @@
import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { Resource } from '@opentelemetry/resources';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
let sdk: NodeSDK | null = null;
export function initOpenTelemetry(serviceName: string): NodeSDK | null {
const endpoint = process.env['OTEL_EXPORTER_OTLP_ENDPOINT'];
if (!endpoint) {
return null;
}
if (process.env['OTEL_LOG_LEVEL'] === 'debug') {
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG);
}
sdk = new NodeSDK({
resource: new Resource({
[ATTR_SERVICE_NAME]: serviceName,
}),
traceExporter: new OTLPTraceExporter({
url: endpoint.endsWith('/v1/traces') ? endpoint : `${endpoint.replace(/\/$/, '')}/v1/traces`,
}),
});
sdk.start();
return sdk;
}
export async function shutdownOpenTelemetry(): Promise<void> {
if (sdk) {
await sdk.shutdown();
sdk = null;
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}

View File

@@ -0,0 +1,17 @@
{
"name": "@hexahost/permissions",
"version": "0.0.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"devDependencies": {
"@hexahost/typescript-config": "workspace:*",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
}
}

View File

@@ -0,0 +1,134 @@
export type ServerPermission =
| 'server.view'
| 'server.start'
| 'server.stop'
| 'server.restart'
| 'server.kill'
| 'server.console.view'
| 'server.console.command'
| 'server.files.view'
| 'server.files.edit'
| 'server.files.upload'
| 'server.files.download'
| 'server.files.delete'
| 'server.software.change'
| 'server.addons.manage'
| 'server.worlds.manage'
| 'server.players.manage'
| 'server.backups.view'
| 'server.backups.create'
| 'server.backups.restore'
| 'server.backups.delete'
| 'server.schedules.manage'
| 'server.members.manage'
| 'server.billing.view'
| 'server.delete';
export type ServerMemberRole =
| 'OWNER'
| 'ADMIN'
| 'OPERATOR'
| 'DEVELOPER'
| 'VIEWER';
const ROLE_PERMISSIONS: Record<ServerMemberRole, ServerPermission[]> = {
OWNER: [
'server.view',
'server.start',
'server.stop',
'server.restart',
'server.kill',
'server.console.view',
'server.console.command',
'server.files.view',
'server.files.edit',
'server.files.upload',
'server.files.download',
'server.files.delete',
'server.software.change',
'server.addons.manage',
'server.worlds.manage',
'server.players.manage',
'server.backups.view',
'server.backups.create',
'server.backups.restore',
'server.backups.delete',
'server.schedules.manage',
'server.members.manage',
'server.billing.view',
'server.delete',
],
ADMIN: [
'server.view',
'server.start',
'server.stop',
'server.restart',
'server.kill',
'server.console.view',
'server.console.command',
'server.files.view',
'server.files.edit',
'server.files.upload',
'server.files.download',
'server.files.delete',
'server.software.change',
'server.addons.manage',
'server.worlds.manage',
'server.players.manage',
'server.backups.view',
'server.backups.create',
'server.backups.restore',
'server.members.manage',
'server.billing.view',
],
OPERATOR: [
'server.view',
'server.start',
'server.stop',
'server.restart',
'server.console.view',
'server.console.command',
'server.players.manage',
'server.backups.view',
],
DEVELOPER: [
'server.view',
'server.console.view',
'server.console.command',
'server.files.view',
'server.files.edit',
'server.files.upload',
'server.files.download',
'server.addons.manage',
'server.worlds.manage',
'server.backups.view',
'server.backups.create',
],
VIEWER: ['server.view', 'server.console.view', 'server.files.view', 'server.backups.view'],
};
export function permissionsForRole(role: ServerMemberRole): ServerPermission[] {
return ROLE_PERMISSIONS[role];
}
export function hasPermission(
role: ServerMemberRole,
permission: ServerPermission,
overrides?: ServerPermission[] | null,
): boolean {
if (overrides?.includes(permission)) {
return true;
}
return ROLE_PERMISSIONS[role].includes(permission);
}
export function assertPermission(
role: ServerMemberRole,
permission: ServerPermission,
overrides?: ServerPermission[] | null,
): void {
if (!hasPermission(role, permission, overrides)) {
throw new Error(`Missing permission: ${permission}`);
}
}

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { hasPermission, permissionsForRole } from './index';
describe('permissions', () => {
it('grants owner full access', () => {
expect(hasPermission('OWNER', 'server.delete')).toBe(true);
expect(permissionsForRole('OWNER')).toContain('server.kill');
});
it('restricts viewer to read-only', () => {
expect(hasPermission('VIEWER', 'server.start')).toBe(false);
expect(hasPermission('VIEWER', 'server.view')).toBe(true);
});
it('honors explicit overrides', () => {
expect(hasPermission('VIEWER', 'server.start', ['server.start'])).toBe(true);
});
});

View File

@@ -0,0 +1,8 @@
{
"extends": "@hexahost/typescript-config/library.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}

View File

@@ -0,0 +1,17 @@
{
"name": "@hexahost/server-state",
"version": "0.0.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"devDependencies": {
"@hexahost/typescript-config": "workspace:*",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
}
}

View File

@@ -0,0 +1,107 @@
export type GameServerStatus =
| 'DRAFT'
| 'ALLOCATING'
| 'PROVISIONING'
| 'INSTALLING'
| 'STOPPED'
| 'QUEUED'
| 'STARTING'
| 'RUNNING'
| 'STOPPING'
| 'BACKING_UP'
| 'RESTORING'
| 'MIGRATING'
| 'SUSPENDED'
| 'MAINTENANCE'
| 'UNKNOWN'
| 'ERROR'
| 'DELETING'
| 'DELETED';
export const ALLOWED_TRANSITIONS: Record<GameServerStatus, GameServerStatus[]> = {
DRAFT: ['ALLOCATING', 'PROVISIONING', 'DELETING'],
ALLOCATING: ['PROVISIONING', 'ERROR', 'DELETING'],
PROVISIONING: ['INSTALLING', 'ERROR', 'DELETING'],
INSTALLING: ['STOPPED', 'ERROR', 'DELETING'],
STOPPED: [
'STARTING',
'QUEUED',
'PROVISIONING',
'BACKING_UP',
'RESTORING',
'MIGRATING',
'SUSPENDED',
'MAINTENANCE',
'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'],
MIGRATING: ['STOPPED', 'ERROR'],
SUSPENDED: ['STOPPED', 'DELETING'],
MAINTENANCE: ['STOPPED', 'DELETING'],
UNKNOWN: ['STOPPED', 'STARTING', 'ERROR', 'DELETING'],
ERROR: ['STOPPED', 'STARTING', 'QUEUED', 'PROVISIONING', 'DELETING'],
DELETING: ['DELETED'],
DELETED: [],
};
export const IN_PROGRESS_STATUSES: GameServerStatus[] = [
'ALLOCATING',
'PROVISIONING',
'INSTALLING',
'STARTING',
'STOPPING',
'BACKING_UP',
'RESTORING',
'MIGRATING',
'DELETING',
];
export function assertTransition(
fromStatus: GameServerStatus,
toStatus: GameServerStatus,
): void {
const allowed = ALLOWED_TRANSITIONS[fromStatus];
if (!allowed.includes(toStatus)) {
throw new Error(`Invalid state transition from ${fromStatus} to ${toStatus}`);
}
}
export function isInProgress(status: GameServerStatus): boolean {
return IN_PROGRESS_STATUSES.includes(status);
}
export function resolveStartTarget(status: GameServerStatus): GameServerStatus {
switch (status) {
case 'DRAFT':
case 'ERROR':
return 'PROVISIONING';
case 'STOPPED':
case 'UNKNOWN':
case 'QUEUED':
return 'STARTING';
default:
throw new Error(`Cannot start server while in status ${status}`);
}
}
export function resolveStopTarget(status: GameServerStatus): GameServerStatus {
if (status !== 'RUNNING') {
throw new Error(`Cannot stop server while in status ${status}`);
}
return 'STOPPING';
}
export function resolveKillTarget(status: GameServerStatus): GameServerStatus {
if (status !== 'RUNNING' && status !== 'STARTING' && status !== 'STOPPING') {
throw new Error(`Cannot kill server while in status ${status}`);
}
return 'STOPPED';
}

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import {
assertTransition,
isInProgress,
resolveKillTarget,
resolveStartTarget,
} from './index';
describe('server state machine', () => {
it('allows draft to provisioning', () => {
expect(() => assertTransition('DRAFT', 'PROVISIONING')).not.toThrow();
});
it('rejects invalid transition', () => {
expect(() => assertTransition('DELETED', 'RUNNING')).toThrow();
});
it('detects in-progress statuses', () => {
expect(isInProgress('STARTING')).toBe(true);
expect(isInProgress('STOPPED')).toBe(false);
});
it('resolves start and kill targets', () => {
expect(resolveStartTarget('STOPPED')).toBe('STARTING');
expect(resolveKillTarget('RUNNING')).toBe('STOPPED');
});
});

View File

@@ -0,0 +1,8 @@
{
"extends": "@hexahost/typescript-config/library.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}

View File

@@ -12,6 +12,10 @@
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"

View File

@@ -0,0 +1,112 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef, type HTMLAttributes } from "react";
import { cn } from "../lib/cn";
export const Dialog = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;
export const DialogPortal = DialogPrimitive.Portal;
export const DialogClose = DialogPrimitive.Close;
export const DialogOverlay = forwardRef<
ElementRef<typeof DialogPrimitive.Overlay>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-zinc-950/60 backdrop-blur-sm",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
export const DialogContent = forwardRef<
ElementRef<typeof DialogPrimitive.Content>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2",
"rounded-lg border border-zinc-200 bg-white p-6 shadow-lg",
"dark:border-zinc-800 dark:bg-zinc-900",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 focus-visible:ring-offset-2",
"dark:focus-visible:ring-offset-zinc-950",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close
className={cn(
"absolute right-4 top-4 rounded-md p-1 text-zinc-500 transition-colors",
"hover:bg-zinc-100 hover:text-zinc-900",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 focus-visible:ring-offset-2",
"dark:hover:bg-zinc-800 dark:hover:text-zinc-100 dark:focus-visible:ring-offset-zinc-950",
)}
aria-label="Close"
>
<span aria-hidden="true" className="text-lg leading-none">
×
</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
export interface DialogHeaderProps extends HTMLAttributes<HTMLDivElement> {}
export const DialogHeader = forwardRef<HTMLDivElement, DialogHeaderProps>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col gap-1.5 pr-8 text-left", className)} {...props} />
),
);
DialogHeader.displayName = "DialogHeader";
export interface DialogFooterProps extends HTMLAttributes<HTMLDivElement> {}
export const DialogFooter = forwardRef<HTMLDivElement, DialogFooterProps>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
),
);
DialogFooter.displayName = "DialogFooter";
export const DialogTitle = forwardRef<
ElementRef<typeof DialogPrimitive.Title>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-base font-semibold tracking-tight text-zinc-900 dark:text-zinc-50", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
export const DialogDescription = forwardRef<
ElementRef<typeof DialogPrimitive.Description>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-zinc-600 dark:text-zinc-400", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;

View File

@@ -0,0 +1,199 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef, type HTMLAttributes } from "react";
import { cn } from "../lib/cn";
export const DropdownMenu = DropdownMenuPrimitive.Root;
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
export const DropdownMenuGroup = DropdownMenuPrimitive.Group;
export const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
export const DropdownMenuSub = DropdownMenuPrimitive.Sub;
export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
export const DropdownMenuSubTrigger = forwardRef<
ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
"text-zinc-700 focus:bg-zinc-100 data-[state=open]:bg-zinc-100",
"dark:text-zinc-300 dark:focus:bg-zinc-800 dark:data-[state=open]:bg-zinc-800",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<span className="ml-auto text-xs text-zinc-500 dark:text-zinc-400" aria-hidden="true">
</span>
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
export const DropdownMenuSubContent = forwardRef<
ElementRef<typeof DropdownMenuPrimitive.SubContent>,
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-zinc-200 bg-white p-1 shadow-md",
"dark:border-zinc-800 dark:bg-zinc-900",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
export const DropdownMenuContent = forwardRef<
ElementRef<typeof DropdownMenuPrimitive.Content>,
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-zinc-200 bg-white p-1 shadow-md",
"dark:border-zinc-800 dark:bg-zinc-900",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
export const DropdownMenuItem = forwardRef<
ElementRef<typeof DropdownMenuPrimitive.Item>,
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
"text-zinc-700 focus:bg-zinc-100 focus:text-zinc-900",
"dark:text-zinc-300 dark:focus:bg-zinc-800 dark:focus:text-zinc-50",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
export const DropdownMenuCheckboxItem = forwardRef<
ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none",
"text-zinc-700 focus:bg-zinc-100 focus:text-zinc-900",
"dark:text-zinc-300 dark:focus:bg-zinc-800 dark:focus:text-zinc-50",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<span className="text-xs" aria-hidden="true">
</span>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
export const DropdownMenuRadioItem = forwardRef<
ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none",
"text-zinc-700 focus:bg-zinc-100 focus:text-zinc-900",
"dark:text-zinc-300 dark:focus:bg-zinc-800 dark:focus:text-zinc-50",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<span className="size-1.5 rounded-full bg-current" aria-hidden="true" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
export interface DropdownMenuLabelProps
extends ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> {
inset?: boolean;
}
export const DropdownMenuLabel = forwardRef<
ElementRef<typeof DropdownMenuPrimitive.Label>,
DropdownMenuLabelProps
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
export const DropdownMenuSeparator = forwardRef<
ElementRef<typeof DropdownMenuPrimitive.Separator>,
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-zinc-200 dark:bg-zinc-800", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
export interface DropdownMenuShortcutProps extends HTMLAttributes<HTMLSpanElement> {}
export const DropdownMenuShortcut = forwardRef<HTMLSpanElement, DropdownMenuShortcutProps>(
({ className, ...props }, ref) => (
<span
ref={ref}
className={cn("ml-auto text-xs tracking-widest text-zinc-500 dark:text-zinc-400", className)}
{...props}
/>
),
);
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";

View File

@@ -13,4 +13,37 @@ export {
type CardProps,
type CardTitleProps,
} from "./components/Card";
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
type DialogFooterProps,
type DialogHeaderProps,
} from "./components/Dialog";
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
type DropdownMenuLabelProps,
type DropdownMenuShortcutProps,
} from "./components/DropdownMenu";
export { cn } from "./lib/cn";