diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 28bcde2..75907fe 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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 { diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index b108234..96a84e1 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -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: { diff --git a/apps/api/src/routes/agents.ts b/apps/api/src/routes/agents.ts index a75b3d2..346843d 100644 --- a/apps/api/src/routes/agents.ts +++ b/apps/api/src/routes/agents.ts @@ -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 = {}) { + 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); diff --git a/apps/api/src/routes/analytics.ts b/apps/api/src/routes/analytics.ts index aa094ae..9d4324e 100644 --- a/apps/api/src/routes/analytics.ts +++ b/apps/api/src/routes/analytics.ts @@ -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)); diff --git a/apps/api/src/routes/calendar.ts b/apps/api/src/routes/calendar.ts index e0dd827..6d18bcd 100644 --- a/apps/api/src/routes/calendar.ts +++ b/apps/api/src/routes/calendar.ts @@ -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) diff --git a/apps/api/src/routes/canvas.ts b/apps/api/src/routes/canvas.ts index eee24ad..1d14d25 100644 --- a/apps/api/src/routes/canvas.ts +++ b/apps/api/src/routes/canvas.ts @@ -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({ diff --git a/apps/api/src/routes/custom-fields.ts b/apps/api/src/routes/custom-fields.ts index 9743f11..42a9a02 100644 --- a/apps/api/src/routes/custom-fields.ts +++ b/apps/api/src/routes/custom-fields.ts @@ -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); diff --git a/apps/api/src/routes/daily-notes.ts b/apps/api/src/routes/daily-notes.ts index 4bc034d..534a443 100644 --- a/apps/api/src/routes/daily-notes.ts +++ b/apps/api/src/routes/daily-notes.ts @@ -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); diff --git a/apps/api/src/routes/domains.ts b/apps/api/src/routes/domains.ts index 6955b2e..f1a4d62 100644 --- a/apps/api/src/routes/domains.ts +++ b/apps/api/src/routes/domains.ts @@ -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 = { 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) diff --git a/apps/api/src/routes/graph.ts b/apps/api/src/routes/graph.ts index 8803475..60ab574 100644 --- a/apps/api/src/routes/graph.ts +++ b/apps/api/src/routes/graph.ts @@ -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) { diff --git a/apps/api/src/routes/habits.ts b/apps/api/src/routes/habits.ts index a02b3c4..2509415 100644 --- a/apps/api/src/routes/habits.ts +++ b/apps/api/src/routes/habits.ts @@ -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 { const completions = await db.select({ date: habitCompletions.date }) @@ -62,21 +66,21 @@ async function calculateStreak(habitId: string, skipDays: number[]): Promise { 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(); + 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 }) diff --git a/apps/api/src/routes/import-export.ts b/apps/api/src/routes/import-export.ts index 845b9a1..98ddbb4 100644 --- a/apps/api/src/routes/import-export.ts +++ b/apps/api/src/routes/import-export.ts @@ -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 = { + 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); diff --git a/apps/api/src/routes/mcp.ts b/apps/api/src/routes/mcp.ts index bc08944..039fe37 100644 --- a/apps/api/src/routes/mcp.ts +++ b/apps/api/src/routes/mcp.ts @@ -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 = {}; 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 = { 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", diff --git a/apps/api/src/routes/note-link-service.ts b/apps/api/src/routes/note-link-service.ts index 9c5d506..070da31 100644 --- a/apps/api/src/routes/note-link-service.ts +++ b/apps/api/src/routes/note-link-service.ts @@ -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 { +export async function syncNoteLinks(noteId: string, content: string, domainId: string): Promise { 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); } diff --git a/apps/api/src/routes/notes.ts b/apps/api/src/routes/notes.ts index a1112c1..0ba7e31 100644 --- a/apps/api/src/routes/notes.ts +++ b/apps/api/src/routes/notes.ts @@ -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) diff --git a/apps/api/src/routes/projects.ts b/apps/api/src/routes/projects.ts index 3b87d04..3ea3294 100644 --- a/apps/api/src/routes/projects.ts +++ b/apps/api/src/routes/projects.ts @@ -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 }) diff --git a/apps/api/src/routes/search.ts b/apps/api/src/routes/search.ts index 48773a8..4cf654b 100644 --- a/apps/api/src/routes/search.ts +++ b/apps/api/src/routes/search.ts @@ -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 }> = []; diff --git a/apps/api/src/routes/tags.ts b/apps/api/src/routes/tags.ts index 2ec0a54..264bb50 100644 --- a/apps/api/src/routes/tags.ts +++ b/apps/api/src/routes/tags.ts @@ -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); diff --git a/apps/api/src/routes/tasks.ts b/apps/api/src/routes/tasks.ts index 7aa1073..d7652fe 100644 --- a/apps/api/src/routes/tasks.ts +++ b/apps/api/src/routes/tasks.ts @@ -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) diff --git a/apps/api/src/routes/webhooks.ts b/apps/api/src/routes/webhooks.ts index a541c00..a6f6623 100644 --- a/apps/api/src/routes/webhooks.ts +++ b/apps/api/src/routes/webhooks.ts @@ -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); diff --git a/apps/web/src/components/entities/tag-manager.tsx b/apps/web/src/components/entities/tag-manager.tsx index 8c65cb1..f0e8513 100644 --- a/apps/web/src/components/entities/tag-manager.tsx +++ b/apps/web/src/components/entities/tag-manager.tsx @@ -40,6 +40,9 @@ export function TagManager({ entityType, entityId, tags }: TagManagerProps) { const availableTags = allTags.filter((t) => !assignedIds.has(t.id)); const refreshEntity = () => { + // Refresh the list view (["tasks", ...], ["habits", ...], ["notes", ...]) + // and the detail view (["task", id], ...) so badges stay in sync in both. + queryClient.invalidateQueries({ queryKey: [plural] }); queryClient.invalidateQueries({ queryKey: [entityType, entityId] }); }; diff --git a/apps/web/src/components/shell/command-palette.tsx b/apps/web/src/components/shell/command-palette.tsx index 36d1586..5c9e73b 100644 --- a/apps/web/src/components/shell/command-palette.tsx +++ b/apps/web/src/components/shell/command-palette.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useCallback, useRef } from "react"; -import { useNavigate } from "@tanstack/react-router"; +import { useNavigate, useLocation } from "@tanstack/react-router"; import { LayoutDashboard, ListTodo, @@ -28,6 +28,8 @@ import { CommandSeparator, } from "@/components/ui/command"; import { useThemeStore, type AccentColor, ACCENT_PALETTE } from "@/lib/stores/use-theme-store"; +import { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store"; +import { useApiDomain } from "@/lib/stores/use-active-domain-store"; interface NavItem { label: string; @@ -70,7 +72,9 @@ function addRecentPage(href: string) { export function CommandPalette() { const navigate = useNavigate(); + const location = useLocation(); const { mode, setMode, accent, setAccent } = useThemeStore(); + const activeDomainId = useApiDomain(); const [open, setOpen] = useState(false); const [searchResults, setSearchResults] = useState< Array<{ type: string; items: Array<{ id: string; title: string; link?: string }> }> @@ -94,36 +98,50 @@ export function CommandPalette() { // Track page navigation for recent items useEffect(() => { - const path = window.location.pathname; - if (path !== "/login") addRecentPage(path); - }, []); + if (location.pathname !== "/login") addRecentPage(location.pathname); + }, [location.pathname]); // Quick actions const quickActions: QuickAction[] = [ { label: "New task", icon: ListTodo, - action: () => navigate({ to: "/tasks" }), + action: () => { + useCreateDialogStore.getState().openCreate("task"); + navigate({ to: "/tasks" }); + }, }, { label: "New habit", icon: Flame, - action: () => navigate({ to: "/habits" }), + action: () => { + useCreateDialogStore.getState().openCreate("habit"); + navigate({ to: "/habits" }); + }, }, { label: "New project", icon: FolderKanban, - action: () => navigate({ to: "/projects" }), + action: () => { + useCreateDialogStore.getState().openCreate("project"); + navigate({ to: "/projects" }); + }, }, { label: "New note", icon: NotebookPen, - action: () => navigate({ to: "/notes" }), + action: () => { + useCreateDialogStore.getState().openCreate("note"); + navigate({ to: "/notes" }); + }, }, { label: "New event", icon: CalendarDays, - action: () => navigate({ to: "/calendar" }), + action: () => { + useCreateDialogStore.getState().openCreate("event"); + navigate({ to: "/calendar" }); + }, }, ]; @@ -172,7 +190,7 @@ export function CommandPalette() { const mentionQuery = query.slice(1).trim(); if (mentionQuery) { try { - const res = await fetch(`/api/agents?q=${encodeURIComponent(mentionQuery)}`); + const res = await fetch(`/api/agents?q=${encodeURIComponent(mentionQuery)}` + (activeDomainId ? "&domain=" + activeDomainId : "")); if (res.ok) { const data = await res.json(); setSearchResults([ @@ -195,16 +213,24 @@ export function CommandPalette() { // Debounced API search searchTimeoutRef.current = setTimeout(async () => { try { - const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=5`); + const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=5` + (activeDomainId ? "&domain=" + activeDomainId : "")); if (res.ok) { const data = await res.json(); - setSearchResults(data.results || []); + // The API returns a flat list of SearchResult objects; group them by + // entity type for the grouped render below. + const flat: Array<{ type: string; id: string; title: string; link?: string }> = data.results || []; + const grouped: Record> = {}; + for (const r of flat) { + const key = r.type.charAt(0).toUpperCase() + r.type.slice(1) + "s"; + (grouped[key] = grouped[key] || []).push({ id: r.id, title: r.title, link: r.link }); + } + setSearchResults(Object.entries(grouped).map(([type, items]) => ({ type, items }))); } } catch { // Ignore search errors } }, 300); - }, []); + }, [activeDomainId]); const runCommand = useCallback( (command: () => void) => { @@ -320,7 +346,7 @@ export function CommandPalette() { runCommand(() => {}); return; } - const link = group.type === "domain" ? "/" : item.link!; + const link = group.type === "Domains" ? "/" : item.link!; runCommand(() => navigate({ to: link })); }} > diff --git a/apps/web/src/components/shell/shortcuts-help.tsx b/apps/web/src/components/shell/shortcuts-help.tsx index 70228e8..3cf698a 100644 --- a/apps/web/src/components/shell/shortcuts-help.tsx +++ b/apps/web/src/components/shell/shortcuts-help.tsx @@ -28,14 +28,15 @@ const shortcutGroups = [ { keys: "n then h", description: "New habit" }, { keys: "n then p", description: "New project" }, { keys: "n then n", description: "New note" }, - { keys: "c", description: "Focus create in palette" }, + { keys: "⌘N / Ctrl+N", description: "New task" }, ], }, { heading: "General", shortcuts: [ { keys: "⌘K / Ctrl+K", description: "Open command palette" }, - { keys: "/", description: "Focus search" }, + { keys: "/", description: "Open command palette" }, + { keys: "c", description: "Open command palette" }, { keys: "?", description: "Show this help" }, { keys: "Esc", description: "Close dialogs / panels" }, ], diff --git a/apps/web/src/components/shell/sidebar.tsx b/apps/web/src/components/shell/sidebar.tsx index 1d4196b..906e39e 100644 --- a/apps/web/src/components/shell/sidebar.tsx +++ b/apps/web/src/components/shell/sidebar.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from "react"; -import { Link, useLocation } from "@tanstack/react-router"; +import { Link, useLocation, useNavigate } from "@tanstack/react-router"; import { cn } from "@/lib/utils"; import { useSidebarStore } from "@/lib/stores/use-sidebar-store"; +import { useAuthStore } from "@/lib/stores/use-auth-store"; import { LayoutDashboard, ListTodo, @@ -46,6 +47,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { DomainPicker } from "@/components/shell/domain-picker"; interface NavItem { href: string; @@ -75,7 +77,11 @@ const bottomItems: NavItem[] = [ export function Sidebar() { const location = useLocation(); + const navigate = useNavigate(); const { collapsed, toggle, mobileOpen, setMobileOpen } = useSidebarStore(); + const user = useAuthStore((s) => s.user); + const userName = user?.name || user?.email?.split("@")[0] || "User"; + const userInitials = (user?.name || user?.email || "U").slice(0, 2).toUpperCase(); // Sidebar position (left/right) is set in Settings. Read once on mount and // update live via the "sidebar-position-change" custom event dispatched by @@ -204,12 +210,12 @@ export function Sidebar() { - {}}> + navigate({ to: "/settings" })}> Profile @@ -229,13 +235,13 @@ export function Sidebar() { - {}}> + navigate({ to: "/settings" })}> Profile @@ -261,6 +267,9 @@ export function Sidebar() { Project E Navigate your workspace. +
+ +
{navigation(false, () => setMobileOpen(false))} diff --git a/apps/web/src/components/shell/topbar.tsx b/apps/web/src/components/shell/topbar.tsx index 2ac618b..e2c9763 100644 --- a/apps/web/src/components/shell/topbar.tsx +++ b/apps/web/src/components/shell/topbar.tsx @@ -2,7 +2,9 @@ import { Search, Bell, Plus, Menu, RefreshCw } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useSidebarStore } from "@/lib/stores/use-sidebar-store"; import { useApiDomain } from "@/lib/stores/use-active-domain-store"; +import { useAuthStore } from "@/lib/stores/use-auth-store"; import { useApiQuery } from "@/lib/api"; +import { useNavigate } from "@tanstack/react-router"; import { useQueryClient } from "@tanstack/react-query"; import { DomainPicker } from "@/components/shell/domain-picker"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; @@ -38,7 +40,12 @@ function readableEntityType(entityType: string): string { export function Topbar() { const { setMobileOpen } = useSidebarStore(); const queryClient = useQueryClient(); + const navigate = useNavigate(); const domainId = useApiDomain(); + const user = useAuthStore((s) => s.user); + const userName = user?.name || "User"; + const userEmail = user?.email || ""; + const userInitials = (user?.name || user?.email || "U").slice(0, 2).toUpperCase(); const openPalette = () => { document.dispatchEvent(new CustomEvent("open-command-palette")); @@ -179,15 +186,15 @@ export function Topbar() {
- U + {userInitials}
- User - user@projecte.app + {userName} + {userEmail}
- {}}> + navigate({ to: "/settings" })}> Profile { diff --git a/apps/web/src/hooks/use-keyboard-shortcuts.ts b/apps/web/src/hooks/use-keyboard-shortcuts.ts index 95ed13a..1bacf73 100644 --- a/apps/web/src/hooks/use-keyboard-shortcuts.ts +++ b/apps/web/src/hooks/use-keyboard-shortcuts.ts @@ -1,6 +1,7 @@ import { useEffect, useRef } from "react"; import { useNavigate } from "@tanstack/react-router"; import { useKeyboardShortcutsStore } from "@/lib/stores/use-keyboard-shortcuts-store"; +import { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store"; import { install, uninstall } from "@github/hotkey"; export function useKeyboardShortcuts() { @@ -49,16 +50,18 @@ export function useKeyboardShortcuts() { }); } - // n+letter new-entity sequences - const newMap: Record = { - "n t": "/tasks", - "n h": "/habits", - "n p": "/projects", - "n n": "/notes", + // n+letter new-entity sequences — navigate AND open the create dialog on + // the target page (the page's effect consumes the store request). + const newMap: Record = { + "n t": { path: "/tasks", type: "task" }, + "n h": { path: "/habits", type: "habit" }, + "n p": { path: "/projects", type: "project" }, + "n n": { path: "/notes", type: "note" }, }; - for (const [seq, path] of Object.entries(newMap)) { + for (const [seq, { path, type }] of Object.entries(newMap)) { addHotkey(seq, () => { + useCreateDialogStore.getState().openCreate(type); navigate({ to: path }); }); } @@ -84,6 +87,14 @@ export function useKeyboardShortcuts() { return; } + // Cmd+N / Ctrl+N — new task + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "n") { + e.preventDefault(); + useCreateDialogStore.getState().openCreate("task"); + navigate({ to: "/tasks" }); + return; + } + // Single-key shortcuts (no modifiers) if (e.metaKey || e.ctrlKey || e.altKey) return; diff --git a/apps/web/src/hooks/use-open-create-dialog.ts b/apps/web/src/hooks/use-open-create-dialog.ts new file mode 100644 index 0000000..907d809 --- /dev/null +++ b/apps/web/src/hooks/use-open-create-dialog.ts @@ -0,0 +1,23 @@ +import { useEffect } from "react"; +import { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store"; + +/** + * Opens a page's local create dialog when the global "new entity" shortcut or + * command palette requests it. Pages must call this once with their entity type + * and a callback that flips their own create-open state. + * + * The store is re-read inside the effect (rather than trusting the captured + * value) so React StrictMode's double-invoked effects can't fire onOpen twice. + */ +export function useOpenCreateDialog(type: "task" | "habit" | "project" | "note" | "event", onOpen: () => void) { + const open = useCreateDialogStore((s) => s.open); + const storeType = useCreateDialogStore((s) => s.type); + + useEffect(() => { + const state = useCreateDialogStore.getState(); + if (state.open && state.type === type) { + useCreateDialogStore.getState().closeCreate(); + onOpen(); + } + }, [open, storeType, type, onOpen]); +} diff --git a/apps/web/src/hooks/use-realtime.ts b/apps/web/src/hooks/use-realtime.ts index 7063f03..b7039ef 100644 --- a/apps/web/src/hooks/use-realtime.ts +++ b/apps/web/src/hooks/use-realtime.ts @@ -1,5 +1,6 @@ import { useEffect, useRef, useCallback } from "react"; import { useQueryClient } from "@tanstack/react-query"; +import { useApiDomain } from "@/lib/stores/use-active-domain-store"; import type { RealtimeEvent } from "@/lib/types"; const API_BASE = "/api"; @@ -10,7 +11,11 @@ interface UseRealtimeOptions { } export function useRealtime(options: UseRealtimeOptions = {}) { - const { workspaceId, enabled = true } = options; + const { workspaceId: explicitWorkspace, enabled = true } = options; + // Default to the user's active domain so clients never receive (or act on) + // events from other workspaces. Callers can still pin a specific workspace. + const activeDomainId = useApiDomain(); + const workspaceId = explicitWorkspace || activeDomainId || undefined; const queryClient = useQueryClient(); const eventSourceRef = useRef(null); const reconnectTimeoutRef = useRef | null>(null); @@ -23,13 +28,13 @@ export function useRealtime(options: UseRealtimeOptions = {}) { switch (entityType) { case "task": - queryKeys.push(["tasks"], ["tasks-due"], ["stats"], ["productivity-chart"]); + queryKeys.push(["tasks"], ["tasks-due"], ["stats"], ["productivity-chart"], ["analytics-daily"], ["analytics-projects"]); break; case "habit": - queryKeys.push(["habits"], ["habits-today"], ["streaks"]); + queryKeys.push(["habits"], ["habits-today"], ["streaks"], ["analytics-habits"]); break; case "project": - queryKeys.push(["projects"], ["active-projects"]); + queryKeys.push(["projects"], ["active-projects"], ["analytics-projects"]); break; case "note": queryKeys.push(["notes"], ["recent-notes"]); @@ -40,6 +45,29 @@ export function useRealtime(options: UseRealtimeOptions = {}) { case "dashboard_widget": queryKeys.push(["dashboard-widgets"]); break; + case "daily_note": + queryKeys.push(["daily-notes-list"], ["daily-note"]); + break; + case "agent": + queryKeys.push(["agents"], ["agents-list"], ["agent-activity"]); + break; + case "canvas": + queryKeys.push(["canvas"]); + break; + case "webhook": + queryKeys.push(["webhooks"]); + break; + case "custom_field": + queryKeys.push(["custom-fields"]); + break; + case "section": + case "member": + queryKeys.push(["projects"]); + break; + case "comment": + case "attachment": + queryKeys.push(["tasks"], ["task"]); + break; case "graph_edge": queryKeys.push(["graph"]); break; diff --git a/apps/web/src/lib/session.ts b/apps/web/src/lib/session.ts new file mode 100644 index 0000000..75be85e --- /dev/null +++ b/apps/web/src/lib/session.ts @@ -0,0 +1,25 @@ +import { useAuthStore } from "./stores/use-auth-store"; + +/** + * Boot-time session check. Runs once before the app renders so the shell never + * flashes for unauthenticated users and the logged-in identity is available + * immediately. Redirects to /login when unauthenticated and to / when an + * authenticated user lands on /login. + */ +export async function bootstrapSession(): Promise { + try { + const res = await fetch("/api/auth/session", { credentials: "include" }); + const data = await res.json().catch(() => ({ authenticated: false, user: null })); + const user = data?.authenticated ? data.user : null; + useAuthStore.setState({ user: user ?? null, checked: true }); + const path = window.location.pathname; + if (user && path === "/login") { + window.location.replace("/"); + } else if (!user && path !== "/login") { + window.location.replace("/login"); + } + } catch { + useAuthStore.setState({ user: null, checked: true }); + if (window.location.pathname !== "/login") window.location.replace("/login"); + } +} diff --git a/apps/web/src/lib/stores/use-auth-store.ts b/apps/web/src/lib/stores/use-auth-store.ts new file mode 100644 index 0000000..7fcc7c0 --- /dev/null +++ b/apps/web/src/lib/stores/use-auth-store.ts @@ -0,0 +1,21 @@ +import { create } from "zustand"; + +export interface AuthUser { + id: string; + email: string; + name: string; +} + +interface AuthState { + user: AuthUser | null; + checked: boolean; + setUser: (user: AuthUser | null) => void; + setChecked: (checked: boolean) => void; +} + +export const useAuthStore = create()((set) => ({ + user: null, + checked: false, + setUser: (user) => set({ user }), + setChecked: (checked) => set({ checked }), +})); diff --git a/apps/web/src/lib/stores/use-sidebar-store.ts b/apps/web/src/lib/stores/use-sidebar-store.ts index e696d1e..3a460a7 100644 --- a/apps/web/src/lib/stores/use-sidebar-store.ts +++ b/apps/web/src/lib/stores/use-sidebar-store.ts @@ -22,6 +22,9 @@ export const useSidebarStore = create()( }), { name: "project-e-sidebar", + // Never persist the transient mobile drawer state — restoring it on the + // next load would reopen the Sheet (and its dark overlay) on desktop. + partialize: (state) => ({ collapsed: state.collapsed }) as SidebarState, } ) ); diff --git a/apps/web/src/lib/types/index.ts b/apps/web/src/lib/types/index.ts index 9e067c2..63eebf6 100644 --- a/apps/web/src/lib/types/index.ts +++ b/apps/web/src/lib/types/index.ts @@ -104,8 +104,9 @@ export interface Note { } export interface Backlink { - noteId: string; - noteTitle: string; + id: string; + title: string; + excerpt?: string; } export interface OutgoingLink { @@ -246,9 +247,10 @@ export interface WebhookDelivery { export interface ErrorLog { id: string; level: string; + source: string; message: string; - stack: string | null; - context: Record | null; + stackTrace: string | null; + metadata: Record | null; createdAt: string; } @@ -291,6 +293,8 @@ export interface AgentActivity { entityType: string; entityId: string; metadata: Record | null; + details?: Record | null; + errorMessage?: string | null; createdAt: string; } diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index fdca837..58158b2 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,10 +1,12 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { RouterProvider, createRouter } from "@tanstack/react-router"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClient, QueryClientProvider, MutationCache } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { toast } from "sonner"; import { routeTree } from "./routeTree"; import { ThemeProvider } from "@/components/shell/theme-provider"; +import { bootstrapSession } from "./lib/session"; import "./index.css"; const queryClient = new QueryClient({ @@ -15,6 +17,15 @@ const queryClient = new QueryClient({ refetchOnWindowFocus: true, }, }, + mutationCache: new MutationCache({ + // Surface failures that individual mutations don't handle themselves so + // silent validation/network errors never go unnoticed. + onError: (error, _variables, _context, mutation) => { + if (!mutation.options.onError) { + toast.error((error as Error).message || "Request failed"); + } + }, + }), }); const router = createRouter({ routeTree }); @@ -28,13 +39,15 @@ declare module "@tanstack/react-router" { const rootEl = document.getElementById("root"); if (!rootEl) throw new Error("Root element not found"); -ReactDOM.createRoot(rootEl).render( - - - - - - {import.meta.env.DEV && } - - -); +bootstrapSession().finally(() => { + ReactDOM.createRoot(rootEl).render( + + + + + + {import.meta.env.DEV && } + + + ); +}); diff --git a/apps/web/src/routes/_app.tsx b/apps/web/src/routes/_app.tsx index 0cd1fbc..b211892 100644 --- a/apps/web/src/routes/_app.tsx +++ b/apps/web/src/routes/_app.tsx @@ -10,16 +10,20 @@ import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; function AppLayout() { useKeyboardShortcuts(); - // Apply persisted appearance preferences (density, reduced motion) right - // after the first paint. The settings page updates these live while open; - // this covers reloads where the settings page was never visited. + // Apply persisted appearance preferences (density, reduced motion, font size) + // right after the first paint. The settings page updates these live while + // open; this covers reloads where the settings page was never visited. useEffect(() => { const root = document.documentElement; - root.classList.remove("density-compact", "density-spacious"); + root.classList.remove("density-compact", "density-spacious", "reduce-motion"); const density = localStorage.getItem("density"); if (density === "compact") root.classList.add("density-compact"); if (density === "spacious") root.classList.add("density-spacious"); if (localStorage.getItem("reduced-motion") === "true") root.classList.add("reduce-motion"); + const fontSize = localStorage.getItem("font-size"); + if (fontSize === "large") root.style.fontSize = "18px"; + else if (fontSize === "small") root.style.fontSize = "13px"; + else root.style.fontSize = "16px"; }, []); return ( diff --git a/apps/web/src/routes/_app/agents.tsx b/apps/web/src/routes/_app/agents.tsx index 019f985..9a0fe3c 100644 --- a/apps/web/src/routes/_app/agents.tsx +++ b/apps/web/src/routes/_app/agents.tsx @@ -1,11 +1,15 @@ -import { createRoute } from "@tanstack/react-router"; +import { createRoute, useNavigate } from "@tanstack/react-router"; import { Route as appRoute } from "../_app"; function AgentsPage() { + const navigate = useNavigate(); return (

Agent Activity

-

Coming in T7 — agent activity feed.

+

The agent activity feed moved to its own page.

+
); } diff --git a/apps/web/src/routes/_app/agents/activity.tsx b/apps/web/src/routes/_app/agents/activity.tsx index 51a2f83..7a20cb0 100644 --- a/apps/web/src/routes/_app/agents/activity.tsx +++ b/apps/web/src/routes/_app/agents/activity.tsx @@ -3,6 +3,7 @@ import { createRoute } from "@tanstack/react-router"; import { Route as appRoute } from "../../_app"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { api, useApiQuery, useApiMutation } from "@/lib/api"; +import { useApiDomain } from "@/lib/stores/use-active-domain-store"; import { Bot, Filter, Calendar, RefreshCw, ExternalLink, Clock, Activity } from "lucide-react"; import { Button } from "@/components/ui/button"; import { LoadingState, EmptyState } from "@/components/state"; @@ -32,52 +33,76 @@ function AgentActivityPage() { const [dateTo, setDateTo] = useState(""); const [liveActivities, setLiveActivities] = useState([]); const eventSourceRef = useRef(null); + const activeDomainId = useApiDomain(); + + // Keep latest filters reachable from the SSE effect without reconnecting. + const filtersRef = useRef({ agentFilter, actionFilter, dateFrom, dateTo }); + filtersRef.current = { agentFilter, actionFilter, dateFrom, dateTo }; + + // Live entries are transient and never filtered server-side, so clear them + // whenever the user changes any filter. + useEffect(() => { + setLiveActivities([]); + }, [agentFilter, actionFilter, dateFrom, dateTo]); // Fetch agents for filter dropdown - const { data: agentsData } = useApiQuery>(["agents-list"], "/agents"); + const { data: agentsData } = useApiQuery>(["agents-list", activeDomainId], "/agents" + (activeDomainId ? "?domain=" + activeDomainId : "")); const agents = agentsData?.items || []; // Build query params const params = new URLSearchParams({ limit: "100" }); - if (agentFilter) params.set("agentId", agentFilter); - if (actionFilter) params.set("action", actionFilter); + if (activeDomainId) params.set("domain", activeDomainId); + if (agentFilter && agentFilter !== "all") params.set("agentId", agentFilter); + if (actionFilter && actionFilter !== "all") params.set("action", actionFilter); if (dateFrom) params.set("from", dateFrom); if (dateTo) params.set("to", dateTo); const { data: activityData, isLoading } = useApiQuery<{ items: AgentActivity[]; totalItems: number }>( ["agent-activity", agentFilter, actionFilter, dateFrom, dateTo], - "/agents/" + (agentFilter || "_all") + "/activity?" + params.toString() + "/agents/" + (agentFilter && agentFilter !== "all" ? agentFilter : "_all") + "/activity?" + params.toString() ); const activities = [...liveActivities, ...(activityData?.items || [])]; // SSE for live updates useEffect(() => { - const es = new EventSource("/api/realtime"); + const params = new URLSearchParams(); + if (activeDomainId) params.set("workspace_id", activeDomainId); + const es = new EventSource("/api/realtime" + (params.toString() ? "?" + params.toString() : "")); eventSourceRef.current = es; es.onmessage = (event) => { try { const data = JSON.parse(event.data); // Realtime events are flat: { type: entityType, action, id, workspace_id }. - // Match only agent events so unrelated task/habit/etc. activity doesn't leak in. - if (data.type === "agent") { - const entry: AgentActivity = { - id: `${data.id}-live-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - agentId: data.id, - action: data.action, - description: `Live update: ${data.action}`, - entityType: "agent", - entityId: data.id, - metadata: null, - createdAt: new Date().toISOString(), - }; - setLiveActivities((prev) => [entry, ...prev].slice(0, 5)); - } + // Match only agent events for the active workspace so unrelated task/habit + // etc. activity (or another workspace's events) doesn't leak in. + if (data.type !== "agent") return; + if (activeDomainId && data.workspace_id !== activeDomainId) return; + + const { agentFilter, actionFilter, dateFrom, dateTo } = filtersRef.current; + if (agentFilter && agentFilter !== "all" && data.id !== agentFilter) return; + if (actionFilter && actionFilter !== "all" && data.action !== actionFilter) return; + const now = new Date(); + const nowKey = now.toISOString().slice(0, 10); + if (dateFrom && nowKey < dateFrom) return; + if (dateTo && nowKey > dateTo) return; + + const entry: AgentActivity = { + id: `${data.id}-live-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + agentId: data.id, + action: data.action, + description: `Live update: ${data.action}`, + entityType: "agent", + entityId: data.id, + metadata: null, + createdAt: now.toISOString(), + }; + setLiveActivities((prev) => [entry, ...prev].slice(0, 5)); } catch {} }; es.onerror = () => {}; return () => { es.close(); }; - }, []); + }, [activeDomainId]); const getActionColor = (action: string) => { const found = ACTION_TYPES.find((a) => a.id === action); @@ -106,7 +131,7 @@ function AgentActivityPage() { - All agents + All agents {agents.map((a) => ( {a.name} ))} @@ -118,7 +143,7 @@ function AgentActivityPage() { - All actions + All actions {ACTION_TYPES.map((a) => ( {a.label} ))} @@ -153,6 +178,10 @@ function AgentActivityPage() { {a.entityType} {a.description &&

{a.description}

} + {!a.description && a.details && Object.keys(a.details).length > 0 && ( +

{JSON.stringify(a.details)}

+ )} + {a.errorMessage &&

{a.errorMessage}

}
{format(parseISO(a.createdAt), "MMM d, HH:mm:ss")} diff --git a/apps/web/src/routes/_app/analytics.tsx b/apps/web/src/routes/_app/analytics.tsx index 34750da..a6b1ae6 100644 --- a/apps/web/src/routes/_app/analytics.tsx +++ b/apps/web/src/routes/_app/analytics.tsx @@ -3,6 +3,7 @@ import { createRoute } from "@tanstack/react-router"; import { Route as appRoute } from "../_app"; import { useApiQuery } from "@/lib/api"; import { useApiDomain } from "@/lib/stores/use-active-domain-store"; +import { useRealtime } from "@/hooks/use-realtime"; import { Download, Calendar } from "lucide-react"; import { Button } from "@/components/ui/button"; import { LoadingState, ErrorState } from "@/components/state"; @@ -140,6 +141,8 @@ function AnalyticsPage() { const activeDomainId = useApiDomain(); const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; + useRealtime({ enabled: true }); + const { data: habitData, isLoading: habitsLoading, error: habitsError, refetch: refetchHabits } = useApiQuery(["analytics-habits", activeDomainId, range], "/analytics/habits?range=" + range + domainSuffix); const { data: projectData, isLoading: projectsLoading, error: projectsError, refetch: refetchProjects } = useApiQuery(["analytics-projects", activeDomainId, range], "/analytics/projects?range=" + range + domainSuffix); const { data: dailyData, isLoading: dailyLoading, error: dailyError, refetch: refetchDaily } = useApiQuery(["analytics-daily", activeDomainId, range], "/analytics/daily?range=" + range + domainSuffix); diff --git a/apps/web/src/routes/_app/calendar.tsx b/apps/web/src/routes/_app/calendar.tsx index a2133cd..f9b3f32 100644 --- a/apps/web/src/routes/_app/calendar.tsx +++ b/apps/web/src/routes/_app/calendar.tsx @@ -5,6 +5,7 @@ import { useQueryClient, useMutation } from "@tanstack/react-query"; import { api, useApiQuery, useApiMutation } from "@/lib/api"; import { useApiDomain } from "@/lib/stores/use-active-domain-store"; import { useRealtime } from "@/hooks/use-realtime"; +import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog"; import { Plus, Trash2, ChevronLeft, ChevronRight } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -95,6 +96,7 @@ function CustomToolbar({ date, onNavigate, label }: any) { function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => void }) { const queryClient = useQueryClient(); + const activeDomainId = useApiDomain(); const [title, setTitle] = useState(event?.title || ""); const [startTime, setStartTime] = useState(event?.startTime ? event.startTime.slice(0, 16) : ""); const [endTime, setEndTime] = useState(event?.endTime ? event.endTime.slice(0, 16) : ""); @@ -126,7 +128,7 @@ function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => v if (startTime) data.startTime = new Date(startTime).toISOString(); if (endTime) data.endTime = new Date(endTime).toISOString(); if (event) updateMutation.mutate(data); - else createMutation.mutate(data); + else createMutation.mutate({ ...data, ...(activeDomainId ? { domain: activeDomainId } : {}) }); }; return ( @@ -142,7 +144,7 @@ function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => v
- setStartTime(e.target.value)} /> + setStartTime(e.target.value)} required />
@@ -172,6 +174,8 @@ function CalendarPage() { useRealtime({ enabled: true }); + useOpenCreateDialog("event", () => setCreateOpen(true)); + useEffect(() => { const check = () => setIsMobile(window.innerWidth < 768); check(); @@ -183,7 +187,7 @@ function CalendarPage() { const activeDomainId = useApiDomain(); const { data: eventsData, isLoading: eventsLoading } = useApiQuery<{ items: CalendarEvent[]; totalItems: number }>( - ["calendar-events", activeDomainId, date.toISOString()], + ["calendar-events", activeDomainId], `/calendar/events?from=${new Date(0).toISOString()}&to=${new Date("2100-01-01").toISOString()}` + (activeDomainId ? "&domain=" + activeDomainId : "") ); diff --git a/apps/web/src/routes/_app/canvas.tsx b/apps/web/src/routes/_app/canvas.tsx index eed8fdd..f457812 100644 --- a/apps/web/src/routes/_app/canvas.tsx +++ b/apps/web/src/routes/_app/canvas.tsx @@ -15,6 +15,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; import { cn } from "@/lib/utils"; +import { useApiDomain } from "@/lib/stores/use-active-domain-store"; import type { Canvas, CanvasCard, PaginatedResponse } from "@/lib/types"; const BLOCK_TYPES = [ @@ -382,12 +383,13 @@ function CanvasList() { const navigate = useNavigate(); const [createOpen, setCreateOpen] = useState(false); const [newName, setNewName] = useState(""); + const activeDomainId = useApiDomain(); - const { data, isLoading } = useApiQuery>(["canvas"], "/canvas"); + const { data, isLoading } = useApiQuery>(["canvas", activeDomainId], "/canvas" + (activeDomainId ? "?domain=" + activeDomainId : "")); const canvases = data?.items || []; const createMutation = useMutation({ - mutationFn: (name: string) => api.post("/canvas", { name }), + mutationFn: (name: string) => api.post("/canvas", { name, ...(activeDomainId ? { domain: activeDomainId } : {}) }), onSuccess: (canvas) => { queryClient.invalidateQueries({ queryKey: ["canvas"] }); setCreateOpen(false); @@ -429,9 +431,34 @@ function CanvasList() { ) : (
{canvases.map((c) => ( - navigate({ to: "/canvas/$id", params: { id: c.id } })}> + navigate({ to: "/canvas/$id", params: { id: c.id } })}> - {c.name} +
+ {c.name} + + + + + + + Delete Canvas + Are you sure you want to delete "{c.name}"? All blocks in it will be removed. This cannot be undone. + + + e.stopPropagation()}>Cancel + { e.stopPropagation(); deleteMutation.mutate(c.id); }} className="bg-destructive text-destructive-foreground">Delete + + + +
diff --git a/apps/web/src/routes/_app/daily.tsx b/apps/web/src/routes/_app/daily.tsx index c55efe1..a0e41a4 100644 --- a/apps/web/src/routes/_app/daily.tsx +++ b/apps/web/src/routes/_app/daily.tsx @@ -13,6 +13,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; import { ScrollArea } from "@/components/ui/scroll-area"; import { cn } from "@/lib/utils"; +import { useApiDomain } from "@/lib/stores/use-active-domain-store"; import type { DailyNote } from "@/lib/types"; import { format, startOfMonth, endOfMonth, eachDayOfInterval, getDay, isSameDay, isToday, addMonths, subMonths } from "date-fns"; @@ -26,7 +27,8 @@ function CalendarSidebar({ selectedDate, onSelectDate }: { selectedDate: Date; o const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; // Check which dates have notes - const { data } = useApiQuery<{ items: DailyNote[]; totalItems: number }>(["daily-notes-list"], "/daily-notes"); + const activeDomainId = useApiDomain(); + const { data } = useApiQuery<{ items: DailyNote[]; totalItems: number }>(["daily-notes-list", activeDomainId], "/daily-notes" + (activeDomainId ? "?domain=" + activeDomainId : "")); const notes = data?.items || []; // The API stores daily notes at UTC midnight (YYYY-MM-DDT00:00:00.000Z). // Slicing off the time portion yields the calendar date the note belongs to @@ -88,6 +90,8 @@ function CalendarSidebar({ selectedDate, onSelectDate }: { selectedDate: Date; o function DailyNoteEditor({ date }: { date: Date }) { const queryClient = useQueryClient(); + const activeDomainId = useApiDomain(); + const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; const dateStr = format(date, "yyyy-MM-dd"); const [content, setContent] = useState(""); const [mood, setMood] = useState(null); @@ -106,8 +110,8 @@ function DailyNoteEditor({ date }: { date: Date }) { noteIdRef.current = noteId; const { data: note, isLoading } = useApiQuery( - ["daily-note", dateStr], - "/daily-notes?date=" + dateStr + ["daily-note", dateStr, activeDomainId], + "/daily-notes?date=" + dateStr + domainSuffix ); useEffect(() => { @@ -174,10 +178,10 @@ function DailyNoteEditor({ date }: { date: Date }) { if (id) { updateMutation.mutate({ id, data: { content: newContent, mood: newMood, energy: newEnergy } }); } else if (newContent.trim()) { - createMutation.mutate({ date: dateStr, content: newContent, mood: newMood, energy: newEnergy }); + createMutation.mutate({ date: dateStr, content: newContent, mood: newMood, energy: newEnergy, ...(activeDomainId ? { domain: activeDomainId } : {}) }); } }, 1500); - }, [dateStr, updateMutation, createMutation]); + }, [dateStr, activeDomainId, updateMutation, createMutation]); const handleContentChange = (value: string) => { setContent(value); @@ -191,7 +195,7 @@ function DailyNoteEditor({ date }: { date: Date }) { } else if (isNew && !createMutation.isPending) { // No note exists for this day yet — create it so the mood is recorded // even before any content is typed. - createMutation.mutate({ date: dateStr, content: content, mood: value, energy: energy }); + createMutation.mutate({ date: dateStr, content: content, mood: value, energy: energy, ...(activeDomainId ? { domain: activeDomainId } : {}) }); } }; @@ -202,7 +206,7 @@ function DailyNoteEditor({ date }: { date: Date }) { } else if (isNew && !createMutation.isPending) { // No note exists for this day yet — create it so the energy is recorded // even before any content is typed. - createMutation.mutate({ date: dateStr, content: content, mood: mood, energy: value }); + createMutation.mutate({ date: dateStr, content: content, mood: mood, energy: value, ...(activeDomainId ? { domain: activeDomainId } : {}) }); } }; diff --git a/apps/web/src/routes/_app/habits.tsx b/apps/web/src/routes/_app/habits.tsx index c8269fd..676b85e 100644 --- a/apps/web/src/routes/_app/habits.tsx +++ b/apps/web/src/routes/_app/habits.tsx @@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { api, useApiQuery, useApiMutation } from "@/lib/api"; import { useApiDomain } from "@/lib/stores/use-active-domain-store"; import { useRealtime } from "@/hooks/use-realtime"; +import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog"; import { Plus, Flame, Trash2, Check, Pencil } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -23,6 +24,7 @@ import type { Habit, HabitCompletion, PaginatedResponse } from "@/lib/types"; function HabitForm({ habit, onClose }: { habit?: Habit; onClose: () => void }) { const queryClient = useQueryClient(); + const activeDomainId = useApiDomain(); const [name, setName] = useState(habit?.name || ""); const [description, setDescription] = useState(habit?.description || ""); const [frequency, setFrequency] = useState(habit?.frequency || "daily"); @@ -44,7 +46,7 @@ function HabitForm({ habit, onClose }: { habit?: Habit; onClose: () => void }) { if (!name.trim()) return; const data = { name: name.trim(), description: description || null, frequency, difficulty, goalPerPeriod }; if (habit) updateMutation.mutate(data); - else createMutation.mutate(data); + else createMutation.mutate({ ...data, ...(activeDomainId ? { domain: activeDomainId } : {}) }); }; return ( @@ -124,14 +126,35 @@ function HabitsPage() { useRealtime({ enabled: true }); + useOpenCreateDialog("habit", () => setCreateOpen(true)); + const activeDomainId = useApiDomain(); + const habitQueryUrl = () => "/habits?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : ""); + const { data: habitsData, isLoading, isError, refetch } = useApiQuery>( ["habits", activeDomainId], - "/habits?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "") + habitQueryUrl() ); const habits = habitsData?.items || []; + const hasMoreHabits = habits.length < (habitsData?.totalItems || 0); + const [loadingMoreHabits, setLoadingMoreHabits] = useState(false); + + const loadMoreHabits = async () => { + if (!hasMoreHabits || loadingMoreHabits) return; + setLoadingMoreHabits(true); + try { + const next = await api.get>(habitQueryUrl() + "&offset=" + habits.length); + queryClient.setQueryData>(["habits", activeDomainId], (old) => { + if (!old) return old; + const seen = new Set(old.items.map((h) => h.id)); + return { ...old, items: [...old.items, ...next.items.filter((h) => !seen.has(h.id))] }; + }); + } finally { + setLoadingMoreHabits(false); + } + }; const completeMutation = useMutation({ mutationFn: (id: string) => api.post("/habits/" + id + "/complete", {}), @@ -203,6 +226,11 @@ function HabitsPage() {
)} - + {hasMoreHabits && ( +
+ +
+ )} + + { setPanelOpen(o); if (!o) setDetailTab("overview"); }} title={selectedHabit?.name || "Habit Details"}> {selectedHabit && (
diff --git a/apps/web/src/routes/_app/index.tsx b/apps/web/src/routes/_app/index.tsx index 438b6f4..e16937b 100644 --- a/apps/web/src/routes/_app/index.tsx +++ b/apps/web/src/routes/_app/index.tsx @@ -4,6 +4,7 @@ import { Route as appRoute } from "../_app"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { api, useApiQuery, useApiMutation } from "@/lib/api"; import { useRealtime } from "@/hooks/use-realtime"; +import { useApiDomain } from "@/lib/stores/use-active-domain-store"; import { Plus, Settings2, Trash2, ListTodo, Flame, FileText, FolderKanban, Calendar, Zap, TrendingUp, Target } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -31,7 +32,9 @@ const WIDGET_TYPES = [ ] as const; function TasksDueWidget() { - const { data } = useApiQuery>(["tasks-due"], "/tasks?limit=10&status=todo,in_progress&sort=due_date"); + const activeDomainId = useApiDomain(); + const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; + const { data } = useApiQuery>(["tasks-due", activeDomainId], "/tasks?limit=10&status=todo,in_progress&sort=due_date" + domainSuffix); const tasks = data?.items || []; const today = tasks.filter((t) => t.dueDate && isToday(parseISO(t.dueDate))); const overdue = tasks.filter((t) => t.dueDate && isPast(parseISO(t.dueDate)) && t.status !== "done"); @@ -70,7 +73,9 @@ function TasksDueWidget() { } function HabitsTodayWidget() { - const { data } = useApiQuery>(["habits-today"], "/habits?limit=20"); + const activeDomainId = useApiDomain(); + const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; + const { data } = useApiQuery>(["habits-today", activeDomainId], "/habits?limit=20" + domainSuffix); const habits = data?.items || []; const queryClient = useQueryClient(); const completeMutation = useMutation({ @@ -87,30 +92,39 @@ function HabitsTodayWidget() { {habits.length === 0 ? (

No habits yet

) : ( - habits.slice(0, 6).map((h) => ( -
- - {h.name} - {h.streakCount > 0 && ( - - {h.streakCount} - - )} -
- )) + habits.slice(0, 6).map((h) => { + const doneToday = (h.recentCompletions || []).some((c) => { + const d = new Date(c.date); + const today = new Date(); + return d.getUTCFullYear() === today.getUTCFullYear() && d.getUTCMonth() === today.getUTCMonth() && d.getUTCDate() === today.getUTCDate(); + }); + return ( +
+ + {h.name} + {h.streakCount > 0 && ( + + {h.streakCount} + + )} +
+ ); + }) )}
); } function RecentNotesWidget() { - const { data } = useApiQuery>(["recent-notes"], "/notes?limit=5&sort=-updated"); + const activeDomainId = useApiDomain(); + const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; + const { data } = useApiQuery>(["recent-notes", activeDomainId], "/notes?limit=5&sort=-updated" + domainSuffix); const notes = data?.items || []; return (
@@ -129,7 +143,9 @@ function RecentNotesWidget() { } function ActiveProjectsWidget() { - const { data } = useApiQuery>(["active-projects"], "/projects?limit=10&status=active"); + const activeDomainId = useApiDomain(); + const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; + const { data } = useApiQuery>(["active-projects", activeDomainId], "/projects?limit=10&status=active" + domainSuffix); const projects = data?.items || []; return (
@@ -151,7 +167,9 @@ function ActiveProjectsWidget() { } function UpcomingEventsWidget() { - const { data } = useApiQuery>(["upcoming-events"], "/calendar/events?limit=20"); + const activeDomainId = useApiDomain(); + const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; + const { data } = useApiQuery>(["upcoming-events", activeDomainId], "/calendar/events?limit=20" + domainSuffix); const events = data?.items || []; const now = new Date(); const weekFromNow = addDays(now, 7); @@ -177,7 +195,9 @@ function UpcomingEventsWidget() { } function StreakCounterWidget() { - const { data } = useApiQuery>(["streaks"], "/habits?limit=50"); + const activeDomainId = useApiDomain(); + const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; + const { data } = useApiQuery>(["streaks", activeDomainId], "/habits?limit=50" + domainSuffix); const habits = data?.items || []; const bestStreak = Math.max(...habits.map((h) => h.streakCount || 0), 0); const totalActive = habits.filter((h) => h.streakCount > 0).length; @@ -199,10 +219,11 @@ function StreakCounterWidget() { function QuickCaptureWidget() { const queryClient = useQueryClient(); + const activeDomainId = useApiDomain(); const [text, setText] = useState(""); const [type, setType] = useState<"task" | "note">("task"); const createTask = useMutation({ - mutationFn: (title: string) => api.post("/tasks", { title, status: "todo", priority: "medium" }), + mutationFn: (title: string) => api.post("/tasks", { title, status: "todo", priority: "medium", ...(activeDomainId ? { domain: activeDomainId } : {}) }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["tasks-due"] }); setText(""); @@ -211,7 +232,7 @@ function QuickCaptureWidget() { onError: (err) => toast.error(err.message || "Failed to create task"), }); const createNote = useMutation({ - mutationFn: (title: string) => api.post("/notes", { title, content: "" }), + mutationFn: (title: string) => api.post("/notes", { title, content: "", ...(activeDomainId ? { domain: activeDomainId } : {}) }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["recent-notes"] }); setText(""); @@ -244,7 +265,9 @@ function QuickCaptureWidget() { } function ProductivityChartWidget() { - const { data } = useApiQuery(["productivity-chart"], "/analytics/productivity?range=30"); + const activeDomainId = useApiDomain(); + const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; + const { data } = useApiQuery(["productivity-chart", activeDomainId], "/analytics/productivity?range=30" + domainSuffix); const stats = data; if (!stats) return

Loading...

; return ( @@ -269,7 +292,9 @@ function ProductivityChartWidget() { } function StatsWidget() { - const { data } = useApiQuery(["stats"], "/analytics/productivity?range=30"); + const activeDomainId = useApiDomain(); + const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; + const { data } = useApiQuery(["stats", activeDomainId], "/analytics/productivity?range=30" + domainSuffix); const stats = data; if (!stats) return

Loading...

; return ( @@ -379,7 +404,6 @@ function ConfigureWidgetDialog({ widget, open, onOpenChange, onSave }: { widget: 2 columns 3 columns 4 columns - 6 columns
@@ -459,7 +483,13 @@ function DashboardPage() { ) : widgets.length === 0 ? (

Your dashboard is empty. Add some widgets to get started!

- +
) : (
diff --git a/apps/web/src/routes/_app/notes.tsx b/apps/web/src/routes/_app/notes.tsx index abcac1a..75c152c 100644 --- a/apps/web/src/routes/_app/notes.tsx +++ b/apps/web/src/routes/_app/notes.tsx @@ -5,13 +5,16 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { api, useApiQuery } from "@/lib/api"; import { useApiDomain } from "@/lib/stores/use-active-domain-store"; import { useRealtime } from "@/hooks/use-realtime"; +import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog"; import { Plus, Trash2, Search, Pin, FileText, Link as LinkIcon, History } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; import { ScrollArea } from "@/components/ui/scroll-area"; import { cn } from "@/lib/utils"; import type { Note, PaginatedResponse } from "@/lib/types"; +import { format, parseISO } from "date-fns"; import { useEditor, EditorContent } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import Link from "@tiptap/extension-link"; @@ -135,9 +138,10 @@ const NoteTitleInput = memo(function NoteTitleInput({ noteId, initialTitle }: { }); // Memoized right pane - only re-renders when note changes, not on parent re-renders -const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete }: { note: Note; onDelete: (id: string) => void }) { +const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete, onOpenNote }: { note: Note; onDelete: (id: string) => void; onOpenNote: (note: Note) => void }) { const [showBacklinks, setShowBacklinks] = useState(false); const [showVersions, setShowVersions] = useState(false); + const [versions, setVersions] = useState<{ id: string; createdAt: string; action?: string; changes?: Record | null }[]>([]); const queryClient = useQueryClient(); const updateMutation = useMutation({ @@ -157,7 +161,7 @@ const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete }: { note: - @@ -188,8 +192,12 @@ const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete }: { note:

Linked from

{note.backlinks.map((bl) => ( -
- {bl.noteTitle} +
{ const linked: Note = { ...note, id: bl.id, title: bl.title }; onOpenNote(linked); }} + > + {bl.title}
))}
@@ -199,7 +207,18 @@ const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete }: { note: {showVersions && (

Version History

-

Version history available via API.

+ {versions.length === 0 ? ( +

No versions yet.

+ ) : ( +
+ {versions.map((v) => ( +
+ {format(parseISO(v.createdAt), "MMM d, yyyy HH:mm")} + {v.action && {v.action}} +
+ ))} +
+ )}
)} @@ -214,17 +233,39 @@ function NotesPage() { useRealtime({ enabled: true }); + useOpenCreateDialog("note", () => createMutation.mutate()); + const activeDomainId = useApiDomain(); + const notesQueryUrl = () => + "/notes?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "") + (search ? "&search=" + encodeURIComponent(search) : ""); + const { data: notesData, isLoading } = useApiQuery>( ["notes", activeDomainId, search], - "/notes?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "") + (search ? "&search=" + encodeURIComponent(search) : "") + notesQueryUrl() ); const notes = notesData?.items || []; + const hasMoreNotes = notes.length < (notesData?.totalItems || 0); + const [loadingMoreNotes, setLoadingMoreNotes] = useState(false); + + const loadMoreNotes = async () => { + if (!hasMoreNotes || loadingMoreNotes) return; + setLoadingMoreNotes(true); + try { + const next = await api.get>(notesQueryUrl() + "&offset=" + notes.length); + queryClient.setQueryData>(["notes", activeDomainId, search], (old) => { + if (!old) return old; + const seen = new Set(old.items.map((n) => n.id)); + return { ...old, items: [...old.items, ...next.items.filter((n) => !seen.has(n.id))] }; + }); + } finally { + setLoadingMoreNotes(false); + } + }; const createMutation = useMutation({ - mutationFn: () => api.post("/notes", { title: "Untitled", content: "" }), + mutationFn: () => api.post("/notes", { title: "Untitled", content: "", ...(activeDomainId ? { domain: activeDomainId } : {}) }), onSuccess: (note) => { queryClient.invalidateQueries({ queryKey: ["notes"] }); selectedNoteRef.current = note; @@ -264,7 +305,7 @@ function NotesPage() {
- setSearch(e.target.value)} className="pl-8" tabIndex={-1} onMouseDown={(e) => e.preventDefault()} /> + setSearch(e.target.value)} className="pl-8" aria-label="Search notes" />
@@ -299,13 +340,20 @@ function NotesPage() { ))}
)} + {hasMoreNotes && ( +
+ +
+ )}
{/* Right pane - editor (memoized, won't re-render on parent state changes) */}
{selectedNote ? ( - + ) : (
diff --git a/apps/web/src/routes/_app/projects.tsx b/apps/web/src/routes/_app/projects.tsx index 128d315..b783cda 100644 --- a/apps/web/src/routes/_app/projects.tsx +++ b/apps/web/src/routes/_app/projects.tsx @@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { api, useApiQuery, useApiMutation } from "@/lib/api"; import { useApiDomain } from "@/lib/stores/use-active-domain-store"; import { useRealtime } from "@/hooks/use-realtime"; +import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog"; import { Plus, Pencil, Trash2, FolderKanban, Users, Calendar, ListTodo, GripVertical } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -26,6 +27,7 @@ import type { Project, Section, Task, PaginatedResponse } from "@/lib/types"; function ProjectForm({ project, onClose }: { project?: Project; onClose: () => void }) { const queryClient = useQueryClient(); + const activeDomainId = useApiDomain(); const [name, setName] = useState(project?.name || ""); const [description, setDescription] = useState(project?.description || ""); const [status, setStatus] = useState(project?.status || "active"); @@ -48,7 +50,7 @@ function ProjectForm({ project, onClose }: { project?: Project; onClose: () => v const data: any = { name: name.trim(), description: description || null, status, color }; if (targetDate) data.targetDate = new Date(targetDate).toISOString(); if (project) updateMutation.mutate(data); - else createMutation.mutate(data); + else createMutation.mutate({ ...data, ...(activeDomainId ? { domain: activeDomainId } : {}) }); }; return ( @@ -103,14 +105,35 @@ function ProjectsPage() { useRealtime({ enabled: true }); + useOpenCreateDialog("project", () => setCreateOpen(true)); + const activeDomainId = useApiDomain(); + const projectQueryUrl = () => "/projects?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : ""); + const { data: projectsData, isLoading, isError, refetch } = useApiQuery>( ["projects", activeDomainId], - "/projects?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "") + projectQueryUrl() ); const projects = projectsData?.items || []; + const hasMoreProjects = projects.length < (projectsData?.totalItems || 0); + const [loadingMoreProjects, setLoadingMoreProjects] = useState(false); + + const loadMoreProjects = async () => { + if (!hasMoreProjects || loadingMoreProjects) return; + setLoadingMoreProjects(true); + try { + const next = await api.get>(projectQueryUrl() + "&offset=" + projects.length); + queryClient.setQueryData>(["projects", activeDomainId], (old) => { + if (!old) return old; + const seen = new Set(old.items.map((p) => p.id)); + return { ...old, items: [...old.items, ...next.items.filter((p) => !seen.has(p.id))] }; + }); + } finally { + setLoadingMoreProjects(false); + } + }; const deleteMutation = useMutation({ mutationFn: (id: string) => api.delete("/projects/" + id), @@ -191,6 +214,14 @@ function ProjectsPage() {
)} + {hasMoreProjects && ( +
+ +
+ )} + {selectedProject && ( diff --git a/apps/web/src/routes/_app/search.tsx b/apps/web/src/routes/_app/search.tsx index c104853..a84317d 100644 --- a/apps/web/src/routes/_app/search.tsx +++ b/apps/web/src/routes/_app/search.tsx @@ -10,6 +10,7 @@ import { Badge } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; import { cn } from "@/lib/utils"; +import { useApiDomain } from "@/lib/stores/use-active-domain-store"; import type { SearchResult } from "@/lib/types"; const SEARCH_TYPES = [ @@ -54,9 +55,11 @@ function SearchPage() { if (inputRef.current) inputRef.current.focus(); }, []); + const activeDomainId = useApiDomain(); + const { data: searchData, isLoading } = useApiQuery<{ results: SearchResult[]; totalCount: number }>( - ["search", debouncedQuery, ...Array.from(selectedTypes)], - "/search?q=" + encodeURIComponent(debouncedQuery) + "&types=" + Array.from(selectedTypes).join(",") + "&limit=50" + ["search", activeDomainId, debouncedQuery, ...Array.from(selectedTypes)], + "/search?q=" + encodeURIComponent(debouncedQuery) + "&types=" + Array.from(selectedTypes).join(",") + "&limit=50" + (activeDomainId ? "&domain=" + activeDomainId : "") ); const results = searchData?.results || []; @@ -166,7 +169,7 @@ function SearchPage() {
navigate({ to: result.link as any })} + onClick={() => navigate({ to: result.type === "domain" ? "/settings" : (result.link as any) })} >

{result.title}

diff --git a/apps/web/src/routes/_app/settings.tsx b/apps/web/src/routes/_app/settings.tsx index 2d27c2f..15d2f67 100644 --- a/apps/web/src/routes/_app/settings.tsx +++ b/apps/web/src/routes/_app/settings.tsx @@ -19,6 +19,7 @@ import { Separator } from "@/components/ui/separator"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; import { cn } from "@/lib/utils"; import { useThemeStore, ACCENT_PALETTE, type ThemeMode, type AccentColor } from "@/lib/stores/use-theme-store"; +import { useApiDomain } from "@/lib/stores/use-active-domain-store"; import type { Domain, CustomField, Webhook as WebhookType, ErrorLog, Agent, PaginatedResponse } from "@/lib/types"; const SETTINGS_TABS = [ @@ -40,15 +41,19 @@ const ACCENT_COLORS = Object.entries(ACCENT_PALETTE).map(([key, val]) => ({ const SHORTCUTS_MAP: Record = { "Cmd+K": "Command palette", + "Cmd+N": "New task", "g+t": "Go to Tasks", "g+h": "Go to Habits", "g+p": "Go to Projects", "g+n": "Go to Notes", "g+c": "Go to Calendar", + "g+g": "Go to Graph", "g+d": "Go to Dashboard", "g+s": "Go to Settings", - "g+a": "Go to Analytics", - "n": "New task / note (context dependent)", + "n+t": "New task", + "n+h": "New habit", + "n+p": "New project", + "n+n": "New note", "?": "Show keyboard shortcuts help", }; @@ -204,7 +209,7 @@ function DomainsTab() { - Delete DomainAre you sure? This cannot be undone. + Delete DomainAre you sure? This permanently deletes this workspace and all of its tasks, habits, projects, notes, calendar events, and settings. This cannot be undone. Cancel deleteMutation.mutate(d.id)} className="bg-destructive">Delete @@ -279,8 +284,9 @@ function TagsTab() { function CustomFieldsTab() { const queryClient = useQueryClient(); + const activeDomainId = useApiDomain(); const [entityFilter, setEntityFilter] = useState(""); - const { data } = useApiQuery>(["custom-fields", entityFilter], "/custom-fields" + (entityFilter ? "?entity=" + entityFilter : "")); + const { data } = useApiQuery>(["custom-fields", entityFilter, activeDomainId], "/custom-fields" + (activeDomainId ? "?domain=" + activeDomainId : "") + (entityFilter && entityFilter !== "all" ? "&entity=" + entityFilter : "")); const fields = data?.items || []; const [createOpen, setCreateOpen] = useState(false); const [editField, setEditField] = useState(null); @@ -303,7 +309,7 @@ function CustomFieldsTab() { const data: any = { name: form.name, type: form.type, entityType: form.entityType, required: form.required }; if (form.type === "select" || form.type === "multi_select") data.options = form.options.split(",").map((s) => s.trim()).filter(Boolean); if (editField) updateMutation.mutate({ id: editField.id, data }); - else createMutation.mutate(data); + else createMutation.mutate({ ...data, ...(activeDomainId ? { domain: activeDomainId } : {}) }); }; return ( @@ -314,7 +320,7 @@ function CustomFieldsTab() {
- +
@@ -592,7 +599,8 @@ function AgentsTab() { function WebhooksTab() { const queryClient = useQueryClient(); - const { data } = useApiQuery>(["webhooks"], "/webhooks"); + const activeDomainId = useApiDomain(); + const { data } = useApiQuery>(["webhooks", activeDomainId], "/webhooks" + (activeDomainId ? "?domain=" + activeDomainId : "")); const webhooks = data?.items || []; const [createOpen, setCreateOpen] = useState(false); const [form, setForm] = useState({ name: "", url: "", events: "task.created,note.created" }); @@ -607,6 +615,8 @@ function WebhooksTab() { }); const testMutation = useMutation({ mutationFn: (id: string) => api.post("/webhooks/" + id + "/test", {}), + onSuccess: () => toast.success("Test webhook queued"), + onError: (err) => toast.error(err.message || "Failed to queue test webhook"), }); return ( @@ -621,7 +631,7 @@ function WebhooksTab() {
setForm({ ...form, name: e.target.value })} />
setForm({ ...form, url: e.target.value })} placeholder="https://example.com/webhook" />
setForm({ ...form, events: e.target.value })} />
- +
@@ -699,6 +709,7 @@ function downloadBlob(blob: Blob, filename: string) { function ImportExportTab() { const queryClient = useQueryClient(); + const activeDomainId = useApiDomain(); const [importData, setImportData] = useState(""); const [importResult, setImportResult] = useState(null); const [exportFormat, setExportFormat] = useState("json"); @@ -749,7 +760,7 @@ function ImportExportTab() { const handleExport = async () => { try { - const data = await api.post("/export", { collections: exportCollections }); + const data = await api.post("/export", { collections: exportCollections, ...(activeDomainId ? { domain: activeDomainId } : {}) }); if (exportFormat === "csv") { // One CSV file per selected collection; empty collections are skipped. @@ -837,7 +848,7 @@ function ImportExportTab() { function ErrorLogTab() { const [level, setLevel] = useState(""); - const { data } = useApiQuery>(["error-log", level], "/error-log" + (level ? "?level=" + level : "")); + const { data } = useApiQuery>(["error-log", level], "/error-log" + (level && level !== "all" ? "?level=" + level : "")); const errors = data?.items || []; const queryClient = useQueryClient(); const [expanded, setExpanded] = useState(null); @@ -855,7 +866,7 @@ function ErrorLogTab() { - All statuses + All statuses {STATUS_COLUMNS.map((c) => ( {c.label} ))} @@ -466,6 +497,14 @@ function TasksPage() {
)} + {hasMoreTasks && ( +
+ +
+ )} + {selectedTask && (
diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx index 1565611..19901e8 100644 --- a/apps/web/src/routes/login.tsx +++ b/apps/web/src/routes/login.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Loader2, Sparkles } from "lucide-react"; +import { useAuthStore } from "@/lib/stores/use-auth-store"; function LoginPage() { const navigate = useNavigate(); @@ -30,6 +31,8 @@ function LoginPage() { setError(data.error?.message || data.message || "Login failed"); return; } + const data = await res.json().catch(() => ({})); + if (data?.user) useAuthStore.getState().setUser(data.user); navigate({ to: "/" }); } catch { setError("Network error");