Merge redesign/ui-v2 into main: full v2 rewrite (Vite SPA + Hono API + Bun worker)
Resolved conflicts in web-legacy pages and report schema by taking v2 side. v2 is the deployed, current architecture; v1 paths preserved under apps/web-legacy.
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// 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 });
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// 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',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
// 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 };
|
||||
@@ -0,0 +1,26 @@
|
||||
// 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// 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 });
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
// 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 });
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
// 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 });
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// 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 });
|
||||
});
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
// 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 });
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
// 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 });
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
// 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 });
|
||||
});
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
// 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',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
// 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 });
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
// 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 });
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
// 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 });
|
||||
});
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
// 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 });
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// 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 });
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
// 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);
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
// 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 });
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
// 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);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// 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 });
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
// 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 });
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// 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, ' '),
|
||||
})),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
// 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',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
// 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, requireWorkspaceAccess } from '@/lib/auth';
|
||||
import { recordActivity } from '@/lib/activity';
|
||||
import { db, habits, habitTags } from '@project-e/db';
|
||||
import { and, eq, isNull } 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, 'Name is required').optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
domain: z.string().min(1, 'Domain is required').optional(),
|
||||
frequency: habitFrequencyEnum.optional(),
|
||||
difficulty: habitDifficultyEnum.optional(),
|
||||
goalPerPeriod: z.number().int().positive().optional(),
|
||||
active: z.boolean().optional(),
|
||||
tagIds: z.array(z.string().uuid()).optional(),
|
||||
});
|
||||
|
||||
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 [habit] = await db.select()
|
||||
.from(habits)
|
||||
.where(and(eq(habits.id, id), isNull(habits.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!habit) {
|
||||
return createErrorResponse('NOT_FOUND', 'Habit not found', 404);
|
||||
}
|
||||
|
||||
return NextResponse.json(habit);
|
||||
});
|
||||
|
||||
// PATCH /api/habits/[id] — Update a habit
|
||||
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { id } = await context!.params;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = updateHabitSchema.parse(body);
|
||||
|
||||
if (data.domain) {
|
||||
await requireWorkspaceAccess(data.domain);
|
||||
}
|
||||
|
||||
const updateValues: Record<string, any> = { updatedAt: new Date() };
|
||||
if (data.name !== undefined) updateValues.name = data.name;
|
||||
if (data.description !== undefined) updateValues.description = data.description;
|
||||
if (data.domain !== undefined) updateValues.domainId = data.domain;
|
||||
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.active !== undefined) updateValues.active = data.active;
|
||||
|
||||
const [habit] = await db.update(habits)
|
||||
.set(updateValues)
|
||||
.where(and(eq(habits.id, id), isNull(habits.deletedAt)))
|
||||
.returning();
|
||||
|
||||
if (!habit) {
|
||||
return createErrorResponse('NOT_FOUND', 'Habit not found', 404);
|
||||
}
|
||||
|
||||
if (data.tagIds) {
|
||||
await db.delete(habitTags).where(eq(habitTags.habitId, id));
|
||||
if (data.tagIds.length > 0) {
|
||||
await db.insert(habitTags).values(
|
||||
data.tagIds.map(tagId => ({ habitId: id, tagId }))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: 'updated',
|
||||
entityType: 'habit',
|
||||
entityId: habit.id,
|
||||
changes: { name: habit.name },
|
||||
workspaceId: habit.domainId,
|
||||
});
|
||||
|
||||
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] — Soft-delete a habit
|
||||
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { id } = await context!.params;
|
||||
|
||||
const [habit] = await db.update(habits)
|
||||
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
||||
.where(and(eq(habits.id, id), isNull(habits.deletedAt)))
|
||||
.returning();
|
||||
|
||||
if (!habit) {
|
||||
return createErrorResponse('NOT_FOUND', 'Habit not found', 404);
|
||||
}
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: 'deleted',
|
||||
entityType: 'habit',
|
||||
entityId: habit.id,
|
||||
changes: { name: habit.name },
|
||||
workspaceId: habit.domainId,
|
||||
});
|
||||
|
||||
return new NextResponse(null, { status: 204 });
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
// 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',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
// 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',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,746 @@
|
||||
// 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 }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// 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 });
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,63 @@
|
||||
// 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 });
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
// 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
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
// 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',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
// 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 });
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
// 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 });
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse, resolveActiveDomain } from '@/lib/auth';
|
||||
import { recordActivity } from '@/lib/activity';
|
||||
import { db, tasks, habits, notes, projects } from '@project-e/db';
|
||||
import { z } from 'zod';
|
||||
|
||||
const quickCaptureSchema = z.object({
|
||||
type: z.enum(['task', 'habit', 'note', 'project']),
|
||||
text: z.string().min(1, 'Text is required'),
|
||||
description: z.string().optional().nullable(),
|
||||
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional().default('medium'),
|
||||
domain: z.string().optional(),
|
||||
});
|
||||
|
||||
// POST /api/quick-capture — Create an entity from quick text input
|
||||
// Forwards to the appropriate create logic after resolving the active domain.
|
||||
export const POST = withAuth(async (request: NextRequest, user) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = quickCaptureSchema.parse(body);
|
||||
const domainId = data.domain || (await resolveActiveDomain(user)).id;
|
||||
|
||||
let result;
|
||||
|
||||
switch (data.type) {
|
||||
case 'task': {
|
||||
const [task] = await db.insert(tasks).values({
|
||||
title: data.text,
|
||||
description: data.description ?? null,
|
||||
domainId,
|
||||
priority: data.priority,
|
||||
}).returning();
|
||||
result = task;
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: 'created',
|
||||
entityType: 'task',
|
||||
entityId: task.id,
|
||||
changes: { title: task.title },
|
||||
workspaceId: domainId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'habit': {
|
||||
const [habit] = await db.insert(habits).values({
|
||||
name: data.text,
|
||||
description: data.description ?? null,
|
||||
domainId,
|
||||
}).returning();
|
||||
result = habit;
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: 'created',
|
||||
entityType: 'habit',
|
||||
entityId: habit.id,
|
||||
changes: { name: habit.name },
|
||||
workspaceId: domainId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'note': {
|
||||
const [note] = await db.insert(notes).values({
|
||||
title: data.text,
|
||||
content: data.description ?? null,
|
||||
domainId,
|
||||
}).returning();
|
||||
result = note;
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: 'created',
|
||||
entityType: 'note',
|
||||
entityId: note.id,
|
||||
changes: { title: note.title },
|
||||
workspaceId: domainId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'project': {
|
||||
const [project] = await db.insert(projects).values({
|
||||
name: data.text,
|
||||
description: data.description ?? null,
|
||||
domainId,
|
||||
}).returning();
|
||||
result = project;
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: 'created',
|
||||
entityType: 'project',
|
||||
entityId: project.id,
|
||||
changes: { name: project.name },
|
||||
workspaceId: domainId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||
}
|
||||
console.error('[quick-capture POST] error:', error);
|
||||
return createErrorResponse('INTERNAL_ERROR', 'Failed to create', 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
// 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 } from 'next/server';
|
||||
import { getAuthUser } from '@/lib/auth';
|
||||
import postgres from 'postgres';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300; // 5 minutes
|
||||
|
||||
// GET /api/realtime — SSE endpoint backed by PostgreSQL LISTEN/NOTIFY.
|
||||
// Supports v2 entities: task, habit, project, note, domain, tag, section, habit_completion, activity
|
||||
export async function GET(request: NextRequest) {
|
||||
const user = await getAuthUser(request);
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const workspaceId = searchParams.get('workspace_id');
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const listener = postgres(process.env.DATABASE_URL!, { max: 1 });
|
||||
let unlisten: (() => Promise<void>) | undefined;
|
||||
let keepalive: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// Send connected event
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ type: 'connected', workspace_id: workspaceId })}\n\n`
|
||||
)
|
||||
);
|
||||
|
||||
const subscription = await listener.listen('project_e_events', (payload) => {
|
||||
try {
|
||||
const event = JSON.parse(payload) as {
|
||||
type: string;
|
||||
action: string;
|
||||
id: string;
|
||||
workspace_id?: string;
|
||||
};
|
||||
|
||||
// Filter by workspace_id if specified
|
||||
if (workspaceId && event.workspace_id !== workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||
} catch {
|
||||
// Ignore malformed database notifications and closed streams.
|
||||
}
|
||||
});
|
||||
unlisten = subscription.unlisten;
|
||||
|
||||
// Keepalive ping every 30 seconds
|
||||
keepalive = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(':ping\n\n'));
|
||||
} catch {
|
||||
if (keepalive) clearInterval(keepalive);
|
||||
}
|
||||
}, 30000);
|
||||
},
|
||||
|
||||
async cancel() {
|
||||
if (keepalive) clearInterval(keepalive);
|
||||
await unlisten?.();
|
||||
await listener.end({ timeout: 5 });
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { db, reports } from '@project-e/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
function mapReport(report: Record<string, any>) {
|
||||
return {
|
||||
id: report.id,
|
||||
title: report.title,
|
||||
content: report.content || '',
|
||||
report_type: report.reportType || report.report_type || 'custom',
|
||||
date_range_start: report.dateRangeStart?.toISOString?.() || report.date_range_start || null,
|
||||
date_range_end: report.dateRangeEnd?.toISOString?.() || report.date_range_end || null,
|
||||
domain: report.domain || 'personal',
|
||||
is_draft: report.isDraft ?? report.is_draft ?? false,
|
||||
created: report.createdAt?.toISOString?.() || report.created || new Date().toISOString(),
|
||||
updated: report.updatedAt?.toISOString?.() || report.updated || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const updateReportSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required').optional(),
|
||||
content: z.string().optional(),
|
||||
report_type: z.enum(['weekly', 'monthly', 'project', 'habit', 'custom']).optional(),
|
||||
date_range_start: z.string().optional(),
|
||||
date_range_end: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
is_draft: z.boolean().optional(),
|
||||
});
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
// GET /api/reports/[id] — Get a single report
|
||||
export const GET = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
|
||||
const { id } = await context!.params;
|
||||
|
||||
const [report] = await db.select()
|
||||
.from(reports)
|
||||
.where(eq(reports.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!report) {
|
||||
return createErrorResponse('NOT_FOUND', 'Report not found', 404);
|
||||
}
|
||||
|
||||
return NextResponse.json(mapReport(report));
|
||||
});
|
||||
|
||||
// PATCH /api/reports/[id] — Update a report
|
||||
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
|
||||
try {
|
||||
const { id } = await context!.params;
|
||||
const body = await request.json();
|
||||
const data = updateReportSchema.parse(body);
|
||||
|
||||
const updateValues: Record<string, any> = {};
|
||||
if (data.title !== undefined) updateValues.title = data.title;
|
||||
if (data.content !== undefined) updateValues.content = data.content;
|
||||
if (data.report_type !== undefined) updateValues.reportType = data.report_type;
|
||||
if (data.date_range_start !== undefined) updateValues.dateRangeStart = new Date(data.date_range_start);
|
||||
if (data.date_range_end !== undefined) updateValues.dateRangeEnd = new Date(data.date_range_end);
|
||||
if (data.domain !== undefined) updateValues.domain = data.domain;
|
||||
if (data.is_draft !== undefined) updateValues.isDraft = data.is_draft;
|
||||
updateValues.updatedAt = new Date();
|
||||
|
||||
const [report] = await db.update(reports)
|
||||
.set(updateValues)
|
||||
.where(eq(reports.id, id))
|
||||
.returning();
|
||||
|
||||
if (!report) {
|
||||
return createErrorResponse('NOT_FOUND', 'Report not found', 404);
|
||||
}
|
||||
|
||||
return NextResponse.json(mapReport(report));
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/reports/[id] — Delete a report
|
||||
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
|
||||
const { id } = await context!.params;
|
||||
|
||||
const [deleted] = await db.delete(reports)
|
||||
.where(eq(reports.id, id))
|
||||
.returning({ id: reports.id });
|
||||
|
||||
if (!deleted) {
|
||||
return createErrorResponse('NOT_FOUND', 'Report not found', 404);
|
||||
}
|
||||
|
||||
return new NextResponse(null, { status: 204 });
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { db, reports } from '@project-e/db';
|
||||
import { and, asc, desc, eq, ilike, or, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Map Drizzle DB fields to frontend-expected field names
|
||||
function mapReport(report: Record<string, any>) {
|
||||
return {
|
||||
id: report.id,
|
||||
title: report.title,
|
||||
content: report.content || '',
|
||||
report_type: report.reportType || report.report_type || 'custom',
|
||||
date_range_start: report.dateRangeStart?.toISOString?.() || report.date_range_start || null,
|
||||
date_range_end: report.dateRangeEnd?.toISOString?.() || report.date_range_end || null,
|
||||
domain: report.domain || 'personal',
|
||||
is_draft: report.isDraft ?? report.is_draft ?? false,
|
||||
created: report.createdAt?.toISOString?.() || report.created || new Date().toISOString(),
|
||||
updated: report.updatedAt?.toISOString?.() || report.updated || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const createReportSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required').default('Untitled report'),
|
||||
content: z.string().optional().default(''),
|
||||
report_type: z.enum(['weekly', 'monthly', 'project', 'habit', 'custom']).default('custom'),
|
||||
date_range_start: z.string().optional(),
|
||||
date_range_end: z.string().optional(),
|
||||
domain: z.string().default('personal'),
|
||||
});
|
||||
|
||||
// GET /api/reports — List reports
|
||||
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') || '-created';
|
||||
const filter = searchParams.get('filter') || undefined;
|
||||
|
||||
const sortDir = sortParam.startsWith('-') ? 'desc' : 'asc';
|
||||
const sortField = sortParam.replace(/^-/, '');
|
||||
const sortColumns: Record<string, any> = {
|
||||
title: reports.title,
|
||||
report_type: reports.reportType,
|
||||
domain: reports.domain,
|
||||
created: reports.createdAt,
|
||||
updated: reports.updatedAt,
|
||||
};
|
||||
const orderBy = sortDir === 'asc'
|
||||
? asc(sortColumns[sortField] || reports.createdAt)
|
||||
: desc(sortColumns[sortField] || reports.createdAt);
|
||||
|
||||
const conditions: any[] = [];
|
||||
if (filter) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(reports.title, `%${filter}%`),
|
||||
)!
|
||||
);
|
||||
}
|
||||
|
||||
const offset = (page - 1) * perPage;
|
||||
|
||||
const [items, countResult] = await Promise.all([
|
||||
db.select()
|
||||
.from(reports)
|
||||
.where(and(...conditions))
|
||||
.orderBy(orderBy)
|
||||
.limit(perPage)
|
||||
.offset(offset),
|
||||
db.select({ count: sql<number>`count(*)` })
|
||||
.from(reports)
|
||||
.where(and(...conditions)),
|
||||
]);
|
||||
|
||||
const totalItems = Number(countResult[0]?.count || 0);
|
||||
|
||||
return NextResponse.json({
|
||||
items: items.map(mapReport),
|
||||
totalItems,
|
||||
totalPages: Math.ceil(totalItems / perPage),
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/reports — Create a report
|
||||
export const POST = withAuth(async (request: NextRequest, _user) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = createReportSchema.parse(body);
|
||||
|
||||
const [report] = await db.insert(reports)
|
||||
.values({
|
||||
title: data.title,
|
||||
content: data.content || '',
|
||||
reportType: data.report_type,
|
||||
dateRangeStart: data.date_range_start ? new Date(data.date_range_start) : null,
|
||||
dateRangeEnd: data.date_range_end ? new Date(data.date_range_end) : null,
|
||||
domain: data.domain,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return NextResponse.json(mapReport(report), { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
// 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 { searchEntities } from '@/lib/search-service';
|
||||
|
||||
// GET /api/search?q=&type=&domain=&limit=&offset=
|
||||
// Full-text search across all entity types using PostgreSQL tsvector/tsquery
|
||||
export const GET = withAuth(async (request: NextRequest, user) => {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const q = (searchParams.get('q') || '').trim();
|
||||
const types = searchParams.get('types')?.split(',').filter(Boolean) || ['task', 'note', 'project', 'habit', 'domain'];
|
||||
let domain = searchParams.get('domain') || undefined;
|
||||
if (!domain) {
|
||||
const active = await resolveActiveDomain(user);
|
||||
domain = active.id;
|
||||
}
|
||||
const limit = Math.max(1, Math.min(50, parseInt(searchParams.get('limit') || '20')));
|
||||
const offset = Math.max(0, parseInt(searchParams.get('offset') || '0'));
|
||||
|
||||
if (!q) {
|
||||
return NextResponse.json({ results: [], totalCount: 0 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { results, totalCount } = await searchEntities({
|
||||
query: q,
|
||||
types,
|
||||
domainId: domain,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
results,
|
||||
totalCount,
|
||||
query: q,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[search GET] error:', error);
|
||||
return createErrorResponse('INTERNAL_ERROR', 'Search failed', 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// 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 { updateTagSchema } from '@project-e/shared';
|
||||
import { z } from 'zod';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
// GET /api/tags/[id] — Get a single tag
|
||||
export const GET = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
|
||||
const { id } = await context!.params;
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const tag = await pb.collection('tags').getOne(id);
|
||||
|
||||
return NextResponse.json(tag);
|
||||
});
|
||||
|
||||
// PATCH /api/tags/[id] — Update a tag
|
||||
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
|
||||
try {
|
||||
const { id } = await context!.params;
|
||||
const body = await request.json();
|
||||
const data = updateTagSchema.parse(body);
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const tag = await pb.collection('tags').update(id, data);
|
||||
|
||||
return NextResponse.json(tag);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/tags/[id] — Delete a tag
|
||||
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
|
||||
const { id } = await context!.params;
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
await pb.collection('tags').delete(id);
|
||||
|
||||
return new NextResponse(null, { status: 204 });
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// 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 { createTagSchema } from '@project-e/shared';
|
||||
import { z } from 'zod';
|
||||
|
||||
// GET /api/tags — List tags 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') || 'name';
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const result = await pb.collection('tags').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/tags — Create a tag
|
||||
export const POST = withAuth(async (request: NextRequest, _user) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = createTagSchema.parse(body);
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const tag = await pb.collection('tags').create(data);
|
||||
|
||||
return NextResponse.json(tag, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
// 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 { recordActivity } from '@/lib/activity';
|
||||
import { db, tasks } from '@project-e/db';
|
||||
import { and, eq, isNull } 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, 'Title is required').optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
status: taskStatusEnum.optional(),
|
||||
priority: taskPriorityEnum.optional(),
|
||||
projectId: z.string().uuid().nullable().optional(),
|
||||
sectionId: z.string().uuid().nullable().optional(),
|
||||
parentId: z.string().uuid().nullable().optional(),
|
||||
dueDate: z.string().datetime().nullable().optional(),
|
||||
estimatedMinutes: z.number().int().positive().nullable().optional(),
|
||||
order: z.number().int().optional(),
|
||||
});
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
// GET /api/tasks/[id] — Get a single task
|
||||
export const GET = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
|
||||
const { id } = await context!.params;
|
||||
|
||||
const [task] = await db.select()
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!task) {
|
||||
return createErrorResponse('NOT_FOUND', 'Task not found', 404);
|
||||
}
|
||||
|
||||
return NextResponse.json(task);
|
||||
});
|
||||
|
||||
// PATCH /api/tasks/[id] — Update a task
|
||||
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { id } = await context!.params;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = updateTaskSchema.parse(body);
|
||||
|
||||
if (data.parentId) {
|
||||
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 updateValues: Record<string, any> = { updatedAt: new Date() };
|
||||
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;
|
||||
|
||||
const [task] = await db.update(tasks)
|
||||
.set(updateValues)
|
||||
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
|
||||
.returning();
|
||||
|
||||
if (!task) {
|
||||
return createErrorResponse('NOT_FOUND', 'Task not found', 404);
|
||||
}
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: 'updated',
|
||||
entityType: 'task',
|
||||
entityId: task.id,
|
||||
changes: { title: task.title, status: task.status },
|
||||
workspaceId: task.domainId,
|
||||
});
|
||||
|
||||
return NextResponse.json(task);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/tasks/[id] — Soft-delete a task
|
||||
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { id } = await context!.params;
|
||||
|
||||
const [task] = await db.update(tasks)
|
||||
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
||||
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
|
||||
.returning();
|
||||
|
||||
if (!task) {
|
||||
return createErrorResponse('NOT_FOUND', 'Task not found', 404);
|
||||
}
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: 'deleted',
|
||||
entityType: 'task',
|
||||
entityId: task.id,
|
||||
changes: { title: task.title },
|
||||
workspaceId: task.domainId,
|
||||
});
|
||||
|
||||
return new NextResponse(null, { status: 204 });
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
// 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 { z } from 'zod';
|
||||
|
||||
const bulkCreateSchema = z.object({
|
||||
tasks: z.array(z.object({
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
status: z.enum(['todo', 'in_progress', 'done', 'cancelled']).optional(),
|
||||
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
|
||||
due_date: z.string().optional(),
|
||||
project_id: z.string().optional(),
|
||||
domain: z.string(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
})).min(1).max(100),
|
||||
});
|
||||
|
||||
const bulkUpdateSchema = z.object({
|
||||
ids: z.array(z.string()).min(1),
|
||||
updates: z.object({
|
||||
status: z.enum(['todo', 'in_progress', 'done', 'cancelled']).optional(),
|
||||
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
|
||||
project_id: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const bulkDeleteSchema = z.object({
|
||||
ids: z.array(z.string()).min(1),
|
||||
});
|
||||
|
||||
// POST /api/tasks/bulk — Bulk create/update/delete
|
||||
export const POST = withAuth(async (request: NextRequest, _user) => {
|
||||
const body = await request.json();
|
||||
const pb = createPocketBaseClient();
|
||||
|
||||
// Determine operation from body shape
|
||||
if ('tasks' in body) {
|
||||
// Bulk create
|
||||
const data = bulkCreateSchema.parse(body);
|
||||
const created = [];
|
||||
for (const task of data.tasks) {
|
||||
const result = await pb.collection('tasks').create(task);
|
||||
created.push(result);
|
||||
}
|
||||
return NextResponse.json({ created: created.length, items: created }, { status: 201 });
|
||||
}
|
||||
|
||||
if ('ids' in body && 'updates' in body) {
|
||||
// Bulk update
|
||||
const data = bulkUpdateSchema.parse(body);
|
||||
const updated = [];
|
||||
for (const id of data.ids) {
|
||||
const result = await pb.collection('tasks').update(id, data.updates);
|
||||
updated.push(result);
|
||||
}
|
||||
return NextResponse.json({ updated: updated.length, items: updated });
|
||||
}
|
||||
|
||||
if ('ids' in body) {
|
||||
// Bulk delete
|
||||
const data = bulkDeleteSchema.parse(body);
|
||||
for (const id of data.ids) {
|
||||
await pb.collection('tasks').delete(id);
|
||||
}
|
||||
return NextResponse.json({ deleted: data.ids.length });
|
||||
}
|
||||
|
||||
return createErrorResponse('INVALID_REQUEST', 'Must specify tasks, ids+updates, or ids', 400);
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse, resolveActiveDomain } from '@/lib/auth';
|
||||
import { recordActivity } from '@/lib/activity';
|
||||
import { db, tasks, taskTags, 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 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'),
|
||||
domain: z.string().min(1, 'Domain is required'),
|
||||
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(),
|
||||
tagIds: z.array(z.string().uuid()).optional(),
|
||||
});
|
||||
|
||||
// GET /api/tasks — List tasks 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 status = searchParams.get('status');
|
||||
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: tasks.createdAt,
|
||||
updated: tasks.updatedAt,
|
||||
title: tasks.title,
|
||||
status: tasks.status,
|
||||
priority: tasks.priority,
|
||||
order: tasks.order,
|
||||
due_date: tasks.dueDate,
|
||||
};
|
||||
const orderBy = sortDir === 'asc'
|
||||
? asc(sortColumns[sortField] || tasks.createdAt)
|
||||
: desc(sortColumns[sortField] || tasks.createdAt);
|
||||
|
||||
const conditions: any[] = [isNull(tasks.deletedAt)];
|
||||
if (domainId) conditions.push(eq(tasks.domainId, domainId));
|
||||
if (status) {
|
||||
const statuses = status.split(',');
|
||||
conditions.push(inArray(tasks.status, statuses as any));
|
||||
}
|
||||
if (filter) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(tasks.title, `%${filter}%`),
|
||||
ilike(tasks.description, `%${filter}%`),
|
||||
)!
|
||||
);
|
||||
}
|
||||
|
||||
const offset = (page - 1) * perPage;
|
||||
|
||||
const [items, countResult] = await Promise.all([
|
||||
db.select()
|
||||
.from(tasks)
|
||||
.where(and(...conditions))
|
||||
.orderBy(orderBy)
|
||||
.limit(perPage)
|
||||
.offset(offset),
|
||||
db.select({ count: sql<number>`count(*)` })
|
||||
.from(tasks)
|
||||
.where(and(...conditions)),
|
||||
]);
|
||||
|
||||
const totalItems = Number(countResult[0]?.count || 0);
|
||||
|
||||
return NextResponse.json({
|
||||
items,
|
||||
totalItems,
|
||||
totalPages: Math.ceil(totalItems / perPage),
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/tasks — Create a task
|
||||
export const POST = withAuth(async (request: NextRequest, user) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = createTaskSchema.parse({
|
||||
...body,
|
||||
domain: body.domain || (await resolveActiveDomain(user)).id,
|
||||
});
|
||||
|
||||
const [task] = await db.insert(tasks).values({
|
||||
title: data.title,
|
||||
description: data.description ?? null,
|
||||
status: data.status,
|
||||
priority: data.priority,
|
||||
domainId: data.domain,
|
||||
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,
|
||||
}).returning();
|
||||
|
||||
if (data.tagIds && data.tagIds.length > 0) {
|
||||
await db.insert(taskTags).values(
|
||||
data.tagIds.map(tagId => ({ taskId: task.id, tagId }))
|
||||
);
|
||||
}
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: 'created',
|
||||
entityType: 'task',
|
||||
entityId: task.id,
|
||||
changes: { title: task.title },
|
||||
workspaceId: data.domain,
|
||||
});
|
||||
|
||||
return NextResponse.json(task, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||
}
|
||||
console.error('[tasks POST] error:', error);
|
||||
return createErrorResponse('INTERNAL_ERROR', 'Failed to create task', 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// 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/time-summary — Aggregated time by domain/project/tag
|
||||
export const GET = withAuth(async (request: NextRequest, _user) => {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const startDate = searchParams.get('start') || new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const endDate = searchParams.get('end') || new Date().toISOString();
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
|
||||
const entries = await pb.collection('task_time_entries').getFullList({
|
||||
filter: `started_at >= "${startDate}" && started_at <= "${endDate}"`,
|
||||
});
|
||||
|
||||
const byDomain: Record<string, number> = {};
|
||||
const byDate: Record<string, number> = {};
|
||||
const byProject: Record<string, number> = {};
|
||||
const byTag: Record<string, number> = {};
|
||||
let totalMinutes = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const duration = (entry.duration_minutes as number) || (entry.duration as number) || 0;
|
||||
totalMinutes += duration;
|
||||
const startedAt = entry.started_at as string | undefined;
|
||||
if (startedAt) {
|
||||
const date = new Date(startedAt).toISOString().slice(0, 10);
|
||||
byDate[date] = (byDate[date] || 0) + duration;
|
||||
}
|
||||
|
||||
// Get task for domain/project/tags
|
||||
const task = await pb.collection('tasks').getOne(entry.task_id as string);
|
||||
|
||||
const domain = task.domain as string;
|
||||
byDomain[domain] = (byDomain[domain] || 0) + duration;
|
||||
|
||||
const projectId = task.project_id as string | undefined;
|
||||
if (projectId) {
|
||||
byProject[projectId] = (byProject[projectId] || 0) + duration;
|
||||
}
|
||||
|
||||
const tags = (task.tags as string[]) || [];
|
||||
for (const tag of tags) {
|
||||
byTag[tag] = (byTag[tag] || 0) + duration;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
totalMinutes,
|
||||
byDomain,
|
||||
byDate,
|
||||
byProject,
|
||||
byTag,
|
||||
startDate,
|
||||
endDate,
|
||||
}, {
|
||||
headers: {
|
||||
'Cache-Control': 'private, max-age=300, stale-while-revalidate=600',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// 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';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
// POST /api/webhook-deliveries/[id]/retry — Manually retry a failed delivery
|
||||
export const POST = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
|
||||
const { id } = await context!.params;
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
|
||||
// Get the failed delivery
|
||||
const delivery = await pb.collection('webhook_deliveries').getOne(id);
|
||||
|
||||
if (delivery.status === 'success') {
|
||||
return createErrorResponse('INVALID_STATE', 'Cannot retry a successful delivery', 400);
|
||||
}
|
||||
|
||||
// Get the webhook to get the URL
|
||||
const webhook = await pb.collection('webhooks').getOne(delivery.webhook_id);
|
||||
|
||||
// Create a new queue job for retry
|
||||
await pb.collection('queue_jobs').create({
|
||||
queue: 'webhooks',
|
||||
type: 'webhook_delivery',
|
||||
payload: {
|
||||
webhook_id: webhook.id,
|
||||
webhook_url: webhook.url,
|
||||
webhook_secret: webhook.secret || '',
|
||||
event_type: delivery.event_type,
|
||||
event_payload: delivery.payload,
|
||||
},
|
||||
status: 'pending',
|
||||
retry_count: 0,
|
||||
max_attempts: 3,
|
||||
scheduled_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Update the delivery status to pending
|
||||
await pb.collection('webhook_deliveries').update(id, {
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Retry queued' });
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// 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 { db, webhookDeliveries } from '@project-e/db';
|
||||
import { and, desc, eq, sql } from 'drizzle-orm';
|
||||
|
||||
// GET /api/webhook-deliveries — List webhook deliveries with filtering
|
||||
export const GET = withAuth(async (request: NextRequest, _user) => {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
|
||||
const offset = parseInt(searchParams.get('offset') || '0');
|
||||
const webhookId = searchParams.get('webhook_id') || '';
|
||||
|
||||
const conditions = [];
|
||||
if (webhookId) {
|
||||
conditions.push(eq(webhookDeliveries.webhookId, webhookId));
|
||||
}
|
||||
|
||||
const [items, countResult] = await Promise.all([
|
||||
db.select()
|
||||
.from(webhookDeliveries)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(desc(webhookDeliveries.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db.select({ count: sql<number>`count(*)` })
|
||||
.from(webhookDeliveries)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
items,
|
||||
totalItems: Number(countResult[0]?.count || 0),
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// 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 { updateWebhookSchema } from '@project-e/shared';
|
||||
import { z } from 'zod';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
// GET /api/webhooks/[id] — Get a single webhook
|
||||
export const GET = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
|
||||
const { id } = await context!.params;
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const webhook = await pb.collection('webhooks').getOne(id);
|
||||
|
||||
return NextResponse.json(webhook);
|
||||
});
|
||||
|
||||
// PATCH /api/webhooks/[id] — Update a webhook
|
||||
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
|
||||
try {
|
||||
const { id } = await context!.params;
|
||||
const body = await request.json();
|
||||
const data = updateWebhookSchema.parse(body);
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const webhook = await pb.collection('webhooks').update(id, data);
|
||||
|
||||
return NextResponse.json(webhook);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/webhooks/[id] — Delete a webhook
|
||||
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
|
||||
const { id } = await context!.params;
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
await pb.collection('webhooks').delete(id);
|
||||
|
||||
return new NextResponse(null, { status: 204 });
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
// 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';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
// POST /api/webhooks/[id]/test — Send a test event to the webhook
|
||||
export const POST = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
|
||||
const { id } = await context!.params;
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
|
||||
// Get the webhook
|
||||
const webhook = await pb.collection('webhooks').getOne(id);
|
||||
|
||||
if (!webhook.active) {
|
||||
return createErrorResponse('WEBHOOK_DISABLED', 'Cannot test a disabled webhook', 400);
|
||||
}
|
||||
|
||||
// Create a test payload
|
||||
const testPayload = {
|
||||
event: 'test.ping',
|
||||
timestamp: new Date().toISOString(),
|
||||
data: {
|
||||
message: 'This is a test webhook delivery from Project E.',
|
||||
webhook_id: webhook.id,
|
||||
webhook_name: webhook.name,
|
||||
},
|
||||
};
|
||||
|
||||
// Create HMAC signature if secret is provided
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Event-Type': 'test.ping',
|
||||
};
|
||||
|
||||
if (webhook.secret) {
|
||||
const crypto = await import('node:crypto');
|
||||
const body = JSON.stringify(testPayload);
|
||||
const signature = crypto
|
||||
.createHmac('sha256', webhook.secret)
|
||||
.update(body)
|
||||
.digest('hex');
|
||||
headers['X-Webhook-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 pb.collection('webhook_deliveries').create({
|
||||
webhook_id: webhook.id,
|
||||
event_type: 'test.ping',
|
||||
payload: testPayload as Record<string, unknown>,
|
||||
success: response.ok,
|
||||
response_status: response.status,
|
||||
response_body: 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);
|
||||
|
||||
// Record the failed delivery
|
||||
await pb.collection('webhook_deliveries').create({
|
||||
webhook_id: webhook.id,
|
||||
event_type: 'test.ping',
|
||||
payload: testPayload as Record<string, unknown>,
|
||||
success: false,
|
||||
response_status: 0,
|
||||
response_body: errorMessage,
|
||||
attempts: 1,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
status: 0,
|
||||
response: errorMessage,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// 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 { createWebhookSchema } from '@project-e/shared';
|
||||
import { z } from 'zod';
|
||||
|
||||
// GET /api/webhooks — List webhooks 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('webhooks').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/webhooks — Create a webhook
|
||||
export const POST = withAuth(async (request: NextRequest, _user) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = createWebhookSchema.parse(body);
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const webhook = await pb.collection('webhooks').create(data);
|
||||
|
||||
return NextResponse.json(webhook, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user