feat: add server error logging and tighten workspace isolation

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