Initial commit: Dimension47 project setup

- NestJS backend with JWT auth, Prisma ORM, Swagger docs
- Vite + React 19 frontend with TypeScript
- Tailwind CSS v4 with custom dark theme design system
- Auth module: Login, Register, Protected routes
- Campaigns module: CRUD, Member management
- Full Prisma schema for PF2e campaign management
- Docker Compose for PostgreSQL

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-18 16:24:18 +01:00
commit 090aae53d8
73 changed files with 19842 additions and 0 deletions

22
server/.env.example Normal file
View File

@@ -0,0 +1,22 @@
# Dimension47 Server Environment Variables
# Database (PostgreSQL)
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/dimension47?schema=public"
# JWT Authentication
JWT_SECRET="change-this-to-a-secure-random-string"
JWT_EXPIRES_IN="7d"
# Server Configuration
PORT=5000
NODE_ENV=development
# CORS Origins (comma separated)
CORS_ORIGINS="http://localhost:3000,http://localhost:5173"
# Claude API (for translations)
CLAUDE_API_KEY=""
# File Upload
UPLOAD_DIR="./uploads"
MAX_FILE_SIZE=10485760

5
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
# Keep environment variables out of version control
.env
/generated/prisma

4
server/.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

98
server/README.md Normal file
View File

@@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).

35
server/eslint.config.mjs Normal file
View File

@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);

8
server/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

11487
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

94
server/package.json Normal file
View File

@@ -0,0 +1,94 @@
{
"name": "dimension47-server",
"version": "1.0.0",
"description": "Dimension47 TTRPG Campaign Management API",
"author": "Zeasy Software",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:studio": "prisma studio",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/platform-socket.io": "^11.1.12",
"@nestjs/swagger": "^11.2.5",
"@nestjs/websockets": "^11.1.12",
"@prisma/client": "^7.2.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"dotenv": "^17.2.3",
"multer": "^2.0.2",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"socket.io": "^4.8.3",
"swagger-ui-express": "^5.0.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/multer": "^2.0.0",
"@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"prisma": "^7.2.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

14
server/prisma.config.ts Normal file
View File

@@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});

542
server/prisma/schema.prisma Normal file
View File

