feat: add server error logging and tighten workspace isolation
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 }> = [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user