refactor: migrate to monorepo structure with Docker, PocketBase, and e2e tests
- Reorganize into apps/, packages/, docs/, e2e/, pocketbase/ directories - Add Dockerfiles for web, worker, and PocketBase services - Add docker-compose.yml for local orchestration - Add turbo.json for monorepo task management - Add Playwright e2e test infrastructure - Add PocketBase backend with migrations - Remove Vite/Next.js/ESLint/PostCSS config files - Update package.json with workspace dependencies - Add .env.example and .dockerignore
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
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,24 @@
|
||||
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,24 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
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,46 @@
|
||||
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,49 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } 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') || '';
|
||||
const sort = searchParams.get('sort') || '-created';
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const result = await pb.collection('agents').getList(page, perPage, {
|
||||
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);
|
||||
|
||||
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,62 @@
|
||||
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,22 @@
|
||||
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,40 @@
|
||||
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,52 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
import { z } from 'zod';
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { email, password } = loginSchema.parse(body);
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
|
||||
// Authenticate with PocketBase
|
||||
const authData = await pb.collection('users').authWithPassword(email, password);
|
||||
|
||||
// Set auth token in httpOnly cookie
|
||||
const response = NextResponse.json({
|
||||
user: {
|
||||
id: authData.record.id,
|
||||
email: authData.record.email,
|
||||
name: authData.record.name || authData.record.email,
|
||||
},
|
||||
token: authData.token,
|
||||
});
|
||||
|
||||
response.cookies.set('pb_auth', authData.token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.COOKIE_SECURE === 'true',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'VALIDATION_ERROR', message: 'Invalid input', details: error.issues } },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'AUTH_ERROR', message: 'Invalid email or password' } },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function POST() {
|
||||
const response = NextResponse.json({ success: true });
|
||||
|
||||
// Clear auth cookie
|
||||
response.cookies.set('pb_auth', '', {
|
||||
httpOnly: true,
|
||||
secure: process.env.COOKIE_SECURE === 'true',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 0, // Expire immediately
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const token = request.cookies.get('pb_auth')?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'UNAUTHORIZED', message: 'Not authenticated' } },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const pb = createPocketBaseClient(token);
|
||||
|
||||
// Get current user
|
||||
const authData = await pb.collection('users').authRefresh();
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: authData.record.id,
|
||||
email: authData.record.email,
|
||||
name: authData.record.name || authData.record.email,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'AUTH_ERROR', message: 'Invalid or expired token' } },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const token = request.cookies.get('pb_auth')?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'UNAUTHORIZED', message: 'No auth token' } },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const pb = createPocketBaseClient(token);
|
||||
|
||||
// Refresh the auth token
|
||||
await pb.collection('users').authRefresh();
|
||||
|
||||
const newToken = pb.authStore.token;
|
||||
|
||||
const response = NextResponse.json({
|
||||
token: newToken,
|
||||
});
|
||||
|
||||
response.cookies.set('pb_auth', newToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.COOKIE_SECURE === 'true',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'AUTH_ERROR', message: 'Token refresh failed' } },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } 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') || '';
|
||||
const sort = searchParams.get('sort') || '-created';
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const result = await pb.collection('canvases').getList(page, perPage, {
|
||||
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);
|
||||
|
||||
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,46 @@
|
||||
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<{ id: string }> };
|
||||
|
||||
// GET /api/domains/[id] — Get a single domain
|
||||
export const GET = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
|
||||
const { 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 { 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 { id } = await context!.params;
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
await pb.collection('domains').delete(id);
|
||||
|
||||
return new NextResponse(null, { status: 204 });
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
import { createDomainSchema } from '@project-e/shared';
|
||||
import { z } from 'zod';
|
||||
|
||||
// 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 = parseInt(searchParams.get('page') || '1');
|
||||
const perPage = parseInt(searchParams.get('perPage') || '50');
|
||||
const filter = searchParams.get('filter') || '';
|
||||
const sort = searchParams.get('sort') || 'sort_order';
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const result = await pb.collection('domains').getList(page, perPage, {
|
||||
filter,
|
||||
sort,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
items: result.items,
|
||||
totalItems: result.totalItems,
|
||||
totalPages: result.totalPages,
|
||||
page: result.page,
|
||||
perPage: result.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);
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const domain = await pb.collection('domains').create(data);
|
||||
|
||||
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,33 @@
|
||||
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,62 @@
|
||||
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,31 @@
|
||||
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,
|
||||
sort: '-logged_at',
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
items: result.items,
|
||||
totalItems: result.totalItems,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
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') || '';
|
||||
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,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
import { updateHabitSchema } from '@project-e/shared';
|
||||
import { z } from 'zod';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
// GET /api/habits/[id] — Get a single habit
|
||||
export const GET = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
|
||||
const { id } = await context!.params;
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const habit = await pb.collection('habits').getOne(id);
|
||||
|
||||
return NextResponse.json(habit);
|
||||
});
|
||||
|
||||
// PATCH /api/habits/[id] — Update a habit
|
||||
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
|
||||
try {
|
||||
const { id } = await context!.params;
|
||||
const body = await request.json();
|
||||
const data = updateHabitSchema.parse(body);
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const habit = await pb.collection('habits').update(id, data);
|
||||
|
||||
return NextResponse.json(habit);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/habits/[id] — Delete a habit
|
||||
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
|
||||
const { id } = await context!.params;
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
await pb.collection('habits').delete(id);
|
||||
|
||||
return new NextResponse(null, { status: 204 });
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
import { createHabitSchema } from '@project-e/shared';
|
||||
import { z } from 'zod';
|
||||
|
||||
// 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 = parseInt(searchParams.get('page') || '1');
|
||||
const perPage = parseInt(searchParams.get('perPage') || '50');
|
||||
const filter = searchParams.get('filter') || '';
|
||||
const sort = searchParams.get('sort') || '-created';
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const result = await pb.collection('habits').getList(page, perPage, {
|
||||
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/habits — Create a habit
|
||||
export const POST = withAuth(async (request: NextRequest, _user) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = createHabitSchema.parse(body);
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const habit = await pb.collection('habits').create(data);
|
||||
|
||||
return NextResponse.json(habit, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
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,9 @@
|
||||
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,82 @@
|
||||
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
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { id, created, 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,127 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
|
||||
import { createMcpServer } from '@/lib/mcp/server';
|
||||
import { createAdminClient } from '@/lib/pocketbase';
|
||||
|
||||
// Store transports by session ID for stateful mode
|
||||
const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
|
||||
|
||||
async function authenticateRequest(request: NextRequest): Promise<boolean> {
|
||||
// Check for API key in Authorization header
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (!authHeader) return false;
|
||||
|
||||
const apiKey = authHeader.replace('Bearer ', '').trim();
|
||||
if (!apiKey) return false;
|
||||
|
||||
try {
|
||||
const pb = createAdminClient();
|
||||
// Look up agent by API key
|
||||
const result = await pb.collection('agents').getList(1, 1, {
|
||||
filter: `api_key = "${apiKey}" && status = "active"`,
|
||||
});
|
||||
return result.items.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
// Authenticate
|
||||
if (!(await authenticateRequest(request))) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized. Provide a valid API key in the Authorization header.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create server and transport for SSE connection
|
||||
const server = createMcpServer();
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => crypto.randomUUID(),
|
||||
});
|
||||
|
||||
await server.connect(transport);
|
||||
|
||||
// Store transport for POST requests
|
||||
if (transport.sessionId) {
|
||||
transports.set(transport.sessionId, transport);
|
||||
}
|
||||
|
||||
// Handle the request
|
||||
return transport.handleRequest(request);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// Authenticate
|
||||
if (!(await authenticateRequest(request))) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized. Provide a valid API key in the Authorization header.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get session ID from header
|
||||
const sessionId = request.headers.get('mcp-session-id');
|
||||
|
||||
if (sessionId) {
|
||||
// Route to existing transport
|
||||
const transport = transports.get(sessionId);
|
||||
if (transport) {
|
||||
return transport.handleRequest(request);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'Session not found. Connect via GET first.' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// No session ID — this should be an initialization request
|
||||
const server = createMcpServer();
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => crypto.randomUUID(),
|
||||
});
|
||||
|
||||
await server.connect(transport);
|
||||
|
||||
// Store transport for subsequent requests
|
||||
if (transport.sessionId) {
|
||||
transports.set(transport.sessionId, transport);
|
||||
}
|
||||
|
||||
return transport.handleRequest(request);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
// Authenticate
|
||||
if (!(await authenticateRequest(request))) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized. Provide a valid API key in the Authorization header.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const sessionId = request.headers.get('mcp-session-id');
|
||||
if (!sessionId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing mcp-session-id header' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const transport = transports.get(sessionId);
|
||||
if (!transport) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Session not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Handle the DELETE to terminate the session
|
||||
const response = await transport.handleRequest(request);
|
||||
|
||||
// Clean up
|
||||
transports.delete(sessionId);
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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,53 @@
|
||||
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') || '';
|
||||
const sort = searchParams.get('sort') || '-created';
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const result = await pb.collection('milestones').getList(page, perPage, {
|
||||
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,18 @@
|
||||
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,58 @@
|
||||
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,236 @@
|
||||
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,13 @@
|
||||
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,61 @@
|
||||
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') || '';
|
||||
const sort = searchParams.get('sort') || '-created';
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const result = await pb.collection('notes').getList(page, perPage, {
|
||||
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,12 @@
|
||||
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,46 @@
|
||||
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
|
||||
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);
|
||||
|
||||
return NextResponse.json(project);
|
||||
});
|
||||
|
||||
// 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,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
import { createProjectSchema } from '@project-e/shared';
|
||||
import { z } from 'zod';
|
||||
|
||||
// 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 = parseInt(searchParams.get('page') || '1');
|
||||
const perPage = parseInt(searchParams.get('perPage') || '50');
|
||||
const filter = searchParams.get('filter') || '';
|
||||
const sort = searchParams.get('sort') || '-created';
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const result = await pb.collection('projects').getList(page, perPage, {
|
||||
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/projects — Create a project
|
||||
export const POST = withAuth(async (request: NextRequest, _user) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = createProjectSchema.parse(body);
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const project = await pb.collection('projects').create(data);
|
||||
|
||||
return NextResponse.json(project, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { getAuthUser, getAuthToken } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300; // 5 minutes
|
||||
|
||||
const DEFAULT_COLLECTIONS = [
|
||||
'tasks',
|
||||
'habits',
|
||||
'projects',
|
||||
'notes',
|
||||
'reports',
|
||||
'milestones',
|
||||
'notifications',
|
||||
];
|
||||
|
||||
// GET /api/realtime — Multiplexed SSE endpoint for PocketBase realtime subscriptions
|
||||
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' },
|
||||
});
|
||||
}
|
||||
|
||||
// Parse subscription preferences from query params
|
||||
const { searchParams } = new URL(request.url);
|
||||
const collectionsParam = searchParams.get('collections') || '';
|
||||
const collections = collectionsParam
|
||||
.split(',')
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const subscribedCollections =
|
||||
collections.length > 0 ? collections : DEFAULT_COLLECTIONS;
|
||||
|
||||
const token = getAuthToken(request);
|
||||
const pb = createPocketBaseClient(token || undefined);
|
||||
const encoder = new TextEncoder();
|
||||
const unsubscribeFns: Array<() => Promise<void>> = [];
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// Send connected event
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ type: 'connected', collections: subscribedCollections })}\n\n`
|
||||
)
|
||||
);
|
||||
|
||||
// Subscribe to each collection
|
||||
for (const collection of subscribedCollections) {
|
||||
try {
|
||||
const unsub = await pb.collection(collection).subscribe('*', (e) => {
|
||||
try {
|
||||
const event = {
|
||||
type: e.action,
|
||||
collection,
|
||||
record: e.record,
|
||||
};
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify(event)}\n\n`)
|
||||
);
|
||||
} catch {
|
||||
// Controller might be closed
|
||||
}
|
||||
});
|
||||
unsubscribeFns.push(unsub);
|
||||
} catch {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ type: 'subscription_error', collection })}\n\n`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Keepalive ping every 30 seconds
|
||||
const keepalive = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(':ping\n\n'));
|
||||
} catch {
|
||||
clearInterval(keepalive);
|
||||
}
|
||||
}, 30000);
|
||||
},
|
||||
|
||||
async cancel() {
|
||||
// Client disconnected — cleanup all subscriptions
|
||||
for (const unsub of unsubscribeFns) {
|
||||
try {
|
||||
await unsub();
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
unsubscribeFns.length = 0;
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
import { updateReportSchema } from '@project-e/shared';
|
||||
import { z } from 'zod';
|
||||
|
||||
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 pb = createPocketBaseClient();
|
||||
const report = await pb.collection('reports').getOne(id);
|
||||
|
||||
return NextResponse.json(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 pb = createPocketBaseClient();
|
||||
const report = await pb.collection('reports').update(id, data);
|
||||
|
||||
return NextResponse.json(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 pb = createPocketBaseClient();
|
||||
await pb.collection('reports').delete(id);
|
||||
|
||||
return new NextResponse(null, { status: 204 });
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
import { createReportSchema } from '@project-e/shared';
|
||||
import { z } from 'zod';
|
||||
|
||||
// GET /api/reports — List reports 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') || '';
|
||||
const sort = searchParams.get('sort') || '-created';
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const result = await pb.collection('reports').getList(page, perPage, {
|
||||
filter,
|
||||
sort,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
items: result.items,
|
||||
totalItems: result.totalItems,
|
||||
totalPages: result.totalPages,
|
||||
page: result.page,
|
||||
perPage: result.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 pb = createPocketBaseClient();
|
||||
const report = await pb.collection('reports').create(data);
|
||||
|
||||
return NextResponse.json(report, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
// GET /api/search — Cross-entity full-text search
|
||||
export const GET = withAuth(async (request: NextRequest, _user) => {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get('q') || '';
|
||||
const types = searchParams.get('types')?.split(',') || ['tasks', 'habits', 'projects', 'notes', 'reports'];
|
||||
const limit = parseInt(searchParams.get('limit') || '10');
|
||||
|
||||
if (!query.trim()) {
|
||||
return NextResponse.json({ results: [] });
|
||||
}
|
||||
|
||||
// Escape double quotes in query to prevent filter injection
|
||||
const safeQuery = query.replace(/"/g, '\\"');
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const results: Array<{ type: string; items: unknown[] }> = [];
|
||||
|
||||
for (const type of types) {
|
||||
try {
|
||||
let filter = '';
|
||||
|
||||
switch (type) {
|
||||
case 'tasks':
|
||||
filter = `title ~ "${safeQuery}" || description ~ "${safeQuery}"`;
|
||||
break;
|
||||
case 'habits':
|
||||
filter = `name ~ "${safeQuery}" || description ~ "${safeQuery}"`;
|
||||
break;
|
||||
case 'projects':
|
||||
filter = `name ~ "${safeQuery}" || description ~ "${safeQuery}"`;
|
||||
break;
|
||||
case 'notes':
|
||||
filter = `title ~ "${safeQuery}" || content ~ "${safeQuery}"`;
|
||||
break;
|
||||
case 'reports':
|
||||
filter = `title ~ "${safeQuery}" || content ~ "${safeQuery}"`;
|
||||
break;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
|
||||
const items = await pb.collection(type).getList(1, limit, { filter });
|
||||
results.push({ type, items: items.items });
|
||||
} catch {
|
||||
// Skip collections that fail (e.g. missing or inaccessible)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ results });
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
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,46 @@
|
||||
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') || '';
|
||||
const sort = searchParams.get('sort') || 'name';
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const result = await pb.collection('tags').getList(page, perPage, {
|
||||
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,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
import { updateTaskSchema } from '@project-e/shared';
|
||||
import { z } from 'zod';
|
||||
|
||||
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 pb = createPocketBaseClient();
|
||||
const task = await pb.collection('tasks').getOne(id);
|
||||
|
||||
return NextResponse.json(task);
|
||||
});
|
||||
|
||||
// PATCH /api/tasks/[id] — Update a task
|
||||
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
|
||||
try {
|
||||
const { id } = await context!.params;
|
||||
const body = await request.json();
|
||||
const data = updateTaskSchema.parse(body);
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const task = await pb.collection('tasks').update(id, data);
|
||||
|
||||
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] — Delete a task
|
||||
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
|
||||
const { id } = await context!.params;
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
await pb.collection('tasks').delete(id);
|
||||
|
||||
return new NextResponse(null, { status: 204 });
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
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,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
import { createTaskSchema } from '@project-e/shared';
|
||||
import { z } from 'zod';
|
||||
|
||||
// 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 = parseInt(searchParams.get('page') || '1');
|
||||
const perPage = parseInt(searchParams.get('perPage') || '50');
|
||||
const filter = searchParams.get('filter') || '';
|
||||
const sort = searchParams.get('sort') || '-created';
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const result = await pb.collection('tasks').getList(page, perPage, {
|
||||
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/tasks — Create a task
|
||||
export const POST = withAuth(async (request: NextRequest, _user) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = createTaskSchema.parse(body);
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const task = await pb.collection('tasks').create(data);
|
||||
|
||||
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,55 @@
|
||||
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 byProject: Record<string, number> = {};
|
||||
const byTag: Record<string, number> = {};
|
||||
let totalMinutes = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const duration = (entry.duration_minutes as number) || 0;
|
||||
totalMinutes += 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,
|
||||
byProject,
|
||||
byTag,
|
||||
startDate,
|
||||
endDate,
|
||||
}, {
|
||||
headers: {
|
||||
'Cache-Control': 'private, max-age=300, stale-while-revalidate=600',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
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,35 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
// GET /api/webhook-deliveries — List webhook deliveries with filtering
|
||||
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') || '';
|
||||
const sort = searchParams.get('sort') || '-created';
|
||||
const webhookId = searchParams.get('webhook_id') || '';
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
|
||||
let combinedFilter = filter;
|
||||
if (webhookId) {
|
||||
combinedFilter = combinedFilter
|
||||
? `${combinedFilter} && webhook_id = "${webhookId}"`
|
||||
: `webhook_id = "${webhookId}"`;
|
||||
}
|
||||
|
||||
const result = await pb.collection('webhook_deliveries').getList(page, perPage, {
|
||||
filter: combinedFilter,
|
||||
sort,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
items: result.items,
|
||||
totalItems: result.totalItems,
|
||||
totalPages: result.totalPages,
|
||||
page: result.page,
|
||||
perPage: result.perPage,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
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,93 @@
|
||||
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,46 @@
|
||||
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') || '';
|
||||
const sort = searchParams.get('sort') || '-created';
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const result = await pb.collection('webhooks').getList(page, perPage, {
|
||||
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