feat: full plan execution - CI/CD, critical fixes, UX polish, secondary/advanced features, E2E + docs

Phase 0 (CI/CD): fix root typecheck to cover api+worker+web; reconcile migration
story into idempotent db:migrate (db:sync + db:triggers); add Gitea Actions
quality/deploy/smoke workflow; rewrite README/AGENTS/DEPLOY docs; add
requireWorkspaceAccess + recordActivityForEntity conventions.

Phase 1 (critical fixes): calendar delete + drag/resize DnD; canvas card CRUD +
bulk save + debounced autosave; logout route; graph edge workspaceId derivation;
real analytics endpoints (drop Math.random); task board droppable columns +
reorder persistence; Tiptap notes editor with sanitized HTML rendering; remove
insecure passkey auth; domain/owner scoping (IDOR) on all by-ID routes + search/
export/realtime scoping; command palette routing + agent mention fetch; agent
activity SSE handler; graph fly-to with tracked positions.

Phase 2 (UX polish): login on design system; Sonner toasts app-wide; shared
Loading/Empty/Error state components; working density/sidebarPos/reduce-motion
settings; Inter typography; consolidated status-colors lib; unified detail
routes; dashboard sort/realtime/responsive fixes; mobile responsive; a11y
(radiogroups, sanitized snippets, badge labels).

Phase 3 (features): daily notes timezone fix + delete + autosave + mood/energy
create; active-domain store + topbar picker; graph domain picker + navigable
entity links; tag assign/remove UI + server-side tag filter; real CSV export +
import validation; custom fields on tasks.

Phase 4 (advanced): migrate job worker into apps/worker (webhook delivery with
HMAC, recurring spawn, ai_dispatch disabled); webhook queue helper + entity
event enqueuing + test endpoint fix; recurring scheduledJobs pipeline; agents
CRUD + permission editing + activity filters; real notifications feed; MCP
polish (validation, error codes, domain scoping, dead sql leftover).

