diff --git a/.claude/guidelines/code-style.md b/.claude/guidelines/code-style.md index a27430ccf..6fbffb0f3 100644 --- a/.claude/guidelines/code-style.md +++ b/.claude/guidelines/code-style.md @@ -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`, `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`, `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> { - // ... + // ... } // 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, }); ``` diff --git a/.claude/guidelines/database.md b/.claude/guidelines/database.md index 8a293b06a..49bfbebf4 100644 --- a/.claude/guidelines/database.md +++ b/.claude/guidelines/database.md @@ -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 | null = null; let db: ReturnType | 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; @@ -65,22 +66,22 @@ export const DATABASE_CONNECTION = 'DATABASE_CONNECTION'; @Global() @Module({ - providers: [ - { - provide: DATABASE_CONNECTION, - useFactory: (configService: ConfigService): Database => { - const databaseUrl = configService.get('DATABASE_URL'); - return getDb(databaseUrl); - }, - inject: [ConfigService], - }, - ], - exports: [DATABASE_CONNECTION], + providers: [ + { + provide: DATABASE_CONNECTION, + useFactory: (configService: ConfigService): Database => { + const databaseUrl = configService.get('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> { - 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`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`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`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`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 diff --git a/.claude/guidelines/error-handling.md b/.claude/guidelines/error-handling.md index 7664b2f48..f107fc235 100644 --- a/.claude/guidelines/error-handling.md +++ b/.claude/guidelines/error-handling.md @@ -5,6 +5,7 @@ We use **explicit error handling** inspired by Go's error handling pattern. Instead of throwing exceptions everywhere, we return `Result` 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 = - | { 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 = Promise>; @@ -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 { - 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 { + 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 { - // Validation - if (!dto.name?.trim()) { - return err(ValidationError.required('name')); - } + async create(userId: string, dto: CreateFileDto): AsyncResult { + // 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 { - const fileResult = await this.findById(id, userId); - if (!isOk(fileResult)) { - return fileResult; // Propagate error - } + async delete(id: string, userId: string): AsyncResult { + 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(); - const status = getHttpStatus(exception); + catch(exception: AppError, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + 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 { - ok: boolean; - data?: T; - error?: AppError; + ok: boolean; + data?: T; + error?: AppError; } -async function apiRequest( - endpoint: string, - options: RequestInit = {} -): Promise> { - try { - const token = await getAuthToken(); +async function apiRequest(endpoint: string, options: RequestInit = {}): Promise> { + 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 = await response.json(); + const json: ApiResponse = 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(`/files/${id}`), - list: (folderId?: string) => apiRequest(`/files?folderId=${folderId ?? ''}`), - create: (data: CreateFileDto) => apiRequest('/files', { - method: 'POST', - body: JSON.stringify(data), - }), - delete: (id: string) => apiRequest(`/files/${id}`, { method: 'DELETE' }), - }, + files: { + get: (id: string) => apiRequest(`/files/${id}`), + list: (folderId?: string) => apiRequest(`/files?folderId=${folderId ?? ''}`), + create: (data: CreateFileDto) => + apiRequest('/files', { + method: 'POST', + body: JSON.stringify(data), + }), + delete: (id: string) => apiRequest(`/files/${id}`, { method: 'DELETE' }), + }, }; ``` @@ -417,49 +432,49 @@ export const api = { ```svelte ``` @@ -472,37 +487,37 @@ import { api } from '../services/api'; import { ErrorCode, Result, AppError } from '@manacore/shared-errors'; export function useFiles() { - const [files, setFiles] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 => { - const result = await api.files.delete(id); + const deleteFile = useCallback(async (id: string): Promise => { + 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> { - // 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> { - 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> { + 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'); + } + } } ``` diff --git a/.claude/guidelines/expo-mobile.md b/.claude/guidelines/expo-mobile.md index b3b123f09..5f6be7d46 100644 --- a/.claude/guidelines/expo-mobile.md +++ b/.claude/guidelines/expo-mobile.md @@ -53,22 +53,22 @@ import { Stack } from 'expo-router'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { AuthProvider } from '../context/AuthProvider'; import { ThemeProvider } from '../context/ThemeProvider'; -import '../global.css'; // NativeWind styles +import '../global.css'; // NativeWind styles export default function RootLayout() { - return ( - - - - - - - - - - - - ); + return ( + + + + + + + + + + + + ); } ``` @@ -82,18 +82,18 @@ import { Drawer } from 'expo-router/drawer'; import CustomDrawer from '../../components/layout/CustomDrawer'; export default function DrawerLayout() { - return ( - } - screenOptions={{ - drawerType: 'front', - headerShown: false, - }} - > - - - - ); + return ( + } + screenOptions={{ + drawerType: 'front', + headerShown: false, + }} + > + + + + ); } ``` @@ -105,42 +105,36 @@ import { Tabs } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; export default function TabLayout() { - return ( - - ( - - ), - }} - /> - ( - - ), - }} - /> - ( - - ), - }} - /> - - ); + return ( + + , + }} + /> + , + }} + /> + , + }} + /> + + ); } ``` @@ -176,18 +170,18 @@ import { router } from 'expo-router'; import { api } from '../services/api'; interface User { - id: string; - email: string; - name: string; + id: string; + email: string; + name: string; } interface AuthContextType { - user: User | null; - token: string | null; - loading: boolean; - login: (email: string, password: string) => Promise; - logout: () => Promise; - isAuthenticated: boolean; + user: User | null; + token: string | null; + loading: boolean; + login: (email: string, password: string) => Promise; + logout: () => Promise; + isAuthenticated: boolean; } const AuthContext = createContext(undefined); @@ -196,80 +190,80 @@ const TOKEN_KEY = 'auth_token'; const USER_KEY = 'auth_user'; export function AuthProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(null); - const [token, setToken] = useState(null); - const [loading, setLoading] = useState(true); + const [user, setUser] = useState(null); + const [token, setToken] = useState(null); + const [loading, setLoading] = useState(true); - useEffect(() => { - loadStoredAuth(); - }, []); + useEffect(() => { + loadStoredAuth(); + }, []); - async function loadStoredAuth() { - try { - const storedToken = await SecureStore.getItemAsync(TOKEN_KEY); - const storedUser = await SecureStore.getItemAsync(USER_KEY); + async function loadStoredAuth() { + try { + const storedToken = await SecureStore.getItemAsync(TOKEN_KEY); + const storedUser = await SecureStore.getItemAsync(USER_KEY); - if (storedToken && storedUser) { - setToken(storedToken); - setUser(JSON.parse(storedUser)); - } - } catch (error) { - console.error('Failed to load auth:', error); - } finally { - setLoading(false); - } - } + if (storedToken && storedUser) { + setToken(storedToken); + setUser(JSON.parse(storedUser)); + } + } catch (error) { + console.error('Failed to load auth:', error); + } finally { + setLoading(false); + } + } - async function login(email: string, password: string): Promise { - const result = await api.auth.login({ email, password }); + async function login(email: string, password: string): Promise { + const result = await api.auth.login({ email, password }); - if (!result.ok) { - return false; - } + if (!result.ok) { + return false; + } - const { token: newToken, user: newUser } = result.data; + const { token: newToken, user: newUser } = result.data; - await SecureStore.setItemAsync(TOKEN_KEY, newToken); - await SecureStore.setItemAsync(USER_KEY, JSON.stringify(newUser)); + await SecureStore.setItemAsync(TOKEN_KEY, newToken); + await SecureStore.setItemAsync(USER_KEY, JSON.stringify(newUser)); - setToken(newToken); - setUser(newUser); + setToken(newToken); + setUser(newUser); - return true; - } + return true; + } - async function logout() { - await SecureStore.deleteItemAsync(TOKEN_KEY); - await SecureStore.deleteItemAsync(USER_KEY); + async function logout() { + await SecureStore.deleteItemAsync(TOKEN_KEY); + await SecureStore.deleteItemAsync(USER_KEY); - setToken(null); - setUser(null); + setToken(null); + setUser(null); - router.replace('/login'); - } + router.replace('/login'); + } - return ( - - {children} - - ); + return ( + + {children} + + ); } export function useAuth() { - const context = useContext(AuthContext); - if (context === undefined) { - throw new Error('useAuth must be used within an AuthProvider'); - } - return context; + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; } ``` @@ -284,56 +278,59 @@ import { api } from '../services/api'; import type { File, AppError } from '../types'; interface UseFilesResult { - files: File[]; - loading: boolean; - error: AppError | null; - loadFiles: (folderId?: string) => Promise; - deleteFile: (id: string) => Promise; - refresh: () => Promise; + files: File[]; + loading: boolean; + error: AppError | null; + loadFiles: (folderId?: string) => Promise; + deleteFile: (id: string) => Promise; + refresh: () => Promise; } export function useFiles(initialFolderId?: string): UseFilesResult { - const [files, setFiles] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [folderId, setFolderId] = useState(initialFolderId); + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [folderId, setFolderId] = useState(initialFolderId); - const loadFiles = useCallback(async (newFolderId?: string) => { - const targetFolderId = newFolderId ?? folderId; - setFolderId(targetFolderId); - setLoading(true); - setError(null); + const loadFiles = useCallback( + async (newFolderId?: string) => { + const targetFolderId = newFolderId ?? folderId; + setFolderId(targetFolderId); + setLoading(true); + setError(null); - const result = await api.files.list(targetFolderId); + const result = await api.files.list(targetFolderId); - if (result.ok) { - setFiles(result.data); - } else { - setError(result.error); - } + if (result.ok) { + setFiles(result.data); + } else { + setError(result.error); + } - setLoading(false); - }, [folderId]); + setLoading(false); + }, + [folderId] + ); - const deleteFile = useCallback(async (id: string): Promise => { - const result = await api.files.delete(id); + const deleteFile = useCallback(async (id: string): Promise => { + const result = await api.files.delete(id); - if (result.ok) { - setFiles(prev => prev.filter(f => f.id !== id)); - return true; - } + if (result.ok) { + setFiles((prev) => prev.filter((f) => f.id !== id)); + return true; + } - setError(result.error); - return false; - }, []); + setError(result.error); + return false; + }, []); - const refresh = useCallback(() => loadFiles(), [loadFiles]); + const refresh = useCallback(() => loadFiles(), [loadFiles]); - useEffect(() => { - loadFiles(); - }, []); + useEffect(() => { + loadFiles(); + }, []); - return { files, loading, error, loadFiles, deleteFile, refresh }; + return { files, loading, error, loadFiles, deleteFile, refresh }; } ``` @@ -346,32 +343,32 @@ import { api } from '../services/api'; import type { File, CreateFileDto, AppError } from '../types'; interface UseCreateFileResult { - create: (data: CreateFileDto) => Promise; - loading: boolean; - error: AppError | null; + create: (data: CreateFileDto) => Promise; + loading: boolean; + error: AppError | null; } export function useCreateFile(): UseCreateFileResult { - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); - const create = useCallback(async (data: CreateFileDto): Promise => { - setLoading(true); - setError(null); + const create = useCallback(async (data: CreateFileDto): Promise => { + setLoading(true); + setError(null); - const result = await api.files.create(data); + const result = await api.files.create(data); - setLoading(false); + setLoading(false); - if (result.ok) { - return result.data; - } + if (result.ok) { + return result.data; + } - setError(result.error); - return null; - }, []); + setError(result.error); + return null; + }, []); - return { create, loading, error }; + return { create, loading, error }; } ``` @@ -388,84 +385,79 @@ const API_URL = Constants.expoConfig?.extra?.apiUrl ?? 'http://localhost:3016'; const TOKEN_KEY = 'auth_token'; interface ApiResponse { - ok: boolean; - data?: T; - error?: AppError; + ok: boolean; + data?: T; + error?: AppError; } async function getToken(): Promise { - try { - return await SecureStore.getItemAsync(TOKEN_KEY); - } catch { - return null; - } + try { + return await SecureStore.getItemAsync(TOKEN_KEY); + } catch { + return null; + } } -async function request( - endpoint: string, - options: RequestInit = {} -): Promise> { - try { - const token = await getToken(); +async function request(endpoint: string, options: RequestInit = {}): Promise> { + try { + const token = await getToken(); - 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 = await response.json(); + const json: ApiResponse = 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' }, + }; + } } export const api = { - auth: { - login: (data: { email: string; password: string }) => - request<{ token: string; user: User }>('/api/v1/auth/login', { - method: 'POST', - body: JSON.stringify(data), - }), + auth: { + login: (data: { email: string; password: string }) => + request<{ token: string; user: User }>('/api/v1/auth/login', { + method: 'POST', + body: JSON.stringify(data), + }), - register: (data: { email: string; password: string; name: string }) => - request<{ token: string; user: User }>('/api/v1/auth/register', { - method: 'POST', - body: JSON.stringify(data), - }), - }, + register: (data: { email: string; password: string; name: string }) => + request<{ token: string; user: User }>('/api/v1/auth/register', { + method: 'POST', + body: JSON.stringify(data), + }), + }, - files: { - list: (folderId?: string) => - request(`/api/v1/files${folderId ? `?folderId=${folderId}` : ''}`), + files: { + list: (folderId?: string) => + request(`/api/v1/files${folderId ? `?folderId=${folderId}` : ''}`), - get: (id: string) => - request(`/api/v1/files/${id}`), + get: (id: string) => request(`/api/v1/files/${id}`), - create: (data: CreateFileDto) => - request('/api/v1/files', { - method: 'POST', - body: JSON.stringify(data), - }), + create: (data: CreateFileDto) => + request('/api/v1/files', { + method: 'POST', + body: JSON.stringify(data), + }), - delete: (id: string) => - request(`/api/v1/files/${id}`, { method: 'DELETE' }), - }, + delete: (id: string) => request(`/api/v1/files/${id}`, { method: 'DELETE' }), + }, }; ``` @@ -483,35 +475,30 @@ import { ErrorView } from '../../../components/ui/ErrorView'; import { EmptyState } from '../../../components/ui/EmptyState'; export default function FilesScreen() { - const { files, loading, error, refresh } = useFiles(); + const { files, loading, error, refresh } = useFiles(); - if (loading && files.length === 0) { - return ; - } + if (loading && files.length === 0) { + return ; + } - if (error) { - return ; - } + if (error) { + return ; + } - return ( - - item.id} - renderItem={({ item }) => } - refreshControl={ - - } - ListEmptyComponent={ - - } - contentContainerStyle={{ padding: 16, gap: 12 }} - /> - - ); + return ( + + item.id} + renderItem={({ item }) => } + refreshControl={} + ListEmptyComponent={ + + } + contentContainerStyle={{ padding: 16, gap: 12 }} + /> + + ); } ``` @@ -526,46 +513,46 @@ import type { File } from '../../types'; import { formatBytes, formatDate } from '../../lib/utils'; interface FileCardProps { - file: File; - onDelete?: () => void; + file: File; + onDelete?: () => void; } export function FileCard({ file, onDelete }: FileCardProps) { - const handlePress = () => { - router.push({ pathname: '/file/[id]', params: { id: file.id } }); - }; + const handlePress = () => { + router.push({ pathname: '/file/[id]', params: { id: file.id } }); + }; - return ( - - - - - + return ( + + + + + - - - {file.name} - - - {formatBytes(file.size)} • {formatDate(file.createdAt)} - - + + + {file.name} + + + {formatBytes(file.size)} • {formatDate(file.createdAt)} + + - {onDelete && ( - - - - )} - - - ); + {onDelete && ( + + + + )} + + + ); } ``` @@ -576,76 +563,73 @@ export function FileCard({ file, onDelete }: FileCardProps) { import { Pressable, Text, ActivityIndicator, PressableProps } from 'react-native'; import { cva, type VariantProps } from 'class-variance-authority'; -const buttonVariants = cva( - 'flex-row items-center justify-center rounded-xl', - { - variants: { - variant: { - primary: 'bg-primary', - secondary: 'bg-secondary', - outline: 'border border-border bg-transparent', - ghost: 'bg-transparent', - }, - size: { - sm: 'h-9 px-3', - md: 'h-11 px-4', - lg: 'h-14 px-6', - }, - }, - defaultVariants: { - variant: 'primary', - size: 'md', - }, - } -); +const buttonVariants = cva('flex-row items-center justify-center rounded-xl', { + variants: { + variant: { + primary: 'bg-primary', + secondary: 'bg-secondary', + outline: 'border border-border bg-transparent', + ghost: 'bg-transparent', + }, + size: { + sm: 'h-9 px-3', + md: 'h-11 px-4', + lg: 'h-14 px-6', + }, + }, + defaultVariants: { + variant: 'primary', + size: 'md', + }, +}); const textVariants = cva('font-medium', { - variants: { - variant: { - primary: 'text-white', - secondary: 'text-secondary-foreground', - outline: 'text-foreground', - ghost: 'text-foreground', - }, - size: { - sm: 'text-sm', - md: 'text-base', - lg: 'text-lg', - }, - }, - defaultVariants: { - variant: 'primary', - size: 'md', - }, + variants: { + variant: { + primary: 'text-white', + secondary: 'text-secondary-foreground', + outline: 'text-foreground', + ghost: 'text-foreground', + }, + size: { + sm: 'text-sm', + md: 'text-base', + lg: 'text-lg', + }, + }, + defaultVariants: { + variant: 'primary', + size: 'md', + }, }); interface ButtonProps extends PressableProps, VariantProps { - children: string; - loading?: boolean; + children: string; + loading?: boolean; } export function Button({ - children, - variant, - size, - loading = false, - disabled, - className, - ...props + children, + variant, + size, + loading = false, + disabled, + className, + ...props }: ButtonProps) { - return ( - - {loading ? ( - - ) : ( - {children} - )} - - ); + return ( + + {loading ? ( + + ) : ( + {children} + )} + + ); } ``` @@ -656,25 +640,22 @@ export function Button({ ```javascript // tailwind.config.js module.exports = { - content: [ - './app/**/*.{js,ts,tsx}', - './components/**/*.{js,ts,tsx}', - ], - presets: [require('nativewind/preset')], - theme: { - extend: { - colors: { - primary: '#0A84FF', - secondary: '#5856D6', - background: '#F2F2F7', - foreground: '#1C1C1E', - card: '#FFFFFF', - border: '#E5E5EA', - muted: '#8E8E93', - 'muted-foreground': '#8E8E93', - }, - }, - }, + content: ['./app/**/*.{js,ts,tsx}', './components/**/*.{js,ts,tsx}'], + presets: [require('nativewind/preset')], + theme: { + extend: { + colors: { + primary: '#0A84FF', + secondary: '#5856D6', + background: '#F2F2F7', + foreground: '#1C1C1E', + card: '#FFFFFF', + border: '#E5E5EA', + muted: '#8E8E93', + 'muted-foreground': '#8E8E93', + }, + }, + }, }; ``` @@ -708,68 +689,62 @@ import { useAuth } from '../../context/AuthProvider'; import { Button } from '../../components/ui/Button'; export default function LoginScreen() { - const { login } = useAuth(); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [loading, setLoading] = useState(false); + const { login } = useAuth(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); - async function handleLogin() { - if (!email.trim() || !password) { - Alert.alert('Error', 'Please fill in all fields'); - return; - } + async function handleLogin() { + if (!email.trim() || !password) { + Alert.alert('Error', 'Please fill in all fields'); + return; + } - setLoading(true); - const success = await login(email.trim(), password); - setLoading(false); + setLoading(true); + const success = await login(email.trim(), password); + setLoading(false); - if (success) { - router.replace('/'); - } else { - Alert.alert('Error', 'Invalid email or password'); - } - } + if (success) { + router.replace('/'); + } else { + Alert.alert('Error', 'Invalid email or password'); + } + } - return ( - - - Welcome Back - + return ( + + Welcome Back - - - - Email - - - + + + Email + + - - - Password - - - + + Password + + - - - - ); + + + + ); } ``` diff --git a/.claude/guidelines/nestjs-backend.md b/.claude/guidelines/nestjs-backend.md index c35c9e7d6..b00490882 100644 --- a/.claude/guidelines/nestjs-backend.md +++ b/.claude/guidelines/nestjs-backend.md @@ -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> { - try { - const conditions = [ - eq(files.userId, userId), - eq(files.isDeleted, false), - ]; + async findAll(userId: string, query: QueryFilesDto): Promise> { + 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> { - 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> { + 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> { - // Validation - if (!dto.name?.trim()) { - return err(ErrorCode.MISSING_REQUIRED_FIELD, 'File name is required'); - } + async create(userId: string, dto: CreateFileDto): Promise> { + // 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> { - // Check ownership first - const existingResult = await this.findById(id, userId); - if (!existingResult.ok) return existingResult; + async update(id: string, userId: string, dto: UpdateFileDto): Promise> { + // 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> { - // Check ownership first - const existingResult = await this.findById(id, userId); - if (!existingResult.ok) return existingResult; + async delete(id: string, userId: string): Promise> { + // 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> { - // 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> { + // 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(); + catch(exception: AppException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); - 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', + }; + } + } } ``` diff --git a/.claude/guidelines/sveltekit-web.md b/.claude/guidelines/sveltekit-web.md index 9d5704a30..a307c9352 100644 --- a/.claude/guidelines/sveltekit-web.md +++ b/.claude/guidelines/sveltekit-web.md @@ -44,22 +44,22 @@ apps/{project}/apps/web/ ```svelte ``` @@ -67,25 +67,21 @@ apps/{project}/apps/web/ ```svelte ``` @@ -93,30 +89,30 @@ apps/{project}/apps/web/ ```svelte ``` @@ -124,34 +120,29 @@ apps/{project}/apps/web/ ```svelte
onSelect?.(file)}> - {file.name} - {#if onDelete} - - {/if} + {file.name} + {#if onDelete} + + {/if}
``` @@ -159,12 +150,12 @@ apps/{project}/apps/web/ ```svelte @@ -190,68 +181,76 @@ let error = $state(null); let selectedId = $state(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 { - 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 { - 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 {#if fileStore.loading} - + {:else if fileStore.error} - + {:else} - fileStore.selectFile(file.id)} - onDelete={handleDelete} - /> + 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 { - ok: boolean; - data?: T; - error?: AppError; + ok: boolean; + data?: T; + error?: AppError; } -async function request( - endpoint: string, - options: RequestInit = {} -): Promise> { - if (!browser) { - return { ok: false, error: { code: ErrorCode.INTERNAL_ERROR, message: 'SSR not supported' } }; - } +async function request(endpoint: string, options: RequestInit = {}): Promise> { + 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 = await response.json(); + const json: ApiResponse = 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(`/api/v1/files${folderId ? `?folderId=${folderId}` : ''}`), + files: { + list: (folderId?: string) => + request(`/api/v1/files${folderId ? `?folderId=${folderId}` : ''}`), - get: (id: string) => - request(`/api/v1/files/${id}`), + get: (id: string) => request(`/api/v1/files/${id}`), - create: (data: CreateFileDto) => - request('/api/v1/files', { - method: 'POST', - body: JSON.stringify(data), - }), + create: (data: CreateFileDto) => + request('/api/v1/files', { + method: 'POST', + body: JSON.stringify(data), + }), - update: (id: string, data: UpdateFileDto) => - request(`/api/v1/files/${id}`, { - method: 'PATCH', - body: JSON.stringify(data), - }), + update: (id: string, data: UpdateFileDto) => + request(`/api/v1/files/${id}`, { + method: 'PATCH', + body: JSON.stringify(data), + }), - delete: (id: string) => - request(`/api/v1/files/${id}`, { method: 'DELETE' }), - }, + delete: (id: string) => request(`/api/v1/files/${id}`, { method: 'DELETE' }), + }, - folders: { - list: () => request('/api/v1/folders'), - get: (id: string) => request(`/api/v1/folders/${id}`), - create: (data: CreateFolderDto) => - request('/api/v1/folders', { - method: 'POST', - body: JSON.stringify(data), - }), - }, + folders: { + list: () => request('/api/v1/folders'), + get: (id: string) => request(`/api/v1/folders/${id}`), + create: (data: CreateFolderDto) => + request('/api/v1/folders', { + method: 'POST', + body: JSON.stringify(data), + }), + }, }; ``` @@ -412,32 +406,32 @@ src/routes/ ```svelte {#if authStore.isAuthenticated} -
- -
- {@render children()} -
-
+
+ +
+ {@render children()} +
+
{:else} -
- -
+
+ +
{/if} ``` @@ -446,41 +440,41 @@ src/routes/ ```svelte {#if loading} - + {:else if error} - + {:else if file} - + {/if} ``` @@ -491,51 +485,51 @@ src/routes/ ```svelte
e.key === 'Enter' && onSelect?.()} + onclick={onSelect} + role="button" + tabindex="0" + onkeydown={(e) => e.key === 'Enter' && onSelect?.()} > -
- +
+ -
-

{file.name}

-

- {formattedSize} • {formattedDate} -

-
+
+

{file.name}

+

+ {formattedSize} • {formattedDate} +

+
- {#if onDelete} - - {/if} -
+ {#if onDelete} + + {/if} +
``` @@ -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
- {#if errors.form} -
{errors.form}
- {/if} + {#if errors.form} +
{errors.form}
+ {/if} -
- - - {#if errors.name} - {errors.name} - {/if} -
+
+ + + {#if errors.name} + {errors.name} + {/if} +
-
- - - {#if errors.email} - {errors.email} - {/if} -
+
+ + + {#if errors.email} + {errors.email} + {/if} +
- +
``` diff --git a/COMMANDS.md b/COMMANDS.md index 349a90584..86c7a7b1e 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -4,7 +4,6 @@ pnpm docker:up:all - pnpm docker:down pnpm dev:chat:app @@ -16,26 +15,30 @@ pnpm dev:zitare:app pnpm dev:presi:app # Deployment Landingpages: -## Einzelne Landing Page - pnpm deploy:landing:chat - pnpm deploy:landing:picture - pnpm deploy:landing:manacore - pnpm deploy:landing:manadeck - pnpm deploy:landing:zitare - Hier sind alle Landing Page URLs: +## Einzelne Landing Page + +pnpm deploy:landing:chat +pnpm deploy:landing:picture +pnpm deploy:landing:manacore +pnpm deploy:landing:manadeck +pnpm deploy:landing:zitare + +Hier sind alle Landing Page URLs: | Projekt | URL | - |----------|------------------------------------| - | Chat | https://chat-landing-90m.pages.dev | - | Picture | https://picture-landing.pages.dev | - | ManaCore | https://manacore-landing.pages.dev | - | ManaDeck | https://manadeck-landing.pages.dev | - | Zitare | https://zitare-landing.pages.dev | - | Presi | https://presi-landing.pages.dev | - ## Alle auf einmal - pnpm deploy:landing:all +|----------|------------------------------------| +| Chat | https://chat-landing-90m.pages.dev | +| Picture | https://picture-landing.pages.dev | +| ManaCore | https://manacore-landing.pages.dev | +| ManaDeck | https://manadeck-landing.pages.dev | +| Zitare | https://zitare-landing.pages.dev | +| Presi | https://presi-landing.pages.dev | + +## Alle auf einmal + +pnpm deploy:landing:all Übersicht aller wichtigen Befehle zum Starten, Stoppen und Verwalten der Apps. diff --git a/apps/calendar/apps/backend/src/calendar/calendar.controller.ts b/apps/calendar/apps/backend/src/calendar/calendar.controller.ts index 54f19297d..6e645b82d 100644 --- a/apps/calendar/apps/backend/src/calendar/calendar.controller.ts +++ b/apps/calendar/apps/backend/src/calendar/calendar.controller.ts @@ -1,13 +1,4 @@ -import { - Controller, - Get, - Post, - Put, - Delete, - Body, - Param, - UseGuards, -} from '@nestjs/common'; +import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common'; import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; import { CalendarService } from './calendar.service'; import { CreateCalendarDto, UpdateCalendarDto } from './dto'; diff --git a/apps/calendar/apps/backend/src/calendar/calendar.service.ts b/apps/calendar/apps/backend/src/calendar/calendar.service.ts index a7ae8cbd2..72f4ec317 100644 --- a/apps/calendar/apps/backend/src/calendar/calendar.service.ts +++ b/apps/calendar/apps/backend/src/calendar/calendar.service.ts @@ -81,9 +81,7 @@ export class CalendarService { } } - await this.db - .delete(calendars) - .where(and(eq(calendars.id, id), eq(calendars.userId, userId))); + await this.db.delete(calendars).where(and(eq(calendars.id, id), eq(calendars.userId, userId))); } async getOrCreateDefaultCalendar(userId: string): Promise { diff --git a/apps/calendar/apps/backend/src/db/schema/events.schema.ts b/apps/calendar/apps/backend/src/db/schema/events.schema.ts index bc6fa8952..0318d408a 100644 --- a/apps/calendar/apps/backend/src/db/schema/events.schema.ts +++ b/apps/calendar/apps/backend/src/db/schema/events.schema.ts @@ -1,4 +1,13 @@ -import { pgTable, uuid, timestamp, varchar, text, boolean, jsonb, index } from 'drizzle-orm/pg-core'; +import { + pgTable, + uuid, + timestamp, + varchar, + text, + boolean, + jsonb, + index, +} from 'drizzle-orm/pg-core'; import { calendars } from './calendars.schema'; /** diff --git a/apps/calendar/apps/backend/src/db/schema/external-calendars.schema.ts b/apps/calendar/apps/backend/src/db/schema/external-calendars.schema.ts index 218a7b682..ab3c04c9e 100644 --- a/apps/calendar/apps/backend/src/db/schema/external-calendars.schema.ts +++ b/apps/calendar/apps/backend/src/db/schema/external-calendars.schema.ts @@ -1,4 +1,13 @@ -import { pgTable, uuid, timestamp, varchar, text, boolean, jsonb, integer } from 'drizzle-orm/pg-core'; +import { + pgTable, + uuid, + timestamp, + varchar, + text, + boolean, + jsonb, + integer, +} from 'drizzle-orm/pg-core'; /** * Provider-specific metadata diff --git a/apps/calendar/apps/backend/src/event/event.controller.ts b/apps/calendar/apps/backend/src/event/event.controller.ts index 15c816779..518ae4e83 100644 --- a/apps/calendar/apps/backend/src/event/event.controller.ts +++ b/apps/calendar/apps/backend/src/event/event.controller.ts @@ -1,14 +1,4 @@ -import { - Controller, - Get, - Post, - Put, - Delete, - Body, - Param, - Query, - UseGuards, -} from '@nestjs/common'; +import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common'; import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; import { EventService } from './event.service'; import { CreateEventDto, UpdateEventDto, QueryEventsDto } from './dto'; diff --git a/apps/calendar/apps/backend/src/event/event.service.ts b/apps/calendar/apps/backend/src/event/event.service.ts index 87c08a840..68eeb64b9 100644 --- a/apps/calendar/apps/backend/src/event/event.service.ts +++ b/apps/calendar/apps/backend/src/event/event.service.ts @@ -32,9 +32,7 @@ export class EventService { // Exclude cancelled unless requested if (!query.includeCancelled) { - conditions.push( - or(eq(events.status, 'confirmed'), eq(events.status, 'tentative')) as any - ); + conditions.push(or(eq(events.status, 'confirmed'), eq(events.status, 'tentative')) as any); } // Search filter diff --git a/apps/calendar/apps/backend/src/reminder/reminder.controller.ts b/apps/calendar/apps/backend/src/reminder/reminder.controller.ts index fa3256290..77d18b7bd 100644 --- a/apps/calendar/apps/backend/src/reminder/reminder.controller.ts +++ b/apps/calendar/apps/backend/src/reminder/reminder.controller.ts @@ -1,12 +1,4 @@ -import { - Controller, - Get, - Post, - Delete, - Body, - Param, - UseGuards, -} from '@nestjs/common'; +import { Controller, Get, Post, Delete, Body, Param, UseGuards } from '@nestjs/common'; import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; import { ReminderService } from './reminder.service'; import { CreateReminderDto } from './dto'; @@ -17,10 +9,7 @@ export class ReminderController { constructor(private readonly reminderService: ReminderService) {} @Get('events/:eventId/reminders') - async findByEvent( - @CurrentUser() user: CurrentUserData, - @Param('eventId') eventId: string - ) { + async findByEvent(@CurrentUser() user: CurrentUserData, @Param('eventId') eventId: string) { const reminders = await this.reminderService.findByEvent(eventId, user.userId); return { reminders }; } diff --git a/apps/calendar/apps/backend/src/reminder/reminder.service.ts b/apps/calendar/apps/backend/src/reminder/reminder.service.ts index 304796598..a8453dee8 100644 --- a/apps/calendar/apps/backend/src/reminder/reminder.service.ts +++ b/apps/calendar/apps/backend/src/reminder/reminder.service.ts @@ -61,9 +61,7 @@ export class ReminderService { throw new NotFoundException(`Reminder with id ${id} not found`); } - await this.db - .delete(reminders) - .where(and(eq(reminders.id, id), eq(reminders.userId, userId))); + await this.db.delete(reminders).where(and(eq(reminders.id, id), eq(reminders.userId, userId))); } async getPendingReminders(): Promise { @@ -74,9 +72,7 @@ export class ReminderService { return this.db .select() .from(reminders) - .where( - and(eq(reminders.status, 'pending'), lte(reminders.reminderTime, oneMinuteFromNow)) - ); + .where(and(eq(reminders.status, 'pending'), lte(reminders.reminderTime, oneMinuteFromNow))); } async markAsSent(id: string): Promise { @@ -116,7 +112,9 @@ export class ReminderService { // TODO: Implement actual notification sending // For now, just log and mark as sent - console.log(`[Reminder] Event "${event.title}" starting in ${reminder.minutesBefore} minutes`); + console.log( + `[Reminder] Event "${event.title}" starting in ${reminder.minutesBefore} minutes` + ); if (reminder.notifyPush) { // TODO: Send push notification via Expo Push API @@ -145,9 +143,7 @@ export class ReminderService { .where(and(eq(reminders.eventId, eventId), eq(reminders.status, 'pending'))); for (const reminder of eventReminders) { - const newReminderTime = new Date( - newStartTime.getTime() - reminder.minutesBefore * 60 * 1000 - ); + const newReminderTime = new Date(newStartTime.getTime() - reminder.minutesBefore * 60 * 1000); await this.db .update(reminders) diff --git a/apps/calendar/apps/backend/src/share/dto/create-share.dto.ts b/apps/calendar/apps/backend/src/share/dto/create-share.dto.ts index b68accc93..2ef3d78f8 100644 --- a/apps/calendar/apps/backend/src/share/dto/create-share.dto.ts +++ b/apps/calendar/apps/backend/src/share/dto/create-share.dto.ts @@ -1,4 +1,12 @@ -import { IsString, IsOptional, IsBoolean, IsIn, IsEmail, IsDateString, IsUUID } from 'class-validator'; +import { + IsString, + IsOptional, + IsBoolean, + IsIn, + IsEmail, + IsDateString, + IsUUID, +} from 'class-validator'; export class CreateShareDto { @IsUUID() diff --git a/apps/calendar/apps/backend/src/share/share.controller.ts b/apps/calendar/apps/backend/src/share/share.controller.ts index 7278d7969..0180c281c 100644 --- a/apps/calendar/apps/backend/src/share/share.controller.ts +++ b/apps/calendar/apps/backend/src/share/share.controller.ts @@ -1,13 +1,4 @@ -import { - Controller, - Get, - Post, - Put, - Delete, - Body, - Param, - UseGuards, -} from '@nestjs/common'; +import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common'; import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; import { ShareService } from './share.service'; import { CreateShareDto, UpdateShareDto } from './dto'; @@ -50,10 +41,7 @@ export class ShareController { } @Delete('calendars/:calendarId/shares/:shareId') - async delete( - @CurrentUser() user: CurrentUserData, - @Param('shareId') shareId: string - ) { + async delete(@CurrentUser() user: CurrentUserData, @Param('shareId') shareId: string) { await this.shareService.delete(shareId, user.userId); return { success: true }; } @@ -69,19 +57,13 @@ export class ShareController { } @Post('shares/:shareId/accept') - async acceptInvitation( - @CurrentUser() user: CurrentUserData, - @Param('shareId') shareId: string - ) { + async acceptInvitation(@CurrentUser() user: CurrentUserData, @Param('shareId') shareId: string) { const share = await this.shareService.acceptInvitation(shareId, user.userId); return { share }; } @Post('shares/:shareId/decline') - async declineInvitation( - @CurrentUser() user: CurrentUserData, - @Param('shareId') shareId: string - ) { + async declineInvitation(@CurrentUser() user: CurrentUserData, @Param('shareId') shareId: string) { const share = await this.shareService.declineInvitation(shareId, user.userId); return { share }; } diff --git a/apps/calendar/apps/backend/src/share/share.service.ts b/apps/calendar/apps/backend/src/share/share.service.ts index d5694e2b7..0cfd94314 100644 --- a/apps/calendar/apps/backend/src/share/share.service.ts +++ b/apps/calendar/apps/backend/src/share/share.service.ts @@ -22,17 +22,11 @@ export class ShareService { // Verify user owns the calendar await this.calendarService.findByIdOrThrow(calendarId, userId); - return this.db - .select() - .from(calendarShares) - .where(eq(calendarShares.calendarId, calendarId)); + return this.db.select().from(calendarShares).where(eq(calendarShares.calendarId, calendarId)); } async findById(id: string): Promise { - const result = await this.db - .select() - .from(calendarShares) - .where(eq(calendarShares.id, id)); + const result = await this.db.select().from(calendarShares).where(eq(calendarShares.id, id)); return result[0] || null; } @@ -43,10 +37,7 @@ export class ShareService { .where( and( eq(calendarShares.status, 'pending'), - or( - eq(calendarShares.sharedWithUserId, userId), - eq(calendarShares.sharedWithEmail, email) - ) + or(eq(calendarShares.sharedWithUserId, userId), eq(calendarShares.sharedWithEmail, email)) ) ); } @@ -174,10 +165,7 @@ export class ShareService { .select() .from(calendarShares) .where( - and( - eq(calendarShares.sharedWithUserId, userId), - eq(calendarShares.status, 'accepted') - ) + and(eq(calendarShares.sharedWithUserId, userId), eq(calendarShares.status, 'accepted')) ); } } diff --git a/apps/calendar/apps/landing/src/components/CTA.astro b/apps/calendar/apps/landing/src/components/CTA.astro index 4591fb0e3..f7935d81f 100644 --- a/apps/calendar/apps/landing/src/components/CTA.astro +++ b/apps/calendar/apps/landing/src/components/CTA.astro @@ -4,7 +4,8 @@
-
+
+
@@ -12,39 +13,44 @@ Bereit, deine Zeit zu organisieren?

- Starte kostenlos und erlebe, wie einfach Zeitmanagement sein kann. - Keine Kreditkarte erforderlich. + Starte kostenlos und erlebe, wie einfach Zeitmanagement sein kann. Keine Kreditkarte + erforderlich.

- + Kostenlos starten
- + Keine Kreditkarte
- + Jederzeit kündbar
diff --git a/apps/calendar/apps/landing/src/components/Features.astro b/apps/calendar/apps/landing/src/components/Features.astro index d47f0fa2a..ba79d4c87 100644 --- a/apps/calendar/apps/landing/src/components/Features.astro +++ b/apps/calendar/apps/landing/src/components/Features.astro @@ -7,43 +7,49 @@ const features = [ `, title: 'Mehrere Kalender', - description: 'Verwalte verschiedene Kalender für Arbeit, Privates, Familie und mehr - alles übersichtlich farbcodiert.' + description: + 'Verwalte verschiedene Kalender für Arbeit, Privates, Familie und mehr - alles übersichtlich farbcodiert.', }, { icon: ` `, title: 'Kalender teilen', - description: 'Teile Kalender mit Familie, Freunden oder Kollegen. Vergib Lese- oder Bearbeitungsrechte.' + description: + 'Teile Kalender mit Familie, Freunden oder Kollegen. Vergib Lese- oder Bearbeitungsrechte.', }, { icon: ` `, title: 'CalDAV & iCal Sync', - description: 'Synchronisiere mit Google Calendar, Apple Calendar, Outlook und jedem CalDAV-kompatiblen Dienst.' + description: + 'Synchronisiere mit Google Calendar, Apple Calendar, Outlook und jedem CalDAV-kompatiblen Dienst.', }, { icon: ` `, title: 'Smarte Erinnerungen', - description: 'Nie wieder einen Termin verpassen. Push-Benachrichtigungen und E-Mail-Erinnerungen zur rechten Zeit.' + description: + 'Nie wieder einen Termin verpassen. Push-Benachrichtigungen und E-Mail-Erinnerungen zur rechten Zeit.', }, { icon: ` `, title: 'Wiederkehrende Termine', - description: 'Erstelle einmalige oder wiederkehrende Termine mit flexiblen Wiederholungsregeln nach RFC 5545.' + description: + 'Erstelle einmalige oder wiederkehrende Termine mit flexiblen Wiederholungsregeln nach RFC 5545.', }, { icon: ` `, title: 'Mobile & Desktop', - description: 'Greife von überall auf deine Termine zu - Web-App, iOS und Android mit Offline-Support.' - } + description: + 'Greife von überall auf deine Termine zu - Web-App, iOS und Android mit Offline-Support.', + }, ]; --- @@ -54,9 +60,7 @@ const features = [ Funktionen -

- Alles was du brauchst -

+

Alles was du brauchst

Kalender bietet alle Funktionen, die du für effektives Zeitmanagement benötigst.

@@ -64,15 +68,17 @@ const features = [
- {features.map((feature) => ( -
-
- + { + features.map((feature) => ( +
+
+ +
+

{feature.title}

+

{feature.description}

-

{feature.title}

-

{feature.description}

-
- ))} + )) + }
diff --git a/apps/calendar/apps/landing/src/components/Footer.astro b/apps/calendar/apps/landing/src/components/Footer.astro index eff17e4a8..486ce44d4 100644 --- a/apps/calendar/apps/landing/src/components/Footer.astro +++ b/apps/calendar/apps/landing/src/components/Footer.astro @@ -8,18 +8,18 @@ const links = { { name: 'Funktionen', href: '#features' }, { name: 'Preise', href: '#pricing' }, { name: 'Changelog', href: '/changelog' }, - { name: 'Roadmap', href: '/roadmap' } + { name: 'Roadmap', href: '/roadmap' }, ], legal: [ { name: 'Impressum', href: '/impressum' }, { name: 'Datenschutz', href: '/datenschutz' }, - { name: 'AGB', href: '/agb' } + { name: 'AGB', href: '/agb' }, ], support: [ { name: 'FAQ', href: '/faq' }, { name: 'Kontakt', href: '/kontakt' }, - { name: 'Status', href: '/status' } - ] + { name: 'Status', href: '/status' }, + ], }; --- @@ -29,53 +29,75 @@ const links = {
- - + + Kalender
-

- Smart Calendar Management für besseres Zeitmanagement. -

+

Smart Calendar Management für besseres Zeitmanagement.

Produkt

Rechtliches

Support

-
+

© {currentYear} Kalender. Alle Rechte vorbehalten.

diff --git a/apps/calendar/apps/landing/src/components/Hero.astro b/apps/calendar/apps/landing/src/components/Hero.astro index 0954007fb..2bf3c8ff7 100644 --- a/apps/calendar/apps/landing/src/components/Hero.astro +++ b/apps/calendar/apps/landing/src/components/Hero.astro @@ -4,23 +4,24 @@
-
-
+
-
-
+
-
+
- + Smart Kalender-Management
@@ -33,34 +34,38 @@

- Persönliche Kalender, geteilte Termine, CalDAV-Synchronisation und smarte Erinnerungen - alles an einem Ort. Behalte den Überblick über dein Leben. + Persönliche Kalender, geteilte Termine, CalDAV-Synchronisation und smarte Erinnerungen - + alles an einem Ort. Behalte den Überblick über dein Leben.

- {[1, 2, 3, 4, 5].map((i) => ( -
- ))} + { + [1, 2, 3, 4, 5].map((i) => ( +
+ )) + }

500+ Nutzer vertrauen Kalender @@ -70,7 +75,10 @@

-
+
+
@@ -89,14 +97,20 @@
- {['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'].map((day) => ( -
{day}
- ))} - {Array.from({ length: 35 }, (_, i) => ( -
- {((i % 31) + 1).toString()} -
- ))} + { + ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'].map((day) => ( +
{day}
+ )) + } + { + Array.from({ length: 35 }, (_, i) => ( +
+ {((i % 31) + 1).toString()} +
+ )) + }
diff --git a/apps/calendar/apps/landing/src/pages/index.astro b/apps/calendar/apps/landing/src/pages/index.astro index dc8c26416..56fff2432 100644 --- a/apps/calendar/apps/landing/src/pages/index.astro +++ b/apps/calendar/apps/landing/src/pages/index.astro @@ -114,104 +114,139 @@ const pricingPlans = [ - {StepsSection && ( - - )} + { + StepsSection && ( + + ) + } - {!StepsSection && ( -
-
-
- - So funktioniert's - -

So einfach geht's

-

In drei Schritten zum organisierten Leben

-
+ { + !StepsSection && ( +
+
+
+ + So funktioniert's + +

So einfach geht's

+

In drei Schritten zum organisierten Leben

+
-
- {steps.map((step) => ( -
-
- {step.number} -
-

{step.title}

-

{step.description}

-
- ))} -
-
-
- )} - - {PricingSection && ( - - )} - - {!PricingSection && ( -
-
-
- - Preise - -

Einfache, transparente Preise

-

Starte kostenlos, upgrade wenn du mehr brauchst

-
- -
- {pricingPlans.map((plan) => ( -
- {plan.badge && ( -
- {plan.badge} +
+ {steps.map((step) => ( +
+
+ {step.number}
- )} -

{plan.name}

-

{plan.description}

-
- {plan.price}€ - {plan.period} +

{step.title}

+

{step.description}

-
    - {plan.features.map((feature) => ( -
  • - {feature.included ? ( - - - - ) : ( - - - - )} - {feature.text} -
  • - ))} -
- - {plan.cta.text} - -
- ))} + ))} +
-
-
- )} +
+ ) + } + + { + PricingSection && ( + + ) + } + + { + !PricingSection && ( +
+
+
+ + Preise + +

Einfache, transparente Preise

+

Starte kostenlos, upgrade wenn du mehr brauchst

+
+ +
+ {pricingPlans.map((plan) => ( +
+ {plan.badge && ( +
+ {plan.badge} +
+ )} +

{plan.name}

+

{plan.description}

+
+ {plan.price}€ + {plan.period} +
+
    + {plan.features.map((feature) => ( +
  • + {feature.included ? ( + + + + ) : ( + + + + )} + {feature.text} +
  • + ))} +
+ + {plan.cta.text} + +
+ ))} +
+
+
+ ) + }
diff --git a/apps/calendar/apps/web/src/lib/components/calendar/CalendarHeader.svelte b/apps/calendar/apps/web/src/lib/components/calendar/CalendarHeader.svelte index 92b511c70..4a2ec4a7c 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/CalendarHeader.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/CalendarHeader.svelte @@ -23,9 +23,17 @@ const weekStart = viewStore.viewRange.start; const weekEnd = viewStore.viewRange.end; if (weekStart.getMonth() === weekEnd.getMonth()) { - return format(weekStart, 'd.', { locale: de }) + ' - ' + format(weekEnd, 'd. MMMM yyyy', { locale: de }); + return ( + format(weekStart, 'd.', { locale: de }) + + ' - ' + + format(weekEnd, 'd. MMMM yyyy', { locale: de }) + ); } - return format(weekStart, 'd. MMM', { locale: de }) + ' - ' + format(weekEnd, 'd. MMM yyyy', { locale: de }); + return ( + format(weekStart, 'd. MMM', { locale: de }) + + ' - ' + + format(weekEnd, 'd. MMM yyyy', { locale: de }) + ); case 'month': return format(date, 'MMMM yyyy', { locale: de }); case 'year': @@ -44,17 +52,28 @@
- +
@@ -102,7 +107,12 @@ {#if contacts.length > 0}
- + - + {error} - +
{/if} @@ -131,14 +146,27 @@
- +

Archiv ist leer

-

Archivierte Kontakte erscheinen hier. Du kannst sie später wiederherstellen oder endgültig löschen.

+

+ Archivierte Kontakte erscheinen hier. Du kannst sie später wiederherstellen oder endgültig + löschen. +

- + Zu Kontakten @@ -147,7 +175,12 @@
- +

Keine Ergebnisse

@@ -156,7 +189,12 @@ {:else}
- + Archivierte Kontakte können wiederhergestellt oder endgültig gelöscht werden.
@@ -201,7 +239,12 @@ title="Wiederherstellen" > - +
@@ -219,7 +267,9 @@ {/each}
-

{contacts.length} archiviert{contacts.length !== 1 ? 'e Kontakte' : 'er Kontakt'}

+

+ {contacts.length} archiviert{contacts.length !== 1 ? 'e Kontakte' : 'er Kontakt'} +

{/if}
@@ -443,7 +493,11 @@ width: 3rem; height: 3rem; border-radius: 50%; - background: linear-gradient(135deg, hsl(var(--color-primary)) 0%, hsl(var(--color-primary) / 0.7) 100%); + background: linear-gradient( + 135deg, + hsl(var(--color-primary)) 0%, + hsl(var(--color-primary) / 0.7) 100% + ); color: hsl(var(--color-primary-foreground)); display: flex; align-items: center; diff --git a/apps/contacts/apps/web/src/routes/(app)/contacts/[id]/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/contacts/[id]/+page.svelte index 4e45e4f62..6eafc6ad5 100644 --- a/apps/contacts/apps/web/src/routes/(app)/contacts/[id]/+page.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/contacts/[id]/+page.svelte @@ -153,14 +153,36 @@

{editing ? 'Bearbeiten' : 'Kontakt'}

{#if contact && !editing && !loading}
- -
@@ -172,8 +194,20 @@ {#if loading}
- - + +

Lade Kontakt...

@@ -181,7 +215,12 @@
- +

{error}

@@ -191,7 +230,12 @@ {#if error} @@ -207,8 +251,18 @@
@@ -218,13 +272,24 @@ {/if}
-
{ e.preventDefault(); handleSave(); }} class="form"> + { + e.preventDefault(); + handleSave(); + }} + class="form" + >
- +

Name

@@ -246,7 +311,12 @@
- +

Kontakt

@@ -255,7 +325,12 @@
- +
@@ -265,7 +340,12 @@
- +
@@ -274,7 +354,12 @@
- +
@@ -287,7 +372,12 @@
- +

Arbeit

@@ -307,8 +397,18 @@
- - + +

Adresse

@@ -338,7 +438,12 @@
- +

Notizen

@@ -348,19 +453,42 @@
- @@ -401,7 +540,12 @@
- +
Anrufen @@ -411,7 +555,12 @@
- +
E-Mail @@ -421,7 +570,12 @@
- +
Nachricht @@ -437,7 +591,12 @@
- +

Kontakt

@@ -446,7 +605,12 @@ {#if contact.email}
- +
E-Mail @@ -457,7 +621,12 @@ {#if contact.phone} - +
Telefon @@ -468,7 +637,12 @@ {#if contact.mobile} - +
Mobil @@ -486,7 +660,12 @@
- +

Arbeit

@@ -495,7 +674,12 @@ {#if contact.company}
- +
Firma @@ -506,7 +690,12 @@ {#if contact.jobTitle}
- +
Position @@ -524,8 +713,18 @@
- - + +

Adresse

@@ -533,7 +732,9 @@
{#if contact.street}
{contact.street}
{/if} {#if contact.postalCode || contact.city} -
{[contact.postalCode, contact.city].filter(Boolean).join(' ')}
+
+ {[contact.postalCode, contact.city].filter(Boolean).join(' ')} +
{/if} {#if contact.country}
{contact.country}
{/if}
@@ -546,7 +747,12 @@
- +

Notizen

@@ -719,7 +925,11 @@ width: 100px; height: 100px; border-radius: 50%; - background: linear-gradient(135deg, hsl(var(--color-primary)) 0%, hsl(var(--color-primary) / 0.7) 100%); + background: linear-gradient( + 135deg, + hsl(var(--color-primary)) 0%, + hsl(var(--color-primary) / 0.7) 100% + ); color: hsl(var(--color-primary-foreground)); display: flex; align-items: center; diff --git a/apps/contacts/apps/web/src/routes/(app)/contacts/new/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/contacts/new/+page.svelte index a0af7798c..4f5900295 100644 --- a/apps/contacts/apps/web/src/routes/(app)/contacts/new/+page.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/contacts/new/+page.svelte @@ -89,8 +89,18 @@
@@ -103,19 +113,35 @@ {#if error} {/if} - { e.preventDefault(); handleSubmit(); }} class="form"> + { + e.preventDefault(); + handleSubmit(); + }} + class="form" + >
- +

Name

@@ -149,7 +175,12 @@
- +

Kontakt

@@ -158,7 +189,12 @@
- + Telefon
- + Mobil
- +
- +

Arbeit

@@ -240,8 +291,18 @@
- - + +

Adresse

@@ -269,13 +330,7 @@
- +
@@ -295,7 +350,12 @@
- +

Notizen

@@ -310,19 +370,34 @@
- - Abbrechen - + Abbrechen +
{/if} @@ -118,14 +130,26 @@
- +

Keine Favoriten

-

Markiere Kontakte als Favoriten, um sie hier schnell zu finden.

+

+ Markiere Kontakte als Favoriten, um sie hier schnell zu finden. +

- + Zu Kontakten @@ -134,7 +158,12 @@
- +

Keine Ergebnisse

@@ -179,7 +208,9 @@ aria-label="Aus Favoriten entfernen" > - +
@@ -399,7 +430,11 @@ width: 3rem; height: 3rem; border-radius: 50%; - background: linear-gradient(135deg, hsl(var(--color-primary)) 0%, hsl(var(--color-primary) / 0.7) 100%); + background: linear-gradient( + 135deg, + hsl(var(--color-primary)) 0%, + hsl(var(--color-primary) / 0.7) 100% + ); color: hsl(var(--color-primary-foreground)); display: flex; align-items: center; diff --git a/apps/contacts/apps/web/src/routes/(app)/groups/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/groups/+page.svelte index 512e69ca5..bf61f9b1f 100644 --- a/apps/contacts/apps/web/src/routes/(app)/groups/+page.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/groups/+page.svelte @@ -13,9 +13,7 @@ if (!searchQuery.trim()) return groups; const query = searchQuery.toLowerCase(); return groups.filter( - (g) => - g.name.toLowerCase().includes(query) || - g.description?.toLowerCase().includes(query) + (g) => g.name.toLowerCase().includes(query) || g.description?.toLowerCase().includes(query) ); }); @@ -77,7 +75,12 @@
- + - + {error}
@@ -104,14 +112,24 @@
- +

Keine Gruppen

Erstelle deine erste Gruppe um Kontakte zu organisieren.

- + Neue Gruppe @@ -120,7 +138,12 @@
- +

Keine Ergebnisse

@@ -150,11 +173,21 @@ aria-label="Gruppe löschen" > - + - +
diff --git a/apps/contacts/apps/web/src/routes/(app)/groups/[id]/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/groups/[id]/+page.svelte index d79133980..1ed80ba52 100644 --- a/apps/contacts/apps/web/src/routes/(app)/groups/[id]/+page.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/groups/[id]/+page.svelte @@ -22,8 +22,18 @@ let color = $state('#6366f1'); const presetColors = [ - '#ef4444', '#f97316', '#f59e0b', '#84cc16', '#22c55e', '#14b8a6', - '#06b6d4', '#3b82f6', '#6366f1', '#8b5cf6', '#a855f7', '#ec4899', + '#ef4444', + '#f97316', + '#f59e0b', + '#84cc16', + '#22c55e', + '#14b8a6', + '#06b6d4', + '#3b82f6', + '#6366f1', + '#8b5cf6', + '#a855f7', + '#ec4899', ]; const groupContacts = $derived(() => { @@ -162,11 +172,16 @@ -

{isEditing ? 'Gruppe bearbeiten' : (group?.name || 'Gruppe')}

+

{isEditing ? 'Gruppe bearbeiten' : group?.name || 'Gruppe'}

{#if !loading && group && !isEditing} - {:else} @@ -182,7 +197,12 @@
- +

Fehler

@@ -193,10 +213,15 @@ {#if error} {/if} @@ -205,18 +230,34 @@
- +

{name || 'Gruppenname'}

- { e.preventDefault(); handleSave(); }} class="form"> + { + e.preventDefault(); + handleSave(); + }} + class="form" + >
- +

Details

@@ -227,7 +268,8 @@
- +
@@ -235,7 +277,12 @@
- +

Farbe

@@ -247,11 +294,16 @@ class="color-option" class:selected={color === presetColor} style="background-color: {presetColor}" - onclick={() => color = presetColor} + onclick={() => (color = presetColor)} > {#if color === presetColor} - + {/if} @@ -264,8 +316,22 @@ @@ -287,7 +358,12 @@
- +

{group.name}

@@ -301,13 +377,23 @@
- +

Kontakte ({groupContacts().length})

- @@ -332,9 +418,18 @@ {contact.email} {/if}
-
@@ -348,15 +443,20 @@ {#if showAddContacts} -