@@ -0,0 +1,542 @@
// Dimension47 - TTRPG Campaign Management Platform
// Prisma Schema
generator client {
provider = "prisma-client-js"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
// ==========================================
// ENUMS
// ==========================================
enum UserRole {
ADMIN
GM
PLAYER
}
enum CharacterType {
PC
NPC
}
enum AbilityType {
STR
DEX
CON
INT
WIS
CHA
}
enum Proficiency {
UNTRAINED
TRAINED
EXPERT
MASTER
LEGENDARY
}
enum FeatSource {
CLASS
ANCESTRY
GENERAL
SKILL
BONUS
ARCHETYPE
}
enum SpellTradition {
ARCANE
DIVINE
OCCULT
PRIMAL
}
enum CombatantType {
PC
NPC
MONSTER
}
enum ActionType {
ACTION
REACTION
FREE
}
enum HighlightColor {
YELLOW
GREEN
BLUE
PINK
}
enum TranslationType {
FEAT
EQUIPMENT
SPELL
TRAIT
ANCESTRY
HERITAGE
CLASS
BACKGROUND
CONDITION
ACTION
}
enum TranslationQuality {
HIGH
MEDIUM
LOW
}
// ==========================================
// USER & AUTH
// ==========================================
model User {
id String @id @default(uuid())
username String @unique
email String @unique
passwordHash String
role UserRole @default(PLAYER)
avatarUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
gmCampaigns Campaign[] @relation("CampaignGM")
campaignMembers CampaignMember[]
characters Character[] @relation("CharacterOwner")
uploadedDocuments Document[]
documentAccess DocumentAccess[]
highlights Highlight[]
notes Note[]
noteShares NoteShare[]
}
// ==========================================
// CAMPAIGNS
// ==========================================
model Campaign {
id String @id @default(uuid())
name String
description String?
gmId String
imageUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
gm User @relation("CampaignGM", fields: [gmId], references: [id])
members CampaignMember[]
characters Character[]
battleMaps BattleMap[]
combatants Combatant[]
battleSessions BattleSession[]
documents Document[]
notes Note[]
}
model CampaignMember {
campaignId String
userId String
joinedAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([campaignId, userId])
}
// ==========================================
// CHARACTERS (Normalisiert)
// ==========================================
model Character {
id String @id @default(uuid())
campaignId String
ownerId String?
name String
type CharacterType @default(PC)
level Int @default(1)
avatarUrl String?
// Core Stats
hpCurrent Int
hpMax Int
hpTemp Int @default(0)
// Ancestry/Class/Background (References)
ancestryId String?
heritageId String?
classId String?
backgroundId String?
// Experience
experiencePoints Int @default(0)
// Pathbuilder Import Data (JSON blob for original import)
pathbuilderData Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
owner User? @relation("CharacterOwner", fields: [ownerId], references: [id])
abilities CharacterAbility[]
feats CharacterFeat[]
skills CharacterSkill[]
spells CharacterSpell[]
items CharacterItem[]
conditions CharacterCondition[]
resources CharacterResource[]
battleTokens BattleToken[]
documentAccess DocumentAccess[]
}
model CharacterAbility {
id String @id @default(uuid())
characterId String
ability AbilityType
score Int
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
@@unique([characterId, ability])
}
model CharacterFeat {
id String @id @default(uuid())
characterId String
featId String? // Reference to Feat table
name String // English name
nameGerman String? // German translation
level Int // Level obtained
source FeatSource
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
feat Feat? @relation(fields: [featId], references: [id])
}
model CharacterSkill {
id String @id @default(uuid())
characterId String
skillName String
proficiency Proficiency @default(UNTRAINED)
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
@@unique([characterId, skillName])
}
model CharacterSpell {
id String @id @default(uuid())
characterId String
spellId String?
name String
nameGerman String?
tradition SpellTradition
spellLevel Int
prepared Boolean @default(false)
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
spell Spell? @relation(fields: [spellId], references: [id])
}
model CharacterItem {
id String @id @default(uuid())
characterId String
equipmentId String? // Reference to Equipment library
name String
nameGerman String?
quantity Int @default(1)
bulk Decimal @default(0)
equipped Boolean @default(false)
invested Boolean @default(false)
containerId String? // For containers
notes String?
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
equipment Equipment? @relation(fields: [equipmentId], references: [id])
}
model CharacterCondition {
id String @id @default(uuid())
characterId String
name String
nameGerman String?
value Int? // For valued conditions like Frightened 2
duration String?
source String?
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
}
model CharacterResource {
id String @id @default(uuid())
characterId String
name String
current Int
max Int
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
@@unique([characterId, name])
}
// ==========================================
// BATTLE SYSTEM
// ==========================================
model BattleMap {
id String @id @default(uuid())
campaignId String
name String
imageUrl String
gridSizeX Int @default(20)
gridSizeY Int @default(20)
gridOffsetX Int @default(0)
gridOffsetY Int @default(0)
createdAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
battleSessions BattleSession[]
}
model Combatant {
id String @id @default(uuid())
campaignId String
name String
type CombatantType
level Int
hpMax Int
ac Int
// Saves
fortitude Int
reflex Int
will Int
perception Int
// Speed
speed Int @default(25)
avatarUrl String?
description String?
createdAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
abilities CombatantAbility[]
battleTokens BattleToken[]
}
model CombatantAbility {
id String @id @default(uuid())
combatantId String
name String
actionCost Int // 1, 2, 3, or 0 for free/reaction
actionType ActionType
description String
damage String? // e.g. "2d6+4 slashing"
traits String[]
combatant Combatant @relation(fields: [combatantId], references: [id], onDelete: Cascade)
}
model BattleSession {
id String @id @default(uuid())
campaignId String
mapId String?
name String?
isActive Boolean @default(false)
roundNumber Int @default(0)
createdAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
map BattleMap? @relation(fields: [mapId], references: [id])
tokens BattleToken[]
}
model BattleToken {
id String @id @default(uuid())
battleSessionId String
combatantId String?
characterId String?
name String
positionX Float
positionY Float
hpCurrent Int
hpMax Int
initiative Int?
conditions String[]
size Int @default(1) // 1 = Medium, 2 = Large, etc.
battleSession BattleSession @relation(fields: [battleSessionId], references: [id], onDelete: Cascade)
combatant Combatant? @relation(fields: [combatantId], references: [id])
character Character? @relation(fields: [characterId], references: [id])
}
// ==========================================
// DOCUMENTS SYSTEM
// ==========================================
model Document {
id String @id @default(uuid())
campaignId String
title String
description String?
category String?
tags String[]
filePath String
fileType String // html, pdf
uploadedBy String
createdAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
uploader User @relation(fields: [uploadedBy], references: [id])
access DocumentAccess[]
highlights Highlight[]
}
model DocumentAccess {
id String @id @default(uuid())
documentId String
userId String? // NULL = all campaign members
characterId String? // Access per character
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id])
character Character? @relation(fields: [characterId], references: [id])
@@unique([documentId, userId, characterId])
}
model Highlight {
id String @id @default(uuid())
documentId String
userId String
selectionText String
startOffset Int
endOffset Int
color HighlightColor
note String?
createdAt DateTime @default(now())
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
}
model Note {
id String @id @default(uuid())
userId String
campaignId String
title String
content String // Markdown
isShared Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
shares NoteShare[]
}
model NoteShare {
noteId String
userId String
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
@@id([noteId, userId])
}
// ==========================================
// REFERENCE DATA (Pathfinder 2e)
// ==========================================
model Feat {
id String @id @default(uuid())
name String @unique
traits String[]
summary String?
actions String?
url String?
level Int?
sourceBook String?
characterFeats CharacterFeat[]
}
model Equipment {
id String @id @default(uuid())
name String @unique
traits String[]
itemCategory String
itemSubcategory String?
bulk String?
url String?
summary String?
activation String?
hands String?
damage String?
range String?
weaponCategory String?
price Int? // In CP
level Int?
characterItems CharacterItem[]
}
model Spell {
id String @id @default(uuid())
name String @unique
level Int
actions String?
traditions String[]
traits String[]
range String?
targets String?
duration String?
description String?
url String?
characterSpells CharacterSpell[]
}
model Trait {
id String @id @default(uuid())
name String @unique
description String?
url String?
}
// ==========================================
// TRANSLATION CACHE
// ==========================================
model Translation {
id String @id @default(uuid())
type TranslationType
englishName String
germanName String
germanSummary String?
germanDescription String?
quality TranslationQuality @default(MEDIUM)
translatedBy String @default("claude-api")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([type, englishName])
@@index([type])
@@index([englishName])
}

