T1/Phase 1: scaffold Vite SPA + Hono API + Bun worker
- apps/web: Vite + React 19 + TanStack Router/Query + shadcn/ui - apps/api: Hono + Bun on :3001 with /api/health, /api/auth/*, /mcp stubs - apps/worker: Bun worker stub, DB connection, graceful SIGTERM - apps/web-legacy/: old Next.js code moved aside (preserved for T2-T8 reference) - Dockerfiles: api (Bun), worker (Bun), spa (multi-stage Caddy) - Caddyfile: serves dist + reverse-proxies /api/* + /mcp to api - docker-compose.yml: 4-service target (api, spa, db, worker) - packages/db/src/client.ts: shared Drizzle client for api + worker - db/client.ts: root-level alias for convenience Parent: t_e1cbd87d -> t_24c9c3fd (T0)
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth } from '@/lib/auth';
|
||||
import { getBacklinks } from '@/lib/services/note-service';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
// GET /api/notes/[id]/backlinks — Get notes that link to this note
|
||||
export const GET = withAuth<RouteContext>(
|
||||
async (request: NextRequest, _user, context) => {
|
||||
const { id } = await context!.params;
|
||||
const backlinks = await getBacklinks(id);
|
||||
|
||||
return NextResponse.json({
|
||||
items: backlinks,
|
||||
totalItems: backlinks.length,
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,63 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
import { updateNoteSchema } from '@project-e/shared';
|
||||
import { syncNoteLinks, syncNoteTasks, getBacklinks } from '@/lib/services';
|
||||
import { z } from 'zod';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
// GET /api/notes/[id] — Get a single note with backlinks
|
||||
export const GET = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
|
||||
const { id } = await context!.params;
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const note = await pb.collection('notes').getOne(id);
|
||||
const backlinks = await getBacklinks(id);
|
||||
|
||||
return NextResponse.json({
|
||||
...note,
|
||||
backlinks,
|
||||
});
|
||||
});
|
||||
|
||||
// PATCH /api/notes/[id] — Update a note, then re-sync links and tasks
|
||||
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
|
||||
try {
|
||||
const { id } = await context!.params;
|
||||
const body = await request.json();
|
||||
const data = updateNoteSchema.parse(body);
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const note = await pb.collection('notes').update(id, data);
|
||||
|
||||
// Re-sync wikilinks and checkbox tasks from content
|
||||
const content = data.content ?? note.content;
|
||||
if (content) {
|
||||
await syncNoteLinks(id, content);
|
||||
await syncNoteTasks(id, content);
|
||||
}
|
||||
|
||||
return NextResponse.json(note);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/notes/[id] — Delete a note
|
||||
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, _user, context) => {
|
||||
const { id } = await context!.params;
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
await pb.collection('notes').delete(id);
|
||||
|
||||
return new NextResponse(null, { status: 204 });
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Parse a "YYYY-MM-DD" string into start/end ISO boundaries (UTC). */
|
||||
function dayBounds(dateStr: string) {
|
||||
const start = new Date(`${dateStr}T00:00:00.000Z`);
|
||||
const end = new Date(`${dateStr}T23:59:59.999Z`);
|
||||
return { start: start.toISOString(), end: end.toISOString() };
|
||||
}
|
||||
|
||||
/** Format minutes into a human-readable "Xh Ym" string. */
|
||||
function formatMinutes(total: number): string {
|
||||
if (total < 60) return `${total}m`;
|
||||
const h = Math.floor(total / 60);
|
||||
const m = total % 60;
|
||||
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||
}
|
||||
|
||||
/** Escape HTML special characters. */
|
||||
function esc(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** Build an <ul> of items, or an empty-state <p> if the list is empty. */
|
||||
function list(items: string[], emptyMsg: string): string {
|
||||
if (items.length === 0) {
|
||||
return `<p><em>${esc(emptyMsg)}</em></p>`;
|
||||
}
|
||||
return `<ul>${items.map((t) => `<li>${t}</li>`).join('')}</ul>`;
|
||||
}
|
||||
|
||||
/** Generate the full HTML body for a daily note. */
|
||||
function buildDailyNoteHtml(ctx: {
|
||||
completedTasks: string[];
|
||||
habitLogs: string[];
|
||||
timeEntries: string[];
|
||||
overdueTasks: string[];
|
||||
}): string {
|
||||
return [
|
||||
`<h2>Tasks Completed</h2>`,
|
||||
list(ctx.completedTasks, 'No tasks completed today.'),
|
||||
`<h2>Habits Logged</h2>`,
|
||||
list(ctx.habitLogs, 'No habits logged today.'),
|
||||
`<h2>Time Tracked</h2>`,
|
||||
list(ctx.timeEntries, 'No time tracked today.'),
|
||||
`<h2>Overdue Items</h2>`,
|
||||
list(ctx.overdueTasks, 'Nothing overdue.'),
|
||||
`<h2>Notes</h2>`,
|
||||
`<p></p>`,
|
||||
`<h2>Reflections</h2>`,
|
||||
`<p></p>`,
|
||||
`<h2>Gratitude</h2>`,
|
||||
`<p></p>`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ── Route handlers ───────────────────────────────────────────────────────────
|
||||
|
||||
/** GET /api/notes/daily?date=YYYY-MM-DD — return the daily note if it exists. */
|
||||
export const GET = withAuth(async (request: NextRequest) => {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const date = searchParams.get('date');
|
||||
|
||||
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
return createErrorResponse(
|
||||
'VALIDATION_ERROR',
|
||||
'A valid date parameter (YYYY-MM-DD) is required.',
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const title = `Daily Note - ${date}`;
|
||||
const pb = createPocketBaseClient();
|
||||
|
||||
const result = await pb.collection('notes').getList(1, 1, {
|
||||
filter: `title = "${title}"`,
|
||||
});
|
||||
|
||||
if (result.items.length === 0) {
|
||||
return NextResponse.json({ note: null });
|
||||
}
|
||||
|
||||
return NextResponse.json({ note: result.items[0] });
|
||||
});
|
||||
|
||||
/** POST /api/notes/daily — create today's daily note (idempotent). */
|
||||
export const POST = withAuth(async (request: NextRequest) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const date: string | undefined = body?.date;
|
||||
|
||||
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
return createErrorResponse(
|
||||
'VALIDATION_ERROR',
|
||||
'A valid date string (YYYY-MM-DD) is required in the request body.',
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const title = `Daily Note - ${date}`;
|
||||
const pb = createPocketBaseClient();
|
||||
|
||||
// ── 1. Idempotency check ────────────────────────────────────────────────
|
||||
const existing = await pb.collection('notes').getList(1, 1, {
|
||||
filter: `title = "${title}"`,
|
||||
});
|
||||
if (existing.items.length > 0) {
|
||||
return NextResponse.json(existing.items[0]);
|
||||
}
|
||||
|
||||
// ── 2. Date boundaries ──────────────────────────────────────────────────
|
||||
const { start, end } = dayBounds(date);
|
||||
|
||||
// ── 3. Fetch all data in parallel ───────────────────────────────────────
|
||||
const [
|
||||
completedTaskRecords,
|
||||
habitLogRecords,
|
||||
timeEntryRecords,
|
||||
overdueTaskRecords,
|
||||
habitsAll,
|
||||
] = await Promise.all([
|
||||
// Tasks completed today
|
||||
pb.collection('tasks').getFullList({
|
||||
filter: `completed_at >= "${start}" && completed_at <= "${end}"`,
|
||||
sort: 'completed_at',
|
||||
}),
|
||||
// Habit logs for the day
|
||||
pb.collection('habit_logs').getFullList({
|
||||
filter: `logged_at >= "${start}" && logged_at <= "${end}"`,
|
||||
sort: 'logged_at',
|
||||
}),
|
||||
// Time entries for the day
|
||||
pb.collection('task_time_entries').getFullList({
|
||||
filter: `started_at >= "${start}" && started_at <= "${end}"`,
|
||||
sort: 'started_at',
|
||||
}),
|
||||
// Overdue tasks (due before today, not done)
|
||||
pb.collection('tasks').getFullList({
|
||||
filter: `due_date < "${start}" && status != "done" && status != "cancelled"`,
|
||||
sort: 'due_date',
|
||||
}),
|
||||
// All active habits (for name lookup)
|
||||
pb.collection('habits').getFullList({
|
||||
filter: 'active = true',
|
||||
}),
|
||||
]);
|
||||
|
||||
// ── 4. Build lookup maps ────────────────────────────────────────────────
|
||||
const habitNameById = new Map<string, string>();
|
||||
for (const h of habitsAll) {
|
||||
habitNameById.set(h.id, h.name as string);
|
||||
}
|
||||
|
||||
// Collect task IDs from time entries so we can resolve names
|
||||
const taskIdsForTimeEntries = [
|
||||
...new Set(timeEntryRecords.map((e) => e.task_id as string)),
|
||||
];
|
||||
const taskNamesMap = new Map<string, string>();
|
||||
|
||||
// Fetch task names in parallel for time entries and overdue tasks
|
||||
const allTaskIds = new Set<string>();
|
||||
for (const t of completedTaskRecords) allTaskIds.add(t.id);
|
||||
for (const t of overdueTaskRecords) allTaskIds.add(t.id);
|
||||
for (const id of taskIdsForTimeEntries) allTaskIds.add(id);
|
||||
|
||||
const taskFetches = await Promise.allSettled(
|
||||
[...allTaskIds].map((id) => pb.collection('tasks').getOne(id))
|
||||
);
|
||||
for (const res of taskFetches) {
|
||||
if (res.status === 'fulfilled') {
|
||||
const t = res.value;
|
||||
taskNamesMap.set(t.id, t.title as string);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. Format sections ──────────────────────────────────────────────────
|
||||
const completedTasks = completedTaskRecords.map((t) => {
|
||||
const name = taskNamesMap.get(t.id) ?? (t.title as string);
|
||||
return `${esc(name)}`;
|
||||
});
|
||||
|
||||
const habitLogs = habitLogRecords.map((log) => {
|
||||
const habitName = habitNameById.get(log.habit_id) ?? 'Unknown habit';
|
||||
const status = log.completed ? '✓' : log.skipped ? 'skipped' : '—';
|
||||
const mood = log.mood != null ? ` (mood: ${log.mood}/5)` : '';
|
||||
return `${esc(habitName)} — ${status}${mood}`;
|
||||
});
|
||||
|
||||
const timeEntries = timeEntryRecords.map((entry) => {
|
||||
const taskName = taskNamesMap.get(entry.task_id as string) ?? 'Unknown task';
|
||||
const dur = formatMinutes((entry.duration_minutes as number) || 0);
|
||||
const notes = entry.notes ? ` — ${esc(entry.notes as string)}` : '';
|
||||
return `<strong>${dur}</strong> on ${esc(taskName)}${notes}`;
|
||||
});
|
||||
|
||||
const overdueTasks = overdueTaskRecords.map((t) => {
|
||||
const name = taskNamesMap.get(t.id) ?? (t.title as string);
|
||||
const due = t.due_date
|
||||
? ` (due ${new Date(t.due_date as string).toLocaleDateString()})`
|
||||
: '';
|
||||
return `${esc(name)}${due}`;
|
||||
});
|
||||
|
||||
// ── 6. Build HTML content ───────────────────────────────────────────────
|
||||
const content = buildDailyNoteHtml({
|
||||
completedTasks,
|
||||
habitLogs,
|
||||
timeEntries,
|
||||
overdueTasks,
|
||||
});
|
||||
|
||||
// ── 7. Create note ──────────────────────────────────────────────────────
|
||||
const note = await pb.collection('notes').create({
|
||||
title,
|
||||
content,
|
||||
domain: 'personal',
|
||||
tags: ['daily'],
|
||||
});
|
||||
|
||||
return NextResponse.json(note, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Failed to create daily note:', error);
|
||||
return createErrorResponse(
|
||||
'INTERNAL_ERROR',
|
||||
'Failed to create daily note.',
|
||||
500
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { withAuth } from '@/lib/auth';
|
||||
import { getNoteGraph } from '@/lib/services/note-service';
|
||||
|
||||
// GET /api/notes/graph — Get note graph data for visualization
|
||||
export const GET = withAuth(async () => {
|
||||
const graph = await getNoteGraph();
|
||||
return NextResponse.json(graph, {
|
||||
headers: {
|
||||
'Cache-Control': 'private, max-age=60, stale-while-revalidate=300',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
import { createNoteSchema } from '@project-e/shared';
|
||||
import { syncNoteLinks, syncNoteTasks } from '@/lib/services';
|
||||
import { z } from 'zod';
|
||||
|
||||
// GET /api/notes — List notes with filtering, sorting, pagination
|
||||
export const GET = withAuth(async (request: NextRequest, _user) => {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const perPage = parseInt(searchParams.get('perPage') || '50');
|
||||
const filter = searchParams.get('filter') || undefined;
|
||||
const sort = searchParams.get('sort') || '-created';
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const result = await pb.collection('notes').getList(page, perPage, {
|
||||
...(filter ? { filter } : {}),
|
||||
sort,
|
||||
});
|
||||
|
||||
const response = NextResponse.json({
|
||||
items: result.items,
|
||||
totalItems: result.totalItems,
|
||||
totalPages: result.totalPages,
|
||||
page: result.page,
|
||||
perPage: result.perPage,
|
||||
});
|
||||
|
||||
// Cache for 60 seconds with stale-while-revalidate
|
||||
response.headers.set(
|
||||
'Cache-Control',
|
||||
'private, max-age=60, stale-while-revalidate=300'
|
||||
);
|
||||
|
||||
return response;
|
||||
});
|
||||
|
||||
// POST /api/notes — Create a note, then sync links and tasks
|
||||
export const POST = withAuth(async (request: NextRequest, _user) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = createNoteSchema.parse(body);
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const note = await pb.collection('notes').create(data);
|
||||
|
||||
// Sync wikilinks and checkbox tasks from content
|
||||
if (data.content) {
|
||||
await syncNoteLinks(note.id, data.content);
|
||||
await syncNoteTasks(note.id, data.content);
|
||||
}
|
||||
|
||||
return NextResponse.json(note, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user