feat: Charaktere-Modul mit Pathbuilder Import

Backend:
- Characters-Modul (CRUD, HP-Tracking, Conditions)
- Pathbuilder 2e JSON Import Service
- Claude API Integration für automatische Übersetzungen
- Translations-Modul mit Datenbank-Caching
- Prisma Schema erweitert (Character, Abilities, Skills, Feats, Items, Resources)

Frontend:
- Kampagnen-Detailseite mit Mitglieder- und Charakterverwaltung
- Charakter erstellen Modal
- Pathbuilder Import Modal (Datei-Upload + JSON-Paste)
- Logo-Integration (Dimension 47 + Zeasy)
- Cinzel Font für Branding

Weitere Änderungen:
- Auth 401 Redirect Fix für Login-Seite
- PROGRESS.md mit Projektfortschritt

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alexander Zielonka
2026-01-18 20:36:44 +01:00
parent 090aae53d8
commit 94335ecd12
53 changed files with 4581 additions and 114 deletions

268
server/package-lock.json generated
View File

@@ -1,14 +1,15 @@
{
"name": "server",
"version": "0.0.1",
"name": "dimension47-server",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "server",
"version": "0.0.1",
"name": "dimension47-server",
"version": "1.0.0",
"license": "UNLICENSED",
"dependencies": {
"@anthropic-ai/sdk": "^0.71.2",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
@@ -18,6 +19,7 @@
"@nestjs/platform-socket.io": "^11.1.12",
"@nestjs/swagger": "^11.2.5",
"@nestjs/websockets": "^11.1.12",
"@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
@@ -205,6 +207,26 @@
"tslib": "^2.1.0"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.71.2",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.2.tgz",
"integrity": "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@babel/code-frame": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
@@ -236,7 +258,6 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@@ -667,6 +688,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -809,8 +839,7 @@
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.2.tgz",
"integrity": "sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true
"license": "Apache-2.0"
},
"node_modules/@electric-sql/pglite-socket": {
"version": "0.0.6",
@@ -2250,7 +2279,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.12.tgz",
"integrity": "sha512-v6U3O01YohHO+IE3EIFXuRuu3VJILWzyMmSYZXpyBbnp0hk0mFyHxK2w3dF4I5WnbwiRbWlEXdeXFvPQ7qaZzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"file-type": "21.3.0",
"iterare": "1.2.1",
@@ -2310,7 +2338,6 @@
"integrity": "sha512-97DzTYMf5RtGAVvX1cjwpKRiCUpkeQ9CCzSAenqkAhOmNVVFaApbhuw+xrDt13rsCa2hHVOYPrV4dBgOYMJjsA==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@nuxt/opencollective": "0.4.1",
"fast-safe-stringify": "2.1.1",
@@ -2394,7 +2421,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.12.tgz",
"integrity": "sha512-GYK/vHI0SGz5m8mxr7v3Urx8b9t78Cf/dj5aJMZlGd9/1D9OI1hAl00BaphjEXINUJ/BQLxIlF2zUjrYsd6enQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"cors": "2.8.5",
"express": "5.2.1",
@@ -2416,7 +2442,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.12.tgz",
"integrity": "sha512-1itTTYsAZecrq2NbJOkch32y8buLwN7UpcNRdJrhlS+ovJ5GxLx3RyJ3KylwBhbYnO5AeYyL1U/i4W5mg/4qDA==",
"license": "MIT",
"peer": true,
"dependencies": {
"socket.io": "4.8.3",
"tslib": "2.8.1"
@@ -2595,7 +2620,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.12.tgz",
"integrity": "sha512-ulSOYcgosx1TqY425cRC5oXtAu1R10+OSmVfgyR9ueR25k4luekURt8dzAZxhxSCI0OsDj9WKCFLTkEuAwg0wg==",
"license": "MIT",
"peer": true,
"dependencies": {
"iterare": "1.2.1",
"object-hash": "3.0.0",
@@ -2677,6 +2701,17 @@
"url": "https://opencollective.com/pkgr"
}
},
"node_modules/@prisma/adapter-pg": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.2.0.tgz",
"integrity": "sha512-euIdQ13cRB2wZ3jPsnDnFhINquo1PYFPCg6yVL8b2rp3EdinQHsX9EDdCtRr489D5uhphcRk463OdQAFlsCr0w==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/driver-adapter-utils": "7.2.0",
"pg": "^8.16.3",
"postgres-array": "3.0.4"
}
},
"node_modules/@prisma/client": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.2.0.tgz",
@@ -2724,7 +2759,6 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz",
"integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/dev": {
@@ -2753,6 +2787,15 @@
"zeptomatch": "2.0.2"
}
},
"node_modules/@prisma/driver-adapter-utils": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.2.0.tgz",
"integrity": "sha512-gzrUcbI9VmHS24Uf+0+7DNzdIw7keglJsD5m/MHxQOU68OhGVzlphQRobLiDMn8CHNA2XN8uugwKjudVtnfMVQ==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "7.2.0"
}
},
"node_modules/@prisma/engines": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.2.0.tgz",
@@ -3049,7 +3092,6 @@
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "*",
"@types/json-schema": "*"
@@ -3188,7 +3230,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -3370,7 +3411,6 @@
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.53.0",
"@typescript-eslint/types": "8.53.0",
@@ -4052,7 +4092,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4102,7 +4141,6 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -4545,7 +4583,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -4807,7 +4844,6 @@
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
@@ -4865,15 +4901,13 @@
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/class-validator": {
"version": "0.14.3",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz",
"integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/validator": "^13.15.3",
"libphonenumber-js": "^1.11.1",
@@ -5227,7 +5261,8 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/debug": {
"version": "4.4.3",
@@ -5691,7 +5726,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5752,7 +5786,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -5985,7 +6018,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -6723,7 +6755,6 @@
"integrity": "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==",
"devOptional": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -7110,7 +7141,6 @@
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "30.2.0",
"@jest/types": "30.2.0",
@@ -7904,6 +7934,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -8896,7 +8939,6 @@
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"passport-strategy": "1.x.x",
"pause": "0.0.1",
@@ -9024,6 +9066,104 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/pg": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.17.1.tgz",
"integrity": "sha512-EIR+jXdYNSMOrpRp7g6WgQr7SaZNZfS7IzZIO0oTNEeibq956JxeD15t3Jk3zZH0KH8DmOIx38qJfQenoE8bXQ==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.10.0",
"pg-pool": "^3.11.0",
"pg-protocol": "^1.11.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.3.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.10.0.tgz",
"integrity": "sha512-ur/eoPKzDx2IjPaYyXS6Y8NSblxM7X64deV2ObV57vhjsWiwLvUD6meukAzogiOsu60GO8m/3Cb6FdJsWNjwXg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz",
"integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
"integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pg-types/node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -9159,6 +9299,45 @@
"url": "https://github.com/sponsors/porsager"
}
},
"node_modules/postgres-array": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz",
"integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -9175,7 +9354,6 @@
"integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -9234,7 +9412,6 @@
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@prisma/config": "7.2.0",
"@prisma/dev": "0.17.0",
@@ -9445,8 +9622,7 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0",
"peer": true
"license": "Apache-2.0"
},
"node_modules/regexp-to-ast": {
"version": "0.5.0",
@@ -9583,7 +9759,6 @@
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.1.0"
}
@@ -9619,7 +9794,8 @@
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"devOptional": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/schema-utils": {
"version": "3.3.0",
@@ -9952,6 +10128,15 @@
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
@@ -10319,7 +10504,6 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -10551,6 +10735,12 @@
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
},
"node_modules/ts-api-utils": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
@@ -10657,7 +10847,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -10805,7 +10994,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -11087,7 +11275,6 @@
"integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@@ -11157,7 +11344,6 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",

View File

@@ -23,6 +23,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.71.2",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
@@ -32,6 +33,7 @@
"@nestjs/platform-socket.io": "^11.1.12",
"@nestjs/swagger": "^11.2.5",
"@nestjs/websockets": "^11.1.12",
"@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",

View File

