T1/Phase 1: scaffold Vite SPA + Hono API + Bun worker

- apps/web: Vite + React 19 + TanStack Router/Query + shadcn/ui
   - apps/api: Hono + Bun on :3001 with /api/health, /api/auth/*, /mcp stubs
   - apps/worker: Bun worker stub, DB connection, graceful SIGTERM
   - apps/web-legacy/: old Next.js code moved aside (preserved for T2-T8 reference)
   - Dockerfiles: api (Bun), worker (Bun), spa (multi-stage Caddy)
   - Caddyfile: serves dist + reverse-proxies /api/* + /mcp to api
   - docker-compose.yml: 4-service target (api, spa, db, worker)
   - packages/db/src/client.ts: shared Drizzle client for api + worker
   - db/client.ts: root-level alias for convenience

   Parent: t_e1cbd87d -> t_24c9c3fd (T0)
This commit is contained in:
Hermes
2026-08-01 01:15:31 +00:00
parent 9203aee758
commit fca56ab77e
312 changed files with 3489 additions and 196 deletions
-3
View File
@@ -1,3 +0,0 @@
{
"extends": ["next/core-web-vitals"]
}
-243
View File
@@ -1,243 +0,0 @@
/**
* API tests for habits routes.
* These tests verify the habit CRUD API logic using mocked Drizzle.
* Run with: npm test -- --testPathPattern=habits
*/
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
// Mock the database module
jest.mock('@project-e/db', () => ({
db: {
select: jest.fn(),
insert: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
sql: { unsafe: jest.fn() },
habits: {},
habitCompletions: {},
habitTags: {},
tags: {},
activityFeed: {},
}));
jest.mock('@/lib/auth', () => ({
withAuth: (handler: any) => {
return (request: any, context: any) => {
const mockUser = { id: 'user-1', email: 'test@test.com', name: 'Test User' };
return handler(request, mockUser, context);
};
},
requireWorkspaceAccess: jest.fn().mockResolvedValue(undefined),
createErrorResponse: (code: string, message: string, status: number, details?: unknown) => ({
code,
message,
status,
details,
}),
ApiError: class ApiError extends Error {
constructor(message: string, public status: number = 400, public code: string = 'BAD_REQUEST') {
super(message);
}
},
}));
jest.mock('@/lib/activity', () => ({
recordActivity: jest.fn().mockResolvedValue(undefined),
}));
// Build a Drizzle-like chain that resolves to the given value when awaited
function chain(resolvedValue: any) {
const c: any = {};
c.from = jest.fn().mockReturnValue(c);
c.where = jest.fn().mockReturnValue(c);
c.orderBy = jest.fn().mockReturnValue(c);
c.limit = jest.fn().mockReturnValue(c);
c.offset = jest.fn().mockReturnValue(c);
c.innerJoin = jest.fn().mockReturnValue(c);
c.having = jest.fn().mockReturnValue(c);
c.then = (onfulfilled: any) => Promise.resolve(resolvedValue).then(onfulfilled);
c.catch = (onrejected: any) => Promise.resolve(resolvedValue).catch(onrejected);
return c;
}
function insertChain(resolvedValue: any) {
const c: any = {};
c.values = jest.fn().mockReturnValue(c);
c.returning = jest.fn().mockReturnValue(c);
c.then = (onfulfilled: any) => Promise.resolve(resolvedValue).then(onfulfilled);
c.catch = (onrejected: any) => Promise.resolve(resolvedValue).catch(onrejected);
return c;
}
function updateChain(resolvedValue?: any) {
const c: any = {};
c.set = jest.fn().mockReturnValue(c);
c.where = jest.fn().mockReturnValue(c);
c.returning = jest.fn().mockReturnValue(c);
c.then = (onfulfilled: any) => Promise.resolve(resolvedValue !== undefined ? resolvedValue : undefined).then(onfulfilled);
c.catch = (onrejected: any) => Promise.resolve(resolvedValue !== undefined ? resolvedValue : undefined).catch(onrejected);
return c;
}
describe('Habits API', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET /api/domains/[domainId]/habits', () => {
it('should list habits with default pagination', async () => {
const { GET } = await import('@/app/api/domains/[domainId]/habits/route');
const { db } = require('@project-e/db');
const mockHabits = [
{ id: '1', name: 'Habit 1', frequency: 'daily', difficulty: 'medium', domainId: 'domain-1', streakCount: 5, bestStreak: 10 },
{ id: '2', name: 'Habit 2', frequency: 'weekly', difficulty: 'hard', domainId: 'domain-1', streakCount: 0, bestStreak: 3 },
];
db.select
.mockReturnValueOnce(chain(mockHabits)) // main query
.mockReturnValueOnce(chain([{ count: 2 }])) // count query
.mockReturnValueOnce(chain([])); // tags query
const request = new Request('http://localhost:3000/api/domains/domain-1/habits');
const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
expect(response).toBeDefined();
});
it('should filter by active status', async () => {
const { GET } = await import('@/app/api/domains/[domainId]/habits/route');
const { db } = require('@project-e/db');
db.select
.mockReturnValueOnce(chain([{ id: '1', name: 'Habit 1', domainId: 'domain-1' }]))
.mockReturnValueOnce(chain([{ count: 1 }]))
.mockReturnValueOnce(chain([]));
const request = new Request('http://localhost:3000/api/domains/domain-1/habits?active=true');
const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
expect(response).toBeDefined();
});
});
describe('POST /api/domains/[domainId]/habits', () => {
it('should create a habit with required fields', async () => {
const { POST } = await import('@/app/api/domains/[domainId]/habits/route');
const { db } = require('@project-e/db');
const mockHabit = {
id: 'new-habit-1',
name: 'Test Habit',
frequency: 'daily',
difficulty: 'medium',
domainId: 'domain-1',
};
db.insert.mockReturnValue(insertChain([mockHabit]));
const request = new Request('http://localhost:3000/api/domains/domain-1/habits', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Test Habit' }),
});
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
expect(response).toBeDefined();
});
it('should reject empty name', async () => {
const { POST } = await import('@/app/api/domains/[domainId]/habits/route');
const request = new Request('http://localhost:3000/api/domains/domain-1/habits', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: '' }),
});
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
expect(response).toBeDefined();
});
});
describe('POST /api/domains/[domainId]/habits/[id]/complete', () => {
it('should complete a habit and return streak info', async () => {
const { POST } = await import('@/app/api/domains/[domainId]/habits/[id]/complete/route');
const { db } = require('@project-e/db');
const mockHabit = {
id: 'habit-1',
name: 'Test Habit',
domainId: 'domain-1',
skipDays: [0, 6],
streakCount: 3,
bestStreak: 10,
};
db.select
.mockReturnValueOnce(chain([mockHabit])) // verify habit exists
.mockReturnValueOnce(chain([])); // completions for streak calc
db.insert.mockReturnValue(insertChain([{ id: 'comp-1', habitId: 'habit-1', date: new Date(), value: 1 }]));
db.update.mockReturnValue(updateChain());
const request = new Request('http://localhost:3000/api/domains/domain-1/habits/habit-1/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: 1 }),
});
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'habit-1' }) });
expect(response).toBeDefined();
});
});
describe('PATCH /api/domains/[domainId]/habits/[id]', () => {
it('should update a habit', async () => {
const { PATCH } = await import('@/app/api/domains/[domainId]/habits/[id]/route');
const { db } = require('@project-e/db');
const existingHabit = {
id: 'habit-1',
name: 'Test Habit',
domainId: 'domain-1',
};
db.select.mockReturnValue(chain([existingHabit]));
db.update.mockReturnValue(updateChain([{ ...existingHabit, name: 'Updated Habit' }]));
const request = new Request('http://localhost:3000/api/domains/domain-1/habits/habit-1', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Updated Habit' }),
});
const response = await PATCH(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'habit-1' }) });
expect(response).toBeDefined();
});
});
describe('DELETE /api/domains/[domainId]/habits/[id]', () => {
it('should soft delete a habit', async () => {
const { DELETE } = await import('@/app/api/domains/[domainId]/habits/[id]/route');
const { db } = require('@project-e/db');
const existingHabit = {
id: 'habit-1',
name: 'Test Habit',
domainId: 'domain-1',
};
db.select.mockReturnValue(chain([existingHabit]));
db.update.mockReturnValue(updateChain());
const request = new Request('http://localhost:3000/api/domains/domain-1/habits/habit-1', {
method: 'DELETE',
});
const response = await DELETE(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'habit-1' }) });
expect(response.status).toBe(204);
});
});
});
-273
View File
@@ -1,273 +0,0 @@
/**
* API tests for projects and sections routes.
* These tests verify the project CRUD and section API logic using mocked Drizzle.
* Run with: npm test -- --testPathPattern=projects
*/
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
// Mock the database module
jest.mock('@project-e/db', () => ({
db: {
select: jest.fn(),
insert: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
sql: { unsafe: jest.fn() },
projects: {},
sections: {},
tasks: {},
projectTags: {},
tags: {},
activityFeed: {},
}));
jest.mock('@/lib/auth', () => ({
withAuth: (handler: any) => {
return (request: any, context: any) => {
const mockUser = { id: 'user-1', email: 'test@test.com', name: 'Test User' };
return handler(request, mockUser, context);
};
},
requireWorkspaceAccess: jest.fn().mockResolvedValue(undefined),
createErrorResponse: (code: string, message: string, status: number, details?: unknown) => ({
code,
message,
status,
details,
}),
ApiError: class ApiError extends Error {
constructor(message: string, public status: number = 400, public code: string = 'BAD_REQUEST') {
super(message);
}
},
}));
jest.mock('@/lib/activity', () => ({
recordActivity: jest.fn().mockResolvedValue(undefined),
}));
// Build a Drizzle-like chain that resolves to the given value when awaited
function chain(resolvedValue: any) {
const c: any = {};
c.from = jest.fn().mockReturnValue(c);
c.where = jest.fn().mockReturnValue(c);
c.orderBy = jest.fn().mockReturnValue(c);
c.limit = jest.fn().mockReturnValue(c);
c.offset = jest.fn().mockReturnValue(c);
c.innerJoin = jest.fn().mockReturnValue(c);
c.having = jest.fn().mockReturnValue(c);
c.then = (onfulfilled: any) => Promise.resolve(resolvedValue).then(onfulfilled);
c.catch = (onrejected: any) => Promise.resolve(resolvedValue).catch(onrejected);
return c;
}
function insertChain(resolvedValue: any) {
const c: any = {};
c.values = jest.fn().mockReturnValue(c);
c.returning = jest.fn().mockReturnValue(c);
c.then = (onfulfilled: any) => Promise.resolve(resolvedValue).then(onfulfilled);
c.catch = (onrejected: any) => Promise.resolve(resolvedValue).catch(onrejected);
return c;
}
function updateChain(resolvedValue?: any) {
const c: any = {};
c.set = jest.fn().mockReturnValue(c);
c.where = jest.fn().mockReturnValue(c);
c.returning = jest.fn().mockReturnValue(c);
c.then = (onfulfilled: any) => Promise.resolve(resolvedValue !== undefined ? resolvedValue : undefined).then(onfulfilled);
c.catch = (onrejected: any) => Promise.resolve(resolvedValue !== undefined ? resolvedValue : undefined).catch(onrejected);
return c;
}
describe('Projects API', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET /api/domains/[domainId]/projects', () => {
it('should list projects with default pagination', async () => {
const { GET } = await import('@/app/api/domains/[domainId]/projects/route');
const { db } = require('@project-e/db');
const mockProjects = [
{ id: '1', name: 'Project 1', status: 'active', domainId: 'domain-1' },
{ id: '2', name: 'Project 2', status: 'paused', domainId: 'domain-1' },
];
db.select
.mockReturnValueOnce(chain(mockProjects)) // main query
.mockReturnValueOnce(chain([{ count: 2 }])) // count query
.mockReturnValueOnce(chain([])) // tags query
.mockReturnValueOnce(chain([{ count: 0 }])) // task count for project 1
.mockReturnValueOnce(chain([{ count: 0 }])) // completed count for project 1
.mockReturnValueOnce(chain([{ count: 0 }])) // task count for project 2
.mockReturnValueOnce(chain([{ count: 0 }])); // completed count for project 2
const request = new Request('http://localhost:3000/api/domains/domain-1/projects');
const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
expect(response).toBeDefined();
});
it('should filter by status', async () => {
const { GET } = await import('@/app/api/domains/[domainId]/projects/route');
const { db } = require('@project-e/db');
db.select
.mockReturnValueOnce(chain([{ id: '1', name: 'Project 1', domainId: 'domain-1' }]))
.mockReturnValueOnce(chain([{ count: 1 }]))
.mockReturnValueOnce(chain([]))
.mockReturnValueOnce(chain([{ count: 0 }]))
.mockReturnValueOnce(chain([{ count: 0 }]));
const request = new Request('http://localhost:3000/api/domains/domain-1/projects?status=active');
const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
expect(response).toBeDefined();
});
});
describe('POST /api/domains/[domainId]/projects', () => {
it('should create a project with required fields', async () => {
const { POST } = await import('@/app/api/domains/[domainId]/projects/route');
const { db } = require('@project-e/db');
const mockProject = {
id: 'new-project-1',
name: 'Test Project',
status: 'active',
domainId: 'domain-1',
};
db.insert.mockReturnValue(insertChain([mockProject]));
const request = new Request('http://localhost:3000/api/domains/domain-1/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Test Project' }),
});
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
expect(response).toBeDefined();
});
it('should reject empty name', async () => {
const { POST } = await import('@/app/api/domains/[domainId]/projects/route');
const request = new Request('http://localhost:3000/api/domains/domain-1/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: '' }),
});
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
expect(response).toBeDefined();
});
});
describe('PATCH /api/domains/[domainId]/projects/[id]', () => {
it('should update a project', async () => {
const { PATCH } = await import('@/app/api/domains/[domainId]/projects/[id]/route');
const { db } = require('@project-e/db');
const existingProject = {
id: 'project-1',
name: 'Test Project',
domainId: 'domain-1',
};
db.select.mockReturnValue(chain([existingProject]));
db.update.mockReturnValue(updateChain([{ ...existingProject, name: 'Updated Project' }]));
const request = new Request('http://localhost:3000/api/domains/domain-1/projects/project-1', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Updated Project' }),
});
const response = await PATCH(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'project-1' }) });
expect(response).toBeDefined();
});
});
describe('DELETE /api/domains/[domainId]/projects/[id]', () => {
it('should soft delete a project', async () => {
const { DELETE } = await import('@/app/api/domains/[domainId]/projects/[id]/route');
const { db } = require('@project-e/db');
const existingProject = {
id: 'project-1',
name: 'Test Project',
domainId: 'domain-1',
};
db.select.mockReturnValue(chain([existingProject]));
db.update.mockReturnValue(updateChain());
const request = new Request('http://localhost:3000/api/domains/domain-1/projects/project-1', {
method: 'DELETE',
});
const response = await DELETE(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'project-1' }) });
expect(response.status).toBe(204);
});
});
});
describe('Sections API', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET /api/domains/[domainId]/projects/[projectId]/sections', () => {
it('should list sections for a project', async () => {
const { GET } = await import('@/app/api/domains/[domainId]/projects/[projectId]/sections/route');
const { db } = require('@project-e/db');
const mockSections = [
{ id: '1', name: 'Section 1', projectId: 'project-1', kind: 'section', sortOrder: 0 },
{ id: '2', name: 'Milestone 1', projectId: 'project-1', kind: 'milestone', sortOrder: 1 },
];
db.select
.mockReturnValueOnce(chain([{ id: 'project-1' }])) // verify project
.mockReturnValueOnce(chain(mockSections)); // list sections
const request = new Request('http://localhost:3000/api/domains/domain-1/projects/project-1/sections');
const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1', projectId: 'project-1' }) });
expect(response).toBeDefined();
});
});
describe('POST /api/domains/[domainId]/projects/[projectId]/sections', () => {
it('should create a section', async () => {
const { POST } = await import('@/app/api/domains/[domainId]/projects/[projectId]/sections/route');
const { db } = require('@project-e/db');
const mockProject = { id: 'project-1', name: 'Test Project' };
const mockSection = {
id: 'new-section-1',
name: 'Test Section',
projectId: 'project-1',
kind: 'section',
sortOrder: 0,
};
db.select
.mockReturnValueOnce(chain([mockProject])) // verify project
.mockReturnValueOnce(chain([{ max: -1 }])); // max sort order
db.insert.mockReturnValue(insertChain([mockSection]));
const request = new Request('http://localhost:3000/api/domains/domain-1/projects/project-1/sections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Test Section' }),
});
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1', projectId: 'project-1' }) });
expect(response).toBeDefined();
});
});
});
-241
View File
@@ -1,241 +0,0 @@
/**
* API tests for tasks routes.
* These tests verify the task CRUD API logic using mocked Drizzle.
* Run with: npm test -- --testPathPattern=tasks
*/
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
// Mock the database module
jest.mock('@project-e/db', () => ({
db: {
select: jest.fn(),
insert: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
sql: { unsafe: jest.fn() },
tasks: {},
taskTags: {},
taskDependencies: {},
tags: {},
activityFeed: {},
}));
jest.mock('@/lib/auth', () => ({
withAuth: (handler: any) => handler,
requireWorkspaceAccess: jest.fn().mockResolvedValue(undefined),
createErrorResponse: (code: string, message: string, status: number, details?: unknown) => ({
code,
message,
status,
details,
}),
ApiError: class ApiError extends Error {
constructor(message: string, public status: number = 400, public code: string = 'BAD_REQUEST') {
super(message);
}
},
}));
jest.mock('@/lib/activity', () => ({
recordActivity: jest.fn().mockResolvedValue(undefined),
}));
describe('Tasks API', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET /api/domains/[domainId]/tasks', () => {
it('should list tasks with default pagination', async () => {
const { GET } = await import('@/app/api/domains/[domainId]/tasks/route');
const { db } = require('@project-e/db');
const mockTasks = [
{ id: '1', title: 'Task 1', status: 'todo', priority: 'medium', domainId: 'domain-1' },
{ id: '2', title: 'Task 2', status: 'in_progress', priority: 'high', domainId: 'domain-1' },
];
// Mock the db.select chain
const mockSelect = jest.fn().mockReturnThis();
const mockFrom = jest.fn().mockReturnThis();
const mockWhere = jest.fn().mockReturnThis();
const mockOrderBy = jest.fn().mockReturnThis();
const mockLimit = jest.fn().mockReturnThis();
const mockOffset = jest.fn().mockResolvedValue(mockTasks);
db.select.mockReturnValue({
from: jest.fn().mockReturnValue({
where: jest.fn().mockReturnValue({
orderBy: jest.fn().mockReturnValue({
limit: jest.fn().mockReturnValue({
offset: jest.fn().mockResolvedValue(mockTasks),
}),
}),
}),
}),
});
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks');
const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
expect(response).toBeDefined();
});
it('should filter by status', async () => {
const { GET } = await import('@/app/api/domains/[domainId]/tasks/route');
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks?status=todo');
const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
expect(response).toBeDefined();
});
it('should filter by search term', async () => {
const { GET } = await import('@/app/api/domains/[domainId]/tasks/route');
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks?search=test');
const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
expect(response).toBeDefined();
});
});
describe('POST /api/domains/[domainId]/tasks', () => {
it('should create a task with required fields', async () => {
const { POST } = await import('@/app/api/domains/[domainId]/tasks/route');
const { db } = require('@project-e/db');
const mockTask = {
id: 'new-task-1',
title: 'Test Task',
status: 'todo',
priority: 'medium',
domainId: 'domain-1',
};
db.insert.mockReturnValue({
values: jest.fn().mockReturnValue({
returning: jest.fn().mockResolvedValue([mockTask]),
}),
});
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Test Task' }),
});
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
expect(response).toBeDefined();
});
it('should reject empty title', async () => {
const { POST } = await import('@/app/api/domains/[domainId]/tasks/route');
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: '' }),
});
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
expect(response).toBeDefined();
});
});
describe('PATCH /api/domains/[domainId]/tasks/[id]', () => {
it('should update task status', async () => {
const { PATCH } = await import('@/app/api/domains/[domainId]/tasks/[id]/route');
const { db } = require('@project-e/db');
const existingTask = {
id: 'task-1',
title: 'Test Task',
status: 'todo',
domainId: 'domain-1',
};
db.select.mockReturnValue({
from: jest.fn().mockReturnValue({
where: jest.fn().mockReturnValue({
limit: jest.fn().mockResolvedValue([existingTask]),
}),
}),
});
db.update.mockReturnValue({
set: jest.fn().mockReturnValue({
where: jest.fn().mockReturnValue({
returning: jest.fn().mockResolvedValue([{ ...existingTask, status: 'done' }]),
}),
}),
});
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks/task-1', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'done' }),
});
const response = await PATCH(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'task-1' }) });
expect(response).toBeDefined();
});
});
describe('DELETE /api/domains/[domainId]/tasks/[id]', () => {
it('should soft delete a task', async () => {
const { DELETE } = await import('@/app/api/domains/[domainId]/tasks/[id]/route');
const { db } = require('@project-e/db');
const existingTask = {
id: 'task-1',
title: 'Test Task',
domainId: 'domain-1',
};
db.select.mockReturnValue({
from: jest.fn().mockReturnValue({
where: jest.fn().mockReturnValue({
limit: jest.fn().mockResolvedValue([existingTask]),
}),
}),
});
db.update.mockReturnValue({
set: jest.fn().mockReturnValue({
where: jest.fn().mockResolvedValue(undefined),
}),
});
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks/task-1', {
method: 'DELETE',
});
const response = await DELETE(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'task-1' }) });
expect(response.status).toBe(204);
});
});
describe('Dependencies cycle detection', () => {
it('should detect direct self-loop', async () => {
const { POST } = await import('@/app/api/domains/[domainId]/tasks/[id]/dependencies/route');
const { db } = require('@project-e/db');
const mockTask = { id: 'task-1', title: 'Test', domainId: 'domain-1' };
db.select.mockReturnValue({
from: jest.fn().mockReturnValue({
where: jest.fn().mockReturnValue({
limit: jest.fn().mockResolvedValue([mockTask]),
}),
}),
});
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks/task-1/dependencies', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ taskId: 'task-1' }),
});
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'task-1' }) });
expect(response).toBeDefined();
});
});
});
-83
View File
@@ -1,83 +0,0 @@
/**
* 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,
}));
});
});
-132
View File
@@ -1,132 +0,0 @@
import { describe, it, expect } from '@jest/globals';
import { parseWikilinks, extractLinkTargets, hasWikilinks, formatWikilinkDisplay } from '@/lib/wikilink-parser';
describe('wikilink-parser', () => {
describe('parseWikilinks', () => {
it('parses simple [[Title]] links', () => {
const result = parseWikilinks('Check out [[Meeting Notes]] for details');
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
raw: '[[Meeting Notes]]',
entityType: '',
title: 'Meeting Notes',
displayText: null,
});
});
it('parses [[Title|Display]] links', () => {
const result = parseWikilinks('See [[Long Note Title|this note]]');
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
raw: '[[Long Note Title|this note]]',
entityType: '',
title: 'Long Note Title',
displayText: 'this note',
});
});
it('parses [[entity_type:Title]] cross-entity links', () => {
const result = parseWikilinks('Complete [[task:Buy milk]] and [[habit:Exercise]]');
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
raw: '[[task:Buy milk]]',
entityType: 'task',
title: 'Buy milk',
displayText: null,
});
expect(result[1]).toEqual({
raw: '[[habit:Exercise]]',
entityType: 'habit',
title: 'Exercise',
displayText: null,
});
});
it('parses [[entity_type:Title|Display]] links', () => {
const result = parseWikilinks('See [[project:Stratos|the Stratos project]]');
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
raw: '[[project:Stratos|the Stratos project]]',
entityType: 'project',
title: 'Stratos',
displayText: 'the Stratos project',
});
});
it('handles multiple wikilinks in one string', () => {
const result = parseWikilinks('[[Note A]] and [[task:Task B]] and [[Note C|display]]');
expect(result).toHaveLength(3);
});
it('returns empty array for content with no wikilinks', () => {
const result = parseWikilinks('Plain text with no links');
expect(result).toHaveLength(0);
});
it('returns empty array for empty content', () => {
expect(parseWikilinks('')).toHaveLength(0);
});
it('handles titles with special characters', () => {
const result = parseWikilinks('[[Task:Buy milk & eggs!]]');
expect(result).toHaveLength(1);
expect(result[0].title).toBe('Buy milk & eggs!');
});
it('trims whitespace from titles', () => {
const result = parseWikilinks('[[ Spaced Title ]]');
expect(result[0].title).toBe('Spaced Title');
});
it('handles entity types with underscores', () => {
const result = parseWikilinks('[[note:My Note]]');
expect(result[0].entityType).toBe('note');
expect(result[0].title).toBe('My Note');
});
});
describe('extractLinkTargets', () => {
it('extracts unique link targets', () => {
const result = extractLinkTargets('[[Note A]] and [[Note A]] and [[task:Task B]]');
expect(result).toHaveLength(2);
expect(result).toContainEqual({ entityType: '', title: 'Note A' });
expect(result).toContainEqual({ entityType: 'task', title: 'Task B' });
});
it('deduplicates identical targets', () => {
const result = extractLinkTargets('[[Note A]] and [[Note A|display]]');
expect(result).toHaveLength(1);
});
});
describe('hasWikilinks', () => {
it('returns true when wikilinks exist', () => {
expect(hasWikilinks('Text with [[a link]]')).toBe(true);
});
it('returns false when no wikilinks exist', () => {
expect(hasWikilinks('Plain text')).toBe(false);
});
it('returns false for empty content', () => {
expect(hasWikilinks('')).toBe(false);
});
});
describe('formatWikilinkDisplay', () => {
it('uses display text when available', () => {
const match = parseWikilinks('[[Title|Display]]')[0];
expect(formatWikilinkDisplay(match)).toBe('Display');
});
it('formats entity links without display text', () => {
const match = parseWikilinks('[[task:Buy milk]]')[0];
expect(formatWikilinkDisplay(match)).toBe('task: Buy milk');
});
it('returns title for simple note links', () => {
const match = parseWikilinks('[[Meeting Notes]]')[0];
expect(formatWikilinkDisplay(match)).toBe('Meeting Notes');
});
});
});
-3
View File
@@ -1,3 +0,0 @@
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
-89
View File
@@ -1,89 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { signIn } from 'next-auth/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { handleApiError } from '@/lib/errors';
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setErrorMessage('');
setLoading(true);
try {
const result = await signIn('credentials', {
email,
password,
redirect: false,
});
if (!result?.ok) throw new Error('Unable to sign in. Check your credentials and try again.');
router.push('/dashboard');
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Unable to sign in. Check your credentials and try again.');
handleApiError(error, 'Login failed');
} finally {
setLoading(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center p-6">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Project E</CardTitle>
<CardDescription>Sign in to your workspace</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{errorMessage && (
<div id="login-error" role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{errorMessage}
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
aria-describedby={errorMessage ? 'login-error' : undefined}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
aria-describedby={errorMessage ? 'login-error' : undefined}
/>
</div>
</CardContent>
<CardFooter>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Signing in...' : 'Sign in'}
</Button>
</CardFooter>
</form>
</Card>
</div>
);
}
-431
View File
@@ -1,431 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { Activity, CheckCircle2, XCircle, Clock, RotateCcw } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { DispatchPanel } from '@/components/agents/dispatch-panel';
interface Agent {
id: string;
name: string;
avatar?: string;
description?: string;
permission_tier: string;
status: 'active' | 'disabled';
last_activity_at?: string;
}
interface AgentActivity {
id: string;
agent_id: string;
action: string;
entity_type: string;
entity_id: string;
before_state?: Record<string, unknown>;
after_state?: Record<string, unknown>;
created: string;
}
interface AgentTask {
id: string;
agent_id: string;
task_type: string;
input: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
output?: Record<string, unknown>;
created: string;
}
export default function AgentsPage() {
const [agents, setAgents] = useState<Agent[]>([]);
const [activity, setActivity] = useState<AgentActivity[]>([]);
const [agentTasks, setAgentTasks] = useState<AgentTask[]>([]);
const [agentsLoading, setAgentsLoading] = useState(true);
const [activityLoading, setActivityLoading] = useState(true);
const [agentTasksLoading, setAgentTasksLoading] = useState(true);
const [agentsError, setAgentsError] = useState<string | null>(null);
const [activityError, setActivityError] = useState<string | null>(null);
const [agentTasksError, setAgentTasksError] = useState<string | null>(null);
const [undoingActivityId, setUndoingActivityId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
useEffect(() => {
fetchAgents();
fetchActivity();
fetchAgentTasks();
}, []);
async function fetchAgents() {
setAgentsLoading(true);
setAgentsError(null);
try {
const response = await fetch('/api/agents');
if (!response.ok) {
throw new Error('Unable to load agents.');
}
const data = await response.json();
setAgents(data.items || []);
} catch (error) {
console.error('Failed to fetch agents:', error);
setAgentsError('Unable to load agents. Please try again.');
} finally {
setAgentsLoading(false);
}
}
async function fetchActivity() {
setActivityLoading(true);
setActivityError(null);
try {
const response = await fetch('/api/agent-activity?sort=-created&perPage=50');
if (!response.ok) {
throw new Error('Unable to load agent activity.');
}
const data = await response.json();
setActivity(data.items || []);
} catch (error) {
console.error('Failed to fetch activity:', error);
setActivityError('Unable to load agent activity. Please try again.');
} finally {
setActivityLoading(false);
}
}
async function fetchAgentTasks() {
setAgentTasksLoading(true);
setAgentTasksError(null);
try {
const response = await fetch('/api/agent-tasks?sort=-created&perPage=50');
if (!response.ok) {
throw new Error('Unable to load agent tasks.');
}
const data = await response.json();
setAgentTasks(data.items || []);
} catch (error) {
console.error('Failed to fetch agent tasks:', error);
setAgentTasksError('Unable to load agent tasks. Please try again.');
} finally {
setAgentTasksLoading(false);
}
}
async function undoActivity(activityId: string) {
setUndoingActivityId(activityId);
setFeedback(null);
try {
const response = await fetch(`/api/agent-activity/${activityId}/undo`, {
method: 'POST',
});
if (!response.ok) {
throw new Error('Unable to undo this activity.');
}
setFeedback({ type: 'success', message: 'Activity undone successfully.' });
await fetchActivity();
} catch (error) {
console.error('Failed to undo activity:', error);
setFeedback({ type: 'error', message: 'Unable to undo this activity. Please try again.' });
} finally {
setUndoingActivityId(null);
}
}
function getAgentName(agentId: string): string {
const agent = agents.find((a) => a.id === agentId);
return agent?.name || 'Unknown Agent';
}
function getStatusIcon(status: string) {
switch (status) {
case 'completed':
return <CheckCircle2 className="h-4 w-4 text-green-600" />;
case 'failed':
return <XCircle className="h-4 w-4 text-red-600" />;
case 'in_progress':
return <Clock className="h-4 w-4 text-blue-600 animate-pulse" />;
default:
return <Clock className="h-4 w-4 text-muted-foreground" />;
}
}
function getActionLabel(action: string): string {
const labels: Record<string, string> = {
create: 'Created',
update: 'Updated',
delete: 'Deleted',
complete: 'Completed',
assign: 'Assigned',
};
return labels[action] || action;
}
return (
<div>
<div className="mb-6 flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold">Agent Activity</h1>
<p className="mt-1 text-muted-foreground">
Every agent action, visible and reversible.
</p>
</div>
<DispatchPanel triggerLabel="+ New task" triggerVariant="default" />
</div>
{feedback && (
<p
className={`mt-3 text-sm ${
feedback.type === 'success' ? 'text-green-600' : 'text-red-600'
}`}
role={feedback.type === 'error' ? 'alert' : 'status'}
>
{feedback.message}
</p>
)}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[300px_1fr]">
{/* Agents list */}
<Card>
<CardHeader>
<CardTitle className="text-base">Agents</CardTitle>
</CardHeader>
<CardContent>
{agentsLoading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Loading agents...</p>
) : agentsError ? (
<div className="py-8 text-center">
<p className="text-sm text-red-600" role="alert">{agentsError}</p>
<Button variant="outline" size="sm" className="mt-3" onClick={fetchAgents}>
Retry
</Button>
</div>
) : agents.length === 0 ? (
<div className="py-8 text-center">
<p className="text-sm text-muted-foreground">No agents configured</p>
<Button variant="outline" size="sm" className="mt-3" onClick={fetchAgents}>
Refresh agents
</Button>
</div>
) : (
<div className="space-y-2">
{agents.map((agent) => (
<button
key={agent.id}
onClick={() => setSelectedAgent(agent)}
aria-label={`View activity for agent: ${agent.name}`}
aria-current={selectedAgent?.id === agent.id ? 'true' : undefined}
className={`w-full rounded-lg p-3 text-left transition-colors ${
selectedAgent?.id === agent.id
? 'bg-accent'
: 'hover:bg-accent/50'
}`}
>
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback>
{agent.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium">{agent.name}</p>
<div className="flex items-center gap-2">
<Badge
variant={agent.status === 'active' ? 'default' : 'secondary'}
className="text-xs"
>
{agent.status}
</Badge>
<span className="text-xs text-muted-foreground">
{agent.permission_tier}
</span>
</div>
</div>
{agent.last_activity_at && (
<span className="text-xs text-muted-foreground">
{new Date(agent.last_activity_at).toLocaleDateString()}
</span>
)}
</div>
</button>
))}
</div>
)}
</CardContent>
</Card>
{/* Activity feed */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" aria-hidden="true" />
Activity Feed
</CardTitle>
</CardHeader>
<CardContent>
<Tabs defaultValue="activity">
<TabsList>
<TabsTrigger value="activity">Activity</TabsTrigger>
<TabsTrigger value="tasks">Tasks</TabsTrigger>
</TabsList>
<TabsContent value="activity" className="mt-4">
{activityLoading ? (
<p className="py-8 text-center text-muted-foreground">Loading agent activity...</p>
) : activityError ? (
<div className="py-8 text-center">
<p className="text-sm text-red-600" role="alert">{activityError}</p>
<Button variant="outline" size="sm" className="mt-3" onClick={fetchActivity}>
Retry
</Button>
</div>
) : activity.length === 0 ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">No agent activity yet</p>
<Button variant="outline" size="sm" className="mt-3" onClick={fetchActivity}>
Refresh activity
</Button>
</div>
) : (
<div className="space-y-3">
{activity.map((item) => (
<div
key={item.id}
className="rounded-lg border p-4 transition-colors hover:bg-accent/50"
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback>
{getAgentName(item.agent_id).charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">
{getAgentName(item.agent_id)}
</span>
<span className="text-sm text-muted-foreground">
{getActionLabel(item.action)}
</span>
<Badge variant="outline" className="text-xs">
{item.entity_type}
</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{new Date(item.created).toLocaleString()}
</p>
{item.before_state && item.after_state && (
<details className="mt-2">
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground" role="button">
View changes
</summary>
<div className="mt-2 grid grid-cols-2 gap-2 text-xs">
<div>
<p className="font-semibold text-red-600">Before</p>
<pre className="mt-1 rounded bg-muted p-2 overflow-x-auto">
{JSON.stringify(item.before_state, null, 2)}
</pre>
</div>
<div>
<p className="font-semibold text-green-600">After</p>
<pre className="mt-1 rounded bg-muted p-2 overflow-x-auto">
{JSON.stringify(item.after_state, null, 2)}
</pre>
</div>
</div>
</details>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => undoActivity(item.id)}
disabled={undoingActivityId !== null}
className="shrink-0"
aria-label={`Undo activity: ${getActionLabel(item.action)} ${item.entity_type}`}
>
<RotateCcw className="mr-1 h-3 w-3" aria-hidden="true" />
{undoingActivityId === item.id ? 'Undoing...' : 'Undo'}
</Button>
</div>
</div>
))}
</div>
)}
</TabsContent>
<TabsContent value="tasks" className="mt-4">
{agentTasksLoading ? (
<p className="py-8 text-center text-muted-foreground">Loading agent tasks...</p>
) : agentTasksError ? (
<div className="py-8 text-center">
<p className="text-sm text-red-600" role="alert">{agentTasksError}</p>
<Button variant="outline" size="sm" className="mt-3" onClick={fetchAgentTasks}>
Retry
</Button>
</div>
) : agentTasks.length === 0 ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">No agent tasks yet</p>
<Button variant="outline" size="sm" className="mt-3" onClick={fetchAgentTasks}>
Refresh tasks
</Button>
</div>
) : (
<div className="space-y-3">
{agentTasks.map((task) => (
<div
key={task.id}
className="rounded-lg border p-4"
>
<div className="flex items-start gap-3">
{getStatusIcon(task.status)}
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">
{getAgentName(task.agent_id)}
</span>
<Badge
variant={
task.status === 'completed'
? 'default'
: task.status === 'failed'
? 'destructive'
: 'secondary'
}
className="text-xs"
>
{task.status}
</Badge>
</div>
<p className="mt-1 text-sm">{task.input}</p>
<p className="mt-1 text-xs text-muted-foreground">
{new Date(task.created).toLocaleString()}
</p>
{task.output && (
<details className="mt-2">
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground" role="button">
View output
</summary>
<pre className="mt-2 rounded bg-muted p-2 text-xs overflow-x-auto">
{JSON.stringify(task.output, null, 2)}
</pre>
</details>
)}
</div>
</div>
</div>
))}
</div>
)}
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
</div>
);
}
-306
View File
@@ -1,306 +0,0 @@
'use client';
import { useEffect, useState, Suspense } from 'react';
import {
TrendingUp,
Target,
Clock,
Flame,
BarChart3,
PieChart as PieChartIcon,
} from 'lucide-react';
import dynamic from 'next/dynamic';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui/tabs';
// Lazy load recharts (~180KB)
const AnalyticsCharts = dynamic(
() => import('@/components/analytics/analytics-charts').then((m) => m.AnalyticsCharts),
{
ssr: false,
loading: () => (
<div className="grid grid-cols-1 gap-6">
{[1, 2].map((i) => (
<div key={i} className="h-[350px] animate-pulse rounded-lg border bg-muted/30 p-6">
<div className="mb-4 h-5 w-40 rounded bg-muted/50" />
<div className="h-full rounded bg-muted/30" />
</div>
))}
</div>
),
}
);
interface AnalyticsData {
taskCompletionRate: number;
habitConsistency: number;
totalTimeMinutes: number;
activeStreaks: number;
bestStreak: number;
period: number;
}
interface TimeData {
date: string;
tasks: number;
habits: number;
time: number;
}
interface DomainData {
name: string;
value: number;
color: string;
}
interface HabitData {
name: string;
streak: number;
score: number;
consistency: number;
}
function countByDate(records: Array<Record<string, unknown>>, field: string) {
return records.reduce<Record<string, number>>((counts, record) => {
const value = record[field];
if (typeof value === 'string' && !Number.isNaN(new Date(value).getTime())) {
const date = new Date(value).toISOString().slice(0, 10);
counts[date] = (counts[date] || 0) + 1;
}
return counts;
}, {});
}
export default function AnalyticsPage() {
const [analytics, setAnalytics] = useState<AnalyticsData | null>(null);
const [timeData, setTimeData] = useState<TimeData[]>([]);
const [domainData, setDomainData] = useState<DomainData[]>([]);
const [habitData, setHabitData] = useState<HabitData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchAnalytics();
}, []);
async function fetchAnalytics() {
setLoading(true);
setError(null);
try {
const startDate = new Date();
startDate.setDate(startDate.getDate() - 29);
const start = startDate.toISOString();
const [analyticsResponse, timeResponse, habitsResponse, tasksResponse, habitLogsResponse] = await Promise.all([
fetch('/api/analytics?period=30'),
fetch(`/api/time-summary?start=${encodeURIComponent(start)}`),
fetch('/api/habits/streaks'),
fetch('/api/tasks?perPage=500'),
fetch(`/api/habit-logs?start=${encodeURIComponent(start)}`),
]);
if (![analyticsResponse, timeResponse, habitsResponse, tasksResponse, habitLogsResponse].every((response) => response.ok)) {
throw new Error('One or more analytics sources could not be loaded.');
}
const [analyticsData, timeSummary, habitsData, tasksData, habitLogsData] = await Promise.all([
analyticsResponse.json(),
timeResponse.json(),
habitsResponse.json(),
tasksResponse.json(),
habitLogsResponse.json(),
]);
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4'];
const domains: DomainData[] = Object.entries(timeSummary.byDomain || {}).map(([name, value], index) => ({
name,
value: value as number,
color: COLORS[index % COLORS.length],
}));
const habits: HabitData[] = (habitsData.streaks || []).map(
(s: { habit: { name: string; score?: number }; current_streak: number }) => ({
name: s.habit.name,
streak: s.current_streak,
score: s.habit.score || 0,
consistency: 0,
})
);
const completedByDate = countByDate(tasksData.items || [], 'completed_at');
const habitsByDate = countByDate(habitLogsData.items || [], 'logged_at');
const minutesByDate = timeSummary.byDate || {};
const dailyData = Array.from({ length: 30 }, (_, index) => {
const date = new Date(startDate);
date.setDate(startDate.getDate() + index);
const key = date.toISOString().slice(0, 10);
return {
date: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
tasks: completedByDate[key] || 0,
habits: habitsByDate[key] || 0,
time: minutesByDate[key] || 0,
};
});
setAnalytics(analyticsData);
setDomainData(domains);
setHabitData(habits);
setTimeData(dailyData);
} catch (error) {
console.error('Failed to fetch analytics:', error);
setError('Analytics could not be loaded. Please try again.');
} finally {
setLoading(false);
}
}
if (loading) {
return <p className="text-muted-foreground">Loading analytics...</p>;
}
if (error || !analytics) {
return (
<div className="space-y-4 py-20 text-center">
<p className="text-muted-foreground" role="alert">{error || 'Analytics are unavailable.'}</p>
<Button onClick={fetchAnalytics}>Retry</Button>
</div>
);
}
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold">Analytics</h1>
<p className="mt-1 text-muted-foreground">Patterns behind your progress.</p>
</div>
{/* Summary cards */}
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">
Task Completion
</CardTitle>
<Target className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{analytics.taskCompletionRate}%
</div>
<p className="text-xs text-muted-foreground">
Last {analytics.period} days
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">
Habit Consistency
</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{analytics.habitConsistency}%
</div>
<p className="text-xs text-muted-foreground">
Last {analytics.period} days
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Time Tracked</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{Math.round(analytics.totalTimeMinutes / 60)}h
</div>
<p className="text-xs text-muted-foreground">
Last {analytics.period} days
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">
Active Streaks
</CardTitle>
<Flame className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{analytics.activeStreaks}</div>
<p className="text-xs text-muted-foreground">
Best: {analytics.bestStreak} days
</p>
</CardContent>
</Card>
</div>
{/* Charts */}
<Tabs defaultValue="trends">
<TabsList>
<TabsTrigger value="trends">Trends</TabsTrigger>
<TabsTrigger value="habits">Habits</TabsTrigger>
<TabsTrigger value="time">Time</TabsTrigger>
</TabsList>
<TabsContent value="trends" className="mt-6">
<Suspense
fallback={
<div className="grid grid-cols-1 gap-6">
{[1, 2].map((i) => (
<div key={i} className="h-[350px] animate-pulse rounded-lg border bg-muted/30 p-6" />
))}
</div>
}
>
<AnalyticsCharts
timeData={timeData}
domainData={domainData}
habitData={habitData}
activeTab="trends"
/>
</Suspense>
</TabsContent>
<TabsContent value="habits" className="mt-6">
<Suspense
fallback={
<div className="h-[350px] animate-pulse rounded-lg border bg-muted/30 p-6" />
}
>
<AnalyticsCharts
timeData={timeData}
domainData={domainData}
habitData={habitData}
activeTab="habits"
/>
</Suspense>
</TabsContent>
<TabsContent value="time" className="mt-6">
<Suspense
fallback={
<div className="h-[350px] animate-pulse rounded-lg border bg-muted/30 p-6" />
}
>
<AnalyticsCharts
timeData={timeData}
domainData={domainData}
habitData={habitData}
activeTab="time"
/>
</Suspense>
</TabsContent>
</Tabs>
</div>
);
}
-419
View File
@@ -1,419 +0,0 @@
'use client';
import { useEffect, useState, useMemo, useCallback, Suspense } from 'react';
import { Filter, ChevronLeft, ChevronRight, CalendarDays, Calendar as CalendarIcon } from 'lucide-react';
import dynamic from 'next/dynamic';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { useRouter } from 'next/navigation';
// Lazy load react-big-calendar
const BigCalendar = dynamic(
() => import('@/components/calendar/big-calendar-wrapper').then((m) => m.BigCalendarWrapper),
{
ssr: false,
loading: () => (
<div className="flex h-[600px] items-center justify-center rounded-lg border bg-muted/30">
<div className="animate-pulse text-sm text-muted-foreground">Loading calendar...</div>
</div>
),
}
);
interface CalendarEvent {
id: string;
title: string;
start: Date;
end: Date;
type: 'task' | 'habit' | 'project' | 'milestone';
entityType: string;
entityId: string;
color: string;
domainId: string;
href: string;
priority?: string;
difficulty?: string;
status?: string;
}
export default function CalendarPage() {
const router = useRouter();
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentDate, setCurrentDate] = useState(new Date());
const [view, setView] = useState<'month' | 'week' | 'day'>('month');
const [showTasks, setShowTasks] = useState(true);
const [showHabits, setShowHabits] = useState(true);
const [showProjects, setShowProjects] = useState(true);
const [showMilestones, setShowMilestones] = useState(true);
const [selectedDomains, setSelectedDomains] = useState<string[]>([]);
const [domainOptions, setDomainOptions] = useState<{id: string; name: string; color: string | null}[]>([]);
const [currentDomainId, setCurrentDomainId] = useState<string | null>(null);
// Auto-switch to day view on mobile
useEffect(() => {
const checkWidth = () => {
if (window.innerWidth < 640 && view !== 'day') {
setView('day');
}
};
checkWidth();
window.addEventListener('resize', checkWidth);
return () => window.removeEventListener('resize', checkWidth);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Get current domain from URL or default
useEffect(() => {
const pathParts = window.location.pathname.split('/');
// Try to find domain from sidebar or use first domain
fetchDomains();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function fetchDomains() {
try {
const res = await fetch('/api/domains?sort=sort_order');
if (res.ok) {
const data = await res.json();
setDomainOptions(data.items || []);
if (data.items?.length > 0 && !currentDomainId) {
setCurrentDomainId(data.items[0].id);
}
}
} catch {}
}
const fetchEvents = useCallback(async (domainId: string, from: Date, to: Date) => {
if (!domainId) return;
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
from: from.toISOString(),
to: to.toISOString(),
types: ['task', 'habit', 'project', 'milestone'].join(','),
});
const res = await fetch(`/api/domains/${domainId}/calendar/events?${params}`);
if (!res.ok) throw new Error('Failed to fetch events');
const data = await res.json();
const calendarEvents: CalendarEvent[] = (data.events || []).map((e: any) => ({
...e,
start: new Date(e.start),
end: new Date(e.end),
}));
setEvents(calendarEvents);
} catch (err) {
console.error('Failed to fetch calendar events:', err);
setError('Calendar events could not be loaded. Please try again.');
} finally {
setLoading(false);
}
}, []);
// Fetch events when domain or date range changes
useEffect(() => {
if (!currentDomainId) return;
const range = getViewRange(currentDate, view);
fetchEvents(currentDomainId, range.from, range.to);
}, [currentDomainId, currentDate, view, fetchEvents]);
// Calendar keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable
) {
return;
}
switch (e.key.toLowerCase()) {
case 't':
navigate('today');
e.preventDefault();
break;
case 'm':
setView('month');
e.preventDefault();
break;
case 'w':
setView('week');
e.preventDefault();
break;
case 'd':
setView('day');
e.preventDefault();
break;
case 'arrowleft':
navigate('prev');
e.preventDefault();
break;
case 'arrowright':
navigate('next');
e.preventDefault();
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentDate, view]);
function getViewRange(date: Date, v: string): { from: Date; to: Date } {
const from = new Date(date);
const to = new Date(date);
switch (v) {
case 'month':
from.setDate(1);
from.setHours(0, 0, 0, 0);
to.setMonth(to.getMonth() + 1, 0);
to.setHours(23, 59, 59, 999);
// Add buffer for week overlap
from.setDate(from.getDate() - 7);
to.setDate(to.getDate() + 7);
break;
case 'week': {
const day = from.getDay();
from.setDate(from.getDate() - day);
from.setHours(0, 0, 0, 0);
to.setDate(to.getDate() + (6 - day));
to.setHours(23, 59, 59, 999);
break;
}
case 'day':
from.setHours(0, 0, 0, 0);
to.setHours(23, 59, 59, 999);
break;
}
return { from, to };
}
const navigate = (direction: 'prev' | 'next' | 'today') => {
const d = new Date(currentDate);
switch (direction) {
case 'prev':
if (view === 'month') d.setMonth(d.getMonth() - 1);
else if (view === 'week') d.setDate(d.getDate() - 7);
else d.setDate(d.getDate() - 1);
break;
case 'next':
if (view === 'month') d.setMonth(d.getMonth() + 1);
else if (view === 'week') d.setDate(d.getDate() + 7);
else d.setDate(d.getDate() + 1);
break;
case 'today':
d.setTime(Date.now());
break;
}
setCurrentDate(d);
};
const handleEventDrop = async (event: CalendarEvent, newStart: Date) => {
if (event.entityType !== 'task') return;
try {
const res = await fetch(`/api/domains/${currentDomainId}/tasks/${event.entityId}/schedule`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dueDate: newStart.toISOString() }),
});
if (!res.ok) throw new Error('Failed to reschedule');
// Refresh events
const range = getViewRange(currentDate, view);
fetchEvents(currentDomainId!, range.from, range.to);
} catch (err) {
console.error('Failed to reschedule task:', err);
}
};
const filteredEvents = useMemo(() => {
return events.filter((event) => {
if (event.type === 'task' && !showTasks) return false;
if (event.type === 'habit' && !showHabits) return false;
if (event.type === 'project' && !showProjects) return false;
if (event.type === 'milestone' && !showMilestones) return false;
if (selectedDomains.length > 0 && !selectedDomains.includes(event.domainId)) return false;
return true;
});
}, [events, showTasks, showHabits, showProjects, showMilestones, selectedDomains]);
const toggleDomain = (domainId: string) => {
setSelectedDomains((prev) =>
prev.includes(domainId) ? prev.filter((d) => d !== domainId) : [...prev, domainId]
);
};
const formatTitle = () => {
const opts: Intl.DateTimeFormatOptions = {};
if (view === 'month') { opts.month = 'long'; opts.year = 'numeric'; }
else if (view === 'week') { opts.month = 'short'; opts.day = 'numeric'; }
else { opts.weekday = 'long'; opts.month = 'long'; opts.day = 'numeric'; opts.year = 'numeric'; }
return currentDate.toLocaleDateString('en-US', opts);
};
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Calendar</h1>
<p className="mt-1 text-muted-foreground">Your commitments, in time.</p>
</div>
</div>
{/* Toolbar */}
<div className="mb-4 flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={() => navigate('today')}>
<CalendarIcon className="mr-1 h-4 w-4" />
Today
</Button>
<div className="flex">
<Button variant="ghost" size="icon" onClick={() => navigate('prev')}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => navigate('next')}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<h2 className="min-w-[180px] text-lg font-semibold">{formatTitle()}</h2>
<div className="ml-auto flex rounded-lg border">
{(['month', 'week', 'day'] as const).map((v) => (
<Button
key={v}
variant={view === v ? 'default' : 'ghost'}
size="sm"
className="rounded-none capitalize"
onClick={() => setView(v)}
>
{v}
</Button>
))}
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[250px_1fr]">
{/* Filters sidebar */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Filter className="h-4 w-4" aria-hidden="true" />
Filters
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Entity types */}
<div className="space-y-3">
<h3 className="text-sm font-semibold">Show</h3>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox id="tasks" checked={showTasks} onCheckedChange={(c) => setShowTasks(c === true)} />
<Label htmlFor="tasks" className="flex items-center gap-2">
<div className="h-3 w-3 rounded" style={{ backgroundColor: '#3b82f6' }} />
Tasks
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox id="habits" checked={showHabits} onCheckedChange={(c) => setShowHabits(c === true)} />
<Label htmlFor="habits" className="flex items-center gap-2">
<div className="h-3 w-3 rounded" style={{ backgroundColor: '#22c55e' }} />
Habits
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox id="projects" checked={showProjects} onCheckedChange={(c) => setShowProjects(c === true)} />
<Label htmlFor="projects" className="flex items-center gap-2">
<div className="h-3 w-3 rounded" style={{ backgroundColor: '#8b5cf6' }} />
Projects
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox id="milestones" checked={showMilestones} onCheckedChange={(c) => setShowMilestones(c === true)} />
<Label htmlFor="milestones" className="flex items-center gap-2">
<div className="h-3 w-3 rounded" style={{ backgroundColor: '#f59e0b' }} />
Milestones
</Label>
</div>
</div>
</div>
{/* Domains */}
<div className="space-y-3">
<h3 className="text-sm font-semibold">Domains</h3>
<div className="space-y-2">
{domainOptions.map((domain) => (
<div key={domain.id} className="flex items-center space-x-2">
<Checkbox
id={domain.id}
checked={selectedDomains.includes(domain.id)}
onCheckedChange={() => toggleDomain(domain.id)}
/>
<Label htmlFor={domain.id}>
<Badge variant="outline">{domain.name}</Badge>
</Label>
</div>
))}
</div>
{selectedDomains.length > 0 && (
<Button variant="ghost" size="sm" onClick={() => setSelectedDomains([])} className="text-xs">
Clear filters
</Button>
)}
</div>
{/* Legend */}
<div className="space-y-2 border-t pt-4">
<h3 className="text-sm font-semibold">Legend</h3>
<div className="space-y-1 text-xs text-muted-foreground">
<p> Tasks show on due date (color = priority)</p>
<p> Habits show daily (color = difficulty)</p>
<p> Projects show on deadline</p>
<p> Milestones show on due date</p>
</div>
</div>
</CardContent>
</Card>
{/* Calendar */}
<Card>
<CardContent className="p-4">
{error ? (
<div className="flex flex-col items-center gap-4 py-20">
<p className="text-muted-foreground" role="alert">{error}</p>
<Button onClick={() => {
const range = getViewRange(currentDate, view);
if (currentDomainId) fetchEvents(currentDomainId, range.from, range.to);
}}>
Retry
</Button>
</div>
) : (
<Suspense
fallback={
<div className="flex h-[600px] items-center justify-center rounded-lg border bg-muted/30">
<div className="animate-pulse text-sm text-muted-foreground">Loading calendar...</div>
</div>
}
>
<BigCalendar
events={filteredEvents}
onEventDrop={handleEventDrop}
defaultView={view}
date={currentDate}
onNavigate={setCurrentDate}
onViewChange={(v: string) => setView(v as 'month' | 'week' | 'day')}
/>
</Suspense>
)}
</CardContent>
</Card>
</div>
</div>
);
}
-523
View File
@@ -1,523 +0,0 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { LayoutGrid, Plus, Trash2, GripVertical, X, ZoomIn, ZoomOut, Maximize2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
interface CanvasCard {
id: string;
canvas_id: string;
type: 'note' | 'task' | 'image' | 'entity';
entity_id?: string;
title?: string;
content?: string;
x: number;
y: number;
width: number;
height: number;
rotation: number;
color?: string;
z_index: number;
created: string;
updated: string;
}
interface CanvasConnection {
id: string;
source_card_id: string;
target_card_id: string;
label?: string;
style: 'solid' | 'dashed' | 'dotted';
}
interface Canvas {
id: string;
name: string;
description?: string;
mode: 'freeform' | 'graph';
domain: string;
tags: string[];
cards: CanvasCard[];
connections: CanvasConnection[];
viewport?: { x: number; y: number; zoom: number };
background?: string;
created: string;
updated: string;
}
function CanvasBoard({ canvas, onBack }: { canvas: Canvas; onBack: () => void }) {
const [cards, setCards] = useState<CanvasCard[]>(canvas.cards || []);
const [connections] = useState<CanvasConnection[]>(canvas.connections || []);
const [dragging, setDragging] = useState<string | null>(null);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [viewport, setViewport] = useState(canvas.viewport || { x: 0, y: 0, zoom: 1 });
const [editingCard, setEditingCard] = useState<CanvasCard | null>(null);
const [editTitle, setEditTitle] = useState('');
const [editContent, setEditContent] = useState('');
const boardRef = useRef<HTMLDivElement>(null);
const handlePointerDown = useCallback(
(e: React.PointerEvent, cardId: string) => {
e.preventDefault();
const card = cards.find((c) => c.id === cardId);
if (!card) return;
setDragging(cardId);
setDragOffset({
x: e.clientX - card.x * viewport.zoom,
y: e.clientY - card.y * viewport.zoom,
});
(e.target as HTMLElement).setPointerCapture(e.pointerId);
},
[cards, viewport.zoom]
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!dragging) return;
const newX = (e.clientX - dragOffset.x) / viewport.zoom;
const newY = (e.clientY - dragOffset.y) / viewport.zoom;
setCards((prev) =>
prev.map((c) => (c.id === dragging ? { ...c, x: Math.max(0, newX), y: Math.max(0, newY) } : c))
);
},
[dragging, dragOffset, viewport.zoom]
);
const handlePointerUp = useCallback(() => {
setDragging(null);
}, []);
async function saveCardPosition(card: CanvasCard) {
try {
await fetch(`/api/canvases/${canvas.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cards: cards.map((c) =>
c.id === card.id
? { ...c, x: card.x, y: card.y }
: c
),
}),
});
} catch (err) {
console.error('Failed to save card position:', err);
}
}
async function addCard() {
const newCard: CanvasCard = {
id: crypto.randomUUID(),
canvas_id: canvas.id,
type: 'note',
title: 'New note',
content: '',
x: 50 + Math.random() * 200,
y: 50 + Math.random() * 200,
width: 200,
height: 150,
rotation: 0,
z_index: cards.length,
created: new Date().toISOString(),
updated: new Date().toISOString(),
};
const updatedCards = [...cards, newCard];
setCards(updatedCards);
try {
await fetch(`/api/canvases/${canvas.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cards: updatedCards }),
});
toast.success('Card added');
} catch (err) {
console.error('Failed to add card:', err);
toast.error('Failed to add card');
}
}
async function deleteCard(cardId: string) {
const updatedCards = cards.filter((c) => c.id !== cardId);
setCards(updatedCards);
try {
await fetch(`/api/canvases/${canvas.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cards: updatedCards }),
});
toast.success('Card removed');
} catch (err) {
console.error('Failed to delete card:', err);
toast.error('Failed to delete card');
}
}
async function saveCardEdit() {
if (!editingCard) return;
const updatedCards = cards.map((c) =>
c.id === editingCard.id
? { ...c, title: editTitle, content: editContent }
: c
);
setCards(updatedCards);
setEditingCard(null);
try {
await fetch(`/api/canvases/${canvas.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cards: updatedCards }),
});
} catch (err) {
console.error('Failed to save card:', err);
}
}
function openEdit(card: CanvasCard) {
setEditingCard(card);
setEditTitle(card.title || '');
setEditContent(card.content || '');
}
return (
<div className="flex h-full flex-col">
{/* Toolbar */}
<div className="flex items-center justify-between border-b px-4 py-2">
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={onBack}>
<X className="mr-1 h-4 w-4" />
Back
</Button>
<h2 className="text-lg font-semibold">{canvas.name}</h2>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setViewport((v) => ({ ...v, zoom: Math.max(0.25, v.zoom - 0.1) }))}
aria-label="Zoom out"
>
<ZoomOut className="h-4 w-4" />
</Button>
<span className="min-w-[3rem] text-center text-xs text-muted-foreground">
{Math.round(viewport.zoom * 100)}%
</span>
<Button
variant="outline"
size="sm"
onClick={() => setViewport((v) => ({ ...v, zoom: Math.min(3, v.zoom + 0.1) }))}
aria-label="Zoom in"
>
<ZoomIn className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setViewport({ x: 0, y: 0, zoom: 1 })}
aria-label="Reset view"
>
<Maximize2 className="h-4 w-4" />
</Button>
<Button variant="default" size="sm" onClick={addCard}>
<Plus className="mr-1 h-4 w-4" />
Add card
</Button>
</div>
</div>
{/* Board */}
<div
ref={boardRef}
className="relative flex-1 overflow-hidden bg-muted/30"
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
style={{ cursor: dragging ? 'grabbing' : 'default' }}
>
<div
className="absolute"
style={{
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`,
transformOrigin: '0 0',
}}
>
{/* Connections */}
<svg className="pointer-events-none absolute inset-0" style={{ width: 4000, height: 4000 }}>
{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;
return (
<line
key={conn.id}
x1={source.x + source.width / 2}
y1={source.y + source.height / 2}
x2={target.x + target.width / 2}
y2={target.y + target.height / 2}
stroke="hsl(var(--muted-foreground))"
strokeWidth={2}
strokeDasharray={conn.style === 'dashed' ? '6,3' : conn.style === 'dotted' ? '2,2' : undefined}
opacity={0.4}
/>
);
})}
</svg>
{/* Cards */}
{cards.map((card) => (
<div
key={card.id}
className="absolute rounded-lg border bg-card shadow-sm transition-shadow hover:shadow-md"
style={{
left: card.x,
top: card.y,
width: card.width,
height: card.height,
zIndex: dragging === card.id ? 999 : card.z_index,
transform: `rotate(${card.rotation}deg)`,
}}
>
{/* Drag handle */}
<div
className="flex cursor-grab items-center gap-1 border-b bg-muted/30 px-2 py-1 rounded-t-lg"
onPointerDown={(e) => handlePointerDown(e, card.id)}
style={{ touchAction: 'none' }}
>
<GripVertical className="h-3 w-3 text-muted-foreground" />
<span className="flex-1 truncate text-xs font-medium">
{card.title || 'Untitled'}
</span>
<button
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
onClick={() => openEdit(card)}
aria-label="Edit card"
>
<span className="text-xs">Edit</span>
</button>
<button
className="rounded p-0.5 text-muted-foreground hover:text-destructive"
onClick={() => deleteCard(card.id)}
aria-label="Delete card"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
{/* Content */}
<div className="overflow-auto p-2 text-xs text-muted-foreground" style={{ height: 'calc(100% - 28px)' }}>
{card.content || 'No content'}
</div>
</div>
))}
{cards.length === 0 && (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-center">
<LayoutGrid className="mx-auto h-8 w-8 text-muted-foreground" />
<p className="mt-2 text-sm text-muted-foreground">No cards yet</p>
<Button className="mt-2" size="sm" onClick={addCard}>
<Plus className="mr-1 h-4 w-4" />
Add your first card
</Button>
</div>
)}
</div>
</div>
{/* Edit dialog */}
<Dialog open={!!editingCard} onOpenChange={(open) => !open && setEditingCard(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit card</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">Title</label>
<Input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
placeholder="Card title"
/>
</div>
<div>
<label className="text-sm font-medium">Content</label>
<Textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
placeholder="Card content"
rows={4}
/>
</div>
<Button onClick={saveCardEdit}>Save</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}
export default function CanvasPage() {
const [canvases, setCanvases] = useState<Canvas[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeCanvas, setActiveCanvas] = useState<Canvas | null>(null);
const [creating, setCreating] = useState(false);
const fetchCanvases = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/canvases?sort=-updated');
if (!res.ok) throw new Error('Unable to load canvases.');
const data = await res.json();
setCanvases(data.items || []);
} catch (err) {
console.error('Failed to fetch canvases:', err);
setError('Unable to load canvases. Please try again.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchCanvases();
}, [fetchCanvases]);
async function createCanvas() {
setCreating(true);
try {
const res = await fetch('/api/canvases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'New canvas',
mode: 'freeform',
}),
});
if (!res.ok) throw new Error('Unable to create canvas.');
const canvas = await res.json();
setCanvases((prev) => [canvas, ...prev]);
setActiveCanvas(canvas);
toast.success('Canvas created');
} catch (err) {
console.error('Failed to create canvas:', err);
toast.error('Unable to create canvas');
} finally {
setCreating(false);
}
}
async function deleteCanvas(id: string) {
try {
await fetch(`/api/canvases/${id}`, { method: 'DELETE' });
setCanvases((prev) => prev.filter((c) => c.id !== id));
if (activeCanvas?.id === id) setActiveCanvas(null);
toast.success('Canvas deleted');
} catch (err) {
console.error('Failed to delete canvas:', err);
toast.error('Unable to delete canvas');
}
}
async function openCanvas(canvas: Canvas) {
try {
const res = await fetch(`/api/canvases/${canvas.id}`);
if (!res.ok) throw new Error('Unable to load canvas.');
const full = await res.json();
setActiveCanvas(full);
} catch (err) {
console.error('Failed to open canvas:', err);
toast.error('Unable to open canvas');
}
}
if (activeCanvas) {
return (
<div className="flex h-[calc(100vh-8rem)] flex-col">
<CanvasBoard canvas={activeCanvas} onBack={() => setActiveCanvas(null)} />
</div>
);
}
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Canvas</h1>
<p className="mt-1 text-muted-foreground">
Freeform boards for visual thinking.
</p>
</div>
<Button onClick={createCanvas} disabled={creating}>
<Plus className="mr-2 h-4 w-4" />
{creating ? 'Creating...' : 'New canvas'}
</Button>
</div>
{loading ? (
<p className="py-8 text-center text-muted-foreground">Loading canvases...</p>
) : error ? (
<div className="py-8 text-center">
<p className="text-sm text-red-600" role="alert">{error}</p>
<Button variant="outline" size="sm" className="mt-3" onClick={fetchCanvases}>
Retry
</Button>
</div>
) : canvases.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16">
<LayoutGrid className="h-12 w-12 text-muted-foreground" />
<p className="mt-4 text-lg font-medium">No canvases yet</p>
<p className="mt-1 text-sm text-muted-foreground">
Create a canvas to start visual brainstorming.
</p>
<Button className="mt-4" onClick={createCanvas} disabled={creating}>
<Plus className="mr-2 h-4 w-4" />
Create your first canvas
</Button>
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{canvases.map((canvas) => (
<Card
key={canvas.id}
className="group cursor-pointer p-4 transition-colors hover:bg-accent/50"
onClick={() => openCanvas(canvas)}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="truncate font-medium">{canvas.name}</h3>
{canvas.description && (
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">
{canvas.description}
</p>
)}
<p className="mt-2 text-xs text-muted-foreground">
{(canvas.cards || []).length} cards
</p>
</div>
<button
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
deleteCanvas(canvas.id);
}}
aria-label={`Delete ${canvas.name}`}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</Card>
))}
</div>
)}
</div>
);
}
@@ -1,205 +0,0 @@
'use client';
import { useState, useEffect, useCallback, Suspense } from 'react';
import { BookOpen, Plus, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
import dynamic from 'next/dynamic';
import { format, addDays, subDays } from 'date-fns';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
const NoteEditor = dynamic(
() => import('@/components/notes/note-editor').then((m) => m.NoteEditor),
{
ssr: false,
loading: () => (
<div className="flex h-full items-center justify-center">
<div className="animate-pulse text-sm text-muted-foreground">Loading editor...</div>
</div>
),
}
);
interface Note {
id: string;
title: string;
content: string | null;
createdAt: string;
updatedAt: string;
}
export default function DailyNotesPage() {
const [currentDate, setCurrentDate] = useState<Date>(new Date());
const [note, setNote] = useState<Note | null>(null);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [saveStatus, setSaveStatus] = useState<'Saved' | 'Saving' | 'Failed'>('Saved');
const dateStr = format(currentDate, 'yyyy-MM-dd');
const displayDate = format(currentDate, 'EEEE, MMMM d, yyyy');
const fetchDailyNote = useCallback(async () => {
setLoading(true);
setNote(null);
try {
const res = await fetch(`/api/notes/daily?date=${dateStr}`);
if (!res.ok) throw new Error('Failed to fetch daily note');
const data = await res.json();
if (data.note) {
setNote(data.note);
}
} catch (err) {
console.error('Failed to fetch daily note:', err);
} finally {
setLoading(false);
}
}, [dateStr]);
useEffect(() => {
fetchDailyNote();
}, [fetchDailyNote]);
async function handleCreateDailyNote() {
setCreating(true);
try {
const res = await fetch('/api/notes/daily', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date: dateStr }),
});
if (!res.ok) {
const text = await res.text();
console.error('Failed to create daily note', text);
toast.error('Failed to create daily note');
return;
}
const createdNote = await res.json();
setNote(createdNote);
toast.success('Daily note created');
} catch (err) {
console.error('Failed to create daily note:', err);
toast.error('Failed to create daily note');
} finally {
setCreating(false);
}
}
function navigate(delta: number) {
const next = delta > 0 ? addDays(currentDate, 1) : subDays(currentDate, 1);
setCurrentDate(next);
}
function goToToday() {
setCurrentDate(new Date());
}
async function handleSave(content: string) {
if (!note) return;
setSaveStatus('Saving');
try {
const res = await fetch(`/api/notes/${note.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
});
if (!res.ok) throw new Error('Failed to save');
setSaveStatus('Saved');
} catch (err) {
console.error('Failed to save note:', err);
setSaveStatus('Failed');
toast.error('Failed to save note');
}
}
const isToday = dateStr === format(new Date(), 'yyyy-MM-dd');
return (
<div>
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold">Daily Notes</h1>
<p className="mt-1 text-muted-foreground">
{displayDate}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(-1)}
aria-label="Previous day"
>
<ChevronLeft className="h-4 w-4" />
</Button>
{!isToday && (
<Button variant="outline" size="sm" onClick={goToToday}>
Today
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={() => navigate(1)}
aria-label="Next day"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
<Card className="min-h-[500px]">
{loading ? (
<div className="flex h-[500px] items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : note ? (
<div className="flex h-full flex-col">
<div className="border-b p-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{note.title}</h2>
<span className="text-xs text-muted-foreground" role="status">
{saveStatus}
</span>
</div>
</div>
<div className="flex-1 overflow-auto p-4">
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<div className="animate-pulse text-sm text-muted-foreground">
Loading editor...
</div>
</div>
}
>
<NoteEditor
content={note.content || ''}
onChange={(content) => {
setNote({ ...note, content });
handleSave(content);
}}
/>
</Suspense>
</div>
</div>
) : (
<div className="flex h-[500px] flex-col items-center justify-center gap-4">
<BookOpen className="h-12 w-12 text-muted-foreground" />
<div className="text-center">
<p className="text-lg font-medium">No daily note yet</p>
<p className="mt-1 text-sm text-muted-foreground">
{isToday
? 'Create your daily note to track what you accomplished today.'
: 'No daily note exists for this date.'}
</p>
</div>
<Button onClick={handleCreateDailyNote} disabled={creating}>
<Plus className="mr-2 h-4 w-4" />
{creating ? 'Creating...' : 'Create today\'s note'}
</Button>
</div>
)}
</Card>
</div>
);
}
-212
View File
@@ -1,212 +0,0 @@
'use client';
import React, { Suspense, useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { useDashboardStore } from '@/lib/stores/use-dashboard-store';
import { WidgetErrorBoundary } from '@/components/widget-error-boundary';
import { Button } from '@/components/ui/button';
import { Settings2, LayoutGrid } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
// Lazy load react-grid-layout (client-only, ~45KB)
const ResponsiveGridLayout = dynamic(
() => import('@/components/dashboard/responsive-grid-layout'),
{
ssr: false,
loading: () => (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-[200px] animate-pulse rounded-lg border bg-muted/30" />
))}
</div>
),
}
);
// Lazy load individual widgets
const TodayTasksWidget = dynamic(() => import('@/components/dashboard/widgets/today-tasks-widget').then((m) => m.TodayTasksWidget), { ssr: false, loading: () => <WidgetSkeleton /> });
const HabitChecklistWidget = dynamic(() => import('@/components/dashboard/widgets/habit-checklist-widget').then((m) => m.HabitChecklistWidget), { ssr: false, loading: () => <WidgetSkeleton /> });
const WeeklyStatsWidget = dynamic(() => import('@/components/dashboard/widgets/weekly-stats-widget').then((m) => m.WeeklyStatsWidget), { ssr: false, loading: () => <WidgetSkeleton /> });
const ProjectProgressWidget = dynamic(() => import('@/components/dashboard/widgets/project-progress-widget').then((m) => m.ProjectProgressWidget), { ssr: false, loading: () => <WidgetSkeleton /> });
const UpcomingCalendarWidget = dynamic(() => import('@/components/dashboard/widgets/upcoming-calendar-widget').then((m) => m.UpcomingCalendarWidget), { ssr: false, loading: () => <WidgetSkeleton /> });
const RecentNotesWidget = dynamic(() => import('@/components/dashboard/widgets/recent-notes-widget').then((m) => m.RecentNotesWidget), { ssr: false, loading: () => <WidgetSkeleton /> });
const ActivityFeedWidget = dynamic(() => import('@/components/dashboard/widgets/activity-feed-widget').then((m) => m.ActivityFeedWidget), { ssr: false, loading: () => <WidgetSkeleton /> });
const QuickCaptureWidget = dynamic(() => import('@/components/dashboard/widgets/quick-capture-widget').then((m) => m.QuickCaptureWidget), { ssr: false, loading: () => <WidgetSkeleton /> });
function WidgetSkeleton() {
return (
<div className="h-full animate-pulse rounded-lg border bg-muted/30 p-4">
<div className="mb-3 h-4 w-24 rounded bg-muted/50" />
<div className="space-y-2">
<div className="h-3 w-full rounded bg-muted/50" />
<div className="h-3 w-3/4 rounded bg-muted/50" />
<div className="h-3 w-1/2 rounded bg-muted/50" />
</div>
</div>
);
}
const widgetComponents: Record<string, React.ComponentType> = {
'today-tasks': TodayTasksWidget,
'habit-checklist': HabitChecklistWidget,
'weekly-stats': WeeklyStatsWidget,
'project-progress': ProjectProgressWidget,
'upcoming-calendar': UpcomingCalendarWidget,
'recent-notes': RecentNotesWidget,
'activity-feed': ActivityFeedWidget,
'quick-capture': QuickCaptureWidget,
};
const widgetLabels: Record<string, string> = {
'today-tasks': "Today's Tasks",
'habit-checklist': 'Habit Checklist',
'weekly-stats': 'Weekly Stats',
'project-progress': 'Project Progress',
'upcoming-calendar': 'Upcoming Calendar',
'recent-notes': 'Recent Notes',
'activity-feed': 'Activity Feed',
'quick-capture': 'Quick Capture',
};
function DashboardPage() {
const { widgets, setWidgets, addWidget, removeWidget } = useDashboardStore();
const [layoutAnnouncement, setLayoutAnnouncement] = React.useState('');
const [editMode, setEditMode] = React.useState(false);
const [showConfig, setShowConfig] = React.useState(false);
const router = useRouter();
const searchParams = useSearchParams();
const domainFilter = searchParams.get('domain');
const layout = widgets.map((w) => ({
i: w.id,
x: w.x,
y: w.y,
w: w.w,
h: w.h,
}));
function handleLayoutChange(newLayout: ReadonlyArray<{ i: string; x: number; y: number; w: number; h: number }>) {
const updated = widgets.map((w) => {
const layoutItem = newLayout.find((l) => l.i === w.id);
if (layoutItem) {
return { ...w, x: layoutItem.x, y: layoutItem.y, w: layoutItem.w, h: layoutItem.h };
}
return w;
});
setWidgets(updated);
}
const availableWidgets = Object.keys(widgetComponents).filter((id) => !widgets.find((w) => w.id === id));
function addNewWidget(widgetId: string) {
addWidget({
id: widgetId,
type: widgetLabels[widgetId] || widgetId,
x: 0,
y: widgets.length,
w: 4,
h: 3,
visible: true,
});
}
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="mt-1 text-muted-foreground">Your day, at a glance.{domainFilter ? " (Filtered: " + domainFilter + ")" : ""}</p>
</div>
<div className="flex items-center gap-2">
<Button
variant={editMode ? 'default' : 'outline'}
size="sm"
onClick={() => setEditMode(!editMode)}
>
<LayoutGrid className="mr-1 h-4 w-4" />
{editMode ? 'Done' : 'Edit'}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowConfig(!showConfig)}
>
<Settings2 className="mr-1 h-4 w-4" />
Configure
</Button>
</div>
</div>
{/* Widget configuration panel */}
{showConfig && (
<div className="mb-6 rounded-lg border bg-card p-4">
<h3 className="mb-3 text-sm font-semibold">Add Widgets</h3>
<div className="flex flex-wrap gap-2">
{availableWidgets.length === 0 ? (
<p className="text-sm text-muted-foreground">All widgets are already on your dashboard.</p>
) : (
availableWidgets.map((id) => (
<Button
key={id}
variant="outline"
size="sm"
onClick={() => addNewWidget(id)}
>
+ {widgetLabels[id] || id}
</Button>
))
)}
</div>
<div className="mt-4">
<h3 className="mb-3 text-sm font-semibold">Active Widgets</h3>
<div className="space-y-2">
{widgets.map((w) => (
<div key={w.id} className="flex items-center justify-between rounded-md bg-muted/50 px-3 py-2">
<span className="text-sm">{widgetLabels[w.id] || w.type}</span>
<Button
variant="ghost"
size="sm"
className="text-destructive"
onClick={() => removeWidget(w.id)}
>
Remove
</Button>
</div>
))}
</div>
</div>
</div>
)}
<p className="sr-only" aria-live="polite">{layoutAnnouncement}</p>
<ResponsiveGridLayout layout={layout} onLayoutChange={handleLayoutChange} isDraggable={editMode} isResizable={editMode}>
{widgets.map((widget) => {
const WidgetComponent = widgetComponents[widget.id];
if (!WidgetComponent) return null;
return (
<div key={widget.id}>
<WidgetErrorBoundary widgetName={widget.type}>
<div className="h-full rounded-lg border bg-card p-4 shadow-sm">
<div className={editMode ? 'widget-drag-handle' : ''}>
<Suspense fallback={<WidgetSkeleton />}>
<WidgetComponent />
</Suspense>
</div>
</div>
</WidgetErrorBoundary>
</div>
);
})}
</ResponsiveGridLayout>
</div>
);
}
export default function DashboardPageWrapper() {
return (
<Suspense fallback={<div className="py-12 text-center text-muted-foreground">Loading dashboard...</div>}>
<DashboardPage />
</Suspense>
);
}
-352
View File
@@ -1,352 +0,0 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Search, ZoomIn, ZoomOut, RotateCcw, Filter } from 'lucide-react';
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), {
ssr: false,
loading: () => (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">Loading graph...</p>
</div>
),
});
interface GraphNode {
id: string;
label: string;
type: string;
color: string;
}
interface GraphLink {
source: string;
target: string;
type: string;
}
interface GraphData {
nodes: GraphNode[];
links: GraphLink[];
}
const ENTITY_TYPE_COLORS: Record<string, string> = {
task: '#3b82f6',
habit: '#10b981',
project: '#8b5cf6',
note: '#f59e0b',
section: '#ec4899',
tag: '#6b7280',
domain: '#6366f1',
};
const ENTITY_TYPE_LABELS: Record<string, string> = {
task: 'Tasks',
habit: 'Habits',
project: 'Projects',
note: 'Notes',
section: 'Sections',
tag: 'Tags',
domain: 'Domains',
};
export default function GraphPage() {
const router = useRouter();
const [graphData, setGraphData] = useState<GraphData>({ nodes: [], links: [] });
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [domainId, setDomainId] = useState<string | null>(null);
const [domains, setDomains] = useState<{ id: string; name: string }[]>([]);
const [filterTypes, setFilterTypes] = useState<Set<string>>(new Set(['task', 'habit', 'project', 'note', 'section', 'tag', 'domain']));
const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const graphRef = useRef<any>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
// Fetch domains
useEffect(() => {
fetch('/api/domains?sort=sort_order')
.then((res) => res.json())
.then((data) => {
const items = data.items || [];
setDomains(items);
if (items.length > 0 && !domainId) {
setDomainId(items[0].id);
}
})
.catch(() => {});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Fetch graph data
useEffect(() => {
if (domainId) {
fetchGraphData();
}
}, [domainId]); // eslint-disable-line react-hooks/exhaustive-deps
// Update dimensions on resize
useEffect(() => {
function handleResize() {
if (containerRef.current) {
const { width, height } = containerRef.current.getBoundingClientRect();
setDimensions({ width: Math.floor(width) || 800, height: Math.floor(height) || 600 });
}
}
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
async function fetchGraphData() {
if (!domainId) return;
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/domains/${domainId}/graph`);
if (!response.ok) throw new Error('Unable to load graph data.');
const data = await response.json();
// Convert edges to links for react-force-graph-2d
setGraphData({
nodes: data.nodes || [],
links: (data.edges || []).map((e: any) => ({
source: e.source,
target: e.target,
type: e.type,
})),
});
} catch (error) {
console.error('Failed to fetch graph data:', error);
setError('Unable to load graph data.');
} finally {
setLoading(false);
}
}
const toggleFilterType = useCallback((type: string) => {
setFilterTypes((prev) => {
const next = new Set(prev);
if (next.has(type)) next.delete(type);
else next.add(type);
return next;
});
}, []);
// Filter nodes and links
const filteredData = {
nodes: graphData.nodes.filter(
(n) => filterTypes.has(n.type) && (!searchQuery || n.label.toLowerCase().includes(searchQuery.toLowerCase()))
),
links: graphData.links.filter(
(l) => {
const sourceNode = graphData.nodes.find((n) => n.id === l.source);
const targetNode = graphData.nodes.find((n) => n.id === l.target);
return sourceNode && targetNode && filterTypes.has(sourceNode.type) && filterTypes.has(targetNode.type);
}
),
};
function handleNodeClick(node: any) {
const n = node as GraphNode;
switch (n.type) {
case 'task':
router.push(`/tasks?taskId=${n.id}`);
break;
case 'habit':
router.push(`/habits?habitId=${n.id}`);
break;
case 'project':
router.push(`/projects/${n.id}`);
break;
case 'note':
router.push(`/notes?noteId=${n.id}`);
break;
case 'domain':
router.push(`/dashboard?domainId=${n.id}`);
break;
default:
break;
}
}
function handleZoomIn() {
if (graphRef.current) {
const current = graphRef.current.zoom();
graphRef.current.zoom(current * 1.3, 400);
}
}
function handleZoomOut() {
if (graphRef.current) {
const current = graphRef.current.zoom();
graphRef.current.zoom(current / 1.3, 400);
}
}
function handleReset() {
if (graphRef.current) {
graphRef.current.zoomToFit(400);
}
}
if (loading && graphData.nodes.length === 0) {
return <p className="text-muted-foreground" role="status">Loading graph...</p>;
}
if (error) {
return <div className="space-y-3"><p className="text-muted-foreground" role="alert">{error}</p><Button onClick={fetchGraphData}>Retry</Button></div>;
}
return (
<div className="flex h-[calc(100vh-120px)] flex-col">
<div className="mb-4 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Graph</h1>
<p className="mt-1 text-muted-foreground">
Visualize connections between all your entities
</p>
</div>
<div className="flex items-center gap-2">
{domains.length > 1 && (
<select
value={domainId || ''}
onChange={(e) => setDomainId(e.target.value)}
className="rounded-md border bg-background px-3 py-1.5 text-sm"
aria-label="Select domain"
>
{domains.map((d) => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
)}
<Button variant="outline" size="icon" onClick={handleZoomIn} aria-label="Zoom in">
<ZoomIn className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={handleZoomOut} aria-label="Zoom out">
<ZoomOut className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={handleReset} aria-label="Reset view">
<RotateCcw className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex flex-1 gap-4">
{/* Filter sidebar */}
<Card className="w-56 shrink-0 p-4">
<div className="mb-3 flex items-center gap-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Filters</span>
</div>
<div className="mb-3">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search nodes..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-7 text-xs"
/>
</div>
</div>
<div className="space-y-1.5">
{Object.entries(ENTITY_TYPE_LABELS).map(([type, label]) => (
<button
key={type}
onClick={() => toggleFilterType(type)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors ${
filterTypes.has(type) ? 'bg-accent' : 'hover:bg-accent/50'
}`}
>
<span
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: ENTITY_TYPE_COLORS[type] }}
/>
<span className="flex-1 text-left">{label}</span>
<Badge variant="outline" className="text-[10px] px-1">
{graphData.nodes.filter((n) => n.type === type).length}
</Badge>
</button>
))}
</div>
<div className="mt-4 border-t pt-3">
<p className="text-xs text-muted-foreground">
{filteredData.nodes.length} nodes · {filteredData.links.length} edges
</p>
</div>
</Card>
{/* Graph canvas */}
<Card className="flex-1 overflow-hidden" ref={containerRef}>
{filteredData.nodes.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
{searchQuery ? 'No matching nodes found' : 'No graph data available'}
</p>
</div>
) : (
<div className="relative h-full w-full">
<ForceGraph2D
ref={graphRef}
graphData={filteredData}
nodeLabel="label"
nodeColor="color"
nodeVal={(node: any) => {
const edgeCount = filteredData.links.filter(
(e) => e.source === node.id || e.target === node.id
).length;
return Math.max(2, Math.min(edgeCount + 2, 20));
}}
linkColor={() => '#374151'}
linkWidth={0.5}
linkDirectionalArrowLength={4}
linkDirectionalArrowRelPos={0.99}
onNodeClick={(node: any) => handleNodeClick(node)}
onNodeHover={(node: any | null) => {
if (node) {
setHoveredNode(node as GraphNode);
} else {
setHoveredNode(null);
}
}}
width={dimensions.width}
height={dimensions.height}
d3AlphaDecay={0.02}
d3VelocityDecay={0.3}
cooldownTicks={100}
warmupTicks={40}
/>
{/* Tooltip */}
{hoveredNode && (
<div
className="pointer-events-none absolute left-4 top-4 z-10 rounded-lg border bg-background p-3 shadow-lg"
>
<div className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: hoveredNode.color }}
/>
<span className="text-sm font-medium">{hoveredNode.label}</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
Type: {ENTITY_TYPE_LABELS[hoveredNode.type] || hoveredNode.type}
</p>
<p className="text-xs text-muted-foreground">
ID: {hoveredNode.id.slice(0, 8)}...
</p>
</div>
)}
</div>
)}
</Card>
</div>
</div>
);
}
-333
View File
@@ -1,333 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Plus, CheckCircle2, Circle, MoreHorizontal, Flame, Filter, Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { HabitCreateDialog } from "@/components/habits/habit-create-dialog";
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 {
id: string;
name: string;
description: string | null;
domainId: string;
frequency: 'daily' | 'weekly' | 'custom';
difficulty: 'easy' | 'medium' | 'hard';
goalPerPeriod: number;
unit: string | null;
streakCount: number;
bestStreak: number;
active: boolean;
moodTracking: boolean;
tags: { id: string; name: string; color: string | null }[];
}
const difficultyColors: Record<string, string> = {
easy: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
medium: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
hard: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
};
export default function HabitsPage() {
const [habits, setHabits] = useState<Habit[]>([]);
const [domainId, setDomainId] = useState<string | null>(null);
const [domains, setDomains] = useState<{ id: string; name: string }[]>([]);
const [createOpen, setCreateOpen] = useState(false);
const { open: storeOpen, openCreate, closeCreate } = useCreateDialogStore();
const [editHabit, setEditHabit] = useState<Habit | null>(null);
const [completionHabit, setCompletionHabit] = useState<Habit | null>(null);
const [deleteHabit, setDeleteHabit] = useState<Habit | null>(null);
const [deleting, setDeleting] = useState(false);
const [expandedHabit, setExpandedHabit] = useState<string | null>(null);
const [filter, setFilter] = useState<string>('all');
const [loading, setLoading] = useState(true);
// Fetch domains
useEffect(() => {
fetch('/api/domains?sort=sort_order')
.then((res) => res.json())
.then((data) => {
const items = data.items || [];
setDomains(items);
if (items.length > 0 && !domainId) {
setDomainId(items[0].id);
}
})
.catch(() => {});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Fetch habits
const fetchHabits = useCallback(async () => {
if (!domainId) return;
setLoading(true);
try {
const params = new URLSearchParams();
if (filter === 'active') params.set('active', 'true');
const res = await fetch(`/api/domains/${domainId}/habits?${params}`);
const data = await res.json();
setHabits(data.items || []);
} catch {
toast.error('Failed to load habits');
} finally {
setLoading(false);
}
}, [domainId, filter]);
useEffect(() => {
fetchHabits();
}, [fetchHabits]);
// Complete a habit
const handleComplete = async (habit: Habit, value?: number, mood?: number, notes?: string) => {
try {
const res = await fetch(`/api/domains/${domainId}/habits/${habit.id}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: value ?? 1, mood, notes }),
});
if (!res.ok) throw new Error('Failed to complete');
toast.success(`"${habit.name}" logged!`);
fetchHabits();
} catch {
toast.error('Failed to complete habit');
}
};
// Listen for custom event to open create dialog
useEffect(() => {
const handler = () => setCreateOpen(true);
document.addEventListener('open-create-habit', handler);
return () => document.removeEventListener('open-create-habit', handler);
}, []);
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Habits</h1>
<p className="mt-1 text-muted-foreground">Build streaks, track progress, stay consistent.</p>
</div>
<div className="flex items-center gap-2">
{domains.length > 1 && (
<select
value={domainId || ''}
onChange={(e) => setDomainId(e.target.value)}
className="rounded-md border bg-background px-3 py-1.5 text-sm"
aria-label="Select domain"
>
{domains.map((d) => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
)}
<div className="flex items-center gap-1 rounded-md border p-1">
<button
onClick={() => setFilter('all')}
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${filter === 'all' ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
All
</button>
<button
onClick={() => setFilter('active')}
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${filter === 'active' ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
Active
</button>
</div>
<Button onClick={() => { setCreateOpen(true); openCreate('habit'); }}>
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
New habit
</Button>
</div>
</div>
{loading ? (
<div className="py-12 text-center text-muted-foreground">Loading habits...</div>
) : habits.length === 0 ? (
<div className="py-12 text-center">
<Flame className="mx-auto h-12 w-12 text-muted-foreground/50" aria-hidden="true" />
<p className="mt-4 text-muted-foreground">No habits yet. Create your first one!</p>
</div>
) : (
<div className="space-y-2">
{habits.map((habit) => (
<div key={habit.id} className="rounded-lg border bg-card">
<div className="flex items-center gap-3 px-4 py-3">
<button
onClick={() => handleComplete(habit)}
className="shrink-0 text-muted-foreground hover:text-primary transition-colors"
aria-label={`Complete ${habit.name}`}
>
<Circle className="h-5 w-5" />
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{habit.name}</span>
<Badge variant="secondary" className={`text-xs ${difficultyColors[habit.difficulty] || ''}`}>
{habit.difficulty}
</Badge>
{habit.unit && (
<span className="text-xs text-muted-foreground">per {habit.unit}</span>
)}
</div>
{habit.tags.length > 0 && (
<div className="flex gap-1 mt-1">
{habit.tags.map((tag) => (
<span
key={tag.id}
className="inline-flex items-center rounded-full px-2 py-0.5 text-xs"
style={{ backgroundColor: tag.color || '#e2e8f0', color: '#1e293b' }}
>
{tag.name}
</span>
))}
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="flex items-center gap-1 text-sm" title="Current streak">
<Flame className="h-4 w-4 text-orange-500" aria-hidden="true" />
<span className="font-semibold">{habit.streakCount}</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="rounded-md p-1 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
aria-label={`Options for ${habit.name}`}
>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditHabit(habit)}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteHabit(habit)}>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
onClick={() => setExpandedHabit(expandedHabit === habit.id ? null : habit.id)}
className="rounded-md p-1 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
aria-label={expandedHabit === habit.id ? 'Collapse' : 'Expand'}
>
<Filter className="h-4 w-4" />
</button>
</div>
</div>
{expandedHabit === habit.id && (
<div className="border-t px-4 py-3">
<HabitCalendarHeatmap habitId={habit.id} domainId={domainId!} />
</div>
)}
</div>
))}
</div>
)}
{/* Analytics */}
<div className="mt-4">
<HabitAnalytics domainId={domainId || ""} habits={habits} />
</div>
<HabitCreateDialog
open={createOpen}
onOpenChange={(o) => { setCreateOpen(o); if (!o) closeCreate(); }}
domainId={domainId || ''}
onCreated={fetchHabits}
/>
{/* Global CreateItemDialog from useCreateDialogStore — opened by topbar 'New habit' button */}
<CreateItemDialog
type="habit"
open={storeOpen}
onOpenChange={(o) => { if (!o) closeCreate(); else openCreate('habit'); }}
onCreated={fetchHabits}
/>
{editHabit && (
<HabitEditDialog
open={!!editHabit}
onOpenChange={(open) => { if (!open) setEditHabit(null); }}
habit={editHabit}
domainId={domainId || ''}
onUpdated={fetchHabits}
/>
)}
{completionHabit && (
<HabitCompletionDialog
open={!!completionHabit}
onOpenChange={(open) => { if (!open) setCompletionHabit(null); }}
habit={completionHabit}
onComplete={(value, mood, notes) => {
handleComplete(completionHabit, value, mood, notes);
setCompletionHabit(null);
}}
/>
)}
<AlertDialog open={!!deleteHabit} onOpenChange={(open) => { if (!open) setDeleteHabit(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Habit</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{deleteHabit?.name}&quot;? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={deleting}
onClick={async () => {
if (!deleteHabit || !domainId) return;
setDeleting(true);
try {
const res = await fetch(`/api/domains/${domainId}/habits/${deleteHabit.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error('Failed to delete');
toast.success('Habit deleted');
setDeleteHabit(null);
fetchHabits();
} catch {
toast.error('Failed to delete habit');
} finally {
setDeleting(false);
}
}}
>
{deleting ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
-40
View File
@@ -1,40 +0,0 @@
import { Sidebar } from '@/components/sidebar';
import { TopBar } from '@/components/topbar';
import { NetworkErrorBanner } from '@/components/network-error-banner';
import { KeyboardShortcutsProvider } from '@/components/keyboard-shortcuts-provider';
import { WebVitalsTracker } from '@/components/web-vitals-tracker';
import { MobileBottomNav } from '@/components/mobile-bottom-nav';
import { DispatchPanel } from '@/components/agents/dispatch-panel';
import { OnboardingFlow } from '@/components/onboarding/onboarding-flow';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<KeyboardShortcutsProvider>
<WebVitalsTracker />
<a href="#main-content" className="skip-link">
Skip to main content
</a>
<div className="flex min-h-screen">
<NetworkErrorBanner />
<Sidebar />
<div className="flex flex-1 flex-col pb-16 md:pb-0">
<TopBar />
<main id="main-content" className="flex-1 overflow-auto p-4 md:p-6" tabIndex={-1}>
{children}
</main>
</div>
</div>
<MobileBottomNav />
{/* Floating AI dispatch button */}
<div className="fixed bottom-6 right-6 z-50">
<DispatchPanel
triggerLabel="Ask AI"
triggerVariant="default"
triggerClassName="h-12 w-12 rounded-full shadow-lg md:h-auto md:w-auto md:rounded-md md:px-4 md:py-2"
/>
</div>
<OnboardingFlow />
<div className="sr-only" aria-live="polite" aria-atomic="true" id="a11y-announcer" />
</KeyboardShortcutsProvider>
);
}
-652
View File
@@ -1,652 +0,0 @@
'use client';
import { useState, useEffect, useRef, useCallback, Suspense } from 'react';
import { Plus, FileText, Link2, GitBranch, Trash2, Pin, Archive, Search, PinOff, ArchiveRestore } from 'lucide-react';
import dynamic from 'next/dynamic';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { NoteTemplates } from '@/components/notes/note-templates';
import { Card } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
// Lazy load TipTap editor
const NoteEditor = dynamic(
() => import('@/components/notes/note-editor').then((m) => m.NoteEditor),
{
ssr: false,
loading: () => (
<div className="flex h-full items-center justify-center">
<div className="animate-pulse text-sm text-muted-foreground">Loading editor...</div>
</div>
),
}
);
interface Note {
id: string;
title: string;
content: string | null;
domainId: string;
isPinned: boolean;
isArchived: boolean;
createdAt: string;
updatedAt: string;
tags?: { id: string; name: string; color: string | null }[];
}
interface BacklinkItem {
id: string;
title: string;
excerpt: string;
}
interface OutgoingLink {
noteLinks: { id: string; title: string }[];
entityLinks: { entityType: string; entityId: string; title: string | null }[];
}
export default function NotesPage() {
const [notes, setNotes] = useState<Note[]>([]);
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
const [domainId, setDomainId] = useState<string | null>(null);
const [domains, setDomains] = useState<{ id: string; name: string; color: string | null }[]>([]);
const [backlinks, setBacklinks] = useState<BacklinkItem[]>([]);
const [outgoingLinks, setOutgoingLinks] = useState<OutgoingLink | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saveStatus, setSaveStatus] = useState<'Saved' | 'Saving' | 'Failed'>('Saved');
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
const [deleting, setDeleting] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [filterMode, setFilterMode] = useState<'all' | 'pinned' | 'archived'>('all');
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const saveVersion = useRef(0);
const pendingSave = useRef<{ id: string; updates: Partial<Note> } | null>(null);
// Fetch domains on mount
useEffect(() => {
fetch('/api/domains?sort=sort_order')
.then((res) => res.json())
.then((data) => {
const items = data.items || [];
setDomains(items);
if (items.length > 0 && !domainId) {
setDomainId(items[0].id);
}
})
.catch(() => {});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Fetch notes when domain or filter changes
useEffect(() => {
if (domainId) {
fetchNotes();
}
}, [domainId, filterMode]); // eslint-disable-line react-hooks/exhaustive-deps
// Fetch backlinks when selected note changes
useEffect(() => {
if (selectedNote) {
fetchBacklinks(selectedNote.id);
fetchOutgoingLinks(selectedNote.id);
}
}, [selectedNote]); // eslint-disable-line react-hooks/exhaustive-deps
async function fetchNotes() {
if (!domainId) return;
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (filterMode === 'pinned') params.set('pinned', 'true');
else if (filterMode === 'archived') params.set('archived', 'true');
else params.set('archived', 'false');
if (searchQuery) params.set('search', searchQuery);
params.set('sort', 'updated_at');
params.set('order', 'desc');
const response = await fetch(`/api/domains/${domainId}/notes?${params}`);
if (!response.ok) throw new Error('Unable to load notes.');
const data = await response.json();
const notesList = data.items || [];
setNotes(notesList);
if (!selectedNote && notesList.length > 0) {
setSelectedNote(notesList[0]);
}
} catch (error) {
console.error('Failed to fetch notes:', error);
setError('Unable to load notes. Please try again.');
} finally {
setLoading(false);
}
}
async function fetchBacklinks(noteId: string) {
if (!domainId) return;
try {
const response = await fetch(`/api/domains/${domainId}/notes/${noteId}/backlinks`);
if (!response.ok) throw new Error('Unable to load backlinks.');
const data = await response.json();
setBacklinks(data.items || []);
} catch (error) {
console.error('Failed to fetch backlinks:', error);
setBacklinks([]);
}
}
async function fetchOutgoingLinks(noteId: string) {
if (!domainId) return;
try {
const response = await fetch(`/api/domains/${domainId}/notes/${noteId}`);
if (!response.ok) throw new Error('Unable to load note.');
const data = await response.json();
setOutgoingLinks(data.outgoingLinks || null);
} catch (error) {
console.error('Failed to fetch outgoing links:', error);
setOutgoingLinks(null);
}
}
async function createNoteWithContent(content: string) {
if (!domainId) return;
try {
const response = await fetch("/api/domains/" + domainId + "/notes", {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'Untitled note',
content,
}),
});
if (!response.ok) throw new Error('Unable to create note.');
const newNote = await response.json();
setNotes((current) => [newNote, ...current]);
setSelectedNote(newNote);
toast.success('Note created from template');
} catch (error) {
console.error('Failed to create note:', error);
toast.error('Unable to create note');
}
}
async function createNote() {
if (!domainId) return;
try {
const response = await fetch(`/api/domains/${domainId}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'Untitled note',
content: '',
}),
});
if (!response.ok) throw new Error('Unable to create note.');
const newNote = await response.json();
setNotes((current) => [newNote, ...current]);
setSelectedNote(newNote);
toast.success('Note created');
} catch (error) {
console.error('Failed to create note:', error);
toast.error('Unable to create note');
}
}
function scheduleSave(noteId: string, updates: Partial<Note>) {
const version = ++saveVersion.current;
pendingSave.current = {
id: noteId,
updates: { ...(pendingSave.current?.id === noteId ? pendingSave.current.updates : {}), ...updates },
};
if (saveTimer.current) clearTimeout(saveTimer.current);
setSaveStatus('Saving');
saveTimer.current = setTimeout(() => {
const save = pendingSave.current;
pendingSave.current = null;
if (save) updateNote(save.id, save.updates, version);
}, 700);
}
async function updateNote(noteId: string, updates: Partial<Note>, version: number) {
if (!domainId) return;
try {
const response = await fetch(`/api/domains/${domainId}/notes/${noteId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
if (!response.ok) throw new Error('Unable to save note.');
const updated = await response.json();
setNotes((current) => current.map((note) => (note.id === noteId ? { ...note, ...updated } : note)));
if (saveVersion.current === version) setSaveStatus('Saved');
} catch (error) {
console.error('Failed to update note:', error);
if (saveVersion.current === version) setSaveStatus('Failed');
toast.error('Unable to save note');
}
}
async function deleteNote() {
if (!noteToDelete || !domainId) return;
if (pendingSave.current?.id === noteToDelete.id && saveTimer.current) {
clearTimeout(saveTimer.current);
pendingSave.current = null;
}
++saveVersion.current;
setDeleting(true);
const deletedNote = { ...noteToDelete };
try {
const response = await fetch(`/api/domains/${domainId}/notes/${noteToDelete.id}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Unable to delete note.');
setNotes((current) => current.filter((note) => note.id !== noteToDelete.id));
setSelectedNote((selected) =>
selected?.id === noteToDelete.id ? notes.find((note) => note.id !== noteToDelete.id) || null : selected
);
setNoteToDelete(null);
toast.success('Note deleted', {
action: {
label: 'Undo',
onClick: async () => {
try {
const res = await fetch(`/api/domains/${domainId}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: deletedNote.title,
content: deletedNote.content,
}),
});
if (!res.ok) throw new Error();
const restored = await res.json();
setNotes((current) => [restored, ...current]);
setSelectedNote(restored);
toast.success('Note restored');
} catch {
toast.error('Unable to restore note');
}
},
},
});
} catch (error) {
console.error('Failed to delete note:', error);
toast.error('Unable to delete note');
} finally {
setDeleting(false);
}
}
async function togglePinned(note: Note) {
const newPinned = !note.isPinned;
setSelectedNote((prev) => prev?.id === note.id ? { ...prev, isPinned: newPinned } : prev);
setNotes((current) => current.map((n) => n.id === note.id ? { ...n, isPinned: newPinned } : n));
await updateNote(note.id, { isPinned: newPinned }, ++saveVersion.current);
toast.success(newPinned ? 'Note pinned' : 'Note unpinned');
}
async function toggleArchived(note: Note) {
const newArchived = !note.isArchived;
setSelectedNote((prev) => prev?.id === note.id ? { ...prev, isArchived: newArchived } : prev);
setNotes((current) => current.map((n) => n.id === note.id ? { ...n, isArchived: newArchived } : n));
await updateNote(note.id, { isArchived: newArchived }, ++saveVersion.current);
toast.success(newArchived ? 'Note archived' : 'Note restored');
}
async function openBacklink(link: BacklinkItem) {
const existing = notes.find((note) => note.id === link.id);
if (existing) return setSelectedNote(existing);
if (!domainId) return;
try {
const response = await fetch(`/api/domains/${domainId}/notes/${link.id}`);
if (!response.ok) throw new Error('Unable to load linked note.');
const note = await response.json() as Note;
setNotes((current) => [note, ...current]);
setSelectedNote(note);
} catch (error) {
console.error('Failed to fetch linked note:', error);
toast.error('Unable to open linked note');
}
}
const handleRefresh = useCallback(() => {
fetchNotes();
}, [domainId, filterMode, searchQuery]); // eslint-disable-line react-hooks/exhaustive-deps
// Filter notes locally for search
const filteredNotes = notes.filter((note) => {
if (searchQuery && !note.title.toLowerCase().includes(searchQuery.toLowerCase())) return false;
return true;
});
if (loading && notes.length === 0) {
return <p className="text-muted-foreground" role="status">Loading notes...</p>;
}
if (error && notes.length === 0) {
return <div className="space-y-3"><p className="text-muted-foreground" role="alert">{error}</p><Button onClick={fetchNotes}>Retry</Button></div>;
}
return (
<div>
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold">Notes</h1>
<p className="mt-1 text-muted-foreground">
Connect ideas to the work they shape.
</p>
</div>
<div className="flex items-center gap-3">
{domains.length > 1 && (
<select
value={domainId || ''}
onChange={(e) => setDomainId(e.target.value)}
className="rounded-md border bg-background px-3 py-1.5 text-sm"
aria-label="Select domain"
>
{domains.map((d) => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
)}
<NoteTemplates onCreateFromTemplate={(content) => {
createNoteWithContent(content);
}} />
<Button onClick={createNote}>
<Plus className="mr-2 h-4 w-4" />
New note
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[280px_1fr_320px]">
{/* Notes list */}
<Card className="max-h-80 lg:h-[calc(100vh-200px)] lg:max-h-none">
<div className="border-b p-3">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search notes..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
<div className="mt-2 flex gap-1">
<Button
variant={filterMode === 'all' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 text-xs"
onClick={() => setFilterMode('all')}
>
All
</Button>
<Button
variant={filterMode === 'pinned' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 text-xs"
onClick={() => setFilterMode('pinned')}
>
<Pin className="mr-1 h-3 w-3" />
Pinned
</Button>
<Button
variant={filterMode === 'archived' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 text-xs"
onClick={() => setFilterMode('archived')}
>
<Archive className="mr-1 h-3 w-3" />
Archived
</Button>
</div>
</div>
<ScrollArea className="max-h-80 lg:h-[calc(100vh-260px)] lg:max-h-none">
<div className="p-2">
{filteredNotes.length === 0 ? (
<div className="py-8 text-center">
<p className="text-sm text-muted-foreground">No notes found</p>
<Button className="mt-3" size="sm" onClick={createNote}>
<Plus className="mr-2 h-4 w-4" />
Create your first note
</Button>
</div>
) : (
<div className="space-y-1">
{filteredNotes.map((note) => (
<button
key={note.id}
onClick={() => setSelectedNote(note)}
aria-label={`Open note: ${note.title}`}
aria-current={selectedNote?.id === note.id ? 'true' : undefined}
className={`w-full rounded-lg p-3 text-left transition-colors ${
selectedNote?.id === note.id
? 'bg-accent'
: 'hover:bg-accent/50'
}`}
>
<div className="flex items-start gap-2">
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<p className="truncate text-sm font-medium">
{note.title}
</p>
{note.isPinned && <Pin className="h-3 w-3 shrink-0 text-amber-500" />}
{note.isArchived && <Archive className="h-3 w-3 shrink-0 text-muted-foreground" />}
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">
{new Date(note.updatedAt).toLocaleDateString()}
</p>
{note.tags && note.tags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{note.tags.slice(0, 3).map((tag) => (
<Badge key={tag.id} variant="outline" className="text-[10px] px-1.5 py-0">
{tag.name}
</Badge>
))}
</div>
)}
</div>
</div>
</button>
))}
</div>
)}
</div>
</ScrollArea>
</Card>
{/* Note editor */}
<Card className="min-h-[420px] lg:h-[calc(100vh-200px)]">
{selectedNote ? (
<div className="flex h-full flex-col">
<div className="border-b p-4">
<label htmlFor="note-title" className="sr-only">
Note title
</label>
<div className="flex items-center gap-3">
<input
id="note-title"
type="text"
value={selectedNote.title}
onChange={(e) => {
const title = e.target.value;
setSelectedNote({ ...selectedNote, title });
scheduleSave(selectedNote.id, { title });
}}
className="min-w-0 flex-1 text-xl font-semibold outline-none"
placeholder="Note title"
/>
<span className="text-xs text-muted-foreground" role="status">{saveStatus}</span>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => togglePinned(selectedNote)}
aria-label={selectedNote.isPinned ? 'Unpin note' : 'Pin note'}
>
<Pin className={`h-4 w-4 ${selectedNote.isPinned ? 'fill-amber-500 text-amber-500' : ''}`} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => toggleArchived(selectedNote)}
aria-label={selectedNote.isArchived ? 'Restore note' : 'Archive note'}
>
{selectedNote.isArchived ? <ArchiveRestore className="h-4 w-4" /> : <Archive className="h-4 w-4" />}
</Button>
<AlertDialog open={noteToDelete?.id === selectedNote.id} onOpenChange={(open) => !open && setNoteToDelete(null)}>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" aria-label={`Delete ${selectedNote.title}`} onClick={() => setNoteToDelete(selectedNote)}>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader><AlertDialogTitle>Delete {noteToDelete?.title}?</AlertDialogTitle><AlertDialogDescription>This permanently deletes this note.</AlertDialogDescription></AlertDialogHeader>
<AlertDialogFooter><AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel><AlertDialogAction onClick={deleteNote} disabled={deleting}>{deleting ? 'Deleting...' : 'Delete note'}</AlertDialogAction></AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<div className="flex-1 overflow-auto p-4">
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<div className="animate-pulse text-sm text-muted-foreground">
Loading editor...
</div>
</div>
}
>
<NoteEditor
content={selectedNote.content || ''}
onChange={(content) => {
setSelectedNote({ ...selectedNote, content });
scheduleSave(selectedNote.id, { content });
}}
/>
</Suspense>
</div>
</div>
) : (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground">
Select a note or create a new one
</p>
</div>
)}
</Card>
{/* Backlinks and outgoing links panel */}
<Card className="min-h-[360px] lg:h-[calc(100vh-200px)]">
<Tabs defaultValue="backlinks" className="h-full">
<div className="border-b p-2">
<TabsList className="w-full">
<TabsTrigger value="backlinks" className="flex-1 gap-2">
<Link2 className="h-3 w-3" aria-hidden="true" />
Backlinks ({backlinks.length})
</TabsTrigger>
<TabsTrigger value="outgoing" className="flex-1 gap-2">
<GitBranch className="h-3 w-3" aria-hidden="true" />
Links
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="backlinks" className="h-full overflow-auto p-4">
{backlinks.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No backlinks no other notes link to this one
</p>
) : (
<div className="space-y-2">
{backlinks.map((link) => (
<button
key={link.id}
onClick={() => openBacklink(link)}
aria-label={`Open linked note: ${link.title}`}
className="w-full rounded-lg border p-3 text-left transition-colors hover:bg-accent"
>
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
<span className="text-sm font-medium">{link.title}</span>
</div>
{link.excerpt && (
<p className="mt-1 text-xs text-muted-foreground line-clamp-2">{link.excerpt}</p>
)}
</button>
))}
</div>
)}
</TabsContent>
<TabsContent value="outgoing" className="h-full overflow-auto p-4">
{!outgoingLinks ? (
<p className="py-8 text-center text-sm text-muted-foreground">Loading...</p>
) : outgoingLinks.noteLinks.length === 0 && outgoingLinks.entityLinks.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No outgoing links use [[Title]] to link to other notes
</p>
) : (
<div className="space-y-3">
{outgoingLinks.noteLinks.length > 0 && (
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">Notes</p>
{outgoingLinks.noteLinks.map((link) => (
<button
key={link.id}
onClick={() => openBacklink({ id: link.id, title: link.title, excerpt: '' })}
className="w-full rounded-lg border p-2 text-left text-sm transition-colors hover:bg-accent"
>
<FileText className="mr-2 inline h-3 w-3 text-muted-foreground" />
{link.title}
</button>
))}
</div>
)}
{outgoingLinks.entityLinks.length > 0 && (
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">Entities</p>
{outgoingLinks.entityLinks.map((link, i) => (
<div key={`${link.entityType}-${link.entityId}-${i}`} className="rounded-lg border p-2 text-sm">
<span
className="mr-1.5 inline-block h-2 w-2 rounded-full"
style={{
backgroundColor:
link.entityType === 'task' ? '#3b82f6' :
link.entityType === 'habit' ? '#10b981' :
link.entityType === 'project' ? '#8b5cf6' :
link.entityType === 'section' ? '#ec4899' :
link.entityType === 'tag' ? '#6b7280' : '#f59e0b',
}}
/>
<span className="text-xs text-muted-foreground">{link.entityType}:</span>{' '}
{link.title || link.entityId.slice(0, 8)}
</div>
))}
</div>
)}
</div>
)}
</TabsContent>
</Tabs>
</Card>
</div>
</div>
);
}
@@ -1,394 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useParams } from "next/navigation";
import { Plus, ArrowLeft, GripVertical, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { SectionDialog } from "@/components/projects/section-dialog";
import { ProjectTimeline } from "@/components/projects/project-timeline";
import Link from "next/link";
import { toast } from "sonner";
interface Section {
id: string;
name: string;
projectId: string;
kind: 'section' | 'milestone';
status: 'planned' | 'in_progress' | 'complete';
targetDate: string | null;
sortOrder: number;
}
interface Task {
id: string;
title: string;
status: string;
priority: string;
sectionId: string | null;
order: number;
}
interface ProjectDetail {
id: string;
name: string;
description: string | null;
status: string;
color: string | null;
icon: string | null;
targetDate: string | null;
sections: Section[];
tasks: Task[];
taskCount: number;
completedCount: number;
progress: number;
}
const statusColors: Record<string, string> = {
active: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
paused: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
completed: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
archived: "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200",
};
const taskStatusColors: Record<string, string> = {
todo: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
in_progress: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200",
done: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200",
cancelled: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-200",
};
export default function ProjectDetailPage() {
const params = useParams();
const projectId = params.id as string;
const [project, setProject] = useState<ProjectDetail | null>(null);
const [domainId, setDomainId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [sectionDialogOpen, setSectionDialogOpen] = useState(false);
const [editSection, setEditSection] = useState<Section | null>(null);
const [deleteSection, setDeleteSection] = useState<Section | null>(null);
const [deletingSection, setDeletingSection] = useState(false);
const [draggedTaskId, setDraggedTaskId] = useState<string | null>(null);
// Extract domainId from the project data
const fetchProject = useCallback(async () => {
setLoading(true);
try {
// We need to find the domain first — use the first domain
const domainsRes = await fetch('/api/domains?sort=sort_order');
const domainsData = await domainsRes.json();
const firstDomain = domainsData.items?.[0];
if (!firstDomain) {
setLoading(false);
return;
}
setDomainId(firstDomain.id);
const res = await fetch(`/api/domains/${firstDomain.id}/projects/${projectId}`);
if (!res.ok) throw new Error('Not found');
const data = await res.json();
setProject(data);
} catch {
toast.error('Failed to load project');
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
fetchProject();
}, [fetchProject]);
const handleMoveTask = async (taskId: string, sectionId: string | null) => {
try {
const res = await fetch(`/api/domains/${domainId}/tasks/${taskId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sectionId }),
});
if (!res.ok) throw new Error('Failed to move task');
toast.success('Task moved');
fetchProject();
} catch {
toast.error('Failed to move task');
}
};
// Listen for custom event to open section dialog
useEffect(() => {
const handler = () => setSectionDialogOpen(true);
document.addEventListener('open-create-section', handler);
return () => document.removeEventListener('open-create-section', handler);
}, []);
if (loading) {
return <div className="py-12 text-center text-muted-foreground">Loading project...</div>;
}
if (!project) {
return (
<div className="py-12 text-center">
<p className="text-muted-foreground">Project not found.</p>
<Link href="/projects" className="mt-4 inline-block text-primary hover:underline">
Back to projects
</Link>
</div>
);
}
// Group tasks by section
const tasksBySection = new Map<string | 'unsectioned', Task[]>();
tasksBySection.set('unsectioned', []);
for (const section of project.sections) {
tasksBySection.set(section.id, []);
}
for (const task of project.tasks) {
const key = task.sectionId || 'unsectioned';
if (!tasksBySection.has(key)) tasksBySection.set(key, []);
tasksBySection.get(key)!.push(task);
}
return (
<div>
{/* Header */}
<div className="mb-6">
<Link href="/projects" className="mb-2 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
Back to projects
</Link>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
{project.color && (
<div className="h-4 w-4 rounded-full shrink-0" style={{ backgroundColor: project.color }} />
)}
<h1 className="text-2xl font-bold">{project.name}</h1>
<Badge variant="secondary" className={`text-xs ${statusColors[project.status] || ''}`}>
{project.status}
</Badge>
</div>
{project.description && (
<p className="mt-1 text-muted-foreground">{project.description}</p>
)}
{project.targetDate && (
<p className="mt-1 text-sm text-muted-foreground">
Target: {new Date(project.targetDate).toLocaleDateString()}
</p>
)}
</div>
</div>
<div className="mt-4 space-y-1">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>{project.completedCount}/{project.taskCount} tasks completed</span>
<span>{project.progress}%</span>
</div>
<Progress value={project.progress} className="h-2" />
</div>
</div>
{/* Sections board */}
<div className="flex gap-4 overflow-x-auto pb-4">
{/* Unsectioned tasks column */}
<div className="min-w-[280px] max-w-[320px] flex-shrink-0">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
Unassigned
</h3>
<span className="text-xs text-muted-foreground">
{(tasksBySection.get('unsectioned') || []).length}
</span>
</div>
<div className="space-y-2">
{(tasksBySection.get('unsectioned') || []).map((task) => (
<div
key={task.id}
draggable
onDragStart={() => setDraggedTaskId(task.id)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => {
if (draggedTaskId && draggedTaskId !== task.id) {
handleMoveTask(draggedTaskId, null);
}
setDraggedTaskId(null);
}}
className="rounded-lg border bg-card p-3 cursor-grab active:cursor-grabbing"
>
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden="true" />
<span className="text-sm flex-1">{task.title}</span>
<Badge variant="secondary" className={`text-xs ${taskStatusColors[task.status] || ''}`}>
{task.status}
</Badge>
</div>
</div>
))}
{(tasksBySection.get('unsectioned') || []).length === 0 && (
<div className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">
Drop tasks here
</div>
)}
</div>
</div>
{/* Section columns */}
{project.sections.map((section) => (
<div key={section.id} className="min-w-[280px] max-w-[320px] flex-shrink-0">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
{section.name}
</h3>
{section.kind === 'milestone' && (
<Badge variant="outline" className="text-xs">Milestone</Badge>
)}
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">
{(tasksBySection.get(section.id) || []).length}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="rounded-md p-1 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
aria-label={`Options for ${section.name}`}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditSection(section)}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteSection(section)}>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div
className="space-y-2 min-h-[100px]"
onDragOver={(e) => e.preventDefault()}
onDrop={() => {
if (draggedTaskId) {
handleMoveTask(draggedTaskId, section.id);
}
setDraggedTaskId(null);
}}
>
{(tasksBySection.get(section.id) || []).map((task) => (
<div
key={task.id}
draggable
onDragStart={() => setDraggedTaskId(task.id)}
className="rounded-lg border bg-card p-3 cursor-grab active:cursor-grabbing"
>
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden="true" />
<span className="text-sm flex-1">{task.title}</span>
<Badge variant="secondary" className={`text-xs ${taskStatusColors[task.status] || ''}`}>
{task.status}
</Badge>
</div>
</div>
))}
{(tasksBySection.get(section.id) || []).length === 0 && (
<div className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">
Drop tasks here
</div>
)}
</div>
</div>
))}
{/* Add section button */}
<div className="min-w-[280px] max-w-[320px] flex-shrink-0">
<button
onClick={() => setSectionDialogOpen(true)}
className="flex h-full w-full items-center justify-center rounded-lg border-2 border-dashed p-4 text-sm text-muted-foreground hover:text-foreground hover:border-accent-foreground/50 transition-colors"
>
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
Add section
</button>
</div>
</div>
{/* Timeline */}
<ProjectTimeline sections={project.sections} projectTargetDate={project.targetDate} />
<SectionDialog
open={sectionDialogOpen}
onOpenChange={setSectionDialogOpen}
projectId={projectId}
domainId={domainId || ''}
onCreated={fetchProject}
/>
{editSection && (
<SectionDialog
open={!!editSection}
onOpenChange={(open) => { if (!open) setEditSection(null); }}
projectId={projectId}
domainId={domainId || ''}
onCreated={fetchProject}
existingSection={editSection}
/>
)}
<AlertDialog open={!!deleteSection} onOpenChange={(open) => { if (!open) setDeleteSection(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Section</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{deleteSection?.name}&quot;? This action cannot be undone. Tasks in this section will become unassigned.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={deletingSection}
onClick={async () => {
if (!deleteSection || !domainId) return;
setDeletingSection(true);
try {
const res = await fetch(`/api/domains/${domainId}/projects/${projectId}/sections/${deleteSection.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error('Failed to delete');
toast.success('Section deleted');
setDeleteSection(null);
fetchProject();
} catch {
toast.error('Failed to delete section');
} finally {
setDeletingSection(false);
}
}}
>
{deletingSection ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
-288
View File
@@ -1,288 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Plus, FolderKanban, ExternalLink, MoreHorizontal, Pencil, Archive } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { ProjectCreateDialog } from "@/components/projects/project-create-dialog";
import { ProjectEditDialog } from "@/components/projects/project-edit-dialog";
import { CreateItemDialog } from "@/components/create-item-dialog";
import { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store";
import Link from "next/link";
import { toast } from "sonner";
interface Project {
id: string;
name: string;
description: string | null;
status: 'active' | 'paused' | 'completed' | 'archived';
domainId: string;
color: string | null;
icon: string | null;
targetDate: string | null;
taskCount: number;
completedCount: number;
progress: number;
tags: { id: string; name: string; color: string | null }[];
}
const statusColors: Record<string, string> = {
active: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
paused: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
completed: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
archived: "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200",
};
export default function ProjectsPage() {
const [projects, setProjects] = useState<Project[]>([]);
const [domainId, setDomainId] = useState<string | null>(null);
const [domains, setDomains] = useState<{ id: string; name: string }[]>([]);
const [createOpen, setCreateOpen] = useState(false);
const { open: storeOpen, openCreate, closeCreate } = useCreateDialogStore();
const [editProject, setEditProject] = useState<Project | null>(null);
const [archiveProject, setArchiveProject] = useState<Project | null>(null);
const [archiving, setArchiving] = useState(false);
const [loading, setLoading] = useState(true);
// Fetch domains
useEffect(() => {
fetch('/api/domains?sort=sort_order')
.then((res) => res.json())
.then((data) => {
const items = data.items || [];
setDomains(items);
if (items.length > 0 && !domainId) {
setDomainId(items[0].id);
}
})
.catch(() => {});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Fetch projects
const fetchProjects = useCallback(async () => {
if (!domainId) return;
setLoading(true);
try {
const res = await fetch(`/api/domains/${domainId}/projects`);
const data = await res.json();
setProjects(data.items || []);
} catch {
toast.error('Failed to load projects');
} finally {
setLoading(false);
}
}, [domainId]);
useEffect(() => {
fetchProjects();
}, [fetchProjects]);
// Listen for custom event to open create dialog
useEffect(() => {
const handler = () => setCreateOpen(true);
document.addEventListener('open-create-project', handler);
return () => document.removeEventListener('open-create-project', handler);
}, []);
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Projects</h1>
<p className="mt-1 text-muted-foreground">Organize work into milestones and track progress.</p>
</div>
<div className="flex items-center gap-2">
{domains.length > 1 && (
<select
value={domainId || ''}
onChange={(e) => setDomainId(e.target.value)}
className="rounded-md border bg-background px-3 py-1.5 text-sm"
aria-label="Select domain"
>
{domains.map((d) => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
)}
<Button onClick={() => { setCreateOpen(true); openCreate('project'); }}>
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
New project
</Button>
</div>
</div>
{loading ? (
<div className="py-12 text-center text-muted-foreground">Loading projects...</div>
) : projects.length === 0 ? (
<div className="py-12 text-center">
<FolderKanban className="mx-auto h-12 w-12 text-muted-foreground/50" aria-hidden="true" />
<p className="mt-4 text-muted-foreground">No projects yet. Create your first one!</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<div key={project.id} className="relative">
<Link href={`/projects/${project.id}`}>
<Card className="h-full cursor-pointer transition-colors hover:bg-accent/50">
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
{project.color && (
<div
className="h-3 w-3 rounded-full shrink-0"
style={{ backgroundColor: project.color }}
/>
)}
<CardTitle className="text-base">{project.name}</CardTitle>
</div>
<ExternalLink className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden="true" />
</div>
</CardHeader>
<CardContent>
{project.description && (
<p className="mb-3 text-sm text-muted-foreground line-clamp-2">{project.description}</p>
)}
<div className="mb-3 flex items-center gap-2">
<Badge variant="secondary" className={`text-xs ${statusColors[project.status] || ''}`}>
{project.status}
</Badge>
{project.targetDate && (
<span className="text-xs text-muted-foreground">
Due {new Date(project.targetDate).toLocaleDateString()}
</span>
)}
</div>
<div className="space-y-1">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{project.completedCount}/{project.taskCount} tasks</span>
<span>{project.progress}%</span>
</div>
<Progress value={project.progress} className="h-2" />
</div>
{project.tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1">
{project.tags.map((tag) => (
<span
key={tag.id}
className="inline-flex items-center rounded-full px-2 py-0.5 text-xs"
style={{ backgroundColor: tag.color || '#e2e8f0', color: '#1e293b' }}
>
{tag.name}
</span>
))}
</div>
)}
</CardContent>
</Card>
</Link>
<div className="absolute right-2 top-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="rounded-md p-1 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
aria-label={`Options for ${project.name}`}
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); setEditProject(project); }}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); setArchiveProject(project); }}>
<Archive className="mr-2 h-4 w-4" />
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
)}
<ProjectCreateDialog
open={createOpen}
onOpenChange={(o) => { setCreateOpen(o); if (!o) closeCreate(); }}
domainId={domainId || ''}
onCreated={fetchProjects}
/>
{/* Global CreateItemDialog from useCreateDialogStore — opened by topbar 'New project' button */}
<CreateItemDialog
type="project"
open={storeOpen}
onOpenChange={(o) => { if (!o) closeCreate(); else openCreate('project'); }}
onCreated={fetchProjects}
/>
{editProject && (
<ProjectEditDialog
open={!!editProject}
onOpenChange={(open) => { if (!open) setEditProject(null); }}
project={editProject}
domainId={domainId || ''}
onUpdated={fetchProjects}
/>
)}
<AlertDialog open={!!archiveProject} onOpenChange={(open) => { if (!open) setArchiveProject(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Archive Project</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to archive &quot;{archiveProject?.name}&quot;? It will be hidden from the active list.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={archiving}
onClick={async () => {
if (!archiveProject || !domainId) return;
setArchiving(true);
try {
const res = await fetch(`/api/domains/${domainId}/projects/${archiveProject.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'archived' }),
});
if (!res.ok) throw new Error('Failed to archive');
toast.success('Project archived');
setArchiveProject(null);
fetchProjects();
} catch {
toast.error('Failed to archive project');
} finally {
setArchiving(false);
}
}}
>
{archiving ? 'Archiving...' : 'Archive'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
-381
View File
@@ -1,381 +0,0 @@
'use client';
import { useEffect, useRef, useState, Suspense } from 'react';
import { Plus, FileBarChart, Calendar, Target, TrendingUp, Clock, Trash2 } from 'lucide-react';
import dynamic from 'next/dynamic';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
// Lazy load TipTap report editor (~80KB)
const ReportEditor = dynamic(
() => import('@/components/reports/report-editor').then((m) => m.ReportEditor),
{
ssr: false,
loading: () => (
<div className="flex h-full items-center justify-center">
<div className="animate-pulse text-sm text-muted-foreground">Loading editor...</div>
</div>
),
}
);
// Lazy load report templates
const ReportTemplates = dynamic(
() => import('@/components/reports/report-templates').then((m) => m.ReportTemplates),
{
ssr: false,
loading: () => (
<div className="flex h-full items-center justify-center">
<div className="animate-pulse text-sm text-muted-foreground">Loading templates...</div>
</div>
),
}
);
interface Report {
id: string;
title: string;
content: string;
report_type: 'weekly' | 'monthly' | 'project' | 'habit' | 'custom';
date_range_start?: string;
date_range_end?: string;
domain: string;
created: string;
updated: string;
}
export default function ReportsPage() {
const [reports, setReports] = useState<Report[]>([]);
const [selectedReport, setSelectedReport] = useState<Report | null>(null);
const [loading, setLoading] = useState(true);
const [showTemplates, setShowTemplates] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saveStatus, setSaveStatus] = useState<'Saved' | 'Saving' | 'Failed'>('Saved');
const [domainMap, setDomainMap] = useState<Map<string, string>>(new Map());
const [reportToDelete, setReportToDelete] = useState<Report | null>(null);
const [deleting, setDeleting] = useState(false);
const pendingSave = useRef<{ id: string; updates: Partial<Report> } | null>(null);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const saveVersion = useRef(0);
useEffect(() => {
fetchReports();
fetchDomains();
return () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
};
}, []);
async function fetchDomains() {
try {
const res = await fetch('/api/domains?sort=sort_order');
if (res.ok) {
const data = await res.json();
const map = new Map<string, string>();
for (const d of data.items || []) map.set(d.id, d.name);
setDomainMap(map);
}
} catch {}
}
async function fetchReports() {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/reports?sort=-created');
if (!response.ok) throw new Error('Unable to load reports.');
const data = await response.json();
const reportsList = data.items || [];
setReports(reportsList);
setSelectedReport((current) => current || reportsList[0] || null);
} catch (error) {
console.error('Failed to fetch reports:', error);
setError('Unable to load reports. Please try again.');
} finally {
setLoading(false);
}
}
async function createReport(overrides?: Partial<Report>) {
try {
const response = await fetch('/api/reports', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'Untitled report',
content: '',
report_type: 'custom',
domain: 'personal',
...overrides,
}),
});
if (!response.ok) throw new Error('Unable to create report.');
const newReport = await response.json();
setReports((current) => [newReport, ...current]);
setSelectedReport(newReport);
setShowTemplates(false);
toast.success('Report created');
} catch (error) {
console.error('Failed to create report:', error);
toast.error('Unable to create report');
}
}
function scheduleSave(reportId: string, updates: Partial<Report>) {
const version = ++saveVersion.current;
pendingSave.current = {
id: reportId,
updates: { ...(pendingSave.current?.id === reportId ? pendingSave.current.updates : {}), ...updates },
};
if (saveTimer.current) clearTimeout(saveTimer.current);
setSaveStatus('Saving');
saveTimer.current = setTimeout(() => {
const save = pendingSave.current;
pendingSave.current = null;
if (save) updateReport(save.id, save.updates, version);
}, 700);
}
async function updateReport(reportId: string, updates: Partial<Report>, version: number) {
try {
const response = await fetch(`/api/reports/${reportId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
if (!response.ok) throw new Error('Unable to save report.');
const updated = await response.json();
setReports((current) => current.map((report) => (report.id === reportId ? updated : report)));
if (saveVersion.current === version) setSaveStatus('Saved');
} catch (error) {
console.error('Failed to update report:', error);
if (saveVersion.current === version) setSaveStatus('Failed');
toast.error('Unable to save report');
}
}
async function deleteReport() {
if (!reportToDelete) return;
if (pendingSave.current?.id === reportToDelete.id && saveTimer.current) {
clearTimeout(saveTimer.current);
pendingSave.current = null;
}
++saveVersion.current;
setDeleting(true);
try {
const response = await fetch(`/api/reports/${reportToDelete.id}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Unable to delete report.');
setReports((current) => current.filter((report) => report.id !== reportToDelete.id));
setSelectedReport((selected) =>
selected?.id === reportToDelete.id ? reports.find((report) => report.id !== reportToDelete.id) || null : selected
);
setReportToDelete(null);
toast.success('Report deleted');
} catch (error) {
console.error('Failed to delete report:', error);
toast.error('Unable to delete report');
} finally {
setDeleting(false);
}
}
function getReportTypeIcon(type: string) {
switch (type) {
case 'weekly':
return <Calendar className="h-4 w-4" />;
case 'monthly':
return <Calendar className="h-4 w-4" />;
case 'project':
return <Target className="h-4 w-4" />;
case 'habit':
return <TrendingUp className="h-4 w-4" />;
case 'custom':
return <FileBarChart className="h-4 w-4" />;
default:
return <FileBarChart className="h-4 w-4" />;
}
}
if (loading) {
return <p className="text-muted-foreground" role="status">Loading reports...</p>;
}
if (error) {
return <div className="space-y-3"><p className="text-muted-foreground" role="alert">{error}</p><Button onClick={fetchReports}>Retry</Button></div>;
}
if (showTemplates) {
return (
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<div className="animate-pulse text-sm text-muted-foreground">Loading templates...</div>
</div>
}
>
<ReportTemplates
onSelect={(template) => {
createReport({
title: template.name,
report_type: template.type,
content: template.content,
});
}}
onCancel={() => setShowTemplates(false)}
/>
</Suspense>
);
}
return (
<div>
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold">Reports</h1>
<p className="mt-1 text-muted-foreground">Step back and see what changed.</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setShowTemplates(true)}>
From template
</Button>
<Button onClick={() => createReport()}>
<Plus className="mr-2 h-4 w-4" />
New report
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[300px_1fr]">
{/* Reports list */}
<Card className="max-h-80 overflow-auto lg:h-[calc(100vh-200px)] lg:max-h-none">
<div className="p-2">
{reports.length === 0 ? (
<div className="py-8 text-center">
<p className="text-sm text-muted-foreground">No reports yet</p>
<Button className="mt-3" size="sm" onClick={() => createReport()}>
<Plus className="mr-2 h-4 w-4" />
Create your first report
</Button>
</div>
) : (
<div className="space-y-1">
{reports.map((report) => (
<button
key={report.id}
onClick={() => setSelectedReport(report)}
aria-label={`Open report: ${report.title}`}
aria-current={selectedReport?.id === report.id ? 'true' : undefined}
className={`w-full rounded-lg p-3 text-left transition-colors ${
selectedReport?.id === report.id
? 'bg-accent'
: 'hover:bg-accent/50'
}`}
>
<div className="flex items-start gap-2">
<div className="mt-0.5 text-muted-foreground" aria-hidden="true">
{getReportTypeIcon(report.report_type)}
</div>
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium">{report.title}</p>
<p className="mt-1 truncate text-xs text-muted-foreground">
{new Date(report.updated).toLocaleDateString()}
</p>
<div className="mt-1 flex gap-1">
<Badge variant="outline" className="text-xs">
{report.report_type}
</Badge>
<Badge variant="outline" className="text-xs">
{domainMap.get(report.domain) || report.domain}
</Badge>
</div>
</div>
</div>
</button>
))}
</div>
)}
</div>
</Card>
{/* Report editor */}
<Card className="min-h-[420px] lg:h-[calc(100vh-200px)]">
{selectedReport ? (
<div className="flex h-full flex-col">
<div className="border-b p-4">
<label htmlFor="report-title" className="sr-only">
Report title
</label>
<div className="flex items-center gap-3">
<input
id="report-title"
type="text"
value={selectedReport.title}
onChange={(e) => { const title = e.target.value; setSelectedReport({ ...selectedReport, title }); scheduleSave(selectedReport.id, { title }); }}
className="min-w-0 flex-1 text-xl font-semibold outline-none"
placeholder="Report title"
/>
<span className="text-xs text-muted-foreground" role="status">{saveStatus}</span>
<AlertDialog open={reportToDelete?.id === selectedReport.id} onOpenChange={(open) => !open && setReportToDelete(null)}>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" aria-label={`Delete ${selectedReport.title}`} onClick={() => setReportToDelete(selectedReport)}>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader><AlertDialogTitle>Delete {reportToDelete?.title}?</AlertDialogTitle><AlertDialogDescription>This permanently deletes this report.</AlertDialogDescription></AlertDialogHeader>
<AlertDialogFooter><AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel><AlertDialogAction onClick={deleteReport} disabled={deleting}>{deleting ? 'Deleting...' : 'Delete report'}</AlertDialogAction></AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<div className="mt-2 flex gap-2">
<Badge variant="outline">{selectedReport.report_type}</Badge>
<Badge variant="outline">{domainMap.get(selectedReport.domain) || selectedReport.domain}</Badge>
{selectedReport.date_range_start && selectedReport.date_range_end && (
<Badge variant="outline" className="gap-1">
<Clock className="h-3 w-3" />
{new Date(selectedReport.date_range_start).toLocaleDateString()} -{' '}
{new Date(selectedReport.date_range_end).toLocaleDateString()}
</Badge>
)}
</div>
</div>
<div className="flex-1 overflow-auto p-4">
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<div className="animate-pulse text-sm text-muted-foreground">
Loading editor...
</div>
</div>
}
>
<ReportEditor
content={selectedReport.content}
onChange={(content) => { setSelectedReport({ ...selectedReport, content }); scheduleSave(selectedReport.id, { content }); }}
/>
</Suspense>
</div>
</div>
) : (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground">Select a report or create a new one</p>
</div>
)}
</Card>
</div>
</div>
);
}
-291
View File
@@ -1,291 +0,0 @@
'use client';
import { useEffect, useState, useMemo, useCallback, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Search, Calendar, ListTodo, BookOpen, FolderKanban, Hash, ExternalLink, Clock, Filter, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
interface SearchResult {
id: string;
type: 'task' | 'note' | 'project' | 'habit' | 'domain';
title: string;
snippet: string;
score: number;
workspaceId: string;
link: string;
}
const typeIcons: Record<string, React.ReactNode> = {
task: <ListTodo className="h-4 w-4" />,
note: <BookOpen className="h-4 w-4" />,
project: <FolderKanban className="h-4 w-4" />,
habit: <Hash className="h-4 w-4" />,
domain: <Calendar className="h-4 w-4" />,
};
const typeColors: Record<string, string> = {
task: 'bg-blue-500/10 text-blue-600',
note: 'bg-green-500/10 text-green-600',
project: 'bg-purple-500/10 text-purple-600',
habit: 'bg-orange-500/10 text-orange-600',
domain: 'bg-gray-500/10 text-gray-600',
};
export default function SearchPage() {
return (
<Suspense fallback={<div className="flex h-96 items-center justify-center"><div className="animate-pulse text-sm text-muted-foreground">Loading search...</div></div>}>
<SearchPageContent />
</Suspense>
);
}
function SearchPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const initialQuery = searchParams.get('q') || '';
const [query, setQuery] = useState(initialQuery);
const [results, setResults] = useState<SearchResult[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedTypes, setSelectedTypes] = useState<string[]>(['task', 'note', 'project', 'habit', 'domain']);
const [selectedDomain, setSelectedDomain] = useState<string>('all');
const [recentSearches, setRecentSearches] = useState<string[]>([]);
// Load recent searches from localStorage
useEffect(() => {
try {
const stored = localStorage.getItem('project-e-recent-searches');
if (stored) setRecentSearches(JSON.parse(stored));
} catch {}
}, []);
const saveRecentSearch = useCallback((q: string) => {
const updated = [q, ...recentSearches.filter(s => s !== q)].slice(0, 10);
setRecentSearches(updated);
try {
localStorage.setItem('project-e-recent-searches', JSON.stringify(updated));
} catch {}
}, [recentSearches]);
const doSearch = useCallback(async (q: string) => {
if (!q.trim()) {
setResults([]);
setTotalCount(0);
return;
}
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({ q });
if (selectedTypes.length < 5) params.set('types', selectedTypes.join(','));
if (selectedDomain !== 'all') params.set('domain', selectedDomain);
const res = await fetch(`/api/search?${params}`);
if (!res.ok) throw new Error('Search failed');
const data = await res.json();
setResults(data.results || []);
setTotalCount(data.totalCount || 0);
saveRecentSearch(q);
} catch (err) {
setError('Search failed. Please try again.');
console.error('Search error:', err);
} finally {
setLoading(false);
}
}, [selectedTypes, selectedDomain, saveRecentSearch]);
// Initial search from URL param
useEffect(() => {
if (initialQuery) doSearch(initialQuery);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
doSearch(query);
router.replace(`/search?q=${encodeURIComponent(query)}`);
};
const groupedResults = useMemo(() => {
const groups: Record<string, SearchResult[]> = {
task: [], note: [], project: [], habit: [], domain: [],
};
for (const r of results) {
if (groups[r.type]) groups[r.type].push(r);
}
return Object.entries(groups).filter(([, items]) => items.length > 0);
}, [results]);
const toggleType = (type: string) => {
setSelectedTypes(prev =>
prev.includes(type) ? prev.filter(t => t !== type) : [...prev, type]
);
};
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold">Search</h1>
<p className="mt-1 text-muted-foreground">Find anything across your workspace.</p>
</div>
{/* Search bar */}
<form onSubmit={handleSearch} className="mb-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search tasks, notes, projects, habits..."
className="pl-10 pr-20"
autoFocus
/>
<Button
type="submit"
size="sm"
className="absolute right-1 top-1/2 -translate-y-1/2"
disabled={loading || !query.trim()}
>
{loading ? 'Searching...' : 'Search'}
</Button>
</div>
</form>
{/* Filters */}
<div className="mb-6 flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Filter:</span>
</div>
{['task', 'note', 'project', 'habit', 'domain'].map(type => (
<Badge
key={type}
variant={selectedTypes.includes(type) ? 'default' : 'outline'}
className="cursor-pointer capitalize"
onClick={() => toggleType(type)}
>
{typeIcons[type]}
<span className="ml-1">{type}s</span>
</Badge>
))}
</div>
{/* Results */}
{error && (
<Card className="mb-6 border-destructive">
<CardContent className="p-4 text-sm text-destructive">{error}</CardContent>
</Card>
)}
{!loading && !error && query && results.length === 0 && (
<div className="py-12 text-center">
<Search className="mx-auto mb-4 h-12 w-12 text-muted-foreground/50" />
<h3 className="text-lg font-medium">No results found</h3>
<p className="mt-1 text-sm text-muted-foreground">
Try different keywords or adjust your filters.
</p>
</div>
)}
{!query && !loading && (
<div className="py-12 text-center">
<Search className="mx-auto mb-4 h-12 w-12 text-muted-foreground/50" />
<h3 className="text-lg font-medium">Search your workspace</h3>
<p className="mt-1 text-sm text-muted-foreground">
Type a query above to search across tasks, notes, projects, habits, and domains.
</p>
{recentSearches.length > 0 && (
<div className="mt-6">
<h4 className="mb-2 text-sm font-medium text-muted-foreground">Recent searches</h4>
<div className="flex flex-wrap justify-center gap-2">
{recentSearches.map((s, i) => (
<Badge
key={i}
variant="secondary"
className="cursor-pointer"
onClick={() => { setQuery(s); doSearch(s); }}
>
<Clock className="mr-1 h-3 w-3" />
{s}
</Badge>
))}
</div>
</div>
)}
</div>
)}
{loading && (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i}>
<CardContent className="p-4">
<div className="mb-2 h-4 w-48 animate-pulse rounded bg-muted" />
<div className="h-3 w-full animate-pulse rounded bg-muted/50" />
</CardContent>
</Card>
))}
</div>
)}
{!loading && results.length > 0 && (
<div>
<p className="mb-4 text-sm text-muted-foreground">
Found {totalCount} result{totalCount !== 1 ? 's' : ''} for &ldquo;{query}&rdquo;
</p>
<div className="space-y-6">
{groupedResults.map(([type, items]) => (
<div key={type}>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold capitalize">
{typeIcons[type]}
{type}s
<Badge variant="secondary" className="ml-1 text-xs">{items.length}</Badge>
</h3>
<div className="space-y-2">
{items.map((result) => (
<Card
key={`${result.type}-${result.id}`}
className="cursor-pointer transition-colors hover:bg-accent/50"
onClick={() => router.push(result.link)}
>
<CardContent className="flex items-start gap-3 p-3">
<div className={`mt-0.5 rounded p-1.5 ${typeColors[result.type] || 'bg-gray-500/10'}`}>
{typeIcons[result.type]}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{result.title}</span>
<Badge variant="outline" className="shrink-0 text-[10px] capitalize">
{result.type}
</Badge>
</div>
{result.snippet && (
<p
className="mt-1 text-xs text-muted-foreground line-clamp-2"
dangerouslySetInnerHTML={{ __html: result.snippet }}
/>
)}
</div>
<ExternalLink className="mt-1 h-3 w-3 shrink-0 text-muted-foreground" />
</CardContent>
</Card>
))}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
@@ -1,172 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { AlertTriangle, Trash2 } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
interface ErrorLog {
id: string;
level: string;
source: string;
message: string;
metadata: Record<string, unknown>;
created: string;
}
export default function ErrorLogPage() {
const [errors, setErrors] = useState<ErrorLog[]>([]);
const [loading, setLoading] = useState(true);
const [clearing, setClearing] = useState(false);
const [confirmClear, setConfirmClear] = useState(false);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
useEffect(() => {
fetchErrors();
}, []);
async function fetchErrors() {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/error-logs?limit=50');
if (!response.ok) throw new Error('Unable to load error logs.');
const data = await response.json();
setErrors(data.items || []);
} catch (error) {
console.error('Failed to fetch error logs:', error);
setError('Unable to load error logs. Please try again.');
} finally {
setLoading(false);
}
}
async function clearErrors() {
setClearing(true);
setError(null);
setStatus(null);
try {
const response = await fetch('/api/error-logs', { method: 'DELETE' });
if (!response.ok) throw new Error('Unable to clear error logs.');
setErrors([]);
setConfirmClear(false);
setStatus('Error logs cleared successfully.');
} catch (error) {
console.error('Failed to clear error logs:', error);
setError('Unable to clear error logs. Please try again.');
} finally {
setClearing(false);
}
}
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle>Error Log</CardTitle>
<CardDescription>Loading...</CardDescription>
</CardHeader>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Error Log</CardTitle>
<CardDescription>
Recent errors from the application (auto-purged after 30 days)
</CardDescription>
</div>
{errors.length > 0 && (
<AlertDialog open={confirmClear} onOpenChange={setConfirmClear}>
<Button variant="outline" size="sm" onClick={() => setConfirmClear(true)} aria-label="Clear all error logs">
<Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />
Clear all
</Button>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear all error logs?</AlertDialogTitle>
<AlertDialogDescription>This permanently removes all displayed error logs.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={clearing}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={clearErrors} disabled={clearing}>
{clearing ? 'Clearing...' : 'Clear all'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</CardHeader>
<CardContent>
{error && (
<div className="mb-4 flex items-center justify-between gap-3 text-sm text-destructive" role="alert">
<span>{error}</span>
<Button variant="outline" size="sm" onClick={fetchErrors} disabled={loading}>Retry</Button>
</div>
)}
{status && <p className="mb-4 text-sm text-muted-foreground" role="status">{status}</p>}
{errors.length === 0 ? (
<p className="text-center text-muted-foreground py-8">
No errors logged
</p>
) : (
<div className="space-y-3">
{errors.map((error) => (
<div
key={error.id}
className="rounded-lg border border-border bg-card p-4 space-y-2"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-destructive" aria-hidden="true" />
<span className="text-sm font-medium">{error.level}</span>
<span className="text-xs text-muted-foreground">
{error.source}
</span>
</div>
<span className="text-xs text-muted-foreground">
{new Date(error.created).toLocaleString()}
</span>
</div>
<p className="text-sm">{error.message}</p>
{error.metadata &&
Object.keys(error.metadata).length > 0 && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground" role="button">
Details
</summary>
<pre className="mt-2 rounded bg-muted p-2 overflow-x-auto">
{JSON.stringify(error.metadata, null, 2)}
</pre>
</details>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
-118
View File
@@ -1,118 +0,0 @@
'use client';
import {
Palette,
Globe,
Keyboard,
Bot,
Webhook,
Download,
AlertTriangle,
Tag,
} from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { SettingsAppearance } from '@/components/settings/settings-appearance';
import { SettingsDomains } from '@/components/settings/settings-domains';
import { SettingsShortcuts } from '@/components/settings/settings-shortcuts';
import { SettingsAgents } from '@/components/settings/settings-agents';
import { SettingsWebhooks } from '@/components/settings/settings-webhooks';
import { SettingsImportExport } from '@/components/settings/settings-import-export';
import { SettingsTags } from '@/components/settings/settings-tags';
import { SettingsCustomFields } from '@/components/settings/settings-custom-fields';
export default function SettingsPage() {
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold">Settings</h1>
<p className="mt-1 text-muted-foreground">Tune Project E to fit your work.</p>
</div>
<Tabs defaultValue="appearance" orientation="vertical" className="flex flex-col gap-6 md:flex-row">
<TabsList className="flex h-auto w-full flex-row justify-start gap-1 overflow-x-auto bg-transparent p-0 md:w-[200px] md:flex-col">
<TabsTrigger value="appearance" className="shrink-0 justify-start gap-2">
<Palette className="h-4 w-4" aria-hidden="true" />
Appearance
</TabsTrigger>
<TabsTrigger value="domains" className="shrink-0 justify-start gap-2">
<Globe className="h-4 w-4" aria-hidden="true" />
Domains
</TabsTrigger>
<TabsTrigger value="tags" className="shrink-0 justify-start gap-2">
<Tag className="h-4 w-4" aria-hidden="true" />
Tags
</TabsTrigger>
<TabsTrigger value="custom-fields" className="shrink-0 justify-start gap-2">
<Tag className="h-4 w-4" aria-hidden="true" />
Custom Fields
</TabsTrigger>
<TabsTrigger value="shortcuts" className="shrink-0 justify-start gap-2">
<Keyboard className="h-4 w-4" aria-hidden="true" />
Keyboard Shortcuts
</TabsTrigger>
<TabsTrigger value="agents" className="shrink-0 justify-start gap-2">
<Bot className="h-4 w-4" aria-hidden="true" />
Agents & Permissions
</TabsTrigger>
<TabsTrigger value="webhooks" className="shrink-0 justify-start gap-2">
<Webhook className="h-4 w-4" aria-hidden="true" />
Webhooks
</TabsTrigger>
<TabsTrigger value="import-export" className="shrink-0 justify-start gap-2">
<Download className="h-4 w-4" aria-hidden="true" />
Import & Export
</TabsTrigger>
<TabsTrigger value="error-log" className="shrink-0 justify-start gap-2">
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
Error Log
</TabsTrigger>
</TabsList>
<div className="min-w-0 flex-1">
<TabsContent value="appearance">
<SettingsAppearance />
</TabsContent>
<TabsContent value="domains">
<SettingsDomains />
</TabsContent>
<TabsContent value="tags">
<SettingsTags />
</TabsContent>
<TabsContent value="custom-fields">
<SettingsCustomFields />
</TabsContent>
<TabsContent value="shortcuts">
<SettingsShortcuts />
</TabsContent>
<TabsContent value="agents">
<SettingsAgents />
</TabsContent>
<TabsContent value="webhooks">
<SettingsWebhooks />
</TabsContent>
<TabsContent value="import-export">
<SettingsImportExport />
</TabsContent>
<TabsContent value="error-log">
<Card>
<CardHeader>
<CardTitle>Error Log</CardTitle>
<CardDescription>View recent application errors</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
See the{' '}
<a href="/settings/error-log" className="text-primary underline">
detailed error log
</a>{' '}
for more information.
</p>
</CardContent>
</Card>
</TabsContent>
</div>
</Tabs>
</div>
);
}
-72
View File
@@ -1,72 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { LayoutGrid, List, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TasksKanbanView } from "@/components/tasks/tasks-kanban-view";
import { TasksListView } from "@/components/tasks/tasks-list-view";
import { CreateItemDialog } from "@/components/create-item-dialog";
import { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store";
import { RealtimeProvider } from "@/components/realtime-provider";
export default function TasksPage() {
const [view, setView] = useState<"kanban" | "list">("kanban");
const [refreshKey, setRefreshKey] = useState(0);
const [domainId, setDomainId] = useState<string | null>(null);
const [domains, setDomains] = useState<{ id: string; name: string; color: string | null }[]>([]);
const { open, openCreate, closeCreate } = useCreateDialogStore();
// Fetch domains and select first one
useEffect(() => {
fetch('/api/domains?sort=sort_order')
.then((res) => res.json())
.then((data) => {
const items = data.items || [];
setDomains(items);
if (items.length > 0 && !domainId) {
setDomainId(items[0].id);
}
})
.catch(() => {});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<RealtimeProvider>
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Tasks</h1>
<p className="mt-1 text-muted-foreground">Move work forward without losing the thread.</p>
</div>
<div className="flex items-center gap-2">
{domains.length > 1 && (
<select
value={domainId || ''}
onChange={(e) => setDomainId(e.target.value)}
className="rounded-md border bg-background px-3 py-1.5 text-sm"
aria-label="Select domain"
>
{domains.map((d) => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
)}
<Button onClick={() => openCreate("task")}>
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
New task
</Button>
<Tabs value={view} onValueChange={(v) => setView(v as "kanban" | "list")}>
<TabsList>
<TabsTrigger value="kanban" className="gap-2"><LayoutGrid className="h-4 w-4" aria-hidden="true" /> Board</TabsTrigger>
<TabsTrigger value="list" className="gap-2"><List className="h-4 w-4" aria-hidden="true" /> List</TabsTrigger>
</TabsList>
</Tabs>
</div>
</div>
{domainId && (view === "kanban" ? <TasksKanbanView key={refreshKey} domainId={domainId} /> : <TasksListView key={refreshKey} domainId={domainId} />)}
<CreateItemDialog type="task" open={open} onOpenChange={(o) => (o ? openCreate("task") : closeCreate())} onCreated={() => setRefreshKey((k) => k + 1)} />
</div>
</RealtimeProvider>
);
}
@@ -1,46 +0,0 @@
// 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 { getAuthUser, createErrorResponse } from '@/lib/auth';
import { createPocketBaseClient } from '@/lib/pocketbase';
type RouteContext = { params: Promise<{ id: string }> };
// POST /api/agent-activity/[id]/undo — Undo an agent action
export async function POST(request: NextRequest, context: RouteContext) {
const user = await getAuthUser(request);
if (!user) {
return NextResponse.json(
{ error: { code: 'UNAUTHORIZED', message: 'Authentication required' } },
{ status: 401 }
);
}
const { id } = await context.params;
try {
const pb = createPocketBaseClient();
// Get the activity record
const activity = await pb.collection('agent_activity').getOne(id);
if (!activity.before_state) {
return createErrorResponse('CANNOT_UNDO', 'This action cannot be undone', 400);
}
// Restore the previous state
const entityType = activity.entity_type;
const entityId = activity.entity_id;
const beforeState = activity.before_state;
await pb.collection(entityType).update(entityId, beforeState);
return NextResponse.json({ success: true, message: 'Action undone' });
} catch (error) {
console.error('Failed to undo activity:', error);
return createErrorResponse('UNDO_FAILED', 'Failed to undo action', 500);
}
}
-29
View File
@@ -1,29 +0,0 @@
// 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 } from '@/lib/auth';
import { createPocketBaseClient } from '@/lib/pocketbase';
// GET /api/agent-activity — List agent activity
export const GET = withAuth(async (request: NextRequest) => {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const perPage = parseInt(searchParams.get('perPage') || '50');
const sort = searchParams.get('sort') || '-created';
const pb = createPocketBaseClient();
const result = await pb.collection('agent_activity').getList(page, perPage, {
sort,
});
return NextResponse.json({
items: result.items,
totalItems: result.totalItems,
totalPages: result.totalPages,
page: result.page,
perPage: result.perPage,
});
});
-52
View File
@@ -1,52 +0,0 @@
// 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 } from '@/lib/auth';
import { createPocketBaseClient } from '@/lib/pocketbase';
import { createAgentTaskSchema } from '@project-e/shared';
import { z } from 'zod';
// GET /api/agent-tasks — List agent tasks
export const GET = withAuth(async (request: NextRequest) => {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const perPage = parseInt(searchParams.get('perPage') || '50');
const sort = searchParams.get('sort') || '-created';
const pb = createPocketBaseClient();
const result = await pb.collection('agent_tasks').getList(page, perPage, {
sort,
});
return NextResponse.json({
items: result.items,
totalItems: result.totalItems,
totalPages: result.totalPages,
page: result.page,
perPage: result.perPage,
});
});
// POST /api/agent-tasks — Create a new agent task
export const POST = withAuth(async (request: NextRequest) => {
try {
const body = await request.json();
const data = createAgentTaskSchema.parse(body);
const pb = createPocketBaseClient();
const task = await pb.collection('agent_tasks').create({
...data,
status: 'pending',
});
return NextResponse.json(task, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
throw error;
}
});
-50
View File
@@ -1,50 +0,0 @@
// 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 { createAdminClient } from '@/lib/pocketbase';
import { emitEvent, EVENTS } from '@/lib/events/event-bus';
// POST /api/agent-webhook — Receive async agent results
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { agent_task_id, result, status } = body;
if (!agent_task_id) {
return NextResponse.json(
{ error: { code: 'VALIDATION_ERROR', message: 'agent_task_id is required' } },
{ status: 400 },
);
}
const pb = createAdminClient();
// Update agent task with result
await pb.collection('agent_tasks').update(agent_task_id, {
status: status || 'completed',
output: result || {},
});
// Get the agent task to emit event
const agentTask = await pb.collection('agent_tasks').getOne(agent_task_id);
// Emit completion event
emitEvent(EVENTS.AGENT_TASK_COMPLETED, {
agentTaskId: agent_task_id,
agentId: agentTask.agent_id as string,
entityType: (agentTask.entity_type as string) || '',
entityId: (agentTask.entity_id as string) || '',
userId: 'agent',
});
return NextResponse.json({ success: true });
} catch {
return NextResponse.json(
{ error: { code: 'INTERNAL_ERROR', message: 'Failed to process agent webhook' } },
{ status: 500 },
);
}
}
-51
View File
@@ -1,51 +0,0 @@
// 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 } from '@/lib/auth';
import { createPocketBaseClient } from '@/lib/pocketbase';
import { updateAgentSchema } from '@project-e/shared';
import { z } from 'zod';
type RouteContext = { params: Promise<{ id: string }> };
// GET /api/agents/[id] — Get a single agent
export const GET = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
const { id } = await context!.params;
const pb = createPocketBaseClient();
const agent = await pb.collection('agents').getOne(id);
return NextResponse.json(agent);
});
// PATCH /api/agents/[id] — Update an agent
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
try {
const { id } = await context!.params;
const body = await request.json();
const data = updateAgentSchema.parse(body);
const pb = createPocketBaseClient();
const agent = await pb.collection('agents').update(id, data);
return NextResponse.json(agent);
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
throw error;
}
});
// DELETE /api/agents/[id] — Delete an agent
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
const { id } = await context!.params;
const pb = createPocketBaseClient();
await pb.collection('agents').delete(id);
return new NextResponse(null, { status: 204 });
});
-57
View File
@@ -1,57 +0,0 @@
// 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 { createPocketBaseClient } from '@/lib/pocketbase';
import { createAgentSchema } from '@project-e/shared';
import { z } from 'zod';
// GET /api/agents — List agents with filtering, sorting, pagination
export const GET = withAuth(async (request: NextRequest, _user) => {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const perPage = parseInt(searchParams.get('perPage') || '50');
const filter = searchParams.get('filter') || undefined;
const sort = searchParams.get('sort') || '-created';
const pb = createPocketBaseClient();
const result = await pb.collection('agents').getList(page, perPage, {
...(filter ? { filter } : {}),
sort,
});
return NextResponse.json({
items: result.items,
totalItems: result.totalItems,
totalPages: result.totalPages,
page: result.page,
perPage: result.perPage,
});
});
// POST /api/agents — Create an agent with auto-generated API key
export const POST = withAuth(async (request: NextRequest, user) => {
try {
const body = await request.json();
const data = createAgentSchema.parse({
...body,
domain: body.domain || (await resolveActiveDomain(user)).id,
});
const pb = createPocketBaseClient();
const agent = await pb.collection('agents').create({
...data,
api_key: crypto.randomUUID(),
});
return NextResponse.json(agent, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
throw error;
}
});
-67
View File
@@ -1,67 +0,0 @@
// 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 } from '@/lib/auth';
import { createPocketBaseClient } from '@/lib/pocketbase';
// GET /api/analytics — Pre-computed analytics data
export const GET = withAuth(async (request: NextRequest, _user) => {
const { searchParams } = new URL(request.url);
const period = searchParams.get('period') || '30'; // days
const days = parseInt(period);
const pb = createPocketBaseClient();
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
const startStr = startDate.toISOString();
// Task completion rate
const tasks = await pb.collection('tasks').getFullList({
filter: `created >= "${startStr}"`,
});
const completedTasks = tasks.filter((t: Record<string, unknown>) => t.status === 'done');
const taskCompletionRate = tasks.length > 0 ? Math.round((completedTasks.length / tasks.length) * 100) : 0;
// Habit consistency
const habits = await pb.collection('habits').getFullList();
const habitLogs = await pb.collection('habit_logs').getFullList({
filter: `logged_at >= "${startStr}"`,
});
const habitConsistency = habits.length > 0
? Math.round((habitLogs.length / (habits.length * days)) * 100)
: 0;
// Time tracked
const timeEntries = await pb.collection('task_time_entries').getFullList({
filter: `started_at >= "${startStr}"`,
});
const totalTimeMinutes = timeEntries.reduce(
(sum: number, e: Record<string, unknown>) => sum + ((e.duration_minutes as number) || 0),
0,
);
// Active streaks
const activeStreaks = habits.filter(
(h: Record<string, unknown>) => ((h.current_streak as number) || 0) > 0,
);
const bestStreak = Math.max(
...habits.map((h: Record<string, unknown>) => (h.best_streak as number) || 0),
0,
);
return NextResponse.json({
taskCompletionRate,
habitConsistency,
totalTimeMinutes,
activeStreaks: activeStreaks.length,
bestStreak,
period: days,
}, {
headers: {
'Cache-Control': 'private, max-age=300, stale-while-revalidate=600',
},
});
});
@@ -1,27 +0,0 @@
// 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';
// POST /api/analytics/vitals — Receive Web Vitals metrics
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Log to console in development for debugging
if (process.env.NODE_ENV === 'development') {
console.log('[Web Vitals]', body);
}
// In production, this would send to your analytics service
// (e.g., Google Analytics, PostHog, or custom backend)
// For now, just acknowledge receipt
return NextResponse.json({ received: true });
} catch {
// Silently ignore malformed requests
return NextResponse.json({ received: false }, { status: 400 });
}
}
@@ -1,45 +0,0 @@
// 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 } from '@/lib/auth';
import { createPocketBaseClient } from '@/lib/pocketbase';
// POST /api/attachments/upload — Upload file attachment
export const POST = withAuth(async (request: NextRequest, _user) => {
try {
const formData = await request.formData();
const file = formData.get('file') as File | null;
const taskId = formData.get('task_id') as string | null;
if (!file) {
return createErrorResponse('VALIDATION_ERROR', 'File is required', 400);
}
if (!taskId) {
return createErrorResponse('VALIDATION_ERROR', 'task_id is required', 400);
}
// Check file size (5MB limit)
if (file.size > 5 * 1024 * 1024) {
return createErrorResponse('FILE_TOO_LARGE', 'File size must be less than 5MB', 400);
}
const pb = createPocketBaseClient();
// Upload to PocketBase
const attachment = await pb.collection('task_attachments').create({
task_id: taskId,
file,
filename: file.name,
mime_type: file.type,
size: file.size,
});
return NextResponse.json(attachment, { status: 201 });
} catch {
return createErrorResponse('UPLOAD_FAILED', 'Failed to upload file', 500);
}
});
@@ -1,11 +0,0 @@
// 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 NextAuth from 'next-auth';
import { authOptions } from '@/lib/auth-config';
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
-26
View File
@@ -1,26 +0,0 @@
// 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 { getAuthUser } from '@/lib/auth';
export async function GET(request: NextRequest) {
try {
const user = await getAuthUser(request);
if (!user) {
return NextResponse.json(
{ error: { code: 'UNAUTHORIZED', message: 'Not authenticated' } },
{ status: 401 }
);
}
return NextResponse.json({ user });
} catch {
return NextResponse.json(
{ error: { code: 'AUTH_ERROR', message: 'Invalid or expired token' } },
{ status: 401 }
);
}
}
@@ -1,55 +0,0 @@
// 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 { db, users } from '@project-e/db';
import { eq } from 'drizzle-orm';
// POST /api/auth/passkey/login — Verify passkey login
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { credentialId, signature, authenticatorData, clientDataJSON } = body;
if (!credentialId || !signature) {
return NextResponse.json(
{ error: { code: 'VALIDATION_ERROR', message: 'credentialId and signature are required' } },
{ status: 400 }
);
}
// Find user by credential ID
const [user] = await db
.select()
.from(users)
.where(eq(users.passkeyCredentialId, credentialId))
.limit(1);
if (!user) {
return NextResponse.json(
{ error: { code: 'UNAUTHORIZED', message: 'Passkey not found' } },
{ status: 401 }
);
}
// In production, verify the WebAuthn assertion here using SimpleWebAuthn
// For now, we accept the passkey and return the user info
// The actual verification will be implemented with @simplewebauthn/server
return NextResponse.json({
user: {
id: user.id,
email: user.email,
name: user.name,
},
});
} catch (error) {
console.error('[passkey/login] error:', error);
return NextResponse.json(
{ error: { code: 'INTERNAL_ERROR', message: 'Failed to verify passkey' } },
{ status: 500 }
);
}
}
@@ -1,49 +0,0 @@
// 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 { getAuthUser } from '@/lib/auth';
import { db, users } from '@project-e/db';
import { eq } from 'drizzle-orm';
// POST /api/auth/passkey/register — Start passkey registration
export async function POST(request: NextRequest) {
try {
const user = await getAuthUser(request);
if (!user) {
return NextResponse.json(
{ error: { code: 'UNAUTHORIZED', message: 'Not authenticated' } },
{ status: 401 }
);
}
const body = await request.json();
const { credentialId, publicKey, counter } = body;
if (!credentialId || !publicKey) {
return NextResponse.json(
{ error: { code: 'VALIDATION_ERROR', message: 'credentialId and publicKey are required' } },
{ status: 400 }
);
}
await db
.update(users)
.set({
passkeyCredentialId: credentialId,
passkeyPublicKey: publicKey,
passkeyCounter: counter ?? 0,
})
.where(eq(users.id, user.id));
return NextResponse.json({ success: true });
} catch (error) {
console.error('[passkey/register] error:', error);
return NextResponse.json(
{ error: { code: 'INTERNAL_ERROR', message: 'Failed to register passkey' } },
{ status: 500 }
);
}
}
-51
View File
@@ -1,51 +0,0 @@
// 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 } from '@/lib/auth';
import { createPocketBaseClient } from '@/lib/pocketbase';
import { updateCanvasSchema } from '@project-e/shared';
import { z } from 'zod';
type RouteContext = { params: Promise<{ id: string }> };
// GET /api/canvases/[id] — Get a single canvas
export const GET = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
const { id } = await context!.params;
const pb = createPocketBaseClient();
const canvas = await pb.collection('canvases').getOne(id);
return NextResponse.json(canvas);
});
// PATCH /api/canvases/[id] — Update a canvas
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
try {
const { id } = await context!.params;
const body = await request.json();
const data = updateCanvasSchema.parse(body);
const pb = createPocketBaseClient();
const canvas = await pb.collection('canvases').update(id, data);
return NextResponse.json(canvas);
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
throw error;
}
});
// DELETE /api/canvases/[id] — Delete a canvas
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
const { id } = await context!.params;
const pb = createPocketBaseClient();
await pb.collection('canvases').delete(id);
return new NextResponse(null, { status: 204 });
});
-54
View File
@@ -1,54 +0,0 @@
// 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 { createPocketBaseClient } from '@/lib/pocketbase';
import { createCanvasSchema } from '@project-e/shared';
import { z } from 'zod';
// GET /api/canvases — List canvases with filtering, sorting, pagination
export const GET = withAuth(async (request: NextRequest, _user) => {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const perPage = parseInt(searchParams.get('perPage') || '50');
const filter = searchParams.get('filter') || undefined;
const sort = searchParams.get('sort') || '-created';
const pb = createPocketBaseClient();
const result = await pb.collection('canvases').getList(page, perPage, {
...(filter ? { filter } : {}),
sort,
});
return NextResponse.json({
items: result.items,
totalItems: result.totalItems,
totalPages: result.totalPages,
page: result.page,
perPage: result.perPage,
});
});
// POST /api/canvases — Create a canvas
export const POST = withAuth(async (request: NextRequest, user) => {
try {
const body = await request.json();
const data = createCanvasSchema.parse({
...body,
domain: body.domain || (await resolveActiveDomain(user)).id,
});
const pb = createPocketBaseClient();
const canvas = await pb.collection('canvases').create(data);
return NextResponse.json(canvas, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
throw error;
}
});
@@ -1,47 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse } from '@/lib/auth';
import { db, activityFeed } from '@project-e/db';
import { and, desc, eq, sql } from 'drizzle-orm';
type RouteContext = { params: Promise<{ domainId: string }> };
// GET /api/domains/[domainId]/activity — List activity feed for a workspace
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
const { searchParams } = new URL(request.url);
const entityType = searchParams.get('entity_type');
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100);
const offset = parseInt(searchParams.get('offset') || '0');
const conditions: any[] = [eq(activityFeed.workspaceId, domainId)];
if (entityType) {
conditions.push(eq(activityFeed.entityType, entityType));
}
const [items, countResult] = await Promise.all([
db.select()
.from(activityFeed)
.where(and(...conditions))
.orderBy(desc(activityFeed.createdAt))
.limit(limit)
.offset(offset),
db.select({ count: sql<number>`count(*)` })
.from(activityFeed)
.where(and(...conditions)),
]);
return NextResponse.json({
items,
totalItems: Number(countResult[0]?.count || 0),
limit,
offset,
});
});
@@ -1,217 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse } from '@/lib/auth';
import { db, tasks, habits, habitCompletions, projects, sections, domains } from '@project-e/db';
import { and, asc, between, eq, gte, isNull, lte, sql } from 'drizzle-orm';
type RouteContext = { params: Promise<{ domainId: string }> };
interface CalendarEvent {
id: string;
title: string;
start: string;
end: string;
type: 'task' | 'habit' | 'project' | 'milestone';
entityType: string;
entityId: string;
color: string;
domainId: string;
href: string;
priority?: string;
difficulty?: string;
status?: string;
}
// GET /api/domains/[domainId]/calendar/events?from=&to=
// Returns all events (tasks with due_date, habits scheduled for date range, project target dates)
// joined with domain for color/title
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
const { searchParams } = new URL(request.url);
const from = searchParams.get('from');
const to = searchParams.get('to');
const types = searchParams.get('types')?.split(',').filter(Boolean) || ['task', 'habit', 'project', 'milestone'];
if (!from || !to) {
return createErrorResponse('VALIDATION_ERROR', 'from and to query params are required (ISO dates)', 400);
}
const fromDate = new Date(from);
const toDate = new Date(to);
// Get domain for color
const [domain] = await db.select({ color: domains.color, name: domains.name })
.from(domains)
.where(eq(domains.id, domainId))
.limit(1);
const domainColor = domain?.color || '#3b82f6';
const events: CalendarEvent[] = [];
// 1. Tasks with due_date in range
if (types.includes('task')) {
const taskRows = await db.select()
.from(tasks)
.where(and(
eq(tasks.domainId, domainId),
isNull(tasks.deletedAt),
gte(tasks.dueDate, fromDate),
lte(tasks.dueDate, toDate),
))
.orderBy(asc(tasks.dueDate));
for (const task of taskRows) {
if (!task.dueDate) continue;
const color = task.priority === 'urgent' ? '#ef4444'
: task.priority === 'high' ? '#f97316'
: task.priority === 'medium' ? '#3b82f6'
: '#6b7280';
events.push({
id: `task-${task.id}`,
title: task.title,
start: task.dueDate.toISOString(),
end: task.dueDate.toISOString(),
type: 'task',
entityType: 'task',
entityId: task.id,
color,
domainId,
href: `/tasks/${task.id}`,
priority: task.priority,
status: task.status,
});
}
}
// 2. Habits — check if they have completions in range (scheduled habits)
if (types.includes('habit')) {
const habitRows = await db.select()
.from(habits)
.where(and(
eq(habits.domainId, domainId),
eq(habits.active, true),
isNull(habits.deletedAt),
));
for (const habit of habitRows) {
const color = habit.difficulty === 'hard' ? '#ef4444'
: habit.difficulty === 'medium' ? '#f97316'
: '#22c55e';
// Check if habit has completions in range
const completions = await db.select({ date: habitCompletions.date })
.from(habitCompletions)
.where(and(
eq(habitCompletions.habitId, habit.id),
gte(habitCompletions.date, fromDate),
lte(habitCompletions.date, toDate),
));
const completedDates = new Set(completions.map(c => c.date.toISOString().split('T')[0]));
// Generate events for each day in range (for daily habits)
// For weekly/custom, just show the habit as a recurring event
const current = new Date(fromDate);
while (current <= toDate) {
const dayOfWeek = current.getDay();
const skipDays = (habit.skipDays || []) as number[];
const dateStr = current.toISOString().split('T')[0];
if (!skipDays.includes(dayOfWeek)) {
const isCompleted = completedDates.has(dateStr);
events.push({
id: `habit-${habit.id}-${dateStr}`,
title: `${isCompleted ? '✅ ' : '○ '}${habit.name}`,
start: current.toISOString(),
end: current.toISOString(),
type: 'habit',
entityType: 'habit',
entityId: habit.id,
color,
domainId,
href: '/habits',
difficulty: habit.difficulty,
status: isCompleted ? 'completed' : 'pending',
});
}
current.setDate(current.getDate() + 1);
}
}
}
// 3. Projects with target_date in range
if (types.includes('project')) {
const projectRows = await db.select()
.from(projects)
.where(and(
eq(projects.domainId, domainId),
isNull(projects.deletedAt),
gte(projects.targetDate, fromDate),
lte(projects.targetDate, toDate),
))
.orderBy(asc(projects.targetDate));
for (const project of projectRows) {
if (!project.targetDate) continue;
events.push({
id: `project-${project.id}`,
title: `📁 ${project.name}`,
start: project.targetDate.toISOString(),
end: project.targetDate.toISOString(),
type: 'project',
entityType: 'project',
entityId: project.id,
color: project.color || '#8b5cf6',
domainId,
href: `/projects/${project.id}`,
status: project.status,
});
}
}
// 4. Sections (milestones) with target_date in range
if (types.includes('milestone')) {
const milestoneRows = await db.select({
id: sections.id,
name: sections.name,
targetDate: sections.targetDate,
projectId: sections.projectId,
status: sections.status,
kind: sections.kind,
})
.from(sections)
.innerJoin(projects, eq(sections.projectId, projects.id))
.where(and(
eq(projects.domainId, domainId),
eq(sections.kind, 'milestone'),
gte(sections.targetDate, fromDate),
lte(sections.targetDate, toDate),
))
.orderBy(asc(sections.targetDate));
for (const milestone of milestoneRows) {
if (!milestone.targetDate) continue;
events.push({
id: `milestone-${milestone.id}`,
title: `🏁 ${milestone.name}`,
start: milestone.targetDate.toISOString(),
end: milestone.targetDate.toISOString(),
type: 'milestone',
entityType: 'section',
entityId: milestone.id,
color: '#f59e0b',
domainId,
href: `/projects/${milestone.projectId}`,
status: milestone.status,
});
}
}
return NextResponse.json({ events });
});
@@ -1,73 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
import { recordActivity } from '@/lib/activity';
import { db, domains } from '@project-e/db';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
type RouteContext = { params: Promise<{ domainId: string }> };
const layoutItemSchema = z.object({
widgetId: z.string(),
order: z.number().int(),
enabled: z.boolean(),
config: z.record(z.string(), z.unknown()).optional(),
});
const updateLayoutSchema = z.object({
layout: z.array(layoutItemSchema),
});
// PUT /api/domains/[domainId]/dashboard/layout — Update dashboard layout
export const PUT = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = updateLayoutSchema.parse(body);
const [domain] = await db.select({ id: domains.id, customFields: domains.customFields })
.from(domains)
.where(eq(domains.id, domainId))
.limit(1);
if (!domain) {
return createErrorResponse('NOT_FOUND', 'Domain not found', 404);
}
// Store layout in domain's custom_fields
const existingFields = (domain.customFields as Record<string, unknown>) || {};
await db.update(domains)
.set({
customFields: { ...existingFields, dashboard_layout: data.layout },
updatedAt: new Date(),
})
.where(eq(domains.id, domainId));
await recordActivity({
actor: user.name,
action: 'updated',
entityType: 'domain',
entityId: domainId,
changes: { dashboardLayout: data.layout },
workspaceId: domainId,
});
return NextResponse.json({ layout: data.layout });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[dashboard/layout PUT] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to update dashboard layout', 500);
}
});
@@ -1,106 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
import { recordActivity } from '@/lib/activity';
import { db, domains } from '@project-e/db';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
type RouteContext = { params: Promise<{ domainId: string }> };
const layoutItemSchema = z.object({
widgetId: z.string(),
order: z.number().int(),
enabled: z.boolean(),
config: z.record(z.string(), z.unknown()).optional(),
});
const updateLayoutSchema = z.object({
layout: z.array(layoutItemSchema),
});
// GET /api/domains/[domainId]/dashboard — Returns layout (widget order) + widget data
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
// Verify domain exists
const [domain] = await db.select({ id: domains.id, name: domains.name, color: domains.color, customFields: domains.customFields })
.from(domains)
.where(eq(domains.id, domainId))
.limit(1);
if (!domain) {
return createErrorResponse('NOT_FOUND', 'Domain not found', 404);
}
// Dashboard layout is stored in the domain's custom_fields as jsonb
// We use a convention: dashboard_layout key in custom_fields
const storedLayout = (domain.customFields as Record<string, unknown>)?.dashboard_layout as Array<{ widgetId: string; order: number; enabled: boolean; config?: Record<string, unknown> }> | undefined;
const defaultLayout = [
{ widgetId: 'today-tasks', order: 0, enabled: true },
{ widgetId: 'habit-checklist', order: 1, enabled: true },
{ widgetId: 'weekly-stats', order: 2, enabled: true },
{ widgetId: 'project-progress', order: 3, enabled: true },
{ widgetId: 'upcoming-calendar', order: 4, enabled: true },
{ widgetId: 'recent-notes', order: 5, enabled: true },
{ widgetId: 'activity-feed', order: 6, enabled: true },
{ widgetId: 'quick-capture', order: 7, enabled: true },
];
return NextResponse.json({ layout: storedLayout || defaultLayout });
});
// PUT /api/domains/[domainId]/dashboard/layout — Update dashboard layout
export const PUT = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = updateLayoutSchema.parse(body);
const [domain] = await db.select({ id: domains.id, customFields: domains.customFields })
.from(domains)
.where(eq(domains.id, domainId))
.limit(1);
if (!domain) {
return createErrorResponse('NOT_FOUND', 'Domain not found', 404);
}
// Store layout in domain's custom_fields
const existingFields = (domain.customFields as Record<string, unknown>) || {};
await db.update(domains)
.set({
customFields: { ...existingFields, dashboard_layout: data.layout },
updatedAt: new Date(),
})
.where(eq(domains.id, domainId));
await recordActivity({
actor: user.name,
action: 'updated',
entityType: 'domain',
entityId: domainId,
changes: { dashboardLayout: data.layout },
workspaceId: domainId,
});
return NextResponse.json({ layout: data.layout });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[dashboard PUT] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to update dashboard layout', 500);
}
});
@@ -1,25 +0,0 @@
// 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, requireWorkspaceAccess } from '@/lib/auth';
import { db, activityFeed } from '@project-e/db';
import { and, desc, eq } from 'drizzle-orm';
type RouteContext = { params: Promise<{ domainId: string }> };
// GET /api/domains/[domainId]/dashboard/widgets/activity-feed
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
const items = await db.select()
.from(activityFeed)
.where(eq(activityFeed.workspaceId, domainId))
.orderBy(desc(activityFeed.createdAt))
.limit(20);
return NextResponse.json({ items });
});
@@ -1,50 +0,0 @@
// 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, requireWorkspaceAccess } from '@/lib/auth';
import { db, habits, habitCompletions } from '@project-e/db';
import { and, eq, gte, isNull, lte, sql } from 'drizzle-orm';
type RouteContext = { params: Promise<{ domainId: string }> };
// GET /api/domains/[domainId]/dashboard/widgets/habit-checklist
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const activeHabits = await db.select()
.from(habits)
.where(and(
eq(habits.domainId, domainId),
eq(habits.active, true),
isNull(habits.deletedAt),
));
// Check which habits are completed today
const items = [];
for (const habit of activeHabits) {
const [completion] = await db.select({ count: sql<number>`count(*)` })
.from(habitCompletions)
.where(and(
eq(habitCompletions.habitId, habit.id),
gte(habitCompletions.date, today),
lte(habitCompletions.date, tomorrow),
));
const completed = Number(completion?.count || 0) > 0;
items.push({
...habit,
completedToday: completed,
});
}
return NextResponse.json({ items });
});
@@ -1,54 +0,0 @@
// 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, requireWorkspaceAccess } from '@/lib/auth';
import { db, projects, tasks } from '@project-e/db';
import { and, eq, inArray, isNull, sql } from 'drizzle-orm';
type RouteContext = { params: Promise<{ domainId: string }> };
// GET /api/domains/[domainId]/dashboard/widgets/project-progress
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
const activeProjects = await db.select()
.from(projects)
.where(and(
eq(projects.domainId, domainId),
inArray(projects.status, ['active', 'paused']),
isNull(projects.deletedAt),
));
// Compute progress for each project
const items = [];
for (const project of activeProjects) {
const [totalResult] = await db.select({ count: sql<number>`count(*)` })
.from(tasks)
.where(and(eq(tasks.projectId, project.id), isNull(tasks.deletedAt)));
const [completedResult] = await db.select({ count: sql<number>`count(*)` })
.from(tasks)
.where(and(eq(tasks.projectId, project.id), eq(tasks.status, 'done'), isNull(tasks.deletedAt)));
const total = Number(totalResult?.count || 0);
const completed = Number(completedResult?.count || 0);
const progress = total > 0 ? Math.round((completed / total) * 100) : 0;
items.push({
id: project.id,
name: project.name,
status: project.status,
color: project.color,
targetDate: project.targetDate,
taskCount: total,
completedCount: completed,
progress,
});
}
return NextResponse.json({ items });
});
@@ -1,34 +0,0 @@
// 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, requireWorkspaceAccess } from '@/lib/auth';
import { db, notes } from '@project-e/db';
import { and, desc, eq, isNull } from 'drizzle-orm';
type RouteContext = { params: Promise<{ domainId: string }> };
// GET /api/domains/[domainId]/dashboard/widgets/recent-notes
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
const items = await db.select({
id: notes.id,
title: notes.title,
updatedAt: notes.updatedAt,
isPinned: notes.isPinned,
})
.from(notes)
.where(and(
eq(notes.domainId, domainId),
eq(notes.isArchived, false),
isNull(notes.deletedAt),
))
.orderBy(desc(notes.updatedAt))
.limit(5);
return NextResponse.json({ items });
});
@@ -1,35 +0,0 @@
// 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, requireWorkspaceAccess } from '@/lib/auth';
import { db, tasks, habits, habitCompletions, projects, notes, activityFeed, domains } from '@project-e/db';
import { and, asc, desc, eq, gte, inArray, isNull, lte, sql } from 'drizzle-orm';
type RouteContext = { params: Promise<{ domainId: string }> };
// GET /api/domains/[domainId]/dashboard/widgets/today-tasks
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const items = await db.select()
.from(tasks)
.where(and(
eq(tasks.domainId, domainId),
isNull(tasks.deletedAt),
gte(tasks.dueDate, today),
lte(tasks.dueDate, tomorrow),
))
.orderBy(asc(tasks.priority))
.limit(10);
return NextResponse.json({ items });
});
@@ -1,63 +0,0 @@
// 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, requireWorkspaceAccess } from '@/lib/auth';
import { db, tasks, projects, sections } from '@project-e/db';
import { and, asc, eq, gte, isNull, lte, sql } from 'drizzle-orm';
type RouteContext = { params: Promise<{ domainId: string }> };
// GET /api/domains/[domainId]/dashboard/widgets/upcoming-calendar
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
const today = new Date();
today.setHours(0, 0, 0, 0);
const nextWeek = new Date(today);
nextWeek.setDate(nextWeek.getDate() + 7);
// Tasks due in next 7 days
const upcomingTasks = await db.select({
id: tasks.id,
title: tasks.title,
dueDate: tasks.dueDate,
priority: tasks.priority,
status: tasks.status,
})
.from(tasks)
.where(and(
eq(tasks.domainId, domainId),
isNull(tasks.deletedAt),
gte(tasks.dueDate, today),
lte(tasks.dueDate, nextWeek),
))
.orderBy(asc(tasks.dueDate))
.limit(10);
// Projects with target dates in next 7 days
const upcomingProjects = await db.select({
id: projects.id,
name: projects.name,
targetDate: projects.targetDate,
status: projects.status,
color: projects.color,
})
.from(projects)
.where(and(
eq(projects.domainId, domainId),
isNull(projects.deletedAt),
gte(projects.targetDate, today),
lte(projects.targetDate, nextWeek),
))
.orderBy(asc(projects.targetDate))
.limit(5);
return NextResponse.json({
tasks: upcomingTasks,
projects: upcomingProjects,
});
});
@@ -1,66 +0,0 @@
// 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, requireWorkspaceAccess } from '@/lib/auth';
import { db, tasks, habits, habitCompletions } from '@project-e/db';
import { and, eq, gte, isNull, lte, sql } from 'drizzle-orm';
type RouteContext = { params: Promise<{ domainId: string }> };
// GET /api/domains/[domainId]/dashboard/widgets/weekly-stats
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
const now = new Date();
const weekStart = new Date(now);
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
weekStart.setHours(0, 0, 0, 0);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 7);
// Task completions this week
const [taskCompletions] = await db.select({ count: sql<number>`count(*)` })
.from(tasks)
.where(and(
eq(tasks.domainId, domainId),
eq(tasks.status, 'done'),
gte(tasks.completedAt, weekStart),
lte(tasks.completedAt, weekEnd),
isNull(tasks.deletedAt),
));
// Habit completions this week
const [habitCompletionsCount] = await db.select({ count: sql<number>`count(*)` })
.from(habitCompletions)
.innerJoin(habits, eq(habitCompletions.habitId, habits.id))
.where(and(
eq(habits.domainId, domainId),
gte(habitCompletions.date, weekStart),
lte(habitCompletions.date, weekEnd),
isNull(habits.deletedAt),
));
// Streak counts
const activeHabits = await db.select({ id: habits.id, streakCount: habits.streakCount, bestStreak: habits.bestStreak })
.from(habits)
.where(and(
eq(habits.domainId, domainId),
eq(habits.active, true),
isNull(habits.deletedAt),
));
const totalStreak = activeHabits.reduce((sum, h) => sum + (h.streakCount || 0), 0);
const bestStreak = Math.max(...activeHabits.map(h => h.bestStreak || 0), 0);
return NextResponse.json({
taskCompletions: Number(taskCompletions?.count || 0),
habitCompletions: Number(habitCompletionsCount?.count || 0),
totalStreak,
bestStreak,
activeHabits: activeHabits.length,
});
});
@@ -1,24 +0,0 @@
// 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, requireWorkspaceAccess } from '@/lib/auth';
import { getGraphData } from '@/lib/graph-service';
type RouteContext = { params: Promise<{ domainId: string }> };
// GET /api/domains/[domainId]/graph — Get graph data for one domain
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
const graphData = await getGraphData(domainId);
return NextResponse.json(graphData, {
headers: {
'Cache-Control': 'private, max-age=30, stale-while-revalidate=120',
},
});
});
@@ -1,138 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
import { recordActivity } from '@/lib/activity';
import { db, habits, habitCompletions, sql } from '@project-e/db';
import { and, eq, isNull, gte, desc, count } from 'drizzle-orm';
import { z } from 'zod';
const completeHabitSchema = z.object({
value: z.number().int().positive().optional().default(1),
mood: z.number().int().min(1).max(5).optional().nullable(),
notes: z.string().optional().nullable(),
});
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
/**
* Calculate the current streak for a habit.
* Streak = consecutive days with at least one completion, going backwards from today.
* Skip days (e.g. weekends) are excluded from the streak count.
*/
async function calculateStreak(habitId: string, skipDays: number[]): Promise<number> {
// Get all completion dates for this habit, ordered desc
const completions = await db.select({ date: habitCompletions.date })
.from(habitCompletions)
.where(eq(habitCompletions.habitId, habitId))
.orderBy(desc(habitCompletions.date));
if (completions.length === 0) return 0;
const completionDates = new Set(
completions.map(c => c.date.toISOString().split('T')[0])
);
let streak = 0;
const today = new Date();
today.setHours(0, 0, 0, 0);
const checkDate = new Date(today);
// Check up to 365 days back
for (let i = 0; i < 365; i++) {
const dateStr = checkDate.toISOString().split('T')[0];
const dayOfWeek = checkDate.getDay(); // 0=Sun, 6=Sat
if (skipDays.includes(dayOfWeek)) {
// Skip day — move on without breaking streak
checkDate.setDate(checkDate.getDate() - 1);
continue;
}
if (completionDates.has(dateStr)) {
streak++;
checkDate.setDate(checkDate.getDate() - 1);
} else {
break;
}
}
return streak;
}
// POST /api/domains/[domainId]/habits/[id]/complete — Complete a habit
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = completeHabitSchema.parse(body);
// Verify habit exists
const [habit] = await db.select()
.from(habits)
.where(and(eq(habits.id, id), eq(habits.domainId, domainId), isNull(habits.deletedAt)))
.limit(1);
if (!habit) {
return createErrorResponse('NOT_FOUND', 'Habit not found', 404);
}
// Create completion
const [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 || [];
const newStreak = await calculateStreak(id, skipDays);
// Update habit with new streak
const updateData: Record<string, unknown> = {
streakCount: newStreak,
updatedAt: new Date(),
};
// Update best streak if current is higher
if (newStreak > (habit.bestStreak || 0)) {
updateData.bestStreak = newStreak;
}
await db.update(habits)
.set(updateData)
.where(eq(habits.id, id));
// Record activity
await recordActivity({
actor: user.name,
action: 'completed',
entityType: 'habit',
entityId: id,
changes: { value: data.value, mood: data.mood, streak: newStreak },
workspaceId: domainId,
});
return NextResponse.json({
completion,
streakCount: newStreak,
bestStreak: Math.max(newStreak, habit.bestStreak || 0),
}, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[habit complete POST] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to complete habit', 500);
}
});
@@ -1,60 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse } from '@/lib/auth';
import { db, habits, habitCompletions } from '@project-e/db';
import { and, asc, desc, eq, gte, isNull, lte } from 'drizzle-orm';
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
// GET /api/domains/[domainId]/habits/[id]/completions — List completions with date range
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
// Verify habit exists
const [habit] = await db.select({ id: habits.id })
.from(habits)
.where(and(eq(habits.id, id), eq(habits.domainId, domainId), isNull(habits.deletedAt)))
.limit(1);
if (!habit) {
return createErrorResponse('NOT_FOUND', 'Habit not found', 404);
}
const { searchParams } = new URL(request.url);
const from = searchParams.get('from');
const to = searchParams.get('to');
const limit = Math.min(parseInt(searchParams.get('limit') || '365'), 1000);
const offset = parseInt(searchParams.get('offset') || '0');
const order = searchParams.get('order') || 'desc';
const conditions: any[] = [eq(habitCompletions.habitId, id)];
if (from) conditions.push(gte(habitCompletions.date, new Date(from)));
if (to) conditions.push(lte(habitCompletions.date, new Date(to)));
const orderFn = order === 'asc' ? asc : desc;
const [items, countResult] = await Promise.all([
db.select()
.from(habitCompletions)
.where(and(...conditions))
.orderBy(orderFn(habitCompletions.date))
.limit(limit)
.offset(offset),
db.select({ count: db.$count(habitCompletions) })
.from(habitCompletions)
.where(and(...conditions)),
]);
return NextResponse.json({
items,
totalItems: Number(countResult[0]?.count || 0),
limit,
offset,
});
});
@@ -1,160 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
import { recordActivity } from '@/lib/activity';
import { db, habits, habitCompletions, habitTags, tags as tagsTable } from '@project-e/db';
import { and, asc, desc, eq, gte, inArray, isNull, lte, sql } from 'drizzle-orm';
import { z } from 'zod';
const habitFrequencyEnum = z.enum(['daily', 'weekly', 'custom']);
const habitDifficultyEnum = z.enum(['easy', 'medium', 'hard']);
const updateHabitSchema = z.object({
name: z.string().min(1).optional(),
description: z.string().optional().nullable(),
frequency: habitFrequencyEnum.optional(),
difficulty: habitDifficultyEnum.optional(),
goalPerPeriod: z.number().int().positive().optional(),
unit: z.string().optional().nullable(),
reminderTime: z.string().optional().nullable(),
skipDays: z.array(z.number().int().min(0).max(6)).optional(),
moodTracking: z.boolean().optional(),
active: z.boolean().optional(),
});
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
// GET /api/domains/[domainId]/habits/[id] — Get a single habit with streak + recent completions
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
const [habit] = await db.select()
.from(habits)
.where(and(eq(habits.id, id), eq(habits.domainId, domainId), isNull(habits.deletedAt)))
.limit(1);
if (!habit) {
return createErrorResponse('NOT_FOUND', 'Habit not found', 404);
}
// Fetch recent completions (last 30 days)
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const recentCompletions = await db.select()
.from(habitCompletions)
.where(and(
eq(habitCompletions.habitId, id),
gte(habitCompletions.date, thirtyDaysAgo),
))
.orderBy(desc(habitCompletions.date));
// Fetch tags
const tagRows = await db.select({
id: tagsTable.id,
name: tagsTable.name,
color: tagsTable.color,
})
.from(habitTags)
.innerJoin(tagsTable, eq(habitTags.tagId, tagsTable.id))
.where(eq(habitTags.habitId, id));
return NextResponse.json({
...habit,
recentCompletions,
tags: tagRows,
});
});
// PATCH /api/domains/[domainId]/habits/[id] — Update a habit
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = updateHabitSchema.parse(body);
const [existing] = await db.select()
.from(habits)
.where(and(eq(habits.id, id), eq(habits.domainId, domainId), isNull(habits.deletedAt)))
.limit(1);
if (!existing) {
return createErrorResponse('NOT_FOUND', 'Habit not found', 404);
}
const updateValues: Record<string, unknown> = {};
if (data.name !== undefined) updateValues.name = data.name;
if (data.description !== undefined) updateValues.description = data.description;
if (data.frequency !== undefined) updateValues.frequency = data.frequency;
if (data.difficulty !== undefined) updateValues.difficulty = data.difficulty;
if (data.goalPerPeriod !== undefined) updateValues.goalPerPeriod = data.goalPerPeriod;
if (data.unit !== undefined) updateValues.unit = data.unit;
if (data.reminderTime !== undefined) updateValues.reminderTime = data.reminderTime;
if (data.skipDays !== undefined) updateValues.skipDays = data.skipDays;
if (data.moodTracking !== undefined) updateValues.moodTracking = data.moodTracking;
if (data.active !== undefined) updateValues.active = data.active;
updateValues.updatedAt = new Date();
const [updated] = await db.update(habits)
.set(updateValues)
.where(eq(habits.id, id))
.returning();
await recordActivity({
actor: user.name,
action: 'updated',
entityType: 'habit',
entityId: id,
changes: { ...data, previousName: existing.name },
workspaceId: domainId,
});
return NextResponse.json(updated);
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[habits PATCH] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to update habit', 500);
}
});
// DELETE /api/domains/[domainId]/habits/[id] — Soft delete a habit
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
const [existing] = await db.select()
.from(habits)
.where(and(eq(habits.id, id), eq(habits.domainId, domainId), isNull(habits.deletedAt)))
.limit(1);
if (!existing) {
return createErrorResponse('NOT_FOUND', 'Habit not found', 404);
}
await db.update(habits)
.set({ deletedAt: new Date(), updatedAt: new Date() })
.where(eq(habits.id, id));
await recordActivity({
actor: user.name,
action: 'deleted',
entityType: 'habit',
entityId: id,
changes: { name: existing.name },
workspaceId: domainId,
});
return new NextResponse(null, { status: 204 });
});
@@ -1,123 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
import { recordActivity } from '@/lib/activity';
import { db, habits, habitTags, tags as tagsTable } from '@project-e/db';
import { and, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
const tagActionSchema = z.object({
tagId: z.string().uuid(),
});
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
// POST /api/domains/[domainId]/habits/[id]/tags — Add a tag to a habit
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = tagActionSchema.parse(body);
// Verify habit exists
const [habit] = await db.select()
.from(habits)
.where(and(eq(habits.id, id), eq(habits.domainId, domainId), isNull(habits.deletedAt)))
.limit(1);
if (!habit) {
return createErrorResponse('NOT_FOUND', 'Habit not found', 404);
}
// Verify tag exists
const [tag] = await db.select()
.from(tagsTable)
.where(eq(tagsTable.id, data.tagId))
.limit(1);
if (!tag) {
return createErrorResponse('NOT_FOUND', 'Tag not found', 404);
}
// Check if already tagged
const [existing] = await db.select()
.from(habitTags)
.where(and(eq(habitTags.habitId, id), eq(habitTags.tagId, data.tagId)))
.limit(1);
if (existing) {
return createErrorResponse('CONFLICT', 'Tag already added to this habit', 409);
}
await db.insert(habitTags).values({ habitId: id, tagId: data.tagId });
await recordActivity({
actor: user.name,
action: 'tag_added',
entityType: 'habit',
entityId: id,
changes: { tagId: data.tagId, tagName: tag.name },
workspaceId: domainId,
});
return NextResponse.json({ success: true }, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[habit tags POST] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to add tag', 500);
}
});
// DELETE /api/domains/[domainId]/habits/[id]/tags — Remove a tag from a habit
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = tagActionSchema.parse(body);
const [existing] = await db.select()
.from(habitTags)
.where(and(eq(habitTags.habitId, id), eq(habitTags.tagId, data.tagId)))
.limit(1);
if (!existing) {
return createErrorResponse('NOT_FOUND', 'Tag not found on this habit', 404);
}
await db.delete(habitTags)
.where(and(eq(habitTags.habitId, id), eq(habitTags.tagId, data.tagId)));
await recordActivity({
actor: user.name,
action: 'tag_removed',
entityType: 'habit',
entityId: id,
changes: { tagId: data.tagId },
workspaceId: domainId,
});
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[habit tags DELETE] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to remove tag', 500);
}
});
@@ -1,167 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse, ApiError } 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';
import { z } from 'zod';
const habitFrequencyEnum = z.enum(['daily', 'weekly', 'custom']);
const habitDifficultyEnum = z.enum(['easy', 'medium', 'hard']);
const createHabitSchema = z.object({
name: z.string().min(1, 'Name is required'),
description: z.string().optional().nullable(),
frequency: habitFrequencyEnum.optional().default('daily'),
difficulty: habitDifficultyEnum.optional().default('medium'),
goalPerPeriod: z.number().int().positive().optional().default(1),
unit: z.string().optional().nullable(),
reminderTime: z.string().optional().nullable(),
skipDays: z.array(z.number().int().min(0).max(6)).optional().default([]),
moodTracking: z.boolean().optional().default(false),
active: z.boolean().optional().default(true),
tagIds: z.array(z.string().uuid()).optional(),
});
type RouteContext = { params: Promise<{ domainId: string }> };
// GET /api/domains/[domainId]/habits — List habits with filtering
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
const { searchParams } = new URL(request.url);
const active = searchParams.get('active');
const frequency = searchParams.get('frequency');
const difficulty = searchParams.get('difficulty');
const search = searchParams.get('search');
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
const offset = parseInt(searchParams.get('offset') || '0');
const sort = searchParams.get('sort') || 'name';
const order = searchParams.get('order') || 'asc';
const conditions: any[] = [
eq(habits.domainId, domainId),
isNull(habits.deletedAt),
];
if (active === 'true') conditions.push(eq(habits.active, true));
else if (active === 'false') conditions.push(eq(habits.active, false));
if (frequency) conditions.push(eq(habits.frequency, frequency as any));
if (difficulty) conditions.push(eq(habits.difficulty, difficulty as any));
if (search) conditions.push(ilike(habits.name, `%${search}%`));
const orderFn = order === 'desc' ? desc : asc;
let orderColumn;
switch (sort) {
case 'frequency': orderColumn = orderFn(habits.frequency); break;
case 'difficulty': orderColumn = orderFn(habits.difficulty); break;
case 'streak_count': orderColumn = orderFn(habits.streakCount); break;
case 'created_at': orderColumn = orderFn(habits.createdAt); break;
case 'updated_at': orderColumn = orderFn(habits.updatedAt); break;
default: orderColumn = orderFn(habits.name); break;
}
const [items, countResult] = await Promise.all([
db.select()
.from(habits)
.where(and(...conditions))
.orderBy(orderColumn)
.limit(limit)
.offset(offset),
db.select({ count: sql<number>`count(*)` })
.from(habits)
.where(and(...conditions)),
]);
const totalItems = Number(countResult[0]?.count || 0);
// Fetch tags for all habits
let habitTagMap = new Map<string, { id: string; name: string; color: string | null }[]>();
if (items.length > 0) {
const habitIds = items.map(h => h.id);
const tagRows = await db.select({
habitId: habitTags.habitId,
id: tagsTable.id,
name: tagsTable.name,
color: tagsTable.color,
})
.from(habitTags)
.innerJoin(tagsTable, eq(habitTags.tagId, tagsTable.id))
.where(inArray(habitTags.habitId, habitIds));
for (const row of tagRows) {
if (!habitTagMap.has(row.habitId)) habitTagMap.set(row.habitId, []);
habitTagMap.get(row.habitId)!.push({ id: row.id, name: row.name, color: row.color });
}
}
const itemsWithTags = items.map(h => ({
...h,
tags: habitTagMap.get(h.id) || [],
}));
return NextResponse.json({
items: itemsWithTags,
totalItems,
limit,
offset,
});
});
// POST /api/domains/[domainId]/habits — Create a habit
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = createHabitSchema.parse(body);
const [habit] = await db.insert(habits).values({
name: data.name,
description: data.description ?? null,
domainId,
frequency: data.frequency,
difficulty: data.difficulty,
goalPerPeriod: data.goalPerPeriod,
unit: data.unit ?? null,
reminderTime: data.reminderTime ?? null,
skipDays: data.skipDays,
moodTracking: data.moodTracking,
active: data.active,
}).returning();
// Insert tags if provided
if (data.tagIds && data.tagIds.length > 0) {
await db.insert(habitTags).values(
data.tagIds.map(tagId => ({ habitId: habit.id, tagId }))
);
}
// Record activity
await recordActivity({
actor: user.name,
action: 'created',
entityType: 'habit',
entityId: habit.id,
changes: { name: habit.name, frequency: habit.frequency, difficulty: habit.difficulty },
workspaceId: domainId,
});
return NextResponse.json(habit, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[habits POST] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to create habit', 500);
}
});
@@ -1,23 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse } from '@/lib/auth';
import { getBacklinks } from '@/lib/note-link-service';
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
// GET /api/domains/[domainId]/notes/[id]/backlinks — List notes that link to this one
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
const backlinks = await getBacklinks(id);
return NextResponse.json({
items: backlinks,
totalItems: backlinks.length,
});
});
@@ -1,147 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
import { recordActivity } from '@/lib/activity';
import { db, notes, noteTags, tags as tagsTable } from '@project-e/db';
import { and, eq, inArray, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { syncNoteLinks, getBacklinks, getOutgoingLinks } from '@/lib/note-link-service';
const updateNoteSchema = z.object({
title: z.string().min(1).optional(),
content: z.string().optional().nullable(),
isPinned: z.boolean().optional(),
isArchived: z.boolean().optional(),
});
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
// GET /api/domains/[domainId]/notes/[id] — Get a single note with computed links
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
const [note] = await db.select()
.from(notes)
.where(and(eq(notes.id, id), eq(notes.domainId, domainId), isNull(notes.deletedAt)))
.limit(1);
if (!note) {
return createErrorResponse('NOT_FOUND', 'Note not found', 404);
}
// Fetch tags
const tagRows = await db.select({
id: tagsTable.id,
name: tagsTable.name,
color: tagsTable.color,
})
.from(noteTags)
.innerJoin(tagsTable, eq(noteTags.tagId, tagsTable.id))
.where(eq(noteTags.noteId, id));
// Fetch backlinks and outgoing links
const [backlinks, outgoingLinks] = await Promise.all([
getBacklinks(id),
getOutgoingLinks(id),
]);
return NextResponse.json({
...note,
tags: tagRows,
backlinks,
outgoingLinks,
});
});
// PATCH /api/domains/[domainId]/notes/[id] — Update a note, re-parse wikilinks
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = updateNoteSchema.parse(body);
const [existing] = await db.select()
.from(notes)
.where(and(eq(notes.id, id), eq(notes.domainId, domainId), isNull(notes.deletedAt)))
.limit(1);
if (!existing) {
return createErrorResponse('NOT_FOUND', 'Note not found', 404);
}
const updateValues: Record<string, unknown> = {};
if (data.title !== undefined) updateValues.title = data.title;
if (data.content !== undefined) updateValues.content = data.content;
if (data.isPinned !== undefined) updateValues.isPinned = data.isPinned;
if (data.isArchived !== undefined) updateValues.isArchived = data.isArchived;
updateValues.updatedAt = new Date();
const [updated] = await db.update(notes)
.set(updateValues)
.where(eq(notes.id, id))
.returning();
// Re-sync wikilinks if content changed
const content = data.content ?? existing.content;
if (content) {
await syncNoteLinks(id, content);
}
await recordActivity({
actor: user.name,
action: 'updated',
entityType: 'note',
entityId: id,
changes: { ...data, previousTitle: existing.title },
workspaceId: domainId,
});
return NextResponse.json(updated);
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[notes PATCH] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to update note', 500);
}
});
// DELETE /api/domains/[domainId]/notes/[id] — Soft delete a note
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
const [existing] = await db.select()
.from(notes)
.where(and(eq(notes.id, id), eq(notes.domainId, domainId), isNull(notes.deletedAt)))
.limit(1);
if (!existing) {
return createErrorResponse('NOT_FOUND', 'Note not found', 404);
}
await db.update(notes)
.set({ deletedAt: new Date(), updatedAt: new Date() })
.where(eq(notes.id, id));
await recordActivity({
actor: user.name,
action: 'deleted',
entityType: 'note',
entityId: id,
changes: { title: existing.title },
workspaceId: domainId,
});
return new NextResponse(null, { status: 204 });
});
@@ -1,123 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
import { recordActivity } from '@/lib/activity';
import { db, notes, noteTags, tags as tagsTable } from '@project-e/db';
import { and, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
const tagActionSchema = z.object({
tagId: z.string().uuid(),
});
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
// POST /api/domains/[domainId]/notes/[id]/tags — Add a tag to a note
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = tagActionSchema.parse(body);
// Verify note exists
const [note] = await db.select()
.from(notes)
.where(and(eq(notes.id, id), eq(notes.domainId, domainId), isNull(notes.deletedAt)))
.limit(1);
if (!note) {
return createErrorResponse('NOT_FOUND', 'Note not found', 404);
}
// Verify tag exists
const [tag] = await db.select()
.from(tagsTable)
.where(eq(tagsTable.id, data.tagId))
.limit(1);
if (!tag) {
return createErrorResponse('NOT_FOUND', 'Tag not found', 404);
}
// Check if already tagged
const [existing] = await db.select()
.from(noteTags)
.where(and(eq(noteTags.noteId, id), eq(noteTags.tagId, data.tagId)))
.limit(1);
if (existing) {
return createErrorResponse('CONFLICT', 'Tag already added to this note', 409);
}
await db.insert(noteTags).values({ noteId: id, tagId: data.tagId });
await recordActivity({
actor: user.name,
action: 'tag_added',
entityType: 'note',
entityId: id,
changes: { tagId: data.tagId, tagName: tag.name },
workspaceId: domainId,
});
return NextResponse.json({ success: true }, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[note tags POST] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to add tag', 500);
}
});
// DELETE /api/domains/[domainId]/notes/[id]/tags — Remove a tag from a note
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = tagActionSchema.parse(body);
const [existing] = await db.select()
.from(noteTags)
.where(and(eq(noteTags.noteId, id), eq(noteTags.tagId, data.tagId)))
.limit(1);
if (!existing) {
return createErrorResponse('NOT_FOUND', 'Tag not found on this note', 404);
}
await db.delete(noteTags)
.where(and(eq(noteTags.noteId, id), eq(noteTags.tagId, data.tagId)));
await recordActivity({
actor: user.name,
action: 'tag_removed',
entityType: 'note',
entityId: id,
changes: { tagId: data.tagId },
workspaceId: domainId,
});
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[note tags DELETE] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to remove tag', 500);
}
});
@@ -1,154 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
import { recordActivity } from '@/lib/activity';
import { db, notes, noteTags, tags as tagsTable } from '@project-e/db';
import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm';
import { z } from 'zod';
import { syncNoteLinks } from '@/lib/note-link-service';
const createNoteSchema = z.object({
title: z.string().min(1, 'Title is required'),
content: z.string().optional().nullable(),
isPinned: z.boolean().optional().default(false),
isArchived: z.boolean().optional().default(false),
tagIds: z.array(z.string().uuid()).optional(),
});
type RouteContext = { params: Promise<{ domainId: string }> };
// GET /api/domains/[domainId]/notes — List notes with filtering
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
const { searchParams } = new URL(request.url);
const pinned = searchParams.get('pinned');
const archived = searchParams.get('archived');
const search = searchParams.get('search');
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
const offset = parseInt(searchParams.get('offset') || '0');
const sort = searchParams.get('sort') || 'updated_at';
const order = searchParams.get('order') || 'desc';
const conditions: any[] = [
eq(notes.domainId, domainId),
isNull(notes.deletedAt),
];
if (pinned === 'true') conditions.push(eq(notes.isPinned, true));
if (archived === 'true') conditions.push(eq(notes.isArchived, true));
else if (archived !== 'all') conditions.push(eq(notes.isArchived, false));
if (search) conditions.push(ilike(notes.title, `%${search}%`));
const orderFn = order === 'desc' ? desc : asc;
let orderColumn;
switch (sort) {
case 'title': orderColumn = orderFn(notes.title); break;
case 'created_at': orderColumn = orderFn(notes.createdAt); break;
case 'is_pinned': orderColumn = orderFn(notes.isPinned); break;
default: orderColumn = orderFn(notes.updatedAt); break;
}
const [items, countResult] = await Promise.all([
db.select()
.from(notes)
.where(and(...conditions))
.orderBy(orderColumn)
.limit(limit)
.offset(offset),
db.select({ count: sql<number>`count(*)` })
.from(notes)
.where(and(...conditions)),
]);
const totalItems = Number(countResult[0]?.count || 0);
// Fetch tags for all notes
let noteTagMap = new Map<string, { id: string; name: string; color: string | null }[]>();
if (items.length > 0) {
const noteIds = items.map(n => n.id);
const tagRows = await db.select({
noteId: noteTags.noteId,
id: tagsTable.id,
name: tagsTable.name,
color: tagsTable.color,
})
.from(noteTags)
.innerJoin(tagsTable, eq(noteTags.tagId, tagsTable.id))
.where(inArray(noteTags.noteId, noteIds));
for (const row of tagRows) {
if (!noteTagMap.has(row.noteId)) noteTagMap.set(row.noteId, []);
noteTagMap.get(row.noteId)!.push({ id: row.id, name: row.name, color: row.color });
}
}
const itemsWithTags = items.map(n => ({
...n,
tags: noteTagMap.get(n.id) || [],
}));
return NextResponse.json({
items: itemsWithTags,
totalItems,
limit,
offset,
});
});
// POST /api/domains/[domainId]/notes — Create a note
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = createNoteSchema.parse(body);
const [note] = await db.insert(notes).values({
title: data.title,
content: data.content ?? null,
domainId,
isPinned: data.isPinned,
isArchived: data.isArchived,
}).returning();
// Insert tags if provided
if (data.tagIds && data.tagIds.length > 0) {
await db.insert(noteTags).values(
data.tagIds.map(tagId => ({ noteId: note.id, tagId }))
);
}
// Sync wikilinks from content
if (data.content) {
await syncNoteLinks(note.id, data.content);
}
// Record activity
await recordActivity({
actor: user.name,
action: 'created',
entityType: 'note',
entityId: note.id,
changes: { title: note.title },
workspaceId: domainId,
});
return NextResponse.json(note, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[notes POST] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to create note', 500);
}
});
@@ -1,160 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
import { recordActivity } from '@/lib/activity';
import { db, projects, tasks, sections, projectTags, tags as tagsTable } from '@project-e/db';
import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm';
import { z } from 'zod';
const projectStatusEnum = z.enum(['active', 'paused', 'completed', 'archived']);
const updateProjectSchema = z.object({
name: z.string().min(1).optional(),
description: z.string().optional().nullable(),
status: projectStatusEnum.optional(),
color: z.string().optional().nullable(),
icon: z.string().optional().nullable(),
targetDate: z.string().datetime().optional().nullable(),
});
type RouteContext = { params: Promise<{ domainId: string; projectId: string }> };
// GET /api/domains/[domainId]/projects/[id] — Get a single project with sections, task counts, progress
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, projectId: id } = await context!.params;
await requireWorkspaceAccess(domainId);
const [project] = await db.select()
.from(projects)
.where(and(eq(projects.id, id), eq(projects.domainId, domainId), isNull(projects.deletedAt)))
.limit(1);
if (!project) {
return createErrorResponse('NOT_FOUND', 'Project not found', 404);
}
// Fetch sections
const projectSections = await db.select()
.from(sections)
.where(eq(sections.projectId, id))
.orderBy(asc(sections.sortOrder));
// Fetch tasks grouped by section
const projectTasks = await db.select()
.from(tasks)
.where(and(eq(tasks.projectId, id), isNull(tasks.deletedAt)))
.orderBy(asc(tasks.order));
// Fetch tags
const tagRows = await db.select({
id: tagsTable.id,
name: tagsTable.name,
color: tagsTable.color,
})
.from(projectTags)
.innerJoin(tagsTable, eq(projectTags.tagId, tagsTable.id))
.where(eq(projectTags.projectId, id));
// Compute counts
const totalTasks = projectTasks.length;
const completedTasks = projectTasks.filter(t => t.status === 'done').length;
const progress = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
return NextResponse.json({
...project,
sections: projectSections,
tasks: projectTasks,
tags: tagRows,
taskCount: totalTasks,
completedCount: completedTasks,
progress,
});
});
// PATCH /api/domains/[domainId]/projects/[id] — Update a project
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, projectId: id } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = updateProjectSchema.parse(body);
const [existing] = await db.select()
.from(projects)
.where(and(eq(projects.id, id), eq(projects.domainId, domainId), isNull(projects.deletedAt)))
.limit(1);
if (!existing) {
return createErrorResponse('NOT_FOUND', 'Project not found', 404);
}
const updateValues: Record<string, unknown> = {};
if (data.name !== undefined) updateValues.name = data.name;
if (data.description !== undefined) updateValues.description = data.description;
if (data.status !== undefined) updateValues.status = data.status;
if (data.color !== undefined) updateValues.color = data.color;
if (data.icon !== undefined) updateValues.icon = data.icon;
if (data.targetDate !== undefined) updateValues.targetDate = data.targetDate ? new Date(data.targetDate) : null;
updateValues.updatedAt = new Date();
const [updated] = await db.update(projects)
.set(updateValues)
.where(eq(projects.id, id))
.returning();
await recordActivity({
actor: user.name,
action: 'updated',
entityType: 'project',
entityId: id,
changes: { ...data, previousName: existing.name },
workspaceId: domainId,
});
return NextResponse.json(updated);
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[projects PATCH] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to update project', 500);
}
});
// DELETE /api/domains/[domainId]/projects/[id] — Soft delete a project
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, projectId: id } = await context!.params;
await requireWorkspaceAccess(domainId);
const [existing] = await db.select()
.from(projects)
.where(and(eq(projects.id, id), eq(projects.domainId, domainId), isNull(projects.deletedAt)))
.limit(1);
if (!existing) {
return createErrorResponse('NOT_FOUND', 'Project not found', 404);
}
await db.update(projects)
.set({ deletedAt: new Date(), updatedAt: new Date() })
.where(eq(projects.id, id));
await recordActivity({
actor: user.name,
action: 'deleted',
entityType: 'project',
entityId: id,
changes: { name: existing.name },
workspaceId: domainId,
});
return new NextResponse(null, { status: 204 });
});
@@ -1,123 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
import { recordActivity } from '@/lib/activity';
import { db, projects, sections } from '@project-e/db';
import { and, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
const sectionKindEnum = z.enum(['section', 'milestone']);
const sectionStatusEnum = z.enum(['planned', 'in_progress', 'complete']);
const updateSectionSchema = z.object({
name: z.string().min(1).optional(),
kind: sectionKindEnum.optional(),
status: sectionStatusEnum.optional(),
targetDate: z.string().datetime().optional().nullable(),
sortOrder: z.number().int().optional(),
});
type RouteContext = { params: Promise<{ domainId: string; projectId: string; id: string }> };
// GET /api/domains/[domainId]/projects/[projectId]/sections/[id] — Get a single section
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, projectId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
const [section] = await db.select()
.from(sections)
.where(and(eq(sections.id, id), eq(sections.projectId, projectId)))
.limit(1);
if (!section) {
return createErrorResponse('NOT_FOUND', 'Section not found', 404);
}
return NextResponse.json(section);
});
// PATCH /api/domains/[domainId]/projects/[projectId]/sections/[id] — Update a section
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, projectId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = updateSectionSchema.parse(body);
const [existing] = await db.select()
.from(sections)
.where(and(eq(sections.id, id), eq(sections.projectId, projectId)))
.limit(1);
if (!existing) {
return createErrorResponse('NOT_FOUND', 'Section not found', 404);
}
const updateValues: Record<string, unknown> = {};
if (data.name !== undefined) updateValues.name = data.name;
if (data.kind !== undefined) updateValues.kind = data.kind;
if (data.status !== undefined) updateValues.status = data.status;
if (data.targetDate !== undefined) updateValues.targetDate = data.targetDate ? new Date(data.targetDate) : null;
if (data.sortOrder !== undefined) updateValues.sortOrder = data.sortOrder;
updateValues.updatedAt = new Date();
const [updated] = await db.update(sections)
.set(updateValues)
.where(eq(sections.id, id))
.returning();
await recordActivity({
actor: user.name,
action: 'updated',
entityType: 'section',
entityId: id,
changes: { ...data, previousName: existing.name, projectId },
workspaceId: domainId,
});
return NextResponse.json(updated);
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[sections PATCH] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to update section', 500);
}
});
// DELETE /api/domains/[domainId]/projects/[projectId]/sections/[id] — Delete a section
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, projectId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
const [existing] = await db.select()
.from(sections)
.where(and(eq(sections.id, id), eq(sections.projectId, projectId)))
.limit(1);
if (!existing) {
return createErrorResponse('NOT_FOUND', 'Section not found', 404);
}
await db.delete(sections)
.where(eq(sections.id, id));
await recordActivity({
actor: user.name,
action: 'deleted',
entityType: 'section',
entityId: id,
changes: { name: existing.name, projectId },
workspaceId: domainId,
});
return new NextResponse(null, { status: 204 });
});
@@ -1,106 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
import { recordActivity } from '@/lib/activity';
import { db, projects, sections } from '@project-e/db';
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
import { z } from 'zod';
const sectionKindEnum = z.enum(['section', 'milestone']);
const sectionStatusEnum = z.enum(['planned', 'in_progress', 'complete']);
const createSectionSchema = z.object({
name: z.string().min(1, 'Name is required'),
kind: sectionKindEnum.optional().default('section'),
status: sectionStatusEnum.optional().default('planned'),
targetDate: z.string().datetime().optional().nullable(),
sortOrder: z.number().int().optional(),
});
type RouteContext = { params: Promise<{ domainId: string; projectId: string }> };
// GET /api/domains/[domainId]/projects/[projectId]/sections — List sections for a project
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, projectId } = await context!.params;
await requireWorkspaceAccess(domainId);
// Verify project exists and belongs to domain
const [project] = await db.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.id, projectId), eq(projects.domainId, domainId), isNull(projects.deletedAt)))
.limit(1);
if (!project) {
return createErrorResponse('NOT_FOUND', 'Project not found', 404);
}
const items = await db.select()
.from(sections)
.where(eq(sections.projectId, projectId))
.orderBy(asc(sections.sortOrder));
return NextResponse.json({ items });
});
// POST /api/domains/[domainId]/projects/[projectId]/sections — Create a section
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, projectId } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = createSectionSchema.parse(body);
// Verify project exists
const [project] = await db.select({ id: projects.id, name: projects.name })
.from(projects)
.where(and(eq(projects.id, projectId), eq(projects.domainId, domainId), isNull(projects.deletedAt)))
.limit(1);
if (!project) {
return createErrorResponse('NOT_FOUND', 'Project not found', 404);
}
// Determine sort order if not provided
let sortOrder = data.sortOrder;
if (sortOrder === undefined) {
const [maxOrder] = await db.select({ max: sql<number>`COALESCE(MAX(sort_order), -1)` })
.from(sections)
.where(eq(sections.projectId, projectId));
sortOrder = Number(maxOrder?.max || -1) + 1;
}
const [section] = await db.insert(sections).values({
name: data.name,
projectId,
kind: data.kind,
status: data.status,
targetDate: data.targetDate ? new Date(data.targetDate) : null,
sortOrder,
}).returning();
await recordActivity({
actor: user.name,
action: 'created',
entityType: 'section',
entityId: section.id,
changes: { name: section.name, projectId, projectName: project.name, kind: section.kind },
workspaceId: domainId,
});
return NextResponse.json(section, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[sections POST] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to create section', 500);
}
});
@@ -1,181 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
import { recordActivity } from '@/lib/activity';
import { db, projects, tasks, sections, projectTags, tags as tagsTable } from '@project-e/db';
import { and, asc, desc, eq, ilike, inArray, isNull, sql } from 'drizzle-orm';
import { z } from 'zod';
const projectStatusEnum = z.enum(['active', 'paused', 'completed', 'archived']);
const createProjectSchema = z.object({
name: z.string().min(1, 'Name is required'),
description: z.string().optional().nullable(),
status: projectStatusEnum.optional().default('active'),
color: z.string().optional().nullable(),
icon: z.string().optional().nullable(),
targetDate: z.string().datetime().optional().nullable(),
tagIds: z.array(z.string().uuid()).optional(),
});
type RouteContext = { params: Promise<{ domainId: string }> };
// GET /api/domains/[domainId]/projects — List projects with filtering
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
const { searchParams } = new URL(request.url);
const status = searchParams.get('status');
const search = searchParams.get('search');
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
const offset = parseInt(searchParams.get('offset') || '0');
const sort = searchParams.get('sort') || 'name';
const order = searchParams.get('order') || 'asc';
const conditions: any[] = [
eq(projects.domainId, domainId),
isNull(projects.deletedAt),
];
if (status) {
const statuses = status.split(',');
conditions.push(inArray(projects.status, statuses as any));
}
if (search) conditions.push(ilike(projects.name, `%${search}%`));
const orderFn = order === 'desc' ? desc : asc;
let orderColumn;
switch (sort) {
case 'status': orderColumn = orderFn(projects.status); break;
case 'target_date': orderColumn = orderFn(projects.targetDate); break;
case 'created_at': orderColumn = orderFn(projects.createdAt); break;
case 'updated_at': orderColumn = orderFn(projects.updatedAt); break;
default: orderColumn = orderFn(projects.name); break;
}
const [items, countResult] = await Promise.all([
db.select()
.from(projects)
.where(and(...conditions))
.orderBy(orderColumn)
.limit(limit)
.offset(offset),
db.select({ count: sql<number>`count(*)` })
.from(projects)
.where(and(...conditions)),
]);
const totalItems = Number(countResult[0]?.count || 0);
// Fetch task counts and tags for all projects
let projectTagMap = new Map<string, { id: string; name: string; color: string | null }[]>();
let taskCountMap = new Map<string, { total: number; completed: number }>();
if (items.length > 0) {
const projectIds = items.map(p => p.id);
// Tags
const tagRows = await db.select({
projectId: projectTags.projectId,
id: tagsTable.id,
name: tagsTable.name,
color: tagsTable.color,
})
.from(projectTags)
.innerJoin(tagsTable, eq(projectTags.tagId, tagsTable.id))
.where(inArray(projectTags.projectId, projectIds));
for (const row of tagRows) {
if (!projectTagMap.has(row.projectId)) projectTagMap.set(row.projectId, []);
projectTagMap.get(row.projectId)!.push({ id: row.id, name: row.name, color: row.color });
}
// Task counts
for (const projectId of projectIds) {
const [totalResult] = await db.select({ count: sql<number>`count(*)` })
.from(tasks)
.where(and(eq(tasks.projectId, projectId), isNull(tasks.deletedAt)));
const [completedResult] = await db.select({ count: sql<number>`count(*)` })
.from(tasks)
.where(and(eq(tasks.projectId, projectId), eq(tasks.status, 'done'), isNull(tasks.deletedAt)));
taskCountMap.set(projectId, {
total: Number(totalResult?.count || 0),
completed: Number(completedResult?.count || 0),
});
}
}
const itemsWithMeta = items.map(p => {
const counts = taskCountMap.get(p.id) || { total: 0, completed: 0 };
return {
...p,
tags: projectTagMap.get(p.id) || [],
taskCount: counts.total,
completedCount: counts.completed,
progress: counts.total > 0 ? Math.round((counts.completed / counts.total) * 100) : 0,
};
});
return NextResponse.json({
items: itemsWithMeta,
totalItems,
limit,
offset,
});
});
// POST /api/domains/[domainId]/projects — Create a project
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = createProjectSchema.parse(body);
const [project] = await db.insert(projects).values({
name: data.name,
description: data.description ?? null,
status: data.status,
domainId,
color: data.color ?? null,
icon: data.icon ?? null,
targetDate: data.targetDate ? new Date(data.targetDate) : null,
}).returning();
// Insert tags if provided
if (data.tagIds && data.tagIds.length > 0) {
await db.insert(projectTags).values(
data.tagIds.map(tagId => ({ projectId: project.id, tagId }))
);
}
// Record activity
await recordActivity({
actor: user.name,
action: 'created',
entityType: 'project',
entityId: project.id,
changes: { name: project.name, status: project.status },
workspaceId: domainId,
});
return NextResponse.json(project, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[projects POST] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to create project', 500);
}
});
@@ -1,51 +0,0 @@
// 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 } from '@/lib/auth';
import { createPocketBaseClient } from '@/lib/pocketbase';
import { updateDomainSchema } from '@project-e/shared';
import { z } from 'zod';
type RouteContext = { params: Promise<{ domainId: string }> };
// GET /api/domains/[id] — Get a single domain
export const GET = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
const { domainId: id } = await context!.params;
const pb = createPocketBaseClient();
const domain = await pb.collection('domains').getOne(id);
return NextResponse.json(domain);
});
// PATCH /api/domains/[id] — Update a domain
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
try {
const { domainId: id } = await context!.params;
const body = await request.json();
const data = updateDomainSchema.parse(body);
const pb = createPocketBaseClient();
const domain = await pb.collection('domains').update(id, data);
return NextResponse.json(domain);
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
throw error;
}
});
// DELETE /api/domains/[id] — Delete a domain
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
const { domainId: id } = await context!.params;
const pb = createPocketBaseClient();
await pb.collection('domains').delete(id);
return new NextResponse(null, { status: 204 });
});
@@ -1,90 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse } from '@/lib/auth';
import { recordActivity } from '@/lib/activity';
import { db, tasks } from '@project-e/db';
import { and, eq, isNull } from 'drizzle-orm';
import { RRule } from 'rrule';
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
// POST /api/domains/[domainId]/tasks/[id]/complete — Mark task as done
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
const [existing] = await db.select()
.from(tasks)
.where(and(eq(tasks.id, id), eq(tasks.domainId, domainId), isNull(tasks.deletedAt)))
.limit(1);
if (!existing) {
return createErrorResponse('NOT_FOUND', 'Task not found', 404);
}
const [updated] = await db.update(tasks)
.set({
status: 'done',
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(tasks.id, id))
.returning();
await recordActivity({
actor: user.name,
action: 'completed',
entityType: 'task',
entityId: id,
changes: { previousStatus: existing.status },
workspaceId: domainId,
});
// Auto-create next recurring instance if recurrenceRule is set
if (existing.recurrenceRule) {
try {
const rule = RRule.fromString(existing.recurrenceRule);
const nextOccurrence = rule.after(new Date(), true);
if (nextOccurrence) {
const [spawned] = await db.insert(tasks).values({
title: existing.title,
description: existing.description,
status: 'todo',
priority: existing.priority,
domainId: existing.domainId,
projectId: existing.projectId,
sectionId: existing.sectionId,
parentId: existing.parentId,
dueDate: nextOccurrence,
estimatedMinutes: existing.estimatedMinutes,
order: existing.order,
customFields: existing.customFields ?? {},
recurrenceRule: existing.recurrenceRule,
}).returning();
await recordActivity({
actor: user.name,
action: 'created',
entityType: 'task',
entityId: spawned.id,
changes: {
title: spawned.title,
note: 'Auto-created from recurring task',
sourceTaskId: id,
},
workspaceId: domainId,
});
}
} catch (err) {
console.error('[tasks complete] Failed to spawn recurring instance:', err);
// Don't fail the completion — the original task is already marked done
}
}
return NextResponse.json(updated);
});
@@ -1,162 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
import { recordActivity } from '@/lib/activity';
import { db, tasks, taskDependencies } from '@project-e/db';
import { and, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
const addDependencySchema = z.object({
taskId: z.string().uuid(),
});
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
/**
* Cycle detection: check if adding dep (taskId -> dependsOnTaskId) would create a cycle.
* Uses BFS from dependsOnTaskId following the dependency chain.
*/
async function wouldCreateCycle(taskId: string, dependsOnTaskId: string): Promise<boolean> {
if (taskId === dependsOnTaskId) return true;
// BFS: follow dependencies from dependsOnTaskId to see if we reach taskId
const visited = new Set<string>();
const queue = [dependsOnTaskId];
while (queue.length > 0) {
const current = queue.shift()!;
if (current === taskId) return true;
if (visited.has(current)) continue;
visited.add(current);
const deps = await db.select({ dependsOnTaskId: taskDependencies.dependsOnTaskId })
.from(taskDependencies)
.where(eq(taskDependencies.taskId, current));
for (const dep of deps) {
if (!visited.has(dep.dependsOnTaskId)) {
queue.push(dep.dependsOnTaskId);
}
}
}
return false;
}
// POST /api/domains/[domainId]/tasks/[id]/dependencies — Add a dependency
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = addDependencySchema.parse(body);
// Verify both tasks exist
const [task] = await db.select()
.from(tasks)
.where(and(eq(tasks.id, id), eq(tasks.domainId, domainId), isNull(tasks.deletedAt)))
.limit(1);
if (!task) {
return createErrorResponse('NOT_FOUND', 'Task not found', 404);
}
const [depTask] = await db.select()
.from(tasks)
.where(and(eq(tasks.id, data.taskId), eq(tasks.domainId, domainId), isNull(tasks.deletedAt)))
.limit(1);
if (!depTask) {
return createErrorResponse('NOT_FOUND', 'Dependency task not found', 404);
}
// Cycle detection
const cycle = await wouldCreateCycle(id, data.taskId);
if (cycle) {
return createErrorResponse('CONFLICT', 'Adding this dependency would create a cycle', 400);
}
// Check if dependency already exists
const [existing] = await db.select()
.from(taskDependencies)
.where(and(eq(taskDependencies.taskId, id), eq(taskDependencies.dependsOnTaskId, data.taskId)))
.limit(1);
if (existing) {
return createErrorResponse('CONFLICT', 'Dependency already exists', 409);
}
await db.insert(taskDependencies).values({
taskId: id,
dependsOnTaskId: data.taskId,
});
await recordActivity({
actor: user.name,
action: 'dependency_added',
entityType: 'task',
entityId: id,
changes: { dependsOnTaskId: data.taskId, dependsOnTitle: depTask.title },
workspaceId: domainId,
});
return NextResponse.json({ success: true }, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[dependencies POST] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to add dependency', 500);
}
});
// DELETE /api/domains/[domainId]/tasks/[id]/dependencies — Remove a dependency
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = addDependencySchema.parse(body);
const [existing] = await db.select()
.from(taskDependencies)
.where(and(eq(taskDependencies.taskId, id), eq(taskDependencies.dependsOnTaskId, data.taskId)))
.limit(1);
if (!existing) {
return createErrorResponse('NOT_FOUND', 'Dependency not found', 404);
}
await db.delete(taskDependencies)
.where(and(eq(taskDependencies.taskId, id), eq(taskDependencies.dependsOnTaskId, data.taskId)));
await recordActivity({
actor: user.name,
action: 'dependency_removed',
entityType: 'task',
entityId: id,
changes: { dependsOnTaskId: data.taskId },
workspaceId: domainId,
});
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[dependencies DELETE] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to remove dependency', 500);
}
});
@@ -1,205 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
import { recordActivity } from '@/lib/activity';
import { db, tasks, taskTags, tags as tagsTable, taskDependencies } from '@project-e/db';
import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm';
import { z } from 'zod';
const taskStatusEnum = z.enum(['todo', 'in_progress', 'done', 'cancelled']);
const taskPriorityEnum = z.enum(['low', 'medium', 'high', 'urgent']);
const updateTaskSchema = z.object({
title: z.string().min(1).optional(),
description: z.string().optional().nullable(),
status: taskStatusEnum.optional(),
priority: taskPriorityEnum.optional(),
projectId: z.string().uuid().optional().nullable(),
sectionId: z.string().uuid().optional().nullable(),
parentId: z.string().uuid().optional().nullable(),
dueDate: z.string().datetime().optional().nullable(),
estimatedMinutes: z.number().int().positive().optional().nullable(),
order: z.number().int().optional(),
customFields: z.record(z.string(), z.unknown()).optional(),
recurrenceRule: z.string().optional().nullable(),
});
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
// GET /api/domains/[domainId]/tasks/[id] — Get a single task with subtasks + dependencies
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
const [task] = await db.select()
.from(tasks)
.where(and(eq(tasks.id, id), eq(tasks.domainId, domainId), isNull(tasks.deletedAt)))
.limit(1);
if (!task) {
return createErrorResponse('NOT_FOUND', 'Task not found', 404);
}
// Fetch subtasks
const subtasks = await db.select()
.from(tasks)
.where(and(eq(tasks.parentId, id), isNull(tasks.deletedAt)))
.orderBy(asc(tasks.order));
// Fetch tags
const tagRows = await db.select({
id: tagsTable.id,
name: tagsTable.name,
color: tagsTable.color,
})
.from(taskTags)
.innerJoin(tagsTable, eq(taskTags.tagId, tagsTable.id))
.where(eq(taskTags.taskId, id));
// Fetch dependencies (tasks this task depends on)
const depRows = await db.select({
id: tasks.id,
title: tasks.title,
status: tasks.status,
})
.from(taskDependencies)
.innerJoin(tasks, eq(taskDependencies.dependsOnTaskId, tasks.id))
.where(and(eq(taskDependencies.taskId, id), isNull(tasks.deletedAt)));
// Fetch dependents (tasks that depend on this task)
const dependentRows = await db.select({
id: tasks.id,
title: tasks.title,
status: tasks.status,
})
.from(taskDependencies)
.innerJoin(tasks, eq(taskDependencies.taskId, tasks.id))
.where(and(eq(taskDependencies.dependsOnTaskId, id), isNull(tasks.deletedAt)));
return NextResponse.json({
...task,
subtasks,
tags: tagRows,
dependencies: depRows,
dependents: dependentRows,
});
});
// PATCH /api/domains/[domainId]/tasks/[id] — Update a task
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = updateTaskSchema.parse(body);
// Verify task exists
const [existing] = await db.select()
.from(tasks)
.where(and(eq(tasks.id, id), eq(tasks.domainId, domainId), isNull(tasks.deletedAt)))
.limit(1);
if (!existing) {
return createErrorResponse('NOT_FOUND', 'Task not found', 404);
}
// Cycle detection for parentId (can't set parent to self or descendant)
if (data.parentId && data.parentId === id) {
return createErrorResponse('VALIDATION_ERROR', 'A task cannot be its own parent', 400);
}
if (data.parentId) {
// Check for cycles in parent chain
let currentParentId: string | null = data.parentId;
const visited = new Set<string>([id]);
while (currentParentId) {
if (visited.has(currentParentId)) {
return createErrorResponse('VALIDATION_ERROR', 'Circular parent reference detected', 400);
}
visited.add(currentParentId);
const [parent] = await db.select({ parentId: tasks.parentId })
.from(tasks)
.where(eq(tasks.id, currentParentId))
.limit(1);
currentParentId = parent?.parentId ?? null;
}
}
// Build update object
const updateValues: Record<string, unknown> = {};
if (data.title !== undefined) updateValues.title = data.title;
if (data.description !== undefined) updateValues.description = data.description;
if (data.status !== undefined) updateValues.status = data.status;
if (data.priority !== undefined) updateValues.priority = data.priority;
if (data.projectId !== undefined) updateValues.projectId = data.projectId;
if (data.sectionId !== undefined) updateValues.sectionId = data.sectionId;
if (data.parentId !== undefined) updateValues.parentId = data.parentId;
if (data.dueDate !== undefined) updateValues.dueDate = data.dueDate ? new Date(data.dueDate) : null;
if (data.estimatedMinutes !== undefined) updateValues.estimatedMinutes = data.estimatedMinutes;
if (data.order !== undefined) updateValues.order = data.order;
if (data.customFields !== undefined) updateValues.customFields = data.customFields;
if (data.recurrenceRule !== undefined) updateValues.recurrenceRule = data.recurrenceRule;
updateValues.updatedAt = new Date();
const [updated] = await db.update(tasks)
.set(updateValues)
.where(eq(tasks.id, id))
.returning();
// Record activity
await recordActivity({
actor: user.name,
action: 'updated',
entityType: 'task',
entityId: id,
changes: { ...data, previousStatus: existing.status },
workspaceId: domainId,
});
return NextResponse.json(updated);
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[tasks PATCH] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to update task', 500);
}
});
// DELETE /api/domains/[domainId]/tasks/[id] — Soft delete a task
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
const [existing] = await db.select()
.from(tasks)
.where(and(eq(tasks.id, id), eq(tasks.domainId, domainId), isNull(tasks.deletedAt)))
.limit(1);
if (!existing) {
return createErrorResponse('NOT_FOUND', 'Task not found', 404);
}
await db.update(tasks)
.set({ deletedAt: new Date(), updatedAt: new Date() })
.where(eq(tasks.id, id));
// Record activity
await recordActivity({
actor: user.name,
action: 'deleted',
entityType: 'task',
entityId: id,
changes: { title: existing.title },
workspaceId: domainId,
});
return new NextResponse(null, { status: 204 });
});
@@ -1,67 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
import { recordActivity } from '@/lib/activity';
import { db, tasks } from '@project-e/db';
import { and, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
const scheduleSchema = z.object({
dueDate: z.string().datetime().nullable(),
});
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
// PATCH /api/domains/[domainId]/tasks/[id]/schedule — Reschedule a task via drag
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = scheduleSchema.parse(body);
// Verify task exists
const [existing] = await db.select()
.from(tasks)
.where(and(eq(tasks.id, id), eq(tasks.domainId, domainId), isNull(tasks.deletedAt)))
.limit(1);
if (!existing) {
return createErrorResponse('NOT_FOUND', 'Task not found', 404);
}
const [updated] = await db.update(tasks)
.set({
dueDate: data.dueDate ? new Date(data.dueDate) : null,
updatedAt: new Date(),
})
.where(eq(tasks.id, id))
.returning();
// Record activity
await recordActivity({
actor: user.name,
action: 'updated',
entityType: 'task',
entityId: id,
changes: { dueDate: data.dueDate, previousDueDate: existing.dueDate?.toISOString() || null },
workspaceId: domainId,
});
return NextResponse.json(updated);
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[schedule PATCH] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to reschedule task', 500);
}
});
@@ -1,123 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
import { recordActivity } from '@/lib/activity';
import { db, tasks, taskTags, tags as tagsTable } from '@project-e/db';
import { and, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
const tagActionSchema = z.object({
tagId: z.string().uuid(),
});
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
// POST /api/domains/[domainId]/tasks/[id]/tags — Add a tag to a task
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = tagActionSchema.parse(body);
// Verify task exists
const [task] = await db.select()
.from(tasks)
.where(and(eq(tasks.id, id), eq(tasks.domainId, domainId), isNull(tasks.deletedAt)))
.limit(1);
if (!task) {
return createErrorResponse('NOT_FOUND', 'Task not found', 404);
}
// Verify tag exists
const [tag] = await db.select()
.from(tagsTable)
.where(eq(tagsTable.id, data.tagId))
.limit(1);
if (!tag) {
return createErrorResponse('NOT_FOUND', 'Tag not found', 404);
}
// Check if already tagged
const [existing] = await db.select()
.from(taskTags)
.where(and(eq(taskTags.taskId, id), eq(taskTags.tagId, data.tagId)))
.limit(1);
if (existing) {
return createErrorResponse('CONFLICT', 'Tag already added to this task', 409);
}
await db.insert(taskTags).values({ taskId: id, tagId: data.tagId });
await recordActivity({
actor: user.name,
action: 'tag_added',
entityType: 'task',
entityId: id,
changes: { tagId: data.tagId, tagName: tag.name },
workspaceId: domainId,
});
return NextResponse.json({ success: true }, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[tags POST] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to add tag', 500);
}
});
// DELETE /api/domains/[domainId]/tasks/[id]/tags — Remove a tag from a task
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = tagActionSchema.parse(body);
const [existing] = await db.select()
.from(taskTags)
.where(and(eq(taskTags.taskId, id), eq(taskTags.tagId, data.tagId)))
.limit(1);
if (!existing) {
return createErrorResponse('NOT_FOUND', 'Tag not found on this task', 404);
}
await db.delete(taskTags)
.where(and(eq(taskTags.taskId, id), eq(taskTags.tagId, data.tagId)));
await recordActivity({
actor: user.name,
action: 'tag_removed',
entityType: 'task',
entityId: id,
changes: { tagId: data.tagId },
workspaceId: domainId,
});
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[tags DELETE] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to remove tag', 500);
}
});
@@ -1,47 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse } from '@/lib/auth';
import { recordActivity } from '@/lib/activity';
import { db, tasks } from '@project-e/db';
import { and, eq, isNull } from 'drizzle-orm';
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
// POST /api/domains/[domainId]/tasks/[id]/uncomplete — Revert task from done
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
const [existing] = await db.select()
.from(tasks)
.where(and(eq(tasks.id, id), eq(tasks.domainId, domainId), isNull(tasks.deletedAt)))
.limit(1);
if (!existing) {
return createErrorResponse('NOT_FOUND', 'Task not found', 404);
}
const [updated] = await db.update(tasks)
.set({
status: 'todo',
completedAt: null,
updatedAt: new Date(),
})
.where(eq(tasks.id, id))
.returning();
await recordActivity({
actor: user.name,
action: 'uncompleted',
entityType: 'task',
entityId: id,
changes: { previousStatus: existing.status },
workspaceId: domainId,
});
return NextResponse.json(updated);
});
@@ -1,131 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
import { recordActivity } from '@/lib/activity';
import { db, tasks } from '@project-e/db';
import { and, eq, inArray, isNull } from 'drizzle-orm';
import { z } from 'zod';
const bulkUpdateSchema = z.object({
ids: z.array(z.string().uuid()).min(1).max(200),
updates: z.object({
status: z.enum(['todo', 'in_progress', 'done', 'cancelled']).optional(),
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
order: z.number().int().optional(),
projectId: z.string().uuid().optional().nullable(),
sectionId: z.string().uuid().optional().nullable(),
}),
});
const bulkDeleteSchema = z.object({
ids: z.array(z.string().uuid()).min(1).max(200),
});
type RouteContext = { params: Promise<{ domainId: string }> };
// POST /api/domains/[domainId]/tasks/bulk — Bulk update tasks (order, status)
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = bulkUpdateSchema.parse(body);
// Verify all tasks belong to this domain
const existingTasks = await db.select({ id: tasks.id })
.from(tasks)
.where(and(inArray(tasks.id, data.ids), eq(tasks.domainId, domainId), isNull(tasks.deletedAt)));
if (existingTasks.length !== data.ids.length) {
return createErrorResponse('NOT_FOUND', 'One or more tasks not found', 404);
}
const updateValues: Record<string, unknown> = { updatedAt: new Date() };
if (data.updates.status !== undefined) updateValues.status = data.updates.status;
if (data.updates.priority !== undefined) updateValues.priority = data.updates.priority;
if (data.updates.order !== undefined) updateValues.order = data.updates.order;
if (data.updates.projectId !== undefined) updateValues.projectId = data.updates.projectId;
if (data.updates.sectionId !== undefined) updateValues.sectionId = data.updates.sectionId;
const updated = await db.update(tasks)
.set(updateValues)
.where(inArray(tasks.id, data.ids))
.returning();
// Record activity for each task
for (const task of updated) {
await recordActivity({
actor: user.name,
action: 'bulk_updated',
entityType: 'task',
entityId: task.id,
changes: data.updates,
workspaceId: domainId,
});
}
return NextResponse.json({ updated: updated.length, items: updated });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[tasks bulk POST] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to bulk update tasks', 500);
}
});
// DELETE /api/domains/[domainId]/tasks/bulk — Bulk soft-delete tasks
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = bulkDeleteSchema.parse(body);
// Verify all tasks belong to this domain
const existingTasks = await db.select({ id: tasks.id, title: tasks.title })
.from(tasks)
.where(and(inArray(tasks.id, data.ids), eq(tasks.domainId, domainId), isNull(tasks.deletedAt)));
if (existingTasks.length !== data.ids.length) {
return createErrorResponse('NOT_FOUND', 'One or more tasks not found', 404);
}
// Soft delete
await db.update(tasks)
.set({ deletedAt: new Date(), updatedAt: new Date() })
.where(inArray(tasks.id, data.ids));
// Record activity for each task
for (const task of existingTasks) {
await recordActivity({
actor: user.name,
action: 'bulk_deleted',
entityType: 'task',
entityId: task.id,
changes: { title: task.title },
workspaceId: domainId,
});
}
return NextResponse.json({ deleted: existingTasks.length });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[tasks bulk DELETE] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to bulk delete tasks', 500);
}
});
@@ -1,220 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
import { recordActivity } from '@/lib/activity';
import { db, tasks, taskTags, tags as tagsTable, taskDependencies, domains } from '@project-e/db';
import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm';
import { z } from 'zod';
const taskStatusEnum = z.enum(['todo', 'in_progress', 'done', 'cancelled']);
const taskPriorityEnum = z.enum(['low', 'medium', 'high', 'urgent']);
const createTaskSchema = z.object({
title: z.string().min(1, 'Title is required'),
description: z.string().optional().nullable(),
status: taskStatusEnum.optional().default('todo'),
priority: taskPriorityEnum.optional().default('medium'),
projectId: z.string().uuid().optional().nullable(),
sectionId: z.string().uuid().optional().nullable(),
parentId: z.string().uuid().optional().nullable(),
dueDate: z.string().datetime().optional().nullable(),
estimatedMinutes: z.number().int().positive().optional().nullable(),
order: z.number().int().optional(),
customFields: z.record(z.string(), z.unknown()).optional(),
recurrenceRule: z.string().optional().nullable(),
tagIds: z.array(z.string().uuid()).optional(),
});
type RouteContext = { params: Promise<{ domainId: string }> };
// GET /api/domains/[domainId]/tasks — List tasks with filtering, sorting, pagination
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
const { searchParams } = new URL(request.url);
const status = searchParams.get('status');
const priority = searchParams.get('priority');
const tag = searchParams.get('tag');
const search = searchParams.get('search');
const parentId = searchParams.get('parent_id');
const projectId = searchParams.get('project_id');
const sectionId = searchParams.get('section_id');
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
const offset = parseInt(searchParams.get('offset') || '0');
const sort = searchParams.get('sort') || 'order';
const order = searchParams.get('order') || 'asc';
// Build where conditions
const conditions: any[] = [
eq(tasks.domainId, domainId),
isNull(tasks.deletedAt),
];
if (status) {
const statuses = status.split(',');
conditions.push(inArray(tasks.status, statuses as any));
}
if (priority) {
const priorities = priority.split(',');
conditions.push(inArray(tasks.priority, priorities as any));
}
if (search) {
conditions.push(ilike(tasks.title, `%${search}%`));
}
if (parentId === 'null') {
conditions.push(isNull(tasks.parentId));
} else if (parentId) {
conditions.push(eq(tasks.parentId, parentId));
}
if (projectId) {
conditions.push(eq(tasks.projectId, projectId));
}
if (sectionId) {
conditions.push(eq(tasks.sectionId, sectionId));
}
// Build order
const orderFn = order === 'desc' ? desc : asc;
let orderColumn;
switch (sort) {
case 'title': orderColumn = orderFn(tasks.title); break;
case 'status': orderColumn = orderFn(tasks.status); break;
case 'priority': orderColumn = orderFn(tasks.priority); break;
case 'due_date': orderColumn = orderFn(tasks.dueDate); break;
case 'created_at': orderColumn = orderFn(tasks.createdAt); break;
case 'updated_at': orderColumn = orderFn(tasks.updatedAt); break;
default: orderColumn = orderFn(tasks.order); break;
}
const [items, countResult] = await Promise.all([
db.select()
.from(tasks)
.where(and(...conditions))
.orderBy(orderColumn)
.limit(limit)
.offset(offset),
db.select({ count: sql<number>`count(*)` })
.from(tasks)
.where(and(...conditions)),
]);
const totalItems = Number(countResult[0]?.count || 0);
// If tag filter is specified, filter in-memory (or we could do a subquery)
let filteredItems = items;
if (tag) {
const tagIds = tag.split(',');
const taskTagRows = await db.select({ taskId: taskTags.taskId })
.from(taskTags)
.where(inArray(taskTags.tagId, tagIds));
const matchingTaskIds = new Set(taskTagRows.map(r => r.taskId));
filteredItems = items.filter(t => matchingTaskIds.has(t.id));
}
// Fetch tags for all tasks
let taskTagMap = new Map<string, { id: string; name: string; color: string | null }[]>();
if (filteredItems.length > 0) {
const taskIds = filteredItems.map(t => t.id);
const tagRows = await db.select({
taskId: taskTags.taskId,
id: tagsTable.id,
name: tagsTable.name,
color: tagsTable.color,
})
.from(taskTags)
.innerJoin(tagsTable, eq(taskTags.tagId, tagsTable.id))
.where(inArray(taskTags.taskId, taskIds));
for (const row of tagRows) {
if (!taskTagMap.has(row.taskId)) taskTagMap.set(row.taskId, []);
taskTagMap.get(row.taskId)!.push({ id: row.id, name: row.name, color: row.color });
}
}
const itemsWithTags = filteredItems.map(t => ({
...t,
tags: taskTagMap.get(t.id) || [],
}));
return NextResponse.json({
items: itemsWithTags,
totalItems,
limit,
offset,
});
});
// POST /api/domains/[domainId]/tasks — Create a task
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = createTaskSchema.parse(body);
// Validate domain_id matches route param
// domainId is already validated via requireWorkspaceAccess
// Cycle detection for parentId (subtask)
if (data.parentId) {
// Verify parent exists and is not deleted
const [parent] = await db.select({ id: tasks.id })
.from(tasks)
.where(and(eq(tasks.id, data.parentId), isNull(tasks.deletedAt)))
.limit(1);
if (!parent) {
return createErrorResponse('NOT_FOUND', 'Parent task not found', 404);
}
}
const [task] = await db.insert(tasks).values({
title: data.title,
description: data.description ?? null,
status: data.status,
priority: data.priority,
domainId,
projectId: data.projectId ?? null,
sectionId: data.sectionId ?? null,
parentId: data.parentId ?? null,
dueDate: data.dueDate ? new Date(data.dueDate) : null,
estimatedMinutes: data.estimatedMinutes ?? null,
order: data.order ?? 0,
customFields: data.customFields ?? {},
recurrenceRule: data.recurrenceRule ?? null,
}).returning();
// Insert tags if provided
if (data.tagIds && data.tagIds.length > 0) {
await db.insert(taskTags).values(
data.tagIds.map(tagId => ({ taskId: task.id, tagId }))
);
}
// Record activity
await recordActivity({
actor: user.name,
action: 'created',
entityType: 'task',
entityId: task.id,
changes: { title: task.title, status: task.status, priority: task.priority },
workspaceId: domainId,
});
return NextResponse.json(task, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[tasks POST] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to create task', 500);
}
});
@@ -1,50 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse } from '@/lib/auth';
import { db, webhooks, webhookDeliveries } from '@project-e/db';
import { and, desc, eq, sql } from 'drizzle-orm';
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
// GET /api/domains/[domainId]/webhooks/[id]/deliveries — List deliveries for a webhook
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
// Verify webhook exists in this workspace
const [webhook] = await db.select({ id: webhooks.id })
.from(webhooks)
.where(and(eq(webhooks.id, id), eq(webhooks.workspaceId, domainId)))
.limit(1);
if (!webhook) {
return createErrorResponse('NOT_FOUND', 'Webhook not found', 404);
}
const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
const offset = parseInt(searchParams.get('offset') || '0');
const [items, countResult] = await Promise.all([
db.select()
.from(webhookDeliveries)
.where(eq(webhookDeliveries.webhookId, id))
.orderBy(desc(webhookDeliveries.createdAt))
.limit(limit)
.offset(offset),
db.select({ count: sql<number>`count(*)` })
.from(webhookDeliveries)
.where(eq(webhookDeliveries.webhookId, id)),
]);
return NextResponse.json({
items,
totalItems: Number(countResult[0]?.count || 0),
limit,
offset,
});
});
@@ -1,118 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
import { recordActivity } from '@/lib/activity';
import { db, webhooks } from '@project-e/db';
import { and, eq } from 'drizzle-orm';
import { z } from 'zod';
const updateWebhookSchema = z.object({
name: z.string().optional(),
url: z.string().url('Must be a valid URL').optional(),
events: z.array(z.string()).optional(),
active: z.boolean().optional(),
});
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
// GET /api/domains/[domainId]/webhooks/[id] — Get a single webhook
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
const [webhook] = await db.select()
.from(webhooks)
.where(and(eq(webhooks.id, id), eq(webhooks.workspaceId, domainId)))
.limit(1);
if (!webhook) {
return createErrorResponse('NOT_FOUND', 'Webhook not found', 404);
}
// Never return the secret on GET
const { secret: _, ...safe } = webhook;
return NextResponse.json(safe);
});
// PATCH /api/domains/[domainId]/webhooks/[id] — Update a webhook
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = updateWebhookSchema.parse(body);
const [existing] = await db.select()
.from(webhooks)
.where(and(eq(webhooks.id, id), eq(webhooks.workspaceId, domainId)))
.limit(1);
if (!existing) {
return createErrorResponse('NOT_FOUND', 'Webhook not found', 404);
}
const updateData: Record<string, unknown> = { updatedAt: new Date() };
if (data.name !== undefined) updateData.name = data.name;
if (data.url !== undefined) updateData.url = data.url;
if (data.events !== undefined) updateData.events = data.events;
if (data.active !== undefined) updateData.active = data.active;
const [updated] = await db.update(webhooks)
.set(updateData)
.where(eq(webhooks.id, id))
.returning();
await recordActivity({
actor: user.name,
action: 'updated',
entityType: 'webhook',
entityId: updated.id,
changes: updateData,
workspaceId: domainId,
});
const { secret: _, ...safe } = updated;
return NextResponse.json(safe);
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[webhook PATCH] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to update webhook', 500);
}
});
// DELETE /api/domains/[domainId]/webhooks/[id] — Delete a webhook
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
const [existing] = await db.select()
.from(webhooks)
.where(and(eq(webhooks.id, id), eq(webhooks.workspaceId, domainId)))
.limit(1);
if (!existing) {
return createErrorResponse('NOT_FOUND', 'Webhook not found', 404);
}
await db.delete(webhooks).where(eq(webhooks.id, id));
await recordActivity({
actor: user.name,
action: 'deleted',
entityType: 'webhook',
entityId: id,
workspaceId: domainId,
});
return new NextResponse(null, { status: 204 });
});
@@ -1,99 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse } from '@/lib/auth';
import { db, webhooks, webhookDeliveries } from '@project-e/db';
import { and, eq } from 'drizzle-orm';
import { createHmac } from 'node:crypto';
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
// POST /api/domains/[domainId]/webhooks/[id]/test — Send a test event
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
const [webhook] = await db.select()
.from(webhooks)
.where(and(eq(webhooks.id, id), eq(webhooks.workspaceId, domainId)))
.limit(1);
if (!webhook) {
return createErrorResponse('NOT_FOUND', 'Webhook not found', 404);
}
if (!webhook.active) {
return createErrorResponse('WEBHOOK_DISABLED', 'Cannot test a disabled webhook', 400);
}
const testPayload = {
event: 'test.ping',
entity_type: 'test',
entity_id: 'test-001',
data: { message: 'This is a test webhook delivery from Project E.', webhook_id: webhook.id },
timestamp: new Date().toISOString(),
workspace_id: domainId,
};
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Event-Type': 'test.ping',
};
if (webhook.secret) {
const body = JSON.stringify(testPayload);
const signature = createHmac('sha256', webhook.secret)
.update(body)
.digest('hex');
headers['X-ProjectE-Signature'] = signature;
}
try {
const response = await fetch(webhook.url, {
method: 'POST',
headers,
body: JSON.stringify(testPayload),
signal: AbortSignal.timeout(10000),
});
const responseBody = await response.text();
// Record the delivery
await db.insert(webhookDeliveries).values({
webhookId: webhook.id,
event: 'test.ping',
payload: testPayload as Record<string, unknown>,
status: response.ok ? 'success' : 'failed',
statusCode: response.status,
responseBody: responseBody.substring(0, 1000),
attempts: 1,
});
return NextResponse.json({
success: response.ok,
status: response.status,
response: responseBody.substring(0, 500),
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
await db.insert(webhookDeliveries).values({
webhookId: webhook.id,
event: 'test.ping',
payload: testPayload as Record<string, unknown>,
status: 'failed',
statusCode: 0,
responseBody: errorMessage,
attempts: 1,
});
return NextResponse.json({
success: false,
status: 0,
response: errorMessage,
});
}
});
@@ -1,102 +0,0 @@
// 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, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
import { recordActivity } from '@/lib/activity';
import { db, webhooks, webhookDeliveries } from '@project-e/db';
import { and, asc, desc, eq, isNull, sql } from 'drizzle-orm';
import { createHash, randomBytes } from 'node:crypto';
import { z } from 'zod';
const createWebhookSchema = z.object({
name: z.string().optional(),
url: z.string().url('Must be a valid URL'),
events: z.array(z.string()).default([]),
active: z.boolean().optional().default(true),
});
const updateWebhookSchema = z.object({
name: z.string().optional(),
url: z.string().url('Must be a valid URL').optional(),
events: z.array(z.string()).optional(),
active: z.boolean().optional(),
});
type RouteContext = { params: Promise<{ domainId: string }> };
type IdRouteContext = { params: Promise<{ domainId: string; id: string }> };
// GET /api/domains/[domainId]/webhooks — List webhooks
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
const offset = parseInt(searchParams.get('offset') || '0');
const [items, countResult] = await Promise.all([
db.select()
.from(webhooks)
.where(eq(webhooks.workspaceId, domainId))
.orderBy(desc(webhooks.createdAt))
.limit(limit)
.offset(offset),
db.select({ count: sql<number>`count(*)` })
.from(webhooks)
.where(eq(webhooks.workspaceId, domainId)),
]);
return NextResponse.json({
items,
totalItems: Number(countResult[0]?.count || 0),
limit,
offset,
});
});
// POST /api/domains/[domainId]/webhooks — Create a webhook
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = createWebhookSchema.parse(body);
// Generate a webhook secret (shown only once on create)
const secret = `whsec_${randomBytes(24).toString('hex')}`;
const [webhook] = await db.insert(webhooks).values({
name: data.name ?? null,
url: data.url,
secret,
events: data.events,
active: data.active ?? true,
workspaceId: domainId,
}).returning();
await recordActivity({
actor: user.name,
action: 'created',
entityType: 'webhook',
entityId: webhook.id,
changes: { name: webhook.name, url: webhook.url, events: webhook.events },
workspaceId: domainId,
});
// Return the secret on create — it won't be shown again
return NextResponse.json({ ...webhook, secret }, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[webhooks POST] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to create webhook', 500);
}
});
-129
View File
@@ -1,129 +0,0 @@
// 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 { db, domains } from '@project-e/db';
import { and, asc, desc, eq, ilike, or, sql } from 'drizzle-orm';
import { z } from 'zod';
const createDomainSchema = z.object({
name: z.string().min(1, 'Name is required'),
slug: z.string().min(1).optional(),
color: z.string().optional().nullable(),
icon: z.string().optional().nullable(),
parentId: z.string().uuid().optional().nullable(),
});
// GET /api/domains — List domains with filtering, sorting, pagination
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 sortParam = searchParams.get('sort') || 'sort_order';
const filter = searchParams.get('filter') || undefined;
// Build order by — whitelist safe column names
const sortDir = sortParam.startsWith('-') ? 'desc' : 'asc';
const sortField = sortParam.replace(/^-/, '');
const sortColumns: Record<string, any> = {
name: domains.name,
slug: domains.slug,
sort_order: domains.sortOrder,
created_at: domains.createdAt,
updated_at: domains.updatedAt,
};
const orderBy = sortDir === 'asc'
? asc(sortColumns[sortField] || domains.sortOrder)
: desc(sortColumns[sortField] || domains.sortOrder);
// Build where clause — filter by owner
const conditions: any[] = [eq(domains.ownerId, user.id)];
if (filter) {
conditions.push(
or(
ilike(domains.name, `%${filter}%`),
ilike(domains.slug, `%${filter}%`),
)!
);
}
const offset = (page - 1) * perPage;
const [items, countResult] = await Promise.all([
db.select()
.from(domains)
.where(and(...conditions))
.orderBy(orderBy)
.limit(perPage)
.offset(offset),
db.select({ count: sql<number>`count(*)` })
.from(domains)
.where(and(...conditions)),
]);
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<number>`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,
totalItems,
totalPages: Math.ceil(totalItems / perPage),
page,
perPage,
});
});
// POST /api/domains — Create a domain
export const POST = withAuth(async (request: NextRequest, user) => {
try {
const body = await request.json();
const data = createDomainSchema.parse(body);
// Auto-generate slug from name if not provided
const slug = data.slug || data.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || 'domain';
const [domain] = await db.insert(domains)
.values({
name: data.name,
slug,
color: data.color || null,
icon: data.icon || null,
parentId: data.parentId || null,
ownerId: user.id,
})
.returning();
return NextResponse.json(domain, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
throw error;
}
});
-38
View File
@@ -1,38 +0,0 @@
// 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 } from '@/lib/auth';
import { createPocketBaseClient } from '@/lib/pocketbase';
// GET /api/error-logs — List recent error logs
export const GET = withAuth(async (request: NextRequest) => {
const { searchParams } = new URL(request.url);
const limit = parseInt(searchParams.get('limit') || '50');
const pb = createPocketBaseClient();
const result = await pb.collection('error_logs').getList(1, limit, {
sort: '-created',
});
return NextResponse.json({
items: result.items,
totalItems: result.totalItems,
});
});
// DELETE /api/error-logs — Clear all error logs
export const DELETE = withAuth(async () => {
const pb = createPocketBaseClient();
// Get all error logs and delete them
const logs = await pb.collection('error_logs').getFullList();
for (const log of logs) {
await pb.collection('error_logs').delete(log.id);
}
return NextResponse.json({ deleted: logs.length });
});
-67
View File
@@ -1,67 +0,0 @@
// 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 } from '@/lib/auth';
import { createPocketBaseClient } from '@/lib/pocketbase';
const COLLECTIONS = [
'tasks',
'habits',
'projects',
'notes',
'reports',
'milestones',
'domains',
'tags',
'agents',
'webhooks',
] as const;
type ExportCollection = (typeof COLLECTIONS)[number];
// POST /api/export — Export all data as JSON
export const POST = withAuth(async (request: NextRequest, _user) => {
let body: { collections?: ExportCollection[] } = {};
try {
body = await request.json();
} catch {
// Empty body is fine — export everything
}
const requestedCollections = body.collections && body.collections.length > 0
? body.collections.filter((c): c is ExportCollection => COLLECTIONS.includes(c as ExportCollection))
: [...COLLECTIONS];
const pb = createPocketBaseClient();
const exportData: Record<string, unknown> = {
version: '1.0',
exportedAt: new Date().toISOString(),
};
for (const collection of requestedCollections) {
try {
const result = await pb.collection(collection).getList(1, 1000, {
sort: 'created',
});
exportData[collection] = result.items;
} catch (error) {
console.error(`Failed to export collection ${collection}:`, error);
exportData[collection] = [];
}
}
return NextResponse.json(exportData);
});
// GET /api/export — List available collections for export
export const GET = withAuth(async (_request: NextRequest, _user) => {
return NextResponse.json({
collections: COLLECTIONS.map((name) => ({
name,
label: name.charAt(0).toUpperCase() + name.slice(1).replace(/_/g, ' '),
})),
});
});
-19
View File
@@ -1,19 +0,0 @@
// 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 } from '@/lib/auth';
import { getGlobalGraphData } from '@/lib/graph-service';
// GET /api/graph — Get global graph data (all domains the user has access to)
export const GET = withAuth(async (request: NextRequest, user) => {
const graphData = await getGlobalGraphData();
return NextResponse.json(graphData, {
headers: {
'Cache-Control': 'private, max-age=30, stale-while-revalidate=120',
},
});
});
-36
View File
@@ -1,36 +0,0 @@
// 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 } from '@/lib/auth';
import { createPocketBaseClient } from '@/lib/pocketbase';
// GET /api/habit-logs — List habit logs with date filtering
export const GET = withAuth(async (request: NextRequest) => {
const { searchParams } = new URL(request.url);
const start = searchParams.get('start');
const end = searchParams.get('end');
const habitId = searchParams.get('habit_id');
let filter = '';
if (start && end) {
filter = `logged_at >= "${start}" && logged_at <= "${end}"`;
} else if (start) {
filter = `logged_at >= "${start}"`;
} else if (habitId) {
filter = `habit_id = "${habitId}"`;
}
const pb = createPocketBaseClient();
const result = await pb.collection('habit_logs').getList(1, 1000, {
...(filter ? { filter } : {}),
sort: '-logged_at',
});
return NextResponse.json({
items: result.items,
totalItems: result.totalItems,
});
});
@@ -1,61 +0,0 @@
// 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 } from '@/lib/auth';
import { createPocketBaseClient } from '@/lib/pocketbase';
import { logHabitCompletion } from '@/lib/services';
import { z } from 'zod';
type RouteContext = { params: Promise<{ id: string }> };
// GET /api/habits/[id]/logs — List logs for a habit
export const GET = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
const { id } = await context!.params;
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const perPage = parseInt(searchParams.get('perPage') || '50');
const filter = searchParams.get('filter') || undefined;
const sort = searchParams.get('sort') || '-logged_at';
const pb = createPocketBaseClient();
const result = await pb.collection('habit_logs').getList(page, perPage, {
filter: filter ? `habit_id = "${id}" && ${filter}` : `habit_id = "${id}"`,
sort,
});
return NextResponse.json({
items: result.items,
totalItems: result.totalItems,
totalPages: result.totalPages,
page: result.page,
perPage: result.perPage,
});
});
// POST /api/habits/[id]/logs — Create a habit log entry
export const POST = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
try {
const { id } = await context!.params;
const body = await request.json();
const data = z
.object({
logged_at: z.string().datetime().optional(),
mood: z.number().int().min(1).max(5).optional(),
value: z.number().optional(),
notes: z.string().optional(),
})
.parse(body);
const result = await logHabitCompletion(id, data);
return NextResponse.json(result, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
throw error;
}
});
-51
View File
@@ -1,51 +0,0 @@
// 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 } from '@/lib/auth';
import { createPocketBaseClient } from '@/lib/pocketbase';
import { updateHabitSchema } from '@project-e/shared';
import { z } from 'zod';
type RouteContext = { params: Promise<{ id: string }> };
// GET /api/habits/[id] — Get a single habit
export const GET = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
const { id } = await context!.params;
const pb = createPocketBaseClient();
const habit = await pb.collection('habits').getOne(id);
return NextResponse.json(habit);
});
// PATCH /api/habits/[id] — Update a habit
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
try {
const { id } = await context!.params;
const body = await request.json();
const data = updateHabitSchema.parse(body);
const pb = createPocketBaseClient();
const habit = await pb.collection('habits').update(id, data);
return NextResponse.json(habit);
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
throw error;
}
});
// DELETE /api/habits/[id] — Delete a habit
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
const { id } = await context!.params;
const pb = createPocketBaseClient();
await pb.collection('habits').delete(id);
return new NextResponse(null, { status: 204 });
});
-126
View File
@@ -1,126 +0,0 @@
// 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, habits, habitTags, tags as tagsTable } from '@project-e/db';
import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm';
import { z } from 'zod';
const habitFrequencyEnum = z.enum(['daily', 'weekly', 'custom']);
const habitDifficultyEnum = z.enum(['easy', 'medium', 'hard']);
const createHabitSchema = z.object({
name: z.string().min(1, 'Name is required'),
description: z.string().optional().nullable(),
domain: z.string().min(1, 'Domain is required'),
frequency: habitFrequencyEnum.optional().default('daily'),
difficulty: habitDifficultyEnum.optional().default('medium'),
goalPerPeriod: z.number().int().positive().optional().default(1),
active: z.boolean().optional().default(true),
tagIds: z.array(z.string().uuid()).optional(),
});
// GET /api/habits — List habits with filtering, sorting, pagination
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';
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(/^-/, '');
const sortColumns: Record<string, any> = {
created: habits.createdAt,
updated: habits.updatedAt,
name: habits.name,
frequency: habits.frequency,
difficulty: habits.difficulty,
};
const orderBy = sortDir === 'asc'
? asc(sortColumns[sortField] || habits.createdAt)
: desc(sortColumns[sortField] || habits.createdAt);
const conditions: any[] = [isNull(habits.deletedAt)];
if (domainId) conditions.push(eq(habits.domainId, domainId));
if (filter) {
conditions.push(ilike(habits.name, `%${filter}%`));
}
const offset = (page - 1) * perPage;
const [items, countResult] = await Promise.all([
db.select()
.from(habits)
.where(and(...conditions))
.orderBy(orderBy)
.limit(perPage)
.offset(offset),
db.select({ count: sql<number>`count(*)` })
.from(habits)
.where(and(...conditions)),
]);
const totalItems = Number(countResult[0]?.count || 0);
return NextResponse.json({
items,
totalItems,
totalPages: Math.ceil(totalItems / perPage),
page,
perPage,
});
});
// POST /api/habits — Create a habit
export const POST = withAuth(async (request: NextRequest, user) => {
try {
const body = await request.json();
const data = createHabitSchema.parse({
...body,
domain: body.domain || (await resolveActiveDomain(user)).id,
});
const [habit] = await db.insert(habits).values({
name: data.name,
description: data.description ?? null,
domainId: data.domain,
frequency: data.frequency,
difficulty: data.difficulty,
goalPerPeriod: data.goalPerPeriod,
active: data.active,
}).returning();
if (data.tagIds && data.tagIds.length > 0) {
await db.insert(habitTags).values(
data.tagIds.map(tagId => ({ habitId: habit.id, tagId }))
);
}
await recordActivity({
actor: user.name,
action: 'created',
entityType: 'habit',
entityId: habit.id,
changes: { name: habit.name },
workspaceId: data.domain,
});
return NextResponse.json(habit, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
console.error('[habits POST] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to create habit', 500);
}
});
-18
View File
@@ -1,18 +0,0 @@
// 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 { NextResponse } from 'next/server';
import { withAuth } from '@/lib/auth';
import { getHabitStreaks } from '@/lib/services/habit-service';
// GET /api/habits/streaks — Get all habit streaks
export const GET = withAuth(async () => {
const streaks = await getHabitStreaks();
return NextResponse.json({ streaks }, {
headers: {
'Cache-Control': 'private, max-age=60, stale-while-revalidate=300',
},
});
});
-14
View File
@@ -1,14 +0,0 @@
// 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 { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({
status: 'ok',
timestamp: new Date().toISOString(),
version: process.env.npm_package_version || '0.1.0',
});
}
-86
View File
@@ -1,86 +0,0 @@
// 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 } from '@/lib/auth';
import { createPocketBaseClient } from '@/lib/pocketbase';
const COLLECTIONS = [
'tasks',
'habits',
'projects',
'notes',
'reports',
'milestones',
'domains',
'tags',
'agents',
'webhooks',
] as const;
type ImportCollection = (typeof COLLECTIONS)[number];
interface ImportResult {
collection: string;
imported: number;
failed: number;
errors: string[];
}
// POST /api/import — Import data from JSON
export const POST = withAuth(async (request: NextRequest, _user) => {
const body = await request.json();
if (!body || typeof body !== 'object') {
return createErrorResponse('INVALID_DATA', 'Invalid import data format', 400);
}
if (!body.version) {
return createErrorResponse('INVALID_DATA', 'Missing version field — is this a valid Project E export?', 400);
}
const pb = createPocketBaseClient();
const results: ImportResult[] = [];
let totalImported = 0;
let totalFailed = 0;
for (const collection of COLLECTIONS) {
const items = body[collection];
if (!Array.isArray(items) || items.length === 0) continue;
const result: ImportResult = {
collection,
imported: 0,
failed: 0,
errors: [],
};
for (const item of items) {
try {
// Strip id, created, updated to let PocketBase generate new ones
const { id: _id, created: _created, updated: _updated, ...data } = item;
await pb.collection(collection).create(data);
result.imported++;
} catch (error) {
result.failed++;
const message = error instanceof Error ? error.message : String(error);
if (result.errors.length < 5) {
result.errors.push(message);
}
}
}
results.push(result);
totalImported += result.imported;
totalFailed += result.failed;
}
return NextResponse.json({
success: totalFailed === 0,
imported: totalImported,
failed: totalFailed,
results,
});
});
-746
View File
@@ -1,746 +0,0 @@
// 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 { db, apiKeys, users, tasks, taskTags, tags as tagsTable, habits, habitCompletions, projects, notes, noteLinks, domains, activityFeed, webhooks, webhookDeliveries } from '@project-e/db';
import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm';
import { createHash } from 'node:crypto';
import { recordActivity } from '@/lib/activity';
// ── JSON-RPC 2.0 types ─────────────────────────────────────────────────────────
interface JsonRpcRequest {
jsonrpc: '2.0';
method: string;
params?: unknown;
id: string | number | null;
}
interface JsonRpcError {
code: number;
message: string;
data?: unknown;
}
interface JsonRpcResponse {
jsonrpc: '2.0';
result?: unknown;
error?: JsonRpcError;
id: string | number | null;
}
// JSON-RPC error codes
const JSONRPC_PARSE_ERROR = -32700;
const JSONRPC_INVALID_REQUEST = -32600;
const JSONRPC_METHOD_NOT_FOUND = -32601;
const JSONRPC_INVALID_PARAMS = -32602;
const JSONRPC_INTERNAL_ERROR = -32603;
// ── Auth ────────────────────────────────────────────────────────────────────────
async function authenticateApiKey(request: NextRequest): Promise<{ userId: string; userName: string } | null> {
const authHeader = request.headers.get('Authorization');
if (!authHeader) return null;
const apiKey = authHeader.replace('Bearer ', '').trim();
if (!apiKey) return null;
// API keys are stored as sha256 hash
const keyHash = createHash('sha256').update(apiKey).digest('hex');
const [keyRecord] = await db
.select({
userId: apiKeys.userId,
userName: users.name,
})
.from(apiKeys)
.innerJoin(users, eq(apiKeys.userId, users.id))
.where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.active, true)))
.limit(1);
if (!keyRecord) return null;
// Update last_used_at
await db.update(apiKeys)
.set({ lastUsedAt: new Date() })
.where(eq(apiKeys.keyHash, keyHash));
return { userId: keyRecord.userId, userName: keyRecord.userName };
}
// ── Tool definitions ─────────────────────────────────────────────────────────────
interface ToolDefinition {
name: string;
description: string;
inputSchema: Record<string, unknown>;
handler: (params: Record<string, unknown>, auth: { userId: string; userName: string }) => Promise<unknown>;
}
const tools: ToolDefinition[] = [
// ── Tasks ──────────────────────────────────────────────────────────────────────
{
name: 'tasks.list',
description: 'List tasks with optional filters',
inputSchema: {
type: 'object',
properties: {
domain_id: { type: 'string', description: 'Workspace/domain ID' },
status: { type: 'string', enum: ['todo', 'in_progress', 'done', 'cancelled'] },
priority: { type: 'string', enum: ['low', 'medium', 'high', 'urgent'] },
project_id: { type: 'string' },
search: { type: 'string' },
limit: { type: 'number', default: 50 },
offset: { type: 'number', default: 0 },
},
required: ['domain_id'],
},
handler: async (params) => {
const conditions: any[] = [
eq(tasks.domainId, params.domain_id as string),
isNull(tasks.deletedAt),
];
if (params.status) conditions.push(eq(tasks.status, params.status as any));
if (params.priority) conditions.push(eq(tasks.priority, params.priority as any));
if (params.project_id) conditions.push(eq(tasks.projectId, params.project_id as string));
if (params.search) conditions.push(ilike(tasks.title, `%${params.search}%`));
const items = await db.select()
.from(tasks)
.where(and(...conditions))
.orderBy(asc(tasks.order))
.limit(Math.min(Number(params.limit) || 50, 200))
.offset(Number(params.offset) || 0);
return { items, total: items.length };
},
},
{
name: 'tasks.create',
description: 'Create a new task',
inputSchema: {
type: 'object',
properties: {
domain_id: { type: 'string', description: 'Workspace/domain ID' },
title: { type: 'string' },
description: { type: 'string' },
status: { type: 'string', enum: ['todo', 'in_progress', 'done', 'cancelled'] },
priority: { type: 'string', enum: ['low', 'medium', 'high', 'urgent'] },
due_date: { type: 'string' },
project_id: { type: 'string' },
},
required: ['domain_id', 'title'],
},
handler: async (params, auth) => {
const [task] = await db.insert(tasks).values({
title: params.title as string,
description: (params.description as string) ?? null,
status: (params.status as any) ?? 'todo',
priority: (params.priority as any) ?? 'medium',
domainId: params.domain_id as string,
projectId: (params.project_id as string) ?? null,
dueDate: params.due_date ? new Date(params.due_date as string) : null,
}).returning();
await recordActivity({
actor: auth.userName,
action: 'created',
entityType: 'task',
entityId: task.id,
changes: { title: task.title, status: task.status },
workspaceId: params.domain_id as string,
});
return task;
},
},
{
name: 'tasks.update',
description: 'Update an existing task',
inputSchema: {
type: 'object',
properties: {
task_id: { type: 'string' },
title: { type: 'string' },
description: { type: 'string' },
status: { type: 'string', enum: ['todo', 'in_progress', 'done', 'cancelled'] },
priority: { type: 'string', enum: ['low', 'medium', 'high', 'urgent'] },
due_date: { type: 'string' },
},
required: ['task_id'],
},
handler: async (params, auth) => {
const updateData: Record<string, unknown> = {};
if (params.title !== undefined) updateData.title = params.title;
if (params.description !== undefined) updateData.description = params.description;
if (params.status !== undefined) updateData.status = params.status;
if (params.priority !== undefined) updateData.priority = params.priority;
if (params.due_date !== undefined) updateData.dueDate = params.due_date ? new Date(params.due_date as string) : null;
updateData.updatedAt = new Date();
const [task] = await db.update(tasks)
.set(updateData)
.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',
entityType: 'task',
entityId: task.id,
changes: updateData,
workspaceId: task.domainId,
});
return task;
},
},
{
name: 'tasks.delete',
description: 'Soft-delete a task',
inputSchema: {
type: 'object',
properties: {
task_id: { type: 'string' },
},
required: ['task_id'],
},
handler: async (params, auth) => {
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',
entityType: 'task',
entityId: task.id,
workspaceId: task.domainId,
});
return { deleted: true, id: task.id };
},
},
{
name: 'tasks.complete',
description: 'Mark a task as done',
inputSchema: {
type: 'object',
properties: {
task_id: { type: 'string' },
},
required: ['task_id'],
},
handler: async (params, auth) => {
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',
entityType: 'task',
entityId: task.id,
workspaceId: task.domainId,
});
return task;
},
},
// ── Habits ────────────────────────────────────────────────────────────────────
{
name: 'habits.list',
description: 'List habits',
inputSchema: {
type: 'object',
properties: {
domain_id: { type: 'string' },
active: { type: 'boolean' },
},
required: ['domain_id'],
},
handler: async (params) => {
const conditions: any[] = [
eq(habits.domainId, params.domain_id as string),
isNull(habits.deletedAt),
];
if (params.active !== undefined) conditions.push(eq(habits.active, params.active as boolean));
const items = await db.select().from(habits).where(and(...conditions)).orderBy(asc(habits.name));
return { items };
},
},
{
name: 'habits.create',
description: 'Create a new habit',
inputSchema: {
type: 'object',
properties: {
domain_id: { type: 'string' },
name: { type: 'string' },
description: { type: 'string' },
frequency: { type: 'string', enum: ['daily', 'weekly', 'custom'] },
difficulty: { type: 'string', enum: ['easy', 'medium', 'hard'] },
},
required: ['domain_id', 'name'],
},
handler: async (params, auth) => {
const [habit] = await db.insert(habits).values({
name: params.name as string,
description: (params.description as string) ?? null,
domainId: params.domain_id as string,
frequency: (params.frequency as any) ?? 'daily',
difficulty: (params.difficulty as any) ?? 'medium',
}).returning();
await recordActivity({
actor: auth.userName,
action: 'created',
entityType: 'habit',
entityId: habit.id,
workspaceId: params.domain_id as string,
});
return habit;
},
},
{
name: 'habits.complete',
description: 'Log a habit completion',
inputSchema: {
type: 'object',
properties: {
habit_id: { type: 'string' },
date: { type: 'string', description: 'ISO date string' },
value: { type: 'number', default: 1 },
},
required: ['habit_id'],
},
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');
const [completion] = await db.insert(habitCompletions).values({
habitId: params.habit_id as string,
date: params.date ? new Date(params.date as string) : new Date(),
value: Number(params.value) || 1,
}).returning();
await recordActivity({
actor: auth.userName,
action: 'completed',
entityType: 'habit',
entityId: habit.id,
workspaceId: habit.domainId,
});
return completion;
},
},
// ── Projects ───────────────────────────────────────────────────────────────────
{
name: 'projects.list',
description: 'List projects',
inputSchema: {
type: 'object',
properties: {
domain_id: { type: 'string' },
status: { type: 'string', enum: ['active', 'paused', 'completed', 'archived'] },
},
required: ['domain_id'],
},
handler: async (params) => {
const conditions: any[] = [
eq(projects.domainId, params.domain_id as string),
isNull(projects.deletedAt),
];
if (params.status) conditions.push(eq(projects.status, params.status as any));
const items = await db.select().from(projects).where(and(...conditions)).orderBy(asc(projects.name));
return { items };
},
},
{
name: 'projects.create',
description: 'Create a new project',
inputSchema: {
type: 'object',
properties: {
domain_id: { type: 'string' },
name: { type: 'string' },
description: { type: 'string' },
status: { type: 'string', enum: ['active', 'paused', 'completed', 'archived'] },
target_date: { type: 'string' },
},
required: ['domain_id', 'name'],
},
handler: async (params, auth) => {
const [project] = await db.insert(projects).values({
name: params.name as string,
description: (params.description as string) ?? null,
domainId: params.domain_id as string,
status: (params.status as any) ?? 'active',
targetDate: params.target_date ? new Date(params.target_date as string) : null,
}).returning();
await recordActivity({
actor: auth.userName,
action: 'created',
entityType: 'project',
entityId: project.id,
workspaceId: params.domain_id as string,
});
return project;
},
},
// ── Notes ──────────────────────────────────────────────────────────────────────
{
name: 'notes.list',
description: 'List notes',
inputSchema: {
type: 'object',
properties: {
domain_id: { type: 'string' },
is_archived: { type: 'boolean' },
},
required: ['domain_id'],
},
handler: async (params) => {
const conditions: any[] = [
eq(notes.domainId, params.domain_id as string),
isNull(notes.deletedAt),
];
if (params.is_archived !== undefined) conditions.push(eq(notes.isArchived, params.is_archived as boolean));
const items = await db.select().from(notes).where(and(...conditions)).orderBy(desc(notes.updatedAt));
return { items };
},
},
{
name: 'notes.create',
description: 'Create a new note',
inputSchema: {
type: 'object',
properties: {
domain_id: { type: 'string' },
title: { type: 'string' },
content: { type: 'string' },
},
required: ['domain_id', 'title'],
},
handler: async (params, auth) => {
const [note] = await db.insert(notes).values({
title: params.title as string,
content: (params.content as string) ?? null,
domainId: params.domain_id as string,
}).returning();
await recordActivity({
actor: auth.userName,
action: 'created',
entityType: 'note',
entityId: note.id,
workspaceId: params.domain_id as string,
});
return note;
},
},
{
name: 'notes.update',
description: 'Update a note',
inputSchema: {
type: 'object',
properties: {
note_id: { type: 'string' },
title: { type: 'string' },
content: { type: 'string' },
},
required: ['note_id'],
},
handler: async (params, auth) => {
const updateData: Record<string, unknown> = { updatedAt: new Date() };
if (params.title !== undefined) updateData.title = params.title;
if (params.content !== undefined) updateData.content = params.content;
const [note] = await db.update(notes)
.set(updateData)
.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',
entityType: 'note',
entityId: note.id,
workspaceId: note.domainId,
});
return note;
},
},
{
name: 'notes.search',
description: 'Search notes by title or content',
inputSchema: {
type: 'object',
properties: {
domain_id: { type: 'string' },
query: { type: 'string' },
},
required: ['domain_id', 'query'],
},
handler: async (params) => {
const query = params.query as string;
const items = await db.select()
.from(notes)
.where(and(
eq(notes.domainId, params.domain_id as string),
isNull(notes.deletedAt),
or(
ilike(notes.title, `%${query}%`),
ilike(notes.content ?? sql`''`, `%${query}%`)
)
))
.orderBy(desc(notes.updatedAt))
.limit(20);
return { items };
},
},
// ── Domains ────────────────────────────────────────────────────────────────────
{
name: 'domains.list',
description: 'List domains/workspaces',
inputSchema: {
type: 'object',
properties: {},
},
handler: async () => {
const items = await db.select().from(domains).orderBy(asc(domains.name));
return { items };
},
},
{
name: 'domains.create',
description: 'Create a new domain/workspace',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string' },
slug: { type: 'string' },
color: { type: 'string' },
},
required: ['name', 'slug'],
},
handler: async (params, auth) => {
const [domain] = await db.insert(domains).values({
name: params.name as string,
slug: params.slug as string,
color: (params.color as string) ?? null,
}).returning();
await recordActivity({
actor: auth.userName,
action: 'created',
entityType: 'domain',
entityId: domain.id,
workspaceId: domain.id,
});
return domain;
},
},
// ── Search ─────────────────────────────────────────────────────────────────────
{
name: 'search.query',
description: 'Full-text search across entities',
inputSchema: {
type: 'object',
properties: {
domain_id: { type: 'string' },
query: { type: 'string' },
types: { type: 'array', items: { type: 'string' }, description: 'Entity types to search: tasks, notes, projects, habits' },
limit: { type: 'number', default: 20 },
},
required: ['domain_id', 'query'],
},
handler: async (params) => {
const query = params.query as string;
const domainId = params.domain_id as string;
const types = (params.types as string[]) || ['tasks', 'notes', 'projects', 'habits'];
const limit = Math.min(Number(params.limit) || 20, 50);
const results: Record<string, unknown[]> = {};
if (types.includes('tasks')) {
results.tasks = await db.select({
id: tasks.id, title: tasks.title, status: tasks.status, priority: tasks.priority,
}).from(tasks)
.where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt), ilike(tasks.title, `%${query}%`)))
.limit(limit);
}
if (types.includes('notes')) {
results.notes = await db.select({ id: notes.id, title: notes.title }).from(notes)
.where(and(eq(notes.domainId, domainId), isNull(notes.deletedAt), ilike(notes.title, `%${query}%`)))
.limit(limit);
}
if (types.includes('projects')) {
results.projects = await db.select({ id: projects.id, name: projects.name, status: projects.status }).from(projects)
.where(and(eq(projects.domainId, domainId), isNull(projects.deletedAt), ilike(projects.name, `%${query}%`)))
.limit(limit);
}
if (types.includes('habits')) {
results.habits = await db.select({ id: habits.id, name: habits.name, frequency: habits.frequency }).from(habits)
.where(and(eq(habits.domainId, domainId), isNull(habits.deletedAt), ilike(habits.name, `%${query}%`)))
.limit(limit);
}
return results;
},
},
// ── Activity ───────────────────────────────────────────────────────────────────
{
name: 'activity.list',
description: 'List recent activity feed entries',
inputSchema: {
type: 'object',
properties: {
workspace_id: { type: 'string' },
limit: { type: 'number', default: 20 },
offset: { type: 'number', default: 0 },
},
required: ['workspace_id'],
},
handler: async (params) => {
const items = await db.select()
.from(activityFeed)
.where(eq(activityFeed.workspaceId, params.workspace_id as string))
.orderBy(desc(activityFeed.createdAt))
.limit(Math.min(Number(params.limit) || 20, 100))
.offset(Number(params.offset) || 0);
return { items };
},
},
];
// ── Error helper ─────────────────────────────────────────────────────────────────
class JsonRpcErrorResponse extends Error {
constructor(public code: number, message: string, public data?: unknown) {
super(message);
this.name = 'JsonRpcErrorResponse';
}
}
// ── Handler ──────────────────────────────────────────────────────────────────────
function makeError(code: number, message: string, data?: unknown): JsonRpcResponse {
return { jsonrpc: '2.0', error: { code, message, data }, id: null };
}
function makeResult(result: unknown, id: string | number | null): JsonRpcResponse {
return { jsonrpc: '2.0', result, id };
}
async function handleRequest(body: JsonRpcRequest, auth: { userId: string; userName: string }): Promise<JsonRpcResponse> {
const { method, params, id } = body;
if (method === 'server/discover') {
return makeResult({
name: 'project-e',
version: '1.0.0',
capabilities: { tools: {} },
tools: tools.map(t => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
})),
}, id);
}
if (method === 'tools/call') {
const callParams = params as { name?: string; arguments?: Record<string, unknown> } | undefined;
if (!callParams?.name) {
return makeError(JSONRPC_INVALID_PARAMS, 'Missing tool name', id);
}
const tool = tools.find(t => t.name === callParams.name);
if (!tool) {
return makeError(JSONRPC_METHOD_NOT_FOUND, `Unknown tool: ${callParams.name}`, id);
}
try {
const result = await tool.handler(callParams.arguments || {}, auth);
return makeResult(result, id);
} catch (error) {
if (error instanceof JsonRpcErrorResponse) {
return makeError(error.code, error.message, error.data);
}
console.error(`[MCP] Tool ${callParams.name} error:`, error);
return makeError(JSONRPC_INTERNAL_ERROR, error instanceof Error ? error.message : 'Internal error', id);
}
}
return makeError(JSONRPC_METHOD_NOT_FOUND, `Unknown method: ${method}`, id);
}
// ── Route handler ────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
const auth = await authenticateApiKey(request);
if (!auth) {
return NextResponse.json(
{ jsonrpc: '2.0', error: { code: -32001, message: 'Unauthorized. Provide a valid API key in Authorization: Bearer header.' }, id: null },
{ status: 401 }
);
}
let body: JsonRpcRequest;
try {
body = await request.json();
} catch {
return NextResponse.json(
makeError(JSONRPC_PARSE_ERROR, 'Parse error: invalid JSON'),
{ status: 400 }
);
}
// Validate JSON-RPC 2.0
if (!body || body.jsonrpc !== '2.0' || !body.method) {
return NextResponse.json(
makeError(JSONRPC_INVALID_REQUEST, 'Invalid Request: must be valid JSON-RPC 2.0 with method'),
{ status: 400 }
);
}
const response = await handleRequest(body, auth);
return NextResponse.json(response);
}
// GET is not supported — MCP is stateless POST-only
export async function GET() {
return NextResponse.json(
makeError(JSONRPC_METHOD_NOT_FOUND, 'MCP server only accepts POST requests'),
{ status: 405 }
);
}
-51
View File
@@ -1,51 +0,0 @@
// 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 } from '@/lib/auth';
import { createPocketBaseClient } from '@/lib/pocketbase';
import { updateMilestoneSchema } from '@project-e/shared';
import { z } from 'zod';
type RouteContext = { params: Promise<{ id: string }> };
// GET /api/milestones/[id] — Get a single milestone
export const GET = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
const { id } = await context!.params;
const pb = createPocketBaseClient();
const milestone = await pb.collection('milestones').getOne(id);
return NextResponse.json(milestone);
});
// PATCH /api/milestones/[id] — Update a milestone
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
try {
const { id } = await context!.params;
const body = await request.json();
const data = updateMilestoneSchema.parse(body);
const pb = createPocketBaseClient();
const milestone = await pb.collection('milestones').update(id, data);
return NextResponse.json(milestone);
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
throw error;
}
});
// DELETE /api/milestones/[id] — Delete a milestone
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
const { id } = await context!.params;
const pb = createPocketBaseClient();
await pb.collection('milestones').delete(id);
return new NextResponse(null, { status: 204 });
});
-58
View File
@@ -1,58 +0,0 @@
// 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 } from '@/lib/auth';
import { createPocketBaseClient } from '@/lib/pocketbase';
import { createMilestoneSchema } from '@project-e/shared';
import { z } from 'zod';
// GET /api/milestones — List milestones with filtering, sorting, pagination
export const GET = withAuth(async (request: NextRequest, _user) => {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const perPage = parseInt(searchParams.get('perPage') || '50');
const filter = searchParams.get('filter') || undefined;
const sort = searchParams.get('sort') || '-created';
const pb = createPocketBaseClient();
const result = await pb.collection('milestones').getList(page, perPage, {
...(filter ? { filter } : {}),
sort,
});
const response = NextResponse.json({
items: result.items,
totalItems: result.totalItems,
totalPages: result.totalPages,
page: result.page,
perPage: result.perPage,
});
response.headers.set(
'Cache-Control',
'private, max-age=60, stale-while-revalidate=300'
);
return response;
});
// POST /api/milestones — Create a milestone
export const POST = withAuth(async (request: NextRequest, _user) => {
try {
const body = await request.json();
const data = createMilestoneSchema.parse(body);
const pb = createPocketBaseClient();
const milestone = await pb.collection('milestones').create(data);
return NextResponse.json(milestone, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
throw error;
}
});
@@ -1,23 +0,0 @@
// 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 } from '@/lib/auth';
import { getBacklinks } from '@/lib/services/note-service';
type RouteContext = { params: Promise<{ id: string }> };
// GET /api/notes/[id]/backlinks — Get notes that link to this note
export const GET = withAuth<RouteContext>(
async (request: NextRequest, _user, context) => {
const { id } = await context!.params;
const backlinks = await getBacklinks(id);
return NextResponse.json({
items: backlinks,
totalItems: backlinks.length,
});
}
);
-63
View File
@@ -1,63 +0,0 @@
// 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 } from '@/lib/auth';
import { createPocketBaseClient } from '@/lib/pocketbase';
import { updateNoteSchema } from '@project-e/shared';
import { syncNoteLinks, syncNoteTasks, getBacklinks } from '@/lib/services';
import { z } from 'zod';
type RouteContext = { params: Promise<{ id: string }> };
// GET /api/notes/[id] — Get a single note with backlinks
export const GET = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
const { id } = await context!.params;
const pb = createPocketBaseClient();
const note = await pb.collection('notes').getOne(id);
const backlinks = await getBacklinks(id);
return NextResponse.json({
...note,
backlinks,
});
});
// PATCH /api/notes/[id] — Update a note, then re-sync links and tasks
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
try {
const { id } = await context!.params;
const body = await request.json();
const data = updateNoteSchema.parse(body);
const pb = createPocketBaseClient();
const note = await pb.collection('notes').update(id, data);
// Re-sync wikilinks and checkbox tasks from content
const content = data.content ?? note.content;
if (content) {
await syncNoteLinks(id, content);
await syncNoteTasks(id, content);
}
return NextResponse.json(note);
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
throw error;
}
});
// DELETE /api/notes/[id] — Delete a note
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
const { id } = await context!.params;
const pb = createPocketBaseClient();
await pb.collection('notes').delete(id);
return new NextResponse(null, { status: 204 });
});
-241
View File
@@ -1,241 +0,0 @@
// 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 } from '@/lib/auth';
import { createPocketBaseClient } from '@/lib/pocketbase';
// ── Helpers ──────────────────────────────────────────────────────────────────
/** Parse a "YYYY-MM-DD" string into start/end ISO boundaries (UTC). */
function dayBounds(dateStr: string) {
const start = new Date(`${dateStr}T00:00:00.000Z`);
const end = new Date(`${dateStr}T23:59:59.999Z`);
return { start: start.toISOString(), end: end.toISOString() };
}
/** Format minutes into a human-readable "Xh Ym" string. */
function formatMinutes(total: number): string {
if (total < 60) return `${total}m`;
const h = Math.floor(total / 60);
const m = total % 60;
return m > 0 ? `${h}h ${m}m` : `${h}h`;
}
/** Escape HTML special characters. */
function esc(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** Build an <ul> of items, or an empty-state <p> if the list is empty. */
function list(items: string[], emptyMsg: string): string {
if (items.length === 0) {
return `<p><em>${esc(emptyMsg)}</em></p>`;
}
return `<ul>${items.map((t) => `<li>${t}</li>`).join('')}</ul>`;
}
/** Generate the full HTML body for a daily note. */
function buildDailyNoteHtml(ctx: {
completedTasks: string[];
habitLogs: string[];
timeEntries: string[];
overdueTasks: string[];
}): string {
return [
`<h2>Tasks Completed</h2>`,
list(ctx.completedTasks, 'No tasks completed today.'),
`<h2>Habits Logged</h2>`,
list(ctx.habitLogs, 'No habits logged today.'),
`<h2>Time Tracked</h2>`,
list(ctx.timeEntries, 'No time tracked today.'),
`<h2>Overdue Items</h2>`,
list(ctx.overdueTasks, 'Nothing overdue.'),
`<h2>Notes</h2>`,
`<p></p>`,
`<h2>Reflections</h2>`,
`<p></p>`,
`<h2>Gratitude</h2>`,
`<p></p>`,
].join('\n');
}
// ── Route handlers ───────────────────────────────────────────────────────────
/** GET /api/notes/daily?date=YYYY-MM-DD — return the daily note if it exists. */
export const GET = withAuth(async (request: NextRequest) => {
const { searchParams } = new URL(request.url);
const date = searchParams.get('date');
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return createErrorResponse(
'VALIDATION_ERROR',
'A valid date parameter (YYYY-MM-DD) is required.',
400
);
}
const title = `Daily Note - ${date}`;
const pb = createPocketBaseClient();
const result = await pb.collection('notes').getList(1, 1, {
filter: `title = "${title}"`,
});
if (result.items.length === 0) {
return NextResponse.json({ note: null });
}
return NextResponse.json({ note: result.items[0] });
});
/** POST /api/notes/daily — create today's daily note (idempotent). */
export const POST = withAuth(async (request: NextRequest) => {
try {
const body = await request.json();
const date: string | undefined = body?.date;
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return createErrorResponse(
'VALIDATION_ERROR',
'A valid date string (YYYY-MM-DD) is required in the request body.',
400
);
}
const title = `Daily Note - ${date}`;
const pb = createPocketBaseClient();
// ── 1. Idempotency check ────────────────────────────────────────────────
const existing = await pb.collection('notes').getList(1, 1, {
filter: `title = "${title}"`,
});
if (existing.items.length > 0) {
return NextResponse.json(existing.items[0]);
}
// ── 2. Date boundaries ──────────────────────────────────────────────────
const { start, end } = dayBounds(date);
// ── 3. Fetch all data in parallel ───────────────────────────────────────
const [
completedTaskRecords,
habitLogRecords,
timeEntryRecords,
overdueTaskRecords,
habitsAll,
] = await Promise.all([
// Tasks completed today
pb.collection('tasks').getFullList({
filter: `completed_at >= "${start}" && completed_at <= "${end}"`,
sort: 'completed_at',
}),
// Habit logs for the day
pb.collection('habit_logs').getFullList({
filter: `logged_at >= "${start}" && logged_at <= "${end}"`,
sort: 'logged_at',
}),
// Time entries for the day
pb.collection('task_time_entries').getFullList({
filter: `started_at >= "${start}" && started_at <= "${end}"`,
sort: 'started_at',
}),
// Overdue tasks (due before today, not done)
pb.collection('tasks').getFullList({
filter: `due_date < "${start}" && status != "done" && status != "cancelled"`,
sort: 'due_date',
}),
// All active habits (for name lookup)
pb.collection('habits').getFullList({
filter: 'active = true',
}),
]);
// ── 4. Build lookup maps ────────────────────────────────────────────────
const habitNameById = new Map<string, string>();
for (const h of habitsAll) {
habitNameById.set(h.id, h.name as string);
}
// Collect task IDs from time entries so we can resolve names
const taskIdsForTimeEntries = [
...new Set(timeEntryRecords.map((e) => e.task_id as string)),
];
const taskNamesMap = new Map<string, string>();
// Fetch task names in parallel for time entries and overdue tasks
const allTaskIds = new Set<string>();
for (const t of completedTaskRecords) allTaskIds.add(t.id);
for (const t of overdueTaskRecords) allTaskIds.add(t.id);
for (const id of taskIdsForTimeEntries) allTaskIds.add(id);
const taskFetches = await Promise.allSettled(
[...allTaskIds].map((id) => pb.collection('tasks').getOne(id))
);
for (const res of taskFetches) {
if (res.status === 'fulfilled') {
const t = res.value;
taskNamesMap.set(t.id, t.title as string);
}
}
// ── 5. Format sections ──────────────────────────────────────────────────
const completedTasks = completedTaskRecords.map((t) => {
const name = taskNamesMap.get(t.id) ?? (t.title as string);
return `${esc(name)}`;
});
const habitLogs = habitLogRecords.map((log) => {
const habitName = habitNameById.get(log.habit_id) ?? 'Unknown habit';
const status = log.completed ? '✓' : log.skipped ? 'skipped' : '—';
const mood = log.mood != null ? ` (mood: ${log.mood}/5)` : '';
return `${esc(habitName)}${status}${mood}`;
});
const timeEntries = timeEntryRecords.map((entry) => {
const taskName = taskNamesMap.get(entry.task_id as string) ?? 'Unknown task';
const dur = formatMinutes((entry.duration_minutes as number) || 0);
const notes = entry.notes ? `${esc(entry.notes as string)}` : '';
return `<strong>${dur}</strong> on ${esc(taskName)}${notes}`;
});
const overdueTasks = overdueTaskRecords.map((t) => {
const name = taskNamesMap.get(t.id) ?? (t.title as string);
const due = t.due_date
? ` (due ${new Date(t.due_date as string).toLocaleDateString()})`
: '';
return `${esc(name)}${due}`;
});
// ── 6. Build HTML content ───────────────────────────────────────────────
const content = buildDailyNoteHtml({
completedTasks,
habitLogs,
timeEntries,
overdueTasks,
});
// ── 7. Create note ──────────────────────────────────────────────────────
const note = await pb.collection('notes').create({
title,
content,
domain: 'personal',
tags: ['daily'],
});
return NextResponse.json(note, { status: 201 });
} catch (error) {
console.error('Failed to create daily note:', error);
return createErrorResponse(
'INTERNAL_ERROR',
'Failed to create daily note.',
500
);
}
});
-18
View File
@@ -1,18 +0,0 @@
// 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 { NextResponse } from 'next/server';
import { withAuth } from '@/lib/auth';
import { getNoteGraph } from '@/lib/services/note-service';
// GET /api/notes/graph — Get note graph data for visualization
export const GET = withAuth(async () => {
const graph = await getNoteGraph();
return NextResponse.json(graph, {
headers: {
'Cache-Control': 'private, max-age=60, stale-while-revalidate=300',
},
});
});
-66
View File
@@ -1,66 +0,0 @@
// 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 } from '@/lib/auth';
import { createPocketBaseClient } from '@/lib/pocketbase';
import { createNoteSchema } from '@project-e/shared';
import { syncNoteLinks, syncNoteTasks } from '@/lib/services';
import { z } from 'zod';
// GET /api/notes — List notes with filtering, sorting, pagination
export const GET = withAuth(async (request: NextRequest, _user) => {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const perPage = parseInt(searchParams.get('perPage') || '50');
const filter = searchParams.get('filter') || undefined;
const sort = searchParams.get('sort') || '-created';
const pb = createPocketBaseClient();
const result = await pb.collection('notes').getList(page, perPage, {
...(filter ? { filter } : {}),
sort,
});
const response = NextResponse.json({
items: result.items,
totalItems: result.totalItems,
totalPages: result.totalPages,
page: result.page,
perPage: result.perPage,
});
// Cache for 60 seconds with stale-while-revalidate
response.headers.set(
'Cache-Control',
'private, max-age=60, stale-while-revalidate=300'
);
return response;
});
// POST /api/notes — Create a note, then sync links and tasks
export const POST = withAuth(async (request: NextRequest, _user) => {
try {
const body = await request.json();
const data = createNoteSchema.parse(body);
const pb = createPocketBaseClient();
const note = await pb.collection('notes').create(data);
// Sync wikilinks and checkbox tasks from content
if (data.content) {
await syncNoteLinks(note.id, data.content);
await syncNoteTasks(note.id, data.content);
}
return NextResponse.json(note, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
throw error;
}
});
@@ -1,17 +0,0 @@
// 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 } from '@/lib/auth';
import { computeProjectProgress } from '@/lib/services/project-service';
type RouteContext = { params: Promise<{ id: string }> };
// GET /api/projects/[id]/progress — Get project progress
export const GET = withAuth<RouteContext>(async (_request: NextRequest, _user, context) => {
const { id } = await context!.params;
const progress = await computeProjectProgress(id);
return NextResponse.json({ progress });
});
-65
View File
@@ -1,65 +0,0 @@
// 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 } from '@/lib/auth';
import { createPocketBaseClient } from '@/lib/pocketbase';
import { updateProjectSchema } from '@project-e/shared';
import { z } from 'zod';
type RouteContext = { params: Promise<{ id: string }> };
// GET /api/projects/[id] — Get a single project with computed stats
export const GET = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
const { id } = await context!.params;
const pb = createPocketBaseClient();
const project = await pb.collection('projects').getOne(id);
// Compute task stats from related tasks
const tasksResult = await pb.collection('tasks').getFullList({
filter: 'project_id=' + id,
});
const taskCount = tasksResult.length;
const completedCount = tasksResult.filter((t: any) => t.status === 'done').length;
const progress = taskCount > 0 ? Math.round((completedCount / taskCount) * 100) : 0;
return NextResponse.json({
...project,
progress,
task_count: taskCount,
completed_count: completedCount,
});
});
// PATCH /api/projects/[id] — Update a project
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
try {
const { id } = await context!.params;
const body = await request.json();
const data = updateProjectSchema.parse(body);
const pb = createPocketBaseClient();
const project = await pb.collection('projects').update(id, data);
return NextResponse.json(project);
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
throw error;
}
});
// DELETE /api/projects/[id] — Delete a project
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
const { id } = await context!.params;
const pb = createPocketBaseClient();
await pb.collection('projects').delete(id);
return new NextResponse(null, { status: 204 });
});
-124
View File
@@ -1,124 +0,0 @@
// 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, projects, projectTags, tags as tagsTable } from '@project-e/db';
import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm';
import { z } from 'zod';
const projectStatusEnum = z.enum(['active', 'paused', 'completed', 'archived']);
const createProjectSchema = z.object({
name: z.string().min(1, 'Name is required'),
description: z.string().optional().nullable(),
domain: z.string().min(1, 'Domain is required'),
status: projectStatusEnum.optional().default('active'),
color: z.string().optional().nullable(),
icon: z.string().optional().nullable(),
targetDate: z.string().datetime().optional().nullable(),
tagIds: z.array(z.string().uuid()).optional(),
});
// GET /api/projects — List projects with filtering, sorting, pagination
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';
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(/^-/, '');
const sortColumns: Record<string, any> = {
created: projects.createdAt,
updated: projects.updatedAt,
name: projects.name,
status: projects.status,
};
const orderBy = sortDir === 'asc'
? asc(sortColumns[sortField] || projects.createdAt)
: desc(sortColumns[sortField] || projects.createdAt);
const conditions: any[] = [isNull(projects.deletedAt)];
if (domainId) conditions.push(eq(projects.domainId, domainId));
if (filter) {
conditions.push(ilike(projects.name, `%${filter}%`));
}
const offset = (page - 1) * perPage;
const [items, countResult] = await Promise.all([
db.select()
.from(projects)
.where(and(...conditions))
.orderBy(orderBy)
.limit(perPage)
.offset(offset),
db.select({ count: sql<number>`count(*)` })
.from(projects)
.where(and(...conditions)),
]);
const totalItems = Number(countResult[0]?.count || 0);
return NextResponse.json({
items,
totalItems,
totalPages: Math.ceil(totalItems / perPage),
page,
perPage,
});
});
// POST /api/projects — Create a project
export const POST = withAuth(async (request: NextRequest, user) => {
try {
const body = await request.json();
const data = createProjectSchema.parse({
...body,
domain: body.domain || (await resolveActiveDomain(user)).id,
});
const [project] = await db.insert(projects).values({
name: data.name,
description: data.description ?? null,
domainId: data.domain,
status: data.status,
color: data.color ?? null,
icon: data.icon ?? null,
targetDate: data.targetDate ? new Date(data.targetDate) : null,
}).returning();
if (data.tagIds && data.tagIds.length > 0) {
await db.insert(projectTags).values(
data.tagIds.map(tagId => ({ projectId: project.id, tagId }))
);
}
await recordActivity({
actor: user.name,
action: 'created',
entityType: 'project',
entityId: project.id,
changes: { name: project.name },
workspaceId: data.domain,
});
return NextResponse.json(project, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
console.error('[projects POST] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to create project', 500);
}
});

Some files were not shown because too many files have changed in this diff Show More