feat(auth): add avatar upload with S3/MinIO and subscription plans seed

- Add StorageModule for avatar uploads via S3/MinIO
- Create presigned URL endpoint for direct browser uploads
- Create direct upload endpoint (multipart/form-data)
- Add manacore-storage bucket to shared-storage package
- Add manacore-storage bucket to docker-compose.dev.yml
- Create subscription plans seed script (pnpm db:seed:plans)
- Plans: Free (150 credits), Pro (2000/€9.99/mo), Enterprise (10000/€49/mo)
- Update TODO list with completed tasks
This commit is contained in:
Till-JS 2026-02-13 23:06:24 +01:00
parent 1e025b7e72
commit c2842e2546
15 changed files with 756 additions and 102 deletions

View file

@ -91,9 +91,9 @@
- [x] Credits-Seite: Stripe Checkout Integration
- [x] Loading-States und Toast-Benachrichtigungen
**Noch offen:**
**Rechnungs-PDFs:**
- [ ] Rechnungs-PDF generieren
- [x] Stripe Invoice PDFs werden automatisch über Webhooks synchronisiert (`invoicePdfUrl`)
---
@ -167,9 +167,9 @@ Archivierte Apps (memoro, storyteller) wurden bereits entfernt.
| Feature | Backend | Frontend | Priorität |
| ----------------- | ------- | -------- | --------- |
| Profil bearbeiten | ✅ | | Hoch |
| Passwort ändern | ✅ | | Hoch |
| Konto löschen | ✅ | | Mittel |
| Profil bearbeiten | ✅ | | Hoch |
| Passwort ändern | ✅ | | Hoch |
| Konto löschen | ✅ | | Mittel |
| Avatar hochladen | ✅ | ❌ | Niedrig |
| 2FA aktivieren | ❌ | ❌ | Niedrig |
@ -186,7 +186,14 @@ Archivierte Apps (memoro, storyteller) wurden bereits entfernt.
- [x] Profil-Edit Modal erstellt (`EditProfileModal.svelte`)
- [x] Passwort-Ändern Dialog erstellt (`ChangePasswordModal.svelte`)
- [x] Konto-Löschung mit Bestätigung (`DeleteAccountModal.svelte`)
- [ ] Avatar-Upload mit S3/MinIO Integration (noch offen)
**Avatar-Upload (Implementiert 2026-02-13):**
- [x] Storage-Modul für S3/MinIO (`services/mana-core-auth/src/storage/`)
- [x] Presigned URL Endpoint: `POST /api/v1/storage/avatar/upload-url`
- [x] Direct Upload Endpoint: `POST /api/v1/storage/avatar`
- [x] `manacore-storage` Bucket konfiguriert
- [ ] Frontend-Integration (EditProfileModal) noch offen
---
@ -257,7 +264,8 @@ GET /api/v1/subscriptions/invoices # Rechnungen
- [x] Stripe Checkout Integration für Subscriptions
- [x] Billing Portal Integration
- [x] Rechnungsübersicht
- [ ] Stripe Price IDs in DB eintragen (nach Stripe-Setup)
- [x] Subscription-Plans Seed-Script erstellt (`pnpm db:seed:plans`)
- [ ] Stripe Products/Prices erstellen und ENV-Variablen setzen
---
@ -406,4 +414,4 @@ Diese Tasks können schnell erledigt werden:
---
_Zuletzt aktualisiert: 2026-02-13 (Profile + Subscription + Credits Frontend)_
_Zuletzt aktualisiert: 2026-02-13 (Avatar Storage Backend + Subscription Plans Seed)_

View file

@ -76,6 +76,7 @@ services:
entrypoint: >
/bin/sh -c "
mc alias set myminio http://minio:9000 $${MINIO_ROOT_USER:-minioadmin} $${MINIO_ROOT_PASSWORD:-minioadmin};
mc mb --ignore-existing myminio/manacore-storage;
mc mb --ignore-existing myminio/picture-storage;
mc mb --ignore-existing myminio/chat-storage;
mc mb --ignore-existing myminio/manadeck-storage;
@ -87,6 +88,7 @@ services:
mc mb --ignore-existing myminio/inventory-storage;
mc mb --ignore-existing myminio/planta-storage;
mc mb --ignore-existing myminio/projectdoc-storage;
mc anonymous set download myminio/manacore-storage;
mc anonymous set download myminio/picture-storage;
mc anonymous set download myminio/planta-storage;
mc anonymous set download myminio/inventory-storage;

View file

@ -66,6 +66,16 @@ export function createStorageClient(
return new StorageClient(storageConfig, bucketConfig);
}
/**
* Create a storage client for the Mana Core Auth project (avatars, etc.)
*/
export function createManaCoreStorage(publicUrl?: string): StorageClient {
return createStorageClient({
name: BUCKETS.MANACORE,
publicUrl: publicUrl ?? process.env.MANACORE_STORAGE_PUBLIC_URL,
});
}
/**
* Create a storage client for the Picture project
*/