44
server/src/app.module.ts Normal file
View File

@@ -0,0 +1,44 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
// Core Modules
import { PrismaModule } from './prisma/prisma.module';
// Feature Modules
import { AuthModule } from './modules/auth/auth.module';
import { CampaignsModule } from './modules/campaigns/campaigns.module';
// Guards
import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
import { RolesGuard } from './modules/auth/guards/roles.guard';
@Module({
imports: [
// Configuration
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
// Core
PrismaModule,
// Features
AuthModule,
CampaignsModule,
],
providers: [
// Global JWT Auth Guard
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
// Global Roles Guard
{
provide: APP_GUARD,
useClass: RolesGuard,
},
],
})
export class AppModule {}

View File

@@ -0,0 +1,10 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: string | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
return data ? user?.[data] : user;
},
);

View File

@@ -0,0 +1,3 @@
export * from './public.decorator';
export * from './roles.decorator';
export * from './current-user.decorator';

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '../../generated/prisma';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);

58
server/src/main.ts Normal file
View File

@@ -0,0 +1,58 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
// Global prefix
app.setGlobalPrefix('api');
// Validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
);
// CORS
const corsOrigins = configService.get<string>('CORS_ORIGINS', 'http://localhost:3000,http://localhost:5173');
app.enableCors({
origin: corsOrigins.split(','),
credentials: true,
});
// Swagger API Documentation
const config = new DocumentBuilder()
.setTitle('Dimension47 API')
.setDescription('TTRPG Campaign Management Platform API')
.setVersion('1.0')
.addBearerAuth()
.addTag('Auth', 'Authentication endpoints')
.addTag('Campaigns', 'Campaign management')
.addTag('Characters', 'Character management')
.addTag('Battle', 'Battle screen and combat')
.addTag('Documents', 'Document management')
.addTag('Library', 'Combatants and maps library')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
// Start server
const port = configService.get<number>('PORT', 5000);
await app.listen(port);
console.log(`🚀 Dimension47 Server running on http://localhost:${port}`);
console.log(`📚 API Documentation: http://localhost:${port}/api/docs`);
}
bootstrap();

