diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs new file mode 100644 index 000000000..bc41fb657 --- /dev/null +++ b/apps/docs/astro.config.mjs @@ -0,0 +1,101 @@ +import { defineConfig } from 'astro/config'; +import starlight from '@astrojs/starlight'; +import tailwind from '@astrojs/tailwind'; +import sitemap from '@astrojs/sitemap'; + +export default defineConfig({ + site: 'https://docs.manacore.app', + integrations: [ + starlight({ + title: 'Manacore Docs', + description: + 'Documentation for the Manacore ecosystem - a multi-app platform with shared infrastructure.', + logo: { + light: './src/assets/logo-light.svg', + dark: './src/assets/logo-dark.svg', + replacesTitle: false, + }, + social: { + github: 'https://github.com/manacore/manacore-monorepo', + }, + editLink: { + baseUrl: 'https://github.com/manacore/manacore-monorepo/edit/main/apps/docs/', + }, + customCss: ['./src/styles/custom.css'], + sidebar: [ + { + label: 'Getting Started', + items: [ + { label: 'Introduction', slug: 'getting-started/introduction' }, + { label: 'Quick Start', slug: 'getting-started/quick-start' }, + { label: 'Project Structure', slug: 'getting-started/project-structure' }, + ], + }, + { + label: 'Development', + items: [ + { label: 'Local Development', slug: 'development/local-development' }, + { label: 'Environment Variables', slug: 'development/environment-variables' }, + { label: 'Docker Setup', slug: 'development/docker' }, + { label: 'Database Migrations', slug: 'development/database-migrations' }, + { label: 'Testing', slug: 'development/testing' }, + ], + }, + { + label: 'Architecture', + items: [ + { label: 'Overview', slug: 'architecture/overview' }, + { label: 'Authentication', slug: 'architecture/authentication' }, + { label: 'Backend (NestJS)', slug: 'architecture/backend' }, + { label: 'Web (SvelteKit)', slug: 'architecture/web' }, + { label: 'Mobile (Expo)', slug: 'architecture/mobile' }, + { label: 'Search Service', slug: 'architecture/search' }, + { label: 'Storage', slug: 'architecture/storage' }, + ], + }, + { + label: 'Guidelines', + items: [ + { label: 'Code Style', slug: 'guidelines/code-style' }, + { label: 'Error Handling', slug: 'guidelines/error-handling' }, + { label: 'Database Patterns', slug: 'guidelines/database' }, + { label: 'Design & UX', slug: 'guidelines/design-ux' }, + ], + }, + { + label: 'Deployment', + items: [ + { label: 'Overview', slug: 'deployment/overview' }, + { label: 'Cloudflare Pages', slug: 'deployment/cloudflare-pages' }, + { label: 'Mac Mini Server', slug: 'deployment/mac-mini-server' }, + { label: 'Self-Hosting', slug: 'deployment/self-hosting' }, + ], + }, + { + label: 'Projects', + collapsed: true, + items: [ + { label: 'Overview', slug: 'projects' }, + { label: 'Chat', slug: 'projects/chat' }, + ], + }, + { + label: 'API Reference', + collapsed: true, + items: [{ label: 'Overview', slug: 'api' }], + }, + ], + head: [ + { + tag: 'meta', + attrs: { + property: 'og:image', + content: 'https://docs.manacore.app/og-image.png', + }, + }, + ], + }), + tailwind({ applyBaseStyles: false }), + sitemap(), + ], +}); diff --git a/apps/docs/package.json b/apps/docs/package.json new file mode 100644 index 000000000..75aa3e90d --- /dev/null +++ b/apps/docs/package.json @@ -0,0 +1,28 @@ +{ + "name": "@manacore/docs", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "astro dev", + "start": "astro dev", + "build": "astro check && astro build", + "preview": "astro preview", + "astro": "astro", + "type-check": "astro check" + }, + "dependencies": { + "@astrojs/check": "^0.9.0", + "@astrojs/sitemap": "^3.2.1", + "@astrojs/starlight": "^0.32.0", + "astro": "^5.16.0", + "sharp": "^0.33.0", + "typescript": "^5.0.0" + }, + "devDependencies": { + "@astrojs/starlight-tailwind": "^3.0.0", + "@astrojs/tailwind": "^6.0.0", + "@tailwindcss/typography": "^0.5.16", + "tailwindcss": "^3.4.17" + } +} diff --git a/apps/docs/src/assets/logo-dark.svg b/apps/docs/src/assets/logo-dark.svg new file mode 100644 index 000000000..2f04b8d2e --- /dev/null +++ b/apps/docs/src/assets/logo-dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/docs/src/assets/logo-light.svg b/apps/docs/src/assets/logo-light.svg new file mode 100644 index 000000000..2f04b8d2e --- /dev/null +++ b/apps/docs/src/assets/logo-light.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/docs/src/content/config.ts b/apps/docs/src/content/config.ts new file mode 100644 index 000000000..45f60b015 --- /dev/null +++ b/apps/docs/src/content/config.ts @@ -0,0 +1,6 @@ +import { defineCollection } from 'astro:content'; +import { docsSchema } from '@astrojs/starlight/schema'; + +export const collections = { + docs: defineCollection({ schema: docsSchema() }), +}; diff --git a/apps/docs/src/content/docs/api/index.mdx b/apps/docs/src/content/docs/api/index.mdx new file mode 100644 index 000000000..527d8ea7c --- /dev/null +++ b/apps/docs/src/content/docs/api/index.mdx @@ -0,0 +1,151 @@ +--- +title: API Reference +description: API documentation for Manacore services. +--- + +# API Reference + +This section contains API documentation for all Manacore backend services. + +## Authentication + +All API endpoints (except public ones) require authentication via JWT bearer token: + +```bash +curl -X GET https://api.manacore.app/v1/users/me \ + -H "Authorization: Bearer " +``` + +### Getting a Token + +```bash +# Login +curl -X POST https://api.manacore.app/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email": "user@example.com", "password": "password"}' + +# Response +{ + "accessToken": "eyJ...", + "refreshToken": "eyJ...", + "expiresIn": 900 +} +``` + +## Base URLs + +| Service | Development | Production | +|---------|-------------|------------| +| Auth | `http://localhost:3001` | `https://auth.manacore.app` | +| Chat | `http://localhost:3002` | `https://chat-api.manacore.app` | +| Picture | `http://localhost:3006` | `https://picture-api.manacore.app` | +| Zitare | `http://localhost:3007` | `https://zitare-api.manacore.app` | + +## Common Response Format + +### Success Response + +```json +{ + "data": { ... }, + "meta": { + "total": 100, + "page": 1, + "limit": 20 + } +} +``` + +### Error Response + +```json +{ + "statusCode": 400, + "message": "Validation failed", + "error": "Bad Request", + "details": [ + { + "field": "email", + "message": "Invalid email format" + } + ] +} +``` + +## HTTP Status Codes + +| Code | Meaning | +|------|---------| +| 200 | Success | +| 201 | Created | +| 204 | No Content | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not Found | +| 409 | Conflict | +| 422 | Unprocessable Entity | +| 429 | Too Many Requests | +| 500 | Internal Server Error | + +## Rate Limiting + +API endpoints are rate limited: + +| Endpoint Type | Limit | +|---------------|-------| +| Auth endpoints | 10 req/min | +| Read endpoints | 100 req/min | +| Write endpoints | 30 req/min | + +Rate limit headers are included in responses: + +``` +X-RateLimit-Limit: 100 +X-RateLimit-Remaining: 95 +X-RateLimit-Reset: 1640000000 +``` + +## Pagination + +List endpoints support pagination: + +```bash +GET /api/v1/items?page=2&limit=20 +``` + +Response includes pagination metadata: + +```json +{ + "data": [...], + "meta": { + "total": 150, + "page": 2, + "limit": 20, + "totalPages": 8 + } +} +``` + +## Filtering & Sorting + +```bash +# Filter by status +GET /api/v1/items?status=active + +# Sort by date descending +GET /api/v1/items?sort=-createdAt + +# Multiple filters +GET /api/v1/items?status=active&category=tech&sort=-createdAt +``` + +## Service APIs + +Select a service from the sidebar to view its API documentation: + +- **Auth API** - Authentication and user management +- **Chat API** - Conversations and AI completions +- **Picture API** - Image generation +- **Zitare API** - Quotes and favorites diff --git a/apps/docs/src/content/docs/architecture/authentication.mdx b/apps/docs/src/content/docs/architecture/authentication.mdx new file mode 100644 index 000000000..fd683108b --- /dev/null +++ b/apps/docs/src/content/docs/architecture/authentication.mdx @@ -0,0 +1,283 @@ +--- +title: Authentication +description: Central authentication architecture using Mana Core Auth. +--- + +import { Aside, Tabs, TabItem, Steps } from '@astrojs/starlight/components'; + +# Authentication + +All Manacore applications use **Mana Core Auth** as the central authentication service, providing EdDSA JWT-based authentication. + +## Overview + +``` +┌─────────────┐ ┌─────────────┐ ┌────────────────┐ +│ Client │────>│ Backend │────>│ mana-core-auth │ +│ (Web/Mobile)│ │ (NestJS) │ │ (port 3001) │ +└─────────────┘ └─────────────┘ └────────────────┘ + │ │ │ + │ Bearer token │ POST /validate │ + │ │ {token} │ + │ │<────────────────────│ + │ │ {valid, payload} │ + │<──────────────────│ │ + │ Response │ │ +``` + +## JWT Token Structure + +Mana Core Auth uses **EdDSA (Ed25519)** for JWT signing: + +```json +{ + "sub": "user-uuid", + "email": "user@example.com", + "role": "user", + "sid": "session-uuid", + "exp": 1764606251, + "iss": "manacore", + "aud": "manacore" +} +``` + +| Claim | Description | +|-------|-------------| +| `sub` | User ID (UUID) | +| `email` | User's email address | +| `role` | User role (`user`, `admin`) | +| `sid` | Session ID for invalidation | +| `exp` | Expiration timestamp | +| `iss` | Issuer (`manacore`) | +| `aud` | Audience (`manacore`) | + +## Backend Integration + +### Option 1: Simple Auth Only + +Use `@manacore/shared-nestjs-auth` for JWT validation: + +```typescript +// app.module.ts +import { JwtAuthModule } from '@manacore/shared-nestjs-auth'; + +@Module({ + imports: [ + JwtAuthModule.register({ + authServiceUrl: process.env.MANA_CORE_AUTH_URL, + }), + ], +}) +export class AppModule {} +``` + +```typescript +// controller.ts +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; + +@Controller('api') +@UseGuards(JwtAuthGuard) +export class MyController { + @Get('profile') + getProfile(@CurrentUser() user: CurrentUserData) { + return { userId: user.userId, email: user.email }; + } +} +``` + +### Option 2: Auth + Credits + +Use `@mana-core/nestjs-integration` for full integration: + +```typescript +// app.module.ts +import { ManaCoreModule } from '@mana-core/nestjs-integration'; + +@Module({ + imports: [ + 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 +// controller.ts +import { AuthGuard } from '@mana-core/nestjs-integration/guards'; +import { CurrentUser } from '@mana-core/nestjs-integration/decorators'; +import { CreditClientService } from '@mana-core/nestjs-integration'; + +@Controller('api') +@UseGuards(AuthGuard) +export class ApiController { + constructor(private creditClient: CreditClientService) {} + + @Post('generate') + async generate(@CurrentUser() user: any) { + // Consume credits for the operation + await this.creditClient.consumeCredits( + user.sub, + 'generation', + 10, + 'AI generation' + ); + // ... perform operation + } +} +``` + +## Client Integration + +### Web (SvelteKit) + +```typescript +// src/lib/auth.ts +import { createAuthService } from '@manacore/shared-auth'; + +export const auth = createAuthService({ + authUrl: import.meta.env.PUBLIC_MANA_CORE_AUTH_URL, +}); + +// Usage in component +const { data, error } = await auth.login(email, password); +if (data) { + // Store token, redirect to app +} +``` + +### Mobile (Expo) + +```typescript +// src/services/auth.ts +import { createAuthService } from '@manacore/shared-auth'; + +export const auth = createAuthService({ + authUrl: process.env.EXPO_PUBLIC_MANA_CORE_AUTH_URL, + storage: AsyncStorage, // Expo AsyncStorage +}); +``` + +## API Endpoints + +### Public Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/v1/auth/register` | POST | Create new account | +| `/api/v1/auth/login` | POST | Login with credentials | +| `/api/v1/auth/refresh` | POST | Refresh access token | +| `/api/v1/auth/logout` | POST | Invalidate session | + +### Protected Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/v1/auth/me` | GET | Get current user | +| `/api/v1/auth/sessions` | GET | List active sessions | +| `/api/v1/auth/sessions/:id` | DELETE | Revoke session | + +## Environment Variables + +### Backend Services + +```env +# Required +MANA_CORE_AUTH_URL=http://localhost:3001 + +# For development bypass (optional) +NODE_ENV=development +DEV_BYPASS_AUTH=true +DEV_USER_ID=test-user-uuid + +# For credit operations +MANA_CORE_SERVICE_KEY=your-service-key +APP_ID=your-app-id +``` + +### Client Apps + + + + ```env + PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001 + ``` + + + ```env + EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001 + ``` + + + +## Testing Authentication + + +1. **Start Mana Core Auth** + + ```bash + pnpm dev:auth + ``` + +2. **Get a token** + + ```bash + TOKEN=$(curl -s -X POST http://localhost:3001/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email": "test@example.com", "password": "password"}' \ + | jq -r '.accessToken') + ``` + +3. **Call protected endpoint** + + ```bash + curl http://localhost:3002/api/v1/profile \ + -H "Authorization: Bearer $TOKEN" + ``` + + +## Development Bypass + +For faster development, you can bypass authentication: + +```typescript +// Only in development! +if (process.env.DEV_BYPASS_AUTH === 'true') { + // Use mock user + return { + userId: process.env.DEV_USER_ID, + email: 'dev@example.com', + role: 'admin', + }; +} +``` + + + +## Security Best Practices + +1. **Always use HTTPS** in production +2. **Rotate JWT keys** periodically +3. **Keep tokens short-lived** (15m access, 7d refresh) +4. **Validate on every request** - don't trust client storage +5. **Implement rate limiting** on auth endpoints +6. **Log authentication events** for audit trails + +## Integrated Services + +| Backend | Package | Port | +|---------|---------|------| +| Chat | `@mana-core/nestjs-integration` | 3002 | +| Picture | `@manacore/shared-nestjs-auth` | 3006 | +| Zitare | `@manacore/shared-nestjs-auth` | 3007 | +| ManaDeck | `@mana-core/nestjs-integration` | 3009 | +| Contacts | `@manacore/shared-nestjs-auth` | 3015 | diff --git a/apps/docs/src/content/docs/architecture/backend.mdx b/apps/docs/src/content/docs/architecture/backend.mdx new file mode 100644 index 000000000..a00b58d54 --- /dev/null +++ b/apps/docs/src/content/docs/architecture/backend.mdx @@ -0,0 +1,353 @@ +--- +title: Backend (NestJS) +description: NestJS backend architecture patterns in Manacore. +--- + +import { Aside, Tabs, TabItem } from '@astrojs/starlight/components'; + +# Backend Architecture + +All Manacore backends use **NestJS 10-11** with consistent patterns for structure, validation, and error handling. + +## Project Structure + +``` +apps/{project}/apps/backend/ +├── src/ +│ ├── main.ts # Application entry point +│ ├── app.module.ts # Root module +│ ├── app.controller.ts # Health check, root routes +│ ├── config/ +│ │ └── configuration.ts # Environment config +│ ├── drizzle/ +│ │ ├── schema.ts # Database schema +│ │ └── drizzle.module.ts # Drizzle ORM setup +│ ├── {feature}/ +│ │ ├── {feature}.module.ts +│ │ ├── {feature}.controller.ts +│ │ ├── {feature}.service.ts +│ │ ├── dto/ +│ │ │ ├── create-{feature}.dto.ts +│ │ │ └── update-{feature}.dto.ts +│ │ └── entities/ +│ │ └── {feature}.entity.ts +│ └── common/ +│ ├── filters/ +│ ├── guards/ +│ ├── interceptors/ +│ └── decorators/ +├── drizzle/ +│ └── migrations/ # Generated migrations +├── drizzle.config.ts # Drizzle CLI config +└── package.json +``` + +## Module Pattern + +Each feature is a self-contained module: + +```typescript +// users/users.module.ts +import { Module } from '@nestjs/common'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; + +@Module({ + controllers: [UsersController], + providers: [UsersService], + exports: [UsersService], // Export for use in other modules +}) +export class UsersModule {} +``` + +## Controllers + +Controllers handle HTTP requests and delegate to services: + +```typescript +import { + Controller, + Get, + Post, + Body, + Param, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { UsersService } from './users.service'; +import { CreateUserDto } from './dto/create-user.dto'; + +@Controller('api/v1/users') +@UseGuards(JwtAuthGuard) +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Get() + findAll(@CurrentUser() user: CurrentUserData) { + return this.usersService.findAllForUser(user.userId); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.usersService.findById(id); + } + + @Post() + @HttpCode(HttpStatus.CREATED) + create(@Body() dto: CreateUserDto, @CurrentUser() user: CurrentUserData) { + return this.usersService.create(dto, user.userId); + } +} +``` + +## Services + +Services contain business logic and database operations: + +```typescript +import { Injectable, NotFoundException } from '@nestjs/common'; +import { Inject } from '@nestjs/common'; +import { DRIZZLE } from '@manacore/shared-drizzle'; +import { eq, and } from 'drizzle-orm'; +import { users } from '../drizzle/schema'; + +@Injectable() +export class UsersService { + constructor(@Inject(DRIZZLE) private db: DrizzleDB) {} + + async findAllForUser(userId: string) { + return this.db + .select() + .from(users) + .where(eq(users.ownerId, userId)); + } + + async findById(id: string) { + const [user] = await this.db + .select() + .from(users) + .where(eq(users.id, id)); + + if (!user) { + throw new NotFoundException(`User with ID ${id} not found`); + } + + return user; + } + + async create(data: CreateUserDto, ownerId: string) { + const [user] = await this.db + .insert(users) + .values({ ...data, ownerId }) + .returning(); + + return user; + } +} +``` + +## DTOs and Validation + +Use class-validator for input validation: + +```typescript +// dto/create-user.dto.ts +import { IsString, IsEmail, IsOptional, MinLength, MaxLength } from 'class-validator'; + +export class CreateUserDto { + @IsString() + @MinLength(2) + @MaxLength(100) + name: string; + + @IsEmail() + email: string; + + @IsOptional() + @IsString() + @MaxLength(500) + bio?: string; +} + +// dto/update-user.dto.ts +import { PartialType } from '@nestjs/mapped-types'; +import { CreateUserDto } from './create-user.dto'; + +export class UpdateUserDto extends PartialType(CreateUserDto) {} +``` + +Enable validation globally: + +```typescript +// main.ts +import { ValidationPipe } from '@nestjs/common'; + +app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, // Strip unknown properties + forbidNonWhitelisted: true, // Error on unknown properties + transform: true, // Auto-transform types + }), +); +``` + +## Error Handling + +Use Go-style Result types for explicit error handling: + +```typescript +// common/result.ts +export type Result = + | { ok: true; value: T } + | { ok: false; error: E }; + +export const ok = (value: T): Result => ({ ok: true, value }); +export const err = (error: E): Result => ({ ok: false, error }); +``` + +```typescript +// Usage in service +async findById(id: string): Promise> { + const [user] = await this.db + .select() + .from(users) + .where(eq(users.id, id)); + + if (!user) { + return err('NOT_FOUND'); + } + + return ok(user); +} + +// Usage in controller +@Get(':id') +async findOne(@Param('id') id: string) { + const result = await this.usersService.findById(id); + + if (!result.ok) { + throw new NotFoundException('User not found'); + } + + return result.value; +} +``` + +## Configuration + +Use NestJS ConfigModule for environment variables: + +```typescript +// config/configuration.ts +export default () => ({ + port: parseInt(process.env.PORT, 10) || 3000, + database: { + url: process.env.DATABASE_URL, + }, + auth: { + url: process.env.MANA_CORE_AUTH_URL, + }, +}); + +// app.module.ts +import { ConfigModule } from '@nestjs/config'; +import configuration from './config/configuration'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + load: [configuration], + isGlobal: true, + }), + ], +}) +export class AppModule {} +``` + +## Database Setup + +Use Drizzle ORM with PostgreSQL: + +```typescript +// drizzle/drizzle.module.ts +import { Module, Global } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema'; + +export const DRIZZLE = Symbol('DRIZZLE'); + +@Global() +@Module({ + providers: [ + { + provide: DRIZZLE, + inject: [ConfigService], + useFactory: (config: ConfigService) => { + const client = postgres(config.get('DATABASE_URL')); + return drizzle(client, { schema }); + }, + }, + ], + exports: [DRIZZLE], +}) +export class DrizzleModule {} +``` + +## API Response Format + +Consistent response structure: + +```typescript +// Success +{ + "data": { ... }, + "meta": { + "total": 100, + "page": 1, + "limit": 10 + } +} + +// Error +{ + "statusCode": 404, + "message": "User not found", + "error": "Not Found" +} +``` + +## Health Checks + +Every backend should have a health endpoint: + +```typescript +@Controller() +export class AppController { + @Get('health') + health() { + return { + status: 'ok', + timestamp: new Date().toISOString(), + }; + } + + @Get('api/v1/health') + healthApi() { + return this.health(); + } +} +``` + +## Best Practices + + diff --git a/apps/docs/src/content/docs/architecture/mobile.mdx b/apps/docs/src/content/docs/architecture/mobile.mdx new file mode 100644 index 000000000..968f38d11 --- /dev/null +++ b/apps/docs/src/content/docs/architecture/mobile.mdx @@ -0,0 +1,399 @@ +--- +title: Mobile (Expo) +description: Expo React Native mobile application patterns in Manacore. +--- + +import { Aside, Tabs, TabItem } from '@astrojs/starlight/components'; + +# Mobile Architecture + +Manacore mobile apps use **Expo SDK 52+** with React Native, Expo Router, and NativeWind for styling. + +## Project Structure + +``` +apps/{project}/apps/mobile/ +├── app/ # Expo Router routes +│ ├── _layout.tsx # Root layout +│ ├── index.tsx # Home screen +│ ├── (auth)/ # Auth screens +│ │ ├── _layout.tsx +│ │ ├── login.tsx +│ │ └── register.tsx +│ └── (tabs)/ # Tab navigation +│ ├── _layout.tsx +│ ├── index.tsx +│ ├── settings.tsx +│ └── profile.tsx +├── src/ +│ ├── components/ # React components +│ │ ├── ui/ # Base UI components +│ │ └── features/ # Feature components +│ ├── hooks/ # Custom hooks +│ ├── stores/ # Zustand stores +│ ├── services/ # API clients +│ ├── utils/ # Helper functions +│ └── types/ # TypeScript types +├── assets/ # Images, fonts +├── app.json # Expo config +├── tailwind.config.js # NativeWind config +└── package.json +``` + +## Expo Router + +### Root Layout + +```tsx +// app/_layout.tsx +import { Stack } from 'expo-router'; +import { useEffect } from 'react'; +import { useAuth } from '../src/hooks/useAuth'; +import '../global.css'; // NativeWind styles + +export default function RootLayout() { + const { isLoading, isAuthenticated } = useAuth(); + + if (isLoading) { + return ; + } + + return ( + + + + + ); +} +``` + +### Tab Navigation + +```tsx +// app/(tabs)/_layout.tsx +import { Tabs } from 'expo-router'; +import { Home, Settings, User } from 'lucide-react-native'; + +export default function TabLayout() { + return ( + + , + }} + /> + , + }} + /> + , + }} + /> + + ); +} +``` + +### Protected Routes + +```tsx +// app/(tabs)/_layout.tsx +import { Redirect, Tabs } from 'expo-router'; +import { useAuth } from '../../src/hooks/useAuth'; + +export default function TabLayout() { + const { isAuthenticated } = useAuth(); + + if (!isAuthenticated) { + return ; + } + + return ( + + {/* ... */} + + ); +} +``` + +## NativeWind (Tailwind) + +### Setup + +```tsx +// tailwind.config.js +module.exports = { + content: [ + './app/**/*.{js,jsx,ts,tsx}', + './src/**/*.{js,jsx,ts,tsx}', + ], + presets: [require('nativewind/preset')], + theme: { + extend: {}, + }, + plugins: [], +}; +``` + +### Usage + +```tsx +import { View, Text, Pressable } from 'react-native'; + +export function Button({ title, onPress }) { + return ( + + + {title} + + + ); +} +``` + +## State Management (Zustand) + +```tsx +// src/stores/auth.ts +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +interface AuthState { + user: User | null; + token: string | null; + setAuth: (user: User, token: string) => void; + logout: () => void; +} + +export const useAuthStore = create()( + persist( + (set) => ({ + user: null, + token: null, + setAuth: (user, token) => set({ user, token }), + logout: () => set({ user: null, token: null }), + }), + { + name: 'auth-storage', + storage: createJSONStorage(() => AsyncStorage), + } + ) +); +``` + +### Usage in Components + +```tsx +import { useAuthStore } from '../stores/auth'; + +export function ProfileScreen() { + const { user, logout } = useAuthStore(); + + return ( + + {user?.name} + {user?.email} + + + Logout + + + ); +} +``` + +## API Integration + +### API Client + +```tsx +// src/services/api.ts +import { useAuthStore } from '../stores/auth'; + +const API_URL = process.env.EXPO_PUBLIC_API_URL; + +class ApiClient { + private async fetch(path: string, options: RequestInit = {}): Promise { + const token = useAuthStore.getState().token; + + const response = await fetch(`${API_URL}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(token && { Authorization: `Bearer ${token}` }), + ...options.headers, + }, + }); + + if (!response.ok) { + if (response.status === 401) { + useAuthStore.getState().logout(); + } + throw new Error(`API Error: ${response.status}`); + } + + return response.json(); + } + + // API methods + getUser = () => this.fetch('/api/v1/me'); + getItems = () => this.fetch('/api/v1/items'); + createItem = (data: CreateItemDto) => + this.fetch('/api/v1/items', { + method: 'POST', + body: JSON.stringify(data), + }); +} + +export const api = new ApiClient(); +``` + +### React Query Integration + +```tsx +// src/hooks/useItems.ts +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { api } from '../services/api'; + +export function useItems() { + return useQuery({ + queryKey: ['items'], + queryFn: api.getItems, + }); +} + +export function useCreateItem() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: api.createItem, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['items'] }); + }, + }); +} +``` + +## Components + +### Base Component Pattern + +```tsx +// src/components/ui/Card.tsx +import { View, ViewProps } from 'react-native'; +import { cn } from '../../utils/cn'; + +interface CardProps extends ViewProps { + variant?: 'default' | 'elevated'; +} + +export function Card({ + variant = 'default', + className, + children, + ...props +}: CardProps) { + return ( + + {children} + + ); +} +``` + +### List Component + +```tsx +// src/components/features/ItemList.tsx +import { FlatList, View, Text, ActivityIndicator } from 'react-native'; +import { useItems } from '../../hooks/useItems'; +import { Card } from '../ui/Card'; + +export function ItemList() { + const { data: items, isLoading, error } = useItems(); + + if (isLoading) { + return ; + } + + if (error) { + return ( + + Error loading items + + ); + } + + return ( + item.id} + renderItem={({ item }) => ( + + {item.title} + {item.description} + + )} + contentContainerClassName="p-4" + /> + ); +} +``` + +## Best Practices + + + +## Common Commands + +```bash +# Start development server +pnpm dev + +# Run on iOS simulator +pnpm ios + +# Run on Android emulator +pnpm android + +# Build development version +pnpm build:dev + +# Build production version +pnpm build:prod +``` diff --git a/apps/docs/src/content/docs/architecture/overview.mdx b/apps/docs/src/content/docs/architecture/overview.mdx new file mode 100644 index 000000000..75945d737 --- /dev/null +++ b/apps/docs/src/content/docs/architecture/overview.mdx @@ -0,0 +1,159 @@ +--- +title: Architecture Overview +description: High-level architecture of the Manacore ecosystem. +--- + +import { Card, CardGrid } from '@astrojs/starlight/components'; + +# Architecture Overview + +Manacore is a multi-app ecosystem with shared infrastructure, enabling rapid development of interconnected applications. + +## System Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Client Layer │ +├─────────────────┬─────────────────┬─────────────────────────────┤ +│ Mobile Apps │ Web Apps │ Landing Pages │ +│ (Expo/RN) │ (SvelteKit) │ (Astro) │ +└────────┬────────┴────────┬────────┴──────────────┬──────────────┘ + │ │ │ + └─────────────────┼───────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ API Gateway │ +│ (Individual NestJS backends) │ +└─────────────────────────────┬───────────────────────────────────┘ + │ + ┌────────────────────┼────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Mana Core Auth │ │ Mana Search │ │ Mana Storage │ +│ (Port 3001) │ │ (Port 3021) │ │ (S3/MinIO) │ +└────────┬────────┘ └────────┬────────┘ └─────────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ +│ PostgreSQL │ │ Redis │ +│ │ │ │ +└─────────────────┘ └─────────────────┘ +``` + +## Core Principles + + + + All apps authenticate through **Mana Core Auth** using EdDSA JWT tokens. + + + Each service has its own PostgreSQL database for isolation. + + + Common code lives in `packages/` for reuse across apps. + + + All apps follow the same architectural patterns and conventions. + + + +## Technology Stack + +### Frontend + +| Layer | Technology | Purpose | +|-------|------------|---------| +| **Mobile** | Expo SDK 52+, React Native | iOS & Android apps | +| **Web** | SvelteKit 2, Svelte 5 | Web applications | +| **Landing** | Astro 5 | Marketing pages | +| **Styling** | Tailwind CSS, NativeWind | Consistent design | + +### Backend + +| Layer | Technology | Purpose | +|-------|------------|---------| +| **API** | NestJS 10-11 | REST APIs | +| **ORM** | Drizzle ORM | Database access | +| **Database** | PostgreSQL | Data persistence | +| **Cache** | Redis | Sessions, caching | +| **Storage** | MinIO/Hetzner S3 | File storage | + +### Infrastructure + +| Component | Technology | Purpose | +|-----------|------------|---------| +| **Package Manager** | pnpm 9.15+ | Dependency management | +| **Build System** | Turborepo | Monorepo orchestration | +| **Containerization** | Docker | Local dev & deployment | +| **CDN** | Cloudflare Pages | Static hosting | + +## Service Communication + +### Authentication Flow + +``` +┌──────────┐ ┌──────────┐ ┌─────────────────┐ +│ Client │────>│ Backend │────>│ Mana Core Auth │ +└──────────┘ └──────────┘ └─────────────────┘ + │ │ │ + │ Bearer JWT │ POST /validate │ + │ │ {token} │ + │ │<───────────────────│ + │ │ {valid, payload} │ + │<──────────────│ │ + │ Response │ │ +``` + +### Service Ports + +| Service | Port | Purpose | +|---------|------|---------| +| mana-core-auth | 3001 | Authentication | +| chat-backend | 3002 | Chat API | +| picture-backend | 3006 | Image generation | +| zitare-backend | 3007 | Quotes API | +| manadeck-backend | 3009 | Card management | +| contacts-backend | 3015 | Contacts API | +| calendar-backend | 3014 | Calendar API | +| mana-search | 3021 | Search service | + +## Data Flow + +### Request Lifecycle + +1. **Client** sends request with JWT token +2. **Backend** validates token with Mana Core Auth +3. **Service** processes request with Drizzle ORM +4. **Database** returns data +5. **Backend** transforms and returns response +6. **Client** receives and displays data + +### Caching Strategy + +- **Redis**: Session tokens, rate limiting +- **CDN**: Static assets, landing pages +- **Application**: In-memory caches for hot data + +## Deployment Architecture + +### Development + +- Local Docker containers +- Hot reload on all services +- MinIO for S3-compatible storage + +### Production + +- Docker Compose on Mac Mini server +- Cloudflare Pages for static sites +- Hetzner Object Storage for files +- PostgreSQL for persistence + +## Next Steps + +- [Authentication](/architecture/authentication) - How auth works +- [Backend (NestJS)](/architecture/backend) - API patterns +- [Web (SvelteKit)](/architecture/web) - Frontend patterns +- [Storage](/architecture/storage) - File handling diff --git a/apps/docs/src/content/docs/architecture/search.mdx b/apps/docs/src/content/docs/architecture/search.mdx new file mode 100644 index 000000000..1f005a9fa --- /dev/null +++ b/apps/docs/src/content/docs/architecture/search.mdx @@ -0,0 +1,274 @@ +--- +title: Search Service +description: Centralized web search and content extraction with Mana Search. +--- + +import { Tabs, TabItem, Aside } from '@astrojs/starlight/components'; + +# Search Service + +**Mana Search** provides web search and content extraction capabilities for Manacore applications. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Consumer Apps │ +│ Questions │ Chat │ Project Doc Bot │ Future Apps │ +└─────────────────────────┬───────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ mana-search (Port 3021) │ +│ Search API │ Extract API │ Redis Cache │ +└─────────────────────────┬───────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ SearXNG (Port 8080, internal) │ +│ Google │ Bing │ DuckDuckGo │ Wikipedia │ arXiv │ ... │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Quick Start + +```bash +# Start SearXNG + Redis (for local development) +cd services/mana-search +docker-compose -f docker-compose.dev.yml up -d + +# Start NestJS API +pnpm --filter @mana-search/service dev + +# Or start everything via Docker +cd services/mana-search +docker-compose up -d +``` + +## API Endpoints + +### Web Search + +```bash +POST /api/v1/search +Content-Type: application/json + +{ + "query": "quantum computing basics", + "options": { + "categories": ["general", "science"], + "engines": ["google", "wikipedia"], + "limit": 10 + } +} +``` + +Response: +```json +{ + "results": [ + { + "title": "Quantum Computing - Wikipedia", + "url": "https://en.wikipedia.org/wiki/Quantum_computing", + "snippet": "Quantum computing is a type of computation...", + "engine": "wikipedia" + } + ], + "meta": { + "query": "quantum computing basics", + "totalResults": 10, + "searchTime": 0.523 + } +} +``` + +### Content Extraction + +```bash +POST /api/v1/extract +Content-Type: application/json + +{ + "url": "https://example.com/article", + "options": { + "includeMarkdown": true + } +} +``` + +Response: +```json +{ + "title": "Article Title", + "content": "Full article text...", + "markdown": "# Article Title\n\nFull article text...", + "metadata": { + "author": "John Doe", + "publishedAt": "2024-01-15" + } +} +``` + +### Bulk Extract + +```bash +POST /api/v1/extract/bulk +Content-Type: application/json + +{ + "urls": [ + "https://example.com/article1", + "https://example.com/article2" + ], + "options": { + "includeMarkdown": true + } +} +``` + +## Search Categories + +| Category | Engines | +|----------|---------| +| `general` | Google, Bing, DuckDuckGo, Brave, Wikipedia | +| `news` | Google News, Bing News | +| `science` | arXiv, Google Scholar, PubMed, Semantic Scholar | +| `it` | GitHub, StackOverflow, NPM, MDN | + +## Usage in Backend + +### Direct Fetch + +```typescript +const response = await fetch('http://mana-search:3021/api/v1/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: 'machine learning basics', + options: { + categories: ['general', 'science'], + limit: 5, + }, + }), +}); + +const { results, meta } = await response.json(); +``` + +### Service Class + +```typescript +// src/services/search.service.ts +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class SearchService { + private readonly baseUrl: string; + + constructor(config: ConfigService) { + this.baseUrl = config.get('MANA_SEARCH_URL'); + } + + async search(query: string, options?: SearchOptions) { + const response = await fetch(`${this.baseUrl}/api/v1/search`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, options }), + }); + + if (!response.ok) { + throw new Error(`Search failed: ${response.status}`); + } + + return response.json(); + } + + async extract(url: string, options?: ExtractOptions) { + const response = await fetch(`${this.baseUrl}/api/v1/extract`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url, options }), + }); + + if (!response.ok) { + throw new Error(`Extract failed: ${response.status}`); + } + + return response.json(); + } +} +``` + +## Caching + +Mana Search uses Redis for caching: + +| Cache | TTL | Purpose | +|-------|-----|---------| +| Search results | 1 hour | Reduce API calls to search engines | +| Extracted content | 24 hours | Cache parsed articles | + + + +## Environment Variables + + + + ```env + MANA_SEARCH_URL=http://localhost:3021 + ``` + + + ```env + SEARXNG_URL=http://localhost:8080 + REDIS_HOST=localhost + REDIS_PORT=6379 + CACHE_SEARCH_TTL=3600 + CACHE_EXTRACT_TTL=86400 + ``` + + + +## Health Check + +```bash +GET /health + +# Response +{ + "status": "ok", + "searxng": "connected", + "redis": "connected" +} +``` + +## Rate Limiting + +The service includes built-in rate limiting: + +- **Search**: 30 requests/minute per IP +- **Extract**: 60 requests/minute per IP +- **Bulk Extract**: 10 requests/minute per IP + +## Error Handling + +```typescript +try { + const results = await searchService.search('query'); +} catch (error) { + if (error.status === 429) { + // Rate limited - wait and retry + } else if (error.status === 503) { + // Search engines unavailable + } +} +``` + +## Best Practices + +1. **Cache aggressively** - Search results rarely change +2. **Use appropriate categories** - More specific = better results +3. **Limit results** - Only fetch what you need +4. **Handle failures gracefully** - External services can be unreliable +5. **Respect rate limits** - Implement backoff strategies diff --git a/apps/docs/src/content/docs/architecture/storage.mdx b/apps/docs/src/content/docs/architecture/storage.mdx new file mode 100644 index 000000000..08621d4d9 --- /dev/null +++ b/apps/docs/src/content/docs/architecture/storage.mdx @@ -0,0 +1,313 @@ +--- +title: Storage +description: S3-compatible object storage for files and media in Manacore. +--- + +import { Tabs, TabItem, Aside } from '@astrojs/starlight/components'; + +# Storage + +Manacore uses S3-compatible object storage for file uploads, generated images, and other media. + +## Architecture + +| Environment | Service | Purpose | +|-------------|---------|---------| +| **Local** | MinIO (Docker) | S3-compatible local storage | +| **Production** | Hetzner Object Storage | Cost-effective cloud storage | + +## Local Development + +```bash +# Start infrastructure (includes MinIO) +pnpm docker:up + +# MinIO Web Console +open http://localhost:9001 + +# Credentials +# Username: minioadmin +# Password: minioadmin +``` + +## Pre-configured Buckets + +| Bucket | Project | Purpose | +|--------|---------|---------| +| `picture-storage` | Picture | AI-generated images | +| `chat-storage` | Chat | User file uploads | +| `manadeck-storage` | ManaDeck | Card/deck assets | +| `nutriphi-storage` | NutriPhi | Meal photos | +| `contacts-storage` | Contacts | Contact avatars | +| `calendar-storage` | Calendar | Event attachments | + +## Usage + +### Backend Integration + +```typescript +import { + createPictureStorage, + generateUserFileKey, + getContentType, +} from '@manacore/shared-storage'; + +const storage = createPictureStorage(); + +// Upload a file +async function uploadImage(userId: string, file: Buffer, filename: string) { + const key = generateUserFileKey(userId, filename); + + const result = await storage.upload(key, file, { + contentType: getContentType(filename), + public: true, + }); + + return result.url; +} + +// Download a file +async function downloadImage(key: string) { + const data = await storage.download(key); + return data; +} + +// Generate presigned URLs +async function getUploadUrl(key: string) { + return storage.getUploadUrl(key, { expiresIn: 3600 }); +} + +async function getDownloadUrl(key: string) { + return storage.getDownloadUrl(key, { expiresIn: 3600 }); +} + +// Delete a file +async function deleteImage(key: string) { + await storage.delete(key); +} + +// List files +async function listUserFiles(userId: string) { + const prefix = `users/${userId}/`; + return storage.list(prefix); +} +``` + +### Factory Functions + +```typescript +import { + createPictureStorage, + createChatStorage, + createManaDeckStorage, + createContactsStorage, +} from '@manacore/shared-storage'; + +// Each creates a client configured for that bucket +const pictureStorage = createPictureStorage(); +const chatStorage = createChatStorage(); +``` + +### Custom Storage Client + +```typescript +import { createStorageClient } from '@manacore/shared-storage'; + +const customStorage = createStorageClient({ + bucket: 'my-custom-bucket', + region: process.env.S3_REGION, + endpoint: process.env.S3_ENDPOINT, + accessKeyId: process.env.S3_ACCESS_KEY, + secretAccessKey: process.env.S3_SECRET_KEY, +}); +``` + +## File Key Patterns + +```typescript +// User-specific files +const key = generateUserFileKey(userId, 'photo.jpg'); +// → users/{userId}/photo.jpg + +// With subfolder +const key = generateUserFileKey(userId, 'avatars/profile.jpg'); +// → users/{userId}/avatars/profile.jpg + +// Public assets +const key = `public/assets/${filename}`; + +// Temporary files +const key = `temp/${Date.now()}-${filename}`; +``` + +## Environment Variables + + + + ```env + S3_ENDPOINT=http://localhost:9000 + S3_REGION=us-east-1 + S3_ACCESS_KEY=minioadmin + S3_SECRET_KEY=minioadmin + ``` + + + ```env + S3_ENDPOINT=https://fsn1.your-objectstorage.com + S3_REGION=fsn1 + S3_ACCESS_KEY=your-access-key + S3_SECRET_KEY=your-secret-key + ``` + + + +## Direct Upload (Presigned URLs) + +For large files, use presigned URLs to upload directly from the client: + +### Backend: Generate URL + +```typescript +@Post('upload-url') +async getUploadUrl( + @CurrentUser() user: CurrentUserData, + @Body() dto: { filename: string; contentType: string } +) { + const key = generateUserFileKey(user.userId, dto.filename); + + const uploadUrl = await storage.getUploadUrl(key, { + expiresIn: 300, // 5 minutes + contentType: dto.contentType, + }); + + return { uploadUrl, key }; +} +``` + +### Client: Upload File + +```typescript +// 1. Get presigned URL from backend +const { uploadUrl, key } = await api.getUploadUrl({ + filename: file.name, + contentType: file.type, +}); + +// 2. Upload directly to S3 +await fetch(uploadUrl, { + method: 'PUT', + body: file, + headers: { + 'Content-Type': file.type, + }, +}); + +// 3. Notify backend of completed upload +await api.confirmUpload({ key }); +``` + +## Public vs Private Files + +### Public Files + +```typescript +// Upload as public (anyone can access) +await storage.upload(key, buffer, { + contentType: 'image/png', + public: true, +}); + +// Access via direct URL +const url = `${S3_ENDPOINT}/${bucket}/${key}`; +``` + +### Private Files + +```typescript +// Upload as private (default) +await storage.upload(key, buffer, { + contentType: 'image/png', +}); + +// Access via presigned URL +const url = await storage.getDownloadUrl(key, { + expiresIn: 3600, // 1 hour +}); +``` + +## Image Processing + +For image uploads, consider processing: + +```typescript +import sharp from 'sharp'; + +async function processAndUpload(userId: string, buffer: Buffer, filename: string) { + // Create thumbnail + const thumbnail = await sharp(buffer) + .resize(200, 200, { fit: 'cover' }) + .jpeg({ quality: 80 }) + .toBuffer(); + + // Create optimized version + const optimized = await sharp(buffer) + .resize(1200, 1200, { fit: 'inside', withoutEnlargement: true }) + .jpeg({ quality: 85 }) + .toBuffer(); + + // Upload both + const [thumbResult, fullResult] = await Promise.all([ + storage.upload( + generateUserFileKey(userId, `thumbs/${filename}`), + thumbnail, + { contentType: 'image/jpeg', public: true } + ), + storage.upload( + generateUserFileKey(userId, filename), + optimized, + { contentType: 'image/jpeg', public: true } + ), + ]); + + return { + thumbnailUrl: thumbResult.url, + fullUrl: fullResult.url, + }; +} +``` + +## Best Practices + + + +## Troubleshooting + +### Connection Refused + +Check MinIO is running: +```bash +docker ps | grep minio +pnpm docker:logs minio +``` + +### Access Denied + +Verify credentials in `.env`: +```bash +echo $S3_ACCESS_KEY +echo $S3_SECRET_KEY +``` + +### Bucket Not Found + +Create the bucket in MinIO console or via CLI: +```bash +docker exec minio mc mb /data/my-bucket +``` diff --git a/apps/docs/src/content/docs/architecture/web.mdx b/apps/docs/src/content/docs/architecture/web.mdx new file mode 100644 index 000000000..449d097ab --- /dev/null +++ b/apps/docs/src/content/docs/architecture/web.mdx @@ -0,0 +1,386 @@ +--- +title: Web (SvelteKit) +description: SvelteKit web application patterns in Manacore. +--- + +import { Aside, Tabs, TabItem } from '@astrojs/starlight/components'; + +# Web Architecture + +All Manacore web applications use **SvelteKit 2** with **Svelte 5** (runes mode) and Tailwind CSS. + +## Project Structure + +``` +apps/{project}/apps/web/ +├── src/ +│ ├── app.html # HTML template +│ ├── app.css # Global styles (Tailwind) +│ ├── lib/ +│ │ ├── components/ # Reusable components +│ │ │ ├── ui/ # Base UI components +│ │ │ └── features/ # Feature-specific components +│ │ ├── stores/ # Svelte stores +│ │ ├── services/ # API clients, auth +│ │ ├── utils/ # Helper functions +│ │ └── types/ # TypeScript types +│ └── routes/ +│ ├── +layout.svelte # Root layout +│ ├── +page.svelte # Home page +│ ├── (auth)/ # Auth route group +│ │ ├── login/ +│ │ └── register/ +│ └── (app)/ # App route group (protected) +│ ├── +layout.svelte +│ ├── dashboard/ +│ └── settings/ +├── static/ # Static assets +├── svelte.config.js +├── vite.config.ts +└── tailwind.config.js +``` + +## Svelte 5 Runes + + + +### State Management + +```svelte + + + +``` + +### Props + +```svelte + + +

