diff --git a/docs/BACKEND_ARCHITECTURE.md b/docs/BACKEND_ARCHITECTURE.md new file mode 100644 index 000000000..dc899de9b --- /dev/null +++ b/docs/BACKEND_ARCHITECTURE.md @@ -0,0 +1,946 @@ +# Backend-Architektur im Manacore Monorepo + +Diese Dokumentation beschreibt die Backend-Implementierungen aller Projekte im Manacore Monorepo. + +## Übersicht + +Das Monorepo enthält 6 Hauptprojekte mit unterschiedlichen Backend-Architekturen: + +| Projekt | Backend-Typ | Datenbank | Status | +|---------|-------------|-----------|--------| +| **Maerchenzauber** | NestJS v10 | Supabase (PostgreSQL) | Aktiv | +| **Manadeck** | NestJS v11 | PostgreSQL + Drizzle ORM | Aktiv | +| **Uload** | NestJS v11 | PostgreSQL + Drizzle ORM | Aktiv | +| **Picture** | Kein Backend | - | Frontend-only | +| **Memoro** | Kein Backend | - | Frontend-only | +| **Manacore** | Kein Backend (extern) | - | Externes Backend | + +--- + +## 1. Maerchenzauber + +**Pfad:** `/maerchenzauber/apps/backend` + +**Zweck:** KI-gestützte Kindergeschichten-Generierung mit benutzerdefinierten Charakteren. + +### Technologie-Stack + +- **Framework:** NestJS 10.0.0 +- **Datenbank:** Supabase (PostgreSQL) +- **ORM:** `@supabase/supabase-js` v2.81.1 +- **AI-Services:** Azure OpenAI, Google Gemini, Replicate + +### Architektur + +``` +apps/backend/ +├── src/ +│ ├── character/ # Charakter-Modul +│ │ ├── character.controller.ts +│ │ ├── character.service.ts +│ │ └── character.repository.ts +│ ├── story/ # Story-Modul +│ │ ├── story.controller.ts +│ │ ├── story.service.ts +│ │ └── pipelines/ # Story-Generierung-Pipelines +│ ├── core/ # Kern-Services +│ │ └── services/ +│ │ └── prompting.service.ts +│ ├── settings/ # Benutzereinstellungen +│ ├── health/ # Health-Checks +│ └── feedback/ # Feedback-Modul +``` + +### Datenbank-Schema + +**Tabellen:** +- `characters` - Benutzercharaktere +- `stories` - Generierte Geschichten +- `story_collections` - Sammlungen von Geschichten +- `user_settings` - Benutzereinstellungen + +**Sicherheit:** Row-Level Security (RLS) für Datenzugriffskontrolle + +### Authentifizierung + +Mana Core Integration via `@mana-core/nestjs-integration`: + +```typescript +// Beispiel: Geschützter Endpoint +@UseGuards(AuthGuard) +@Get('characters') +async getCharacters(@CurrentUser() user: User) { + return this.characterService.findByUser(user.id); +} +``` + +**Auth-Endpoint:** `https://mana-core-middleware-111768794939.europe-west3.run.app` + +### AI-Services + +| Service | Verwendung | API | +|---------|------------|-----| +| Azure OpenAI (GPT-4) | Story-Generierung | `MAERCHENZAUBER_AZURE_OPENAI_ENDPOINT` | +| Google Gemini | Charakter-Generierung | `GOOGLE_GEMINI_API_KEY` | +| Replicate (Flux) | Bildgenerierung | `REPLICATE_API_TOKEN` | + +### File Storage + +- **Provider:** Supabase Storage +- **Bucket:** `maerchenzauber` +- **Verwendung:** Charakter- und Story-Bilder + +### Deployment + +- **Plattform:** Google Cloud Run +- **Region:** europe-west3 +- **URL:** `https://storyteller-backend-111768794939.europe-west3.run.app` +- **Port:** 3002 (Development) + +--- + +## 2. Manadeck + +**Pfad:** `/manadeck/apps/backend` + +**Zweck:** KI-gestützte Lernkarten-Generierung (Flashcards, Quizzes, Mixed). + +### Technologie-Stack + +- **Framework:** NestJS 11.0.1 +- **Datenbank:** PostgreSQL 16 +- **ORM:** Drizzle ORM +- **AI-Service:** Google Gemini API + +### Architektur + +``` +apps/backend/ +├── src/ +│ ├── api.controller.ts # Haupt-API-Endpoints +│ ├── public.controller.ts # Öffentliche Endpoints +│ ├── health.controller.ts # Health-Checks +│ ├── ai.service.ts # AI-Generierung +│ └── repositories/ +│ ├── deck.repository.ts +│ ├── card.repository.ts +│ ├── user-stats.repository.ts +│ └── deck-template.repository.ts +``` + +### Datenbank-Package + +Das Datenbank-Schema ist in einem separaten Package ausgelagert: + +**Pfad:** `/packages/manadeck-database` + +```typescript +// Verwendung im Backend +import { db, schema } from '@manacore/manadeck-database'; + +const decks = await db.query.decks.findMany({ + where: eq(schema.decks.userId, userId) +}); +``` + +**Drizzle-Konfiguration:** +```typescript +// drizzle.config.ts +export default { + schema: './src/schema/*', + out: './migrations', + driver: 'pg', + dbCredentials: { + connectionString: process.env.DATABASE_URL + } +}; +``` + +### Authentifizierung + +```typescript +import { AuthGuard, CurrentUser } from '@mana-core/nestjs-integration'; + +@Controller('api') +@UseGuards(AuthGuard) +export class ApiController { + @Post('decks') + async createDeck(@CurrentUser() user: User, @Body() dto: CreateDeckDto) { + // Credit-Prüfung und Deck-Erstellung + } +} +``` + +### Credit-System + +Integration mit Mana Core Credit Service: + +```typescript +import { CreditClientService } from '@mana-core/nestjs-integration'; + +@Injectable() +export class AiService { + constructor(private creditClient: CreditClientService) {} + + async generateDeck(userId: string, input: GenerateInput) { + // 1. Credit-Balance prüfen + const hasCredits = await this.creditClient.checkBalance(userId, 'DECK_CREATION'); + + // 2. Deck generieren + const deck = await this.generateWithGemini(input); + + // 3. Credits abziehen + await this.creditClient.deduct(userId, 'DECK_CREATION'); + + return deck; + } +} +``` + +### AI-Generierung + +**Unterstützte Kartentypen:** +- `text` - Textbasierte Karten +- `flashcard` - Klassische Lernkarten +- `quiz` - Multiple-Choice Quiz +- `mixed` - Gemischte Inhalte + +**Schwierigkeitsgrade:** +- `beginner` +- `intermediate` +- `advanced` + +### Docker-Setup + +```yaml +# docker-compose.yml (Lokale Entwicklung) +services: + postgres: + image: postgres:16 + ports: + - "5433:5432" + environment: + POSTGRES_DB: manadeck + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + + pgadmin: + image: dpage/pgadmin4 + ports: + - "5050:80" +``` + +### Deployment + +- **Docker Image:** Multi-stage Build (Node 18-alpine) +- **Port:** 8080 +- **Health-Check:** `/health` + +--- + +## 3. Uload + +**Pfad:** `/uload/apps/backend` + +**Zweck:** URL-Shortener mit Link-Analytics. + +### Technologie-Stack + +- **Framework:** NestJS 11.0.1 +- **Datenbank:** PostgreSQL 16 +- **ORM:** Drizzle ORM +- **Cache:** Redis (optional) + +### Architektur + +``` +uload/apps/backend/ +├── src/ +│ ├── main.ts +│ ├── app.module.ts +│ ├── config/ +│ │ └── validation.schema.ts +│ ├── controllers/ +│ │ ├── redirect.controller.ts # GET /:code (public redirect) +│ │ ├── links.controller.ts # CRUD /api/links +│ │ ├── analytics.controller.ts # GET /api/analytics +│ │ └── health.controller.ts +│ ├── services/ +│ │ ├── links.service.ts +│ │ ├── redirect.service.ts +│ │ └── analytics.service.ts +│ └── database/ +│ ├── database.module.ts +│ └── repositories/ +│ ├── link.repository.ts +│ └── click.repository.ts +├── Dockerfile +└── package.json +``` + +### Datenbank-Package + +**Pfad:** `/packages/uload-database` + +```typescript +// Verwendung im Backend +import { db, links, clicks, eq, desc } from '@manacore/uload-database'; + +const userLinks = await db.query.links.findMany({ + where: eq(links.userId, userId), + orderBy: desc(links.createdAt) +}); +``` + +### API Endpoints + +| Endpoint | Method | Auth | Beschreibung | +|----------|--------|------|--------------| +| `/:code` | GET | Public | Redirect zu Original-URL | +| `/api/links` | GET | Protected | Liste aller Links | +| `/api/links` | POST | Protected | Link erstellen | +| `/api/links/:id` | GET | Protected | Link Details | +| `/api/links/:id` | PATCH | Protected | Link aktualisieren | +| `/api/links/:id` | DELETE | Protected | Link löschen | +| `/api/analytics/:linkId` | GET | Protected | Link-Statistiken | +| `/health` | GET | Public | Health Check | + +### Authentifizierung + +Mana Core Integration via `@mana-core/nestjs-integration`: + +```typescript +import { AuthGuard, CurrentUser } from '@mana-core/nestjs-integration'; + +@Controller('api/links') +@UseGuards(AuthGuard) +export class LinksController { + @Get() + async getLinks(@CurrentUser() user: any) { + return this.linksService.getLinks(user.sub); + } +} +``` + +### Deployment + +- **Docker Image:** Multi-stage Build (Node 20-alpine) +- **Port:** 3003 +- **Health-Check:** `/health` + +--- + +## 4. Picture + +**Pfad:** `/picture` + +**Zweck:** Bild- und Medienverwaltung. + +### Architektur + +**Kein dediziertes Backend.** Picture verwendet: + +- SvelteKit Server-Routes für Backend-Logik +- Mana Core für Authentifizierung +- Shared Packages aus `/packages` + +``` +picture/ +├── apps/ +│ ├── mobile/ # React Native Expo +│ ├── web/ # SvelteKit +│ └── landing/ # Astro +└── packages/ + ├── design-tokens/ # Design System + ├── mobile-ui/ # Mobile UI Components + └── shared/ # Utilities +``` + +--- + +## 5. Memoro + +**Pfad:** `/memoro` + +**Zweck:** Legacy-Content und Memory-Preservation. + +### Architektur + +**Kein dediziertes Backend.** Memoro verwendet: + +- SvelteKit Server-Routes +- Mana Core für Authentifizierung +- Supabase (Legacy-Konfiguration vorhanden) + +``` +memoro/ +├── apps/ +│ ├── mobile/ +│ ├── web/ +│ └── landing/ +└── supabase/ # Legacy Supabase Config +``` + +--- + +## 6. Manacore + +**Pfad:** `/manacore` + +**Zweck:** Core-Authentifizierung und Credit-System. + +### Architektur + +Das Manacore-Backend ist **extern gehostet** und nicht Teil des Monorepos: + +- **URL:** `https://mana-core-middleware-111768794939.europe-west3.run.app` +- **Integration:** Via `@mana-core/nestjs-integration` Package + +``` +manacore/ +├── apps/ +│ ├── mobile/ # Auth-Flow UI +│ ├── web/ # Dashboard +│ └── landing/ # Marketing +``` + +--- + +## Shared Packages für Backend + +### @manacore/manadeck-database + +PostgreSQL-Datenbankschema für Manadeck. + +``` +packages/manadeck-database/ +├── src/ +│ ├── schema/ # Drizzle Schema +│ ├── client.ts # DB Client +│ └── index.ts # Exports +├── drizzle.config.ts +└── docker-compose.yml +``` + +### @manacore/uload-database + +PostgreSQL-Datenbankschema für Uload URL-Shortener. + +``` +packages/uload-database/ +├── src/ +│ ├── schema/ +│ │ ├── users.ts +│ │ ├── links.ts +│ │ ├── clicks.ts +│ │ ├── tags.ts +│ │ ├── workspaces.ts +│ │ ├── accounts.ts +│ │ └── relations.ts +│ ├── client.ts # DB Client +│ └── index.ts # Exports +├── drizzle.config.ts +└── docker-compose.yml +``` + +### @mana-core/nestjs-integration + +Externe Dependency für Backend-Integration: + +```typescript +// Installation via git +"@mana-core/nestjs-integration": "git+https://github.com/mana-core/nestjs-integration.git" +``` + +**Bereitgestellte Features:** +- `AuthGuard` - JWT-Authentifizierung +- `@CurrentUser()` - User-Context Decorator +- `CreditClientService` - Credit-Operationen +- Konfigurationsmodule + +--- + +## Authentifizierungs-Pattern + +Alle Projekte nutzen zentrale **Mana Core Authentifizierung**: + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Frontend │────▶│ Project Backend │────▶│ Mana Core │ +│ (Web/Mobile) │ │ (NestJS/etc.) │ │ Middleware │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ + │ │ │ + ▼ ▼ ▼ + shared-auth AuthGuard JWT Validation + shared-auth-ui @CurrentUser Credit Service + shared-auth-stores CreditClient User Management +``` + +### Frontend-Integration + +```typescript +// Shared Auth Store (Svelte) +import { authStore } from '@manacore/shared-auth-stores'; + +// Login +await authStore.login(email, password); + +// Token für API-Requests +const token = authStore.getAccessToken(); +``` + +### Backend-Integration + +```typescript +// NestJS Module Setup +@Module({ + imports: [ + ManaCoreModule.forRoot({ + serviceKey: process.env.MANA_CORE_SERVICE_KEY, + baseUrl: process.env.MANA_CORE_URL, + }), + ], +}) +export class AppModule {} +``` + +--- + +## Datenbank-Migrationen + +### Manadeck (Drizzle) + +```bash +# Migration generieren +pnpm --filter @manacore/manadeck-database drizzle-kit generate + +# Migration ausführen +pnpm --filter @manacore/manadeck-database drizzle-kit push +``` + +### Maerchenzauber (Supabase) + +```bash +# Supabase CLI +supabase migration new +supabase db push +``` + +--- + +## Umgebungsvariablen + +### Maerchenzauber Backend + +```env +# Supabase +SUPABASE_URL= +SUPABASE_SERVICE_ROLE_KEY= + +# Mana Core +MANA_CORE_URL=https://mana-core-middleware-111768794939.europe-west3.run.app +MANA_CORE_SERVICE_KEY= + +# AI Services +MAERCHENZAUBER_AZURE_OPENAI_ENDPOINT= +AZURE_OPENAI_API_KEY= +GOOGLE_GEMINI_API_KEY= +REPLICATE_API_TOKEN= +``` + +### Manadeck Backend + +```env +# Database +DATABASE_URL=postgresql://postgres:postgres@localhost:5433/manadeck + +# Mana Core +MANA_CORE_URL= +MANA_CORE_SERVICE_KEY= + +# AI +GOOGLE_GEMINI_API_KEY= + +# Server +PORT=8080 +``` + +### Uload + +```env +# Database +DATABASE_URL=postgresql://... + +# Redis +REDIS_URL=redis://localhost:6379 + +# PocketBase +POCKETBASE_URL= +``` + +--- + +## Lokale Entwicklung + +### Maerchenzauber Backend + +```bash +cd maerchenzauber/apps/backend +pnpm install +pnpm run start:dev +# Läuft auf Port 3002 +``` + +### Manadeck Backend + +```bash +# 1. Datenbank starten +cd packages/manadeck-database +docker-compose up -d + +# 2. Backend starten +cd manadeck/apps/backend +pnpm install +pnpm run start:dev +# Läuft auf Port 8080 +``` + +### Uload + +```bash +cd uload +docker-compose up -d # PostgreSQL + Redis +pnpm install +pnpm run dev +``` + +--- + +## Zusammenfassung + +Das Manacore Monorepo verwendet verschiedene Backend-Strategien: + +1. **Full Backend (NestJS):** Maerchenzauber, Manadeck - Für komplexe Geschäftslogik und AI-Integration +2. **Embedded Database (PocketBase):** Uload - Für einfache CRUD-Operationen +3. **Frontend-only:** Picture, Memoro - Server-Routes in SvelteKit +4. **External Backend:** Manacore - Zentrale Auth/Credit-Services + +Alle Projekte teilen sich: +- Gemeinsame Authentifizierung via Mana Core +- Shared Packages für UI, Auth, Types +- Einheitliches Deployment-Pattern (Docker + Cloud Run) + +--- + +## Vereinheitlichungs-Roadmap + +### Aktuelle Fragmentierung + +| Aspekt | Maerchenzauber | Manadeck | Uload | +|--------|----------------|----------|-------| +| Framework | NestJS v10 | NestJS v11 | PocketBase | +| Datenbank | Supabase | PostgreSQL | PocketBase + PG | +| ORM | @supabase/js | Drizzle | Drizzle | +| Auth | Mana Core | Mana Core | PocketBase + Mana Core | + +--- + +### Strategie 1: Shared NestJS Backend Package + +**Ziel:** Ein gemeinsames `@manacore/shared-backend` Package mit wiederverwendbaren Modulen. + +``` +packages/shared-backend/ +├── src/ +│ ├── auth/ +│ │ ├── auth.module.ts +│ │ ├── auth.guard.ts +│ │ └── current-user.decorator.ts +│ ├── database/ +│ │ ├── database.module.ts +│ │ ├── drizzle.provider.ts +│ │ └── base.repository.ts +│ ├── health/ +│ │ └── health.module.ts +│ ├── credits/ +│ │ └── credits.module.ts +│ └── common/ +│ ├── filters/ +│ ├── interceptors/ +│ └── pipes/ +``` + +**Vorteile:** +- Einheitliche Auth-Integration +- Wiederverwendbare Module +- Konsistente Error-Handling + +**Aufwand:** Mittel + +--- + +### Strategie 2: Einheitliche Datenbank-Strategie + +#### Option A: Alles auf Drizzle + PostgreSQL (Empfohlen) + +```typescript +// packages/shared-database/src/base-schema.ts +export const baseColumns = { + id: uuid('id').primaryKey().defaultRandom(), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at').defaultNow(), + userId: text('user_id').notNull(), +}; + +// Projekt-spezifische Erweiterung +// maerchenzauber/database/schema/characters.ts +import { baseColumns } from '@manacore/shared-database'; + +export const characters = pgTable('characters', { + ...baseColumns, + name: text('name').notNull(), + traits: jsonb('traits'), +}); +``` + +**Migration von Supabase:** +- Supabase ist PostgreSQL → Schema kann übernommen werden +- RLS-Policies in Application-Layer verschieben +- Storage → S3/Cloudflare R2 + +#### Option B: Alles auf Supabase + +```typescript +// packages/shared-supabase/src/client.ts +export const createProjectClient = (project: 'maerchenzauber' | 'manadeck' | 'uload') => { + return createClient( + process.env[`${project.toUpperCase()}_SUPABASE_URL`], + process.env[`${project.toUpperCase()}_SUPABASE_KEY`] + ); +}; +``` + +**Vorteile Supabase:** +- Eingebaute Auth (optional nutzbar) +- Storage inklusive +- Realtime-Subscriptions +- Edge Functions möglich + +**Nachteile Supabase:** +- Vendor Lock-in +- Weniger Kontrolle über Schema + +**Empfehlung:** Drizzle + PostgreSQL wegen Type-Safety, moderner API und keinem Vendor Lock-in. + +--- + +### Strategie 3: Einheitliche Monorepo Backend Struktur + +**Ziel-Architektur:** + +``` +packages/ +├── shared-backend/ # Gemeinsame NestJS Module +│ ├── auth/ +│ ├── database/ +│ ├── health/ +│ └── credits/ +├── shared-database/ # Drizzle Basis-Schema +│ ├── base-schema.ts +│ ├── migrations/ +│ └── client.ts +├── maerchenzauber-database/ # Projekt-Schema +├── manadeck-database/ # ✓ Existiert bereits +└── uload-database/ # Neu + +apps/ +├── maerchenzauber-backend/ # Nutzt shared-backend +├── manadeck-backend/ # Nutzt shared-backend +└── uload-backend/ # Neues NestJS Backend (ersetzt PocketBase) +``` + +--- + +### Strategie 4: Shared Backend als Service-Layer + +**Ziel:** Gemeinsamer Service-Layer, projekt-spezifische Controller. + +```typescript +// packages/shared-backend/src/services/ai.service.ts +@Injectable() +export class BaseAiService { + constructor( + private gemini: GeminiClient, + private credits: CreditService, + ) {} + + protected async generateWithCredits( + userId: string, + operation: string, + generator: () => Promise + ): Promise { + await this.credits.check(userId, operation); + const result = await generator(); + await this.credits.deduct(userId, operation); + return result; + } +} + +// maerchenzauber/backend/src/story/story.service.ts +@Injectable() +export class StoryService extends BaseAiService { + async generateStory(userId: string, input: StoryInput) { + return this.generateWithCredits(userId, 'STORY_GENERATION', async () => { + // Projekt-spezifische Logik + }); + } +} +``` + +--- + +### Strategie 5: API-Gateway Pattern (Optional) + +**Ziel:** Ein zentrales Gateway vor allen Backends. + +``` + ┌─────────────────┐ + │ API Gateway │ + │ (Kong/Traefik) │ + └────────┬────────┘ + │ + ┌────────────────────┼────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ Maerchenzauber│ │ Manadeck │ │ Uload │ +│ Backend │ │ Backend │ │ Backend │ +└───────────────┘ └───────────────┘ └───────────────┘ +``` + +**Vorteile:** +- Zentrale Auth-Validierung +- Rate Limiting +- Request Logging +- Einheitliche API-Struktur + +**Aufwand:** Hoch - Empfohlen erst bei Skalierungsbedarf + +--- + +### Empfohlene Implementierungsreihenfolge + +#### Phase 1: Shared Backend Package + +**Priorität:** Hoch +**Aufwand:** 2-3 Wochen + +Neues Package `packages/shared-backend/` mit: +- Auth Module (wraps @mana-core/nestjs-integration) +- Health Module +- Credits Module +- Base Repository Pattern +- Common Decorators, Guards, Filters + +#### Phase 2: Datenbank-Vereinheitlichung + +**Priorität:** Hoch +**Aufwand:** 3-4 Wochen + +1. `packages/shared-database/` mit Drizzle Basis-Schema erstellen +2. Maerchenzauber von Supabase auf Drizzle migrieren +3. Uload PocketBase durch PostgreSQL + Drizzle ersetzen + +#### Phase 3: Uload Backend Neubau (Optional) + +**Priorität:** Mittel +**Aufwand:** 2-3 Wochen + +PocketBase → NestJS Migration: +- Konsistenz mit anderen Projekten +- Bessere Integration mit Mana Core +- Einheitliches Deployment + +--- + +### Optionen-Vergleich + +| Option | Aufwand | Benefit | Empfehlung | +|--------|---------|---------|------------| +| Shared Backend Package | Mittel | Hoch | ✅ Priorität 1 | +| Drizzle überall | Mittel-Hoch | Hoch | ✅ Priorität 2 | +| Uload auf NestJS | Hoch | Mittel | ⚡ Optional | +| API Gateway | Sehr Hoch | Mittel | ⏳ Später | + +--- + +### Quick Wins (sofort umsetzbar) + +1. **NestJS Version angleichen** → Alle auf v11 +2. **Einheitliche Health-Endpoints** → `/health`, `/health/ready` +3. **Gemeinsame ESLint/Prettier Config** → `@manacore/eslint-config-backend` +4. **Einheitliche Error-Response-Struktur:** + +```typescript +// Einheitliches Error-Format für alle Backends +interface ApiError { + statusCode: number; + error: string; + message: string; + timestamp: string; + path: string; +} +``` + +5. **Einheitliche Logging-Struktur:** + +```typescript +// packages/shared-backend/src/logging/logger.service.ts +@Injectable() +export class AppLogger { + log(context: string, message: string, meta?: Record) { + console.log(JSON.stringify({ + level: 'info', + context, + message, + timestamp: new Date().toISOString(), + ...meta, + })); + } +} +``` + +--- + +### Ziel-Architektur nach Vereinheitlichung + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Shared Packages │ +├─────────────────┬─────────────────┬─────────────────────────┤ +│ shared-backend │ shared-database │ shared-types │ +│ - AuthModule │ - baseColumns │ - ApiError │ +│ - HealthModule │ - drizzleClient │ - User │ +│ - CreditsModule │ - migrations │ - CreditOperation │ +│ - BaseRepo │ │ │ +└────────┬────────┴────────┬────────┴────────┬────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Maerchenzauber │ │ Manadeck │ │ Uload │ +│ Backend │ │ Backend │ │ Backend │ +├─────────────────┤ ├─────────────────┤ ├─────────────────┤ +│ NestJS v11 │ │ NestJS v11 │ │ NestJS v11 │ +│ PostgreSQL │ │ PostgreSQL │ │ PostgreSQL │ +│ Drizzle ORM │ │ Drizzle ORM │ │ Drizzle ORM │ +│ Port: 3002 │ │ Port: 8080 │ │ Port: 3003 │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ + └─────────────────┼─────────────────┘ + ▼ + ┌─────────────────────┐ + │ Mana Core │ + │ (Auth + Credits) │ + └─────────────────────┘ +``` diff --git a/docs/I18N.md b/docs/I18N.md new file mode 100644 index 000000000..487d3f2fc --- /dev/null +++ b/docs/I18N.md @@ -0,0 +1,226 @@ +# Internationalization (i18n) im Manacore Monorepo + +Alle Web-Projekte im Monorepo verwenden **svelte-i18n** für die Internationalisierung. Diese Dokumentation beschreibt die einheitliche Implementierung. + +## Übersicht + +| Projekt | Sprachen | Default | +|---------|----------|---------| +| maerchenzauber | de, en, es, fr, it | de | +| manacore | de, en, es, fr, it | de | +| manadeck | de, en, es, fr, it | de | +| memoro | de, en, es, fr, it | de | +| picture | de, en | de | +| uload | de, en, es, fr, it | en | + +## Projektstruktur + +Jedes Web-Projekt hat die folgende i18n-Struktur: + +``` +apps/web/ +└── src/ + └── lib/ + └── i18n/ + ├── index.ts # Initialisierung & Konfiguration + └── locales/ + ├── de.json # Deutsche Übersetzungen + ├── en.json # Englische Übersetzungen + ├── es.json # Spanische Übersetzungen (optional) + ├── fr.json # Französische Übersetzungen (optional) + └── it.json # Italienische Übersetzungen (optional) +``` + +## Implementierung + +### 1. index.ts - Konfiguration + +```typescript +import { browser } from '$app/environment'; +import { init, register, locale, waitLocale } from 'svelte-i18n'; + +// Sprachen registrieren +register('de', () => import('./locales/de.json')); +register('en', () => import('./locales/en.json')); +// ... weitere Sprachen + +// Unterstützte Sprachen +export const supportedLocales = ['de', 'en', 'es', 'fr', 'it'] as const; +export type SupportedLocale = (typeof supportedLocales)[number]; + +const defaultLocale = 'de'; + +// Initiale Sprache ermitteln +function getInitialLocale(): SupportedLocale { + if (browser) { + // 1. localStorage prüfen + const stored = localStorage.getItem('locale'); + if (stored && supportedLocales.includes(stored as SupportedLocale)) { + return stored as SupportedLocale; + } + + // 2. Browser-Sprache prüfen + const browserLang = navigator.language.split('-')[0]; + if (supportedLocales.includes(browserLang as SupportedLocale)) { + return browserLang as SupportedLocale; + } + } + return defaultLocale; +} + +// i18n initialisieren +init({ + fallbackLocale: defaultLocale, + initialLocale: getInitialLocale() +}); + +// Sprache ändern und speichern +export function setLocale(newLocale: SupportedLocale) { + locale.set(newLocale); + if (browser) { + localStorage.setItem('locale', newLocale); + } +} + +export { waitLocale }; +``` + +### 2. Locale-Dateien (JSON) + +Die Übersetzungen werden als flache oder verschachtelte JSON-Objekte gespeichert: + +```json +{ + "nav_login": "Anmelden", + "nav_register": "Registrieren", + "common": { + "save": "Speichern", + "cancel": "Abbrechen" + } +} +``` + +### 3. Verwendung in Svelte-Komponenten + +```svelte + + + + + + + + + +

