diff --git a/apps/calendar/apps/server/package.json b/apps/calendar/apps/server/package.json new file mode 100644 index 000000000..50f94bfd5 --- /dev/null +++ b/apps/calendar/apps/server/package.json @@ -0,0 +1,17 @@ +{ + "name": "@calendar/server", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts" + }, + "dependencies": { + "@manacore/shared-hono": "workspace:*", + "hono": "^4.7.0" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/apps/calendar/apps/server/src/index.ts b/apps/calendar/apps/server/src/index.ts new file mode 100644 index 000000000..1c30f3d80 --- /dev/null +++ b/apps/calendar/apps/server/src/index.ts @@ -0,0 +1,119 @@ +/** + * Calendar Hono Server — RRULE expansion + Google Calendar sync + * + * CRUD for calendars/events handled by mana-sync. + * This server handles recurring event expansion and external calendar sync. + */ + +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { authMiddleware, healthRoute, errorHandler, notFoundHandler } from '@manacore/shared-hono'; + +const PORT = parseInt(process.env.PORT || '3003', 10); +const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5173').split(','); + +const app = new Hono(); + +app.onError(errorHandler); +app.notFound(notFoundHandler); +app.use('*', cors({ origin: CORS_ORIGINS, credentials: true })); +app.route('/health', healthRoute('calendar-server')); +app.use('/api/*', authMiddleware()); + +// ─── RRULE Expansion (server-only: DoS protection) ────────── + +app.post('/api/v1/events/expand', async (c) => { + const { rrule, dtstart, until, maxOccurrences } = await c.req.json(); + + if (!rrule || !dtstart) return c.json({ error: 'rrule and dtstart required' }, 400); + + const max = Math.min(maxOccurrences || 365, 5000); + + try { + // Simple RRULE expansion (daily, weekly, monthly, yearly) + const start = new Date(dtstart); + const end = until ? new Date(until) : new Date(start.getTime() + 365 * 24 * 60 * 60 * 1000); + const occurrences: string[] = []; + + const parts = rrule.replace('RRULE:', '').split(';'); + const freq = parts.find((p: string) => p.startsWith('FREQ='))?.split('=')[1]; + const interval = parseInt( + parts.find((p: string) => p.startsWith('INTERVAL='))?.split('=')[1] || '1', + 10 + ); + + let current = new Date(start); + + while (current <= end && occurrences.length < max) { + occurrences.push(current.toISOString()); + + switch (freq) { + case 'DAILY': + current = new Date(current.getTime() + interval * 24 * 60 * 60 * 1000); + break; + case 'WEEKLY': + current = new Date(current.getTime() + interval * 7 * 24 * 60 * 60 * 1000); + break; + case 'MONTHLY': + current = new Date(current.setMonth(current.getMonth() + interval)); + break; + case 'YEARLY': + current = new Date(current.setFullYear(current.getFullYear() + interval)); + break; + default: + occurrences.push(current.toISOString()); + current = end; // Break loop + } + } + + return c.json({ occurrences, count: occurrences.length }); + } catch (_err) { + return c.json({ error: 'RRULE expansion failed' }, 500); + } +}); + +// ─── Google Calendar Import (server-only: OAuth) ───────────── + +app.post('/api/v1/sync/google', async (c) => { + // TODO: Implement Google Calendar OAuth flow + // This requires server-side OAuth token exchange + return c.json({ error: 'Google Calendar sync not yet implemented' }, 501); +}); + +// ─── ICS Import (server-only: parsing) ─────────────────────── + +app.post('/api/v1/import/ics', async (c) => { + const formData = await c.req.formData(); + const file = formData.get('file') as File | null; + + if (!file) return c.json({ error: 'No file' }, 400); + + const text = await file.text(); + const events = parseICS(text); + return c.json({ events, count: events.length }); +}); + +function parseICS(text: string): Array> { + const events: Array> = []; + const blocks = text.split('BEGIN:VEVENT').filter((b) => b.includes('END:VEVENT')); + + for (const block of blocks) { + const event: Record = {}; + const lines = block.split(/\r?\n/); + + for (const line of lines) { + if (line.startsWith('SUMMARY:')) event.title = line.slice(8); + if (line.startsWith('DTSTART')) event.start = line.split(':').pop() || ''; + if (line.startsWith('DTEND')) event.end = line.split(':').pop() || ''; + if (line.startsWith('DESCRIPTION:')) event.description = line.slice(12); + if (line.startsWith('LOCATION:')) event.location = line.slice(9); + if (line.startsWith('RRULE:')) event.rrule = line.slice(6); + } + + if (event.title || event.start) events.push(event); + } + + return events; +} + +export default { port: PORT, fetch: app.fetch }; diff --git a/apps/calendar/apps/server/tsconfig.json b/apps/calendar/apps/server/tsconfig.json new file mode 100644 index 000000000..9c7e5fa56 --- /dev/null +++ b/apps/calendar/apps/server/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/calendar/apps/web/src/lib/composables/useDragToCreate.test.ts b/apps/calendar/apps/web/src/lib/composables/useDragToCreate.test.ts index 6ba089f64..c3176825c 100644 --- a/apps/calendar/apps/web/src/lib/composables/useDragToCreate.test.ts +++ b/apps/calendar/apps/web/src/lib/composables/useDragToCreate.test.ts @@ -11,6 +11,7 @@ if (typeof globalThis.PointerEvent === 'undefined') { vi.mock('$lib/utils/calendarConstants', () => ({ SNAP_INTERVAL_MINUTES: 15, + DEFAULT_EVENT_DURATION_MINUTES: 30, })); import { useDragToCreate } from './useDragToCreate.svelte'; diff --git a/apps/calendar/apps/web/src/lib/stores/external-calendars.test.ts b/apps/calendar/apps/web/src/lib/stores/external-calendars.test.ts index 680f254f5..bed028db3 100644 --- a/apps/calendar/apps/web/src/lib/stores/external-calendars.test.ts +++ b/apps/calendar/apps/web/src/lib/stores/external-calendars.test.ts @@ -14,6 +14,13 @@ vi.mock('@manacore/shared-ui', () => ({ toastStore: { error: vi.fn(), success: vi.fn() }, })); +vi.mock('svelte-i18n', () => { + const { readable } = require('svelte/store'); + return { + _: readable((key: string) => key), + }; +}); + import * as api from '$lib/api/sync'; import { externalCalendarsStore } from './external-calendars.svelte'; import type { ExternalCalendar } from '@calendar/shared'; diff --git a/apps/contacts/apps/server/package.json b/apps/contacts/apps/server/package.json new file mode 100644 index 000000000..a08a89747 --- /dev/null +++ b/apps/contacts/apps/server/package.json @@ -0,0 +1,18 @@ +{ + "name": "@contacts/server", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts" + }, + "dependencies": { + "@manacore/shared-hono": "workspace:*", + "@manacore/shared-storage": "workspace:*", + "hono": "^4.7.0" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/apps/contacts/apps/server/src/index.ts b/apps/contacts/apps/server/src/index.ts new file mode 100644 index 000000000..54d3e97ef --- /dev/null +++ b/apps/contacts/apps/server/src/index.ts @@ -0,0 +1,89 @@ +/** + * Contacts Hono Server — Photo upload + vCard/CSV import + * + * CRUD for contacts handled by mana-sync. + */ + +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { authMiddleware, healthRoute, errorHandler, notFoundHandler } from '@manacore/shared-hono'; + +const PORT = parseInt(process.env.PORT || '3004', 10); +const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5173').split(','); + +const app = new Hono(); + +app.onError(errorHandler); +app.notFound(notFoundHandler); +app.use('*', cors({ origin: CORS_ORIGINS, credentials: true })); +app.route('/health', healthRoute('contacts-server')); +app.use('/api/*', authMiddleware()); + +// ─── Avatar Upload (server-only: S3) ───────────────────────── + +app.post('/api/v1/contacts/:id/avatar', async (c) => { + const userId = c.get('userId'); + const formData = await c.req.formData(); + const file = formData.get('file') as File | null; + + if (!file) return c.json({ error: 'No file' }, 400); + if (file.size > 5 * 1024 * 1024) return c.json({ error: 'Max 5MB' }, 400); + + try { + const { createContactsStorage, generateUserFileKey, getContentType } = await import( + '@manacore/shared-storage' + ); + const storage = createContactsStorage(); + const key = generateUserFileKey( + userId, + `avatar-${c.req.param('id')}.${file.name.split('.').pop()}` + ); + const buffer = Buffer.from(await file.arrayBuffer()); + + const result = await storage.upload(key, buffer, { + contentType: getContentType(file.name), + public: true, + }); + + return c.json({ avatarUrl: result.url }, 201); + } catch (_err) { + return c.json({ error: 'Upload failed' }, 500); + } +}); + +// ─── vCard Import (server-only: parsing) ───────────────────── + +app.post('/api/v1/import/vcard', async (c) => { + const formData = await c.req.formData(); + const file = formData.get('file') as File | null; + + if (!file) return c.json({ error: 'No file' }, 400); + + const text = await file.text(); + const contacts = parseVCard(text); + return c.json({ contacts, count: contacts.length }); +}); + +function parseVCard(text: string): Array> { + const contacts: Array> = []; + const cards = text.split('BEGIN:VCARD').filter((c) => c.includes('END:VCARD')); + + for (const card of cards) { + const contact: Record = {}; + const lines = card.split(/\r?\n/); + + for (const line of lines) { + if (line.startsWith('FN:')) contact.name = line.slice(3); + if (line.startsWith('EMAIL')) contact.email = line.split(':').pop() || ''; + if (line.startsWith('TEL')) contact.phone = line.split(':').pop() || ''; + if (line.startsWith('ORG:')) contact.company = line.slice(4); + if (line.startsWith('TITLE:')) contact.title = line.slice(6); + } + + if (contact.name || contact.email) contacts.push(contact); + } + + return contacts; +} + +export default { port: PORT, fetch: app.fetch }; diff --git a/apps/contacts/apps/server/tsconfig.json b/apps/contacts/apps/server/tsconfig.json new file mode 100644 index 000000000..9c7e5fa56 --- /dev/null +++ b/apps/contacts/apps/server/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/storage/apps/server/package.json b/apps/storage/apps/server/package.json new file mode 100644 index 000000000..18f636349 --- /dev/null +++ b/apps/storage/apps/server/package.json @@ -0,0 +1,18 @@ +{ + "name": "@storage/server", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts" + }, + "dependencies": { + "@manacore/shared-hono": "workspace:*", + "@manacore/shared-storage": "workspace:*", + "hono": "^4.7.0" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/apps/storage/apps/server/src/index.ts b/apps/storage/apps/server/src/index.ts new file mode 100644 index 000000000..7b86cfd5c --- /dev/null +++ b/apps/storage/apps/server/src/index.ts @@ -0,0 +1,117 @@ +/** + * Storage Hono Server — File upload/download via S3 + * + * Metadata CRUD for files/folders handled by mana-sync. + * This server handles S3 operations (upload, download, presigned URLs). + */ + +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { authMiddleware, healthRoute, errorHandler, notFoundHandler } from '@manacore/shared-hono'; + +const PORT = parseInt(process.env.PORT || '3016', 10); +const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5185').split(','); + +const app = new Hono(); + +app.onError(errorHandler); +app.notFound(notFoundHandler); +app.use('*', cors({ origin: CORS_ORIGINS, credentials: true })); +app.route('/health', healthRoute('storage-server')); +app.use('/api/*', authMiddleware()); + +// ─── File Upload (server-only: S3) ────────────────────────── + +app.post('/api/v1/files/upload', async (c) => { + const userId = c.get('userId'); + const formData = await c.req.formData(); + const file = formData.get('file') as File | null; + const folderId = formData.get('folderId') as string | null; + + if (!file) return c.json({ error: 'No file' }, 400); + if (file.size > 100 * 1024 * 1024) return c.json({ error: 'Max 100MB' }, 400); + + try { + const { createStorageStorage, generateUserFileKey, getContentType } = await import( + '@manacore/shared-storage' + ); + const storage = createStorageStorage(); + const key = generateUserFileKey(userId, file.name); + const buffer = Buffer.from(await file.arrayBuffer()); + + await storage.upload(key, buffer, { + contentType: getContentType(file.name), + public: false, + }); + + return c.json( + { + id: crypto.randomUUID(), + name: file.name, + storagePath: key, + storageKey: key, + mimeType: file.type, + size: file.size, + parentFolderId: folderId, + }, + 201 + ); + } catch (_err) { + return c.json({ error: 'Upload failed' }, 500); + } +}); + +// ─── File Download (server-only: S3 presigned URL) ────────── + +app.get('/api/v1/files/:id/download', async (c) => { + const storagePath = c.req.query('storagePath'); + const urlOnly = c.req.query('url') === 'true'; + + if (!storagePath) return c.json({ error: 'storagePath required' }, 400); + + try { + const { createStorageStorage } = await import('@manacore/shared-storage'); + const storage = createStorageStorage(); + + if (urlOnly) { + const url = await storage.getDownloadUrl(storagePath, { expiresIn: 3600 }); + return c.json({ url }); + } + + const data = await storage.download(storagePath); + return new Response(data.body, { + headers: { + 'Content-Type': data.contentType || 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${storagePath.split('/').pop()}"`, + }, + }); + } catch (_err) { + return c.json({ error: 'Download failed' }, 500); + } +}); + +// ─── Version Upload ───────────────────────────────────────── + +app.post('/api/v1/files/:id/versions', async (c) => { + const userId = c.get('userId'); + const fileId = c.req.param('id'); + const formData = await c.req.formData(); + const file = formData.get('file') as File | null; + + if (!file) return c.json({ error: 'No file' }, 400); + + try { + const { createStorageStorage, generateUserFileKey } = await import('@manacore/shared-storage'); + const storage = createStorageStorage(); + const key = generateUserFileKey(userId, `v-${Date.now()}-${file.name}`); + const buffer = Buffer.from(await file.arrayBuffer()); + + await storage.upload(key, buffer, { contentType: file.type }); + + return c.json({ fileId, storagePath: key, size: file.size }, 201); + } catch (_err) { + return c.json({ error: 'Version upload failed' }, 500); + } +}); + +export default { port: PORT, fetch: app.fetch }; diff --git a/apps/storage/apps/server/tsconfig.json b/apps/storage/apps/server/tsconfig.json new file mode 100644 index 000000000..9c7e5fa56 --- /dev/null +++ b/apps/storage/apps/server/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/todo/apps/backend/src/admin/admin.service.ts b/apps/todo/apps/backend/src/admin/admin.service.ts index c13a54767..0682ed91f 100644 --- a/apps/todo/apps/backend/src/admin/admin.service.ts +++ b/apps/todo/apps/backend/src/admin/admin.service.ts @@ -52,13 +52,6 @@ export class AdminService { .where(eq(schema.reminders.userId, userId)); const remindersCount = remindersResult[0]?.count ?? 0; - // Count kanban boards - const kanbanBoardsResult = await this.db - .select({ count: sql`count(*)::int` }) - .from(schema.kanbanBoards) - .where(eq(schema.kanbanBoards.userId, userId)); - const kanbanBoardsCount = kanbanBoardsResult[0]?.count ?? 0; - // Get last activity (most recent task update) const lastTask = await this.db .select({ updatedAt: schema.tasks.updatedAt }) @@ -73,11 +66,9 @@ export class AdminService { { entity: 'tasks', count: tasksCount, label: 'Tasks' }, { entity: 'labels', count: labelsCount, label: 'Labels' }, { entity: 'reminders', count: remindersCount, label: 'Reminders' }, - { entity: 'kanban_boards', count: kanbanBoardsCount, label: 'Kanban Boards' }, ]; - const totalCount = - projectsCount + tasksCount + labelsCount + remindersCount + kanbanBoardsCount; + const totalCount = projectsCount + tasksCount + labelsCount + remindersCount; return { entities, @@ -151,38 +142,6 @@ export class AdminService { }); totalDeleted += deletedTasks.length; - // Delete kanban columns (through boards owned by user) - const userBoards = await this.db - .select({ id: schema.kanbanBoards.id }) - .from(schema.kanbanBoards) - .where(eq(schema.kanbanBoards.userId, userId)); - const boardIds = userBoards.map((b) => b.id); - - if (boardIds.length > 0) { - const deletedColumns = await this.db - .delete(schema.kanbanColumns) - .where(inArray(schema.kanbanColumns.boardId, boardIds)) - .returning(); - deletedCounts.push({ - entity: 'kanban_columns', - count: deletedColumns.length, - label: 'Kanban Columns', - }); - totalDeleted += deletedColumns.length; - } - - // Delete kanban boards - const deletedBoards = await this.db - .delete(schema.kanbanBoards) - .where(eq(schema.kanbanBoards.userId, userId)) - .returning(); - deletedCounts.push({ - entity: 'kanban_boards', - count: deletedBoards.length, - label: 'Kanban Boards', - }); - totalDeleted += deletedBoards.length; - // Delete projects const deletedProjects = await this.db .delete(schema.projects) diff --git a/apps/todo/apps/backend/src/app.module.ts b/apps/todo/apps/backend/src/app.module.ts index c2ae08508..d026b6b0f 100644 --- a/apps/todo/apps/backend/src/app.module.ts +++ b/apps/todo/apps/backend/src/app.module.ts @@ -10,7 +10,6 @@ import { ProjectModule } from './project/project.module'; import { TaskModule } from './task/task.module'; import { LabelModule } from './label/label.module'; import { ReminderModule } from './reminder/reminder.module'; -import { KanbanModule } from './kanban/kanban.module'; import { NetworkModule } from './network/network.module'; import { AdminModule } from './admin/admin.module'; @@ -46,7 +45,6 @@ import { AdminModule } from './admin/admin.module'; TaskModule, LabelModule, ReminderModule, - KanbanModule, NetworkModule, AdminModule, ], diff --git a/apps/todo/apps/backend/src/db/schema/index.ts b/apps/todo/apps/backend/src/db/schema/index.ts index 93670d553..d2f17ee12 100644 --- a/apps/todo/apps/backend/src/db/schema/index.ts +++ b/apps/todo/apps/backend/src/db/schema/index.ts @@ -1,6 +1,4 @@ export * from './projects.schema'; -export * from './kanban-boards.schema'; -export * from './kanban-columns.schema'; export * from './tasks.schema'; export * from './labels.schema'; export * from './task-labels.schema'; diff --git a/apps/todo/apps/backend/src/db/schema/kanban-boards.schema.ts b/apps/todo/apps/backend/src/db/schema/kanban-boards.schema.ts deleted file mode 100644 index 1d074c7cc..000000000 --- a/apps/todo/apps/backend/src/db/schema/kanban-boards.schema.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { - pgTable, - uuid, - text, - timestamp, - varchar, - boolean, - integer, - index, -} from 'drizzle-orm/pg-core'; -import { projects } from './projects.schema'; - -export const kanbanBoards = pgTable( - 'kanban_boards', - { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id').notNull(), - projectId: uuid('project_id').references(() => projects.id, { onDelete: 'cascade' }), - - // Board properties - name: varchar('name', { length: 100 }).notNull(), - color: varchar('color', { length: 7 }).default('#8b5cf6'), - icon: varchar('icon', { length: 50 }), - order: integer('order').default(0).notNull(), - - // Special flags - isGlobal: boolean('is_global').default(false), - - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => ({ - userIdx: index('kanban_boards_user_idx').on(table.userId), - projectIdx: index('kanban_boards_project_idx').on(table.projectId), - orderIdx: index('kanban_boards_order_idx').on(table.userId, table.order), - globalIdx: index('kanban_boards_global_idx').on(table.userId, table.isGlobal), - }) -); - -export type KanbanBoard = typeof kanbanBoards.$inferSelect; -export type NewKanbanBoard = typeof kanbanBoards.$inferInsert; diff --git a/apps/todo/apps/backend/src/db/schema/kanban-columns.schema.ts b/apps/todo/apps/backend/src/db/schema/kanban-columns.schema.ts deleted file mode 100644 index 98a8faddd..000000000 --- a/apps/todo/apps/backend/src/db/schema/kanban-columns.schema.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { - pgTable, - uuid, - text, - timestamp, - varchar, - boolean, - integer, - index, -} from 'drizzle-orm/pg-core'; -import { kanbanBoards } from './kanban-boards.schema'; - -// Define locally to avoid circular dependency with tasks.schema -export type KanbanTaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'; - -export const kanbanColumns = pgTable( - 'kanban_columns', - { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id').notNull(), - boardId: uuid('board_id') - .references(() => kanbanBoards.id, { onDelete: 'cascade' }) - .notNull(), - - // Column properties - name: varchar('name', { length: 100 }).notNull(), - color: varchar('color', { length: 7 }).default('#6B7280'), - order: integer('order').default(0).notNull(), - - // Behavior - isDefault: boolean('is_default').default(false), - defaultStatus: varchar('default_status', { length: 20 }).$type(), - autoComplete: boolean('auto_complete').default(false), - - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => ({ - userIdx: index('kanban_columns_user_idx').on(table.userId), - boardIdx: index('kanban_columns_board_idx').on(table.boardId), - orderIdx: index('kanban_columns_order_idx').on(table.boardId, table.order), - }) -); - -export type KanbanColumn = typeof kanbanColumns.$inferSelect; -export type NewKanbanColumn = typeof kanbanColumns.$inferInsert; diff --git a/apps/todo/apps/backend/src/db/schema/tasks.schema.ts b/apps/todo/apps/backend/src/db/schema/tasks.schema.ts index 2f94b9b1e..8ca1a7651 100644 --- a/apps/todo/apps/backend/src/db/schema/tasks.schema.ts +++ b/apps/todo/apps/backend/src/db/schema/tasks.schema.ts @@ -11,7 +11,6 @@ import { foreignKey, } from 'drizzle-orm/pg-core'; import { projects } from './projects.schema'; -import { kanbanColumns } from './kanban-columns.schema'; export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent'; export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'; @@ -75,8 +74,8 @@ export const tasks = pgTable( // Ordering order: integer('order').default(0), - // Kanban - columnId: uuid('column_id').references(() => kanbanColumns.id, { onDelete: 'set null' }), + // Kanban (legacy - kept for existing data, no longer referenced) + columnId: uuid('column_id'), columnOrder: integer('column_order').default(0), // Recurrence (RFC 5545 RRULE format) diff --git a/apps/todo/apps/backend/src/kanban/__tests__/kanban.service.spec.ts b/apps/todo/apps/backend/src/kanban/__tests__/kanban.service.spec.ts deleted file mode 100644 index 9d86c7cf6..000000000 --- a/apps/todo/apps/backend/src/kanban/__tests__/kanban.service.spec.ts +++ /dev/null @@ -1,632 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { NotFoundException, BadRequestException } from '@nestjs/common'; -import { KanbanService } from '../kanban.service'; -import { DATABASE_CONNECTION } from '../../db/database.module'; - -const mockDb: any = { - query: { - kanbanBoards: { - findMany: jest.fn(), - findFirst: jest.fn(), - }, - kanbanColumns: { - findMany: jest.fn(), - findFirst: jest.fn(), - }, - tasks: { - findMany: jest.fn(), - findFirst: jest.fn(), - }, - }, - insert: jest.fn().mockReturnThis(), - update: jest.fn().mockReturnThis(), - delete: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - returning: jest.fn(), - transaction: jest.fn(), -}; - -// Make transaction execute callback with mockDb as tx -mockDb.transaction.mockImplementation((cb: any) => cb(mockDb)); - -describe('KanbanService', () => { - let service: KanbanService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - KanbanService, - { - provide: DATABASE_CONNECTION, - useValue: mockDb, - }, - ], - }).compile(); - - service = module.get(KanbanService); - - jest.clearAllMocks(); - - // Re-set transaction mock after clearAllMocks - mockDb.transaction.mockImplementation((cb: any) => cb(mockDb)); - // Re-set chainable mocks - mockDb.insert.mockReturnThis(); - mockDb.update.mockReturnThis(); - mockDb.delete.mockReturnThis(); - mockDb.values.mockReturnThis(); - mockDb.set.mockReturnThis(); - mockDb.where.mockReturnThis(); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - // ===================== - // Board operations - // ===================== - - describe('findAllBoards', () => { - it('should return all boards for a user', async () => { - const userId = 'user-123'; - const mockBoards = [ - { id: 'board-1', name: 'Board 1', userId, order: 0 }, - { id: 'board-2', name: 'Board 2', userId, order: 1 }, - ]; - - mockDb.query.kanbanBoards.findMany.mockResolvedValue(mockBoards); - - const result = await service.findAllBoards(userId); - - expect(result).toHaveLength(2); - expect(mockDb.query.kanbanBoards.findMany).toHaveBeenCalled(); - }); - - it('should return empty array when no boards', async () => { - mockDb.query.kanbanBoards.findMany.mockResolvedValue([]); - - const result = await service.findAllBoards('user-123'); - - expect(result).toEqual([]); - }); - }); - - describe('findBoardById', () => { - it('should return a board when found', async () => { - const userId = 'user-123'; - const boardId = 'board-1'; - const mockBoard = { id: boardId, name: 'Board 1', userId }; - - mockDb.query.kanbanBoards.findFirst.mockResolvedValue(mockBoard); - - const result = await service.findBoardById(boardId, userId); - - expect(result).toBeDefined(); - expect(result?.id).toBe(boardId); - }); - - it('should return null when board not found', async () => { - mockDb.query.kanbanBoards.findFirst.mockResolvedValue(undefined); - - const result = await service.findBoardById('non-existent', 'user-123'); - - expect(result).toBeNull(); - }); - }); - - describe('findBoardByIdOrThrow', () => { - it('should return a board when found', async () => { - const userId = 'user-123'; - const boardId = 'board-1'; - const mockBoard = { id: boardId, name: 'Board 1', userId }; - - mockDb.query.kanbanBoards.findFirst.mockResolvedValue(mockBoard); - - const result = await service.findBoardByIdOrThrow(boardId, userId); - - expect(result.id).toBe(boardId); - }); - - it('should throw NotFoundException when board not found', async () => { - mockDb.query.kanbanBoards.findFirst.mockResolvedValue(undefined); - - await expect(service.findBoardByIdOrThrow('non-existent', 'user-123')).rejects.toThrow( - NotFoundException - ); - }); - }); - - describe('createBoard', () => { - it('should create a board with correct order', async () => { - const userId = 'user-123'; - const dto = { name: 'New Board', color: '#ff0000' }; - const existingBoards = [ - { id: 'board-1', name: 'Board 1', userId, order: 0 }, - { id: 'board-2', name: 'Board 2', userId, order: 1 }, - ]; - const createdBoard = { id: 'board-new', name: 'New Board', userId, order: 2 }; - - mockDb.query.kanbanBoards.findMany.mockResolvedValue(existingBoards); - mockDb.returning.mockResolvedValue([createdBoard]); - - const result = await service.createBoard(userId, dto); - - expect(result.order).toBe(2); - expect(result.name).toBe('New Board'); - expect(mockDb.insert).toHaveBeenCalled(); - }); - - it('should set order to 0 when no existing boards', async () => { - const userId = 'user-123'; - const dto = { name: 'First Board' }; - const createdBoard = { id: 'board-first', name: 'First Board', userId, order: 0 }; - - mockDb.query.kanbanBoards.findMany.mockResolvedValue([]); - mockDb.returning.mockResolvedValue([createdBoard]); - - const result = await service.createBoard(userId, dto); - - expect(result.order).toBe(0); - expect(mockDb.insert).toHaveBeenCalled(); - }); - - it('should create default columns along with the board', async () => { - const userId = 'user-123'; - const dto = { name: 'New Board' }; - const createdBoard = { id: 'board-new', name: 'New Board', userId, order: 0 }; - - mockDb.query.kanbanBoards.findMany.mockResolvedValue([]); - mockDb.returning.mockResolvedValue([createdBoard]); - - await service.createBoard(userId, dto); - - // insert should be called twice: once for board, once for columns - expect(mockDb.insert).toHaveBeenCalledTimes(2); - }); - }); - - describe('updateBoard', () => { - it('should update a board', async () => { - const userId = 'user-123'; - const boardId = 'board-1'; - const dto = { name: 'Updated Board' }; - const existingBoard = { id: boardId, name: 'Original', userId }; - const updatedBoard = { id: boardId, name: 'Updated Board', userId }; - - mockDb.query.kanbanBoards.findFirst.mockResolvedValue(existingBoard); - mockDb.returning.mockResolvedValue([updatedBoard]); - - const result = await service.updateBoard(boardId, userId, dto); - - expect(result.name).toBe('Updated Board'); - }); - - it('should throw when board does not exist', async () => { - mockDb.query.kanbanBoards.findFirst.mockResolvedValue(undefined); - - await expect( - service.updateBoard('non-existent', 'user-123', { name: 'Test' }) - ).rejects.toThrow(NotFoundException); - }); - }); - - describe('deleteBoard', () => { - it('should delete a non-global board', async () => { - const userId = 'user-123'; - const boardId = 'board-1'; - const existingBoard = { id: boardId, name: 'Board 1', userId, isGlobal: false }; - const globalBoard = { id: 'board-global', name: 'Alle Aufgaben', userId, isGlobal: true }; - const globalColumns = [ - { id: 'col-g1', name: 'To Do', boardId: 'board-global', userId, order: 0 }, - ]; - const boardColumns = [{ id: 'col-1', name: 'To Do', boardId, userId, order: 0 }]; - - // findBoardByIdOrThrow - mockDb.query.kanbanBoards.findFirst - .mockResolvedValueOnce(existingBoard) // deleteBoard -> findBoardByIdOrThrow - .mockResolvedValueOnce(globalBoard); // getOrCreateGlobalBoard -> findFirst - - // findAllColumns calls for global board, then board columns - mockDb.query.kanbanColumns.findMany - .mockResolvedValueOnce(globalColumns) // findAllColumns(globalBoard.id) - .mockResolvedValueOnce(boardColumns); // findAllColumns(id) inside transaction - - await service.deleteBoard(boardId, userId); - - expect(mockDb.delete).toHaveBeenCalled(); - }); - - it('should throw when board does not exist', async () => { - mockDb.query.kanbanBoards.findFirst.mockResolvedValue(undefined); - - await expect(service.deleteBoard('non-existent', 'user-123')).rejects.toThrow( - NotFoundException - ); - }); - - it('should throw BadRequestException when deleting global board', async () => { - const userId = 'user-123'; - const boardId = 'board-global'; - const globalBoard = { id: boardId, name: 'Alle Aufgaben', userId, isGlobal: true }; - - mockDb.query.kanbanBoards.findFirst.mockResolvedValue(globalBoard); - - await expect(service.deleteBoard(boardId, userId)).rejects.toThrow(BadRequestException); - }); - }); - - describe('reorderBoards', () => { - it('should update order for each board and return all', async () => { - const userId = 'user-123'; - const boardIds = ['board-2', 'board-1', 'board-3']; - const reorderedBoards = [ - { id: 'board-2', order: 0 }, - { id: 'board-1', order: 1 }, - { id: 'board-3', order: 2 }, - ]; - - mockDb.query.kanbanBoards.findMany.mockResolvedValue(reorderedBoards); - - const result = await service.reorderBoards(userId, boardIds); - - expect(mockDb.update).toHaveBeenCalledTimes(3); - expect(result).toEqual(reorderedBoards); - }); - }); - - // ===================== - // Column operations - // ===================== - - describe('findAllColumns', () => { - it('should return all columns for a board', async () => { - const userId = 'user-123'; - const boardId = 'board-1'; - const mockColumns = [ - { id: 'col-1', name: 'To Do', boardId, userId, order: 0 }, - { id: 'col-2', name: 'In Progress', boardId, userId, order: 1 }, - ]; - - mockDb.query.kanbanColumns.findMany.mockResolvedValue(mockColumns); - - const result = await service.findAllColumns(boardId, userId); - - expect(result).toHaveLength(2); - expect(mockDb.query.kanbanColumns.findMany).toHaveBeenCalled(); - }); - - it('should return empty array when no columns', async () => { - mockDb.query.kanbanColumns.findMany.mockResolvedValue([]); - - const result = await service.findAllColumns('board-1', 'user-123'); - - expect(result).toEqual([]); - }); - }); - - describe('findColumnByIdOrThrow', () => { - it('should return a column when found', async () => { - const userId = 'user-123'; - const columnId = 'col-1'; - const mockColumn = { id: columnId, name: 'To Do', userId }; - - mockDb.query.kanbanColumns.findFirst.mockResolvedValue(mockColumn); - - const result = await service.findColumnByIdOrThrow(columnId, userId); - - expect(result.id).toBe(columnId); - }); - - it('should throw NotFoundException when column not found', async () => { - mockDb.query.kanbanColumns.findFirst.mockResolvedValue(undefined); - - await expect(service.findColumnByIdOrThrow('non-existent', 'user-123')).rejects.toThrow( - NotFoundException - ); - }); - }); - - describe('createColumn', () => { - it('should create a column with correct order', async () => { - const userId = 'user-123'; - const boardId = 'board-1'; - const dto = { boardId, name: 'New Column', defaultStatus: 'pending' as const }; - const existingBoard = { id: boardId, name: 'Board 1', userId }; - const existingColumns = [ - { id: 'col-1', name: 'To Do', boardId, userId, order: 0 }, - { id: 'col-2', name: 'Done', boardId, userId, order: 1 }, - ]; - const createdColumn = { id: 'col-new', name: 'New Column', boardId, userId, order: 2 }; - - mockDb.query.kanbanBoards.findFirst.mockResolvedValue(existingBoard); - mockDb.query.kanbanColumns.findMany.mockResolvedValue(existingColumns); - mockDb.returning.mockResolvedValue([createdColumn]); - - const result = await service.createColumn(userId, dto); - - expect(result.order).toBe(2); - expect(result.name).toBe('New Column'); - expect(mockDb.insert).toHaveBeenCalled(); - }); - - it('should throw when board does not exist', async () => { - const dto = { boardId: 'non-existent', name: 'Column', defaultStatus: 'pending' as const }; - - mockDb.query.kanbanBoards.findFirst.mockResolvedValue(undefined); - - await expect(service.createColumn('user-123', dto)).rejects.toThrow(NotFoundException); - }); - }); - - describe('updateColumn', () => { - it('should update a column', async () => { - const userId = 'user-123'; - const columnId = 'col-1'; - const dto = { name: 'Updated Column' }; - const existingColumn = { id: columnId, name: 'Original', userId }; - const updatedColumn = { id: columnId, name: 'Updated Column', userId }; - - mockDb.query.kanbanColumns.findFirst.mockResolvedValue(existingColumn); - mockDb.returning.mockResolvedValue([updatedColumn]); - - const result = await service.updateColumn(columnId, userId, dto); - - expect(result.name).toBe('Updated Column'); - }); - - it('should throw when column does not exist', async () => { - mockDb.query.kanbanColumns.findFirst.mockResolvedValue(undefined); - - await expect( - service.updateColumn('non-existent', 'user-123', { name: 'Test' }) - ).rejects.toThrow(NotFoundException); - }); - }); - - describe('deleteColumn', () => { - it('should delete a column and move tasks to another column', async () => { - const userId = 'user-123'; - const columnId = 'col-2'; - const boardId = 'board-1'; - const existingColumn = { id: columnId, name: 'In Progress', boardId, userId }; - const allColumns = [ - { id: 'col-1', name: 'To Do', boardId, userId, order: 0 }, - { id: columnId, name: 'In Progress', boardId, userId, order: 1 }, - ]; - - mockDb.query.kanbanColumns.findFirst.mockResolvedValue(existingColumn); - mockDb.query.kanbanColumns.findMany.mockResolvedValue(allColumns); - - await service.deleteColumn(columnId, userId); - - // Should move tasks and then delete - expect(mockDb.update).toHaveBeenCalled(); - expect(mockDb.delete).toHaveBeenCalled(); - }); - - it('should throw when column does not exist', async () => { - mockDb.query.kanbanColumns.findFirst.mockResolvedValue(undefined); - - await expect(service.deleteColumn('non-existent', 'user-123')).rejects.toThrow( - NotFoundException - ); - }); - - it('should throw BadRequestException when deleting the last column', async () => { - const userId = 'user-123'; - const columnId = 'col-1'; - const boardId = 'board-1'; - const existingColumn = { id: columnId, name: 'Only Column', boardId, userId }; - // Only this one column exists, no other column to move tasks to - const allColumns = [{ id: columnId, name: 'Only Column', boardId, userId, order: 0 }]; - - mockDb.query.kanbanColumns.findFirst.mockResolvedValue(existingColumn); - mockDb.query.kanbanColumns.findMany.mockResolvedValue(allColumns); - - await expect(service.deleteColumn(columnId, userId)).rejects.toThrow(BadRequestException); - }); - }); - - describe('reorderColumns', () => { - it('should update order for each column and return all', async () => { - const userId = 'user-123'; - const boardId = 'board-1'; - const columnIds = ['col-3', 'col-1', 'col-2']; - const firstColumn = { id: 'col-3', boardId, userId }; - const reorderedColumns = [ - { id: 'col-3', order: 0, boardId }, - { id: 'col-1', order: 1, boardId }, - { id: 'col-2', order: 2, boardId }, - ]; - - mockDb.query.kanbanColumns.findFirst.mockResolvedValue(firstColumn); - mockDb.query.kanbanColumns.findMany.mockResolvedValue(reorderedColumns); - - const result = await service.reorderColumns(userId, columnIds); - - expect(mockDb.update).toHaveBeenCalledTimes(3); - expect(result).toEqual(reorderedColumns); - }); - - it('should return empty array when first column not found', async () => { - const userId = 'user-123'; - const columnIds = ['col-non-existent']; - - mockDb.query.kanbanColumns.findFirst.mockResolvedValue(undefined); - - const result = await service.reorderColumns(userId, columnIds); - - expect(result).toEqual([]); - }); - }); - - // ===================== - // Task operations - // ===================== - - describe('moveTaskToColumn', () => { - it('should move a task to a column', async () => { - const userId = 'user-123'; - const taskId = 'task-1'; - const columnId = 'col-2'; - const existingTask = { id: taskId, userId, columnId: 'col-1' }; - const column = { - id: columnId, - userId, - autoComplete: false, - defaultStatus: 'in_progress', - }; - const movedTask = { - id: taskId, - userId, - columnId, - status: 'in_progress', - isCompleted: false, - }; - - mockDb.query.tasks.findFirst.mockResolvedValue(existingTask); - mockDb.query.kanbanColumns.findFirst.mockResolvedValue(column); - mockDb.returning.mockResolvedValue([movedTask]); - - const result = await service.moveTaskToColumn(taskId, userId, columnId); - - expect(result.columnId).toBe(columnId); - expect(result.status).toBe('in_progress'); - }); - - it('should auto-complete task when moving to autoComplete column', async () => { - const userId = 'user-123'; - const taskId = 'task-1'; - const columnId = 'col-done'; - const existingTask = { id: taskId, userId, columnId: 'col-1', isCompleted: false }; - const column = { - id: columnId, - userId, - autoComplete: true, - defaultStatus: 'completed', - }; - const completedTask = { - id: taskId, - userId, - columnId, - status: 'completed', - isCompleted: true, - }; - - mockDb.query.tasks.findFirst.mockResolvedValue(existingTask); - mockDb.query.kanbanColumns.findFirst.mockResolvedValue(column); - mockDb.returning.mockResolvedValue([completedTask]); - - const result = await service.moveTaskToColumn(taskId, userId, columnId); - - expect(result.isCompleted).toBe(true); - expect(result.status).toBe('completed'); - }); - - it('should throw NotFoundException when task not found', async () => { - mockDb.query.tasks.findFirst.mockResolvedValue(undefined); - - await expect(service.moveTaskToColumn('non-existent', 'user-123', 'col-1')).rejects.toThrow( - NotFoundException - ); - }); - - it('should throw NotFoundException when column not found', async () => { - const existingTask = { id: 'task-1', userId: 'user-123' }; - - mockDb.query.tasks.findFirst.mockResolvedValue(existingTask); - mockDb.query.kanbanColumns.findFirst.mockResolvedValue(undefined); - - await expect(service.moveTaskToColumn('task-1', 'user-123', 'non-existent')).rejects.toThrow( - NotFoundException - ); - }); - - it('should accept optional order parameter', async () => { - const userId = 'user-123'; - const taskId = 'task-1'; - const columnId = 'col-2'; - const existingTask = { id: taskId, userId }; - const column = { id: columnId, userId, autoComplete: false, defaultStatus: null }; - const movedTask = { id: taskId, userId, columnId, columnOrder: 5 }; - - mockDb.query.tasks.findFirst.mockResolvedValue(existingTask); - mockDb.query.kanbanColumns.findFirst.mockResolvedValue(column); - mockDb.returning.mockResolvedValue([movedTask]); - - const result = await service.moveTaskToColumn(taskId, userId, columnId, 5); - - expect(result.columnOrder).toBe(5); - }); - }); - - describe('getOrCreateGlobalBoard', () => { - it('should return existing global board', async () => { - const userId = 'user-123'; - const globalBoard = { id: 'board-global', name: 'Alle Aufgaben', userId, isGlobal: true }; - - mockDb.query.kanbanBoards.findFirst.mockResolvedValue(globalBoard); - - const result = await service.getOrCreateGlobalBoard(userId); - - expect(result.isGlobal).toBe(true); - expect(mockDb.insert).not.toHaveBeenCalled(); - }); - - it('should create global board when none exists', async () => { - const userId = 'user-123'; - const createdBoard = { - id: 'board-new', - name: 'Alle Aufgaben', - userId, - isGlobal: true, - order: 0, - }; - - mockDb.query.kanbanBoards.findFirst.mockResolvedValue(undefined); - mockDb.returning.mockResolvedValue([createdBoard]); - - const result = await service.getOrCreateGlobalBoard(userId); - - expect(result.isGlobal).toBe(true); - expect(result.name).toBe('Alle Aufgaben'); - expect(mockDb.insert).toHaveBeenCalled(); - }); - }); - - describe('initializeDefaultColumns', () => { - it('should return existing columns if they exist', async () => { - const userId = 'user-123'; - const boardId = 'board-1'; - const existingColumns = [{ id: 'col-1', name: 'To Do', boardId, userId, order: 0 }]; - - mockDb.query.kanbanColumns.findMany.mockResolvedValue(existingColumns); - - const result = await service.initializeDefaultColumns(boardId, userId); - - expect(result).toEqual(existingColumns); - expect(mockDb.insert).not.toHaveBeenCalled(); - }); - - it('should create default columns when none exist', async () => { - const userId = 'user-123'; - const boardId = 'board-1'; - const createdColumns = [ - { id: 'col-1', name: 'To Do', boardId, userId, order: 0 }, - { id: 'col-2', name: 'In Arbeit', boardId, userId, order: 1 }, - { id: 'col-3', name: 'Erledigt', boardId, userId, order: 2 }, - ]; - - mockDb.query.kanbanColumns.findMany - .mockResolvedValueOnce([]) // first call: check existing - .mockResolvedValueOnce(createdColumns); // second call: return created - - const result = await service.initializeDefaultColumns(boardId, userId); - - expect(result).toHaveLength(3); - expect(mockDb.insert).toHaveBeenCalled(); - }); - }); -}); diff --git a/apps/todo/apps/backend/src/kanban/dto/create-board.dto.ts b/apps/todo/apps/backend/src/kanban/dto/create-board.dto.ts deleted file mode 100644 index 761fe4349..000000000 --- a/apps/todo/apps/backend/src/kanban/dto/create-board.dto.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { IsString, IsOptional, MaxLength } from 'class-validator'; - -export class CreateBoardDto { - @IsString() - @MaxLength(100) - name: string; - - @IsOptional() - @IsString() - projectId?: string; - - @IsOptional() - @IsString() - @MaxLength(7) - color?: string; - - @IsOptional() - @IsString() - @MaxLength(50) - icon?: string; -} diff --git a/apps/todo/apps/backend/src/kanban/dto/create-column.dto.ts b/apps/todo/apps/backend/src/kanban/dto/create-column.dto.ts deleted file mode 100644 index 0f15982cf..000000000 --- a/apps/todo/apps/backend/src/kanban/dto/create-column.dto.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { IsString, IsOptional, IsBoolean, MaxLength, IsIn } from 'class-validator'; -import type { KanbanTaskStatus } from '../../db/schema/kanban-columns.schema'; - -export class CreateColumnDto { - @IsString() - @MaxLength(100) - name: string; - - @IsString() - boardId: string; - - @IsOptional() - @IsString() - @MaxLength(7) - color?: string; - - @IsOptional() - @IsBoolean() - isDefault?: boolean; - - @IsOptional() - @IsIn(['pending', 'in_progress', 'completed', 'cancelled']) - defaultStatus?: KanbanTaskStatus; - - @IsOptional() - @IsBoolean() - autoComplete?: boolean; -} diff --git a/apps/todo/apps/backend/src/kanban/dto/index.ts b/apps/todo/apps/backend/src/kanban/dto/index.ts deleted file mode 100644 index 6c7711c9e..000000000 --- a/apps/todo/apps/backend/src/kanban/dto/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Board DTOs -export * from './create-board.dto'; -export * from './update-board.dto'; -export * from './reorder-boards.dto'; - -// Column DTOs -export * from './create-column.dto'; -export * from './update-column.dto'; -export * from './reorder-columns.dto'; - -// Task DTOs -export * from './move-task.dto'; diff --git a/apps/todo/apps/backend/src/kanban/dto/move-task.dto.ts b/apps/todo/apps/backend/src/kanban/dto/move-task.dto.ts deleted file mode 100644 index a748d1bf5..000000000 --- a/apps/todo/apps/backend/src/kanban/dto/move-task.dto.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { IsString, IsNumber, IsOptional } from 'class-validator'; - -export class MoveTaskToColumnDto { - @IsString() - columnId: string; - - @IsOptional() - @IsNumber() - order?: number; -} - -export class ReorderTasksDto { - @IsString() - columnId: string; - - @IsString({ each: true }) - taskIds: string[]; -} diff --git a/apps/todo/apps/backend/src/kanban/dto/reorder-boards.dto.ts b/apps/todo/apps/backend/src/kanban/dto/reorder-boards.dto.ts deleted file mode 100644 index 0c9616410..000000000 --- a/apps/todo/apps/backend/src/kanban/dto/reorder-boards.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IsString, IsArray } from 'class-validator'; - -export class ReorderBoardsDto { - @IsArray() - @IsString({ each: true }) - boardIds: string[]; -} diff --git a/apps/todo/apps/backend/src/kanban/dto/reorder-columns.dto.ts b/apps/todo/apps/backend/src/kanban/dto/reorder-columns.dto.ts deleted file mode 100644 index a4bf970bc..000000000 --- a/apps/todo/apps/backend/src/kanban/dto/reorder-columns.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IsArray, IsString } from 'class-validator'; - -export class ReorderColumnsDto { - @IsArray() - @IsString({ each: true }) - columnIds: string[]; -} diff --git a/apps/todo/apps/backend/src/kanban/dto/update-board.dto.ts b/apps/todo/apps/backend/src/kanban/dto/update-board.dto.ts deleted file mode 100644 index 5a5622296..000000000 --- a/apps/todo/apps/backend/src/kanban/dto/update-board.dto.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { IsString, IsOptional, MaxLength } from 'class-validator'; - -export class UpdateBoardDto { - @IsOptional() - @IsString() - @MaxLength(100) - name?: string; - - @IsOptional() - @IsString() - @MaxLength(7) - color?: string; - - @IsOptional() - @IsString() - @MaxLength(50) - icon?: string; -} diff --git a/apps/todo/apps/backend/src/kanban/dto/update-column.dto.ts b/apps/todo/apps/backend/src/kanban/dto/update-column.dto.ts deleted file mode 100644 index 0d538eef4..000000000 --- a/apps/todo/apps/backend/src/kanban/dto/update-column.dto.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { IsString, IsOptional, IsBoolean, MaxLength, IsIn } from 'class-validator'; -import type { KanbanTaskStatus } from '../../db/schema/kanban-columns.schema'; - -export class UpdateColumnDto { - @IsOptional() - @IsString() - @MaxLength(100) - name?: string; - - @IsOptional() - @IsString() - @MaxLength(7) - color?: string; - - @IsOptional() - @IsIn(['pending', 'in_progress', 'completed', 'cancelled']) - defaultStatus?: KanbanTaskStatus; - - @IsOptional() - @IsBoolean() - autoComplete?: boolean; -} diff --git a/apps/todo/apps/backend/src/kanban/kanban.controller.ts b/apps/todo/apps/backend/src/kanban/kanban.controller.ts deleted file mode 100644 index 465aac8a3..000000000 --- a/apps/todo/apps/backend/src/kanban/kanban.controller.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common'; -import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; -import { KanbanService } from './kanban.service'; -import { - CreateBoardDto, - UpdateBoardDto, - ReorderBoardsDto, - CreateColumnDto, - UpdateColumnDto, - ReorderColumnsDto, - MoveTaskToColumnDto, - ReorderTasksDto, -} from './dto'; - -@Controller('kanban') -@UseGuards(JwtAuthGuard) -export class KanbanController { - constructor(private readonly kanbanService: KanbanService) {} - - // ===================== - // Board endpoints - // ===================== - - @Get('boards') - async getBoards(@CurrentUser() user: CurrentUserData) { - const boards = await this.kanbanService.findAllBoards(user.userId); - return { boards }; - } - - @Get('boards/global') - async getGlobalBoard(@CurrentUser() user: CurrentUserData) { - const board = await this.kanbanService.getOrCreateGlobalBoard(user.userId); - return { board }; - } - - @Get('boards/:id') - async getBoard(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { - const board = await this.kanbanService.findBoardByIdOrThrow(id, user.userId); - return { board }; - } - - @Post('boards') - async createBoard(@CurrentUser() user: CurrentUserData, @Body() dto: CreateBoardDto) { - const board = await this.kanbanService.createBoard(user.userId, dto); - return { board }; - } - - @Put('boards/reorder') - async reorderBoards(@CurrentUser() user: CurrentUserData, @Body() dto: ReorderBoardsDto) { - const boards = await this.kanbanService.reorderBoards(user.userId, dto.boardIds); - return { boards }; - } - - @Put('boards/:id') - async updateBoard( - @CurrentUser() user: CurrentUserData, - @Param('id') id: string, - @Body() dto: UpdateBoardDto - ) { - const board = await this.kanbanService.updateBoard(id, user.userId, dto); - return { board }; - } - - @Delete('boards/:id') - async deleteBoard(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { - await this.kanbanService.deleteBoard(id, user.userId); - return { success: true }; - } - - // ===================== - // Column endpoints - // ===================== - - @Get('columns') - async getColumns(@CurrentUser() user: CurrentUserData, @Query('boardId') boardId: string) { - const columns = await this.kanbanService.findAllColumns(boardId, user.userId); - return { columns }; - } - - @Post('columns') - async createColumn(@CurrentUser() user: CurrentUserData, @Body() dto: CreateColumnDto) { - const column = await this.kanbanService.createColumn(user.userId, dto); - return { column }; - } - - @Put('columns/:id') - async updateColumn( - @CurrentUser() user: CurrentUserData, - @Param('id') id: string, - @Body() dto: UpdateColumnDto - ) { - const column = await this.kanbanService.updateColumn(id, user.userId, dto); - return { column }; - } - - @Delete('columns/:id') - async deleteColumn(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { - await this.kanbanService.deleteColumn(id, user.userId); - return { success: true }; - } - - @Put('columns/reorder') - async reorderColumns(@CurrentUser() user: CurrentUserData, @Body() dto: ReorderColumnsDto) { - const columns = await this.kanbanService.reorderColumns(user.userId, dto.columnIds); - return { columns }; - } - - @Post('columns/init') - async initializeColumns(@CurrentUser() user: CurrentUserData, @Query('boardId') boardId: string) { - const columns = await this.kanbanService.initializeDefaultColumns(boardId, user.userId); - return { columns }; - } - - // ===================== - // Task endpoints - // ===================== - - @Get('tasks') - async getTasksGrouped(@CurrentUser() user: CurrentUserData, @Query('boardId') boardId: string) { - const result = await this.kanbanService.getTasksGroupedByColumn(boardId, user.userId); - return result; - } - - @Post('tasks/:taskId/move') - async moveTaskToColumn( - @CurrentUser() user: CurrentUserData, - @Param('taskId') taskId: string, - @Body() dto: MoveTaskToColumnDto - ) { - const task = await this.kanbanService.moveTaskToColumn( - taskId, - user.userId, - dto.columnId, - dto.order - ); - return { task }; - } - - @Put('tasks/reorder') - async reorderTasks(@CurrentUser() user: CurrentUserData, @Body() dto: ReorderTasksDto) { - const tasks = await this.kanbanService.reorderTasksInColumn( - user.userId, - dto.columnId, - dto.taskIds - ); - return { tasks }; - } -} diff --git a/apps/todo/apps/backend/src/kanban/kanban.module.ts b/apps/todo/apps/backend/src/kanban/kanban.module.ts deleted file mode 100644 index 38f22fc66..000000000 --- a/apps/todo/apps/backend/src/kanban/kanban.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { KanbanController } from './kanban.controller'; -import { KanbanService } from './kanban.service'; - -@Module({ - controllers: [KanbanController], - providers: [KanbanService], - exports: [KanbanService], -}) -export class KanbanModule {} diff --git a/apps/todo/apps/backend/src/kanban/kanban.service.ts b/apps/todo/apps/backend/src/kanban/kanban.service.ts deleted file mode 100644 index d234188fe..000000000 --- a/apps/todo/apps/backend/src/kanban/kanban.service.ts +++ /dev/null @@ -1,481 +0,0 @@ -import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common'; -import { eq, and, asc } from 'drizzle-orm'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { type Database } from '../db/connection'; -import { - kanbanBoards, - type KanbanBoard, - type NewKanbanBoard, -} from '../db/schema/kanban-boards.schema'; -import { - kanbanColumns, - type KanbanColumn, - type NewKanbanColumn, - type KanbanTaskStatus, -} from '../db/schema/kanban-columns.schema'; -import { tasks, type Task } from '../db/schema/tasks.schema'; -import { CreateBoardDto, UpdateBoardDto, CreateColumnDto, UpdateColumnDto } from './dto'; - -// Default columns configuration -const DEFAULT_COLUMNS: Omit[] = [ - { - name: 'To Do', - color: '#6B7280', - order: 0, - isDefault: true, - defaultStatus: 'pending' as KanbanTaskStatus, - autoComplete: false, - }, - { - name: 'In Arbeit', - color: '#3B82F6', - order: 1, - isDefault: true, - defaultStatus: 'in_progress' as KanbanTaskStatus, - autoComplete: false, - }, - { - name: 'Erledigt', - color: '#22C55E', - order: 2, - isDefault: true, - defaultStatus: 'completed' as KanbanTaskStatus, - autoComplete: true, - }, -]; - -@Injectable() -export class KanbanService { - constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} - - // ===================== - // Board operations - // ===================== - - async findAllBoards(userId: string): Promise { - return this.db.query.kanbanBoards.findMany({ - where: eq(kanbanBoards.userId, userId), - orderBy: [asc(kanbanBoards.order)], - }); - } - - async findBoardById(id: string, userId: string): Promise { - const result = await this.db.query.kanbanBoards.findFirst({ - where: and(eq(kanbanBoards.id, id), eq(kanbanBoards.userId, userId)), - }); - return result ?? null; - } - - async findBoardByIdOrThrow(id: string, userId: string): Promise { - const board = await this.findBoardById(id, userId); - if (!board) { - throw new NotFoundException(`Board with id ${id} not found`); - } - return board; - } - - async getOrCreateGlobalBoard(userId: string): Promise { - // Check if global board exists - const existingGlobal = await this.db.query.kanbanBoards.findFirst({ - where: and(eq(kanbanBoards.userId, userId), eq(kanbanBoards.isGlobal, true)), - }); - - if (existingGlobal) { - return existingGlobal; - } - - // Create global board and default columns atomically - return this.db.transaction(async (tx) => { - const [globalBoard] = await tx - .insert(kanbanBoards) - .values({ - userId, - name: 'Alle Aufgaben', - color: '#8b5cf6', - order: 0, - isGlobal: true, - }) - .returning(); - - // Create default columns inline (can't call initializeDefaultColumns since it uses this.db) - const columnsToCreate: NewKanbanColumn[] = DEFAULT_COLUMNS.map((col) => ({ - ...col, - userId, - boardId: globalBoard.id, - })); - await tx.insert(kanbanColumns).values(columnsToCreate); - - return globalBoard; - }); - } - - async createBoard(userId: string, dto: CreateBoardDto): Promise { - // Get the highest order value - const existingBoards = await this.findAllBoards(userId); - const maxOrder = existingBoards.reduce((max, b) => Math.max(max, b.order ?? 0), -1); - - const newBoard: NewKanbanBoard = { - userId, - projectId: dto.projectId ?? null, - name: dto.name, - color: dto.color ?? '#8b5cf6', - icon: dto.icon ?? null, - order: maxOrder + 1, - isGlobal: false, - }; - - // Create board and default columns atomically - return this.db.transaction(async (tx) => { - const [created] = await tx.insert(kanbanBoards).values(newBoard).returning(); - - const columnsToCreate: NewKanbanColumn[] = DEFAULT_COLUMNS.map((col) => ({ - ...col, - userId, - boardId: created.id, - })); - await tx.insert(kanbanColumns).values(columnsToCreate); - - return created; - }); - } - - async updateBoard(id: string, userId: string, dto: UpdateBoardDto): Promise { - await this.findBoardByIdOrThrow(id, userId); - - const [updated] = await this.db - .update(kanbanBoards) - .set({ - ...dto, - updatedAt: new Date(), - }) - .where(and(eq(kanbanBoards.id, id), eq(kanbanBoards.userId, userId))) - .returning(); - - return updated; - } - - async deleteBoard(id: string, userId: string): Promise { - const board = await this.findBoardByIdOrThrow(id, userId); - - if (board.isGlobal) { - throw new BadRequestException('Cannot delete the global board'); - } - - // Get global board to move tasks to - const globalBoard = await this.getOrCreateGlobalBoard(userId); - const globalColumns = await this.findAllColumns(globalBoard.id, userId); - const firstGlobalColumn = globalColumns[0]; - - // Move tasks and delete board atomically - await this.db.transaction(async (tx) => { - if (firstGlobalColumn) { - // Get all columns for this board - const boardColumns = await this.findAllColumns(id, userId); - - // Move tasks from board columns to first global column - for (const column of boardColumns) { - await tx - .update(tasks) - .set({ - columnId: firstGlobalColumn.id, - updatedAt: new Date(), - }) - .where(eq(tasks.columnId, column.id)); - } - } - - // Delete the board (columns will cascade delete) - await tx - .delete(kanbanBoards) - .where(and(eq(kanbanBoards.id, id), eq(kanbanBoards.userId, userId))); - }); - } - - async reorderBoards(userId: string, boardIds: string[]): Promise { - // Update order for each board atomically - await this.db.transaction(async (tx) => { - for (const [index, id] of boardIds.entries()) { - await tx - .update(kanbanBoards) - .set({ order: index, updatedAt: new Date() }) - .where(and(eq(kanbanBoards.id, id), eq(kanbanBoards.userId, userId))); - } - }); - - return this.findAllBoards(userId); - } - - // ===================== - // Column operations - // ===================== - - async findAllColumns(boardId: string, userId: string): Promise { - return this.db.query.kanbanColumns.findMany({ - where: and(eq(kanbanColumns.boardId, boardId), eq(kanbanColumns.userId, userId)), - orderBy: [asc(kanbanColumns.order)], - }); - } - - async findColumnById(id: string, userId: string): Promise { - const result = await this.db.query.kanbanColumns.findFirst({ - where: and(eq(kanbanColumns.id, id), eq(kanbanColumns.userId, userId)), - }); - return result ?? null; - } - - async findColumnByIdOrThrow(id: string, userId: string): Promise { - const column = await this.findColumnById(id, userId); - if (!column) { - throw new NotFoundException(`Column with id ${id} not found`); - } - return column; - } - - async createColumn(userId: string, dto: CreateColumnDto): Promise { - // Verify board exists - await this.findBoardByIdOrThrow(dto.boardId, userId); - - // Get the highest order value for this board - const existingColumns = await this.findAllColumns(dto.boardId, userId); - const maxOrder = existingColumns.reduce((max, c) => Math.max(max, c.order ?? 0), -1); - - const newColumn: NewKanbanColumn = { - userId, - boardId: dto.boardId, - name: dto.name, - color: dto.color ?? '#6B7280', - order: maxOrder + 1, - isDefault: dto.isDefault ?? false, - defaultStatus: dto.defaultStatus, - autoComplete: dto.autoComplete ?? false, - }; - - const [created] = await this.db.insert(kanbanColumns).values(newColumn).returning(); - return created; - } - - async updateColumn(id: string, userId: string, dto: UpdateColumnDto): Promise { - await this.findColumnByIdOrThrow(id, userId); - - const [updated] = await this.db - .update(kanbanColumns) - .set({ - ...dto, - updatedAt: new Date(), - }) - .where(and(eq(kanbanColumns.id, id), eq(kanbanColumns.userId, userId))) - .returning(); - - return updated; - } - - async deleteColumn(id: string, userId: string): Promise { - const column = await this.findColumnByIdOrThrow(id, userId); - - // Get first column to move tasks to - const columns = await this.findAllColumns(column.boardId, userId); - const firstColumn = columns.find((c) => c.id !== id); - - if (!firstColumn) { - throw new BadRequestException('Cannot delete the last column'); - } - - // Move tasks and delete column atomically - await this.db.transaction(async (tx) => { - // Move all tasks from this column to the first column - await tx - .update(tasks) - .set({ - columnId: firstColumn.id, - updatedAt: new Date(), - }) - .where(eq(tasks.columnId, id)); - - // Delete the column - await tx - .delete(kanbanColumns) - .where(and(eq(kanbanColumns.id, id), eq(kanbanColumns.userId, userId))); - }); - } - - async reorderColumns(userId: string, columnIds: string[]): Promise { - // Update order for each column atomically - await this.db.transaction(async (tx) => { - for (const [index, id] of columnIds.entries()) { - await tx - .update(kanbanColumns) - .set({ order: index, updatedAt: new Date() }) - .where(and(eq(kanbanColumns.id, id), eq(kanbanColumns.userId, userId))); - } - }); - - // Determine boardId from first column - const firstColumn = await this.findColumnById(columnIds[0], userId); - if (!firstColumn) { - return []; - } - return this.findAllColumns(firstColumn.boardId, userId); - } - - async initializeDefaultColumns(boardId: string, userId: string): Promise { - // Check if columns already exist - const existing = await this.findAllColumns(boardId, userId); - if (existing.length > 0) { - return existing; - } - - // Create default columns - const columnsToCreate: NewKanbanColumn[] = DEFAULT_COLUMNS.map((col) => ({ - ...col, - userId, - boardId, - })); - - await this.db.insert(kanbanColumns).values(columnsToCreate); - - return this.findAllColumns(boardId, userId); - } - - // ===================== - // Task operations - // ===================== - - async getTasksGroupedByColumn( - boardId: string, - userId: string - ): Promise<{ columns: KanbanColumn[]; tasksByColumn: Record }> { - // Get board to check if it's global - const board = await this.findBoardByIdOrThrow(boardId, userId); - - // Ensure columns exist - const columns = await this.initializeDefaultColumns(boardId, userId); - - // Get tasks based on board type - let userTasks: Task[]; - if (board.isGlobal) { - // Global board: all tasks - userTasks = await this.db.query.tasks.findMany({ - where: eq(tasks.userId, userId), - orderBy: [asc(tasks.columnOrder), asc(tasks.createdAt)], - }); - } else if (board.projectId) { - // Project-specific board: only project tasks - userTasks = await this.db.query.tasks.findMany({ - where: and(eq(tasks.userId, userId), eq(tasks.projectId, board.projectId)), - orderBy: [asc(tasks.columnOrder), asc(tasks.createdAt)], - }); - } else { - // Custom board without project: tasks assigned to this board's columns - const columnIds = columns.map((c) => c.id); - userTasks = await this.db.query.tasks.findMany({ - where: eq(tasks.userId, userId), - orderBy: [asc(tasks.columnOrder), asc(tasks.createdAt)], - }); - // Filter to only tasks in this board's columns - userTasks = userTasks.filter((t) => t.columnId && columnIds.includes(t.columnId)); - } - - // Group tasks by column - const tasksByColumn: Record = {}; - - // Initialize empty arrays for each column - for (const column of columns) { - tasksByColumn[column.id] = []; - } - - // Distribute tasks - for (const task of userTasks) { - if (task.columnId && tasksByColumn[task.columnId]) { - // Task has explicit column assignment - tasksByColumn[task.columnId].push(task); - } else { - // Map based on status to default column - const matchingColumn = columns.find((c) => c.defaultStatus === task.status); - if (matchingColumn) { - tasksByColumn[matchingColumn.id].push(task); - } else { - // Fallback to first column - const firstColumn = columns[0]; - if (firstColumn) { - tasksByColumn[firstColumn.id].push(task); - } - } - } - } - - return { columns, tasksByColumn }; - } - - async moveTaskToColumn( - taskId: string, - userId: string, - columnId: string, - order?: number - ): Promise { - // Verify task exists and belongs to user - const task = await this.db.query.tasks.findFirst({ - where: and(eq(tasks.id, taskId), eq(tasks.userId, userId)), - }); - - if (!task) { - throw new NotFoundException(`Task with id ${taskId} not found`); - } - - // Verify column exists and belongs to user - const column = await this.findColumnByIdOrThrow(columnId, userId); - - // Determine new status and completion state - const updateData: Partial = { - columnId, - columnOrder: order ?? 0, - updatedAt: new Date(), - }; - - // If column has autoComplete, mark task as completed - if (column.autoComplete) { - updateData.isCompleted = true; - updateData.completedAt = new Date(); - updateData.status = 'completed'; - } else if (column.defaultStatus) { - // Update status based on column's default status - updateData.status = column.defaultStatus; - if (column.defaultStatus !== 'completed') { - updateData.isCompleted = false; - updateData.completedAt = null; - } - } - - const [updated] = await this.db - .update(tasks) - .set(updateData) - .where(and(eq(tasks.id, taskId), eq(tasks.userId, userId))) - .returning(); - - return updated; - } - - async reorderTasksInColumn(userId: string, columnId: string, taskIds: string[]): Promise { - // Verify column exists - await this.findColumnByIdOrThrow(columnId, userId); - - // Update order for each task atomically - await this.db.transaction(async (tx) => { - for (const [index, id] of taskIds.entries()) { - await tx - .update(tasks) - .set({ - columnId, - columnOrder: index, - updatedAt: new Date(), - }) - .where(and(eq(tasks.id, id), eq(tasks.userId, userId))); - } - }); - - // Return updated tasks - return this.db.query.tasks.findMany({ - where: and(eq(tasks.userId, userId), eq(tasks.columnId, columnId)), - orderBy: [asc(tasks.columnOrder)], - }); - } -}