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:
@@ -15,6 +15,7 @@
|
||||
"hono": "^4.6.0",
|
||||
"jose": "^5.9.6",
|
||||
"postgres": "^3.4.9",
|
||||
"rrule": "^2.8.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { customFieldRoutes } from "./routes/custom-fields";
|
||||
import { errorLogRoutes } from "./routes/error-log";
|
||||
import { analyticsRoutes } from "./routes/analytics";
|
||||
import { importExportRoutes } from "./routes/import-export";
|
||||
import { notificationRoutes } from "./routes/notifications";
|
||||
import { healthHandler } from "./routes/health";
|
||||
|
||||
const app = new Hono();
|
||||
@@ -57,6 +58,7 @@ app.route("/api/tags", tagRoutes);
|
||||
app.route("/api/custom-fields", customFieldRoutes);
|
||||
app.route("/api/error-log", errorLogRoutes);
|
||||
app.route("/api/analytics", analyticsRoutes);
|
||||
app.route("/api/notifications", notificationRoutes);
|
||||
app.route("/api", importExportRoutes);
|
||||
app.route("/api", realtimeRoutes);
|
||||
app.route("/api/mcp", mcpRoutes);
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
import { db, sql, activityFeed } from "@project-e/db";
|
||||
import {
|
||||
db,
|
||||
sql,
|
||||
activityFeed,
|
||||
tasks,
|
||||
habits,
|
||||
projects,
|
||||
notes,
|
||||
canvases,
|
||||
dailyNotes,
|
||||
calendarEvents,
|
||||
webhooks,
|
||||
agents,
|
||||
customFields,
|
||||
dashboardWidgets,
|
||||
} from "@project-e/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { AnyPgColumn, AnyPgTable } from "drizzle-orm/pg-core";
|
||||
|
||||
export interface RecordActivityParams {
|
||||
actor: string;
|
||||
@@ -24,3 +41,79 @@ export async function recordActivity(params: RecordActivityParams): Promise<void
|
||||
const payload = JSON.stringify({ type: entityType, action, id: entityId, workspace_id: workspaceId });
|
||||
await sql`SELECT pg_notify('project_e_events', ${payload}::text)`;
|
||||
}
|
||||
|
||||
// ── recordActivityForEntity ────────────────────────────────────────────────────
|
||||
// Variant that derives the workspaceId from the entity itself when the caller
|
||||
// omits it. Keeps `recordActivity` unchanged so existing call sites compile as-is.
|
||||
|
||||
export interface RecordActivityForEntityParams {
|
||||
actor: string;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
changes?: Record<string, unknown>;
|
||||
workspaceId?: string | null;
|
||||
}
|
||||
|
||||
interface EntityWorkspaceLookup {
|
||||
table: AnyPgTable;
|
||||
idColumn: AnyPgColumn;
|
||||
workspaceColumn: AnyPgColumn;
|
||||
}
|
||||
|
||||
// Maps an entityType (as recorded in activity_feed) to the table + column that
|
||||
// holds its owning workspace/domain. Tables with domain_id use that; webhooks
|
||||
// store it as workspace_id.
|
||||
const entityWorkspaceLookups: Record<string, EntityWorkspaceLookup> = {
|
||||
task: { table: tasks, idColumn: tasks.id, workspaceColumn: tasks.domainId },
|
||||
habit: { table: habits, idColumn: habits.id, workspaceColumn: habits.domainId },
|
||||
project: { table: projects, idColumn: projects.id, workspaceColumn: projects.domainId },
|
||||
note: { table: notes, idColumn: notes.id, workspaceColumn: notes.domainId },
|
||||
canvas: { table: canvases, idColumn: canvases.id, workspaceColumn: canvases.domainId },
|
||||
daily_note: { table: dailyNotes, idColumn: dailyNotes.id, workspaceColumn: dailyNotes.domainId },
|
||||
calendar_event: { table: calendarEvents, idColumn: calendarEvents.id, workspaceColumn: calendarEvents.domainId },
|
||||
webhook: { table: webhooks, idColumn: webhooks.id, workspaceColumn: webhooks.workspaceId },
|
||||
agent: { table: agents, idColumn: agents.id, workspaceColumn: agents.domainId },
|
||||
custom_field: { table: customFields, idColumn: customFields.id, workspaceColumn: customFields.domainId },
|
||||
dashboard_widget: { table: dashboardWidgets, idColumn: dashboardWidgets.id, workspaceColumn: dashboardWidgets.domainId },
|
||||
};
|
||||
|
||||
async function resolveEntityWorkspaceId(entityType: string, entityId: string): Promise<string | null> {
|
||||
const lookup = entityWorkspaceLookups[entityType];
|
||||
if (!lookup) return null;
|
||||
|
||||
const [row] = await db
|
||||
.select({ workspaceId: lookup.workspaceColumn })
|
||||
.from(lookup.table)
|
||||
.where(eq(lookup.idColumn, entityId))
|
||||
.limit(1);
|
||||
|
||||
// AnyPgColumn erases the concrete type, so the selected value is `unknown`.
|
||||
return (row?.workspaceId as string | undefined) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an activity event, deriving the workspaceId from the entity when not
|
||||
* provided (or empty). Unknown entity types or unresolvable entities are logged
|
||||
* and skipped rather than crashing the request.
|
||||
*/
|
||||
export async function recordActivityForEntity(params: RecordActivityForEntityParams): Promise<void> {
|
||||
let workspaceId = params.workspaceId;
|
||||
|
||||
if (!workspaceId || workspaceId.trim() === "") {
|
||||
workspaceId = await resolveEntityWorkspaceId(params.entityType, params.entityId);
|
||||
if (!workspaceId) {
|
||||
console.warn(`[activity] Could not resolve workspace for ${params.entityType}:${params.entityId}; skipping activity`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await recordActivity({
|
||||
actor: params.actor,
|
||||
action: params.action,
|
||||
entityType: params.entityType,
|
||||
entityId: params.entityId,
|
||||
changes: params.changes,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { createMiddleware } from "hono/factory";
|
||||
import type { Context, Next } from "hono";
|
||||
import { jwtVerify, SignJWT } from "jose";
|
||||
import { createHash } from "node:crypto";
|
||||
import { db, users, apiKeys } from "@project-e/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db, users, apiKeys, domains } from "@project-e/db";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
|
||||
const AUTH_SECRET = new TextEncoder().encode(process.env.AUTH_SECRET || process.env.NEXTAUTH_SECRET || "fallback-secret-change-me");
|
||||
const COOKIE_NAME = "session";
|
||||
@@ -101,10 +101,44 @@ export async function requireAuth(c: Context): Promise<AuthUser> {
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function resolveActiveDomain(user: { id: string; email: string; name?: string | null }): Promise<{ id: string; name: string; created: boolean }> {
|
||||
const { domains } = await import("@project-e/db");
|
||||
const { asc } = await import("drizzle-orm");
|
||||
/**
|
||||
* Require access to a workspace (domain). Verifies the workspace exists and,
|
||||
* when a user is present on the context, that the user owns it.
|
||||
*
|
||||
* Ownership fallback: domain rows created before the ownership model was
|
||||
* introduced may have a NULL ownerId. For those rows we fall back to the
|
||||
* existence check only, so legacy data isn't locked out.
|
||||
*
|
||||
* @returns the domain row so callers can reuse it (id, name, slug, ownerId, …)
|
||||
* @throws AuthError 403 FORBIDDEN when workspaceId is missing/empty or not owned
|
||||
* @throws AuthError 404 NOT_FOUND when no such workspace exists
|
||||
*/
|
||||
export async function requireWorkspaceAccess(c: Context, workspaceId: string): Promise<typeof domains.$inferSelect> {
|
||||
if (!workspaceId || workspaceId.trim() === "") {
|
||||
throw new AuthError("Workspace ID is required", 403, "FORBIDDEN");
|
||||
}
|
||||
|
||||
const [domain] = await db
|
||||
.select()
|
||||
.from(domains)
|
||||
.where(eq(domains.id, workspaceId))
|
||||
.limit(1);
|
||||
|
||||
if (!domain) {
|
||||
throw new AuthError("Workspace not found", 404, "NOT_FOUND");
|
||||
}
|
||||
|
||||
const user = c.get("user");
|
||||
// Single-user/personal app: ownership is enforced when a user is known.
|
||||
// Rows with NULL ownerId (legacy) are allowed through the existence check above.
|
||||
if (user && domain.ownerId !== null && domain.ownerId !== user.id) {
|
||||
throw new AuthError("You do not have access to this workspace", 403, "FORBIDDEN");
|
||||
}
|
||||
|
||||
return domain;
|
||||
}
|
||||
|
||||
export async function resolveActiveDomain(user: { id: string; email: string; name?: string | null }): Promise<{ id: string; name: string; created: boolean }> {
|
||||
const [existing] = await db
|
||||
.select({ id: domains.id, name: domains.name })
|
||||
.from(domains)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { db, jobs, webhooks } from "@project-e/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Enqueue a single webhook delivery job for a specific webhook. Used directly by
|
||||
* the test endpoint and by `enqueueWebhooks` for every matching webhook. The
|
||||
* worker reads this job, signs the payload with the webhook secret, delivers it
|
||||
* via fetch, and records the delivery row.
|
||||
*/
|
||||
export async function enqueueWebhookDelivery({
|
||||
webhookId,
|
||||
event,
|
||||
entityType,
|
||||
entityId,
|
||||
data,
|
||||
workspaceId,
|
||||
}: {
|
||||
webhookId: string;
|
||||
event: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
data?: Record<string, unknown>;
|
||||
workspaceId: string;
|
||||
}): Promise<void> {
|
||||
await db.insert(jobs).values({
|
||||
type: "webhook_delivery",
|
||||
payload: {
|
||||
webhook_id: webhookId,
|
||||
event,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
data: data ?? {},
|
||||
timestamp: new Date().toISOString(),
|
||||
workspace_id: workspaceId,
|
||||
},
|
||||
status: "pending",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue webhook deliveries for every active webhook in a workspace that
|
||||
* subscribes to the given event. Never throws — failures are logged and the
|
||||
* caller's request proceeds, matching the activity-feed behavior.
|
||||
*/
|
||||
export async function enqueueWebhooks({
|
||||
workspaceId,
|
||||
event,
|
||||
entityType,
|
||||
entityId,
|
||||
data,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
event: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
data?: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const activeWebhooks = await db.select()
|
||||
.from(webhooks)
|
||||
.where(and(eq(webhooks.workspaceId, workspaceId), eq(webhooks.active, true)));
|
||||
|
||||
const matches = activeWebhooks.filter((webhook) =>
|
||||
(webhook.events ?? []).includes(event)
|
||||
);
|
||||
|
||||
for (const webhook of matches) {
|
||||
await enqueueWebhookDelivery({
|
||||
webhookId: webhook.id,
|
||||
event,
|
||||
entityType,
|
||||
entityId,
|
||||
data,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
if (matches.length > 0) {
|
||||
console.log(`[webhooks] Enqueued ${matches.length} delivery job(s) for ${entityType}.${event}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[webhooks] Failed to enqueue webhook deliveries for ${entityType}.${event}:`, error);
|
||||
}
|
||||
}
|
||||
+102
-10
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, agents, agentActivity, agentTasks } from "@project-e/db";
|
||||
import { and, asc, desc, eq, isNull, sql } from "drizzle-orm";
|
||||
import { requireAuth, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth";
|
||||
import { and, asc, desc, eq, gte, ilike, isNull, lte, sql } from "drizzle-orm";
|
||||
import { requireAuth, createErrorResponse, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -30,6 +30,33 @@ const updateAgentSchema = z.object({
|
||||
customFields: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
// ── Activity filter helpers ──────────────────────────────────────────────────
|
||||
// GET /activity and GET /:id/activity honor the `action`, `from`, `to` and
|
||||
// `limit` query params the frontend activity page sends. Invalid dates are
|
||||
// ignored rather than erroring; a bare "YYYY-MM-DD" bounds the whole day for
|
||||
// the `to` filter so a date-picker value doesn't silently drop that day.
|
||||
|
||||
function parseActivityDate(value: string): Date | null {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) date.setUTCHours(23, 59, 59, 999);
|
||||
return date;
|
||||
}
|
||||
|
||||
function parseActivityFilters(c: any): { conditions: any[]; limit: number } {
|
||||
const conditions: any[] = [];
|
||||
const action = c.req.query("action");
|
||||
const from = c.req.query("from");
|
||||
const to = c.req.query("to");
|
||||
if (action) conditions.push(eq(agentActivity.action, action));
|
||||
const fromDate = from ? parseActivityDate(from) : null;
|
||||
if (fromDate) conditions.push(gte(agentActivity.createdAt, fromDate));
|
||||
const toDate = to ? parseActivityDate(to) : null;
|
||||
if (toDate) conditions.push(lte(agentActivity.createdAt, toDate));
|
||||
const limit = Math.min(Math.max(parseInt(c.req.query("limit") || "100", 10) || 100, 1), 500);
|
||||
return { conditions, limit };
|
||||
}
|
||||
|
||||
// GET /api/agents — List agents
|
||||
agentRoutes.get("/", async (c) => {
|
||||
try {
|
||||
@@ -38,13 +65,16 @@ agentRoutes.get("/", async (c) => {
|
||||
const page = Math.max(1, parseInt(url.searchParams.get("page") || "1"));
|
||||
const perPage = Math.min(100, Math.max(1, parseInt(url.searchParams.get("perPage") || "50")));
|
||||
const sort = url.searchParams.get("sort") || "-created";
|
||||
const q = url.searchParams.get("q")?.trim();
|
||||
let domainId = url.searchParams.get("domain") || undefined;
|
||||
if (!domainId) {
|
||||
const active = await resolveActiveDomain(user);
|
||||
domainId = active.id;
|
||||
}
|
||||
await requireWorkspaceAccess(c, domainId);
|
||||
|
||||
const conditions: any[] = [eq(agents.domainId, domainId)];
|
||||
if (q) conditions.push(ilike(agents.name, `%${q}%`));
|
||||
const sortField = sort.replace(/^-/, "");
|
||||
const sortDir = sort.startsWith("-") ? "desc" : "asc";
|
||||
const sortColumns: Record<string, any> = { created: agents.createdAt, updated: agents.updatedAt, name: agents.name };
|
||||
@@ -73,6 +103,8 @@ agentRoutes.post("/", async (c) => {
|
||||
domain: body.domain || (await resolveActiveDomain(user)).id,
|
||||
});
|
||||
|
||||
await requireWorkspaceAccess(c, data.domain);
|
||||
|
||||
const [agent] = await db.insert(agents).values({
|
||||
name: data.name,
|
||||
description: data.description ?? null,
|
||||
@@ -104,11 +136,27 @@ agentRoutes.post("/", async (c) => {
|
||||
// GET /api/agents/activity — All activity (bare path, no agent filter)
|
||||
agentRoutes.get("/activity", async (c) => {
|
||||
try {
|
||||
await requireAuth(c);
|
||||
const items = await db.select()
|
||||
const user = await requireAuth(c);
|
||||
// agent_activity has no domain_id — scope through the owning agent
|
||||
const domainId = c.req.query("domain") || (await resolveActiveDomain(user)).id;
|
||||
await requireWorkspaceAccess(c, domainId);
|
||||
const { conditions: filterConditions, limit } = parseActivityFilters(c);
|
||||
const items = await db.select({
|
||||
id: agentActivity.id,
|
||||
agentId: agentActivity.agentId,
|
||||
action: agentActivity.action,
|
||||
entityType: agentActivity.entityType,
|
||||
entityId: agentActivity.entityId,
|
||||
details: agentActivity.details,
|
||||
success: agentActivity.success,
|
||||
errorMessage: agentActivity.errorMessage,
|
||||
createdAt: agentActivity.createdAt,
|
||||
})
|
||||
.from(agentActivity)
|
||||
.innerJoin(agents, eq(agentActivity.agentId, agents.id))
|
||||
.where(and(eq(agents.domainId, domainId), ...filterConditions))
|
||||
.orderBy(desc(agentActivity.createdAt))
|
||||
.limit(100);
|
||||
.limit(limit);
|
||||
return c.json({ items, totalItems: items.length });
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
@@ -131,6 +179,7 @@ agentRoutes.get("/:id", async (c) => {
|
||||
}
|
||||
const [agent] = await db.select().from(agents).where(eq(agents.id, id)).limit(1);
|
||||
if (!agent) return c.json({ error: { code: "NOT_FOUND", message: "Agent not found" } }, 404);
|
||||
await requireWorkspaceAccess(c, agent.domainId);
|
||||
return c.json(agent);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
@@ -150,6 +199,8 @@ agentRoutes.patch("/:id", async (c) => {
|
||||
const [existing] = await db.select().from(agents).where(eq(agents.id, id)).limit(1);
|
||||
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Agent 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;
|
||||
@@ -185,6 +236,8 @@ agentRoutes.delete("/:id", async (c) => {
|
||||
const [existing] = await db.select().from(agents).where(eq(agents.id, id)).limit(1);
|
||||
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Agent not found" } }, 404);
|
||||
|
||||
await requireWorkspaceAccess(c, existing.domainId);
|
||||
|
||||
await db.delete(agents).where(eq(agents.id, id));
|
||||
|
||||
await recordActivity({
|
||||
@@ -211,6 +264,10 @@ agentRoutes.post("/:id/permissions", async (c) => {
|
||||
customPermissions: z.array(z.string()).optional().default([]),
|
||||
}).parse(body);
|
||||
|
||||
const [existing] = await db.select().from(agents).where(eq(agents.id, id)).limit(1);
|
||||
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Agent not found" } }, 404);
|
||||
await requireWorkspaceAccess(c, existing.domainId);
|
||||
|
||||
const [updated] = await db.update(agents)
|
||||
.set({ permissionTier, customPermissions: customPermissions ?? [], updatedAt: new Date() })
|
||||
.where(eq(agents.id, id))
|
||||
@@ -236,11 +293,12 @@ agentRoutes.get("/:id/permissions", async (c) => {
|
||||
await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
const [agent] = await db.select({
|
||||
id: agents.id, permissionTier: agents.permissionTier, customPermissions: agents.customPermissions,
|
||||
id: agents.id, permissionTier: agents.permissionTier, customPermissions: agents.customPermissions, domainId: agents.domainId,
|
||||
}).from(agents).where(eq(agents.id, id)).limit(1);
|
||||
|
||||
if (!agent) return c.json({ error: { code: "NOT_FOUND", message: "Agent not found" } }, 404);
|
||||
return c.json(agent);
|
||||
await requireWorkspaceAccess(c, agent.domainId);
|
||||
return c.json({ id: agent.id, permissionTier: agent.permissionTier, customPermissions: agent.customPermissions });
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
console.error("[agents] GET /:id/permissions error:", error);
|
||||
@@ -251,13 +309,47 @@ agentRoutes.get("/:id/permissions", async (c) => {
|
||||
// GET /api/agents/:id/activity — Agent activity log (or all if id=_all)
|
||||
agentRoutes.get("/:id/activity", async (c) => {
|
||||
try {
|
||||
await requireAuth(c);
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
|
||||
if (id === "_all") {
|
||||
// agent_activity has no domain_id — scope through the owning agent
|
||||
const domainId = c.req.query("domain") || (await resolveActiveDomain(user)).id;
|
||||
await requireWorkspaceAccess(c, domainId);
|
||||
const { conditions: filterConditions, limit } = parseActivityFilters(c);
|
||||
const items = await db.select({
|
||||
id: agentActivity.id,
|
||||
agentId: agentActivity.agentId,
|
||||
action: agentActivity.action,
|
||||
entityType: agentActivity.entityType,
|
||||
entityId: agentActivity.entityId,
|
||||
details: agentActivity.details,
|
||||
success: agentActivity.success,
|
||||
errorMessage: agentActivity.errorMessage,
|
||||
createdAt: agentActivity.createdAt,
|
||||
})
|
||||
.from(agentActivity)
|
||||
.innerJoin(agents, eq(agentActivity.agentId, agents.id))
|
||||
.where(and(eq(agents.domainId, domainId), ...filterConditions))
|
||||
.orderBy(desc(agentActivity.createdAt))
|
||||
.limit(limit);
|
||||
return c.json({ items, totalItems: items.length });
|
||||
}
|
||||
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Agent not found" } }, 404);
|
||||
}
|
||||
|
||||
const [agent] = await db.select().from(agents).where(eq(agents.id, id)).limit(1);
|
||||
if (!agent) return c.json({ error: { code: "NOT_FOUND", message: "Agent not found" } }, 404);
|
||||
await requireWorkspaceAccess(c, agent.domainId);
|
||||
|
||||
const { conditions: filterConditions, limit } = parseActivityFilters(c);
|
||||
const items = await db.select()
|
||||
.from(agentActivity)
|
||||
.where(id === "_all" ? undefined : eq(agentActivity.agentId, id))
|
||||
.where(and(eq(agentActivity.agentId, id), ...filterConditions))
|
||||
.orderBy(desc(agentActivity.createdAt))
|
||||
.limit(100);
|
||||
.limit(limit);
|
||||
return c.json({ items, totalItems: items.length });
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, tasks, habits, habitCompletions } from "@project-e/db";
|
||||
import { and, eq, gte, isNull } from "drizzle-orm";
|
||||
import { db, tasks, habits, habitCompletions, projects } from "@project-e/db";
|
||||
import { and, eq, gte, inArray, isNull, or } from "drizzle-orm";
|
||||
import { requireAuth, resolveActiveDomain, AuthError } from "../middleware/auth";
|
||||
|
||||
export const analyticsRoutes = new Hono();
|
||||
@@ -65,9 +65,17 @@ analyticsRoutes.get("/habits", async (c) => {
|
||||
.from(habits)
|
||||
.where(and(eq(habits.domainId, domainId), isNull(habits.deletedAt)));
|
||||
|
||||
const allLogs = await db.select()
|
||||
.from(habitCompletions)
|
||||
.where(gte(habitCompletions.date, startDate));
|
||||
const habitIds = allHabits.map((h) => h.id);
|
||||
|
||||
// Only count completions belonging to habits in this domain (not all completions globally)
|
||||
const allLogs = habitIds.length > 0
|
||||
? await db.select()
|
||||
.from(habitCompletions)
|
||||
.where(and(
|
||||
inArray(habitCompletions.habitId, habitIds),
|
||||
gte(habitCompletions.date, startDate),
|
||||
))
|
||||
: [];
|
||||
|
||||
const habitConsistency = allHabits.length > 0
|
||||
? Math.round((allLogs.length / (allHabits.length * range)) * 100)
|
||||
@@ -93,7 +101,7 @@ analyticsRoutes.get("/habits", async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/analytics/projects?range=... — Project progress
|
||||
// GET /api/analytics/projects?range=... — Per-project progress
|
||||
analyticsRoutes.get("/projects", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
@@ -105,24 +113,48 @@ analyticsRoutes.get("/projects", async (c) => {
|
||||
domainId = active.id;
|
||||
}
|
||||
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - range);
|
||||
const allProjects = await db.select()
|
||||
.from(projects)
|
||||
.where(and(eq(projects.domainId, domainId), isNull(projects.deletedAt)));
|
||||
|
||||
const allTasks = await db.select()
|
||||
.from(tasks)
|
||||
.where(and(
|
||||
eq(tasks.domainId, domainId),
|
||||
gte(tasks.createdAt, startDate),
|
||||
isNull(tasks.deletedAt),
|
||||
));
|
||||
const projectIds = allProjects.map((p) => p.id);
|
||||
|
||||
const completedTasks = allTasks.filter(t => t.status === "done");
|
||||
const taskCompletionRate = allTasks.length > 0 ? Math.round((completedTasks.length / allTasks.length) * 100) : 0;
|
||||
// Count tasks per project (any status, including non-done) for the domain
|
||||
const taskRows = projectIds.length > 0
|
||||
? await db.select({ projectId: tasks.projectId, status: tasks.status })
|
||||
.from(tasks)
|
||||
.where(and(
|
||||
isNull(tasks.deletedAt),
|
||||
inArray(tasks.projectId, projectIds),
|
||||
))
|
||||
: [];
|
||||
|
||||
const counts = new Map<string, { totalTasks: number; completedTasks: number }>();
|
||||
for (const t of taskRows) {
|
||||
if (!t.projectId) continue;
|
||||
const entry = counts.get(t.projectId) ?? { totalTasks: 0, completedTasks: 0 };
|
||||
entry.totalTasks += 1;
|
||||
if (t.status === "done") entry.completedTasks += 1;
|
||||
counts.set(t.projectId, entry);
|
||||
}
|
||||
|
||||
const projectsData = allProjects.map((p) => {
|
||||
const stats = counts.get(p.id) ?? { totalTasks: 0, completedTasks: 0 };
|
||||
const progress = stats.totalTasks > 0
|
||||
? Math.round((stats.completedTasks / stats.totalTasks) * 100) / 100
|
||||
: 0;
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
totalTasks: stats.totalTasks,
|
||||
completedTasks: stats.completedTasks,
|
||||
progress,
|
||||
};
|
||||
});
|
||||
|
||||
return c.json({
|
||||
taskCompletionRate,
|
||||
totalTasks: allTasks.length,
|
||||
completedTasks: completedTasks.length,
|
||||
projects: projectsData,
|
||||
totalProjects: allProjects.length,
|
||||
period: range,
|
||||
}, {
|
||||
headers: { "Cache-Control": "private, max-age=300, stale-while-revalidate=600" },
|
||||
@@ -133,3 +165,76 @@ analyticsRoutes.get("/projects", async (c) => {
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get project analytics" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/analytics/daily?range=... — Daily task creation & completion time series
|
||||
analyticsRoutes.get("/daily", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const url = new URL(c.req.url);
|
||||
const range = parseInt(url.searchParams.get("range") || "30");
|
||||
let domainId = url.searchParams.get("domain") || undefined;
|
||||
if (!domainId) {
|
||||
const active = await resolveActiveDomain(user);
|
||||
domainId = active.id;
|
||||
}
|
||||
|
||||
// Buckets cover the last `range` days ending today, matching the frontend's expectation.
|
||||
const firstDay = new Date();
|
||||
firstDay.setDate(firstDay.getDate() - (range - 1));
|
||||
firstDay.setHours(0, 0, 0, 0);
|
||||
|
||||
const domainTasks = await db.select()
|
||||
.from(tasks)
|
||||
.where(and(
|
||||
eq(tasks.domainId, domainId),
|
||||
isNull(tasks.deletedAt),
|
||||
or(
|
||||
gte(tasks.createdAt, firstDay),
|
||||
gte(tasks.completedAt, firstDay),
|
||||
),
|
||||
));
|
||||
|
||||
// Bucket by local calendar date (yyyy-MM-dd) so keys line up with the frontend's
|
||||
// date-fns day generation (which uses local time as well).
|
||||
const localDateKey = (d: Date) => {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
};
|
||||
|
||||
const createdByDay = new Map<string, number>();
|
||||
const completedByDay = new Map<string, number>();
|
||||
for (const t of domainTasks) {
|
||||
const createdKey = localDateKey(t.createdAt);
|
||||
createdByDay.set(createdKey, (createdByDay.get(createdKey) || 0) + 1);
|
||||
if (t.status === "done" && t.completedAt) {
|
||||
const completedKey = localDateKey(t.completedAt);
|
||||
completedByDay.set(completedKey, (completedByDay.get(completedKey) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const items: Array<{ date: string; created: number; completed: number }> = [];
|
||||
const cursor = new Date(firstDay);
|
||||
for (let i = 0; i < range; i++) {
|
||||
const key = localDateKey(cursor);
|
||||
items.push({
|
||||
date: key,
|
||||
created: createdByDay.get(key) || 0,
|
||||
completed: completedByDay.get(key) || 0,
|
||||
});
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
items,
|
||||
period: range,
|
||||
}, {
|
||||
headers: { "Cache-Control": "private, max-age=300, stale-while-revalidate=600" },
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
console.error("[analytics] GET /daily error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get daily analytics" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
+13
-74
@@ -1,12 +1,17 @@
|
||||
import { Hono } from "hono";
|
||||
import { setCookie } from "hono/cookie";
|
||||
import { deleteCookie, setCookie } from "hono/cookie";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { db, users } from "@project-e/db";
|
||||
import { count, eq } from "drizzle-orm";
|
||||
import { createToken, requireAuth, createErrorResponse, AuthError } from "../middleware/auth";
|
||||
import { createToken } from "../middleware/auth";
|
||||
|
||||
export const authRoutes = new Hono();
|
||||
|
||||
// NOTE: Passkeys are intentionally NOT implemented. The legacy passkey routes were
|
||||
// removed because they issued a session without verifying the WebAuthn signature
|
||||
// (an authentication bypass). Do not re-add passkey endpoints without full
|
||||
// WebAuthn challenge/attestation verification.
|
||||
|
||||
// POST /api/auth/credentials — Login with email + password
|
||||
authRoutes.post("/credentials", async (c) => {
|
||||
try {
|
||||
@@ -67,6 +72,12 @@ authRoutes.get("/session", async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/logout — Clear the session cookie
|
||||
authRoutes.post("/logout", (c) => {
|
||||
deleteCookie(c, "session", { path: "/" });
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// GET /api/auth/me — Return current user profile
|
||||
authRoutes.get("/me", async (c) => {
|
||||
try {
|
||||
@@ -79,75 +90,3 @@ authRoutes.get("/me", async (c) => {
|
||||
return c.json({ error: { code: "AUTH_ERROR", message: "Invalid or expired token" } }, 401);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/passkey/register — Register a passkey
|
||||
authRoutes.post("/passkey/register", async (c) => {
|
||||
try {
|
||||
const user = c.get("user");
|
||||
if (!user) {
|
||||
return c.json({ error: { code: "UNAUTHORIZED", message: "Not authenticated" } }, 401);
|
||||
}
|
||||
|
||||
const body = await c.req.json();
|
||||
const { credentialId, publicKey, counter } = body;
|
||||
|
||||
if (!credentialId || !publicKey) {
|
||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "credentialId and publicKey are required" } }, 400);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
passkeyCredentialId: credentialId,
|
||||
passkeyPublicKey: publicKey,
|
||||
passkeyCounter: counter ?? 0,
|
||||
})
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
return c.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("[passkey/register] error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to register passkey" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/passkey/authenticate — Verify passkey login
|
||||
authRoutes.post("/passkey/authenticate", async (c) => {
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const { credentialId, signature, authenticatorData, clientDataJSON } = body;
|
||||
|
||||
if (!credentialId || !signature) {
|
||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "credentialId and signature are required" } }, 400);
|
||||
}
|
||||
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.passkeyCredentialId, credentialId))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: { code: "UNAUTHORIZED", message: "Passkey not found" } }, 401);
|
||||
}
|
||||
const token = await createToken({ id: user.id, email: user.email, name: user.name });
|
||||
setCookie(c, "session", token, {
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: "Lax",
|
||||
path: "/",
|
||||
maxAge: 30 * 24 * 60 * 60,
|
||||
});
|
||||
|
||||
return c.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[passkey/authenticate] error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to verify passkey" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, calendarEvents } from "@project-e/db";
|
||||
import { and, asc, desc, eq, gte, lte, isNull } from "drizzle-orm";
|
||||
import { requireAuth, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth";
|
||||
import { requireAuth, createErrorResponse, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -46,6 +46,7 @@ calendarRoutes.get("/events", async (c) => {
|
||||
const active = await resolveActiveDomain(user);
|
||||
domainId = active.id;
|
||||
}
|
||||
await requireWorkspaceAccess(c, domainId);
|
||||
|
||||
const conditions: any[] = [eq(calendarEvents.domainId, domainId)];
|
||||
if (from) conditions.push(gte(calendarEvents.startTime, new Date(from)));
|
||||
@@ -76,6 +77,8 @@ calendarRoutes.post("/events", async (c) => {
|
||||
domain: body.domain || (await resolveActiveDomain(user)).id,
|
||||
});
|
||||
|
||||
await requireWorkspaceAccess(c, data.domain);
|
||||
|
||||
const [event] = await db.insert(calendarEvents).values({
|
||||
title: data.title,
|
||||
description: data.description ?? null,
|
||||
@@ -129,6 +132,8 @@ calendarRoutes.patch("/events/:id", async (c) => {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Event not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, existing.domainId);
|
||||
|
||||
const updateValues: Record<string, unknown> = {};
|
||||
if (data.title !== undefined) updateValues.title = data.title;
|
||||
if (data.description !== undefined) updateValues.description = data.description;
|
||||
@@ -184,6 +189,8 @@ calendarRoutes.delete("/events/:id", async (c) => {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Event not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, existing.domainId);
|
||||
|
||||
await db.delete(calendarEvents).where(eq(calendarEvents.id, id));
|
||||
|
||||
await recordActivity({
|
||||
@@ -216,6 +223,7 @@ calendarRoutes.get("/upcoming", async (c) => {
|
||||
const active = await resolveActiveDomain(user);
|
||||
domainId = active.id;
|
||||
}
|
||||
await requireWorkspaceAccess(c, domainId);
|
||||
|
||||
const now = new Date();
|
||||
const end = new Date();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, canvases, canvasCards, canvasConnections } from "@project-e/db";
|
||||
import { and, asc, desc, eq, sql } from "drizzle-orm";
|
||||
import { requireAuth, resolveActiveDomain, AuthError } from "../middleware/auth";
|
||||
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -28,6 +28,39 @@ const updateCanvasSchema = z.object({
|
||||
customFields: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const createCardSchema = z.object({
|
||||
type: z.string().min(1).default("note"),
|
||||
content: z.string().optional().nullable().default(""),
|
||||
title: z.string().optional().nullable(),
|
||||
x: z.number().int().optional(),
|
||||
y: z.number().int().optional(),
|
||||
width: z.number().int().optional(),
|
||||
height: z.number().int().optional(),
|
||||
rotation: z.number().int().optional(),
|
||||
color: z.string().optional().nullable(),
|
||||
zIndex: z.number().int().optional(),
|
||||
});
|
||||
|
||||
const updateCardSchema = createCardSchema.partial();
|
||||
|
||||
const bulkSaveCardsSchema = z.object({
|
||||
cards: z.array(
|
||||
z.object({
|
||||
id: z.string().uuid().optional(),
|
||||
type: z.string().min(1).default("note"),
|
||||
content: z.string().optional().nullable().default(""),
|
||||
title: z.string().optional().nullable(),
|
||||
x: z.number().int().optional(),
|
||||
y: z.number().int().optional(),
|
||||
width: z.number().int().optional(),
|
||||
height: z.number().int().optional(),
|
||||
rotation: z.number().int().optional(),
|
||||
color: z.string().optional().nullable(),
|
||||
zIndex: z.number().int().optional(),
|
||||
})
|
||||
).default([]),
|
||||
});
|
||||
|
||||
// GET /api/canvas — List canvases
|
||||
canvasRoutes.get("/", async (c) => {
|
||||
try {
|
||||
@@ -41,6 +74,7 @@ canvasRoutes.get("/", async (c) => {
|
||||
const active = await resolveActiveDomain(user);
|
||||
domainId = active.id;
|
||||
}
|
||||
await requireWorkspaceAccess(c, domainId);
|
||||
|
||||
const conditions: any[] = [eq(canvases.domainId, domainId)];
|
||||
const sortField = sort.replace(/^-/, "");
|
||||
@@ -71,6 +105,8 @@ canvasRoutes.post("/", async (c) => {
|
||||
domain: body.domain || (await resolveActiveDomain(user)).id,
|
||||
});
|
||||
|
||||
await requireWorkspaceAccess(c, data.domain);
|
||||
|
||||
const [canvas] = await db.insert(canvases).values({
|
||||
name: data.name,
|
||||
description: data.description ?? null,
|
||||
@@ -104,6 +140,8 @@ canvasRoutes.get("/:id", async (c) => {
|
||||
const [canvas] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1);
|
||||
if (!canvas) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404);
|
||||
|
||||
await requireWorkspaceAccess(c, canvas.domainId);
|
||||
|
||||
const [cards, connections] = await Promise.all([
|
||||
db.select().from(canvasCards).where(eq(canvasCards.canvasId, id)).orderBy(asc(canvasCards.zIndex)),
|
||||
db.select().from(canvasConnections).where(eq(canvasConnections.canvasId, id)),
|
||||
@@ -128,6 +166,8 @@ canvasRoutes.patch("/:id", async (c) => {
|
||||
const [existing] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1);
|
||||
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Canvas 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;
|
||||
@@ -162,6 +202,8 @@ canvasRoutes.delete("/:id", async (c) => {
|
||||
const [existing] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1);
|
||||
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404);
|
||||
|
||||
await requireWorkspaceAccess(c, existing.domainId);
|
||||
|
||||
await db.delete(canvases).where(eq(canvases.id, id));
|
||||
|
||||
await recordActivity({
|
||||
@@ -176,3 +218,175 @@ canvasRoutes.delete("/:id", async (c) => {
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete canvas" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/canvas/:id/cards — Create one card (new block)
|
||||
canvasRoutes.post("/:id/cards", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
const body = await c.req.json();
|
||||
const data = createCardSchema.parse(body);
|
||||
|
||||
const [canvas] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1);
|
||||
if (!canvas) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404);
|
||||
|
||||
await requireWorkspaceAccess(c, canvas.domainId);
|
||||
|
||||
// New cards append to the end of the vertical document unless an explicit zIndex is given
|
||||
const [maxRow] = await db
|
||||
.select({ max: sql<number>`max(${canvasCards.zIndex})` })
|
||||
.from(canvasCards)
|
||||
.where(eq(canvasCards.canvasId, id));
|
||||
const zIndex = data.zIndex ?? Number(maxRow?.max ?? -1) + 1;
|
||||
|
||||
const [card] = await db.insert(canvasCards).values({
|
||||
canvasId: id,
|
||||
type: data.type,
|
||||
content: data.content ?? "",
|
||||
title: data.title ?? null,
|
||||
x: data.x ?? 0,
|
||||
y: data.y ?? 0,
|
||||
width: data.width ?? 200,
|
||||
height: data.height ?? 150,
|
||||
rotation: data.rotation ?? 0,
|
||||
color: data.color ?? null,
|
||||
zIndex,
|
||||
}).returning();
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name, action: "created", entityType: "canvas_card", entityId: card.id,
|
||||
changes: { type: card.type, zIndex: card.zIndex }, workspaceId: canvas.domainId,
|
||||
});
|
||||
|
||||
return c.json(card, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
if (error instanceof z.ZodError) return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
|
||||
console.error("[canvas] POST /:id/cards error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create canvas card" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/canvas/:id/cards — Bulk replace all cards (primary save path)
|
||||
canvasRoutes.put("/:id/cards", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
const body = await c.req.json();
|
||||
const data = bulkSaveCardsSchema.parse(body);
|
||||
|
||||
const [canvas] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1);
|
||||
if (!canvas) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404);
|
||||
|
||||
await requireWorkspaceAccess(c, canvas.domainId);
|
||||
|
||||
const cards = await db.transaction(async (tx) => {
|
||||
await tx.delete(canvasCards).where(eq(canvasCards.canvasId, id));
|
||||
if (data.cards.length === 0) return [];
|
||||
return tx.insert(canvasCards).values(
|
||||
data.cards.map((card, i) => ({
|
||||
...(card.id ? { id: card.id } : {}),
|
||||
canvasId: id,
|
||||
type: card.type,
|
||||
content: card.content ?? "",
|
||||
title: card.title ?? null,
|
||||
x: card.x ?? 0,
|
||||
y: card.y ?? 0,
|
||||
width: card.width ?? 200,
|
||||
height: card.height ?? 150,
|
||||
rotation: card.rotation ?? 0,
|
||||
color: card.color ?? null,
|
||||
zIndex: card.zIndex ?? i,
|
||||
}))
|
||||
).returning();
|
||||
});
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name, action: "updated", entityType: "canvas", entityId: id,
|
||||
changes: { name: canvas.name, cardCount: cards.length }, workspaceId: canvas.domainId,
|
||||
});
|
||||
|
||||
return c.json({ ...canvas, cards });
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
if (error instanceof z.ZodError) return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
|
||||
console.error("[canvas] PUT /:id/cards error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to save canvas cards" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/canvas/cards/:cardId — Update one card
|
||||
canvasRoutes.patch("/cards/:cardId", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const cardId = c.req.param("cardId");
|
||||
const body = await c.req.json();
|
||||
const data = updateCardSchema.parse(body);
|
||||
|
||||
const [card] = await db.select().from(canvasCards).where(eq(canvasCards.id, cardId)).limit(1);
|
||||
if (!card) return c.json({ error: { code: "NOT_FOUND", message: "Canvas card not found" } }, 404);
|
||||
|
||||
// canvas_cards has no domain_id — resolve ownership through the parent canvas
|
||||
const [canvas] = await db.select().from(canvases).where(eq(canvases.id, card.canvasId)).limit(1);
|
||||
if (canvas) {
|
||||
await requireWorkspaceAccess(c, canvas.domainId);
|
||||
}
|
||||
|
||||
const updateValues: Record<string, unknown> = {};
|
||||
if (data.type !== undefined) updateValues.type = data.type;
|
||||
if (data.content !== undefined) updateValues.content = data.content;
|
||||
if (data.title !== undefined) updateValues.title = data.title;
|
||||
if (data.x !== undefined) updateValues.x = data.x;
|
||||
if (data.y !== undefined) updateValues.y = data.y;
|
||||
if (data.width !== undefined) updateValues.width = data.width;
|
||||
if (data.height !== undefined) updateValues.height = data.height;
|
||||
if (data.rotation !== undefined) updateValues.rotation = data.rotation;
|
||||
if (data.color !== undefined) updateValues.color = data.color;
|
||||
if (data.zIndex !== undefined) updateValues.zIndex = data.zIndex;
|
||||
updateValues.updatedAt = new Date();
|
||||
|
||||
const [updated] = await db.update(canvasCards).set(updateValues).where(eq(canvasCards.id, cardId)).returning();
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name, action: "updated", entityType: "canvas_card", entityId: cardId,
|
||||
changes: { ...data }, workspaceId: canvas?.domainId ?? "",
|
||||
});
|
||||
|
||||
return c.json(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
if (error instanceof z.ZodError) return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
|
||||
console.error("[canvas] PATCH /cards/:cardId error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update canvas card" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/canvas/cards/:cardId — Delete one card
|
||||
canvasRoutes.delete("/cards/:cardId", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const cardId = c.req.param("cardId");
|
||||
const [card] = await db.select().from(canvasCards).where(eq(canvasCards.id, cardId)).limit(1);
|
||||
if (!card) return c.json({ error: { code: "NOT_FOUND", message: "Canvas card not found" } }, 404);
|
||||
|
||||
// canvas_cards has no domain_id — resolve ownership through the parent canvas
|
||||
const [canvas] = await db.select().from(canvases).where(eq(canvases.id, card.canvasId)).limit(1);
|
||||
if (canvas) {
|
||||
await requireWorkspaceAccess(c, canvas.domainId);
|
||||
}
|
||||
|
||||
// Hard delete — canvas_cards has no deleted_at column
|
||||
await db.delete(canvasCards).where(eq(canvasCards.id, cardId));
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name, action: "deleted", entityType: "canvas_card", entityId: cardId,
|
||||
changes: { type: card.type }, workspaceId: canvas?.domainId ?? "",
|
||||
});
|
||||
|
||||
return c.body(null, 204);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
console.error("[canvas] DELETE /cards/:cardId error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete canvas card" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, customFields } from "@project-e/db";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { requireAuth, resolveActiveDomain, AuthError } from "../middleware/auth";
|
||||
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -37,6 +37,7 @@ customFieldRoutes.get("/", async (c) => {
|
||||
const active = await resolveActiveDomain(user);
|
||||
domainId = active.id;
|
||||
}
|
||||
await requireWorkspaceAccess(c, domainId);
|
||||
|
||||
const conditions: any[] = [eq(customFields.domainId, domainId)];
|
||||
if (entityType) {
|
||||
@@ -66,6 +67,8 @@ customFieldRoutes.post("/", async (c) => {
|
||||
domain: body.domain || (await resolveActiveDomain(user)).id,
|
||||
});
|
||||
|
||||
await requireWorkspaceAccess(c, data.domain);
|
||||
|
||||
const [field] = await db.insert(customFields).values({
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
@@ -102,6 +105,8 @@ customFieldRoutes.patch("/:id", async (c) => {
|
||||
const [existing] = await db.select().from(customFields).where(eq(customFields.id, id)).limit(1);
|
||||
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Custom field not found" } }, 404);
|
||||
|
||||
await requireWorkspaceAccess(c, existing.domainId);
|
||||
|
||||
const updateValues: Record<string, unknown> = {};
|
||||
if (data.name !== undefined) updateValues.name = data.name;
|
||||
if (data.type !== undefined) updateValues.type = data.type;
|
||||
@@ -135,6 +140,8 @@ customFieldRoutes.delete("/:id", async (c) => {
|
||||
const [existing] = await db.select().from(customFields).where(eq(customFields.id, id)).limit(1);
|
||||
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Custom field not found" } }, 404);
|
||||
|
||||
await requireWorkspaceAccess(c, existing.domainId);
|
||||
|
||||
await db.delete(customFields).where(eq(customFields.id, id));
|
||||
|
||||
await recordActivity({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, dailyNotes } from "@project-e/db";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { requireAuth, resolveActiveDomain, AuthError } from "../middleware/auth";
|
||||
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -33,6 +33,7 @@ dailyNoteRoutes.get("/", async (c) => {
|
||||
const active = await resolveActiveDomain(user);
|
||||
domainId = active.id;
|
||||
}
|
||||
await requireWorkspaceAccess(c, domainId);
|
||||
|
||||
if (dateStr) {
|
||||
const startOfDay = new Date(dateStr + "T00:00:00.000Z");
|
||||
@@ -70,6 +71,8 @@ dailyNoteRoutes.post("/", async (c) => {
|
||||
domain: body.domain || (await resolveActiveDomain(user)).id,
|
||||
});
|
||||
|
||||
await requireWorkspaceAccess(c, data.domain);
|
||||
|
||||
const [note] = await db.insert(dailyNotes).values({
|
||||
date: new Date(data.date + "T00:00:00.000Z"),
|
||||
content: data.content ?? null,
|
||||
@@ -104,6 +107,8 @@ dailyNoteRoutes.patch("/:id", async (c) => {
|
||||
const [existing] = await db.select().from(dailyNotes).where(eq(dailyNotes.id, id)).limit(1);
|
||||
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Daily note not found" } }, 404);
|
||||
|
||||
await requireWorkspaceAccess(c, existing.domainId);
|
||||
|
||||
const updateValues: Record<string, unknown> = {};
|
||||
if (data.content !== undefined) updateValues.content = data.content;
|
||||
if (data.mood !== undefined) updateValues.mood = data.mood;
|
||||
@@ -126,3 +131,30 @@ dailyNoteRoutes.patch("/:id", async (c) => {
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update daily note" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/daily-notes/:id — Delete a note
|
||||
dailyNoteRoutes.delete("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
|
||||
const [existing] = await db.select().from(dailyNotes).where(eq(dailyNotes.id, id)).limit(1);
|
||||
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Daily note not found" } }, 404);
|
||||
|
||||
await requireWorkspaceAccess(c, existing.domainId);
|
||||
|
||||
// daily_notes has no deleted_at column, so this is a hard delete.
|
||||
await db.delete(dailyNotes).where(eq(dailyNotes.id, id));
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name, action: "deleted", entityType: "daily_note", entityId: id,
|
||||
changes: { date: existing.date.toISOString() }, workspaceId: existing.domainId,
|
||||
});
|
||||
|
||||
return c.body(null, 204);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
console.error("[daily-notes] DELETE error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete daily note" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, dashboardWidgets } from "@project-e/db";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { requireAuth, resolveActiveDomain, AuthError } from "../middleware/auth";
|
||||
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -70,6 +70,8 @@ dashboardRoutes.post("/widgets", async (c) => {
|
||||
domain: body.domain || (await resolveActiveDomain(user)).id,
|
||||
});
|
||||
|
||||
await requireWorkspaceAccess(c, data.domain!);
|
||||
|
||||
const [widget] = await db.insert(dashboardWidgets).values({
|
||||
userId: user.id,
|
||||
type: data.type,
|
||||
@@ -118,6 +120,8 @@ dashboardRoutes.patch("/widgets/:id", async (c) => {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Widget not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, existing.domainId);
|
||||
|
||||
const updateValues: Record<string, unknown> = {};
|
||||
if (data.type !== undefined) updateValues.type = data.type;
|
||||
if (data.title !== undefined) updateValues.title = data.title;
|
||||
@@ -167,6 +171,8 @@ dashboardRoutes.delete("/widgets/:id", async (c) => {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Widget not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, existing.domainId);
|
||||
|
||||
await db.delete(dashboardWidgets).where(eq(dashboardWidgets.id, id));
|
||||
|
||||
await recordActivity({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, domains, notes, noteLinks, noteEntityLinks, tasks, taskDependencies, habits, projects, sections, tags as tagsTable } from "@project-e/db";
|
||||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
import { requireAuth, AuthError } from "../middleware/auth";
|
||||
import { requireAuth, requireWorkspaceAccess, AuthError } from "../middleware/auth";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -80,6 +80,20 @@ async function getGraphData(domainId: string): Promise<{ nodes: GraphNode[]; edg
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
// Resolve the owning domain for a graph edge source. `type` may be an edge type
|
||||
// (note_link / note_entity / task_dependency) or a source entity type (note / task).
|
||||
async function resolveEdgeWorkspaceId(sourceId: string, type: string): Promise<string | null> {
|
||||
if (type === "note_link" || type === "note_entity" || type === "note") {
|
||||
const [row] = await db.select({ domainId: notes.domainId }).from(notes).where(eq(notes.id, sourceId)).limit(1);
|
||||
return row?.domainId ?? null;
|
||||
}
|
||||
if (type === "task_dependency" || type === "task") {
|
||||
const [row] = await db.select({ domainId: tasks.domainId }).from(tasks).where(eq(tasks.id, sourceId)).limit(1);
|
||||
return row?.domainId ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// GET /api/graph/nodes — All nodes
|
||||
graphRoutes.get("/nodes", async (c) => {
|
||||
try {
|
||||
@@ -89,6 +103,7 @@ graphRoutes.get("/nodes", async (c) => {
|
||||
if (!domainId) {
|
||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "domain parameter is required" } }, 400);
|
||||
}
|
||||
await requireWorkspaceAccess(c, domainId);
|
||||
const data = await getGraphData(domainId);
|
||||
return c.json({ items: data.nodes, totalItems: data.nodes.length });
|
||||
} catch (error) {
|
||||
@@ -109,6 +124,7 @@ graphRoutes.get("/edges", async (c) => {
|
||||
if (!domainId) {
|
||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "domain parameter is required" } }, 400);
|
||||
}
|
||||
await requireWorkspaceAccess(c, domainId);
|
||||
const data = await getGraphData(domainId);
|
||||
return c.json({ items: data.edges, totalItems: data.edges.length });
|
||||
} catch (error) {
|
||||
@@ -131,6 +147,18 @@ graphRoutes.post("/edges", async (c) => {
|
||||
type: z.string().default("note_link"),
|
||||
}).parse(body);
|
||||
|
||||
// Verify ownership before mutating anything. Both endpoints of the edge
|
||||
// must belong to the caller's domain.
|
||||
const workspaceId = await resolveEdgeWorkspaceId(sourceId, type);
|
||||
if (workspaceId) {
|
||||
await requireWorkspaceAccess(c, workspaceId);
|
||||
}
|
||||
const targetType = type === "note_link" ? "note" : (type === "note_entity" || type === "task_dependency") ? "task" : type;
|
||||
const targetWorkspaceId = await resolveEdgeWorkspaceId(targetId, targetType);
|
||||
if (targetWorkspaceId) {
|
||||
await requireWorkspaceAccess(c, targetWorkspaceId);
|
||||
}
|
||||
|
||||
if (type === "note_link") {
|
||||
await db.insert(noteLinks).values({ sourceNoteId: sourceId, targetNoteId: targetId });
|
||||
} else if (type === "note_entity") {
|
||||
@@ -141,14 +169,18 @@ graphRoutes.post("/edges", async (c) => {
|
||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "Unknown edge type: " + type } }, 400);
|
||||
}
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "created",
|
||||
entityType: "graph_edge",
|
||||
entityId: sourceId + "-" + targetId,
|
||||
changes: { type, sourceId, targetId },
|
||||
workspaceId: "",
|
||||
});
|
||||
if (!workspaceId) {
|
||||
console.warn(`[graph] POST /edges: could not resolve workspace for source ${sourceId} (type ${type}); skipping activity`);
|
||||
} else {
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "created",
|
||||
entityType: "graph_edge",
|
||||
entityId: sourceId + "-" + targetId,
|
||||
changes: { type, sourceId, targetId },
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ success: true }, 201);
|
||||
} catch (error) {
|
||||
@@ -170,6 +202,16 @@ graphRoutes.delete("/edges/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const [sourceId, targetId] = id.split("-");
|
||||
|
||||
// The type isn't known at delete time, so resolve from the source entity:
|
||||
// it's either a note or a task. Verify ownership before mutating anything.
|
||||
let workspaceId = await resolveEdgeWorkspaceId(sourceId, "note");
|
||||
if (!workspaceId) {
|
||||
workspaceId = await resolveEdgeWorkspaceId(sourceId, "task");
|
||||
}
|
||||
if (workspaceId) {
|
||||
await requireWorkspaceAccess(c, workspaceId);
|
||||
}
|
||||
|
||||
// Try deleting from note_links first
|
||||
const result = await db.delete(noteLinks)
|
||||
.where(and(eq(noteLinks.sourceNoteId, sourceId), eq(noteLinks.targetNoteId, targetId)))
|
||||
@@ -181,14 +223,18 @@ graphRoutes.delete("/edges/:id", async (c) => {
|
||||
.where(and(eq(taskDependencies.taskId, sourceId), eq(taskDependencies.dependsOnTaskId, targetId)));
|
||||
}
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "deleted",
|
||||
entityType: "graph_edge",
|
||||
entityId: id,
|
||||
changes: {},
|
||||
workspaceId: "",
|
||||
});
|
||||
if (!workspaceId) {
|
||||
console.warn(`[graph] DELETE /edges/${id}: could not resolve workspace for source ${sourceId}; skipping activity`);
|
||||
} else {
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "deleted",
|
||||
entityType: "graph_edge",
|
||||
entityId: id,
|
||||
changes: {},
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
return c.body(null, 204);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, habits, habitCompletions, habitTags, tags as tagsTable } from "@project-e/db";
|
||||
import { and, asc, desc, eq, gte, ilike, inArray, isNull, lte, sql } from "drizzle-orm";
|
||||
import { requireAuth, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth";
|
||||
import { and, asc, desc, eq, exists, gte, ilike, inArray, isNull, lte, sql } from "drizzle-orm";
|
||||
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 habitRoutes = new Hono();
|
||||
@@ -96,6 +97,7 @@ habitRoutes.get("/", async (c) => {
|
||||
const active = url.searchParams.get("active");
|
||||
const frequency = url.searchParams.get("frequency");
|
||||
const difficulty = url.searchParams.get("difficulty");
|
||||
const tag = url.searchParams.get("tag");
|
||||
const search = url.searchParams.get("search");
|
||||
const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 200);
|
||||
const offset = parseInt(url.searchParams.get("offset") || "0");
|
||||
@@ -107,6 +109,8 @@ habitRoutes.get("/", async (c) => {
|
||||
domainId = active.id;
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, domainId);
|
||||
|
||||
const conditions: any[] = [
|
||||
eq(habits.domainId, domainId),
|
||||
isNull(habits.deletedAt),
|
||||
@@ -118,6 +122,20 @@ habitRoutes.get("/", async (c) => {
|
||||
if (difficulty) conditions.push(eq(habits.difficulty, difficulty as any));
|
||||
if (search) conditions.push(ilike(habits.name, `%${search}%`));
|
||||
if (filter) conditions.push(ilike(habits.name, `%${filter}%`));
|
||||
// Tag filter applied in SQL (EXISTS on the junction table) so it runs over
|
||||
// the full dataset before pagination.
|
||||
if (tag) {
|
||||
const tagIds = tag.split(",").map((t) => t.trim()).filter(Boolean);
|
||||
if (tagIds.length > 0) {
|
||||
conditions.push(
|
||||
exists(
|
||||
db.select({ one: sql`1` })
|
||||
.from(habitTags)
|
||||
.where(and(eq(habitTags.habitId, habits.id), inArray(habitTags.tagId, tagIds)))
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const sortDir = sort.startsWith("-") ? "desc" : "asc";
|
||||
const sortField = sort.replace(/^-/, "");
|
||||
@@ -202,6 +220,8 @@ habitRoutes.post("/", async (c) => {
|
||||
domain: body.domain || (await resolveActiveDomain(user)).id,
|
||||
});
|
||||
|
||||
await requireWorkspaceAccess(c, data.domain);
|
||||
|
||||
const [habit] = await db.insert(habits).values({
|
||||
name: data.name,
|
||||
description: data.description ?? null,
|
||||
@@ -231,6 +251,8 @@ habitRoutes.post("/", async (c) => {
|
||||
workspaceId: data.domain,
|
||||
});
|
||||
|
||||
await enqueueWebhooks({ workspaceId: data.domain, event: "habit.created", entityType: "habit", entityId: habit.id, data: { name: habit.name } });
|
||||
|
||||
return c.json(habit, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
@@ -259,6 +281,8 @@ habitRoutes.get("/:id", async (c) => {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, habit.domainId);
|
||||
|
||||
// Fetch recent completions (last 30 days)
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
@@ -312,6 +336,8 @@ habitRoutes.patch("/:id", async (c) => {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Habit 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;
|
||||
@@ -339,6 +365,8 @@ habitRoutes.patch("/:id", async (c) => {
|
||||
workspaceId: existing.domainId,
|
||||
});
|
||||
|
||||
await enqueueWebhooks({ workspaceId: existing.domainId, event: "habit.updated", entityType: "habit", entityId: id, data: { ...data, previousName: existing.name } });
|
||||
|
||||
return c.json(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
@@ -367,6 +395,8 @@ habitRoutes.delete("/:id", async (c) => {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, existing.domainId);
|
||||
|
||||
await db.update(habits)
|
||||
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(habits.id, id));
|
||||
@@ -380,6 +410,8 @@ habitRoutes.delete("/:id", async (c) => {
|
||||
workspaceId: existing.domainId,
|
||||
});
|
||||
|
||||
await enqueueWebhooks({ workspaceId: existing.domainId, event: "habit.deleted", entityType: "habit", entityId: id, data: { name: existing.name } });
|
||||
|
||||
return c.body(null, 204);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
@@ -390,6 +422,96 @@ habitRoutes.delete("/:id", async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/habits/:id/tags — Assign a tag to a habit
|
||||
habitRoutes.post("/:id/tags", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
const body = await c.req.json();
|
||||
const { tagId } = z.object({ tagId: z.string().uuid("Invalid tag id") }).parse(body);
|
||||
|
||||
const [habit] = await db.select({ id: habits.id, domainId: habits.domainId })
|
||||
.from(habits)
|
||||
.where(and(eq(habits.id, id), isNull(habits.deletedAt)))
|
||||
.limit(1);
|
||||
if (!habit) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, habit.domainId);
|
||||
|
||||
const [tag] = await db.select({ id: tagsTable.id, name: tagsTable.name })
|
||||
.from(tagsTable)
|
||||
.where(eq(tagsTable.id, tagId))
|
||||
.limit(1);
|
||||
if (!tag) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Tag not found" } }, 404);
|
||||
}
|
||||
|
||||
// Junction table has a composite PK — ignore re-assigns instead of erroring
|
||||
await db.insert(habitTags).values({ habitId: id, tagId }).onConflictDoNothing();
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "tagged",
|
||||
entityType: "habit",
|
||||
entityId: id,
|
||||
changes: { tagId, tagName: tag.name },
|
||||
workspaceId: habit.domainId,
|
||||
});
|
||||
|
||||
return c.json({ success: true }, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
|
||||
}
|
||||
console.error("[habits] POST /:id/tags error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to assign tag" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/habits/:id/tags/:tagId — Remove a tag from a habit
|
||||
habitRoutes.delete("/:id/tags/:tagId", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
const tagId = c.req.param("tagId");
|
||||
|
||||
const [habit] = await db.select({ id: habits.id, domainId: habits.domainId })
|
||||
.from(habits)
|
||||
.where(and(eq(habits.id, id), isNull(habits.deletedAt)))
|
||||
.limit(1);
|
||||
if (!habit) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, habit.domainId);
|
||||
|
||||
// Junction tables have no deleted_at — hard delete is correct here
|
||||
await db.delete(habitTags).where(and(eq(habitTags.habitId, id), eq(habitTags.tagId, tagId)));
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "untagged",
|
||||
entityType: "habit",
|
||||
entityId: id,
|
||||
changes: { tagId },
|
||||
workspaceId: habit.domainId,
|
||||
});
|
||||
|
||||
return c.body(null, 204);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
}
|
||||
console.error("[habits] DELETE /:id/tags/:tagId error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to remove tag" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/habits/:id/complete — Complete a habit for today
|
||||
habitRoutes.post("/:id/complete", async (c) => {
|
||||
try {
|
||||
@@ -407,6 +529,8 @@ habitRoutes.post("/:id/complete", async (c) => {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, habit.domainId);
|
||||
|
||||
const [completion] = await db.insert(habitCompletions).values({
|
||||
habitId: id,
|
||||
date: new Date(),
|
||||
@@ -465,7 +589,7 @@ habitRoutes.get("/:id/completions", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const url = new URL(c.req.url);
|
||||
|
||||
const [habit] = await db.select({ id: habits.id })
|
||||
const [habit] = await db.select({ id: habits.id, domainId: habits.domainId })
|
||||
.from(habits)
|
||||
.where(and(eq(habits.id, id), isNull(habits.deletedAt)))
|
||||
.limit(1);
|
||||
@@ -474,6 +598,8 @@ habitRoutes.get("/:id/completions", async (c) => {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, habit.domainId);
|
||||
|
||||
const from = url.searchParams.get("from");
|
||||
const to = url.searchParams.get("to");
|
||||
const limit = Math.min(parseInt(url.searchParams.get("limit") || "365"), 1000);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, tasks, habits, projects, notes, tags as tagsTable, agents, webhooks } from "@project-e/db";
|
||||
import { eq, isNull } from "drizzle-orm";
|
||||
import { requireAuth, createErrorResponse, AuthError } from "../middleware/auth";
|
||||
import { db, tasks, habits, projects, notes, tags as tagsTable, agents, webhooks, taskTags, habitTags, projectTags, noteTags } from "@project-e/db";
|
||||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
import { requireAuth, requireWorkspaceAccess, resolveActiveDomain, createErrorResponse, AuthError } from "../middleware/auth";
|
||||
import { z } from "zod";
|
||||
|
||||
export const importExportRoutes = new Hono();
|
||||
@@ -22,6 +22,11 @@ importExportRoutes.post("/import", async (c) => {
|
||||
return c.json({ error: { code: "INVALID_DATA", message: "Missing version field" } }, 400);
|
||||
}
|
||||
|
||||
// Every imported entity is forced into a single target domain that the
|
||||
// current user owns. Payload-supplied domain ids are never trusted.
|
||||
const targetDomain = body.domain || body.domain_id || (await resolveActiveDomain(user)).id;
|
||||
await requireWorkspaceAccess(c, targetDomain);
|
||||
|
||||
const results: Array<{ collection: string; imported: number; failed: number; errors: string[] }> = [];
|
||||
let totalImported = 0;
|
||||
let totalFailed = 0;
|
||||
@@ -38,25 +43,25 @@ importExportRoutes.post("/import", async (c) => {
|
||||
// Map to the right table
|
||||
switch (collection) {
|
||||
case 'tasks':
|
||||
await db.insert(tasks).values({ ...data, domainId: data.domain_id || data.domainId });
|
||||
await db.insert(tasks).values({ ...data, domainId: targetDomain });
|
||||
break;
|
||||
case 'habits':
|
||||
await db.insert(habits).values({ ...data, domainId: data.domain_id || data.domainId });
|
||||
await db.insert(habits).values({ ...data, domainId: targetDomain });
|
||||
break;
|
||||
case 'projects':
|
||||
await db.insert(projects).values({ ...data, domainId: data.domain_id || data.domainId });
|
||||
await db.insert(projects).values({ ...data, domainId: targetDomain });
|
||||
break;
|
||||
case 'notes':
|
||||
await db.insert(notes).values({ ...data, domainId: data.domain_id || data.domainId });
|
||||
await db.insert(notes).values({ ...data, domainId: targetDomain });
|
||||
break;
|
||||
case 'tags':
|
||||
await db.insert(tagsTable).values(data);
|
||||
break;
|
||||
case 'agents':
|
||||
await db.insert(agents).values({ ...data, domainId: data.domain_id || data.domainId });
|
||||
await db.insert(agents).values({ ...data, domainId: targetDomain });
|
||||
break;
|
||||
case 'webhooks':
|
||||
await db.insert(webhooks).values({ ...data, workspaceId: data.workspace_id || data.workspaceId || data.domain_id || data.domainId });
|
||||
await db.insert(webhooks).values({ ...data, workspaceId: targetDomain });
|
||||
break;
|
||||
}
|
||||
result.imported++;
|
||||
@@ -101,9 +106,15 @@ importExportRoutes.get("/export", async (c) => {
|
||||
importExportRoutes.post("/export", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
let body: { collections?: string[] } = {};
|
||||
let body: { collections?: string[]; domain?: string } = {};
|
||||
try { body = await c.req.json(); } catch { /* empty body is fine */ }
|
||||
|
||||
// Scope the entire export to one domain owned by the current user.
|
||||
// An optional `domain` in the body can override the active domain, but it
|
||||
// must still pass the ownership check.
|
||||
const domainId = body.domain || (await resolveActiveDomain(user)).id;
|
||||
await requireWorkspaceAccess(c, domainId);
|
||||
|
||||
const requestedCollections = body.collections && body.collections.length > 0
|
||||
? body.collections.filter(c => COLLECTIONS.includes(c as typeof COLLECTIONS[number]))
|
||||
: [...COLLECTIONS];
|
||||
@@ -117,13 +128,33 @@ importExportRoutes.post("/export", async (c) => {
|
||||
try {
|
||||
let items: any[] = [];
|
||||
switch (collection) {
|
||||
case 'tasks': items = await db.select().from(tasks).where(isNull(tasks.deletedAt)); break;
|
||||
case 'habits': items = await db.select().from(habits).where(isNull(habits.deletedAt)); break;
|
||||
case 'projects': items = await db.select().from(projects).where(isNull(projects.deletedAt)); break;
|
||||
case 'notes': items = await db.select().from(notes).where(isNull(notes.deletedAt)); break;
|
||||
case 'tags': items = await db.select().from(tagsTable); break;
|
||||
case 'agents': items = await db.select().from(agents); break;
|
||||
case 'webhooks': items = await db.select().from(webhooks); break;
|
||||
case 'tasks': items = await db.select().from(tasks).where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt))); break;
|
||||
case 'habits': items = await db.select().from(habits).where(and(eq(habits.domainId, domainId), isNull(habits.deletedAt))); break;
|
||||
case 'projects': items = await db.select().from(projects).where(and(eq(projects.domainId, domainId), isNull(projects.deletedAt))); break;
|
||||
case 'notes': items = await db.select().from(notes).where(and(eq(notes.domainId, domainId), isNull(notes.deletedAt))); break;
|
||||
case 'tags': {
|
||||
// Tags are global (no domain_id); export only tags actually used by
|
||||
// this domain's entities via the four junction tables.
|
||||
const [taskTagIds, habitTagIds, projectTagIds, noteTagIds] = await Promise.all([
|
||||
db.select({ tagId: taskTags.tagId }).from(taskTags)
|
||||
.innerJoin(tasks, eq(taskTags.taskId, tasks.id))
|
||||
.where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt))),
|
||||
db.select({ tagId: habitTags.tagId }).from(habitTags)
|
||||
.innerJoin(habits, eq(habitTags.habitId, habits.id))
|
||||
.where(and(eq(habits.domainId, domainId), isNull(habits.deletedAt))),
|
||||
db.select({ tagId: projectTags.tagId }).from(projectTags)
|
||||
.innerJoin(projects, eq(projectTags.projectId, projects.id))
|
||||
.where(and(eq(projects.domainId, domainId), isNull(projects.deletedAt))),
|
||||
db.select({ tagId: noteTags.tagId }).from(noteTags)
|
||||
.innerJoin(notes, eq(noteTags.noteId, notes.id))
|
||||
.where(and(eq(notes.domainId, domainId), isNull(notes.deletedAt))),
|
||||
]);
|
||||
const tagIds = [...new Set([...taskTagIds, ...habitTagIds, ...projectTagIds, ...noteTagIds].map(r => r.tagId))];
|
||||
items = tagIds.length > 0 ? await db.select().from(tagsTable).where(inArray(tagsTable.id, tagIds)) : [];
|
||||
break;
|
||||
}
|
||||
case 'agents': items = await db.select().from(agents).where(eq(agents.domainId, domainId)); break;
|
||||
case 'webhooks': items = await db.select().from(webhooks).where(eq(webhooks.workspaceId, domainId)); break;
|
||||
}
|
||||
exportData[collection] = items;
|
||||
} catch (error) {
|
||||
|
||||
+67
-14
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { createHash } from "node:crypto";
|
||||
import { db, apiKeys, users, tasks, taskTags, tags as tagsTable, habits, habitCompletions, projects, notes, noteLinks, domains, activityFeed, webhooks, webhookDeliveries } from "@project-e/db";
|
||||
import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, ilike, isNull, or } from "drizzle-orm";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
|
||||
export const mcpRoutes = new Hono();
|
||||
@@ -484,7 +484,7 @@ const tools: ToolDefinition[] = [
|
||||
.where(and(
|
||||
eq(notes.domainId, params.domain_id as string),
|
||||
isNull(notes.deletedAt),
|
||||
or(ilike(notes.title, `%${query}%`), ilike(notes.content ?? sql``, `%${query}%`))
|
||||
or(ilike(notes.title, `%${query}%`), ilike(notes.content, `%${query}%`))
|
||||
))
|
||||
.orderBy(desc(notes.updatedAt))
|
||||
.limit(20);
|
||||
@@ -493,10 +493,15 @@ const tools: ToolDefinition[] = [
|
||||
},
|
||||
{
|
||||
name: "domains.list",
|
||||
description: "List domains/workspaces",
|
||||
description: "List the caller's domains/workspaces",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
handler: async () => {
|
||||
const items = await db.select().from(domains).orderBy(asc(domains.name));
|
||||
handler: async (_params, auth) => {
|
||||
// Only the caller's own domains (plus legacy ownerless rows) — never every
|
||||
// domain in the database.
|
||||
const items = await db.select()
|
||||
.from(domains)
|
||||
.where(or(eq(domains.ownerId, auth.userId), isNull(domains.ownerId)))
|
||||
.orderBy(asc(domains.name));
|
||||
return { items };
|
||||
},
|
||||
},
|
||||
@@ -517,6 +522,7 @@ const tools: ToolDefinition[] = [
|
||||
name: params.name as string,
|
||||
slug: params.slug as string,
|
||||
color: (params.color as string) ?? null,
|
||||
ownerId: auth.userId,
|
||||
}).returning();
|
||||
|
||||
await recordActivity({
|
||||
@@ -603,14 +609,48 @@ class JsonRpcErrorResponse extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function makeError(code: number, message: string, data?: unknown): JsonRpcResponse {
|
||||
return { jsonrpc: "2.0", error: { code, message, data }, id: null };
|
||||
function makeError(code: number, message: string, data?: unknown, id: string | number | null = null): JsonRpcResponse {
|
||||
return { jsonrpc: "2.0", error: { code, message, data }, id };
|
||||
}
|
||||
|
||||
function makeResult(result: unknown, id: string | number | null): JsonRpcResponse {
|
||||
return { jsonrpc: "2.0", result, id };
|
||||
}
|
||||
|
||||
// Validate that every field listed in the tool's inputSchema `required` array is
|
||||
// present. Prevents silent empty-result queries (e.g. a missing domain_id) from
|
||||
// reaching the database.
|
||||
function validateParams(tool: ToolDefinition, args: Record<string, unknown>): string | null {
|
||||
const schema = tool.inputSchema as { required?: string[] } | undefined;
|
||||
for (const field of schema?.required || []) {
|
||||
const value = args[field];
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return `Missing required parameter: ${field}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Domain/workspace ownership check for tools that receive a domain_id or
|
||||
// workspace_id. Mirrors requireWorkspaceAccess but works without a Hono context:
|
||||
// legacy domains with a NULL ownerId pass through the existence check only.
|
||||
async function verifyDomainAccess(domainId: string, userId: string): Promise<void> {
|
||||
if (!domainId || domainId.trim() === "") {
|
||||
throw new JsonRpcErrorResponse(JSONRPC_INVALID_PARAMS, "domain_id is required");
|
||||
}
|
||||
const [domain] = await db
|
||||
.select({ id: domains.id, ownerId: domains.ownerId })
|
||||
.from(domains)
|
||||
.where(eq(domains.id, domainId))
|
||||
.limit(1);
|
||||
if (!domain) {
|
||||
throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, `Domain not found: ${domainId}`);
|
||||
}
|
||||
if (domain.ownerId !== null && domain.ownerId !== userId) {
|
||||
throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, `No access to domain: ${domainId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRequest(body: JsonRpcRequest, auth: { userId: string; userName: string }): Promise<JsonRpcResponse> {
|
||||
const { method, params, id } = body;
|
||||
|
||||
@@ -644,23 +684,36 @@ async function handleRequest(body: JsonRpcRequest, auth: { userId: string; userN
|
||||
if (method === "tools/call") {
|
||||
const callParams = params as { name?: string; arguments?: Record<string, unknown> } | undefined;
|
||||
if (!callParams?.name) {
|
||||
return makeError(JSONRPC_INVALID_PARAMS, "Missing tool name", id);
|
||||
return makeError(JSONRPC_INVALID_PARAMS, "Missing tool name", undefined, id);
|
||||
}
|
||||
|
||||
const tool = tools.find(t => t.name === callParams.name);
|
||||
if (!tool) {
|
||||
return makeError(JSONRPC_METHOD_NOT_FOUND, `Unknown tool: ${callParams.name}`, id);
|
||||
return makeError(JSONRPC_METHOD_NOT_FOUND, `Unknown tool: ${callParams.name}`, undefined, id);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await tool.handler(callParams.arguments || {}, auth);
|
||||
const args = callParams.arguments || {};
|
||||
|
||||
const missing = validateParams(tool, args);
|
||||
if (missing) {
|
||||
return makeError(JSONRPC_INVALID_PARAMS, missing, undefined, id);
|
||||
}
|
||||
|
||||
// Scope to the caller's domains when a domain/workspace is passed.
|
||||
const domainId = (args.domain_id as string | undefined) ?? (args.workspace_id as string | undefined);
|
||||
if (domainId) {
|
||||
await verifyDomainAccess(domainId, auth.userId);
|
||||
}
|
||||
|
||||
const result = await tool.handler(args, auth);
|
||||
return makeResult({ content: [{ type: "text", text: JSON.stringify(result) }] }, id);
|
||||
} catch (error) {
|
||||
if (error instanceof JsonRpcErrorResponse) {
|
||||
return makeError(error.code, error.message, error.data);
|
||||
return makeError(error.code, error.message, error.data, id);
|
||||
}
|
||||
console.error(`[MCP] Tool ${callParams.name} error:`, error);
|
||||
return makeError(JSONRPC_INTERNAL_ERROR, error instanceof Error ? error.message : "Internal error", id);
|
||||
return makeError(JSONRPC_INTERNAL_ERROR, error instanceof Error ? error.message : "Internal error", undefined, id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -700,7 +753,7 @@ async function handleRequest(body: JsonRpcRequest, auth: { userId: string; userN
|
||||
if (method === "resources/read") {
|
||||
const readParams = params as { uri?: string } | undefined;
|
||||
if (!readParams?.uri) {
|
||||
return makeError(JSONRPC_INVALID_PARAMS, "Missing resource URI", id);
|
||||
return makeError(JSONRPC_INVALID_PARAMS, "Missing resource URI", undefined, id);
|
||||
}
|
||||
return makeResult({
|
||||
contents: [
|
||||
@@ -727,7 +780,7 @@ async function handleRequest(body: JsonRpcRequest, auth: { userId: string; userN
|
||||
}, id);
|
||||
}
|
||||
|
||||
return makeError(JSONRPC_METHOD_NOT_FOUND, `Unknown method: ${method}`, id);
|
||||
return makeError(JSONRPC_METHOD_NOT_FOUND, `Unknown method: ${method}`, undefined, id);
|
||||
}
|
||||
|
||||
// ── Route handler ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, notes, noteTags, 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 { and, asc, desc, eq, exists, ilike, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
import { enqueueWebhooks } from "../middleware/webhook-queue";
|
||||
import { syncNoteLinks, getBacklinks, getOutgoingLinks } from "./note-link-service";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -35,6 +36,7 @@ noteRoutes.get("/", async (c) => {
|
||||
const sort = url.searchParams.get("sort") || "-updated_at";
|
||||
const pinned = url.searchParams.get("pinned");
|
||||
const archived = url.searchParams.get("archived");
|
||||
const tag = url.searchParams.get("tag");
|
||||
const search = url.searchParams.get("search");
|
||||
const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 200);
|
||||
const offset = parseInt(url.searchParams.get("offset") || "0");
|
||||
@@ -46,6 +48,8 @@ noteRoutes.get("/", async (c) => {
|
||||
domainId = active.id;
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, domainId);
|
||||
|
||||
const conditions: any[] = [
|
||||
eq(notes.domainId, domainId),
|
||||
isNull(notes.deletedAt),
|
||||
@@ -56,6 +60,20 @@ noteRoutes.get("/", async (c) => {
|
||||
else if (archived !== "all") conditions.push(eq(notes.isArchived, false));
|
||||
if (search) conditions.push(ilike(notes.title, `%${search}%`));
|
||||
if (filter) conditions.push(ilike(notes.title, `%${filter}%`));
|
||||
// Tag filter applied in SQL (EXISTS on the junction table) so it runs over
|
||||
// the full dataset before pagination.
|
||||
if (tag) {
|
||||
const tagIds = tag.split(",").map((t) => t.trim()).filter(Boolean);
|
||||
if (tagIds.length > 0) {
|
||||
conditions.push(
|
||||
exists(
|
||||
db.select({ one: sql`1` })
|
||||
.from(noteTags)
|
||||
.where(and(eq(noteTags.noteId, notes.id), inArray(noteTags.tagId, tagIds)))
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const sortDir = sort.startsWith("-") ? "desc" : "asc";
|
||||
const sortField = sort.replace(/^-/, "");
|
||||
@@ -136,6 +154,8 @@ noteRoutes.post("/", async (c) => {
|
||||
domain: body.domain || (await resolveActiveDomain(user)).id,
|
||||
});
|
||||
|
||||
await requireWorkspaceAccess(c, data.domain);
|
||||
|
||||
const [note] = await db.insert(notes).values({
|
||||
title: data.title,
|
||||
content: data.content ?? null,
|
||||
@@ -164,6 +184,8 @@ noteRoutes.post("/", async (c) => {
|
||||
workspaceId: data.domain,
|
||||
});
|
||||
|
||||
await enqueueWebhooks({ workspaceId: data.domain, event: "note.created", entityType: "note", entityId: note.id, data: { title: note.title } });
|
||||
|
||||
return c.json(note, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
@@ -192,6 +214,8 @@ noteRoutes.get("/:id", async (c) => {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, note.domainId);
|
||||
|
||||
// Fetch tags
|
||||
const tagRows = await db.select({
|
||||
id: tagsTable.id,
|
||||
@@ -240,6 +264,8 @@ noteRoutes.patch("/:id", async (c) => {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, existing.domainId);
|
||||
|
||||
const updateValues: Record<string, unknown> = {};
|
||||
if (data.title !== undefined) updateValues.title = data.title;
|
||||
if (data.content !== undefined) updateValues.content = data.content;
|
||||
@@ -267,6 +293,8 @@ noteRoutes.patch("/:id", async (c) => {
|
||||
workspaceId: existing.domainId,
|
||||
});
|
||||
|
||||
await enqueueWebhooks({ workspaceId: existing.domainId, event: "note.updated", entityType: "note", entityId: id, data: { ...data, previousTitle: existing.title } });
|
||||
|
||||
return c.json(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
@@ -295,6 +323,8 @@ noteRoutes.delete("/:id", async (c) => {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, existing.domainId);
|
||||
|
||||
await db.update(notes)
|
||||
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(notes.id, id));
|
||||
@@ -308,6 +338,8 @@ noteRoutes.delete("/:id", async (c) => {
|
||||
workspaceId: existing.domainId,
|
||||
});
|
||||
|
||||
await enqueueWebhooks({ workspaceId: existing.domainId, event: "note.deleted", entityType: "note", entityId: id, data: { title: existing.title } });
|
||||
|
||||
return c.body(null, 204);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
@@ -318,12 +350,113 @@ noteRoutes.delete("/:id", async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/notes/:id/tags — Assign a tag to a note
|
||||
noteRoutes.post("/:id/tags", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
const body = await c.req.json();
|
||||
const { tagId } = z.object({ tagId: z.string().uuid("Invalid tag id") }).parse(body);
|
||||
|
||||
const [note] = await db.select({ id: notes.id, domainId: notes.domainId })
|
||||
.from(notes)
|
||||
.where(and(eq(notes.id, id), isNull(notes.deletedAt)))
|
||||
.limit(1);
|
||||
if (!note) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, note.domainId);
|
||||
|
||||
const [tag] = await db.select({ id: tagsTable.id, name: tagsTable.name })
|
||||
.from(tagsTable)
|
||||
.where(eq(tagsTable.id, tagId))
|
||||
.limit(1);
|
||||
if (!tag) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Tag not found" } }, 404);
|
||||
}
|
||||
|
||||
// Junction table has a composite PK — ignore re-assigns instead of erroring
|
||||
await db.insert(noteTags).values({ noteId: id, tagId }).onConflictDoNothing();
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "tagged",
|
||||
entityType: "note",
|
||||
entityId: id,
|
||||
changes: { tagId, tagName: tag.name },
|
||||
workspaceId: note.domainId,
|
||||
});
|
||||
|
||||
return c.json({ success: true }, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
|
||||
}
|
||||
console.error("[notes] POST /:id/tags error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to assign tag" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/notes/:id/tags/:tagId — Remove a tag from a note
|
||||
noteRoutes.delete("/:id/tags/:tagId", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
const tagId = c.req.param("tagId");
|
||||
|
||||
const [note] = await db.select({ id: notes.id, domainId: notes.domainId })
|
||||
.from(notes)
|
||||
.where(and(eq(notes.id, id), isNull(notes.deletedAt)))
|
||||
.limit(1);
|
||||
if (!note) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, note.domainId);
|
||||
|
||||
// Junction tables have no deleted_at — hard delete is correct here
|
||||
await db.delete(noteTags).where(and(eq(noteTags.noteId, id), eq(noteTags.tagId, tagId)));
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "untagged",
|
||||
entityType: "note",
|
||||
entityId: id,
|
||||
changes: { tagId },
|
||||
workspaceId: note.domainId,
|
||||
});
|
||||
|
||||
return c.body(null, 204);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
}
|
||||
console.error("[notes] DELETE /:id/tags/:tagId error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to remove tag" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/notes/:id/backlinks — Notes that link TO this one
|
||||
noteRoutes.get("/:id/backlinks", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
|
||||
const [note] = await db.select({ id: notes.id, domainId: notes.domainId })
|
||||
.from(notes)
|
||||
.where(and(eq(notes.id, id), isNull(notes.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!note) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, note.domainId);
|
||||
|
||||
const backlinks = await getBacklinks(id);
|
||||
|
||||
return c.json({
|
||||
@@ -345,11 +478,23 @@ noteRoutes.get("/:id/versions", async (c) => {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
|
||||
const [note] = await db.select({ id: notes.id, domainId: notes.domainId })
|
||||
.from(notes)
|
||||
.where(and(eq(notes.id, id), isNull(notes.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!note) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, note.domainId);
|
||||
|
||||
const versions = await db.select()
|
||||
.from(activityFeed)
|
||||
.where(and(
|
||||
eq(activityFeed.entityId, id),
|
||||
eq(activityFeed.entityType, "note"),
|
||||
eq(activityFeed.workspaceId, note.domainId),
|
||||
))
|
||||
.orderBy(desc(activityFeed.createdAt))
|
||||
.limit(100);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, activityFeed } from "@project-e/db";
|
||||
import { and, desc, eq, gte, ne, sql } from "drizzle-orm";
|
||||
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
|
||||
|
||||
export const notificationRoutes = new Hono();
|
||||
|
||||
// The bell in the topbar shows a badge for activity_feed events from the last
|
||||
// 7 days. There is no read/unread state yet, so "count" doubles as the unread
|
||||
// badge. graph_edge rows are workspace-internal graph plumbing, not user-facing
|
||||
// activity, so they are excluded from both the count and the feed.
|
||||
const NOTIFICATION_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// GET /api/notifications?workspace_id=&limit= — Recent activity for a workspace.
|
||||
notificationRoutes.get("/", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
let workspaceId = c.req.query("workspace_id");
|
||||
if (!workspaceId) {
|
||||
const active = await resolveActiveDomain(user);
|
||||
workspaceId = active.id;
|
||||
}
|
||||
await requireWorkspaceAccess(c, workspaceId);
|
||||
|
||||
const limit = Math.min(Math.max(parseInt(c.req.query("limit") || "20", 10) || 20, 1), 100);
|
||||
const since = new Date(Date.now() - NOTIFICATION_WINDOW_MS);
|
||||
const conditions = [
|
||||
eq(activityFeed.workspaceId, workspaceId),
|
||||
gte(activityFeed.createdAt, since),
|
||||
ne(activityFeed.entityType, "graph_edge"),
|
||||
];
|
||||
|
||||
const [items, countResult] = await Promise.all([
|
||||
db.select().from(activityFeed).where(and(...conditions)).orderBy(desc(activityFeed.createdAt)).limit(limit),
|
||||
db.select({ count: sql<number>`count(*)` }).from(activityFeed).where(and(...conditions)),
|
||||
]);
|
||||
|
||||
return c.json({ items, count: Number(countResult[0]?.count || 0) });
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
console.error("[notifications] GET error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get notifications" } }, 500);
|
||||
}
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import postgres from "postgres";
|
||||
import { requireWorkspaceAccess, AuthError } from "../middleware/auth";
|
||||
|
||||
export const realtimeRoutes = new Hono();
|
||||
|
||||
@@ -13,6 +14,21 @@ realtimeRoutes.get("/realtime", async (c) => {
|
||||
const url = new URL(c.req.url);
|
||||
const workspaceId = url.searchParams.get("workspace_id");
|
||||
|
||||
// IDOR guard: if a workspace is requested, verify the current user actually
|
||||
// owns it before subscribing to the event stream. Without this check any
|
||||
// authenticated user could tail another workspace's events.
|
||||
if (workspaceId) {
|
||||
try {
|
||||
await requireWorkspaceAccess(c, workspaceId);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
}
|
||||
console.error("[realtime] workspace validation error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to validate workspace" } }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const listener = postgres(process.env.DATABASE_URL!, { max: 1 });
|
||||
let unlisten: (() => Promise<void>) | undefined;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, sql } from "@project-e/db";
|
||||
import { requireAuth, AuthError } from "../middleware/auth";
|
||||
import { requireAuth, resolveActiveDomain, AuthError } from "../middleware/auth";
|
||||
|
||||
export const searchRoutes = new Hono();
|
||||
|
||||
@@ -31,6 +31,11 @@ searchRoutes.get("/", async (c) => {
|
||||
return c.json({ results: [], totalCount: 0 });
|
||||
}
|
||||
|
||||
// Scope all searches to the user's active domain so users can never see
|
||||
// another workspace's data.
|
||||
const userDomain = await resolveActiveDomain(user);
|
||||
const userDomainId = userDomain.id;
|
||||
|
||||
const results: Array<{ id: string; type: string; title: string; snippet: string; score: number; workspaceId: string; link: string }> = [];
|
||||
|
||||
for (const type of types) {
|
||||
@@ -44,6 +49,17 @@ searchRoutes.get("/", async (c) => {
|
||||
conditions.push(deletedColumn + " IS NULL");
|
||||
}
|
||||
|
||||
// Restrict results to the user's active domain. For `domain` the
|
||||
// workspace column is the table's own `id`; for all other entities it
|
||||
// is `domain_id`. userDomainId is a trusted uuid from the DB, but we
|
||||
// escape single quotes defensively anyway.
|
||||
const domainValue = String(userDomainId).replace(/'/g, "''");
|
||||
if (type === 'domain') {
|
||||
conditions.push(workspaceColumn + " = '" + domainValue + "'");
|
||||
} else {
|
||||
conditions.push("domain_id = '" + domainValue + "'");
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(' AND ');
|
||||
const headlineColumn = contentColumn || titleColumn;
|
||||
|
||||
|
||||
+277
-17
@@ -1,9 +1,11 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, tasks, taskTags, tags as tagsTable, taskDependencies, activityFeed } from "@project-e/db";
|
||||
import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { requireAuth, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth";
|
||||
import { db, tasks, taskTags, tags as tagsTable, taskDependencies, activityFeed, scheduledJobs } from "@project-e/db";
|
||||
import { and, asc, desc, eq, exists, ilike, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
import { enqueueWebhooks } from "../middleware/webhook-queue";
|
||||
import { z } from "zod";
|
||||
import { RRule } from "rrule";
|
||||
|
||||
export const taskRoutes = new Hono();
|
||||
|
||||
@@ -42,6 +44,38 @@ const updateTaskSchema = z.object({
|
||||
recurrenceRule: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
// ── Recurring tasks ────────────────────────────────────────────────────────────────
|
||||
// Keeps scheduled_jobs in sync with a task's recurrence_rule so the worker's
|
||||
// recurring_spawn handler has work to do. A malformed rule must never fail the
|
||||
// create/update — fall back to +1 day and log.
|
||||
|
||||
function computeNextOccurrenceAt(recurrenceRule: string): Date {
|
||||
try {
|
||||
const rule = RRule.fromString(recurrenceRule);
|
||||
const next = rule.after(new Date());
|
||||
if (next) return next;
|
||||
} catch {
|
||||
// fall through to fallback
|
||||
}
|
||||
return new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
async function syncScheduledJob(taskId: string, recurrenceRule: string | null): Promise<void> {
|
||||
try {
|
||||
await db.delete(scheduledJobs).where(and(eq(scheduledJobs.entityType, "task"), eq(scheduledJobs.entityId, taskId)));
|
||||
if (recurrenceRule) {
|
||||
await db.insert(scheduledJobs).values({
|
||||
entityType: "task",
|
||||
entityId: taskId,
|
||||
recurrenceRule,
|
||||
nextOccurrenceAt: computeNextOccurrenceAt(recurrenceRule),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[tasks] Failed to sync scheduled job for task ${taskId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/tasks — List tasks with filtering, sorting, pagination
|
||||
taskRoutes.get("/", async (c) => {
|
||||
try {
|
||||
@@ -68,6 +102,8 @@ taskRoutes.get("/", async (c) => {
|
||||
domainId = active.id;
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, domainId);
|
||||
|
||||
// Build conditions
|
||||
const conditions: any[] = [
|
||||
eq(tasks.domainId, domainId),
|
||||
@@ -104,6 +140,21 @@ taskRoutes.get("/", async (c) => {
|
||||
if (sectionId) {
|
||||
conditions.push(eq(tasks.sectionId, sectionId));
|
||||
}
|
||||
// Tag filter applied in SQL (EXISTS on the junction table) so it runs over
|
||||
// the full dataset before pagination — filtering in-memory after fetching a
|
||||
// page would miss tasks beyond the limit and report a wrong totalItems.
|
||||
if (tag) {
|
||||
const tagIds = tag.split(",").map((t) => t.trim()).filter(Boolean);
|
||||
if (tagIds.length > 0) {
|
||||
conditions.push(
|
||||
exists(
|
||||
db.select({ one: sql`1` })
|
||||
.from(taskTags)
|
||||
.where(and(eq(taskTags.taskId, tasks.id), inArray(taskTags.tagId, tagIds)))
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build order
|
||||
const orderFn = order === "desc" ? desc : asc;
|
||||
@@ -138,21 +189,10 @@ taskRoutes.get("/", async (c) => {
|
||||
|
||||
const totalItems = Number(countResult[0]?.count || 0);
|
||||
|
||||
// If tag filter is specified, filter in-memory
|
||||
let filteredItems = items;
|
||||
if (tag) {
|
||||
const tagIds = tag.split(",");
|
||||
const taskTagRows = await db.select({ taskId: taskTags.taskId })
|
||||
.from(taskTags)
|
||||
.where(inArray(taskTags.tagId, tagIds));
|
||||
const matchingTaskIds = new Set(taskTagRows.map(r => r.taskId));
|
||||
filteredItems = items.filter(t => matchingTaskIds.has(t.id));
|
||||
}
|
||||
|
||||
// Fetch tags for all tasks
|
||||
let taskTagMap = new Map<string, { id: string; name: string; color: string | null }[]>();
|
||||
if (filteredItems.length > 0) {
|
||||
const taskIds = filteredItems.map(t => t.id);
|
||||
if (items.length > 0) {
|
||||
const taskIds = items.map(t => t.id);
|
||||
const tagRows = await db.select({
|
||||
taskId: taskTags.taskId,
|
||||
id: tagsTable.id,
|
||||
@@ -169,7 +209,7 @@ taskRoutes.get("/", async (c) => {
|
||||
}
|
||||
}
|
||||
|
||||
const itemsWithTags = filteredItems.map(t => ({
|
||||
const itemsWithTags = items.map(t => ({
|
||||
...t,
|
||||
tags: taskTagMap.get(t.id) || [],
|
||||
}));
|
||||
@@ -202,6 +242,8 @@ taskRoutes.post("/", async (c) => {
|
||||
domain: body.domain || (await resolveActiveDomain(user)).id,
|
||||
});
|
||||
|
||||
await requireWorkspaceAccess(c, data.domain);
|
||||
|
||||
// Cycle detection for parentId (subtask)
|
||||
if (data.parentId) {
|
||||
const [parent] = await db.select({ id: tasks.id })
|
||||
@@ -244,6 +286,12 @@ taskRoutes.post("/", async (c) => {
|
||||
workspaceId: data.domain,
|
||||
});
|
||||
|
||||
await enqueueWebhooks({ workspaceId: data.domain, event: "task.created", entityType: "task", entityId: task.id, data: { title: task.title } });
|
||||
|
||||
if (data.recurrenceRule) {
|
||||
await syncScheduledJob(task.id, data.recurrenceRule);
|
||||
}
|
||||
|
||||
return c.json(task, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
@@ -257,6 +305,71 @@ taskRoutes.post("/", async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
const reorderTasksSchema = z.object({
|
||||
orderedIds: z.array(z.string().uuid()).min(1, "orderedIds is required"),
|
||||
domain: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
// POST /api/tasks/reorder — Persist Kanban board column ordering
|
||||
taskRoutes.post("/reorder", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const body = await c.req.json();
|
||||
const { orderedIds, domain } = reorderTasksSchema.parse(body);
|
||||
|
||||
// Resolve the workspace: explicit domain, or the first task's domain
|
||||
let workspaceId = domain;
|
||||
if (!workspaceId) {
|
||||
const [firstTask] = await db.select({ domainId: tasks.domainId })
|
||||
.from(tasks)
|
||||
.where(eq(tasks.id, orderedIds[0]))
|
||||
.limit(1);
|
||||
if (!firstTask) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
|
||||
}
|
||||
workspaceId = firstTask.domainId;
|
||||
}
|
||||
await requireWorkspaceAccess(c, workspaceId);
|
||||
|
||||
// Verify every task exists in this workspace and is not soft-deleted
|
||||
const existing = await db.select({ id: tasks.id })
|
||||
.from(tasks)
|
||||
.where(and(inArray(tasks.id, orderedIds), eq(tasks.domainId, workspaceId), isNull(tasks.deletedAt)));
|
||||
if (existing.length !== orderedIds.length) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "One or more tasks not found" } }, 404);
|
||||
}
|
||||
|
||||
// Update each task's order to its index in a transaction
|
||||
await db.transaction(async (tx) => {
|
||||
for (let i = 0; i < orderedIds.length; i++) {
|
||||
await tx.update(tasks)
|
||||
.set({ order: i, updatedAt: new Date() })
|
||||
.where(eq(tasks.id, orderedIds[i]));
|
||||
}
|
||||
});
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "reordered",
|
||||
entityType: "task",
|
||||
entityId: orderedIds[0],
|
||||
changes: { orderedIds },
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return c.json({ success: true, orderedIds });
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
|
||||
}
|
||||
console.error("[tasks] POST /reorder error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to reorder tasks" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/tasks/:id — Get a single task with subtasks + dependencies
|
||||
taskRoutes.get("/:id", async (c) => {
|
||||
try {
|
||||
@@ -272,6 +385,8 @@ taskRoutes.get("/:id", async (c) => {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, task.domainId);
|
||||
|
||||
// Fetch subtasks
|
||||
const subtasks = await db.select()
|
||||
.from(tasks)
|
||||
@@ -341,6 +456,8 @@ taskRoutes.patch("/:id", async (c) => {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, existing.domainId);
|
||||
|
||||
// Cycle detection for parentId
|
||||
if (data.parentId && data.parentId === id) {
|
||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "A task cannot be its own parent" } }, 400);
|
||||
@@ -390,6 +507,12 @@ taskRoutes.patch("/:id", async (c) => {
|
||||
workspaceId: existing.domainId,
|
||||
});
|
||||
|
||||
await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.updated", entityType: "task", entityId: id, data: { ...data, previousStatus: existing.status } });
|
||||
|
||||
if (data.recurrenceRule !== undefined) {
|
||||
await syncScheduledJob(id, data.recurrenceRule);
|
||||
}
|
||||
|
||||
return c.json(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
@@ -418,6 +541,8 @@ taskRoutes.delete("/:id", async (c) => {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, existing.domainId);
|
||||
|
||||
await db.update(tasks)
|
||||
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(tasks.id, id));
|
||||
@@ -431,6 +556,11 @@ taskRoutes.delete("/:id", async (c) => {
|
||||
workspaceId: existing.domainId,
|
||||
});
|
||||
|
||||
await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.deleted", entityType: "task", entityId: id, data: { title: existing.title } });
|
||||
|
||||
// Stop recurring spawns for a deleted task
|
||||
await syncScheduledJob(id, null);
|
||||
|
||||
return c.body(null, 204);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
@@ -441,6 +571,96 @@ taskRoutes.delete("/:id", async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/tasks/:id/tags — Assign a tag to a task
|
||||
taskRoutes.post("/:id/tags", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
const body = await c.req.json();
|
||||
const { tagId } = z.object({ tagId: z.string().uuid("Invalid tag id") }).parse(body);
|
||||
|
||||
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
|
||||
.limit(1);
|
||||
if (!task) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, task.domainId);
|
||||
|
||||
const [tag] = await db.select({ id: tagsTable.id, name: tagsTable.name })
|
||||
.from(tagsTable)
|
||||
.where(eq(tagsTable.id, tagId))
|
||||
.limit(1);
|
||||
if (!tag) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Tag not found" } }, 404);
|
||||
}
|
||||
|
||||
// Junction table has a composite PK — ignore re-assigns instead of erroring
|
||||
await db.insert(taskTags).values({ taskId: id, tagId }).onConflictDoNothing();
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "tagged",
|
||||
entityType: "task",
|
||||
entityId: id,
|
||||
changes: { tagId, tagName: tag.name },
|
||||
workspaceId: task.domainId,
|
||||
});
|
||||
|
||||
return c.json({ success: true }, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
|
||||
}
|
||||
console.error("[tasks] POST /:id/tags error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to assign tag" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/tasks/:id/tags/:tagId — Remove a tag from a task
|
||||
taskRoutes.delete("/:id/tags/:tagId", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
const tagId = c.req.param("tagId");
|
||||
|
||||
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
|
||||
.limit(1);
|
||||
if (!task) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, task.domainId);
|
||||
|
||||
// Junction tables have no deleted_at — hard delete is correct here
|
||||
await db.delete(taskTags).where(and(eq(taskTags.taskId, id), eq(taskTags.tagId, tagId)));
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "untagged",
|
||||
entityType: "task",
|
||||
entityId: id,
|
||||
changes: { tagId },
|
||||
workspaceId: task.domainId,
|
||||
});
|
||||
|
||||
return c.body(null, 204);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
}
|
||||
console.error("[tasks] DELETE /:id/tags/:tagId error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to remove tag" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/tasks/:id/status — Change task status (Kanban drag)
|
||||
taskRoutes.post("/:id/status", async (c) => {
|
||||
try {
|
||||
@@ -460,6 +680,8 @@ taskRoutes.post("/:id/status", async (c) => {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, existing.domainId);
|
||||
|
||||
const updateValues: Record<string, unknown> = {
|
||||
status: newStatus,
|
||||
updatedAt: new Date(),
|
||||
@@ -501,11 +723,23 @@ taskRoutes.get("/:id/history", async (c) => {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
|
||||
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!task) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, task.domainId);
|
||||
|
||||
const history = await db.select()
|
||||
.from(activityFeed)
|
||||
.where(and(
|
||||
eq(activityFeed.entityId, id),
|
||||
eq(activityFeed.entityType, "task"),
|
||||
eq(activityFeed.workspaceId, task.domainId),
|
||||
))
|
||||
.orderBy(desc(activityFeed.createdAt))
|
||||
.limit(100);
|
||||
@@ -526,11 +760,23 @@ taskRoutes.get("/:id/comments", async (c) => {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
|
||||
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!task) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, task.domainId);
|
||||
|
||||
const comments = await db.select()
|
||||
.from(activityFeed)
|
||||
.where(and(
|
||||
eq(activityFeed.entityId, id),
|
||||
eq(activityFeed.entityType, "comment"),
|
||||
eq(activityFeed.workspaceId, task.domainId),
|
||||
))
|
||||
.orderBy(asc(activityFeed.createdAt));
|
||||
|
||||
@@ -564,6 +810,8 @@ taskRoutes.post("/:id/comments", async (c) => {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, task.domainId);
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "commented",
|
||||
@@ -592,12 +840,24 @@ taskRoutes.get("/:id/attachments", async (c) => {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
|
||||
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!task) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, task.domainId);
|
||||
|
||||
// Attachments are stored in activity feed with entityType=attachment
|
||||
const attachments = await db.select()
|
||||
.from(activityFeed)
|
||||
.where(and(
|
||||
eq(activityFeed.entityId, id),
|
||||
eq(activityFeed.entityType, "attachment"),
|
||||
eq(activityFeed.workspaceId, task.domainId),
|
||||
))
|
||||
.orderBy(desc(activityFeed.createdAt));
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, webhooks, webhookDeliveries } from "@project-e/db";
|
||||
import { db, webhooks } from "@project-e/db";
|
||||
import { and, asc, desc, eq, sql } from "drizzle-orm";
|
||||
import { requireAuth, resolveActiveDomain, AuthError } from "../middleware/auth";
|
||||
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
import { enqueueWebhookDelivery } from "../middleware/webhook-queue";
|
||||
import { z } from "zod";
|
||||
|
||||
export const webhookRoutes = new Hono();
|
||||
@@ -43,6 +44,7 @@ webhookRoutes.get("/", async (c) => {
|
||||
const active = await resolveActiveDomain(user);
|
||||
domainId = active.id;
|
||||
}
|
||||
await requireWorkspaceAccess(c, domainId);
|
||||
|
||||
const conditions: any[] = [eq(webhooks.workspaceId, domainId)];
|
||||
const sortField = sort.replace(/^-/, "");
|
||||
@@ -73,6 +75,8 @@ webhookRoutes.post("/", async (c) => {
|
||||
domain: body.domain || (await resolveActiveDomain(user)).id,
|
||||
});
|
||||
|
||||
await requireWorkspaceAccess(c, data.domain);
|
||||
|
||||
const [webhook] = await db.insert(webhooks).values({
|
||||
name: data.name,
|
||||
url: data.url,
|
||||
@@ -107,6 +111,8 @@ webhookRoutes.patch("/:id", async (c) => {
|
||||
const [existing] = await db.select().from(webhooks).where(eq(webhooks.id, id)).limit(1);
|
||||
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Webhook not found" } }, 404);
|
||||
|
||||
await requireWorkspaceAccess(c, existing.workspaceId);
|
||||
|
||||
const updateValues: Record<string, unknown> = {};
|
||||
if (data.name !== undefined) updateValues.name = data.name;
|
||||
if (data.url !== undefined) updateValues.url = data.url;
|
||||
@@ -139,6 +145,8 @@ webhookRoutes.delete("/:id", async (c) => {
|
||||
const [existing] = await db.select().from(webhooks).where(eq(webhooks.id, id)).limit(1);
|
||||
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Webhook not found" } }, 404);
|
||||
|
||||
await requireWorkspaceAccess(c, existing.workspaceId);
|
||||
|
||||
await db.delete(webhooks).where(eq(webhooks.id, id));
|
||||
|
||||
await recordActivity({
|
||||
@@ -162,13 +170,17 @@ webhookRoutes.post("/:id/test", async (c) => {
|
||||
const [webhook] = await db.select().from(webhooks).where(eq(webhooks.id, id)).limit(1);
|
||||
if (!webhook) return c.json({ error: { code: "NOT_FOUND", message: "Webhook not found" } }, 404);
|
||||
|
||||
const testPayload = { event: "test", data: { message: "This is a test webhook from Project E", timestamp: new Date().toISOString() } };
|
||||
await requireWorkspaceAccess(c, webhook.workspaceId);
|
||||
|
||||
await db.insert(webhookDeliveries).values({
|
||||
// Enqueue a delivery job instead of inserting a delivery row directly — the
|
||||
// worker performs the delivery and records the webhook_deliveries row.
|
||||
await enqueueWebhookDelivery({
|
||||
webhookId: id,
|
||||
event: "test",
|
||||
payload: testPayload,
|
||||
status: "pending",
|
||||
entityType: "test",
|
||||
entityId: webhook.id,
|
||||
data: { message: "This is a test webhook from Project E", timestamp: new Date().toISOString() },
|
||||
workspaceId: webhook.workspaceId,
|
||||
});
|
||||
|
||||
return c.json({ success: true, message: "Test webhook queued" });
|
||||
|
||||
Reference in New Issue
Block a user