View File

@@ -0,0 +1,79 @@
import {
Controller,
Post,
Get,
Put,
Body,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { RegisterDto, LoginDto } from './dto';
import { Public } from '../../common/decorators/public.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
import { UserRole } from '../../generated/prisma';
@ApiTags('Auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@Post('register')
@ApiOperation({ summary: 'Register a new user' })
@ApiResponse({ status: 201, description: 'User registered successfully' })
@ApiResponse({ status: 409, description: 'Username or email already exists' })
async register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
@Public()
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Login with username/email and password' })
@ApiResponse({ status: 200, description: 'Login successful' })
@ApiResponse({ status: 401, description: 'Invalid credentials' })
async login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@Get('me')
@ApiBearerAuth()
@ApiOperation({ summary: 'Get current user profile' })
@ApiResponse({ status: 200, description: 'User profile' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getProfile(@CurrentUser('id') userId: string) {
return this.authService.getProfile(userId);
}
@Put('profile')
@ApiBearerAuth()
@ApiOperation({ summary: 'Update current user profile' })
@ApiResponse({ status: 200, description: 'Profile updated' })
async updateProfile(
@CurrentUser('id') userId: string,
@Body() data: { avatarUrl?: string },
) {
return this.authService.updateProfile(userId, data);
}
@Get('users/search')
@ApiBearerAuth()
@Roles(UserRole.ADMIN, UserRole.GM)
@ApiOperation({ summary: 'Search for users (GM/Admin only)' })
@ApiResponse({ status: 200, description: 'List of matching users' })
async searchUsers(
@Query('q') query: string,
@CurrentUser('id') currentUserId: string,
) {
return this.authService.searchUsers(query || '', currentUserId);
}
}

View File

@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { RolesGuard } from './guards/roles.guard';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET') || 'fallback-secret',
signOptions: {
expiresIn: '7d',
},
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, JwtAuthGuard, RolesGuard],
exports: [AuthService, JwtAuthGuard, RolesGuard],
})
export class AuthModule {}

View File

@@ -0,0 +1,177 @@
import {
Injectable,
ConflictException,
UnauthorizedException,
NotFoundException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../../prisma/prisma.service';
import { RegisterDto, LoginDto } from './dto';
import { UserRole } from '../../generated/prisma';
@Injectable()
export class AuthService {
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
) {}
async register(dto: RegisterDto) {
// Check if username or email already exists
const existingUser = await this.prisma.user.findFirst({
where: {
OR: [
{ username: dto.username.toLowerCase() },
{ email: dto.email.toLowerCase() },
],
},
});
if (existingUser) {
if (existingUser.username.toLowerCase() === dto.username.toLowerCase()) {
throw new ConflictException('Username already taken');
}
throw new ConflictException('Email already registered');
}
// Hash password
const saltRounds = 10;
const passwordHash = await bcrypt.hash(dto.password, saltRounds);
// Create user
const user = await this.prisma.user.create({
data: {
username: dto.username.toLowerCase(),
email: dto.email.toLowerCase(),
passwordHash,
role: UserRole.PLAYER,
},
select: {
id: true,
username: true,
email: true,
role: true,
avatarUrl: true,
createdAt: true,
},
});
// Generate JWT
const token = this.generateToken(user.id, user.username, user.role);
return {
user,
token,
};
}
async login(dto: LoginDto) {
// Find user by username or email
const user = await this.prisma.user.findFirst({
where: {
OR: [
{ username: dto.identifier.toLowerCase() },
{ email: dto.identifier.toLowerCase() },
],
},
});
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
// Verify password
const isPasswordValid = await bcrypt.compare(dto.password, user.passwordHash);
if (!isPasswordValid) {
throw new UnauthorizedException('Invalid credentials');
}
// Generate JWT
const token = this.generateToken(user.id, user.username, user.role);
return {
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role,
avatarUrl: user.avatarUrl,
},
token,
};
}
async getProfile(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
username: true,
email: true,
role: true,
avatarUrl: true,
createdAt: true,
updatedAt: true,
},
});
if (!user) {
throw new NotFoundException('User not found');
}
return user;
}
async updateProfile(userId: string, data: { avatarUrl?: string }) {
const user = await this.prisma.user.update({
where: { id: userId },
data,
select: {
id: true,
username: true,
email: true,
role: true,
avatarUrl: true,
},
});
return user;
}
async searchUsers(query: string, currentUserId: string) {
const users = await this.prisma.user.findMany({
where: {
AND: [
{ id: { not: currentUserId } },
{
OR: [
{ username: { contains: query, mode: 'insensitive' } },
{ email: { contains: query, mode: 'insensitive' } },
],
},
],
},
select: {
id: true,
username: true,
email: true,
avatarUrl: true,
},
take: 10,
});
return users;
}
private generateToken(userId: string, username: string, role: UserRole): string {
const payload = {
sub: userId,
username,
role,
};
return this.jwtService.sign(payload);
}
}