Phase 5 (E2E + docs): rewrite Playwright suite for the Vite SPA (15 specs, new
auth helpers, chromium-only in CI); add ephemeral-Postgres e2e CI job; rewrite
docs/API.md for the real Hono API.
This commit is contained in:
2026-08-10 08:53:18 +00:00
parent 6cb4b9f1b5
commit a60b75f075
99 changed files with 6238 additions and 2954 deletions
+63 -3
View File
@@ -1,8 +1,9 @@
import { Hono } from "hono";
import { db, projects, tasks, sections, projectTags, tags as tagsTable, activityFeed } from "@project-e/db";
import { and, asc, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm";
import { requireAuth, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth";
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
import { enqueueWebhooks } from "../middleware/webhook-queue";
import { z } from "zod";
export const projectRoutes = new Hono();
@@ -69,6 +70,8 @@ projectRoutes.get("/", async (c) => {
domainId = active.id;
}
await requireWorkspaceAccess(c, domainId);
const conditions: any[] = [
eq(projects.domainId, domainId),
isNull(projects.deletedAt),
@@ -189,6 +192,8 @@ projectRoutes.post("/", async (c) => {
domain: body.domain || (await resolveActiveDomain(user)).id,
});
await requireWorkspaceAccess(c, data.domain);
const [project] = await db.insert(projects).values({
name: data.name,
description: data.description ?? null,
@@ -214,6 +219,8 @@ projectRoutes.post("/", async (c) => {
workspaceId: data.domain,
});
await enqueueWebhooks({ workspaceId: data.domain, event: "project.created", entityType: "project", entityId: project.id, data: { name: project.name } });
return c.json(project, 201);
} catch (error) {
if (error instanceof AuthError) {
@@ -242,6 +249,8 @@ projectRoutes.get("/:id", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
}
await requireWorkspaceAccess(c, project.domainId);
// Fetch sections
const projectSections = await db.select()
.from(sections)
@@ -303,6 +312,8 @@ projectRoutes.patch("/:id", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
}
await requireWorkspaceAccess(c, existing.domainId);
const updateValues: Record<string, unknown> = {};
if (data.name !== undefined) updateValues.name = data.name;
if (data.description !== undefined) updateValues.description = data.description;
@@ -326,6 +337,8 @@ projectRoutes.patch("/:id", async (c) => {
workspaceId: existing.domainId,
});
await enqueueWebhooks({ workspaceId: existing.domainId, event: "project.updated", entityType: "project", entityId: id, data: { ...data, previousName: existing.name } });
return c.json(updated);
} catch (error) {
if (error instanceof AuthError) {
@@ -354,6 +367,8 @@ projectRoutes.delete("/:id", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
}
await requireWorkspaceAccess(c, existing.domainId);
await db.update(projects)
.set({ deletedAt: new Date(), updatedAt: new Date() })
.where(eq(projects.id, id));
@@ -367,6 +382,8 @@ projectRoutes.delete("/:id", async (c) => {
workspaceId: existing.domainId,
});
await enqueueWebhooks({ workspaceId: existing.domainId, event: "project.deleted", entityType: "project", entityId: id, data: { name: existing.name } });
return c.body(null, 204);
} catch (error) {
if (error instanceof AuthError) {
@@ -383,7 +400,7 @@ projectRoutes.get("/:id/sections", async (c) => {
const user = await requireAuth(c);
const projectId = c.req.param("id");
const [project] = await db.select({ id: projects.id })
const [project] = await db.select({ id: projects.id, domainId: projects.domainId })
.from(projects)
.where(and(eq(projects.id, projectId), isNull(projects.deletedAt)))
.limit(1);
@@ -392,6 +409,8 @@ projectRoutes.get("/:id/sections", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
}
await requireWorkspaceAccess(c, project.domainId);
const items = await db.select()
.from(sections)
.where(eq(sections.projectId, projectId))
@@ -424,6 +443,8 @@ projectRoutes.post("/:id/sections", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
}
await requireWorkspaceAccess(c, project.domainId);
let sortOrder = data.sortOrder;
if (sortOrder === undefined) {
const [maxOrder] = await db.select({ max: sql<number>`COALESCE(MAX(sort_order), -1)` })
@@ -470,6 +491,17 @@ projectRoutes.get("/:id/sections/:sid", async (c) => {
const projectId = c.req.param("id");
const id = c.req.param("sid");
const [project] = await db.select({ id: projects.id, domainId: projects.domainId })
.from(projects)
.where(and(eq(projects.id, projectId), isNull(projects.deletedAt)))
.limit(1);
if (!project) {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
}
await requireWorkspaceAccess(c, project.domainId);
const [section] = await db.select()
.from(sections)
.where(and(eq(sections.id, id), eq(sections.projectId, projectId)))
@@ -498,6 +530,17 @@ projectRoutes.patch("/:id/sections/:sid", async (c) => {
const body = await c.req.json();
const data = updateSectionSchema.parse(body);
const [project] = await db.select({ id: projects.id, domainId: projects.domainId })
.from(projects)
.where(and(eq(projects.id, projectId), isNull(projects.deletedAt)))
.limit(1);
if (!project) {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
}
await requireWorkspaceAccess(c, project.domainId);
const [existing] = await db.select()
.from(sections)
.where(and(eq(sections.id, id), eq(sections.projectId, projectId)))
@@ -555,6 +598,17 @@ projectRoutes.delete("/:id/sections/:sid", async (c) => {
const projectId = c.req.param("id");
const id = c.req.param("sid");
const [project] = await db.select({ id: projects.id, domainId: projects.domainId })
.from(projects)
.where(and(eq(projects.id, projectId), isNull(projects.deletedAt)))
.limit(1);
if (!project) {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
}
await requireWorkspaceAccess(c, project.domainId);
const [existing] = await db.select()
.from(sections)
.where(and(eq(sections.id, id), eq(sections.projectId, projectId)))
@@ -598,7 +652,7 @@ projectRoutes.get("/:id/members", async (c) => {
const user = await requireAuth(c);
const id = c.req.param("id");
const [project] = await db.select({ id: projects.id })
const [project] = await db.select({ id: projects.id, domainId: projects.domainId })
.from(projects)
.where(and(eq(projects.id, id), isNull(projects.deletedAt)))
.limit(1);
@@ -607,6 +661,8 @@ projectRoutes.get("/:id/members", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
}
await requireWorkspaceAccess(c, project.domainId);
// Members are stored in activity feed with entityType=member
const members = await db.select()
.from(activityFeed)
@@ -646,6 +702,8 @@ projectRoutes.post("/:id/members", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
}
await requireWorkspaceAccess(c, project.domainId);
await recordActivity({
actor: user.name,
action: "added",
@@ -684,6 +742,8 @@ projectRoutes.delete("/:id/members/:uid", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
}
await requireWorkspaceAccess(c, project.domainId);
await recordActivity({
actor: user.name,
action: "removed",