View file

@ -5,6 +5,7 @@ export { StorageClient } from './client';
export {
createStorageClient,
getStorageConfig,
createManaCoreStorage,
createPictureStorage,
createChatStorage,
createManaDeckStorage,

View file

@ -76,6 +76,7 @@ export interface FileInfo {
* Predefined bucket names for each project
*/
export const BUCKETS = {
MANACORE: 'manacore-storage',
PICTURE: 'picture-storage',
CHAT: 'chat-storage',
MANADECK: 'manadeck-storage',

317
pnpm-lock.yaml generated
View file

@ -196,7 +196,7 @@ importers:
version: 0.5.21
ts-jest:
specifier: ^29.2.5
version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3)
version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3)
ts-loader:
specifier: ^9.5.1
version: 9.5.4(typescript@5.9.3)(webpack@5.100.2)
@ -2371,7 +2371,7 @@ importers:
version: 0.5.21
ts-jest:
specifier: ^29.2.5
version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3)
version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3)
ts-loader:
specifier: ^9.5.1
version: 9.5.4(typescript@5.9.3)(webpack@5.100.2)
@ -5656,7 +5656,7 @@ importers:
version: 1.57.0
jest:
specifier: ^29.0.0
version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
version: 29.7.0(@types/node@24.10.1)
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.2.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
@ -5803,7 +5803,7 @@ importers:
version: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
ts-jest:
specifier: ^29.2.5
version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3)
version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3)
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3)
@ -5819,6 +5819,9 @@ importers:
'@google/generative-ai':
specifier: ^0.24.1
version: 0.24.1
'@manacore/shared-storage':
specifier: workspace:*
version: link:../../packages/shared-storage
'@nestjs/axios':
specifier: ^4.0.1
version: 4.0.1(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.2)(rxjs@7.8.2)
@ -5843,6 +5846,9 @@ importers:
'@nestjs/throttler':
specifier: ^6.2.1
version: 6.4.0(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(reflect-metadata@0.2.2)
'@types/multer':
specifier: ^2.0.0
version: 2.0.0
axios:
specifier: ^1.7.2
version: 1.13.2
@ -5885,6 +5891,9 @@ importers:
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.3
multer:
specifier: ^1.4.5-lts.1
version: 1.4.5-lts.2
nanoid:
specifier: ^5.0.9
version: 5.1.6
@ -5918,7 +5927,7 @@ importers:
devDependencies:
'@nestjs/cli':
specifier: ^11.0.0
version: 11.0.12(@types/node@22.19.1)
version: 11.0.12(@types/node@22.19.1)(esbuild@0.19.12)
'@nestjs/schematics':
specifier: ^11.0.0
version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3)
@ -5978,10 +5987,10 @@ importers:
version: 7.1.4
ts-jest:
specifier: ^29.2.5
version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3)
version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3)
ts-loader:
specifier: ^9.5.1
version: 9.5.4(typescript@5.9.3)(webpack@5.100.2)
version: 9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.19.12))
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3)
@ -6090,7 +6099,7 @@ importers:
version: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
ts-jest:
specifier: ^29.2.5
version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3)
version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3)
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3)
@ -6280,7 +6289,7 @@ importers:
version: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
ts-jest:
specifier: ^29.2.5
version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3)
version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3)
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3)
@ -6353,7 +6362,7 @@ importers:
version: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
ts-jest:
specifier: ^29.2.5
version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3)
version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3)
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3)
@ -27388,7 +27397,7 @@ snapshots:
wrap-ansi: 7.0.0
ws: 8.18.3
optionalDependencies:
expo-router: 6.0.15(g2vconqrtzzmzlh6ymhbjirn5e)
expo-router: 6.0.15(5ll7ovd7i5kd7vxhny3dgbs3xy)
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
@ -28696,7 +28705,7 @@ snapshots:
jest-util: 30.2.0
slash: 3.0.0
'@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))':
'@jest/core@29.7.0':
dependencies:
'@jest/console': 29.7.0
'@jest/reporters': 29.7.0
@ -28710,7 +28719,7 @@ snapshots:
exit: 0.1.2
graceful-fs: 4.2.11
jest-changed-files: 29.7.0
jest-config: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
jest-config: 29.7.0(@types/node@22.19.1)
jest-haste-map: 29.7.0
jest-message-util: 29.7.0
jest-regex-util: 29.6.3
@ -28731,7 +28740,7 @@ snapshots:
- supports-color
- ts-node
'@jest/core@29.7.0(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))':
'@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))':
dependencies:
'@jest/console': 29.7.0
'@jest/reporters': 29.7.0
@ -28745,7 +28754,7 @@ snapshots:
exit: 0.1.2
graceful-fs: 4.2.11
jest-changed-files: 29.7.0
jest-config: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
jest-config: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
jest-haste-map: 29.7.0
jest-message-util: 29.7.0
jest-regex-util: 29.6.3
@ -29406,7 +29415,7 @@ snapshots:
- uglify-js
- webpack-cli
'@nestjs/cli@11.0.12(@types/node@22.19.1)':
'@nestjs/cli@11.0.12(@types/node@22.19.1)(esbuild@0.19.12)':
dependencies:
'@angular-devkit/core': 19.2.19(chokidar@4.0.3)
'@angular-devkit/schematics': 19.2.19(chokidar@4.0.3)
@ -29417,14 +29426,14 @@ snapshots:
chokidar: 4.0.3
cli-table3: 0.6.5
commander: 4.1.1
fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.100.2)
fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.100.2(esbuild@0.19.12))
glob: 12.0.0
node-emoji: 1.11.0
ora: 5.4.1
tsconfig-paths: 4.2.0
tsconfig-paths-webpack-plugin: 4.2.0
typescript: 5.9.3
webpack: 5.100.2
webpack: 5.100.2(esbuild@0.19.12)
webpack-node-externals: 3.0.0
transitivePeerDependencies:
- '@types/node'
@ -33063,6 +33072,19 @@ snapshots:
picocolors: 1.1.1
pretty-format: 27.5.1
'@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
jest-matcher-utils: 30.2.0
picocolors: 1.1.1
pretty-format: 30.2.0
react: 19.1.0
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)
react-test-renderer: 19.1.0(react@19.1.0)
redent: 3.0.0
optionalDependencies:
jest: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
optional: true
'@testing-library/react-native@13.3.3(jest@30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
jest-matcher-utils: 30.2.0
@ -36483,13 +36505,13 @@ snapshots:
- supports-color
- ts-node
create-jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)):
create-jest@29.7.0(@types/node@24.10.1):
dependencies:
'@jest/types': 29.6.3
chalk: 4.1.2
exit: 0.1.2
graceful-fs: 4.2.11
jest-config: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
jest-config: 29.7.0(@types/node@24.10.1)
jest-util: 29.7.0
prompts: 2.4.2
transitivePeerDependencies:
@ -39270,6 +39292,53 @@ snapshots:
- react-native
- supports-color
expo-router@6.0.15(5ll7ovd7i5kd7vxhny3dgbs3xy):
dependencies:
'@expo/metro-runtime': 6.1.2(expo@54.0.25)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
'@expo/schema-utils': 0.1.7
'@radix-ui/react-slot': 1.2.0(@types/react@19.2.7)(react@19.1.0)
'@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@react-navigation/bottom-tabs': 7.8.6(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
'@react-navigation/native': 7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
'@react-navigation/native-stack': 7.8.0(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
client-only: 0.0.1
debug: 4.4.3
escape-string-regexp: 4.0.0
expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
expo-constants: 18.0.10(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))
expo-linking: 8.0.9(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
expo-server: 1.0.4
fast-deep-equal: 3.1.3
invariant: 2.2.4
nanoid: 3.3.11
query-string: 7.1.3
react: 19.1.0
react-fast-compare: 3.2.2
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)
react-native-is-edge-to-edge: 1.2.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
react-native-screens: 4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
semver: 7.6.3
server-only: 0.0.1
sf-symbols-typescript: 2.1.0
shallowequal: 1.1.0
use-latest-callback: 0.2.6(react@19.1.0)
vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
optionalDependencies:
'@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
'@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)
react-dom: 19.1.0(react@19.1.0)
react-native-gesture-handler: 2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react-server-dom-webpack: 19.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(webpack@5.100.2(esbuild@0.19.12))
transitivePeerDependencies:
- '@react-native-masked-view/masked-view'
- '@types/react'
- '@types/react-dom'
- supports-color
optional: true
expo-router@6.0.15(6hayu32hencph7rqfkncbd2qum):
dependencies:
'@expo/metro-runtime': 6.1.2(expo@54.0.25)(react-dom@19.1.0(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1)
@ -40430,6 +40499,23 @@ snapshots:
typescript: 5.7.2
webpack: 5.97.1
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.100.2(esbuild@0.19.12)):
dependencies:
'@babel/code-frame': 7.27.1
chalk: 4.1.2
chokidar: 4.0.3
cosmiconfig: 8.3.6(typescript@5.9.3)
deepmerge: 4.3.1
fs-extra: 10.1.0
memfs: 3.5.3
minimatch: 3.1.2
node-abort-controller: 3.1.1
schema-utils: 3.3.0
semver: 7.7.3
tapable: 2.3.0
typescript: 5.9.3
webpack: 5.100.2(esbuild@0.19.12)
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.0)):
dependencies:
'@babel/code-frame': 7.27.1
@ -40447,23 +40533,6 @@ snapshots:
typescript: 5.9.3
webpack: 5.100.2(esbuild@0.27.0)
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.100.2):
dependencies:
'@babel/code-frame': 7.27.1
chalk: 4.1.2
chokidar: 4.0.3
cosmiconfig: 8.3.6(typescript@5.9.3)
deepmerge: 4.3.1
fs-extra: 10.1.0
memfs: 3.5.3
minimatch: 3.1.2
node-abort-controller: 3.1.1
schema-utils: 3.3.0
semver: 7.7.3
tapable: 2.3.0
typescript: 5.9.3
webpack: 5.100.2
form-data-encoder@1.7.2: {}
form-data@2.3.3:
@ -41875,16 +41944,16 @@ snapshots:
- supports-color
- ts-node
jest-cli@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)):
jest-cli@29.7.0(@types/node@24.10.1):
dependencies:
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
'@jest/core': 29.7.0
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
chalk: 4.1.2
create-jest: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
create-jest: 29.7.0(@types/node@24.10.1)
exit: 0.1.2
import-local: 3.2.0
jest-config: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
jest-config: 29.7.0(@types/node@24.10.1)
jest-util: 29.7.0
jest-validate: 29.7.0
yargs: 17.7.2
@ -41973,6 +42042,36 @@ snapshots:
- ts-node
optional: true
jest-config@29.7.0(@types/node@22.19.1):
dependencies:
'@babel/core': 7.28.5
'@jest/test-sequencer': 29.7.0
'@jest/types': 29.6.3
babel-jest: 29.7.0(@babel/core@7.28.5)
chalk: 4.1.2
ci-info: 3.9.0
deepmerge: 4.3.1
glob: 7.2.3
graceful-fs: 4.2.11
jest-circus: 29.7.0
jest-environment-node: 29.7.0
jest-get-type: 29.6.3
jest-regex-util: 29.6.3
jest-resolve: 29.7.0
jest-runner: 29.7.0
jest-util: 29.7.0
jest-validate: 29.7.0
micromatch: 4.0.8
parse-json: 5.2.0
pretty-format: 29.7.0
slash: 3.0.0
strip-json-comments: 3.1.1
optionalDependencies:
'@types/node': 22.19.1
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
jest-config@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
dependencies:
'@babel/core': 7.28.5
@ -42004,38 +42103,7 @@ snapshots:
- babel-plugin-macros
- supports-color
jest-config@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)):
dependencies:
'@babel/core': 7.28.5
'@jest/test-sequencer': 29.7.0
'@jest/types': 29.6.3
babel-jest: 29.7.0(@babel/core@7.28.5)
chalk: 4.1.2
ci-info: 3.9.0
deepmerge: 4.3.1
glob: 7.2.3
graceful-fs: 4.2.11
jest-circus: 29.7.0
jest-environment-node: 29.7.0
jest-get-type: 29.6.3
jest-regex-util: 29.6.3
jest-resolve: 29.7.0
jest-runner: 29.7.0
jest-util: 29.7.0
jest-validate: 29.7.0
micromatch: 4.0.8
parse-json: 5.2.0
pretty-format: 29.7.0
slash: 3.0.0
strip-json-comments: 3.1.1
optionalDependencies:
'@types/node': 22.19.1
ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.9.3)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
jest-config@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)):
jest-config@29.7.0(@types/node@24.10.1):
dependencies:
'@babel/core': 7.28.5
'@jest/test-sequencer': 29.7.0
@ -42061,7 +42129,6 @@ snapshots:
strip-json-comments: 3.1.1
optionalDependencies:
'@types/node': 24.10.1
ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.9.3)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
@ -42758,12 +42825,12 @@ snapshots:
- supports-color
- ts-node
jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)):
jest@29.7.0(@types/node@24.10.1):
dependencies:
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
'@jest/core': 29.7.0
'@jest/types': 29.6.3
import-local: 3.2.0
jest-cli: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
jest-cli: 29.7.0(@types/node@24.10.1)
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@ -47155,6 +47222,16 @@ snapshots:
webpack-sources: 3.3.3
optional: true
react-server-dom-webpack@19.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(webpack@5.100.2(esbuild@0.19.12)):
dependencies:
acorn-loose: 8.5.2
neo-async: 2.6.2
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
webpack: 5.100.2(esbuild@0.19.12)
webpack-sources: 3.3.3
optional: true
react-server-dom-webpack@19.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(webpack@5.100.2(esbuild@0.27.0)):
dependencies:
acorn-loose: 8.5.2
@ -48665,6 +48742,17 @@ snapshots:
ansi-escapes: 4.3.2
supports-hyperlinks: 2.3.0
terser-webpack-plugin@5.3.14(esbuild@0.19.12)(webpack@5.100.2(esbuild@0.19.12)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.3.3
serialize-javascript: 6.0.2
terser: 5.44.1
webpack: 5.100.2(esbuild@0.19.12)
optionalDependencies:
esbuild: 0.19.12
terser-webpack-plugin@5.3.14(esbuild@0.27.0)(webpack@5.100.2(esbuild@0.27.0)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
@ -48873,6 +48961,27 @@ snapshots:
ts-interface-checker@0.1.13: {}
ts-jest@29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
handlebars: 4.7.8
jest: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
json5: 2.2.3
lodash.memoize: 4.1.2
make-error: 1.3.6
semver: 7.7.3
type-fest: 4.41.0
typescript: 5.9.3
yargs-parser: 21.1.1
optionalDependencies:
'@babel/core': 7.28.5
'@jest/transform': 30.2.0
'@jest/types': 30.2.0
babel-jest: 30.2.0(@babel/core@7.28.5)
esbuild: 0.19.12
jest-util: 30.2.0
ts-jest@29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.27.0)(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3):
dependencies:
bs-logger: 0.2.6
@ -48894,25 +49003,15 @@ snapshots:
esbuild: 0.27.0
jest-util: 30.2.0
ts-jest@29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3):
ts-loader@9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.19.12)):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
handlebars: 4.7.8
jest: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
json5: 2.2.3
lodash.memoize: 4.1.2
make-error: 1.3.6
chalk: 4.1.2
enhanced-resolve: 5.18.3
micromatch: 4.0.8
semver: 7.7.3
type-fest: 4.41.0
source-map: 0.7.6
typescript: 5.9.3
yargs-parser: 21.1.1
optionalDependencies:
'@babel/core': 7.28.5
'@jest/transform': 30.2.0
'@jest/types': 30.2.0
babel-jest: 30.2.0(@babel/core@7.28.5)
jest-util: 30.2.0
webpack: 5.100.2(esbuild@0.19.12)
ts-loader@9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.0)):
dependencies:
@ -50157,6 +50256,38 @@ snapshots:
- esbuild
- uglify-js
webpack@5.100.2(esbuild@0.19.12):
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.8
'@types/json-schema': 7.0.15
'@webassemblyjs/ast': 1.14.1
'@webassemblyjs/wasm-edit': 1.14.1
'@webassemblyjs/wasm-parser': 1.14.1
acorn: 8.15.0
acorn-import-phases: 1.0.4(acorn@8.15.0)
browserslist: 4.28.0
chrome-trace-event: 1.0.4
enhanced-resolve: 5.18.3
es-module-lexer: 1.7.0
eslint-scope: 5.1.1
events: 3.3.0
glob-to-regexp: 0.4.1
graceful-fs: 4.2.11
json-parse-even-better-errors: 2.3.1
loader-runner: 4.3.1
mime-types: 2.1.35
neo-async: 2.6.2
schema-utils: 4.3.3
tapable: 2.3.0
terser-webpack-plugin: 5.3.14(esbuild@0.19.12)(webpack@5.100.2(esbuild@0.19.12))
watchpack: 2.4.4
webpack-sources: 3.3.3
transitivePeerDependencies:
- '@swc/core'
- esbuild
- uglify-js
webpack@5.100.2(esbuild@0.27.0):
dependencies:
'@types/eslint-scope': 3.7.7

