diff --git a/.claude/GUIDELINES.md b/.claude/GUIDELINES.md new file mode 100644 index 000000000..0ddb99306 --- /dev/null +++ b/.claude/GUIDELINES.md @@ -0,0 +1,127 @@ +# Claude Code Guidelines + +This directory contains comprehensive guidelines for working in the Mana Universe monorepo. These documents are designed to help Claude Code (and developers) maintain consistency across all projects. + +## Quick Reference + +| Document | Purpose | +|----------|---------| +| [Code Style](./guidelines/code-style.md) | Formatting, naming conventions, linting rules | +| [Database](./guidelines/database.md) | Drizzle ORM patterns, schema design, migrations | +| [Testing](./guidelines/testing.md) | Jest/Vitest patterns, mock factories, coverage | +| [NestJS Backend](./guidelines/nestjs-backend.md) | Controllers, services, DTOs, modules | +| [Error Handling](./guidelines/error-handling.md) | Go-style errors, error codes, Result types | +| [SvelteKit Web](./guidelines/sveltekit-web.md) | Svelte 5 runes, stores, routing | +| [Expo Mobile](./guidelines/expo-mobile.md) | React Native, NativeWind, navigation | +| [Authentication](./guidelines/authentication.md) | Mana Core Auth integration | + +## Core Principles + +### 1. Explicit Over Implicit +- Use Go-style error handling with explicit `Result` returns +- Prefer named exports over default exports +- Use explicit types instead of `any` + +### 2. Consistency Over Preference +- Follow existing patterns in the codebase +- Use shared packages for common functionality +- Maintain consistent naming across all projects + +### 3. Simplicity Over Cleverness +- Don't over-engineer solutions +- Avoid premature abstractions +- Keep files focused and small + +### 4. Safety First +- Always validate user input +- Use parameterized queries (Drizzle handles this) +- Never expose sensitive data in responses + +## Technology Stack Summary + +| Layer | Technology | Notes | +|-------|------------|-------| +| **Package Manager** | pnpm 9.15+ | Workspace monorepo | +| **Build System** | Turborepo | Parallel task execution | +| **Backend** | NestJS 10-11 | TypeScript, Drizzle ORM | +| **Web** | SvelteKit 2 + Svelte 5 | Runes mode only | +| **Mobile** | Expo SDK 52-54 | React Native, NativeWind | +| **Database** | PostgreSQL | Via Drizzle ORM | +| **Auth** | Mana Core Auth | Better Auth, EdDSA JWT | +| **Storage** | S3-compatible | MinIO (dev), Hetzner (prod) | + +## Project Structure + +``` +manacore-monorepo/ +├── .claude/ +│ ├── GUIDELINES.md # This file +│ ├── guidelines/ # Detailed guidelines +│ └── templates/ # Code templates +├── apps/ # Product applications +│ └── {project}/ +│ ├── apps/ +│ │ ├── backend/ # NestJS API +│ │ ├── web/ # SvelteKit web +│ │ ├── mobile/ # Expo app +│ │ └── landing/ # Astro landing +│ └── packages/ # Project-specific shared +├── packages/ # Monorepo-wide shared +│ ├── shared-errors/ # Error codes & Result types +│ ├── shared-nestjs-auth/ # NestJS auth guards +│ ├── shared-auth/ # Client auth service +│ └── ... +├── services/ # Standalone microservices +│ └── mana-core-auth/ # Central auth service +└── CLAUDE.md # Root project overview +``` + +## Before Making Changes + +1. **Read the relevant guideline** for the area you're working in +2. **Check existing patterns** in similar files +3. **Use shared packages** when available +4. **Follow the error handling pattern** with Result types +5. **Write tests** for new functionality + +## Error Handling Philosophy + +We use **Go-style error handling** across the entire stack: + +```typescript +// Backend services return Result +const result = await userService.findById(id); +if (!result.ok) { + // Handle error with error code + throw new AppException(result.error); +} +return result.data; + +// Frontend handles errors explicitly +const { data, error } = await api.getUser(id); +if (error) { + showToast(error.message); + return; +} +``` + +See [Error Handling](./guidelines/error-handling.md) for complete details. + +## Quick Commands + +```bash +# Development +pnpm install # Install dependencies +pnpm {project}:dev # Start project (all apps) +pnpm dev:{project}:backend # Start just backend +pnpm dev:{project}:web # Start just web + +# Quality +pnpm type-check # TypeScript validation +pnpm format # Format code +pnpm test # Run tests + +# Database +pnpm {project}:db:push # Push schema changes +pnpm {project}:db:studio # Open Drizzle Studio +``` diff --git a/.claude/guidelines/authentication.md b/.claude/guidelines/authentication.md new file mode 100644 index 000000000..fe885da13 --- /dev/null +++ b/.claude/guidelines/authentication.md @@ -0,0 +1,597 @@ +# Authentication Guidelines + +## Overview + +All authentication is handled by **Mana Core Auth**, a centralized authentication service using **Better Auth** with **EdDSA JWT** tokens. + +## Architecture + +``` +┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐ +│ Web/Mobile │────>│ Backend API │────>│ mana-core-auth │ +│ Client │ │ (NestJS) │ │ (port 3001) │ +└─────────────────┘ └─────────────────┘ └──────────────────┘ + │ │ │ + │ 1. Login │ │ + │─────────────────────────────────────────────>│ + │ │ │ + │ 2. JWT Token │ │ + │<─────────────────────────────────────────────│ + │ │ │ + │ 3. API Request │ │ + │ + Bearer Token │ │ + │──────────────────────>│ │ + │ │ │ + │ │ 4. Validate Token │ + │ │──────────────────────>│ + │ │ │ + │ │ 5. {valid, payload} │ + │ │<──────────────────────│ + │ │ │ + │ 6. Response │ │ + │<──────────────────────│ │ +``` + +## Token Structure (EdDSA JWT) + +```json +{ + "sub": "user-uuid-123", + "email": "user@example.com", + "role": "user", + "sid": "session-id-456", + "iat": 1701234567, + "exp": 1701238167, + "iss": "manacore", + "aud": "manacore" +} +``` + +**Important**: Keep claims minimal. Do NOT include: +- Credit balance (changes frequently) +- Organization data (use API instead) +- Feature flags +- Other dynamic data + +## Shared Packages + +| Package | Purpose | Use Case | +|---------|---------|----------| +| `@manacore/shared-nestjs-auth` | NestJS guards/decorators | Backend APIs | +| `@mana-core/nestjs-integration` | Auth + Credits integration | Backends with credits | +| `@manacore/shared-auth` | Client auth service | Web/Mobile apps | + +## Backend Integration + +### Option 1: Simple Auth Only + +Use `@manacore/shared-nestjs-auth` for JWT validation: + +```typescript +// app.module.ts +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; + +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + // No auth module needed - guards handle it + ], +}) +export class AppModule {} +``` + +```typescript +// file.controller.ts +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; + +@Controller('files') +@UseGuards(JwtAuthGuard) // Apply to all routes +export class FileController { + @Get() + async listFiles(@CurrentUser() user: CurrentUserData) { + // user.userId, user.email, user.role available + return this.fileService.findAll(user.userId); + } +} +``` + +### Option 2: Auth + Credits + +Use `@mana-core/nestjs-integration` for full integration: + +```typescript +// app.module.ts +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { ManaCoreModule } from '@mana-core/nestjs-integration'; + +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + ManaCoreModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (config: ConfigService) => ({ + appId: config.get('APP_ID'), + serviceKey: config.get('MANA_CORE_SERVICE_KEY'), + debug: config.get('NODE_ENV') === 'development', + }), + inject: [ConfigService], + }), + ], +}) +export class AppModule {} +``` + +```typescript +// generation.controller.ts +import { Controller, Post, UseGuards, Body } from '@nestjs/common'; +import { AuthGuard } from '@mana-core/nestjs-integration/guards'; +import { CurrentUser } from '@mana-core/nestjs-integration/decorators'; +import { CreditClientService } from '@mana-core/nestjs-integration'; + +@Controller('generations') +@UseGuards(AuthGuard) +export class GenerationController { + constructor(private creditClient: CreditClientService) {} + + @Post() + async generate(@CurrentUser() user: any, @Body() dto: GenerateDto) { + // Check and consume credits + const result = await this.creditClient.consumeCredits( + user.sub, + 'ai_generation', + 10, + 'AI image generation' + ); + + if (!result.ok) { + throw new AppException(result.error); + } + + // Proceed with generation + return this.generationService.generate(user.sub, dto); + } +} +``` + +## Environment Variables + +```env +# Required for all backends +MANA_CORE_AUTH_URL=http://localhost:3001 + +# Development bypass (optional) +NODE_ENV=development +DEV_BYPASS_AUTH=true +DEV_USER_ID=dev-user-123 + +# For credit operations (when using nestjs-integration) +MANA_CORE_SERVICE_KEY=your-service-key +APP_ID=your-app-id +``` + +## Client Integration (Web) + +### Setup + +```typescript +// src/lib/stores/auth.svelte.ts +import { browser } from '$app/environment'; +import { goto } from '$app/navigation'; +import { initializeWebAuth } from '@manacore/shared-auth'; +import { PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public'; + +const AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001'; + +// Lazy initialize to avoid SSR issues +let _authService: ReturnType['authService'] | null = null; + +function getAuthService() { + if (!browser) return null; + if (!_authService) { + const auth = initializeWebAuth({ baseUrl: AUTH_URL }); + _authService = auth.authService; + } + return _authService; +} + +// State +let user = $state(null); +let token = $state(null); +let loading = $state(true); + +// Initialize on app start +async function initialize() { + if (!browser) return; + + const authService = getAuthService(); + if (!authService) return; + + const currentUser = await authService.getCurrentUser(); + if (currentUser) { + user = currentUser; + token = await authService.getAccessToken(); + } + + loading = false; +} + +// Actions +async function login(email: string, password: string): Promise { + const authService = getAuthService(); + if (!authService) return false; + + try { + const result = await authService.signIn({ email, password }); + user = result.user; + token = result.accessToken; + return true; + } catch { + return false; + } +} + +async function logout() { + const authService = getAuthService(); + if (authService) { + await authService.signOut(); + } + user = null; + token = null; + goto('/login'); +} + +export const authStore = { + get user() { return user; }, + get token() { return token; }, + get loading() { return loading; }, + get isAuthenticated() { return !!token; }, + initialize, + login, + logout, +}; +``` + +### Protected Routes + +```svelte + + + +{#if authStore.loading} + +{:else if authStore.isAuthenticated} + {@render children()} +{/if} +``` + +### API Requests with Token + +```typescript +// src/lib/api/client.ts +import { authStore } from '$lib/stores/auth.svelte'; +import { goto } from '$app/navigation'; +import { PUBLIC_BACKEND_URL } from '$env/static/public'; + +async function request(endpoint: string, options: RequestInit = {}): Promise> { + const token = authStore.token; + + const response = await fetch(`${PUBLIC_BACKEND_URL}${endpoint}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...options.headers, + }, + }); + + // Handle 401 - session expired + if (response.status === 401) { + authStore.logout(); + goto('/login'); + return { ok: false, error: { code: 'ERR_2000', message: 'Session expired' } }; + } + + const json = await response.json(); + return json.ok ? { ok: true, data: json.data } : { ok: false, error: json.error }; +} +``` + +## Client Integration (Mobile) + +### Auth Provider + +```tsx +// context/AuthProvider.tsx +import { createContext, useContext, useState, useEffect } from 'react'; +import * as SecureStore from 'expo-secure-store'; +import { initializeMobileAuth } from '@manacore/shared-auth'; +import Constants from 'expo-constants'; + +const AUTH_URL = Constants.expoConfig?.extra?.authUrl ?? 'http://localhost:3001'; +const TOKEN_KEY = 'mana_auth_token'; +const USER_KEY = 'mana_auth_user'; + +interface AuthContextType { + user: User | null; + token: string | null; + loading: boolean; + login: (email: string, password: string) => Promise; + logout: () => Promise; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [token, setToken] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + loadStoredAuth(); + }, []); + + async function loadStoredAuth() { + try { + const storedToken = await SecureStore.getItemAsync(TOKEN_KEY); + const storedUser = await SecureStore.getItemAsync(USER_KEY); + + if (storedToken && storedUser) { + // Validate token is still valid + const isValid = await validateToken(storedToken); + if (isValid) { + setToken(storedToken); + setUser(JSON.parse(storedUser)); + } else { + // Token expired, clear storage + await SecureStore.deleteItemAsync(TOKEN_KEY); + await SecureStore.deleteItemAsync(USER_KEY); + } + } + } finally { + setLoading(false); + } + } + + async function validateToken(token: string): Promise { + try { + const response = await fetch(`${AUTH_URL}/api/v1/auth/validate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + const result = await response.json(); + return result.valid === true; + } catch { + return false; + } + } + + async function login(email: string, password: string): Promise { + try { + const response = await fetch(`${AUTH_URL}/api/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + + const result = await response.json(); + + if (result.accessToken && result.user) { + await SecureStore.setItemAsync(TOKEN_KEY, result.accessToken); + await SecureStore.setItemAsync(USER_KEY, JSON.stringify(result.user)); + setToken(result.accessToken); + setUser(result.user); + return true; + } + + return false; + } catch { + return false; + } + } + + async function logout() { + await SecureStore.deleteItemAsync(TOKEN_KEY); + await SecureStore.deleteItemAsync(USER_KEY); + setToken(null); + setUser(null); + } + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (!context) throw new Error('useAuth must be within AuthProvider'); + return context; +} +``` + +## Auth Endpoints + +### Mana Core Auth API + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/v1/auth/register` | POST | Register new user | +| `/api/v1/auth/login` | POST | Login, returns JWT | +| `/api/v1/auth/logout` | POST | Logout, invalidates session | +| `/api/v1/auth/validate` | POST | Validate JWT token | +| `/api/v1/auth/refresh` | POST | Refresh access token | +| `/api/v1/auth/me` | GET | Get current user | +| `/api/v1/auth/jwks` | GET | Get JWKS for token verification | + +### Request/Response Examples + +**Register** +```bash +POST /api/v1/auth/register +{ + "email": "user@example.com", + "password": "securepassword123", + "name": "John Doe" +} + +Response: +{ + "user": { "id": "...", "email": "...", "name": "..." }, + "accessToken": "eyJ...", + "refreshToken": "..." +} +``` + +**Login** +```bash +POST /api/v1/auth/login +{ + "email": "user@example.com", + "password": "securepassword123" +} + +Response: +{ + "user": { "id": "...", "email": "...", "name": "..." }, + "accessToken": "eyJ...", + "refreshToken": "..." +} +``` + +**Validate Token** +```bash +POST /api/v1/auth/validate +{ + "token": "eyJ..." +} + +Response: +{ + "valid": true, + "payload": { + "sub": "user-id", + "email": "user@example.com", + "role": "user", + "sid": "session-id" + } +} +``` + +## Development Bypass + +For local development, you can bypass auth: + +```env +DEV_BYPASS_AUTH=true +DEV_USER_ID=dev-user-123 +``` + +The guard will inject a mock user: + +```typescript +// From JwtAuthGuard when bypass is enabled +request.user = { + userId: process.env.DEV_USER_ID || 'dev-user', + email: 'dev@example.com', + role: 'user', +}; +``` + +## Testing with Auth + +### Unit Tests + +```typescript +// Mock the guard +const module = await Test.createTestingModule({ + controllers: [FileController], + providers: [FileService], +}) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: () => true }) + .compile(); + +// Mock user in controller tests +const mockUser = { userId: 'test-user', email: 'test@example.com', role: 'user' }; +await controller.listFiles(mockUser); +``` + +### E2E Tests + +```typescript +// Get a real token +const loginResponse = await request(app.getHttpServer()) + .post('/api/v1/auth/login') + .send({ email: 'test@example.com', password: 'password' }); + +const token = loginResponse.body.accessToken; + +// Use token in requests +await request(app.getHttpServer()) + .get('/api/v1/files') + .set('Authorization', `Bearer ${token}`) + .expect(200); +``` + +## Security Considerations + +1. **Store tokens securely** + - Web: HttpOnly cookies or memory (not localStorage) + - Mobile: SecureStore (not AsyncStorage) + +2. **Token refresh** + - Access tokens expire in 1 hour + - Use refresh tokens to get new access tokens + - Handle 401 responses gracefully + +3. **CORS configuration** + - Only allow known origins + - Include credentials for cookie-based auth + +4. **Never trust client data** + - Always validate token server-side + - Use `@CurrentUser()` decorator, not request body + +## Debugging + +### Token not validating? + +```bash +# 1. Check algorithm (should be EdDSA) +echo $TOKEN | cut -d'.' -f1 | base64 -d + +# 2. Check JWKS endpoint +curl http://localhost:3001/api/v1/auth/jwks + +# 3. Check issuer/audience +# Should match between signing and validation +``` + +### 401 errors? + +1. Check token exists in Authorization header +2. Check token format: `Bearer ` +3. Check token hasn't expired +4. Check MANA_CORE_AUTH_URL is correct diff --git a/.claude/guidelines/code-style.md b/.claude/guidelines/code-style.md new file mode 100644 index 000000000..6fbffb0f3 --- /dev/null +++ b/.claude/guidelines/code-style.md @@ -0,0 +1,312 @@ +# Code Style Guidelines + +## Formatting + +### Prettier Configuration + +All projects use the root `.prettierrc.json`: + +```json +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "es5", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte", "prettier-plugin-astro"] +} +``` + +### Key Rules + +- **Tabs** for indentation (not spaces) +- **Single quotes** for strings +- **Trailing commas** in ES5-compatible positions +- **100 character** line width +- **Semicolons** required + +## Naming Conventions + +### Files & Directories + +| Type | Convention | Example | +| -------------------- | ----------------- | ------------------------------------------------ | +| **Components** | PascalCase | `MessageBubble.svelte`, `ChatInput.tsx` | +| **Services** | kebab-case | `auth.service.ts`, `user-credits.service.ts` | +| **Schemas** | kebab-case | `users.schema.ts`, `batch-generations.schema.ts` | +| **Utilities** | kebab-case | `format-date.ts`, `string-utils.ts` | +| **Types/Interfaces** | kebab-case | `user.types.ts`, `api-response.ts` | +| **Constants** | kebab-case | `error-codes.ts`, `config.ts` | +| **Test files** | `.spec.ts` suffix | `auth.service.spec.ts` | + +### Code Identifiers + +| Type | Convention | Example | +| ------------------ | ------------------------------------------------ | ---------------------------------- | +| **Classes** | PascalCase | `UserService`, `AuthController` | +| **Interfaces** | PascalCase | `UserData`, `CreateEventDto` | +| **Type aliases** | PascalCase | `Result`, `ErrorCode` | +| **Functions** | camelCase | `findById`, `createUser` | +| **Variables** | camelCase | `userId`, `isLoading` | +| **Constants** | SCREAMING_SNAKE_CASE | `MAX_FILE_SIZE`, `DEFAULT_TIMEOUT` | +| **Enums** | PascalCase (type), SCREAMING_SNAKE_CASE (values) | `ErrorCode.NOT_FOUND` | +| **Private fields** | camelCase (no underscore prefix) | `private db: Database` | + +### Database Naming + +| Type | Convention | Example | +| ---------------- | ---------------------- | ------------------------------- | +| **Tables** | snake_case, plural | `users`, `user_sessions` | +| **Columns** | snake_case | `user_id`, `created_at` | +| **Foreign keys** | `{entity}_id` | `user_id`, `folder_id` | +| **Booleans** | `is_` or `has_` prefix | `is_deleted`, `has_password` | +| **Timestamps** | `_at` suffix | `created_at`, `deleted_at` | +| **Indexes** | `idx_` prefix | `idx_user_id`, `idx_created_at` | + +## TypeScript + +### Strict Mode + +All projects use strict TypeScript: + +```json +{ + "compilerOptions": { + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noUncheckedIndexedAccess": true + } +} +``` + +### Type Annotations + +```typescript +// GOOD - Explicit return types for public APIs +async function findById(id: string): Promise> { + // ... +} + +// GOOD - Interface for complex objects +interface CreateUserDto { + email: string; + name: string; + password: string; +} + +// BAD - Avoid `any` +function process(data: any) {} // Never do this + +// GOOD - Use `unknown` when type is truly unknown +function process(data: unknown) { + if (isUser(data)) { + // Now TypeScript knows it's a User + } +} +``` + +### Imports + +```typescript +// Order: external → internal → relative +import { Injectable } from '@nestjs/common'; // 1. External +import { Result, ErrorCode } from '@manacore/shared-errors'; // 2. Internal packages +import { UserService } from '../services/user.service'; // 3. Relative + +// Use named exports (not default) +export { UserService }; // GOOD +export default UserService; // AVOID + +// Use type-only imports for types +import type { User } from './user.types'; +``` + +## ESLint Rules + +### Critical Rules (Errors) + +```javascript +{ + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/explicit-function-return-type": "error", // For public APIs + "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], + "no-console": ["error", { "allow": ["warn", "error"] }], +} +``` + +### Recommended Rules (Warnings) + +```javascript +{ + "@typescript-eslint/no-floating-promises": "warn", + "@typescript-eslint/await-thenable": "warn", + "prefer-const": "warn", +} +``` + +## Comments + +### When to Comment + +```typescript +// GOOD - Explain WHY, not WHAT +// We use optimistic locking here because concurrent credit operations +// could otherwise result in race conditions and incorrect balances +const [updated] = await this.db + .update(balances) + .set({ amount: newAmount, version: sql`version + 1` }) + .where(and(eq(balances.userId, userId), eq(balances.version, currentVersion))) + .returning(); + +// BAD - Explaining obvious code +// Loop through users +for (const user of users) { +} + +// BAD - Outdated comment +// Returns the user's email <-- but function now returns full user object +function getUser() {} +``` + +### JSDoc for Public APIs + +```typescript +/** + * Consumes credits from a user's balance. + * + * @param userId - The user's unique identifier + * @param amount - Number of credits to consume + * @param reason - Human-readable reason for the charge + * @returns Result with the updated balance or an error + * + * @example + * const result = await creditsService.consume(userId, 10, 'AI generation'); + * if (!result.ok) { + * logger.error('Credit consumption failed', result.error); + * } + */ +async consume(userId: string, amount: number, reason: string): Promise> { + // ... +} +``` + +## Code Organization + +### File Size + +- **Maximum**: ~300 lines per file +- **Ideal**: 100-200 lines +- Split large files into focused modules + +### Function Size + +- **Maximum**: ~50 lines per function +- **Ideal**: 10-25 lines +- Extract complex logic into helper functions + +### Module Structure (NestJS) + +``` +feature/ +├── feature.controller.ts # HTTP layer +├── feature.service.ts # Business logic +├── feature.module.ts # DI configuration +├── feature.spec.ts # Tests +└── dto/ + ├── create-feature.dto.ts + └── update-feature.dto.ts +``` + +### Component Structure (Svelte/React) + +``` +components/ +├── feature/ +│ ├── FeatureList.svelte # Container component +│ ├── FeatureItem.svelte # Presentational component +│ └── feature.types.ts # Shared types +└── ui/ + ├── Button.svelte # Reusable UI + └── Input.svelte +``` + +## Anti-Patterns to Avoid + +### 1. Magic Numbers/Strings + +```typescript +// BAD +if (user.role === 'admin') { +} +if (credits < 10) { +} + +// GOOD +const ROLES = { ADMIN: 'admin', USER: 'user' } as const; +const MIN_CREDITS_FOR_OPERATION = 10; + +if (user.role === ROLES.ADMIN) { +} +if (credits < MIN_CREDITS_FOR_OPERATION) { +} +``` + +### 2. Nested Callbacks + +```typescript +// BAD +getUser(id, (user) => { + getCredits(user.id, (credits) => { + updateBalance(credits, (result) => { + // ... + }); + }); +}); + +// GOOD +const user = await getUser(id); +const credits = await getCredits(user.id); +const result = await updateBalance(credits); +``` + +### 3. Mutating Parameters + +```typescript +// BAD +function processUser(user: User): void { + user.name = user.name.trim(); // Mutates input +} + +// GOOD +function processUser(user: User): User { + return { ...user, name: user.name.trim() }; // Returns new object +} +``` + +### 4. Boolean Trap + +```typescript +// BAD - What does `true` mean? +createUser(email, password, true, false); + +// GOOD - Use options object +createUser({ + email, + password, + sendWelcomeEmail: true, + requireEmailVerification: false, +}); +``` + +## Formatting Commands + +```bash +# Format all files +pnpm format + +# Check formatting without changes +pnpm format:check + +# Format specific project +pnpm --filter @chat/backend format +``` diff --git a/.claude/guidelines/database.md b/.claude/guidelines/database.md new file mode 100644 index 000000000..49bfbebf4 --- /dev/null +++ b/.claude/guidelines/database.md @@ -0,0 +1,493 @@ +# Database Guidelines + +## Overview + +All projects use **Drizzle ORM** with **PostgreSQL**. This document covers schema design patterns, naming conventions, and migration strategies. + +## ORM: Drizzle + +### Why Drizzle? + +- Full TypeScript type inference +- SQL-like syntax (no magic) +- Lightweight and fast +- Excellent PostgreSQL support + +### Connection Pattern + +```typescript +// src/db/connection.ts +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema'; + +let connection: ReturnType | null = null; +let db: ReturnType | null = null; + +export function getConnection(databaseUrl: string) { + if (!connection) { + connection = postgres(databaseUrl, { + max: 10, // Max connections + idle_timeout: 20, // Seconds before closing idle + connect_timeout: 10, // Connection timeout + }); + } + return connection; +} + +export function getDb(databaseUrl: string) { + if (!db) { + const conn = getConnection(databaseUrl); + db = drizzle(conn, { schema }); + } + return db; +} + +export async function closeConnection() { + if (connection) { + await connection.end(); + connection = null; + db = null; + } +} + +export type Database = ReturnType; +``` + +### NestJS Integration + +```typescript +// src/db/database.module.ts +import { Global, Module, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { getDb, closeConnection, Database } from './connection'; + +export const DATABASE_CONNECTION = 'DATABASE_CONNECTION'; + +@Global() +@Module({ + providers: [ + { + provide: DATABASE_CONNECTION, + useFactory: (configService: ConfigService): Database => { + const databaseUrl = configService.get('DATABASE_URL'); + return getDb(databaseUrl); + }, + inject: [ConfigService], + }, + ], + exports: [DATABASE_CONNECTION], +}) +export class DatabaseModule implements OnModuleDestroy { + async onModuleDestroy() { + await closeConnection(); + } +} +``` + +## Schema Design + +### File Organization + +``` +src/db/ +├── schema/ +│ ├── index.ts # Exports all schemas +│ ├── users.schema.ts # User-related tables +│ ├── files.schema.ts # File-related tables +│ └── ... +├── connection.ts # DB connection singleton +├── database.module.ts # NestJS module +└── migrations/ # Generated migrations +``` + +### Table Definition Pattern + +```typescript +// src/db/schema/files.schema.ts +import { + pgTable, + uuid, + varchar, + text, + boolean, + timestamp, + bigint, + integer, +} from 'drizzle-orm/pg-core'; +import { relations } from 'drizzle-orm'; + +export const files = pgTable( + 'files', + { + // Primary key - always UUID with auto-generation + id: uuid('id').primaryKey().defaultRandom(), + + // Foreign keys + userId: varchar('user_id', { length: 255 }).notNull(), + parentFolderId: uuid('parent_folder_id').references(() => folders.id, { onDelete: 'set null' }), + + // Required fields + name: varchar('name', { length: 500 }).notNull(), + mimeType: varchar('mime_type', { length: 255 }).notNull(), + size: bigint('size', { mode: 'number' }).notNull(), + storagePath: varchar('storage_path', { length: 1000 }).notNull(), + storageKey: varchar('storage_key', { length: 500 }).notNull().unique(), + + // Optional fields + description: text('description'), + + // Boolean flags with defaults + isFavorite: boolean('is_favorite').default(false).notNull(), + isPublic: boolean('is_public').default(false).notNull(), + + // Soft delete + isDeleted: boolean('is_deleted').default(false).notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true }), + + // Timestamps - ALWAYS include these + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + // Indexes for common queries + userIdIdx: index('idx_files_user_id').on(table.userId), + parentFolderIdx: index('idx_files_parent_folder').on(table.parentFolderId), + createdAtIdx: index('idx_files_created_at').on(table.createdAt), + }) +); + +// Type exports - ALWAYS include these +export type File = typeof files.$inferSelect; +export type NewFile = typeof files.$inferInsert; +``` + +### Relations + +```typescript +// Define relations separately for clarity +export const filesRelations = relations(files, ({ one, many }) => ({ + folder: one(folders, { + fields: [files.parentFolderId], + references: [folders.id], + }), + versions: many(fileVersions), + tags: many(fileTags), +})); + +export const foldersRelations = relations(folders, ({ one, many }) => ({ + parent: one(folders, { + fields: [folders.parentFolderId], + references: [folders.id], + relationName: 'parentChild', + }), + children: many(folders, { relationName: 'parentChild' }), + files: many(files), +})); +``` + +## Naming Conventions + +### Tables + +| Rule | Example | +| -------------------------------------- | -------------------------------- | +| Use snake_case | `user_sessions`, `file_versions` | +| Use plural nouns | `users`, `files`, `tags` | +| Junction tables: `{entity1}_{entity2}` | `file_tags`, `user_roles` | + +### Columns + +| Type | Convention | Example | +| ----------- | ------------------------------ | ---------------------------- | +| Primary key | `id` | `id` | +| Foreign key | `{entity}_id` | `user_id`, `folder_id` | +| Boolean | `is_` or `has_` prefix | `is_deleted`, `has_password` | +| Timestamp | `_at` suffix | `created_at`, `deleted_at` | +| Count | `_count` suffix | `download_count` | +| Version | `version` or `current_version` | `version` | + +### Indexes + +```typescript +// Pattern: idx_{table}_{column(s)} +index('idx_files_user_id').on(table.userId); +index('idx_files_created_at').on(table.createdAt); +index('idx_messages_conversation_created').on(table.conversationId, table.createdAt); +``` + +## Common Patterns + +### 1. Soft Deletes + +```typescript +// Schema +isDeleted: boolean('is_deleted').default(false).notNull(), +deletedAt: timestamp('deleted_at', { withTimezone: true }), + +// Query - always filter out deleted +const activeFiles = await db + .select() + .from(files) + .where(and( + eq(files.userId, userId), + eq(files.isDeleted, false) // Always include this + )); + +// Soft delete +await db + .update(files) + .set({ isDeleted: true, deletedAt: new Date() }) + .where(eq(files.id, fileId)); + +// Hard delete (permanent) +await db + .delete(files) + .where(eq(files.id, fileId)); +``` + +### 2. Timestamps + +```typescript +// Schema - always include both +createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), +updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + +// Update - always set updatedAt +await db + .update(files) + .set({ name: newName, updatedAt: new Date() }) + .where(eq(files.id, fileId)); +``` + +### 3. Optimistic Locking (for concurrent updates) + +```typescript +// Schema +version: integer('version').default(1).notNull(), + +// Update with version check +const [updated] = await db + .update(balances) + .set({ + amount: newAmount, + version: sql`version + 1`, + updatedAt: new Date(), + }) + .where(and( + eq(balances.userId, userId), + eq(balances.version, currentVersion) // Only update if version matches + )) + .returning(); + +if (!updated) { + return err(ErrorCode.CONFLICT, 'Balance was modified by another operation'); +} +``` + +### 4. JSONB for Flexible Data + +```typescript +// Schema +metadata: jsonb('metadata').$type>(), +settings: jsonb('settings').default({}).$type(), +tags: jsonb('tags').$type().default([]), + +// Query JSONB +const usersWithTag = await db + .select() + .from(users) + .where(sql`${users.tags} @> '["premium"]'::jsonb`); +``` + +### 5. Enums + +```typescript +// Define enum +export const transactionTypeEnum = pgEnum('transaction_type', [ + 'purchase', + 'usage', + 'refund', + 'bonus', + 'adjustment', +]); + +// Use in table +type: transactionTypeEnum('type').notNull(), + +// TypeScript type +type TransactionType = typeof transactionTypeEnum.enumValues[number]; +``` + +### 6. Pagination + +```typescript +async function getPaginated( + userId: string, + page: number = 1, + limit: number = 20 +): Promise> { + const offset = (page - 1) * limit; + + const [items, countResult] = await Promise.all([ + db + .select() + .from(files) + .where(and(eq(files.userId, userId), eq(files.isDeleted, false))) + .orderBy(desc(files.createdAt)) + .limit(limit) + .offset(offset), + db + .select({ count: sql`count(*)` }) + .from(files) + .where(and(eq(files.userId, userId), eq(files.isDeleted, false))), + ]); + + return ok({ items, total: countResult[0].count }); +} +``` + +## Migrations + +### Configuration + +```typescript +// drizzle.config.ts +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/db/schema/index.ts', + out: './src/db/migrations', + driver: 'pg', + dbCredentials: { + connectionString: process.env.DATABASE_URL!, + }, + verbose: true, + strict: true, +}); +``` + +### Commands + +```bash +# Generate migration from schema changes +pnpm drizzle-kit generate + +# Push schema directly (development only) +pnpm drizzle-kit push + +# Open Drizzle Studio +pnpm drizzle-kit studio + +# Run migrations (production) +pnpm db:migrate +``` + +### Migration Runner + +```typescript +// src/db/migrate.ts +import { drizzle } from 'drizzle-orm/postgres-js'; +import { migrate } from 'drizzle-orm/postgres-js/migrator'; +import postgres from 'postgres'; + +async function runMigrations() { + const connection = postgres(process.env.DATABASE_URL!, { max: 1 }); + const db = drizzle(connection); + + console.log('Running migrations...'); + await migrate(db, { migrationsFolder: './src/db/migrations' }); + console.log('Migrations complete'); + + await connection.end(); +} + +runMigrations().catch(console.error); +``` + +## Query Patterns + +### Select with Joins + +```typescript +const filesWithTags = await db + .select({ + file: files, + tags: sql`array_agg(${tags.name})`, + }) + .from(files) + .leftJoin(fileTags, eq(files.id, fileTags.fileId)) + .leftJoin(tags, eq(fileTags.tagId, tags.id)) + .where(eq(files.userId, userId)) + .groupBy(files.id); +``` + +### Transactions + +```typescript +const result = await db.transaction(async (tx) => { + // All operations in same transaction + const [file] = await tx.insert(files).values(newFile).returning(); + + await tx.insert(fileVersions).values({ fileId: file.id, versionNumber: 1 }); + + return file; +}); +``` + +### Upsert + +```typescript +await db + .insert(userSettings) + .values({ userId, theme: 'dark' }) + .onConflictDoUpdate({ + target: userSettings.userId, + set: { theme: 'dark', updatedAt: new Date() }, + }); +``` + +## Anti-Patterns + +### 1. N+1 Queries + +```typescript +// BAD - N+1 queries +const files = await db.select().from(files); +for (const file of files) { + const tags = await db.select().from(tags).where(eq(tags.fileId, file.id)); // N queries! +} + +// GOOD - Single query with join +const filesWithTags = await db + .select() + .from(files) + .leftJoin(fileTags, eq(files.id, fileTags.fileId)) + .leftJoin(tags, eq(fileTags.tagId, tags.id)); +``` + +### 2. Missing Indexes + +```typescript +// If you frequently query by a column, add an index +// BAD - No index on frequently queried column +const userFiles = await db.select().from(files).where(eq(files.userId, userId)); + +// GOOD - Index defined in schema +}, (table) => ({ + userIdIdx: index('idx_files_user_id').on(table.userId), +})); +``` + +### 3. Storing Derived Data + +```typescript +// BAD - Storing calculated totals that can become stale +totalFiles: integer('total_files'), + +// GOOD - Calculate when needed +const { count } = await db + .select({ count: sql`count(*)` }) + .from(files) + .where(eq(files.folderId, folderId)); +``` diff --git a/.claude/guidelines/error-handling.md b/.claude/guidelines/error-handling.md new file mode 100644 index 000000000..f107fc235 --- /dev/null +++ b/.claude/guidelines/error-handling.md @@ -0,0 +1,605 @@ +# Error Handling Guidelines + +## Philosophy: Go-Style Error Handling + +We use **explicit error handling** inspired by Go's error handling pattern. Instead of throwing exceptions everywhere, we return `Result` types that force callers to handle errors explicitly. + +### Why? + +1. **Explicit over implicit** - Errors are part of the function signature +2. **No surprise exceptions** - You know exactly what can fail +3. **Consistent error codes** - Same codes across frontend and backend +4. **Better error messages** - Structured errors with codes and context + +## Package: @manacore/shared-errors + +The error handling system is implemented in `packages/shared-errors/`. Import from it: + +```typescript +import { + // Result type and helpers + Result, + AsyncResult, + ok, + err, + isOk, + isErr, + unwrap, + unwrapOr, + map, + andThen, + match, + tryCatch, + tryCatchAsync, + combine, + + // Error codes + ErrorCode, + ERROR_CODE_TO_HTTP_STATUS, + + // Error classes + AppError, + ValidationError, + NotFoundError, + AuthError, + CreditError, + ServiceError, + RateLimitError, + NetworkError, + DatabaseError, + + // Type guards + isAppError, + isValidationError, + isNotFoundError, + hasErrorCode, + isRetryable, + getHttpStatus, + + // Utilities + wrap, + toAppError, +} from '@manacore/shared-errors'; +``` + +## Core Types + +### Result Type + +```typescript +// Result represents success or failure +export type Result = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: E }; + +// Async version for async functions +export type AsyncResult = Promise>; + +// Create success result +const user = ok({ id: '123', name: 'John' }); + +// Create failure result +const error = err(new NotFoundError('User', userId)); +``` + +### Error Classes + +```typescript +// Base error class +class AppError extends Error { + code: ErrorCode; + context?: ErrorContext; + cause?: Error; +} + +// Specialized error classes +ValidationError.invalidInput('email', 'must be valid email'); +NotFoundError.user(userId); +AuthError.tokenExpired(); +CreditError.insufficient(required, available); +ServiceError.generation('AI generation failed'); +RateLimitError.exceeded(retryAfter); +NetworkError.timeout(); +DatabaseError.constraint('unique_email'); +``` + +## Error Codes + +All error codes are defined in `@manacore/shared-errors`: + +```typescript +export enum ErrorCode { + // Validation (400) + VALIDATION_FAILED = 'VALIDATION_FAILED', + INVALID_INPUT = 'INVALID_INPUT', + MISSING_REQUIRED_FIELD = 'MISSING_REQUIRED_FIELD', + INVALID_FORMAT = 'INVALID_FORMAT', + + // Authentication (401) + AUTHENTICATION_REQUIRED = 'AUTHENTICATION_REQUIRED', + INVALID_TOKEN = 'INVALID_TOKEN', + TOKEN_EXPIRED = 'TOKEN_EXPIRED', + + // Authorization (403) + PERMISSION_DENIED = 'PERMISSION_DENIED', + RESOURCE_NOT_OWNED = 'RESOURCE_NOT_OWNED', + + // Not Found (404) + RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND', + USER_NOT_FOUND = 'USER_NOT_FOUND', + + // Payment/Credit (402) + INSUFFICIENT_CREDITS = 'INSUFFICIENT_CREDITS', + PAYMENT_REQUIRED = 'PAYMENT_REQUIRED', + + // Conflict (409) + CONFLICT = 'CONFLICT', + DUPLICATE_ENTRY = 'DUPLICATE_ENTRY', + + // Rate Limiting (429) + RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED', + TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS', + + // Service Errors (500) + INTERNAL_ERROR = 'INTERNAL_ERROR', + SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE', + GENERATION_FAILED = 'GENERATION_FAILED', + EXTERNAL_SERVICE_ERROR = 'EXTERNAL_SERVICE_ERROR', + + // Network Errors + NETWORK_ERROR = 'NETWORK_ERROR', + TIMEOUT = 'TIMEOUT', + + // Database Errors + DATABASE_ERROR = 'DATABASE_ERROR', + CONSTRAINT_VIOLATION = 'CONSTRAINT_VIOLATION', + + // Unknown + UNKNOWN_ERROR = 'UNKNOWN_ERROR', +} +``` + +### HTTP Status Mapping + +```typescript +import { ERROR_CODE_TO_HTTP_STATUS, getHttpStatus } from '@manacore/shared-errors'; + +// Get HTTP status for an error code +const status = ERROR_CODE_TO_HTTP_STATUS[ErrorCode.RESOURCE_NOT_FOUND]; // 404 + +// Or use helper function +const status = getHttpStatus(error); // Returns appropriate HTTP status +``` + +### Retryable Errors + +```typescript +import { isRetryable } from '@manacore/shared-errors'; + +// Check if an error is worth retrying +if (isRetryable(error)) { + await delay(1000); + return retry(operation); +} +``` + +## Backend Usage + +### Service Layer + +```typescript +// src/files/file.service.ts +import { Injectable, Inject } from '@nestjs/common'; +import { + AsyncResult, + ok, + err, + isOk, + NotFoundError, + DatabaseError, + ValidationError, +} from '@manacore/shared-errors'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { files, File, NewFile } from '../db/schema'; + +@Injectable() +export class FileService { + constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + + async findById(id: string, userId: string): AsyncResult { + try { + const [file] = await this.db + .select() + .from(files) + .where(and(eq(files.id, id), eq(files.userId, userId), eq(files.isDeleted, false))); + + if (!file) { + return err(new NotFoundError('File', id)); + } + + return ok(file); + } catch (error) { + return err(DatabaseError.query('Failed to fetch file', error)); + } + } + + async create(userId: string, dto: CreateFileDto): AsyncResult { + // Validation + if (!dto.name?.trim()) { + return err(ValidationError.required('name')); + } + + try { + const newFile: NewFile = { + userId, + name: dto.name.trim(), + mimeType: dto.mimeType, + size: dto.size, + storagePath: dto.storagePath, + storageKey: dto.storageKey, + parentFolderId: dto.folderId, + }; + + const [created] = await this.db.insert(files).values(newFile).returning(); + return ok(created); + } catch (error) { + // Handle unique constraint violations + if (error.code === '23505') { + return err(DatabaseError.constraint('A file with this name already exists')); + } + return err(DatabaseError.query('Failed to create file', error)); + } + } + + async delete(id: string, userId: string): AsyncResult { + const fileResult = await this.findById(id, userId); + if (!isOk(fileResult)) { + return fileResult; // Propagate error + } + + try { + await this.db + .update(files) + .set({ isDeleted: true, deletedAt: new Date() }) + .where(eq(files.id, id)); + + return ok(undefined); + } catch (error) { + return err(DatabaseError.query('Failed to delete file', error)); + } + } +} +``` + +### Controller Layer + +```typescript +// src/files/file.controller.ts +import { Controller, Get, Post, Delete, Param, Body, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { isOk, unwrap } from '@manacore/shared-errors'; +import { FileService } from './file.service'; + +@Controller('files') +@UseGuards(JwtAuthGuard) +export class FileController { + constructor(private readonly fileService: FileService) {} + + @Get(':id') + async getFile(@Param('id') id: string, @CurrentUser() user: CurrentUserData) { + const result = await this.fileService.findById(id, user.userId); + + if (!isOk(result)) { + throw result.error; // AppError extends Error, caught by exception filter + } + + return { file: result.value }; + } + + @Post() + async createFile(@Body() dto: CreateFileDto, @CurrentUser() user: CurrentUserData) { + const result = await this.fileService.create(user.userId, dto); + + if (!isOk(result)) { + throw result.error; + } + + return { file: result.value }; + } + + @Delete(':id') + async deleteFile(@Param('id') id: string, @CurrentUser() user: CurrentUserData) { + // Alternative: use unwrap() which throws on error + unwrap(await this.fileService.delete(id, user.userId)); + + return { success: true }; + } +} +``` + +### Exception Filter + +The package provides a ready-to-use exception filter: + +```typescript +// In main.ts or app.module.ts +import { AppExceptionFilter } from '@manacore/shared-errors/nestjs'; + +// Apply globally +app.useGlobalFilters(new AppExceptionFilter()); +``` + +The filter automatically: + +- Maps `ErrorCode` to HTTP status codes +- Returns consistent JSON error format +- Logs server errors (5xx) + +Custom filter example: + +```typescript +// src/common/filters/app-exception.filter.ts +import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus, Logger } from '@nestjs/common'; +import { Response } from 'express'; +import { AppError, isAppError, getHttpStatus } from '@manacore/shared-errors'; + +@Catch(AppError) +export class AppExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger('AppException'); + + catch(exception: AppError, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const status = getHttpStatus(exception); + + // Log server errors + if (status >= 500) { + this.logger.error(exception.message, exception.stack); + } + + response.status(status).json({ + ok: false, + error: { + code: exception.code, + message: exception.message, + }, + }); + } +} +``` + +## Frontend Usage + +### API Client + +```typescript +// lib/api/client.ts +import { Result, err, ErrorCode, AppError } from '@manacore/shared-errors'; + +interface ApiResponse { + ok: boolean; + data?: T; + error?: AppError; +} + +async function apiRequest(endpoint: string, options: RequestInit = {}): Promise> { + try { + const token = await getAuthToken(); + + const response = await fetch(`${API_URL}${endpoint}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...options.headers, + }, + }); + + const json: ApiResponse = await response.json(); + + if (!json.ok || json.error) { + return { + ok: false, + error: json.error ?? { + code: ErrorCode.UNKNOWN_ERROR, + message: 'Request failed', + }, + }; + } + + return { ok: true, data: json.data as T }; + } catch (error) { + return err(ErrorCode.EXTERNAL_SERVICE_ERROR, 'Network request failed'); + } +} + +// Typed API methods +export const api = { + files: { + get: (id: string) => apiRequest(`/files/${id}`), + list: (folderId?: string) => apiRequest(`/files?folderId=${folderId ?? ''}`), + create: (data: CreateFileDto) => + apiRequest('/files', { + method: 'POST', + body: JSON.stringify(data), + }), + delete: (id: string) => apiRequest(`/files/${id}`, { method: 'DELETE' }), + }, +}; +``` + +### Component Usage (Svelte 5) + +```svelte + +``` + +### Component Usage (React Native) + +```typescript +// hooks/useFiles.ts +import { useState, useCallback } from 'react'; +import { api } from '../services/api'; +import { ErrorCode, Result, AppError } from '@manacore/shared-errors'; + +export function useFiles() { + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const loadFiles = useCallback(async () => { + setLoading(true); + setError(null); + + const result = await api.files.list(); + + if (!result.ok) { + setError(result.error); + } else { + setFiles(result.data); + } + + setLoading(false); + }, []); + + const deleteFile = useCallback(async (id: string): Promise => { + const result = await api.files.delete(id); + + if (!result.ok) { + return false; + } + + setFiles((prev) => prev.filter((f) => f.id !== id)); + return true; + }, []); + + return { files, loading, error, loadFiles, deleteFile }; +} +``` + +## Error Chaining + +### Wrapping Errors with Context + +```typescript +async function processUpload(userId: string, file: File): Promise> { + // Validate file + const validationResult = validateFile(file); + if (!validationResult.ok) { + return validationResult; // Return validation error as-is + } + + // Upload to storage + const uploadResult = await storageService.upload(file); + if (!uploadResult.ok) { + // Add context to storage error + return err(ErrorCode.UPLOAD_FAILED, `Failed to upload file: ${uploadResult.error.message}`, { + originalError: uploadResult.error, + }); + } + + // Save to database + const saveResult = await fileService.create(userId, { + name: file.name, + storagePath: uploadResult.data.path, + }); + + if (!saveResult.ok) { + // Cleanup on failure + await storageService.delete(uploadResult.data.path); + return saveResult; + } + + return saveResult; +} +``` + +## Logging Errors + +```typescript +import { Logger } from '@nestjs/common'; + +@Injectable() +export class FileService { + private readonly logger = new Logger(FileService.name); + + async create(userId: string, dto: CreateFileDto): Promise> { + try { + // ... operation + } catch (error) { + // Log full error for debugging + this.logger.error('Failed to create file', { + userId, + fileName: dto.name, + error: error.message, + stack: error.stack, + }); + + // Return user-friendly error + return err(ErrorCode.DATABASE_ERROR, 'Failed to create file'); + } + } +} +``` + +## Best Practices + +### Do's + +1. **Always check result.ok before accessing data** +2. **Use specific error codes** rather than generic ones +3. **Include helpful messages** for debugging +4. **Log errors at the service layer** +5. **Return early on errors** to avoid nested conditions + +### Don'ts + +1. **Don't throw exceptions in services** - use Result instead +2. **Don't expose internal error details** to users +3. **Don't use try-catch for flow control** +4. **Don't ignore error results** - always handle them +5. **Don't use string error codes** - use the ErrorCode enum diff --git a/.claude/guidelines/expo-mobile.md b/.claude/guidelines/expo-mobile.md new file mode 100644 index 000000000..5f6be7d46 --- /dev/null +++ b/.claude/guidelines/expo-mobile.md @@ -0,0 +1,789 @@ +# Expo Mobile Guidelines + +## Overview + +All mobile applications use **Expo SDK 52+** with **React Native** and **Expo Router** for file-based routing. Styling uses **NativeWind** (Tailwind for React Native). + +## Project Structure + +``` +apps/{project}/apps/mobile/ +├── app/ +│ ├── _layout.tsx # Root layout (Stack) +│ ├── index.tsx # Home screen +│ ├── (auth)/ # Auth screens +│ │ ├── _layout.tsx +│ │ ├── login.tsx +│ │ └── register.tsx +│ ├── (drawer)/ # Main app with drawer +│ │ ├── _layout.tsx +│ │ └── (tabs)/ +│ │ ├── _layout.tsx +│ │ ├── home.tsx +│ │ ├── files.tsx +│ │ └── settings.tsx +│ └── file/[id].tsx # Dynamic route +├── components/ +│ ├── ui/ # Reusable UI components +│ ├── layout/ # Layout components +│ └── {feature}/ # Feature components +├── context/ +│ └── AuthProvider.tsx # Auth context +├── hooks/ +│ ├── useAuth.ts +│ └── useFiles.ts +├── services/ +│ └── api.ts # API client +├── lib/ +│ └── utils.ts # Utilities +├── types/ +│ └── index.ts +├── assets/ # Images, fonts +├── app.json # Expo config +├── tailwind.config.js # NativeWind config +├── babel.config.js +└── package.json +``` + +## App Entry Point + +```tsx +// app/_layout.tsx +import { Stack } from 'expo-router'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { AuthProvider } from '../context/AuthProvider'; +import { ThemeProvider } from '../context/ThemeProvider'; +import '../global.css'; // NativeWind styles + +export default function RootLayout() { + return ( + + + + + + + + + + + + ); +} +``` + +## Navigation + +### Drawer Navigation + +```tsx +// app/(drawer)/_layout.tsx +import { Drawer } from 'expo-router/drawer'; +import CustomDrawer from '../../components/layout/CustomDrawer'; + +export default function DrawerLayout() { + return ( + } + screenOptions={{ + drawerType: 'front', + headerShown: false, + }} + > + + + + ); +} +``` + +### Tab Navigation + +```tsx +// app/(drawer)/(tabs)/_layout.tsx +import { Tabs } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; + +export default function TabLayout() { + return ( + + , + }} + /> + , + }} + /> + , + }} + /> + + ); +} +``` + +### Programmatic Navigation + +```tsx +import { router } from 'expo-router'; + +// Navigate to route +router.push('/files'); +router.push('/file/123'); + +// Navigate with params +router.push({ pathname: '/file/[id]', params: { id: '123' } }); + +// Replace (no back) +router.replace('/home'); + +// Go back +router.back(); + +// Navigate to modal +router.push('/modal'); +``` + +## Auth Context + +```tsx +// context/AuthProvider.tsx +import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import * as SecureStore from 'expo-secure-store'; +import { router } from 'expo-router'; +import { api } from '../services/api'; + +interface User { + id: string; + email: string; + name: string; +} + +interface AuthContextType { + user: User | null; + token: string | null; + loading: boolean; + login: (email: string, password: string) => Promise; + logout: () => Promise; + isAuthenticated: boolean; +} + +const AuthContext = createContext(undefined); + +const TOKEN_KEY = 'auth_token'; +const USER_KEY = 'auth_user'; + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [token, setToken] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + loadStoredAuth(); + }, []); + + async function loadStoredAuth() { + try { + const storedToken = await SecureStore.getItemAsync(TOKEN_KEY); + const storedUser = await SecureStore.getItemAsync(USER_KEY); + + if (storedToken && storedUser) { + setToken(storedToken); + setUser(JSON.parse(storedUser)); + } + } catch (error) { + console.error('Failed to load auth:', error); + } finally { + setLoading(false); + } + } + + async function login(email: string, password: string): Promise { + const result = await api.auth.login({ email, password }); + + if (!result.ok) { + return false; + } + + const { token: newToken, user: newUser } = result.data; + + await SecureStore.setItemAsync(TOKEN_KEY, newToken); + await SecureStore.setItemAsync(USER_KEY, JSON.stringify(newUser)); + + setToken(newToken); + setUser(newUser); + + return true; + } + + async function logout() { + await SecureStore.deleteItemAsync(TOKEN_KEY); + await SecureStore.deleteItemAsync(USER_KEY); + + setToken(null); + setUser(null); + + router.replace('/login'); + } + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} +``` + +## Custom Hooks + +### Data Fetching Hook + +```tsx +// hooks/useFiles.ts +import { useState, useCallback, useEffect } from 'react'; +import { api } from '../services/api'; +import type { File, AppError } from '../types'; + +interface UseFilesResult { + files: File[]; + loading: boolean; + error: AppError | null; + loadFiles: (folderId?: string) => Promise; + deleteFile: (id: string) => Promise; + refresh: () => Promise; +} + +export function useFiles(initialFolderId?: string): UseFilesResult { + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [folderId, setFolderId] = useState(initialFolderId); + + const loadFiles = useCallback( + async (newFolderId?: string) => { + const targetFolderId = newFolderId ?? folderId; + setFolderId(targetFolderId); + setLoading(true); + setError(null); + + const result = await api.files.list(targetFolderId); + + if (result.ok) { + setFiles(result.data); + } else { + setError(result.error); + } + + setLoading(false); + }, + [folderId] + ); + + const deleteFile = useCallback(async (id: string): Promise => { + const result = await api.files.delete(id); + + if (result.ok) { + setFiles((prev) => prev.filter((f) => f.id !== id)); + return true; + } + + setError(result.error); + return false; + }, []); + + const refresh = useCallback(() => loadFiles(), [loadFiles]); + + useEffect(() => { + loadFiles(); + }, []); + + return { files, loading, error, loadFiles, deleteFile, refresh }; +} +``` + +### Mutation Hook + +```tsx +// hooks/useCreateFile.ts +import { useState, useCallback } from 'react'; +import { api } from '../services/api'; +import type { File, CreateFileDto, AppError } from '../types'; + +interface UseCreateFileResult { + create: (data: CreateFileDto) => Promise; + loading: boolean; + error: AppError | null; +} + +export function useCreateFile(): UseCreateFileResult { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const create = useCallback(async (data: CreateFileDto): Promise => { + setLoading(true); + setError(null); + + const result = await api.files.create(data); + + setLoading(false); + + if (result.ok) { + return result.data; + } + + setError(result.error); + return null; + }, []); + + return { create, loading, error }; +} +``` + +## API Client + +```typescript +// services/api.ts +import * as SecureStore from 'expo-secure-store'; +import Constants from 'expo-constants'; +import type { Result, AppError } from '@manacore/shared-errors'; +import { ErrorCode } from '@manacore/shared-errors'; + +const API_URL = Constants.expoConfig?.extra?.apiUrl ?? 'http://localhost:3016'; +const TOKEN_KEY = 'auth_token'; + +interface ApiResponse { + ok: boolean; + data?: T; + error?: AppError; +} + +async function getToken(): Promise { + try { + return await SecureStore.getItemAsync(TOKEN_KEY); + } catch { + return null; + } +} + +async function request(endpoint: string, options: RequestInit = {}): Promise> { + try { + const token = await getToken(); + + const response = await fetch(`${API_URL}${endpoint}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...options.headers, + }, + }); + + const json: ApiResponse = await response.json(); + + if (!json.ok || json.error) { + return { + ok: false, + error: json.error ?? { code: ErrorCode.UNKNOWN_ERROR, message: 'Request failed' }, + }; + } + + return { ok: true, data: json.data as T }; + } catch (error) { + return { + ok: false, + error: { code: ErrorCode.EXTERNAL_SERVICE_ERROR, message: 'Network error' }, + }; + } +} + +export const api = { + auth: { + login: (data: { email: string; password: string }) => + request<{ token: string; user: User }>('/api/v1/auth/login', { + method: 'POST', + body: JSON.stringify(data), + }), + + register: (data: { email: string; password: string; name: string }) => + request<{ token: string; user: User }>('/api/v1/auth/register', { + method: 'POST', + body: JSON.stringify(data), + }), + }, + + files: { + list: (folderId?: string) => + request(`/api/v1/files${folderId ? `?folderId=${folderId}` : ''}`), + + get: (id: string) => request(`/api/v1/files/${id}`), + + create: (data: CreateFileDto) => + request('/api/v1/files', { + method: 'POST', + body: JSON.stringify(data), + }), + + delete: (id: string) => request(`/api/v1/files/${id}`, { method: 'DELETE' }), + }, +}; +``` + +## Components + +### Screen Component + +```tsx +// app/(drawer)/(tabs)/files.tsx +import { View, FlatList, RefreshControl } from 'react-native'; +import { useFiles } from '../../../hooks/useFiles'; +import { FileCard } from '../../../components/files/FileCard'; +import { LoadingSpinner } from '../../../components/ui/LoadingSpinner'; +import { ErrorView } from '../../../components/ui/ErrorView'; +import { EmptyState } from '../../../components/ui/EmptyState'; + +export default function FilesScreen() { + const { files, loading, error, refresh } = useFiles(); + + if (loading && files.length === 0) { + return ; + } + + if (error) { + return ; + } + + return ( + + item.id} + renderItem={({ item }) => } + refreshControl={} + ListEmptyComponent={ + + } + contentContainerStyle={{ padding: 16, gap: 12 }} + /> + + ); +} +``` + +### Reusable Component + +```tsx +// components/files/FileCard.tsx +import { View, Text, Pressable } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { router } from 'expo-router'; +import type { File } from '../../types'; +import { formatBytes, formatDate } from '../../lib/utils'; + +interface FileCardProps { + file: File; + onDelete?: () => void; +} + +export function FileCard({ file, onDelete }: FileCardProps) { + const handlePress = () => { + router.push({ pathname: '/file/[id]', params: { id: file.id } }); + }; + + return ( + + + + + + + + + {file.name} + + + {formatBytes(file.size)} • {formatDate(file.createdAt)} + + + + {onDelete && ( + + + + )} + + + ); +} +``` + +### UI Component + +```tsx +// components/ui/Button.tsx +import { Pressable, Text, ActivityIndicator, PressableProps } from 'react-native'; +import { cva, type VariantProps } from 'class-variance-authority'; + +const buttonVariants = cva('flex-row items-center justify-center rounded-xl', { + variants: { + variant: { + primary: 'bg-primary', + secondary: 'bg-secondary', + outline: 'border border-border bg-transparent', + ghost: 'bg-transparent', + }, + size: { + sm: 'h-9 px-3', + md: 'h-11 px-4', + lg: 'h-14 px-6', + }, + }, + defaultVariants: { + variant: 'primary', + size: 'md', + }, +}); + +const textVariants = cva('font-medium', { + variants: { + variant: { + primary: 'text-white', + secondary: 'text-secondary-foreground', + outline: 'text-foreground', + ghost: 'text-foreground', + }, + size: { + sm: 'text-sm', + md: 'text-base', + lg: 'text-lg', + }, + }, + defaultVariants: { + variant: 'primary', + size: 'md', + }, +}); + +interface ButtonProps extends PressableProps, VariantProps { + children: string; + loading?: boolean; +} + +export function Button({ + children, + variant, + size, + loading = false, + disabled, + className, + ...props +}: ButtonProps) { + return ( + + {loading ? ( + + ) : ( + {children} + )} + + ); +} +``` + +## NativeWind Setup + +### Configuration + +```javascript +// tailwind.config.js +module.exports = { + content: ['./app/**/*.{js,ts,tsx}', './components/**/*.{js,ts,tsx}'], + presets: [require('nativewind/preset')], + theme: { + extend: { + colors: { + primary: '#0A84FF', + secondary: '#5856D6', + background: '#F2F2F7', + foreground: '#1C1C1E', + card: '#FFFFFF', + border: '#E5E5EA', + muted: '#8E8E93', + 'muted-foreground': '#8E8E93', + }, + }, + }, +}; +``` + +### Usage + +```tsx +// NativeWind uses className prop + + Title + Subtitle + + +// Conditional classes + + +// Dynamic classes + + +// Platform-specific (use Platform.select for complex cases) + +``` + +## Form Handling + +```tsx +// app/(auth)/login.tsx +import { useState } from 'react'; +import { View, Text, TextInput, Alert } from 'react-native'; +import { router } from 'expo-router'; +import { useAuth } from '../../context/AuthProvider'; +import { Button } from '../../components/ui/Button'; + +export default function LoginScreen() { + const { login } = useAuth(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + + async function handleLogin() { + if (!email.trim() || !password) { + Alert.alert('Error', 'Please fill in all fields'); + return; + } + + setLoading(true); + const success = await login(email.trim(), password); + setLoading(false); + + if (success) { + router.replace('/'); + } else { + Alert.alert('Error', 'Invalid email or password'); + } + } + + return ( + + Welcome Back + + + + Email + + + + + Password + + + + + + + ); +} +``` + +## Environment Variables + +```typescript +// Access via Expo Constants +import Constants from 'expo-constants'; + +const API_URL = Constants.expoConfig?.extra?.apiUrl; + +// app.json / app.config.js +{ + "expo": { + "extra": { + "apiUrl": process.env.EXPO_PUBLIC_API_URL + } + } +} + +// .env +EXPO_PUBLIC_API_URL=http://localhost:3016 +EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001 +``` + +## Best Practices + +### Do's + +1. **Use Expo Router** for navigation (file-based) +2. **Use NativeWind** for styling (consistent with web) +3. **Use SecureStore** for sensitive data (tokens) +4. **Create custom hooks** for data fetching +5. **Use TypeScript** with strict mode + +### Don'ts + +1. **Don't use inline styles** - use NativeWind classes +2. **Don't store tokens in AsyncStorage** - use SecureStore +3. **Don't make API calls in render** - use effects/hooks +4. **Don't ignore loading states** - always show feedback +5. **Don't forget error handling** - handle all error cases diff --git a/.claude/guidelines/nestjs-backend.md b/.claude/guidelines/nestjs-backend.md new file mode 100644 index 000000000..b00490882 --- /dev/null +++ b/.claude/guidelines/nestjs-backend.md @@ -0,0 +1,659 @@ +# NestJS Backend Guidelines + +## Overview + +All backend services use NestJS with a consistent architecture. This guide covers controllers, services, DTOs, modules, and integration with the error handling system. + +## Project Structure + +``` +apps/{project}/apps/backend/ +├── src/ +│ ├── main.ts # Bootstrap +│ ├── app.module.ts # Root module +│ ├── db/ +│ │ ├── schema/ # Drizzle schemas +│ │ ├── connection.ts # DB singleton +│ │ ├── database.module.ts # NestJS module +│ │ └── migrations/ # Migration files +│ ├── common/ +│ │ ├── filters/ # Exception filters +│ │ ├── guards/ # Custom guards +│ │ └── decorators/ # Custom decorators +│ ├── health/ +│ │ ├── health.controller.ts +│ │ └── health.module.ts +│ └── {feature}/ +│ ├── {feature}.controller.ts +│ ├── {feature}.service.ts +│ ├── {feature}.module.ts +│ ├── {feature}.spec.ts +│ └── dto/ +│ ├── create-{feature}.dto.ts +│ └── update-{feature}.dto.ts +├── test/ +│ ├── jest-e2e.json +│ └── app.e2e-spec.ts +├── drizzle.config.ts +├── nest-cli.json +├── package.json +└── tsconfig.json +``` + +## Bootstrap (main.ts) + +```typescript +// src/main.ts +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe, Logger } from '@nestjs/common'; +import { AppModule } from './app.module'; +import { AppExceptionFilter } from './common/filters/app-exception.filter'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + const logger = new Logger('Bootstrap'); + + // CORS + const corsOrigins = process.env.CORS_ORIGINS?.split(',').map((o) => o.trim()) || [ + 'http://localhost:3000', + 'http://localhost:5173', + 'http://localhost:8081', + ]; + + app.enableCors({ + origin: corsOrigins, + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + credentials: true, + }); + + // Global validation pipe + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, // Strip unknown properties + forbidNonWhitelisted: true, // Reject unknown properties + transform: true, // Auto-transform types + transformOptions: { + enableImplicitConversion: true, + }, + }) + ); + + // Global exception filter + app.useGlobalFilters(new AppExceptionFilter()); + + // API prefix + app.setGlobalPrefix('api/v1'); + + const port = process.env.PORT || 3000; + await app.listen(port); + logger.log(`Application running on http://localhost:${port}`); +} + +bootstrap(); +``` + +## App Module + +```typescript +// src/app.module.ts +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { DatabaseModule } from './db/database.module'; +import { HealthModule } from './health/health.module'; +import { FileModule } from './file/file.module'; +import { FolderModule } from './folder/folder.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: '.env', + }), + DatabaseModule, + HealthModule, + FileModule, + FolderModule, + ], +}) +export class AppModule {} +``` + +## Controllers + +### Basic Pattern + +```typescript +// src/file/file.controller.ts +import { + Controller, + Get, + Post, + Patch, + Delete, + Param, + Body, + Query, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { AppException } from '@manacore/shared-errors'; +import { FileService } from './file.service'; +import { CreateFileDto, UpdateFileDto, QueryFilesDto } from './dto'; + +@Controller('files') +@UseGuards(JwtAuthGuard) // Apply to all routes in controller +export class FileController { + constructor(private readonly fileService: FileService) {} + + @Get() + async list(@CurrentUser() user: CurrentUserData, @Query() query: QueryFilesDto) { + const result = await this.fileService.findAll(user.userId, query); + if (!result.ok) throw new AppException(result.error); + return { files: result.data }; + } + + @Get(':id') + async getById(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: CurrentUserData) { + const result = await this.fileService.findById(id, user.userId); + if (!result.ok) throw new AppException(result.error); + return { file: result.data }; + } + + @Post() + async create(@Body() dto: CreateFileDto, @CurrentUser() user: CurrentUserData) { + const result = await this.fileService.create(user.userId, dto); + if (!result.ok) throw new AppException(result.error); + return { file: result.data }; + } + + @Patch(':id') + async update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateFileDto, + @CurrentUser() user: CurrentUserData + ) { + const result = await this.fileService.update(id, user.userId, dto); + if (!result.ok) throw new AppException(result.error); + return { file: result.data }; + } + + @Delete(':id') + async delete(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: CurrentUserData) { + const result = await this.fileService.delete(id, user.userId); + if (!result.ok) throw new AppException(result.error); + return { success: true }; + } +} +``` + +### Public Endpoints (No Auth) + +```typescript +@Controller('public') +export class PublicController { + @Get('shares/:token') // No @UseGuards - public access + async getSharedItem(@Param('token') token: string) { + const result = await this.shareService.findByToken(token); + if (!result.ok) throw new AppException(result.error); + return { item: result.data }; + } +} +``` + +## Services + +### Basic Pattern with Result Types + +```typescript +// src/file/file.service.ts +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { Result, ok, err, ErrorCode } from '@manacore/shared-errors'; +import { DATABASE_CONNECTION, Database } from '../db/database.module'; +import { files, File, NewFile } from '../db/schema'; +import { eq, and, desc } from 'drizzle-orm'; +import { CreateFileDto, UpdateFileDto, QueryFilesDto } from './dto'; + +@Injectable() +export class FileService { + private readonly logger = new Logger(FileService.name); + + constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + + async findAll(userId: string, query: QueryFilesDto): Promise> { + try { + const conditions = [eq(files.userId, userId), eq(files.isDeleted, false)]; + + if (query.folderId) { + conditions.push(eq(files.parentFolderId, query.folderId)); + } + + const result = await this.db + .select() + .from(files) + .where(and(...conditions)) + .orderBy(desc(files.createdAt)) + .limit(query.limit ?? 50) + .offset(query.offset ?? 0); + + return ok(result); + } catch (error) { + this.logger.error('Failed to fetch files', { userId, error: error.message }); + return err(ErrorCode.DATABASE_ERROR, 'Failed to fetch files'); + } + } + + async findById(id: string, userId: string): Promise> { + try { + const [file] = await this.db + .select() + .from(files) + .where(and(eq(files.id, id), eq(files.userId, userId), eq(files.isDeleted, false))); + + if (!file) { + return err(ErrorCode.FILE_NOT_FOUND, `File ${id} not found`); + } + + return ok(file); + } catch (error) { + this.logger.error('Failed to fetch file', { id, userId, error: error.message }); + return err(ErrorCode.DATABASE_ERROR, 'Failed to fetch file'); + } + } + + async create(userId: string, dto: CreateFileDto): Promise> { + // Validation + if (!dto.name?.trim()) { + return err(ErrorCode.MISSING_REQUIRED_FIELD, 'File name is required'); + } + + try { + const newFile: NewFile = { + userId, + name: dto.name.trim(), + originalName: dto.originalName, + mimeType: dto.mimeType, + size: dto.size, + storagePath: dto.storagePath, + storageKey: dto.storageKey, + parentFolderId: dto.folderId ?? null, + }; + + const [created] = await this.db.insert(files).values(newFile).returning(); + return ok(created); + } catch (error) { + if (error.code === '23505') { + return err(ErrorCode.DUPLICATE_ENTRY, 'A file with this name already exists'); + } + this.logger.error('Failed to create file', { userId, error: error.message }); + return err(ErrorCode.DATABASE_ERROR, 'Failed to create file'); + } + } + + async update(id: string, userId: string, dto: UpdateFileDto): Promise> { + // Check ownership first + const existingResult = await this.findById(id, userId); + if (!existingResult.ok) return existingResult; + + try { + const [updated] = await this.db + .update(files) + .set({ + ...(dto.name && { name: dto.name.trim() }), + ...(dto.parentFolderId !== undefined && { parentFolderId: dto.parentFolderId }), + updatedAt: new Date(), + }) + .where(eq(files.id, id)) + .returning(); + + return ok(updated); + } catch (error) { + this.logger.error('Failed to update file', { id, error: error.message }); + return err(ErrorCode.DATABASE_ERROR, 'Failed to update file'); + } + } + + async delete(id: string, userId: string): Promise> { + // Check ownership first + const existingResult = await this.findById(id, userId); + if (!existingResult.ok) return existingResult; + + try { + await this.db + .update(files) + .set({ isDeleted: true, deletedAt: new Date() }) + .where(eq(files.id, id)); + + return ok(undefined); + } catch (error) { + this.logger.error('Failed to delete file', { id, error: error.message }); + return err(ErrorCode.DATABASE_ERROR, 'Failed to delete file'); + } + } +} +``` + +### Service with External Dependencies + +```typescript +@Injectable() +export class UploadService { + private readonly logger = new Logger(UploadService.name); + + constructor( + @Inject(DATABASE_CONNECTION) private db: Database, + private readonly storageService: StorageService, + private readonly fileService: FileService + ) {} + + async uploadFile( + userId: string, + file: Express.Multer.File, + folderId?: string + ): Promise> { + // 1. Upload to storage + const storageResult = await this.storageService.upload( + generateStorageKey(userId, file.originalname), + file.buffer, + { contentType: file.mimetype } + ); + + if (!storageResult.ok) { + return err(ErrorCode.UPLOAD_FAILED, 'Failed to upload file to storage'); + } + + // 2. Create database record + const createResult = await this.fileService.create(userId, { + name: file.originalname, + originalName: file.originalname, + mimeType: file.mimetype, + size: file.size, + storagePath: storageResult.data.path, + storageKey: storageResult.data.key, + folderId, + }); + + if (!createResult.ok) { + // Cleanup on failure + await this.storageService.delete(storageResult.data.key); + return createResult; + } + + return createResult; + } +} +``` + +## DTOs + +### Create DTO + +```typescript +// src/file/dto/create-file.dto.ts +import { IsString, IsOptional, IsNumber, IsUUID, MaxLength, Min } from 'class-validator'; + +export class CreateFileDto { + @IsString() + @MaxLength(500) + name: string; + + @IsOptional() + @IsString() + @MaxLength(500) + originalName?: string; + + @IsString() + @MaxLength(255) + mimeType: string; + + @IsNumber() + @Min(0) + size: number; + + @IsString() + @MaxLength(1000) + storagePath: string; + + @IsString() + @MaxLength(500) + storageKey: string; + + @IsOptional() + @IsUUID() + folderId?: string; +} +``` + +### Update DTO (Partial) + +```typescript +// src/file/dto/update-file.dto.ts +import { IsString, IsOptional, IsUUID, MaxLength } from 'class-validator'; + +export class UpdateFileDto { + @IsOptional() + @IsString() + @MaxLength(500) + name?: string; + + @IsOptional() + @IsUUID() + parentFolderId?: string | null; +} +``` + +### Query DTO + +```typescript +// src/file/dto/query-files.dto.ts +import { IsOptional, IsUUID, IsNumber, Min, Max } from 'class-validator'; +import { Transform } from 'class-transformer'; + +export class QueryFilesDto { + @IsOptional() + @IsUUID() + folderId?: string; + + @IsOptional() + @Transform(({ value }) => parseInt(value, 10)) + @IsNumber() + @Min(1) + @Max(100) + limit?: number = 50; + + @IsOptional() + @Transform(({ value }) => parseInt(value, 10)) + @IsNumber() + @Min(0) + offset?: number = 0; +} +``` + +### DTO Index + +```typescript +// src/file/dto/index.ts +export * from './create-file.dto'; +export * from './update-file.dto'; +export * from './query-files.dto'; +``` + +## Modules + +```typescript +// src/file/file.module.ts +import { Module } from '@nestjs/common'; +import { FileController } from './file.controller'; +import { FileService } from './file.service'; +import { UploadService } from './upload.service'; +import { StorageModule } from '../storage/storage.module'; + +@Module({ + imports: [StorageModule], + controllers: [FileController], + providers: [FileService, UploadService], + exports: [FileService], // Export for use in other modules +}) +export class FileModule {} +``` + +## Exception Filter + +```typescript +// src/common/filters/app-exception.filter.ts +import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus, Logger } from '@nestjs/common'; +import { Response } from 'express'; +import { AppException, ERROR_STATUS_MAP, ErrorCode } from '@manacore/shared-errors'; + +@Catch(AppException) +export class AppExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(AppExceptionFilter.name); + + catch(exception: AppException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + const status = ERROR_STATUS_MAP[exception.error.code] ?? HttpStatus.INTERNAL_SERVER_ERROR; + + // Log server errors + if (status >= 500) { + this.logger.error('Server error', { + code: exception.error.code, + message: exception.error.message, + details: exception.error.details, + }); + } + + response.status(status).json({ + ok: false, + error: { + code: exception.error.code, + message: exception.error.message, + ...(process.env.NODE_ENV === 'development' && { + details: exception.error.details, + }), + }, + }); + } +} +``` + +## File Upload + +```typescript +// src/file/file.controller.ts +import { UseInterceptors, UploadedFile, ParseFilePipe, MaxFileSizeValidator } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; + +@Controller('files') +@UseGuards(JwtAuthGuard) +export class FileController { + @Post('upload') + @UseInterceptors(FileInterceptor('file')) + async uploadFile( + @UploadedFile( + new ParseFilePipe({ + validators: [ + new MaxFileSizeValidator({ maxSize: 100 * 1024 * 1024 }), // 100MB + ], + }) + ) + file: Express.Multer.File, + @Query('folderId') folderId: string | undefined, + @CurrentUser() user: CurrentUserData + ) { + const result = await this.uploadService.uploadFile(user.userId, file, folderId); + if (!result.ok) throw new AppException(result.error); + return { file: result.data }; + } +} +``` + +## Health Check + +```typescript +// src/health/health.controller.ts +import { Controller, Get } from '@nestjs/common'; +import { Inject } from '@nestjs/common'; +import { DATABASE_CONNECTION, Database } from '../db/database.module'; +import { sql } from 'drizzle-orm'; + +@Controller('health') +export class HealthController { + constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + + @Get() + async check() { + try { + await this.db.execute(sql`SELECT 1`); + return { + status: 'ok', + timestamp: new Date().toISOString(), + database: 'connected', + }; + } catch (error) { + return { + status: 'error', + timestamp: new Date().toISOString(), + database: 'disconnected', + }; + } + } +} +``` + +## API Response Format + +### Success Responses + +```typescript +// Single resource +{ file: { id: '...', name: '...', ... } } + +// Multiple resources +{ files: [...] } + +// With pagination +{ files: [...], total: 100, page: 1, limit: 20 } + +// Action success +{ success: true } + +// Action with data +{ success: true, message: 'File moved', file: {...} } +``` + +### Error Responses + +```typescript +{ + ok: false, + error: { + code: 'ERR_4003', + message: 'File not found' + } +} +``` + +## Environment Variables + +```env +# Required +NODE_ENV=development +PORT=3016 +DATABASE_URL=postgresql://user:pass@localhost:5432/db +MANA_CORE_AUTH_URL=http://localhost:3001 + +# CORS +CORS_ORIGINS=http://localhost:5173,http://localhost:3000 + +# Storage +S3_ENDPOINT=http://localhost:9000 +S3_REGION=us-east-1 +S3_ACCESS_KEY=minioadmin +S3_SECRET_KEY=minioadmin + +# Optional - Development bypass +DEV_BYPASS_AUTH=true +DEV_USER_ID=dev-user-123 +``` diff --git a/.claude/guidelines/sveltekit-web.md b/.claude/guidelines/sveltekit-web.md new file mode 100644 index 000000000..a307c9352 --- /dev/null +++ b/.claude/guidelines/sveltekit-web.md @@ -0,0 +1,764 @@ +# SvelteKit Web Guidelines + +## Overview + +All web applications use **SvelteKit 2** with **Svelte 5** in runes mode. This guide covers component patterns, state management, routing, and API integration. + +## Project Structure + +``` +apps/{project}/apps/web/ +├── src/ +│ ├── app.html # HTML template +│ ├── app.css # Global styles (Tailwind) +│ ├── app.d.ts # Type declarations +│ ├── hooks.server.ts # Server hooks (auth) +│ ├── lib/ +│ │ ├── components/ # Reusable components +│ │ │ ├── ui/ # Generic UI components +│ │ │ └── {feature}/ # Feature-specific components +│ │ ├── stores/ # Svelte 5 stores (.svelte.ts) +│ │ ├── api/ # API client +│ │ ├── utils/ # Utilities +│ │ └── types/ # TypeScript types +│ └── routes/ +│ ├── +layout.svelte # Root layout +│ ├── +page.svelte # Home page +│ ├── (auth)/ # Auth route group +│ │ ├── login/ +│ │ └── register/ +│ └── (protected)/ # Protected route group +│ ├── +layout.svelte +│ ├── files/ +│ └── settings/ +├── static/ # Static assets +├── svelte.config.js +├── vite.config.ts +├── tailwind.config.js +└── package.json +``` + +## Svelte 5 Runes + +### State with $state + +```svelte + +``` + +### Derived Values with $derived + +```svelte + +``` + +### Effects with $effect + +```svelte + +``` + +### Props with $props + +```svelte + + +
onSelect?.(file)}> + {file.name} + {#if onDelete} + + {/if} +
+``` + +### Bindable Props with $bindable + +```svelte + + + + + + +``` + +## Stores (Svelte 5 Pattern) + +### Store File (.svelte.ts) + +```typescript +// src/lib/stores/files.svelte.ts +import { browser } from '$app/environment'; +import { api } from '$lib/api/client'; +import type { File, AppError } from '$lib/types'; + +// Private state +let files = $state([]); +let loading = $state(false); +let error = $state(null); +let selectedId = $state(null); + +// Derived values +const selectedFile = $derived(files.find((f) => f.id === selectedId) ?? null); + +const fileCount = $derived(files.length); + +// Actions +async function loadFiles(folderId?: string): Promise { + if (!browser) return; + + loading = true; + error = null; + + const result = await api.files.list(folderId); + + if (result.ok) { + files = result.data; + } else { + error = result.error; + } + + loading = false; +} + +async function deleteFile(id: string): Promise { + const result = await api.files.delete(id); + + if (result.ok) { + files = files.filter((f) => f.id !== id); + if (selectedId === id) selectedId = null; + return true; + } + + error = result.error; + return false; +} + +function selectFile(id: string | null): void { + selectedId = id; +} + +function reset(): void { + files = []; + loading = false; + error = null; + selectedId = null; +} + +// Export as object with getters +export const fileStore = { + // Getters for state + get files() { + return files; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + get selectedFile() { + return selectedFile; + }, + get fileCount() { + return fileCount; + }, + + // Actions + loadFiles, + deleteFile, + selectFile, + reset, +}; +``` + +### Using Stores in Components + +```svelte + + +{#if fileStore.loading} + +{:else if fileStore.error} + +{:else} + fileStore.selectFile(file.id)} + onDelete={handleDelete} + /> +{/if} +``` + +## API Client + +```typescript +// src/lib/api/client.ts +import { browser } from '$app/environment'; +import { goto } from '$app/navigation'; +import { authStore } from '$lib/stores/auth.svelte'; +import type { Result, AppError } from '@manacore/shared-errors'; +import { ErrorCode } from '@manacore/shared-errors'; +import { PUBLIC_BACKEND_URL } from '$env/static/public'; + +interface ApiResponse { + ok: boolean; + data?: T; + error?: AppError; +} + +async function request(endpoint: string, options: RequestInit = {}): Promise> { + if (!browser) { + return { ok: false, error: { code: ErrorCode.INTERNAL_ERROR, message: 'SSR not supported' } }; + } + + try { + const token = authStore.token; + + const response = await fetch(`${PUBLIC_BACKEND_URL}${endpoint}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...options.headers, + }, + }); + + // Handle 401 - redirect to login + if (response.status === 401) { + authStore.logout(); + goto('/login'); + return { ok: false, error: { code: ErrorCode.UNAUTHORIZED, message: 'Session expired' } }; + } + + const json: ApiResponse = await response.json(); + + if (!json.ok || json.error) { + return { + ok: false, + error: json.error ?? { code: ErrorCode.UNKNOWN_ERROR, message: 'Request failed' }, + }; + } + + return { ok: true, data: json.data as T }; + } catch (error) { + return { + ok: false, + error: { code: ErrorCode.EXTERNAL_SERVICE_ERROR, message: 'Network error' }, + }; + } +} + +// Typed API endpoints +export const api = { + files: { + list: (folderId?: string) => + request(`/api/v1/files${folderId ? `?folderId=${folderId}` : ''}`), + + get: (id: string) => request(`/api/v1/files/${id}`), + + create: (data: CreateFileDto) => + request('/api/v1/files', { + method: 'POST', + body: JSON.stringify(data), + }), + + update: (id: string, data: UpdateFileDto) => + request(`/api/v1/files/${id}`, { + method: 'PATCH', + body: JSON.stringify(data), + }), + + delete: (id: string) => request(`/api/v1/files/${id}`, { method: 'DELETE' }), + }, + + folders: { + list: () => request('/api/v1/folders'), + get: (id: string) => request(`/api/v1/folders/${id}`), + create: (data: CreateFolderDto) => + request('/api/v1/folders', { + method: 'POST', + body: JSON.stringify(data), + }), + }, +}; +``` + +## Routing + +### Route Groups + +``` +src/routes/ +├── +layout.svelte # Root layout (applies to all) +├── +page.svelte # / (home) +├── (auth)/ # Auth pages (no sidebar) +│ ├── +layout.svelte # Auth layout +│ ├── login/+page.svelte +│ └── register/+page.svelte +└── (app)/ # App pages (with sidebar) + ├── +layout.svelte # App layout with auth check + ├── files/ + │ ├── +page.svelte # /files + │ └── [id]/+page.svelte # /files/:id + └── settings/+page.svelte +``` + +### Layout with Auth Check + +```svelte + + + +{#if authStore.isAuthenticated} +
+ +
+ {@render children()} +
+
+{:else} +
+ +
+{/if} +``` + +### Dynamic Routes + +```svelte + + + +{#if loading} + +{:else if error} + +{:else if file} + +{/if} +``` + +## Components + +### Component Pattern + +```svelte + + + +
e.key === 'Enter' && onSelect?.()} +> +
+ + +
+

{file.name}

+

+ {formattedSize} • {formattedDate} +

+
+ + {#if onDelete} + + {/if} +
+
+``` + +### Snippets (Slot Replacement) + +```svelte + + + + + {#snippet header()} +

Confirm Delete

+ {/snippet} + + {#snippet content()} +

Are you sure you want to delete this file?

+ {/snippet} + + {#snippet footer()} + + + {/snippet} +
+ + + + +{#if open} + +{/if} +``` + +## Styling + +### Tailwind Configuration + +```javascript +// tailwind.config.js +import sharedConfig from '@manacore/shared-tailwind'; + +export default { + presets: [sharedConfig], + content: ['./src/**/*.{html,js,svelte,ts}'], + theme: { + extend: { + // Project-specific overrides + }, + }, +}; +``` + +### Global Styles + +```css +/* src/app.css */ +@import 'tailwindcss'; +@import '@manacore/shared-tailwind/theme.css'; + +/* Custom utilities */ +@layer utilities { + .scrollbar-thin { + scrollbar-width: thin; + } +} + +/* Custom components */ +@layer components { + .btn-primary { + @apply px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors; + } +} +``` + +## Form Handling + +```svelte + + +
+ {#if errors.form} +
{errors.form}
+ {/if} + +
+ + + {#if errors.name} + {errors.name} + {/if} +
+ +
+ + + {#if errors.email} + {errors.email} + {/if} +
+ + +
+``` + +## Environment Variables + +```typescript +// Access in .svelte or .ts files +import { PUBLIC_BACKEND_URL, PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public'; + +// .env file +PUBLIC_BACKEND_URL=http://localhost:3016 +PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001 +``` + +## Anti-Patterns to Avoid + +### Don't Use Old Svelte Syntax + +```svelte + + + + + +``` + +### Don't Create Stores in Components + +```svelte + + + + + +``` + +### Don't Fetch in Render + +```svelte + + + +{#await promise}...{/await} + + + +``` diff --git a/.claude/guidelines/testing.md b/.claude/guidelines/testing.md new file mode 100644 index 000000000..e0fa64d99 --- /dev/null +++ b/.claude/guidelines/testing.md @@ -0,0 +1,577 @@ +# Testing Guidelines + +## Overview + +| App Type | Framework | Config | File Pattern | +|----------|-----------|--------|--------------| +| **NestJS Backend** | Jest + ts-jest | `jest.config.js` | `*.spec.ts` | +| **Expo Mobile** | Jest + jest-expo | `jest.config.js` | `*.test.tsx` | +| **SvelteKit Web** | Vitest | `vitest.config.ts` | `*.test.ts` | +| **E2E** | Playwright | `playwright.config.ts` | `e2e/*.spec.ts` | + +## Coverage Requirements + +```javascript +// Target: 80% for all new code +coverageThresholds: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80, + }, +} +``` + +## Test File Organization + +``` +src/ +├── __tests__/ +│ ├── utils/ +│ │ ├── mock-factories.ts # Centralized factories +│ │ └── test-helpers.ts # Shared utilities +│ └── fixtures/ # Test data files +├── feature/ +│ ├── feature.service.ts +│ └── feature.service.spec.ts # Colocated test +└── ... +``` + +## Mock Factories Pattern + +Create reusable factories for test data: + +```typescript +// src/__tests__/utils/mock-factories.ts +import { nanoid } from 'nanoid'; + +export const mockUserFactory = { + create: (overrides: Partial = {}): User => ({ + id: nanoid(), + email: `test-${nanoid(6)}@example.com`, + name: 'Test User', + role: 'user', + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }), + + createMany: (count: number, overrides: Partial = {}): User[] => { + return Array.from({ length: count }, () => mockUserFactory.create(overrides)); + }, +}; + +export const mockFileFactory = { + create: (overrides: Partial = {}): File => ({ + id: nanoid(), + userId: nanoid(), + name: `file-${nanoid(6)}.txt`, + mimeType: 'text/plain', + size: 1024, + storagePath: `/files/${nanoid()}`, + storageKey: nanoid(), + isDeleted: false, + isFavorite: false, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }), + + createMany: (count: number, overrides: Partial = {}): File[] => { + return Array.from({ length: count }, () => mockFileFactory.create(overrides)); + }, +}; + +// Usage in tests: +const user = mockUserFactory.create({ role: 'admin' }); +const files = mockFileFactory.createMany(5, { userId: user.id }); +``` + +## Test Helpers + +```typescript +// src/__tests__/utils/test-helpers.ts +import { ConfigService } from '@nestjs/config'; + +// Mock config service +export function createMockConfigService(overrides: Record = {}) { + const config: Record = { + DATABASE_URL: 'postgresql://test:test@localhost:5432/test', + MANA_CORE_AUTH_URL: 'http://localhost:3001', + ...overrides, + }; + + return { + get: jest.fn((key: string) => config[key]), + getOrThrow: jest.fn((key: string) => { + if (!(key in config)) throw new Error(`Missing config: ${key}`); + return config[key]; + }), + } as unknown as ConfigService; +} + +// Mock database with chainable methods +export function createMockDb() { + const results: any[] = []; + let resultIndex = 0; + + const mockDb = { + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + offset: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + returning: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + transaction: jest.fn(), + + // Thenable for await + then: jest.fn((resolve) => resolve(results[resultIndex++] || [])), + + // Helper to set results + mockResults: (...newResults: any[]) => { + results.length = 0; + results.push(...newResults); + resultIndex = 0; + }, + + // Reset all mocks + reset: () => { + jest.clearAllMocks(); + results.length = 0; + resultIndex = 0; + }, + }; + + return mockDb; +} + +// Assertion helpers +export const assertHelpers = { + assertIsUuid: (value: string) => { + expect(value).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + ); + }, + + assertIsRecent: (date: Date, toleranceMs = 5000) => { + const now = Date.now(); + expect(date.getTime()).toBeGreaterThan(now - toleranceMs); + expect(date.getTime()).toBeLessThanOrEqual(now); + }, + + assertResultOk: (result: Result): T => { + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('Expected ok result'); + return result.data; + }, + + assertResultErr: (result: Result, expectedCode?: ErrorCode) => { + expect(result.ok).toBe(false); + if (result.ok) throw new Error('Expected error result'); + if (expectedCode) { + expect(result.error.code).toBe(expectedCode); + } + return result.error; + }, +}; +``` + +## NestJS Unit Tests + +### Service Tests + +```typescript +// src/files/file.service.spec.ts +import { Test, TestingModule } from '@nestjs/testing'; +import { FileService } from './file.service'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { mockFileFactory, createMockDb, assertHelpers } from '../__tests__/utils'; +import { ErrorCode } from '@manacore/shared-errors'; + +describe('FileService', () => { + let service: FileService; + let mockDb: ReturnType; + + beforeEach(async () => { + mockDb = createMockDb(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FileService, + { provide: DATABASE_CONNECTION, useValue: mockDb }, + ], + }).compile(); + + service = module.get(FileService); + }); + + afterEach(() => { + mockDb.reset(); + }); + + describe('findById', () => { + it('should return file when found', async () => { + const mockFile = mockFileFactory.create(); + mockDb.mockResults([mockFile]); + + const result = await service.findById(mockFile.id, mockFile.userId); + + const file = assertHelpers.assertResultOk(result); + expect(file.id).toBe(mockFile.id); + expect(mockDb.select).toHaveBeenCalled(); + }); + + it('should return NOT_FOUND error when file does not exist', async () => { + mockDb.mockResults([]); + + const result = await service.findById('non-existent', 'user-123'); + + const error = assertHelpers.assertResultErr(result, ErrorCode.FILE_NOT_FOUND); + expect(error.message).toContain('not found'); + }); + + it('should not return files belonging to other users', async () => { + mockDb.mockResults([]); // Query returns empty due to userId filter + + const result = await service.findById('file-123', 'different-user'); + + assertHelpers.assertResultErr(result, ErrorCode.FILE_NOT_FOUND); + }); + }); + + describe('create', () => { + it('should create and return new file', async () => { + const userId = 'user-123'; + const dto = { + name: 'test.txt', + mimeType: 'text/plain', + size: 1024, + storagePath: '/files/test.txt', + storageKey: 'key-123', + }; + const createdFile = mockFileFactory.create({ ...dto, userId }); + mockDb.mockResults([createdFile]); + + const result = await service.create(userId, dto); + + const file = assertHelpers.assertResultOk(result); + expect(file.name).toBe(dto.name); + expect(mockDb.insert).toHaveBeenCalled(); + }); + + it('should return validation error for empty name', async () => { + const result = await service.create('user-123', { + name: '', + mimeType: 'text/plain', + size: 100, + storagePath: '/test', + storageKey: 'key', + }); + + assertHelpers.assertResultErr(result, ErrorCode.MISSING_REQUIRED_FIELD); + }); + }); +}); +``` + +### Controller Tests + +```typescript +// src/files/file.controller.spec.ts +import { Test, TestingModule } from '@nestjs/testing'; +import { FileController } from './file.controller'; +import { FileService } from './file.service'; +import { JwtAuthGuard } from '@manacore/shared-nestjs-auth'; +import { mockFileFactory } from '../__tests__/utils'; +import { ok, err, ErrorCode, AppException } from '@manacore/shared-errors'; + +describe('FileController', () => { + let controller: FileController; + let fileService: jest.Mocked; + + const mockUser = { userId: 'user-123', email: 'test@example.com', role: 'user' }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [FileController], + providers: [ + { + provide: FileService, + useValue: { + findById: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + }, + }, + ], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(FileController); + fileService = module.get(FileService) as jest.Mocked; + }); + + describe('GET /files/:id', () => { + it('should return file when found', async () => { + const mockFile = mockFileFactory.create(); + fileService.findById.mockResolvedValue(ok(mockFile)); + + const result = await controller.getFile(mockFile.id, mockUser); + + expect(result.file).toEqual(mockFile); + expect(fileService.findById).toHaveBeenCalledWith(mockFile.id, mockUser.userId); + }); + + it('should throw AppException when file not found', async () => { + fileService.findById.mockResolvedValue( + err(ErrorCode.FILE_NOT_FOUND, 'File not found') + ); + + await expect(controller.getFile('non-existent', mockUser)) + .rejects + .toThrow(AppException); + }); + }); + + describe('Guards', () => { + it('should have JwtAuthGuard applied', () => { + const guards = Reflect.getMetadata('__guards__', FileController); + expect(guards).toContain(JwtAuthGuard); + }); + }); +}); +``` + +## Vitest (SvelteKit) Tests + +### Store Tests + +```typescript +// src/lib/stores/files.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { fileStore } from './files.svelte'; +import { api } from '$lib/api/client'; + +vi.mock('$lib/api/client', () => ({ + api: { + files: { + list: vi.fn(), + delete: vi.fn(), + }, + }, +})); + +describe('fileStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + fileStore.reset(); + }); + + it('should load files successfully', async () => { + const mockFiles = [ + { id: '1', name: 'file1.txt' }, + { id: '2', name: 'file2.txt' }, + ]; + vi.mocked(api.files.list).mockResolvedValue({ ok: true, data: mockFiles }); + + await fileStore.loadFiles(); + + expect(fileStore.files).toEqual(mockFiles); + expect(fileStore.loading).toBe(false); + expect(fileStore.error).toBeNull(); + }); + + it('should handle load error', async () => { + vi.mocked(api.files.list).mockResolvedValue({ + ok: false, + error: { code: 'ERR_7001', message: 'Database error' }, + }); + + await fileStore.loadFiles(); + + expect(fileStore.files).toEqual([]); + expect(fileStore.error).toBe('Database error'); + }); + + it('should remove file from list after delete', async () => { + fileStore.files = [ + { id: '1', name: 'file1.txt' }, + { id: '2', name: 'file2.txt' }, + ]; + vi.mocked(api.files.delete).mockResolvedValue({ ok: true, data: undefined }); + + await fileStore.deleteFile('1'); + + expect(fileStore.files).toHaveLength(1); + expect(fileStore.files[0].id).toBe('2'); + }); +}); +``` + +### Component Tests + +```typescript +// src/lib/components/FileItem.test.ts +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import FileItem from './FileItem.svelte'; + +describe('FileItem', () => { + const mockFile = { + id: '1', + name: 'document.pdf', + size: 1024, + mimeType: 'application/pdf', + createdAt: new Date('2024-01-01'), + }; + + it('should render file name', () => { + render(FileItem, { props: { file: mockFile } }); + + expect(screen.getByText('document.pdf')).toBeInTheDocument(); + }); + + it('should format file size', () => { + render(FileItem, { props: { file: mockFile } }); + + expect(screen.getByText('1 KB')).toBeInTheDocument(); + }); + + it('should call onDelete when delete button clicked', async () => { + const onDelete = vi.fn(); + render(FileItem, { props: { file: mockFile, onDelete } }); + + const deleteButton = screen.getByRole('button', { name: /delete/i }); + await fireEvent.click(deleteButton); + + expect(onDelete).toHaveBeenCalledWith(mockFile.id); + }); +}); +``` + +## E2E Tests (Playwright) + +### Configuration + +```typescript +// playwright.config.ts +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'Mobile Safari', use: { ...devices['iPhone 12'] } }, + ], + + webServer: { + command: 'pnpm run build && pnpm run preview', + port: 5173, + reuseExistingServer: !process.env.CI, + }, +}); +``` + +### E2E Test Example + +```typescript +// e2e/file-upload.spec.ts +import { test, expect } from '@playwright/test'; + +test.describe('File Upload', () => { + test.beforeEach(async ({ page }) => { + // Login before each test + await page.goto('/login'); + await page.fill('[name="email"]', 'test@example.com'); + await page.fill('[name="password"]', 'password123'); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL('/files'); + }); + + test('should upload a file successfully', async ({ page }) => { + // Open upload dialog + await page.click('button:has-text("Upload")'); + + // Select file + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles('./e2e/fixtures/test-file.txt'); + + // Wait for upload + await expect(page.getByText('test-file.txt')).toBeVisible(); + await page.click('button:has-text("Upload")'); + + // Verify file appears in list + await expect(page.getByRole('listitem', { name: 'test-file.txt' })).toBeVisible(); + }); + + test('should show error for oversized file', async ({ page }) => { + await page.click('button:has-text("Upload")'); + + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles('./e2e/fixtures/large-file.zip'); + + await expect(page.getByText(/file too large/i)).toBeVisible(); + }); +}); +``` + +## Running Tests + +```bash +# Run all tests +pnpm test + +# Run with coverage +pnpm test:cov + +# Run specific project +pnpm --filter @storage/backend test + +# Run in watch mode +pnpm test:watch + +# Run E2E tests +pnpm test:e2e + +# Run E2E in headed mode +pnpm test:e2e --headed +``` + +## Best Practices + +### Do's + +1. **Use factories** for consistent test data +2. **Test behavior, not implementation** +3. **One assertion per test** when possible +4. **Clean up** after each test (reset mocks, state) +5. **Use descriptive test names** that explain expected behavior + +### Don'ts + +1. **Don't test framework code** - trust NestJS, Svelte, etc. +2. **Don't mock everything** - integration tests are valuable +3. **Don't test private methods** - test through public API +4. **Don't share state between tests** - each test should be independent +5. **Don't write flaky tests** - fix or remove them diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 000000000..ee2ec94c8 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +pnpm exec lint-staged +pnpm run type-check diff --git a/CLAUDE.md b/CLAUDE.md index 516e25aaa..22bf65174 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,24 @@ This is a pnpm workspace monorepo containing multiple product applications with **Build System:** Turborepo **Node Version:** 20+ +## Detailed Guidelines + +For comprehensive guidelines on code patterns and conventions, see the `.claude/` directory: + +| Document | Purpose | +|----------|---------| +| [`.claude/GUIDELINES.md`](.claude/GUIDELINES.md) | Main reference overview | +| [`.claude/guidelines/code-style.md`](.claude/guidelines/code-style.md) | Formatting, naming, linting | +| [`.claude/guidelines/database.md`](.claude/guidelines/database.md) | Drizzle ORM, schema patterns | +| [`.claude/guidelines/testing.md`](.claude/guidelines/testing.md) | Jest/Vitest, mock factories | +| [`.claude/guidelines/nestjs-backend.md`](.claude/guidelines/nestjs-backend.md) | Controllers, services, DTOs | +| [`.claude/guidelines/error-handling.md`](.claude/guidelines/error-handling.md) | Go-style Result types, error codes | +| [`.claude/guidelines/sveltekit-web.md`](.claude/guidelines/sveltekit-web.md) | Svelte 5 runes, stores | +| [`.claude/guidelines/expo-mobile.md`](.claude/guidelines/expo-mobile.md) | React Native, NativeWind | +| [`.claude/guidelines/authentication.md`](.claude/guidelines/authentication.md) | Mana Core Auth integration | + +**Always consult these guidelines before making changes.** + ## Projects | Project | Description | Apps | diff --git a/COMMANDS.md b/COMMANDS.md index 79edfa683..47a53b1ff 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -4,7 +4,6 @@ pnpm docker:up:all - pnpm docker:down pnpm dev:chat:app @@ -17,26 +16,30 @@ pnpm dev:zitare:app pnpm dev:presi:app # Deployment Landingpages: -## Einzelne Landing Page - pnpm deploy:landing:chat - pnpm deploy:landing:picture - pnpm deploy:landing:manacore - pnpm deploy:landing:manadeck - pnpm deploy:landing:zitare - Hier sind alle Landing Page URLs: +## Einzelne Landing Page + +pnpm deploy:landing:chat +pnpm deploy:landing:picture +pnpm deploy:landing:manacore +pnpm deploy:landing:manadeck +pnpm deploy:landing:zitare + +Hier sind alle Landing Page URLs: | Projekt | URL | - |----------|------------------------------------| - | Chat | https://chat-landing-90m.pages.dev | - | Picture | https://picture-landing.pages.dev | - | ManaCore | https://manacore-landing.pages.dev | - | ManaDeck | https://manadeck-landing.pages.dev | - | Zitare | https://zitare-landing.pages.dev | - | Presi | https://presi-landing.pages.dev | - ## Alle auf einmal - pnpm deploy:landing:all +|----------|------------------------------------| +| Chat | https://chat-landing-90m.pages.dev | +| Picture | https://picture-landing.pages.dev | +| ManaCore | https://manacore-landing.pages.dev | +| ManaDeck | https://manadeck-landing.pages.dev | +| Zitare | https://zitare-landing.pages.dev | +| Presi | https://presi-landing.pages.dev | + +## Alle auf einmal + +pnpm deploy:landing:all Übersicht aller wichtigen Befehle zum Starten, Stoppen und Verwalten der Apps. diff --git a/apps/calendar/apps/backend/src/calendar/calendar.controller.ts b/apps/calendar/apps/backend/src/calendar/calendar.controller.ts index 18d3489ec..ae2a4c049 100644 --- a/apps/calendar/apps/backend/src/calendar/calendar.controller.ts +++ b/apps/calendar/apps/backend/src/calendar/calendar.controller.ts @@ -1,13 +1,4 @@ -import { - Controller, - Get, - Post, - Put, - Delete, - Body, - Param, - UseGuards, -} from '@nestjs/common'; +import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common'; import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; import { CalendarService } from './calendar.service'; import { CreateCalendarDto, UpdateCalendarDto } from './dto'; diff --git a/apps/calendar/apps/backend/src/calendar/calendar.service.ts b/apps/calendar/apps/backend/src/calendar/calendar.service.ts index 44ba6de0e..66ad7a9c8 100644 --- a/apps/calendar/apps/backend/src/calendar/calendar.service.ts +++ b/apps/calendar/apps/backend/src/calendar/calendar.service.ts @@ -81,9 +81,7 @@ export class CalendarService { } } - await this.db - .delete(calendars) - .where(and(eq(calendars.id, id), eq(calendars.userId, userId))); + await this.db.delete(calendars).where(and(eq(calendars.id, id), eq(calendars.userId, userId))); } async getOrCreateDefaultCalendar(userId: string): Promise { diff --git a/apps/calendar/apps/backend/src/db/schema/events.schema.ts b/apps/calendar/apps/backend/src/db/schema/events.schema.ts index bc6fa8952..0318d408a 100644 --- a/apps/calendar/apps/backend/src/db/schema/events.schema.ts +++ b/apps/calendar/apps/backend/src/db/schema/events.schema.ts @@ -1,4 +1,13 @@ -import { pgTable, uuid, timestamp, varchar, text, boolean, jsonb, index } from 'drizzle-orm/pg-core'; +import { + pgTable, + uuid, + timestamp, + varchar, + text, + boolean, + jsonb, + index, +} from 'drizzle-orm/pg-core'; import { calendars } from './calendars.schema'; /** diff --git a/apps/calendar/apps/backend/src/db/schema/external-calendars.schema.ts b/apps/calendar/apps/backend/src/db/schema/external-calendars.schema.ts index 218a7b682..ab3c04c9e 100644 --- a/apps/calendar/apps/backend/src/db/schema/external-calendars.schema.ts +++ b/apps/calendar/apps/backend/src/db/schema/external-calendars.schema.ts @@ -1,4 +1,13 @@ -import { pgTable, uuid, timestamp, varchar, text, boolean, jsonb, integer } from 'drizzle-orm/pg-core'; +import { + pgTable, + uuid, + timestamp, + varchar, + text, + boolean, + jsonb, + integer, +} from 'drizzle-orm/pg-core'; /** * Provider-specific metadata diff --git a/apps/calendar/apps/backend/src/event/event.controller.ts b/apps/calendar/apps/backend/src/event/event.controller.ts index 15c816779..518ae4e83 100644 --- a/apps/calendar/apps/backend/src/event/event.controller.ts +++ b/apps/calendar/apps/backend/src/event/event.controller.ts @@ -1,14 +1,4 @@ -import { - Controller, - Get, - Post, - Put, - Delete, - Body, - Param, - Query, - UseGuards, -} from '@nestjs/common'; +import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common'; import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; import { EventService } from './event.service'; import { CreateEventDto, UpdateEventDto, QueryEventsDto } from './dto'; diff --git a/apps/calendar/apps/backend/src/event/event.service.ts b/apps/calendar/apps/backend/src/event/event.service.ts index 87c08a840..68eeb64b9 100644 --- a/apps/calendar/apps/backend/src/event/event.service.ts +++ b/apps/calendar/apps/backend/src/event/event.service.ts @@ -32,9 +32,7 @@ export class EventService { // Exclude cancelled unless requested if (!query.includeCancelled) { - conditions.push( - or(eq(events.status, 'confirmed'), eq(events.status, 'tentative')) as any - ); + conditions.push(or(eq(events.status, 'confirmed'), eq(events.status, 'tentative')) as any); } // Search filter diff --git a/apps/calendar/apps/backend/src/reminder/reminder.controller.ts b/apps/calendar/apps/backend/src/reminder/reminder.controller.ts index fa3256290..77d18b7bd 100644 --- a/apps/calendar/apps/backend/src/reminder/reminder.controller.ts +++ b/apps/calendar/apps/backend/src/reminder/reminder.controller.ts @@ -1,12 +1,4 @@ -import { - Controller, - Get, - Post, - Delete, - Body, - Param, - UseGuards, -} from '@nestjs/common'; +import { Controller, Get, Post, Delete, Body, Param, UseGuards } from '@nestjs/common'; import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; import { ReminderService } from './reminder.service'; import { CreateReminderDto } from './dto'; @@ -17,10 +9,7 @@ export class ReminderController { constructor(private readonly reminderService: ReminderService) {} @Get('events/:eventId/reminders') - async findByEvent( - @CurrentUser() user: CurrentUserData, - @Param('eventId') eventId: string - ) { + async findByEvent(@CurrentUser() user: CurrentUserData, @Param('eventId') eventId: string) { const reminders = await this.reminderService.findByEvent(eventId, user.userId); return { reminders }; } diff --git a/apps/calendar/apps/backend/src/reminder/reminder.service.ts b/apps/calendar/apps/backend/src/reminder/reminder.service.ts index 304796598..a8453dee8 100644 --- a/apps/calendar/apps/backend/src/reminder/reminder.service.ts +++ b/apps/calendar/apps/backend/src/reminder/reminder.service.ts @@ -61,9 +61,7 @@ export class ReminderService { throw new NotFoundException(`Reminder with id ${id} not found`); } - await this.db - .delete(reminders) - .where(and(eq(reminders.id, id), eq(reminders.userId, userId))); + await this.db.delete(reminders).where(and(eq(reminders.id, id), eq(reminders.userId, userId))); } async getPendingReminders(): Promise { @@ -74,9 +72,7 @@ export class ReminderService { return this.db .select() .from(reminders) - .where( - and(eq(reminders.status, 'pending'), lte(reminders.reminderTime, oneMinuteFromNow)) - ); + .where(and(eq(reminders.status, 'pending'), lte(reminders.reminderTime, oneMinuteFromNow))); } async markAsSent(id: string): Promise { @@ -116,7 +112,9 @@ export class ReminderService { // TODO: Implement actual notification sending // For now, just log and mark as sent - console.log(`[Reminder] Event "${event.title}" starting in ${reminder.minutesBefore} minutes`); + console.log( + `[Reminder] Event "${event.title}" starting in ${reminder.minutesBefore} minutes` + ); if (reminder.notifyPush) { // TODO: Send push notification via Expo Push API @@ -145,9 +143,7 @@ export class ReminderService { .where(and(eq(reminders.eventId, eventId), eq(reminders.status, 'pending'))); for (const reminder of eventReminders) { - const newReminderTime = new Date( - newStartTime.getTime() - reminder.minutesBefore * 60 * 1000 - ); + const newReminderTime = new Date(newStartTime.getTime() - reminder.minutesBefore * 60 * 1000); await this.db .update(reminders) diff --git a/apps/calendar/apps/backend/src/share/dto/create-share.dto.ts b/apps/calendar/apps/backend/src/share/dto/create-share.dto.ts index b68accc93..2ef3d78f8 100644 --- a/apps/calendar/apps/backend/src/share/dto/create-share.dto.ts +++ b/apps/calendar/apps/backend/src/share/dto/create-share.dto.ts @@ -1,4 +1,12 @@ -import { IsString, IsOptional, IsBoolean, IsIn, IsEmail, IsDateString, IsUUID } from 'class-validator'; +import { + IsString, + IsOptional, + IsBoolean, + IsIn, + IsEmail, + IsDateString, + IsUUID, +} from 'class-validator'; export class CreateShareDto { @IsUUID() diff --git a/apps/calendar/apps/backend/src/share/share.controller.ts b/apps/calendar/apps/backend/src/share/share.controller.ts index 7278d7969..0180c281c 100644 --- a/apps/calendar/apps/backend/src/share/share.controller.ts +++ b/apps/calendar/apps/backend/src/share/share.controller.ts @@ -1,13 +1,4 @@ -import { - Controller, - Get, - Post, - Put, - Delete, - Body, - Param, - UseGuards, -} from '@nestjs/common'; +import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common'; import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; import { ShareService } from './share.service'; import { CreateShareDto, UpdateShareDto } from './dto'; @@ -50,10 +41,7 @@ export class ShareController { } @Delete('calendars/:calendarId/shares/:shareId') - async delete( - @CurrentUser() user: CurrentUserData, - @Param('shareId') shareId: string - ) { + async delete(@CurrentUser() user: CurrentUserData, @Param('shareId') shareId: string) { await this.shareService.delete(shareId, user.userId); return { success: true }; } @@ -69,19 +57,13 @@ export class ShareController { } @Post('shares/:shareId/accept') - async acceptInvitation( - @CurrentUser() user: CurrentUserData, - @Param('shareId') shareId: string - ) { + async acceptInvitation(@CurrentUser() user: CurrentUserData, @Param('shareId') shareId: string) { const share = await this.shareService.acceptInvitation(shareId, user.userId); return { share }; } @Post('shares/:shareId/decline') - async declineInvitation( - @CurrentUser() user: CurrentUserData, - @Param('shareId') shareId: string - ) { + async declineInvitation(@CurrentUser() user: CurrentUserData, @Param('shareId') shareId: string) { const share = await this.shareService.declineInvitation(shareId, user.userId); return { share }; } diff --git a/apps/calendar/apps/backend/src/share/share.service.ts b/apps/calendar/apps/backend/src/share/share.service.ts index d5694e2b7..0cfd94314 100644 --- a/apps/calendar/apps/backend/src/share/share.service.ts +++ b/apps/calendar/apps/backend/src/share/share.service.ts @@ -22,17 +22,11 @@ export class ShareService { // Verify user owns the calendar await this.calendarService.findByIdOrThrow(calendarId, userId); - return this.db - .select() - .from(calendarShares) - .where(eq(calendarShares.calendarId, calendarId)); + return this.db.select().from(calendarShares).where(eq(calendarShares.calendarId, calendarId)); } async findById(id: string): Promise { - const result = await this.db - .select() - .from(calendarShares) - .where(eq(calendarShares.id, id)); + const result = await this.db.select().from(calendarShares).where(eq(calendarShares.id, id)); return result[0] || null; } @@ -43,10 +37,7 @@ export class ShareService { .where( and( eq(calendarShares.status, 'pending'), - or( - eq(calendarShares.sharedWithUserId, userId), - eq(calendarShares.sharedWithEmail, email) - ) + or(eq(calendarShares.sharedWithUserId, userId), eq(calendarShares.sharedWithEmail, email)) ) ); } @@ -174,10 +165,7 @@ export class ShareService { .select() .from(calendarShares) .where( - and( - eq(calendarShares.sharedWithUserId, userId), - eq(calendarShares.status, 'accepted') - ) + and(eq(calendarShares.sharedWithUserId, userId), eq(calendarShares.status, 'accepted')) ); } } diff --git a/apps/calendar/apps/landing/src/components/CTA.astro b/apps/calendar/apps/landing/src/components/CTA.astro index 4591fb0e3..f7935d81f 100644 --- a/apps/calendar/apps/landing/src/components/CTA.astro +++ b/apps/calendar/apps/landing/src/components/CTA.astro @@ -4,7 +4,8 @@
-
+
+
@@ -12,39 +13,44 @@ Bereit, deine Zeit zu organisieren?

- Starte kostenlos und erlebe, wie einfach Zeitmanagement sein kann. - Keine Kreditkarte erforderlich. + Starte kostenlos und erlebe, wie einfach Zeitmanagement sein kann. Keine Kreditkarte + erforderlich.

- + Kostenlos starten
- + Keine Kreditkarte
- + Jederzeit kündbar
diff --git a/apps/calendar/apps/landing/src/components/Features.astro b/apps/calendar/apps/landing/src/components/Features.astro index d47f0fa2a..ba79d4c87 100644 --- a/apps/calendar/apps/landing/src/components/Features.astro +++ b/apps/calendar/apps/landing/src/components/Features.astro @@ -7,43 +7,49 @@ const features = [ `, title: 'Mehrere Kalender', - description: 'Verwalte verschiedene Kalender für Arbeit, Privates, Familie und mehr - alles übersichtlich farbcodiert.' + description: + 'Verwalte verschiedene Kalender für Arbeit, Privates, Familie und mehr - alles übersichtlich farbcodiert.', }, { icon: ` `, title: 'Kalender teilen', - description: 'Teile Kalender mit Familie, Freunden oder Kollegen. Vergib Lese- oder Bearbeitungsrechte.' + description: + 'Teile Kalender mit Familie, Freunden oder Kollegen. Vergib Lese- oder Bearbeitungsrechte.', }, { icon: ` `, title: 'CalDAV & iCal Sync', - description: 'Synchronisiere mit Google Calendar, Apple Calendar, Outlook und jedem CalDAV-kompatiblen Dienst.' + description: + 'Synchronisiere mit Google Calendar, Apple Calendar, Outlook und jedem CalDAV-kompatiblen Dienst.', }, { icon: ` `, title: 'Smarte Erinnerungen', - description: 'Nie wieder einen Termin verpassen. Push-Benachrichtigungen und E-Mail-Erinnerungen zur rechten Zeit.' + description: + 'Nie wieder einen Termin verpassen. Push-Benachrichtigungen und E-Mail-Erinnerungen zur rechten Zeit.', }, { icon: ` `, title: 'Wiederkehrende Termine', - description: 'Erstelle einmalige oder wiederkehrende Termine mit flexiblen Wiederholungsregeln nach RFC 5545.' + description: + 'Erstelle einmalige oder wiederkehrende Termine mit flexiblen Wiederholungsregeln nach RFC 5545.', }, { icon: ` `, title: 'Mobile & Desktop', - description: 'Greife von überall auf deine Termine zu - Web-App, iOS und Android mit Offline-Support.' - } + description: + 'Greife von überall auf deine Termine zu - Web-App, iOS und Android mit Offline-Support.', + }, ]; --- @@ -54,9 +60,7 @@ const features = [ Funktionen -

- Alles was du brauchst -

+

Alles was du brauchst

Kalender bietet alle Funktionen, die du für effektives Zeitmanagement benötigst.

@@ -64,15 +68,17 @@ const features = [
- {features.map((feature) => ( -
-
- + { + features.map((feature) => ( +
+
+ +
+

{feature.title}

+

{feature.description}

-

{feature.title}

-

{feature.description}

-
- ))} + )) + }
diff --git a/apps/calendar/apps/landing/src/components/Footer.astro b/apps/calendar/apps/landing/src/components/Footer.astro index eff17e4a8..486ce44d4 100644 --- a/apps/calendar/apps/landing/src/components/Footer.astro +++ b/apps/calendar/apps/landing/src/components/Footer.astro @@ -8,18 +8,18 @@ const links = { { name: 'Funktionen', href: '#features' }, { name: 'Preise', href: '#pricing' }, { name: 'Changelog', href: '/changelog' }, - { name: 'Roadmap', href: '/roadmap' } + { name: 'Roadmap', href: '/roadmap' }, ], legal: [ { name: 'Impressum', href: '/impressum' }, { name: 'Datenschutz', href: '/datenschutz' }, - { name: 'AGB', href: '/agb' } + { name: 'AGB', href: '/agb' }, ], support: [ { name: 'FAQ', href: '/faq' }, { name: 'Kontakt', href: '/kontakt' }, - { name: 'Status', href: '/status' } - ] + { name: 'Status', href: '/status' }, + ], }; --- @@ -29,53 +29,75 @@ const links = {
- - + + Kalender
-

- Smart Calendar Management für besseres Zeitmanagement. -

+

Smart Calendar Management für besseres Zeitmanagement.

Produkt

Rechtliches

Support

-
+

© {currentYear} Kalender. Alle Rechte vorbehalten.

diff --git a/apps/calendar/apps/landing/src/components/Hero.astro b/apps/calendar/apps/landing/src/components/Hero.astro index 0954007fb..2bf3c8ff7 100644 --- a/apps/calendar/apps/landing/src/components/Hero.astro +++ b/apps/calendar/apps/landing/src/components/Hero.astro @@ -4,23 +4,24 @@
-
-
+
-
-
+
-
+
- + Smart Kalender-Management
@@ -33,34 +34,38 @@

- Persönliche Kalender, geteilte Termine, CalDAV-Synchronisation und smarte Erinnerungen - alles an einem Ort. Behalte den Überblick über dein Leben. + Persönliche Kalender, geteilte Termine, CalDAV-Synchronisation und smarte Erinnerungen - + alles an einem Ort. Behalte den Überblick über dein Leben.

- {[1, 2, 3, 4, 5].map((i) => ( -
- ))} + { + [1, 2, 3, 4, 5].map((i) => ( +
+ )) + }

500+ Nutzer vertrauen Kalender @@ -70,7 +75,10 @@

-
+
+
@@ -89,14 +97,20 @@
- {['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'].map((day) => ( -
{day}
- ))} - {Array.from({ length: 35 }, (_, i) => ( -
- {((i % 31) + 1).toString()} -
- ))} + { + ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'].map((day) => ( +
{day}
+ )) + } + { + Array.from({ length: 35 }, (_, i) => ( +
+ {((i % 31) + 1).toString()} +
+ )) + }
diff --git a/apps/calendar/apps/landing/src/pages/index.astro b/apps/calendar/apps/landing/src/pages/index.astro index dc8c26416..56fff2432 100644 --- a/apps/calendar/apps/landing/src/pages/index.astro +++ b/apps/calendar/apps/landing/src/pages/index.astro @@ -114,104 +114,139 @@ const pricingPlans = [ - {StepsSection && ( - - )} + { + StepsSection && ( + + ) + } - {!StepsSection && ( -
-
-
- - So funktioniert's - -

So einfach geht's

-

In drei Schritten zum organisierten Leben

-
+ { + !StepsSection && ( +
+
+
+ + So funktioniert's + +

So einfach geht's

+

In drei Schritten zum organisierten Leben

+
-
- {steps.map((step) => ( -
-
- {step.number} -
-

{step.title}

-

{step.description}

-
- ))} -
-
-
- )} - - {PricingSection && ( - - )} - - {!PricingSection && ( -
-
-
- - Preise - -

Einfache, transparente Preise

-

Starte kostenlos, upgrade wenn du mehr brauchst

-
- -
- {pricingPlans.map((plan) => ( -
- {plan.badge && ( -
- {plan.badge} +
+ {steps.map((step) => ( +
+
+ {step.number}
- )} -

{plan.name}

-

{plan.description}

-
- {plan.price}€ - {plan.period} +

{step.title}

+

{step.description}

-
    - {plan.features.map((feature) => ( -
  • - {feature.included ? ( - - - - ) : ( - - - - )} - {feature.text} -
  • - ))} -
- - {plan.cta.text} - -
- ))} + ))} +
-
-
- )} +
+ ) + } + + { + PricingSection && ( + + ) + } + + { + !PricingSection && ( +
+
+
+ + Preise + +

Einfache, transparente Preise

+

Starte kostenlos, upgrade wenn du mehr brauchst

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

{plan.name}

+

{plan.description}

+
+ {plan.price}€ + {plan.period} +
+
    + {plan.features.map((feature) => ( +
  • + {feature.included ? ( + + + + ) : ( + + + + )} + {feature.text} +
  • + ))} +
+ + {plan.cta.text} + +
+ ))} +
+
+
+ ) + }
diff --git a/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte index fb4151272..bd1a21fa6 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte @@ -417,8 +417,14 @@ >
- {format(typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime, 'HH:mm')} - - {format(typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime, 'HH:mm')} + {format( + typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime, + 'HH:mm' + )} - + {format( + typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime, + 'HH:mm' + )} {event.title} {#if event.location} diff --git a/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte index 5c9449541..eb7119004 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte @@ -235,17 +235,21 @@ tabindex="0" > {#if !event.isAllDay} - {format(typeof event.startTime === 'string' ? new Date(event.startTime) : event.startTime, 'HH:mm')} + {format( + typeof event.startTime === 'string' + ? new Date(event.startTime) + : event.startTime, + 'HH:mm' + )} {/if} {event.title}
{/each} {#if eventsStore.getEventsForDay(day).length > 3} - {/if} diff --git a/apps/calendar/apps/web/src/lib/components/event/EventForm.svelte b/apps/calendar/apps/web/src/lib/components/event/EventForm.svelte index 35309054d..69e5568d4 100644 --- a/apps/calendar/apps/web/src/lib/components/event/EventForm.svelte +++ b/apps/calendar/apps/web/src/lib/components/event/EventForm.svelte @@ -40,7 +40,8 @@ // Initialize date/time fields using settings for default duration $effect(() => { if (event) { - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; + const start = + typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; startDate = format(start, 'yyyy-MM-dd'); startTime = format(start, 'HH:mm'); @@ -112,7 +113,11 @@
- {#each calendarsStore.calendars as cal} {/each} @@ -144,12 +149,24 @@
- +
{#if !isAllDay}
- +
{/if}
@@ -157,12 +174,24 @@
- +
{#if !isAllDay}
- +
{/if}
@@ -190,7 +219,11 @@
-
- diff --git a/apps/calendar/apps/web/src/lib/stores/events.svelte.ts b/apps/calendar/apps/web/src/lib/stores/events.svelte.ts index 1bb8544d8..fb8dddfed 100644 --- a/apps/calendar/apps/web/src/lib/stores/events.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/events.svelte.ts @@ -59,12 +59,16 @@ export const eventsStore = { if (!Array.isArray(currentEvents)) return []; return currentEvents.filter((event) => { - const eventStart = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; + const eventStart = + typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; const eventEnd = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; // For all-day events, check if day falls within event range if (event.isAllDay) { - return isWithinInterval(date, { start: eventStart, end: eventEnd }) || isSameDay(date, eventStart); + return ( + isWithinInterval(date, { start: eventStart, end: eventEnd }) || + isSameDay(date, eventStart) + ); } // For timed events, check if event starts on this day @@ -81,7 +85,8 @@ export const eventsStore = { if (!Array.isArray(currentEvents)) return []; return currentEvents.filter((event) => { - const eventStart = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; + const eventStart = + typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; const eventEnd = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; // Check if event overlaps with the range diff --git a/apps/calendar/apps/web/src/routes/+page.svelte b/apps/calendar/apps/web/src/routes/+page.svelte index 9de367a86..eeae19ee4 100644 --- a/apps/calendar/apps/web/src/routes/+page.svelte +++ b/apps/calendar/apps/web/src/routes/+page.svelte @@ -73,10 +73,7 @@ Neuer Termin - + diff --git a/apps/calendar/apps/web/src/routes/agenda/+page.svelte b/apps/calendar/apps/web/src/routes/agenda/+page.svelte index ebcb1d3e5..924deaec9 100644 --- a/apps/calendar/apps/web/src/routes/agenda/+page.svelte +++ b/apps/calendar/apps/web/src/routes/agenda/+page.svelte @@ -83,9 +83,7 @@ {:else if groupedEvents.length === 0}

Keine Termine in den nächsten 30 Tagen

- +
{:else}
@@ -106,8 +104,16 @@ {#if event.isAllDay} Ganztägig {:else} - {format(typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime, 'HH:mm')} - - {format(typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime, 'HH:mm')} + {format( + typeof event.startTime === 'string' + ? parseISO(event.startTime) + : event.startTime, + 'HH:mm' + )} - + {format( + typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime, + 'HH:mm' + )} {/if}
{event.title}
diff --git a/apps/calendar/apps/web/src/routes/calendars/+page.svelte b/apps/calendar/apps/web/src/routes/calendars/+page.svelte index 16b5294b3..52beeb4eb 100644 --- a/apps/calendar/apps/web/src/routes/calendars/+page.svelte +++ b/apps/calendar/apps/web/src/routes/calendars/+page.svelte @@ -71,15 +71,18 @@
{#if showNewForm}

Neuer Kalender

-
{ e.preventDefault(); handleCreateCalendar(); }}> + { + e.preventDefault(); + handleCreateCalendar(); + }} + >
- +
- +
{:else} @@ -150,10 +137,7 @@ {/if}
- {#if !calendar.isDefault} diff --git a/apps/calendar/apps/web/src/routes/event/[id]/+page.svelte b/apps/calendar/apps/web/src/routes/event/[id]/+page.svelte index ac22ecc3c..a69886aff 100644 --- a/apps/calendar/apps/web/src/routes/event/[id]/+page.svelte +++ b/apps/calendar/apps/web/src/routes/event/[id]/+page.svelte @@ -87,23 +87,14 @@

{isEditing ? 'Termin bearbeiten' : event.title}

{#if !isEditing}
- - + +
{/if}
{#if isEditing} - + {:else}
@@ -133,9 +124,7 @@ {/if}
- +
{/if} diff --git a/apps/calendar/apps/web/src/routes/settings/+page.svelte b/apps/calendar/apps/web/src/routes/settings/+page.svelte index cb1b7436b..adb8603e4 100644 --- a/apps/calendar/apps/web/src/routes/settings/+page.svelte +++ b/apps/calendar/apps/web/src/routes/settings/+page.svelte @@ -136,7 +136,9 @@ > - + Hell @@ -415,7 +417,10 @@
-
diff --git a/apps/calendar/packages/shared/src/utils/date.ts b/apps/calendar/packages/shared/src/utils/date.ts index 93bff3396..5f22e03cc 100644 --- a/apps/calendar/packages/shared/src/utils/date.ts +++ b/apps/calendar/packages/shared/src/utils/date.ts @@ -232,11 +232,6 @@ export function getEventDurationMinutes(start: Date, end: Date): number { /** * Check if two time ranges overlap */ -export function doTimeRangesOverlap( - start1: Date, - end1: Date, - start2: Date, - end2: Date -): boolean { +export function doTimeRangesOverlap(start1: Date, end1: Date, start2: Date, end2: Date): boolean { return start1 < end2 && end1 > start2; } diff --git a/apps/calendar/packages/shared/src/utils/recurrence.ts b/apps/calendar/packages/shared/src/utils/recurrence.ts index 7623a1463..5802478c2 100644 --- a/apps/calendar/packages/shared/src/utils/recurrence.ts +++ b/apps/calendar/packages/shared/src/utils/recurrence.ts @@ -111,7 +111,11 @@ export function describeRecurrence(pattern: RecurrencePattern | null): string { case 'WEEKLY': if (pattern.byDay && pattern.byDay.length > 0) { - if (pattern.byDay.length === 5 && !pattern.byDay.includes('SA') && !pattern.byDay.includes('SU')) { + if ( + pattern.byDay.length === 5 && + !pattern.byDay.includes('SA') && + !pattern.byDay.includes('SU') + ) { return interval === 1 ? 'Every weekday' : `Every ${interval} weeks on weekdays`; } const days = pattern.byDay.map(dayToLabel).join(', '); @@ -122,7 +126,9 @@ export function describeRecurrence(pattern: RecurrencePattern | null): string { case 'MONTHLY': if (pattern.byMonthDay && pattern.byMonthDay.length > 0) { const days = pattern.byMonthDay.join(', '); - return interval === 1 ? `Monthly on day ${days}` : `Every ${interval} months on day ${days}`; + return interval === 1 + ? `Monthly on day ${days}` + : `Every ${interval} months on day ${days}`; } return interval === 1 ? 'Monthly' : `Every ${interval} months`; @@ -205,7 +211,10 @@ export function generateOccurrences( // Check if this date matches the pattern if (matchesPattern(currentDate, pattern)) { // Check if date is in range and not in exceptions - if (currentDate >= rangeStart && !exceptionsSet.has(currentDate.toISOString().split('T')[0])) { + if ( + currentDate >= rangeStart && + !exceptionsSet.has(currentDate.toISOString().split('T')[0]) + ) { occurrences.push(new Date(currentDate)); } } diff --git a/apps/contacts/apps/backend/drizzle.config.ts b/apps/contacts/apps/backend/drizzle.config.ts index 744ef1f70..271ac5f4d 100644 --- a/apps/contacts/apps/backend/drizzle.config.ts +++ b/apps/contacts/apps/backend/drizzle.config.ts @@ -5,7 +5,10 @@ export default defineConfig({ schema: './src/db/schema/index.ts', out: './src/db/migrations', dbCredentials: { - url: process.env.CONTACTS_DATABASE_URL || process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/contacts', + url: + process.env.CONTACTS_DATABASE_URL || + process.env.DATABASE_URL || + 'postgresql://manacore:devpassword@localhost:5432/contacts', }, verbose: true, strict: true, diff --git a/apps/contacts/apps/backend/src/activity/activity.service.ts b/apps/contacts/apps/backend/src/activity/activity.service.ts index b7fcd846f..fb0984f94 100644 --- a/apps/contacts/apps/backend/src/activity/activity.service.ts +++ b/apps/contacts/apps/backend/src/activity/activity.service.ts @@ -10,11 +10,7 @@ export type ActivityType = 'created' | 'updated' | 'called' | 'emailed' | 'met' export class ActivityService { constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} - async findByContactId( - contactId: string, - userId: string, - limit = 50 - ): Promise { + async findByContactId(contactId: string, userId: string, limit = 50): Promise { return this.db .select() .from(contactActivities) diff --git a/apps/contacts/apps/backend/src/contact/contact.controller.ts b/apps/contacts/apps/backend/src/contact/contact.controller.ts index cdface08e..aec5fb2ce 100644 --- a/apps/contacts/apps/backend/src/contact/contact.controller.ts +++ b/apps/contacts/apps/backend/src/contact/contact.controller.ts @@ -173,10 +173,7 @@ export class ContactController { } @Get(':id') - async findOne( - @CurrentUser() user: CurrentUserData, - @Param('id', ParseUUIDPipe) id: string - ) { + async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { const contact = await this.contactService.findById(id, user.userId); if (!contact) { return { contact: null }; @@ -212,10 +209,7 @@ export class ContactController { } @Delete(':id') - async delete( - @CurrentUser() user: CurrentUserData, - @Param('id', ParseUUIDPipe) id: string - ) { + async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { await this.contactService.delete(id, user.userId); return { success: true }; } diff --git a/apps/contacts/apps/backend/src/group/group.controller.ts b/apps/contacts/apps/backend/src/group/group.controller.ts index 33c6f8fe4..ca802e1f4 100644 --- a/apps/contacts/apps/backend/src/group/group.controller.ts +++ b/apps/contacts/apps/backend/src/group/group.controller.ts @@ -62,10 +62,7 @@ export class GroupController { } @Get(':id') - async findOne( - @CurrentUser() user: CurrentUserData, - @Param('id', ParseUUIDPipe) id: string - ) { + async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { const group = await this.groupService.findById(id, user.userId); const contactIds = group ? await this.groupService.getContactsInGroup(id) : []; return { group, contactIds }; @@ -91,10 +88,7 @@ export class GroupController { } @Delete(':id') - async delete( - @CurrentUser() user: CurrentUserData, - @Param('id', ParseUUIDPipe) id: string - ) { + async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { await this.groupService.delete(id, user.userId); return { success: true }; } diff --git a/apps/contacts/apps/backend/src/group/group.service.ts b/apps/contacts/apps/backend/src/group/group.service.ts index 47a17a42c..eb41c1f09 100644 --- a/apps/contacts/apps/backend/src/group/group.service.ts +++ b/apps/contacts/apps/backend/src/group/group.service.ts @@ -51,10 +51,7 @@ export class GroupService { } async addContactToGroup(contactId: string, groupId: string): Promise { - await this.db - .insert(contactToGroups) - .values({ contactId, groupId }) - .onConflictDoNothing(); + await this.db.insert(contactToGroups).values({ contactId, groupId }).onConflictDoNothing(); } async removeContactFromGroup(contactId: string, groupId: string): Promise { diff --git a/apps/contacts/apps/backend/src/note/note.controller.ts b/apps/contacts/apps/backend/src/note/note.controller.ts index c63b0367c..08e6b09ac 100644 --- a/apps/contacts/apps/backend/src/note/note.controller.ts +++ b/apps/contacts/apps/backend/src/note/note.controller.ts @@ -77,19 +77,13 @@ export class NoteController { } @Delete(':id') - async delete( - @CurrentUser() user: CurrentUserData, - @Param('id', ParseUUIDPipe) id: string - ) { + async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { await this.noteService.delete(id, user.userId); return { success: true }; } @Post(':id/pin') - async togglePin( - @CurrentUser() user: CurrentUserData, - @Param('id', ParseUUIDPipe) id: string - ) { + async togglePin(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { const note = await this.noteService.togglePin(id, user.userId); return { note }; } diff --git a/apps/contacts/apps/backend/src/tag/tag.controller.ts b/apps/contacts/apps/backend/src/tag/tag.controller.ts index 453087ab8..0371afd80 100644 --- a/apps/contacts/apps/backend/src/tag/tag.controller.ts +++ b/apps/contacts/apps/backend/src/tag/tag.controller.ts @@ -67,10 +67,7 @@ export class TagController { } @Delete(':id') - async delete( - @CurrentUser() user: CurrentUserData, - @Param('id', ParseUUIDPipe) id: string - ) { + async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { await this.tagService.delete(id, user.userId); return { success: true }; } diff --git a/apps/contacts/apps/web/src/lib/components/LanguageSelector.svelte b/apps/contacts/apps/web/src/lib/components/LanguageSelector.svelte index 4f63784d2..cd22db3b2 100644 --- a/apps/contacts/apps/web/src/lib/components/LanguageSelector.svelte +++ b/apps/contacts/apps/web/src/lib/components/LanguageSelector.svelte @@ -30,9 +30,7 @@ {#if isOpen} -
+
{#each supportedLocales as lang} +
{/if} @@ -131,14 +146,27 @@
- +

Archiv ist leer

-

Archivierte Kontakte erscheinen hier. Du kannst sie später wiederherstellen oder endgültig löschen.

+

+ Archivierte Kontakte erscheinen hier. Du kannst sie später wiederherstellen oder endgültig + löschen. +

- + Zu Kontakten @@ -147,7 +175,12 @@
- +

Keine Ergebnisse

@@ -156,7 +189,12 @@ {:else}
- + Archivierte Kontakte können wiederhergestellt oder endgültig gelöscht werden.
@@ -201,7 +239,12 @@ title="Wiederherstellen" > - +
@@ -219,7 +267,9 @@ {/each}
-

{contacts.length} archiviert{contacts.length !== 1 ? 'e Kontakte' : 'er Kontakt'}

+

+ {contacts.length} archiviert{contacts.length !== 1 ? 'e Kontakte' : 'er Kontakt'} +

{/if}
@@ -443,7 +493,11 @@ width: 3rem; height: 3rem; border-radius: 50%; - background: linear-gradient(135deg, hsl(var(--color-primary)) 0%, hsl(var(--color-primary) / 0.7) 100%); + background: linear-gradient( + 135deg, + hsl(var(--color-primary)) 0%, + hsl(var(--color-primary) / 0.7) 100% + ); color: hsl(var(--color-primary-foreground)); display: flex; align-items: center; diff --git a/apps/contacts/apps/web/src/routes/(app)/contacts/[id]/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/contacts/[id]/+page.svelte index 4e45e4f62..6eafc6ad5 100644 --- a/apps/contacts/apps/web/src/routes/(app)/contacts/[id]/+page.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/contacts/[id]/+page.svelte @@ -153,14 +153,36 @@

{editing ? 'Bearbeiten' : 'Kontakt'}

{#if contact && !editing && !loading}
- -
@@ -172,8 +194,20 @@ {#if loading}
- - + +

Lade Kontakt...

@@ -181,7 +215,12 @@
- +

{error}

@@ -191,7 +230,12 @@ {#if error} @@ -207,8 +251,18 @@
@@ -218,13 +272,24 @@ {/if}
-
{ e.preventDefault(); handleSave(); }} class="form"> + { + e.preventDefault(); + handleSave(); + }} + class="form" + >
- +

Name

@@ -246,7 +311,12 @@
- +

Kontakt

@@ -255,7 +325,12 @@
- +
@@ -265,7 +340,12 @@
- +
@@ -274,7 +354,12 @@
- +
@@ -287,7 +372,12 @@
- +

Arbeit

@@ -307,8 +397,18 @@
- - + +

Adresse

@@ -338,7 +438,12 @@
- +

Notizen

@@ -348,19 +453,42 @@
- @@ -401,7 +540,12 @@
- +
Anrufen @@ -411,7 +555,12 @@
- +
E-Mail @@ -421,7 +570,12 @@
- +
Nachricht @@ -437,7 +591,12 @@
- +

Kontakt

@@ -446,7 +605,12 @@ {#if contact.email}
- +
E-Mail @@ -457,7 +621,12 @@ {#if contact.phone} - +
Telefon @@ -468,7 +637,12 @@ {#if contact.mobile} - +
Mobil @@ -486,7 +660,12 @@
- +

Arbeit

@@ -495,7 +674,12 @@ {#if contact.company}
- +
Firma @@ -506,7 +690,12 @@ {#if contact.jobTitle}
- +
Position @@ -524,8 +713,18 @@
- - + +

Adresse

@@ -533,7 +732,9 @@
{#if contact.street}
{contact.street}
{/if} {#if contact.postalCode || contact.city} -
{[contact.postalCode, contact.city].filter(Boolean).join(' ')}
+
+ {[contact.postalCode, contact.city].filter(Boolean).join(' ')} +
{/if} {#if contact.country}
{contact.country}
{/if}
@@ -546,7 +747,12 @@
- +

Notizen

@@ -719,7 +925,11 @@ width: 100px; height: 100px; border-radius: 50%; - background: linear-gradient(135deg, hsl(var(--color-primary)) 0%, hsl(var(--color-primary) / 0.7) 100%); + background: linear-gradient( + 135deg, + hsl(var(--color-primary)) 0%, + hsl(var(--color-primary) / 0.7) 100% + ); color: hsl(var(--color-primary-foreground)); display: flex; align-items: center; diff --git a/apps/contacts/apps/web/src/routes/(app)/contacts/new/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/contacts/new/+page.svelte index a0af7798c..4f5900295 100644 --- a/apps/contacts/apps/web/src/routes/(app)/contacts/new/+page.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/contacts/new/+page.svelte @@ -89,8 +89,18 @@
@@ -103,19 +113,35 @@ {#if error} {/if} - { e.preventDefault(); handleSubmit(); }} class="form"> + { + e.preventDefault(); + handleSubmit(); + }} + class="form" + >
- +

Name

@@ -149,7 +175,12 @@
- +

Kontakt

@@ -158,7 +189,12 @@
- + Telefon
- + Mobil
- +
- +

Arbeit

@@ -240,8 +291,18 @@
- - + +

Adresse

@@ -269,13 +330,7 @@
- +
@@ -295,7 +350,12 @@
- +

Notizen

@@ -310,19 +370,34 @@
- - Abbrechen - + Abbrechen +
{/if} @@ -118,14 +130,26 @@
- +

Keine Favoriten

-

Markiere Kontakte als Favoriten, um sie hier schnell zu finden.

+

+ Markiere Kontakte als Favoriten, um sie hier schnell zu finden. +

- + Zu Kontakten @@ -134,7 +158,12 @@
- +

Keine Ergebnisse

@@ -179,7 +208,9 @@ aria-label="Aus Favoriten entfernen" > - +
@@ -399,7 +430,11 @@ width: 3rem; height: 3rem; border-radius: 50%; - background: linear-gradient(135deg, hsl(var(--color-primary)) 0%, hsl(var(--color-primary) / 0.7) 100%); + background: linear-gradient( + 135deg, + hsl(var(--color-primary)) 0%, + hsl(var(--color-primary) / 0.7) 100% + ); color: hsl(var(--color-primary-foreground)); display: flex; align-items: center; diff --git a/apps/contacts/apps/web/src/routes/(app)/groups/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/groups/+page.svelte index 512e69ca5..bf61f9b1f 100644 --- a/apps/contacts/apps/web/src/routes/(app)/groups/+page.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/groups/+page.svelte @@ -13,9 +13,7 @@ if (!searchQuery.trim()) return groups; const query = searchQuery.toLowerCase(); return groups.filter( - (g) => - g.name.toLowerCase().includes(query) || - g.description?.toLowerCase().includes(query) + (g) => g.name.toLowerCase().includes(query) || g.description?.toLowerCase().includes(query) ); }); @@ -77,7 +75,12 @@
- + - + {error}
@@ -104,14 +112,24 @@
- +

Keine Gruppen

Erstelle deine erste Gruppe um Kontakte zu organisieren.

- + Neue Gruppe @@ -120,7 +138,12 @@
- +

Keine Ergebnisse

@@ -150,11 +173,21 @@ aria-label="Gruppe löschen" > - + - +
diff --git a/apps/contacts/apps/web/src/routes/(app)/groups/[id]/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/groups/[id]/+page.svelte index d79133980..1ed80ba52 100644 --- a/apps/contacts/apps/web/src/routes/(app)/groups/[id]/+page.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/groups/[id]/+page.svelte @@ -22,8 +22,18 @@ let color = $state('#6366f1'); const presetColors = [ - '#ef4444', '#f97316', '#f59e0b', '#84cc16', '#22c55e', '#14b8a6', - '#06b6d4', '#3b82f6', '#6366f1', '#8b5cf6', '#a855f7', '#ec4899', + '#ef4444', + '#f97316', + '#f59e0b', + '#84cc16', + '#22c55e', + '#14b8a6', + '#06b6d4', + '#3b82f6', + '#6366f1', + '#8b5cf6', + '#a855f7', + '#ec4899', ]; const groupContacts = $derived(() => { @@ -162,11 +172,16 @@ -

{isEditing ? 'Gruppe bearbeiten' : (group?.name || 'Gruppe')}

+

{isEditing ? 'Gruppe bearbeiten' : group?.name || 'Gruppe'}

{#if !loading && group && !isEditing} - {:else} @@ -182,7 +197,12 @@
- +

Fehler

@@ -193,10 +213,15 @@ {#if error} {/if} @@ -205,18 +230,34 @@
- +

{name || 'Gruppenname'}

- { e.preventDefault(); handleSave(); }} class="form"> + { + e.preventDefault(); + handleSave(); + }} + class="form" + >
- +

Details

@@ -227,7 +268,8 @@
- +
@@ -235,7 +277,12 @@
- +

Farbe

@@ -247,11 +294,16 @@ class="color-option" class:selected={color === presetColor} style="background-color: {presetColor}" - onclick={() => color = presetColor} + onclick={() => (color = presetColor)} > {#if color === presetColor} - + {/if} @@ -264,8 +316,22 @@ @@ -287,7 +358,12 @@
- +

{group.name}

@@ -301,13 +377,23 @@
- +

Kontakte ({groupContacts().length})

- @@ -332,9 +418,18 @@ {contact.email} {/if}
-
@@ -348,15 +443,20 @@ {#if showAddContacts} -