mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:21:10 +02:00
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:
parent
708299b35e
commit
ab387b9b3d
43 changed files with 598 additions and 2398 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
137
.claude/guidelines/hono-server.md
Normal file
137
.claude/guidelines/hono-server.md
Normal 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 |
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue