mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
📝 docs: add Astro Starlight public documentation site
Add comprehensive documentation site using Astro Starlight with: - Getting Started guides (introduction, quick-start, project structure) - Development docs (local dev, env vars, docker, migrations, testing) - Architecture docs (overview, auth, backend, web, mobile, storage, search) - Guidelines (code style, error handling, database, design/UX) - Deployment docs (overview, Cloudflare Pages, Mac Mini, self-hosting) - Project pages (overview, chat) - API reference structure Features: - Dark mode support - Full-text search (Pagefind) - Tailwind CSS styling - Cloudflare Pages deployment ready - Edit on GitHub links
This commit is contained in:
parent
dff153ca1e
commit
4b322f59b1
38 changed files with 7497 additions and 234 deletions
101
apps/docs/astro.config.mjs
Normal file
101
apps/docs/astro.config.mjs
Normal file
|
|
@ -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(),
|
||||
],
|
||||
});
|
||||
28
apps/docs/package.json
Normal file
28
apps/docs/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
5
apps/docs/src/assets/logo-dark.svg
Normal file
5
apps/docs/src/assets/logo-dark.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="8" fill="#0ea5e9"/>
|
||||
<path d="M8 12L16 8L24 12V20L16 24L8 20V12Z" stroke="white" stroke-width="2" fill="none"/>
|
||||
<circle cx="16" cy="16" r="3" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 298 B |
5
apps/docs/src/assets/logo-light.svg
Normal file
5
apps/docs/src/assets/logo-light.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="8" fill="#0ea5e9"/>
|
||||
<path d="M8 12L16 8L24 12V20L16 24L8 20V12Z" stroke="white" stroke-width="2" fill="none"/>
|
||||
<circle cx="16" cy="16" r="3" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 298 B |
6
apps/docs/src/content/config.ts
Normal file
6
apps/docs/src/content/config.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { defineCollection } from 'astro:content';
|
||||
import { docsSchema } from '@astrojs/starlight/schema';
|
||||
|
||||
export const collections = {
|
||||
docs: defineCollection({ schema: docsSchema() }),
|
||||
};
|
||||
151
apps/docs/src/content/docs/api/index.mdx
Normal file
151
apps/docs/src/content/docs/api/index.mdx
Normal file
|
|
@ -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 <your-token>"
|
||||
```
|
||||
|
||||
### 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
|
||||
283
apps/docs/src/content/docs/architecture/authentication.mdx
Normal file
283
apps/docs/src/content/docs/architecture/authentication.mdx
Normal file
|
|
@ -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
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="SvelteKit">
|
||||
```env
|
||||
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="Expo">
|
||||
```env
|
||||
EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Testing Authentication
|
||||
|
||||
<Steps>
|
||||
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"
|
||||
```
|
||||
</Steps>
|
||||
|
||||
## 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',
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
<Aside type="danger">
|
||||
Never enable auth bypass in production!
|
||||
</Aside>
|
||||
|
||||
## 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 |
|
||||
353
apps/docs/src/content/docs/architecture/backend.mdx
Normal file
353
apps/docs/src/content/docs/architecture/backend.mdx
Normal file
|
|
@ -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<T, E = Error> =
|
||||
| { ok: true; value: T }
|
||||
| { ok: false; error: E };
|
||||
|
||||
export const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
|
||||
export const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Usage in service
|
||||
async findById(id: string): Promise<Result<User, 'NOT_FOUND'>> {
|
||||
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
|
||||
|
||||
<Aside type="tip">
|
||||
1. **Keep controllers thin** - Delegate logic to services
|
||||
2. **Use DTOs** - Never trust raw input
|
||||
3. **Handle errors explicitly** - Use Result types
|
||||
4. **Inject dependencies** - Don't instantiate services manually
|
||||
5. **Write tests** - Unit test services, integration test controllers
|
||||
</Aside>
|
||||
399
apps/docs/src/content/docs/architecture/mobile.mdx
Normal file
399
apps/docs/src/content/docs/architecture/mobile.mdx
Normal file
|
|
@ -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 <LoadingScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="(auth)" />
|
||||
<Stack.Screen name="(tabs)" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 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 (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: '#3b82f6',
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Home',
|
||||
tabBarIcon: ({ color, size }) => <Home color={color} size={size} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: 'Settings',
|
||||
tabBarIcon: ({ color, size }) => <Settings color={color} size={size} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: 'Profile',
|
||||
tabBarIcon: ({ color, size }) => <User color={color} size={size} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 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 <Redirect href="/login" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs>
|
||||
{/* ... */}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 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 (
|
||||
<Pressable
|
||||
className="bg-blue-500 px-4 py-2 rounded-lg active:bg-blue-600"
|
||||
onPress={onPress}
|
||||
>
|
||||
<Text className="text-white font-semibold text-center">
|
||||
{title}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 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<AuthState>()(
|
||||
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 (
|
||||
<View className="flex-1 p-4">
|
||||
<Text className="text-xl font-bold">{user?.name}</Text>
|
||||
<Text className="text-gray-500">{user?.email}</Text>
|
||||
|
||||
<Pressable
|
||||
className="mt-4 bg-red-500 p-3 rounded-lg"
|
||||
onPress={logout}
|
||||
>
|
||||
<Text className="text-white text-center">Logout</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 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<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
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<User>('/api/v1/me');
|
||||
getItems = () => this.fetch<Item[]>('/api/v1/items');
|
||||
createItem = (data: CreateItemDto) =>
|
||||
this.fetch<Item>('/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 (
|
||||
<View
|
||||
className={cn(
|
||||
'bg-white rounded-xl p-4',
|
||||
variant === 'elevated' && 'shadow-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 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 <ActivityIndicator className="flex-1" />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Text className="text-red-500">Error loading items</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={items}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<Card className="mb-3">
|
||||
<Text className="font-semibold">{item.title}</Text>
|
||||
<Text className="text-gray-500">{item.description}</Text>
|
||||
</Card>
|
||||
)}
|
||||
contentContainerClassName="p-4"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
<Aside type="tip">
|
||||
1. **Use Expo Router** for file-based navigation
|
||||
2. **NativeWind** for consistent Tailwind-style styling
|
||||
3. **Zustand** for global state (persisted with AsyncStorage)
|
||||
4. **React Query** for server state and caching
|
||||
5. **Organize by feature** in the `src/` directory
|
||||
6. **Type everything** with TypeScript
|
||||
</Aside>
|
||||
|
||||
## 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
|
||||
```
|
||||
159
apps/docs/src/content/docs/architecture/overview.mdx
Normal file
159
apps/docs/src/content/docs/architecture/overview.mdx
Normal file
|
|
@ -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
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Shared Authentication" icon="shield">
|
||||
All apps authenticate through **Mana Core Auth** using EdDSA JWT tokens.
|
||||
</Card>
|
||||
<Card title="Independent Databases" icon="database">
|
||||
Each service has its own PostgreSQL database for isolation.
|
||||
</Card>
|
||||
<Card title="Shared Packages" icon="puzzle">
|
||||
Common code lives in `packages/` for reuse across apps.
|
||||
</Card>
|
||||
<Card title="Consistent Patterns" icon="document">
|
||||
All apps follow the same architectural patterns and conventions.
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
## 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
|
||||
274
apps/docs/src/content/docs/architecture/search.mdx
Normal file
274
apps/docs/src/content/docs/architecture/search.mdx
Normal file
|
|
@ -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 |
|
||||
|
||||
<Aside type="tip">
|
||||
Caching significantly reduces load on external services and improves response times for repeated queries.
|
||||
</Aside>
|
||||
|
||||
## Environment Variables
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Consumer Apps">
|
||||
```env
|
||||
MANA_SEARCH_URL=http://localhost:3021
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="Mana Search Service">
|
||||
```env
|
||||
SEARXNG_URL=http://localhost:8080
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
CACHE_SEARCH_TTL=3600
|
||||
CACHE_EXTRACT_TTL=86400
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## 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
|
||||
313
apps/docs/src/content/docs/architecture/storage.mdx
Normal file
313
apps/docs/src/content/docs/architecture/storage.mdx
Normal file
|
|
@ -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
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Local (.env.development)">
|
||||
```env
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_REGION=us-east-1
|
||||
S3_ACCESS_KEY=minioadmin
|
||||
S3_SECRET_KEY=minioadmin
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="Production">
|
||||
```env
|
||||
S3_ENDPOINT=https://fsn1.your-objectstorage.com
|
||||
S3_REGION=fsn1
|
||||
S3_ACCESS_KEY=your-access-key
|
||||
S3_SECRET_KEY=your-secret-key
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## 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
|
||||
|
||||
<Aside type="tip">
|
||||
1. **Use presigned URLs** for large file uploads
|
||||
2. **Generate unique keys** to avoid collisions
|
||||
3. **Set appropriate content types** for proper browser handling
|
||||
4. **Use private by default** - only make public what needs to be
|
||||
5. **Clean up orphaned files** - delete when parent records are deleted
|
||||
6. **Validate file types** before uploading
|
||||
</Aside>
|
||||
|
||||
## 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
|
||||
```
|
||||
386
apps/docs/src/content/docs/architecture/web.mdx
Normal file
386
apps/docs/src/content/docs/architecture/web.mdx
Normal file
|
|
@ -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
|
||||
|
||||
<Aside type="caution">
|
||||
Always use Svelte 5 runes mode. Never use the old `$:` reactive syntax.
|
||||
</Aside>
|
||||
|
||||
### State Management
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
// Reactive state
|
||||
let count = $state(0);
|
||||
|
||||
// Derived values
|
||||
let doubled = $derived(count * 2);
|
||||
|
||||
// Deep reactive objects
|
||||
let user = $state({
|
||||
name: 'John',
|
||||
settings: { theme: 'dark' }
|
||||
});
|
||||
|
||||
// Effects (side effects on state change)
|
||||
$effect(() => {
|
||||
console.log('Count changed:', count);
|
||||
// Cleanup function (optional)
|
||||
return () => console.log('Cleanup');
|
||||
});
|
||||
|
||||
function increment() {
|
||||
count++;
|
||||
}
|
||||
</script>
|
||||
|
||||
<button onclick={increment}>
|
||||
Count: {count} (doubled: {doubled})
|
||||
</button>
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
title: string;
|
||||
count?: number;
|
||||
onUpdate?: (value: number) => void;
|
||||
}
|
||||
|
||||
let { title, count = 0, onUpdate }: Props = $props();
|
||||
</script>
|
||||
|
||||
<h1>{title}</h1>
|
||||
<p>Count: {count}</p>
|
||||
<button onclick={() => onUpdate?.(count + 1)}>Update</button>
|
||||
```
|
||||
|
||||
### Bindings
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let value = $state('');
|
||||
let checked = $state(false);
|
||||
</script>
|
||||
|
||||
<input bind:value />
|
||||
<input type="checkbox" bind:checked />
|
||||
```
|
||||
|
||||
## 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
|
||||
<!-- routes/(app)/dashboard/+page.svelte -->
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<h1>Dashboard</h1>
|
||||
<p>Total items: {data.stats.total}</p>
|
||||
```
|
||||
|
||||
## 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<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
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<User[]>('/api/v1/users');
|
||||
}
|
||||
|
||||
async createUser(data: CreateUserDto) {
|
||||
return this.fetch<User>('/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<AuthState>({
|
||||
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
|
||||
<!-- src/lib/components/ui/Button.svelte -->
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
variant?: 'primary' | 'secondary' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
disabled?: boolean;
|
||||
class?: string;
|
||||
onclick?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
disabled = false,
|
||||
class: className,
|
||||
onclick,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-blue-500 text-white hover:bg-blue-600',
|
||||
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
|
||||
ghost: 'bg-transparent hover:bg-gray-100',
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-2 py-1 text-sm',
|
||||
md: 'px-4 py-2',
|
||||
lg: 'px-6 py-3 text-lg',
|
||||
};
|
||||
</script>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-md font-medium transition-colors',
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
{disabled}
|
||||
{onclick}
|
||||
{...rest}
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
```
|
||||
|
||||
### 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
|
||||
<!-- routes/settings/+page.svelte -->
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { ActionData } from './$types';
|
||||
|
||||
let { form }: { form: ActionData } = $props();
|
||||
</script>
|
||||
|
||||
<form method="POST" action="?/updateProfile" use:enhance>
|
||||
<input name="name" required minlength="2" />
|
||||
|
||||
{#if form?.error}
|
||||
<p class="text-red-500">{form.error}</p>
|
||||
{/if}
|
||||
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
## 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
|
||||
242
apps/docs/src/content/docs/deployment/cloudflare-pages.mdx
Normal file
242
apps/docs/src/content/docs/deployment/cloudflare-pages.mdx
Normal file
|
|
@ -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
|
||||
|
||||
<Steps>
|
||||
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
|
||||
```
|
||||
</Steps>
|
||||
|
||||
## 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://<branch>.<project>.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 <deployment-id> --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
|
||||
|
||||
<Steps>
|
||||
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
|
||||
```
|
||||
</Steps>
|
||||
|
||||
## 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)
|
||||
|
||||
<Aside type="tip">
|
||||
Use `dig` to check DNS propagation:
|
||||
```bash
|
||||
dig chat.manacore.app
|
||||
```
|
||||
</Aside>
|
||||
316
apps/docs/src/content/docs/deployment/mac-mini-server.mdx
Normal file
316
apps/docs/src/content/docs/deployment/mac-mini-server.mdx
Normal file
|
|
@ -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
|
||||
```
|
||||
|
||||
<Aside type="note">
|
||||
Requires `cloudflared` installed: `brew install cloudflare/cloudflare/cloudflared`
|
||||
</Aside>
|
||||
|
||||
## 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
|
||||
|
||||
<Aside type="note">
|
||||
Monitoring with Prometheus/Grafana is planned. Currently using manual checks.
|
||||
</Aside>
|
||||
|
||||
## 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
|
||||
```
|
||||
151
apps/docs/src/content/docs/deployment/overview.mdx
Normal file
151
apps/docs/src/content/docs/deployment/overview.mdx
Normal file
|
|
@ -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
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Cloudflare Pages" icon="cloud">
|
||||
Static sites: Landing pages, documentation
|
||||
</Card>
|
||||
<Card title="Mac Mini Server" icon="server">
|
||||
Docker containers: Backends, databases
|
||||
</Card>
|
||||
<Card title="Hetzner Storage" icon="document">
|
||||
S3-compatible object storage for files
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
## 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
|
||||
|
||||
<Aside type="note">
|
||||
Full CI/CD pipelines are planned. Currently deployment is manual.
|
||||
</Aside>
|
||||
|
||||
### 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 <deployment-id> --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
|
||||
322
apps/docs/src/content/docs/deployment/self-hosting.mdx
Normal file
322
apps/docs/src/content/docs/deployment/self-hosting.mdx
Normal file
|
|
@ -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
|
||||
|
||||
<Steps>
|
||||
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
|
||||
```
|
||||
</Steps>
|
||||
|
||||
## 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
|
||||
|
||||
<Steps>
|
||||
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
|
||||
```
|
||||
</Steps>
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
<Aside type="caution">
|
||||
Always test updates on a staging environment before applying to production.
|
||||
</Aside>
|
||||
274
apps/docs/src/content/docs/development/database-migrations.mdx
Normal file
274
apps/docs/src/content/docs/development/database-migrations.mdx
Normal file
|
|
@ -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:
|
||||
|
||||
<Steps>
|
||||
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
|
||||
```
|
||||
</Steps>
|
||||
|
||||
<Aside type="tip">
|
||||
`db:push` is fast for development but doesn't create migration files. Use `db:generate` for production migrations.
|
||||
</Aside>
|
||||
|
||||
## Production Migrations
|
||||
|
||||
For production, use proper migration files:
|
||||
|
||||
<Steps>
|
||||
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
|
||||
```
|
||||
</Steps>
|
||||
|
||||
## 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
|
||||
292
apps/docs/src/content/docs/development/docker.mdx
Normal file
292
apps/docs/src/content/docs/development/docker.mdx
Normal file
|
|
@ -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
|
||||
```
|
||||
185
apps/docs/src/content/docs/development/environment-variables.mdx
Normal file
185
apps/docs/src/content/docs/development/environment-variables.mdx
Normal file
|
|
@ -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
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Chat">
|
||||
| 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 |
|
||||
</TabItem>
|
||||
<TabItem label="Picture">
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `PICTURE_BACKEND_PORT` | Backend port (3006) |
|
||||
| `REPLICATE_API_KEY` | Replicate AI key |
|
||||
</TabItem>
|
||||
<TabItem label="Zitare">
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `ZITARE_BACKEND_PORT` | Backend port (3007) |
|
||||
| `ZITARE_DATABASE_URL` | PostgreSQL connection |
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Adding New Variables
|
||||
|
||||
<Steps>
|
||||
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
|
||||
```
|
||||
</Steps>
|
||||
|
||||
## 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
|
||||
|
||||
<Aside type="caution">
|
||||
Running `pnpm setup:env` will overwrite your changes in `.env` files. Use `.env.local` for persistent overrides.
|
||||
</Aside>
|
||||
|
||||
## 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/<project>/apps/mobile
|
||||
npx expo start -c
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
<Aside type="danger">
|
||||
- `.env.development` contains **development-only** values
|
||||
- Never put production secrets in this file
|
||||
- The JWT keys are for local development only
|
||||
- Use separate secrets management for production
|
||||
</Aside>
|
||||
227
apps/docs/src/content/docs/development/local-development.mdx
Normal file
227
apps/docs/src/content/docs/development/local-development.mdx
Normal file
|
|
@ -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:
|
||||
|
||||
<Steps>
|
||||
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
|
||||
```
|
||||
</Steps>
|
||||
|
||||
## 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`:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Option 1: Setup Script">
|
||||
```bash
|
||||
pnpm setup:db:chat # or whichever service
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="Option 2: Manual">
|
||||
```bash
|
||||
PGPASSWORD=devpassword psql -h localhost -U manacore -d postgres -c "CREATE DATABASE chat;"
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### 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 <PID>
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
<Aside type="tip">
|
||||
See the [Project Structure](/getting-started/project-structure) guide for the full directory layout.
|
||||
</Aside>
|
||||
|
||||
### 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
|
||||
```
|
||||
285
apps/docs/src/content/docs/development/testing.mdx
Normal file
285
apps/docs/src/content/docs/development/testing.mdx
Normal file
|
|
@ -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>(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<UsersService>;
|
||||
|
||||
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>(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(<Button title="Press me" onPress={() => {}} />);
|
||||
expect(screen.getByText('Press me')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('calls onPress when pressed', () => {
|
||||
const onPress = jest.fn();
|
||||
render(<Button title="Press" onPress={onPress} />);
|
||||
|
||||
fireEvent.press(screen.getByText('Press'));
|
||||
expect(onPress).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## E2E Testing
|
||||
|
||||
### NestJS with Supertest
|
||||
|
||||
```typescript
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as request from 'supertest';
|
||||
import { AppModule } from '../src/app.module';
|
||||
|
||||
describe('Users (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('/users (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/users')
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('/users (POST)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.post('/users')
|
||||
.send({ email: 'test@example.com', name: 'Test User' })
|
||||
.expect(201)
|
||||
.expect((res) => {
|
||||
expect(res.body.email).toBe('test@example.com');
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Mock Factories
|
||||
|
||||
Create reusable mock factories:
|
||||
|
||||
```typescript
|
||||
// test/factories/user.factory.ts
|
||||
export const createMockUser = (overrides = {}) => ({
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Usage
|
||||
const user = createMockUser({ name: 'Custom Name' });
|
||||
```
|
||||
|
||||
## Test Configuration
|
||||
|
||||
### Jest Config (Backend)
|
||||
|
||||
```javascript
|
||||
// jest.config.js
|
||||
module.exports = {
|
||||
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||
rootDir: 'src',
|
||||
testRegex: '.*\\.spec\\.ts$',
|
||||
transform: {
|
||||
'^.+\\.(t|j)s$': 'ts-jest',
|
||||
},
|
||||
collectCoverageFrom: ['**/*.(t|j)s'],
|
||||
coverageDirectory: '../coverage',
|
||||
testEnvironment: 'node',
|
||||
};
|
||||
```
|
||||
|
||||
### Vitest Config (Frontend)
|
||||
|
||||
```typescript
|
||||
// vitest.config.ts
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte({ hot: !process.env.VITEST })],
|
||||
test: {
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
<Aside type="tip" title="Testing Guidelines">
|
||||
1. **Test behavior, not implementation** - Focus on what the code does, not how
|
||||
2. **Use descriptive test names** - `it('should return 404 when user not found')`
|
||||
3. **One assertion per test** - Makes failures easier to diagnose
|
||||
4. **Mock external dependencies** - Database, APIs, file system
|
||||
5. **Keep tests fast** - Unit tests should run in milliseconds
|
||||
</Aside>
|
||||
|
||||
### Coverage Goals
|
||||
|
||||
- **Unit tests**: 80% coverage for business logic
|
||||
- **Integration tests**: Critical paths and API endpoints
|
||||
- **E2E tests**: Happy path user flows
|
||||
74
apps/docs/src/content/docs/getting-started/introduction.mdx
Normal file
74
apps/docs/src/content/docs/getting-started/introduction.mdx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
---
|
||||
title: Introduction
|
||||
description: Welcome to the Manacore documentation - learn about our multi-app ecosystem platform.
|
||||
---
|
||||
|
||||
import { Card, CardGrid } from '@astrojs/starlight/components';
|
||||
|
||||
# Welcome to Manacore
|
||||
|
||||
Manacore is a **multi-app ecosystem platform** built as a pnpm monorepo. It provides shared infrastructure, authentication, and common patterns for building modern web and mobile applications.
|
||||
|
||||
## What is Manacore?
|
||||
|
||||
Manacore is designed to streamline the development of multiple interconnected applications:
|
||||
|
||||
- **Shared Authentication** via Mana Core Auth (EdDSA JWT)
|
||||
- **Unified Database Patterns** with Drizzle ORM and PostgreSQL
|
||||
- **Cross-Platform Apps** with SvelteKit (web), Expo (mobile), and NestJS (backend)
|
||||
- **Shared UI Components** for consistent design across all apps
|
||||
- **Centralized Services** for search, storage, and more
|
||||
|
||||
## Quick Links
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Quick Start" icon="rocket">
|
||||
Get up and running in minutes with our [Quick Start Guide](/getting-started/quick-start).
|
||||
</Card>
|
||||
<Card title="Project Structure" icon="folder">
|
||||
Understand the [monorepo organization](/getting-started/project-structure).
|
||||
</Card>
|
||||
<Card title="Local Development" icon="laptop">
|
||||
Set up your [local development environment](/development/local-development).
|
||||
</Card>
|
||||
<Card title="Architecture" icon="puzzle">
|
||||
Learn about the [system architecture](/architecture/overview).
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
## Projects in the Ecosystem
|
||||
|
||||
| Project | Description | Status |
|
||||
|---------|-------------|--------|
|
||||
| **Chat** | AI chat application with multiple models | Stable |
|
||||
| **Picture** | AI image generation | Stable |
|
||||
| **Zitare** | Daily inspiration quotes | Stable |
|
||||
| **ManaDeck** | Card/deck management | Beta |
|
||||
| **Contacts** | Contact management | Beta |
|
||||
| **Calendar** | Calendar & scheduling | Alpha |
|
||||
| **Matrix** | Matrix chat client | Alpha |
|
||||
|
||||
## Technology Stack
|
||||
|
||||
### Frontend
|
||||
- **Web**: SvelteKit 2.x + Svelte 5 (Runes mode)
|
||||
- **Mobile**: Expo SDK 52+ with React Native
|
||||
- **Landing Pages**: Astro 5.x
|
||||
|
||||
### Backend
|
||||
- **API**: NestJS 10-11
|
||||
- **Database**: PostgreSQL with Drizzle ORM
|
||||
- **Cache**: Redis
|
||||
- **Storage**: S3-compatible (MinIO local, Hetzner production)
|
||||
|
||||
### Infrastructure
|
||||
- **Package Manager**: pnpm 9.15+
|
||||
- **Build System**: Turborepo
|
||||
- **Deployment**: Cloudflare Pages, Docker
|
||||
- **Auth**: Mana Core Auth (Better Auth + EdDSA JWT)
|
||||
|
||||
## Getting Help
|
||||
|
||||
- Check the [FAQ](/faq) for common questions
|
||||
- Browse the [API Reference](/api) for endpoint details
|
||||
- Join our community on Discord
|
||||
146
apps/docs/src/content/docs/getting-started/project-structure.mdx
Normal file
146
apps/docs/src/content/docs/getting-started/project-structure.mdx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
---
|
||||
title: Project Structure
|
||||
description: Understand how the Manacore monorepo is organized.
|
||||
---
|
||||
|
||||
import { FileTree } from '@astrojs/starlight/components';
|
||||
|
||||
# Project Structure
|
||||
|
||||
Manacore uses a pnpm workspace monorepo with Turborepo for build orchestration.
|
||||
|
||||
## Overview
|
||||
|
||||
<FileTree>
|
||||
- apps/ Main product applications
|
||||
- chat/
|
||||
- apps/
|
||||
- backend/ NestJS API
|
||||
- mobile/ Expo React Native
|
||||
- web/ SvelteKit app
|
||||
- landing/ Astro marketing page
|
||||
- packages/ Chat-specific packages
|
||||
- picture/
|
||||
- zitare/
|
||||
- contacts/
|
||||
- calendar/
|
||||
- ...
|
||||
- services/ Standalone microservices
|
||||
- mana-core-auth/ Central authentication
|
||||
- mana-search/ Search & content extraction
|
||||
- mana-tts/ Text-to-speech
|
||||
- mana-stt/ Speech-to-text
|
||||
- packages/ Shared packages
|
||||
- shared-auth/ Client auth utilities
|
||||
- shared-nestjs-auth/ NestJS auth guards
|
||||
- shared-storage/ S3 storage utilities
|
||||
- shared-ui/ React Native components
|
||||
- shared-types/ TypeScript types
|
||||
- shared-utils/ Utility functions
|
||||
- ...
|
||||
- docker/ Docker configurations
|
||||
- docs/ Documentation source files
|
||||
- .claude/ Claude Code guidelines
|
||||
</FileTree>
|
||||
|
||||
## Apps Structure
|
||||
|
||||
Each application in `apps/` follows a consistent structure:
|
||||
|
||||
```
|
||||
apps/{project}/
|
||||
├── apps/
|
||||
│ ├── backend/ # NestJS API (optional)
|
||||
│ ├── mobile/ # Expo app (optional)
|
||||
│ ├── web/ # SvelteKit app
|
||||
│ └── landing/ # Astro landing page
|
||||
├── packages/ # Project-specific packages
|
||||
├── package.json # Project root (for turbo)
|
||||
└── CLAUDE.md # Project documentation
|
||||
```
|
||||
|
||||
### App Types
|
||||
|
||||
| Type | Framework | Purpose |
|
||||
|------|-----------|---------|
|
||||
| `backend` | NestJS 10-11 | REST API, business logic, database access |
|
||||
| `web` | SvelteKit 2 + Svelte 5 | Main web application |
|
||||
| `mobile` | Expo SDK 52+ | iOS and Android app |
|
||||
| `landing` | Astro 5 | Marketing/landing page |
|
||||
|
||||
## Services
|
||||
|
||||
The `services/` directory contains standalone microservices:
|
||||
|
||||
| Service | Port | Purpose |
|
||||
|---------|------|---------|
|
||||
| `mana-core-auth` | 3001 | Central authentication (EdDSA JWT) |
|
||||
| `mana-search` | 3021 | Web search & content extraction |
|
||||
| `mana-tts` | 3031 | Text-to-speech generation |
|
||||
| `mana-stt` | 3032 | Speech-to-text transcription |
|
||||
|
||||
## Shared Packages
|
||||
|
||||
Packages in `packages/` are shared across all applications:
|
||||
|
||||
### Authentication
|
||||
- `@manacore/shared-auth` - Client-side auth utilities
|
||||
- `@manacore/shared-nestjs-auth` - NestJS JWT guards
|
||||
- `@mana-core/nestjs-integration` - Full NestJS auth + credits module
|
||||
|
||||
### UI & Styling
|
||||
- `@manacore/shared-ui` - React Native components
|
||||
- `@manacore/shared-landing-ui` - Astro landing page components
|
||||
- `@manacore/shared-theme` - Theme configuration
|
||||
- `@manacore/shared-tailwind` - Tailwind presets
|
||||
|
||||
### Data & Utilities
|
||||
- `@manacore/shared-types` - Common TypeScript types
|
||||
- `@manacore/shared-utils` - Utility functions
|
||||
- `@manacore/shared-storage` - S3 storage utilities
|
||||
- `@manacore/shared-i18n` - Internationalization
|
||||
|
||||
## Workspace Configuration
|
||||
|
||||
### pnpm-workspace.yaml
|
||||
|
||||
```yaml
|
||||
packages:
|
||||
- 'apps/*'
|
||||
- 'apps/*/apps/*'
|
||||
- 'apps/*/packages/*'
|
||||
- 'services/*'
|
||||
- 'packages/*'
|
||||
```
|
||||
|
||||
### turbo.json
|
||||
|
||||
Turborepo handles task orchestration with:
|
||||
- **Parallel execution** for independent tasks
|
||||
- **Dependency ordering** for build tasks
|
||||
- **Caching** for faster rebuilds
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
| Type | Pattern | Example |
|
||||
|------|---------|---------|
|
||||
| App package | `@{project}/{app}` | `@chat/backend` |
|
||||
| Shared package | `@manacore/shared-{name}` | `@manacore/shared-auth` |
|
||||
| Service | `@mana-{name}/service` | `@mana-search/service` |
|
||||
|
||||
## Adding a New Project
|
||||
|
||||
1. Create the directory structure:
|
||||
```bash
|
||||
mkdir -p apps/myproject/apps/{backend,web,landing}
|
||||
```
|
||||
|
||||
2. Add a `CLAUDE.md` with project documentation
|
||||
|
||||
3. Create `package.json` files for each app
|
||||
|
||||
4. Add commands to root `package.json`
|
||||
|
||||
5. Configure in `turbo.json` if needed
|
||||
|
||||
See the existing projects as templates.
|
||||
128
apps/docs/src/content/docs/getting-started/quick-start.mdx
Normal file
128
apps/docs/src/content/docs/getting-started/quick-start.mdx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
---
|
||||
title: Quick Start
|
||||
description: Get up and running with Manacore in minutes.
|
||||
---
|
||||
|
||||
import { Steps, Tabs, TabItem, Code } from '@astrojs/starlight/components';
|
||||
|
||||
# Quick Start
|
||||
|
||||
Get the Manacore monorepo running locally in just a few steps.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have:
|
||||
|
||||
- **Node.js** 20 or higher
|
||||
- **pnpm** 9.15.0 (`npm install -g pnpm@9.15.0`)
|
||||
- **Docker** and Docker Compose
|
||||
- **Git**
|
||||
|
||||
## Installation
|
||||
|
||||
<Steps>
|
||||
1. **Clone the repository**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/manacore/manacore-monorepo.git
|
||||
cd manacore-monorepo
|
||||
```
|
||||
|
||||
2. **Install dependencies**
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. **Start infrastructure services**
|
||||
|
||||
This starts PostgreSQL, Redis, and MinIO (S3-compatible storage):
|
||||
|
||||
```bash
|
||||
pnpm docker:up
|
||||
```
|
||||
|
||||
4. **Setup environment variables**
|
||||
|
||||
Generate environment files for all apps:
|
||||
|
||||
```bash
|
||||
pnpm setup:env
|
||||
```
|
||||
|
||||
5. **Start an application**
|
||||
|
||||
Use `dev:*:full` commands for the best experience - they automatically set up databases:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Chat">
|
||||
```bash
|
||||
pnpm dev:chat:full
|
||||
```
|
||||
Opens at `http://localhost:5173` (web) and `http://localhost:3002` (API)
|
||||
</TabItem>
|
||||
<TabItem label="Zitare">
|
||||
```bash
|
||||
pnpm dev:zitare:full
|
||||
```
|
||||
Opens at `http://localhost:5174` (web) and `http://localhost:3007` (API)
|
||||
</TabItem>
|
||||
<TabItem label="Picture">
|
||||
```bash
|
||||
pnpm dev:picture:full
|
||||
```
|
||||
Opens at `http://localhost:5175` (web)
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
</Steps>
|
||||
|
||||
## What `dev:*:full` Does
|
||||
|
||||
The `full` commands automatically:
|
||||
|
||||
1. **Create the database** if it doesn't exist
|
||||
2. **Push the latest schema** with Drizzle
|
||||
3. **Start Mana Core Auth** (authentication service)
|
||||
4. **Start the backend** (NestJS API)
|
||||
5. **Start the web app** (SvelteKit)
|
||||
|
||||
All processes run concurrently with color-coded output.
|
||||
|
||||
## Verify Installation
|
||||
|
||||
Once running, you should be able to:
|
||||
|
||||
1. **Open the web app** at the URL shown in the terminal
|
||||
2. **Check the API health** endpoint:
|
||||
```bash
|
||||
curl http://localhost:3002/api/v1/health
|
||||
```
|
||||
3. **Access MinIO console** at `http://localhost:9001` (user: `minioadmin`, password: `minioadmin`)
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Learn about the [Project Structure](/getting-started/project-structure)
|
||||
- Configure [Environment Variables](/development/environment-variables)
|
||||
- Understand the [Architecture](/architecture/overview)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Docker not running
|
||||
```bash
|
||||
# Make sure Docker Desktop is running, then:
|
||||
pnpm docker:up
|
||||
```
|
||||
|
||||
### Port already in use
|
||||
Check what's using the port and stop it:
|
||||
```bash
|
||||
lsof -i :3002 # Check port 3002
|
||||
kill -9 <PID> # Stop the process
|
||||
```
|
||||
|
||||
### Database connection issues
|
||||
Ensure PostgreSQL is running:
|
||||
```bash
|
||||
docker ps # Should show postgres container
|
||||
pnpm docker:logs postgres # Check logs
|
||||
```
|
||||
305
apps/docs/src/content/docs/guidelines/code-style.mdx
Normal file
305
apps/docs/src/content/docs/guidelines/code-style.mdx
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
---
|
||||
title: Code Style
|
||||
description: Coding standards and formatting conventions for Manacore.
|
||||
---
|
||||
|
||||
import { Aside, Tabs, TabItem } from '@astrojs/starlight/components';
|
||||
|
||||
# Code Style
|
||||
|
||||
Consistent code style across the monorepo ensures readability and maintainability.
|
||||
|
||||
## Formatting
|
||||
|
||||
All projects use the same base formatting rules:
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Indentation | Tabs (displayed as 2 spaces) |
|
||||
| Quotes | Single quotes |
|
||||
| Line width | 100 characters |
|
||||
| Semicolons | Yes |
|
||||
| Trailing commas | ES5 style |
|
||||
|
||||
### Prettier Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"useTabs": true,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"semi": true,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Files and Directories
|
||||
|
||||
| Type | Convention | Example |
|
||||
|------|------------|---------|
|
||||
| Components | PascalCase | `UserProfile.tsx`, `Button.svelte` |
|
||||
| Utilities | camelCase | `formatDate.ts`, `cn.ts` |
|
||||
| Types | camelCase | `user.types.ts` |
|
||||
| Constants | SCREAMING_SNAKE | `constants.ts` (values inside) |
|
||||
| Test files | `*.spec.ts` or `*.test.ts` | `users.service.spec.ts` |
|
||||
|
||||
### Code
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="TypeScript">
|
||||
```typescript
|
||||
// Classes - PascalCase
|
||||
class UserService {}
|
||||
|
||||
// Interfaces - PascalCase, no "I" prefix
|
||||
interface User {}
|
||||
interface CreateUserDto {}
|
||||
|
||||
// Types - PascalCase
|
||||
type UserRole = 'admin' | 'user';
|
||||
|
||||
// Functions - camelCase
|
||||
function getUserById(id: string) {}
|
||||
|
||||
// Variables - camelCase
|
||||
const currentUser = null;
|
||||
|
||||
// Constants - SCREAMING_SNAKE_CASE
|
||||
const MAX_RETRIES = 3;
|
||||
const API_BASE_URL = '/api';
|
||||
|
||||
// Enums - PascalCase (values too)
|
||||
enum UserStatus {
|
||||
Active = 'active',
|
||||
Inactive = 'inactive',
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="Svelte">
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
// Props - camelCase
|
||||
let { userName, onUpdate }: Props = $props();
|
||||
|
||||
// State - camelCase
|
||||
let isLoading = $state(false);
|
||||
let userData = $state<User | null>(null);
|
||||
|
||||
// Derived - camelCase
|
||||
let fullName = $derived(`${userData?.first} ${userData?.last}`);
|
||||
|
||||
// Functions - camelCase
|
||||
function handleSubmit() {}
|
||||
</script>
|
||||
|
||||
<!-- Events - on:eventname (lowercase) -->
|
||||
<button onclick={handleSubmit}>Submit</button>
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="React Native">
|
||||
```tsx
|
||||
// Components - PascalCase
|
||||
function UserProfile({ user }: Props) {
|
||||
// Hooks - use prefix
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Handlers - handle prefix
|
||||
const handlePress = () => {};
|
||||
|
||||
return <View />;
|
||||
}
|
||||
|
||||
// Styles - camelCase
|
||||
const styles = StyleSheet.create({
|
||||
container: {},
|
||||
headerText: {},
|
||||
});
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## 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<User | null> {
|
||||
// ...
|
||||
}
|
||||
|
||||
// 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<T>(data: T) {}
|
||||
function processData(data: unknown) {}
|
||||
function processData(data: Record<string, unknown>) {}
|
||||
```
|
||||
|
||||
## 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<User | null> {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 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<T, E = Error> =
|
||||
| { ok: true; value: T }
|
||||
| { ok: false; error: E };
|
||||
|
||||
// Usage
|
||||
async function findUser(id: string): Promise<Result<User, 'NOT_FOUND'>> {
|
||||
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
|
||||
|
||||
<Aside type="tip">
|
||||
1. **Keep functions small** - Under 50 lines ideally
|
||||
2. **Single responsibility** - One function, one job
|
||||
3. **Descriptive names** - `getUserByEmail` not `getUser2`
|
||||
4. **Early returns** - Reduce nesting
|
||||
5. **Avoid magic numbers** - Use named constants
|
||||
6. **Immutability** - Prefer `const`, avoid mutations
|
||||
</Aside>
|
||||
|
||||
### 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
|
||||
}
|
||||
```
|
||||
412
apps/docs/src/content/docs/guidelines/database.mdx
Normal file
412
apps/docs/src/content/docs/guidelines/database.mdx
Normal file
|
|
@ -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<UserSettings>().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
|
||||
|
||||
<Aside type="tip">
|
||||
1. **Always use UUIDs** for primary keys
|
||||
2. **Include audit fields** (createdAt, updatedAt) on all tables
|
||||
3. **Prefer soft deletes** for important data
|
||||
4. **Use transactions** for multi-step operations
|
||||
5. **Add indexes** for frequently queried columns
|
||||
6. **Use enums** for fixed sets of values
|
||||
7. **Type your JSON columns** with `$type<T>()`
|
||||
</Aside>
|
||||
322
apps/docs/src/content/docs/guidelines/design-ux.mdx
Normal file
322
apps/docs/src/content/docs/guidelines/design-ux.mdx
Normal file
|
|
@ -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
|
||||
<button className="bg-blue-500 hover:bg-blue-600 text-white">
|
||||
Submit
|
||||
</button>
|
||||
|
||||
// Success state
|
||||
<div className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
Success!
|
||||
</div>
|
||||
|
||||
// Error state
|
||||
<span className="text-red-500">Error message</span>
|
||||
|
||||
// Neutral backgrounds
|
||||
<div className="bg-white dark:bg-gray-900">
|
||||
<p className="text-gray-900 dark:text-gray-100">Content</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 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
|
||||
<p className="font-normal">Body text</p>
|
||||
|
||||
// Emphasis
|
||||
<p className="font-medium">Important text</p>
|
||||
|
||||
// Headings
|
||||
<h2 className="font-semibold">Section Title</h2>
|
||||
|
||||
// Strong emphasis
|
||||
<h1 className="font-bold">Page Title</h1>
|
||||
```
|
||||
|
||||
## 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
|
||||
<div className="p-4 space-y-3">
|
||||
<h3 className="text-lg font-semibold">Title</h3>
|
||||
<p className="text-gray-600">Description</p>
|
||||
<button className="mt-4">Action</button>
|
||||
</div>
|
||||
|
||||
// List with gaps
|
||||
<ul className="space-y-2">
|
||||
<li>Item 1</li>
|
||||
<li>Item 2</li>
|
||||
</ul>
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### Buttons
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Variants">
|
||||
```tsx
|
||||
// Primary - main actions
|
||||
<button className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-lg">
|
||||
Primary
|
||||
</button>
|
||||
|
||||
// Secondary - alternative actions
|
||||
<button className="bg-gray-200 hover:bg-gray-300 text-gray-900 px-4 py-2 rounded-lg">
|
||||
Secondary
|
||||
</button>
|
||||
|
||||
// Ghost - subtle actions
|
||||
<button className="hover:bg-gray-100 text-gray-700 px-4 py-2 rounded-lg">
|
||||
Ghost
|
||||
</button>
|
||||
|
||||
// Destructive - dangerous actions
|
||||
<button className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-lg">
|
||||
Delete
|
||||
</button>
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="States">
|
||||
```tsx
|
||||
// Disabled
|
||||
<button className="bg-gray-300 text-gray-500 cursor-not-allowed" disabled>
|
||||
Disabled
|
||||
</button>
|
||||
|
||||
// Loading
|
||||
<button className="bg-blue-500 text-white flex items-center gap-2" disabled>
|
||||
<Spinner className="w-4 h-4 animate-spin" />
|
||||
Loading...
|
||||
</button>
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Cards
|
||||
|
||||
```tsx
|
||||
// Basic card
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h3 className="font-semibold">Card Title</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">Card content</p>
|
||||
</div>
|
||||
|
||||
// Interactive card
|
||||
<button className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4 hover:shadow-md hover:border-blue-300 transition-all w-full text-left">
|
||||
<h3 className="font-semibold">Clickable Card</h3>
|
||||
</button>
|
||||
```
|
||||
|
||||
### Forms
|
||||
|
||||
```tsx
|
||||
// Input
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Enter text..."
|
||||
/>
|
||||
|
||||
// With label and error
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
className="w-full px-3 py-2 border border-red-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
/>
|
||||
<p className="text-sm text-red-500">Invalid email address</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Loading States
|
||||
|
||||
### Skeletons
|
||||
|
||||
```tsx
|
||||
// Text skeleton
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2 mt-2"></div>
|
||||
</div>
|
||||
|
||||
// Card skeleton
|
||||
<div className="animate-pulse p-4 border rounded-xl">
|
||||
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-1/3 mb-4"></div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Spinners
|
||||
|
||||
```tsx
|
||||
// Simple spinner
|
||||
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
|
||||
|
||||
// Full page loading
|
||||
<div className="fixed inset-0 bg-white/80 dark:bg-gray-900/80 flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-3 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Animations
|
||||
|
||||
### Transitions
|
||||
|
||||
```tsx
|
||||
// Fade in
|
||||
<div className="transition-opacity duration-200 opacity-0 data-[visible=true]:opacity-100">
|
||||
|
||||
// Slide up
|
||||
<div className="transition-all duration-200 translate-y-2 opacity-0 data-[visible=true]:translate-y-0 data-[visible=true]:opacity-100">
|
||||
|
||||
// Scale
|
||||
<button className="transition-transform hover:scale-105 active:scale-95">
|
||||
```
|
||||
|
||||
### Motion Guidelines
|
||||
|
||||
- **Duration**: 150-300ms for micro-interactions, 300-500ms for larger transitions
|
||||
- **Easing**: Use `ease-out` for entrances, `ease-in` for exits
|
||||
- **Purpose**: Every animation should have a purpose (feedback, guidance, delight)
|
||||
|
||||
## Accessibility
|
||||
|
||||
<Aside type="caution">
|
||||
Accessibility is not optional. All apps must meet WCAG 2.1 AA standards.
|
||||
</Aside>
|
||||
|
||||
### Requirements
|
||||
|
||||
1. **Color contrast** - 4.5:1 for text, 3:1 for large text
|
||||
2. **Focus indicators** - Visible focus rings on all interactive elements
|
||||
3. **Keyboard navigation** - All features usable without mouse
|
||||
4. **Screen readers** - Proper ARIA labels and semantic HTML
|
||||
5. **Motion** - Respect `prefers-reduced-motion`
|
||||
|
||||
### Implementation
|
||||
|
||||
```tsx
|
||||
// Focus visible
|
||||
<button className="focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500">
|
||||
|
||||
// Screen reader text
|
||||
<button aria-label="Close menu">
|
||||
<XIcon className="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
// Reduced motion
|
||||
<div className="motion-safe:animate-bounce motion-reduce:animate-none">
|
||||
|
||||
// Skip link
|
||||
<a href="#main" className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4">
|
||||
Skip to main content
|
||||
</a>
|
||||
```
|
||||
|
||||
## Dark Mode
|
||||
|
||||
All components must support dark mode:
|
||||
|
||||
```tsx
|
||||
// Background
|
||||
<div className="bg-white dark:bg-gray-900">
|
||||
|
||||
// Text
|
||||
<p className="text-gray-900 dark:text-gray-100">
|
||||
|
||||
// Borders
|
||||
<div className="border-gray-200 dark:border-gray-700">
|
||||
|
||||
// Hover states
|
||||
<button className="hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||
```
|
||||
|
||||
## Responsive Design
|
||||
|
||||
Mobile-first approach with breakpoints:
|
||||
|
||||
| Breakpoint | Min Width | Usage |
|
||||
|------------|-----------|-------|
|
||||
| (default) | 0px | Mobile |
|
||||
| `sm:` | 640px | Large phones |
|
||||
| `md:` | 768px | Tablets |
|
||||
| `lg:` | 1024px | Laptops |
|
||||
| `xl:` | 1280px | Desktops |
|
||||
|
||||
```tsx
|
||||
// Responsive grid
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
|
||||
// Responsive padding
|
||||
<div className="p-4 md:p-6 lg:p-8">
|
||||
|
||||
// Hide/show
|
||||
<div className="hidden md:block">Desktop only</div>
|
||||
<div className="md:hidden">Mobile only</div>
|
||||
```
|
||||
312
apps/docs/src/content/docs/guidelines/error-handling.mdx
Normal file
312
apps/docs/src/content/docs/guidelines/error-handling.mdx
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
---
|
||||
title: Error Handling
|
||||
description: Go-style Result types and error handling patterns in Manacore.
|
||||
---
|
||||
|
||||
import { Aside, Tabs, TabItem } from '@astrojs/starlight/components';
|
||||
|
||||
# Error Handling
|
||||
|
||||
Manacore uses **Go-style Result types** for explicit error handling, avoiding unexpected exceptions.
|
||||
|
||||
## Result Type Pattern
|
||||
|
||||
### Definition
|
||||
|
||||
```typescript
|
||||
// types/result.ts
|
||||
export type Result<T, E = Error> =
|
||||
| { ok: true; value: T }
|
||||
| { ok: false; error: E };
|
||||
|
||||
// Helper functions
|
||||
export const ok = <T>(value: T): Result<T, never> => ({
|
||||
ok: true,
|
||||
value,
|
||||
});
|
||||
|
||||
export const err = <E>(error: E): Result<never, E> => ({
|
||||
ok: false,
|
||||
error,
|
||||
});
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```typescript
|
||||
import { Result, ok, err } from '../types/result';
|
||||
|
||||
// Define error types
|
||||
type UserError = 'NOT_FOUND' | 'INVALID_EMAIL' | 'ALREADY_EXISTS';
|
||||
|
||||
// Function with Result return type
|
||||
async function findUserByEmail(email: string): Promise<Result<User, UserError>> {
|
||||
if (!isValidEmail(email)) {
|
||||
return err('INVALID_EMAIL');
|
||||
}
|
||||
|
||||
const user = await db.users.findByEmail(email);
|
||||
|
||||
if (!user) {
|
||||
return err('NOT_FOUND');
|
||||
}
|
||||
|
||||
return ok(user);
|
||||
}
|
||||
|
||||
// Calling the function
|
||||
const result = await findUserByEmail('user@example.com');
|
||||
|
||||
if (!result.ok) {
|
||||
switch (result.error) {
|
||||
case 'NOT_FOUND':
|
||||
throw new NotFoundException('User not found');
|
||||
case 'INVALID_EMAIL':
|
||||
throw new BadRequestException('Invalid email format');
|
||||
case 'ALREADY_EXISTS':
|
||||
throw new ConflictException('User already exists');
|
||||
}
|
||||
}
|
||||
|
||||
// TypeScript knows result.value is User here
|
||||
return result.value;
|
||||
```
|
||||
|
||||
## Why Result Types?
|
||||
|
||||
### Problems with Exceptions
|
||||
|
||||
```typescript
|
||||
// Bad - caller doesn't know what can throw
|
||||
async function getUser(id: string): Promise<User> {
|
||||
const user = await db.users.find(id);
|
||||
if (!user) throw new Error('Not found'); // Hidden!
|
||||
return user;
|
||||
}
|
||||
|
||||
// Caller has no idea this can throw
|
||||
const user = await getUser(id); // 💥 might explode
|
||||
```
|
||||
|
||||
### Benefits of Results
|
||||
|
||||
```typescript
|
||||
// Good - explicit about possible failures
|
||||
async function getUser(id: string): Promise<Result<User, 'NOT_FOUND'>> {
|
||||
const user = await db.users.find(id);
|
||||
if (!user) return err('NOT_FOUND');
|
||||
return ok(user);
|
||||
}
|
||||
|
||||
// Caller MUST handle the error
|
||||
const result = await getUser(id);
|
||||
if (!result.ok) {
|
||||
// Handle error
|
||||
}
|
||||
// TypeScript ensures result.value exists here
|
||||
```
|
||||
|
||||
## Error Type Design
|
||||
|
||||
### Use String Literals
|
||||
|
||||
```typescript
|
||||
// Good - specific, exhaustive
|
||||
type CreateUserError =
|
||||
| 'EMAIL_TAKEN'
|
||||
| 'INVALID_EMAIL'
|
||||
| 'WEAK_PASSWORD'
|
||||
| 'RATE_LIMITED';
|
||||
|
||||
// Bad - too generic
|
||||
type CreateUserError = Error | string;
|
||||
```
|
||||
|
||||
### Include Context When Needed
|
||||
|
||||
```typescript
|
||||
type ValidationError = {
|
||||
code: 'VALIDATION_FAILED';
|
||||
field: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type DatabaseError = {
|
||||
code: 'DATABASE_ERROR';
|
||||
originalError: Error;
|
||||
};
|
||||
|
||||
type CreateUserError = ValidationError | DatabaseError | 'EMAIL_TAKEN';
|
||||
|
||||
// Usage
|
||||
function validateUser(data: unknown): Result<UserDto, ValidationError> {
|
||||
if (!data.email) {
|
||||
return err({
|
||||
code: 'VALIDATION_FAILED',
|
||||
field: 'email',
|
||||
message: 'Email is required',
|
||||
});
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern in Services
|
||||
|
||||
### Service Layer
|
||||
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
async create(dto: CreateUserDto): Promise<Result<User, CreateUserError>> {
|
||||
// Check for existing user
|
||||
const existing = await this.findByEmail(dto.email);
|
||||
if (existing.ok) {
|
||||
return err('EMAIL_TAKEN');
|
||||
}
|
||||
|
||||
// Validate password
|
||||
if (!this.isStrongPassword(dto.password)) {
|
||||
return err('WEAK_PASSWORD');
|
||||
}
|
||||
|
||||
// Create user
|
||||
try {
|
||||
const user = await this.db.insert(users).values(dto).returning();
|
||||
return ok(user[0]);
|
||||
} catch (e) {
|
||||
return err('DATABASE_ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Result<User, 'NOT_FOUND'>> {
|
||||
const [user] = await this.db.select().from(users).where(eq(users.id, id));
|
||||
return user ? ok(user) : err('NOT_FOUND');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Controller Layer
|
||||
|
||||
```typescript
|
||||
@Controller('api/v1/users')
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
@Post()
|
||||
async create(@Body() dto: CreateUserDto) {
|
||||
const result = await this.usersService.create(dto);
|
||||
|
||||
if (!result.ok) {
|
||||
switch (result.error) {
|
||||
case 'EMAIL_TAKEN':
|
||||
throw new ConflictException('Email already registered');
|
||||
case 'WEAK_PASSWORD':
|
||||
throw new BadRequestException('Password too weak');
|
||||
case 'DATABASE_ERROR':
|
||||
throw new InternalServerErrorException('Failed to create user');
|
||||
}
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Combining Results
|
||||
|
||||
### Sequential Operations
|
||||
|
||||
```typescript
|
||||
async function processOrder(orderId: string): Promise<Result<Order, OrderError>> {
|
||||
// Step 1: Find order
|
||||
const orderResult = await findOrder(orderId);
|
||||
if (!orderResult.ok) return orderResult;
|
||||
|
||||
// Step 2: Validate
|
||||
const validationResult = validateOrder(orderResult.value);
|
||||
if (!validationResult.ok) return validationResult;
|
||||
|
||||
// Step 3: Process payment
|
||||
const paymentResult = await processPayment(orderResult.value);
|
||||
if (!paymentResult.ok) return paymentResult;
|
||||
|
||||
// All successful
|
||||
return ok(orderResult.value);
|
||||
}
|
||||
```
|
||||
|
||||
### Parallel Operations
|
||||
|
||||
```typescript
|
||||
async function getUserWithPosts(
|
||||
userId: string
|
||||
): Promise<Result<{ user: User; posts: Post[] }, 'USER_NOT_FOUND' | 'POSTS_FAILED'>> {
|
||||
const [userResult, postsResult] = await Promise.all([
|
||||
findUser(userId),
|
||||
findPosts(userId),
|
||||
]);
|
||||
|
||||
if (!userResult.ok) return err('USER_NOT_FOUND');
|
||||
if (!postsResult.ok) return err('POSTS_FAILED');
|
||||
|
||||
return ok({
|
||||
user: userResult.value,
|
||||
posts: postsResult.value,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## When to Throw
|
||||
|
||||
<Aside type="caution">
|
||||
Use exceptions only for **truly exceptional** situations:
|
||||
</Aside>
|
||||
|
||||
```typescript
|
||||
// Throw - configuration/programming errors
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error('DATABASE_URL must be set');
|
||||
}
|
||||
|
||||
// Throw - assertion violations (should never happen)
|
||||
if (user.id !== request.userId) {
|
||||
throw new Error('Invariant violated: user mismatch');
|
||||
}
|
||||
|
||||
// Result - expected business cases
|
||||
if (!user) {
|
||||
return err('NOT_FOUND'); // Expected - user might not exist
|
||||
}
|
||||
```
|
||||
|
||||
## HTTP Error Mapping
|
||||
|
||||
| Result Error | HTTP Status | NestJS Exception |
|
||||
|--------------|-------------|------------------|
|
||||
| `NOT_FOUND` | 404 | `NotFoundException` |
|
||||
| `UNAUTHORIZED` | 401 | `UnauthorizedException` |
|
||||
| `FORBIDDEN` | 403 | `ForbiddenException` |
|
||||
| `VALIDATION_FAILED` | 400 | `BadRequestException` |
|
||||
| `CONFLICT` / `ALREADY_EXISTS` | 409 | `ConflictException` |
|
||||
| `RATE_LIMITED` | 429 | `ThrottlerException` |
|
||||
| `DATABASE_ERROR` | 500 | `InternalServerErrorException` |
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Be specific** - Use descriptive error codes, not generic strings
|
||||
2. **Exhaustive handling** - Handle all error cases with `switch`
|
||||
3. **Don't swallow errors** - Always handle or propagate
|
||||
4. **Document errors** - List possible errors in JSDoc
|
||||
5. **Keep errors at boundaries** - Convert Results to HTTP errors in controllers
|
||||
57
apps/docs/src/content/docs/index.mdx
Normal file
57
apps/docs/src/content/docs/index.mdx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
---
|
||||
title: Manacore Documentation
|
||||
description: Documentation for the Manacore ecosystem - a multi-app platform with shared infrastructure.
|
||||
template: splash
|
||||
hero:
|
||||
tagline: Build modern applications with shared infrastructure
|
||||
image:
|
||||
file: ../../assets/logo-light.svg
|
||||
actions:
|
||||
- text: Get Started
|
||||
link: /getting-started/introduction/
|
||||
icon: right-arrow
|
||||
variant: primary
|
||||
- text: View on GitHub
|
||||
link: https://github.com/manacore/manacore-monorepo
|
||||
icon: external
|
||||
---
|
||||
|
||||
import { Card, CardGrid } from '@astrojs/starlight/components';
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/manacore/manacore-monorepo.git
|
||||
cd manacore-monorepo
|
||||
pnpm install
|
||||
pnpm docker:up
|
||||
pnpm dev:chat:full
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
<CardGrid stagger>
|
||||
<Card title="Shared Authentication" icon="shield">
|
||||
Central auth service with EdDSA JWT tokens across all apps.
|
||||
</Card>
|
||||
<Card title="Multiple Apps" icon="puzzle">
|
||||
Chat, Picture, Zitare, ManaDeck, and more - all sharing infrastructure.
|
||||
</Card>
|
||||
<Card title="Modern Stack" icon="rocket">
|
||||
SvelteKit, Expo, NestJS, Drizzle ORM, Tailwind CSS.
|
||||
</Card>
|
||||
<Card title="Easy Deployment" icon="cloud">
|
||||
Deploy to Cloudflare Pages, Docker, or self-host.
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
## Projects
|
||||
|
||||
| Project | Description |
|
||||
|---------|-------------|
|
||||
| **Chat** | AI chat with multiple models |
|
||||
| **Picture** | AI image generation |
|
||||
| **Zitare** | Daily inspiration quotes |
|
||||
| **ManaDeck** | Card & deck management |
|
||||
| **Contacts** | Contact management |
|
||||
| **Calendar** | Calendar & scheduling |
|
||||
171
apps/docs/src/content/docs/projects/chat.mdx
Normal file
171
apps/docs/src/content/docs/projects/chat.mdx
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
---
|
||||
title: Chat
|
||||
description: AI chat application with multiple models.
|
||||
---
|
||||
|
||||
import { Aside, Tabs, TabItem, Badge } from '@astrojs/starlight/components';
|
||||
|
||||
# Chat
|
||||
|
||||
<Badge text="Stable" variant="success" />
|
||||
|
||||
AI chat application supporting multiple language models including local (Ollama) and cloud providers (OpenRouter).
|
||||
|
||||
## Overview
|
||||
|
||||
| Component | Technology | Port |
|
||||
|-----------|------------|------|
|
||||
| Backend | NestJS | 3002 |
|
||||
| Web | SvelteKit | 5173 |
|
||||
| Mobile | Expo | - |
|
||||
| Landing | Astro | - |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Start everything (auth + database + backend + web)
|
||||
pnpm dev:chat:full
|
||||
|
||||
# Or individual components
|
||||
pnpm dev:chat:backend
|
||||
pnpm dev:chat:web
|
||||
pnpm dev:chat:mobile
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Client │────>│ Backend │────>│ AI Models │
|
||||
│ (Web/Mobile)│ │ (NestJS) │ │ Ollama/API │
|
||||
└─────────────┘ └──────┬──────┘ └─────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ PostgreSQL │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/v1/health` | GET | Health check |
|
||||
| `/api/v1/chat/models` | GET | List available AI models |
|
||||
| `/api/v1/chat/completions` | POST | Create chat completion |
|
||||
| `/api/v1/conversations` | GET | List user conversations |
|
||||
| `/api/v1/conversations/:id` | GET | Get conversation details |
|
||||
| `/api/v1/conversations/:id/messages` | GET | Get conversation messages |
|
||||
| `/api/v1/conversations` | POST | Create new conversation |
|
||||
| `/api/v1/conversations/:id/messages` | POST | Add message to conversation |
|
||||
|
||||
## AI Models
|
||||
|
||||
### Local Models (Ollama - Free)
|
||||
|
||||
| Model | Best For |
|
||||
|-------|----------|
|
||||
| Gemma 3 4B | Everyday tasks (default) |
|
||||
| Llama 3.2 | General conversation |
|
||||
| Mistral | Code assistance |
|
||||
|
||||
### Cloud Models (OpenRouter - Paid)
|
||||
|
||||
| Model | Price | Best For |
|
||||
|-------|-------|----------|
|
||||
| Llama 3.1 8B | $0.05/M | Fast cloud alternative |
|
||||
| Llama 3.1 70B | $0.35/M | Complex reasoning |
|
||||
| DeepSeek V3 | $0.14/M | Reasoning at low cost |
|
||||
| Claude 3.5 Sonnet | $3/M | Best quality |
|
||||
| GPT-4o Mini | $0.15/M | Balanced performance |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Backend
|
||||
|
||||
```env
|
||||
# AI Models
|
||||
OPENROUTER_API_KEY=sk-or-v1-xxx
|
||||
OLLAMA_URL=http://localhost:11434
|
||||
OLLAMA_TIMEOUT=120000
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/chat
|
||||
|
||||
# Auth
|
||||
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
|
||||
# Server
|
||||
PORT=3002
|
||||
```
|
||||
|
||||
### Web
|
||||
|
||||
```env
|
||||
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
PUBLIC_BACKEND_URL=http://localhost:3002
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
```typescript
|
||||
// Conversations
|
||||
export const conversations = pgTable('conversations', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').notNull(),
|
||||
title: varchar('title', { length: 255 }),
|
||||
modelId: varchar('model_id', { length: 100 }),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow(),
|
||||
});
|
||||
|
||||
// Messages
|
||||
export const messages = pgTable('messages', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
conversationId: uuid('conversation_id')
|
||||
.notNull()
|
||||
.references(() => conversations.id, { onDelete: 'cascade' }),
|
||||
role: varchar('role', { length: 20 }).notNull(), // 'user' | 'assistant'
|
||||
content: text('content').notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
});
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-model support** - Switch between local and cloud models
|
||||
- **Conversation history** - Persistent chat history
|
||||
- **Streaming responses** - Real-time AI responses
|
||||
- **Code highlighting** - Syntax highlighting in responses
|
||||
- **Markdown rendering** - Rich text formatting
|
||||
- **Mobile app** - Native iOS and Android app
|
||||
|
||||
## Development
|
||||
|
||||
### Seed Database
|
||||
|
||||
First time setup requires seeding AI models:
|
||||
|
||||
```bash
|
||||
pnpm --filter @chat/backend db:push
|
||||
pnpm --filter @chat/backend db:seed
|
||||
```
|
||||
|
||||
### Run Tests
|
||||
|
||||
```bash
|
||||
pnpm --filter @chat/backend test
|
||||
pnpm --filter @chat/backend test:e2e
|
||||
```
|
||||
|
||||
### Open Database GUI
|
||||
|
||||
```bash
|
||||
pnpm --filter @chat/backend db:studio
|
||||
```
|
||||
|
||||
## Links
|
||||
|
||||
- **Web App**: http://localhost:5173
|
||||
- **API**: http://localhost:3002
|
||||
- **Drizzle Studio**: http://localhost:4983
|
||||
99
apps/docs/src/content/docs/projects/index.mdx
Normal file
99
apps/docs/src/content/docs/projects/index.mdx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
---
|
||||
title: Projects
|
||||
description: Overview of all projects in the Manacore ecosystem.
|
||||
---
|
||||
|
||||
import { Card, CardGrid, Badge } from '@astrojs/starlight/components';
|
||||
|
||||
# Projects
|
||||
|
||||
Manacore contains multiple interconnected applications, each serving a specific purpose.
|
||||
|
||||
## Active Projects
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Chat" icon="comment">
|
||||
AI chat with multiple models (Ollama, OpenRouter)
|
||||
|
||||
**Stack**: NestJS, SvelteKit, Expo
|
||||
|
||||
[View Details](/projects/chat)
|
||||
</Card>
|
||||
|
||||
<Card title="Picture" icon="image">
|
||||
AI image generation
|
||||
|
||||
**Stack**: NestJS, SvelteKit, Expo
|
||||
|
||||
[View Details](/projects/picture)
|
||||
</Card>
|
||||
|
||||
<Card title="Zitare" icon="quote">
|
||||
Daily inspiration quotes
|
||||
|
||||
**Stack**: NestJS, SvelteKit, Expo
|
||||
|
||||
[View Details](/projects/zitare)
|
||||
</Card>
|
||||
|
||||
<Card title="ManaDeck" icon="card">
|
||||
Card & deck management
|
||||
|
||||
**Stack**: NestJS, SvelteKit, Expo
|
||||
|
||||
[View Details](/projects/manadeck)
|
||||
</Card>
|
||||
|
||||
<Card title="Contacts" icon="person">
|
||||
Contact management
|
||||
|
||||
**Stack**: NestJS, SvelteKit
|
||||
|
||||
[View Details](/projects/contacts)
|
||||
</Card>
|
||||
|
||||
<Card title="Calendar" icon="calendar">
|
||||
Calendar & scheduling
|
||||
|
||||
**Stack**: NestJS, SvelteKit
|
||||
|
||||
[View Details](/projects/calendar)
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
## Project Status
|
||||
|
||||
| Project | Backend | Web | Mobile | Status |
|
||||
|---------|---------|-----|--------|--------|
|
||||
| Chat | 3002 | 5173 | Yes | Stable |
|
||||
| Picture | 3006 | 5175 | Yes | Stable |
|
||||
| Zitare | 3007 | 5177 | Yes | Stable |
|
||||
| ManaDeck | 3009 | 5178 | Yes | Beta |
|
||||
| Contacts | 3015 | 5184 | No | Beta |
|
||||
| Calendar | 3014 | 5179 | No | Alpha |
|
||||
| Clock | 3017 | 5187 | No | Alpha |
|
||||
| Todo | 3018 | 5188 | No | Alpha |
|
||||
| Matrix | - | 5180 | No | Alpha |
|
||||
| Mail | - | 5190 | No | Alpha |
|
||||
|
||||
## Microservices
|
||||
|
||||
| Service | Port | Purpose |
|
||||
|---------|------|---------|
|
||||
| mana-core-auth | 3001 | Central authentication |
|
||||
| mana-search | 3021 | Web search & extraction |
|
||||
| mana-tts | 3031 | Text-to-speech |
|
||||
| mana-stt | 3032 | Speech-to-text |
|
||||
|
||||
## Start Any Project
|
||||
|
||||
```bash
|
||||
# Use dev:*:full for the best experience
|
||||
pnpm dev:chat:full
|
||||
pnpm dev:picture:full
|
||||
pnpm dev:zitare:full
|
||||
pnpm dev:contacts:full
|
||||
pnpm dev:calendar:full
|
||||
```
|
||||
|
||||
These commands automatically set up databases and start all required services.
|
||||
66
apps/docs/src/styles/custom.css
Normal file
66
apps/docs/src/styles/custom.css
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/* Manacore Docs - Custom Starlight Styles */
|
||||
|
||||
/* Override Starlight CSS variables */
|
||||
:root {
|
||||
--sl-color-accent-low: #0c4a6e;
|
||||
--sl-color-accent: #0ea5e9;
|
||||
--sl-color-accent-high: #bae6fd;
|
||||
--sl-color-white: #ffffff;
|
||||
--sl-color-gray-1: #f1f5f9;
|
||||
--sl-color-gray-2: #e2e8f0;
|
||||
--sl-color-gray-3: #cbd5e1;
|
||||
--sl-color-gray-4: #94a3b8;
|
||||
--sl-color-gray-5: #64748b;
|
||||
--sl-color-gray-6: #475569;
|
||||
--sl-color-black: #0f172a;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
--sl-color-accent-low: #082f49;
|
||||
--sl-color-accent: #0ea5e9;
|
||||
--sl-color-accent-high: #7dd3fc;
|
||||
--sl-color-white: #0f172a;
|
||||
--sl-color-gray-1: #1e293b;
|
||||
--sl-color-gray-2: #334155;
|
||||
--sl-color-gray-3: #475569;
|
||||
--sl-color-gray-4: #64748b;
|
||||
--sl-color-gray-5: #94a3b8;
|
||||
--sl-color-gray-6: #cbd5e1;
|
||||
--sl-color-black: #f8fafc;
|
||||
}
|
||||
|
||||
/* Custom font settings */
|
||||
html {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
code,
|
||||
pre {
|
||||
font-family: 'JetBrains Mono', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
/* Better code block styling */
|
||||
.expressive-code pre {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Sidebar improvements */
|
||||
.sidebar-content {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* Custom badge styles for project status */
|
||||
.badge-stable {
|
||||
background-color: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-beta {
|
||||
background-color: #f59e0b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-alpha {
|
||||
background-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
44
apps/docs/tailwind.config.mjs
Normal file
44
apps/docs/tailwind.config.mjs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import starlightPlugin from '@astrojs/starlight-tailwind';
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Manacore brand colors
|
||||
accent: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
200: '#bae6fd',
|
||||
300: '#7dd3fc',
|
||||
400: '#38bdf8',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
800: '#075985',
|
||||
900: '#0c4a6e',
|
||||
950: '#082f49',
|
||||
},
|
||||
gray: {
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
300: '#cbd5e1',
|
||||
400: '#94a3b8',
|
||||
500: '#64748b',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1e293b',
|
||||
900: '#0f172a',
|
||||
950: '#020617',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'Menlo', 'monospace'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [starlightPlugin()],
|
||||
};
|
||||
9
apps/docs/tsconfig.json
Normal file
9
apps/docs/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
6
apps/docs/wrangler.toml
Normal file
6
apps/docs/wrangler.toml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Cloudflare Pages configuration for Manacore Docs
|
||||
# Deployed via GitHub Actions (Direct Upload)
|
||||
|
||||
name = "manacore-docs"
|
||||
compatibility_date = "2024-12-01"
|
||||
pages_build_output_dir = "dist"
|
||||
|
|
@ -209,6 +209,9 @@
|
|||
"deploy:landing:mail": "pnpm --filter @mail/landing build && npx wrangler pages deploy apps/mail/apps/landing/dist --project-name=mail-landing",
|
||||
"deploy:landing:moodlit": "pnpm --filter @moodlit/landing build && npx wrangler pages deploy apps/moodlit/apps/landing/dist --project-name=moodlit-landing",
|
||||
"deploy:landing:all": "pnpm deploy:landing:calendar && pnpm deploy:landing:chat && pnpm deploy:landing:picture && pnpm deploy:landing:manacore && pnpm deploy:landing:manadeck && pnpm deploy:landing:zitare && pnpm deploy:landing:presi && pnpm deploy:landing:clock && pnpm deploy:landing:mail && pnpm deploy:landing:nutriphi",
|
||||
"dev:docs": "pnpm --filter @manacore/docs dev",
|
||||
"build:docs": "pnpm --filter @manacore/docs build",
|
||||
"deploy:docs": "pnpm --filter @manacore/docs build && npx wrangler pages deploy apps/docs/dist --project-name=manacore-docs",
|
||||
"cf:login": "npx wrangler login",
|
||||
"cf:projects:list": "npx wrangler pages project list",
|
||||
"cf:projects:create": "echo 'Creating Cloudflare Pages projects...' && npx wrangler pages project create chat-landing --production-branch=main && npx wrangler pages project create picture-landing --production-branch=main && npx wrangler pages project create manacore-landing --production-branch=main && npx wrangler pages project create manadeck-landing --production-branch=main && npx wrangler pages project create zitare-landing --production-branch=main",
|
||||
|
|
|
|||
820
pnpm-lock.yaml
generated
820
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue