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..988b6f4 100644 --- a/apps/web/app/(dashboard)/canvas/page.tsx +++ b/apps/web/app/(dashboard)/canvas/page.tsx @@ -399,7 +399,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/api/agents/route.ts b/apps/web/app/api/agents/route.ts index c3de684..9805292 100644 --- a/apps/web/app/api/agents/route.ts +++ b/apps/web/app/api/agents/route.ts @@ -4,7 +4,7 @@ // See AGENTS.md for full rules. import { NextRequest, NextResponse } from 'next/server'; -import { withAuth, createErrorResponse } from '@/lib/auth'; +import { withAuth, createErrorResponse, resolveActiveDomain } from '@/lib/auth'; import { createPocketBaseClient } from '@/lib/pocketbase'; import { createAgentSchema } from '@project-e/shared'; import { z } from 'zod'; @@ -33,10 +33,13 @@ export const GET = withAuth(async (request: NextRequest, _user) => { }); // POST /api/agents — Create an agent with auto-generated API key -export const POST = withAuth(async (request: NextRequest, _user) => { +export const POST = withAuth(async (request: NextRequest, user) => { try { const body = await request.json(); - const data = createAgentSchema.parse(body); + const data = createAgentSchema.parse({ + ...body, + domain: body.domain || (await resolveActiveDomain(user)).id, + }); const pb = createPocketBaseClient(); const agent = await pb.collection('agents').create({ diff --git a/apps/web/app/api/canvases/route.ts b/apps/web/app/api/canvases/route.ts index d216266..c4b8e79 100644 --- a/apps/web/app/api/canvases/route.ts +++ b/apps/web/app/api/canvases/route.ts @@ -4,7 +4,7 @@ // See AGENTS.md for full rules. import { NextRequest, NextResponse } from 'next/server'; -import { withAuth, createErrorResponse } from '@/lib/auth'; +import { withAuth, createErrorResponse, resolveActiveDomain } from '@/lib/auth'; import { createPocketBaseClient } from '@/lib/pocketbase'; import { createCanvasSchema } from '@project-e/shared'; import { z } from 'zod'; @@ -33,10 +33,13 @@ export const GET = withAuth(async (request: NextRequest, _user) => { }); // POST /api/canvases — Create a canvas -export const POST = withAuth(async (request: NextRequest, _user) => { +export const POST = withAuth(async (request: NextRequest, user) => { try { const body = await request.json(); - const data = createCanvasSchema.parse(body); + const data = createCanvasSchema.parse({ + ...body, + domain: body.domain || (await resolveActiveDomain(user)).id, + }); const pb = createPocketBaseClient(); const canvas = await pb.collection('canvases').create(data); diff --git a/apps/web/app/api/domains/route.ts b/apps/web/app/api/domains/route.ts index fcc9e59..eadc193 100644 --- a/apps/web/app/api/domains/route.ts +++ b/apps/web/app/api/domains/route.ts @@ -4,7 +4,7 @@ // See AGENTS.md for full rules. import { NextRequest, NextResponse } from 'next/server'; -import { withAuth, createErrorResponse } from '@/lib/auth'; +import { withAuth, createErrorResponse, resolveActiveDomain } from '@/lib/auth'; import { db, domains } from '@project-e/db'; import { and, asc, desc, eq, ilike, or, sql } from 'drizzle-orm'; import { z } from 'zod'; @@ -18,7 +18,7 @@ const createDomainSchema = z.object({ }); // GET /api/domains — List domains with filtering, sorting, pagination -export const GET = withAuth(async (request: NextRequest, _user) => { +export const GET = withAuth(async (request: NextRequest, user) => { const { searchParams } = new URL(request.url); const page = Math.max(1, parseInt(searchParams.get('page') || '1')); const perPage = Math.min(100, Math.max(1, parseInt(searchParams.get('perPage') || '50'))); @@ -39,8 +39,8 @@ export const GET = withAuth(async (request: NextRequest, _user) => { ? asc(sortColumns[sortField] || domains.sortOrder) : desc(sortColumns[sortField] || domains.sortOrder); - // Build where clause - const conditions: any[] = []; + // Build where clause — filter by owner + const conditions: any[] = [eq(domains.ownerId, user.id)]; if (filter) { conditions.push( or( @@ -64,7 +64,31 @@ export const GET = withAuth(async (request: NextRequest, _user) => { .where(and(...conditions)), ]); - const totalItems = Number(countResult[0]?.count || 0); + let totalItems = Number(countResult[0]?.count || 0); + + // If user has no domains, auto-create a default "Personal" domain + if (totalItems === 0) { + const active = await resolveActiveDomain(user); + // Re-fetch to include the newly created domain + const [newItems, newCount] = await Promise.all([ + db.select() + .from(domains) + .where(eq(domains.ownerId, user.id)) + .orderBy(orderBy) + .limit(perPage) + .offset(offset), + db.select({ count: sql`count(*)` }) + .from(domains) + .where(eq(domains.ownerId, user.id)), + ]); + return NextResponse.json({ + items: newItems, + totalItems: Number(newCount[0]?.count || 0), + totalPages: Math.ceil(Number(newCount[0]?.count || 0) / perPage), + page, + perPage, + }); + } return NextResponse.json({ items, @@ -76,7 +100,7 @@ export const GET = withAuth(async (request: NextRequest, _user) => { }); // POST /api/domains — Create a domain -export const POST = withAuth(async (request: NextRequest, _user) => { +export const POST = withAuth(async (request: NextRequest, user) => { try { const body = await request.json(); const data = createDomainSchema.parse(body); @@ -88,6 +112,7 @@ export const POST = withAuth(async (request: NextRequest, _user) => { color: data.color || null, icon: data.icon || null, parentId: data.parentId || null, + ownerId: user.id, }) .returning(); diff --git a/apps/web/app/api/habits/route.ts b/apps/web/app/api/habits/route.ts index aa30f29..a0153c5 100644 --- a/apps/web/app/api/habits/route.ts +++ b/apps/web/app/api/habits/route.ts @@ -4,7 +4,7 @@ // See AGENTS.md for full rules. import { NextRequest, NextResponse } from 'next/server'; -import { withAuth, createErrorResponse } from '@/lib/auth'; +import { withAuth, createErrorResponse, resolveActiveDomain } from '@/lib/auth'; import { recordActivity } from '@/lib/activity'; import { db, habits, habitTags, tags as tagsTable } from '@project-e/db'; import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm'; @@ -25,13 +25,17 @@ const createHabitSchema = z.object({ }); // GET /api/habits — List habits with filtering, sorting, pagination -export const GET = withAuth(async (request: NextRequest, _user) => { +export const GET = withAuth(async (request: NextRequest, user) => { const { searchParams } = new URL(request.url); const page = Math.max(1, parseInt(searchParams.get('page') || '1')); const perPage = Math.min(100, Math.max(1, parseInt(searchParams.get('perPage') || '50'))); const filter = searchParams.get('filter') || undefined; const sort = searchParams.get('sort') || '-created'; - const domainId = searchParams.get('domain') || undefined; + let domainId = searchParams.get('domain') || undefined; + if (!domainId) { + const active = await resolveActiveDomain(user); + domainId = active.id; + } const sortDir = sort.startsWith('-') ? 'desc' : 'asc'; const sortField = sort.replace(/^-/, ''); @@ -81,7 +85,10 @@ export const GET = withAuth(async (request: NextRequest, _user) => { export const POST = withAuth(async (request: NextRequest, user) => { try { const body = await request.json(); - const data = createHabitSchema.parse(body); + const data = createHabitSchema.parse({ + ...body, + domain: body.domain || (await resolveActiveDomain(user)).id, + }); const [habit] = await db.insert(habits).values({ name: data.name, diff --git a/apps/web/app/api/projects/route.ts b/apps/web/app/api/projects/route.ts index 807c0d4..f0b542d 100644 --- a/apps/web/app/api/projects/route.ts +++ b/apps/web/app/api/projects/route.ts @@ -4,7 +4,7 @@ // See AGENTS.md for full rules. import { NextRequest, NextResponse } from 'next/server'; -import { withAuth, createErrorResponse } from '@/lib/auth'; +import { withAuth, createErrorResponse, resolveActiveDomain } from '@/lib/auth'; import { recordActivity } from '@/lib/activity'; import { db, projects, projectTags, tags as tagsTable } from '@project-e/db'; import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm'; @@ -24,13 +24,17 @@ const createProjectSchema = z.object({ }); // GET /api/projects — List projects with filtering, sorting, pagination -export const GET = withAuth(async (request: NextRequest, _user) => { +export const GET = withAuth(async (request: NextRequest, user) => { const { searchParams } = new URL(request.url); const page = Math.max(1, parseInt(searchParams.get('page') || '1')); const perPage = Math.min(100, Math.max(1, parseInt(searchParams.get('perPage') || '50'))); const filter = searchParams.get('filter') || undefined; const sort = searchParams.get('sort') || '-created'; - const domainId = searchParams.get('domain') || undefined; + let domainId = searchParams.get('domain') || undefined; + if (!domainId) { + const active = await resolveActiveDomain(user); + domainId = active.id; + } const sortDir = sort.startsWith('-') ? 'desc' : 'asc'; const sortField = sort.replace(/^-/, ''); @@ -79,7 +83,10 @@ export const GET = withAuth(async (request: NextRequest, _user) => { export const POST = withAuth(async (request: NextRequest, user) => { try { const body = await request.json(); - const data = createProjectSchema.parse(body); + const data = createProjectSchema.parse({ + ...body, + domain: body.domain || (await resolveActiveDomain(user)).id, + }); const [project] = await db.insert(projects).values({ name: data.name, diff --git a/apps/web/app/api/quick-capture/route.ts b/apps/web/app/api/quick-capture/route.ts new file mode 100644 index 0000000..0775011 --- /dev/null +++ b/apps/web/app/api/quick-capture/route.ts @@ -0,0 +1,110 @@ +// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST: +// 1. Insert activity feed entry +// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id })) +// See AGENTS.md for full rules. + +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse, resolveActiveDomain } from '@/lib/auth'; +import { recordActivity } from '@/lib/activity'; +import { db, tasks, habits, notes, projects } from '@project-e/db'; +import { z } from 'zod'; + +const quickCaptureSchema = z.object({ + type: z.enum(['task', 'habit', 'note', 'project']), + text: z.string().min(1, 'Text is required'), + description: z.string().optional().nullable(), + priority: z.enum(['low', 'medium', 'high', 'urgent']).optional().default('medium'), + domain: z.string().optional(), +}); + +// POST /api/quick-capture — Create an entity from quick text input +// Forwards to the appropriate create logic after resolving the active domain. +export const POST = withAuth(async (request: NextRequest, user) => { + try { + const body = await request.json(); + const data = quickCaptureSchema.parse(body); + const domainId = data.domain || (await resolveActiveDomain(user)).id; + + let result; + + switch (data.type) { + case 'task': { + const [task] = await db.insert(tasks).values({ + title: data.text, + description: data.description ?? null, + domainId, + priority: data.priority, + }).returning(); + result = task; + await recordActivity({ + actor: user.name, + action: 'created', + entityType: 'task', + entityId: task.id, + changes: { title: task.title }, + workspaceId: domainId, + }); + break; + } + case 'habit': { + const [habit] = await db.insert(habits).values({ + name: data.text, + description: data.description ?? null, + domainId, + }).returning(); + result = habit; + await recordActivity({ + actor: user.name, + action: 'created', + entityType: 'habit', + entityId: habit.id, + changes: { name: habit.name }, + workspaceId: domainId, + }); + break; + } + case 'note': { + const [note] = await db.insert(notes).values({ + title: data.text, + content: data.description ?? null, + domainId, + }).returning(); + result = note; + await recordActivity({ + actor: user.name, + action: 'created', + entityType: 'note', + entityId: note.id, + changes: { title: note.title }, + workspaceId: domainId, + }); + break; + } + case 'project': { + const [project] = await db.insert(projects).values({ + name: data.text, + description: data.description ?? null, + domainId, + }).returning(); + result = project; + await recordActivity({ + actor: user.name, + action: 'created', + entityType: 'project', + entityId: project.id, + changes: { name: project.name }, + workspaceId: domainId, + }); + break; + } + } + + return NextResponse.json(result, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + console.error('[quick-capture POST] error:', error); + return createErrorResponse('INTERNAL_ERROR', 'Failed to create', 500); + } +}); diff --git a/apps/web/app/api/search/route.ts b/apps/web/app/api/search/route.ts index 7598f51..5a315c4 100644 --- a/apps/web/app/api/search/route.ts +++ b/apps/web/app/api/search/route.ts @@ -4,7 +4,7 @@ // See AGENTS.md for full rules. import { NextRequest, NextResponse } from 'next/server'; -import { withAuth, createErrorResponse } from '@/lib/auth'; +import { withAuth, createErrorResponse, resolveActiveDomain } from '@/lib/auth'; import { searchEntities } from '@/lib/search-service'; // GET /api/search?q=&type=&domain=&limit=&offset= @@ -13,7 +13,11 @@ export const GET = withAuth(async (request: NextRequest, user) => { const { searchParams } = new URL(request.url); const q = (searchParams.get('q') || '').trim(); const types = searchParams.get('types')?.split(',').filter(Boolean) || ['task', 'note', 'project', 'habit', 'domain']; - const domain = searchParams.get('domain') || undefined; + let domain = searchParams.get('domain') || undefined; + if (!domain) { + const active = await resolveActiveDomain(user); + domain = active.id; + } const limit = Math.max(1, Math.min(50, parseInt(searchParams.get('limit') || '20'))); const offset = Math.max(0, parseInt(searchParams.get('offset') || '0')); diff --git a/apps/web/app/api/tasks/route.ts b/apps/web/app/api/tasks/route.ts index 53adbd7..3a00c21 100644 --- a/apps/web/app/api/tasks/route.ts +++ b/apps/web/app/api/tasks/route.ts @@ -4,7 +4,7 @@ // See AGENTS.md for full rules. import { NextRequest, NextResponse } from 'next/server'; -import { withAuth, createErrorResponse } from '@/lib/auth'; +import { withAuth, createErrorResponse, resolveActiveDomain } from '@/lib/auth'; import { recordActivity } from '@/lib/activity'; import { db, tasks, taskTags, tags as tagsTable } from '@project-e/db'; import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm'; @@ -29,13 +29,17 @@ const createTaskSchema = z.object({ }); // GET /api/tasks — List tasks with filtering, sorting, pagination -export const GET = withAuth(async (request: NextRequest, _user) => { +export const GET = withAuth(async (request: NextRequest, user) => { const { searchParams } = new URL(request.url); const page = Math.max(1, parseInt(searchParams.get('page') || '1')); const perPage = Math.min(100, Math.max(1, parseInt(searchParams.get('perPage') || '50'))); const filter = searchParams.get('filter') || undefined; const sort = searchParams.get('sort') || '-created'; - const domainId = searchParams.get('domain') || undefined; + let domainId = searchParams.get('domain') || undefined; + if (!domainId) { + const active = await resolveActiveDomain(user); + domainId = active.id; + } const sortDir = sort.startsWith('-') ? 'desc' : 'asc'; const sortField = sort.replace(/^-/, ''); @@ -92,7 +96,10 @@ export const GET = withAuth(async (request: NextRequest, _user) => { export const POST = withAuth(async (request: NextRequest, user) => { try { const body = await request.json(); - const data = createTaskSchema.parse(body); + const data = createTaskSchema.parse({ + ...body, + domain: body.domain || (await resolveActiveDomain(user)).id, + }); const [task] = await db.insert(tasks).values({ title: data.title, 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({}),