Merge redesign/ui-v2 into main: full v2 rewrite (Vite SPA + Hono API + Bun worker)

Resolved conflicts in web-legacy pages and report schema by taking v2 side.
v2 is the deployed, current architecture; v1 paths preserved under apps/web-legacy.
This commit is contained in:
Hermes
2026-08-09 23:32:14 +00:00
435 changed files with 34938 additions and 2186 deletions
+127
View File
@@ -0,0 +1,127 @@
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
// 1. Insert activity feed entry
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
// See AGENTS.md for full rules.
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, createErrorResponse } from '@/lib/auth';
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<RouteContext>(async (request: NextRequest, _user, context) => {
const { id } = await context!.params;
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<RouteContext>(async (request: NextRequest, user, context) => {
const { id } = await context!.params;
try {
const body = await request.json();
const data = updateTaskSchema.parse(body);
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<string, any> = { 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) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
throw error;
}
});
// DELETE /api/tasks/[id] — Soft-delete a task
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { id } = await context!.params;
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 });
});
@@ -0,0 +1,76 @@
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
// 1. Insert activity feed entry
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
// See AGENTS.md for full rules.
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, createErrorResponse } from '@/lib/auth';
import { createPocketBaseClient } from '@/lib/pocketbase';
import { z } from 'zod';
const bulkCreateSchema = z.object({
tasks: z.array(z.object({
title: z.string().min(1),
description: z.string().optional(),
status: z.enum(['todo', 'in_progress', 'done', 'cancelled']).optional(),
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
due_date: z.string().optional(),
project_id: z.string().optional(),
domain: z.string(),
tags: z.array(z.string()).optional(),
})).min(1).max(100),
});
const bulkUpdateSchema = z.object({
ids: z.array(z.string()).min(1),
updates: z.object({
status: z.enum(['todo', 'in_progress', 'done', 'cancelled']).optional(),
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
project_id: z.string().optional(),
domain: z.string().optional(),
}),
});
const bulkDeleteSchema = z.object({
ids: z.array(z.string()).min(1),
});
// POST /api/tasks/bulk — Bulk create/update/delete
export const POST = withAuth(async (request: NextRequest, _user) => {
const body = await request.json();
const pb = createPocketBaseClient();
// Determine operation from body shape
if ('tasks' in body) {
// Bulk create
const data = bulkCreateSchema.parse(body);
const created = [];
for (const task of data.tasks) {
const result = await pb.collection('tasks').create(task);
created.push(result);
}
return NextResponse.json({ created: created.length, items: created }, { status: 201 });
}
if ('ids' in body && 'updates' in body) {
// Bulk update
const data = bulkUpdateSchema.parse(body);
const updated = [];
for (const id of data.ids) {
const result = await pb.collection('tasks').update(id, data.updates);
updated.push(result);
}
return NextResponse.json({ updated: updated.length, items: updated });
}
if ('ids' in body) {
// Bulk delete
const data = bulkDeleteSchema.parse(body);
for (const id of data.ids) {
await pb.collection('tasks').delete(id);
}
return NextResponse.json({ deleted: data.ids.length });
}
return createErrorResponse('INVALID_REQUEST', 'Must specify tasks, ids+updates, or ids', 400);
});
+146
View File
@@ -0,0 +1,146 @@
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
// 1. Insert activity feed entry
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
// See AGENTS.md for full rules.
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, createErrorResponse, resolveActiveDomain } from '@/lib/auth';
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 = 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';
let domainId = searchParams.get('domain') || undefined;
if (!domainId) {
const active = await resolveActiveDomain(user);
domainId = active.id;
}
const sortDir = sort.startsWith('-') ? 'desc' : 'asc';
const sortField = sort.replace(/^-/, '');
const sortColumns: Record<string, any> = {
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 (status) {
const statuses = status.split(',');
conditions.push(inArray(tasks.status, statuses as any));
}
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<number>`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,
});
});
// POST /api/tasks — Create a task
export const POST = withAuth(async (request: NextRequest, user) => {
try {
const body = await request.json();
const data = createTaskSchema.parse({
...body,
domain: body.domain || (await resolveActiveDomain(user)).id,
});
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);
}
console.error('[tasks POST] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to create task', 500);
}
});