{$t('welcome', { values: { name: 'Max' } })}

+``` + +### 4. Language Switcher + +```svelte + + +{#each languages as lang} + +{/each} +``` + +## Neues Projekt einrichten + +1. **svelte-i18n installieren:** + ```bash + pnpm add svelte-i18n + ``` + +2. **i18n-Ordner erstellen:** + ```bash + mkdir -p src/lib/i18n/locales + ``` + +3. **index.ts kopieren** von einem bestehenden Projekt und anpassen + +4. **Locale-Dateien erstellen** (de.json, en.json, etc.) + +5. **In +layout.svelte importieren:** + ```svelte + + ``` + +## Neue Übersetzung hinzufügen + +1. Key in allen Locale-Dateien hinzufügen: + ```json + // de.json + { "new_key": "Neue Übersetzung" } + + // en.json + { "new_key": "New translation" } + ``` + +2. In Komponente verwenden: + ```svelte + {$t('new_key')} + ``` + +## Best Practices + +- **Keys:** Snake_case verwenden (`nav_login`, `home_title`) +- **Namespacing:** Präfixe für Bereiche (`auth_`, `nav_`, `home_`, `toast_`) +- **Fallback:** Immer `fallbackLocale` setzen +- **SSR:** `waitLocale()` in Server-Load-Funktionen verwenden +- **Konsistenz:** Gleiche Keys in allen Projekten für gemeinsame Elemente + +## Fehlerbehebung + +### Übersetzung wird nicht angezeigt + +1. Prüfen ob Key in allen Locale-Dateien existiert +2. Prüfen ob `$lib/i18n` importiert wurde +3. Browser-Cache leeren + +### SSR-Fehler + +```typescript +// In +layout.ts oder +page.ts +import { waitLocale } from '$lib/i18n'; + +export const load = async () => { + await waitLocale(); + return {}; +}; +``` + +### Sprache wechselt nicht + +Prüfen ob `locale.set()` aufgerufen wird und localStorage Zugriff erlaubt ist. + +## Shared i18n Package + +Für gemeinsame Übersetzungen zwischen Projekten kann das Package `@manacore/shared-i18n` verwendet werden (wenn vorhanden). + +```typescript +// In index.ts +import sharedTranslations from '@manacore/shared-i18n/de.json'; + +register('de', async () => { + const local = await import('./locales/de.json'); + return { ...sharedTranslations, ...local.default }; +}); +``` diff --git a/docs/SELF-HOSTING-GUIDE.md b/docs/SELF-HOSTING-GUIDE.md new file mode 100644 index 000000000..c8c57cdd4 --- /dev/null +++ b/docs/SELF-HOSTING-GUIDE.md @@ -0,0 +1,684 @@ +# Self-Hosting Guide - Manacore Monorepo + +Komplette Anleitung zum Hosten aller Projekte auf eigener Infrastruktur (VPS). + +## Projektübersicht + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ MANACORE MONOREPO │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ MAERCHENZAUBER│ │ MANACORE │ │ MANADECK │ │ MEMORO │ │ +│ │ (Storyteller)│ │ (Auth Hub) │ │ (Deck App) │ │ (Voice App) │ │ +│ ├──────────────┤ ├──────────────┤ ├──────────────┤ ├──────────────┤ │ +│ │ • Web │ │ • Web │ │ • Web │ │ • Web │ │ +│ │ • Mobile │ │ • Mobile │ │ • Mobile │ │ • Mobile │ │ +│ │ • Landing │ │ • Landing │ │ • Landing │ │ • Landing │ │ +│ │ • Backend │ │ │ │ • Backend │ │ │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ PICTURE │ │ ULOAD │ │ +│ │ (Canvas App) │ │(URL Shortener)│ │ +│ ├──────────────┤ ├──────────────┤ │ +│ │ • Web │ │ • Web │ │ +│ │ • Mobile │ │ │ │ +│ │ • Landing │ │ │ │ +│ └──────────────┘ └──────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Technologie-Stack pro Projekt + +| Projekt | Web App | Landing | Backend | Mobile | Datenbank | +|---------|---------|---------|---------|--------|-----------| +| **Maerchenzauber** | SvelteKit | Astro | NestJS | Expo | Supabase | +| **Manacore** | SvelteKit | Astro | - | Expo | Supabase | +| **Manadeck** | SvelteKit | Astro | NestJS | Expo | PostgreSQL | +| **Memoro** | SvelteKit | Astro | - | Expo | Supabase | +| **Picture** | SvelteKit | Astro | - | Expo | Supabase | +| **uLoad** | SvelteKit | - | - | - | PostgreSQL + Redis | + +--- + +## Deployment-Optionen im Überblick + +### Option A: Single VPS mit Coolify (Empfohlen für Start) +- **Kosten:** ~€15-30/Monat +- **Komplexität:** Niedrig +- **Skalierung:** Begrenzt + +### Option B: Multi-VPS mit Coolify +- **Kosten:** ~€50-100/Monat +- **Komplexität:** Mittel +- **Skalierung:** Gut + +### Option C: Kubernetes (K3s) +- **Kosten:** ~€30-80/Monat +- **Komplexität:** Hoch +- **Skalierung:** Sehr gut + +### Option D: Hybrid (Self-Hosted + Managed) +- **Kosten:** ~€20-50/Monat + Supabase +- **Komplexität:** Niedrig-Mittel +- **Skalierung:** Flexibel + +--- + +# Option A: Single VPS mit Coolify + +Die einfachste Lösung für den Start. Alle Services auf einem Server. + +## Architektur + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Hetzner VPS (CX31+) │ +│ 4 vCPU, 8GB RAM, 80GB │ +├─────────────────────────────────────────────────────────────────┤ +│ COOLIFY │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ TRAEFIK │ │ +│ │ (Reverse Proxy + SSL) │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ │ │ │ +│ ┌──────┴──────┐ ┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐ │ +│ │ Web Apps │ │ Backends │ │ Databases │ │ Landing │ │ +│ │ (Node.js) │ │ (NestJS) │ │ (PG+Redis)│ │ (Astro) │ │ +│ │ :3000-3005 │ │ :4000-4001│ │ :5432,6379│ │ :8080+ │ │ +│ └─────────────┘ └───────────┘ └───────────┘ └───────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Ressourcen-Anforderungen + +| Komponente | RAM | CPU | Disk | +|------------|-----|-----|------| +| PostgreSQL | 1GB | 0.5 | 10GB | +| Redis | 256MB | 0.2 | 1GB | +| Coolify | 512MB | 0.3 | 5GB | +| Traefik | 128MB | 0.1 | - | +| Pro Web App | 256MB | 0.3 | - | +| Pro Backend | 512MB | 0.5 | - | +| Pro Landing | 64MB | 0.1 | - | +| **Gesamt (alle)** | **~6GB** | **~4** | **~30GB** | + +**Empfohlener Server:** Hetzner CX31 (4 vCPU, 8GB RAM, 80GB) - €8.98/Monat + +## Schritt-für-Schritt Setup + +### 1. VPS bestellen und Coolify installieren + +```bash +# SSH zum Server +ssh root@YOUR-IP + +# System updaten +apt update && apt upgrade -y + +# Coolify installieren +curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash +``` + +### 2. Datenbank-Services erstellen + +**PostgreSQL:** +``` +Name: shared-postgres +Version: 16-alpine +Databases: manacore, manadeck, uload +``` + +**Redis:** +``` +Name: shared-redis +Version: 7-alpine +``` + +### 3. Projekte deployen + +#### Deployment-Reihenfolge (wichtig!) + +1. **Datenbanken** (PostgreSQL, Redis) +2. **Backends** (Maerchenzauber, Manadeck) +3. **Web Apps** (alle) +4. **Landing Pages** (alle) + +#### Konfiguration pro Projekt + +**Alle SvelteKit Web Apps:** +``` +Base Directory: / +Dockerfile: {projekt}/apps/web/Dockerfile # Falls vorhanden +oder +Build Pack: Nixpacks +Build Command: cd {projekt}/apps/web && pnpm build +Start Command: cd {projekt}/apps/web && node build +Port: 3000 +``` + +**Alle Astro Landing Pages:** +``` +Build Pack: Static +Base Directory: {projekt}/apps/landing +Build Command: pnpm build +Publish Directory: dist +``` + +**NestJS Backends:** +``` +Base Directory: / +Dockerfile: {projekt}/apps/backend/Dockerfile +oder +Dockerfile: {projekt}/backend/Dockerfile +Port: 4000 +``` + +### 4. Domain-Mapping + +| Service | Domain | Port | +|---------|--------|------| +| uload-web | ulo.ad | 3000 | +| maerchenzauber-web | app.maerchenzauber.de | 3001 | +| maerchenzauber-landing | maerchenzauber.de | 8080 | +| maerchenzauber-backend | api.maerchenzauber.de | 4000 | +| manacore-web | app.manacore.io | 3002 | +| manacore-landing | manacore.io | 8081 | +| manadeck-web | app.manadeck.de | 3003 | +| manadeck-landing | manadeck.de | 8082 | +| manadeck-backend | api.manadeck.de | 4001 | +| memoro-web | app.memoro.ai | 3004 | +| memoro-landing | memoro.ai | 8083 | +| picture-web | app.picture.io | 3005 | +| picture-landing | picture.io | 8084 | + +--- + +# Option B: Multi-VPS mit Coolify + +Bessere Isolation und Skalierung durch mehrere Server. + +## Architektur + +``` + ┌─────────────────┐ + │ DNS / CDN │ + │ (Cloudflare) │ + └────────┬────────┘ + │ + ┌───────────────────────────┼───────────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ VPS 1 │ │ VPS 2 │ │ VPS 3 │ +│ (Apps) │ │ (Backends) │ │ (Databases) │ +│ CX21 │ │ CX21 │ │ CX31 │ +├───────────────┤ ├───────────────┤ ├───────────────┤ +│ • Web Apps │ ◄─────► │ • NestJS APIs │ ◄─────► │ • PostgreSQL │ +│ • Landing │ │ • Workers │ │ • Redis │ +│ Pages │ │ │ │ • Backups │ +└───────────────┘ └───────────────┘ └───────────────┘ +``` + +## Server-Aufteilung + +### VPS 1: Frontend (CX21 - €4.49/Monat) +- Alle SvelteKit Web Apps +- Alle Astro Landing Pages +- Traefik Reverse Proxy + +### VPS 2: Backends (CX21 - €4.49/Monat) +- Maerchenzauber NestJS Backend +- Manadeck NestJS Backend +- Background Workers + +### VPS 3: Datenbanken (CX31 - €8.98/Monat) +- PostgreSQL (shared) +- Redis (shared) +- Automated Backups + +**Gesamtkosten:** ~€18/Monat + +## Einrichtung + +### VPS 3 (Datenbanken) zuerst + +```bash +# Coolify installieren +curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash + +# PostgreSQL mit externem Zugriff +# In Coolify: Network → Enable External Access +``` + +### VPS 2 (Backends) + +```bash +# Coolify installieren +# Backends deployen mit DATABASE_URL zu VPS 3 +``` + +### VPS 1 (Frontends) + +```bash +# Coolify installieren +# Web Apps deployen mit API_URL zu VPS 2 +``` + +## Netzwerk-Sicherheit + +```bash +# Auf VPS 3 (Datenbanken): Nur VPS 1+2 erlauben +ufw allow from VPS1-IP to any port 5432 +ufw allow from VPS2-IP to any port 5432 +ufw allow from VPS1-IP to any port 6379 +ufw allow from VPS2-IP to any port 6379 +ufw deny 5432 +ufw deny 6379 +``` + +--- + +# Option C: Kubernetes mit K3s + +Für maximale Skalierung und Automatisierung. + +## Architektur + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ K3s Cluster │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ INGRESS (Traefik) │ │ +│ └────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌───────────────────────────┼───────────────────────────────┐ │ +│ │ │ │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ +│ │ │ Namespace │ │ Namespace │ │ Namespace │ │ │ +│ │ │ uload │ │ maerchen- │ │ manadeck │ │ │ +│ │ │ │ │ zauber │ │ │ │ │ +│ │ │ ┌─────────┐ │ │ ┌─────────┐ │ │ ┌─────────┐ │ │ │ +│ │ │ │ web:3 │ │ │ │ web:2 │ │ │ │ web:2 │ │ │ │ +│ │ │ │ replicas│ │ │ │ backend │ │ │ │ backend │ │ │ │ +│ │ │ └─────────┘ │ │ │ landing │ │ │ │ landing │ │ │ │ +│ │ └─────────────┘ │ └─────────┘ │ │ └─────────┘ │ │ │ +│ │ └─────────────┘ └─────────────┘ │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────┐ │ │ +│ │ │ Shared Services │ │ │ +│ │ │ PostgreSQL (StatefulSet) │ Redis (StatefulSet) │ │ │ +│ │ └─────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ │ +│ Node 1 (CX21) Node 2 (CX21) Node 3 (CX21) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## K3s Setup + +### Master Node installieren + +```bash +# Auf Node 1 +curl -sfL https://get.k3s.io | sh - + +# Token für Worker holen +cat /var/lib/rancher/k3s/server/node-token +``` + +### Worker Nodes hinzufügen + +```bash +# Auf Node 2 und 3 +curl -sfL https://get.k3s.io | K3S_URL=https://NODE1-IP:6443 K3S_TOKEN=TOKEN sh - +``` + +### Helm Charts deployen + +```yaml +# values-uload.yaml +replicaCount: 2 +image: + repository: ghcr.io/your-org/uload-web + tag: latest +service: + port: 3000 +ingress: + enabled: true + hosts: + - host: ulo.ad + paths: + - path: / +env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: db-credentials + key: url +``` + +```bash +helm install uload ./charts/sveltekit -f values-uload.yaml +``` + +## Vorteile K8s + +- Auto-Scaling bei Last +- Rolling Updates ohne Downtime +- Self-Healing bei Ausfällen +- Resource Limits pro App + +## Nachteile K8s + +- Höhere Komplexität +- Mehr Overhead (RAM für K8s selbst) +- Lernkurve + +--- + +# Option D: Hybrid (Self-Hosted + Managed) + +Kombination aus Self-Hosting und Managed Services für beste Balance. + +## Architektur + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ MANAGED SERVICES │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ SUPABASE │ │ CLOUDFLARE │ │ VERCEL/ │ │ +│ │ (Database) │ │ (CDN) │ │ NETLIFY │ │ +│ │ PostgreSQL │ │ DNS, Cache │ │ (Landing) │ │ +│ │ Auth, Store │ │ DDoS Prot. │ │ Static │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ │ │ │ +└─────────┼──────────────────┼──────────────────┼──────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ SELF-HOSTED (VPS) │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Hetzner CX21 │ │ +│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ +│ │ │ Web Apps │ │ Backends │ │ uLoad │ │ │ +│ │ │ (SvelteKit)│ │ (NestJS) │ │ + DB │ │ │ +│ │ └────────────┘ └────────────┘ └────────────┘ │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Was wo hosten? + +### Managed Services (empfohlen) + +| Service | Anbieter | Kosten | Grund | +|---------|----------|--------|-------| +| Datenbank | Supabase | Free-$25/M | Auth + Realtime inklusive | +| Landing Pages | Vercel/Netlify | Free | CDN + Edge | +| CDN | Cloudflare | Free | DDoS + Caching | +| Email | Resend | Free-$20/M | Deliverability | +| Payments | Stripe | % per Tx | Compliance | + +### Self-Hosted (VPS) + +| Service | Grund | +|---------|-------| +| Web Apps | Volle Kontrolle, günstiger bei Traffic | +| Backends | Custom Code, API Keys | +| uLoad | Komplett eigene Infra gewünscht | +| Redis | Falls benötigt | + +## Setup + +### 1. Supabase Projekt erstellen + +Für: Maerchenzauber, Manacore, Memoro, Picture + +```bash +# Supabase CLI +supabase init +supabase db push +``` + +### 2. Landing Pages auf Vercel + +```bash +# In jedem Landing-Projekt +cd maerchenzauber/apps/landing +vercel deploy --prod +``` + +### 3. VPS für Web Apps + Backends + +```bash +# Coolify auf Hetzner CX21 +curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash + +# Apps deployen mit Supabase URLs +``` + +## Kosten-Vergleich + +| Komponente | Full Self-Hosted | Hybrid | +|------------|------------------|--------| +| VPS | €9-18/Monat | €4.50/Monat | +| Supabase | - | Free-€25/Monat | +| Vercel | - | Free | +| Cloudflare | - | Free | +| **Gesamt** | **€9-18/Monat** | **€4.50-30/Monat** | + +--- + +# Dockerfiles für alle Projekte + +## SvelteKit Web Apps (Template) + +Erstelle für jedes Projekt ohne Dockerfile: + +```dockerfile +# {projekt}/apps/web/Dockerfile + +FROM node:20-alpine AS builder +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate +WORKDIR /app + +# Monorepo files +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY {projekt}/apps/web/ ./{projekt}/apps/web/ +COPY packages/ ./packages/ + +# Install and build +RUN pnpm install --filter @{projekt}/web... --shamefully-hoist +WORKDIR /app/{projekt}/apps/web +RUN pnpm build + +# Runner +FROM node:20-alpine +RUN adduser -D sveltekit +WORKDIR /app +COPY --from=builder /app/{projekt}/apps/web/build ./build +COPY --from=builder /app/{projekt}/apps/web/package.json ./ +COPY --from=builder /app/node_modules ./node_modules + +USER sveltekit +ENV NODE_ENV=production PORT=3000 +EXPOSE 3000 +CMD ["node", "build"] +``` + +## Astro Landing Pages + +```dockerfile +# {projekt}/apps/landing/Dockerfile + +FROM node:20-alpine AS builder +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate +WORKDIR /app + +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY {projekt}/apps/landing/ ./{projekt}/apps/landing/ +COPY packages/ ./packages/ + +RUN pnpm install --filter @{projekt}/landing... --shamefully-hoist +WORKDIR /app/{projekt}/apps/landing +RUN pnpm build + +# Nginx for static files +FROM nginx:alpine +COPY --from=builder /app/{projekt}/apps/landing/dist /usr/share/nginx/html +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] +``` + +## NestJS Backends + +Bereits vorhanden in: +- `maerchenzauber/apps/backend/Dockerfile` +- `manadeck/backend/Dockerfile` + +--- + +# Environment Variables + +## Gemeinsame Variablen (alle Projekte) + +```env +NODE_ENV=production +``` + +## Supabase-basierte Projekte + +```env +# Maerchenzauber, Manacore, Memoro, Picture +PUBLIC_SUPABASE_URL=https://xxx.supabase.co +PUBLIC_SUPABASE_ANON_KEY=eyJxx... +SUPABASE_SERVICE_ROLE_KEY=eyJxx... # Nur Backend +``` + +## PostgreSQL-basierte Projekte + +```env +# Manadeck, uLoad +DATABASE_URL=postgresql://user:pass@host:5432/db +``` + +## Projekt-spezifische Variablen + +### Maerchenzauber Backend +```env +AZURE_OPENAI_ENDPOINT=https://xxx.openai.azure.com +AZURE_OPENAI_API_KEY=xxx +GOOGLE_GEMINI_API_KEY=xxx +REPLICATE_API_TOKEN=xxx +GOOGLE_APPLICATION_CREDENTIALS=/path/to/credentials.json +``` + +### Manadeck Backend +```env +GOOGLE_GEMINI_API_KEY=xxx +``` + +### uLoad +```env +REDIS_URL=redis://localhost:6379 +STRIPE_SECRET_KEY=sk_live_xxx +STRIPE_WEBHOOK_SECRET=whsec_xxx +RESEND_API_KEY=re_xxx +R2_ACCESS_KEY_ID=xxx +R2_SECRET_ACCESS_KEY=xxx +R2_BUCKET_NAME=xxx +R2_ENDPOINT=https://xxx.r2.cloudflarestorage.com +AUTH_SECRET=xxx +``` + +--- + +# Checkliste: Komplettes Self-Hosting + +## Infrastruktur +- [ ] VPS bestellt (Hetzner CX21/CX31) +- [ ] SSH-Zugang eingerichtet +- [ ] Coolify installiert +- [ ] Firewall konfiguriert + +## Datenbanken +- [ ] PostgreSQL läuft +- [ ] Redis läuft (falls benötigt) +- [ ] Backups eingerichtet +- [ ] Connection Strings notiert + +## Projekte (für jedes) +- [ ] Dockerfile erstellt/geprüft +- [ ] Environment Variables gesetzt +- [ ] Domain konfiguriert +- [ ] SSL-Zertifikat aktiv +- [ ] Health-Check funktioniert + +## DNS (für jede Domain) +- [ ] A-Record auf Server-IP +- [ ] www CNAME (optional) +- [ ] Propagation geprüft + +## Monitoring +- [ ] Logs erreichbar +- [ ] Alerting eingerichtet (optional) +- [ ] Uptime-Monitoring (optional) + +## Backups +- [ ] Datenbank-Backup automatisiert +- [ ] Backup-Test durchgeführt +- [ ] Offsite-Backup (optional) + +--- + +# Empfehlung + +## Für den Start: Option D (Hybrid) + +1. **Supabase** für Datenbank + Auth (Free Tier) +2. **Vercel/Netlify** für Landing Pages (Free) +3. **Hetzner CX21** für Web Apps + Backends (€4.50/Monat) +4. **Cloudflare** für DNS + CDN (Free) + +**Vorteile:** +- Schneller Start +- Geringe Kosten +- Managed Auth & Realtime +- Einfache Skalierung später + +## Für Wachstum: Option B (Multi-VPS) + +Wenn Traffic steigt: +1. Datenbanken auf eigenen VPS migrieren +2. Frontend/Backend trennen +3. Load Balancing hinzufügen + +## Für Enterprise: Option C (Kubernetes) + +Wenn benötigt: +- Auto-Scaling +- Zero-Downtime Deployments +- Multi-Region + +--- + +# Support & Links + +- **Coolify Docs:** https://coolify.io/docs +- **Hetzner:** https://www.hetzner.com/cloud +- **Supabase:** https://supabase.com/docs +- **K3s:** https://k3s.io +- **Traefik:** https://doc.traefik.io/traefik/ diff --git a/docs/ULOAD-DEPLOYMENT.md b/docs/ULOAD-DEPLOYMENT.md new file mode 100644 index 000000000..c86d51b7d --- /dev/null +++ b/docs/ULOAD-DEPLOYMENT.md @@ -0,0 +1,446 @@ +# uload Deployment Guide + +Schritt-für-Schritt Anleitung zum Deployment von uload mit Coolify auf Hetzner VPS. + +## Architektur + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Hetzner VPS │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ Coolify │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ │ +│ │ │ uload │ │ PostgreSQL │ │ Redis │ │ │ +│ │ │ (Node) │ │ (16) │ │ (7) │ │ │ +│ │ │ :3000 │ │ :5432 │ │ :6379 │ │ │ +│ │ └─────────────┘ └─────────────┘ └─────────────────┘ │ │ +│ │ │ │ │ │ │ +│ │ └────────────────┴──────────────────┘ │ │ +│ │ Traefik (SSL/Proxy) │ │ +│ │ :80 / :443 │ │ +│ └───────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ + https://ulo.ad +``` + +--- + +## Voraussetzungen + +- [ ] Hetzner VPS (mindestens CX21: 2 vCPU, 4GB RAM, 40GB SSD) +- [ ] Domain mit DNS-Zugang (z.B. ulo.ad) +- [ ] GitHub Account mit Zugriff auf das Repository +- [ ] Accounts für externe Services: + - Resend (Email) + - Stripe (Payments) + - Cloudflare R2 (Storage) + +--- + +## Schritt 1: Hetzner VPS einrichten + +### 1.1 Server erstellen + +1. Gehe zu [Hetzner Cloud Console](https://console.hetzner.cloud) +2. Erstelle neues Projekt oder wähle bestehendes +3. Klicke **Add Server** +4. Wähle: + - **Location:** Falkenstein oder Nürnberg (DE) + - **Image:** Ubuntu 22.04 + - **Type:** CX21 (2 vCPU, 4GB RAM) oder größer + - **SSH Key:** Füge deinen öffentlichen SSH-Key hinzu +5. Klicke **Create & Buy Now** +6. Notiere die **IP-Adresse** + +### 1.2 Mit Server verbinden + +```bash +ssh root@DEINE-SERVER-IP +``` + +### 1.3 System updaten + +```bash +apt update && apt upgrade -y +``` + +--- + +## Schritt 2: Coolify installieren + +### 2.1 Installation + +```bash +curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash +``` + +Die Installation dauert ca. 2-5 Minuten. + +### 2.2 Coolify öffnen + +1. Öffne im Browser: `http://DEINE-SERVER-IP:8000` +2. Erstelle Admin-Account (E-Mail + Passwort) +3. Wähle **Self-hosted** als Instance Type +4. Der Server "localhost" wird automatisch hinzugefügt + +--- + +## Schritt 3: PostgreSQL Datenbank erstellen + +### 3.1 In Coolify + +1. Klicke **+ New Resource** +2. Wähle **Database** +3. Wähle **PostgreSQL** +4. Konfiguriere: + - **Name:** `uload-postgres` + - **Version:** `16-alpine` + - **Database Name:** `uload` + - **Database User:** `uload` + - **Password:** (automatisch generiert oder eigenes) +5. Klicke **Start** + +### 3.2 Connection String notieren + +Nach dem Start findest du unter **Connect** die Internal URL: + +``` +postgresql://uload:PASSWORT@uload-postgres:5432/uload +``` + +**Wichtig:** Kopiere diese URL - du brauchst sie später! + +--- + +## Schritt 4: Redis erstellen (optional, aber empfohlen) + +### 4.1 In Coolify + +1. Klicke **+ New Resource** +2. Wähle **Database** +3. Wähle **Redis** +4. Konfiguriere: + - **Name:** `uload-redis` + - **Version:** `7-alpine` +5. Klicke **Start** + +### 4.2 Connection String notieren + +``` +redis://uload-redis:6379 +``` + +--- + +## Schritt 5: GitHub Repository verbinden + +### 5.1 GitHub App erstellen + +1. In Coolify: Gehe zu **Sources** (linke Sidebar) +2. Klicke **+ Add** +3. Wähle **GitHub App** +4. Klicke **Register GitHub App** +5. Du wirst zu GitHub weitergeleitet +6. Gib der App einen Namen (z.B. "coolify-uload") +7. Klicke **Create GitHub App** +8. Installiere die App für dein Repository + +### 5.2 Repository-Zugriff gewähren + +1. Wähle **Only select repositories** +2. Wähle `manacore-monorepo` +3. Klicke **Install** + +--- + +## Schritt 6: uload Application erstellen + +### 6.1 Neue Application + +1. Klicke **+ New Resource** +2. Wähle **Application** +3. Wähle deine **GitHub App** als Source +4. Wähle das Repository `manacore-monorepo` +5. Wähle Branch: `main` + +### 6.2 Build-Konfiguration (WICHTIG!) + +Da uload Teil eines Monorepos ist, muss die Build-Konfiguration genau so sein: + +| Einstellung | Wert | +|-------------|------| +| **Base Directory** | `/` (leer lassen oder `/`) | +| **Build Pack** | Dockerfile | +| **Dockerfile Location** | `uload/Dockerfile` | +| **Port Exposes** | `3000` | + +**Warum `/` als Base Directory?** +Das Dockerfile benötigt Zugriff auf: +- `uload/apps/web/` (die App) +- `packages/shared-*` (gemeinsame Packages) +- `pnpm-workspace.yaml` und `pnpm-lock.yaml` (Workspace-Config) + +--- + +## Schritt 7: Environment Variables setzen + +### 7.1 In Coolify + +Gehe zu deiner Application → **Environment Variables** → **Add Variable** + +### 7.2 Erforderliche Variablen + +```env +# === APP === +NODE_ENV=production +PORT=3000 +HOST=0.0.0.0 +ORIGIN=https://ulo.ad + +# === DATABASE === +# Von Schritt 3.2 - PostgreSQL Internal URL +DATABASE_URL=postgresql://uload:DEIN-PASSWORT@uload-postgres:5432/uload + +# === REDIS (optional) === +# Von Schritt 4.2 +REDIS_URL=redis://uload-redis:6379 + +# === AUTH === +# Generiere mit: openssl rand -base64 32 +AUTH_SECRET=GENERIERE-EINEN-SICHEREN-STRING-HIER + +# === EMAIL (Resend) === +RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# === PAYMENTS (Stripe) === +STRIPE_SECRET_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx +STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxx + +# === STORAGE (Cloudflare R2) === +R2_ACCESS_KEY_ID=xxxxxxxxxxxxxxxxxxxxxxxxxx +R2_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +R2_BUCKET_NAME=uload-uploads +R2_ENDPOINT=https://xxxxxxxxxx.r2.cloudflarestorage.com +``` + +### 7.3 AUTH_SECRET generieren + +Auf deinem lokalen Rechner: + +```bash +openssl rand -base64 32 +``` + +Kopiere das Ergebnis als `AUTH_SECRET`. + +--- + +## Schritt 8: Domain konfigurieren + +### 8.1 DNS-Einträge setzen + +Bei deinem DNS-Provider (z.B. Cloudflare, Namecheap): + +| Type | Name | Value | TTL | +|------|------|-------|-----| +| A | @ | DEINE-SERVER-IP | 3600 | +| A | www | DEINE-SERVER-IP | 3600 | + +### 8.2 Domain in Coolify hinzufügen + +1. Gehe zu deiner Application → **Settings** +2. Unter **Domains** klicke **+ Add** +3. Gib ein: `ulo.ad` +4. Aktiviere: **Generate SSL Certificate** (Let's Encrypt) +5. Optional: Füge auch `www.ulo.ad` hinzu mit Redirect + +### 8.3 Warten + +DNS-Änderungen können 5-30 Minuten dauern. SSL-Zertifikate werden automatisch erstellt. + +--- + +## Schritt 9: Deployment starten + +### 9.1 Erster Deploy + +1. Gehe zu deiner Application +2. Klicke **Deploy** +3. Warte auf den Build (ca. 3-5 Minuten) + +### 9.2 Build-Logs überwachen + +Klicke auf das laufende Deployment um die Logs zu sehen. + +**Erfolgreicher Build zeigt:** +``` +✔ done +Listening on http://0.0.0.0:3000 +``` + +--- + +## Schritt 10: Datenbank-Migration + +### 10.1 Nach erstem Deployment + +Die Datenbank-Tabellen müssen erstellt werden: + +1. In Coolify: Gehe zu deiner Application → **Terminal** +2. Oder via SSH: + +```bash +# Container-Name finden +docker ps | grep uload + +# In Container gehen +docker exec -it CONTAINER-NAME sh + +# Migration ausführen +npx drizzle-kit push +``` + +### 10.2 Alternative: Pre-Deploy Command + +In Coolify → Application → **Settings** → **Pre-Deploy Command**: + +```bash +cd /app && npx drizzle-kit push +``` + +--- + +## Schritt 11: Verifizieren + +### 11.1 Health Check + +```bash +curl https://ulo.ad/api/health +``` + +Erwartete Antwort: +```json +{"status":"ok","timestamp":"2025-11-25T12:00:00.000Z","uptime":123.45} +``` + +### 11.2 Website öffnen + +Öffne `https://ulo.ad` im Browser. + +--- + +## Automatische Deployments + +### Webhook (Standard) + +Coolify erstellt automatisch einen GitHub Webhook. Bei jedem Push auf `main` wird automatisch deployed. + +### Manuelles Deployment + +In Coolify: Application → **Redeploy** + +--- + +## Wartung & Monitoring + +### Logs anzeigen + +**In Coolify:** +Application → **Logs** + +**Via SSH:** +```bash +docker logs -f $(docker ps -qf "name=uload") +``` + +### Container neustarten + +In Coolify: Application → **Restart** + +### Datenbank Backup + +```bash +# Manuelles Backup +docker exec uload-postgres pg_dump -U uload uload > backup_$(date +%Y%m%d).sql + +# Backup wiederherstellen +cat backup_20251125.sql | docker exec -i uload-postgres psql -U uload uload +``` + +--- + +## Troubleshooting + +### Build schlägt fehl + +| Problem | Lösung | +|---------|--------| +| "Cannot find package" | Prüfe Base Directory (muss `/` sein) | +| "pnpm-lock.yaml not found" | Prüfe dass pnpm-lock.yaml im Repo ist | +| Timeout beim Build | Erhöhe Build-Timeout in Coolify Settings | + +### Container startet nicht + +| Problem | Lösung | +|---------|--------| +| "Missing API key" | Prüfe RESEND_API_KEY Environment Variable | +| "Cannot connect to database" | Prüfe DATABASE_URL (Internal URL!) | +| Port already in use | Prüfe ob alter Container noch läuft | + +### SSL-Zertifikat Fehler + +1. Prüfe DNS-Einträge (A-Record auf Server-IP) +2. Warte 5-10 Minuten +3. In Coolify: Domain löschen und neu hinzufügen +4. Prüfe ob Port 80 erreichbar ist (Firewall) + +### Datenbank-Verbindung fehlgeschlagen + +1. Prüfe ob PostgreSQL-Container läuft +2. Verwende **Internal URL** (nicht External!) +3. Teste Verbindung: + ```bash + docker exec -it uload-postgres psql -U uload -d uload -c "SELECT 1" + ``` + +--- + +## Checkliste Production-Ready + +- [ ] Hetzner VPS erstellt und SSH funktioniert +- [ ] Coolify installiert und Admin-Account erstellt +- [ ] PostgreSQL läuft und CONNECTION_STRING notiert +- [ ] Redis läuft (optional) +- [ ] GitHub Repository verbunden +- [ ] Application mit korrektem Dockerfile-Pfad erstellt +- [ ] Alle Environment Variables gesetzt +- [ ] AUTH_SECRET generiert (min. 32 Zeichen) +- [ ] DNS A-Records konfiguriert +- [ ] Domain in Coolify hinzugefügt +- [ ] SSL-Zertifikat aktiv +- [ ] Erster Deploy erfolgreich +- [ ] Datenbank-Migration ausgeführt +- [ ] Health-Check funktioniert (`/api/health`) +- [ ] Website erreichbar + +--- + +## Dateien im Repository + +| Datei | Beschreibung | +|-------|--------------| +| `uload/Dockerfile` | Multi-Stage Docker Build | +| `uload/docker-compose.yml` | Lokale Entwicklung | +| `uload/docker-compose.coolify.yml` | Coolify Deployment | +| `uload/docker-compose.prod.yml` | Standalone Production | + +--- + +## Support + +Bei Problemen: +1. Coolify Logs prüfen +2. Container Logs prüfen (`docker logs`) +3. GitHub Issues: https://github.com/anthropics/claude-code/issues diff --git a/maerchenzauber/apps/landing/src/pages/de/index.astro b/maerchenzauber/apps/landing/src/pages/de/index.astro new file mode 100644 index 000000000..375f31c41 --- /dev/null +++ b/maerchenzauber/apps/landing/src/pages/de/index.astro @@ -0,0 +1,4 @@ +--- +// Redirect /de to / - the site is already in German +return Astro.redirect('/', 301); +--- diff --git a/packages/shared-branding/src/config.ts b/packages/shared-branding/src/config.ts index fa1592769..fe2f45abc 100644 --- a/packages/shared-branding/src/config.ts +++ b/packages/shared-branding/src/config.ts @@ -51,6 +51,18 @@ export const APP_BRANDING: Record = { logoStroke: true, logoStrokeWidth: 1.5, }, + uload: { + id: 'uload', + name: 'uLoad', + tagline: 'Smart URL Shortener', + primaryColor: '#3b82f6', + secondaryColor: '#60a5fa', + // Link/Chain icon + logoPath: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1', + logoViewBox: '0 0 24 24', + logoStroke: true, + logoStrokeWidth: 2, + }, }; /** diff --git a/packages/shared-branding/src/index.ts b/packages/shared-branding/src/index.ts index 13b2ce22e..55c836a52 100644 --- a/packages/shared-branding/src/index.ts +++ b/packages/shared-branding/src/index.ts @@ -18,7 +18,8 @@ export { MemoroLogo, ManaCoreLogo, ManaDeckLogo, - StorytellerLogo + StorytellerLogo, + UloadLogo } from './logos'; // Configuration diff --git a/packages/shared-branding/src/logos/UloadLogo.svelte b/packages/shared-branding/src/logos/UloadLogo.svelte new file mode 100644 index 000000000..1b0ec869e --- /dev/null +++ b/packages/shared-branding/src/logos/UloadLogo.svelte @@ -0,0 +1,13 @@ + + + diff --git a/packages/shared-branding/src/logos/index.ts b/packages/shared-branding/src/logos/index.ts index e764a713d..b3de1b56f 100644 --- a/packages/shared-branding/src/logos/index.ts +++ b/packages/shared-branding/src/logos/index.ts @@ -6,3 +6,4 @@ export { default as MemoroLogo } from './MemoroLogo.svelte'; export { default as ManaCoreLogo } from './ManaCoreLogo.svelte'; export { default as ManaDeckLogo } from './ManaDeckLogo.svelte'; export { default as StorytellerLogo } from './StorytellerLogo.svelte'; +export { default as UloadLogo } from './UloadLogo.svelte'; diff --git a/packages/shared-branding/src/types.ts b/packages/shared-branding/src/types.ts index 0d73a6774..39312253a 100644 --- a/packages/shared-branding/src/types.ts +++ b/packages/shared-branding/src/types.ts @@ -1,7 +1,7 @@ /** * App identifiers for branding */ -export type AppId = 'memoro' | 'manacore' | 'manadeck' | 'maerchenzauber'; +export type AppId = 'memoro' | 'manacore' | 'manadeck' | 'maerchenzauber' | 'uload'; /** * App branding configuration diff --git a/packages/uload-database/.env.example b/packages/uload-database/.env.example new file mode 100644 index 000000000..aa808af05 --- /dev/null +++ b/packages/uload-database/.env.example @@ -0,0 +1,4 @@ +# Database connection string +DATABASE_URL=postgresql://postgres:postgres@localhost:5434/uload +# Or use project-specific variable +ULOAD_DATABASE_URL=postgresql://postgres:postgres@localhost:5434/uload diff --git a/packages/uload-database/docker-compose.yml b/packages/uload-database/docker-compose.yml new file mode 100644 index 000000000..6a6cea47d --- /dev/null +++ b/packages/uload-database/docker-compose.yml @@ -0,0 +1,36 @@ +services: + postgres: + image: postgres:16-alpine + container_name: uload-postgres + restart: unless-stopped + ports: + - '5434:5432' + environment: + POSTGRES_DB: uload + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + volumes: + - uload_postgres_data:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U postgres'] + interval: 5s + timeout: 5s + retries: 5 + + pgadmin: + image: dpage/pgadmin4:latest + container_name: uload-pgadmin + restart: unless-stopped + ports: + - '5051:80' + environment: + PGADMIN_DEFAULT_EMAIL: admin@uload.local + PGADMIN_DEFAULT_PASSWORD: admin + volumes: + - uload_pgadmin_data:/var/lib/pgadmin + depends_on: + - postgres + +volumes: + uload_postgres_data: + uload_pgadmin_data: diff --git a/packages/uload-database/drizzle.config.ts b/packages/uload-database/drizzle.config.ts new file mode 100644 index 000000000..16d3dac56 --- /dev/null +++ b/packages/uload-database/drizzle.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/schema/index.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL || process.env.ULOAD_DATABASE_URL || '', + }, + verbose: true, + strict: true, +}); diff --git a/packages/uload-database/package.json b/packages/uload-database/package.json new file mode 100644 index 000000000..bbb523c1c --- /dev/null +++ b/packages/uload-database/package.json @@ -0,0 +1,54 @@ +{ + "name": "@manacore/uload-database", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.js", + "default": "./dist/index.js" + }, + "./schema": { + "types": "./dist/schema/index.d.ts", + "import": "./dist/schema/index.js", + "require": "./dist/schema/index.js", + "default": "./dist/schema/index.js" + }, + "./client": { + "types": "./dist/client.d.ts", + "import": "./dist/client.js", + "require": "./dist/client.js", + "default": "./dist/client.js" + } + }, + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "prepare": "pnpm build", + "docker:up": "docker compose up -d", + "docker:down": "docker compose down", + "docker:logs": "docker compose logs -f postgres", + "db:generate": "dotenv -- drizzle-kit generate", + "db:migrate": "dotenv -- drizzle-kit migrate", + "db:push": "dotenv -- drizzle-kit push --force", + "db:studio": "dotenv -- drizzle-kit studio", + "db:reset": "docker compose down -v && docker compose up -d && sleep 3 && pnpm db:push", + "db:test": "dotenv -- tsx src/test-connection.ts", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "drizzle-orm": "^0.36.0", + "postgres": "^3.4.5" + }, + "devDependencies": { + "dotenv-cli": "^7.4.0", + "drizzle-kit": "^0.28.0", + "tsx": "^4.19.0", + "typescript": "^5.7.3", + "@types/node": "^22.10.0" + } +} diff --git a/packages/uload-database/src/client.ts b/packages/uload-database/src/client.ts new file mode 100644 index 000000000..6f5331345 --- /dev/null +++ b/packages/uload-database/src/client.ts @@ -0,0 +1,97 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema/index.js'; + +// Singleton instance for the database client +let dbInstance: ReturnType> | null = null; +let pgClient: ReturnType | null = null; + +/** + * Get the database URL from environment variables + */ +function getDatabaseUrl(): string { + const url = process.env.DATABASE_URL || process.env.ULOAD_DATABASE_URL; + if (!url) { + throw new Error( + 'Database URL not found. Set DATABASE_URL or ULOAD_DATABASE_URL environment variable.' + ); + } + return url; +} + +/** + * Create a new database client + * Uses connection pooling with sensible defaults for serverless environments + */ +export function createClient(connectionString?: string) { + const url = connectionString || getDatabaseUrl(); + + const client = postgres(url, { + max: 10, // Maximum connections in the pool + idle_timeout: 20, // Close idle connections after 20 seconds + connect_timeout: 10, // Connection timeout in seconds + prepare: false, // Disable prepared statements for serverless + }); + + return drizzle(client, { schema }); +} + +/** + * Get the singleton database instance + * Creates a new instance if one doesn't exist + */ +export function getDb() { + if (!dbInstance) { + const url = getDatabaseUrl(); + pgClient = postgres(url, { + max: 10, + idle_timeout: 20, + connect_timeout: 10, + prepare: false, + }); + dbInstance = drizzle(pgClient, { schema }); + } + return dbInstance; +} + +/** + * Close the database connection + * Should be called when shutting down the application + */ +export async function closeDb() { + if (pgClient) { + await pgClient.end(); + pgClient = null; + dbInstance = null; + } +} + +// Export the database type for typing purposes +export type Database = ReturnType; + +// Re-export commonly used Drizzle utilities +export { + eq, + ne, + gt, + gte, + lt, + lte, + and, + or, + not, + inArray, + notInArray, + isNull, + isNotNull, + like, + ilike, + sql, + asc, + desc, + count, + sum, + avg, + min, + max, +} from 'drizzle-orm'; diff --git a/packages/uload-database/src/index.ts b/packages/uload-database/src/index.ts new file mode 100644 index 000000000..3cc8cff15 --- /dev/null +++ b/packages/uload-database/src/index.ts @@ -0,0 +1,32 @@ +// Database client exports +export { createClient, getDb, closeDb, type Database } from './client.js'; + +// Re-export Drizzle utilities +export { + eq, + ne, + gt, + gte, + lt, + lte, + and, + or, + not, + inArray, + notInArray, + isNull, + isNotNull, + like, + ilike, + sql, + asc, + desc, + count, + sum, + avg, + min, + max, +} from './client.js'; + +// Schema exports +export * from './schema/index.js'; diff --git a/packages/uload-database/src/schema/accounts.ts b/packages/uload-database/src/schema/accounts.ts new file mode 100644 index 000000000..1391b4852 --- /dev/null +++ b/packages/uload-database/src/schema/accounts.ts @@ -0,0 +1,32 @@ +import { + pgTable, + uuid, + text, + boolean, + timestamp, + jsonb, + index, +} from 'drizzle-orm/pg-core'; +import { users } from './users.js'; + +export const accounts = pgTable( + 'accounts', + { + id: uuid('id').primaryKey().defaultRandom(), + name: text('name').notNull(), + owner: uuid('owner') + .references(() => users.id) + .notNull(), + isActive: boolean('is_active').default(true), + planType: text('plan_type', { enum: ['free', 'team', 'enterprise'] }).default( + 'free' + ), + settings: jsonb('settings'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), + }, + (table) => [index('accounts_owner_idx').on(table.owner)] +); + +export type Account = typeof accounts.$inferSelect; +export type NewAccount = typeof accounts.$inferInsert; diff --git a/packages/uload-database/src/schema/clicks.ts b/packages/uload-database/src/schema/clicks.ts new file mode 100644 index 000000000..938158491 --- /dev/null +++ b/packages/uload-database/src/schema/clicks.ts @@ -0,0 +1,33 @@ +import { pgTable, uuid, text, timestamp, index } from 'drizzle-orm/pg-core'; +import { links } from './links.js'; + +export const clicks = pgTable( + 'clicks', + { + id: uuid('id').primaryKey().defaultRandom(), + linkId: uuid('link_id') + .references(() => links.id, { onDelete: 'cascade' }) + .notNull(), + ipHash: text('ip_hash'), + userAgent: text('user_agent'), + referer: text('referer'), + browser: text('browser'), + deviceType: text('device_type'), + os: text('os'), + country: text('country'), + city: text('city'), + clickedAt: timestamp('clicked_at').defaultNow().notNull(), + utmSource: text('utm_source'), + utmMedium: text('utm_medium'), + utmCampaign: text('utm_campaign'), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (table) => [ + index('clicks_link_id_idx').on(table.linkId), + index('clicks_clicked_at_idx').on(table.clickedAt), + index('clicks_country_idx').on(table.country), + ] +); + +export type Click = typeof clicks.$inferSelect; +export type NewClick = typeof clicks.$inferInsert; diff --git a/packages/uload-database/src/schema/index.ts b/packages/uload-database/src/schema/index.ts new file mode 100644 index 000000000..5a0fabfe2 --- /dev/null +++ b/packages/uload-database/src/schema/index.ts @@ -0,0 +1,18 @@ +// Tables +export { users, type User, type NewUser } from './users.js'; +export { accounts, type Account, type NewAccount } from './accounts.js'; +export { workspaces, type Workspace, type NewWorkspace } from './workspaces.js'; +export { links, type Link, type NewLink } from './links.js'; +export { clicks, type Click, type NewClick } from './clicks.js'; +export { tags, linkTags, type Tag, type NewTag, type LinkTag, type NewLinkTag } from './tags.js'; + +// Relations +export { + usersRelations, + linksRelations, + clicksRelations, + tagsRelations, + linkTagsRelations, + accountsRelations, + workspacesRelations, +} from './relations.js'; diff --git a/packages/uload-database/src/schema/links.ts b/packages/uload-database/src/schema/links.ts new file mode 100644 index 000000000..2a027156a --- /dev/null +++ b/packages/uload-database/src/schema/links.ts @@ -0,0 +1,50 @@ +import { + pgTable, + uuid, + text, + boolean, + integer, + timestamp, + jsonb, + index, +} from 'drizzle-orm/pg-core'; +import { users } from './users.js'; +import { accounts } from './accounts.js'; +import { workspaces } from './workspaces.js'; + +export const links = pgTable( + 'links', + { + id: uuid('id').primaryKey().defaultRandom(), + shortCode: text('short_code').unique().notNull(), + customCode: text('custom_code'), + originalUrl: text('original_url').notNull(), + title: text('title'), + description: text('description'), + userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }), + isActive: boolean('is_active').default(true), + password: text('password'), // hashed + maxClicks: integer('max_clicks'), + expiresAt: timestamp('expires_at'), + clickCount: integer('click_count').default(0), + qrCodeUrl: text('qr_code_url'), + tags: jsonb('tags').$type(), + utmSource: text('utm_source'), + utmMedium: text('utm_medium'), + utmCampaign: text('utm_campaign'), + accountOwner: uuid('account_owner').references(() => accounts.id), + workspaceId: uuid('workspace_id').references(() => workspaces.id), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), + }, + (table) => [ + index('links_user_id_idx').on(table.userId), + index('links_short_code_idx').on(table.shortCode), + index('links_workspace_id_idx').on(table.workspaceId), + index('links_account_owner_idx').on(table.accountOwner), + index('links_is_active_idx').on(table.isActive), + ] +); + +export type Link = typeof links.$inferSelect; +export type NewLink = typeof links.$inferInsert; diff --git a/packages/uload-database/src/schema/relations.ts b/packages/uload-database/src/schema/relations.ts new file mode 100644 index 000000000..90d1ab695 --- /dev/null +++ b/packages/uload-database/src/schema/relations.ts @@ -0,0 +1,52 @@ +import { relations } from 'drizzle-orm'; +import { users } from './users.js'; +import { links } from './links.js'; +import { clicks } from './clicks.js'; +import { tags, linkTags } from './tags.js'; +import { accounts } from './accounts.js'; +import { workspaces } from './workspaces.js'; + +export const usersRelations = relations(users, ({ many }) => ({ + links: many(links), + tags: many(tags), + ownedAccounts: many(accounts), + ownedWorkspaces: many(workspaces), +})); + +export const linksRelations = relations(links, ({ one, many }) => ({ + user: one(users, { fields: [links.userId], references: [users.id] }), + account: one(accounts, { + fields: [links.accountOwner], + references: [accounts.id], + }), + workspace: one(workspaces, { + fields: [links.workspaceId], + references: [workspaces.id], + }), + clicks: many(clicks), + linkTags: many(linkTags), +})); + +export const clicksRelations = relations(clicks, ({ one }) => ({ + link: one(links, { fields: [clicks.linkId], references: [links.id] }), +})); + +export const tagsRelations = relations(tags, ({ one, many }) => ({ + user: one(users, { fields: [tags.userId], references: [users.id] }), + linkTags: many(linkTags), +})); + +export const linkTagsRelations = relations(linkTags, ({ one }) => ({ + link: one(links, { fields: [linkTags.linkId], references: [links.id] }), + tag: one(tags, { fields: [linkTags.tagId], references: [tags.id] }), +})); + +export const accountsRelations = relations(accounts, ({ one, many }) => ({ + owner: one(users, { fields: [accounts.owner], references: [users.id] }), + links: many(links), +})); + +export const workspacesRelations = relations(workspaces, ({ one, many }) => ({ + owner: one(users, { fields: [workspaces.owner], references: [users.id] }), + links: many(links), +})); diff --git a/packages/uload-database/src/schema/tags.ts b/packages/uload-database/src/schema/tags.ts new file mode 100644 index 000000000..f3c7ae3f5 --- /dev/null +++ b/packages/uload-database/src/schema/tags.ts @@ -0,0 +1,56 @@ +import { + pgTable, + uuid, + text, + boolean, + integer, + timestamp, + index, +} from 'drizzle-orm/pg-core'; +import { users } from './users.js'; +import { links } from './links.js'; + +export const tags = pgTable( + 'tags', + { + id: uuid('id').primaryKey().defaultRandom(), + name: text('name').notNull(), + slug: text('slug').notNull(), + color: text('color'), + icon: text('icon'), + isPublic: boolean('is_public').default(false), + usageCount: integer('usage_count').default(0), + userId: uuid('user_id').references(() => users.id), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), + }, + (table) => [ + index('tags_user_id_idx').on(table.userId), + index('tags_slug_idx').on(table.slug), + ] +); + +export type Tag = typeof tags.$inferSelect; +export type NewTag = typeof tags.$inferInsert; + +export const linkTags = pgTable( + 'link_tags', + { + id: uuid('id').primaryKey().defaultRandom(), + linkId: uuid('link_id') + .references(() => links.id, { onDelete: 'cascade' }) + .notNull(), + tagId: uuid('tag_id') + .references(() => tags.id, { onDelete: 'cascade' }) + .notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (table) => [ + index('link_tags_link_id_idx').on(table.linkId), + index('link_tags_tag_id_idx').on(table.tagId), + index('link_tags_unique_idx').on(table.linkId, table.tagId), + ] +); + +export type LinkTag = typeof linkTags.$inferSelect; +export type NewLinkTag = typeof linkTags.$inferInsert; diff --git a/packages/uload-database/src/schema/users.ts b/packages/uload-database/src/schema/users.ts new file mode 100644 index 000000000..0a78b8eb7 --- /dev/null +++ b/packages/uload-database/src/schema/users.ts @@ -0,0 +1,47 @@ +import { + pgTable, + uuid, + text, + boolean, + integer, + timestamp, + index, +} from 'drizzle-orm/pg-core'; +import { relations } from 'drizzle-orm'; + +export const users = pgTable( + 'users', + { + id: uuid('id').primaryKey().defaultRandom(), + externalAuthId: text('external_auth_id').unique(), // For Mana Core auth + email: text('email').unique().notNull(), + username: text('username').unique().notNull(), + name: text('name'), + avatarUrl: text('avatar_url'), + bio: text('bio'), + location: text('location'), + website: text('website'), + github: text('github'), + twitter: text('twitter'), + linkedin: text('linkedin'), + instagram: text('instagram'), + publicProfile: boolean('public_profile').default(false), + showClickStats: boolean('show_click_stats').default(true), + emailNotifications: boolean('email_notifications').default(true), + defaultExpiry: integer('default_expiry'), + profileBackground: text('profile_background'), + verified: boolean('verified').default(false), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), + }, + (table) => [ + index('users_email_idx').on(table.email), + index('users_username_idx').on(table.username), + index('users_external_auth_id_idx').on(table.externalAuthId), + ] +); + +export type User = typeof users.$inferSelect; +export type NewUser = typeof users.$inferInsert; + +// Relations will be defined in relations.ts to avoid circular imports diff --git a/packages/uload-database/src/schema/workspaces.ts b/packages/uload-database/src/schema/workspaces.ts new file mode 100644 index 000000000..cfea1b790 --- /dev/null +++ b/packages/uload-database/src/schema/workspaces.ts @@ -0,0 +1,24 @@ +import { pgTable, uuid, text, timestamp, index } from 'drizzle-orm/pg-core'; +import { users } from './users.js'; + +export const workspaces = pgTable( + 'workspaces', + { + id: uuid('id').primaryKey().defaultRandom(), + name: text('name').notNull(), + slug: text('slug').unique().notNull(), + type: text('type', { enum: ['personal', 'team'] }).notNull(), + owner: uuid('owner') + .references(() => users.id) + .notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), + }, + (table) => [ + index('workspaces_slug_idx').on(table.slug), + index('workspaces_owner_idx').on(table.owner), + ] +); + +export type Workspace = typeof workspaces.$inferSelect; +export type NewWorkspace = typeof workspaces.$inferInsert; diff --git a/packages/uload-database/tsconfig.json b/packages/uload-database/tsconfig.json new file mode 100644 index 000000000..0241d6699 --- /dev/null +++ b/packages/uload-database/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/picture/apps/landing/src/content/comparisons/en/placeholder.md b/picture/apps/landing/src/content/comparisons/en/placeholder.md new file mode 100644 index 000000000..bf1d121e7 --- /dev/null +++ b/picture/apps/landing/src/content/comparisons/en/placeholder.md @@ -0,0 +1,6 @@ +--- +title: "Picture vs Traditional Design" +order: 1 +--- + +Compare Picture's AI-powered approach with traditional design workflows and see how you can save time and resources. diff --git a/picture/apps/landing/src/content/faq/en/placeholder.md b/picture/apps/landing/src/content/faq/en/placeholder.md new file mode 100644 index 000000000..0155194e8 --- /dev/null +++ b/picture/apps/landing/src/content/faq/en/placeholder.md @@ -0,0 +1,6 @@ +--- +title: "What is Picture?" +order: 1 +--- + +Picture is an AI-powered image generation platform that helps you create stunning visuals from text descriptions. diff --git a/picture/apps/landing/src/content/useCases/en/placeholder.md b/picture/apps/landing/src/content/useCases/en/placeholder.md new file mode 100644 index 000000000..396f38293 --- /dev/null +++ b/picture/apps/landing/src/content/useCases/en/placeholder.md @@ -0,0 +1,7 @@ +--- +title: "Marketing & Social Media" +icon: "📱" +order: 1 +--- + +Create eye-catching visuals for your social media campaigns and marketing materials with AI-generated images. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 908e7f42d..5237f7963 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -312,7 +312,7 @@ importers: version: 17.0.7(expo@54.0.25)(react@19.1.0) expo-router: specifier: ~6.0.14 - version: 6.0.15(jqlydxfw6sdg7rjjcbafgjr6wy) + version: 6.0.15(wy3aqjqih33pc4tbjsiq2nsp7q) expo-secure-store: specifier: ~15.0.7 version: 15.0.7(expo@54.0.25) @@ -415,7 +415,7 @@ importers: version: 7.28.5 '@testing-library/react-native': specifier: ^13.3.3 - version: 13.3.3(jest@29.5.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))(react@19.1.0) + version: 13.3.3(jest@29.5.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) '@types/jest': specifier: ^29.5.12 version: 29.5.14 @@ -433,10 +433,10 @@ importers: version: 10.0.0 jest: specifier: ^29.2.1 - version: 29.5.0(@types/node@18.15.11)(ts-node@10.9.2(@types/node@18.15.11)(typescript@5.9.3)) + version: 29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) jest-expo: specifier: ~54.0.13 - version: 54.0.13(@babel/core@7.28.5)(expo@54.0.25)(jest@29.5.0)(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)(webpack@5.100.2) + version: 54.0.13(@babel/core@7.28.5)(expo@54.0.25)(jest@29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(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)(webpack@5.100.2) patch-package: specifier: ^8.0.0 version: 8.0.1 @@ -1861,6 +1861,31 @@ importers: specifier: ^5.9.3 version: 5.9.3 + packages/uload-database: + dependencies: + drizzle-orm: + specifier: ^0.36.0 + version: 0.36.4(@types/react@19.2.7)(kysely@0.27.6)(postgres@3.4.7)(react@19.1.0) + postgres: + specifier: ^3.4.5 + version: 3.4.7 + devDependencies: + '@types/node': + specifier: ^22.10.0 + version: 22.19.1 + dotenv-cli: + specifier: ^7.4.0 + version: 7.4.4 + drizzle-kit: + specifier: ^0.28.0 + version: 0.28.1 + tsx: + specifier: ^4.19.0 + version: 4.20.6 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + picture: dependencies: expo: @@ -2267,6 +2292,143 @@ importers: specifier: ~5.8.3 version: 5.8.3 + uload/apps/backend: + dependencies: + '@mana-core/nestjs-integration': + specifier: git+https://github.com/Memo-2023/mana-core-nestjs-package.git + version: git+https://git@github.com:Memo-2023/mana-core-nestjs-package.git#eca58cc09d6e55d4f9d34e719fa0e0ed8d0101c7(@nestjs/axios@4.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.2)(rxjs@7.8.2))(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/config@4.0.2(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2))(@nestjs/core@11.1.9)(axios@1.13.2)(rxjs@7.8.2) + '@manacore/uload-database': + specifier: workspace:* + version: link:../../../packages/uload-database + '@nestjs/axios': + specifier: ^4.0.1 + version: 4.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.2)(rxjs@7.8.2) + '@nestjs/common': + specifier: ^11.0.1 + version: 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: ^4.0.2 + version: 4.0.2(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + '@nestjs/core': + specifier: ^11.0.1 + version: 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/platform-express': + specifier: ^11.0.1 + version: 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9) + '@nestjs/terminus': + specifier: ^11.0.0 + version: 11.0.0(@grpc/grpc-js@1.14.1)(@grpc/proto-loader@0.8.0)(@nestjs/axios@4.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.2)(rxjs@7.8.2))(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) + axios: + specifier: ^1.7.2 + version: 1.13.2 + class-transformer: + specifier: ^0.5.1 + version: 0.5.1 + class-validator: + specifier: ^0.14.2 + version: 0.14.2 + ioredis: + specifier: ^5.4.1 + version: 5.8.2 + joi: + specifier: ^18.0.1 + version: 18.0.2 + nanoid: + specifier: ^5.0.7 + version: 5.1.6 + nestjs-cls: + specifier: ^6.0.1 + version: 6.1.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + ua-parser-js: + specifier: ^2.0.0 + version: 2.0.6 + devDependencies: + '@nestjs/cli': + specifier: ^11.0.0 + version: 11.0.12(@types/node@22.19.1)(esbuild@0.27.0) + '@nestjs/schematics': + specifier: ^11.0.0 + version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) + '@nestjs/testing': + specifier: ^11.0.1 + version: 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(@nestjs/platform-express@11.1.9) + '@types/express': + specifier: ^5.0.0 + version: 5.0.5 + '@types/jest': + specifier: ^30.0.0 + version: 30.0.0 + '@types/node': + specifier: ^22.10.7 + version: 22.19.1 + '@types/supertest': + specifier: ^6.0.2 + version: 6.0.3 + '@types/ua-parser-js': + specifier: ^0.7.39 + version: 0.7.39 + jest: + specifier: ^30.0.0 + version: 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)) + prettier: + specifier: ^3.4.2 + version: 3.6.2 + source-map-support: + specifier: ^0.5.21 + version: 0.5.21 + supertest: + specifier: ^7.0.0 + 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))(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) + ts-loader: + specifier: ^9.5.2 + version: 9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.0)) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3) + tsconfig-paths: + specifier: ^4.2.0 + version: 4.2.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + uload/apps/landing: + dependencies: + '@astrojs/check': + specifier: ^0.9.4 + version: 0.9.5(prettier-plugin-astro@0.14.1)(prettier@3.6.2)(typescript@5.9.3) + '@astrojs/mdx': + specifier: ^4.0.8 + version: 4.3.12(astro@5.16.0(@types/node@22.19.1)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1)) + '@astrojs/sitemap': + specifier: ^3.2.1 + version: 3.6.0 + '@astrojs/tailwind': + specifier: ^6.0.2 + version: 6.0.2(astro@5.16.0(@types/node@22.19.1)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + astro: + specifier: ^5.1.1 + version: 5.16.0(@types/node@22.19.1)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) + tailwindcss: + specifier: ^3.4.17 + version: 3.4.18(tsx@4.20.6)(yaml@2.8.1) + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.19.1 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + uload/apps/web: dependencies: '@aws-sdk/client-s3': @@ -2275,6 +2437,12 @@ importers: '@aws-sdk/s3-request-presigner': specifier: ^3.934.0 version: 3.939.0 + '@manacore/shared-auth-ui': + specifier: workspace:* + version: link:../../../packages/shared-auth-ui + '@manacore/shared-branding': + specifier: workspace:* + version: link:../../../packages/shared-branding drizzle-orm: specifier: ^0.44.7 version: 0.44.7(kysely@0.27.6)(postgres@3.4.7) @@ -6902,6 +7070,9 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/ua-parser-js@0.7.39': + resolution: {integrity: sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -8574,6 +8745,9 @@ packages: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-europe-js@0.1.2: + resolution: {integrity: sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==} + detect-libc@1.0.3: resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} engines: {node: '>=0.10'} @@ -10910,6 +11084,9 @@ packages: resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} engines: {node: '>= 0.4'} + is-standalone-pwa@0.1.1: + resolution: {integrity: sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -12374,6 +12551,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.6: + resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} + engines: {node: ^18 || >=20} + hasBin: true + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -14962,6 +15144,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + ua-is-frozen@0.1.2: + resolution: {integrity: sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==} + ua-parser-js@0.7.41: resolution: {integrity: sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==} hasBin: true @@ -14970,6 +15155,10 @@ packages: resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} hasBin: true + ua-parser-js@2.0.6: + resolution: {integrity: sha512-EmaxXfltJaDW75SokrY4/lXMrVyXomE/0FpIIqP2Ctic93gK7rlme55Cwkz8l3YZ6gqf94fCU7AnIkidd/KXPg==} + hasBin: true + uc.micro@1.0.6: resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==} @@ -16112,6 +16301,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@astrojs/mdx@4.3.12(astro@5.16.0(@types/node@22.19.1)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))': + dependencies: + '@astrojs/markdown-remark': 6.3.9 + '@mdx-js/mdx': 3.1.1 + acorn: 8.15.0 + astro: 5.16.0(@types/node@22.19.1)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) + es-module-lexer: 1.7.0 + estree-util-visit: 2.0.0 + hast-util-to-html: 9.0.5 + piccolore: 0.1.3 + rehype-raw: 7.0.0 + remark-gfm: 4.0.1 + remark-smartypants: 3.0.2 + source-map: 0.7.6 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + '@astrojs/mdx@4.3.12(astro@5.16.0(@types/node@24.10.1)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))': dependencies: '@astrojs/markdown-remark': 6.3.9 @@ -16209,6 +16417,16 @@ snapshots: transitivePeerDependencies: - ts-node + '@astrojs/tailwind@6.0.2(astro@5.16.0(@types/node@22.19.1)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))': + dependencies: + astro: 5.16.0(@types/node@22.19.1)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) + autoprefixer: 10.4.22(postcss@8.5.6) + postcss: 8.5.6 + postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + tailwindcss: 3.4.18(tsx@4.20.6)(yaml@2.8.1) + transitivePeerDependencies: + - ts-node + '@astrojs/tailwind@6.0.2(astro@5.16.0(@types/node@24.10.1)(ioredis@5.8.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))': dependencies: astro: 5.16.0(@types/node@24.10.1)(ioredis@5.8.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) @@ -19252,6 +19470,41 @@ snapshots: - supports-color - ts-node + '@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 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.19.1 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + 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-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + '@jest/core@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))': dependencies: '@jest/console': 30.2.0 @@ -21690,7 +21943,7 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/react-native@13.3.3(jest@29.5.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))(react@19.1.0)': + '@testing-library/react-native@13.3.3(jest@29.5.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 @@ -21700,7 +21953,7 @@ snapshots: react-test-renderer: 19.1.0(react@19.1.0) redent: 3.0.0 optionalDependencies: - jest: 29.5.0(@types/node@18.15.11)(ts-node@10.9.2(@types/node@18.15.11)(typescript@5.9.3)) + jest: 29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) '@testing-library/react-native@13.3.3(jest@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)))(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: @@ -21712,7 +21965,7 @@ snapshots: react-test-renderer: 19.1.0(react@19.1.0) redent: 3.0.0 optionalDependencies: - 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)) + jest: 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)) optional: true '@testing-library/react-native@13.3.3(jest@30.2.0(esbuild-register@3.6.0(esbuild@0.27.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))(react@19.1.0)': @@ -21725,7 +21978,7 @@ snapshots: react-test-renderer: 19.1.0(react@19.1.0) redent: 3.0.0 optionalDependencies: - 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)) + jest: 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)) optional: true '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': @@ -22055,6 +22308,8 @@ snapshots: '@types/trusted-types@2.0.7': optional: true + '@types/ua-parser-js@0.7.39': {} + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -23266,6 +23521,108 @@ snapshots: - uploadthing - yaml + astro@5.16.0(@types/node@22.19.1)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1): + dependencies: + '@astrojs/compiler': 2.13.0 + '@astrojs/internal-helpers': 0.7.5 + '@astrojs/markdown-remark': 6.3.9 + '@astrojs/telemetry': 3.3.0 + '@capsizecss/unpack': 3.0.1 + '@oslojs/encoding': 1.1.0 + '@rollup/pluginutils': 5.3.0(rollup@4.53.3) + acorn: 8.15.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + boxen: 8.0.1 + ci-info: 4.3.1 + clsx: 2.1.1 + common-ancestor-path: 1.0.1 + cookie: 1.0.2 + cssesc: 3.0.0 + debug: 4.4.3 + deterministic-object-hash: 2.0.2 + devalue: 5.5.0 + diff: 5.2.0 + dlv: 1.1.3 + dset: 3.1.4 + es-module-lexer: 1.7.0 + esbuild: 0.25.12 + estree-walker: 3.0.3 + flattie: 1.1.1 + fontace: 0.3.1 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.2.0 + import-meta-resolve: 4.2.0 + js-yaml: 4.1.1 + magic-string: 0.30.21 + magicast: 0.5.1 + mrmime: 2.0.1 + neotraverse: 0.6.18 + p-limit: 6.2.0 + p-queue: 8.1.1 + package-manager-detector: 1.5.0 + piccolore: 0.1.3 + picomatch: 4.0.3 + prompts: 2.4.2 + rehype: 13.0.2 + semver: 7.7.3 + shiki: 3.15.0 + smol-toml: 1.5.2 + svgo: 4.0.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tsconfck: 3.1.6(typescript@5.9.3) + ultrahtml: 1.6.0 + unifont: 0.6.0 + unist-util-visit: 5.0.0 + unstorage: 1.17.3(ioredis@5.8.2) + vfile: 6.0.3 + vite: 6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + vitefu: 1.1.1(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) + xxhash-wasm: 1.1.0 + yargs-parser: 21.1.1 + yocto-spinner: 0.2.3 + zod: 3.25.76 + zod-to-json-schema: 3.25.0(zod@3.25.76) + zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76) + optionalDependencies: + sharp: 0.34.5 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - lightningcss + - rollup + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - typescript + - uploadthing + - yaml + astro@5.16.0(@types/node@24.10.1)(ioredis@5.8.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1): dependencies: '@astrojs/compiler': 2.13.0 @@ -24325,6 +24682,21 @@ snapshots: - supports-color - ts-node + create-jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): + 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@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + create-require@1.1.1: {} cross-fetch@3.2.0: @@ -24527,6 +24899,8 @@ snapshots: destroy@1.2.0: {} + detect-europe-js@0.1.2: {} + detect-libc@1.0.3: {} detect-libc@2.1.2: {} @@ -26451,52 +26825,6 @@ snapshots: - '@types/react-dom' - supports-color - expo-router@6.0.15(jqlydxfw6sdg7rjjcbafgjr6wy): - 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.7.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@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.5.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))(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(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) - transitivePeerDependencies: - - '@react-native-masked-view/masked-view' - - '@types/react' - - '@types/react-dom' - - supports-color - expo-router@6.0.15(qjp3usx4acoq47dkosl6pmu254): dependencies: '@expo/metro-runtime': 6.1.2(expo@54.0.13)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) @@ -26589,6 +26917,52 @@ snapshots: - '@types/react-dom' - supports-color + expo-router@6.0.15(wy3aqjqih33pc4tbjsiq2nsp7q): + 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.7.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@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.5.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(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) + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + - '@types/react' + - '@types/react-dom' + - supports-color + expo-secure-store@15.0.7(expo@54.0.13): dependencies: expo: 54.0.13(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) @@ -28177,6 +28551,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-standalone-pwa@0.1.1: {} + is-stream@2.0.1: {} is-string@1.1.1: @@ -28384,6 +28760,25 @@ snapshots: - supports-color - ts-node + jest-cli@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + exit: 0.1.2 + import-local: 3.2.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-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + jest-cli@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)): dependencies: '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) @@ -28403,6 +28798,26 @@ snapshots: - supports-color - ts-node + jest-cli@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)): + dependencies: + '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + '@jest/test-result': 30.2.0 + '@jest/types': 30.2.0 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 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)) + jest-util: 30.2.0 + jest-validate: 30.2.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + optional: true + jest-config@29.7.0(@types/node@18.15.11)(ts-node@10.9.2(@types/node@18.15.11)(typescript@5.9.3)): dependencies: '@babel/core': 7.28.5 @@ -28465,6 +28880,37 @@ snapshots: - 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 + '@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@22.19.1)(typescript@5.9.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + jest-config@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)): dependencies: '@babel/core': 7.28.5 @@ -28571,7 +29017,7 @@ snapshots: jest-util: 30.2.0 jest-validate: 30.2.0 - jest-expo@54.0.13(@babel/core@7.28.5)(expo@54.0.25)(jest@29.5.0)(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)(webpack@5.100.2): + jest-expo@54.0.13(@babel/core@7.28.5)(expo@54.0.25)(jest@29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(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)(webpack@5.100.2): dependencies: '@expo/config': 12.0.10 '@expo/json-file': 10.0.7 @@ -28582,7 +29028,7 @@ snapshots: jest-environment-jsdom: 29.7.0 jest-snapshot: 29.7.0 jest-watch-select-projects: 2.0.0 - jest-watch-typeahead: 2.2.1(jest@29.5.0) + jest-watch-typeahead: 2.2.1(jest@29.5.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: 4.17.21 react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) @@ -28943,11 +29389,11 @@ snapshots: chalk: 3.0.0 prompts: 2.4.2 - jest-watch-typeahead@2.2.1(jest@29.5.0): + jest-watch-typeahead@2.2.1(jest@29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))): dependencies: ansi-escapes: 6.2.1 chalk: 4.1.2 - jest: 29.5.0(@types/node@18.15.11)(ts-node@10.9.2(@types/node@18.15.11)(typescript@5.9.3)) + jest: 29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) jest-regex-util: 29.6.3 jest-watcher: 29.7.0 slash: 5.1.0 @@ -29009,6 +29455,18 @@ snapshots: - supports-color - ts-node + jest@29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + 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)): dependencies: '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) @@ -29022,6 +29480,20 @@ snapshots: - supports-color - ts-node + jest@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)): + dependencies: + '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + '@jest/types': 30.2.0 + import-local: 3.2.0 + jest-cli: 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + optional: true + jimp-compact@0.16.1: {} jiti@1.21.7: {} @@ -30690,6 +31162,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@5.1.6: {} + napi-postinstall@0.3.4: {} nativewind@4.2.1(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@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.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-svg@15.12.1(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1)): @@ -31219,6 +31693,14 @@ snapshots: postcss: 8.5.6 ts-node: 10.9.2(@types/node@20.19.25)(typescript@5.9.3) + postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): + dependencies: + lilconfig: 3.1.3 + yaml: 2.8.1 + optionalDependencies: + postcss: 8.5.6 + ts-node: 10.9.2(@types/node@22.19.1)(typescript@5.9.3) + postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): dependencies: lilconfig: 3.1.3 @@ -33924,10 +34406,18 @@ snapshots: typescript@5.9.3: {} + ua-is-frozen@0.1.2: {} + ua-parser-js@0.7.41: {} ua-parser-js@1.0.41: {} + ua-parser-js@2.0.6: + dependencies: + detect-europe-js: 0.1.2 + is-standalone-pwa: 0.1.1 + ua-is-frozen: 0.1.2 + uc.micro@1.0.6: {} ufo@1.6.1: {} diff --git a/uload/Dockerfile b/uload/Dockerfile index 0e545e3e2..f013e41c9 100644 --- a/uload/Dockerfile +++ b/uload/Dockerfile @@ -1,47 +1,71 @@ -# Build Stage +# ============================================================================= +# uload Web Application Dockerfile +# Multi-stage build for production deployment with Coolify +# +# IMPORTANT: This Dockerfile must be built from the MONOREPO ROOT, not from uload/ +# docker build -f uload/Dockerfile -t uload-web . +# +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Stage 1: Builder +# ----------------------------------------------------------------------------- FROM node:20-alpine AS builder +# Install pnpm +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate + WORKDIR /app -# Copy package files from apps/web -COPY apps/web/package*.json ./ +# Copy workspace configuration +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ -# Install dependencies -RUN npm ci --legacy-peer-deps +# Copy the uload web app +COPY uload/apps/web/ ./uload/apps/web/ -# Copy web app source -COPY apps/web/ . +# Copy required shared packages +COPY packages/shared-auth-ui/ ./packages/shared-auth-ui/ +COPY packages/shared-branding/ ./packages/shared-branding/ -# Generate .svelte-kit directory first by running vite in prepare mode -RUN npx vite build --mode prepare || true - -# Sync SvelteKit files -RUN npx svelte-kit sync - -# Compile paraglide messages before build -RUN npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/paraglide +# Install dependencies with flat structure for Docker compatibility +RUN pnpm install --filter @uload/web... --shamefully-hoist # Build the app -RUN npm run build +WORKDIR /app/uload/apps/web -# Production Stage -FROM node:20-alpine +# Note: RESEND_API_KEY is needed at build time for SvelteKit prerendering +ENV RESEND_API_KEY=build_placeholder +RUN pnpm build + +# ----------------------------------------------------------------------------- +# Stage 2: Production Runner +# ----------------------------------------------------------------------------- +FROM node:20-alpine AS runner + +# Security: Run as non-root user +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 sveltekit WORKDIR /app -# Copy built app and dependencies -COPY --from=builder /app/build build/ -COPY --from=builder /app/package*.json ./ -COPY --from=builder /app/node_modules node_modules/ -COPY --from=builder /app/drizzle drizzle/ +# Copy built app from the correct path +COPY --from=builder --chown=sveltekit:nodejs /app/uload/apps/web/build ./build +COPY --from=builder --chown=sveltekit:nodejs /app/uload/apps/web/package.json ./ + +# Copy hoisted node_modules from root (contains all deps with flat structure) +COPY --from=builder --chown=sveltekit:nodejs /app/node_modules ./node_modules # Environment ENV NODE_ENV=production ENV PORT=3000 +ENV HOST=0.0.0.0 # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD node -e "require('http').get('http://localhost:3000/health', (r) => {r.statusCode === 200 ? process.exit(0) : process.exit(1)})" + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1 + +# Switch to non-root user +USER sveltekit EXPOSE 3000 diff --git a/uload/apps/backend/.env.example b/uload/apps/backend/.env.example new file mode 100644 index 000000000..baa0f2313 --- /dev/null +++ b/uload/apps/backend/.env.example @@ -0,0 +1,22 @@ +# Server +NODE_ENV=development +PORT=3003 + +# Database +DATABASE_URL=postgresql://postgres:postgres@localhost:5434/uload + +# Redis (for caching) +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= + +# Mana Core Auth +MANA_SERVICE_URL=https://mana-core-middleware-111768794939.europe-west3.run.app +APP_ID=your-uload-app-id +MANA_SERVICE_KEY= + +# Frontend URL (for CORS) +FRONTEND_URL=http://localhost:5173 + +# Short URL base (for generating short links) +SHORT_URL_BASE=https://ulo.ad diff --git a/uload/apps/backend/Dockerfile b/uload/apps/backend/Dockerfile new file mode 100644 index 000000000..73d79f1c4 --- /dev/null +++ b/uload/apps/backend/Dockerfile @@ -0,0 +1,65 @@ +# Build stage +FROM node:20-alpine AS builder + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate + +WORKDIR /app + +# Copy package files +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ + +# Copy workspace packages +COPY packages/uload-database ./packages/uload-database + +# Copy backend source +COPY uload/apps/backend ./uload/apps/backend + +# Install dependencies +RUN pnpm install --frozen-lockfile + +# Build the database package first +WORKDIR /app/packages/uload-database +RUN pnpm build + +# Build the backend +WORKDIR /app/uload/apps/backend +RUN pnpm build + +# Production stage +FROM node:20-alpine AS production + +# Install dumb-init for proper signal handling +RUN apk add --no-cache dumb-init + +# Create non-root user +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nestjs + +WORKDIR /app + +# Copy built artifacts +COPY --from=builder --chown=nestjs:nodejs /app/uload/apps/backend/dist ./dist +COPY --from=builder --chown=nestjs:nodejs /app/uload/apps/backend/package.json ./ +COPY --from=builder --chown=nestjs:nodejs /app/uload/apps/backend/node_modules ./node_modules + +# Copy database package (needed at runtime) +COPY --from=builder --chown=nestjs:nodejs /app/packages/uload-database/dist ./node_modules/@manacore/uload-database/dist +COPY --from=builder --chown=nestjs:nodejs /app/packages/uload-database/package.json ./node_modules/@manacore/uload-database/ + +USER nestjs + +# Expose port +EXPOSE 3003 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3003/health || exit 1 + +# Set environment +ENV NODE_ENV=production +ENV PORT=3003 + +# Start with dumb-init +ENTRYPOINT ["dumb-init", "--"] +CMD ["node", "dist/main"] diff --git a/uload/apps/backend/nest-cli.json b/uload/apps/backend/nest-cli.json new file mode 100644 index 000000000..f9aa683b1 --- /dev/null +++ b/uload/apps/backend/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/uload/apps/backend/package.json b/uload/apps/backend/package.json new file mode 100644 index 000000000..aa4714b95 --- /dev/null +++ b/uload/apps/backend/package.json @@ -0,0 +1,71 @@ +{ + "name": "@uload/backend", + "version": "0.0.1", + "description": "ULOAD URL Shortener Backend", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:e2e": "jest --config ./test/jest-e2e.json", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@mana-core/nestjs-integration": "git+https://github.com/Memo-2023/mana-core-nestjs-package.git", + "@manacore/uload-database": "workspace:*", + "@nestjs/axios": "^4.0.1", + "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11.0.1", + "@nestjs/platform-express": "^11.0.1", + "@nestjs/terminus": "^11.0.0", + "axios": "^1.7.2", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.2", + "ioredis": "^5.4.1", + "joi": "^18.0.1", + "nanoid": "^5.0.7", + "nestjs-cls": "^6.0.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "ua-parser-js": "^2.0.0" + }, + "devDependencies": { + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.1", + "@types/express": "^5.0.0", + "@types/jest": "^30.0.0", + "@types/node": "^22.10.7", + "@types/supertest": "^6.0.2", + "@types/ua-parser-js": "^0.7.39", + "jest": "^30.0.0", + "prettier": "^3.4.2", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.9.3" + }, + "jest": { + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": ["**/*.(t|j)s"], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} diff --git a/uload/apps/backend/src/app.module.ts b/uload/apps/backend/src/app.module.ts new file mode 100644 index 000000000..d34b49dd4 --- /dev/null +++ b/uload/apps/backend/src/app.module.ts @@ -0,0 +1,80 @@ +import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { ClsModule } from 'nestjs-cls'; +import { TerminusModule } from '@nestjs/terminus'; +import { HttpModule } from '@nestjs/axios'; +import { ManaCoreModule } from '@mana-core/nestjs-integration'; + +import { validationSchema } from './config/validation.schema'; +import { DatabaseModule } from './database/database.module'; +import { LinkRepository } from './database/repositories/link.repository'; +import { ClickRepository } from './database/repositories/click.repository'; + +import { HealthController } from './controllers/health.controller'; +import { RedirectController } from './controllers/redirect.controller'; +import { LinksController } from './controllers/links.controller'; +import { AnalyticsController } from './controllers/analytics.controller'; + +import { LinksService } from './services/links.service'; +import { RedirectService } from './services/redirect.service'; +import { AnalyticsService } from './services/analytics.service'; + +@Module({ + imports: [ + // Context-Local Storage for request-scoped data + ClsModule.forRoot({ + global: true, + middleware: { mount: true, generateId: true }, + }), + + // Configuration + ConfigModule.forRoot({ + isGlobal: true, + validationSchema, + validationOptions: { + allowUnknown: true, + abortEarly: false, + }, + ignoreEnvFile: process.env.NODE_ENV === 'production', + }), + + // Mana Core Authentication + ManaCoreModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + manaServiceUrl: configService.get('MANA_SERVICE_URL')!, + appId: configService.get('APP_ID')!, + serviceKey: configService.get('MANA_SERVICE_KEY', ''), + debug: configService.get('NODE_ENV') === 'development', + }), + inject: [ConfigService], + }) as any, + + // Health checks + TerminusModule, + HttpModule, + + // Database + DatabaseModule, + ], + controllers: [ + HealthController, + RedirectController, + LinksController, + AnalyticsController, + ], + providers: [ + // Repositories + LinkRepository, + ClickRepository, + // Services + LinksService, + RedirectService, + AnalyticsService, + ], +}) +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + // Add custom middleware here if needed + } +} diff --git a/uload/apps/backend/src/config/validation.schema.ts b/uload/apps/backend/src/config/validation.schema.ts new file mode 100644 index 000000000..11f5397d1 --- /dev/null +++ b/uload/apps/backend/src/config/validation.schema.ts @@ -0,0 +1,28 @@ +import * as Joi from 'joi'; + +export const validationSchema = Joi.object({ + // Server + NODE_ENV: Joi.string() + .valid('development', 'production', 'test') + .default('development'), + PORT: Joi.number().default(3003), + + // Database + DATABASE_URL: Joi.string().uri().required(), + + // Redis + REDIS_HOST: Joi.string().default('localhost'), + REDIS_PORT: Joi.number().default(6379), + REDIS_PASSWORD: Joi.string().allow('').optional(), + + // Mana Core Auth + MANA_SERVICE_URL: Joi.string().uri().required(), + APP_ID: Joi.string().uuid().required(), + MANA_SERVICE_KEY: Joi.string().allow('').optional(), + + // Frontend + FRONTEND_URL: Joi.string().uri().optional(), + + // Short URL + SHORT_URL_BASE: Joi.string().uri().default('https://ulo.ad'), +}); diff --git a/uload/apps/backend/src/controllers/analytics.controller.ts b/uload/apps/backend/src/controllers/analytics.controller.ts new file mode 100644 index 000000000..74cb6eb0b --- /dev/null +++ b/uload/apps/backend/src/controllers/analytics.controller.ts @@ -0,0 +1,98 @@ +import { + Controller, + Get, + Param, + Query, + UseGuards, + NotFoundException, + ForbiddenException, +} from '@nestjs/common'; +import { AuthGuard, CurrentUser } from '@mana-core/nestjs-integration'; +import { AnalyticsService } from '../services/analytics.service'; +import { LinksService } from '../services/links.service'; + +@Controller('api/analytics') +@UseGuards(AuthGuard) +export class AnalyticsController { + constructor( + private readonly analyticsService: AnalyticsService, + private readonly linksService: LinksService, + ) {} + + @Get('links/:linkId') + async getLinkAnalytics( + @CurrentUser() user: any, + @Param('linkId') linkId: string, + @Query('from') fromDate?: string, + @Query('to') toDate?: string, + ) { + const userId = user.sub; + + // Verify user owns the link + const link = await this.linksService.getLinkById(linkId, userId); + if (!link) { + throw new NotFoundException('Link not found'); + } + + const stats = await this.analyticsService.getStats( + linkId, + fromDate ? new Date(fromDate) : undefined, + toDate ? new Date(toDate) : undefined, + ); + + return { + success: true, + data: { + linkId, + shortCode: link.shortCode, + stats, + }, + }; + } + + @Get('links/:linkId/clicks') + async getLinkClicks( + @CurrentUser() user: any, + @Param('linkId') linkId: string, + @Query('limit') limit: number = 100, + ) { + const userId = user.sub; + + // Verify user owns the link + const link = await this.linksService.getLinkById(linkId, userId); + if (!link) { + throw new NotFoundException('Link not found'); + } + + const { clicks, total } = await this.analyticsService.getRecentClicks( + linkId, + limit, + ); + + return { + success: true, + data: { + linkId, + clicks: clicks.map((click) => ({ + ...click, + ipHash: undefined, // Don't expose IP hash + })), + total, + }, + }; + } + + @Get('overview') + async getOverview(@CurrentUser() user: any) { + const userId = user.sub; + const totalLinks = await this.linksService.getLinkCount(userId); + + return { + success: true, + data: { + totalLinks, + // Add more overview stats as needed + }, + }; + } +} diff --git a/uload/apps/backend/src/controllers/health.controller.ts b/uload/apps/backend/src/controllers/health.controller.ts new file mode 100644 index 000000000..df27ae8c6 --- /dev/null +++ b/uload/apps/backend/src/controllers/health.controller.ts @@ -0,0 +1,35 @@ +import { Controller, Get } from '@nestjs/common'; +import { + HealthCheckService, + HealthCheck, + HealthCheckResult, +} from '@nestjs/terminus'; + +@Controller('health') +export class HealthController { + constructor(private health: HealthCheckService) {} + + @Get() + @HealthCheck() + check(): Promise { + return this.health.check([]); + } + + @Get('ready') + ready() { + return { + status: 'ready', + timestamp: new Date().toISOString(), + }; + } + + @Get('live') + live() { + return { + status: 'live', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + environment: process.env.NODE_ENV || 'development', + }; + } +} diff --git a/uload/apps/backend/src/controllers/links.controller.ts b/uload/apps/backend/src/controllers/links.controller.ts new file mode 100644 index 000000000..a8d04b58e --- /dev/null +++ b/uload/apps/backend/src/controllers/links.controller.ts @@ -0,0 +1,131 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + UseGuards, + NotFoundException, +} from '@nestjs/common'; +import { AuthGuard, CurrentUser } from '@mana-core/nestjs-integration'; +import { LinksService, type CreateLinkDto, type UpdateLinkDto } from '../services/links.service'; + +@Controller('api/links') +@UseGuards(AuthGuard) +export class LinksController { + constructor(private readonly linksService: LinksService) {} + + @Get() + async getLinks( + @CurrentUser() user: any, + @Query('page') page: number = 1, + @Query('limit') limit: number = 20, + @Query('search') search?: string, + @Query('isActive') isActive?: boolean, + ) { + const userId = user.sub; + const { items, total } = await this.linksService.getLinks(userId, { + page, + limit, + search, + isActive, + }); + + return { + success: true, + data: { + links: items.map((link) => ({ + ...link, + shortUrl: this.linksService.getShortUrl(link.shortCode), + hasPassword: !!link.password, + password: undefined, // Never send password to client + })), + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + hasMore: page * limit < total, + }, + }, + }; + } + + @Get(':id') + async getLink(@CurrentUser() user: any, @Param('id') id: string) { + const userId = user.sub; + const link = await this.linksService.getLinkById(id, userId); + + if (!link) { + throw new NotFoundException('Link not found'); + } + + return { + success: true, + data: { + ...link, + shortUrl: this.linksService.getShortUrl(link.shortCode), + hasPassword: !!link.password, + password: undefined, + }, + }; + } + + @Post() + async createLink(@CurrentUser() user: any, @Body() dto: CreateLinkDto) { + const userId = user.sub; + const link = await this.linksService.createLink(userId, dto); + + return { + success: true, + data: { + ...link, + shortUrl: this.linksService.getShortUrl(link.shortCode), + hasPassword: !!link.password, + password: undefined, + }, + }; + } + + @Patch(':id') + async updateLink( + @CurrentUser() user: any, + @Param('id') id: string, + @Body() dto: UpdateLinkDto, + ) { + const userId = user.sub; + const link = await this.linksService.updateLink(id, userId, dto); + + if (!link) { + throw new NotFoundException('Link not found'); + } + + return { + success: true, + data: { + ...link, + shortUrl: this.linksService.getShortUrl(link.shortCode), + hasPassword: !!link.password, + password: undefined, + }, + }; + } + + @Delete(':id') + async deleteLink(@CurrentUser() user: any, @Param('id') id: string) { + const userId = user.sub; + const deleted = await this.linksService.deleteLink(id, userId); + + if (!deleted) { + throw new NotFoundException('Link not found'); + } + + return { + success: true, + message: 'Link deleted successfully', + }; + } +} diff --git a/uload/apps/backend/src/controllers/redirect.controller.ts b/uload/apps/backend/src/controllers/redirect.controller.ts new file mode 100644 index 000000000..10a3076ae --- /dev/null +++ b/uload/apps/backend/src/controllers/redirect.controller.ts @@ -0,0 +1,113 @@ +import { + Controller, + Get, + Post, + Param, + Body, + Req, + Res, + HttpStatus, + Query, +} from '@nestjs/common'; +import { Response, Request } from 'express'; +import { RedirectService } from '../services/redirect.service'; +import { AnalyticsService } from '../services/analytics.service'; + +@Controller() +export class RedirectController { + constructor( + private readonly redirectService: RedirectService, + private readonly analyticsService: AnalyticsService, + ) {} + + @Get(':code') + async redirect( + @Param('code') code: string, + @Query('utm_source') utmSource: string, + @Query('utm_medium') utmMedium: string, + @Query('utm_campaign') utmCampaign: string, + @Req() request: Request, + @Res() response: Response, + ) { + // Skip for API and health routes + if (code === 'v1' || code === 'health') { + return response.status(HttpStatus.NOT_FOUND).json({ + success: false, + error: 'not_found', + }); + } + + const result = await this.redirectService.getRedirect(code); + + if (!result.success) { + switch (result.error) { + case 'not_found': + return response.status(HttpStatus.NOT_FOUND).json({ + success: false, + error: 'Link not found', + }); + + case 'expired': + return response.status(HttpStatus.GONE).json({ + success: false, + error: 'This link has expired', + }); + + case 'inactive': + return response.status(HttpStatus.GONE).json({ + success: false, + error: 'This link is no longer active', + }); + + case 'max_clicks': + return response.status(HttpStatus.GONE).json({ + success: false, + error: 'This link has reached its maximum clicks', + }); + + case 'password_required': + return response.status(HttpStatus.OK).json({ + success: false, + passwordRequired: true, + linkId: result.linkId, + }); + } + } + + // Record click asynchronously (don't wait) + this.analyticsService + .recordClick(result.linkId!, { + userAgent: request.headers['user-agent'] || '', + referer: request.headers['referer'] || '', + ip: request.ip, + utmSource, + utmMedium, + utmCampaign, + }) + .catch((err) => console.error('Failed to record click:', err)); + + // Perform redirect + return response.redirect(302, result.targetUrl!); + } + + @Post(':code/unlock') + async unlockLink( + @Param('code') code: string, + @Body('password') password: string, + @Res() response: Response, + ) { + const result = await this.redirectService.verifyPassword(code, password); + + if (!result.success) { + return response.status(HttpStatus.UNAUTHORIZED).json({ + success: false, + error: 'Invalid password', + }); + } + + return response.json({ + success: true, + targetUrl: result.targetUrl, + }); + } +} diff --git a/uload/apps/backend/src/database/database.module.ts b/uload/apps/backend/src/database/database.module.ts new file mode 100644 index 000000000..f2bc34145 --- /dev/null +++ b/uload/apps/backend/src/database/database.module.ts @@ -0,0 +1,29 @@ +import { Module, Global, OnModuleDestroy, Logger } from '@nestjs/common'; +import { getDb, closeDb, type Database } from '@manacore/uload-database'; + +export const DATABASE_TOKEN = 'DATABASE'; + +@Global() +@Module({ + providers: [ + { + provide: DATABASE_TOKEN, + useFactory: () => { + const logger = new Logger('DatabaseModule'); + logger.log('Initializing database connection'); + return getDb(); + }, + }, + ], + exports: [DATABASE_TOKEN], +}) +export class DatabaseModule implements OnModuleDestroy { + private readonly logger = new Logger(DatabaseModule.name); + + async onModuleDestroy() { + this.logger.log('Closing database connection'); + await closeDb(); + } +} + +export type { Database }; diff --git a/uload/apps/backend/src/database/repositories/click.repository.ts b/uload/apps/backend/src/database/repositories/click.repository.ts new file mode 100644 index 000000000..831398d2b --- /dev/null +++ b/uload/apps/backend/src/database/repositories/click.repository.ts @@ -0,0 +1,162 @@ +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { DATABASE_TOKEN, type Database } from '../database.module'; +import { + clicks, + type Click, + type NewClick, + eq, + desc, + sql, + and, + gte, + lte, +} from '@manacore/uload-database'; + +export interface ClickStats { + totalClicks: number; + uniqueVisitors: number; + topCountries: { country: string; count: number }[]; + topBrowsers: { browser: string; count: number }[]; + topDevices: { deviceType: string; count: number }[]; + clicksByDay: { date: string; count: number }[]; +} + +@Injectable() +export class ClickRepository { + private readonly logger = new Logger(ClickRepository.name); + + constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {} + + async create(data: NewClick): Promise { + const result = await this.db.insert(clicks).values(data).returning(); + return result[0]; + } + + async findByLinkId( + linkId: string, + options: { limit?: number; offset?: number } = {}, + ): Promise { + const { limit = 100, offset = 0 } = options; + return this.db + .select() + .from(clicks) + .where(eq(clicks.linkId, linkId)) + .orderBy(desc(clicks.clickedAt)) + .limit(limit) + .offset(offset); + } + + async countByLinkId(linkId: string): Promise { + const result = await this.db + .select({ count: sql`count(*)::int` }) + .from(clicks) + .where(eq(clicks.linkId, linkId)); + return result[0]?.count || 0; + } + + async getStats( + linkId: string, + fromDate?: Date, + toDate?: Date, + ): Promise { + const conditions = [eq(clicks.linkId, linkId)]; + + if (fromDate) { + conditions.push(gte(clicks.clickedAt, fromDate)); + } + if (toDate) { + conditions.push(lte(clicks.clickedAt, toDate)); + } + + const whereClause = and(...conditions); + + // Total clicks + const totalResult = await this.db + .select({ count: sql`count(*)::int` }) + .from(clicks) + .where(whereClause); + + // Unique visitors (by IP hash) + const uniqueResult = await this.db + .select({ count: sql`count(distinct ${clicks.ipHash})::int` }) + .from(clicks) + .where(whereClause); + + // Top countries + const countriesResult = await this.db + .select({ + country: clicks.country, + count: sql`count(*)::int`, + }) + .from(clicks) + .where(whereClause) + .groupBy(clicks.country) + .orderBy(sql`count(*) desc`) + .limit(10); + + // Top browsers + const browsersResult = await this.db + .select({ + browser: clicks.browser, + count: sql`count(*)::int`, + }) + .from(clicks) + .where(whereClause) + .groupBy(clicks.browser) + .orderBy(sql`count(*) desc`) + .limit(10); + + // Top devices + const devicesResult = await this.db + .select({ + deviceType: clicks.deviceType, + count: sql`count(*)::int`, + }) + .from(clicks) + .where(whereClause) + .groupBy(clicks.deviceType) + .orderBy(sql`count(*) desc`) + .limit(10); + + // Clicks by day (last 30 days) + const clicksByDayResult = await this.db + .select({ + date: sql`date_trunc('day', ${clicks.clickedAt})::date::text`, + count: sql`count(*)::int`, + }) + .from(clicks) + .where(whereClause) + .groupBy(sql`date_trunc('day', ${clicks.clickedAt})`) + .orderBy(sql`date_trunc('day', ${clicks.clickedAt})`) + .limit(30); + + return { + totalClicks: totalResult[0]?.count || 0, + uniqueVisitors: uniqueResult[0]?.count || 0, + topCountries: countriesResult.map((r) => ({ + country: r.country || 'Unknown', + count: r.count, + })), + topBrowsers: browsersResult.map((r) => ({ + browser: r.browser || 'Unknown', + count: r.count, + })), + topDevices: devicesResult.map((r) => ({ + deviceType: r.deviceType || 'Unknown', + count: r.count, + })), + clicksByDay: clicksByDayResult.map((r) => ({ + date: r.date, + count: r.count, + })), + }; + } + + async deleteByLinkId(linkId: string): Promise { + const result = await this.db + .delete(clicks) + .where(eq(clicks.linkId, linkId)) + .returning({ id: clicks.id }); + return result.length; + } +} diff --git a/uload/apps/backend/src/database/repositories/index.ts b/uload/apps/backend/src/database/repositories/index.ts new file mode 100644 index 000000000..119d02c0d --- /dev/null +++ b/uload/apps/backend/src/database/repositories/index.ts @@ -0,0 +1,2 @@ +export { LinkRepository, type ListLinksOptions } from './link.repository'; +export { ClickRepository, type ClickStats } from './click.repository'; diff --git a/uload/apps/backend/src/database/repositories/link.repository.ts b/uload/apps/backend/src/database/repositories/link.repository.ts new file mode 100644 index 000000000..b9c482706 --- /dev/null +++ b/uload/apps/backend/src/database/repositories/link.repository.ts @@ -0,0 +1,148 @@ +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { DATABASE_TOKEN, type Database } from '../database.module'; +import { + links, + type Link, + type NewLink, + eq, + and, + desc, + sql, + or, + ilike, +} from '@manacore/uload-database'; + +export interface ListLinksOptions { + page?: number; + limit?: number; + search?: string; + isActive?: boolean; +} + +@Injectable() +export class LinkRepository { + private readonly logger = new Logger(LinkRepository.name); + + constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {} + + async findByShortCode(shortCode: string): Promise { + const result = await this.db + .select() + .from(links) + .where(eq(links.shortCode, shortCode)) + .limit(1); + return result[0] || null; + } + + async findById(id: string): Promise { + const result = await this.db + .select() + .from(links) + .where(eq(links.id, id)) + .limit(1); + return result[0] || null; + } + + async findByIdAndUserId(id: string, userId: string): Promise { + const result = await this.db + .select() + .from(links) + .where(and(eq(links.id, id), eq(links.userId, userId))) + .limit(1); + return result[0] || null; + } + + async findByUserId( + userId: string, + options: ListLinksOptions = {}, + ): Promise<{ items: Link[]; total: number }> { + const { page = 1, limit = 20, search, isActive } = options; + const offset = (page - 1) * limit; + + const conditions = [eq(links.userId, userId)]; + + if (search) { + conditions.push( + or( + ilike(links.title, `%${search}%`), + ilike(links.originalUrl, `%${search}%`), + ilike(links.shortCode, `%${search}%`), + )!, + ); + } + + if (isActive !== undefined) { + conditions.push(eq(links.isActive, isActive)); + } + + const [countResult, items] = await Promise.all([ + this.db + .select({ count: sql`count(*)::int` }) + .from(links) + .where(and(...conditions)), + this.db + .select() + .from(links) + .where(and(...conditions)) + .orderBy(desc(links.createdAt)) + .limit(limit) + .offset(offset), + ]); + + return { + items, + total: countResult[0]?.count || 0, + }; + } + + async create(data: NewLink): Promise { + this.logger.debug(`Creating link: ${data.shortCode}`); + const result = await this.db.insert(links).values(data).returning(); + return result[0]; + } + + async update( + id: string, + userId: string, + data: Partial>, + ): Promise { + const result = await this.db + .update(links) + .set({ ...data, updatedAt: new Date() }) + .where(and(eq(links.id, id), eq(links.userId, userId))) + .returning(); + return result[0] || null; + } + + async delete(id: string, userId: string): Promise { + const result = await this.db + .delete(links) + .where(and(eq(links.id, id), eq(links.userId, userId))) + .returning({ id: links.id }); + return result.length > 0; + } + + async incrementClickCount(id: string): Promise { + await this.db + .update(links) + .set({ clickCount: sql`${links.clickCount} + 1` }) + .where(eq(links.id, id)); + } + + async isShortCodeAvailable(shortCode: string): Promise { + const result = await this.db + .select({ id: links.id }) + .from(links) + .where(eq(links.shortCode, shortCode)) + .limit(1); + return result.length === 0; + } + + async countByUserId(userId: string): Promise { + const result = await this.db + .select({ count: sql`count(*)::int` }) + .from(links) + .where(eq(links.userId, userId)); + return result[0]?.count || 0; + } +} diff --git a/uload/apps/backend/src/main.ts b/uload/apps/backend/src/main.ts new file mode 100644 index 000000000..afce6d962 --- /dev/null +++ b/uload/apps/backend/src/main.ts @@ -0,0 +1,47 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const logger = new Logger('Bootstrap'); + + const app = await NestFactory.create(AppModule, { + logger: ['error', 'warn', 'log', 'debug', 'verbose'], + }); + + const configService = app.get(ConfigService); + + // CORS configuration + app.enableCors({ + origin: configService.get('FRONTEND_URL') || true, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'], + }); + + // Global validation pipe + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { + enableImplicitConversion: true, + }, + }), + ); + + // Global prefix for API routes (except health and redirect) + app.setGlobalPrefix('v1', { + exclude: ['health', 'health/(.*)', ':code'], + }); + + const port = configService.get('PORT') || 3003; + + await app.listen(port); + logger.log(`ULOAD Backend running on port ${port}`); + logger.log(`Health check: http://localhost:${port}/health`); +} + +bootstrap(); diff --git a/uload/apps/backend/src/services/analytics.service.ts b/uload/apps/backend/src/services/analytics.service.ts new file mode 100644 index 000000000..9ae261103 --- /dev/null +++ b/uload/apps/backend/src/services/analytics.service.ts @@ -0,0 +1,102 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as UAParser from 'ua-parser-js'; +import { ClickRepository, type ClickStats } from '../database/repositories'; +import { RedirectService } from './redirect.service'; +import type { NewClick } from '@manacore/uload-database'; + +export interface RecordClickData { + userAgent: string; + referer?: string; + ip?: string; + utmSource?: string; + utmMedium?: string; + utmCampaign?: string; +} + +@Injectable() +export class AnalyticsService { + private readonly logger = new Logger(AnalyticsService.name); + + constructor( + private readonly clickRepository: ClickRepository, + private readonly redirectService: RedirectService, + ) {} + + async recordClick(linkId: string, data: RecordClickData): Promise { + try { + // Parse user agent + const parser = new UAParser.UAParser(data.userAgent); + const browser = parser.getBrowser(); + const os = parser.getOS(); + const device = parser.getDevice(); + + // Hash IP for privacy + const ipHash = data.ip ? this.hashIp(data.ip) : null; + + // Determine device type + let deviceType = 'desktop'; + if (device.type === 'mobile') { + deviceType = 'mobile'; + } else if (device.type === 'tablet') { + deviceType = 'tablet'; + } + + const clickData: NewClick = { + linkId, + ipHash, + userAgent: data.userAgent, + referer: data.referer, + browser: browser.name || 'Unknown', + deviceType, + os: os.name || 'Unknown', + // TODO: Geo lookup from IP + country: null, + city: null, + utmSource: data.utmSource, + utmMedium: data.utmMedium, + utmCampaign: data.utmCampaign, + }; + + await this.clickRepository.create(clickData); + + // Increment click count on the link + await this.redirectService.incrementClickCount(linkId); + + this.logger.debug(`Recorded click for link ${linkId}`); + } catch (error) { + this.logger.error(`Failed to record click for link ${linkId}:`, error); + // Don't throw - click recording should not block redirect + } + } + + async getStats( + linkId: string, + fromDate?: Date, + toDate?: Date, + ): Promise { + return this.clickRepository.getStats(linkId, fromDate, toDate); + } + + async getRecentClicks( + linkId: string, + limit: number = 100, + ): Promise<{ clicks: any[]; total: number }> { + const [clicks, total] = await Promise.all([ + this.clickRepository.findByLinkId(linkId, { limit }), + this.clickRepository.countByLinkId(linkId), + ]); + + return { clicks, total }; + } + + private hashIp(ip: string): string { + // Simple hash for privacy - in production use a proper hash function + let hash = 0; + for (let i = 0; i < ip.length; i++) { + const char = ip.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32bit integer + } + return hash.toString(16); + } +} diff --git a/uload/apps/backend/src/services/links.service.ts b/uload/apps/backend/src/services/links.service.ts new file mode 100644 index 000000000..4fbb8298b --- /dev/null +++ b/uload/apps/backend/src/services/links.service.ts @@ -0,0 +1,144 @@ +import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { nanoid } from 'nanoid'; +import { LinkRepository, type ListLinksOptions } from '../database/repositories'; +import type { Link, NewLink } from '@manacore/uload-database'; + +export interface CreateLinkDto { + originalUrl: string; + customCode?: string; + title?: string; + description?: string; + password?: string; + maxClicks?: number; + expiresAt?: Date; + tags?: string[]; + utmSource?: string; + utmMedium?: string; + utmCampaign?: string; + workspaceId?: string; +} + +export interface UpdateLinkDto { + title?: string; + description?: string; + password?: string; + maxClicks?: number; + expiresAt?: Date; + isActive?: boolean; + tags?: string[]; + utmSource?: string; + utmMedium?: string; + utmCampaign?: string; +} + +@Injectable() +export class LinksService { + private readonly logger = new Logger(LinksService.name); + private readonly shortUrlBase: string; + + constructor( + private readonly linkRepository: LinkRepository, + private readonly configService: ConfigService, + ) { + this.shortUrlBase = this.configService.get('SHORT_URL_BASE', 'https://ulo.ad'); + } + + async createLink(userId: string, dto: CreateLinkDto): Promise { + // Generate or validate short code + let shortCode = dto.customCode; + + if (shortCode) { + // Validate custom code format + if (!/^[a-zA-Z0-9_-]+$/.test(shortCode)) { + throw new BadRequestException( + 'Custom code can only contain letters, numbers, hyphens and underscores', + ); + } + + // Check if custom code is available + const isAvailable = await this.linkRepository.isShortCodeAvailable(shortCode); + if (!isAvailable) { + throw new BadRequestException('This custom code is already taken'); + } + } else { + // Generate random short code + shortCode = nanoid(7); + + // Make sure it's unique (very unlikely to collide, but check anyway) + let attempts = 0; + while ( + !(await this.linkRepository.isShortCodeAvailable(shortCode)) && + attempts < 5 + ) { + shortCode = nanoid(7); + attempts++; + } + } + + const newLink: NewLink = { + shortCode, + customCode: dto.customCode, + originalUrl: dto.originalUrl, + title: dto.title, + description: dto.description, + userId, + password: dto.password, // TODO: Hash password if provided + maxClicks: dto.maxClicks, + expiresAt: dto.expiresAt, + tags: dto.tags, + utmSource: dto.utmSource, + utmMedium: dto.utmMedium, + utmCampaign: dto.utmCampaign, + workspaceId: dto.workspaceId, + }; + + const link = await this.linkRepository.create(newLink); + this.logger.log(`Created link ${link.shortCode} for user ${userId}`); + + return link; + } + + async updateLink( + id: string, + userId: string, + dto: UpdateLinkDto, + ): Promise { + const link = await this.linkRepository.update(id, userId, dto); + + if (link) { + this.logger.log(`Updated link ${link.shortCode} for user ${userId}`); + } + + return link; + } + + async deleteLink(id: string, userId: string): Promise { + const deleted = await this.linkRepository.delete(id, userId); + + if (deleted) { + this.logger.log(`Deleted link ${id} for user ${userId}`); + } + + return deleted; + } + + async getLinkById(id: string, userId: string): Promise { + return this.linkRepository.findByIdAndUserId(id, userId); + } + + async getLinks( + userId: string, + options: ListLinksOptions, + ): Promise<{ items: Link[]; total: number }> { + return this.linkRepository.findByUserId(userId, options); + } + + async getLinkCount(userId: string): Promise { + return this.linkRepository.countByUserId(userId); + } + + getShortUrl(shortCode: string): string { + return `${this.shortUrlBase}/${shortCode}`; + } +} diff --git a/uload/apps/backend/src/services/redirect.service.ts b/uload/apps/backend/src/services/redirect.service.ts new file mode 100644 index 000000000..17b83a8ed --- /dev/null +++ b/uload/apps/backend/src/services/redirect.service.ts @@ -0,0 +1,77 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { LinkRepository } from '../database/repositories'; +import type { Link } from '@manacore/uload-database'; + +export interface RedirectResult { + success: boolean; + targetUrl?: string; + linkId?: string; + error?: 'not_found' | 'expired' | 'inactive' | 'max_clicks' | 'password_required'; +} + +@Injectable() +export class RedirectService { + private readonly logger = new Logger(RedirectService.name); + + constructor(private readonly linkRepository: LinkRepository) {} + + async getRedirect(shortCode: string): Promise { + const link = await this.linkRepository.findByShortCode(shortCode); + + if (!link) { + return { success: false, error: 'not_found' }; + } + + // Check if link is active + if (!link.isActive) { + return { success: false, error: 'inactive', linkId: link.id }; + } + + // Check if link has expired + if (link.expiresAt && new Date(link.expiresAt) < new Date()) { + return { success: false, error: 'expired', linkId: link.id }; + } + + // Check max clicks + if (link.maxClicks && (link.clickCount ?? 0) >= link.maxClicks) { + return { success: false, error: 'max_clicks', linkId: link.id }; + } + + // Check if password protected + if (link.password) { + return { success: false, error: 'password_required', linkId: link.id }; + } + + return { + success: true, + targetUrl: link.originalUrl, + linkId: link.id, + }; + } + + async verifyPassword( + shortCode: string, + password: string, + ): Promise { + const link = await this.linkRepository.findByShortCode(shortCode); + + if (!link) { + return { success: false, error: 'not_found' }; + } + + // TODO: Compare hashed passwords + if (link.password !== password) { + return { success: false, error: 'password_required', linkId: link.id }; + } + + return { + success: true, + targetUrl: link.originalUrl, + linkId: link.id, + }; + } + + async incrementClickCount(linkId: string): Promise { + await this.linkRepository.incrementClickCount(linkId); + } +} diff --git a/uload/apps/backend/tsconfig.json b/uload/apps/backend/tsconfig.json new file mode 100644 index 000000000..5c48f6334 --- /dev/null +++ b/uload/apps/backend/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "resolveJsonModule": true + } +} diff --git a/uload/apps/landing/astro.config.mjs b/uload/apps/landing/astro.config.mjs new file mode 100644 index 000000000..c517fdcfa --- /dev/null +++ b/uload/apps/landing/astro.config.mjs @@ -0,0 +1,20 @@ +import { defineConfig } from 'astro/config'; +import tailwind from '@astrojs/tailwind'; +import mdx from '@astrojs/mdx'; +import sitemap from '@astrojs/sitemap'; + +export default defineConfig({ + site: 'https://ulo.ad', + integrations: [ + tailwind(), + mdx(), + sitemap() + ], + i18n: { + defaultLocale: 'de', + locales: ['de', 'en'], + routing: { + prefixDefaultLocale: false + } + } +}); diff --git a/uload/apps/landing/package.json b/uload/apps/landing/package.json new file mode 100644 index 000000000..70db703ee --- /dev/null +++ b/uload/apps/landing/package.json @@ -0,0 +1,24 @@ +{ + "name": "@uload/landing", + "type": "module", + "version": "1.0.0", + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "astro": "astro", + "check": "astro check" + }, + "dependencies": { + "@astrojs/check": "^0.9.4", + "@astrojs/mdx": "^4.0.8", + "@astrojs/sitemap": "^3.2.1", + "@astrojs/tailwind": "^6.0.2", + "astro": "^5.1.1", + "tailwindcss": "^3.4.17" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.7.2" + } +} diff --git a/uload/apps/landing/src/components/FeaturesSection.astro b/uload/apps/landing/src/components/FeaturesSection.astro new file mode 100644 index 000000000..1785bc3e2 --- /dev/null +++ b/uload/apps/landing/src/components/FeaturesSection.astro @@ -0,0 +1,76 @@ +--- +const features = [ + { + icon: '🔗', + title: 'URL-Verkürzung', + description: 'Verwandeln Sie lange URLs in kurze, teilbare Links mit nur einem Klick. Perfekt für Social Media und Marketing.' + }, + { + icon: '📊', + title: 'Detaillierte Analytics', + description: 'Verfolgen Sie Klicks, geografische Herkunft, Geräte und Engagement Ihrer Links in Echtzeit.' + }, + { + icon: '🎨', + title: 'QR-Code Generator', + description: 'Erstellen Sie anpassbare QR-Codes in verschiedenen Farben und Formaten für jeden Link.' + }, + { + icon: '💳', + title: 'Digitale Visitenkarten', + description: 'Erstellen Sie professionelle digitale Visitenkarten mit QR-Codes und Kontaktinformationen.' + }, + { + icon: '🔒', + title: 'Passwortschutz', + description: 'Schützen Sie Ihre Links mit Passwörtern und setzen Sie Ablaufdaten für zeitlich begrenzte Aktionen.' + }, + { + icon: '🏷️', + title: 'Tag-System', + description: 'Organisieren Sie Ihre Links mit Tags und Kategorien für eine bessere Übersicht und Filterung.' + }, + { + icon: '👥', + title: 'Team Workspaces', + description: 'Arbeiten Sie im Team zusammen mit gemeinsamen Workspaces und granularen Berechtigungen.' + }, + { + icon: '⚡', + title: 'Blitzschnell', + description: 'Unsere Links sind weltweit über ein CDN verteilt für minimale Ladezeiten und maximale Verfügbarkeit.' + }, + { + icon: '🔌', + title: 'API Zugang', + description: 'Integrieren Sie uLoad in Ihre Anwendungen mit unserer RESTful API für automatisierte Workflows.' + } +]; +--- + +
+
+
+

+ Alles was du für professionelles Link-Management brauchst +

+

+ Von einfacher URL-Verkürzung bis hin zu Team-Kollaboration – uLoad bietet alle Features die du brauchst. +

+
+ +
+ {features.map(feature => ( +
+
{feature.icon}
+

+ {feature.title} +

+

+ {feature.description} +

+
+ ))} +
+
+
diff --git a/uload/apps/landing/src/components/Footer.astro b/uload/apps/landing/src/components/Footer.astro new file mode 100644 index 000000000..6fad092df --- /dev/null +++ b/uload/apps/landing/src/components/Footer.astro @@ -0,0 +1,95 @@ +--- +const currentYear = new Date().getFullYear(); + +const footerLinks = { + produkt: [ + { href: '/features', label: 'Features' }, + { href: '/#pricing', label: 'Preise' }, + { href: '/blog', label: 'Blog' }, + ], + unternehmen: [ + { href: '/about', label: 'Über uns' }, + ], + rechtliches: [ + { href: '/datenschutz', label: 'Datenschutz' }, + { href: '/impressum', label: 'Impressum' }, + { href: '/agb', label: 'AGB' }, + { href: '/sicherheit', label: 'Sicherheit' }, + ], +}; + +const appUrl = 'https://app.ulo.ad'; +--- + +
+
+
+ +
+ +
+ u +
+ uLoad +
+

+ Der intelligente URL-Shortener für Profis. Verkürzen Sie Links, erstellen Sie QR-Codes und analysieren Sie Klicks. +

+
+ + +
+

Produkt

+ +
+ + +
+

Unternehmen

+ +
+ + +
+

Rechtliches

+ +
+
+ + +
+

+ © {currentYear} uLoad. Alle Rechte vorbehalten. +

+ +
+
+
diff --git a/uload/apps/landing/src/components/HeroSection.astro b/uload/apps/landing/src/components/HeroSection.astro new file mode 100644 index 000000000..c1cd3b29f --- /dev/null +++ b/uload/apps/landing/src/components/HeroSection.astro @@ -0,0 +1,138 @@ +--- +const appUrl = 'https://app.ulo.ad'; +--- + +
+ +
+
+
+
+ +
+
+ +
+ + + + + DSGVO-konform + + + + + + Blitzschnell + + + + + + 100% Sicher + +
+ + +

+ More than links. + + Your digital identity. + +

+ +

+ Der einzige Link-Shortener mit integriertem Profile-Builder. + Erstelle kurze Links, beeindruckende Profilkarten und manage alles im Team. +

+ + + + + +
+ +

+ Keine Anmeldung erforderlich • Kostenlos • QR-Code inklusive +

+
+
+ + +
+ +
+
+ + + +
+

Smart Links

+

+ Kurze URLs mit Tracking, Ablaufdatum und Passwortschutz +

+ + Mehr erfahren → + +
+ + +
+
+ + + +
+

Profile Cards

+

+ Beeindruckende Profilseiten mit Drag & Drop Builder +

+ + Templates ansehen → + +
+ + +
+
+ + + +
+

Team Workspace

+

+ Gemeinsam Links verwalten mit granularen Berechtigungen +

+ + Für Teams → + +
+
+
+
diff --git a/uload/apps/landing/src/components/Navigation.astro b/uload/apps/landing/src/components/Navigation.astro new file mode 100644 index 000000000..350523212 --- /dev/null +++ b/uload/apps/landing/src/components/Navigation.astro @@ -0,0 +1,87 @@ +--- +const navLinks = [ + { href: '/features', label: 'Features' }, + { href: '/blog', label: 'Blog' }, + { href: '/about', label: 'Über uns' }, +]; + +const appUrl = 'https://app.ulo.ad'; +--- + +
+ +
+ + diff --git a/uload/apps/landing/src/components/PricingSection.astro b/uload/apps/landing/src/components/PricingSection.astro new file mode 100644 index 000000000..c321fa31e --- /dev/null +++ b/uload/apps/landing/src/components/PricingSection.astro @@ -0,0 +1,185 @@ +--- +const appUrl = 'https://app.ulo.ad'; + +const plans = [ + { + id: 'free', + name: 'Free', + price: 0, + period: '/Monat', + description: 'Perfekt zum Ausprobieren', + features: [ + '10 Links pro Monat', + 'Basis Analytics', + 'QR-Code Generator', + 'Link Anpassung', + 'Standard Support' + ], + cta: 'Kostenlos starten', + highlighted: false, + href: `${appUrl}/register` + }, + { + id: 'pro-monthly', + name: 'Pro', + price: 4.99, + period: '/Monat', + description: 'Für Freelancer & Creators', + features: [ + 'Unbegrenzte Links', + 'Erweiterte Analytics', + 'Custom QR Codes', + 'Link Anpassung', + 'Priority Support', + 'API Zugang' + ], + cta: 'Pro wählen', + highlighted: false, + href: `${appUrl}/register?plan=pro` + }, + { + id: 'pro-yearly', + name: 'Pro Jährlich', + price: 3.33, + period: '/Monat', + description: 'Beste Wahl für Power User', + features: [ + 'Unbegrenzte Links', + 'Erweiterte Analytics', + 'Custom QR Codes', + 'Link Anpassung', + 'Priority Support', + 'API Zugang' + ], + cta: 'Jährlich sparen', + highlighted: true, + badge: 'Spare 20€/Jahr', + href: `${appUrl}/register?plan=pro-yearly` + }, + { + id: 'lifetime', + name: 'Pro Lifetime', + price: 129.99, + period: 'einmalig', + description: 'Einmalig zahlen, für immer nutzen', + features: [ + 'Alle Pro Features', + 'Lebenslanger Zugang', + 'Alle zukünftigen Features', + 'Early Access', + 'Priority Support' + ], + cta: 'Lifetime sichern', + highlighted: false, + badge: 'Einmalig', + href: `${appUrl}/register?plan=lifetime` + } +]; + +function formatPrice(price: number): string { + return new Intl.NumberFormat('de-DE', { + style: 'currency', + currency: 'EUR', + minimumFractionDigits: price % 1 === 0 ? 0 : 2 + }).format(price); +} +--- + +
+
+
+

+ Transparente Preise, keine versteckten Kosten +

+

+ Starte kostenlos und upgrade wenn du bereit bist. Jederzeit kündbar. +

+
+ + +
+ {plans.map(plan => ( +
+ {plan.badge && ( +
+ + {plan.badge} + +
+ )} + +
+

{plan.name}

+

{plan.description}

+ +
+
+ + {formatPrice(plan.price)} + + {plan.period} +
+
+ + + {plan.cta} + + +
+

+ Inklusive: +

+ {plan.features.map(feature => ( +
+ + + + {feature} +
+ ))} +
+
+
+ ))} +
+ + +
+
+
+

💳 Keine Kreditkarte erforderlich

+

+ Starte komplett kostenlos. Upgrade nur wenn du mehr brauchst. +

+
+
+

🔄 Jederzeit kündbar

+

+ Keine Vertragsbindung. Kündige monatlich ohne Probleme. +

+
+
+

🚀 Sofort startklar

+

+ Nach der Anmeldung kannst du sofort alle Features nutzen. +

+
+
+
+
+
diff --git a/uload/apps/landing/src/content/blog/link-tracking-guide.md b/uload/apps/landing/src/content/blog/link-tracking-guide.md new file mode 100644 index 000000000..f75f98eef --- /dev/null +++ b/uload/apps/landing/src/content/blog/link-tracking-guide.md @@ -0,0 +1,85 @@ +--- +title: Der ultimative Link-Tracking Guide für 2024 +description: Erfahren Sie, wie Sie mit modernem Link-Tracking Ihre Marketing-Performance messbar verbessern und dabei DSGVO-konform bleiben. +pubDate: 2024-01-20 +author: Till Schneider +tags: [tracking, analytics, dsgvo, marketing] +--- + +Link-Tracking ist der Schlüssel zu datengetriebenem Marketing. In diesem umfassenden Guide zeigen wir Ihnen, wie Sie Ihre Links professionell tracken, dabei datenschutzkonform bleiben und Ihre Conversion-Rate signifikant steigern. + +## Was ist Link-Tracking? + +Link-Tracking ermöglicht es Ihnen, das Verhalten Ihrer Nutzer zu verstehen: +- Woher kommen Ihre Besucher? +- Welche Kampagnen funktionieren? +- Wie hoch ist Ihre Conversion-Rate? +- Welche Inhalte performen am besten? + +## Die wichtigsten Metriken + +### 1. Click-Through-Rate (CTR) +Die CTR zeigt, wie viele Personen Ihren Link gesehen und geklickt haben. Eine gute CTR liegt je nach Kanal zwischen 2-5%. + +### 2. Conversion Rate +Der Prozentsatz der Klicks, die zu einer gewünschten Aktion führen. + +### 3. Bounce Rate +Wie viele Nutzer verlassen Ihre Seite sofort wieder? + +### 4. Geographic Distribution +Verstehen Sie, aus welchen Ländern und Regionen Ihre Besucher kommen. + +## UTM-Parameter richtig einsetzen + +UTM-Parameter sind der Standard für Campaign-Tracking: + +``` +https://ulo.ad/angebot +?utm_source=newsletter +&utm_medium=email +&utm_campaign=winter-sale +``` + +### Die 5 UTM-Parameter + +1. **utm_source**: Woher kommt der Traffic? +2. **utm_medium**: Welches Medium? +3. **utm_campaign**: Welche Kampagne? +4. **utm_content**: Welcher spezifische Link? +5. **utm_term**: Welches Keyword? + +## DSGVO-konformes Tracking + +### Was ist erlaubt? + +✅ **Anonymisierte Daten** +- Gerätetyp +- Browser +- Ungefährer Standort +- Referrer + +### Was braucht Zustimmung? + +❌ **Personenbezogene Daten** +- Vollständige IP-Adressen +- Device Fingerprinting +- Cross-Site Tracking + +## Best Practices für Link-Tracking + +### 1. Konsistente Namenskonvention + +Entwickeln Sie ein einheitliches Schema für Ihre Kampagnen. + +### 2. Dokumentation führen + +Erstellen Sie eine Tracking-Tabelle für alle Kampagnen. + +### 3. Regelmäßige Bereinigung + +Löschen Sie alte, inaktive Links regelmäßig. + +## Fazit + +Professionelles Link-Tracking ist kein Nice-to-have, sondern ein Must-have für erfolgreiches digitales Marketing. Mit den richtigen Tools und Prozessen können Sie Ihre Marketing-Performance signifikant steigern. diff --git a/uload/apps/landing/src/content/blog/psychologie-kurzer-urls.md b/uload/apps/landing/src/content/blog/psychologie-kurzer-urls.md new file mode 100644 index 000000000..7d47969d5 --- /dev/null +++ b/uload/apps/landing/src/content/blog/psychologie-kurzer-urls.md @@ -0,0 +1,73 @@ +--- +title: Die Psychologie kurzer URLs - Warum unser Gehirn sie liebt +description: 42% weniger Klicks bei langen URLs – diese erstaunliche Zahl zeigt, wie stark die Länge eines Links unsere Entscheidung beeinflusst. Erfahren Sie die Wissenschaft dahinter. +pubDate: 2024-01-15 +author: Till Schneider +tags: [urls, psychology, conversion, marketing] +--- + +**42% weniger Klicks bei langen URLs** – diese erstaunliche Zahl zeigt, wie stark die Länge eines Links unsere Entscheidung beeinflusst, darauf zu klicken oder nicht. In diesem umfassenden Artikel tauchen wir tief in die Psychologie hinter kurzen URLs ein und zeigen Ihnen, wie Sie dieses Wissen für Ihren digitalen Erfolg nutzen können. + +## Das Problem mit langen URLs: Wenn Links Misstrauen erzeugen + +Stellen Sie sich vor: Fast die Hälfte Ihrer potenziellen Besucher klickt nicht auf Ihren Link – nur weil er zu lang ist. Was auf den ersten Blick wie eine technische Kleinigkeit erscheint, ist in Wahrheit ein psychologisches Phänomen mit enormen Auswirkungen auf Ihre Online-Performance. + +### Die Spam-Alarm-Reaktion unseres Gehirns + +Aktuelle Studien zeigen eindeutig: URLs, die länger als 100 Zeichen sind, lösen automatisch Misstrauen aus. Unser Gehirn hat über Jahre hinweg gelernt, dass lange, unleserliche Links mit unzähligen Parametern oft zu zweifelhaften Inhalten führen. + +Vergleichen Sie diese beiden URLs: + +**Lange URL (schlecht):** +``` +https://example.com/product?id=12345&utm_source=newsletter&utm_medium=email&utm_campaign=summer2024 +``` + +**Kurze URL (gut):** +``` +https://ulo.ad/summer-sale +``` + +### Mobile Nutzer: Die vergessene Mehrheit + +In einer Welt, in der über 60% des Web-Traffics von mobilen Geräten kommt, sind lange URLs ein noch größeres Problem. Mobile Nutzer scrollen definitiv nicht horizontal, um einen Link vollständig zu sehen. + +## Die Wissenschaft dahinter: Cognitive Load Theory + +Die Cognitive Load Theory erklärt, warum kurze URLs so effektiv sind. Unser Gehirn ist darauf programmiert, Energie zu sparen. Bei der Verarbeitung von Informationen sucht es immer nach dem Weg des geringsten Widerstands. + +## Die vier Säulen des Link-Vertrauens + +1. **Erkennbare Domain (60% Wichtigkeit)** - Menschen wollen wissen, wo sie landen werden +2. **Keine kryptischen Zeichen (25% Wichtigkeit)** - Zufällige Zahlen-Buchstaben-Kombinationen schrecken ab +3. **Optimale Länge (10% Wichtigkeit)** - Die magische Grenze liegt bei etwa 50 Zeichen +4. **HTTPS-Verschlüsselung (5% Wichtigkeit)** - Ein Hygienefaktor + +## Praktische Optimierungsstrategien + +### 1. Sprechende URLs verwenden + +❌ **Schlecht:** `ulo.ad/p47829` +✅ **Gut:** `ulo.ad/sommer-sale` + +### 2. Die 50-Zeichen-Regel + +Halten Sie Ihre URLs unter 50 Zeichen. Das ist: +- Kurz genug für Twitter/X +- Lesbar auf Mobilgeräten +- Merkbar für Nutzer + +### 3. A/B-Testing ist Ihr Freund + +Testen Sie verschiedene URL-Varianten und messen Sie die Performance. + +## Fazit: Die Macht der Kürze + +Die Psychologie kurzer URLs ist keine Raketenwissenschaft, aber ihre Auswirkungen sind enorm. In einer Welt, in der Aufmerksamkeit die wertvollste Währung ist, können kurze, vertrauenswürdige Links den Unterschied zwischen Erfolg und Misserfolg ausmachen. + +### Die wichtigsten Takeaways + +1. **42% weniger Klicks** bei URLs über 100 Zeichen +2. **Cognitive Load Theory**: Unser Gehirn liebt Einfachheit +3. **50 Zeichen** ist die magische Grenze +4. **Sprechende URLs** performen 39% besser diff --git a/uload/apps/landing/src/content/config.ts b/uload/apps/landing/src/content/config.ts new file mode 100644 index 000000000..9cace6579 --- /dev/null +++ b/uload/apps/landing/src/content/config.ts @@ -0,0 +1,17 @@ +import { defineCollection, z } from 'astro:content'; + +const blogCollection = defineCollection({ + type: 'content', + schema: z.object({ + title: z.string(), + description: z.string(), + pubDate: z.date(), + author: z.string().optional(), + image: z.string().optional(), + tags: z.array(z.string()).optional(), + }), +}); + +export const collections = { + blog: blogCollection, +}; diff --git a/uload/apps/landing/src/env.d.ts b/uload/apps/landing/src/env.d.ts new file mode 100644 index 000000000..acef35f17 --- /dev/null +++ b/uload/apps/landing/src/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/uload/apps/landing/src/layouts/BaseLayout.astro b/uload/apps/landing/src/layouts/BaseLayout.astro new file mode 100644 index 000000000..91f46af54 --- /dev/null +++ b/uload/apps/landing/src/layouts/BaseLayout.astro @@ -0,0 +1,52 @@ +--- +import '../styles/global.css'; +import Navigation from '../components/Navigation.astro'; +import Footer from '../components/Footer.astro'; + +interface Props { + title: string; + description?: string; + ogImage?: string; +} + +const { title, description = 'uLoad - Der intelligente URL-Shortener für Profis. Verkürzen Sie Links, erstellen Sie QR-Codes und analysieren Sie Klicks.', ogImage = '/og-image.png' } = Astro.props; +const canonicalURL = new URL(Astro.url.pathname, Astro.site); +--- + + + + + + + + + + {title} | uLoad + + + + + + + + + + + + + + + + + + + + + + +
+ +
+