📝 docs: add comprehensive Claude Code guidelines

Add detailed documentation for Claude Code in .claude/ directory:
- code-style.md: formatting, naming, linting rules
- database.md: Drizzle ORM patterns and schema conventions
- testing.md: Jest/Vitest patterns with mock factories
- nestjs-backend.md: controller, service, DTO patterns
- error-handling.md: Go-style Result types and error codes
- sveltekit-web.md: Svelte 5 runes and store patterns
- expo-mobile.md: React Native with NativeWind
- authentication.md: Mana Core Auth integration

Update root CLAUDE.md to reference new guidelines
This commit is contained in:
Wuesteon 2025-12-03 00:44:49 +01:00
parent 2154c3b37a
commit 0b539bde6b
10 changed files with 4971 additions and 0 deletions

127
.claude/GUIDELINES.md Normal file
View file

@ -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<T>` 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<T>
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
```

View file

@ -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<typeof initializeWebAuth>['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<User | null>(null);
let token = $state<string | null>(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<boolean> {
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
<!-- src/routes/(protected)/+layout.svelte -->
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { onMount } from 'svelte';
let { children } = $props();
onMount(() => {
authStore.initialize();
});
$effect(() => {
if (browser && !authStore.loading && !authStore.isAuthenticated) {
goto('/login');
}
});
</script>
{#if authStore.loading}
<LoadingScreen />
{: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<T>(endpoint: string, options: RequestInit = {}): Promise<Result<T>> {
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<boolean>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(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<boolean> {
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<boolean> {
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 (
<AuthContext.Provider value={{ user, token, loading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
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 <token>`
3. Check token hasn't expired
4. Check MANA_CORE_AUTH_URL is correct

View file

@ -0,0 +1,306 @@
# 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<T>`, `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<Result<User>> {
// ...
}
// 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<Result<Balance>> {
// ...
}
```
## 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
```

View file

@ -0,0 +1,484 @@
# 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<typeof postgres> | null = null;
let db: ReturnType<typeof drizzle> | 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<typeof getDb>;
```
### 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<string>('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<Record<string, unknown>>(),
settings: jsonb('settings').default({}).$type<UserSettings>(),
tags: jsonb('tags').$type<string[]>().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<Result<{ items: File[]; total: number }>> {
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<number>`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<string[]>`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<number>`count(*)` })
.from(files)
.where(eq(files.folderId, folderId));
```

View file

@ -0,0 +1,592 @@
# 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<T>` 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<T, E extends AppError = AppError> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: E };
// Async version for async functions
export type AsyncResult<T, E extends AppError = AppError> = Promise<Result<T, E>>;
// 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<File> {
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<File> {
// 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<void> {
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<Response>();
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<T> {
ok: boolean;
data?: T;
error?: AppError;
}
async function apiRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<Result<T>> {
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<T> = 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<File>(`/files/${id}`),
list: (folderId?: string) => apiRequest<File[]>(`/files?folderId=${folderId ?? ''}`),
create: (data: CreateFileDto) => apiRequest<File>('/files', {
method: 'POST',
body: JSON.stringify(data),
}),
delete: (id: string) => apiRequest<void>(`/files/${id}`, { method: 'DELETE' }),
},
};
```
### Component Usage (Svelte 5)
```svelte
<script lang="ts">
import { api } from '$lib/api/client';
import { ErrorCode } from '@manacore/shared-errors';
let files = $state<File[]>([]);
let error = $state<string | null>(null);
let loading = $state(false);
async function loadFiles() {
loading = true;
error = null;
const result = await api.files.list();
if (!result.ok) {
// Handle specific error codes
switch (result.error.code) {
case ErrorCode.UNAUTHORIZED:
goto('/login');
break;
case ErrorCode.FORBIDDEN:
error = 'You do not have permission to view these files';
break;
default:
error = result.error.message;
}
} else {
files = result.data;
}
loading = false;
}
async function deleteFile(id: string) {
const result = await api.files.delete(id);
if (!result.ok) {
showToast({ type: 'error', message: result.error.message });
return;
}
files = files.filter(f => f.id !== id);
showToast({ type: 'success', message: 'File deleted' });
}
</script>
```
### 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<File[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<AppError | null>(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<boolean> => {
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<Result<FileRecord>> {
// 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<Result<File>> {
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

View file

@ -0,0 +1,814 @@
# 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 (
<GestureHandlerRootView style={{ flex: 1 }}>
<ThemeProvider>
<AuthProvider>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(auth)" />
<Stack.Screen name="(drawer)" />
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
</Stack>
</AuthProvider>
</ThemeProvider>
</GestureHandlerRootView>
);
}
```
## 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 (
<Drawer
drawerContent={(props) => <CustomDrawer {...props} />}
screenOptions={{
drawerType: 'front',
headerShown: false,
}}
>
<Drawer.Screen name="(tabs)" options={{ title: 'Home' }} />
<Drawer.Screen name="settings" options={{ title: 'Settings' }} />
</Drawer>
);
}
```
### Tab Navigation
```tsx
// app/(drawer)/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#0A84FF',
headerShown: false,
}}
>
<Tabs.Screen
name="home"
options={{
title: 'Home',
tabBarIcon: ({ color, size }) => (
<Ionicons name="home" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="files"
options={{
title: 'Files',
tabBarIcon: ({ color, size }) => (
<Ionicons name="folder" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="settings"
options={{
title: 'Settings',
tabBarIcon: ({ color, size }) => (
<Ionicons name="settings" size={size} color={color} />
),
}}
/>
</Tabs>
);
}
```
### 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<boolean>;
logout: () => Promise<void>;
isAuthenticated: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const TOKEN_KEY = 'auth_token';
const USER_KEY = 'auth_user';
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(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<boolean> {
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 (
<AuthContext.Provider
value={{
user,
token,
loading,
login,
logout,
isAuthenticated: !!token,
}}
>
{children}
</AuthContext.Provider>
);
}
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<void>;
deleteFile: (id: string) => Promise<boolean>;
refresh: () => Promise<void>;
}
export function useFiles(initialFolderId?: string): UseFilesResult {
const [files, setFiles] = useState<File[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<AppError | null>(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<boolean> => {
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<File | null>;
loading: boolean;
error: AppError | null;
}
export function useCreateFile(): UseCreateFileResult {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<AppError | null>(null);
const create = useCallback(async (data: CreateFileDto): Promise<File | null> => {
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<T> {
ok: boolean;
data?: T;
error?: AppError;
}
async function getToken(): Promise<string | null> {
try {
return await SecureStore.getItemAsync(TOKEN_KEY);
} catch {
return null;
}
}
async function request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<Result<T>> {
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<T> = 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<File[]>(`/api/v1/files${folderId ? `?folderId=${folderId}` : ''}`),
get: (id: string) =>
request<File>(`/api/v1/files/${id}`),
create: (data: CreateFileDto) =>
request<File>('/api/v1/files', {
method: 'POST',
body: JSON.stringify(data),
}),
delete: (id: string) =>
request<void>(`/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 <LoadingSpinner />;
}
if (error) {
return <ErrorView message={error.message} onRetry={refresh} />;
}
return (
<View className="flex-1 bg-background">
<FlatList
data={files}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <FileCard file={item} />}
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refresh} />
}
ListEmptyComponent={
<EmptyState
title="No files"
description="Upload your first file to get started"
/>
}
contentContainerStyle={{ padding: 16, gap: 12 }}
/>
</View>
);
}
```
### 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 (
<Pressable
onPress={handlePress}
className="bg-card rounded-xl p-4 border border-border active:opacity-80"
>
<View className="flex-row items-center gap-3">
<View className="w-10 h-10 bg-primary/10 rounded-lg items-center justify-center">
<Ionicons name="document" size={20} color="#0A84FF" />
</View>
<View className="flex-1">
<Text className="font-medium text-foreground" numberOfLines={1}>
{file.name}
</Text>
<Text className="text-sm text-muted-foreground">
{formatBytes(file.size)} • {formatDate(file.createdAt)}
</Text>
</View>
{onDelete && (
<Pressable
onPress={onDelete}
className="p-2"
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="trash-outline" size={20} color="#FF3B30" />
</Pressable>
)}
</View>
</Pressable>
);
}
```
### 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<typeof buttonVariants> {
children: string;
loading?: boolean;
}
export function Button({
children,
variant,
size,
loading = false,
disabled,
className,
...props
}: ButtonProps) {
return (
<Pressable
disabled={disabled || loading}
className={`${buttonVariants({ variant, size })} ${disabled ? 'opacity-50' : ''} ${className}`}
{...props}
>
{loading ? (
<ActivityIndicator color={variant === 'primary' ? '#fff' : '#000'} />
) : (
<Text className={textVariants({ variant, size })}>{children}</Text>
)}
</Pressable>
);
}
```
## 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
<View className="flex-1 bg-background p-4">
<Text className="text-lg font-bold text-foreground">Title</Text>
<Text className="text-muted-foreground">Subtitle</Text>
</View>
// Conditional classes
<View className={`p-4 rounded-xl ${selected ? 'bg-primary' : 'bg-card'}`}>
// Dynamic classes
<Text className={`text-${size} font-${weight}`}>
// Platform-specific (use Platform.select for complex cases)
<View className="ios:pt-12 android:pt-4">
```
## 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 (
<View className="flex-1 bg-background p-6 justify-center">
<Text className="text-3xl font-bold text-foreground mb-8 text-center">
Welcome Back
</Text>
<View className="gap-4">
<View>
<Text className="text-sm font-medium text-muted-foreground mb-1">
Email
</Text>
<TextInput
value={email}
onChangeText={setEmail}
placeholder="you@example.com"
keyboardType="email-address"
autoCapitalize="none"
className="h-12 px-4 bg-card border border-border rounded-xl text-foreground"
/>
</View>
<View>
<Text className="text-sm font-medium text-muted-foreground mb-1">
Password
</Text>
<TextInput
value={password}
onChangeText={setPassword}
placeholder="••••••••"
secureTextEntry
className="h-12 px-4 bg-card border border-border rounded-xl text-foreground"
/>
</View>
<Button onPress={handleLogin} loading={loading} className="mt-4">
Sign In
</Button>
</View>
</View>
);
}
```
## 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

View file

@ -0,0 +1,686 @@
# 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<Result<File[]>> {
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<Result<File>> {
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<Result<File>> {
// 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<Result<File>> {
// 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<Result<void>> {
// 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<Result<File>> {
// 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<Response>();
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
```

View file

@ -0,0 +1,770 @@
# 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
<script lang="ts">
// Reactive state
let count = $state(0);
let name = $state('');
let items = $state<string[]>([]);
// Object state
let user = $state<User | null>(null);
// Functions that modify state
function increment() {
count++; // Direct mutation works
}
function addItem(item: string) {
items = [...items, item]; // Or reassignment
}
</script>
```
### Derived Values with $derived
```svelte
<script lang="ts">
let count = $state(0);
let items = $state<Item[]>([]);
// Computed value - updates automatically
const doubled = $derived(count * 2);
const itemCount = $derived(items.length);
const hasItems = $derived(items.length > 0);
// Complex derived
const sortedItems = $derived(
[...items].sort((a, b) => a.name.localeCompare(b.name))
);
// Derived with conditions
const displayText = $derived(
count === 0 ? 'No items' :
count === 1 ? '1 item' :
`${count} items`
);
</script>
```
### Effects with $effect
```svelte
<script lang="ts">
import { browser } from '$app/environment';
let searchQuery = $state('');
let results = $state<SearchResult[]>([]);
// Run effect when dependencies change
$effect(() => {
if (!browser) return;
// This runs when searchQuery changes
const timer = setTimeout(async () => {
results = await search(searchQuery);
}, 300);
// Cleanup function
return () => clearTimeout(timer);
});
// Effect for initialization
$effect(() => {
if (browser) {
loadInitialData();
}
});
</script>
```
### Props with $props
```svelte
<script lang="ts">
import type { File } from '$lib/types';
// Define props with types
interface Props {
file: File;
selected?: boolean;
onDelete?: (id: string) => void;
onSelect?: (file: File) => void;
}
// Destructure with defaults
let {
file,
selected = false,
onDelete,
onSelect
}: Props = $props();
function handleDelete() {
onDelete?.(file.id);
}
</script>
<div class:selected onclick={() => onSelect?.(file)}>
<span>{file.name}</span>
{#if onDelete}
<button onclick={handleDelete}>Delete</button>
{/if}
</div>
```
### Bindable Props with $bindable
```svelte
<script lang="ts">
interface Props {
value: string;
disabled?: boolean;
}
let { value = $bindable(), disabled = false }: Props = $props();
</script>
<input bind:value {disabled} />
<!-- Usage: -->
<!-- <TextInput bind:value={searchQuery} /> -->
```
## 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<File[]>([]);
let loading = $state(false);
let error = $state<AppError | null>(null);
let selectedId = $state<string | null>(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<void> {
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<boolean> {
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
<script lang="ts">
import { fileStore } from '$lib/stores/files.svelte';
import { onMount } from 'svelte';
onMount(() => {
fileStore.loadFiles();
});
async function handleDelete(id: string) {
const success = await fileStore.deleteFile(id);
if (success) {
showToast('File deleted');
}
}
</script>
{#if fileStore.loading}
<LoadingSpinner />
{:else if fileStore.error}
<ErrorMessage message={fileStore.error.message} />
{:else}
<FileList
files={fileStore.files}
selectedId={fileStore.selectedFile?.id}
onSelect={(file) => 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<T> {
ok: boolean;
data?: T;
error?: AppError;
}
async function request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<Result<T>> {
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<T> = 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<File[]>(`/api/v1/files${folderId ? `?folderId=${folderId}` : ''}`),
get: (id: string) =>
request<File>(`/api/v1/files/${id}`),
create: (data: CreateFileDto) =>
request<File>('/api/v1/files', {
method: 'POST',
body: JSON.stringify(data),
}),
update: (id: string, data: UpdateFileDto) =>
request<File>(`/api/v1/files/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
delete: (id: string) =>
request<void>(`/api/v1/files/${id}`, { method: 'DELETE' }),
},
folders: {
list: () => request<Folder[]>('/api/v1/folders'),
get: (id: string) => request<Folder>(`/api/v1/folders/${id}`),
create: (data: CreateFolderDto) =>
request<Folder>('/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
<!-- src/routes/(app)/+layout.svelte -->
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import Sidebar from '$lib/components/layout/Sidebar.svelte';
let { children } = $props();
// Check auth on mount
$effect(() => {
if (browser && !authStore.isAuthenticated) {
goto('/login');
}
});
</script>
{#if authStore.isAuthenticated}
<div class="flex h-screen">
<Sidebar />
<main class="flex-1 overflow-auto">
{@render children()}
</main>
</div>
{:else}
<div class="flex items-center justify-center h-screen">
<LoadingSpinner />
</div>
{/if}
```
### Dynamic Routes
```svelte
<!-- src/routes/(app)/files/[id]/+page.svelte -->
<script lang="ts">
import { page } from '$app/stores';
import { api } from '$lib/api/client';
let file = $state<File | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
// Load file when ID changes
$effect(() => {
const fileId = $page.params.id;
loadFile(fileId);
});
async function loadFile(id: string) {
loading = true;
error = null;
const result = await api.files.get(id);
if (result.ok) {
file = result.data;
} else {
error = result.error.message;
}
loading = false;
}
</script>
{#if loading}
<LoadingSpinner />
{:else if error}
<ErrorMessage message={error} />
{:else if file}
<FileViewer {file} />
{/if}
```
## Components
### Component Pattern
```svelte
<!-- src/lib/components/files/FileCard.svelte -->
<script lang="ts">
import type { File } from '$lib/types';
import { formatBytes, formatDate } from '$lib/utils/format';
import FileIcon from './FileIcon.svelte';
interface Props {
file: File;
selected?: boolean;
onSelect?: () => void;
onDelete?: () => void;
}
let { file, selected = false, onSelect, onDelete }: Props = $props();
const formattedSize = $derived(formatBytes(file.size));
const formattedDate = $derived(formatDate(file.createdAt));
</script>
<div
class="p-4 rounded-lg border transition-colors cursor-pointer
{selected ? 'border-primary bg-primary/5' : 'border-gray-200 hover:border-gray-300'}"
onclick={onSelect}
role="button"
tabindex="0"
onkeydown={(e) => e.key === 'Enter' && onSelect?.()}
>
<div class="flex items-start gap-3">
<FileIcon mimeType={file.mimeType} />
<div class="flex-1 min-w-0">
<h3 class="font-medium truncate">{file.name}</h3>
<p class="text-sm text-gray-500">
{formattedSize} • {formattedDate}
</p>
</div>
{#if onDelete}
<button
class="p-2 text-gray-400 hover:text-red-500"
onclick|stopPropagation={onDelete}
aria-label="Delete file"
>
<TrashIcon />
</button>
{/if}
</div>
</div>
```
### Snippets (Slot Replacement)
```svelte
<!-- Parent component -->
<script lang="ts">
import Modal from '$lib/components/ui/Modal.svelte';
let showModal = $state(false);
</script>
<Modal bind:open={showModal}>
{#snippet header()}
<h2>Confirm Delete</h2>
{/snippet}
{#snippet content()}
<p>Are you sure you want to delete this file?</p>
{/snippet}
{#snippet footer()}
<button onclick={() => showModal = false}>Cancel</button>
<button onclick={handleDelete}>Delete</button>
{/snippet}
</Modal>
<!-- Modal.svelte -->
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
open: boolean;
header?: Snippet;
content?: Snippet;
footer?: Snippet;
}
let { open = $bindable(), header, content, footer }: Props = $props();
</script>
{#if open}
<div class="modal-overlay" onclick={() => open = false}>
<div class="modal" onclick|stopPropagation>
{#if header}
<div class="modal-header">{@render header()}</div>
{/if}
{#if content}
<div class="modal-content">{@render content()}</div>
{/if}
{#if footer}
<div class="modal-footer">{@render footer()}</div>
{/if}
</div>
</div>
{/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
<script lang="ts">
import { api } from '$lib/api/client';
let name = $state('');
let email = $state('');
let loading = $state(false);
let errors = $state<Record<string, string>>({});
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
errors = {};
// Client-side validation
if (!name.trim()) errors.name = 'Name is required';
if (!email.trim()) errors.email = 'Email is required';
if (Object.keys(errors).length > 0) return;
loading = true;
const result = await api.users.create({ name, email });
loading = false;
if (result.ok) {
goto('/users');
} else {
// Handle server errors
if (result.error.code === 'ERR_5002') {
errors.email = 'Email already exists';
} else {
errors.form = result.error.message;
}
}
}
</script>
<form onsubmit={handleSubmit}>
{#if errors.form}
<div class="text-red-500 mb-4">{errors.form}</div>
{/if}
<div class="mb-4">
<label for="name">Name</label>
<input id="name" bind:value={name} class:border-red-500={errors.name} />
{#if errors.name}
<span class="text-red-500 text-sm">{errors.name}</span>
{/if}
</div>
<div class="mb-4">
<label for="email">Email</label>
<input id="email" type="email" bind:value={email} class:border-red-500={errors.email} />
{#if errors.email}
<span class="text-red-500 text-sm">{errors.email}</span>
{/if}
</div>
<button type="submit" disabled={loading} class="btn-primary">
{loading ? 'Saving...' : 'Save'}
</button>
</form>
```
## 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
<!-- BAD - Old Svelte 4 syntax -->
<script>
let count = 0;
$: doubled = count * 2;
$: console.log(count);
</script>
<!-- GOOD - Svelte 5 runes -->
<script>
let count = $state(0);
const doubled = $derived(count * 2);
$effect(() => console.log(count));
</script>
```
### Don't Create Stores in Components
```svelte
<!-- BAD - Store created in component -->
<script>
let store = $state({ items: [] }); // This is local, not shared
</script>
<!-- GOOD - Import store from .svelte.ts file -->
<script>
import { itemStore } from '$lib/stores/items.svelte';
</script>
```
### Don't Fetch in Render
```svelte
<!-- BAD - Fetches on every render -->
<script>
const promise = fetch('/api/data').then(r => r.json());
</script>
{#await promise}...{/await}
<!-- GOOD - Fetch in effect or onMount -->
<script>
import { onMount } from 'svelte';
let data = $state(null);
onMount(async () => {
data = await fetch('/api/data').then(r => r.json());
});
</script>
```

View file

@ -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> = {}): 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> = {}): User[] => {
return Array.from({ length: count }, () => mockUserFactory.create(overrides));
},
};
export const mockFileFactory = {
create: (overrides: Partial<File> = {}): 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> = {}): 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<string, any> = {}) {
const config: Record<string, any> = {
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: <T>(result: Result<T>): T => {
expect(result.ok).toBe(true);
if (!result.ok) throw new Error('Expected ok result');
return result.data;
},
assertResultErr: (result: Result<any>, 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<typeof createMockDb>;
beforeEach(async () => {
mockDb = createMockDb();
const module: TestingModule = await Test.createTestingModule({
providers: [
FileService,
{ provide: DATABASE_CONNECTION, useValue: mockDb },
],
}).compile();
service = module.get<FileService>(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<FileService>;
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>(FileController);
fileService = module.get(FileService) as jest.Mocked<FileService>;
});
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