From 29ff83cfd69418f77fc74a7c1b368a160f89e05d Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Thu, 6 Aug 2026 13:47:15 +0000 Subject: [PATCH 1/5] fix(worker): honor jobs.maxAttempts and treat NULL nextRetryAt as due --- worker/index.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/worker/index.ts b/worker/index.ts index 370a362..6681499 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -1,5 +1,5 @@ import { db, jobs, webhooks, webhookDeliveries, scheduledJobs, tasks, habits, habitCompletions } from '@project-e/db'; -import { and, eq, lte, isNull, sql } from 'drizzle-orm'; +import { and, eq, lte, isNull, or } from 'drizzle-orm'; import { createHmac } from 'node:crypto'; import rrule from 'rrule'; const { RRule } = rrule; @@ -21,12 +21,13 @@ async function poll(): Promise { try { const now = new Date(); - // Get pending jobs that are due + // Get pending jobs that are due. + // nextRetryAt is NULL for freshly-queued jobs, which are due immediately. const pendingJobs = await db.select() .from(jobs) .where(and( eq(jobs.status, 'pending'), - lte(jobs.nextRetryAt ?? sql`now()`, now), + or(isNull(jobs.nextRetryAt), lte(jobs.nextRetryAt, now)), )) .orderBy(jobs.createdAt) .limit(10); @@ -90,8 +91,9 @@ async function processJob(job: typeof jobs.$inferSelect): Promise { } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); const attempts = (job.attempts || 0) + 1; + const maxAttempts = job.maxAttempts || MAX_RETRIES; - if (attempts >= MAX_RETRIES) { + if (attempts >= maxAttempts) { // Max retries reached — mark as failed await db.update(jobs) .set({ From 2bb28fe65f773601c9e683ceedce1b59e1a3672a Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Thu, 6 Aug 2026 13:53:29 +0000 Subject: [PATCH 2/5] fix(tasks): support status query param in list API and use it in today-tasks widget The dashboard widget was filtering with a broken filter=status!=done query param that the list API never interpreted, so completed tasks could appear. Add a status= todo,in_progress filter to GET /api/tasks and update the widget to use it (also fix domain badge to use domainId). --- apps/web/app/api/tasks/route.ts | 5 +++++ .../components/dashboard/widgets/today-tasks-widget.tsx | 7 ++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/web/app/api/tasks/route.ts b/apps/web/app/api/tasks/route.ts index 53adbd7..ae53d26 100644 --- a/apps/web/app/api/tasks/route.ts +++ b/apps/web/app/api/tasks/route.ts @@ -34,6 +34,7 @@ export const GET = withAuth(async (request: NextRequest, _user) => { 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 status = searchParams.get('status'); const sort = searchParams.get('sort') || '-created'; const domainId = searchParams.get('domain') || undefined; @@ -54,6 +55,10 @@ export const GET = withAuth(async (request: NextRequest, _user) => { const conditions: any[] = [isNull(tasks.deletedAt)]; if (domainId) conditions.push(eq(tasks.domainId, domainId)); + if (status) { + const statuses = status.split(','); + conditions.push(inArray(tasks.status, statuses as any)); + } if (filter) { conditions.push( or( diff --git a/apps/web/components/dashboard/widgets/today-tasks-widget.tsx b/apps/web/components/dashboard/widgets/today-tasks-widget.tsx index 1508a34..dba365f 100644 --- a/apps/web/components/dashboard/widgets/today-tasks-widget.tsx +++ b/apps/web/components/dashboard/widgets/today-tasks-widget.tsx @@ -12,7 +12,8 @@ interface Task { title: string; status: string; priority: string; - domain: string; + domainId?: string; + domain?: string; } interface Domain { id: string; name: string; color: string; } @@ -43,7 +44,7 @@ export function TodayTasksWidget() { async function fetchTasks() { try { const response = await fetch( - '/api/tasks?filter=status!%3D%22done%22&perPage=5&sort=-priority' + '/api/tasks?status=todo,in_progress&perPage=5&sort=-priority' ); if (response.ok) { const data = await response.json(); @@ -115,7 +116,7 @@ export function TodayTasksWidget() { {task.title} - {domainMap.get(task.domain) || task.domain} + {domainMap.get(task.domainId ?? task.domain ?? '') || task.domainId || task.domain} ))} From 2dec21d886b133444f95d28aca1856b7142094d3 Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Thu, 6 Aug 2026 13:53:33 +0000 Subject: [PATCH 3/5] fix(tasks,habits): replace dead PocketBase stubs with Drizzle ORM + soft-delete + activity feed GET/PATCH/DELETE for single task and habit used createPocketBaseClient(), which is a dead stub, and DELETE did a hard delete violating the soft-delete rule in AGENTS.md. Rewrite with Drizzle ORM: soft-delete (deleted_at), activity feed insert via recordActivity(), Zod validation, parent-task existence check, workspace access check, and 404 NOT_FOUND handling. --- apps/web/app/api/habits/[id]/route.ts | 100 ++++++++++++++++++++++---- apps/web/app/api/tasks/[id]/route.ts | 100 ++++++++++++++++++++++---- 2 files changed, 175 insertions(+), 25 deletions(-) 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 }); }); From ab7735d6fc9d5abc5213792badbf0eda6534575d Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Thu, 6 Aug 2026 13:56:22 +0000 Subject: [PATCH 4/5] fix(graph): filter soft-deleted projects in getGraphData projectIds query omitted isNull(projects.deletedAt), unlike the sibling projectRows query, so sections of soft-deleted projects were loaded as orphan nodes via inArray(sections.projectId, projectIds). --- apps/web/lib/graph-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/lib/graph-service.ts b/apps/web/lib/graph-service.ts index dac144a..648f010 100644 --- a/apps/web/lib/graph-service.ts +++ b/apps/web/lib/graph-service.ts @@ -60,7 +60,7 @@ export async function getGraphData(domainId: string): Promise { } // Fetch all entities in this domain - const projectIds = (await db.select({ id: projects.id }).from(projects).where(eq(projects.domainId, domainId))).map(p => p.id); + const projectIds = (await db.select({ id: projects.id }).from(projects).where(and(eq(projects.domainId, domainId), isNull(projects.deletedAt)))).map(p => p.id); const [noteRows, taskRows, habitRows, projectRows, sectionRows, tagRows, domainRows] = await Promise.all([ db.select({ id: notes.id, title: notes.title }).from(notes).where(and(eq(notes.domainId, domainId), isNull(notes.deletedAt))), db.select({ id: tasks.id, title: tasks.title, projectId: tasks.projectId }).from(tasks).where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt))), From ca7a0d7ff780628fe852867e36482916be416809 Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Thu, 6 Aug 2026 13:56:29 +0000 Subject: [PATCH 5/5] fix(reports): honor passed token in report-service generate* helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generateWeeklySummary/generateProjectHealth/generateHabitAnalysis/ generateTimeAudit helpers all had "const pb = token ? createAdminClient() : createAdminClient();" — both branches return the admin client, so a passed user token was silently discarded and every caller got admin-level access. Use createPocketBaseClient(token) when a token is provided, matching the sibling project-service.ts / note-service.ts pattern. --- apps/web/lib/services/report-service.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/lib/services/report-service.ts b/apps/web/lib/services/report-service.ts index 807044f..14be7d4 100644 --- a/apps/web/lib/services/report-service.ts +++ b/apps/web/lib/services/report-service.ts @@ -1,4 +1,4 @@ -import { createAdminClient } from '../pocketbase'; +import { createAdminClient, createPocketBaseClient } from '../pocketbase'; import type { Task, Habit, @@ -46,7 +46,7 @@ export async function generateWeeklySummary( streaks: Array<{ name: string; streak: number }>; byDomain: Record; }> { - const pb = token ? createAdminClient() : createAdminClient(); + const pb = token ? createPocketBaseClient(token) : createAdminClient(); // Tasks completed this week const taskResults = await pb.collection('tasks').getFullList({ @@ -112,7 +112,7 @@ export async function generateProjectHealth( milestoneStatus: Record; completionRate: number; }> { - const pb = token ? createAdminClient() : createAdminClient(); + const pb = token ? createPocketBaseClient(token) : createAdminClient(); const taskResults = await pb.collection('tasks').getFullList({ filter: `project_id = "${projectId}"`, @@ -169,7 +169,7 @@ export async function generateHabitAnalysis( }>; atRiskHabits: string[]; }> { - const pb = token ? createAdminClient() : createAdminClient(); + const pb = token ? createPocketBaseClient(token) : createAdminClient(); const startDate = new Date(); startDate.setDate(startDate.getDate() - days); @@ -225,7 +225,7 @@ export async function generateTimeAudit( byProject: Record; byTag: Record; }> { - const pb = token ? createAdminClient() : createAdminClient(); + const pb = token ? createPocketBaseClient(token) : createAdminClient(); const entryResults = await pb.collection('time_entries').getFullList({ filter: `started_at >= "${startDate}" && started_at <= "${endDate}"`,