diff --git a/apps/web/__tests__/lib/auth.test.ts b/apps/web/__tests__/lib/auth.test.ts new file mode 100644 index 0000000..0de01b6 --- /dev/null +++ b/apps/web/__tests__/lib/auth.test.ts @@ -0,0 +1,83 @@ +/** + * Unit tests for resolveActiveDomain helper in lib/auth.ts. + * Tests both branches: existing domain returned, and auto-creation of "Personal" domain. + */ + +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; + +// Mock the database module +const mockDb = { + select: jest.fn(), + insert: jest.fn(), +}; +const mockDomains = {}; + +jest.mock('@project-e/db', () => ({ + db: mockDb, + domains: mockDomains, +})); + +// Mock next-auth +jest.mock('next-auth', () => ({ + getServerSession: jest.fn(), +})); + +// Mock next-auth config +jest.mock('@/lib/auth-config', () => ({ + authOptions: {}, +})); + +describe('resolveActiveDomain', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return the user\'s first existing domain without creating one', async () => { + const { resolveActiveDomain } = await import('@/lib/auth'); + + const mockUser = { id: 'user-1', email: 'test@example.com', name: 'Test' }; + const mockDomain = { id: 'domain-1', name: 'Work' }; + + // Mock the select chain to return an existing domain + const mockLimit = jest.fn().mockResolvedValue([mockDomain]); + const mockOrderBy = jest.fn().mockReturnValue({ limit: mockLimit }); + const mockWhere = jest.fn().mockReturnValue({ orderBy: mockOrderBy }); + const mockFrom = jest.fn().mockReturnValue({ where: mockWhere }); + mockDb.select.mockReturnValue({ from: mockFrom }); + + const result = await resolveActiveDomain(mockUser); + + expect(result).toEqual({ id: 'domain-1', name: 'Work', created: false }); + expect(mockDb.select).toHaveBeenCalledWith({ id: expect.anything(), name: expect.anything() }); + expect(mockDb.insert).not.toHaveBeenCalled(); + }); + + it('should create a "Personal" domain when the user has none', async () => { + const { resolveActiveDomain } = await import('@/lib/auth'); + + const mockUser = { id: 'user-2', email: 'new@example.com', name: 'New User' }; + const mockCreatedDomain = { id: 'new-domain-id', name: 'Personal' }; + + // First call: no existing domain + const mockLimit1 = jest.fn().mockResolvedValue([]); + const mockOrderBy1 = jest.fn().mockReturnValue({ limit: mockLimit1 }); + const mockWhere1 = jest.fn().mockReturnValue({ orderBy: mockOrderBy1 }); + const mockFrom1 = jest.fn().mockReturnValue({ where: mockWhere1 }); + mockDb.select.mockReturnValue({ from: mockFrom1 }); + + // Insert returns the created domain + const mockReturning = jest.fn().mockResolvedValue([mockCreatedDomain]); + const mockValues = jest.fn().mockReturnValue({ returning: mockReturning }); + mockDb.insert.mockReturnValue({ values: mockValues }); + + const result = await resolveActiveDomain(mockUser); + + expect(result).toEqual({ id: 'new-domain-id', name: 'Personal', created: true }); + expect(mockDb.insert).toHaveBeenCalled(); + expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({ + ownerId: 'user-2', + name: 'Personal', + sortOrder: 0, + })); + }); +}); diff --git a/apps/web/app/(dashboard)/canvas/page.tsx b/apps/web/app/(dashboard)/canvas/page.tsx index 7207141..ae83e5e 100644 --- a/apps/web/app/(dashboard)/canvas/page.tsx +++ b/apps/web/app/(dashboard)/canvas/page.tsx @@ -58,6 +58,7 @@ interface Canvas { function CanvasBoard({ canvas, onBack }: { canvas: Canvas; onBack: () => void }) { const [cards, setCards] = useState(canvas.cards || []); + const [connections] = useState(canvas.connections || []); const [dragging, setDragging] = useState(null); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); const [viewport, setViewport] = useState(canvas.viewport || { x: 0, y: 0, zoom: 1 }); @@ -252,7 +253,7 @@ function CanvasBoard({ canvas, onBack }: { canvas: Canvas; onBack: () => void }) > {/* Connections */} - {canvas.connections.map((conn) => { + {connections.map((conn) => { const source = cards.find((c) => c.id === conn.source_card_id); const target = cards.find((c) => c.id === conn.target_card_id); if (!source || !target) return null; @@ -399,7 +400,6 @@ export default function CanvasPage() { body: JSON.stringify({ name: 'New canvas', mode: 'freeform', - domain: 'personal', }), }); if (!res.ok) throw new Error('Unable to create canvas.'); diff --git a/apps/web/app/(dashboard)/habits/page.tsx b/apps/web/app/(dashboard)/habits/page.tsx index 43cf3d3..3545abf 100644 --- a/apps/web/app/(dashboard)/habits/page.tsx +++ b/apps/web/app/(dashboard)/habits/page.tsx @@ -25,6 +25,8 @@ import { HabitEditDialog } from "@/components/habits/habit-edit-dialog"; import { HabitCompletionDialog } from "@/components/habits/habit-completion-dialog"; import { HabitCalendarHeatmap } from "@/components/habits/habit-calendar-heatmap"; import { HabitAnalytics } from "@/components/habits/habit-analytics"; +import { CreateItemDialog } from "@/components/create-item-dialog"; +import { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store"; import { toast } from "sonner"; interface Habit { @@ -54,6 +56,7 @@ export default function HabitsPage() { const [domainId, setDomainId] = useState(null); const [domains, setDomains] = useState<{ id: string; name: string }[]>([]); const [createOpen, setCreateOpen] = useState(false); + const { open: storeOpen, openCreate, closeCreate } = useCreateDialogStore(); const [editHabit, setEditHabit] = useState(null); const [completionHabit, setCompletionHabit] = useState(null); const [deleteHabit, setDeleteHabit] = useState(null); @@ -154,7 +157,7 @@ export default function HabitsPage() { Active -
- +
); diff --git a/apps/web/components/settings/settings-domains.tsx b/apps/web/components/settings/settings-domains.tsx index fe60472..4d7b96a 100644 --- a/apps/web/components/settings/settings-domains.tsx +++ b/apps/web/components/settings/settings-domains.tsx @@ -203,7 +203,7 @@ export function SettingsDomains() { {/* Add new domain */} -
+
@@ -226,7 +226,7 @@ export function SettingsDomains() { disabled={creating} />
- diff --git a/apps/web/components/tasks/tasks-kanban-view.tsx b/apps/web/components/tasks/tasks-kanban-view.tsx index 253426a..c2301c2 100644 --- a/apps/web/components/tasks/tasks-kanban-view.tsx +++ b/apps/web/components/tasks/tasks-kanban-view.tsx @@ -76,7 +76,7 @@ function TaskCard({ onClick: () => void; }) { return ( - + { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick(); } }} aria-label={task.title}>
diff --git a/apps/web/components/tasks/tasks-list-view.tsx b/apps/web/components/tasks/tasks-list-view.tsx index 0d1d8f7..36eac05 100644 --- a/apps/web/components/tasks/tasks-list-view.tsx +++ b/apps/web/components/tasks/tasks-list-view.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState, useCallback, useMemo } from 'react'; +import { useEffect, useState, useCallback, useMemo, useRef } from 'react'; import { useRouter, useSearchParams, usePathname } from 'next/navigation'; import { Table, @@ -177,6 +177,7 @@ export function TasksListView({ const [offset, setOffset] = useState(0); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); + const lastErrorRef = useRef(null); const [selectedTask, setSelectedTask] = useState(null); const [deleteId, setDeleteId] = useState(null); const [deleting, setDeleting] = useState(false); @@ -249,9 +250,22 @@ export function TasksListView({ setOffset(0); } setTotalCount(data.totalItems || 0); + // Successful fetch — reset the dedup tracker so the next error + // class toasts fresh instead of being suppressed. + lastErrorRef.current = null; } catch (error) { console.error('Failed to fetch tasks:', error); - toast.error('Unable to load tasks'); + // Only toast once per unique error message to prevent infinite spam + // when realtime subscriptions re-trigger fetchTasks() on every event. + const message = error instanceof Error ? error.message : 'Unable to load tasks'; + if (message !== lastErrorRef.current) { + lastErrorRef.current = message; + if (message.includes('429') || message.toLowerCase().includes('rate')) { + toast.error('Rate limited — slowing down'); + } else { + toast.error('Unable to load tasks'); + } + } } finally { setLoading(false); setLoadingMore(false); @@ -286,13 +300,26 @@ export function TasksListView({ // Subscribe to realtime updates useEffect(() => { if (!domainId) return; - const unsubscribe = subscribe(['task'], (event: any) => { - if (event.type === 'task') { + // Debounce realtime-triggered refetches so a burst of events does + // not cause a flood of fetchTasks() calls + toasts. + let timer: ReturnType | null = null; + const debouncedRefetch = () => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { fetchTasks(); onRefresh?.(); + }, 750); + }; + debouncedRefetch.cancel = () => { if (timer) { clearTimeout(timer); timer = null; } }; + const unsubscribe = subscribe(['task'], (event: any) => { + if (event.type === 'task') { + debouncedRefetch(); } }); - return unsubscribe; + return () => { + debouncedRefetch.cancel(); + unsubscribe; + }; }, [domainId, subscribe, fetchTasks, onRefresh]); // Clear selection when tasks change diff --git a/apps/web/components/topbar.tsx b/apps/web/components/topbar.tsx index d243f2d..a790796 100644 --- a/apps/web/components/topbar.tsx +++ b/apps/web/components/topbar.tsx @@ -17,9 +17,11 @@ export function TopBar() { if (pathname.startsWith('/projects')) openCreate('project'); else if (pathname.startsWith('/habits')) openCreate('habit'); else if (pathname.startsWith('/tasks')) openCreate('task'); - else { - document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })); - } + // On other pages (e.g. /settings) the topbar's "Quick add" button + // visually overlaps form action buttons (e.g. Domain "Add") because + // both are anchored top-right. Dispatching Cmd+K here would cause + // those form clicks to be mis-interpreted as opening the palette. + // So we no-op on those pages — the form buttons handle their own actions. } const label = pathname.startsWith('/projects') ? 'New project' @@ -39,10 +41,14 @@ export function TopBar() {
- + {(pathname.startsWith('/projects') || + pathname.startsWith('/habits') || + pathname.startsWith('/tasks')) && ( + + )}
diff --git a/apps/web/lib/auth.ts b/apps/web/lib/auth.ts index a051833..05bead9 100644 --- a/apps/web/lib/auth.ts +++ b/apps/web/lib/auth.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from './auth-config'; import { db, domains } from '@project-e/db'; -import { eq } from 'drizzle-orm'; +import { asc, eq } from 'drizzle-orm'; export interface AuthUser { id: string; @@ -56,6 +56,34 @@ export async function requireWorkspaceAccess(workspaceId: string): Promise } } +/** + * Resolve the user's active workspace/domain. If the user has any domain, + * return the first one (ordered by sort_order then created_at). If they + * have none (onboarding skipped), auto-create a default "Personal" + * domain for them and return that. + * + * This is the single source of truth for "what domain is this user + * working in right now?" -- every ambiguous caller should route through + * this before touching the DB. + */ +export async function resolveActiveDomain(user: { id: string; email: string; name?: string | null }): Promise<{ id: string; name: string; created: boolean }> { + const [existing] = await db + .select({ id: domains.id, name: domains.name }) + .from(domains) + .where(eq(domains.ownerId, user.id)) + .orderBy(asc(domains.sortOrder), asc(domains.createdAt)) + .limit(1); + if (existing) return { ...existing, created: false }; + const slug = 'personal-' + user.id.slice(0, 8); + const [created] = await db.insert(domains).values({ + ownerId: user.id, + name: 'Personal', + slug: slug, + sortOrder: 0, + }).returning({ id: domains.id, name: domains.name }); + return { ...created, created: true }; +} + /** * Auth middleware for API routes * Wraps a route handler and ensures authentication diff --git a/drizzle/0004_add_owner_id_to_domains.sql b/drizzle/0004_add_owner_id_to_domains.sql new file mode 100644 index 0000000..f2b33c8 --- /dev/null +++ b/drizzle/0004_add_owner_id_to_domains.sql @@ -0,0 +1,3 @@ +ALTER TABLE "domains" ADD COLUMN "owner_id" uuid REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +CREATE INDEX "domains_owner_id_idx" ON "domains" USING btree ("owner_id"); diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 5ebf1a5..82b87de 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -57,6 +57,7 @@ export const domains = pgTable( slug: text('slug').notNull().unique(), color: text('color'), icon: text('icon'), + ownerId: uuid('owner_id').references(() => users.id, { onDelete: 'cascade' }), parentId: uuid('parent_id').references((): any => domains.id, { onDelete: 'set null' }), sortOrder: integer('sort_order').default(0), customFields: jsonb('custom_fields').$type>().default({}), diff --git a/packages/shared/src/schemas/agent.ts b/packages/shared/src/schemas/agent.ts index bcc31f0..6df0ab6 100644 --- a/packages/shared/src/schemas/agent.ts +++ b/packages/shared/src/schemas/agent.ts @@ -36,9 +36,9 @@ export const agentTaskSchema = z.object({ id: z.string(), agent_id: z.string(), task_type: z.string(), - input: z.record(z.unknown()), + input: z.record(z.string(), z.unknown()), status: z.enum(['pending', 'running', 'completed', 'failed']).default('pending'), - output: z.record(z.unknown()).optional(), + output: z.record(z.string(), z.unknown()).optional(), error_message: z.string().optional(), started_at: z.string().datetime().optional(), completed_at: z.string().datetime().optional(), @@ -70,8 +70,8 @@ export const agentSchema = z.object({ last_active_at: z.string().datetime().optional(), domain: z.string(), tags: z.array(z.string()).default([]), - config: z.record(z.unknown()).optional(), - custom_fields: z.record(z.unknown()).optional(), + config: z.record(z.string(), z.unknown()).optional(), + custom_fields: z.record(z.string(), z.unknown()).optional(), created: z.string().datetime(), updated: z.string().datetime(), }); diff --git a/packages/shared/src/schemas/canvas.ts b/packages/shared/src/schemas/canvas.ts index c59e066..f54ffbc 100644 --- a/packages/shared/src/schemas/canvas.ts +++ b/packages/shared/src/schemas/canvas.ts @@ -56,7 +56,7 @@ export const canvasSchema = z.object({ zoom: z.number().positive().default(1), }).optional(), background: z.string().optional(), - custom_fields: z.record(z.unknown()).optional(), + custom_fields: z.record(z.string(), z.unknown()).optional(), created: z.string().datetime(), updated: z.string().datetime(), }); diff --git a/packages/shared/src/schemas/common.ts b/packages/shared/src/schemas/common.ts index 6ae7d6a..6cc0a58 100644 --- a/packages/shared/src/schemas/common.ts +++ b/packages/shared/src/schemas/common.ts @@ -110,7 +110,7 @@ export const errorLogSchema = z.object({ source: z.string(), message: z.string(), stack_trace: z.string().optional(), - metadata: z.record(z.unknown()).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), resolved: z.boolean().default(false), created: z.string().datetime(), updated: z.string().datetime(), @@ -134,11 +134,11 @@ export const queueJobSchema = z.object({ id: z.string(), queue: z.string(), type: z.string(), - payload: z.record(z.unknown()), + payload: z.record(z.string(), z.unknown()), status: queueJobStatusEnum.default('pending'), attempts: z.number().int().nonnegative().default(0), max_attempts: z.number().int().positive().default(3), - result: z.record(z.unknown()).optional(), + result: z.record(z.string(), z.unknown()).optional(), error_message: z.string().optional(), scheduled_at: z.string().datetime().optional(), started_at: z.string().datetime().optional(), diff --git a/packages/shared/src/schemas/habit.ts b/packages/shared/src/schemas/habit.ts index f672b05..a3aff07 100644 --- a/packages/shared/src/schemas/habit.ts +++ b/packages/shared/src/schemas/habit.ts @@ -62,7 +62,7 @@ export const habitSchema = z.object({ score_config: habitScoreConfigSchema.optional(), active: z.boolean().default(true), tags: z.array(z.string()).default([]), - custom_fields: z.record(z.unknown()).optional(), + custom_fields: z.record(z.string(), z.unknown()).optional(), created: z.string().datetime(), updated: z.string().datetime(), }); diff --git a/packages/shared/src/schemas/milestone.ts b/packages/shared/src/schemas/milestone.ts index 60b3d66..66ec4db 100644 --- a/packages/shared/src/schemas/milestone.ts +++ b/packages/shared/src/schemas/milestone.ts @@ -70,7 +70,7 @@ export const milestoneSchema = z.object({ completed_at: z.string().datetime().optional(), tasks: z.array(z.string()).default([]), dependencies: z.array(milestoneDependencySchema).default([]), - custom_fields: z.record(z.unknown()).optional(), + custom_fields: z.record(z.string(), z.unknown()).optional(), created: z.string().datetime(), updated: z.string().datetime(), }); diff --git a/packages/shared/src/schemas/note.ts b/packages/shared/src/schemas/note.ts index caf3332..6e2ad05 100644 --- a/packages/shared/src/schemas/note.ts +++ b/packages/shared/src/schemas/note.ts @@ -53,7 +53,7 @@ export const noteSchema = z.object({ size: z.number(), url: z.string(), })).default([]), - custom_fields: z.record(z.unknown()).optional(), + custom_fields: z.record(z.string(), z.unknown()).optional(), created: z.string().datetime(), updated: z.string().datetime(), }); diff --git a/packages/shared/src/schemas/project.ts b/packages/shared/src/schemas/project.ts index 476d987..c2e7961 100644 --- a/packages/shared/src/schemas/project.ts +++ b/packages/shared/src/schemas/project.ts @@ -14,7 +14,7 @@ export const projectSettingsSchema = z.object({ pomodoro_focus_minutes: z.number().int().positive().default(25), pomodoro_break_minutes: z.number().int().positive().default(5), notifications_enabled: z.boolean().default(true), - custom_fields: z.record(z.unknown()).optional(), + custom_fields: z.record(z.string(), z.unknown()).optional(), }); // ── Project Schema ─────────────────────────────────────────────────────────── @@ -33,7 +33,7 @@ export const projectSchema = z.object({ target_date: z.string().datetime().optional(), completed_at: z.string().datetime().optional(), settings: projectSettingsSchema.optional(), - custom_fields: z.record(z.unknown()).optional(), + custom_fields: z.record(z.string(), z.unknown()).optional(), created: z.string().datetime(), updated: z.string().datetime(), }); diff --git a/packages/shared/src/schemas/report.ts b/packages/shared/src/schemas/report.ts index da96b1c..5e11b92 100644 --- a/packages/shared/src/schemas/report.ts +++ b/packages/shared/src/schemas/report.ts @@ -14,7 +14,7 @@ export const reportTemplateSchema = z.object({ sections: z.array(z.object({ title: z.string(), type: z.enum(['summary', 'chart', 'table', 'list', 'text']).default('text'), - config: z.record(z.unknown()).optional(), + config: z.record(z.string(), z.unknown()).optional(), sort_order: z.number().int().nonnegative().default(0), })).default([]), created: z.string().datetime(), @@ -42,14 +42,14 @@ export const reportSchema = z.object({ sections: z.array(z.object({ title: z.string(), content: z.string().optional(), - data: z.record(z.unknown()).optional(), + data: z.record(z.string(), z.unknown()).optional(), sort_order: z.number().int().nonnegative().default(0), })).default([]), summary: z.string().optional(), is_draft: z.boolean().default(true), generated_at: z.string().datetime().optional(), tags: z.array(z.string()).default([]), - custom_fields: z.record(z.unknown()).optional(), + custom_fields: z.record(z.string(), z.unknown()).optional(), created: z.string().datetime(), updated: z.string().datetime(), }); diff --git a/packages/shared/src/schemas/task.ts b/packages/shared/src/schemas/task.ts index d8f9cd5..02b6e05 100644 --- a/packages/shared/src/schemas/task.ts +++ b/packages/shared/src/schemas/task.ts @@ -47,7 +47,7 @@ export const taskSchema = z.object({ attachments: z.array(attachmentSchema).default([]), dependencies: z.array(z.string()).default([]), subtasks: z.array(subtaskSchema).default([]), - custom_fields: z.record(z.unknown()).optional(), + custom_fields: z.record(z.string(), z.unknown()).optional(), completed_at: z.string().datetime().optional(), created: z.string().datetime(), updated: z.string().datetime(), diff --git a/packages/shared/src/schemas/webhook.ts b/packages/shared/src/schemas/webhook.ts index c71262f..4c5594e 100644 --- a/packages/shared/src/schemas/webhook.ts +++ b/packages/shared/src/schemas/webhook.ts @@ -6,7 +6,7 @@ export const webhookDeliverySchema = z.object({ id: z.string(), webhook_id: z.string(), event: z.string(), - payload: z.record(z.unknown()), + payload: z.record(z.string(), z.unknown()), status: z.enum(['success', 'failed', 'pending']).default('pending'), status_code: z.number().int().optional(), response_body: z.string().optional(), @@ -27,10 +27,10 @@ export const webhookSchema = z.object({ secret: z.string().optional(), active: z.boolean().default(true), domain: z.string(), - headers: z.record(z.string()).optional(), + headers: z.record(z.string(), z.string()).optional(), retry_count: z.number().int().nonnegative().default(3), last_triggered_at: z.string().datetime().optional(), - custom_fields: z.record(z.unknown()).optional(), + custom_fields: z.record(z.string(), z.unknown()).optional(), created: z.string().datetime(), updated: z.string().datetime(), });