chore: remove all NestJS backend references, replace with Hono/Bun

- Delete nestjs-backend.md guideline (replaced by hono-server.md)
- Delete Dockerfile.nestjs-base and Dockerfile.nestjs templates
- Delete stale BACKEND_ARCHITECTURE.md doc (NestJS-era, obsolete)
- Update CLAUDE.md, GUIDELINES.md, authentication.md to Hono/Bun first
- Update all app CLAUDE.md files: backend/ → server/, NestJS → Hono+Bun
- Update all app package.json files: @*/backend → @*/server
- Update docs: LOCAL_DEVELOPMENT, PORT_SCHEMA, ENVIRONMENT_VARIABLES,
  DATABASE_MIGRATIONS, MAC_MINI_SERVER, PROJECT_OVERVIEW
- Update scripts: generate-env.mjs, setup-databases.sh, build-app.sh
- Update CI/CD: cd-macmini.yml backend → server paths
- Update Astro docs site: @chat/backend → @chat/server

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-31 16:52:25 +02:00
parent 708299b35e
commit ab387b9b3d
43 changed files with 598 additions and 2398 deletions

View file

@ -9,7 +9,7 @@ This directory contains comprehensive guidelines for working in the Mana Univers
| [Code Style](./guidelines/code-style.md) | Formatting, naming conventions, linting rules |
| [Database](./guidelines/database.md) | Drizzle ORM patterns, schema design, migrations |
| [Testing](./guidelines/testing.md) | Jest/Vitest patterns, mock factories, coverage |
| [NestJS Backend](./guidelines/nestjs-backend.md) | Controllers, services, DTOs, modules |
| [Hono Server](./guidelines/hono-server.md) | Compute servers (Hono + Bun) |
| [Error Handling](./guidelines/error-handling.md) | Go-style errors, error codes, Result types |
| [SvelteKit Web](./guidelines/sveltekit-web.md) | Svelte 5 runes, stores, routing |
| [Expo Mobile](./guidelines/expo-mobile.md) | React Native, NativeWind, navigation |
@ -44,7 +44,7 @@ This directory contains comprehensive guidelines for working in the Mana Univers
|-------|------------|-------|
| **Package Manager** | pnpm 9.15+ | Workspace monorepo |
| **Build System** | Turborepo | Parallel task execution |
| **Backend** | NestJS 10-11 | TypeScript, Drizzle ORM |
| **Server** | Hono + Bun | TypeScript, Drizzle ORM |
| **Web** | SvelteKit 2 + Svelte 5 | Runes mode only |
| **Mobile** | Expo SDK 52-54 | React Native, NativeWind |
| **Database** | PostgreSQL | Via Drizzle ORM |
@ -62,15 +62,15 @@ manacore-monorepo/
├── apps/ # Product applications
│ └── {project}/
│ ├── apps/
│ │ ├── backend/ # NestJS API
│ │ ├── server/ # Hono/Bun compute server
│ │ ├── web/ # SvelteKit web
│ │ ├── mobile/ # Expo app
│ │ └── landing/ # Astro landing
│ └── packages/ # Project-specific shared
├── packages/ # Monorepo-wide shared
│ ├── shared-errors/ # Error codes & Result types
│ ├── shared-nestjs-auth/ # NestJS auth guards
│ ├── shared-auth/ # Client auth service
│ ├── local-store/ # Local-first data layer (Dexie.js + sync)
│ └── ...
├── services/ # Standalone microservices
│ └── mana-core-auth/ # Central auth service
@ -114,7 +114,8 @@ See [Error Handling](./guidelines/error-handling.md) for complete details.
# Development
pnpm install # Install dependencies
pnpm {project}:dev # Start project (all apps)
pnpm dev:{project}:backend # Start just backend
pnpm dev:{project}:server # Start just Hono server
pnpm dev:{project}:local # Start sync + server + web (no auth)
pnpm dev:{project}:web # Start just web
# Quality
@ -122,7 +123,7 @@ pnpm type-check # TypeScript validation
pnpm format # Format code
pnpm test # Run tests
# Database
pnpm {project}:db:push # Push schema changes
pnpm {project}:db:studio # Open Drizzle Studio
# Database (for apps with Drizzle in server)
pnpm --filter @{project}/server db:push # Push schema changes
pnpm --filter @{project}/server db:studio # Open Drizzle Studio
```

View file

@ -8,8 +8,8 @@ All authentication is handled by **Mana Core Auth**, a centralized authenticatio
```
┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐
│ Web/Mobile │────>│ Backend API │────>│ mana-core-auth
│ Client │ │ (NestJS) │ │ (port 3001) │
│ Web/Mobile │────>│ Compute Server │────>│ mana-auth
│ Client │ │ (Hono/Bun) │ │ (port 3001) │
└─────────────────┘ └─────────────────┘ └──────────────────┘
│ │ │
│ 1. Login │ │
@ -85,101 +85,48 @@ Always use `text` type for `user_id` columns in all database schemas.
| Package | Purpose | Use Case |
|---------|---------|----------|
| `@manacore/shared-nestjs-auth` | NestJS guards/decorators | Backend APIs |
| `@mana-core/nestjs-integration` | Auth + Credits integration | Backends with credits |
| `@manacore/shared-hono` | Hono auth middleware + helpers | All compute servers (Hono/Bun) |
| `@manacore/shared-auth` | Client auth service | Web/Mobile apps |
| `@mana-core/nestjs-integration` | Auth + Credits for NestJS | `@arcade/backend` only |
## Backend Integration
## Server Integration (Hono/Bun)
### Option 1: Simple Auth Only
Use `@manacore/shared-nestjs-auth` for JWT validation:
All compute servers use `@manacore/shared-hono`:
```typescript
// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { Hono } from 'hono';
import { authMiddleware, healthRoute, errorHandler, notFoundHandler } from '@manacore/shared-hono';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
// No auth module needed - guards handle it
],
})
export class AppModule {}
const app = new Hono();
app.onError(errorHandler);
app.notFound(notFoundHandler);
app.route('/health', healthRoute('my-server'));
// Protect all /api/* routes
app.use('/api/*', authMiddleware());
// Access user in route handlers
app.get('/api/v1/data', (c) => {
const userId = c.get('userId'); // Better Auth user ID (not UUID)
const email = c.get('userEmail');
return c.json({ userId });
});
```
```typescript
// file.controller.ts
import { Controller, Get, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
### NestJS (arcade only)
@Controller('files')
@UseGuards(JwtAuthGuard) // Apply to all routes
export class FileController {
@Get()
async listFiles(@CurrentUser() user: CurrentUserData) {
// user.userId, user.email, user.role available
return this.fileService.findAll(user.userId);
}
}
```
### Option 2: Auth + Credits
Use `@mana-core/nestjs-integration` for full integration:
`@arcade/backend` still uses NestJS with `@mana-core/nestjs-integration`:
```typescript
// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ManaCoreModule } from '@mana-core/nestjs-integration';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ManaCoreModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
appId: config.get('APP_ID'),
serviceKey: config.get('MANA_CORE_SERVICE_KEY'),
debug: config.get('NODE_ENV') === 'development',
}),
inject: [ConfigService],
}),
],
})
export class AppModule {}
```
```typescript
// generation.controller.ts
import { Controller, Post, UseGuards, Body } from '@nestjs/common';
import { AuthGuard } from '@mana-core/nestjs-integration/guards';
import { CurrentUser } from '@mana-core/nestjs-integration/decorators';
import { CreditClientService } from '@mana-core/nestjs-integration';
@Controller('generations')
@Controller('api')
@UseGuards(AuthGuard)
export class GenerationController {
constructor(private creditClient: CreditClientService) {}
@Post()
async generate(@CurrentUser() user: any, @Body() dto: GenerateDto) {
// Check and consume credits
const result = await this.creditClient.consumeCredits(
user.sub,
'ai_generation',
10,
'AI image generation'
);
if (!result.ok) {
throw new AppException(result.error);
}
// Proceed with generation
return this.generationService.generate(user.sub, dto);
export class ApiController {
@Get('data')
getData(@CurrentUser() user: any) {
return this.service.findAll(user.sub);
}
}
```
@ -187,7 +134,7 @@ export class GenerationController {
## Environment Variables
```env
# Required for all backends
# Required for all servers
MANA_CORE_AUTH_URL=http://localhost:3001
# Development bypass (optional)

View file

@ -236,13 +236,12 @@ All SvelteKit web apps use **Phosphor icons** via `@manacore/shared-icons` (re-e
- **Ideal**: 10-25 lines
- Extract complex logic into helper functions
### Module Structure (NestJS)
### Module Structure (Hono/Bun Server)
```
feature/
├── feature.controller.ts # HTTP layer
├── feature.routes.ts # HTTP routes
├── feature.service.ts # Business logic
├── feature.module.ts # DI configuration
├── feature.spec.ts # Tests
└── dto/
├── create-feature.dto.ts
@ -340,5 +339,5 @@ pnpm format
pnpm format:check
# Format specific project
pnpm --filter @chat/backend format
pnpm --filter @chat/server format
```

View file

@ -0,0 +1,137 @@
# Hono Server Guidelines
## Overview
All app compute servers use Hono + Bun with a lightweight architecture. Servers handle only what can't run client-side: file uploads (S3), AI calls, RRULE expansion, external API integration, etc. All CRUD is handled client-side via local-first (Dexie.js + mana-sync).
## Project Structure
```
apps/{project}/apps/server/
├── src/
│ ├── index.ts # Entry point + route mounting
│ ├── routes/ # Route handlers
│ │ ├── {feature}.ts
│ │ └── admin.ts
│ ├── lib/ # Shared utilities
│ └── db/ # Drizzle schema (if needed)
│ ├── schema/
│ ├── connection.ts
│ └── drizzle.config.ts
├── package.json
└── tsconfig.json
```
## Entry Point (index.ts)
```typescript
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { authMiddleware, healthRoute, errorHandler, notFoundHandler } from '@manacore/shared-hono';
const PORT = parseInt(process.env.PORT || '3031', 10);
const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5173').split(',');
const app = new Hono();
// Standard middleware stack
app.onError(errorHandler);
app.notFound(notFoundHandler);
app.use('*', cors({ origin: CORS_ORIGINS, credentials: true }));
app.route('/health', healthRoute('my-server'));
app.use('/api/*', authMiddleware());
// Routes
app.route('/api/v1/compute', computeRoutes);
export default { port: PORT, fetch: app.fetch };
```
## Shared Hono Package
Use `@manacore/shared-hono` for consistent middleware across all servers:
```typescript
import {
authMiddleware, // JWT validation via mana-auth JWKS
healthRoute, // Standard /health endpoint
errorHandler, // Global error handler with logging
notFoundHandler, // 404 handler
} from '@manacore/shared-hono';
```
## Route Handlers
```typescript
// src/routes/upload.ts
import { Hono } from 'hono';
export const uploadRoutes = new Hono();
uploadRoutes.post('/avatar', async (c) => {
const userId = c.get('userId'); // Set by authMiddleware
const formData = await c.req.formData();
const file = formData.get('file') as File | null;
if (!file) return c.json({ error: 'No file' }, 400);
if (file.size > 5 * 1024 * 1024) return c.json({ error: 'Max 5MB' }, 400);
// ... handle upload
return c.json({ url: result.url }, 201);
});
```
## Database (Optional)
Only servers that need their own database use Drizzle. Most apps rely on mana-sync for data persistence.
**Servers with Drizzle:** chat, todo, moodlit, context, planta, presi, traces, uload, wisekeep, news
**Servers without Drizzle (mana-sync only):** calendar, contacts, manadeck, mukke, nutriphi, picture, questions, storage
## Running Servers
```bash
# Development (with watch)
cd apps/{project}/apps/server && bun run --watch src/index.ts
# Via root scripts
pnpm dev:{project}:server # Just the server
pnpm dev:{project}:local # sync + server + web (no auth needed)
pnpm dev:{project}:full # auth + sync + server + web
```
## When to Add a Server
Add a Hono server when the app needs:
- **File operations**: S3 uploads, image processing
- **AI/LLM calls**: Gemini, OpenAI, etc. (API keys can't be client-side)
- **External APIs**: Google Calendar sync, vCard parsing, etc.
- **Heavy compute**: RRULE expansion, PDF generation, etc.
- **Admin endpoints**: GDPR compliance, data export
Do NOT add a server for pure CRUD — use local-first + mana-sync instead.
## Environment Variables
Servers read `.env` from their directory (Bun auto-loads it):
```env
PORT=3031
CORS_ORIGINS=http://localhost:5173,http://localhost:5188
MANA_CORE_AUTH_URL=http://localhost:3001
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/myapp # if Drizzle
```
## Key Differences from Old NestJS Pattern
| Aspect | Old (NestJS) | New (Hono + Bun) |
| ----------- | ----------------------- | ------------------------------------- |
| Runtime | Node.js | Bun |
| Framework | NestJS (decorators, DI) | Hono (functional, minimal) |
| CRUD | Server handles all CRUD | Client-side (local-first + mana-sync) |
| Server role | Full backend | Compute-only endpoints |
| Auth | NestJS guards | `@manacore/shared-hono` middleware |
| Startup | `nest start --watch` | `bun run --watch src/index.ts` |
| Config | `ConfigModule` + DI | `process.env` directly |

View file

@ -1,659 +0,0 @@
# NestJS Backend Guidelines
## Overview
All backend services use NestJS with a consistent architecture. This guide covers controllers, services, DTOs, modules, and integration with the error handling system.
## Project Structure
```
apps/{project}/apps/backend/
├── src/
│ ├── main.ts # Bootstrap
│ ├── app.module.ts # Root module
│ ├── db/
│ │ ├── schema/ # Drizzle schemas
│ │ ├── connection.ts # DB singleton
│ │ ├── database.module.ts # NestJS module
│ │ └── migrations/ # Migration files
│ ├── common/
│ │ ├── filters/ # Exception filters
│ │ ├── guards/ # Custom guards
│ │ └── decorators/ # Custom decorators
│ ├── health/
│ │ ├── health.controller.ts
│ │ └── health.module.ts
│ └── {feature}/
│ ├── {feature}.controller.ts
│ ├── {feature}.service.ts
│ ├── {feature}.module.ts
│ ├── {feature}.spec.ts
│ └── dto/
│ ├── create-{feature}.dto.ts
│ └── update-{feature}.dto.ts
├── test/
│ ├── jest-e2e.json
│ └── app.e2e-spec.ts
├── drizzle.config.ts
├── nest-cli.json
├── package.json
└── tsconfig.json
```
## Bootstrap (main.ts)
```typescript
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { AppModule } from './app.module';
import { AppExceptionFilter } from './common/filters/app-exception.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const logger = new Logger('Bootstrap');
// CORS
const corsOrigins = process.env.CORS_ORIGINS?.split(',').map((o) => o.trim()) || [
'http://localhost:3000',
'http://localhost:5173',
'http://localhost:8081',
];
app.enableCors({
origin: corsOrigins,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
credentials: true,
});
// Global validation pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // Strip unknown properties
forbidNonWhitelisted: true, // Reject unknown properties
transform: true, // Auto-transform types
transformOptions: {
enableImplicitConversion: true,
},
})
);
// Global exception filter
app.useGlobalFilters(new AppExceptionFilter());
// API prefix
app.setGlobalPrefix('api/v1');
const port = process.env.PORT || 3000;
await app.listen(port);
logger.log(`Application running on http://localhost:${port}`);
}
bootstrap();
```
## App Module
```typescript
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './db/database.module';
import { HealthModule } from './health/health.module';
import { FileModule } from './file/file.module';
import { FolderModule } from './folder/folder.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
DatabaseModule,
HealthModule,
FileModule,
FolderModule,
],
})
export class AppModule {}
```
## Controllers
### Basic Pattern
```typescript
// src/file/file.controller.ts
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
Query,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { AppException } from '@manacore/shared-errors';
import { FileService } from './file.service';
import { CreateFileDto, UpdateFileDto, QueryFilesDto } from './dto';
@Controller('files')
@UseGuards(JwtAuthGuard) // Apply to all routes in controller
export class FileController {
constructor(private readonly fileService: FileService) {}
@Get()
async list(@CurrentUser() user: CurrentUserData, @Query() query: QueryFilesDto) {
const result = await this.fileService.findAll(user.userId, query);
if (!result.ok) throw new AppException(result.error);
return { files: result.data };
}
@Get(':id')
async getById(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: CurrentUserData) {
const result = await this.fileService.findById(id, user.userId);
if (!result.ok) throw new AppException(result.error);
return { file: result.data };
}
@Post()
async create(@Body() dto: CreateFileDto, @CurrentUser() user: CurrentUserData) {
const result = await this.fileService.create(user.userId, dto);
if (!result.ok) throw new AppException(result.error);
return { file: result.data };
}
@Patch(':id')
async update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateFileDto,
@CurrentUser() user: CurrentUserData
) {
const result = await this.fileService.update(id, user.userId, dto);
if (!result.ok) throw new AppException(result.error);
return { file: result.data };
}
@Delete(':id')
async delete(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: CurrentUserData) {
const result = await this.fileService.delete(id, user.userId);
if (!result.ok) throw new AppException(result.error);
return { success: true };
}
}
```
### Public Endpoints (No Auth)
```typescript
@Controller('public')
export class PublicController {
@Get('shares/:token') // No @UseGuards - public access
async getSharedItem(@Param('token') token: string) {
const result = await this.shareService.findByToken(token);
if (!result.ok) throw new AppException(result.error);
return { item: result.data };
}
}
```
## Services
### Basic Pattern with Result Types
```typescript
// src/file/file.service.ts
import { Injectable, Inject, Logger } from '@nestjs/common';
import { Result, ok, err, ErrorCode } from '@manacore/shared-errors';
import { DATABASE_CONNECTION, Database } from '../db/database.module';
import { files, File, NewFile } from '../db/schema';
import { eq, and, desc } from 'drizzle-orm';
import { CreateFileDto, UpdateFileDto, QueryFilesDto } from './dto';
@Injectable()
export class FileService {
private readonly logger = new Logger(FileService.name);
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findAll(userId: string, query: QueryFilesDto): Promise<Result<File[]>> {
try {
const conditions = [eq(files.userId, userId), eq(files.isDeleted, false)];
if (query.folderId) {
conditions.push(eq(files.parentFolderId, query.folderId));
}
const result = await this.db
.select()
.from(files)
.where(and(...conditions))
.orderBy(desc(files.createdAt))
.limit(query.limit ?? 50)
.offset(query.offset ?? 0);
return ok(result);
} catch (error) {
this.logger.error('Failed to fetch files', { userId, error: error.message });
return err(ErrorCode.DATABASE_ERROR, 'Failed to fetch files');
}
}
async findById(id: string, userId: string): Promise<Result<File>> {
try {
const [file] = await this.db
.select()
.from(files)
.where(and(eq(files.id, id), eq(files.userId, userId), eq(files.isDeleted, false)));
if (!file) {
return err(ErrorCode.FILE_NOT_FOUND, `File ${id} not found`);
}
return ok(file);
} catch (error) {
this.logger.error('Failed to fetch file', { id, userId, error: error.message });
return err(ErrorCode.DATABASE_ERROR, 'Failed to fetch file');
}
}
async create(userId: string, dto: CreateFileDto): Promise<Result<File>> {
// Validation
if (!dto.name?.trim()) {
return err(ErrorCode.MISSING_REQUIRED_FIELD, 'File name is required');
}
try {
const newFile: NewFile = {
userId,
name: dto.name.trim(),
originalName: dto.originalName,
mimeType: dto.mimeType,
size: dto.size,
storagePath: dto.storagePath,
storageKey: dto.storageKey,
parentFolderId: dto.folderId ?? null,
};
const [created] = await this.db.insert(files).values(newFile).returning();
return ok(created);
} catch (error) {
if (error.code === '23505') {
return err(ErrorCode.DUPLICATE_ENTRY, 'A file with this name already exists');
}
this.logger.error('Failed to create file', { userId, error: error.message });
return err(ErrorCode.DATABASE_ERROR, 'Failed to create file');
}
}
async update(id: string, userId: string, dto: UpdateFileDto): Promise<Result<File>> {
// Check ownership first
const existingResult = await this.findById(id, userId);
if (!existingResult.ok) return existingResult;
try {
const [updated] = await this.db
.update(files)
.set({
...(dto.name && { name: dto.name.trim() }),
...(dto.parentFolderId !== undefined && { parentFolderId: dto.parentFolderId }),
updatedAt: new Date(),
})
.where(eq(files.id, id))
.returning();
return ok(updated);
} catch (error) {
this.logger.error('Failed to update file', { id, error: error.message });
return err(ErrorCode.DATABASE_ERROR, 'Failed to update file');
}
}
async delete(id: string, userId: string): Promise<Result<void>> {
// Check ownership first
const existingResult = await this.findById(id, userId);
if (!existingResult.ok) return existingResult;
try {
await this.db
.update(files)
.set({ isDeleted: true, deletedAt: new Date() })
.where(eq(files.id, id));
return ok(undefined);
} catch (error) {
this.logger.error('Failed to delete file', { id, error: error.message });
return err(ErrorCode.DATABASE_ERROR, 'Failed to delete file');
}
}
}
```
### Service with External Dependencies
```typescript
@Injectable()
export class UploadService {
private readonly logger = new Logger(UploadService.name);
constructor(
@Inject(DATABASE_CONNECTION) private db: Database,
private readonly storageService: StorageService,
private readonly fileService: FileService
) {}
async uploadFile(
userId: string,
file: Express.Multer.File,
folderId?: string
): Promise<Result<File>> {
// 1. Upload to storage
const storageResult = await this.storageService.upload(
generateStorageKey(userId, file.originalname),
file.buffer,
{ contentType: file.mimetype }
);
if (!storageResult.ok) {
return err(ErrorCode.UPLOAD_FAILED, 'Failed to upload file to storage');
}
// 2. Create database record
const createResult = await this.fileService.create(userId, {
name: file.originalname,
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
storagePath: storageResult.data.path,
storageKey: storageResult.data.key,
folderId,
});
if (!createResult.ok) {
// Cleanup on failure
await this.storageService.delete(storageResult.data.key);
return createResult;
}
return createResult;
}
}
```
## DTOs
### Create DTO
```typescript
// src/file/dto/create-file.dto.ts
import { IsString, IsOptional, IsNumber, IsUUID, MaxLength, Min } from 'class-validator';
export class CreateFileDto {
@IsString()
@MaxLength(500)
name: string;
@IsOptional()
@IsString()
@MaxLength(500)
originalName?: string;
@IsString()
@MaxLength(255)
mimeType: string;
@IsNumber()
@Min(0)
size: number;
@IsString()
@MaxLength(1000)
storagePath: string;
@IsString()
@MaxLength(500)
storageKey: string;
@IsOptional()
@IsUUID()
folderId?: string;
}
```
### Update DTO (Partial)
```typescript
// src/file/dto/update-file.dto.ts
import { IsString, IsOptional, IsUUID, MaxLength } from 'class-validator';
export class UpdateFileDto {
@IsOptional()
@IsString()
@MaxLength(500)
name?: string;
@IsOptional()
@IsUUID()
parentFolderId?: string | null;
}
```
### Query DTO
```typescript
// src/file/dto/query-files.dto.ts
import { IsOptional, IsUUID, IsNumber, Min, Max } from 'class-validator';
import { Transform } from 'class-transformer';
export class QueryFilesDto {
@IsOptional()
@IsUUID()
folderId?: string;
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
@IsNumber()
@Min(1)
@Max(100)
limit?: number = 50;
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
@IsNumber()
@Min(0)
offset?: number = 0;
}
```
### DTO Index
```typescript
// src/file/dto/index.ts
export * from './create-file.dto';
export * from './update-file.dto';
export * from './query-files.dto';
```
## Modules
```typescript
// src/file/file.module.ts
import { Module } from '@nestjs/common';
import { FileController } from './file.controller';
import { FileService } from './file.service';
import { UploadService } from './upload.service';
import { StorageModule } from '../storage/storage.module';
@Module({
imports: [StorageModule],
controllers: [FileController],
providers: [FileService, UploadService],
exports: [FileService], // Export for use in other modules
})
export class FileModule {}
```
## Exception Filter
```typescript
// src/common/filters/app-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus, Logger } from '@nestjs/common';
import { Response } from 'express';
import { AppException, ERROR_STATUS_MAP, ErrorCode } from '@manacore/shared-errors';
@Catch(AppException)
export class AppExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(AppExceptionFilter.name);
catch(exception: AppException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status = ERROR_STATUS_MAP[exception.error.code] ?? HttpStatus.INTERNAL_SERVER_ERROR;
// Log server errors
if (status >= 500) {
this.logger.error('Server error', {
code: exception.error.code,
message: exception.error.message,
details: exception.error.details,
});
}
response.status(status).json({
ok: false,
error: {
code: exception.error.code,
message: exception.error.message,
...(process.env.NODE_ENV === 'development' && {
details: exception.error.details,
}),
},
});
}
}
```
## File Upload
```typescript
// src/file/file.controller.ts
import { UseInterceptors, UploadedFile, ParseFilePipe, MaxFileSizeValidator } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
@Controller('files')
@UseGuards(JwtAuthGuard)
export class FileController {
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
async uploadFile(
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 100 * 1024 * 1024 }), // 100MB
],
})
)
file: Express.Multer.File,
@Query('folderId') folderId: string | undefined,
@CurrentUser() user: CurrentUserData
) {
const result = await this.uploadService.uploadFile(user.userId, file, folderId);
if (!result.ok) throw new AppException(result.error);
return { file: result.data };
}
}
```
## Health Check
```typescript
// src/health/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { DATABASE_CONNECTION, Database } from '../db/database.module';
import { sql } from 'drizzle-orm';
@Controller('health')
export class HealthController {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
@Get()
async check() {
try {
await this.db.execute(sql`SELECT 1`);
return {
status: 'ok',
timestamp: new Date().toISOString(),
database: 'connected',
};
} catch (error) {
return {
status: 'error',
timestamp: new Date().toISOString(),
database: 'disconnected',
};
}
}
}
```
## API Response Format
### Success Responses
```typescript
// Single resource
{ file: { id: '...', name: '...', ... } }
// Multiple resources
{ files: [...] }
// With pagination
{ files: [...], total: 100, page: 1, limit: 20 }
// Action success
{ success: true }
// Action with data
{ success: true, message: 'File moved', file: {...} }
```
### Error Responses
```typescript
{
ok: false,
error: {
code: 'ERR_4003',
message: 'File not found'
}
}
```
## Environment Variables
```env
# Required
NODE_ENV=development
PORT=3016
DATABASE_URL=postgresql://user:pass@localhost:5432/db
MANA_CORE_AUTH_URL=http://localhost:3001
# CORS
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
# Storage
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
# Optional - Development bypass
DEV_BYPASS_AUTH=true
DEV_USER_ID=dev-user-123
```

View file

@ -546,7 +546,7 @@ pnpm test
pnpm test:cov
# Run specific project
pnpm --filter @storage/backend test
pnpm --filter @storage/server test
# Run in watch mode
pnpm test:watch