{title}

+

Count: {count}

+ +``` + +### Bindings + +```svelte + + + + +``` + +## Routing + +### Route Groups + +Use route groups for layout organization: + +``` +routes/ +├── (marketing)/ # Public pages +│ ├── +layout.svelte # Marketing layout +│ ├── +page.svelte # Landing page +│ └── pricing/ +├── (auth)/ # Auth pages (no sidebar) +│ ├── +layout.svelte +│ ├── login/ +│ └── register/ +└── (app)/ # Protected app pages + ├── +layout.svelte # App layout with sidebar + ├── +layout.server.ts # Auth check + └── dashboard/ +``` + +### Layout Data + +```typescript +// routes/(app)/+layout.server.ts +import { redirect } from '@sveltejs/kit'; +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = async ({ locals }) => { + if (!locals.user) { + throw redirect(302, '/login'); + } + + return { + user: locals.user, + }; +}; +``` + +### Page Data + +```typescript +// routes/(app)/dashboard/+page.server.ts +import type { PageServerLoad } from './$types'; +import { api } from '$lib/services/api'; + +export const load: PageServerLoad = async ({ locals }) => { + const stats = await api.getStats(locals.user.id); + + return { + stats, + }; +}; +``` + +```svelte + + + +

Dashboard

+

Total items: {data.stats.total}

+``` + +## API Integration + +### API Client + +```typescript +// src/lib/services/api.ts +import { PUBLIC_API_URL } from '$env/static/public'; + +class ApiClient { + private baseUrl = PUBLIC_API_URL; + private token: string | null = null; + + setToken(token: string) { + this.token = token; + } + + private async fetch(path: string, options: RequestInit = {}): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(this.token && { Authorization: `Bearer ${this.token}` }), + ...options.headers, + }, + }); + + if (!response.ok) { + throw new Error(`API Error: ${response.status}`); + } + + return response.json(); + } + + async getUsers() { + return this.fetch('/api/v1/users'); + } + + async createUser(data: CreateUserDto) { + return this.fetch('/api/v1/users', { + method: 'POST', + body: JSON.stringify(data), + }); + } +} + +export const api = new ApiClient(); +``` + +## Stores + +Use Svelte stores for global state: + +```typescript +// src/lib/stores/auth.ts +import { writable, derived } from 'svelte/store'; + +interface AuthState { + user: User | null; + token: string | null; + loading: boolean; +} + +function createAuthStore() { + const { subscribe, set, update } = writable({ + user: null, + token: null, + loading: true, + }); + + return { + subscribe, + setUser: (user: User, token: string) => { + update((state) => ({ ...state, user, token, loading: false })); + }, + logout: () => { + set({ user: null, token: null, loading: false }); + }, + setLoading: (loading: boolean) => { + update((state) => ({ ...state, loading })); + }, + }; +} + +export const auth = createAuthStore(); +export const isAuthenticated = derived(auth, ($auth) => !!$auth.user); +``` + +## Components + +### Base Component Pattern + +```svelte + + + + +``` + +### Utility Function + +```typescript +// src/lib/utils/cn.ts +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} +``` + +## Forms + +### Form Actions + +```typescript +// routes/settings/+page.server.ts +import type { Actions } from './$types'; +import { fail } from '@sveltejs/kit'; + +export const actions: Actions = { + updateProfile: async ({ request, locals }) => { + const data = await request.formData(); + const name = data.get('name') as string; + + if (!name || name.length < 2) { + return fail(400, { error: 'Name must be at least 2 characters' }); + } + + await api.updateUser(locals.user.id, { name }); + + return { success: true }; + }, +}; +``` + +```svelte + + + +
+ + + {#if form?.error} +

{form.error}

+ {/if} + + +
+``` + +## Best Practices + +1. **Use runes** - Always use `$state`, `$derived`, `$effect` +2. **Type everything** - Use TypeScript for all components +3. **Server-side data loading** - Use `+page.server.ts` for API calls +4. **Form actions** - Use SvelteKit form actions for mutations +5. **Tailwind** - Use utility classes, avoid custom CSS +6. **Component composition** - Build small, reusable components diff --git a/apps/docs/src/content/docs/deployment/cloudflare-pages.mdx b/apps/docs/src/content/docs/deployment/cloudflare-pages.mdx new file mode 100644 index 000000000..44b4dbf6a --- /dev/null +++ b/apps/docs/src/content/docs/deployment/cloudflare-pages.mdx @@ -0,0 +1,242 @@ +--- +title: Cloudflare Pages +description: Deploying static sites and landing pages to Cloudflare Pages. +--- + +import { Steps, Tabs, TabItem, Aside } from '@astrojs/starlight/components'; + +# Cloudflare Pages + +All landing pages and static sites are deployed to **Cloudflare Pages** using Direct Upload via Wrangler CLI. + +## Deployed Sites + +| Project | Package | Cloudflare Project | URL | +|---------|---------|-------------------|-----| +| Chat | `@chat/landing` | `chat-landing` | chat.manacore.app | +| Picture | `@picture/landing` | `picture-landing` | picture.manacore.app | +| Manacore | `@manacore/landing` | `manacore-landing` | manacore.app | +| ManaDeck | `@manadeck/landing` | `manadeck-landing` | manadeck.manacore.app | +| Zitare | `@zitare/landing` | `zitare-landing` | zitare.manacore.app | +| Docs | `@manacore/docs` | `manacore-docs` | docs.manacore.app | + +## Quick Deploy + +```bash +# Login to Cloudflare (first time only) +pnpm cf:login + +# Deploy a landing page +pnpm deploy:landing:chat +pnpm deploy:landing:picture + +# Deploy documentation +pnpm deploy:docs + +# Deploy all landing pages +pnpm deploy:landing:all +``` + +## Setup Guide + + +1. **Login to Cloudflare** + + ```bash + npx wrangler login + ``` + + This opens a browser to authenticate with your Cloudflare account. + +2. **Create a project** (first time only) + + ```bash + npx wrangler pages project create chat-landing --production-branch=main + ``` + +3. **Configure wrangler.toml** + + Each landing page needs a `wrangler.toml`: + + ```toml + name = "chat-landing" + compatibility_date = "2024-12-01" + pages_build_output_dir = "dist" + ``` + +4. **Deploy** + + ```bash + # Build and deploy + pnpm --filter @chat/landing build + npx wrangler pages deploy apps/chat/apps/landing/dist --project-name=chat-landing + ``` + + +## Project Configuration + +### wrangler.toml + +```toml +# Cloudflare Pages configuration +name = "project-landing" +compatibility_date = "2024-12-01" +pages_build_output_dir = "dist" +``` + +### Deploy Script Pattern + +Each project has a deploy script in root `package.json`: + +```json +{ + "scripts": { + "deploy:landing:chat": "pnpm --filter @chat/landing build && npx wrangler pages deploy apps/chat/apps/landing/dist --project-name=chat-landing" + } +} +``` + +## Custom Domains + +### Add a Custom Domain + +```bash +# Via CLI +npx wrangler pages project add-domain chat-landing chat.manacore.app + +# Or via Cloudflare Dashboard: +# 1. Go to Pages > chat-landing > Custom domains +# 2. Add domain: chat.manacore.app +# 3. Configure DNS (automatic if using Cloudflare DNS) +``` + +### DNS Configuration + +If using Cloudflare DNS (recommended): + +| Type | Name | Content | Proxy | +|------|------|---------|-------| +| CNAME | chat | chat-landing.pages.dev | Proxied | + +If using external DNS: + +| Type | Name | Content | +|------|------|---------| +| CNAME | chat | chat-landing.pages.dev | + +## Environment Variables + +### Build-time Variables + +Set in Cloudflare Dashboard or via `wrangler.toml`: + +```toml +[vars] +PUBLIC_API_URL = "https://api.manacore.app" +``` + +### Via Dashboard + +1. Go to Pages > Project > Settings > Environment variables +2. Add variables for Production and Preview environments + +## Deployment Previews + +Every push to a branch creates a preview deployment: + +- **Production**: `https://chat-landing.pages.dev` +- **Preview**: `https://..pages.dev` + +## Rollback + +### Via CLI + +```bash +# List deployments +npx wrangler pages deployment list --project-name=chat-landing + +# Promote a previous deployment to production +npx wrangler pages deployment promote --project-name=chat-landing +``` + +### Via Dashboard + +1. Go to Pages > Project > Deployments +2. Find the deployment to rollback to +3. Click "..." > "Rollback to this deployment" + +## Adding a New Landing Page + + +1. **Create the landing page** + + ``` + apps/{project}/apps/landing/ + ├── src/ + ├── public/ + ├── astro.config.mjs + ├── wrangler.toml + └── package.json + ``` + +2. **Add wrangler.toml** + + ```toml + name = "{project}-landing" + compatibility_date = "2024-12-01" + pages_build_output_dir = "dist" + ``` + +3. **Create Cloudflare project** + + ```bash + npx wrangler pages project create {project}-landing --production-branch=main + ``` + +4. **Add deploy script to root package.json** + + ```json + "deploy:landing:{project}": "pnpm --filter @{project}/landing build && npx wrangler pages deploy apps/{project}/apps/landing/dist --project-name={project}-landing" + ``` + +5. **Deploy** + + ```bash + pnpm deploy:landing:{project} + ``` + +6. **Add custom domain** (optional) + + ```bash + npx wrangler pages project add-domain {project}-landing {project}.manacore.app + ``` + + +## Troubleshooting + +### "Project not found" + +Create the project first: +```bash +npx wrangler pages project create project-name --production-branch=main +``` + +### Build Fails + +Check the build locally: +```bash +pnpm --filter @project/landing build +``` + +### Domain Not Working + +1. Verify DNS records are correct +2. Check SSL/TLS mode is "Full" in Cloudflare +3. Wait for DNS propagation (up to 24 hours) + + diff --git a/apps/docs/src/content/docs/deployment/mac-mini-server.mdx b/apps/docs/src/content/docs/deployment/mac-mini-server.mdx new file mode 100644 index 000000000..114924d9c --- /dev/null +++ b/apps/docs/src/content/docs/deployment/mac-mini-server.mdx @@ -0,0 +1,316 @@ +--- +title: Mac Mini Server +description: Production server setup and management for Manacore backends. +--- + +import { Steps, Aside, Tabs, TabItem } from '@astrojs/starlight/components'; + +# Mac Mini Server + +The production environment runs on a Mac Mini, accessible via Cloudflare Tunnel. + +## Server Access + +**Domain**: mana.how + +### SSH Configuration + +Add to `~/.ssh/config`: + +``` +Host mana-server + HostName mac-mini.mana.how + User till + ProxyCommand /opt/homebrew/bin/cloudflared access ssh --hostname %h +``` + +### Connect + +```bash +ssh mana-server +``` + + + +## Directory Structure + +``` +~/projects/manacore-monorepo/ +├── docker-compose.macmini.yml # Production compose file +├── .env.production # Production environment +├── scripts/mac-mini/ # Server management scripts +│ ├── status.sh # Check all services +│ ├── deploy.sh # Pull & restart +│ └── health-check.sh # Run health checks +└── ... +``` + +## Common Operations + +### Check Status + +```bash +ssh mana-server +cd ~/projects/manacore-monorepo +./scripts/mac-mini/status.sh +``` + +Output: +``` +=== Service Status === +postgres running (healthy) +redis running (healthy) +mana-auth running (healthy) +chat-backend running (healthy) +... +``` + +### Deploy Updates + +```bash +ssh mana-server +cd ~/projects/manacore-monorepo +./scripts/mac-mini/deploy.sh +``` + +This script: +1. Pulls latest code from git +2. Rebuilds Docker images +3. Restarts containers with zero-downtime + +### View Logs + +```bash +# All services +docker compose -f docker-compose.macmini.yml logs -f + +# Specific service +docker compose -f docker-compose.macmini.yml logs -f chat-backend + +# Last 100 lines +docker compose -f docker-compose.macmini.yml logs --tail=100 mana-auth +``` + +### Restart Services + +```bash +# Single service +docker compose -f docker-compose.macmini.yml restart chat-backend + +# All services +docker compose -f docker-compose.macmini.yml restart + +# Full rebuild +docker compose -f docker-compose.macmini.yml up -d --build +``` + +## Docker Compose Configuration + +### docker-compose.macmini.yml + +```yaml +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: manacore + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U manacore"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + command: redis-server --requirepass ${REDIS_PASSWORD} + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + mana-auth: + build: + context: . + dockerfile: services/mana-core-auth/Dockerfile + environment: + - DATABASE_URL=postgresql://manacore:${POSTGRES_PASSWORD}@postgres:5432/manacore + - REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3001/health"] + interval: 30s + timeout: 10s + retries: 3 + + chat-backend: + build: + context: . + dockerfile: apps/chat/apps/backend/Dockerfile + environment: + - DATABASE_URL=postgresql://manacore:${POSTGRES_PASSWORD}@postgres:5432/chat + - MANA_CORE_AUTH_URL=http://mana-auth:3001 + depends_on: + - mana-auth + - postgres + +volumes: + postgres_data: + redis_data: +``` + +## Health Checks + +### Run Health Checks + +```bash +./scripts/mac-mini/health-check.sh +``` + +### Check Individual Service + +```bash +# Auth service +curl http://localhost:3001/health + +# Chat backend +curl http://localhost:3002/api/v1/health + +# From outside via tunnel +curl https://api.mana.how/health +``` + +## Cloudflare Tunnel + +### Configuration + +The tunnel routes traffic from Cloudflare to local services: + +```yaml +# ~/.cloudflared/config.yml +tunnel: mana-tunnel +credentials-file: ~/.cloudflared/mana-tunnel.json + +ingress: + - hostname: api.mana.how + service: http://localhost:3001 + - hostname: chat-api.mana.how + service: http://localhost:3002 + - hostname: mac-mini.mana.how + service: ssh://localhost:22 + - service: http_status:404 +``` + +### Manage Tunnel + +```bash +# Check tunnel status +cloudflared tunnel info mana-tunnel + +# Restart tunnel +sudo launchctl stop com.cloudflare.cloudflared +sudo launchctl start com.cloudflare.cloudflared + +# View tunnel logs +tail -f /var/log/cloudflared.log +``` + +## Backup & Recovery + +### Database Backup + +```bash +# Manual backup +docker exec postgres pg_dump -U manacore manacore > backup_$(date +%Y%m%d).sql + +# Automated daily backups (via cron) +0 2 * * * /home/till/scripts/backup-databases.sh +``` + +### Restore Database + +```bash +# Stop services +docker compose -f docker-compose.macmini.yml stop chat-backend + +# Restore +docker exec -i postgres psql -U manacore manacore < backup_20240115.sql + +# Start services +docker compose -f docker-compose.macmini.yml start chat-backend +``` + +## Monitoring + +### Resource Usage + +```bash +# Container stats +docker stats + +# Disk usage +docker system df + +# System resources +htop +``` + +### Alerts + + + +## Troubleshooting + +### Container Won't Start + +```bash +# Check logs +docker compose -f docker-compose.macmini.yml logs service-name + +# Check container status +docker ps -a + +# Restart with fresh build +docker compose -f docker-compose.macmini.yml up -d --build service-name +``` + +### Out of Disk Space + +```bash +# Check disk usage +df -h + +# Clean Docker +docker system prune -a --volumes + +# Remove old images +docker image prune -a +``` + +### Database Connection Issues + +```bash +# Check postgres is running +docker compose -f docker-compose.macmini.yml ps postgres + +# Test connection +docker exec -it postgres psql -U manacore -c "SELECT 1" + +# Check logs +docker compose -f docker-compose.macmini.yml logs postgres +``` diff --git a/apps/docs/src/content/docs/deployment/overview.mdx b/apps/docs/src/content/docs/deployment/overview.mdx new file mode 100644 index 000000000..b212863e9 --- /dev/null +++ b/apps/docs/src/content/docs/deployment/overview.mdx @@ -0,0 +1,151 @@ +--- +title: Deployment Overview +description: Deployment strategies and infrastructure for Manacore applications. +--- + +import { Card, CardGrid, Aside } from '@astrojs/starlight/components'; + +# Deployment Overview + +Manacore uses multiple deployment strategies depending on the application type. + +## Deployment Targets + + + + Static sites: Landing pages, documentation + + + Docker containers: Backends, databases + + + S3-compatible object storage for files + + + +## Application Types + +| Type | Deployment Target | Method | +|------|-------------------|--------| +| Landing Pages (Astro) | Cloudflare Pages | Direct Upload | +| Web Apps (SvelteKit) | Cloudflare Pages | Adapter | +| Backends (NestJS) | Mac Mini / Docker | Docker Compose | +| Databases | Mac Mini / Docker | Docker Compose | +| Static Assets | Hetzner S3 | Direct Upload | + +## Infrastructure Overview + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Cloudflare │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ chat.mana │ │ docs.mana │ │ picture.mana│ ... │ +│ │ core.app │ │ core.app │ │ core.app │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ │ │ │ +│ └────────────────┼────────────────┘ │ +│ │ │ +│ Cloudflare Tunnel │ +└──────────────────────────┼──────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Mac Mini Server │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ mana-core- │ │ chat- │ │ zitare- │ ... │ +│ │ auth:3001 │ │ backend:3002│ │ backend:3007│ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ │ │ │ +│ └────────────────┼────────────────┘ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ PostgreSQL:5432 │ Redis:6379 │ MinIO:9000 │ │ +│ └─────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Deployment Commands + +### Landing Pages + +```bash +# Deploy individual landing page +pnpm deploy:landing:chat +pnpm deploy:landing:picture +pnpm deploy:landing:zitare + +# Deploy all landing pages +pnpm deploy:landing:all +``` + +### Documentation + +```bash +# Deploy documentation site +pnpm deploy:docs +``` + +### Backends (via SSH) + +```bash +# Connect to server +ssh mana-server + +# Pull latest changes +cd ~/projects/manacore-monorepo +git pull + +# Restart services +./scripts/mac-mini/deploy.sh +``` + +## Environments + +| Environment | Purpose | URL Pattern | +|-------------|---------|-------------| +| **Development** | Local testing | `localhost:*` | +| **Staging** | Pre-production testing | `staging.*.manacore.app` | +| **Production** | Live users | `*.manacore.app` | + +## CI/CD + + + +### Planned Pipeline + +1. **On PR**: Run tests, type-check, lint +2. **On merge to main**: Deploy to staging +3. **On release tag**: Deploy to production + +## Rollback Procedures + +### Cloudflare Pages + +```bash +# View deployments +npx wrangler pages deployment list --project-name=chat-landing + +# Rollback to previous +npx wrangler pages deployment tail --project-name=chat-landing +``` + +### Docker Services + +```bash +ssh mana-server +cd ~/projects/manacore-monorepo + +# Revert to previous commit +git checkout HEAD~1 + +# Rebuild and restart +./scripts/mac-mini/deploy.sh +``` + +## Next Steps + +- [Cloudflare Pages](/deployment/cloudflare-pages) - Static site deployment +- [Mac Mini Server](/deployment/mac-mini-server) - Backend deployment +- [Self-Hosting](/deployment/self-hosting) - Host your own instance diff --git a/apps/docs/src/content/docs/deployment/self-hosting.mdx b/apps/docs/src/content/docs/deployment/self-hosting.mdx new file mode 100644 index 000000000..ae647cd0a --- /dev/null +++ b/apps/docs/src/content/docs/deployment/self-hosting.mdx @@ -0,0 +1,322 @@ +--- +title: Self-Hosting +description: Host your own Manacore instance with Docker Compose. +--- + +import { Steps, Aside, Tabs, TabItem } from '@astrojs/starlight/components'; + +# Self-Hosting + +Run your own Manacore instance using Docker Compose. + +## Requirements + +- **Docker** 24.0+ +- **Docker Compose** v2.0+ +- **4GB RAM** minimum (8GB recommended) +- **20GB disk space** minimum +- **Domain name** (optional, for HTTPS) + +## Quick Start + + +1. **Clone the repository** + + ```bash + git clone https://github.com/manacore/manacore-monorepo.git + cd manacore-monorepo + ``` + +2. **Create environment file** + + ```bash + cp .env.example .env.production + ``` + + Edit `.env.production` with your settings: + + ```env + # Database + POSTGRES_USER=manacore + POSTGRES_PASSWORD=your-secure-password + + # Redis + REDIS_PASSWORD=your-redis-password + + # JWT (generate new keys!) + JWT_PRIVATE_KEY=your-private-key + JWT_PUBLIC_KEY=your-public-key + + # Domain + DOMAIN=your-domain.com + ``` + +3. **Start services** + + ```bash + docker compose -f docker-compose.production.yml up -d + ``` + +4. **Verify deployment** + + ```bash + # Check services + docker compose -f docker-compose.production.yml ps + + # Check health + curl http://localhost:3001/health + ``` + + +## Configuration + +### docker-compose.production.yml + +```yaml +version: '3.8' + +services: + # Database + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + - ./docker/init-db:/docker-entrypoint-initdb.d + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] + interval: 10s + timeout: 5s + retries: 5 + + # Cache + redis: + image: redis:7-alpine + command: redis-server --requirepass ${REDIS_PASSWORD} + volumes: + - redis_data:/data + restart: unless-stopped + + # Object Storage + minio: + image: minio/minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${S3_ACCESS_KEY} + MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY} + volumes: + - minio_data:/data + restart: unless-stopped + + # Auth Service + mana-auth: + image: ghcr.io/manacore/mana-core-auth:latest + environment: + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/manacore + REDIS_HOST: redis + REDIS_PASSWORD: ${REDIS_PASSWORD} + JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} + JWT_PUBLIC_KEY: ${JWT_PUBLIC_KEY} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + restart: unless-stopped + + # Chat Backend + chat-backend: + image: ghcr.io/manacore/chat-backend:latest + environment: + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/chat + MANA_CORE_AUTH_URL: http://mana-auth:3001 + JWT_PUBLIC_KEY: ${JWT_PUBLIC_KEY} + depends_on: + - mana-auth + restart: unless-stopped + +volumes: + postgres_data: + redis_data: + minio_data: +``` + +## Generate JWT Keys + +Manacore uses EdDSA (Ed25519) for JWT signing: + +```bash +# Generate private key +openssl genpkey -algorithm ed25519 -out private.pem + +# Extract public key +openssl pkey -in private.pem -pubout -out public.pem + +# Convert to base64 for .env +echo "JWT_PRIVATE_KEY=$(cat private.pem | base64 -w 0)" +echo "JWT_PUBLIC_KEY=$(cat public.pem | base64 -w 0)" +``` + +## Reverse Proxy Setup + +### Nginx + +```nginx +server { + listen 80; + server_name api.your-domain.com; + return 301 https://$server_name$request_uri; +} + +server { + listen 443 ssl http2; + server_name api.your-domain.com; + + ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; + + location / { + proxy_pass http://localhost:3001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +### Caddy + +``` +api.your-domain.com { + reverse_proxy localhost:3001 +} + +chat-api.your-domain.com { + reverse_proxy localhost:3002 +} +``` + +## SSL with Let's Encrypt + +```bash +# Install certbot +apt install certbot python3-certbot-nginx + +# Get certificates +certbot --nginx -d api.your-domain.com -d chat-api.your-domain.com + +# Auto-renewal (added automatically) +certbot renew --dry-run +``` + +## Backup Strategy + +### Daily Database Backup + +Create `/etc/cron.daily/manacore-backup`: + +```bash +#!/bin/bash +BACKUP_DIR=/var/backups/manacore +DATE=$(date +%Y%m%d) + +# Create backup directory +mkdir -p $BACKUP_DIR + +# Backup all databases +docker exec postgres pg_dumpall -U manacore > $BACKUP_DIR/all_$DATE.sql + +# Compress +gzip $BACKUP_DIR/all_$DATE.sql + +# Keep last 7 days +find $BACKUP_DIR -name "*.gz" -mtime +7 -delete +``` + +### Restore from Backup + +```bash +# Stop services +docker compose -f docker-compose.production.yml stop + +# Restore +gunzip -c /var/backups/manacore/all_20240115.sql.gz | docker exec -i postgres psql -U manacore + +# Start services +docker compose -f docker-compose.production.yml start +``` + +## Updating + + +1. **Pull latest images** + + ```bash + docker compose -f docker-compose.production.yml pull + ``` + +2. **Backup database** (always!) + + ```bash + docker exec postgres pg_dumpall -U manacore > backup_before_update.sql + ``` + +3. **Restart services** + + ```bash + docker compose -f docker-compose.production.yml up -d + ``` + +4. **Verify** + + ```bash + docker compose -f docker-compose.production.yml ps + curl http://localhost:3001/health + ``` + + +## Troubleshooting + +### Services Not Starting + +```bash +# Check logs +docker compose -f docker-compose.production.yml logs + +# Check specific service +docker compose -f docker-compose.production.yml logs mana-auth +``` + +### Database Connection Failed + +```bash +# Verify postgres is running +docker compose -f docker-compose.production.yml ps postgres + +# Check connection +docker exec -it postgres psql -U manacore -c "SELECT 1" +``` + +### Out of Memory + +Increase Docker memory limits or add swap: + +```bash +# Add 2GB swap +fallocate -l 2G /swapfile +chmod 600 /swapfile +mkswap /swapfile +swapon /swapfile +echo '/swapfile none swap sw 0 0' >> /etc/fstab +``` + + diff --git a/apps/docs/src/content/docs/development/database-migrations.mdx b/apps/docs/src/content/docs/development/database-migrations.mdx new file mode 100644 index 000000000..42d17e67f --- /dev/null +++ b/apps/docs/src/content/docs/development/database-migrations.mdx @@ -0,0 +1,274 @@ +--- +title: Database Migrations +description: Managing database schemas with Drizzle ORM in the Manacore monorepo. +--- + +import { Aside, Steps, Tabs, TabItem } from '@astrojs/starlight/components'; + +# Database Migrations + +Manacore uses **Drizzle ORM** for database management with PostgreSQL. + +## Overview + +Each backend service has its own database and schema: + +| Service | Database | Port | +|---------|----------|------| +| mana-core-auth | manacore | 3001 | +| chat | chat | 3002 | +| zitare | zitare | 3007 | +| contacts | contacts | 3015 | +| calendar | calendar | 3014 | + +## Quick Commands + +```bash +# Push schema changes (development) +pnpm --filter @chat/backend db:push + +# Generate migration files +pnpm --filter @chat/backend db:generate + +# Run migrations +pnpm --filter @chat/backend db:migrate + +# Open Drizzle Studio (database GUI) +pnpm --filter @chat/backend db:studio +``` + +## Development Workflow + +For local development, use `db:push` to quickly sync schema changes: + + +1. **Modify your schema** + + Edit the schema file (e.g., `src/drizzle/schema.ts`): + + ```typescript + export const users = pgTable('users', { + id: uuid('id').primaryKey().defaultRandom(), + email: varchar('email', { length: 255 }).notNull().unique(), + name: varchar('name', { length: 255 }), + createdAt: timestamp('created_at').defaultNow(), + // Add new field + avatarUrl: varchar('avatar_url', { length: 500 }), + }); + ``` + +2. **Push changes** + + ```bash + pnpm --filter @chat/backend db:push + ``` + +3. **Verify in Studio** + + ```bash + pnpm --filter @chat/backend db:studio + ``` + + + + +## Production Migrations + +For production, use proper migration files: + + +1. **Generate migration** + + ```bash + pnpm --filter @chat/backend db:generate + ``` + + This creates a timestamped SQL file in `drizzle/migrations/`. + +2. **Review migration** + + Check the generated SQL: + + ```sql + -- 0001_add_avatar_url.sql + ALTER TABLE "users" ADD COLUMN "avatar_url" varchar(500); + ``` + +3. **Run migration** + + ```bash + pnpm --filter @chat/backend db:migrate + ``` + + +## Schema Patterns + +### Basic Table + +```typescript +import { pgTable, uuid, varchar, timestamp, boolean } from 'drizzle-orm/pg-core'; + +export const posts = pgTable('posts', { + id: uuid('id').primaryKey().defaultRandom(), + title: varchar('title', { length: 255 }).notNull(), + content: text('content'), + published: boolean('published').default(false), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at').defaultNow(), +}); +``` + +### Foreign Keys + +```typescript +export const comments = pgTable('comments', { + id: uuid('id').primaryKey().defaultRandom(), + postId: uuid('post_id') + .notNull() + .references(() => posts.id, { onDelete: 'cascade' }), + userId: uuid('user_id') + .notNull() + .references(() => users.id), + content: text('content').notNull(), + createdAt: timestamp('created_at').defaultNow(), +}); +``` + +### Indexes + +```typescript +import { pgTable, uuid, varchar, index } from 'drizzle-orm/pg-core'; + +export const users = pgTable('users', { + id: uuid('id').primaryKey().defaultRandom(), + email: varchar('email', { length: 255 }).notNull().unique(), + organizationId: uuid('organization_id'), +}, (table) => ({ + emailIdx: index('users_email_idx').on(table.email), + orgIdx: index('users_org_idx').on(table.organizationId), +})); +``` + +### Enums + +```typescript +import { pgEnum } from 'drizzle-orm/pg-core'; + +export const roleEnum = pgEnum('role', ['user', 'admin', 'moderator']); + +export const users = pgTable('users', { + id: uuid('id').primaryKey().defaultRandom(), + role: roleEnum('role').default('user'), +}); +``` + +## Drizzle Configuration + +Each backend has a `drizzle.config.ts`: + +```typescript +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/drizzle/schema.ts', + out: './drizzle/migrations', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL!, + }, + verbose: true, + strict: true, +}); +``` + +## Connecting in Services + +### NestJS Module + +```typescript +import { DrizzleModule } from '@manacore/shared-drizzle'; + +@Module({ + imports: [ + DrizzleModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (config: ConfigService) => ({ + connectionString: config.get('DATABASE_URL'), + }), + inject: [ConfigService], + }), + ], +}) +export class AppModule {} +``` + +### Using in Services + +```typescript +import { Inject, Injectable } from '@nestjs/common'; +import { DRIZZLE } from '@manacore/shared-drizzle'; +import { eq } from 'drizzle-orm'; +import { users } from '../drizzle/schema'; + +@Injectable() +export class UsersService { + constructor(@Inject(DRIZZLE) private db: DrizzleDB) {} + + async findById(id: string) { + const [user] = await this.db + .select() + .from(users) + .where(eq(users.id, id)); + return user; + } + + async create(data: { email: string; name: string }) { + const [user] = await this.db + .insert(users) + .values(data) + .returning(); + return user; + } +} +``` + +## Troubleshooting + +### "relation does not exist" + +Schema not pushed: + +```bash +pnpm --filter @chat/backend db:push --force +``` + +### Migration conflicts + +Reset and regenerate: + +```bash +# Delete old migrations +rm -rf apps/chat/apps/backend/drizzle/migrations/* + +# Regenerate +pnpm --filter @chat/backend db:generate +``` + +### Connection refused + +Check PostgreSQL is running: + +```bash +pnpm docker:up +docker ps | grep postgres +``` + +## Best Practices + +1. **Always review generated migrations** before running in production +2. **Use transactions** for multi-step operations +3. **Test migrations** on a copy of production data +4. **Keep schemas in sync** across services that share data +5. **Use `db:push` for development**, `db:migrate` for production diff --git a/apps/docs/src/content/docs/development/docker.mdx b/apps/docs/src/content/docs/development/docker.mdx new file mode 100644 index 000000000..a849e8259 --- /dev/null +++ b/apps/docs/src/content/docs/development/docker.mdx @@ -0,0 +1,292 @@ +--- +title: Docker Setup +description: Working with Docker for local development and production in Manacore. +--- + +import { Tabs, TabItem, Aside, Steps } from '@astrojs/starlight/components'; + +# Docker Setup + +Manacore uses Docker for local development services and production deployment. + +## Overview + +Docker provides: + +- **Development**: Local PostgreSQL, Redis, MinIO (S3-compatible storage) +- **CI/CD**: Automated builds and tests +- **Production**: Container deployment + +## Quick Start + +```bash +# Start all development infrastructure +pnpm docker:up + +# View logs +pnpm docker:logs + +# Stop services +pnpm docker:down + +# Complete reset (removes volumes) +pnpm docker:clean +``` + +## Services Included + +| Service | Port | Purpose | +|---------|------|---------| +| PostgreSQL | 5432 | Database | +| Redis | 6379 | Caching, sessions | +| MinIO | 9000 (API), 9001 (Console) | S3-compatible storage | + +### MinIO Access + +- **Console**: http://localhost:9001 +- **Username**: `minioadmin` +- **Password**: `minioadmin` + +## Docker Templates + +Templates are in `docker/templates/` for creating new Dockerfiles: + +### NestJS Backend + +```bash +# Copy template +cp docker/templates/Dockerfile.nestjs apps/myproject/apps/backend/Dockerfile +``` + +Build arguments: +- `SERVICE_PATH`: Path to service +- `PORT`: Service port +- `HEALTH_PATH`: Health check endpoint + +```bash +docker build \ + --build-arg SERVICE_PATH=apps/chat/apps/backend \ + --build-arg PORT=3002 \ + --build-arg HEALTH_PATH=/api/health \ + -t chat-backend:latest \ + -f docker/templates/Dockerfile.nestjs \ + . +``` + +### SvelteKit Web + +```bash +docker build \ + --build-arg SERVICE_PATH=apps/chat/apps/web \ + --build-arg PORT=3000 \ + -t chat-web:latest \ + -f docker/templates/Dockerfile.sveltekit \ + . +``` + +### Astro Landing + +Static site served with Nginx: + +```bash +docker build \ + --build-arg SERVICE_PATH=apps/chat/apps/landing \ + -t chat-landing:latest \ + -f docker/templates/Dockerfile.astro \ + . +``` + +## Building Images + +### Development Build + +```bash +# Build single service +docker build -t service-name:dev -f apps/project/apps/service/Dockerfile . + +# Build with cache +docker build --cache-from service-name:latest -t service-name:dev . +``` + +### Production Build + +```bash +# Multi-platform build +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + -t service-name:latest \ + . +``` + +## Running Containers + +```bash +# Run with environment file +docker run -d \ + --name chat-backend \ + --env-file .env.production \ + -p 3002:3002 \ + chat-backend:latest + +# View logs +docker logs -f chat-backend + +# Execute shell in container +docker exec -it chat-backend sh +``` + +## Docker Compose + +### Development + +```bash +# Start services +pnpm docker:up + +# View status +docker compose -f docker-compose.dev.yml ps + +# View specific service logs +docker compose -f docker-compose.dev.yml logs -f postgres +``` + +### Production + +```bash +# Deploy to production +docker compose -f docker-compose.production.yml up -d + +# Rolling update +docker compose -f docker-compose.production.yml up -d --no-deps service-name + +# Scale service +docker compose up -d --scale service=3 service +``` + +## Best Practices + +### 1. Optimize Layer Caching + +```dockerfile +# Good - cache dependencies +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install +COPY . . + +# Bad - invalidates cache on every change +COPY . . +RUN pnpm install +``` + +### 2. Use Multi-Stage Builds + +```dockerfile +# Build stage +FROM node:20-alpine AS builder +WORKDIR /app +COPY . . +RUN pnpm install && pnpm build + +# Production stage +FROM node:20-alpine AS production +COPY --from=builder /app/dist ./dist +CMD ["node", "dist/main.js"] +``` + +### 3. Security + +```dockerfile +# Use non-root user +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nestjs -u 1001 +USER nestjs +``` + +### 4. Health Checks + +```dockerfile +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 +``` + +### 5. Resource Limits + +```yaml +services: + backend: + deploy: + resources: + limits: + cpus: '1' + memory: 512M +``` + +## Troubleshooting + +### Container Won't Start + +```bash +# View logs +docker logs container-name + +# Check exit code +docker inspect --format='{{.State.ExitCode}}' container-name + +# Run interactively +docker run -it --rm image-name sh +``` + +### Out of Disk Space + +```bash +# Check usage +docker system df + +# Clean everything unused +docker system prune -a --volumes +``` + +### Network Issues + +```bash +# List networks +docker network ls + +# Test connectivity +docker exec container1 ping container2 +``` + +### Permission Issues + +```bash +# Check ownership +docker exec container-name ls -la /app + +# Fix in Dockerfile +RUN chown -R nodejs:nodejs /app +``` + +## Advanced + +### BuildKit + +```bash +export DOCKER_BUILDKIT=1 +docker build . +``` + +### Custom Networks + +```bash +docker network create --driver bridge my-network +docker run --network my-network image-name +``` + +### Volume Backup + +```bash +docker run --rm \ + -v my-data:/data \ + -v $(pwd):/backup \ + alpine tar czf /backup/backup.tar.gz /data +``` diff --git a/apps/docs/src/content/docs/development/environment-variables.mdx b/apps/docs/src/content/docs/development/environment-variables.mdx new file mode 100644 index 000000000..a9c22a53c --- /dev/null +++ b/apps/docs/src/content/docs/development/environment-variables.mdx @@ -0,0 +1,185 @@ +--- +title: Environment Variables +description: Centralized environment variable management in the Manacore monorepo. +--- + +import { Tabs, TabItem, Aside, Steps } from '@astrojs/starlight/components'; + +# Environment Variables + +Manacore uses a centralized environment variable system. All development variables are managed from a single file. + +## Quick Start + +```bash +# After cloning, install dependencies (auto-generates .env files) +pnpm install + +# Or manually generate .env files +pnpm setup:env +``` + +That's it! All app-specific `.env` files are generated from `.env.development`. + +## How It Works + +``` +.env.development # Central config (committed) + │ + ▼ +scripts/generate-env.mjs # Transforms variables + │ + ▼ +apps/**/apps/**/.env # Generated files (gitignored) +``` + +The generator reads `.env.development` and creates app-specific `.env` files with the correct prefixes for each platform: + +| Platform | Prefix | Example | +|----------|--------|---------| +| Expo (mobile) | `EXPO_PUBLIC_` | `EXPO_PUBLIC_SUPABASE_URL` | +| SvelteKit (web) | `PUBLIC_` | `PUBLIC_SUPABASE_URL` | +| NestJS (backend) | None | `SUPABASE_URL` | + +## File Locations + +### Source File +- **`.env.development`** - Single source of truth, committed to git + +### Generated Files (gitignored) +- `services/mana-core-auth/.env` +- `apps/chat/apps/backend/.env` +- `apps/chat/apps/mobile/.env` +- `apps/chat/apps/web/.env` +- And more... + +## Variable Reference + +### Shared Variables + +| Variable | Description | Used By | +|----------|-------------|---------| +| `MANA_CORE_AUTH_URL` | Auth service URL | All apps | +| `JWT_PRIVATE_KEY` | JWT signing key | mana-core-auth | +| `JWT_PUBLIC_KEY` | JWT verification key | All backends | +| `POSTGRES_USER` | Database user | Docker, backends | +| `POSTGRES_PASSWORD` | Database password | Docker, backends | +| `REDIS_HOST` | Redis host | mana-core-auth | +| `REDIS_PORT` | Redis port | mana-core-auth | + +### Mana Core Auth Service + +| Variable | Description | Default | +|----------|-------------|---------| +| `MANA_CORE_AUTH_PORT` | Service port | `3001` | +| `MANA_CORE_AUTH_DATABASE_URL` | PostgreSQL connection | - | +| `JWT_ACCESS_TOKEN_EXPIRY` | Access token TTL | `15m` | +| `JWT_REFRESH_TOKEN_EXPIRY` | Refresh token TTL | `7d` | +| `JWT_ISSUER` | JWT issuer claim | `manacore` | +| `JWT_AUDIENCE` | JWT audience claim | `manacore` | +| `STRIPE_SECRET_KEY` | Stripe secret key | - | +| `CREDITS_SIGNUP_BONUS` | Credits on signup | `150` | +| `CREDITS_DAILY_FREE` | Daily free credits | `5` | + +### Project-Specific Variables + + + + | Variable | Description | + |----------|-------------| + | `CHAT_BACKEND_PORT` | Backend port (3002) | + | `CHAT_DATABASE_URL` | PostgreSQL connection | + | `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint | + | `AZURE_OPENAI_API_KEY` | Azure OpenAI key | + + + | Variable | Description | + |----------|-------------| + | `PICTURE_BACKEND_PORT` | Backend port (3006) | + | `REPLICATE_API_KEY` | Replicate AI key | + + + | Variable | Description | + |----------|-------------| + | `ZITARE_BACKEND_PORT` | Backend port (3007) | + | `ZITARE_DATABASE_URL` | PostgreSQL connection | + + + +## Adding New Variables + + +1. **Add to `.env.development`** + + ```bash + MY_NEW_PROJECT_API_KEY=your-api-key + MY_NEW_PROJECT_URL=https://api.example.com + ``` + +2. **Update the Generator Script** + + Edit `scripts/generate-env.mjs`: + + ```javascript + { + path: 'apps/my-project/apps/backend/.env', + vars: { + API_KEY: (env) => env.MY_NEW_PROJECT_API_KEY, + API_URL: (env) => env.MY_NEW_PROJECT_URL, + }, + }, + ``` + +3. **Regenerate** + + ```bash + pnpm setup:env + ``` + + +## Local Overrides + +If you need to override variables locally: + +1. The generated `.env` files are gitignored +2. You can manually edit them after generation +3. Or create `.env.local` files (also gitignored) that some frameworks auto-load + + + +## Docker Integration + +The root `.env.development` is also used by Docker Compose: + +```bash +# Start all services with shared env +pnpm docker:up +``` + +## Troubleshooting + +### "Variable is undefined" Error + +1. Check if the variable exists in `.env.development` +2. Run `pnpm setup:env` to regenerate +3. Restart your dev server (env changes require restart) + +### Expo Not Picking Up Changes + +Expo caches environment variables. Clear the cache: + +```bash +cd apps//apps/mobile +npx expo start -c +``` + +## Security Notes + + diff --git a/apps/docs/src/content/docs/development/local-development.mdx b/apps/docs/src/content/docs/development/local-development.mdx new file mode 100644 index 000000000..fd9837854 --- /dev/null +++ b/apps/docs/src/content/docs/development/local-development.mdx @@ -0,0 +1,227 @@ +--- +title: Local Development +description: Set up and run Manacore applications locally with automatic database setup. +--- + +import { Tabs, TabItem, Steps, Aside } from '@astrojs/starlight/components'; + +# Local Development + +This guide explains how to set up and run applications locally with automatic database setup. + +## Quick Start + +For any project with a backend, use the `dev:*:full` command: + +```bash +pnpm dev:chat:full # Start chat with auth + database setup +pnpm dev:zitare:full # Start zitare with auth + database setup +pnpm dev:contacts:full # Start contacts with auth + database setup +``` + +These commands automatically: +1. Create the database if it doesn't exist +2. Push the latest schema (Drizzle `db:push`) +3. Start the auth service (mana-core-auth) +4. Start the backend and web app with colored output + +## Available Full Dev Commands + +| Command | Database | Backend Port | Web Port | +|---------|----------|--------------|----------| +| `pnpm dev:chat:full` | chat | 3002 | 5173 | +| `pnpm dev:zitare:full` | zitare | 3007 | 5177 | +| `pnpm dev:contacts:full` | contacts | 3015 | 5184 | +| `pnpm dev:calendar:full` | calendar | 3014 | 5179 | +| `pnpm dev:clock:full` | clock | 3017 | 5187 | +| `pnpm dev:todo:full` | todo | 3018 | 5188 | +| `pnpm dev:picture:full` | picture | 3006 | 5175 | + +## Prerequisites + +Before running any `dev:*:full` command: + + +1. **Start Docker infrastructure** + + This starts PostgreSQL, Redis, and MinIO: + ```bash + pnpm docker:up + ``` + +2. **Generate environment files** + + This runs automatically on `pnpm install`, but you can run manually: + ```bash + pnpm setup:env + ``` + + +## Database Setup Commands + +### Individual Service Setup + +```bash +pnpm setup:db:auth # Setup mana-core-auth database + schema +pnpm setup:db:chat # Setup chat database + schema +pnpm setup:db:zitare # Setup zitare database + schema +pnpm setup:db:contacts # Setup contacts database + schema +pnpm setup:db:calendar # Setup calendar database + schema +pnpm setup:db:clock # Setup clock database + schema +pnpm setup:db:todo # Setup todo database + schema +pnpm setup:db:picture # Setup picture database + schema +``` + +### Setup All Databases + +```bash +pnpm setup:db # Creates ALL databases and pushes ALL schemas +``` + +This is useful when setting up a fresh environment or after pulling new schema changes. + +## How It Works + +### Docker Init Script + +On first `pnpm docker:up`, the PostgreSQL container runs `docker/init-db/01-create-databases.sql` which creates all databases: + +- manacore, chat, zitare, contacts, calendar, clock, todo, manadeck +- storage, mail, moodlit, finance, inventory, techbase, voxel_lava, figgos + +### Setup Script + +The `scripts/setup-databases.sh` script: + +1. **Creates database** if it doesn't exist (using `psql`) +2. **Pushes schema** using `drizzle-kit push --force` + +The `--force` flag auto-approves schema changes without interactive prompts. + +## Apps Without Full Commands + +Some apps don't have backends or don't use Drizzle: + +| App | Reason | +|-----|--------| +| manacore | No backend (uses other services) | +| manadeck | Backend exists but no db:push | + +For these, use the regular dev commands: + +```bash +pnpm dev:manacore:web +pnpm dev:manadeck:app +``` + +## Troubleshooting + +### Database doesn't exist + +If you see `database "xxx" does not exist`: + + + + ```bash + pnpm setup:db:chat # or whichever service + ``` + + + ```bash + PGPASSWORD=devpassword psql -h localhost -U manacore -d postgres -c "CREATE DATABASE chat;" + ``` + + + +### Schema out of date + +If you see errors about missing tables/columns: + +```bash +# Push the latest schema +pnpm --filter @chat/backend db:push --force +``` + +### Port already in use + +If auth (port 3001) is already running: + +```bash +# Check what's using the port +lsof -i :3001 + +# Kill the process if needed +kill +``` + +### Fresh Start (Nuclear Option) + +To completely reset all databases: + +```bash +# Stop and remove all containers + volumes +pnpm docker:clean + +# Start fresh +pnpm docker:up + +# Setup all databases +pnpm setup:db +``` + +## Adding a New Application + + + +### Step 1: Create Project Structure + +``` +apps/newproject/ +├── apps/ +│ ├── backend/ # NestJS API (if needed) +│ ├── mobile/ # Expo React Native app +│ ├── web/ # SvelteKit web app +│ └── landing/ # Astro marketing page +├── packages/ # Project-specific shared code +├── package.json # Workspace root +└── CLAUDE.md # Project documentation +``` + +### Step 2: Configure Backend Database + +If your backend uses Drizzle ORM: + +1. **Add database to Docker init** (`docker/init-db/01-create-databases.sql`): + ```sql + CREATE DATABASE IF NOT EXISTS newproject; + GRANT ALL PRIVILEGES ON DATABASE newproject TO manacore; + ``` + +2. **Add DATABASE_URL to `.env.development`**: + ```env + NEWPROJECT_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/newproject + ``` + +3. **Update `scripts/generate-env.mjs`** to generate the backend `.env` file. + +### Step 3: Add Package.json Scripts + +Add to root `package.json`: + +```json +{ + "scripts": { + "dev:newproject:full": "./scripts/setup-databases.sh newproject && ./scripts/setup-databases.sh auth && concurrently \"pnpm dev:auth\" \"pnpm dev:newproject:backend\" \"pnpm dev:newproject:web\"", + "setup:db:newproject": "./scripts/setup-databases.sh newproject" + } +} +``` + +### Step 4: Test + +```bash +pnpm setup:db:newproject +pnpm dev:newproject:full +``` diff --git a/apps/docs/src/content/docs/development/testing.mdx b/apps/docs/src/content/docs/development/testing.mdx new file mode 100644 index 000000000..8a2e40225 --- /dev/null +++ b/apps/docs/src/content/docs/development/testing.mdx @@ -0,0 +1,285 @@ +--- +title: Testing +description: Testing patterns and practices in the Manacore monorepo. +--- + +import { Tabs, TabItem, Aside } from '@astrojs/starlight/components'; + +# Testing + +Manacore uses Jest for backend testing and Vitest for frontend testing. + +## Quick Start + +```bash +# Run all tests +pnpm test + +# Run tests for specific project +pnpm --filter @chat/backend test + +# Run tests in watch mode +pnpm --filter @chat/backend test:watch + +# Run with coverage +pnpm --filter @chat/backend test:cov +``` + +## Test Structure + +Tests are colocated with source files: + +``` +src/ +├── users/ +│ ├── users.service.ts +│ ├── users.service.spec.ts # Unit test +│ ├── users.controller.ts +│ └── users.controller.spec.ts +└── __tests__/ + └── users.e2e.spec.ts # E2E test +``` + +## Unit Testing + +### NestJS Services + +```typescript +import { Test, TestingModule } from '@nestjs/testing'; +import { UsersService } from './users.service'; +import { DRIZZLE } from '@manacore/shared-drizzle'; + +describe('UsersService', () => { + let service: UsersService; + let mockDb: any; + + beforeEach(async () => { + mockDb = { + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockResolvedValue([{ id: '1', email: 'test@example.com' }]), + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + returning: jest.fn().mockResolvedValue([{ id: '1', email: 'test@example.com' }]), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UsersService, + { provide: DRIZZLE, useValue: mockDb }, + ], + }).compile(); + + service = module.get(UsersService); + }); + + it('should find user by id', async () => { + const user = await service.findById('1'); + expect(user).toEqual({ id: '1', email: 'test@example.com' }); + }); + + it('should create user', async () => { + const user = await service.create({ email: 'new@example.com', name: 'Test' }); + expect(mockDb.insert).toHaveBeenCalled(); + expect(user.email).toBe('test@example.com'); + }); +}); +``` + +### NestJS Controllers + +```typescript +import { Test, TestingModule } from '@nestjs/testing'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; + +describe('UsersController', () => { + let controller: UsersController; + let mockService: Partial; + + beforeEach(async () => { + mockService = { + findById: jest.fn().mockResolvedValue({ id: '1', email: 'test@example.com' }), + create: jest.fn().mockResolvedValue({ id: '2', email: 'new@example.com' }), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [UsersController], + providers: [{ provide: UsersService, useValue: mockService }], + }).compile(); + + controller = module.get(UsersController); + }); + + it('should get user', async () => { + const user = await controller.getUser('1'); + expect(user.email).toBe('test@example.com'); + expect(mockService.findById).toHaveBeenCalledWith('1'); + }); +}); +``` + +## Frontend Testing + +### SvelteKit Components (Vitest) + +```typescript +import { render, screen } from '@testing-library/svelte'; +import { describe, it, expect } from 'vitest'; +import Button from './Button.svelte'; + +describe('Button', () => { + it('renders with text', () => { + render(Button, { props: { label: 'Click me' } }); + expect(screen.getByText('Click me')).toBeInTheDocument(); + }); + + it('handles click', async () => { + const { component } = render(Button, { props: { label: 'Click' } }); + let clicked = false; + component.$on('click', () => { clicked = true; }); + + await screen.getByText('Click').click(); + expect(clicked).toBe(true); + }); +}); +``` + +### React Native (Jest) + +```typescript +import { render, screen, fireEvent } from '@testing-library/react-native'; +import { Button } from './Button'; + +describe('Button', () => { + it('renders correctly', () => { + render( + ``` + + + ```tsx + // Components - PascalCase + function UserProfile({ user }: Props) { + // Hooks - use prefix + const [isLoading, setIsLoading] = useState(false); + const queryClient = useQueryClient(); + + // Handlers - handle prefix + const handlePress = () => {}; + + return ; + } + + // Styles - camelCase + const styles = StyleSheet.create({ + container: {}, + headerText: {}, + }); + ``` + + + +## TypeScript Guidelines + +### Strict Mode + +All projects use TypeScript strict mode: + +```json +{ + "compilerOptions": { + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noUnusedLocals": true, + "noUnusedParameters": true + } +} +``` + +### Type Annotations + +```typescript +// Prefer explicit return types for public functions +function getUser(id: string): Promise { + // ... +} + +// Use inference for simple cases +const users = []; // Inferred as never[], be explicit! +const users: User[] = []; // Better + +// Prefer interfaces for objects +interface User { + id: string; + name: string; +} + +// Use types for unions/intersections +type UserRole = 'admin' | 'user' | 'guest'; +type UserWithRole = User & { role: UserRole }; +``` + +### Avoid `any` + +```typescript +// Bad +function processData(data: any) {} + +// Good +function processData(data: T) {} +function processData(data: unknown) {} +function processData(data: Record) {} +``` + +## Import Organization + +Organize imports in this order: + +```typescript +// 1. Node.js built-ins +import { readFile } from 'fs/promises'; + +// 2. External packages +import { Injectable } from '@nestjs/common'; +import { eq } from 'drizzle-orm'; + +// 3. Monorepo packages +import { JwtAuthGuard } from '@manacore/shared-nestjs-auth'; + +// 4. Relative imports - parent directories +import { AppModule } from '../app.module'; + +// 5. Relative imports - same directory +import { UsersService } from './users.service'; +import { CreateUserDto } from './dto/create-user.dto'; +``` + +## Comments + +### When to Comment + +```typescript +// Good - explains WHY, not WHAT +// We retry 3 times because the external API has occasional timeouts +const MAX_RETRIES = 3; + +// Bad - explains WHAT (obvious from code) +// Set max retries to 3 +const MAX_RETRIES = 3; +``` + +### JSDoc for Public APIs + +```typescript +/** + * Fetches a user by their ID. + * + * @param id - The user's UUID + * @returns The user object or null if not found + * @throws {UnauthorizedException} If the caller lacks permission + */ +async function getUserById(id: string): Promise { + // ... +} +``` + +### TODO Comments + +```typescript +// TODO: Implement pagination (issue #123) +// FIXME: This breaks when user.name is null +// HACK: Workaround for library bug, remove after v2.0 +``` + +## Error Handling + +### Use Result Types + +```typescript +type Result = + | { ok: true; value: T } + | { ok: false; error: E }; + +// Usage +async function findUser(id: string): Promise> { + const user = await db.users.find(id); + if (!user) { + return { ok: false, error: 'NOT_FOUND' }; + } + return { ok: true, value: user }; +} +``` + +### Throw Only for Exceptional Cases + +```typescript +// Good - expected case, use Result +const result = await findUser(id); +if (!result.ok) { + return res.status(404).json({ error: 'User not found' }); +} + +// Good - truly exceptional, throw +if (!process.env.DATABASE_URL) { + throw new Error('DATABASE_URL is required'); +} +``` + +## Best Practices + + + +### Early Returns + +```typescript +// Bad - nested +function processUser(user: User | null) { + if (user) { + if (user.isActive) { + if (user.hasPermission) { + // actual logic + } + } + } +} + +// Good - early returns +function processUser(user: User | null) { + if (!user) return; + if (!user.isActive) return; + if (!user.hasPermission) return; + + // actual logic +} +``` diff --git a/apps/docs/src/content/docs/guidelines/database.mdx b/apps/docs/src/content/docs/guidelines/database.mdx new file mode 100644 index 000000000..22341183b --- /dev/null +++ b/apps/docs/src/content/docs/guidelines/database.mdx @@ -0,0 +1,412 @@ +--- +title: Database Patterns +description: Drizzle ORM patterns and database best practices in Manacore. +--- + +import { Aside, Tabs, TabItem } from '@astrojs/starlight/components'; + +# Database Patterns + +Manacore uses **Drizzle ORM** with PostgreSQL for type-safe database access. + +## Schema Design + +### Basic Table + +```typescript +import { + pgTable, + uuid, + varchar, + text, + timestamp, + boolean, + integer, +} from 'drizzle-orm/pg-core'; + +export const users = pgTable('users', { + // Primary key - always UUID + id: uuid('id').primaryKey().defaultRandom(), + + // Required fields + email: varchar('email', { length: 255 }).notNull().unique(), + name: varchar('name', { length: 100 }).notNull(), + + // Optional fields + bio: text('bio'), + avatarUrl: varchar('avatar_url', { length: 500 }), + + // Soft delete pattern + deletedAt: timestamp('deleted_at'), + + // Audit fields - always include + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); +``` + +### Foreign Keys + +```typescript +export const posts = pgTable('posts', { + id: uuid('id').primaryKey().defaultRandom(), + title: varchar('title', { length: 255 }).notNull(), + content: text('content'), + + // Foreign key with cascade delete + authorId: uuid('author_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + + // Foreign key with set null + categoryId: uuid('category_id') + .references(() => categories.id, { onDelete: 'set null' }), + + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); +``` + +### Indexes + +```typescript +import { pgTable, uuid, varchar, index, uniqueIndex } from 'drizzle-orm/pg-core'; + +export const users = pgTable('users', { + id: uuid('id').primaryKey().defaultRandom(), + email: varchar('email', { length: 255 }).notNull(), + organizationId: uuid('organization_id'), + status: varchar('status', { length: 20 }), +}, (table) => ({ + // Single column index + emailIdx: uniqueIndex('users_email_idx').on(table.email), + + // Composite index + orgStatusIdx: index('users_org_status_idx').on(table.organizationId, table.status), +})); +``` + +### Enums + +```typescript +import { pgEnum, pgTable, uuid } from 'drizzle-orm/pg-core'; + +// Define enum +export const userRoleEnum = pgEnum('user_role', ['admin', 'user', 'guest']); +export const statusEnum = pgEnum('status', ['active', 'inactive', 'pending']); + +// Use in table +export const users = pgTable('users', { + id: uuid('id').primaryKey().defaultRandom(), + role: userRoleEnum('role').default('user').notNull(), + status: statusEnum('status').default('pending').notNull(), +}); +``` + +### JSON Columns + +```typescript +import { pgTable, uuid, jsonb } from 'drizzle-orm/pg-core'; + +// Type for JSON structure +interface UserSettings { + theme: 'light' | 'dark'; + notifications: boolean; + language: string; +} + +export const users = pgTable('users', { + id: uuid('id').primaryKey().defaultRandom(), + settings: jsonb('settings').$type().default({ + theme: 'light', + notifications: true, + language: 'en', + }), +}); +``` + +## Query Patterns + +### Basic Queries + +```typescript +import { eq, and, or, like, ilike, isNull, isNotNull } from 'drizzle-orm'; + +// Select all +const allUsers = await db.select().from(users); + +// Select with condition +const activeUsers = await db + .select() + .from(users) + .where(eq(users.status, 'active')); + +// Select specific columns +const emails = await db + .select({ email: users.email, name: users.name }) + .from(users); + +// Multiple conditions +const result = await db + .select() + .from(users) + .where( + and( + eq(users.status, 'active'), + isNull(users.deletedAt), + or( + eq(users.role, 'admin'), + eq(users.role, 'moderator') + ) + ) + ); + +// Pattern matching +const matched = await db + .select() + .from(users) + .where(ilike(users.name, '%john%')); +``` + +### Joins + +```typescript +import { eq } from 'drizzle-orm'; + +// Inner join +const postsWithAuthors = await db + .select({ + post: posts, + author: users, + }) + .from(posts) + .innerJoin(users, eq(posts.authorId, users.id)); + +// Left join +const usersWithPosts = await db + .select() + .from(users) + .leftJoin(posts, eq(users.id, posts.authorId)); +``` + +### Ordering and Pagination + +```typescript +import { desc, asc } from 'drizzle-orm'; + +// Order by +const sorted = await db + .select() + .from(users) + .orderBy(desc(users.createdAt)); + +// Pagination +const page = 1; +const pageSize = 20; + +const paginated = await db + .select() + .from(users) + .orderBy(desc(users.createdAt)) + .limit(pageSize) + .offset((page - 1) * pageSize); +``` + +### Aggregations + +```typescript +import { count, sum, avg, min, max } from 'drizzle-orm'; + +// Count +const [{ total }] = await db + .select({ total: count() }) + .from(users) + .where(eq(users.status, 'active')); + +// Group by +const countByRole = await db + .select({ + role: users.role, + count: count(), + }) + .from(users) + .groupBy(users.role); +``` + +### Insert + +```typescript +// Single insert +const [newUser] = await db + .insert(users) + .values({ + email: 'user@example.com', + name: 'John Doe', + }) + .returning(); + +// Bulk insert +const newUsers = await db + .insert(users) + .values([ + { email: 'user1@example.com', name: 'User 1' }, + { email: 'user2@example.com', name: 'User 2' }, + ]) + .returning(); + +// Upsert (insert or update) +await db + .insert(users) + .values({ email: 'user@example.com', name: 'John' }) + .onConflictDoUpdate({ + target: users.email, + set: { name: 'John Updated' }, + }); +``` + +### Update + +```typescript +// Update with returning +const [updated] = await db + .update(users) + .set({ + name: 'New Name', + updatedAt: new Date(), + }) + .where(eq(users.id, userId)) + .returning(); + +// Conditional update +await db + .update(users) + .set({ status: 'inactive' }) + .where( + and( + eq(users.status, 'active'), + lt(users.lastLoginAt, thirtyDaysAgo) + ) + ); +``` + +### Delete + +```typescript +// Hard delete +await db.delete(users).where(eq(users.id, userId)); + +// Soft delete (preferred) +await db + .update(users) + .set({ deletedAt: new Date() }) + .where(eq(users.id, userId)); +``` + +## Transactions + +```typescript +import { db } from './drizzle'; + +// Basic transaction +await db.transaction(async (tx) => { + const [user] = await tx + .insert(users) + .values({ email: 'user@example.com', name: 'John' }) + .returning(); + + await tx + .insert(profiles) + .values({ userId: user.id, bio: 'Hello!' }); + + // If anything throws, entire transaction rolls back +}); + +// With savepoints +await db.transaction(async (tx) => { + await tx.insert(users).values({ /* ... */ }); + + try { + await tx.insert(optionalData).values({ /* ... */ }); + } catch (e) { + // This specific insert failed, but transaction continues + console.log('Optional insert failed, continuing...'); + } + + await tx.insert(requiredData).values({ /* ... */ }); +}); +``` + +## Service Pattern + +```typescript +@Injectable() +export class UsersService { + constructor(@Inject(DRIZZLE) private db: DrizzleDB) {} + + async findAll(options?: { status?: string; page?: number; limit?: number }) { + const { status, page = 1, limit = 20 } = options || {}; + + const query = this.db + .select() + .from(users) + .where( + and( + isNull(users.deletedAt), + status ? eq(users.status, status) : undefined + ) + ) + .orderBy(desc(users.createdAt)) + .limit(limit) + .offset((page - 1) * limit); + + return query; + } + + async findById(id: string) { + const [user] = await this.db + .select() + .from(users) + .where(and(eq(users.id, id), isNull(users.deletedAt))); + + return user || null; + } + + async create(data: CreateUserDto) { + const [user] = await this.db + .insert(users) + .values(data) + .returning(); + + return user; + } + + async update(id: string, data: UpdateUserDto) { + const [user] = await this.db + .update(users) + .set({ ...data, updatedAt: new Date() }) + .where(eq(users.id, id)) + .returning(); + + return user; + } + + async softDelete(id: string) { + await this.db + .update(users) + .set({ deletedAt: new Date() }) + .where(eq(users.id, id)); + } +} +``` + +## Best Practices + + diff --git a/apps/docs/src/content/docs/guidelines/design-ux.mdx b/apps/docs/src/content/docs/guidelines/design-ux.mdx new file mode 100644 index 000000000..9f2376d77 --- /dev/null +++ b/apps/docs/src/content/docs/guidelines/design-ux.mdx @@ -0,0 +1,322 @@ +--- +title: Design & UX +description: UI patterns, animations, and accessibility guidelines for Manacore. +--- + +import { Aside, Tabs, TabItem } from '@astrojs/starlight/components'; + +# Design & UX + +Consistent design patterns across all Manacore applications for a unified user experience. + +## Design Principles + +1. **Consistency** - Same patterns across all apps +2. **Simplicity** - Clean, uncluttered interfaces +3. **Accessibility** - Usable by everyone +4. **Performance** - Fast, responsive interactions +5. **Feedback** - Clear system status communication + +## Color System + +### Semantic Colors + +| Purpose | Light Mode | Dark Mode | Usage | +|---------|------------|-----------|-------| +| **Primary** | `blue-500` | `blue-400` | CTAs, links, active states | +| **Success** | `green-500` | `green-400` | Confirmations, completed | +| **Warning** | `amber-500` | `amber-400` | Caution, attention | +| **Error** | `red-500` | `red-400` | Errors, destructive actions | +| **Neutral** | `gray-*` | `gray-*` | Text, backgrounds, borders | + +### Tailwind Usage + +```tsx +// Primary actions + + +// Success state +
+ Success! +
+ +// Error state +Error message + +// Neutral backgrounds +
+

Content

+
+``` + +## Typography + +### Scale + +| Size | Class | Usage | +|------|-------|-------| +| xs | `text-xs` | Labels, captions | +| sm | `text-sm` | Secondary text, metadata | +| base | `text-base` | Body text | +| lg | `text-lg` | Emphasis, subheadings | +| xl | `text-xl` | Section headings | +| 2xl | `text-2xl` | Page headings | +| 3xl+ | `text-3xl` | Hero text, landing pages | + +### Font Weights + +```tsx +// Regular content +

Body text

+ +// Emphasis +

Important text

+ +// Headings +

Section Title

+ +// Strong emphasis +

Page Title

+``` + +## Spacing + +Use Tailwind's spacing scale consistently: + +| Space | Class | Pixels | Usage | +|-------|-------|--------|-------| +| 1 | `p-1`, `m-1` | 4px | Tight spacing | +| 2 | `p-2`, `m-2` | 8px | Compact elements | +| 3 | `p-3`, `m-3` | 12px | Default padding | +| 4 | `p-4`, `m-4` | 16px | Card padding | +| 6 | `p-6`, `m-6` | 24px | Section spacing | +| 8 | `p-8`, `m-8` | 32px | Large sections | + +```tsx +// Card with consistent spacing +
+

Title

+

Description

+ +
+ +// List with gaps +
    +
  • Item 1
  • +
  • Item 2
  • +
+``` + +## Components + +### Buttons + + + + ```tsx + // Primary - main actions + + + // Secondary - alternative actions + + + // Ghost - subtle actions + + + // Destructive - dangerous actions + + ``` + + + ```tsx + // Disabled + + + // Loading + + ``` + + + +### Cards + +```tsx +// Basic card +
+

Card Title

+

Card content

+
+ +// Interactive card + +``` + +### Forms + +```tsx +// Input + + +// With label and error +
+ + +

Invalid email address

+
+``` + +## Loading States + +### Skeletons + +```tsx +// Text skeleton +
+
+
+
+ +// Card skeleton +
+
+
+
+
+
+
+``` + +### Spinners + +```tsx +// Simple spinner +
+ +// Full page loading +
+
+
+``` + +## Animations + +### Transitions + +```tsx +// Fade in +
+ +// Slide up +
+ +// Scale + + +// Reduced motion +
+ +// Skip link + + Skip to main content + +``` + +## Dark Mode + +All components must support dark mode: + +```tsx +// Background +
+ +// Text +

+ +// Borders +

+ +// Hover states +