💄 style: apply prettier formatting across codebase

Run prettier --write to fix formatting inconsistencies in 80 files
across calendar, contacts, picture, presi, storage, zitare apps
and shared packages/documentation.
This commit is contained in:
Wuesteon 2025-12-03 02:02:09 +01:00
parent 6c9e8972a7
commit ea3582d487
79 changed files with 3122 additions and 2387 deletions

View file

@ -8,15 +8,16 @@ All projects use the root `.prettierrc.json`:
```json
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-astro"]
"useTabs": true,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-astro"]
}
```
### Key Rules
- **Tabs** for indentation (not spaces)
- **Single quotes** for strings
- **Trailing commas** in ES5-compatible positions
@ -27,39 +28,39 @@ All projects use the root `.prettierrc.json`:
### Files & Directories
| Type | Convention | Example |
|------|------------|---------|
| **Components** | PascalCase | `MessageBubble.svelte`, `ChatInput.tsx` |
| **Services** | kebab-case | `auth.service.ts`, `user-credits.service.ts` |
| **Schemas** | kebab-case | `users.schema.ts`, `batch-generations.schema.ts` |
| **Utilities** | kebab-case | `format-date.ts`, `string-utils.ts` |
| **Types/Interfaces** | kebab-case | `user.types.ts`, `api-response.ts` |
| **Constants** | kebab-case | `error-codes.ts`, `config.ts` |
| **Test files** | `.spec.ts` suffix | `auth.service.spec.ts` |
| Type | Convention | Example |
| -------------------- | ----------------- | ------------------------------------------------ |
| **Components** | PascalCase | `MessageBubble.svelte`, `ChatInput.tsx` |
| **Services** | kebab-case | `auth.service.ts`, `user-credits.service.ts` |
| **Schemas** | kebab-case | `users.schema.ts`, `batch-generations.schema.ts` |
| **Utilities** | kebab-case | `format-date.ts`, `string-utils.ts` |
| **Types/Interfaces** | kebab-case | `user.types.ts`, `api-response.ts` |
| **Constants** | kebab-case | `error-codes.ts`, `config.ts` |
| **Test files** | `.spec.ts` suffix | `auth.service.spec.ts` |
### Code Identifiers
| Type | Convention | Example |
|------|------------|---------|
| **Classes** | PascalCase | `UserService`, `AuthController` |
| **Interfaces** | PascalCase | `UserData`, `CreateEventDto` |
| **Type aliases** | PascalCase | `Result<T>`, `ErrorCode` |
| **Functions** | camelCase | `findById`, `createUser` |
| **Variables** | camelCase | `userId`, `isLoading` |
| **Constants** | SCREAMING_SNAKE_CASE | `MAX_FILE_SIZE`, `DEFAULT_TIMEOUT` |
| **Enums** | PascalCase (type), SCREAMING_SNAKE_CASE (values) | `ErrorCode.NOT_FOUND` |
| **Private fields** | camelCase (no underscore prefix) | `private db: Database` |
| Type | Convention | Example |
| ------------------ | ------------------------------------------------ | ---------------------------------- |
| **Classes** | PascalCase | `UserService`, `AuthController` |
| **Interfaces** | PascalCase | `UserData`, `CreateEventDto` |
| **Type aliases** | PascalCase | `Result<T>`, `ErrorCode` |
| **Functions** | camelCase | `findById`, `createUser` |
| **Variables** | camelCase | `userId`, `isLoading` |
| **Constants** | SCREAMING_SNAKE_CASE | `MAX_FILE_SIZE`, `DEFAULT_TIMEOUT` |
| **Enums** | PascalCase (type), SCREAMING_SNAKE_CASE (values) | `ErrorCode.NOT_FOUND` |
| **Private fields** | camelCase (no underscore prefix) | `private db: Database` |
### Database Naming
| Type | Convention | Example |
|------|------------|---------|
| **Tables** | snake_case, plural | `users`, `user_sessions` |
| **Columns** | snake_case | `user_id`, `created_at` |
| **Foreign keys** | `{entity}_id` | `user_id`, `folder_id` |
| **Booleans** | `is_` or `has_` prefix | `is_deleted`, `has_password` |
| **Timestamps** | `_at` suffix | `created_at`, `deleted_at` |
| **Indexes** | `idx_` prefix | `idx_user_id`, `idx_created_at` |
| Type | Convention | Example |
| ---------------- | ---------------------- | ------------------------------- |
| **Tables** | snake_case, plural | `users`, `user_sessions` |
| **Columns** | snake_case | `user_id`, `created_at` |
| **Foreign keys** | `{entity}_id` | `user_id`, `folder_id` |
| **Booleans** | `is_` or `has_` prefix | `is_deleted`, `has_password` |
| **Timestamps** | `_at` suffix | `created_at`, `deleted_at` |
| **Indexes** | `idx_` prefix | `idx_user_id`, `idx_created_at` |
## TypeScript
@ -69,12 +70,12 @@ All projects use strict TypeScript:
```json
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true
}
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true
}
}
```
@ -83,24 +84,24 @@ All projects use strict TypeScript:
```typescript
// GOOD - Explicit return types for public APIs
async function findById(id: string): Promise<Result<User>> {
// ...
// ...
}
// GOOD - Interface for complex objects
interface CreateUserDto {
email: string;
name: string;
password: string;
email: string;
name: string;
password: string;
}
// BAD - Avoid `any`
function process(data: any) { } // Never do this
function process(data: any) {} // Never do this
// GOOD - Use `unknown` when type is truly unknown
function process(data: unknown) {
if (isUser(data)) {
// Now TypeScript knows it's a User
}
if (isUser(data)) {
// Now TypeScript knows it's a User
}
}
```
@ -108,13 +109,13 @@ function process(data: unknown) {
```typescript
// Order: external → internal → relative
import { Injectable } from '@nestjs/common'; // 1. External
import { Injectable } from '@nestjs/common'; // 1. External
import { Result, ErrorCode } from '@manacore/shared-errors'; // 2. Internal packages
import { UserService } from '../services/user.service'; // 3. Relative
import { UserService } from '../services/user.service'; // 3. Relative
// Use named exports (not default)
export { UserService }; // GOOD
export default UserService; // AVOID
export { UserService }; // GOOD
export default UserService; // AVOID
// Use type-only imports for types
import type { User } from './user.types';
@ -152,18 +153,19 @@ import type { User } from './user.types';
// We use optimistic locking here because concurrent credit operations
// could otherwise result in race conditions and incorrect balances
const [updated] = await this.db
.update(balances)
.set({ amount: newAmount, version: sql`version + 1` })
.where(and(eq(balances.userId, userId), eq(balances.version, currentVersion)))
.returning();
.update(balances)
.set({ amount: newAmount, version: sql`version + 1` })
.where(and(eq(balances.userId, userId), eq(balances.version, currentVersion)))
.returning();
// BAD - Explaining obvious code
// Loop through users
for (const user of users) { }
for (const user of users) {
}
// BAD - Outdated comment
// Returns the user's email <-- but function now returns full user object
function getUser() { }
function getUser() {}
```
### JSDoc for Public APIs
@ -234,15 +236,19 @@ components/
```typescript
// BAD
if (user.role === 'admin') { }
if (credits < 10) { }
if (user.role === 'admin') {
}
if (credits < 10) {
}
// GOOD
const ROLES = { ADMIN: 'admin', USER: 'user' } as const;
const MIN_CREDITS_FOR_OPERATION = 10;
if (user.role === ROLES.ADMIN) { }
if (credits < MIN_CREDITS_FOR_OPERATION) { }
if (user.role === ROLES.ADMIN) {
}
if (credits < MIN_CREDITS_FOR_OPERATION) {
}
```
### 2. Nested Callbacks
@ -250,11 +256,11 @@ if (credits < MIN_CREDITS_FOR_OPERATION) { }
```typescript
// BAD
getUser(id, (user) => {
getCredits(user.id, (credits) => {
updateBalance(credits, (result) => {
// ...
});
});
getCredits(user.id, (credits) => {
updateBalance(credits, (result) => {
// ...
});
});
});
// GOOD
@ -268,12 +274,12 @@ const result = await updateBalance(credits);
```typescript
// BAD
function processUser(user: User): void {
user.name = user.name.trim(); // Mutates input
user.name = user.name.trim(); // Mutates input
}
// GOOD
function processUser(user: User): User {
return { ...user, name: user.name.trim() }; // Returns new object
return { ...user, name: user.name.trim() }; // Returns new object
}
```
@ -285,10 +291,10 @@ createUser(email, password, true, false);
// GOOD - Use options object
createUser({
email,
password,
sendWelcomeEmail: true,
requireEmailVerification: false,
email,
password,
sendWelcomeEmail: true,
requireEmailVerification: false,
});
```

View file

@ -7,6 +7,7 @@ All projects use **Drizzle ORM** with **PostgreSQL**. This document covers schem
## ORM: Drizzle
### Why Drizzle?
- Full TypeScript type inference
- SQL-like syntax (no magic)
- Lightweight and fast
@ -24,30 +25,30 @@ let connection: ReturnType<typeof postgres> | null = null;
let db: ReturnType<typeof drizzle> | null = null;
export function getConnection(databaseUrl: string) {
if (!connection) {
connection = postgres(databaseUrl, {
max: 10, // Max connections
idle_timeout: 20, // Seconds before closing idle
connect_timeout: 10, // Connection timeout
});
}
return connection;
if (!connection) {
connection = postgres(databaseUrl, {
max: 10, // Max connections
idle_timeout: 20, // Seconds before closing idle
connect_timeout: 10, // Connection timeout
});
}
return connection;
}
export function getDb(databaseUrl: string) {
if (!db) {
const conn = getConnection(databaseUrl);
db = drizzle(conn, { schema });
}
return db;
if (!db) {
const conn = getConnection(databaseUrl);
db = drizzle(conn, { schema });
}
return db;
}
export async function closeConnection() {
if (connection) {
await connection.end();
connection = null;
db = null;
}
if (connection) {
await connection.end();
connection = null;
db = null;
}
}
export type Database = ReturnType<typeof getDb>;
@ -65,22 +66,22 @@ export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule implements OnModuleDestroy {
async onModuleDestroy() {
await closeConnection();
}
async onModuleDestroy() {
await closeConnection();
}
}
```
@ -104,44 +105,57 @@ src/db/
```typescript
// src/db/schema/files.schema.ts
import { pgTable, uuid, varchar, text, boolean, timestamp, bigint, integer } from 'drizzle-orm/pg-core';
import {
pgTable,
uuid,
varchar,
text,
boolean,
timestamp,
bigint,
integer,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
export const files = pgTable('files', {
// Primary key - always UUID with auto-generation
id: uuid('id').primaryKey().defaultRandom(),
export const files = pgTable(
'files',
{
// Primary key - always UUID with auto-generation
id: uuid('id').primaryKey().defaultRandom(),
// Foreign keys
userId: varchar('user_id', { length: 255 }).notNull(),
parentFolderId: uuid('parent_folder_id').references(() => folders.id, { onDelete: 'set null' }),
// Foreign keys
userId: varchar('user_id', { length: 255 }).notNull(),
parentFolderId: uuid('parent_folder_id').references(() => folders.id, { onDelete: 'set null' }),
// Required fields
name: varchar('name', { length: 500 }).notNull(),
mimeType: varchar('mime_type', { length: 255 }).notNull(),
size: bigint('size', { mode: 'number' }).notNull(),
storagePath: varchar('storage_path', { length: 1000 }).notNull(),
storageKey: varchar('storage_key', { length: 500 }).notNull().unique(),
// Required fields
name: varchar('name', { length: 500 }).notNull(),
mimeType: varchar('mime_type', { length: 255 }).notNull(),
size: bigint('size', { mode: 'number' }).notNull(),
storagePath: varchar('storage_path', { length: 1000 }).notNull(),
storageKey: varchar('storage_key', { length: 500 }).notNull().unique(),
// Optional fields
description: text('description'),
// Optional fields
description: text('description'),
// Boolean flags with defaults
isFavorite: boolean('is_favorite').default(false).notNull(),
isPublic: boolean('is_public').default(false).notNull(),
// Boolean flags with defaults
isFavorite: boolean('is_favorite').default(false).notNull(),
isPublic: boolean('is_public').default(false).notNull(),
// Soft delete
isDeleted: boolean('is_deleted').default(false).notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
// Soft delete
isDeleted: boolean('is_deleted').default(false).notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
// Timestamps - ALWAYS include these
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => ({
// Indexes for common queries
userIdIdx: index('idx_files_user_id').on(table.userId),
parentFolderIdx: index('idx_files_parent_folder').on(table.parentFolderId),
createdAtIdx: index('idx_files_created_at').on(table.createdAt),
}));
// Timestamps - ALWAYS include these
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
// Indexes for common queries
userIdIdx: index('idx_files_user_id').on(table.userId),
parentFolderIdx: index('idx_files_parent_folder').on(table.parentFolderId),
createdAtIdx: index('idx_files_created_at').on(table.createdAt),
})
);
// Type exports - ALWAYS include these
export type File = typeof files.$inferSelect;
@ -153,22 +167,22 @@ export type NewFile = typeof files.$inferInsert;
```typescript
// Define relations separately for clarity
export const filesRelations = relations(files, ({ one, many }) => ({
folder: one(folders, {
fields: [files.parentFolderId],
references: [folders.id],
}),
versions: many(fileVersions),
tags: many(fileTags),
folder: one(folders, {
fields: [files.parentFolderId],
references: [folders.id],
}),
versions: many(fileVersions),
tags: many(fileTags),
}));
export const foldersRelations = relations(folders, ({ one, many }) => ({
parent: one(folders, {
fields: [folders.parentFolderId],
references: [folders.id],
relationName: 'parentChild',
}),
children: many(folders, { relationName: 'parentChild' }),
files: many(files),
parent: one(folders, {
fields: [folders.parentFolderId],
references: [folders.id],
relationName: 'parentChild',
}),
children: many(folders, { relationName: 'parentChild' }),
files: many(files),
}));
```
@ -176,30 +190,30 @@ export const foldersRelations = relations(folders, ({ one, many }) => ({
### Tables
| Rule | Example |
|------|---------|
| Use snake_case | `user_sessions`, `file_versions` |
| Use plural nouns | `users`, `files`, `tags` |
| Junction tables: `{entity1}_{entity2}` | `file_tags`, `user_roles` |
| Rule | Example |
| -------------------------------------- | -------------------------------- |
| Use snake_case | `user_sessions`, `file_versions` |
| Use plural nouns | `users`, `files`, `tags` |
| Junction tables: `{entity1}_{entity2}` | `file_tags`, `user_roles` |
### Columns
| Type | Convention | Example |
|------|------------|---------|
| Primary key | `id` | `id` |
| Foreign key | `{entity}_id` | `user_id`, `folder_id` |
| Boolean | `is_` or `has_` prefix | `is_deleted`, `has_password` |
| Timestamp | `_at` suffix | `created_at`, `deleted_at` |
| Count | `_count` suffix | `download_count` |
| Version | `version` or `current_version` | `version` |
| Type | Convention | Example |
| ----------- | ------------------------------ | ---------------------------- |
| Primary key | `id` | `id` |
| Foreign key | `{entity}_id` | `user_id`, `folder_id` |
| Boolean | `is_` or `has_` prefix | `is_deleted`, `has_password` |
| Timestamp | `_at` suffix | `created_at`, `deleted_at` |
| Count | `_count` suffix | `download_count` |
| Version | `version` or `current_version` | `version` |
### Indexes
```typescript
// Pattern: idx_{table}_{column(s)}
index('idx_files_user_id').on(table.userId)
index('idx_files_created_at').on(table.createdAt)
index('idx_messages_conversation_created').on(table.conversationId, table.createdAt)
index('idx_files_user_id').on(table.userId);
index('idx_files_created_at').on(table.createdAt);
index('idx_messages_conversation_created').on(table.conversationId, table.createdAt);
```
## Common Patterns
@ -309,27 +323,27 @@ type TransactionType = typeof transactionTypeEnum.enumValues[number];
```typescript
async function getPaginated(
userId: string,
page: number = 1,
limit: number = 20
userId: string,
page: number = 1,
limit: number = 20
): Promise<Result<{ items: File[]; total: number }>> {
const offset = (page - 1) * limit;
const offset = (page - 1) * limit;
const [items, countResult] = await Promise.all([
db
.select()
.from(files)
.where(and(eq(files.userId, userId), eq(files.isDeleted, false)))
.orderBy(desc(files.createdAt))
.limit(limit)
.offset(offset),
db
.select({ count: sql<number>`count(*)` })
.from(files)
.where(and(eq(files.userId, userId), eq(files.isDeleted, false))),
]);
const [items, countResult] = await Promise.all([
db
.select()
.from(files)
.where(and(eq(files.userId, userId), eq(files.isDeleted, false)))
.orderBy(desc(files.createdAt))
.limit(limit)
.offset(offset),
db
.select({ count: sql<number>`count(*)` })
.from(files)
.where(and(eq(files.userId, userId), eq(files.isDeleted, false))),
]);
return ok({ items, total: countResult[0].count });
return ok({ items, total: countResult[0].count });
}
```
@ -342,14 +356,14 @@ async function getPaginated(
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema/index.ts',
out: './src/db/migrations',
driver: 'pg',
dbCredentials: {
connectionString: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
schema: './src/db/schema/index.ts',
out: './src/db/migrations',
driver: 'pg',
dbCredentials: {
connectionString: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
});
```
@ -378,14 +392,14 @@ import { migrate } from 'drizzle-orm/postgres-js/migrator';
import postgres from 'postgres';
async function runMigrations() {
const connection = postgres(process.env.DATABASE_URL!, { max: 1 });
const db = drizzle(connection);
const connection = postgres(process.env.DATABASE_URL!, { max: 1 });
const db = drizzle(connection);
console.log('Running migrations...');
await migrate(db, { migrationsFolder: './src/db/migrations' });
console.log('Migrations complete');
console.log('Running migrations...');
await migrate(db, { migrationsFolder: './src/db/migrations' });
console.log('Migrations complete');
await connection.end();
await connection.end();
}
runMigrations().catch(console.error);
@ -397,32 +411,27 @@ runMigrations().catch(console.error);
```typescript
const filesWithTags = await db
.select({
file: files,
tags: sql<string[]>`array_agg(${tags.name})`,
})
.from(files)
.leftJoin(fileTags, eq(files.id, fileTags.fileId))
.leftJoin(tags, eq(fileTags.tagId, tags.id))
.where(eq(files.userId, userId))
.groupBy(files.id);
.select({
file: files,
tags: sql<string[]>`array_agg(${tags.name})`,
})
.from(files)
.leftJoin(fileTags, eq(files.id, fileTags.fileId))
.leftJoin(tags, eq(fileTags.tagId, tags.id))
.where(eq(files.userId, userId))
.groupBy(files.id);
```
### Transactions
```typescript
const result = await db.transaction(async (tx) => {
// All operations in same transaction
const [file] = await tx
.insert(files)
.values(newFile)
.returning();
// All operations in same transaction
const [file] = await tx.insert(files).values(newFile).returning();
await tx
.insert(fileVersions)
.values({ fileId: file.id, versionNumber: 1 });
await tx.insert(fileVersions).values({ fileId: file.id, versionNumber: 1 });
return file;
return file;
});
```
@ -430,12 +439,12 @@ const result = await db.transaction(async (tx) => {
```typescript
await db
.insert(userSettings)
.values({ userId, theme: 'dark' })
.onConflictDoUpdate({
target: userSettings.userId,
set: { theme: 'dark', updatedAt: new Date() },
});
.insert(userSettings)
.values({ userId, theme: 'dark' })
.onConflictDoUpdate({
target: userSettings.userId,
set: { theme: 'dark', updatedAt: new Date() },
});
```
## Anti-Patterns
@ -446,15 +455,15 @@ await db
// BAD - N+1 queries
const files = await db.select().from(files);
for (const file of files) {
const tags = await db.select().from(tags).where(eq(tags.fileId, file.id)); // N queries!
const tags = await db.select().from(tags).where(eq(tags.fileId, file.id)); // N queries!
}
// GOOD - Single query with join
const filesWithTags = await db
.select()
.from(files)
.leftJoin(fileTags, eq(files.id, fileTags.fileId))
.leftJoin(tags, eq(fileTags.tagId, tags.id));
.select()
.from(files)
.leftJoin(fileTags, eq(files.id, fileTags.fileId))
.leftJoin(tags, eq(fileTags.tagId, tags.id));
```
### 2. Missing Indexes

View file

@ -5,6 +5,7 @@
We use **explicit error handling** inspired by Go's error handling pattern. Instead of throwing exceptions everywhere, we return `Result<T>` types that force callers to handle errors explicitly.
### Why?
1. **Explicit over implicit** - Errors are part of the function signature
2. **No surprise exceptions** - You know exactly what can fail
3. **Consistent error codes** - Same codes across frontend and backend
@ -16,25 +17,48 @@ The error handling system is implemented in `packages/shared-errors/`. Import fr
```typescript
import {
// Result type and helpers
Result, AsyncResult, ok, err, isOk, isErr,
unwrap, unwrapOr, map, andThen, match,
tryCatch, tryCatchAsync, combine,
// Result type and helpers
Result,
AsyncResult,
ok,
err,
isOk,
isErr,
unwrap,
unwrapOr,
map,
andThen,
match,
tryCatch,
tryCatchAsync,
combine,
// Error codes
ErrorCode, ERROR_CODE_TO_HTTP_STATUS,
// Error codes
ErrorCode,
ERROR_CODE_TO_HTTP_STATUS,
// Error classes
AppError, ValidationError, NotFoundError,
AuthError, CreditError, ServiceError,
RateLimitError, NetworkError, DatabaseError,
// Error classes
AppError,
ValidationError,
NotFoundError,
AuthError,
CreditError,
ServiceError,
RateLimitError,
NetworkError,
DatabaseError,
// Type guards
isAppError, isValidationError, isNotFoundError,
hasErrorCode, isRetryable, getHttpStatus,
// Type guards
isAppError,
isValidationError,
isNotFoundError,
hasErrorCode,
isRetryable,
getHttpStatus,
// Utilities
wrap, toAppError,
// Utilities
wrap,
toAppError,
} from '@manacore/shared-errors';
```
@ -45,8 +69,8 @@ import {
```typescript
// Result represents success or failure
export type Result<T, E extends AppError = AppError> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: E };
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: E };
// Async version for async functions
export type AsyncResult<T, E extends AppError = AppError> = Promise<Result<T, E>>;
@ -63,9 +87,9 @@ const error = err(new NotFoundError('User', userId));
```typescript
// Base error class
class AppError extends Error {
code: ErrorCode;
context?: ErrorContext;
cause?: Error;
code: ErrorCode;
context?: ErrorContext;
cause?: Error;
}
// Specialized error classes
@ -85,53 +109,53 @@ All error codes are defined in `@manacore/shared-errors`:
```typescript
export enum ErrorCode {
// Validation (400)
VALIDATION_FAILED = 'VALIDATION_FAILED',
INVALID_INPUT = 'INVALID_INPUT',
MISSING_REQUIRED_FIELD = 'MISSING_REQUIRED_FIELD',
INVALID_FORMAT = 'INVALID_FORMAT',
// Validation (400)
VALIDATION_FAILED = 'VALIDATION_FAILED',
INVALID_INPUT = 'INVALID_INPUT',
MISSING_REQUIRED_FIELD = 'MISSING_REQUIRED_FIELD',
INVALID_FORMAT = 'INVALID_FORMAT',
// Authentication (401)
AUTHENTICATION_REQUIRED = 'AUTHENTICATION_REQUIRED',
INVALID_TOKEN = 'INVALID_TOKEN',
TOKEN_EXPIRED = 'TOKEN_EXPIRED',
// Authentication (401)
AUTHENTICATION_REQUIRED = 'AUTHENTICATION_REQUIRED',
INVALID_TOKEN = 'INVALID_TOKEN',
TOKEN_EXPIRED = 'TOKEN_EXPIRED',
// Authorization (403)
PERMISSION_DENIED = 'PERMISSION_DENIED',
RESOURCE_NOT_OWNED = 'RESOURCE_NOT_OWNED',
// Authorization (403)
PERMISSION_DENIED = 'PERMISSION_DENIED',
RESOURCE_NOT_OWNED = 'RESOURCE_NOT_OWNED',
// Not Found (404)
RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND',
USER_NOT_FOUND = 'USER_NOT_FOUND',
// Not Found (404)
RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND',
USER_NOT_FOUND = 'USER_NOT_FOUND',
// Payment/Credit (402)
INSUFFICIENT_CREDITS = 'INSUFFICIENT_CREDITS',
PAYMENT_REQUIRED = 'PAYMENT_REQUIRED',
// Payment/Credit (402)
INSUFFICIENT_CREDITS = 'INSUFFICIENT_CREDITS',
PAYMENT_REQUIRED = 'PAYMENT_REQUIRED',
// Conflict (409)
CONFLICT = 'CONFLICT',
DUPLICATE_ENTRY = 'DUPLICATE_ENTRY',
// Conflict (409)
CONFLICT = 'CONFLICT',
DUPLICATE_ENTRY = 'DUPLICATE_ENTRY',
// Rate Limiting (429)
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS',
// Rate Limiting (429)
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS',
// Service Errors (500)
INTERNAL_ERROR = 'INTERNAL_ERROR',
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
GENERATION_FAILED = 'GENERATION_FAILED',
EXTERNAL_SERVICE_ERROR = 'EXTERNAL_SERVICE_ERROR',
// Service Errors (500)
INTERNAL_ERROR = 'INTERNAL_ERROR',
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
GENERATION_FAILED = 'GENERATION_FAILED',
EXTERNAL_SERVICE_ERROR = 'EXTERNAL_SERVICE_ERROR',
// Network Errors
NETWORK_ERROR = 'NETWORK_ERROR',
TIMEOUT = 'TIMEOUT',
// Network Errors
NETWORK_ERROR = 'NETWORK_ERROR',
TIMEOUT = 'TIMEOUT',
// Database Errors
DATABASE_ERROR = 'DATABASE_ERROR',
CONSTRAINT_VIOLATION = 'CONSTRAINT_VIOLATION',
// Database Errors
DATABASE_ERROR = 'DATABASE_ERROR',
CONSTRAINT_VIOLATION = 'CONSTRAINT_VIOLATION',
// Unknown
UNKNOWN_ERROR = 'UNKNOWN_ERROR',
// Unknown
UNKNOWN_ERROR = 'UNKNOWN_ERROR',
}
```
@ -154,8 +178,8 @@ import { isRetryable } from '@manacore/shared-errors';
// Check if an error is worth retrying
if (isRetryable(error)) {
await delay(1000);
return retry(operation);
await delay(1000);
return retry(operation);
}
```
@ -167,82 +191,83 @@ if (isRetryable(error)) {
// src/files/file.service.ts
import { Injectable, Inject } from '@nestjs/common';
import {
AsyncResult, ok, err, isOk,
NotFoundError, DatabaseError, ValidationError
AsyncResult,
ok,
err,
isOk,
NotFoundError,
DatabaseError,
ValidationError,
} from '@manacore/shared-errors';
import { DATABASE_CONNECTION } from '../db/database.module';
import { files, File, NewFile } from '../db/schema';
@Injectable()
export class FileService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findById(id: string, userId: string): AsyncResult<File> {
try {
const [file] = await this.db
.select()
.from(files)
.where(and(
eq(files.id, id),
eq(files.userId, userId),
eq(files.isDeleted, false)
));
async findById(id: string, userId: string): AsyncResult<File> {
try {
const [file] = await this.db
.select()
.from(files)
.where(and(eq(files.id, id), eq(files.userId, userId), eq(files.isDeleted, false)));
if (!file) {
return err(new NotFoundError('File', id));
}
if (!file) {
return err(new NotFoundError('File', id));
}
return ok(file);
} catch (error) {
return err(DatabaseError.query('Failed to fetch file', error));
}
}
return ok(file);
} catch (error) {
return err(DatabaseError.query('Failed to fetch file', error));
}
}
async create(userId: string, dto: CreateFileDto): AsyncResult<File> {
// Validation
if (!dto.name?.trim()) {
return err(ValidationError.required('name'));
}
async create(userId: string, dto: CreateFileDto): AsyncResult<File> {
// Validation
if (!dto.name?.trim()) {
return err(ValidationError.required('name'));
}
try {
const newFile: NewFile = {
userId,
name: dto.name.trim(),
mimeType: dto.mimeType,
size: dto.size,
storagePath: dto.storagePath,
storageKey: dto.storageKey,
parentFolderId: dto.folderId,
};
try {
const newFile: NewFile = {
userId,
name: dto.name.trim(),
mimeType: dto.mimeType,
size: dto.size,
storagePath: dto.storagePath,
storageKey: dto.storageKey,
parentFolderId: dto.folderId,
};
const [created] = await this.db.insert(files).values(newFile).returning();
return ok(created);
} catch (error) {
// Handle unique constraint violations
if (error.code === '23505') {
return err(DatabaseError.constraint('A file with this name already exists'));
}
return err(DatabaseError.query('Failed to create file', error));
}
}
const [created] = await this.db.insert(files).values(newFile).returning();
return ok(created);
} catch (error) {
// Handle unique constraint violations
if (error.code === '23505') {
return err(DatabaseError.constraint('A file with this name already exists'));
}
return err(DatabaseError.query('Failed to create file', error));
}
}
async delete(id: string, userId: string): AsyncResult<void> {
const fileResult = await this.findById(id, userId);
if (!isOk(fileResult)) {
return fileResult; // Propagate error
}
async delete(id: string, userId: string): AsyncResult<void> {
const fileResult = await this.findById(id, userId);
if (!isOk(fileResult)) {
return fileResult; // Propagate error
}
try {
await this.db
.update(files)
.set({ isDeleted: true, deletedAt: new Date() })
.where(eq(files.id, id));
try {
await this.db
.update(files)
.set({ isDeleted: true, deletedAt: new Date() })
.where(eq(files.id, id));
return ok(undefined);
} catch (error) {
return err(DatabaseError.query('Failed to delete file', error));
}
}
return ok(undefined);
} catch (error) {
return err(DatabaseError.query('Failed to delete file', error));
}
}
}
```
@ -258,46 +283,37 @@ import { FileService } from './file.service';
@Controller('files')
@UseGuards(JwtAuthGuard)
export class FileController {
constructor(private readonly fileService: FileService) {}
constructor(private readonly fileService: FileService) {}
@Get(':id')
async getFile(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData
) {
const result = await this.fileService.findById(id, user.userId);
@Get(':id')
async getFile(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
const result = await this.fileService.findById(id, user.userId);
if (!isOk(result)) {
throw result.error; // AppError extends Error, caught by exception filter
}
if (!isOk(result)) {
throw result.error; // AppError extends Error, caught by exception filter
}
return { file: result.value };
}
return { file: result.value };
}
@Post()
async createFile(
@Body() dto: CreateFileDto,
@CurrentUser() user: CurrentUserData
) {
const result = await this.fileService.create(user.userId, dto);
@Post()
async createFile(@Body() dto: CreateFileDto, @CurrentUser() user: CurrentUserData) {
const result = await this.fileService.create(user.userId, dto);
if (!isOk(result)) {
throw result.error;
}
if (!isOk(result)) {
throw result.error;
}
return { file: result.value };
}
return { file: result.value };
}
@Delete(':id')
async deleteFile(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData
) {
// Alternative: use unwrap() which throws on error
unwrap(await this.fileService.delete(id, user.userId));
@Delete(':id')
async deleteFile(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
// Alternative: use unwrap() which throws on error
unwrap(await this.fileService.delete(id, user.userId));
return { success: true };
}
return { success: true };
}
}
```
@ -314,6 +330,7 @@ app.useGlobalFilters(new AppExceptionFilter());
```
The filter automatically:
- Maps `ErrorCode` to HTTP status codes
- Returns consistent JSON error format
- Logs server errors (5xx)
@ -328,26 +345,26 @@ import { AppError, isAppError, getHttpStatus } from '@manacore/shared-errors';
@Catch(AppError)
export class AppExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger('AppException');
private readonly logger = new Logger('AppException');
catch(exception: AppError, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status = getHttpStatus(exception);
catch(exception: AppError, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status = getHttpStatus(exception);
// Log server errors
if (status >= 500) {
this.logger.error(exception.message, exception.stack);
}
// Log server errors
if (status >= 500) {
this.logger.error(exception.message, exception.stack);
}
response.status(status).json({
ok: false,
error: {
code: exception.code,
message: exception.message,
},
});
}
response.status(status).json({
ok: false,
error: {
code: exception.code,
message: exception.message,
},
});
}
}
```
@ -360,56 +377,54 @@ export class AppExceptionFilter implements ExceptionFilter {
import { Result, err, ErrorCode, AppError } from '@manacore/shared-errors';
interface ApiResponse<T> {
ok: boolean;
data?: T;
error?: AppError;
ok: boolean;
data?: T;
error?: AppError;
}
async function apiRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<Result<T>> {
try {
const token = await getAuthToken();
async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<Result<T>> {
try {
const token = await getAuthToken();
const response = await fetch(`${API_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
const response = await fetch(`${API_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
const json: ApiResponse<T> = await response.json();
const json: ApiResponse<T> = await response.json();
if (!json.ok || json.error) {
return {
ok: false,
error: json.error ?? {
code: ErrorCode.UNKNOWN_ERROR,
message: 'Request failed',
},
};
}
if (!json.ok || json.error) {
return {
ok: false,
error: json.error ?? {
code: ErrorCode.UNKNOWN_ERROR,
message: 'Request failed',
},
};
}
return { ok: true, data: json.data as T };
} catch (error) {
return err(ErrorCode.EXTERNAL_SERVICE_ERROR, 'Network request failed');
}
return { ok: true, data: json.data as T };
} catch (error) {
return err(ErrorCode.EXTERNAL_SERVICE_ERROR, 'Network request failed');
}
}
// Typed API methods
export const api = {
files: {
get: (id: string) => apiRequest<File>(`/files/${id}`),
list: (folderId?: string) => apiRequest<File[]>(`/files?folderId=${folderId ?? ''}`),
create: (data: CreateFileDto) => apiRequest<File>('/files', {
method: 'POST',
body: JSON.stringify(data),
}),
delete: (id: string) => apiRequest<void>(`/files/${id}`, { method: 'DELETE' }),
},
files: {
get: (id: string) => apiRequest<File>(`/files/${id}`),
list: (folderId?: string) => apiRequest<File[]>(`/files?folderId=${folderId ?? ''}`),
create: (data: CreateFileDto) =>
apiRequest<File>('/files', {
method: 'POST',
body: JSON.stringify(data),
}),
delete: (id: string) => apiRequest<void>(`/files/${id}`, { method: 'DELETE' }),
},
};
```
@ -417,49 +432,49 @@ export const api = {
```svelte
<script lang="ts">
import { api } from '$lib/api/client';
import { ErrorCode } from '@manacore/shared-errors';
import { api } from '$lib/api/client';
import { ErrorCode } from '@manacore/shared-errors';
let files = $state<File[]>([]);
let error = $state<string | null>(null);
let loading = $state(false);
let files = $state<File[]>([]);
let error = $state<string | null>(null);
let loading = $state(false);
async function loadFiles() {
loading = true;
error = null;
async function loadFiles() {
loading = true;
error = null;
const result = await api.files.list();
const result = await api.files.list();
if (!result.ok) {
// Handle specific error codes
switch (result.error.code) {
case ErrorCode.UNAUTHORIZED:
goto('/login');
break;
case ErrorCode.FORBIDDEN:
error = 'You do not have permission to view these files';
break;
default:
error = result.error.message;
}
} else {
files = result.data;
}
if (!result.ok) {
// Handle specific error codes
switch (result.error.code) {
case ErrorCode.UNAUTHORIZED:
goto('/login');
break;
case ErrorCode.FORBIDDEN:
error = 'You do not have permission to view these files';
break;
default:
error = result.error.message;
}
} else {
files = result.data;
}
loading = false;
}
loading = false;
}
async function deleteFile(id: string) {
const result = await api.files.delete(id);
async function deleteFile(id: string) {
const result = await api.files.delete(id);
if (!result.ok) {
showToast({ type: 'error', message: result.error.message });
return;
}
if (!result.ok) {
showToast({ type: 'error', message: result.error.message });
return;
}
files = files.filter(f => f.id !== id);
showToast({ type: 'success', message: 'File deleted' });
}
files = files.filter((f) => f.id !== id);
showToast({ type: 'success', message: 'File deleted' });
}
</script>
```
@ -472,37 +487,37 @@ import { api } from '../services/api';
import { ErrorCode, Result, AppError } from '@manacore/shared-errors';
export function useFiles() {
const [files, setFiles] = useState<File[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<AppError | null>(null);
const [files, setFiles] = useState<File[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<AppError | null>(null);
const loadFiles = useCallback(async () => {
setLoading(true);
setError(null);
const loadFiles = useCallback(async () => {
setLoading(true);
setError(null);
const result = await api.files.list();
const result = await api.files.list();
if (!result.ok) {
setError(result.error);
} else {
setFiles(result.data);
}
if (!result.ok) {
setError(result.error);
} else {
setFiles(result.data);
}
setLoading(false);
}, []);
setLoading(false);
}, []);
const deleteFile = useCallback(async (id: string): Promise<boolean> => {
const result = await api.files.delete(id);
const deleteFile = useCallback(async (id: string): Promise<boolean> => {
const result = await api.files.delete(id);
if (!result.ok) {
return false;
}
if (!result.ok) {
return false;
}
setFiles(prev => prev.filter(f => f.id !== id));
return true;
}, []);
setFiles((prev) => prev.filter((f) => f.id !== id));
return true;
}, []);
return { files, loading, error, loadFiles, deleteFile };
return { files, loading, error, loadFiles, deleteFile };
}
```
@ -512,36 +527,34 @@ export function useFiles() {
```typescript
async function processUpload(userId: string, file: File): Promise<Result<FileRecord>> {
// Validate file
const validationResult = validateFile(file);
if (!validationResult.ok) {
return validationResult; // Return validation error as-is
}
// Validate file
const validationResult = validateFile(file);
if (!validationResult.ok) {
return validationResult; // Return validation error as-is
}
// Upload to storage
const uploadResult = await storageService.upload(file);
if (!uploadResult.ok) {
// Add context to storage error
return err(
ErrorCode.UPLOAD_FAILED,
`Failed to upload file: ${uploadResult.error.message}`,
{ originalError: uploadResult.error }
);
}
// Upload to storage
const uploadResult = await storageService.upload(file);
if (!uploadResult.ok) {
// Add context to storage error
return err(ErrorCode.UPLOAD_FAILED, `Failed to upload file: ${uploadResult.error.message}`, {
originalError: uploadResult.error,
});
}
// Save to database
const saveResult = await fileService.create(userId, {
name: file.name,
storagePath: uploadResult.data.path,
});
// Save to database
const saveResult = await fileService.create(userId, {
name: file.name,
storagePath: uploadResult.data.path,
});
if (!saveResult.ok) {
// Cleanup on failure
await storageService.delete(uploadResult.data.path);
return saveResult;
}
if (!saveResult.ok) {
// Cleanup on failure
await storageService.delete(uploadResult.data.path);
return saveResult;
}
return saveResult;
return saveResult;
}
```
@ -552,24 +565,24 @@ import { Logger } from '@nestjs/common';
@Injectable()
export class FileService {
private readonly logger = new Logger(FileService.name);
private readonly logger = new Logger(FileService.name);
async create(userId: string, dto: CreateFileDto): Promise<Result<File>> {
try {
// ... operation
} catch (error) {
// Log full error for debugging
this.logger.error('Failed to create file', {
userId,
fileName: dto.name,
error: error.message,
stack: error.stack,
});
async create(userId: string, dto: CreateFileDto): Promise<Result<File>> {
try {
// ... operation
} catch (error) {
// Log full error for debugging
this.logger.error('Failed to create file', {
userId,
fileName: dto.name,
error: error.message,
stack: error.stack,
});
// Return user-friendly error
return err(ErrorCode.DATABASE_ERROR, 'Failed to create file');
}
}
// Return user-friendly error
return err(ErrorCode.DATABASE_ERROR, 'Failed to create file');
}
}
}
```

File diff suppressed because it is too large Load diff

View file

@ -50,43 +50,43 @@ 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');
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',
];
// 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,
});
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 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());
// Global exception filter
app.useGlobalFilters(new AppExceptionFilter());
// API prefix
app.setGlobalPrefix('api/v1');
// API prefix
app.setGlobalPrefix('api/v1');
const port = process.env.PORT || 3000;
await app.listen(port);
logger.log(`Application running on http://localhost:${port}`);
const port = process.env.PORT || 3000;
await app.listen(port);
logger.log(`Application running on http://localhost:${port}`);
}
bootstrap();
@ -104,16 +104,16 @@ import { FileModule } from './file/file.module';
import { FolderModule } from './folder/folder.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
DatabaseModule,
HealthModule,
FileModule,
FolderModule,
],
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
DatabaseModule,
HealthModule,
FileModule,
FolderModule,
],
})
export class AppModule {}
```
@ -125,16 +125,16 @@ export class AppModule {}
```typescript
// src/file/file.controller.ts
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
Query,
UseGuards,
ParseUUIDPipe,
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';
@ -142,60 +142,48 @@ import { FileService } from './file.service';
import { CreateFileDto, UpdateFileDto, QueryFilesDto } from './dto';
@Controller('files')
@UseGuards(JwtAuthGuard) // Apply to all routes in controller
@UseGuards(JwtAuthGuard) // Apply to all routes in controller
export class FileController {
constructor(private readonly fileService: FileService) {}
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()
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 };
}
@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 };
}
@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 };
}
@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 };
}
@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 };
}
}
```
@ -204,12 +192,12 @@ export class FileController {
```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 };
}
@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 };
}
}
```
@ -228,129 +216,120 @@ import { CreateFileDto, UpdateFileDto, QueryFilesDto } from './dto';
@Injectable()
export class FileService {
private readonly logger = new Logger(FileService.name);
private readonly logger = new Logger(FileService.name);
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
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),
];
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));
}
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);
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');
}
}
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)
)
);
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`);
}
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');
}
}
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');
}
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,
};
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');
}
}
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;
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();
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');
}
}
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;
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));
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');
}
}
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');
}
}
}
```
@ -359,49 +338,49 @@ export class FileService {
```typescript
@Injectable()
export class UploadService {
private readonly logger = new Logger(UploadService.name);
private readonly logger = new Logger(UploadService.name);
constructor(
@Inject(DATABASE_CONNECTION) private db: Database,
private readonly storageService: StorageService,
private readonly fileService: FileService
) {}
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 }
);
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');
}
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,
});
// 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;
}
if (!createResult.ok) {
// Cleanup on failure
await this.storageService.delete(storageResult.data.key);
return createResult;
}
return createResult;
}
return createResult;
}
}
```
@ -414,34 +393,34 @@ export class UploadService {
import { IsString, IsOptional, IsNumber, IsUUID, MaxLength, Min } from 'class-validator';
export class CreateFileDto {
@IsString()
@MaxLength(500)
name: string;
@IsString()
@MaxLength(500)
name: string;
@IsOptional()
@IsString()
@MaxLength(500)
originalName?: string;
@IsOptional()
@IsString()
@MaxLength(500)
originalName?: string;
@IsString()
@MaxLength(255)
mimeType: string;
@IsString()
@MaxLength(255)
mimeType: string;
@IsNumber()
@Min(0)
size: number;
@IsNumber()
@Min(0)
size: number;
@IsString()
@MaxLength(1000)
storagePath: string;
@IsString()
@MaxLength(1000)
storagePath: string;
@IsString()
@MaxLength(500)
storageKey: string;
@IsString()
@MaxLength(500)
storageKey: string;
@IsOptional()
@IsUUID()
folderId?: string;
@IsOptional()
@IsUUID()
folderId?: string;
}
```
@ -452,14 +431,14 @@ export class CreateFileDto {
import { IsString, IsOptional, IsUUID, MaxLength } from 'class-validator';
export class UpdateFileDto {
@IsOptional()
@IsString()
@MaxLength(500)
name?: string;
@IsOptional()
@IsString()
@MaxLength(500)
name?: string;
@IsOptional()
@IsUUID()
parentFolderId?: string | null;
@IsOptional()
@IsUUID()
parentFolderId?: string | null;
}
```
@ -471,22 +450,22 @@ import { IsOptional, IsUUID, IsNumber, Min, Max } from 'class-validator';
import { Transform } from 'class-transformer';
export class QueryFilesDto {
@IsOptional()
@IsUUID()
folderId?: string;
@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(1)
@Max(100)
limit?: number = 50;
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
@IsNumber()
@Min(0)
offset?: number = 0;
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
@IsNumber()
@Min(0)
offset?: number = 0;
}
```
@ -510,10 +489,10 @@ 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
imports: [StorageModule],
controllers: [FileController],
providers: [FileService, UploadService],
exports: [FileService], // Export for use in other modules
})
export class FileModule {}
```
@ -522,46 +501,40 @@ export class FileModule {}
```typescript
// src/common/filters/app-exception.filter.ts
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpStatus,
Logger,
} from '@nestjs/common';
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);
private readonly logger = new Logger(AppExceptionFilter.name);
catch(exception: AppException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
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;
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,
});
}
// 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,
}),
},
});
}
response.status(status).json({
ok: false,
error: {
code: exception.error.code,
message: exception.error.message,
...(process.env.NODE_ENV === 'development' && {
details: exception.error.details,
}),
},
});
}
}
```
@ -575,24 +548,24 @@ 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 };
}
@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 };
}
}
```
@ -607,25 +580,25 @@ import { sql } from 'drizzle-orm';
@Controller('health')
export class HealthController {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
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',
};
}
}
@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',
};
}
}
}
```

View file

@ -44,22 +44,22 @@ apps/{project}/apps/web/
```svelte
<script lang="ts">
// Reactive state
let count = $state(0);
let name = $state('');
let items = $state<string[]>([]);
// Reactive state
let count = $state(0);
let name = $state('');
let items = $state<string[]>([]);
// Object state
let user = $state<User | null>(null);
// Object state
let user = $state<User | null>(null);
// Functions that modify state
function increment() {
count++; // Direct mutation works
}
// Functions that modify state
function increment() {
count++; // Direct mutation works
}
function addItem(item: string) {
items = [...items, item]; // Or reassignment
}
function addItem(item: string) {
items = [...items, item]; // Or reassignment
}
</script>
```
@ -67,25 +67,21 @@ apps/{project}/apps/web/
```svelte
<script lang="ts">
let count = $state(0);
let items = $state<Item[]>([]);
let count = $state(0);
let items = $state<Item[]>([]);
// Computed value - updates automatically
const doubled = $derived(count * 2);
const itemCount = $derived(items.length);
const hasItems = $derived(items.length > 0);
// Computed value - updates automatically
const doubled = $derived(count * 2);
const itemCount = $derived(items.length);
const hasItems = $derived(items.length > 0);
// Complex derived
const sortedItems = $derived(
[...items].sort((a, b) => a.name.localeCompare(b.name))
);
// Complex derived
const sortedItems = $derived([...items].sort((a, b) => a.name.localeCompare(b.name)));
// Derived with conditions
const displayText = $derived(
count === 0 ? 'No items' :
count === 1 ? '1 item' :
`${count} items`
);
// Derived with conditions
const displayText = $derived(
count === 0 ? 'No items' : count === 1 ? '1 item' : `${count} items`
);
</script>
```
@ -93,30 +89,30 @@ apps/{project}/apps/web/
```svelte
<script lang="ts">
import { browser } from '$app/environment';
import { browser } from '$app/environment';
let searchQuery = $state('');
let results = $state<SearchResult[]>([]);
let searchQuery = $state('');
let results = $state<SearchResult[]>([]);
// Run effect when dependencies change
$effect(() => {
if (!browser) return;
// Run effect when dependencies change
$effect(() => {
if (!browser) return;
// This runs when searchQuery changes
const timer = setTimeout(async () => {
results = await search(searchQuery);
}, 300);
// This runs when searchQuery changes
const timer = setTimeout(async () => {
results = await search(searchQuery);
}, 300);
// Cleanup function
return () => clearTimeout(timer);
});
// Cleanup function
return () => clearTimeout(timer);
});
// Effect for initialization
$effect(() => {
if (browser) {
loadInitialData();
}
});
// Effect for initialization
$effect(() => {
if (browser) {
loadInitialData();
}
});
</script>
```
@ -124,34 +120,29 @@ apps/{project}/apps/web/
```svelte
<script lang="ts">
import type { File } from '$lib/types';
import type { File } from '$lib/types';
// Define props with types
interface Props {
file: File;
selected?: boolean;
onDelete?: (id: string) => void;
onSelect?: (file: File) => void;
}
// Define props with types
interface Props {
file: File;
selected?: boolean;
onDelete?: (id: string) => void;
onSelect?: (file: File) => void;
}
// Destructure with defaults
let {
file,
selected = false,
onDelete,
onSelect
}: Props = $props();
// Destructure with defaults
let { file, selected = false, onDelete, onSelect }: Props = $props();
function handleDelete() {
onDelete?.(file.id);
}
function handleDelete() {
onDelete?.(file.id);
}
</script>
<div class:selected onclick={() => onSelect?.(file)}>
<span>{file.name}</span>
{#if onDelete}
<button onclick={handleDelete}>Delete</button>
{/if}
<span>{file.name}</span>
{#if onDelete}
<button onclick={handleDelete}>Delete</button>
{/if}
</div>
```
@ -159,12 +150,12 @@ apps/{project}/apps/web/
```svelte
<script lang="ts">
interface Props {
value: string;
disabled?: boolean;
}
interface Props {
value: string;
disabled?: boolean;
}
let { value = $bindable(), disabled = false }: Props = $props();
let { value = $bindable(), disabled = false }: Props = $props();
</script>
<input bind:value {disabled} />
@ -190,68 +181,76 @@ let error = $state<AppError | null>(null);
let selectedId = $state<string | null>(null);
// Derived values
const selectedFile = $derived(
files.find(f => f.id === selectedId) ?? null
);
const selectedFile = $derived(files.find((f) => f.id === selectedId) ?? null);
const fileCount = $derived(files.length);
// Actions
async function loadFiles(folderId?: string): Promise<void> {
if (!browser) return;
if (!browser) return;
loading = true;
error = null;
loading = true;
error = null;
const result = await api.files.list(folderId);
const result = await api.files.list(folderId);
if (result.ok) {
files = result.data;
} else {
error = result.error;
}
if (result.ok) {
files = result.data;
} else {
error = result.error;
}
loading = false;
loading = false;
}
async function deleteFile(id: string): Promise<boolean> {
const result = await api.files.delete(id);
const result = await api.files.delete(id);
if (result.ok) {
files = files.filter(f => f.id !== id);
if (selectedId === id) selectedId = null;
return true;
}
if (result.ok) {
files = files.filter((f) => f.id !== id);
if (selectedId === id) selectedId = null;
return true;
}
error = result.error;
return false;
error = result.error;
return false;
}
function selectFile(id: string | null): void {
selectedId = id;
selectedId = id;
}
function reset(): void {
files = [];
loading = false;
error = null;
selectedId = null;
files = [];
loading = false;
error = null;
selectedId = null;
}
// Export as object with getters
export const fileStore = {
// Getters for state
get files() { return files; },
get loading() { return loading; },
get error() { return error; },
get selectedFile() { return selectedFile; },
get fileCount() { return fileCount; },
// Getters for state
get files() {
return files;
},
get loading() {
return loading;
},
get error() {
return error;
},
get selectedFile() {
return selectedFile;
},
get fileCount() {
return fileCount;
},
// Actions
loadFiles,
deleteFile,
selectFile,
reset,
// Actions
loadFiles,
deleteFile,
selectFile,
reset,
};
```
@ -259,32 +258,32 @@ export const fileStore = {
```svelte
<script lang="ts">
import { fileStore } from '$lib/stores/files.svelte';
import { onMount } from 'svelte';
import { fileStore } from '$lib/stores/files.svelte';
import { onMount } from 'svelte';
onMount(() => {
fileStore.loadFiles();
});
onMount(() => {
fileStore.loadFiles();
});
async function handleDelete(id: string) {
const success = await fileStore.deleteFile(id);
if (success) {
showToast('File deleted');
}
}
async function handleDelete(id: string) {
const success = await fileStore.deleteFile(id);
if (success) {
showToast('File deleted');
}
}
</script>
{#if fileStore.loading}
<LoadingSpinner />
<LoadingSpinner />
{:else if fileStore.error}
<ErrorMessage message={fileStore.error.message} />
<ErrorMessage message={fileStore.error.message} />
{:else}
<FileList
files={fileStore.files}
selectedId={fileStore.selectedFile?.id}
onSelect={(file) => fileStore.selectFile(file.id)}
onDelete={handleDelete}
/>
<FileList
files={fileStore.files}
selectedId={fileStore.selectedFile?.id}
onSelect={(file) => fileStore.selectFile(file.id)}
onDelete={handleDelete}
/>
{/if}
```
@ -300,90 +299,85 @@ import { ErrorCode } from '@manacore/shared-errors';
import { PUBLIC_BACKEND_URL } from '$env/static/public';
interface ApiResponse<T> {
ok: boolean;
data?: T;
error?: AppError;
ok: boolean;
data?: T;
error?: AppError;
}
async function request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<Result<T>> {
if (!browser) {
return { ok: false, error: { code: ErrorCode.INTERNAL_ERROR, message: 'SSR not supported' } };
}
async function request<T>(endpoint: string, options: RequestInit = {}): Promise<Result<T>> {
if (!browser) {
return { ok: false, error: { code: ErrorCode.INTERNAL_ERROR, message: 'SSR not supported' } };
}
try {
const token = authStore.token;
try {
const token = authStore.token;
const response = await fetch(`${PUBLIC_BACKEND_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
const response = await fetch(`${PUBLIC_BACKEND_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
// Handle 401 - redirect to login
if (response.status === 401) {
authStore.logout();
goto('/login');
return { ok: false, error: { code: ErrorCode.UNAUTHORIZED, message: 'Session expired' } };
}
// Handle 401 - redirect to login
if (response.status === 401) {
authStore.logout();
goto('/login');
return { ok: false, error: { code: ErrorCode.UNAUTHORIZED, message: 'Session expired' } };
}
const json: ApiResponse<T> = await response.json();
const json: ApiResponse<T> = await response.json();
if (!json.ok || json.error) {
return {
ok: false,
error: json.error ?? { code: ErrorCode.UNKNOWN_ERROR, message: 'Request failed' },
};
}
if (!json.ok || json.error) {
return {
ok: false,
error: json.error ?? { code: ErrorCode.UNKNOWN_ERROR, message: 'Request failed' },
};
}
return { ok: true, data: json.data as T };
} catch (error) {
return {
ok: false,
error: { code: ErrorCode.EXTERNAL_SERVICE_ERROR, message: 'Network error' },
};
}
return { ok: true, data: json.data as T };
} catch (error) {
return {
ok: false,
error: { code: ErrorCode.EXTERNAL_SERVICE_ERROR, message: 'Network error' },
};
}
}
// Typed API endpoints
export const api = {
files: {
list: (folderId?: string) =>
request<File[]>(`/api/v1/files${folderId ? `?folderId=${folderId}` : ''}`),
files: {
list: (folderId?: string) =>
request<File[]>(`/api/v1/files${folderId ? `?folderId=${folderId}` : ''}`),
get: (id: string) =>
request<File>(`/api/v1/files/${id}`),
get: (id: string) => request<File>(`/api/v1/files/${id}`),
create: (data: CreateFileDto) =>
request<File>('/api/v1/files', {
method: 'POST',
body: JSON.stringify(data),
}),
create: (data: CreateFileDto) =>
request<File>('/api/v1/files', {
method: 'POST',
body: JSON.stringify(data),
}),
update: (id: string, data: UpdateFileDto) =>
request<File>(`/api/v1/files/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
update: (id: string, data: UpdateFileDto) =>
request<File>(`/api/v1/files/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
delete: (id: string) =>
request<void>(`/api/v1/files/${id}`, { method: 'DELETE' }),
},
delete: (id: string) => request<void>(`/api/v1/files/${id}`, { method: 'DELETE' }),
},
folders: {
list: () => request<Folder[]>('/api/v1/folders'),
get: (id: string) => request<Folder>(`/api/v1/folders/${id}`),
create: (data: CreateFolderDto) =>
request<Folder>('/api/v1/folders', {
method: 'POST',
body: JSON.stringify(data),
}),
},
folders: {
list: () => request<Folder[]>('/api/v1/folders'),
get: (id: string) => request<Folder>(`/api/v1/folders/${id}`),
create: (data: CreateFolderDto) =>
request<Folder>('/api/v1/folders', {
method: 'POST',
body: JSON.stringify(data),
}),
},
};
```
@ -412,32 +406,32 @@ src/routes/
```svelte
<!-- src/routes/(app)/+layout.svelte -->
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import Sidebar from '$lib/components/layout/Sidebar.svelte';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import Sidebar from '$lib/components/layout/Sidebar.svelte';
let { children } = $props();
let { children } = $props();
// Check auth on mount
$effect(() => {
if (browser && !authStore.isAuthenticated) {
goto('/login');
}
});
// Check auth on mount
$effect(() => {
if (browser && !authStore.isAuthenticated) {
goto('/login');
}
});
</script>
{#if authStore.isAuthenticated}
<div class="flex h-screen">
<Sidebar />
<main class="flex-1 overflow-auto">
{@render children()}
</main>
</div>
<div class="flex h-screen">
<Sidebar />
<main class="flex-1 overflow-auto">
{@render children()}
</main>
</div>
{:else}
<div class="flex items-center justify-center h-screen">
<LoadingSpinner />
</div>
<div class="flex items-center justify-center h-screen">
<LoadingSpinner />
</div>
{/if}
```
@ -446,41 +440,41 @@ src/routes/
```svelte
<!-- src/routes/(app)/files/[id]/+page.svelte -->
<script lang="ts">
import { page } from '$app/stores';
import { api } from '$lib/api/client';
import { page } from '$app/stores';
import { api } from '$lib/api/client';
let file = $state<File | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let file = $state<File | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
// Load file when ID changes
$effect(() => {
const fileId = $page.params.id;
loadFile(fileId);
});
// Load file when ID changes
$effect(() => {
const fileId = $page.params.id;
loadFile(fileId);
});
async function loadFile(id: string) {
loading = true;
error = null;
async function loadFile(id: string) {
loading = true;
error = null;
const result = await api.files.get(id);
const result = await api.files.get(id);
if (result.ok) {
file = result.data;
} else {
error = result.error.message;
}
if (result.ok) {
file = result.data;
} else {
error = result.error.message;
}
loading = false;
}
loading = false;
}
</script>
{#if loading}
<LoadingSpinner />
<LoadingSpinner />
{:else if error}
<ErrorMessage message={error} />
<ErrorMessage message={error} />
{:else if file}
<FileViewer {file} />
<FileViewer {file} />
{/if}
```
@ -491,51 +485,51 @@ src/routes/
```svelte
<!-- src/lib/components/files/FileCard.svelte -->
<script lang="ts">
import type { File } from '$lib/types';
import { formatBytes, formatDate } from '$lib/utils/format';
import FileIcon from './FileIcon.svelte';
import type { File } from '$lib/types';
import { formatBytes, formatDate } from '$lib/utils/format';
import FileIcon from './FileIcon.svelte';
interface Props {
file: File;
selected?: boolean;
onSelect?: () => void;
onDelete?: () => void;
}
interface Props {
file: File;
selected?: boolean;
onSelect?: () => void;
onDelete?: () => void;
}
let { file, selected = false, onSelect, onDelete }: Props = $props();
let { file, selected = false, onSelect, onDelete }: Props = $props();
const formattedSize = $derived(formatBytes(file.size));
const formattedDate = $derived(formatDate(file.createdAt));
const formattedSize = $derived(formatBytes(file.size));
const formattedDate = $derived(formatDate(file.createdAt));
</script>
<div
class="p-4 rounded-lg border transition-colors cursor-pointer
class="p-4 rounded-lg border transition-colors cursor-pointer
{selected ? 'border-primary bg-primary/5' : 'border-gray-200 hover:border-gray-300'}"
onclick={onSelect}
role="button"
tabindex="0"
onkeydown={(e) => e.key === 'Enter' && onSelect?.()}
onclick={onSelect}
role="button"
tabindex="0"
onkeydown={(e) => e.key === 'Enter' && onSelect?.()}
>
<div class="flex items-start gap-3">
<FileIcon mimeType={file.mimeType} />
<div class="flex items-start gap-3">
<FileIcon mimeType={file.mimeType} />
<div class="flex-1 min-w-0">
<h3 class="font-medium truncate">{file.name}</h3>
<p class="text-sm text-gray-500">
{formattedSize} • {formattedDate}
</p>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-medium truncate">{file.name}</h3>
<p class="text-sm text-gray-500">
{formattedSize} • {formattedDate}
</p>
</div>
{#if onDelete}
<button
class="p-2 text-gray-400 hover:text-red-500"
onclick|stopPropagation={onDelete}
aria-label="Delete file"
>
<TrashIcon />
</button>
{/if}
</div>
{#if onDelete}
<button
class="p-2 text-gray-400 hover:text-red-500"
onclick|stopPropagation={onDelete}
aria-label="Delete file"
>
<TrashIcon />
</button>
{/if}
</div>
</div>
```
@ -606,13 +600,13 @@ src/routes/
import sharedConfig from '@manacore/shared-tailwind';
export default {
presets: [sharedConfig],
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {
// Project-specific overrides
},
},
presets: [sharedConfig],
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {
// Project-specific overrides
},
},
};
```
@ -625,16 +619,16 @@ export default {
/* Custom utilities */
@layer utilities {
.scrollbar-thin {
scrollbar-width: thin;
}
.scrollbar-thin {
scrollbar-width: thin;
}
}
/* Custom components */
@layer components {
.btn-primary {
@apply px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors;
}
.btn-primary {
@apply px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors;
}
}
```
@ -642,63 +636,63 @@ export default {
```svelte
<script lang="ts">
import { api } from '$lib/api/client';
import { api } from '$lib/api/client';
let name = $state('');
let email = $state('');
let loading = $state(false);
let errors = $state<Record<string, string>>({});
let name = $state('');
let email = $state('');
let loading = $state(false);
let errors = $state<Record<string, string>>({});
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
errors = {};
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
errors = {};
// Client-side validation
if (!name.trim()) errors.name = 'Name is required';
if (!email.trim()) errors.email = 'Email is required';
if (Object.keys(errors).length > 0) return;
// Client-side validation
if (!name.trim()) errors.name = 'Name is required';
if (!email.trim()) errors.email = 'Email is required';
if (Object.keys(errors).length > 0) return;
loading = true;
const result = await api.users.create({ name, email });
loading = false;
loading = true;
const result = await api.users.create({ name, email });
loading = false;
if (result.ok) {
goto('/users');
} else {
// Handle server errors
if (result.error.code === 'ERR_5002') {
errors.email = 'Email already exists';
} else {
errors.form = result.error.message;
}
}
}
if (result.ok) {
goto('/users');
} else {
// Handle server errors
if (result.error.code === 'ERR_5002') {
errors.email = 'Email already exists';
} else {
errors.form = result.error.message;
}
}
}
</script>
<form onsubmit={handleSubmit}>
{#if errors.form}
<div class="text-red-500 mb-4">{errors.form}</div>
{/if}
{#if errors.form}
<div class="text-red-500 mb-4">{errors.form}</div>
{/if}
<div class="mb-4">
<label for="name">Name</label>
<input id="name" bind:value={name} class:border-red-500={errors.name} />
{#if errors.name}
<span class="text-red-500 text-sm">{errors.name}</span>
{/if}
</div>
<div class="mb-4">
<label for="name">Name</label>
<input id="name" bind:value={name} class:border-red-500={errors.name} />
{#if errors.name}
<span class="text-red-500 text-sm">{errors.name}</span>
{/if}
</div>
<div class="mb-4">
<label for="email">Email</label>
<input id="email" type="email" bind:value={email} class:border-red-500={errors.email} />
{#if errors.email}
<span class="text-red-500 text-sm">{errors.email}</span>
{/if}
</div>
<div class="mb-4">
<label for="email">Email</label>
<input id="email" type="email" bind:value={email} class:border-red-500={errors.email} />
{#if errors.email}
<span class="text-red-500 text-sm">{errors.email}</span>
{/if}
</div>
<button type="submit" disabled={loading} class="btn-primary">
{loading ? 'Saving...' : 'Save'}
</button>
<button type="submit" disabled={loading} class="btn-primary">
{loading ? 'Saving...' : 'Save'}
</button>
</form>
```