View file

@ -21,10 +21,12 @@
"db:migrate": "tsx src/db/migrate.ts",
"db:studio": "drizzle-kit studio",
"db:seed:dev": "tsx src/db/seed-dev-user.ts",
"db:seed:oidc": "tsx src/db/seeds/seed-oidc-clients.ts"
"db:seed:oidc": "tsx src/db/seeds/seed-oidc-clients.ts",
"db:seed:plans": "tsx src/db/seeds/seed-subscription-plans.ts"
},
"dependencies": {
"@google/generative-ai": "^0.24.1",
"@manacore/shared-storage": "workspace:*",
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
@ -33,6 +35,7 @@
"@nestjs/schedule": "^4.1.2",
"@nestjs/swagger": "^8.1.0",
"@nestjs/throttler": "^6.2.1",
"@types/multer": "^2.0.0",
"axios": "^1.7.2",
"bcrypt": "^5.1.1",
"better-auth": "^1.4.3",
@ -47,6 +50,7 @@
"helmet": "^8.0.0",
"jose": "^6.1.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"nanoid": "^5.0.9",
"nodemailer": "^7.0.12",
"postgres": "^3.4.5",

View file

@ -12,6 +12,7 @@ import { FeedbackModule } from './feedback/feedback.module';
import { HealthModule } from './health/health.module';
import { ReferralsModule } from './referrals/referrals.module';
import { SettingsModule } from './settings/settings.module';
import { StorageModule } from './storage/storage.module';
import { TagsModule } from './tags/tags.module';
import { MeModule } from './me/me.module';
import { SubscriptionsModule } from './subscriptions/subscriptions.module';
@ -45,6 +46,7 @@ import { LoggerModule } from './common/logger';
HealthModule,
ReferralsModule,
SettingsModule,
StorageModule,
TagsModule,
MeModule,
StripeModule,

View file

@ -72,5 +72,9 @@ export default () => ({
geminiApiKey: env.GOOGLE_GENAI_API_KEY || '',
},
storage: {
publicUrl: env.MANACORE_STORAGE_PUBLIC_URL || '',
},
baseUrl: env.BASE_URL || (isDevelopment() ? 'http://localhost:3001' : ''),
});

View file

@ -55,6 +55,9 @@ const envSchema = z.object({
// AI
GOOGLE_GENAI_API_KEY: z.string().optional(),
// Storage
MANACORE_STORAGE_PUBLIC_URL: z.string().optional(),
// Base URL for callbacks
BASE_URL: z.string().url().optional(),

View file

@ -0,0 +1,184 @@
/**
* Seed subscription plans with Stripe Price IDs
*
* This script creates/updates the default subscription plans in the database.
* Plans are idempotent - running multiple times won't create duplicates.
*
* Usage:
* pnpm db:seed:plans
*
* Prerequisites:
* 1. Create products and prices in Stripe Dashboard
* 2. Set STRIPE_* environment variables with the price IDs
*
* Stripe Products to create:
* - Mana Free (price: 0 EUR)
* - Mana Pro (prices: 9.99 EUR/month, 99 EUR/year)
* - Mana Enterprise (contact sales)
*/
import 'dotenv/config';
import postgres from 'postgres';
import { drizzle } from 'drizzle-orm/postgres-js';
import { eq } from 'drizzle-orm';
import { plans } from '../schema/subscriptions.schema';
// Environment configuration
const DATABASE_URL =
process.env.DATABASE_URL || 'postgresql://manacore:manacore@localhost:5432/manacore_auth';
// Stripe Price IDs from environment (or defaults for development)
const STRIPE_CONFIG = {
// Free plan (no Stripe price needed)
FREE_PRODUCT_ID: process.env.STRIPE_FREE_PRODUCT_ID || '',
// Pro plan
PRO_PRODUCT_ID: process.env.STRIPE_PRO_PRODUCT_ID || '',
PRO_PRICE_MONTHLY: process.env.STRIPE_PRO_PRICE_MONTHLY || '', // e.g., price_xxx
PRO_PRICE_YEARLY: process.env.STRIPE_PRO_PRICE_YEARLY || '', // e.g., price_xxx
// Enterprise plan
ENTERPRISE_PRODUCT_ID: process.env.STRIPE_ENTERPRISE_PRODUCT_ID || '',
ENTERPRISE_PRICE_MONTHLY: process.env.STRIPE_ENTERPRISE_PRICE_MONTHLY || '',
ENTERPRISE_PRICE_YEARLY: process.env.STRIPE_ENTERPRISE_PRICE_YEARLY || '',
};
// Plan definitions
const PLANS = [
{
name: 'Free',
description: 'Kostenlos starten mit grundlegenden Features',
monthlyCredits: 150,
priceMonthlyEuroCents: 0,
priceYearlyEuroCents: 0,
stripePriceIdMonthly: null,
stripePriceIdYearly: null,
stripeProductId: STRIPE_CONFIG.FREE_PRODUCT_ID || null,
features: [
'150 Credits pro Monat',
'5 tägliche Gratis-Credits',
'Zugang zu allen Apps',
'Basis-Support',
],
maxTeamMembers: null,
maxOrganizations: null,
isDefault: true,
isEnterprise: false,
sortOrder: 0,
},
{
name: 'Pro',
description: 'Für Power-User mit mehr Credits und Features',
monthlyCredits: 2000,
priceMonthlyEuroCents: 999, // 9.99 EUR
priceYearlyEuroCents: 9900, // 99 EUR (2 months free)
stripePriceIdMonthly: STRIPE_CONFIG.PRO_PRICE_MONTHLY || null,
stripePriceIdYearly: STRIPE_CONFIG.PRO_PRICE_YEARLY || null,
stripeProductId: STRIPE_CONFIG.PRO_PRODUCT_ID || null,
features: [
'2.000 Credits pro Monat',
'20 tägliche Gratis-Credits',
'Prioritäts-Support',
'Erweiterte AI-Modelle',
'API-Zugang',
],
maxTeamMembers: 5,
maxOrganizations: 3,
isDefault: false,
isEnterprise: false,
sortOrder: 1,
},
{
name: 'Enterprise',
description: 'Für Teams und Unternehmen mit individuellen Anforderungen',
monthlyCredits: 10000,
priceMonthlyEuroCents: 4900, // 49 EUR
priceYearlyEuroCents: 49000, // 490 EUR (2 months free)
stripePriceIdMonthly: STRIPE_CONFIG.ENTERPRISE_PRICE_MONTHLY || null,
stripePriceIdYearly: STRIPE_CONFIG.ENTERPRISE_PRICE_YEARLY || null,
stripeProductId: STRIPE_CONFIG.ENTERPRISE_PRODUCT_ID || null,
features: [
'10.000 Credits pro Monat',
'Unbegrenzte tägliche Credits',
'Dedizierter Account Manager',
'SLA-garantierte Verfügbarkeit',
'Custom AI-Modelle',
'SSO / SAML Integration',
'Admin-Dashboard',
],
maxTeamMembers: null, // Unlimited
maxOrganizations: null, // Unlimited
isDefault: false,
isEnterprise: true,
sortOrder: 2,
},
];
async function seedPlans() {
console.log('Seeding subscription plans...');
console.log(`Database: ${DATABASE_URL.replace(/:[^@]+@/, ':***@')}`);
const client = postgres(DATABASE_URL);
const db = drizzle(client);
try {
for (const plan of PLANS) {
// Check if plan exists
const [existing] = await db.select().from(plans).where(eq(plans.name, plan.name)).limit(1);
if (existing) {
// Update existing plan
await db
.update(plans)
.set({
...plan,
updatedAt: new Date(),
})
.where(eq(plans.id, existing.id));
console.log(`✓ Updated plan: ${plan.name}`);
} else {
// Insert new plan
await db.insert(plans).values({
...plan,
} as any);
console.log(`✓ Created plan: ${plan.name}`);
}
}
// List all plans
const allPlans = await db.select().from(plans).orderBy(plans.sortOrder);
console.log('\nAll subscription plans:');
console.table(
allPlans.map((p) => ({
name: p.name,
credits: p.monthlyCredits,
monthly: `${(p.priceMonthlyEuroCents / 100).toFixed(2)}`,
yearly: `${(p.priceYearlyEuroCents / 100).toFixed(2)}`,
stripeMonthly: p.stripePriceIdMonthly || '(not set)',
stripeYearly: p.stripePriceIdYearly || '(not set)',
default: p.isDefault,
}))
);
console.log('\n✅ Subscription plans seeded successfully!');
if (!STRIPE_CONFIG.PRO_PRICE_MONTHLY || !STRIPE_CONFIG.PRO_PRICE_YEARLY) {
console.log('\n⚠ Warning: Stripe Price IDs not configured.');
console.log(' Set these environment variables:');
console.log(' - STRIPE_PRO_PRODUCT_ID');
console.log(' - STRIPE_PRO_PRICE_MONTHLY');
console.log(' - STRIPE_PRO_PRICE_YEARLY');
console.log(' - STRIPE_ENTERPRISE_PRODUCT_ID');
console.log(' - STRIPE_ENTERPRISE_PRICE_MONTHLY');
console.log(' - STRIPE_ENTERPRISE_PRICE_YEARLY');
}
} catch (error) {
console.error('Error seeding plans:', error);
process.exit(1);
} finally {
await client.end();
}
}
// Run if called directly
seedPlans();

View file

@ -0,0 +1,3 @@
export { StorageModule } from './storage.module';
export { StorageService } from './storage.service';
export { StorageController } from './storage.controller';

View file

@ -0,0 +1,113 @@
import {
Controller,
Post,
Body,
UseGuards,
UseInterceptors,
UploadedFile,
BadRequestException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
import { StorageService } from './storage.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { CurrentUserData } from '../common/decorators/current-user.decorator';
interface GetUploadUrlDto {
filename: string;
}
@ApiTags('storage')
@Controller('storage')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
export class StorageController {
constructor(private readonly storageService: StorageService) {}
/**
* Get a presigned URL for avatar upload
*
* Returns a presigned URL that the client can use to upload
* the avatar directly to S3/MinIO. This is the recommended approach
* for frontend uploads as it's more efficient.
*/
@Post('avatar/upload-url')
@ApiOperation({
summary: 'Get presigned URL for avatar upload',
description:
'Returns a presigned URL for direct upload to storage. Use this URL to PUT the file.',
})
@ApiResponse({
status: 200,
description: 'Returns presigned upload URL',
schema: {
type: 'object',
properties: {
uploadUrl: { type: 'string', description: 'PUT this URL with the file' },
fileUrl: { type: 'string', description: 'Public URL after upload' },
key: { type: 'string', description: 'Storage key' },
expiresIn: { type: 'number', description: 'URL expires in seconds' },
},
},
})
@ApiResponse({ status: 400, description: 'Invalid file type or storage not configured' })
async getAvatarUploadUrl(
@CurrentUser() user: CurrentUserData,
@Body() dto: GetUploadUrlDto
): Promise<{
uploadUrl: string;
fileUrl: string;
key: string;
expiresIn: number;
}> {
if (!dto.filename) {
throw new BadRequestException('filename is required');
}
return this.storageService.getAvatarUploadUrl(user.userId, dto.filename);
}
/**
* Upload avatar directly (multipart/form-data)
*
* Alternative to presigned URLs. The file is uploaded to the backend
* which then uploads it to S3/MinIO. Simpler but less efficient for
* large files.
*/
@Post('avatar')
@UseInterceptors(
FileInterceptor('file', {
limits: {
fileSize: 5 * 1024 * 1024, // 5MB
},
})
)
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: 'Upload avatar directly',
description: 'Upload avatar file directly to the server',
})
@ApiResponse({
status: 201,
description: 'Avatar uploaded successfully',
schema: {
type: 'object',
properties: {
url: { type: 'string', description: 'Public URL of the uploaded avatar' },
key: { type: 'string', description: 'Storage key' },
},
},
})
@ApiResponse({ status: 400, description: 'Invalid file type or size' })
async uploadAvatar(
@CurrentUser() user: CurrentUserData,
@UploadedFile() file: Express.Multer.File
): Promise<{ url: string; key: string }> {
if (!file) {
throw new BadRequestException('No file uploaded');
}
return this.storageService.uploadAvatar(user.userId, file.buffer, file.originalname);
}
}

View file

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { StorageService } from './storage.service';
import { StorageController } from './storage.controller';
@Module({
imports: [ConfigModule],
controllers: [StorageController],
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}

View file

@ -0,0 +1,176 @@
import { Injectable, Logger, BadRequestException, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
createManaCoreStorage,
generateUserFileKey,
getContentType,
validateFileSize,
validateFileExtension,
IMAGE_EXTENSIONS,
} from '@manacore/shared-storage';
import type { StorageClient } from '@manacore/shared-storage';
const MAX_AVATAR_SIZE = 5 * 1024 * 1024; // 5MB
@Injectable()
export class StorageService implements OnModuleInit {
private readonly logger = new Logger(StorageService.name);
private storage: StorageClient | null = null;
private readonly publicUrl: string | undefined;
constructor(private readonly configService: ConfigService) {
this.publicUrl = this.configService.get<string>('storage.publicUrl');
}
async onModuleInit() {
try {
this.storage = createManaCoreStorage(this.publicUrl);
this.logger.log('Storage service initialized');
} catch (error) {
this.logger.warn(
'Storage service not configured - avatar uploads will be disabled',
error instanceof Error ? error.message : undefined
);
}
}
/**
* Check if storage is available
*/
isAvailable(): boolean {
return this.storage !== null;
}
/**
* Generate a presigned URL for avatar upload
*
* @param userId - User ID
* @param filename - Original filename
* @returns Presigned upload URL and the final file URL
*/
async getAvatarUploadUrl(
userId: string,
filename: string
): Promise<{
uploadUrl: string;
fileUrl: string;
key: string;
expiresIn: number;
}> {
if (!this.storage) {
throw new BadRequestException('Storage service is not configured');
}
// Validate file extension
const ext = filename.split('.').pop()?.toLowerCase();
if (!ext || !validateFileExtension(filename, IMAGE_EXTENSIONS)) {
throw new BadRequestException(`Invalid file type. Allowed: ${IMAGE_EXTENSIONS.join(', ')}`);
}
// Generate unique key for avatar
const key = `avatars/${userId}/${Date.now()}.${ext}`;
const contentType = getContentType(filename);
// Get presigned upload URL (1 hour expiry)
const expiresIn = 3600;
const uploadUrl = await this.storage.getUploadUrl(key, {
expiresIn,
});
// Construct the final public URL
const fileUrl = await this.getPublicUrl(key);
this.logger.debug('Generated avatar upload URL', { userId, key });
return {
uploadUrl,
fileUrl,
key,
expiresIn,
};
}
/**
* Upload avatar directly (for server-side uploads)
*
* @param userId - User ID
* @param buffer - File buffer
* @param filename - Original filename
* @returns Public URL of the uploaded avatar
*/
async uploadAvatar(
userId: string,
buffer: Buffer,
filename: string
): Promise<{ url: string; key: string }> {
if (!this.storage) {
throw new BadRequestException('Storage service is not configured');
}
// Validate file extension
if (!validateFileExtension(filename, IMAGE_EXTENSIONS)) {
throw new BadRequestException(`Invalid file type. Allowed: ${IMAGE_EXTENSIONS.join(', ')}`);
}
// Validate file size
if (!validateFileSize(buffer.length, MAX_AVATAR_SIZE)) {
throw new BadRequestException(
`File too large. Maximum size: ${MAX_AVATAR_SIZE / 1024 / 1024}MB`
);
}
// Generate unique key for avatar
const ext = filename.split('.').pop()?.toLowerCase() || 'jpg';
const key = `avatars/${userId}/${Date.now()}.${ext}`;
// Upload file
const result = await this.storage.upload(key, buffer, {
contentType: getContentType(filename),
public: true,
cacheControl: 'public, max-age=31536000', // 1 year cache
});
const url = result.url || (await this.getPublicUrl(key));
this.logger.log('Avatar uploaded', { userId, key });
return { url, key };
}
/**
* Delete avatar
*
* @param key - Storage key of the avatar
*/
async deleteAvatar(key: string): Promise<void> {
if (!this.storage) {
throw new BadRequestException('Storage service is not configured');
}
await this.storage.delete(key);
this.logger.log('Avatar deleted', { key });
}
/**
* Get public URL for a key
*/
private async getPublicUrl(key: string): Promise<string> {
if (!this.storage) {
throw new BadRequestException('Storage service is not configured');
}
// If we have a configured public URL, use it
if (this.publicUrl) {
return `${this.publicUrl}/${key}`;
}
// Check if the storage has a public URL configured
const publicUrl = this.storage.getPublicUrl(key);
if (publicUrl) {
return publicUrl;
}
// Otherwise, get a presigned URL for reading
return this.storage.getDownloadUrl(key, { expiresIn: 86400 * 365 }); // 1 year
}
}