View File

@@ -0,0 +1,2 @@
export * from './register.dto';
export * from './login.dto';

View File

@@ -0,0 +1,14 @@
import { IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginDto {
@ApiProperty({ example: 'johndoe', description: 'Username or email' })
@IsString()
@MinLength(1)
identifier: string;
@ApiProperty({ example: 'SecurePass123!', description: 'Password' })
@IsString()
@MinLength(1)
password: string;
}

View File

@@ -0,0 +1,23 @@
import { IsEmail, IsString, MinLength, MaxLength, Matches } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RegisterDto {
@ApiProperty({ example: 'johndoe', description: 'Username (3-20 characters)' })
@IsString()
@MinLength(3)
@MaxLength(20)
@Matches(/^[a-zA-Z0-9_]+$/, {
message: 'Username can only contain letters, numbers, and underscores',
})
username: string;
@ApiProperty({ example: 'john@example.com', description: 'Email address' })
@IsEmail()
email: string;
@ApiProperty({ example: 'SecurePass123!', description: 'Password (min 8 characters)' })
@IsString()
@MinLength(8)
@MaxLength(100)
password: string;
}

View File

@@ -0,0 +1,24 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../../../common/decorators/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}

View File

@@ -0,0 +1,23 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { UserRole } from '../../../generated/prisma';
import { ROLES_KEY } from '../../../common/decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) {
return true;
}
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user.role === role);
}
}

View File

@@ -0,0 +1,49 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../../prisma/prisma.service';
export interface JwtPayload {
sub: string;
username: string;
role: string;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
configService: ConfigService,
private prisma: PrismaService,
) {
const secret = configService.get<string>('JWT_SECRET');
if (!secret) {
throw new Error('JWT_SECRET is not defined in environment variables');
}
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: secret,
});
}
async validate(payload: JwtPayload) {
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
select: {
id: true,
username: true,
email: true,
role: true,
avatarUrl: true,
},
});
if (!user) {
throw new UnauthorizedException('User not found');
}
return user;
}
}

View File

