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:
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals"]
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 "{deleteHabit?.name}"? 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 "{deleteSection?.name}"? 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>
|
||||
);
|
||||
}
|
||||
@@ -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 "{archiveProject?.name}"? 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 “{query}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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, ' '),
|
||||
})),
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** 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
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user