diff --git a/apps/web/app/api/habits/[id]/route.ts b/apps/web/app/api/habits/[id]/route.ts index 2767e81..6d6c030 100644 --- a/apps/web/app/api/habits/[id]/route.ts +++ b/apps/web/app/api/habits/[id]/route.ts @@ -4,32 +4,91 @@ // See AGENTS.md for full rules. import { NextRequest, NextResponse } from 'next/server'; -import { withAuth, createErrorResponse } from '@/lib/auth'; -import { createPocketBaseClient } from '@/lib/pocketbase'; -import { updateHabitSchema } from '@project-e/shared'; +import { withAuth, createErrorResponse, requireWorkspaceAccess } from '@/lib/auth'; +import { recordActivity } from '@/lib/activity'; +import { db, habits, habitTags } from '@project-e/db'; +import { and, eq, isNull } from 'drizzle-orm'; import { z } from 'zod'; +const habitFrequencyEnum = z.enum(['daily', 'weekly', 'custom']); +const habitDifficultyEnum = z.enum(['easy', 'medium', 'hard']); + +const updateHabitSchema = z.object({ + name: z.string().min(1, 'Name is required').optional(), + description: z.string().nullable().optional(), + domain: z.string().min(1, 'Domain is required').optional(), + frequency: habitFrequencyEnum.optional(), + difficulty: habitDifficultyEnum.optional(), + goalPerPeriod: z.number().int().positive().optional(), + active: z.boolean().optional(), + tagIds: z.array(z.string().uuid()).optional(), +}); + type RouteContext = { params: Promise<{ id: string }> }; // GET /api/habits/[id] — Get a single habit export const GET = withAuth(async (request: NextRequest, _user, context) => { const { id } = await context!.params; - const pb = createPocketBaseClient(); - const habit = await pb.collection('habits').getOne(id); + const [habit] = await db.select() + .from(habits) + .where(and(eq(habits.id, id), isNull(habits.deletedAt))) + .limit(1); + + if (!habit) { + return createErrorResponse('NOT_FOUND', 'Habit not found', 404); + } return NextResponse.json(habit); }); // PATCH /api/habits/[id] — Update a habit -export const PATCH = withAuth(async (request: NextRequest, _user, context) => { +export const PATCH = withAuth(async (request: NextRequest, user, context) => { + const { id } = await context!.params; + try { - const { id } = await context!.params; const body = await request.json(); const data = updateHabitSchema.parse(body); - const pb = createPocketBaseClient(); - const habit = await pb.collection('habits').update(id, data); + if (data.domain) { + await requireWorkspaceAccess(data.domain); + } + + const updateValues: Record = { updatedAt: new Date() }; + if (data.name !== undefined) updateValues.name = data.name; + if (data.description !== undefined) updateValues.description = data.description; + if (data.domain !== undefined) updateValues.domainId = data.domain; + if (data.frequency !== undefined) updateValues.frequency = data.frequency; + if (data.difficulty !== undefined) updateValues.difficulty = data.difficulty; + if (data.goalPerPeriod !== undefined) updateValues.goalPerPeriod = data.goalPerPeriod; + if (data.active !== undefined) updateValues.active = data.active; + + const [habit] = await db.update(habits) + .set(updateValues) + .where(and(eq(habits.id, id), isNull(habits.deletedAt))) + .returning(); + + if (!habit) { + return createErrorResponse('NOT_FOUND', 'Habit not found', 404); + } + + if (data.tagIds) { + await db.delete(habitTags).where(eq(habitTags.habitId, id)); + if (data.tagIds.length > 0) { + await db.insert(habitTags).values( + data.tagIds.map(tagId => ({ habitId: id, tagId })) + ); + } + } + + await recordActivity({ + actor: user.name, + action: 'updated', + entityType: 'habit', + entityId: habit.id, + changes: { name: habit.name }, + workspaceId: habit.domainId, + }); return NextResponse.json(habit); } catch (error) { @@ -40,12 +99,27 @@ export const PATCH = withAuth(async (request: NextRequest, _user, } }); -// DELETE /api/habits/[id] — Delete a habit -export const DELETE = withAuth(async (request: NextRequest, _user, context) => { +// DELETE /api/habits/[id] — Soft-delete a habit +export const DELETE = withAuth(async (request: NextRequest, user, context) => { const { id } = await context!.params; - const pb = createPocketBaseClient(); - await pb.collection('habits').delete(id); + const [habit] = await db.update(habits) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where(and(eq(habits.id, id), isNull(habits.deletedAt))) + .returning(); + + if (!habit) { + return createErrorResponse('NOT_FOUND', 'Habit not found', 404); + } + + await recordActivity({ + actor: user.name, + action: 'deleted', + entityType: 'habit', + entityId: habit.id, + changes: { name: habit.name }, + workspaceId: habit.domainId, + }); return new NextResponse(null, { status: 204 }); }); diff --git a/apps/web/app/api/tasks/[id]/route.ts b/apps/web/app/api/tasks/[id]/route.ts index 7304000..2b0a218 100644 --- a/apps/web/app/api/tasks/[id]/route.ts +++ b/apps/web/app/api/tasks/[id]/route.ts @@ -5,31 +5,92 @@ import { NextRequest, NextResponse } from 'next/server'; import { withAuth, createErrorResponse } from '@/lib/auth'; -import { createPocketBaseClient } from '@/lib/pocketbase'; -import { updateTaskSchema } from '@project-e/shared'; +import { recordActivity } from '@/lib/activity'; +import { db, tasks } from '@project-e/db'; +import { and, eq, isNull } 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 updateTaskSchema = z.object({ + title: z.string().min(1, 'Title is required').optional(), + description: z.string().nullable().optional(), + status: taskStatusEnum.optional(), + priority: taskPriorityEnum.optional(), + projectId: z.string().uuid().nullable().optional(), + sectionId: z.string().uuid().nullable().optional(), + parentId: z.string().uuid().nullable().optional(), + dueDate: z.string().datetime().nullable().optional(), + estimatedMinutes: z.number().int().positive().nullable().optional(), + order: z.number().int().optional(), +}); + type RouteContext = { params: Promise<{ id: string }> }; // GET /api/tasks/[id] — Get a single task export const GET = withAuth(async (request: NextRequest, _user, context) => { const { id } = await context!.params; - const pb = createPocketBaseClient(); - const task = await pb.collection('tasks').getOne(id); + const [task] = await db.select() + .from(tasks) + .where(and(eq(tasks.id, id), isNull(tasks.deletedAt))) + .limit(1); + + if (!task) { + return createErrorResponse('NOT_FOUND', 'Task not found', 404); + } return NextResponse.json(task); }); // PATCH /api/tasks/[id] — Update a task -export const PATCH = withAuth(async (request: NextRequest, _user, context) => { +export const PATCH = withAuth(async (request: NextRequest, user, context) => { + const { id } = await context!.params; + try { - const { id } = await context!.params; const body = await request.json(); const data = updateTaskSchema.parse(body); - const pb = createPocketBaseClient(); - const task = await pb.collection('tasks').update(id, data); + if (data.parentId) { + const [parent] = await db.select({ id: tasks.id }) + .from(tasks) + .where(and(eq(tasks.id, data.parentId), isNull(tasks.deletedAt))) + .limit(1); + if (!parent) { + return createErrorResponse('NOT_FOUND', 'Parent task not found', 404); + } + } + + const updateValues: Record = { updatedAt: new Date() }; + if (data.title !== undefined) updateValues.title = data.title; + if (data.description !== undefined) updateValues.description = data.description; + if (data.status !== undefined) updateValues.status = data.status; + if (data.priority !== undefined) updateValues.priority = data.priority; + if (data.projectId !== undefined) updateValues.projectId = data.projectId; + if (data.sectionId !== undefined) updateValues.sectionId = data.sectionId; + if (data.parentId !== undefined) updateValues.parentId = data.parentId; + if (data.dueDate !== undefined) updateValues.dueDate = data.dueDate ? new Date(data.dueDate) : null; + if (data.estimatedMinutes !== undefined) updateValues.estimatedMinutes = data.estimatedMinutes; + if (data.order !== undefined) updateValues.order = data.order; + + const [task] = await db.update(tasks) + .set(updateValues) + .where(and(eq(tasks.id, id), isNull(tasks.deletedAt))) + .returning(); + + if (!task) { + return createErrorResponse('NOT_FOUND', 'Task not found', 404); + } + + await recordActivity({ + actor: user.name, + action: 'updated', + entityType: 'task', + entityId: task.id, + changes: { title: task.title, status: task.status }, + workspaceId: task.domainId, + }); return NextResponse.json(task); } catch (error) { @@ -40,12 +101,27 @@ export const PATCH = withAuth(async (request: NextRequest, _user, } }); -// DELETE /api/tasks/[id] — Delete a task -export const DELETE = withAuth(async (request: NextRequest, _user, context) => { +// DELETE /api/tasks/[id] — Soft-delete a task +export const DELETE = withAuth(async (request: NextRequest, user, context) => { const { id } = await context!.params; - const pb = createPocketBaseClient(); - await pb.collection('tasks').delete(id); + const [task] = await db.update(tasks) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where(and(eq(tasks.id, id), isNull(tasks.deletedAt))) + .returning(); + + if (!task) { + return createErrorResponse('NOT_FOUND', 'Task not found', 404); + } + + await recordActivity({ + actor: user.name, + action: 'deleted', + entityType: 'task', + entityId: task.id, + changes: { title: task.title }, + workspaceId: task.domainId, + }); return new NextResponse(null, { status: 204 }); });