feat: add server error logging and tighten workspace isolation

This commit is contained in:
2026-08-10 12:41:46 +00:00
parent 6449f6b4cc
commit 1059512888
48 changed files with 1096 additions and 229 deletions
+20
View File
@@ -1,6 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { cors } from "hono/cors"; import { cors } from "hono/cors";
import { logger } from "hono/logger"; import { logger } from "hono/logger";
import { db, errorLogs } from "@project-e/db";
import { authMiddleware } from "./middleware/auth"; import { authMiddleware } from "./middleware/auth";
import { authRoutes } from "./routes/auth"; import { authRoutes } from "./routes/auth";
import { mcpRoutes } from "./routes/mcp"; import { mcpRoutes } from "./routes/mcp";
@@ -63,6 +64,25 @@ app.route("/api", importExportRoutes);
app.route("/api", realtimeRoutes); app.route("/api", realtimeRoutes);
app.route("/api/mcp", mcpRoutes); 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); const port = parseInt(process.env.PORT || "3001", 10);
export default { export default {
+11
View File
@@ -168,6 +168,17 @@ export class AuthError extends Error {
} }
} }
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* True when a value looks like a UUID. Route params that are entity ids should
* be validated with this before hitting the DB so a malformed id returns 404
* instead of a Postgres "invalid input syntax for type uuid" 500.
*/
export function isUuid(value: string | undefined | null): boolean {
return typeof value === "string" && UUID_REGEX.test(value);
}
export function createErrorResponse(code: string, message: string, status: number = 400, details?: unknown) { export function createErrorResponse(code: string, message: string, status: number = 400, details?: unknown) {
return { return {
error: { error: {
+23
View File
@@ -7,6 +7,25 @@ import { z } from "zod";
export const agentRoutes = new Hono(); export const agentRoutes = new Hono();
// Record an entry in the agent_activity feed. The Agent Activity page reads
// this table, so every lifecycle event (create/update) is captured here in
// addition to the global activity_feed. Delete events can't be persisted
// because agent_activity cascades on the owning agent's removal.
async function recordAgentActivity(agent: { id: string; name: string }, action: string, entityType = "agent", details: Record<string, unknown> = {}) {
try {
await db.insert(agentActivity).values({
agentId: agent.id,
action,
entityType,
entityId: agent.id,
details,
success: true,
});
} catch (error) {
console.error("[agents] recordAgentActivity error:", error);
}
}
const createAgentSchema = z.object({ const createAgentSchema = z.object({
name: z.string().min(1, "Name is required"), name: z.string().min(1, "Name is required"),
description: z.string().optional().nullable(), description: z.string().optional().nullable(),
@@ -123,6 +142,8 @@ agentRoutes.post("/", async (c) => {
changes: { name: agent.name }, workspaceId: data.domain, changes: { name: agent.name }, workspaceId: data.domain,
}); });
await recordAgentActivity(agent, "created", "agent", { name: agent.name, permissionTier: agent.permissionTier });
return c.json(agent, 201); return c.json(agent, 201);
} catch (error) { } catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any); 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, changes: { name: updated.name }, workspaceId: existing.domainId,
}); });
await recordAgentActivity(updated, "updated", "agent", { name: updated.name, permissionTier: updated.permissionTier });
return c.json(updated); return c.json(updated);
} catch (error) { } catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any); if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
+9 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, tasks, habits, habitCompletions, projects } from "@project-e/db"; import { db, tasks, habits, habitCompletions, projects } from "@project-e/db";
import { and, eq, gte, inArray, isNull, or } from "drizzle-orm"; 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(); export const analyticsRoutes = new Hono();
@@ -17,6 +17,8 @@ analyticsRoutes.get("/productivity", async (c) => {
domainId = active.id; domainId = active.id;
} }
await requireWorkspaceAccess(c, domainId);
const startDate = new Date(); const startDate = new Date();
startDate.setDate(startDate.getDate() - range); startDate.setDate(startDate.getDate() - range);
@@ -58,6 +60,8 @@ analyticsRoutes.get("/habits", async (c) => {
domainId = active.id; domainId = active.id;
} }
await requireWorkspaceAccess(c, domainId);
const startDate = new Date(); const startDate = new Date();
startDate.setDate(startDate.getDate() - range); startDate.setDate(startDate.getDate() - range);
@@ -113,6 +117,8 @@ analyticsRoutes.get("/projects", async (c) => {
domainId = active.id; domainId = active.id;
} }
await requireWorkspaceAccess(c, domainId);
const allProjects = await db.select() const allProjects = await db.select()
.from(projects) .from(projects)
.where(and(eq(projects.domainId, domainId), isNull(projects.deletedAt))); .where(and(eq(projects.domainId, domainId), isNull(projects.deletedAt)));
@@ -178,6 +184,8 @@ analyticsRoutes.get("/daily", async (c) => {
domainId = active.id; domainId = active.id;
} }
await requireWorkspaceAccess(c, domainId);
// Buckets cover the last `range` days ending today, matching the frontend's expectation. // Buckets cover the last `range` days ending today, matching the frontend's expectation.
const firstDay = new Date(); const firstDay = new Date();
firstDay.setDate(firstDay.getDate() - (range - 1)); firstDay.setDate(firstDay.getDate() - (range - 1));
+12 -2
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, calendarEvents } from "@project-e/db"; import { db, calendarEvents } from "@project-e/db";
import { and, asc, desc, eq, gte, lte, isNull } from "drizzle-orm"; 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 { recordActivity } from "../middleware/activity";
import { z } from "zod"; import { z } from "zod";
@@ -52,10 +52,14 @@ calendarRoutes.get("/events", async (c) => {
if (from) conditions.push(gte(calendarEvents.startTime, new Date(from))); if (from) conditions.push(gte(calendarEvents.startTime, new Date(from)));
if (to) conditions.push(lte(calendarEvents.startTime, new Date(to))); 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) .from(calendarEvents)
.where(and(...conditions)) .where(and(...conditions))
.orderBy(asc(calendarEvents.startTime)); .orderBy(asc(calendarEvents.startTime));
const items = limit !== null ? await query.limit(limit) : await query;
return c.json({ items, totalItems: items.length }); return c.json({ items, totalItems: items.length });
} catch (error) { } catch (error) {
@@ -120,6 +124,9 @@ calendarRoutes.patch("/events/:id", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 body = await c.req.json();
const data = updateEventSchema.parse(body); const data = updateEventSchema.parse(body);
@@ -179,6 +186,9 @@ calendarRoutes.delete("/events/:id", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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() const [existing] = await db.select()
.from(calendarEvents) .from(calendarEvents)
+35 -4
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, canvases, canvasCards, canvasConnections } from "@project-e/db"; import { db, canvases, canvasCards, canvasConnections } from "@project-e/db";
import { and, asc, desc, eq, sql } from "drizzle-orm"; import { and, asc, desc, eq, notInArray, or, 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 { recordActivity } from "../middleware/activity";
import { z } from "zod"; import { z } from "zod";
@@ -137,6 +137,9 @@ canvasRoutes.get("/:id", async (c) => {
try { try {
await requireAuth(c); await requireAuth(c);
const id = c.req.param("id"); 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); 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); if (!canvas) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404);
@@ -160,6 +163,9 @@ canvasRoutes.patch("/:id", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 body = await c.req.json();
const data = updateCanvasSchema.parse(body); const data = updateCanvasSchema.parse(body);
@@ -199,6 +205,9 @@ canvasRoutes.delete("/:id", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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); 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); 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 { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 body = await c.req.json();
const data = createCardSchema.parse(body); const data = createCardSchema.parse(body);
@@ -272,6 +284,9 @@ canvasRoutes.put("/:id/cards", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 body = await c.req.json();
const data = bulkSaveCardsSchema.parse(body); const data = bulkSaveCardsSchema.parse(body);
@@ -282,8 +297,15 @@ canvasRoutes.put("/:id/cards", async (c) => {
const cards = await db.transaction(async (tx) => { const cards = await db.transaction(async (tx) => {
await tx.delete(canvasCards).where(eq(canvasCards.canvasId, id)); await tx.delete(canvasCards).where(eq(canvasCards.canvasId, id));
if (data.cards.length === 0) return []; if (data.cards.length === 0) {
return tx.insert(canvasCards).values( // 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) => ({ data.cards.map((card, i) => ({
...(card.id ? { id: card.id } : {}), ...(card.id ? { id: card.id } : {}),
canvasId: id, canvasId: id,
@@ -299,6 +321,15 @@ canvasRoutes.put("/:id/cards", async (c) => {
zIndex: card.zIndex ?? i, zIndex: card.zIndex ?? i,
})) }))
).returning(); ).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({ await recordActivity({
+7 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, customFields } from "@project-e/db"; import { db, customFields } from "@project-e/db";
import { and, asc, eq } from "drizzle-orm"; 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 { recordActivity } from "../middleware/activity";
import { z } from "zod"; import { z } from "zod";
@@ -99,6 +99,9 @@ customFieldRoutes.patch("/:id", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 body = await c.req.json();
const data = updateFieldSchema.parse(body); const data = updateFieldSchema.parse(body);
@@ -137,6 +140,9 @@ customFieldRoutes.delete("/:id", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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); 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); if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Custom field not found" } }, 404);
+7 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, dailyNotes } from "@project-e/db"; import { db, dailyNotes } from "@project-e/db";
import { and, desc, eq } from "drizzle-orm"; 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 { recordActivity } from "../middleware/activity";
import { z } from "zod"; import { z } from "zod";
@@ -101,6 +101,9 @@ dailyNoteRoutes.patch("/:id", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 body = await c.req.json();
const data = updateDailyNoteSchema.parse(body); const data = updateDailyNoteSchema.parse(body);
@@ -137,6 +140,9 @@ dailyNoteRoutes.delete("/:id", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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); 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); if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Daily note not found" } }, 404);
+20 -2
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, domains as domainsTable } from "@project-e/db"; import { db, domains as domainsTable } from "@project-e/db";
import { and, asc, desc, eq, ilike, or, sql } from "drizzle-orm"; 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(); export const domainRoutes = new Hono();
@@ -131,6 +131,9 @@ domainRoutes.get("/:id", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 const [domain] = await db
.select() .select()
@@ -157,11 +160,23 @@ domainRoutes.patch("/:id", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 body = await c.req.json();
// Whitelist editable fields so a client can never overwrite ownership,
// the slug, or sort order via an open-ended body spread.
const updateValues: Record<string, unknown> = { updatedAt: new Date() };
if (body.name !== undefined && typeof body.name === "string") updateValues.name = body.name;
if (body.color !== undefined) updateValues.color = body.color ?? null;
if (body.icon !== undefined) updateValues.icon = body.icon ?? null;
if (body.parentId !== undefined) updateValues.parentId = body.parentId ?? null;
if (body.sortOrder !== undefined && typeof body.sortOrder === "number") updateValues.sortOrder = body.sortOrder;
const [domain] = await db const [domain] = await db
.update(domainsTable) .update(domainsTable)
.set({ ...body, updatedAt: new Date() }) .set(updateValues)
.where(and(eq(domainsTable.id, id), eq(domainsTable.ownerId, user.id))) .where(and(eq(domainsTable.id, id), eq(domainsTable.ownerId, user.id)))
.returning(); .returning();
@@ -184,6 +199,9 @@ domainRoutes.delete("/:id", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 const [domain] = await db
.delete(domainsTable) .delete(domainsTable)
+9 -3
View File
@@ -218,9 +218,15 @@ graphRoutes.delete("/edges/:id", async (c) => {
.returning(); .returning();
if (result.length === 0) { if (result.length === 0) {
// Try task_dependencies // Try note_entity_links (note → entity edges)
await db.delete(taskDependencies) const entityResult = await db.delete(noteEntityLinks)
.where(and(eq(taskDependencies.taskId, sourceId), eq(taskDependencies.dependsOnTaskId, targetId))); .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) { if (!workspaceId) {
+83 -14
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, habits, habitCompletions, habitTags, tags as tagsTable } from "@project-e/db"; 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 { and, asc, desc, eq, exists, gte, ilike, inArray, isNull, lt, lte, 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 { recordActivity } from "../middleware/activity";
import { enqueueWebhooks } from "../middleware/webhook-queue"; import { enqueueWebhooks } from "../middleware/webhook-queue";
import { z } from "zod"; import { z } from "zod";
@@ -47,6 +47,10 @@ const completeHabitSchema = z.object({
/** /**
* Calculate the current streak for a habit. * Calculate the current streak for a habit.
*
* Day boundaries are resolved in UTC everywhere (grouping, streak walk and the
* frontend's "completed today" check) so the server and client agree on what
* "today" means even for users outside UTC.
*/ */
async function calculateStreak(habitId: string, skipDays: number[]): Promise<number> { async function calculateStreak(habitId: string, skipDays: number[]): Promise<number> {
const completions = await db.select({ date: habitCompletions.date }) const completions = await db.select({ date: habitCompletions.date })
@@ -62,21 +66,21 @@ async function calculateStreak(habitId: string, skipDays: number[]): Promise<num
let streak = 0; let streak = 0;
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setUTCHours(0, 0, 0, 0);
const checkDate = new Date(today); const checkDate = new Date(today);
for (let i = 0; i < 365; i++) { for (let i = 0; i < 365; i++) {
const dateStr = checkDate.toISOString().split("T")[0]; const dateStr = checkDate.toISOString().split("T")[0];
const dayOfWeek = checkDate.getDay(); const dayOfWeek = checkDate.getUTCDay();
if (skipDays.includes(dayOfWeek)) { if (skipDays.includes(dayOfWeek)) {
checkDate.setDate(checkDate.getDate() - 1); checkDate.setUTCDate(checkDate.getUTCDate() - 1);
continue; continue;
} }
if (completionDates.has(dateStr)) { if (completionDates.has(dateStr)) {
streak++; streak++;
checkDate.setDate(checkDate.getDate() - 1); checkDate.setUTCDate(checkDate.getUTCDate() - 1);
} else { } else {
break; break;
} }
@@ -192,8 +196,29 @@ habitRoutes.get("/", async (c) => {
tags: habitTagMap.get(h.id) || [], tags: habitTagMap.get(h.id) || [],
})); }));
// Fetch recent completions for every habit in the page so the UI can render
// "completed today" / mini-grid state without a second request per row.
let habitCompletionMap = new Map<string, typeof habitCompletions.$inferSelect[]>();
if (items.length > 0) {
const habitIds = items.map(h => h.id);
const since = new Date(Date.now() - 21 * 24 * 60 * 60 * 1000);
const completionRows = await db.select()
.from(habitCompletions)
.where(and(inArray(habitCompletions.habitId, habitIds), gte(habitCompletions.date, since)))
.orderBy(desc(habitCompletions.date));
for (const row of completionRows) {
if (!habitCompletionMap.has(row.habitId)) habitCompletionMap.set(row.habitId, []);
habitCompletionMap.get(row.habitId)!.push(row);
}
}
const itemsWithCompletions = itemsWithTags.map(h => ({
...h,
recentCompletions: habitCompletionMap.get(h.id) || [],
}));
return c.json({ return c.json({
items: itemsWithTags, items: itemsWithCompletions,
totalItems, totalItems,
totalPages: Math.ceil(totalItems / (limit || perPage)), totalPages: Math.ceil(totalItems / (limit || perPage)),
page, page,
@@ -271,6 +296,9 @@ habitRoutes.get("/:id", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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() const [habit] = await db.select()
.from(habits) .from(habits)
@@ -324,6 +352,9 @@ habitRoutes.patch("/:id", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 body = await c.req.json();
const data = updateHabitSchema.parse(body); const data = updateHabitSchema.parse(body);
@@ -385,6 +416,9 @@ habitRoutes.delete("/:id", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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() const [existing] = await db.select()
.from(habits) .from(habits)
@@ -427,6 +461,9 @@ habitRoutes.post("/:id/tags", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 body = await c.req.json();
const { tagId } = z.object({ tagId: z.string().uuid("Invalid tag id") }).parse(body); 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 { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 tagId = c.req.param("tagId");
const [habit] = await db.select({ id: habits.id, domainId: habits.domainId }) const [habit] = await db.select({ id: habits.id, domainId: habits.domainId })
@@ -517,6 +557,9 @@ habitRoutes.post("/:id/complete", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 body = await c.req.json();
const data = completeHabitSchema.parse(body); const data = completeHabitSchema.parse(body);
@@ -531,13 +574,36 @@ habitRoutes.post("/:id/complete", async (c) => {
await requireWorkspaceAccess(c, habit.domainId); await requireWorkspaceAccess(c, habit.domainId);
const [completion] = await db.insert(habitCompletions).values({ // Guard against duplicate completions for the same UTC day: the habit list
habitId: id, // disables the button once completed today, but double-fires (or a stale
date: new Date(), // client) must not inflate history/stats. Update the existing row instead.
value: data.value, const todayStart = new Date();
mood: data.mood ?? null, todayStart.setUTCHours(0, 0, 0, 0);
notes: data.notes ?? null, const tomorrowStart = new Date(todayStart.getTime() + 24 * 60 * 60 * 1000);
}).returning(); 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 // Recalculate streak
const skipDays = habit.skipDays || []; const skipDays = habit.skipDays || [];
@@ -587,6 +653,9 @@ habitRoutes.get("/:id/completions", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 url = new URL(c.req.url);
const [habit] = await db.select({ id: habits.id, domainId: habits.domainId }) const [habit] = await db.select({ id: habits.id, domainId: habits.domainId })
+81 -15
View File
@@ -7,6 +7,13 @@ import { z } from "zod";
export const importExportRoutes = new Hono(); export const importExportRoutes = new Hono();
const COLLECTIONS = ['tasks', 'habits', 'projects', 'notes', 'tags', 'agents', 'webhooks'] as const; const COLLECTIONS = ['tasks', 'habits', 'projects', 'notes', 'tags', 'agents', 'webhooks'] as const;
const JUNCTION_COLLECTIONS = ['task_tags', 'habit_tags', 'project_tags', 'note_tags'] as const;
const JUNCTION_PARENT: Record<string, string> = {
task_tags: 'tasks',
habit_tags: 'habits',
project_tags: 'projects',
note_tags: 'notes',
};
// POST /api/import — Import data from JSON // POST /api/import — Import data from JSON
importExportRoutes.post("/import", async (c) => { importExportRoutes.post("/import", async (c) => {
@@ -31,38 +38,50 @@ importExportRoutes.post("/import", async (c) => {
let totalImported = 0; let totalImported = 0;
let totalFailed = 0; let totalFailed = 0;
for (const collection of COLLECTIONS) { const runCollection = async (collection: string, items: any[]) => {
const items = body[collection];
if (!Array.isArray(items) || items.length === 0) continue;
const result = { collection, imported: 0, failed: 0, errors: [] as string[] }; const result = { collection, imported: 0, failed: 0, errors: [] as string[] };
for (const item of items) { for (const item of items) {
try { try {
const { id: _id, created: _created, updated: _updated, ...data } = item; // Preserve the source id so cross-references (projectId, parentId,
// Map to the right table // 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) { switch (collection) {
case 'tasks': case 'tasks':
await db.insert(tasks).values({ ...data, domainId: targetDomain }); await db.insert(tasks).values({ ...data, id, domainId: targetDomain });
break; break;
case 'habits': case 'habits':
await db.insert(habits).values({ ...data, domainId: targetDomain }); await db.insert(habits).values({ ...data, id, domainId: targetDomain });
break; break;
case 'projects': case 'projects':
await db.insert(projects).values({ ...data, domainId: targetDomain }); await db.insert(projects).values({ ...data, id, domainId: targetDomain });
break; break;
case 'notes': case 'notes':
await db.insert(notes).values({ ...data, domainId: targetDomain }); await db.insert(notes).values({ ...data, id, domainId: targetDomain });
break; break;
case 'tags': case 'tags':
await db.insert(tagsTable).values(data); await db.insert(tagsTable).values({ ...data, id });
break; break;
case 'agents': case 'agents':
await db.insert(agents).values({ ...data, domainId: targetDomain }); await db.insert(agents).values({ ...data, id, domainId: targetDomain });
break; break;
case 'webhooks': case 'webhooks':
await db.insert(webhooks).values({ ...data, workspaceId: targetDomain }); await db.insert(webhooks).values({ ...data, id, workspaceId: targetDomain });
break; 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++; result.imported++;
} catch (error) { } catch (error) {
@@ -71,10 +90,21 @@ importExportRoutes.post("/import", async (c) => {
if (result.errors.length < 5) result.errors.push(message); if (result.errors.length < 5) result.errors.push(message);
} }
} }
results.push(result); results.push(result);
totalImported += result.imported; totalImported += result.imported;
totalFailed += result.failed; 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 }); 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); return c.json(exportData);
} catch (error) { } catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any); if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
+17 -8
View File
@@ -165,6 +165,10 @@ const tools: ToolDefinition[] = [
required: ["task_id"], required: ["task_id"],
}, },
handler: async (params, auth) => { handler: async (params, auth) => {
const [existing] = await db.select().from(tasks).where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt))).limit(1);
if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found");
await verifyDomainAccess(existing.domainId, auth.userId);
const updateData: Record<string, unknown> = {}; const updateData: Record<string, unknown> = {};
if (params.title !== undefined) updateData.title = params.title; if (params.title !== undefined) updateData.title = params.title;
if (params.description !== undefined) updateData.description = params.description; 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))) .where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt)))
.returning(); .returning();
if (!task) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found");
await recordActivity({ await recordActivity({
actor: auth.userName, actor: auth.userName,
action: "updated", action: "updated",
@@ -201,13 +203,15 @@ const tools: ToolDefinition[] = [
required: ["task_id"], required: ["task_id"],
}, },
handler: async (params, auth) => { 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) const [task] = await db.update(tasks)
.set({ deletedAt: new Date(), updatedAt: new Date() }) .set({ deletedAt: new Date(), updatedAt: new Date() })
.where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt))) .where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt)))
.returning(); .returning();
if (!task) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found");
await recordActivity({ await recordActivity({
actor: auth.userName, actor: auth.userName,
action: "deleted", action: "deleted",
@@ -228,13 +232,15 @@ const tools: ToolDefinition[] = [
required: ["task_id"], required: ["task_id"],
}, },
handler: async (params, auth) => { 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) const [task] = await db.update(tasks)
.set({ status: "done", completedAt: new Date(), updatedAt: new Date() }) .set({ status: "done", completedAt: new Date(), updatedAt: new Date() })
.where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt))) .where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt)))
.returning(); .returning();
if (!task) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found");
await recordActivity({ await recordActivity({
actor: auth.userName, actor: auth.userName,
action: "completed", action: "completed",
@@ -313,6 +319,7 @@ const tools: ToolDefinition[] = [
handler: async (params, auth) => { 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); 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"); if (!habit) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Habit not found");
await verifyDomainAccess(habit.domainId, auth.userId);
const [completion] = await db.insert(habitCompletions).values({ const [completion] = await db.insert(habitCompletions).values({
habitId: params.habit_id as string, habitId: params.habit_id as string,
@@ -444,6 +451,10 @@ const tools: ToolDefinition[] = [
required: ["note_id"], required: ["note_id"],
}, },
handler: async (params, auth) => { handler: async (params, auth) => {
const [existing] = await db.select().from(notes).where(and(eq(notes.id, params.note_id as string), isNull(notes.deletedAt))).limit(1);
if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Note not found");
await verifyDomainAccess(existing.domainId, auth.userId);
const updateData: Record<string, unknown> = { updatedAt: new Date() }; const updateData: Record<string, unknown> = { updatedAt: new Date() };
if (params.title !== undefined) updateData.title = params.title; if (params.title !== undefined) updateData.title = params.title;
if (params.content !== undefined) updateData.content = params.content; 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))) .where(and(eq(notes.id, params.note_id as string), isNull(notes.deletedAt)))
.returning(); .returning();
if (!note) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Note not found");
await recordActivity({ await recordActivity({
actor: auth.userName, actor: auth.userName,
action: "updated", action: "updated",
+11 -8
View File
@@ -12,15 +12,18 @@ import { extractLinkTargets } from "./wikilink-parser";
/** /**
* Resolve a single link target to its entity ID. * 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(); const trimmedTitle = title.trim();
if (!entityType) { if (!entityType) {
const [note] = await db const [note] = await db
.select({ id: notes.id }) .select({ id: notes.id })
.from(notes) .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); .limit(1);
if (note) return { entityId: note.id, entityType: "note" }; if (note) return { entityId: note.id, entityType: "note" };
return null; return null;
@@ -31,7 +34,7 @@ async function resolveTarget(entityType: string, title: string): Promise<{ entit
const [note] = await db const [note] = await db
.select({ id: notes.id }) .select({ id: notes.id })
.from(notes) .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); .limit(1);
if (note) return { entityId: note.id, entityType: "note" }; if (note) return { entityId: note.id, entityType: "note" };
return null; return null;
@@ -40,7 +43,7 @@ async function resolveTarget(entityType: string, title: string): Promise<{ entit
const [task] = await db const [task] = await db
.select({ id: tasks.id }) .select({ id: tasks.id })
.from(tasks) .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); .limit(1);
if (task) return { entityId: task.id, entityType: "task" }; if (task) return { entityId: task.id, entityType: "task" };
return null; return null;
@@ -49,7 +52,7 @@ async function resolveTarget(entityType: string, title: string): Promise<{ entit
const [habit] = await db const [habit] = await db
.select({ id: habits.id }) .select({ id: habits.id })
.from(habits) .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); .limit(1);
if (habit) return { entityId: habit.id, entityType: "habit" }; if (habit) return { entityId: habit.id, entityType: "habit" };
return null; return null;
@@ -58,7 +61,7 @@ async function resolveTarget(entityType: string, title: string): Promise<{ entit
const [project] = await db const [project] = await db
.select({ id: projects.id }) .select({ id: projects.id })
.from(projects) .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); .limit(1);
if (project) return { entityId: project.id, entityType: "project" }; if (project) return { entityId: project.id, entityType: "project" };
return null; 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. * Sync wikilinks for a note: parse content, resolve targets, diff existing links.
*/ */
export async function syncNoteLinks(noteId: string, content: string): Promise<void> { export async function syncNoteLinks(noteId: string, content: string, domainId: string): Promise<void> {
const targets = extractLinkTargets(content); const targets = extractLinkTargets(content);
const resolvedTargets: { entityType: string; entityId: string }[] = []; const resolvedTargets: { entityType: string; entityId: string }[] = [];
for (const target of targets) { for (const target of targets) {
const resolved = await resolveTarget(target.entityType, target.title); const resolved = await resolveTarget(target.entityType, target.title, domainId);
if (resolved) { if (resolved) {
resolvedTargets.push(resolved); resolvedTargets.push(resolved);
} }
+24 -3
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, notes, noteTags, tags as tagsTable, activityFeed } from "@project-e/db"; 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 { 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 { recordActivity } from "../middleware/activity";
import { enqueueWebhooks } from "../middleware/webhook-queue"; import { enqueueWebhooks } from "../middleware/webhook-queue";
import { syncNoteLinks, getBacklinks, getOutgoingLinks } from "./note-link-service"; import { syncNoteLinks, getBacklinks, getOutgoingLinks } from "./note-link-service";
@@ -172,7 +172,7 @@ noteRoutes.post("/", async (c) => {
// Sync wikilinks from content // Sync wikilinks from content
if (data.content) { if (data.content) {
await syncNoteLinks(note.id, data.content); await syncNoteLinks(note.id, data.content, data.domain);
} }
await recordActivity({ await recordActivity({
@@ -204,6 +204,9 @@ noteRoutes.get("/:id", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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() const [note] = await db.select()
.from(notes) .from(notes)
@@ -252,6 +255,9 @@ noteRoutes.patch("/:id", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 body = await c.req.json();
const data = updateNoteSchema.parse(body); const data = updateNoteSchema.parse(body);
@@ -281,7 +287,7 @@ noteRoutes.patch("/:id", async (c) => {
// Re-sync wikilinks if content changed // Re-sync wikilinks if content changed
const content = data.content ?? existing.content; const content = data.content ?? existing.content;
if (content) { if (content) {
await syncNoteLinks(id, content); await syncNoteLinks(id, content, existing.domainId);
} }
await recordActivity({ await recordActivity({
@@ -313,6 +319,9 @@ noteRoutes.delete("/:id", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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() const [existing] = await db.select()
.from(notes) .from(notes)
@@ -355,6 +364,9 @@ noteRoutes.post("/:id/tags", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 body = await c.req.json();
const { tagId } = z.object({ tagId: z.string().uuid("Invalid tag id") }).parse(body); 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 { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 tagId = c.req.param("tagId");
const [note] = await db.select({ id: notes.id, domainId: notes.domainId }) const [note] = await db.select({ id: notes.id, domainId: notes.domainId })
@@ -445,6 +460,9 @@ noteRoutes.get("/:id/backlinks", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 }) const [note] = await db.select({ id: notes.id, domainId: notes.domainId })
.from(notes) .from(notes)
@@ -477,6 +495,9 @@ noteRoutes.get("/:id/versions", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 }) const [note] = await db.select({ id: notes.id, domainId: notes.domainId })
.from(notes) .from(notes)
+19 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, projects, tasks, sections, projectTags, tags as tagsTable, activityFeed } from "@project-e/db"; 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 { 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 { recordActivity } from "../middleware/activity";
import { enqueueWebhooks } from "../middleware/webhook-queue"; import { enqueueWebhooks } from "../middleware/webhook-queue";
import { z } from "zod"; import { z } from "zod";
@@ -239,6 +239,9 @@ projectRoutes.get("/:id", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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() const [project] = await db.select()
.from(projects) .from(projects)
@@ -300,6 +303,9 @@ projectRoutes.patch("/:id", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 body = await c.req.json();
const data = updateProjectSchema.parse(body); const data = updateProjectSchema.parse(body);
@@ -357,6 +363,9 @@ projectRoutes.delete("/:id", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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() const [existing] = await db.select()
.from(projects) .from(projects)
@@ -651,6 +660,9 @@ projectRoutes.get("/:id/members", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 }) const [project] = await db.select({ id: projects.id, domainId: projects.domainId })
.from(projects) .from(projects)
@@ -687,6 +699,9 @@ projectRoutes.post("/:id/members", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 body = await c.req.json();
const { userId, role } = z.object({ const { userId, role } = z.object({
userId: z.string().uuid(), userId: z.string().uuid(),
@@ -731,6 +746,9 @@ projectRoutes.delete("/:id/members/:uid", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 userId = c.req.param("uid");
const [project] = await db.select({ id: projects.id, name: projects.name, domainId: projects.domainId }) const [project] = await db.select({ id: projects.id, name: projects.name, domainId: projects.domainId })
+8 -3
View File
@@ -1,6 +1,6 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, sql } from "@project-e/db"; 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(); 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 // Scope all searches to the user's active domain so users can never see
// another workspace's data. // another workspace's data. The frontend passes the selected domain; the
const userDomain = await resolveActiveDomain(user); // 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 userDomainId = userDomain.id;
const results: Array<{ id: string; type: string; title: string; snippet: string; score: number; workspaceId: string; link: string }> = []; const results: Array<{ id: string; type: string; title: string; snippet: string; score: number; workspaceId: string; link: string }> = [];
+10 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, tags as tagsTable } from "@project-e/db"; import { db, tags as tagsTable } from "@project-e/db";
import { and, asc, desc, eq, sql } from "drizzle-orm"; 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 { recordActivity } from "../middleware/activity";
import { z } from "zod"; import { z } from "zod";
@@ -79,6 +79,9 @@ tagRoutes.get("/:id", async (c) => {
try { try {
await requireAuth(c); await requireAuth(c);
const id = c.req.param("id"); 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); 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); if (!tag) return c.json({ error: { code: "NOT_FOUND", message: "Tag not found" } }, 404);
return c.json(tag); return c.json(tag);
@@ -94,6 +97,9 @@ tagRoutes.patch("/:id", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 body = await c.req.json();
const data = updateTagSchema.parse(body); const data = updateTagSchema.parse(body);
@@ -121,6 +127,9 @@ tagRoutes.delete("/:id", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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); 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); if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Tag not found" } }, 404);
+71 -3
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { 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 { 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 { recordActivity } from "../middleware/activity";
import { enqueueWebhooks } from "../middleware/webhook-queue"; import { enqueueWebhooks } from "../middleware/webhook-queue";
import { z } from "zod"; import { z } from "zod";
@@ -246,13 +246,51 @@ taskRoutes.post("/", async (c) => {
// Cycle detection for parentId (subtask) // Cycle detection for parentId (subtask)
if (data.parentId) { if (data.parentId) {
const [parent] = await db.select({ id: tasks.id }) const [parent] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks) .from(tasks)
.where(and(eq(tasks.id, data.parentId), isNull(tasks.deletedAt))) .where(and(eq(tasks.id, data.parentId), isNull(tasks.deletedAt)))
.limit(1); .limit(1);
if (!parent) { if (!parent) {
return c.json({ error: { code: "NOT_FOUND", message: "Parent task not found" } }, 404); 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({ const [task] = await db.insert(tasks).values({
@@ -375,6 +413,9 @@ taskRoutes.get("/:id", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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() const [task] = await db.select()
.from(tasks) .from(tasks)
@@ -444,6 +485,9 @@ taskRoutes.patch("/:id", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 body = await c.req.json();
const data = updateTaskSchema.parse(body); const data = updateTaskSchema.parse(body);
@@ -531,6 +575,9 @@ taskRoutes.delete("/:id", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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() const [existing] = await db.select()
.from(tasks) .from(tasks)
@@ -576,6 +623,9 @@ taskRoutes.post("/:id/tags", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 body = await c.req.json();
const { tagId } = z.object({ tagId: z.string().uuid("Invalid tag id") }).parse(body); 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 { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 tagId = c.req.param("tagId");
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId }) const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
@@ -666,6 +719,9 @@ taskRoutes.post("/:id/status", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 body = await c.req.json();
const { status: newStatus } = z.object({ const { status: newStatus } = z.object({
status: taskStatusEnum, status: taskStatusEnum,
@@ -722,6 +778,9 @@ taskRoutes.get("/:id/history", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 }) const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks) .from(tasks)
@@ -759,6 +818,9 @@ taskRoutes.get("/:id/comments", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 }) const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks) .from(tasks)
@@ -795,6 +857,9 @@ taskRoutes.post("/:id/comments", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 body = await c.req.json();
const { content } = z.object({ const { content } = z.object({
content: z.string().min(1, "Content is required"), content: z.string().min(1, "Content is required"),
@@ -839,6 +904,9 @@ taskRoutes.get("/:id/attachments", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 }) const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks) .from(tasks)
+10 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, webhooks } from "@project-e/db"; import { db, webhooks } from "@project-e/db";
import { and, asc, desc, eq, sql } from "drizzle-orm"; 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 { recordActivity } from "../middleware/activity";
import { enqueueWebhookDelivery } from "../middleware/webhook-queue"; import { enqueueWebhookDelivery } from "../middleware/webhook-queue";
import { z } from "zod"; import { z } from "zod";
@@ -105,6 +105,9 @@ webhookRoutes.patch("/:id", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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 body = await c.req.json();
const data = updateWebhookSchema.parse(body); const data = updateWebhookSchema.parse(body);
@@ -142,6 +145,9 @@ webhookRoutes.delete("/:id", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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); 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); 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 { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); 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); 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); if (!webhook) return c.json({ error: { code: "NOT_FOUND", message: "Webhook not found" } }, 404);
@@ -40,6 +40,9 @@ export function TagManager({ entityType, entityId, tags }: TagManagerProps) {
const availableTags = allTags.filter((t) => !assignedIds.has(t.id)); const availableTags = allTags.filter((t) => !assignedIds.has(t.id));
const refreshEntity = () => { 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] }); queryClient.invalidateQueries({ queryKey: [entityType, entityId] });
}; };
@@ -1,5 +1,5 @@
import { useEffect, useState, useCallback, useRef } from "react"; import { useEffect, useState, useCallback, useRef } from "react";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate, useLocation } from "@tanstack/react-router";
import { import {
LayoutDashboard, LayoutDashboard,
ListTodo, ListTodo,
@@ -28,6 +28,8 @@ import {
CommandSeparator, CommandSeparator,
} from "@/components/ui/command"; } from "@/components/ui/command";
import { useThemeStore, type AccentColor, ACCENT_PALETTE } from "@/lib/stores/use-theme-store"; 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 { interface NavItem {
label: string; label: string;
@@ -70,7 +72,9 @@ function addRecentPage(href: string) {
export function CommandPalette() { export function CommandPalette() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const { mode, setMode, accent, setAccent } = useThemeStore(); const { mode, setMode, accent, setAccent } = useThemeStore();
const activeDomainId = useApiDomain();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [searchResults, setSearchResults] = useState< const [searchResults, setSearchResults] = useState<
Array<{ type: string; items: Array<{ id: string; title: string; link?: string }> }> Array<{ type: string; items: Array<{ id: string; title: string; link?: string }> }>
@@ -94,36 +98,50 @@ export function CommandPalette() {
// Track page navigation for recent items // Track page navigation for recent items
useEffect(() => { useEffect(() => {
const path = window.location.pathname; if (location.pathname !== "/login") addRecentPage(location.pathname);
if (path !== "/login") addRecentPage(path); }, [location.pathname]);
}, []);
// Quick actions // Quick actions
const quickActions: QuickAction[] = [ const quickActions: QuickAction[] = [
{ {
label: "New task", label: "New task",
icon: ListTodo, icon: ListTodo,
action: () => navigate({ to: "/tasks" }), action: () => {
useCreateDialogStore.getState().openCreate("task");
navigate({ to: "/tasks" });
},
}, },
{ {
label: "New habit", label: "New habit",
icon: Flame, icon: Flame,
action: () => navigate({ to: "/habits" }), action: () => {
useCreateDialogStore.getState().openCreate("habit");
navigate({ to: "/habits" });
},
}, },
{ {
label: "New project", label: "New project",
icon: FolderKanban, icon: FolderKanban,
action: () => navigate({ to: "/projects" }), action: () => {
useCreateDialogStore.getState().openCreate("project");
navigate({ to: "/projects" });
},
}, },
{ {
label: "New note", label: "New note",
icon: NotebookPen, icon: NotebookPen,
action: () => navigate({ to: "/notes" }), action: () => {
useCreateDialogStore.getState().openCreate("note");
navigate({ to: "/notes" });
},
}, },
{ {
label: "New event", label: "New event",
icon: CalendarDays, 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(); const mentionQuery = query.slice(1).trim();
if (mentionQuery) { if (mentionQuery) {
try { 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) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setSearchResults([ setSearchResults([
@@ -195,16 +213,24 @@ export function CommandPalette() {
// Debounced API search // Debounced API search
searchTimeoutRef.current = setTimeout(async () => { searchTimeoutRef.current = setTimeout(async () => {
try { 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) { if (res.ok) {
const data = await res.json(); 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<string, Array<{ id: string; title: string; link?: string }>> = {};
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 { } catch {
// Ignore search errors // Ignore search errors
} }
}, 300); }, 300);
}, []); }, [activeDomainId]);
const runCommand = useCallback( const runCommand = useCallback(
(command: () => void) => { (command: () => void) => {
@@ -320,7 +346,7 @@ export function CommandPalette() {
runCommand(() => {}); runCommand(() => {});
return; return;
} }
const link = group.type === "domain" ? "/" : item.link!; const link = group.type === "Domains" ? "/" : item.link!;
runCommand(() => navigate({ to: link })); runCommand(() => navigate({ to: link }));
}} }}
> >
@@ -28,14 +28,15 @@ const shortcutGroups = [
{ keys: "n then h", description: "New habit" }, { keys: "n then h", description: "New habit" },
{ keys: "n then p", description: "New project" }, { keys: "n then p", description: "New project" },
{ keys: "n then n", description: "New note" }, { keys: "n then n", description: "New note" },
{ keys: "c", description: "Focus create in palette" }, { keys: "⌘N / Ctrl+N", description: "New task" },
], ],
}, },
{ {
heading: "General", heading: "General",
shortcuts: [ shortcuts: [
{ keys: "⌘K / Ctrl+K", description: "Open command palette" }, { 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: "?", description: "Show this help" },
{ keys: "Esc", description: "Close dialogs / panels" }, { keys: "Esc", description: "Close dialogs / panels" },
], ],
+15 -6
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react"; 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 { cn } from "@/lib/utils";
import { useSidebarStore } from "@/lib/stores/use-sidebar-store"; import { useSidebarStore } from "@/lib/stores/use-sidebar-store";
import { useAuthStore } from "@/lib/stores/use-auth-store";
import { import {
LayoutDashboard, LayoutDashboard,
ListTodo, ListTodo,
@@ -46,6 +47,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { DomainPicker } from "@/components/shell/domain-picker";
interface NavItem { interface NavItem {
href: string; href: string;
@@ -75,7 +77,11 @@ const bottomItems: NavItem[] = [
export function Sidebar() { export function Sidebar() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const { collapsed, toggle, mobileOpen, setMobileOpen } = useSidebarStore(); 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 // Sidebar position (left/right) is set in Settings. Read once on mount and
// update live via the "sidebar-position-change" custom event dispatched by // update live via the "sidebar-position-change" custom event dispatched by
@@ -204,12 +210,12 @@ export function Sidebar() {
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10"> <Button variant="ghost" size="icon" className="h-10 w-10">
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8">
<AvatarFallback className="text-xs">U</AvatarFallback> <AvatarFallback className="text-xs">{userInitials}</AvatarFallback>
</Avatar> </Avatar>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent side={sidebarPos === "right" ? "left" : "right"} align="start" className="w-48"> <DropdownMenuContent side={sidebarPos === "right" ? "left" : "right"} align="start" className="w-48">
<DropdownMenuItem onClick={() => {}}> <DropdownMenuItem onClick={() => navigate({ to: "/settings" })}>
<User className="mr-2 h-4 w-4" /> <User className="mr-2 h-4 w-4" />
Profile Profile
</DropdownMenuItem> </DropdownMenuItem>
@@ -229,13 +235,13 @@ export function Sidebar() {
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="w-full justify-start gap-3 px-3"> <Button variant="ghost" className="w-full justify-start gap-3 px-3">
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8">
<AvatarFallback className="text-xs">U</AvatarFallback> <AvatarFallback className="text-xs">{userInitials}</AvatarFallback>
</Avatar> </Avatar>
<span className="text-sm font-medium">User</span> <span className="text-sm font-medium truncate">{userName}</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent side={sidebarPos === "right" ? "left" : "right"} align="start" className="w-48"> <DropdownMenuContent side={sidebarPos === "right" ? "left" : "right"} align="start" className="w-48">
<DropdownMenuItem onClick={() => {}}> <DropdownMenuItem onClick={() => navigate({ to: "/settings" })}>
<User className="mr-2 h-4 w-4" /> <User className="mr-2 h-4 w-4" />
Profile Profile
</DropdownMenuItem> </DropdownMenuItem>
@@ -261,6 +267,9 @@ export function Sidebar() {
<SheetTitle>Project E</SheetTitle> <SheetTitle>Project E</SheetTitle>
<SheetDescription>Navigate your workspace.</SheetDescription> <SheetDescription>Navigate your workspace.</SheetDescription>
</SheetHeader> </SheetHeader>
<div className="border-b p-4">
<DomainPicker />
</div>
{navigation(false, () => setMobileOpen(false))} {navigation(false, () => setMobileOpen(false))}
</SheetContent> </SheetContent>
</Sheet> </Sheet>
+11 -4
View File
@@ -2,7 +2,9 @@ import { Search, Bell, Plus, Menu, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useSidebarStore } from "@/lib/stores/use-sidebar-store"; import { useSidebarStore } from "@/lib/stores/use-sidebar-store";
import { useApiDomain } from "@/lib/stores/use-active-domain-store"; import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useAuthStore } from "@/lib/stores/use-auth-store";
import { useApiQuery } from "@/lib/api"; import { useApiQuery } from "@/lib/api";
import { useNavigate } from "@tanstack/react-router";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { DomainPicker } from "@/components/shell/domain-picker"; import { DomainPicker } from "@/components/shell/domain-picker";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@@ -38,7 +40,12 @@ function readableEntityType(entityType: string): string {
export function Topbar() { export function Topbar() {
const { setMobileOpen } = useSidebarStore(); const { setMobileOpen } = useSidebarStore();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate();
const domainId = useApiDomain(); 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 = () => { const openPalette = () => {
document.dispatchEvent(new CustomEvent("open-command-palette")); document.dispatchEvent(new CustomEvent("open-command-palette"));
@@ -179,15 +186,15 @@ export function Topbar() {
<DropdownMenuContent align="end" className="w-56"> <DropdownMenuContent align="end" className="w-56">
<div className="flex items-center gap-2 px-2 py-1.5 text-sm"> <div className="flex items-center gap-2 px-2 py-1.5 text-sm">
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8">
<AvatarFallback className="text-xs">U</AvatarFallback> <AvatarFallback className="text-xs">{userInitials}</AvatarFallback>
</Avatar> </Avatar>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium">User</span> <span className="font-medium truncate">{userName}</span>
<span className="text-xs text-muted-foreground">user@projecte.app</span> <span className="text-xs text-muted-foreground truncate">{userEmail}</span>
</div> </div>
</div> </div>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => {}}> <DropdownMenuItem onClick={() => navigate({ to: "/settings" })}>
Profile Profile
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => { <DropdownMenuItem onClick={() => {
+18 -7
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { useKeyboardShortcutsStore } from "@/lib/stores/use-keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/lib/stores/use-keyboard-shortcuts-store";
import { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store";
import { install, uninstall } from "@github/hotkey"; import { install, uninstall } from "@github/hotkey";
export function useKeyboardShortcuts() { export function useKeyboardShortcuts() {
@@ -49,16 +50,18 @@ export function useKeyboardShortcuts() {
}); });
} }
// n+letter new-entity sequences // n+letter new-entity sequences — navigate AND open the create dialog on
const newMap: Record<string, string> = { // the target page (the page's effect consumes the store request).
"n t": "/tasks", const newMap: Record<string, { path: string; type: "task" | "habit" | "project" | "note" }> = {
"n h": "/habits", "n t": { path: "/tasks", type: "task" },
"n p": "/projects", "n h": { path: "/habits", type: "habit" },
"n n": "/notes", "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, () => { addHotkey(seq, () => {
useCreateDialogStore.getState().openCreate(type);
navigate({ to: path }); navigate({ to: path });
}); });
} }
@@ -84,6 +87,14 @@ export function useKeyboardShortcuts() {
return; 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) // Single-key shortcuts (no modifiers)
if (e.metaKey || e.ctrlKey || e.altKey) return; if (e.metaKey || e.ctrlKey || e.altKey) return;
@@ -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]);
}
+32 -4
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useCallback } from "react"; import { useEffect, useRef, useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import type { RealtimeEvent } from "@/lib/types"; import type { RealtimeEvent } from "@/lib/types";
const API_BASE = "/api"; const API_BASE = "/api";
@@ -10,7 +11,11 @@ interface UseRealtimeOptions {
} }
export function useRealtime(options: 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 queryClient = useQueryClient();
const eventSourceRef = useRef<EventSource | null>(null); const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -23,13 +28,13 @@ export function useRealtime(options: UseRealtimeOptions = {}) {
switch (entityType) { switch (entityType) {
case "task": case "task":
queryKeys.push(["tasks"], ["tasks-due"], ["stats"], ["productivity-chart"]); queryKeys.push(["tasks"], ["tasks-due"], ["stats"], ["productivity-chart"], ["analytics-daily"], ["analytics-projects"]);
break; break;
case "habit": case "habit":
queryKeys.push(["habits"], ["habits-today"], ["streaks"]); queryKeys.push(["habits"], ["habits-today"], ["streaks"], ["analytics-habits"]);
break; break;
case "project": case "project":
queryKeys.push(["projects"], ["active-projects"]); queryKeys.push(["projects"], ["active-projects"], ["analytics-projects"]);
break; break;
case "note": case "note":
queryKeys.push(["notes"], ["recent-notes"]); queryKeys.push(["notes"], ["recent-notes"]);
@@ -40,6 +45,29 @@ export function useRealtime(options: UseRealtimeOptions = {}) {
case "dashboard_widget": case "dashboard_widget":
queryKeys.push(["dashboard-widgets"]); queryKeys.push(["dashboard-widgets"]);
break; 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": case "graph_edge":
queryKeys.push(["graph"]); queryKeys.push(["graph"]);
break; break;
+25
View File
@@ -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<void> {
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");
}
}
+21
View File
@@ -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<AuthState>()((set) => ({
user: null,
checked: false,
setUser: (user) => set({ user }),
setChecked: (checked) => set({ checked }),
}));
@@ -22,6 +22,9 @@ export const useSidebarStore = create<SidebarState>()(
}), }),
{ {
name: "project-e-sidebar", 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,
} }
) )
); );
+8 -4
View File
@@ -104,8 +104,9 @@ export interface Note {
} }
export interface Backlink { export interface Backlink {
noteId: string; id: string;
noteTitle: string; title: string;
excerpt?: string;
} }
export interface OutgoingLink { export interface OutgoingLink {
@@ -246,9 +247,10 @@ export interface WebhookDelivery {
export interface ErrorLog { export interface ErrorLog {
id: string; id: string;
level: string; level: string;
source: string;
message: string; message: string;
stack: string | null; stackTrace: string | null;
context: Record<string, unknown> | null; metadata: Record<string, unknown> | null;
createdAt: string; createdAt: string;
} }
@@ -291,6 +293,8 @@ export interface AgentActivity {
entityType: string; entityType: string;
entityId: string; entityId: string;
metadata: Record<string, unknown> | null; metadata: Record<string, unknown> | null;
details?: Record<string, unknown> | null;
errorMessage?: string | null;
createdAt: string; createdAt: string;
} }
+24 -11
View File
@@ -1,10 +1,12 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router"; 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 { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { toast } from "sonner";
import { routeTree } from "./routeTree"; import { routeTree } from "./routeTree";
import { ThemeProvider } from "@/components/shell/theme-provider"; import { ThemeProvider } from "@/components/shell/theme-provider";
import { bootstrapSession } from "./lib/session";
import "./index.css"; import "./index.css";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -15,6 +17,15 @@ const queryClient = new QueryClient({
refetchOnWindowFocus: true, 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 }); const router = createRouter({ routeTree });
@@ -28,13 +39,15 @@ declare module "@tanstack/react-router" {
const rootEl = document.getElementById("root"); const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("Root element not found"); if (!rootEl) throw new Error("Root element not found");
ReactDOM.createRoot(rootEl).render( bootstrapSession().finally(() => {
<React.StrictMode> ReactDOM.createRoot(rootEl).render(
<QueryClientProvider client={queryClient}> <React.StrictMode>
<ThemeProvider> <QueryClientProvider client={queryClient}>
<RouterProvider router={router} /> <ThemeProvider>
</ThemeProvider> <RouterProvider router={router} />
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />} </ThemeProvider>
</QueryClientProvider> {import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</React.StrictMode> </QueryClientProvider>
); </React.StrictMode>
);
});
+8 -4
View File
@@ -10,16 +10,20 @@ import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
function AppLayout() { function AppLayout() {
useKeyboardShortcuts(); useKeyboardShortcuts();
// Apply persisted appearance preferences (density, reduced motion) right // Apply persisted appearance preferences (density, reduced motion, font size)
// after the first paint. The settings page updates these live while open; // right after the first paint. The settings page updates these live while
// this covers reloads where the settings page was never visited. // open; this covers reloads where the settings page was never visited.
useEffect(() => { useEffect(() => {
const root = document.documentElement; 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"); const density = localStorage.getItem("density");
if (density === "compact") root.classList.add("density-compact"); if (density === "compact") root.classList.add("density-compact");
if (density === "spacious") root.classList.add("density-spacious"); if (density === "spacious") root.classList.add("density-spacious");
if (localStorage.getItem("reduced-motion") === "true") root.classList.add("reduce-motion"); 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 ( return (
+6 -2
View File
@@ -1,11 +1,15 @@
import { createRoute } from "@tanstack/react-router"; import { createRoute, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../_app"; import { Route as appRoute } from "../_app";
function AgentsPage() { function AgentsPage() {
const navigate = useNavigate();
return ( return (
<div className="flex flex-col items-center justify-center min-h-[60vh]"> <div className="flex flex-col items-center justify-center min-h-[60vh]">
<h1 className="text-3xl font-bold mb-2">Agent Activity</h1> <h1 className="text-3xl font-bold mb-2">Agent Activity</h1>
<p className="text-muted-foreground">Coming in T7 agent activity feed.</p> <p className="text-muted-foreground mb-4">The agent activity feed moved to its own page.</p>
<button onClick={() => navigate({ to: "/agents/activity" })} className="text-primary underline underline-offset-4">
Open Agent Activity
</button>
</div> </div>
); );
} }
+51 -22
View File
@@ -3,6 +3,7 @@ import { createRoute } from "@tanstack/react-router";
import { Route as appRoute } from "../../_app"; import { Route as appRoute } from "../../_app";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api"; 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 { Bot, Filter, Calendar, RefreshCw, ExternalLink, Clock, Activity } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { LoadingState, EmptyState } from "@/components/state"; import { LoadingState, EmptyState } from "@/components/state";
@@ -32,52 +33,76 @@ function AgentActivityPage() {
const [dateTo, setDateTo] = useState(""); const [dateTo, setDateTo] = useState("");
const [liveActivities, setLiveActivities] = useState<AgentActivity[]>([]); const [liveActivities, setLiveActivities] = useState<AgentActivity[]>([]);
const eventSourceRef = useRef<EventSource | null>(null); const eventSourceRef = useRef<EventSource | null>(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 // Fetch agents for filter dropdown
const { data: agentsData } = useApiQuery<PaginatedResponse<Agent>>(["agents-list"], "/agents"); const { data: agentsData } = useApiQuery<PaginatedResponse<Agent>>(["agents-list", activeDomainId], "/agents" + (activeDomainId ? "?domain=" + activeDomainId : ""));
const agents = agentsData?.items || []; const agents = agentsData?.items || [];
// Build query params // Build query params
const params = new URLSearchParams({ limit: "100" }); const params = new URLSearchParams({ limit: "100" });
if (agentFilter) params.set("agentId", agentFilter); if (activeDomainId) params.set("domain", activeDomainId);
if (actionFilter) params.set("action", actionFilter); if (agentFilter && agentFilter !== "all") params.set("agentId", agentFilter);
if (actionFilter && actionFilter !== "all") params.set("action", actionFilter);
if (dateFrom) params.set("from", dateFrom); if (dateFrom) params.set("from", dateFrom);
if (dateTo) params.set("to", dateTo); if (dateTo) params.set("to", dateTo);
const { data: activityData, isLoading } = useApiQuery<{ items: AgentActivity[]; totalItems: number }>( const { data: activityData, isLoading } = useApiQuery<{ items: AgentActivity[]; totalItems: number }>(
["agent-activity", agentFilter, actionFilter, dateFrom, dateTo], ["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 || [])]; const activities = [...liveActivities, ...(activityData?.items || [])];
// SSE for live updates // SSE for live updates
useEffect(() => { 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; eventSourceRef.current = es;
es.onmessage = (event) => { es.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
// Realtime events are flat: { type: entityType, action, id, workspace_id }. // Realtime events are flat: { type: entityType, action, id, workspace_id }.
// Match only agent events so unrelated task/habit/etc. activity doesn't leak in. // Match only agent events for the active workspace so unrelated task/habit
if (data.type === "agent") { // etc. activity (or another workspace's events) doesn't leak in.
const entry: AgentActivity = { if (data.type !== "agent") return;
id: `${data.id}-live-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, if (activeDomainId && data.workspace_id !== activeDomainId) return;
agentId: data.id,
action: data.action, const { agentFilter, actionFilter, dateFrom, dateTo } = filtersRef.current;
description: `Live update: ${data.action}`, if (agentFilter && agentFilter !== "all" && data.id !== agentFilter) return;
entityType: "agent", if (actionFilter && actionFilter !== "all" && data.action !== actionFilter) return;
entityId: data.id, const now = new Date();
metadata: null, const nowKey = now.toISOString().slice(0, 10);
createdAt: new Date().toISOString(), if (dateFrom && nowKey < dateFrom) return;
}; if (dateTo && nowKey > dateTo) return;
setLiveActivities((prev) => [entry, ...prev].slice(0, 5));
} 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 {} } catch {}
}; };
es.onerror = () => {}; es.onerror = () => {};
return () => { es.close(); }; return () => { es.close(); };
}, []); }, [activeDomainId]);
const getActionColor = (action: string) => { const getActionColor = (action: string) => {
const found = ACTION_TYPES.find((a) => a.id === action); const found = ACTION_TYPES.find((a) => a.id === action);
@@ -106,7 +131,7 @@ function AgentActivityPage() {
<SelectValue placeholder="All agents" /> <SelectValue placeholder="All agents" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value=" ">All agents</SelectItem> <SelectItem value="all">All agents</SelectItem>
{agents.map((a) => ( {agents.map((a) => (
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem> <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
))} ))}
@@ -118,7 +143,7 @@ function AgentActivityPage() {
<SelectValue placeholder="All actions" /> <SelectValue placeholder="All actions" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value=" ">All actions</SelectItem> <SelectItem value="all">All actions</SelectItem>
{ACTION_TYPES.map((a) => ( {ACTION_TYPES.map((a) => (
<SelectItem key={a.id} value={a.id}>{a.label}</SelectItem> <SelectItem key={a.id} value={a.id}>{a.label}</SelectItem>
))} ))}
@@ -153,6 +178,10 @@ function AgentActivityPage() {
<Badge variant="outline" className="text-[10px]">{a.entityType}</Badge> <Badge variant="outline" className="text-[10px]">{a.entityType}</Badge>
</div> </div>
{a.description && <p className="text-sm text-muted-foreground mt-0.5">{a.description}</p>} {a.description && <p className="text-sm text-muted-foreground mt-0.5">{a.description}</p>}
{!a.description && a.details && Object.keys(a.details).length > 0 && (
<p className="text-sm text-muted-foreground mt-0.5">{JSON.stringify(a.details)}</p>
)}
{a.errorMessage && <p className="text-sm text-destructive mt-0.5">{a.errorMessage}</p>}
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<Clock className="h-3 w-3 text-muted-foreground" /> <Clock className="h-3 w-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">{format(parseISO(a.createdAt), "MMM d, HH:mm:ss")}</span> <span className="text-xs text-muted-foreground">{format(parseISO(a.createdAt), "MMM d, HH:mm:ss")}</span>
+3
View File
@@ -3,6 +3,7 @@ import { createRoute } from "@tanstack/react-router";
import { Route as appRoute } from "../_app"; import { Route as appRoute } from "../_app";
import { useApiQuery } from "@/lib/api"; import { useApiQuery } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store"; import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime";
import { Download, Calendar } from "lucide-react"; import { Download, Calendar } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { LoadingState, ErrorState } from "@/components/state"; import { LoadingState, ErrorState } from "@/components/state";
@@ -140,6 +141,8 @@ function AnalyticsPage() {
const activeDomainId = useApiDomain(); const activeDomainId = useApiDomain();
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
useRealtime({ enabled: true });
const { data: habitData, isLoading: habitsLoading, error: habitsError, refetch: refetchHabits } = useApiQuery<HabitAnalytics>(["analytics-habits", activeDomainId, range], "/analytics/habits?range=" + range + domainSuffix); const { data: habitData, isLoading: habitsLoading, error: habitsError, refetch: refetchHabits } = useApiQuery<HabitAnalytics>(["analytics-habits", activeDomainId, range], "/analytics/habits?range=" + range + domainSuffix);
const { data: projectData, isLoading: projectsLoading, error: projectsError, refetch: refetchProjects } = useApiQuery<ProjectAnalytics>(["analytics-projects", activeDomainId, range], "/analytics/projects?range=" + range + domainSuffix); const { data: projectData, isLoading: projectsLoading, error: projectsError, refetch: refetchProjects } = useApiQuery<ProjectAnalytics>(["analytics-projects", activeDomainId, range], "/analytics/projects?range=" + range + domainSuffix);
const { data: dailyData, isLoading: dailyLoading, error: dailyError, refetch: refetchDaily } = useApiQuery<DailyAnalytics>(["analytics-daily", activeDomainId, range], "/analytics/daily?range=" + range + domainSuffix); const { data: dailyData, isLoading: dailyLoading, error: dailyError, refetch: refetchDaily } = useApiQuery<DailyAnalytics>(["analytics-daily", activeDomainId, range], "/analytics/daily?range=" + range + domainSuffix);
+7 -3
View File
@@ -5,6 +5,7 @@ import { useQueryClient, useMutation } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api"; import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store"; import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime"; import { useRealtime } from "@/hooks/use-realtime";
import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog";
import { Plus, Trash2, ChevronLeft, ChevronRight } from "lucide-react"; import { Plus, Trash2, ChevronLeft, ChevronRight } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -95,6 +96,7 @@ function CustomToolbar({ date, onNavigate, label }: any) {
function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => void }) { function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => void }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const activeDomainId = useApiDomain();
const [title, setTitle] = useState(event?.title || ""); const [title, setTitle] = useState(event?.title || "");
const [startTime, setStartTime] = useState(event?.startTime ? event.startTime.slice(0, 16) : ""); const [startTime, setStartTime] = useState(event?.startTime ? event.startTime.slice(0, 16) : "");
const [endTime, setEndTime] = useState(event?.endTime ? event.endTime.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 (startTime) data.startTime = new Date(startTime).toISOString();
if (endTime) data.endTime = new Date(endTime).toISOString(); if (endTime) data.endTime = new Date(endTime).toISOString();
if (event) updateMutation.mutate(data); if (event) updateMutation.mutate(data);
else createMutation.mutate(data); else createMutation.mutate({ ...data, ...(activeDomainId ? { domain: activeDomainId } : {}) });
}; };
return ( return (
@@ -142,7 +144,7 @@ function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => v
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<Label htmlFor="start">Start</Label> <Label htmlFor="start">Start</Label>
<Input id="start" type={allDay ? "date" : "datetime-local"} value={startTime} onChange={(e) => setStartTime(e.target.value)} /> <Input id="start" type={allDay ? "date" : "datetime-local"} value={startTime} onChange={(e) => setStartTime(e.target.value)} required />
</div> </div>
<div> <div>
<Label htmlFor="end">End</Label> <Label htmlFor="end">End</Label>
@@ -172,6 +174,8 @@ function CalendarPage() {
useRealtime({ enabled: true }); useRealtime({ enabled: true });
useOpenCreateDialog("event", () => setCreateOpen(true));
useEffect(() => { useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768); const check = () => setIsMobile(window.innerWidth < 768);
check(); check();
@@ -183,7 +187,7 @@ function CalendarPage() {
const activeDomainId = useApiDomain(); const activeDomainId = useApiDomain();
const { data: eventsData, isLoading: eventsLoading } = useApiQuery<{ items: CalendarEvent[]; totalItems: number }>( 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 : "") `/calendar/events?from=${new Date(0).toISOString()}&to=${new Date("2100-01-01").toISOString()}` + (activeDomainId ? "&domain=" + activeDomainId : "")
); );
+31 -4
View File
@@ -15,6 +15,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import type { Canvas, CanvasCard, PaginatedResponse } from "@/lib/types"; import type { Canvas, CanvasCard, PaginatedResponse } from "@/lib/types";
const BLOCK_TYPES = [ const BLOCK_TYPES = [
@@ -382,12 +383,13 @@ function CanvasList() {
const navigate = useNavigate(); const navigate = useNavigate();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const activeDomainId = useApiDomain();
const { data, isLoading } = useApiQuery<PaginatedResponse<Canvas>>(["canvas"], "/canvas"); const { data, isLoading } = useApiQuery<PaginatedResponse<Canvas>>(["canvas", activeDomainId], "/canvas" + (activeDomainId ? "?domain=" + activeDomainId : ""));
const canvases = data?.items || []; const canvases = data?.items || [];
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (name: string) => api.post<Canvas>("/canvas", { name }), mutationFn: (name: string) => api.post<Canvas>("/canvas", { name, ...(activeDomainId ? { domain: activeDomainId } : {}) }),
onSuccess: (canvas) => { onSuccess: (canvas) => {
queryClient.invalidateQueries({ queryKey: ["canvas"] }); queryClient.invalidateQueries({ queryKey: ["canvas"] });
setCreateOpen(false); setCreateOpen(false);
@@ -429,9 +431,34 @@ function CanvasList() {
) : ( ) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{canvases.map((c) => ( {canvases.map((c) => (
<Card key={c.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => navigate({ to: "/canvas/$id", params: { id: c.id } })}> <Card key={c.id} className="cursor-pointer hover:shadow-md transition-shadow group" onClick={() => navigate({ to: "/canvas/$id", params: { id: c.id } })}>
<CardHeader className="p-4 pb-2"> <CardHeader className="p-4 pb-2">
<CardTitle className="text-sm font-semibold truncate">{c.name}</CardTitle> <div className="flex items-start justify-between gap-2">
<CardTitle className="text-sm font-semibold truncate">{c.name}</CardTitle>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 opacity-0 group-hover:opacity-100 text-destructive"
onClick={(e) => e.stopPropagation()}
aria-label={"Delete canvas " + c.name}
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Canvas</AlertDialogTitle>
<AlertDialogDescription>Are you sure you want to delete "{c.name}"? All blocks in it will be removed. This cannot be undone.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={(e) => e.stopPropagation()}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(c.id); }} className="bg-destructive text-destructive-foreground">Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardHeader> </CardHeader>
<CardContent className="p-4 pt-0"> <CardContent className="p-4 pt-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
+11 -7
View File
@@ -13,6 +13,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import type { DailyNote } from "@/lib/types"; import type { DailyNote } from "@/lib/types";
import { format, startOfMonth, endOfMonth, eachDayOfInterval, getDay, isSameDay, isToday, addMonths, subMonths } from "date-fns"; 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"]; const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
// Check which dates have notes // 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 || []; const notes = data?.items || [];
// The API stores daily notes at UTC midnight (YYYY-MM-DDT00:00:00.000Z). // 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 // 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 }) { function DailyNoteEditor({ date }: { date: Date }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const activeDomainId = useApiDomain();
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
const dateStr = format(date, "yyyy-MM-dd"); const dateStr = format(date, "yyyy-MM-dd");
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [mood, setMood] = useState<number | null>(null); const [mood, setMood] = useState<number | null>(null);
@@ -106,8 +110,8 @@ function DailyNoteEditor({ date }: { date: Date }) {
noteIdRef.current = noteId; noteIdRef.current = noteId;
const { data: note, isLoading } = useApiQuery<DailyNote | null>( const { data: note, isLoading } = useApiQuery<DailyNote | null>(
["daily-note", dateStr], ["daily-note", dateStr, activeDomainId],
"/daily-notes?date=" + dateStr "/daily-notes?date=" + dateStr + domainSuffix
); );
useEffect(() => { useEffect(() => {
@@ -174,10 +178,10 @@ function DailyNoteEditor({ date }: { date: Date }) {
if (id) { if (id) {
updateMutation.mutate({ id, data: { content: newContent, mood: newMood, energy: newEnergy } }); updateMutation.mutate({ id, data: { content: newContent, mood: newMood, energy: newEnergy } });
} else if (newContent.trim()) { } 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); }, 1500);
}, [dateStr, updateMutation, createMutation]); }, [dateStr, activeDomainId, updateMutation, createMutation]);
const handleContentChange = (value: string) => { const handleContentChange = (value: string) => {
setContent(value); setContent(value);
@@ -191,7 +195,7 @@ function DailyNoteEditor({ date }: { date: Date }) {
} else if (isNew && !createMutation.isPending) { } else if (isNew && !createMutation.isPending) {
// No note exists for this day yet — create it so the mood is recorded // No note exists for this day yet — create it so the mood is recorded
// even before any content is typed. // 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) { } else if (isNew && !createMutation.isPending) {
// No note exists for this day yet — create it so the energy is recorded // No note exists for this day yet — create it so the energy is recorded
// even before any content is typed. // 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 } : {}) });
} }
}; };
+39 -3
View File
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api"; import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store"; import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime"; import { useRealtime } from "@/hooks/use-realtime";
import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog";
import { Plus, Flame, Trash2, Check, Pencil } from "lucide-react"; import { Plus, Flame, Trash2, Check, Pencil } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; 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 }) { function HabitForm({ habit, onClose }: { habit?: Habit; onClose: () => void }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const activeDomainId = useApiDomain();
const [name, setName] = useState(habit?.name || ""); const [name, setName] = useState(habit?.name || "");
const [description, setDescription] = useState(habit?.description || ""); const [description, setDescription] = useState(habit?.description || "");
const [frequency, setFrequency] = useState(habit?.frequency || "daily"); const [frequency, setFrequency] = useState(habit?.frequency || "daily");
@@ -44,7 +46,7 @@ function HabitForm({ habit, onClose }: { habit?: Habit; onClose: () => void }) {
if (!name.trim()) return; if (!name.trim()) return;
const data = { name: name.trim(), description: description || null, frequency, difficulty, goalPerPeriod }; const data = { name: name.trim(), description: description || null, frequency, difficulty, goalPerPeriod };
if (habit) updateMutation.mutate(data); if (habit) updateMutation.mutate(data);
else createMutation.mutate(data); else createMutation.mutate({ ...data, ...(activeDomainId ? { domain: activeDomainId } : {}) });
}; };
return ( return (
@@ -124,14 +126,35 @@ function HabitsPage() {
useRealtime({ enabled: true }); useRealtime({ enabled: true });
useOpenCreateDialog("habit", () => setCreateOpen(true));
const activeDomainId = useApiDomain(); const activeDomainId = useApiDomain();
const habitQueryUrl = () => "/habits?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "");
const { data: habitsData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Habit>>( const { data: habitsData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Habit>>(
["habits", activeDomainId], ["habits", activeDomainId],
"/habits?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "") habitQueryUrl()
); );
const habits = habitsData?.items || []; 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<PaginatedResponse<Habit>>(habitQueryUrl() + "&offset=" + habits.length);
queryClient.setQueryData<PaginatedResponse<Habit>>(["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({ const completeMutation = useMutation({
mutationFn: (id: string) => api.post("/habits/" + id + "/complete", {}), mutationFn: (id: string) => api.post("/habits/" + id + "/complete", {}),
@@ -203,6 +226,11 @@ function HabitsPage() {
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
disabled={(habit.recentCompletions || []).some((c) => {
const d = new Date(c.date);
const t = new Date();
return d.getUTCFullYear() === t.getUTCFullYear() && d.getUTCMonth() === t.getUTCMonth() && d.getUTCDate() === t.getUTCDate();
})}
onClick={(e) => { e.stopPropagation(); completeMutation.mutate(habit.id); }} onClick={(e) => { e.stopPropagation(); completeMutation.mutate(habit.id); }}
aria-label={"Mark " + habit.name + " complete"} aria-label={"Mark " + habit.name + " complete"}
> >
@@ -225,7 +253,15 @@ function HabitsPage() {
</div> </div>
)} )}
<EntityDetailPanel open={panelOpen} onOpenChange={setPanelOpen} title={selectedHabit?.name || "Habit Details"}> {hasMoreHabits && (
<div className="flex justify-center pt-2">
<Button variant="outline" size="sm" onClick={loadMoreHabits} disabled={loadingMoreHabits}>
{loadingMoreHabits ? "Loading..." : "Load more habits"}
</Button>
</div>
)}
<EntityDetailPanel open={panelOpen} onOpenChange={(o) => { setPanelOpen(o); if (!o) setDetailTab("overview"); }} title={selectedHabit?.name || "Habit Details"}>
{selectedHabit && ( {selectedHabit && (
<div className="space-y-4"> <div className="space-y-4">
<Tabs value={detailTab} onValueChange={setDetailTab}> <Tabs value={detailTab} onValueChange={setDetailTab}>
+59 -29
View File
@@ -4,6 +4,7 @@ import { Route as appRoute } from "../_app";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api"; import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { useRealtime } from "@/hooks/use-realtime"; 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 { Plus, Settings2, Trash2, ListTodo, Flame, FileText, FolderKanban, Calendar, Zap, TrendingUp, Target } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -31,7 +32,9 @@ const WIDGET_TYPES = [
] as const; ] as const;
function TasksDueWidget() { function TasksDueWidget() {
const { data } = useApiQuery<PaginatedResponse<Task>>(["tasks-due"], "/tasks?limit=10&status=todo,in_progress&sort=due_date"); const activeDomainId = useApiDomain();
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
const { data } = useApiQuery<PaginatedResponse<Task>>(["tasks-due", activeDomainId], "/tasks?limit=10&status=todo,in_progress&sort=due_date" + domainSuffix);
const tasks = data?.items || []; const tasks = data?.items || [];
const today = tasks.filter((t) => t.dueDate && isToday(parseISO(t.dueDate))); 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"); const overdue = tasks.filter((t) => t.dueDate && isPast(parseISO(t.dueDate)) && t.status !== "done");
@@ -70,7 +73,9 @@ function TasksDueWidget() {
} }
function HabitsTodayWidget() { function HabitsTodayWidget() {
const { data } = useApiQuery<PaginatedResponse<Habit>>(["habits-today"], "/habits?limit=20"); const activeDomainId = useApiDomain();
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
const { data } = useApiQuery<PaginatedResponse<Habit>>(["habits-today", activeDomainId], "/habits?limit=20" + domainSuffix);
const habits = data?.items || []; const habits = data?.items || [];
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const completeMutation = useMutation({ const completeMutation = useMutation({
@@ -87,30 +92,39 @@ function HabitsTodayWidget() {
{habits.length === 0 ? ( {habits.length === 0 ? (
<p className="text-sm text-muted-foreground">No habits yet</p> <p className="text-sm text-muted-foreground">No habits yet</p>
) : ( ) : (
habits.slice(0, 6).map((h) => ( habits.slice(0, 6).map((h) => {
<div key={h.id} className="flex items-center gap-2 py-1"> const doneToday = (h.recentCompletions || []).some((c) => {
<button const d = new Date(c.date);
onClick={() => completeMutation.mutate(h.id)} const today = new Date();
className={cn("w-4 h-4 rounded border shrink-0 flex items-center justify-center", h.streakCount > 0 ? "bg-green-500 border-green-500" : "border-muted-foreground/30 hover:border-primary")} return d.getUTCFullYear() === today.getUTCFullYear() && d.getUTCMonth() === today.getUTCMonth() && d.getUTCDate() === today.getUTCDate();
aria-label={"Complete " + h.name} });
> return (
{h.streakCount > 0 && <span className="text-[10px] text-white">\u2713</span>} <div key={h.id} className="flex items-center gap-2 py-1">
</button> <button
<span className="text-sm truncate flex-1">{h.name}</span> onClick={() => completeMutation.mutate(h.id)}
{h.streakCount > 0 && ( className={cn("w-4 h-4 rounded border shrink-0 flex items-center justify-center", doneToday ? "bg-green-500 border-green-500" : "border-muted-foreground/30 hover:border-primary")}
<Badge variant="secondary" className="text-[10px] shrink-0"> aria-label={doneToday ? h.name + " (completed)" : "Complete " + h.name}
<Flame className="h-2.5 w-2.5 mr-0.5" />{h.streakCount} >
</Badge> {doneToday && <span className="text-[10px] text-white">\u2713</span>}
)} </button>
</div> <span className="text-sm truncate flex-1">{h.name}</span>
)) {h.streakCount > 0 && (
<Badge variant="secondary" className="text-[10px] shrink-0">
<Flame className="h-2.5 w-2.5 mr-0.5" />{h.streakCount}
</Badge>
)}
</div>
);
})
)} )}
</div> </div>
); );
} }
function RecentNotesWidget() { function RecentNotesWidget() {
const { data } = useApiQuery<PaginatedResponse<Note>>(["recent-notes"], "/notes?limit=5&sort=-updated"); const activeDomainId = useApiDomain();
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
const { data } = useApiQuery<PaginatedResponse<Note>>(["recent-notes", activeDomainId], "/notes?limit=5&sort=-updated" + domainSuffix);
const notes = data?.items || []; const notes = data?.items || [];
return ( return (
<div className="space-y-2"> <div className="space-y-2">
@@ -129,7 +143,9 @@ function RecentNotesWidget() {
} }
function ActiveProjectsWidget() { function ActiveProjectsWidget() {
const { data } = useApiQuery<PaginatedResponse<Project>>(["active-projects"], "/projects?limit=10&status=active"); const activeDomainId = useApiDomain();
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
const { data } = useApiQuery<PaginatedResponse<Project>>(["active-projects", activeDomainId], "/projects?limit=10&status=active" + domainSuffix);
const projects = data?.items || []; const projects = data?.items || [];
return ( return (
<div className="space-y-3"> <div className="space-y-3">
@@ -151,7 +167,9 @@ function ActiveProjectsWidget() {
} }
function UpcomingEventsWidget() { function UpcomingEventsWidget() {
const { data } = useApiQuery<PaginatedResponse<CalendarEvent>>(["upcoming-events"], "/calendar/events?limit=20"); const activeDomainId = useApiDomain();
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
const { data } = useApiQuery<PaginatedResponse<CalendarEvent>>(["upcoming-events", activeDomainId], "/calendar/events?limit=20" + domainSuffix);
const events = data?.items || []; const events = data?.items || [];
const now = new Date(); const now = new Date();
const weekFromNow = addDays(now, 7); const weekFromNow = addDays(now, 7);
@@ -177,7 +195,9 @@ function UpcomingEventsWidget() {
} }
function StreakCounterWidget() { function StreakCounterWidget() {
const { data } = useApiQuery<PaginatedResponse<Habit>>(["streaks"], "/habits?limit=50"); const activeDomainId = useApiDomain();
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
const { data } = useApiQuery<PaginatedResponse<Habit>>(["streaks", activeDomainId], "/habits?limit=50" + domainSuffix);
const habits = data?.items || []; const habits = data?.items || [];
const bestStreak = Math.max(...habits.map((h) => h.streakCount || 0), 0); const bestStreak = Math.max(...habits.map((h) => h.streakCount || 0), 0);
const totalActive = habits.filter((h) => h.streakCount > 0).length; const totalActive = habits.filter((h) => h.streakCount > 0).length;
@@ -199,10 +219,11 @@ function StreakCounterWidget() {
function QuickCaptureWidget() { function QuickCaptureWidget() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const activeDomainId = useApiDomain();
const [text, setText] = useState(""); const [text, setText] = useState("");
const [type, setType] = useState<"task" | "note">("task"); const [type, setType] = useState<"task" | "note">("task");
const createTask = useMutation({ const createTask = useMutation({
mutationFn: (title: string) => api.post<Task>("/tasks", { title, status: "todo", priority: "medium" }), mutationFn: (title: string) => api.post<Task>("/tasks", { title, status: "todo", priority: "medium", ...(activeDomainId ? { domain: activeDomainId } : {}) }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks-due"] }); queryClient.invalidateQueries({ queryKey: ["tasks-due"] });
setText(""); setText("");
@@ -211,7 +232,7 @@ function QuickCaptureWidget() {
onError: (err) => toast.error(err.message || "Failed to create task"), onError: (err) => toast.error(err.message || "Failed to create task"),
}); });
const createNote = useMutation({ const createNote = useMutation({
mutationFn: (title: string) => api.post<Note>("/notes", { title, content: "" }), mutationFn: (title: string) => api.post<Note>("/notes", { title, content: "", ...(activeDomainId ? { domain: activeDomainId } : {}) }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["recent-notes"] }); queryClient.invalidateQueries({ queryKey: ["recent-notes"] });
setText(""); setText("");
@@ -244,7 +265,9 @@ function QuickCaptureWidget() {
} }
function ProductivityChartWidget() { function ProductivityChartWidget() {
const { data } = useApiQuery<any>(["productivity-chart"], "/analytics/productivity?range=30"); const activeDomainId = useApiDomain();
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
const { data } = useApiQuery<any>(["productivity-chart", activeDomainId], "/analytics/productivity?range=30" + domainSuffix);
const stats = data; const stats = data;
if (!stats) return <p className="text-sm text-muted-foreground">Loading...</p>; if (!stats) return <p className="text-sm text-muted-foreground">Loading...</p>;
return ( return (
@@ -269,7 +292,9 @@ function ProductivityChartWidget() {
} }
function StatsWidget() { function StatsWidget() {
const { data } = useApiQuery<any>(["stats"], "/analytics/productivity?range=30"); const activeDomainId = useApiDomain();
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
const { data } = useApiQuery<any>(["stats", activeDomainId], "/analytics/productivity?range=30" + domainSuffix);
const stats = data; const stats = data;
if (!stats) return <p className="text-sm text-muted-foreground">Loading...</p>; if (!stats) return <p className="text-sm text-muted-foreground">Loading...</p>;
return ( return (
@@ -379,7 +404,6 @@ function ConfigureWidgetDialog({ widget, open, onOpenChange, onSave }: { widget:
<SelectItem value="2">2 columns</SelectItem> <SelectItem value="2">2 columns</SelectItem>
<SelectItem value="3">3 columns</SelectItem> <SelectItem value="3">3 columns</SelectItem>
<SelectItem value="4">4 columns</SelectItem> <SelectItem value="4">4 columns</SelectItem>
<SelectItem value="6">6 columns</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -459,7 +483,13 @@ function DashboardPage() {
) : widgets.length === 0 ? ( ) : widgets.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-muted-foreground mb-4">Your dashboard is empty. Add some widgets to get started!</p> <p className="text-muted-foreground mb-4">Your dashboard is empty. Add some widgets to get started!</p>
<Button onClick={() => handleAddWidget("tasks_due")}><Plus className="h-4 w-4 mr-2" />Add Default Widgets</Button> <Button onClick={() => {
const defaults = ["tasks_due", "habits_today", "recent_notes", "active_projects", "upcoming_events", "streak_counter", "quick_capture", "productivity_chart"];
defaults.forEach((type, i) => {
const typeInfo = WIDGET_TYPES.find((t) => t.id === type);
createMutation.mutate({ type, title: typeInfo?.label || type, layout: { x: 0, y: i, w: typeInfo?.defaultW || 2, h: typeInfo?.defaultH || 2 } });
});
}}><Plus className="h-4 w-4 mr-2" />Add Default Widgets</Button>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 gap-4 auto-rows-[minmax(120px,auto)] sm:grid-cols-2 lg:grid-cols-4"> <div className="grid grid-cols-1 gap-4 auto-rows-[minmax(120px,auto)] sm:grid-cols-2 lg:grid-cols-4">
+57 -9
View File
@@ -5,13 +5,16 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery } from "@/lib/api"; import { api, useApiQuery } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store"; import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime"; 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 { Plus, Trash2, Search, Pin, FileText, Link as LinkIcon, History } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; 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 { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Note, PaginatedResponse } from "@/lib/types"; import type { Note, PaginatedResponse } from "@/lib/types";
import { format, parseISO } from "date-fns";
import { useEditor, EditorContent } from "@tiptap/react"; import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link"; 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 // 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 [showBacklinks, setShowBacklinks] = useState(false);
const [showVersions, setShowVersions] = useState(false); const [showVersions, setShowVersions] = useState(false);
const [versions, setVersions] = useState<{ id: string; createdAt: string; action?: string; changes?: Record<string, unknown> | null }[]>([]);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const updateMutation = useMutation({ const updateMutation = useMutation({
@@ -157,7 +161,7 @@ const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete }: { note:
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setShowBacklinks(!showBacklinks)} aria-label="Backlinks"> <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setShowBacklinks(!showBacklinks)} aria-label="Backlinks">
<LinkIcon className="h-4 w-4" /> <LinkIcon className="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setShowVersions(!showVersions)} aria-label="Version history"> <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => { setShowVersions(!showVersions); if (!showVersions) { api.get<{ items: { id: string; createdAt: string; action?: string; changes?: Record<string, unknown> | null }[] }>("/notes/" + note.id + "/versions").then((data) => setVersions(data.items || [])).catch(() => setVersions([])); } }} aria-label="Version history">
<History className="h-4 w-4" /> <History className="h-4 w-4" />
</Button> </Button>
<AlertDialog> <AlertDialog>
@@ -188,8 +192,12 @@ const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete }: { note:
<h4 className="text-sm font-semibold mb-2">Linked from</h4> <h4 className="text-sm font-semibold mb-2">Linked from</h4>
<div className="space-y-1"> <div className="space-y-1">
{note.backlinks.map((bl) => ( {note.backlinks.map((bl) => (
<div key={bl.noteId} className="text-sm text-muted-foreground hover:text-foreground cursor-pointer"> <div
{bl.noteTitle} key={bl.id}
className="text-sm text-muted-foreground hover:text-foreground cursor-pointer"
onClick={() => { const linked: Note = { ...note, id: bl.id, title: bl.title }; onOpenNote(linked); }}
>
{bl.title}
</div> </div>
))} ))}
</div> </div>
@@ -199,7 +207,18 @@ const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete }: { note:
{showVersions && ( {showVersions && (
<div className="border-t p-3"> <div className="border-t p-3">
<h4 className="text-sm font-semibold mb-2">Version History</h4> <h4 className="text-sm font-semibold mb-2">Version History</h4>
<p className="text-xs text-muted-foreground">Version history available via API.</p> {versions.length === 0 ? (
<p className="text-xs text-muted-foreground">No versions yet.</p>
) : (
<div className="space-y-1">
{versions.map((v) => (
<div key={v.id} className="flex items-center justify-between text-xs text-muted-foreground">
<span>{format(parseISO(v.createdAt), "MMM d, yyyy HH:mm")}</span>
{v.action && <Badge variant="secondary" className="text-[10px] capitalize">{v.action}</Badge>}
</div>
))}
</div>
)}
</div> </div>
)} )}
</> </>
@@ -214,17 +233,39 @@ function NotesPage() {
useRealtime({ enabled: true }); useRealtime({ enabled: true });
useOpenCreateDialog("note", () => createMutation.mutate());
const activeDomainId = useApiDomain(); const activeDomainId = useApiDomain();
const notesQueryUrl = () =>
"/notes?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "") + (search ? "&search=" + encodeURIComponent(search) : "");
const { data: notesData, isLoading } = useApiQuery<PaginatedResponse<Note>>( const { data: notesData, isLoading } = useApiQuery<PaginatedResponse<Note>>(
["notes", activeDomainId, search], ["notes", activeDomainId, search],
"/notes?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "") + (search ? "&search=" + encodeURIComponent(search) : "") notesQueryUrl()
); );
const notes = notesData?.items || []; 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<PaginatedResponse<Note>>(notesQueryUrl() + "&offset=" + notes.length);
queryClient.setQueryData<PaginatedResponse<Note>>(["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({ const createMutation = useMutation({
mutationFn: () => api.post<Note>("/notes", { title: "Untitled", content: "" }), mutationFn: () => api.post<Note>("/notes", { title: "Untitled", content: "", ...(activeDomainId ? { domain: activeDomainId } : {}) }),
onSuccess: (note) => { onSuccess: (note) => {
queryClient.invalidateQueries({ queryKey: ["notes"] }); queryClient.invalidateQueries({ queryKey: ["notes"] });
selectedNoteRef.current = note; selectedNoteRef.current = note;
@@ -264,7 +305,7 @@ function NotesPage() {
<div className="p-3 border-b"> <div className="p-3 border-b">
<div className="relative"> <div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search notes..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-8" tabIndex={-1} onMouseDown={(e) => e.preventDefault()} /> <Input placeholder="Search notes..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-8" aria-label="Search notes" />
</div> </div>
</div> </div>
<div className="p-2"> <div className="p-2">
@@ -299,13 +340,20 @@ function NotesPage() {
))} ))}
</div> </div>
)} )}
{hasMoreNotes && (
<div className="p-2">
<Button variant="outline" size="sm" className="w-full" onClick={loadMoreNotes} disabled={loadingMoreNotes}>
{loadingMoreNotes ? "Loading..." : "Load more notes"}
</Button>
</div>
)}
</ScrollArea> </ScrollArea>
</div> </div>
{/* Right pane - editor (memoized, won't re-render on parent state changes) */} {/* Right pane - editor (memoized, won't re-render on parent state changes) */}
<div className="flex-1 flex flex-col min-h-64 md:min-h-0"> <div className="flex-1 flex flex-col min-h-64 md:min-h-0">
{selectedNote ? ( {selectedNote ? (
<NoteEditorPane key={selectedNote.id} note={selectedNote} onDelete={handleDeleteNote} /> <NoteEditorPane key={selectedNote.id} note={selectedNote} onDelete={handleDeleteNote} onOpenNote={selectNote} />
) : ( ) : (
<div className="flex items-center justify-center flex-1 text-muted-foreground"> <div className="flex items-center justify-center flex-1 text-muted-foreground">
<div className="text-center"> <div className="text-center">
+33 -2
View File
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api"; import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store"; import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime"; 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 { Plus, Pencil, Trash2, FolderKanban, Users, Calendar, ListTodo, GripVertical } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; 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 }) { function ProjectForm({ project, onClose }: { project?: Project; onClose: () => void }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const activeDomainId = useApiDomain();
const [name, setName] = useState(project?.name || ""); const [name, setName] = useState(project?.name || "");
const [description, setDescription] = useState(project?.description || ""); const [description, setDescription] = useState(project?.description || "");
const [status, setStatus] = useState(project?.status || "active"); 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 }; const data: any = { name: name.trim(), description: description || null, status, color };
if (targetDate) data.targetDate = new Date(targetDate).toISOString(); if (targetDate) data.targetDate = new Date(targetDate).toISOString();
if (project) updateMutation.mutate(data); if (project) updateMutation.mutate(data);
else createMutation.mutate(data); else createMutation.mutate({ ...data, ...(activeDomainId ? { domain: activeDomainId } : {}) });
}; };
return ( return (
@@ -103,14 +105,35 @@ function ProjectsPage() {
useRealtime({ enabled: true }); useRealtime({ enabled: true });
useOpenCreateDialog("project", () => setCreateOpen(true));
const activeDomainId = useApiDomain(); const activeDomainId = useApiDomain();
const projectQueryUrl = () => "/projects?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "");
const { data: projectsData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Project>>( const { data: projectsData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Project>>(
["projects", activeDomainId], ["projects", activeDomainId],
"/projects?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "") projectQueryUrl()
); );
const projects = projectsData?.items || []; 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<PaginatedResponse<Project>>(projectQueryUrl() + "&offset=" + projects.length);
queryClient.setQueryData<PaginatedResponse<Project>>(["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({ const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete("/projects/" + id), mutationFn: (id: string) => api.delete("/projects/" + id),
@@ -191,6 +214,14 @@ function ProjectsPage() {
</div> </div>
)} )}
{hasMoreProjects && (
<div className="flex justify-center pt-2">
<Button variant="outline" size="sm" onClick={loadMoreProjects} disabled={loadingMoreProjects}>
{loadingMoreProjects ? "Loading..." : "Load more projects"}
</Button>
</div>
)}
<EntityDetailPanel open={panelOpen} onOpenChange={setPanelOpen} title={selectedProject?.name || "Project Details"}> <EntityDetailPanel open={panelOpen} onOpenChange={setPanelOpen} title={selectedProject?.name || "Project Details"}>
{selectedProject && ( {selectedProject && (
<Tabs value={detailTab} onValueChange={setDetailTab}> <Tabs value={detailTab} onValueChange={setDetailTab}>
+6 -3
View File
@@ -10,6 +10,7 @@ import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import type { SearchResult } from "@/lib/types"; import type { SearchResult } from "@/lib/types";
const SEARCH_TYPES = [ const SEARCH_TYPES = [
@@ -54,9 +55,11 @@ function SearchPage() {
if (inputRef.current) inputRef.current.focus(); if (inputRef.current) inputRef.current.focus();
}, []); }, []);
const activeDomainId = useApiDomain();
const { data: searchData, isLoading } = useApiQuery<{ results: SearchResult[]; totalCount: number }>( const { data: searchData, isLoading } = useApiQuery<{ results: SearchResult[]; totalCount: number }>(
["search", debouncedQuery, ...Array.from(selectedTypes)], ["search", activeDomainId, debouncedQuery, ...Array.from(selectedTypes)],
"/search?q=" + encodeURIComponent(debouncedQuery) + "&types=" + Array.from(selectedTypes).join(",") + "&limit=50" "/search?q=" + encodeURIComponent(debouncedQuery) + "&types=" + Array.from(selectedTypes).join(",") + "&limit=50" + (activeDomainId ? "&domain=" + activeDomainId : "")
); );
const results = searchData?.results || []; const results = searchData?.results || [];
@@ -166,7 +169,7 @@ function SearchPage() {
<div <div
key={result.id + result.type} key={result.id + result.type}
className="flex items-center justify-between p-3 rounded-lg hover:bg-accent cursor-pointer transition-colors" className="flex items-center justify-between p-3 rounded-lg hover:bg-accent cursor-pointer transition-colors"
onClick={() => navigate({ to: result.link as any })} onClick={() => navigate({ to: result.type === "domain" ? "/settings" : (result.link as any) })}
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="font-medium text-sm truncate">{result.title}</p> <p className="font-medium text-sm truncate">{result.title}</p>
+27 -15
View File
@@ -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 { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useThemeStore, ACCENT_PALETTE, type ThemeMode, type AccentColor } from "@/lib/stores/use-theme-store"; 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"; import type { Domain, CustomField, Webhook as WebhookType, ErrorLog, Agent, PaginatedResponse } from "@/lib/types";
const SETTINGS_TABS = [ const SETTINGS_TABS = [
@@ -40,15 +41,19 @@ const ACCENT_COLORS = Object.entries(ACCENT_PALETTE).map(([key, val]) => ({
const SHORTCUTS_MAP: Record<string, string> = { const SHORTCUTS_MAP: Record<string, string> = {
"Cmd+K": "Command palette", "Cmd+K": "Command palette",
"Cmd+N": "New task",
"g+t": "Go to Tasks", "g+t": "Go to Tasks",
"g+h": "Go to Habits", "g+h": "Go to Habits",
"g+p": "Go to Projects", "g+p": "Go to Projects",
"g+n": "Go to Notes", "g+n": "Go to Notes",
"g+c": "Go to Calendar", "g+c": "Go to Calendar",
"g+g": "Go to Graph",
"g+d": "Go to Dashboard", "g+d": "Go to Dashboard",
"g+s": "Go to Settings", "g+s": "Go to Settings",
"g+a": "Go to Analytics", "n+t": "New task",
"n": "New task / note (context dependent)", "n+h": "New habit",
"n+p": "New project",
"n+n": "New note",
"?": "Show keyboard shortcuts help", "?": "Show keyboard shortcuts help",
}; };
@@ -204,7 +209,7 @@ function DomainsTab() {
<Button variant="ghost" size="icon" className="text-destructive"><Trash2 className="h-4 w-4" /></Button> <Button variant="ghost" size="icon" className="text-destructive"><Trash2 className="h-4 w-4" /></Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader><AlertDialogTitle>Delete Domain</AlertDialogTitle><AlertDialogDescription>Are you sure? This cannot be undone.</AlertDialogDescription></AlertDialogHeader> <AlertDialogHeader><AlertDialogTitle>Delete Domain</AlertDialogTitle><AlertDialogDescription>Are you sure? This permanently deletes this workspace and <span className="font-semibold">all</span> of its tasks, habits, projects, notes, calendar events, and settings. This cannot be undone.</AlertDialogDescription></AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => deleteMutation.mutate(d.id)} className="bg-destructive">Delete</AlertDialogAction> <AlertDialogAction onClick={() => deleteMutation.mutate(d.id)} className="bg-destructive">Delete</AlertDialogAction>
@@ -279,8 +284,9 @@ function TagsTab() {
function CustomFieldsTab() { function CustomFieldsTab() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const activeDomainId = useApiDomain();
const [entityFilter, setEntityFilter] = useState(""); const [entityFilter, setEntityFilter] = useState("");
const { data } = useApiQuery<PaginatedResponse<CustomField>>(["custom-fields", entityFilter], "/custom-fields" + (entityFilter ? "?entity=" + entityFilter : "")); const { data } = useApiQuery<PaginatedResponse<CustomField>>(["custom-fields", entityFilter, activeDomainId], "/custom-fields" + (activeDomainId ? "?domain=" + activeDomainId : "") + (entityFilter && entityFilter !== "all" ? "&entity=" + entityFilter : ""));
const fields = data?.items || []; const fields = data?.items || [];
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [editField, setEditField] = useState<CustomField | null>(null); const [editField, setEditField] = useState<CustomField | null>(null);
@@ -303,7 +309,7 @@ function CustomFieldsTab() {
const data: any = { name: form.name, type: form.type, entityType: form.entityType, required: form.required }; 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 (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 }); if (editField) updateMutation.mutate({ id: editField.id, data });
else createMutation.mutate(data); else createMutation.mutate({ ...data, ...(activeDomainId ? { domain: activeDomainId } : {}) });
}; };
return ( return (
@@ -314,7 +320,7 @@ function CustomFieldsTab() {
<Select value={entityFilter} onValueChange={setEntityFilter}> <Select value={entityFilter} onValueChange={setEntityFilter}>
<SelectTrigger className="w-36"><SelectValue placeholder="All entities" /></SelectTrigger> <SelectTrigger className="w-36"><SelectValue placeholder="All entities" /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value=" ">All entities</SelectItem> <SelectItem value="all">All entities</SelectItem>
<SelectItem value="tasks">Tasks</SelectItem> <SelectItem value="tasks">Tasks</SelectItem>
<SelectItem value="habits">Habits</SelectItem> <SelectItem value="habits">Habits</SelectItem>
<SelectItem value="projects">Projects</SelectItem> <SelectItem value="projects">Projects</SelectItem>
@@ -429,7 +435,8 @@ function ShortcutsTab() {
function AgentsTab() { function AgentsTab() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data } = useApiQuery<PaginatedResponse<Agent>>(["agents"], "/agents"); const activeDomainId = useApiDomain();
const { data } = useApiQuery<PaginatedResponse<Agent>>(["agents", activeDomainId], "/agents" + (activeDomainId ? "?domain=" + activeDomainId : ""));
const agents = data?.items || []; const agents = data?.items || [];
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ name: "", description: "", permissionTier: "read_only" }); const [form, setForm] = useState({ name: "", description: "", permissionTier: "read_only" });
@@ -509,7 +516,7 @@ function AgentsTab() {
))} ))}
</SelectContent> </SelectContent>
</Select></div> </Select></div>
<Button onClick={() => createMutation.mutate(form)} disabled={!form.name.trim() || createMutation.isPending}>Create</Button> <Button onClick={() => createMutation.mutate({ ...form, ...(activeDomainId ? { domain: activeDomainId } : {}) })} disabled={!form.name.trim() || createMutation.isPending}>Create</Button>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -592,7 +599,8 @@ function AgentsTab() {
function WebhooksTab() { function WebhooksTab() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data } = useApiQuery<PaginatedResponse<WebhookType>>(["webhooks"], "/webhooks"); const activeDomainId = useApiDomain();
const { data } = useApiQuery<PaginatedResponse<WebhookType>>(["webhooks", activeDomainId], "/webhooks" + (activeDomainId ? "?domain=" + activeDomainId : ""));
const webhooks = data?.items || []; const webhooks = data?.items || [];
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ name: "", url: "", events: "task.created,note.created" }); const [form, setForm] = useState({ name: "", url: "", events: "task.created,note.created" });
@@ -607,6 +615,8 @@ function WebhooksTab() {
}); });
const testMutation = useMutation({ const testMutation = useMutation({
mutationFn: (id: string) => api.post("/webhooks/" + id + "/test", {}), 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 ( return (
@@ -621,7 +631,7 @@ function WebhooksTab() {
<div><Label>Name</Label><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></div> <div><Label>Name</Label><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></div>
<div><Label>URL</Label><Input value={form.url} onChange={(e) => setForm({ ...form, url: e.target.value })} placeholder="https://example.com/webhook" /></div> <div><Label>URL</Label><Input value={form.url} onChange={(e) => setForm({ ...form, url: e.target.value })} placeholder="https://example.com/webhook" /></div>
<div><Label>Events (comma-separated)</Label><Input value={form.events} onChange={(e) => setForm({ ...form, events: e.target.value })} /></div> <div><Label>Events (comma-separated)</Label><Input value={form.events} onChange={(e) => setForm({ ...form, events: e.target.value })} /></div>
<Button onClick={() => createMutation.mutate({ name: form.name, url: form.url, events: form.events.split(",").map((s) => s.trim()) })} disabled={!form.name.trim() || !form.url.trim() || createMutation.isPending}>Create</Button> <Button onClick={() => createMutation.mutate({ name: form.name, url: form.url, events: form.events.split(",").map((s) => s.trim()), ...(activeDomainId ? { domain: activeDomainId } : {}) })} disabled={!form.name.trim() || !form.url.trim() || createMutation.isPending}>Create</Button>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -699,6 +709,7 @@ function downloadBlob(blob: Blob, filename: string) {
function ImportExportTab() { function ImportExportTab() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const activeDomainId = useApiDomain();
const [importData, setImportData] = useState(""); const [importData, setImportData] = useState("");
const [importResult, setImportResult] = useState<any>(null); const [importResult, setImportResult] = useState<any>(null);
const [exportFormat, setExportFormat] = useState("json"); const [exportFormat, setExportFormat] = useState("json");
@@ -749,7 +760,7 @@ function ImportExportTab() {
const handleExport = async () => { const handleExport = async () => {
try { try {
const data = await api.post<any>("/export", { collections: exportCollections }); const data = await api.post<any>("/export", { collections: exportCollections, ...(activeDomainId ? { domain: activeDomainId } : {}) });
if (exportFormat === "csv") { if (exportFormat === "csv") {
// One CSV file per selected collection; empty collections are skipped. // One CSV file per selected collection; empty collections are skipped.
@@ -837,7 +848,7 @@ function ImportExportTab() {
function ErrorLogTab() { function ErrorLogTab() {
const [level, setLevel] = useState(""); const [level, setLevel] = useState("");
const { data } = useApiQuery<PaginatedResponse<ErrorLog>>(["error-log", level], "/error-log" + (level ? "?level=" + level : "")); const { data } = useApiQuery<PaginatedResponse<ErrorLog>>(["error-log", level], "/error-log" + (level && level !== "all" ? "?level=" + level : ""));
const errors = data?.items || []; const errors = data?.items || [];
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [expanded, setExpanded] = useState<string | null>(null); const [expanded, setExpanded] = useState<string | null>(null);
@@ -855,7 +866,7 @@ function ErrorLogTab() {
<Select value={level} onValueChange={setLevel}> <Select value={level} onValueChange={setLevel}>
<SelectTrigger className="w-32"><SelectValue placeholder="All levels" /></SelectTrigger> <SelectTrigger className="w-32"><SelectValue placeholder="All levels" /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value=" ">All levels</SelectItem> <SelectItem value="all">All levels</SelectItem>
<SelectItem value="error">Error</SelectItem> <SelectItem value="error">Error</SelectItem>
<SelectItem value="warn">Warning</SelectItem> <SelectItem value="warn">Warning</SelectItem>
<SelectItem value="info">Info</SelectItem> <SelectItem value="info">Info</SelectItem>
@@ -882,8 +893,9 @@ function ErrorLogTab() {
{expanded === e.id && ( {expanded === e.id && (
<div className="px-3 pb-3 space-y-2"> <div className="px-3 pb-3 space-y-2">
<Separator /> <Separator />
{e.stack && <pre className="text-xs text-muted-foreground bg-muted/50 p-2 rounded overflow-auto max-h-40">{e.stack}</pre>} {e.source && <pre className="text-xs text-muted-foreground bg-muted/50 p-2 rounded overflow-auto max-h-40">{e.source}</pre>}
{e.context && <pre className="text-xs text-muted-foreground bg-muted/50 p-2 rounded overflow-auto max-h-40">{JSON.stringify(e.context, null, 2)}</pre>} {e.stackTrace && <pre className="text-xs text-muted-foreground bg-muted/50 p-2 rounded overflow-auto max-h-40">{e.stackTrace}</pre>}
{e.metadata && <pre className="text-xs text-muted-foreground bg-muted/50 p-2 rounded overflow-auto max-h-40">{JSON.stringify(e.metadata, null, 2)}</pre>}
</div> </div>
)} )}
</div> </div>
+42 -3
View File
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api"; import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store"; import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime"; import { useRealtime } from "@/hooks/use-realtime";
import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog";
import { DndContext, DragOverlay, closestCorners, KeyboardSensor, PointerSensor, useSensor, useSensors, useDroppable, type DragEndEvent, type DragStartEvent } from "@dnd-kit/core"; import { DndContext, DragOverlay, closestCorners, KeyboardSensor, PointerSensor, useSensor, useSensors, useDroppable, type DragEndEvent, type DragStartEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable"; import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
@@ -100,6 +101,7 @@ function ColumnDroppable({ id, className, children }: { id: string; className?:
function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) { function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const activeDomainId = useApiDomain();
const [title, setTitle] = useState(task?.title || ""); const [title, setTitle] = useState(task?.title || "");
const [description, setDescription] = useState(task?.description || ""); const [description, setDescription] = useState(task?.description || "");
const [status, setStatus] = useState(task?.status || "todo"); const [status, setStatus] = useState(task?.status || "todo");
@@ -133,7 +135,7 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
if (task) { if (task) {
updateMutation.mutate(data); updateMutation.mutate(data);
} else { } else {
createMutation.mutate(data); createMutation.mutate({ ...data, ...(activeDomainId ? { domain: activeDomainId } : {}) });
} }
}; };
@@ -201,14 +203,43 @@ function TasksPage() {
useRealtime({ enabled: true }); useRealtime({ enabled: true });
useOpenCreateDialog("task", () => setCreateOpen(true));
const activeDomainId = useApiDomain(); const activeDomainId = useApiDomain();
const taskQueryParams = () =>
new URLSearchParams({
limit: "200",
...(activeDomainId ? { domain: activeDomainId } : {}),
...(search ? { search } : {}),
...(statusFilter && statusFilter !== "all" ? { status: statusFilter } : {}),
}).toString();
const { data: tasksData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Task>>( const { data: tasksData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Task>>(
["tasks", activeDomainId, search, statusFilter], ["tasks", activeDomainId, search, statusFilter],
"/tasks?" + new URLSearchParams({ limit: "200", ...(activeDomainId ? { domain: activeDomainId } : {}), ...(search ? { search } : {}), ...(statusFilter ? { status: statusFilter } : {}) }).toString() "/tasks?" + taskQueryParams()
); );
const tasks = tasksData?.items || []; const tasks = tasksData?.items || [];
const hasMoreTasks = tasks.length < (tasksData?.totalItems || 0);
const [loadingMoreTasks, setLoadingMoreTasks] = useState(false);
const loadMoreTasks = async () => {
if (!hasMoreTasks || loadingMoreTasks) return;
setLoadingMoreTasks(true);
try {
const next = await api.get<PaginatedResponse<Task>>(
"/tasks?" + new URLSearchParams({ ...Object.fromEntries(new URLSearchParams(taskQueryParams())), offset: String(tasks.length) }).toString()
);
queryClient.setQueryData<PaginatedResponse<Task>>(["tasks", activeDomainId, search, statusFilter], (old) => {
if (!old) return old;
const seen = new Set(old.items.map((t) => t.id));
return { ...old, items: [...old.items, ...next.items.filter((t) => !seen.has(t.id))] };
});
} finally {
setLoadingMoreTasks(false);
}
};
const statusMutation = useMutation({ const statusMutation = useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) => mutationFn: ({ id, status }: { id: string; status: string }) =>
@@ -375,7 +406,7 @@ function TasksPage() {
<Select value={statusFilter} onValueChange={setStatusFilter}> <Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-36"><SelectValue placeholder="All statuses" /></SelectTrigger> <SelectTrigger className="w-36"><SelectValue placeholder="All statuses" /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value=" ">All statuses</SelectItem> <SelectItem value="all">All statuses</SelectItem>
{STATUS_COLUMNS.map((c) => ( {STATUS_COLUMNS.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.label}</SelectItem> <SelectItem key={c.id} value={c.id}>{c.label}</SelectItem>
))} ))}
@@ -466,6 +497,14 @@ function TasksPage() {
</div> </div>
)} )}
{hasMoreTasks && (
<div className="flex justify-center pt-2">
<Button variant="outline" size="sm" onClick={loadMoreTasks} disabled={loadingMoreTasks}>
{loadingMoreTasks ? "Loading..." : "Load more tasks"}
</Button>
</div>
)}
<EntityDetailPanel open={panelOpen} onOpenChange={setPanelOpen} title={selectedTask?.title || "Task Details"}> <EntityDetailPanel open={panelOpen} onOpenChange={setPanelOpen} title={selectedTask?.title || "Task Details"}>
{selectedTask && ( {selectedTask && (
<div className="space-y-4"> <div className="space-y-4">
+3
View File
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Loader2, Sparkles } from "lucide-react"; import { Loader2, Sparkles } from "lucide-react";
import { useAuthStore } from "@/lib/stores/use-auth-store";
function LoginPage() { function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -30,6 +31,8 @@ function LoginPage() {
setError(data.error?.message || data.message || "Login failed"); setError(data.error?.message || data.message || "Login failed");
return; return;
} }
const data = await res.json().catch(() => ({}));
if (data?.user) useAuthStore.getState().setUser(data.user);
navigate({ to: "/" }); navigate({ to: "/" });
} catch { } catch {
setError("Network error"); setError("Network error");