@@ -0,0 +1,101 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { CampaignsService } from './campaigns.service';
import { CreateCampaignDto, UpdateCampaignDto, AddMemberDto } from './dto';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { UserRole } from '../../generated/prisma';
@ApiTags('Campaigns')
@ApiBearerAuth()
@Controller('campaigns')
export class CampaignsController {
constructor(private readonly campaignsService: CampaignsService) {}
@Post()
@ApiOperation({ summary: 'Create a new campaign' })
@ApiResponse({ status: 201, description: 'Campaign created successfully' })
async create(
@Body() dto: CreateCampaignDto,
@CurrentUser('id') userId: string,
) {
return this.campaignsService.create(dto, userId);
}
@Get()
@ApiOperation({ summary: 'Get all campaigns for current user' })
@ApiResponse({ status: 200, description: 'List of campaigns' })
async findAll(@CurrentUser('id') userId: string) {
return this.campaignsService.findAll(userId);
}
@Get(':id')
@ApiOperation({ summary: 'Get campaign by ID' })
@ApiResponse({ status: 200, description: 'Campaign details' })
@ApiResponse({ status: 404, description: 'Campaign not found' })
async findOne(
@Param('id') id: string,
@CurrentUser('id') userId: string,
) {
return this.campaignsService.findOne(id, userId);
}
@Put(':id')
@ApiOperation({ summary: 'Update campaign' })
@ApiResponse({ status: 200, description: 'Campaign updated' })
@ApiResponse({ status: 403, description: 'Not authorized' })
async update(
@Param('id') id: string,
@Body() dto: UpdateCampaignDto,
@CurrentUser('id') userId: string,
) {
return this.campaignsService.update(id, dto, userId);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete campaign' })
@ApiResponse({ status: 200, description: 'Campaign deleted' })
@ApiResponse({ status: 403, description: 'Not authorized' })
async remove(
@Param('id') id: string,
@CurrentUser('id') userId: string,
@CurrentUser('role') userRole: UserRole,
) {
return this.campaignsService.remove(id, userId, userRole);
}
@Post(':id/members')
@ApiOperation({ summary: 'Add member to campaign' })
@ApiResponse({ status: 200, description: 'Member added' })
@ApiResponse({ status: 409, description: 'User already a member' })
async addMember(
@Param('id') id: string,
@Body() dto: AddMemberDto,
@CurrentUser('id') userId: string,
) {
return this.campaignsService.addMember(id, dto, userId);
}
@Delete(':id/members/:userId')
@ApiOperation({ summary: 'Remove member from campaign' })
@ApiResponse({ status: 200, description: 'Member removed' })
async removeMember(
@Param('id') id: string,
@Param('userId') memberUserId: string,
@CurrentUser('id') userId: string,
) {
return this.campaignsService.removeMember(id, memberUserId, userId);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CampaignsController } from './campaigns.controller';
import { CampaignsService } from './campaigns.service';
@Module({
controllers: [CampaignsController],
providers: [CampaignsService],
exports: [CampaignsService],
})
export class CampaignsModule {}

View File

@@ -0,0 +1,289 @@
import {
Injectable,
NotFoundException,
ForbiddenException,
ConflictException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateCampaignDto, UpdateCampaignDto, AddMemberDto } from './dto';
import { UserRole } from '../../generated/prisma';
@Injectable()
export class CampaignsService {
constructor(private prisma: PrismaService) {}
async create(dto: CreateCampaignDto, userId: string) {
// Create campaign with user as GM
const campaign = await this.prisma.campaign.create({
data: {
name: dto.name,
description: dto.description,
imageUrl: dto.imageUrl,
gmId: userId,
members: {
create: {
userId: userId,
},
},
},
include: {
gm: {
select: {
id: true,
username: true,
avatarUrl: true,
},
},
members: {
include: {
user: {
select: {
id: true,
username: true,
avatarUrl: true,
},
},
},
},
_count: {
select: {
characters: true,
},
},
},
});
return campaign;
}
async findAll(userId: string) {
// Get all campaigns where user is GM or member
const campaigns = await this.prisma.campaign.findMany({
where: {
OR: [
{ gmId: userId },
{ members: { some: { userId } } },
],
},
include: {
gm: {
select: {
id: true,
username: true,
avatarUrl: true,
},
},
members: {
include: {
user: {
select: {
id: true,
username: true,
avatarUrl: true,
},
},
},
},
_count: {
select: {
characters: true,
},
},
},
orderBy: {
updatedAt: 'desc',
},
});
return campaigns;
}
async findOne(id: string, userId: string) {
const campaign = await this.prisma.campaign.findUnique({
where: { id },
include: {
gm: {
select: {
id: true,
username: true,
email: true,
avatarUrl: true,
},
},
members: {
include: {
user: {
select: {
id: true,
username: true,
email: true,
avatarUrl: true,
},
},
},
},
characters: {
select: {
id: true,
name: true,
type: true,
level: true,
avatarUrl: true,
hpCurrent: true,
hpMax: true,
owner: {
select: {
id: true,
username: true,
},
},
},
orderBy: {
name: 'asc',
},
},
_count: {
select: {
characters: true,
battleSessions: true,
documents: true,
},
},
},
});
if (!campaign) {
throw new NotFoundException('Campaign not found');
}
// Check if user has access
const hasAccess =
campaign.gmId === userId ||
campaign.members.some((m) => m.userId === userId);
if (!hasAccess) {
throw new ForbiddenException('No access to this campaign');
}
return campaign;
}
async update(id: string, dto: UpdateCampaignDto, userId: string) {
const campaign = await this.findOne(id, userId);
// Only GM can update campaign
if (campaign.gmId !== userId) {
throw new ForbiddenException('Only the GM can update the campaign');
}
return this.prisma.campaign.update({
where: { id },
data: dto,
include: {
gm: {
select: {
id: true,
username: true,
avatarUrl: true,
},
},
},
});
}
async remove(id: string, userId: string, userRole: UserRole) {
const campaign = await this.findOne(id, userId);
// Only GM or Admin can delete campaign
if (campaign.gmId !== userId && userRole !== UserRole.ADMIN) {
throw new ForbiddenException('Only the GM or an Admin can delete the campaign');
}
await this.prisma.campaign.delete({
where: { id },
});
return { message: 'Campaign deleted successfully' };
}
async addMember(campaignId: string, dto: AddMemberDto, userId: string) {
const campaign = await this.findOne(campaignId, userId);
// Only GM can add members
if (campaign.gmId !== userId) {
throw new ForbiddenException('Only the GM can add members');
}
// Check if user exists
const userToAdd = await this.prisma.user.findUnique({
where: { id: dto.userId },
});
if (!userToAdd) {
throw new NotFoundException('User not found');
}
// Check if user is already a member
const existingMember = await this.prisma.campaignMember.findUnique({
where: {
campaignId_userId: {
campaignId,
userId: dto.userId,
},
},
});
if (existingMember) {
throw new ConflictException('User is already a member of this campaign');
}
// Add member
await this.prisma.campaignMember.create({
data: {
campaignId,
userId: dto.userId,
},
});
return this.findOne(campaignId, userId);
}
async removeMember(campaignId: string, memberUserId: string, userId: string) {
const campaign = await this.findOne(campaignId, userId);
// Only GM can remove members
if (campaign.gmId !== userId) {
throw new ForbiddenException('Only the GM can remove members');
}
// Cannot remove GM
if (memberUserId === campaign.gmId) {
throw new ForbiddenException('Cannot remove the GM from the campaign');
}
// Check if member exists
const member = await this.prisma.campaignMember.findUnique({
where: {
campaignId_userId: {
campaignId,
userId: memberUserId,
},
},
});
if (!member) {
throw new NotFoundException('Member not found in campaign');
}
await this.prisma.campaignMember.delete({
where: {
campaignId_userId: {
campaignId,
userId: memberUserId,
},
},
});
return this.findOne(campaignId, userId);
}
}

View File

@@ -0,0 +1,9 @@
import { IsString, IsUUID } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class AddMemberDto {
@ApiProperty({ description: 'User ID to add as member' })
@IsString()
@IsUUID()
userId: string;
}

View File

@@ -0,0 +1,21 @@
import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateCampaignDto {
@ApiProperty({ example: 'Rise of the Runelords', description: 'Campaign name' })
@IsString()
@MinLength(1)
@MaxLength(100)
name: string;
@ApiPropertyOptional({ description: 'Campaign description' })
@IsString()
@IsOptional()
@MaxLength(2000)
description?: string;
@ApiPropertyOptional({ description: 'Campaign image URL' })
@IsString()
@IsOptional()
imageUrl?: string;
}

View File

@@ -0,0 +1,3 @@
export * from './create-campaign.dto';
export * from './update-campaign.dto';
export * from './add-member.dto';

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateCampaignDto } from './create-campaign.dto';
export class UpdateCampaignDto extends PartialType(CreateCampaignDto) {}

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@@ -0,0 +1,22 @@
import 'dotenv/config';
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '../generated/prisma';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor() {
super({
log: process.env.NODE_ENV === 'development'
? ['query', 'info', 'warn', 'error']
: ['error'],
});
}
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}

View File

@@ -0,0 +1,25 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});

View File

@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

25
server/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}