@@ -0,0 +1,543 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'GM', 'PLAYER');
-- CreateEnum
CREATE TYPE "CharacterType" AS ENUM ('PC', 'NPC');
-- CreateEnum
CREATE TYPE "AbilityType" AS ENUM ('STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA');
-- CreateEnum
CREATE TYPE "Proficiency" AS ENUM ('UNTRAINED', 'TRAINED', 'EXPERT', 'MASTER', 'LEGENDARY');
-- CreateEnum
CREATE TYPE "FeatSource" AS ENUM ('CLASS', 'ANCESTRY', 'GENERAL', 'SKILL', 'BONUS', 'ARCHETYPE');
-- CreateEnum
CREATE TYPE "SpellTradition" AS ENUM ('ARCANE', 'DIVINE', 'OCCULT', 'PRIMAL');
-- CreateEnum
CREATE TYPE "CombatantType" AS ENUM ('PC', 'NPC', 'MONSTER');
-- CreateEnum
CREATE TYPE "ActionType" AS ENUM ('ACTION', 'REACTION', 'FREE');
-- CreateEnum
CREATE TYPE "HighlightColor" AS ENUM ('YELLOW', 'GREEN', 'BLUE', 'PINK');
-- CreateEnum
CREATE TYPE "TranslationType" AS ENUM ('FEAT', 'EQUIPMENT', 'SPELL', 'TRAIT', 'ANCESTRY', 'HERITAGE', 'CLASS', 'BACKGROUND', 'CONDITION', 'ACTION');
-- CreateEnum
CREATE TYPE "TranslationQuality" AS ENUM ('HIGH', 'MEDIUM', 'LOW');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"role" "UserRole" NOT NULL DEFAULT 'PLAYER',
"avatarUrl" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Campaign" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"gmId" TEXT NOT NULL,
"imageUrl" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Campaign_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CampaignMember" (
"campaignId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CampaignMember_pkey" PRIMARY KEY ("campaignId","userId")
);
-- CreateTable
CREATE TABLE "Character" (
"id" TEXT NOT NULL,
"campaignId" TEXT NOT NULL,
"ownerId" TEXT,
"name" TEXT NOT NULL,
"type" "CharacterType" NOT NULL DEFAULT 'PC',
"level" INTEGER NOT NULL DEFAULT 1,
"avatarUrl" TEXT,
"hpCurrent" INTEGER NOT NULL,
"hpMax" INTEGER NOT NULL,
"hpTemp" INTEGER NOT NULL DEFAULT 0,
"ancestryId" TEXT,
"heritageId" TEXT,
"classId" TEXT,
"backgroundId" TEXT,
"experiencePoints" INTEGER NOT NULL DEFAULT 0,
"pathbuilderData" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Character_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CharacterAbility" (
"id" TEXT NOT NULL,
"characterId" TEXT NOT NULL,
"ability" "AbilityType" NOT NULL,
"score" INTEGER NOT NULL,
CONSTRAINT "CharacterAbility_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CharacterFeat" (
"id" TEXT NOT NULL,
"characterId" TEXT NOT NULL,
"featId" TEXT,
"name" TEXT NOT NULL,
"nameGerman" TEXT,
"level" INTEGER NOT NULL,
"source" "FeatSource" NOT NULL,
CONSTRAINT "CharacterFeat_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CharacterSkill" (
"id" TEXT NOT NULL,
"characterId" TEXT NOT NULL,
"skillName" TEXT NOT NULL,
"proficiency" "Proficiency" NOT NULL DEFAULT 'UNTRAINED',
CONSTRAINT "CharacterSkill_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CharacterSpell" (
"id" TEXT NOT NULL,
"characterId" TEXT NOT NULL,
"spellId" TEXT,
"name" TEXT NOT NULL,
"nameGerman" TEXT,
"tradition" "SpellTradition" NOT NULL,
"spellLevel" INTEGER NOT NULL,
"prepared" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "CharacterSpell_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CharacterItem" (
"id" TEXT NOT NULL,
"characterId" TEXT NOT NULL,
"equipmentId" TEXT,
"name" TEXT NOT NULL,
"nameGerman" TEXT,
"quantity" INTEGER NOT NULL DEFAULT 1,
"bulk" DECIMAL(65,30) NOT NULL DEFAULT 0,
"equipped" BOOLEAN NOT NULL DEFAULT false,
"invested" BOOLEAN NOT NULL DEFAULT false,
"containerId" TEXT,
"notes" TEXT,
CONSTRAINT "CharacterItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CharacterCondition" (
"id" TEXT NOT NULL,
"characterId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"nameGerman" TEXT,
"value" INTEGER,
"duration" TEXT,
"source" TEXT,
CONSTRAINT "CharacterCondition_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CharacterResource" (
"id" TEXT NOT NULL,
"characterId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"current" INTEGER NOT NULL,
"max" INTEGER NOT NULL,
CONSTRAINT "CharacterResource_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BattleMap" (
"id" TEXT NOT NULL,
"campaignId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"imageUrl" TEXT NOT NULL,
"gridSizeX" INTEGER NOT NULL DEFAULT 20,
"gridSizeY" INTEGER NOT NULL DEFAULT 20,
"gridOffsetX" INTEGER NOT NULL DEFAULT 0,
"gridOffsetY" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BattleMap_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Combatant" (
"id" TEXT NOT NULL,
"campaignId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" "CombatantType" NOT NULL,
"level" INTEGER NOT NULL,
"hpMax" INTEGER NOT NULL,
"ac" INTEGER NOT NULL,
"fortitude" INTEGER NOT NULL,
"reflex" INTEGER NOT NULL,
"will" INTEGER NOT NULL,
"perception" INTEGER NOT NULL,
"speed" INTEGER NOT NULL DEFAULT 25,
"avatarUrl" TEXT,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Combatant_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CombatantAbility" (
"id" TEXT NOT NULL,
"combatantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"actionCost" INTEGER NOT NULL,
"actionType" "ActionType" NOT NULL,
"description" TEXT NOT NULL,
"damage" TEXT,
"traits" TEXT[],
CONSTRAINT "CombatantAbility_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BattleSession" (
"id" TEXT NOT NULL,
"campaignId" TEXT NOT NULL,
"mapId" TEXT,
"name" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT false,
"roundNumber" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BattleSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BattleToken" (
"id" TEXT NOT NULL,
"battleSessionId" TEXT NOT NULL,
"combatantId" TEXT,
"characterId" TEXT,
"name" TEXT NOT NULL,
"positionX" DOUBLE PRECISION NOT NULL,
"positionY" DOUBLE PRECISION NOT NULL,
"hpCurrent" INTEGER NOT NULL,
"hpMax" INTEGER NOT NULL,
"initiative" INTEGER,
"conditions" TEXT[],
"size" INTEGER NOT NULL DEFAULT 1,
CONSTRAINT "BattleToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Document" (
"id" TEXT NOT NULL,
"campaignId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"category" TEXT,
"tags" TEXT[],
"filePath" TEXT NOT NULL,
"fileType" TEXT NOT NULL,
"uploadedBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Document_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DocumentAccess" (
"id" TEXT NOT NULL,
"documentId" TEXT NOT NULL,
"userId" TEXT,
"characterId" TEXT,
CONSTRAINT "DocumentAccess_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Highlight" (
"id" TEXT NOT NULL,
"documentId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"selectionText" TEXT NOT NULL,
"startOffset" INTEGER NOT NULL,
"endOffset" INTEGER NOT NULL,
"color" "HighlightColor" NOT NULL,
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Highlight_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Note" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"campaignId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"isShared" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Note_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "NoteShare" (
"noteId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "NoteShare_pkey" PRIMARY KEY ("noteId","userId")
);
-- CreateTable
CREATE TABLE "Feat" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"traits" TEXT[],
"summary" TEXT,
"actions" TEXT,
"url" TEXT,
"level" INTEGER,
"sourceBook" TEXT,
CONSTRAINT "Feat_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Equipment" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"traits" TEXT[],
"itemCategory" TEXT NOT NULL,
"itemSubcategory" TEXT,
"bulk" TEXT,
"url" TEXT,
"summary" TEXT,
"activation" TEXT,
"hands" TEXT,
"damage" TEXT,
"range" TEXT,
"weaponCategory" TEXT,
"price" INTEGER,
"level" INTEGER,
CONSTRAINT "Equipment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Spell" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"level" INTEGER NOT NULL,
"actions" TEXT,
"traditions" TEXT[],
"traits" TEXT[],
"range" TEXT,
"targets" TEXT,
"duration" TEXT,
"description" TEXT,
"url" TEXT,
CONSTRAINT "Spell_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Trait" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"url" TEXT,
CONSTRAINT "Trait_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Translation" (
"id" TEXT NOT NULL,
"type" "TranslationType" NOT NULL,
"englishName" TEXT NOT NULL,
"germanName" TEXT NOT NULL,
"germanSummary" TEXT,
"germanDescription" TEXT,
"quality" "TranslationQuality" NOT NULL DEFAULT 'MEDIUM',
"translatedBy" TEXT NOT NULL DEFAULT 'claude-api',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Translation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "CharacterAbility_characterId_ability_key" ON "CharacterAbility"("characterId", "ability");
-- CreateIndex
CREATE UNIQUE INDEX "CharacterSkill_characterId_skillName_key" ON "CharacterSkill"("characterId", "skillName");
-- CreateIndex
CREATE UNIQUE INDEX "CharacterResource_characterId_name_key" ON "CharacterResource"("characterId", "name");
-- CreateIndex
CREATE UNIQUE INDEX "DocumentAccess_documentId_userId_characterId_key" ON "DocumentAccess"("documentId", "userId", "characterId");
-- CreateIndex
CREATE UNIQUE INDEX "Feat_name_key" ON "Feat"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Equipment_name_key" ON "Equipment"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Spell_name_key" ON "Spell"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Trait_name_key" ON "Trait"("name");
-- CreateIndex
CREATE INDEX "Translation_type_idx" ON "Translation"("type");
-- CreateIndex
CREATE INDEX "Translation_englishName_idx" ON "Translation"("englishName");
-- CreateIndex
CREATE UNIQUE INDEX "Translation_type_englishName_key" ON "Translation"("type", "englishName");
-- AddForeignKey
ALTER TABLE "Campaign" ADD CONSTRAINT "Campaign_gmId_fkey" FOREIGN KEY ("gmId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CampaignMember" ADD CONSTRAINT "CampaignMember_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "Campaign"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CampaignMember" ADD CONSTRAINT "CampaignMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Character" ADD CONSTRAINT "Character_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "Campaign"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Character" ADD CONSTRAINT "Character_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CharacterAbility" ADD CONSTRAINT "CharacterAbility_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CharacterFeat" ADD CONSTRAINT "CharacterFeat_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CharacterFeat" ADD CONSTRAINT "CharacterFeat_featId_fkey" FOREIGN KEY ("featId") REFERENCES "Feat"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CharacterSkill" ADD CONSTRAINT "CharacterSkill_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CharacterSpell" ADD CONSTRAINT "CharacterSpell_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CharacterSpell" ADD CONSTRAINT "CharacterSpell_spellId_fkey" FOREIGN KEY ("spellId") REFERENCES "Spell"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CharacterItem" ADD CONSTRAINT "CharacterItem_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CharacterItem" ADD CONSTRAINT "CharacterItem_equipmentId_fkey" FOREIGN KEY ("equipmentId") REFERENCES "Equipment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CharacterCondition" ADD CONSTRAINT "CharacterCondition_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CharacterResource" ADD CONSTRAINT "CharacterResource_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BattleMap" ADD CONSTRAINT "BattleMap_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "Campaign"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Combatant" ADD CONSTRAINT "Combatant_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "Campaign"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CombatantAbility" ADD CONSTRAINT "CombatantAbility_combatantId_fkey" FOREIGN KEY ("combatantId") REFERENCES "Combatant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BattleSession" ADD CONSTRAINT "BattleSession_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "Campaign"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BattleSession" ADD CONSTRAINT "BattleSession_mapId_fkey" FOREIGN KEY ("mapId") REFERENCES "BattleMap"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BattleToken" ADD CONSTRAINT "BattleToken_battleSessionId_fkey" FOREIGN KEY ("battleSessionId") REFERENCES "BattleSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BattleToken" ADD CONSTRAINT "BattleToken_combatantId_fkey" FOREIGN KEY ("combatantId") REFERENCES "Combatant"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BattleToken" ADD CONSTRAINT "BattleToken_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "Campaign"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_uploadedBy_fkey" FOREIGN KEY ("uploadedBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DocumentAccess" ADD CONSTRAINT "DocumentAccess_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DocumentAccess" ADD CONSTRAINT "DocumentAccess_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DocumentAccess" ADD CONSTRAINT "DocumentAccess_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Highlight" ADD CONSTRAINT "Highlight_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Highlight" ADD CONSTRAINT "Highlight_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Note" ADD CONSTRAINT "Note_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Note" ADD CONSTRAINT "Note_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "Campaign"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NoteShare" ADD CONSTRAINT "NoteShare_noteId_fkey" FOREIGN KEY ("noteId") REFERENCES "Note"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NoteShare" ADD CONSTRAINT "NoteShare_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -2,8 +2,9 @@
// Prisma Schema
generator client {
provider = "prisma-client-js"
output = "../src/generated/prisma"
provider = "prisma-client"
output = "../src/generated/prisma"
moduleFormat = "cjs"
}
datasource db {

28
server/prisma/seed.ts Normal file
View File

@@ -0,0 +1,28 @@
import 'dotenv/config';
import * as bcrypt from 'bcrypt';
import { PrismaClient } from '../src/generated/prisma/client.js';
import { PrismaPg } from '@prisma/adapter-pg';
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter });
async function main() {
const passwordHash = await bcrypt.hash('admin123', 10);
const user = await prisma.user.upsert({
where: { email: 'admin@dimension47.local' },
update: {},
create: {
username: 'admin',
email: 'admin@dimension47.local',
passwordHash,
role: 'ADMIN',
},
});
console.log('Admin user created:', user.username, user.email);
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());

View File

@@ -4,10 +4,13 @@ import { APP_GUARD } from '@nestjs/core';
// Core Modules
import { PrismaModule } from './prisma/prisma.module';
import { ClaudeModule } from './modules/claude/claude.module';
// Feature Modules
import { AuthModule } from './modules/auth/auth.module';
import { CampaignsModule } from './modules/campaigns/campaigns.module';
import { CharactersModule } from './modules/characters/characters.module';
import { TranslationsModule } from './modules/translations/translations.module';
// Guards
import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
@@ -23,10 +26,13 @@ import { RolesGuard } from './modules/auth/guards/roles.guard';
// Core
PrismaModule,
ClaudeModule,
// Features
AuthModule,
CampaignsModule,
CharactersModule,
TranslationsModule,
],
providers: [
// Global JWT Auth Guard

View File

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

View File

@@ -24,9 +24,25 @@ async function bootstrap() {
);
// CORS
const corsOrigins = configService.get<string>('CORS_ORIGINS', 'http://localhost:3000,http://localhost:5173');
const nodeEnv = configService.get<string>('NODE_ENV', 'development');
app.enableCors({
origin: corsOrigins.split(','),
origin: (origin, callback) => {
// Allow requests with no origin (mobile apps, curl, etc.)
if (!origin) return callback(null, true);
// In development, allow all localhost origins
if (nodeEnv === 'development' && /^https?:\/\/localhost(:\d+)?$/.test(origin)) {
return callback(null, true);
}
// Check against configured origins
const corsOrigins = configService.get<string>('CORS_ORIGINS', 'http://localhost:3000,http://localhost:5173');
if (corsOrigins.split(',').includes(origin)) {
return callback(null, true);
}
callback(new Error('Not allowed by CORS'));
},
credentials: true,
});

View File

@@ -19,7 +19,7 @@ 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';
import { UserRole } from '../../generated/prisma/client.js';
@ApiTags('Auth')
@Controller('auth')

View File

@@ -8,7 +8,7 @@ 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';
import { UserRole } from '../../generated/prisma/client.js';
@Injectable()
export class AuthService {

View File

@@ -1,6 +1,6 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { UserRole } from '../../../generated/prisma';
import { UserRole } from '../../../generated/prisma/client.js';
import { ROLES_KEY } from '../../../common/decorators/roles.decorator';
@Injectable()

View File

@@ -16,7 +16,7 @@ import {
import { CampaignsService } from './campaigns.service';
import { CreateCampaignDto, UpdateCampaignDto, AddMemberDto } from './dto';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { UserRole } from '../../generated/prisma';
import { UserRole } from '../../generated/prisma/client.js';
@ApiTags('Campaigns')
@ApiBearerAuth()

View File

@@ -6,7 +6,7 @@ import {
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateCampaignDto, UpdateCampaignDto, AddMemberDto } from './dto';
import { UserRole } from '../../generated/prisma';
import { UserRole } from '../../generated/prisma/client.js';
@Injectable()
export class CampaignsService {

View File

@@ -0,0 +1,292 @@
import {
Controller,
Get,
Post,
Put,
Patch,
Delete,
Body,
Param,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { CharactersService } from './characters.service';
import { PathbuilderImportService } from './pathbuilder-import.service';
import {
CreateCharacterDto,
UpdateCharacterDto,
CreateAbilityDto,
CreateSkillDto,
CreateFeatDto,
CreateSpellDto,
CreateItemDto,
CreateConditionDto,
CreateResourceDto,
PathbuilderImportDto,
} from './dto';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('Characters')
@ApiBearerAuth()
@Controller('campaigns/:campaignId/characters')
export class CharactersController {
constructor(
private readonly charactersService: CharactersService,
private readonly pathbuilderImportService: PathbuilderImportService,
) {}
@Post()
@ApiOperation({ summary: 'Create a new character' })
@ApiResponse({ status: 201, description: 'Character created successfully' })
async create(
@Param('campaignId') campaignId: string,
@Body() dto: CreateCharacterDto,
@CurrentUser('id') userId: string,
) {
return this.charactersService.create(campaignId, dto, userId);
}
@Post('import')
@ApiOperation({ summary: 'Import character from Pathbuilder JSON' })
@ApiResponse({ status: 201, description: 'Character imported successfully' })
async importFromPathbuilder(
@Param('campaignId') campaignId: string,
@Body() dto: PathbuilderImportDto,
@CurrentUser('id') userId: string,
) {
return this.pathbuilderImportService.importCharacter(
campaignId,
userId,
dto.pathbuilderJson,
);
}
@Get()
@ApiOperation({ summary: 'Get all characters in a campaign' })
@ApiResponse({ status: 200, description: 'List of characters' })
async findAll(
@Param('campaignId') campaignId: string,
@CurrentUser('id') userId: string,
) {
return this.charactersService.findAllByCampaign(campaignId, userId);
}
@Get(':id')
@ApiOperation({ summary: 'Get character by ID' })
@ApiResponse({ status: 200, description: 'Character details' })
@ApiResponse({ status: 404, description: 'Character not found' })
async findOne(
@Param('id') id: string,
@CurrentUser('id') userId: string,
) {
return this.charactersService.findOne(id, userId);
}
@Put(':id')
@ApiOperation({ summary: 'Update character' })
@ApiResponse({ status: 200, description: 'Character updated' })
async update(
@Param('id') id: string,
@Body() dto: UpdateCharacterDto,
@CurrentUser('id') userId: string,
) {
return this.charactersService.update(id, dto, userId);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete character' })
@ApiResponse({ status: 200, description: 'Character deleted' })
async remove(
@Param('id') id: string,
@CurrentUser('id') userId: string,
) {
return this.charactersService.remove(id, userId);
}
// HP Management
@Patch(':id/hp')
@ApiOperation({ summary: 'Update character HP' })
async updateHp(
@Param('id') id: string,
@Body() body: { hpCurrent: number; hpTemp?: number },
@CurrentUser('id') userId: string,
) {
return this.charactersService.updateHp(id, body.hpCurrent, body.hpTemp, userId);
}
// Abilities
@Put(':id/abilities')
@ApiOperation({ summary: 'Set character abilities' })
async setAbilities(
@Param('id') id: string,
@Body() abilities: CreateAbilityDto[],
@CurrentUser('id') userId: string,
) {
return this.charactersService.setAbilities(id, abilities, userId);
}
// Skills
@Put(':id/skills')
@ApiOperation({ summary: 'Set character skills' })
async setSkills(
@Param('id') id: string,
@Body() skills: CreateSkillDto[],
@CurrentUser('id') userId: string,
) {
return this.charactersService.setSkills(id, skills, userId);
}
@Patch(':id/skills/:skillName')
@ApiOperation({ summary: 'Update single skill proficiency' })
async updateSkill(
@Param('id') id: string,
@Param('skillName') skillName: string,
@Body() body: { proficiency: string },
@CurrentUser('id') userId: string,
) {
return this.charactersService.updateSkill(id, skillName, body.proficiency, userId);
}
// Feats
@Post(':id/feats')
@ApiOperation({ summary: 'Add feat to character' })
async addFeat(
@Param('id') id: string,
@Body() dto: CreateFeatDto,
@CurrentUser('id') userId: string,
) {
return this.charactersService.addFeat(id, dto, userId);
}
@Delete(':id/feats/:featId')
@ApiOperation({ summary: 'Remove feat from character' })
async removeFeat(
@Param('id') id: string,
@Param('featId') featId: string,
@CurrentUser('id') userId: string,
) {
return this.charactersService.removeFeat(id, featId, userId);
}
// Spells
@Post(':id/spells')
@ApiOperation({ summary: 'Add spell to character' })
async addSpell(
@Param('id') id: string,
@Body() dto: CreateSpellDto,
@CurrentUser('id') userId: string,
) {
return this.charactersService.addSpell(id, dto, userId);
}
@Patch(':id/spells/:spellId')
@ApiOperation({ summary: 'Update spell (prepared status)' })
async updateSpell(
@Param('id') id: string,
@Param('spellId') spellId: string,
@Body() body: { prepared: boolean },
@CurrentUser('id') userId: string,
) {
return this.charactersService.updateSpell(id, spellId, body.prepared, userId);
}
@Delete(':id/spells/:spellId')
@ApiOperation({ summary: 'Remove spell from character' })
async removeSpell(
@Param('id') id: string,
@Param('spellId') spellId: string,
@CurrentUser('id') userId: string,
) {
return this.charactersService.removeSpell(id, spellId, userId);
}
// Items
@Post(':id/items')
@ApiOperation({ summary: 'Add item to character' })
async addItem(
@Param('id') id: string,
@Body() dto: CreateItemDto,
@CurrentUser('id') userId: string,
) {
return this.charactersService.addItem(id, dto, userId);
}
@Patch(':id/items/:itemId')
@ApiOperation({ summary: 'Update item' })
async updateItem(
@Param('id') id: string,
@Param('itemId') itemId: string,
@Body() body: Partial<CreateItemDto>,
@CurrentUser('id') userId: string,
) {
return this.charactersService.updateItem(id, itemId, body, userId);
}
@Delete(':id/items/:itemId')
@ApiOperation({ summary: 'Remove item from character' })
async removeItem(
@Param('id') id: string,
@Param('itemId') itemId: string,
@CurrentUser('id') userId: string,
) {
return this.charactersService.removeItem(id, itemId, userId);
}
// Conditions
@Post(':id/conditions')
@ApiOperation({ summary: 'Add condition to character' })
async addCondition(
@Param('id') id: string,
@Body() dto: CreateConditionDto,
@CurrentUser('id') userId: string,
) {
return this.charactersService.addCondition(id, dto, userId);
}
@Patch(':id/conditions/:conditionId')
@ApiOperation({ summary: 'Update condition value' })
async updateCondition(
@Param('id') id: string,
@Param('conditionId') conditionId: string,
@Body() body: { value: number },
@CurrentUser('id') userId: string,
) {
return this.charactersService.updateCondition(id, conditionId, body.value, userId);
}
@Delete(':id/conditions/:conditionId')
@ApiOperation({ summary: 'Remove condition from character' })
async removeCondition(
@Param('id') id: string,
@Param('conditionId') conditionId: string,
@CurrentUser('id') userId: string,
) {
return this.charactersService.removeCondition(id, conditionId, userId);
}
// Resources
@Put(':id/resources')
@ApiOperation({ summary: 'Set character resources' })
async setResources(
@Param('id') id: string,
@Body() resources: CreateResourceDto[],
@CurrentUser('id') userId: string,
) {
return this.charactersService.setResources(id, resources, userId);
}
@Patch(':id/resources/:resourceName')
@ApiOperation({ summary: 'Update resource current value' })
async updateResource(
@Param('id') id: string,
@Param('resourceName') resourceName: string,
@Body() body: { current: number },
@CurrentUser('id') userId: string,
) {
return this.charactersService.updateResource(id, resourceName, body.current, userId);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { CharactersController } from './characters.controller';
import { CharactersService } from './characters.service';
import { PathbuilderImportService } from './pathbuilder-import.service';
import { TranslationsModule } from '../translations/translations.module';
@Module({
imports: [TranslationsModule],
controllers: [CharactersController],
providers: [CharactersService, PathbuilderImportService],
exports: [CharactersService, PathbuilderImportService],
})
export class CharactersModule {}

View File

@@ -0,0 +1,311 @@
import {
Injectable,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import {
CreateCharacterDto,
UpdateCharacterDto,
CreateAbilityDto,
CreateSkillDto,
CreateFeatDto,
CreateSpellDto,
CreateItemDto,
CreateConditionDto,
CreateResourceDto,
} from './dto';
@Injectable()
export class CharactersService {
constructor(private prisma: PrismaService) {}
// Check if user has access to campaign
private async checkCampaignAccess(campaignId: string, userId: string) {
const campaign = await this.prisma.campaign.findUnique({
where: { id: campaignId },
include: { members: true },
});
if (!campaign) {
throw new NotFoundException('Campaign not found');
}
const hasAccess =
campaign.gmId === userId ||
campaign.members.some((m) => m.userId === userId);
if (!hasAccess) {
throw new ForbiddenException('No access to this campaign');
}
return campaign;
}
// Check if user can edit character
private async checkCharacterAccess(characterId: string, userId: string, requireOwnership = false) {
const character = await this.prisma.character.findUnique({
where: { id: characterId },
include: { campaign: { include: { members: true } } },
});
if (!character) {
throw new NotFoundException('Character not found');
}
const isGM = character.campaign.gmId === userId;
const isOwner = character.ownerId === userId;
if (requireOwnership && !isOwner && !isGM) {
throw new ForbiddenException('Only the owner or GM can modify this character');
}
const isMember = character.campaign.members.some((m) => m.userId === userId);
if (!isGM && !isMember) {
throw new ForbiddenException('No access to this character');
}
return character;
}
async create(campaignId: string, dto: CreateCharacterDto, userId: string) {
await this.checkCampaignAccess(campaignId, userId);
return this.prisma.character.create({
data: {
...dto,
campaignId,
ownerId: userId,
pathbuilderData: dto.pathbuilderData as any,
},
include: {
owner: { select: { id: true, username: true } },
abilities: true,
skills: true,
feats: true,
spells: true,
items: true,
conditions: true,
resources: true,
},
});
}
async findAllByCampaign(campaignId: string, userId: string) {
await this.checkCampaignAccess(campaignId, userId);
return this.prisma.character.findMany({
where: { campaignId },
include: {
owner: { select: { id: true, username: true } },
},
orderBy: { name: 'asc' },
});
}
async findOne(id: string, userId: string) {
const character = await this.checkCharacterAccess(id, userId);
return this.prisma.character.findUnique({
where: { id },
include: {
owner: { select: { id: true, username: true, avatarUrl: true } },
abilities: true,
skills: { orderBy: { skillName: 'asc' } },
feats: { orderBy: [{ level: 'asc' }, { name: 'asc' }] },
spells: { orderBy: [{ spellLevel: 'asc' }, { name: 'asc' }] },
items: { orderBy: { name: 'asc' } },
conditions: true,
resources: true,
},
});
}
async update(id: string, dto: UpdateCharacterDto, userId: string) {
await this.checkCharacterAccess(id, userId, true);
return this.prisma.character.update({
where: { id },
data: {
...dto,
pathbuilderData: dto.pathbuilderData as any,
},
include: {
owner: { select: { id: true, username: true } },
abilities: true,
skills: true,
feats: true,
spells: true,
items: true,
conditions: true,
resources: true,
},
});
}
async remove(id: string, userId: string) {
await this.checkCharacterAccess(id, userId, true);
await this.prisma.character.delete({ where: { id } });
return { message: 'Character deleted successfully' };
}
// HP Management
async updateHp(id: string, hpCurrent: number, hpTemp?: number, userId?: string) {
if (userId) {
await this.checkCharacterAccess(id, userId, true);
}
return this.prisma.character.update({
where: { id },
data: {
hpCurrent: Math.max(0, hpCurrent),
...(hpTemp !== undefined && { hpTemp: Math.max(0, hpTemp) }),
},
});
}
// Abilities
async setAbilities(characterId: string, abilities: CreateAbilityDto[], userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
// Delete existing and create new
await this.prisma.characterAbility.deleteMany({ where: { characterId } });
return this.prisma.characterAbility.createMany({
data: abilities.map((a) => ({ ...a, characterId })),
});
}
// Skills
async setSkills(characterId: string, skills: CreateSkillDto[], userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
await this.prisma.characterSkill.deleteMany({ where: { characterId } });
return this.prisma.characterSkill.createMany({
data: skills.map((s) => ({ ...s, characterId })),
});
}
async updateSkill(characterId: string, skillName: string, proficiency: string, userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
return this.prisma.characterSkill.upsert({
where: { characterId_skillName: { characterId, skillName } },
update: { proficiency: proficiency as any },
create: { characterId, skillName, proficiency: proficiency as any },
});
}
// Feats
async addFeat(characterId: string, dto: CreateFeatDto, userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
return this.prisma.characterFeat.create({
data: { ...dto, characterId },
});
}
async removeFeat(characterId: string, featId: string, userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
await this.prisma.characterFeat.delete({ where: { id: featId } });
return { message: 'Feat removed' };
}
// Spells
async addSpell(characterId: string, dto: CreateSpellDto, userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
return this.prisma.characterSpell.create({
data: { ...dto, characterId },
});
}
async updateSpell(characterId: string, spellId: string, prepared: boolean, userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
return this.prisma.characterSpell.update({
where: { id: spellId },
data: { prepared },
});
}
async removeSpell(characterId: string, spellId: string, userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
await this.prisma.characterSpell.delete({ where: { id: spellId } });
return { message: 'Spell removed' };
}
// Items
async addItem(characterId: string, dto: CreateItemDto, userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
return this.prisma.characterItem.create({
data: { ...dto, characterId, bulk: dto.bulk as any },
});
}
async updateItem(characterId: string, itemId: string, data: Partial<CreateItemDto>, userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
return this.prisma.characterItem.update({
where: { id: itemId },
data: { ...data, bulk: data.bulk as any },
});
}
async removeItem(characterId: string, itemId: string, userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
await this.prisma.characterItem.delete({ where: { id: itemId } });
return { message: 'Item removed' };
}
// Conditions
async addCondition(characterId: string, dto: CreateConditionDto, userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
return this.prisma.characterCondition.create({
data: { ...dto, characterId },
});
}
async updateCondition(characterId: string, conditionId: string, value: number, userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
return this.prisma.characterCondition.update({
where: { id: conditionId },
data: { value },
});
}
async removeCondition(characterId: string, conditionId: string, userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
await this.prisma.characterCondition.delete({ where: { id: conditionId } });
return { message: 'Condition removed' };
}
// Resources
async setResources(characterId: string, resources: CreateResourceDto[], userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
await this.prisma.characterResource.deleteMany({ where: { characterId } });
return this.prisma.characterResource.createMany({
data: resources.map((r) => ({ ...r, characterId })),
});
}
async updateResource(characterId: string, resourceName: string, current: number, userId: string) {
await this.checkCharacterAccess(characterId, userId, true);
return this.prisma.characterResource.update({
where: { characterId_name: { characterId, name: resourceName } },
data: { current: Math.max(0, current) },
});
}
}

View File

@@ -0,0 +1,236 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional, IsEnum, IsInt, Min, Max, IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { CharacterType, AbilityType, Proficiency, FeatSource, SpellTradition } from '../../../generated/prisma/client.js';
export class CreateCharacterDto {
@ApiProperty({ description: 'Character name' })
@IsString()
name: string;
@ApiPropertyOptional({ enum: ['PC', 'NPC'], default: 'PC' })
@IsOptional()
@IsEnum(CharacterType)
type?: CharacterType = CharacterType.PC;
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@IsInt()
@Min(1)
@Max(20)
level?: number = 1;
@ApiPropertyOptional()
@IsOptional()
@IsString()
avatarUrl?: string;
@ApiProperty({ description: 'Current HP' })
@IsInt()
@Min(0)
hpCurrent: number;
@ApiProperty({ description: 'Maximum HP' })
@IsInt()
@Min(1)
hpMax: number;
@ApiPropertyOptional({ default: 0 })
@IsOptional()
@IsInt()
@Min(0)
hpTemp?: number = 0;
@ApiPropertyOptional()
@IsOptional()
@IsString()
ancestryId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
heritageId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
classId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
backgroundId?: string;
@ApiPropertyOptional({ default: 0 })
@IsOptional()
@IsInt()
@Min(0)
experiencePoints?: number = 0;
@ApiPropertyOptional({ description: 'Raw Pathbuilder JSON data' })
@IsOptional()
pathbuilderData?: unknown;
}
export class CreateAbilityDto {
@ApiProperty({ enum: ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'] })
@IsEnum(AbilityType)
ability: AbilityType;
@ApiProperty()
@IsInt()
@Min(1)
@Max(30)
score: number;
}
export class CreateSkillDto {
@ApiProperty()
@IsString()
skillName: string;
@ApiPropertyOptional({ enum: ['UNTRAINED', 'TRAINED', 'EXPERT', 'MASTER', 'LEGENDARY'], default: 'UNTRAINED' })
@IsOptional()
@IsEnum(Proficiency)
proficiency?: Proficiency = Proficiency.UNTRAINED;
}
export class CreateFeatDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
featId?: string;
@ApiProperty()
@IsString()
name: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
nameGerman?: string;
@ApiProperty()
@IsInt()
level: number;
@ApiProperty({ enum: ['CLASS', 'ANCESTRY', 'GENERAL', 'SKILL', 'BONUS', 'ARCHETYPE'] })
@IsEnum(FeatSource)
source: FeatSource;
}
export class CreateSpellDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
spellId?: string;
@ApiProperty()
@IsString()
name: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
nameGerman?: string;
@ApiProperty({ enum: ['ARCANE', 'DIVINE', 'OCCULT', 'PRIMAL'] })
@IsEnum(SpellTradition)
tradition: SpellTradition;
@ApiProperty()
@IsInt()
@Min(0)
@Max(10)
spellLevel: number;
@ApiPropertyOptional({ default: false })
@IsOptional()
prepared?: boolean = false;
}
export class CreateItemDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
equipmentId?: string;
@ApiProperty()
@IsString()
name: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
nameGerman?: string;
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@IsInt()
@Min(1)
quantity?: number = 1;
@ApiPropertyOptional({ default: 0 })
@IsOptional()
bulk?: number = 0;
@ApiPropertyOptional({ default: false })
@IsOptional()
equipped?: boolean = false;
@ApiPropertyOptional({ default: false })
@IsOptional()
invested?: boolean = false;
@ApiPropertyOptional()
@IsOptional()
@IsString()
containerId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}
export class CreateConditionDto {
@ApiProperty()
@IsString()
name: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
nameGerman?: string;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
value?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
duration?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
source?: string;
}
export class CreateResourceDto {
@ApiProperty()
@IsString()
name: string;
@ApiProperty()
@IsInt()
@Min(0)
current: number;
@ApiProperty()
@IsInt()
@Min(1)
max: number;
}

View File

@@ -0,0 +1,3 @@
export * from './create-character.dto';
export * from './update-character.dto';
export * from './pathbuilder-import.dto';

View File

@@ -0,0 +1,152 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsObject, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
// Pathbuilder JSON structure
export interface PathbuilderBuild {
name: string;
class: string;
dualClass?: string | null;
level: number;
xp: number;
ancestry: string;
heritage: string;
background: string;
alignment: string;
gender: string;
age: string;
deity: string;
size: number;
sizeName: string;
keyability: string;
languages: string[];
abilities: {
str: number;
dex: number;
con: number;
int: number;
wis: number;
cha: number;
breakdown?: unknown;
};
attributes: {
ancestryhp: number;
classhp: number;
bonushp: number;
bonushpPerLevel: number;
speed: number;
speedBonus: number;
};
proficiencies: {
classDC: number;
perception: number;
fortitude: number;
reflex: number;
will: number;
heavy: number;
medium: number;
light: number;
unarmored: number;
advanced: number;
martial: number;
simple: number;
unarmed: number;
castingArcane: number;
castingDivine: number;
castingOccult: number;
castingPrimal: number;
acrobatics: number;
arcana: number;
athletics: number;
crafting: number;
deception: number;
diplomacy: number;
intimidation: number;
medicine: number;
nature: number;
occultism: number;
performance: number;
religion: number;
society: number;
stealth: number;
survival: number;
thievery: number;
};
feats: Array<[string, unknown, string, number, ...unknown[]]>;
specials: string[];
lores: Array<[string, number]>;
equipment: Array<[string, number, string?]>;
weapons: Array<{
name: string;
qty: number;
prof: string;
die: string;
pot: number;
str: string;
mat: string | null;
display: string;
runes: string[];
damageType: string;
attack: number;
damageBonus: number;
extraDamage: string[];
increasedDice: boolean;
isInventor: boolean;
}>;
armor: Array<{
name: string;
qty: number;
prof: string;
pot: number;
res: string;
mat: string | null;
display: string;
worn: boolean;
runes: string[];
}>;
money: {
cp: number;
sp: number;
gp: number;
pp: number;
};
spellCasters: Array<{
name: string;
magicTradition: string;
spellcastingType: string;
ability: string;
proficiency: number;
focusPoints: number;
spells: Array<{
spellLevel: number;
list: string[];
}>;
perDay: number[];
}>;
focusPoints: number;
focus: unknown;
formula: Array<{
type: string;
known: string[];
}>;
acTotal: {
acProfBonus: number;
acAbilityBonus: number;
acItemBonus: number;
acTotal: number;
shieldBonus: number | null;
};
pets: unknown[];
familiars: unknown[];
}
export interface PathbuilderJson {
success: boolean;
build: PathbuilderBuild;
}
export class PathbuilderImportDto {
@ApiProperty({ description: 'Raw Pathbuilder export JSON' })
@IsObject()
pathbuilderJson: PathbuilderJson;
}

View File

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

View File

@@ -0,0 +1,407 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { TranslationsService } from '../translations/translations.service';
import {
AbilityType,
Proficiency,
FeatSource,
TranslationType,
CharacterType,
} from '../../generated/prisma/client.js';
import { PathbuilderJson, PathbuilderBuild } from './dto/pathbuilder-import.dto';
// Skill name mappings (English -> German)
const SKILL_TRANSLATIONS: Record<string, string> = {
acrobatics: 'Akrobatik',
arcana: 'Arkane Künste',
athletics: 'Athletik',
crafting: 'Handwerk',
deception: 'Täuschung',
diplomacy: 'Diplomatie',
intimidation: 'Einschüchtern',
medicine: 'Medizin',
nature: 'Naturkunde',
occultism: 'Okkultismus',
performance: 'Darbietung',
religion: 'Religionskunde',
society: 'Gesellschaftskunde',
stealth: 'Heimlichkeit',
survival: 'Überleben',
thievery: 'Diebeskunst',
};
// Proficiency value mappings (Pathbuilder uses 0, 2, 4, 6, 8)
function proficiencyFromValue(value: number): Proficiency {
switch (value) {
case 8: return Proficiency.LEGENDARY;
case 6: return Proficiency.MASTER;
case 4: return Proficiency.EXPERT;
case 2: return Proficiency.TRAINED;
default: return Proficiency.UNTRAINED;
}
}
// Feat source mapping
function featSourceFromType(type: string): FeatSource {
const normalized = type.toLowerCase();
if (normalized.includes('class')) return FeatSource.CLASS;
if (normalized.includes('ancestry') || normalized.includes('human')) return FeatSource.ANCESTRY;
if (normalized.includes('skill')) return FeatSource.SKILL;
if (normalized.includes('general')) return FeatSource.GENERAL;
if (normalized.includes('archetype')) return FeatSource.ARCHETYPE;
return FeatSource.BONUS;
}
@Injectable()
export class PathbuilderImportService {
private readonly logger = new Logger(PathbuilderImportService.name);
constructor(
private prisma: PrismaService,
private translationsService: TranslationsService,
) {}
/**
* Import a character from Pathbuilder JSON
*/
async importCharacter(
campaignId: string,
ownerId: string,
pathbuilderJson: PathbuilderJson,
) {
const build = pathbuilderJson.build;
this.logger.log(`Importing character: ${build.name} (Level ${build.level} ${build.class})`);
// Calculate HP
const conModifier = Math.floor((build.abilities.con - 10) / 2);
const hpMax = build.attributes.ancestryhp +
build.attributes.classhp +
build.attributes.bonushp +
(conModifier * build.level) +
(build.attributes.bonushpPerLevel * build.level);
// Translate basics
const [classTranslation, ancestryTranslation, heritageTranslation, backgroundTranslation] =
await Promise.all([
this.translationsService.getTranslation(TranslationType.CLASS, build.class),
this.translationsService.getTranslation(TranslationType.ANCESTRY, build.ancestry),
this.translationsService.getTranslation(TranslationType.HERITAGE, build.heritage),
this.translationsService.getTranslation(TranslationType.BACKGROUND, build.background),
]);
// Create character
const character = await this.prisma.character.create({
data: {
campaignId,
ownerId,
name: build.name,
type: CharacterType.PC,
level: build.level,
hpCurrent: hpMax,
hpMax,
hpTemp: 0,
ancestryId: build.ancestry,
heritageId: build.heritage,
classId: build.class,
backgroundId: build.background,
experiencePoints: build.xp || 0,
pathbuilderData: JSON.parse(JSON.stringify({
...pathbuilderJson,
translations: {
class: classTranslation,
ancestry: ancestryTranslation,
heritage: heritageTranslation,
background: backgroundTranslation,
},
})),
},
});
this.logger.log(`Character created with ID: ${character.id}`);
// Import all related data in parallel
await Promise.all([
this.importAbilities(character.id, build),
this.importSkills(character.id, build),
this.importFeats(character.id, build),
this.importItems(character.id, build),
this.importResources(character.id, build),
]);
// Fetch complete character with relations
return this.prisma.character.findUnique({
where: { id: character.id },
include: {
owner: { select: { id: true, username: true } },
abilities: true,
skills: { orderBy: { skillName: 'asc' } },
feats: { orderBy: [{ level: 'asc' }, { name: 'asc' }] },
items: { orderBy: { name: 'asc' } },
resources: true,
conditions: true,
},
});
}
/**
* Import ability scores
*/
private async importAbilities(characterId: string, build: PathbuilderBuild) {
const abilities = [
{ ability: AbilityType.STR, score: build.abilities.str },
{ ability: AbilityType.DEX, score: build.abilities.dex },
{ ability: AbilityType.CON, score: build.abilities.con },
{ ability: AbilityType.INT, score: build.abilities.int },
{ ability: AbilityType.WIS, score: build.abilities.wis },
{ ability: AbilityType.CHA, score: build.abilities.cha },
];
await this.prisma.characterAbility.createMany({
data: abilities.map(a => ({ ...a, characterId })),
});
this.logger.debug(`Imported ${abilities.length} abilities`);
}
/**
* Import skills with proficiencies
*/
private async importSkills(characterId: string, build: PathbuilderBuild) {
const prof = build.proficiencies;
const skills = [
{ skillName: 'Acrobatics', skillNameGerman: SKILL_TRANSLATIONS.acrobatics, proficiency: proficiencyFromValue(prof.acrobatics) },
{ skillName: 'Arcana', skillNameGerman: SKILL_TRANSLATIONS.arcana, proficiency: proficiencyFromValue(prof.arcana) },
{ skillName: 'Athletics', skillNameGerman: SKILL_TRANSLATIONS.athletics, proficiency: proficiencyFromValue(prof.athletics) },
{ skillName: 'Crafting', skillNameGerman: SKILL_TRANSLATIONS.crafting, proficiency: proficiencyFromValue(prof.crafting) },
{ skillName: 'Deception', skillNameGerman: SKILL_TRANSLATIONS.deception, proficiency: proficiencyFromValue(prof.deception) },
{ skillName: 'Diplomacy', skillNameGerman: SKILL_TRANSLATIONS.diplomacy, proficiency: proficiencyFromValue(prof.diplomacy) },
{ skillName: 'Intimidation', skillNameGerman: SKILL_TRANSLATIONS.intimidation, proficiency: proficiencyFromValue(prof.intimidation) },
{ skillName: 'Medicine', skillNameGerman: SKILL_TRANSLATIONS.medicine, proficiency: proficiencyFromValue(prof.medicine) },
{ skillName: 'Nature', skillNameGerman: SKILL_TRANSLATIONS.nature, proficiency: proficiencyFromValue(prof.nature) },
{ skillName: 'Occultism', skillNameGerman: SKILL_TRANSLATIONS.occultism, proficiency: proficiencyFromValue(prof.occultism) },
{ skillName: 'Performance', skillNameGerman: SKILL_TRANSLATIONS.performance, proficiency: proficiencyFromValue(prof.performance) },
{ skillName: 'Religion', skillNameGerman: SKILL_TRANSLATIONS.religion, proficiency: proficiencyFromValue(prof.religion) },
{ skillName: 'Society', skillNameGerman: SKILL_TRANSLATIONS.society, proficiency: proficiencyFromValue(prof.society) },
{ skillName: 'Stealth', skillNameGerman: SKILL_TRANSLATIONS.stealth, proficiency: proficiencyFromValue(prof.stealth) },
{ skillName: 'Survival', skillNameGerman: SKILL_TRANSLATIONS.survival, proficiency: proficiencyFromValue(prof.survival) },
{ skillName: 'Thievery', skillNameGerman: SKILL_TRANSLATIONS.thievery, proficiency: proficiencyFromValue(prof.thievery) },
// Saves
{ skillName: 'Perception', skillNameGerman: 'Wahrnehmung', proficiency: proficiencyFromValue(prof.perception) },
{ skillName: 'Fortitude', skillNameGerman: 'Zähigkeit', proficiency: proficiencyFromValue(prof.fortitude) },
{ skillName: 'Reflex', skillNameGerman: 'Reflex', proficiency: proficiencyFromValue(prof.reflex) },
{ skillName: 'Will', skillNameGerman: 'Wille', proficiency: proficiencyFromValue(prof.will) },
];
// Add lore skills
for (const [loreName, profValue] of build.lores) {
skills.push({
skillName: `Lore: ${loreName}`,
skillNameGerman: `Wissen: ${loreName}`,
proficiency: proficiencyFromValue(profValue),
});
}
await this.prisma.characterSkill.createMany({
data: skills.map(s => ({
characterId,
skillName: s.skillName,
proficiency: s.proficiency,
})),
});
this.logger.debug(`Imported ${skills.length} skills`);
}
/**
* Import feats with translations
*/
private async importFeats(characterId: string, build: PathbuilderBuild) {
if (!build.feats || build.feats.length === 0) {
return;
}
// Extract feat names for batch translation
const featNames = build.feats.map(f => ({ englishName: f[0] as string }));
const translations = await this.translationsService.getTranslationsBatch(
TranslationType.FEAT,
featNames,
);
const feats = build.feats.map(feat => {
const name = feat[0] as string;
const featType = feat[2] as string;
const level = feat[3] as number;
const translation = translations.get(name);
return {
characterId,
name,
nameGerman: translation?.germanName || name,
level,
source: featSourceFromType(featType),
};
});
await this.prisma.characterFeat.createMany({ data: feats });
this.logger.debug(`Imported ${feats.length} feats`);
}
/**
* Import items (weapons, armor, equipment)
*/
private async importItems(characterId: string, build: PathbuilderBuild) {
const items: Array<{
characterId: string;
name: string;
nameGerman?: string;
quantity: number;
bulk: number;
equipped: boolean;
invested: boolean;
notes?: string;
}> = [];
// Collect all item names for batch translation
const itemNames: Array<{ englishName: string }> = [];
// Weapons
for (const weapon of build.weapons || []) {
itemNames.push({ englishName: weapon.name });
items.push({
characterId,
name: weapon.name,
quantity: weapon.qty || 1,
bulk: 1, // Default bulk for weapons
equipped: true,
invested: false,
notes: `${weapon.die} ${weapon.damageType}${weapon.extraDamage?.length ? ' + ' + weapon.extraDamage.join(', ') : ''}`,
});
}
// Armor
for (const armor of build.armor || []) {
itemNames.push({ englishName: armor.name });
items.push({
characterId,
name: armor.name,
quantity: armor.qty || 1,
bulk: 1, // Default bulk for armor
equipped: armor.worn,
invested: false,
});
}
// General equipment
for (const equip of build.equipment || []) {
const name = equip[0] as string;
const qty = equip[1] as number;
const invested = equip[2] === 'Invested';
itemNames.push({ englishName: name });
items.push({
characterId,
name,
quantity: qty,
bulk: 0,
equipped: false,
invested,
});
}
// Add money as a note item
if (build.money) {
const moneyNote: string[] = [];
if (build.money.pp > 0) moneyNote.push(`${build.money.pp} PP`);
if (build.money.gp > 0) moneyNote.push(`${build.money.gp} GP`);
if (build.money.sp > 0) moneyNote.push(`${build.money.sp} SP`);
if (build.money.cp > 0) moneyNote.push(`${build.money.cp} CP`);
if (moneyNote.length > 0) {
items.push({
characterId,
name: 'Münzbeutel',
quantity: 1,
bulk: 0,
equipped: false,
invested: false,
notes: moneyNote.join(', '),
});
}
}
// Batch translate items
if (itemNames.length > 0) {
const translations = await this.translationsService.getTranslationsBatch(
TranslationType.EQUIPMENT,
itemNames,
);
// Apply translations
for (const item of items) {
if (item.name !== 'Münzbeutel') {
const translation = translations.get(item.name);
if (translation) {
item.nameGerman = translation.germanName;
}
}
}
}
if (items.length > 0) {
await this.prisma.characterItem.createMany({
data: items.map(i => ({
...i,
bulk: i.bulk,
})),
});
this.logger.debug(`Imported ${items.length} items`);
}
}
/**
* Import resources (focus points, spell slots, etc.)
*/
private async importResources(characterId: string, build: PathbuilderBuild) {
const resources: Array<{ characterId: string; name: string; current: number; max: number }> = [];
// Focus Points
if (build.focusPoints > 0) {
resources.push({
characterId,
name: 'Fokuspunkte',
current: build.focusPoints,
max: build.focusPoints,
});
}
// Spell slots from spellcasters
for (const caster of build.spellCasters || []) {
if (caster.perDay) {
for (let level = 0; level < caster.perDay.length; level++) {
const slots = caster.perDay[level];
if (slots > 0) {
resources.push({
characterId,
name: level === 0 ? 'Zaubertricks' : `Zauberplätze Grad ${level}`,
current: slots,
max: slots,
});
}
}
}
}
// Hero Points (always starts at 1)
resources.push({
characterId,
name: 'Heldenpunkte',
current: 1,
max: 3,
});
if (resources.length > 0) {
await this.prisma.characterResource.createMany({ data: resources });
this.logger.debug(`Imported ${resources.length} resources`);
}
}
}

View File

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

View File

@@ -0,0 +1,138 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Anthropic from '@anthropic-ai/sdk';
export interface TranslationRequest {
type: 'FEAT' | 'ITEM' | 'SPELL' | 'ACTION' | 'SKILL' | 'CLASS' | 'ANCESTRY' | 'HERITAGE' | 'BACKGROUND' | 'CONDITION' | 'TRAIT';
englishName: string;
englishDescription?: string;
context?: string;
}
export interface TranslationResponse {
englishName: string;
germanName: string;
germanDescription?: string;
translationQuality: 'HIGH' | 'MEDIUM' | 'LOW';
}
@Injectable()
export class ClaudeService {
private readonly logger = new Logger(ClaudeService.name);
private client: Anthropic;
constructor(private configService: ConfigService) {
const apiKey = this.configService.get<string>('ANTHROPIC_API_KEY');
if (apiKey) {
this.client = new Anthropic({ apiKey });
this.logger.log('Claude API initialized');
} else {
this.logger.warn('ANTHROPIC_API_KEY not set - translations will be disabled');
}
}
async translateBatch(items: TranslationRequest[]): Promise<TranslationResponse[]> {
if (!this.client) {
this.logger.warn('Claude API not available, returning untranslated items');
return items.map(item => ({
englishName: item.englishName,
germanName: item.englishName,
germanDescription: item.englishDescription,
translationQuality: 'LOW' as const,
}));
}
if (items.length === 0) {
return [];
}
try {
const itemsList = items.map((item, i) =>
`${i + 1}. Type: ${item.type}, Name: "${item.englishName}"${item.englishDescription ? `, Description: "${item.englishDescription}"` : ''}${item.context ? `, Context: ${item.context}` : ''}`
).join('\n');
const prompt = `Du bist ein Übersetzer für Pathfinder 2e Spielinhalte von Englisch nach Deutsch.
WICHTIGE ÜBERSETZUNGSREGELN:
- "Feat" = "Talent"
- "Action" = "Aktion"
- "Spell" = "Zauber"
- "Weapon" = "Waffe"
- "Armor" = "Rüstung"
- "Shield" = "Schild"
- "Item" = "Gegenstand"
- "Skill" = "Fertigkeit"
- "Class" = "Klasse"
- "Ancestry" = "Abstammung"
- "Heritage" = "Erbe"
- "Background" = "Hintergrund"
- "Condition" = "Zustand"
- "Trait" = "Merkmal"
Behalte Pathfinder-spezifische Begriffe bei (z.B. "Versatile", "Finesse" bleiben auf Englisch als Spielmechanik-Begriffe).
Übersetze Eigennamen nicht (z.B. "Alchemist's Fire" → "Alchemistenfeuer", aber "Bane" bleibt "Bane").
Übersetze folgende ${items.length} Einträge:
${itemsList}
Antworte NUR mit einem JSON-Array in diesem Format:
[
{
"englishName": "Original English Name",
"germanName": "Deutscher Name",
"germanDescription": "Deutsche Beschreibung (falls vorhanden)",
"confidence": 0.9
}
]
Gib confidence zwischen 0.0 und 1.0 an basierend auf der Übersetzungsqualität.`;
const response = await this.client.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 4000,
messages: [{ role: 'user', content: prompt }],
});
const content = response.content[0];
if (content.type !== 'text') {
throw new Error('Unexpected response type from Claude');
}
// Extract JSON from response
const jsonMatch = content.text.match(/\[[\s\S]*\]/);
if (!jsonMatch) {
this.logger.error('Could not extract JSON from Claude response');
throw new Error('Invalid JSON response from Claude');
}
const translations = JSON.parse(jsonMatch[0]) as Array<{
englishName: string;
germanName: string;
germanDescription?: string;
confidence: number;
}>;
return translations.map(t => ({
englishName: t.englishName,
germanName: t.germanName,
germanDescription: t.germanDescription,
translationQuality: t.confidence >= 0.8 ? 'HIGH' : t.confidence >= 0.6 ? 'MEDIUM' : 'LOW',
}));
} catch (error) {
this.logger.error('Claude translation failed:', error);
// Return untranslated items as fallback
return items.map(item => ({
englishName: item.englishName,
germanName: item.englishName,
germanDescription: item.englishDescription,
translationQuality: 'LOW' as const,
}));
}
}
async translateSingle(item: TranslationRequest): Promise<TranslationResponse> {
const results = await this.translateBatch([item]);
return results[0];
}
}

View File

@@ -0,0 +1,2 @@
export * from './claude.service';
export * from './claude.module';

View File

@@ -0,0 +1,3 @@
export * from './translations.service';
export * from './translations.controller';
export * from './translations.module';

View File

@@ -0,0 +1,57 @@
import {
Controller,
Get,
Post,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { TranslationsService } from './translations.service';
import { TranslationType } from '../../generated/prisma/client.js';
class TranslateRequestDto {
type: TranslationType;
englishName: string;
englishDescription?: string;
}
class TranslateBatchRequestDto {
type: TranslationType;
items: Array<{ englishName: string; englishDescription?: string }>;
}
@ApiTags('translations')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('translations')
export class TranslationsController {
constructor(private translationsService: TranslationsService) {}
@Post()
@ApiOperation({ summary: 'Translate a single item' })
async translate(@Body() dto: TranslateRequestDto) {
return this.translationsService.getTranslation(
dto.type,
dto.englishName,
dto.englishDescription,
);
}
@Post('batch')
@ApiOperation({ summary: 'Translate multiple items' })
async translateBatch(@Body() dto: TranslateBatchRequestDto) {
const result = await this.translationsService.getTranslationsBatch(
dto.type,
dto.items,
);
return Object.fromEntries(result);
}
@Get(':type')
@ApiOperation({ summary: 'Get all cached translations of a type' })
async getAllByType(@Param('type') type: TranslationType) {
return this.translationsService.getAllByType(type);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TranslationsService } from './translations.service';
import { TranslationsController } from './translations.controller';
@Module({
controllers: [TranslationsController],
providers: [TranslationsService],
exports: [TranslationsService],
})
export class TranslationsModule {}

View File

@@ -0,0 +1,159 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { ClaudeService, TranslationRequest, TranslationResponse } from '../claude/claude.service';
import { TranslationType, TranslationQuality } from '../../generated/prisma/client.js';
@Injectable()
export class TranslationsService {
private readonly logger = new Logger(TranslationsService.name);
constructor(
private prisma: PrismaService,
private claudeService: ClaudeService,
) {}
/**
* Get a translation from cache or translate via Claude
*/
async getTranslation(
type: TranslationType,
englishName: string,
englishDescription?: string,
): Promise<TranslationResponse> {
// Check cache first
const cached = await this.prisma.translation.findUnique({
where: { type_englishName: { type, englishName } },
});
if (cached && !this.isIncomplete(cached.germanDescription)) {
this.logger.debug(`Cache hit for ${type}: ${englishName}`);
return {
englishName: cached.englishName,
germanName: cached.germanName,
germanDescription: cached.germanDescription || undefined,
translationQuality: cached.quality,
};
}
// Translate via Claude
this.logger.debug(`Cache miss for ${type}: ${englishName}, calling Claude`);
const translation = await this.claudeService.translateSingle({
type: type as TranslationRequest['type'],
englishName,
englishDescription,
});
// Cache the result
await this.upsertTranslation(type, translation);
return translation;
}
/**
* Get multiple translations, using cache where possible
*/
async getTranslationsBatch(
type: TranslationType,
items: Array<{ englishName: string; englishDescription?: string }>,
): Promise<Map<string, TranslationResponse>> {
const result = new Map<string, TranslationResponse>();
if (items.length === 0) {
return result;
}
// Check cache for all items
const englishNames = items.map(i => i.englishName);
const cached = await this.prisma.translation.findMany({
where: {
type,
englishName: { in: englishNames },
},
});
const cachedMap = new Map(cached.map(c => [c.englishName, c]));
const toTranslate: TranslationRequest[] = [];
for (const item of items) {
const cachedItem = cachedMap.get(item.englishName);
if (cachedItem && !this.isIncomplete(cachedItem.germanDescription)) {
result.set(item.englishName, {
englishName: cachedItem.englishName,
germanName: cachedItem.germanName,
germanDescription: cachedItem.germanDescription || undefined,
translationQuality: cachedItem.quality,
});
} else {
toTranslate.push({
type: type as TranslationRequest['type'],
englishName: item.englishName,
englishDescription: item.englishDescription,
});
}
}
this.logger.log(`${type}: ${cached.length} cached, ${toTranslate.length} to translate`);
// Translate missing items in batches of 20
if (toTranslate.length > 0) {
const batchSize = 20;
for (let i = 0; i < toTranslate.length; i += batchSize) {
const batch = toTranslate.slice(i, i + batchSize);
const translations = await this.claudeService.translateBatch(batch);
for (const translation of translations) {
result.set(translation.englishName, translation);
await this.upsertTranslation(type, translation);
}
}
}
return result;
}
/**
* Store or update a translation in the cache
*/
async upsertTranslation(
type: TranslationType,
translation: TranslationResponse,
): Promise<void> {
await this.prisma.translation.upsert({
where: {
type_englishName: { type, englishName: translation.englishName },
},
update: {
germanName: translation.germanName,
germanDescription: translation.germanDescription,
quality: translation.translationQuality as TranslationQuality,
updatedAt: new Date(),
},
create: {
type,
englishName: translation.englishName,
germanName: translation.germanName,
germanDescription: translation.germanDescription,
quality: translation.translationQuality as TranslationQuality,
translatedBy: 'claude-api',
},
});
}
/**
* Get all cached translations of a type
*/
async getAllByType(type: TranslationType) {
return this.prisma.translation.findMany({
where: { type },
orderBy: { englishName: 'asc' },
});
}
/**
* Check if a translation is incomplete (truncated)
*/
private isIncomplete(description?: string | null): boolean {
if (!description) return false;
return description.trim().endsWith('…') || description.trim().endsWith('...');
}
}

View File

@@ -1,11 +1,17 @@
import 'dotenv/config';
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '../generated/prisma';
import { PrismaClient } from '../generated/prisma/client.js';
import { PrismaPg } from '@prisma/adapter-pg';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor() {
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL,
});
super({
adapter,
log: process.env.NODE_ENV === 'development'
? ['query', 'info', 'warn', 'error']
: ['error'],