feat: add server error logging and tighten workspace isolation

This commit is contained in:
2026-08-10 12:41:46 +00:00
parent 6449f6b4cc
commit 1059512888
48 changed files with 1096 additions and 229 deletions
+20
View File
@@ -1,6 +1,7 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import { db, errorLogs } from "@project-e/db";
import { authMiddleware } from "./middleware/auth";
import { authRoutes } from "./routes/auth";
import { mcpRoutes } from "./routes/mcp";
@@ -63,6 +64,25 @@ app.route("/api", importExportRoutes);
app.route("/api", realtimeRoutes);
app.route("/api/mcp", mcpRoutes);
// Persist uncaught server errors so the Settings → Error Log tab shows real
// diagnostics instead of always being empty. Errors already caught by route
// handlers (which return 500 JSON themselves) still log to the console.
app.onError((err, c) => {
console.error("[api] uncaught error:", err);
try {
void db.insert(errorLogs).values({
level: "error",
source: c.req.path,
message: err instanceof Error ? err.message : String(err),
stackTrace: err instanceof Error ? err.stack ?? null : null,
metadata: { method: c.req.method },
});
} catch {
// Logging must never break the error response.
}
return c.json({ error: { code: "INTERNAL_ERROR", message: "Internal server error" } }, 500);
});
const port = parseInt(process.env.PORT || "3001", 10);
export default {
+11
View File
@@ -168,6 +168,17 @@ export class AuthError extends Error {
}
}
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* True when a value looks like a UUID. Route params that are entity ids should
* be validated with this before hitting the DB so a malformed id returns 404
* instead of a Postgres "invalid input syntax for type uuid" 500.
*/
export function isUuid(value: string | undefined | null): boolean {
return typeof value === "string" && UUID_REGEX.test(value);
}
export function createErrorResponse(code: string, message: string, status: number = 400, details?: unknown) {
return {
error: {
+23
View File
@@ -7,6 +7,25 @@ import { z } from "zod";
export const agentRoutes = new Hono();
// Record an entry in the agent_activity feed. The Agent Activity page reads
// this table, so every lifecycle event (create/update) is captured here in
// addition to the global activity_feed. Delete events can't be persisted
// because agent_activity cascades on the owning agent's removal.
async function recordAgentActivity(agent: { id: string; name: string }, action: string, entityType = "agent", details: Record<string, unknown> = {}) {
try {
await db.insert(agentActivity).values({
agentId: agent.id,
action,
entityType,
entityId: agent.id,
details,
success: true,
});
} catch (error) {
console.error("[agents] recordAgentActivity error:", error);
}
}
const createAgentSchema = z.object({
name: z.string().min(1, "Name is required"),
description: z.string().optional().nullable(),
@@ -123,6 +142,8 @@ agentRoutes.post("/", async (c) => {
changes: { name: agent.name }, workspaceId: data.domain,
});
await recordAgentActivity(agent, "created", "agent", { name: agent.name, permissionTier: agent.permissionTier });
return c.json(agent, 201);
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
@@ -219,6 +240,8 @@ agentRoutes.patch("/:id", async (c) => {
changes: { name: updated.name }, workspaceId: existing.domainId,
});
await recordAgentActivity(updated, "updated", "agent", { name: updated.name, permissionTier: updated.permissionTier });
return c.json(updated);
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
+9 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
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";
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
export const analyticsRoutes = new Hono();
@@ -17,6 +17,8 @@ analyticsRoutes.get("/productivity", async (c) => {
domainId = active.id;
}
await requireWorkspaceAccess(c, domainId);
const startDate = new Date();
startDate.setDate(startDate.getDate() - range);
@@ -58,6 +60,8 @@ analyticsRoutes.get("/habits", async (c) => {
domainId = active.id;
}
await requireWorkspaceAccess(c, domainId);
const startDate = new Date();
startDate.setDate(startDate.getDate() - range);
@@ -113,6 +117,8 @@ analyticsRoutes.get("/projects", async (c) => {
domainId = active.id;
}
await requireWorkspaceAccess(c, domainId);
const allProjects = await db.select()
.from(projects)
.where(and(eq(projects.domainId, domainId), isNull(projects.deletedAt)));
@@ -178,6 +184,8 @@ analyticsRoutes.get("/daily", async (c) => {
domainId = active.id;
}
await requireWorkspaceAccess(c, domainId);
// Buckets cover the last `range` days ending today, matching the frontend's expectation.
const firstDay = new Date();
firstDay.setDate(firstDay.getDate() - (range - 1));
+12 -2
View File
@@ -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, requireWorkspaceAccess, AuthError } from "../middleware/auth";
import { requireAuth, createErrorResponse, resolveActiveDomain, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
import { z } from "zod";
@@ -52,10 +52,14 @@ calendarRoutes.get("/events", async (c) => {
if (from) conditions.push(gte(calendarEvents.startTime, new Date(from)));
if (to) conditions.push(lte(calendarEvents.startTime, new Date(to)));
const items = await db.select()
const limitParam = parseInt(url.searchParams.get("limit") || "", 10);
const limit = Number.isFinite(limitParam) ? Math.max(1, Math.min(limitParam, 500)) : null;
const query = db.select()
.from(calendarEvents)
.where(and(...conditions))
.orderBy(asc(calendarEvents.startTime));
const items = limit !== null ? await query.limit(limit) : await query;
return c.json({ items, totalItems: items.length });
} catch (error) {
@@ -120,6 +124,9 @@ calendarRoutes.patch("/events/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const data = updateEventSchema.parse(body);
@@ -179,6 +186,9 @@ calendarRoutes.delete("/events/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [existing] = await db.select()
.from(calendarEvents)
+35 -4
View File
@@ -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, requireWorkspaceAccess, AuthError } from "../middleware/auth";
import { and, asc, desc, eq, notInArray, or, sql } from "drizzle-orm";
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
import { z } from "zod";
@@ -137,6 +137,9 @@ canvasRoutes.get("/:id", async (c) => {
try {
await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
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);
@@ -160,6 +163,9 @@ canvasRoutes.patch("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const data = updateCanvasSchema.parse(body);
@@ -199,6 +205,9 @@ canvasRoutes.delete("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
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);
@@ -224,6 +233,9 @@ canvasRoutes.post("/:id/cards", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const data = createCardSchema.parse(body);
@@ -272,6 +284,9 @@ canvasRoutes.put("/:id/cards", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const data = bulkSaveCardsSchema.parse(body);
@@ -282,8 +297,15 @@ canvasRoutes.put("/:id/cards", async (c) => {
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(
if (data.cards.length === 0) {
// No cards left — drop every connection on this canvas.
await tx.delete(canvasConnections).where(or(
eq(canvasConnections.sourceCardId, id),
eq(canvasConnections.targetCardId, id),
));
return [];
}
const inserted = await tx.insert(canvasCards).values(
data.cards.map((card, i) => ({
...(card.id ? { id: card.id } : {}),
canvasId: id,
@@ -299,6 +321,15 @@ canvasRoutes.put("/:id/cards", async (c) => {
zIndex: card.zIndex ?? i,
}))
).returning();
// Connections to cards that no longer exist must not linger. Cards that
// were re-inserted with their original id keep their connections; any
// connection whose endpoint is missing is dropped.
const keptIds = inserted.map((c) => c.id);
await tx.delete(canvasConnections).where(or(
notInArray(canvasConnections.sourceCardId, keptIds),
notInArray(canvasConnections.targetCardId, keptIds),
));
return inserted;
});
await recordActivity({
+7 -1
View File
@@ -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, requireWorkspaceAccess, AuthError } from "../middleware/auth";
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
import { z } from "zod";
@@ -99,6 +99,9 @@ customFieldRoutes.patch("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const data = updateFieldSchema.parse(body);
@@ -137,6 +140,9 @@ customFieldRoutes.delete("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
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);
+7 -1
View File
@@ -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, requireWorkspaceAccess, AuthError } from "../middleware/auth";
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
import { z } from "zod";
@@ -101,6 +101,9 @@ dailyNoteRoutes.patch("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const data = updateDailyNoteSchema.parse(body);
@@ -137,6 +140,9 @@ dailyNoteRoutes.delete("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
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);
+20 -2
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { db, domains as domainsTable } from "@project-e/db";
import { and, asc, desc, eq, ilike, or, sql } from "drizzle-orm";
import { requireAuth, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth";
import { requireAuth, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth";
export const domainRoutes = new Hono();
@@ -131,6 +131,9 @@ domainRoutes.get("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [domain] = await db
.select()
@@ -157,11 +160,23 @@ domainRoutes.patch("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
// Whitelist editable fields so a client can never overwrite ownership,
// the slug, or sort order via an open-ended body spread.
const updateValues: Record<string, unknown> = { updatedAt: new Date() };
if (body.name !== undefined && typeof body.name === "string") updateValues.name = body.name;
if (body.color !== undefined) updateValues.color = body.color ?? null;
if (body.icon !== undefined) updateValues.icon = body.icon ?? null;
if (body.parentId !== undefined) updateValues.parentId = body.parentId ?? null;
if (body.sortOrder !== undefined && typeof body.sortOrder === "number") updateValues.sortOrder = body.sortOrder;
const [domain] = await db
.update(domainsTable)
.set({ ...body, updatedAt: new Date() })
.set(updateValues)
.where(and(eq(domainsTable.id, id), eq(domainsTable.ownerId, user.id)))
.returning();
@@ -184,6 +199,9 @@ domainRoutes.delete("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [domain] = await db
.delete(domainsTable)
+9 -3
View File
@@ -218,9 +218,15 @@ graphRoutes.delete("/edges/:id", async (c) => {
.returning();
if (result.length === 0) {
// Try task_dependencies
await db.delete(taskDependencies)
.where(and(eq(taskDependencies.taskId, sourceId), eq(taskDependencies.dependsOnTaskId, targetId)));
// Try note_entity_links (note → entity edges)
const entityResult = await db.delete(noteEntityLinks)
.where(and(eq(noteEntityLinks.noteId, sourceId), eq(noteEntityLinks.entityId, targetId)))
.returning();
if (entityResult.length === 0) {
// Try task_dependencies
await db.delete(taskDependencies)
.where(and(eq(taskDependencies.taskId, sourceId), eq(taskDependencies.dependsOnTaskId, targetId)));
}
}
if (!workspaceId) {
+83 -14
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { db, habits, habitCompletions, habitTags, tags as tagsTable } from "@project-e/db";
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 { and, asc, desc, eq, exists, gte, ilike, inArray, isNull, lt, lte, sql } from "drizzle-orm";
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
import { enqueueWebhooks } from "../middleware/webhook-queue";
import { z } from "zod";
@@ -47,6 +47,10 @@ const completeHabitSchema = z.object({
/**
* Calculate the current streak for a habit.
*
* Day boundaries are resolved in UTC everywhere (grouping, streak walk and the
* frontend's "completed today" check) so the server and client agree on what
* "today" means even for users outside UTC.
*/
async function calculateStreak(habitId: string, skipDays: number[]): Promise<number> {
const completions = await db.select({ date: habitCompletions.date })
@@ -62,21 +66,21 @@ async function calculateStreak(habitId: string, skipDays: number[]): Promise<num
let streak = 0;
const today = new Date();
today.setHours(0, 0, 0, 0);
today.setUTCHours(0, 0, 0, 0);
const checkDate = new Date(today);
for (let i = 0; i < 365; i++) {
const dateStr = checkDate.toISOString().split("T")[0];
const dayOfWeek = checkDate.getDay();
const dayOfWeek = checkDate.getUTCDay();
if (skipDays.includes(dayOfWeek)) {
checkDate.setDate(checkDate.getDate() - 1);
checkDate.setUTCDate(checkDate.getUTCDate() - 1);
continue;
}
if (completionDates.has(dateStr)) {
streak++;
checkDate.setDate(checkDate.getDate() - 1);
checkDate.setUTCDate(checkDate.getUTCDate() - 1);
} else {
break;
}
@@ -192,8 +196,29 @@ habitRoutes.get("/", async (c) => {
tags: habitTagMap.get(h.id) || [],
}));
// Fetch recent completions for every habit in the page so the UI can render
// "completed today" / mini-grid state without a second request per row.
let habitCompletionMap = new Map<string, typeof habitCompletions.$inferSelect[]>();
if (items.length > 0) {
const habitIds = items.map(h => h.id);
const since = new Date(Date.now() - 21 * 24 * 60 * 60 * 1000);
const completionRows = await db.select()
.from(habitCompletions)
.where(and(inArray(habitCompletions.habitId, habitIds), gte(habitCompletions.date, since)))
.orderBy(desc(habitCompletions.date));
for (const row of completionRows) {
if (!habitCompletionMap.has(row.habitId)) habitCompletionMap.set(row.habitId, []);
habitCompletionMap.get(row.habitId)!.push(row);
}
}
const itemsWithCompletions = itemsWithTags.map(h => ({
...h,
recentCompletions: habitCompletionMap.get(h.id) || [],
}));
return c.json({
items: itemsWithTags,
items: itemsWithCompletions,
totalItems,
totalPages: Math.ceil(totalItems / (limit || perPage)),
page,
@@ -271,6 +296,9 @@ habitRoutes.get("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [habit] = await db.select()
.from(habits)
@@ -324,6 +352,9 @@ habitRoutes.patch("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const data = updateHabitSchema.parse(body);
@@ -385,6 +416,9 @@ habitRoutes.delete("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [existing] = await db.select()
.from(habits)
@@ -427,6 +461,9 @@ habitRoutes.post("/:id/tags", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const { tagId } = z.object({ tagId: z.string().uuid("Invalid tag id") }).parse(body);
@@ -478,6 +515,9 @@ habitRoutes.delete("/:id/tags/:tagId", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const tagId = c.req.param("tagId");
const [habit] = await db.select({ id: habits.id, domainId: habits.domainId })
@@ -517,6 +557,9 @@ habitRoutes.post("/:id/complete", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const data = completeHabitSchema.parse(body);
@@ -531,13 +574,36 @@ habitRoutes.post("/:id/complete", async (c) => {
await requireWorkspaceAccess(c, habit.domainId);
const [completion] = await db.insert(habitCompletions).values({
habitId: id,
date: new Date(),
value: data.value,
mood: data.mood ?? null,
notes: data.notes ?? null,
}).returning();
// Guard against duplicate completions for the same UTC day: the habit list
// disables the button once completed today, but double-fires (or a stale
// client) must not inflate history/stats. Update the existing row instead.
const todayStart = new Date();
todayStart.setUTCHours(0, 0, 0, 0);
const tomorrowStart = new Date(todayStart.getTime() + 24 * 60 * 60 * 1000);
const [existingCompletion] = await db.select()
.from(habitCompletions)
.where(and(
eq(habitCompletions.habitId, id),
gte(habitCompletions.date, todayStart),
lt(habitCompletions.date, tomorrowStart),
))
.limit(1);
let completion: typeof habitCompletions.$inferSelect;
if (existingCompletion) {
[completion] = await db.update(habitCompletions)
.set({ value: data.value, mood: data.mood ?? null, notes: data.notes ?? null })
.where(eq(habitCompletions.id, existingCompletion.id))
.returning();
} else {
[completion] = await db.insert(habitCompletions).values({
habitId: id,
date: new Date(),
value: data.value,
mood: data.mood ?? null,
notes: data.notes ?? null,
}).returning();
}
// Recalculate streak
const skipDays = habit.skipDays || [];
@@ -587,6 +653,9 @@ habitRoutes.get("/:id/completions", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const url = new URL(c.req.url);
const [habit] = await db.select({ id: habits.id, domainId: habits.domainId })
+81 -15
View File
@@ -7,6 +7,13 @@ import { z } from "zod";
export const importExportRoutes = new Hono();
const COLLECTIONS = ['tasks', 'habits', 'projects', 'notes', 'tags', 'agents', 'webhooks'] as const;
const JUNCTION_COLLECTIONS = ['task_tags', 'habit_tags', 'project_tags', 'note_tags'] as const;
const JUNCTION_PARENT: Record<string, string> = {
task_tags: 'tasks',
habit_tags: 'habits',
project_tags: 'projects',
note_tags: 'notes',
};
// POST /api/import — Import data from JSON
importExportRoutes.post("/import", async (c) => {
@@ -31,38 +38,50 @@ importExportRoutes.post("/import", async (c) => {
let totalImported = 0;
let totalFailed = 0;
for (const collection of COLLECTIONS) {
const items = body[collection];
if (!Array.isArray(items) || items.length === 0) continue;
const runCollection = async (collection: string, items: any[]) => {
const result = { collection, imported: 0, failed: 0, errors: [] as string[] };
for (const item of items) {
try {
const { id: _id, created: _created, updated: _updated, ...data } = item;
// Map to the right table
// Preserve the source id so cross-references (projectId, parentId,
// sectionId, junction rows) survive the round-trip. Domain is always
// forced to the target workspace.
const { id, domain, domainId, workspaceId, created: _created, updated: _updated, ...data } = item;
switch (collection) {
case 'tasks':
await db.insert(tasks).values({ ...data, domainId: targetDomain });
await db.insert(tasks).values({ ...data, id, domainId: targetDomain });
break;
case 'habits':
await db.insert(habits).values({ ...data, domainId: targetDomain });
await db.insert(habits).values({ ...data, id, domainId: targetDomain });
break;
case 'projects':
await db.insert(projects).values({ ...data, domainId: targetDomain });
await db.insert(projects).values({ ...data, id, domainId: targetDomain });
break;
case 'notes':
await db.insert(notes).values({ ...data, domainId: targetDomain });
await db.insert(notes).values({ ...data, id, domainId: targetDomain });
break;
case 'tags':
await db.insert(tagsTable).values(data);
await db.insert(tagsTable).values({ ...data, id });
break;
case 'agents':
await db.insert(agents).values({ ...data, domainId: targetDomain });
await db.insert(agents).values({ ...data, id, domainId: targetDomain });
break;
case 'webhooks':
await db.insert(webhooks).values({ ...data, workspaceId: targetDomain });
await db.insert(webhooks).values({ ...data, id, workspaceId: targetDomain });
break;
case 'task_tags':
await db.insert(taskTags).values({ taskId: item.taskId, tagId: item.tagId }).onConflictDoNothing();
break;
case 'habit_tags':
await db.insert(habitTags).values({ habitId: item.habitId, tagId: item.tagId }).onConflictDoNothing();
break;
case 'project_tags':
await db.insert(projectTags).values({ projectId: item.projectId, tagId: item.tagId }).onConflictDoNothing();
break;
case 'note_tags':
await db.insert(noteTags).values({ noteId: item.noteId, tagId: item.tagId }).onConflictDoNothing();
break;
default:
continue;
}
result.imported++;
} catch (error) {
@@ -71,10 +90,21 @@ importExportRoutes.post("/import", async (c) => {
if (result.errors.length < 5) result.errors.push(message);
}
}
results.push(result);
totalImported += result.imported;
totalFailed += result.failed;
};
// Entities first (tags too), then junctions so the FK targets exist.
for (const collection of COLLECTIONS) {
const items = body[collection];
if (!Array.isArray(items) || items.length === 0) continue;
await runCollection(collection, items);
}
for (const collection of JUNCTION_COLLECTIONS) {
const items = body[collection];
if (!Array.isArray(items) || items.length === 0) continue;
await runCollection(collection, items);
}
return c.json({ success: totalFailed === 0, imported: totalImported, failed: totalFailed, results });
@@ -163,6 +193,42 @@ importExportRoutes.post("/export", async (c) => {
}
}
// Tag assignments ride along with their parent entity collection so an
// export → import round-trip preserves all tag links.
for (const junction of JUNCTION_COLLECTIONS) {
const parent = JUNCTION_PARENT[junction];
if (!requestedCollections.includes(parent as typeof COLLECTIONS[number])) continue;
try {
let items: any[] = [];
switch (junction) {
case 'task_tags':
items = await db.select({ taskId: taskTags.taskId, tagId: taskTags.tagId }).from(taskTags)
.innerJoin(tasks, eq(taskTags.taskId, tasks.id))
.where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt)));
break;
case 'habit_tags':
items = await db.select({ habitId: habitTags.habitId, tagId: habitTags.tagId }).from(habitTags)
.innerJoin(habits, eq(habitTags.habitId, habits.id))
.where(and(eq(habits.domainId, domainId), isNull(habits.deletedAt)));
break;
case 'project_tags':
items = await db.select({ projectId: projectTags.projectId, tagId: projectTags.tagId }).from(projectTags)
.innerJoin(projects, eq(projectTags.projectId, projects.id))
.where(and(eq(projects.domainId, domainId), isNull(projects.deletedAt)));
break;
case 'note_tags':
items = await db.select({ noteId: noteTags.noteId, tagId: noteTags.tagId }).from(noteTags)
.innerJoin(notes, eq(noteTags.noteId, notes.id))
.where(and(eq(notes.domainId, domainId), isNull(notes.deletedAt)));
break;
}
exportData[junction] = items;
} catch (error) {
console.error("Failed to export collection " + junction + ":", error);
exportData[junction] = [];
}
}
return c.json(exportData);
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
+17 -8
View File
@@ -165,6 +165,10 @@ const tools: ToolDefinition[] = [
required: ["task_id"],
},
handler: async (params, auth) => {
const [existing] = await db.select().from(tasks).where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt))).limit(1);
if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found");
await verifyDomainAccess(existing.domainId, auth.userId);
const updateData: Record<string, unknown> = {};
if (params.title !== undefined) updateData.title = params.title;
if (params.description !== undefined) updateData.description = params.description;
@@ -178,8 +182,6 @@ const tools: ToolDefinition[] = [
.where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt)))
.returning();
if (!task) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found");
await recordActivity({
actor: auth.userName,
action: "updated",
@@ -201,13 +203,15 @@ const tools: ToolDefinition[] = [
required: ["task_id"],
},
handler: async (params, auth) => {
const [existing] = await db.select().from(tasks).where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt))).limit(1);
if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found");
await verifyDomainAccess(existing.domainId, auth.userId);
const [task] = await db.update(tasks)
.set({ deletedAt: new Date(), updatedAt: new Date() })
.where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt)))
.returning();
if (!task) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found");
await recordActivity({
actor: auth.userName,
action: "deleted",
@@ -228,13 +232,15 @@ const tools: ToolDefinition[] = [
required: ["task_id"],
},
handler: async (params, auth) => {
const [existing] = await db.select().from(tasks).where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt))).limit(1);
if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found");
await verifyDomainAccess(existing.domainId, auth.userId);
const [task] = await db.update(tasks)
.set({ status: "done", completedAt: new Date(), updatedAt: new Date() })
.where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt)))
.returning();
if (!task) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found");
await recordActivity({
actor: auth.userName,
action: "completed",
@@ -313,6 +319,7 @@ const tools: ToolDefinition[] = [
handler: async (params, auth) => {
const [habit] = await db.select().from(habits).where(and(eq(habits.id, params.habit_id as string), isNull(habits.deletedAt))).limit(1);
if (!habit) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Habit not found");
await verifyDomainAccess(habit.domainId, auth.userId);
const [completion] = await db.insert(habitCompletions).values({
habitId: params.habit_id as string,
@@ -444,6 +451,10 @@ const tools: ToolDefinition[] = [
required: ["note_id"],
},
handler: async (params, auth) => {
const [existing] = await db.select().from(notes).where(and(eq(notes.id, params.note_id as string), isNull(notes.deletedAt))).limit(1);
if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Note not found");
await verifyDomainAccess(existing.domainId, auth.userId);
const updateData: Record<string, unknown> = { updatedAt: new Date() };
if (params.title !== undefined) updateData.title = params.title;
if (params.content !== undefined) updateData.content = params.content;
@@ -453,8 +464,6 @@ const tools: ToolDefinition[] = [
.where(and(eq(notes.id, params.note_id as string), isNull(notes.deletedAt)))
.returning();
if (!note) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Note not found");
await recordActivity({
actor: auth.userName,
action: "updated",
+11 -8
View File
@@ -12,15 +12,18 @@ import { extractLinkTargets } from "./wikilink-parser";
/**
* Resolve a single link target to its entity ID.
*
* scoped to the source note's workspace so [[Title]] links never resolve to an
* entity in a different domain of the same user.
*/
async function resolveTarget(entityType: string, title: string): Promise<{ entityId: string; entityType: string } | null> {
async function resolveTarget(entityType: string, title: string, domainId: string): Promise<{ entityId: string; entityType: string } | null> {
const trimmedTitle = title.trim();
if (!entityType) {
const [note] = await db
.select({ id: notes.id })
.from(notes)
.where(and(eq(notes.title, trimmedTitle), isNull(notes.deletedAt)))
.where(and(eq(notes.title, trimmedTitle), eq(notes.domainId, domainId), isNull(notes.deletedAt)))
.limit(1);
if (note) return { entityId: note.id, entityType: "note" };
return null;
@@ -31,7 +34,7 @@ async function resolveTarget(entityType: string, title: string): Promise<{ entit
const [note] = await db
.select({ id: notes.id })
.from(notes)
.where(and(eq(notes.title, trimmedTitle), isNull(notes.deletedAt)))
.where(and(eq(notes.title, trimmedTitle), eq(notes.domainId, domainId), isNull(notes.deletedAt)))
.limit(1);
if (note) return { entityId: note.id, entityType: "note" };
return null;
@@ -40,7 +43,7 @@ async function resolveTarget(entityType: string, title: string): Promise<{ entit
const [task] = await db
.select({ id: tasks.id })
.from(tasks)
.where(and(eq(tasks.title, trimmedTitle), isNull(tasks.deletedAt)))
.where(and(eq(tasks.title, trimmedTitle), eq(tasks.domainId, domainId), isNull(tasks.deletedAt)))
.limit(1);
if (task) return { entityId: task.id, entityType: "task" };
return null;
@@ -49,7 +52,7 @@ async function resolveTarget(entityType: string, title: string): Promise<{ entit
const [habit] = await db
.select({ id: habits.id })
.from(habits)
.where(and(eq(habits.name, trimmedTitle), isNull(habits.deletedAt)))
.where(and(eq(habits.name, trimmedTitle), eq(habits.domainId, domainId), isNull(habits.deletedAt)))
.limit(1);
if (habit) return { entityId: habit.id, entityType: "habit" };
return null;
@@ -58,7 +61,7 @@ async function resolveTarget(entityType: string, title: string): Promise<{ entit
const [project] = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.name, trimmedTitle), isNull(projects.deletedAt)))
.where(and(eq(projects.name, trimmedTitle), eq(projects.domainId, domainId), isNull(projects.deletedAt)))
.limit(1);
if (project) return { entityId: project.id, entityType: "project" };
return null;
@@ -89,12 +92,12 @@ async function resolveTarget(entityType: string, title: string): Promise<{ entit
/**
* Sync wikilinks for a note: parse content, resolve targets, diff existing links.
*/
export async function syncNoteLinks(noteId: string, content: string): Promise<void> {
export async function syncNoteLinks(noteId: string, content: string, domainId: string): Promise<void> {
const targets = extractLinkTargets(content);
const resolvedTargets: { entityType: string; entityId: string }[] = [];
for (const target of targets) {
const resolved = await resolveTarget(target.entityType, target.title);
const resolved = await resolveTarget(target.entityType, target.title, domainId);
if (resolved) {
resolvedTargets.push(resolved);
}
+24 -3
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { db, notes, noteTags, tags as tagsTable, activityFeed } from "@project-e/db";
import { and, asc, desc, eq, exists, ilike, inArray, isNull, sql } from "drizzle-orm";
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth";
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
import { enqueueWebhooks } from "../middleware/webhook-queue";
import { syncNoteLinks, getBacklinks, getOutgoingLinks } from "./note-link-service";
@@ -172,7 +172,7 @@ noteRoutes.post("/", async (c) => {
// Sync wikilinks from content
if (data.content) {
await syncNoteLinks(note.id, data.content);
await syncNoteLinks(note.id, data.content, data.domain);
}
await recordActivity({
@@ -204,6 +204,9 @@ noteRoutes.get("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [note] = await db.select()
.from(notes)
@@ -252,6 +255,9 @@ noteRoutes.patch("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const data = updateNoteSchema.parse(body);
@@ -281,7 +287,7 @@ noteRoutes.patch("/:id", async (c) => {
// Re-sync wikilinks if content changed
const content = data.content ?? existing.content;
if (content) {
await syncNoteLinks(id, content);
await syncNoteLinks(id, content, existing.domainId);
}
await recordActivity({
@@ -313,6 +319,9 @@ noteRoutes.delete("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [existing] = await db.select()
.from(notes)
@@ -355,6 +364,9 @@ noteRoutes.post("/:id/tags", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const { tagId } = z.object({ tagId: z.string().uuid("Invalid tag id") }).parse(body);
@@ -406,6 +418,9 @@ noteRoutes.delete("/:id/tags/:tagId", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const tagId = c.req.param("tagId");
const [note] = await db.select({ id: notes.id, domainId: notes.domainId })
@@ -445,6 +460,9 @@ noteRoutes.get("/:id/backlinks", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [note] = await db.select({ id: notes.id, domainId: notes.domainId })
.from(notes)
@@ -477,6 +495,9 @@ noteRoutes.get("/:id/versions", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [note] = await db.select({ id: notes.id, domainId: notes.domainId })
.from(notes)
+19 -1
View File
@@ -1,7 +1,7 @@
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, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth";
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
import { enqueueWebhooks } from "../middleware/webhook-queue";
import { z } from "zod";
@@ -239,6 +239,9 @@ projectRoutes.get("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [project] = await db.select()
.from(projects)
@@ -300,6 +303,9 @@ projectRoutes.patch("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const data = updateProjectSchema.parse(body);
@@ -357,6 +363,9 @@ projectRoutes.delete("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [existing] = await db.select()
.from(projects)
@@ -651,6 +660,9 @@ projectRoutes.get("/:id/members", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [project] = await db.select({ id: projects.id, domainId: projects.domainId })
.from(projects)
@@ -687,6 +699,9 @@ projectRoutes.post("/:id/members", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const { userId, role } = z.object({
userId: z.string().uuid(),
@@ -731,6 +746,9 @@ projectRoutes.delete("/:id/members/:uid", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const userId = c.req.param("uid");
const [project] = await db.select({ id: projects.id, name: projects.name, domainId: projects.domainId })
+8 -3
View File
@@ -1,6 +1,6 @@
import { Hono } from "hono";
import { db, sql } from "@project-e/db";
import { requireAuth, resolveActiveDomain, AuthError } from "../middleware/auth";
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
export const searchRoutes = new Hono();
@@ -32,8 +32,13 @@ searchRoutes.get("/", async (c) => {
}
// Scope all searches to the user's active domain so users can never see
// another workspace's data.
const userDomain = await resolveActiveDomain(user);
// another workspace's data. The frontend passes the selected domain; the
// param is validated so a foreign workspace id is rejected.
let userDomain = await resolveActiveDomain(user);
if (url.searchParams.get("domain")) {
await requireWorkspaceAccess(c, url.searchParams.get("domain")!);
userDomain = { ...userDomain, id: url.searchParams.get("domain")! };
}
const userDomainId = userDomain.id;
const results: Array<{ id: string; type: string; title: string; snippet: string; score: number; workspaceId: string; link: string }> = [];
+10 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { db, tags as tagsTable } from "@project-e/db";
import { and, asc, desc, eq, sql } from "drizzle-orm";
import { requireAuth, AuthError } from "../middleware/auth";
import { requireAuth, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
import { z } from "zod";
@@ -79,6 +79,9 @@ tagRoutes.get("/:id", async (c) => {
try {
await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [tag] = await db.select().from(tagsTable).where(eq(tagsTable.id, id)).limit(1);
if (!tag) return c.json({ error: { code: "NOT_FOUND", message: "Tag not found" } }, 404);
return c.json(tag);
@@ -94,6 +97,9 @@ tagRoutes.patch("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const data = updateTagSchema.parse(body);
@@ -121,6 +127,9 @@ tagRoutes.delete("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [existing] = await db.select().from(tagsTable).where(eq(tagsTable.id, id)).limit(1);
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Tag not found" } }, 404);
+71 -3
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { db, tasks, taskTags, tags as tagsTable, taskDependencies, activityFeed, scheduledJobs } from "@project-e/db";
import { db, tasks, taskTags, tags as tagsTable, taskDependencies, activityFeed, scheduledJobs, projects, sections } 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 { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
import { enqueueWebhooks } from "../middleware/webhook-queue";
import { z } from "zod";
@@ -246,13 +246,51 @@ taskRoutes.post("/", async (c) => {
// Cycle detection for parentId (subtask)
if (data.parentId) {
const [parent] = await db.select({ id: tasks.id })
const [parent] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks)
.where(and(eq(tasks.id, data.parentId), isNull(tasks.deletedAt)))
.limit(1);
if (!parent) {
return c.json({ error: { code: "NOT_FOUND", message: "Parent task not found" } }, 404);
}
if (parent.domainId !== data.domain) {
return c.json({ error: { code: "FORBIDDEN", message: "Parent task does not belong to this workspace" } }, 403);
}
}
// A task's project/section must belong to the same workspace; otherwise a
// task can be linked into another domain's project and leak across workspaces.
if (data.projectId) {
const [project] = await db.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.id, data.projectId), isNull(projects.deletedAt)))
.limit(1);
if (!project) {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
}
const [projectDomain] = await db.select({ domainId: projects.domainId }).from(projects).where(eq(projects.id, data.projectId)).limit(1);
if (!projectDomain || projectDomain.domainId !== data.domain) {
return c.json({ error: { code: "FORBIDDEN", message: "Project does not belong to this workspace" } }, 403);
}
}
if (data.sectionId) {
const [section] = await db.select({ id: sections.id, projectId: sections.projectId })
.from(sections)
.where(eq(sections.id, data.sectionId))
.limit(1);
if (!section) {
return c.json({ error: { code: "NOT_FOUND", message: "Section not found" } }, 404);
}
if (data.projectId && section.projectId !== data.projectId) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Section does not belong to the selected project" } }, 400);
}
if (section.projectId) {
const [sectionProject] = await db.select({ domainId: projects.domainId }).from(projects).where(eq(projects.id, section.projectId)).limit(1);
if (!sectionProject || sectionProject.domainId !== data.domain) {
return c.json({ error: { code: "FORBIDDEN", message: "Section does not belong to this workspace" } }, 403);
}
}
}
const [task] = await db.insert(tasks).values({
@@ -375,6 +413,9 @@ taskRoutes.get("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [task] = await db.select()
.from(tasks)
@@ -444,6 +485,9 @@ taskRoutes.patch("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const data = updateTaskSchema.parse(body);
@@ -531,6 +575,9 @@ taskRoutes.delete("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [existing] = await db.select()
.from(tasks)
@@ -576,6 +623,9 @@ taskRoutes.post("/:id/tags", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const { tagId } = z.object({ tagId: z.string().uuid("Invalid tag id") }).parse(body);
@@ -627,6 +677,9 @@ taskRoutes.delete("/:id/tags/:tagId", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const tagId = c.req.param("tagId");
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
@@ -666,6 +719,9 @@ taskRoutes.post("/:id/status", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const { status: newStatus } = z.object({
status: taskStatusEnum,
@@ -722,6 +778,9 @@ taskRoutes.get("/:id/history", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks)
@@ -759,6 +818,9 @@ taskRoutes.get("/:id/comments", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks)
@@ -795,6 +857,9 @@ taskRoutes.post("/:id/comments", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const { content } = z.object({
content: z.string().min(1, "Content is required"),
@@ -839,6 +904,9 @@ taskRoutes.get("/:id/attachments", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks)
+10 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { db, webhooks } from "@project-e/db";
import { and, asc, desc, eq, sql } from "drizzle-orm";
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
import { enqueueWebhookDelivery } from "../middleware/webhook-queue";
import { z } from "zod";
@@ -105,6 +105,9 @@ webhookRoutes.patch("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const data = updateWebhookSchema.parse(body);
@@ -142,6 +145,9 @@ webhookRoutes.delete("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
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);
@@ -167,6 +173,9 @@ webhookRoutes.post("/:id/test", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
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);