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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user