From f5121f3b8dec08c05ddbe7fc31af35375dbd8e8d Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Wed, 29 Jul 2026 18:48:10 +0000 Subject: [PATCH] fix: rewrite /api/tasks, /api/habits, /api/projects to use Drizzle ORM instead of PocketBase stub --- apps/web/app/api/habits/route.ts | 121 +++++++++++++++++++++------ apps/web/app/api/projects/route.ts | 113 +++++++++++++++++++------ apps/web/app/api/tasks/route.ts | 130 +++++++++++++++++++++++------ 3 files changed, 283 insertions(+), 81 deletions(-) diff --git a/apps/web/app/api/habits/route.ts b/apps/web/app/api/habits/route.ts index 6f36f73..41c66b5 100644 --- a/apps/web/app/api/habits/route.ts +++ b/apps/web/app/api/habits/route.ts @@ -5,54 +5,121 @@ import { NextRequest, NextResponse } from 'next/server'; import { withAuth, createErrorResponse } from '@/lib/auth'; -import { createPocketBaseClient } from '@/lib/pocketbase'; -import { createHabitSchema } from '@project-e/shared'; +import { recordActivity } from '@/lib/activity'; +import { db, habits, habitTags, tags as tagsTable } from '@project-e/db'; +import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm'; import { z } from 'zod'; +const habitFrequencyEnum = z.enum(['daily', 'weekly', 'custom']); +const habitDifficultyEnum = z.enum(['easy', 'medium', 'hard']); + +const createHabitSchema = z.object({ + name: z.string().min(1, 'Name is required'), + description: z.string().optional().nullable(), + domain: z.string().min(1, 'Domain is required'), + frequency: habitFrequencyEnum.optional().default('daily'), + difficulty: habitDifficultyEnum.optional().default('medium'), + completionMode: z.enum(['quick', 'checklist', 'timer']).optional().default('quick'), + goalPerPeriod: z.number().int().positive().optional().default(1), + active: z.boolean().optional().default(true), + color: z.string().optional().nullable(), + icon: z.string().optional().nullable(), + tagIds: z.array(z.string().uuid()).optional(), +}); + // GET /api/habits — List habits with filtering, sorting, pagination export const GET = withAuth(async (request: NextRequest, _user) => { const { searchParams } = new URL(request.url); - const page = parseInt(searchParams.get('page') || '1'); - const perPage = parseInt(searchParams.get('perPage') || '50'); + const page = Math.max(1, parseInt(searchParams.get('page') || '1')); + const perPage = Math.min(100, Math.max(1, parseInt(searchParams.get('perPage') || '50'))); const filter = searchParams.get('filter') || undefined; const sort = searchParams.get('sort') || '-created'; + const domainId = searchParams.get('domain') || undefined; - const pb = createPocketBaseClient(); - const result = await pb.collection('habits').getList(page, perPage, { - ...(filter ? { filter } : {}), - sort, + const sortDir = sort.startsWith('-') ? 'desc' : 'asc'; + const sortField = sort.replace(/^-/, ''); + const sortColumns: Record = { + created: habits.createdAt, + updated: habits.updatedAt, + name: habits.name, + frequency: habits.frequency, + difficulty: habits.difficulty, + }; + const orderBy = sortDir === 'asc' + ? asc(sortColumns[sortField] || habits.createdAt) + : desc(sortColumns[sortField] || habits.createdAt); + + const conditions: any[] = [isNull(habits.deletedAt)]; + if (domainId) conditions.push(eq(habits.domainId, domainId)); + if (filter) { + conditions.push(ilike(habits.name, `%${filter}%`)); + } + + const offset = (page - 1) * perPage; + + const [items, countResult] = await Promise.all([ + db.select() + .from(habits) + .where(and(...conditions)) + .orderBy(orderBy) + .limit(perPage) + .offset(offset), + db.select({ count: sql`count(*)` }) + .from(habits) + .where(and(...conditions)), + ]); + + const totalItems = Number(countResult[0]?.count || 0); + + return NextResponse.json({ + items, + totalItems, + totalPages: Math.ceil(totalItems / perPage), + page, + perPage, }); - - const response = NextResponse.json({ - items: result.items, - totalItems: result.totalItems, - totalPages: result.totalPages, - page: result.page, - perPage: result.perPage, - }); - - response.headers.set( - 'Cache-Control', - 'private, max-age=60, stale-while-revalidate=300' - ); - - return response; }); // POST /api/habits — Create a habit -export const POST = withAuth(async (request: NextRequest, _user) => { +export const POST = withAuth(async (request: NextRequest, user) => { try { const body = await request.json(); const data = createHabitSchema.parse(body); - const pb = createPocketBaseClient(); - const habit = await pb.collection('habits').create(data); + const [habit] = await db.insert(habits).values({ + name: data.name, + description: data.description ?? null, + domainId: data.domain, + frequency: data.frequency, + difficulty: data.difficulty, + completionMode: data.completionMode, + goalPerPeriod: data.goalPerPeriod, + active: data.active, + color: data.color ?? null, + icon: data.icon ?? null, + }).returning(); + + if (data.tagIds && data.tagIds.length > 0) { + await db.insert(habitTags).values( + data.tagIds.map(tagId => ({ habitId: habit.id, tagId })) + ); + } + + await recordActivity({ + actor: user.name, + action: 'created', + entityType: 'habit', + entityId: habit.id, + changes: { name: habit.name }, + workspaceId: data.domain, + }); return NextResponse.json(habit, { status: 201 }); } catch (error) { if (error instanceof z.ZodError) { return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); } - throw error; + console.error('[habits POST] error:', error); + return createErrorResponse('INTERNAL_ERROR', 'Failed to create habit', 500); } }); diff --git a/apps/web/app/api/projects/route.ts b/apps/web/app/api/projects/route.ts index 33fcd1b..807c0d4 100644 --- a/apps/web/app/api/projects/route.ts +++ b/apps/web/app/api/projects/route.ts @@ -5,54 +5,113 @@ import { NextRequest, NextResponse } from 'next/server'; import { withAuth, createErrorResponse } from '@/lib/auth'; -import { createPocketBaseClient } from '@/lib/pocketbase'; -import { createProjectSchema } from '@project-e/shared'; +import { recordActivity } from '@/lib/activity'; +import { db, projects, projectTags, tags as tagsTable } from '@project-e/db'; +import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm'; import { z } from 'zod'; +const projectStatusEnum = z.enum(['active', 'paused', 'completed', 'archived']); + +const createProjectSchema = z.object({ + name: z.string().min(1, 'Name is required'), + description: z.string().optional().nullable(), + domain: z.string().min(1, 'Domain is required'), + status: projectStatusEnum.optional().default('active'), + color: z.string().optional().nullable(), + icon: z.string().optional().nullable(), + targetDate: z.string().datetime().optional().nullable(), + tagIds: z.array(z.string().uuid()).optional(), +}); + // GET /api/projects — List projects with filtering, sorting, pagination export const GET = withAuth(async (request: NextRequest, _user) => { const { searchParams } = new URL(request.url); - const page = parseInt(searchParams.get('page') || '1'); - const perPage = parseInt(searchParams.get('perPage') || '50'); + const page = Math.max(1, parseInt(searchParams.get('page') || '1')); + const perPage = Math.min(100, Math.max(1, parseInt(searchParams.get('perPage') || '50'))); const filter = searchParams.get('filter') || undefined; const sort = searchParams.get('sort') || '-created'; + const domainId = searchParams.get('domain') || undefined; - const pb = createPocketBaseClient(); - const result = await pb.collection('projects').getList(page, perPage, { - ...(filter ? { filter } : {}), - sort, + const sortDir = sort.startsWith('-') ? 'desc' : 'asc'; + const sortField = sort.replace(/^-/, ''); + const sortColumns: Record = { + created: projects.createdAt, + updated: projects.updatedAt, + name: projects.name, + status: projects.status, + }; + const orderBy = sortDir === 'asc' + ? asc(sortColumns[sortField] || projects.createdAt) + : desc(sortColumns[sortField] || projects.createdAt); + + const conditions: any[] = [isNull(projects.deletedAt)]; + if (domainId) conditions.push(eq(projects.domainId, domainId)); + if (filter) { + conditions.push(ilike(projects.name, `%${filter}%`)); + } + + const offset = (page - 1) * perPage; + + const [items, countResult] = await Promise.all([ + db.select() + .from(projects) + .where(and(...conditions)) + .orderBy(orderBy) + .limit(perPage) + .offset(offset), + db.select({ count: sql`count(*)` }) + .from(projects) + .where(and(...conditions)), + ]); + + const totalItems = Number(countResult[0]?.count || 0); + + return NextResponse.json({ + items, + totalItems, + totalPages: Math.ceil(totalItems / perPage), + page, + perPage, }); - - const response = NextResponse.json({ - items: result.items, - totalItems: result.totalItems, - totalPages: result.totalPages, - page: result.page, - perPage: result.perPage, - }); - - response.headers.set( - 'Cache-Control', - 'private, max-age=60, stale-while-revalidate=300' - ); - - return response; }); // POST /api/projects — Create a project -export const POST = withAuth(async (request: NextRequest, _user) => { +export const POST = withAuth(async (request: NextRequest, user) => { try { const body = await request.json(); const data = createProjectSchema.parse(body); - const pb = createPocketBaseClient(); - const project = await pb.collection('projects').create(data); + const [project] = await db.insert(projects).values({ + name: data.name, + description: data.description ?? null, + domainId: data.domain, + status: data.status, + color: data.color ?? null, + icon: data.icon ?? null, + targetDate: data.targetDate ? new Date(data.targetDate) : null, + }).returning(); + + if (data.tagIds && data.tagIds.length > 0) { + await db.insert(projectTags).values( + data.tagIds.map(tagId => ({ projectId: project.id, tagId })) + ); + } + + await recordActivity({ + actor: user.name, + action: 'created', + entityType: 'project', + entityId: project.id, + changes: { name: project.name }, + workspaceId: data.domain, + }); return NextResponse.json(project, { status: 201 }); } catch (error) { if (error instanceof z.ZodError) { return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); } - throw error; + console.error('[projects POST] error:', error); + return createErrorResponse('INTERNAL_ERROR', 'Failed to create project', 500); } }); diff --git a/apps/web/app/api/tasks/route.ts b/apps/web/app/api/tasks/route.ts index d9bf3ca..53adbd7 100644 --- a/apps/web/app/api/tasks/route.ts +++ b/apps/web/app/api/tasks/route.ts @@ -5,54 +5,130 @@ import { NextRequest, NextResponse } from 'next/server'; import { withAuth, createErrorResponse } from '@/lib/auth'; -import { createPocketBaseClient } from '@/lib/pocketbase'; -import { createTaskSchema } from '@project-e/shared'; +import { recordActivity } from '@/lib/activity'; +import { db, tasks, taskTags, tags as tagsTable } from '@project-e/db'; +import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm'; import { z } from 'zod'; +const taskStatusEnum = z.enum(['todo', 'in_progress', 'done', 'cancelled']); +const taskPriorityEnum = z.enum(['low', 'medium', 'high', 'urgent']); + +const createTaskSchema = z.object({ + title: z.string().min(1, 'Title is required'), + description: z.string().optional().nullable(), + status: taskStatusEnum.optional().default('todo'), + priority: taskPriorityEnum.optional().default('medium'), + domain: z.string().min(1, 'Domain is required'), + projectId: z.string().uuid().optional().nullable(), + sectionId: z.string().uuid().optional().nullable(), + parentId: z.string().uuid().optional().nullable(), + dueDate: z.string().datetime().optional().nullable(), + estimatedMinutes: z.number().int().positive().optional().nullable(), + order: z.number().int().optional(), + tagIds: z.array(z.string().uuid()).optional(), +}); + // GET /api/tasks — List tasks with filtering, sorting, pagination export const GET = withAuth(async (request: NextRequest, _user) => { const { searchParams } = new URL(request.url); - const page = parseInt(searchParams.get('page') || '1'); - const perPage = parseInt(searchParams.get('perPage') || '50'); + const page = Math.max(1, parseInt(searchParams.get('page') || '1')); + const perPage = Math.min(100, Math.max(1, parseInt(searchParams.get('perPage') || '50'))); const filter = searchParams.get('filter') || undefined; const sort = searchParams.get('sort') || '-created'; + const domainId = searchParams.get('domain') || undefined; - const pb = createPocketBaseClient(); - const result = await pb.collection('tasks').getList(page, perPage, { - ...(filter ? { filter } : {}), - sort, + const sortDir = sort.startsWith('-') ? 'desc' : 'asc'; + const sortField = sort.replace(/^-/, ''); + const sortColumns: Record = { + created: tasks.createdAt, + updated: tasks.updatedAt, + title: tasks.title, + status: tasks.status, + priority: tasks.priority, + order: tasks.order, + due_date: tasks.dueDate, + }; + const orderBy = sortDir === 'asc' + ? asc(sortColumns[sortField] || tasks.createdAt) + : desc(sortColumns[sortField] || tasks.createdAt); + + const conditions: any[] = [isNull(tasks.deletedAt)]; + if (domainId) conditions.push(eq(tasks.domainId, domainId)); + if (filter) { + conditions.push( + or( + ilike(tasks.title, `%${filter}%`), + ilike(tasks.description, `%${filter}%`), + )! + ); + } + + const offset = (page - 1) * perPage; + + const [items, countResult] = await Promise.all([ + db.select() + .from(tasks) + .where(and(...conditions)) + .orderBy(orderBy) + .limit(perPage) + .offset(offset), + db.select({ count: sql`count(*)` }) + .from(tasks) + .where(and(...conditions)), + ]); + + const totalItems = Number(countResult[0]?.count || 0); + + return NextResponse.json({ + items, + totalItems, + totalPages: Math.ceil(totalItems / perPage), + page, + perPage, }); - - const response = NextResponse.json({ - items: result.items, - totalItems: result.totalItems, - totalPages: result.totalPages, - page: result.page, - perPage: result.perPage, - }); - - response.headers.set( - 'Cache-Control', - 'private, max-age=60, stale-while-revalidate=300' - ); - - return response; }); // POST /api/tasks — Create a task -export const POST = withAuth(async (request: NextRequest, _user) => { +export const POST = withAuth(async (request: NextRequest, user) => { try { const body = await request.json(); const data = createTaskSchema.parse(body); - const pb = createPocketBaseClient(); - const task = await pb.collection('tasks').create(data); + const [task] = await db.insert(tasks).values({ + title: data.title, + description: data.description ?? null, + status: data.status, + priority: data.priority, + domainId: data.domain, + projectId: data.projectId ?? null, + sectionId: data.sectionId ?? null, + parentId: data.parentId ?? null, + dueDate: data.dueDate ? new Date(data.dueDate) : null, + estimatedMinutes: data.estimatedMinutes ?? null, + order: data.order ?? 0, + }).returning(); + + if (data.tagIds && data.tagIds.length > 0) { + await db.insert(taskTags).values( + data.tagIds.map(tagId => ({ taskId: task.id, tagId })) + ); + } + + await recordActivity({ + actor: user.name, + action: 'created', + entityType: 'task', + entityId: task.id, + changes: { title: task.title }, + workspaceId: data.domain, + }); return NextResponse.json(task, { status: 201 }); } catch (error) { if (error instanceof z.ZodError) { return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); } - throw error; + console.error('[tasks POST] error:', error); + return createErrorResponse('INTERNAL_ERROR', 'Failed to create task', 500); } });