diff --git a/apps/web/app/api/reports/[id]/route.ts b/apps/web/app/api/reports/[id]/route.ts index 99b3208..3f091d7 100644 --- a/apps/web/app/api/reports/[id]/route.ts +++ b/apps/web/app/api/reports/[id]/route.ts @@ -1,22 +1,33 @@ -// 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 { updateReportSchema } from '@project-e/shared'; +import { db, reports } from '@project-e/db'; +import { eq } from 'drizzle-orm'; import { z } from 'zod'; +const updateReportSchema = z.object({ + title: z.string().min(1, 'Title is required').optional(), + content: z.string().optional(), + report_type: z.enum(['weekly', 'monthly', 'project', 'habit', 'custom']).optional(), + date_range_start: z.string().optional(), + date_range_end: z.string().optional(), + domain: z.string().optional(), + is_draft: z.boolean().optional(), +}); + type RouteContext = { params: Promise<{ id: string }> }; // GET /api/reports/[id] — Get a single report export const GET = withAuth(async (request: NextRequest, _user, context) => { const { id } = await context!.params; - const pb = createPocketBaseClient(); - const report = await pb.collection('reports').getOne(id); + const [report] = await db.select() + .from(reports) + .where(eq(reports.id, id)) + .limit(1); + + if (!report) { + return createErrorResponse('NOT_FOUND', 'Report not found', 404); + } return NextResponse.json(report); }); @@ -28,8 +39,24 @@ export const PATCH = withAuth(async (request: NextRequest, _user, const body = await request.json(); const data = updateReportSchema.parse(body); - const pb = createPocketBaseClient(); - const report = await pb.collection('reports').update(id, data); + const updateValues: Record = {}; + if (data.title !== undefined) updateValues.title = data.title; + if (data.content !== undefined) updateValues.content = data.content; + if (data.report_type !== undefined) updateValues.reportType = data.report_type; + if (data.date_range_start !== undefined) updateValues.dateRangeStart = new Date(data.date_range_start); + if (data.date_range_end !== undefined) updateValues.dateRangeEnd = new Date(data.date_range_end); + if (data.domain !== undefined) updateValues.domain = data.domain; + if (data.is_draft !== undefined) updateValues.isDraft = data.is_draft; + updateValues.updatedAt = new Date(); + + const [report] = await db.update(reports) + .set(updateValues) + .where(eq(reports.id, id)) + .returning(); + + if (!report) { + return createErrorResponse('NOT_FOUND', 'Report not found', 404); + } return NextResponse.json(report); } catch (error) { @@ -44,8 +71,13 @@ export const PATCH = withAuth(async (request: NextRequest, _user, export const DELETE = withAuth(async (request: NextRequest, _user, context) => { const { id } = await context!.params; - const pb = createPocketBaseClient(); - await pb.collection('reports').delete(id); + const [deleted] = await db.delete(reports) + .where(eq(reports.id, id)) + .returning({ id: reports.id }); + + if (!deleted) { + return createErrorResponse('NOT_FOUND', 'Report not found', 404); + } return new NextResponse(null, { status: 204 }); }); diff --git a/apps/web/app/api/reports/route.ts b/apps/web/app/api/reports/route.ts index fc88114..8772397 100644 --- a/apps/web/app/api/reports/route.ts +++ b/apps/web/app/api/reports/route.ts @@ -1,34 +1,70 @@ -// 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 { createReportSchema } from '@project-e/shared'; +import { db, reports } from '@project-e/db'; +import { and, asc, desc, eq, ilike, or, sql } from 'drizzle-orm'; import { z } from 'zod'; -// GET /api/reports — List reports with filtering, sorting, pagination +const createReportSchema = z.object({ + title: z.string().min(1, 'Title is required').default('Untitled report'), + content: z.string().optional().default(''), + report_type: z.enum(['weekly', 'monthly', 'project', 'habit', 'custom']).default('custom'), + date_range_start: z.string().optional(), + date_range_end: z.string().optional(), + domain: z.string().default('personal'), +}); + +// GET /api/reports — List reports export const GET = withAuth(async (request: NextRequest, _user) => { const { searchParams } = new URL(request.url); - const page = parseInt(searchParams.get('page') || '1'); - const perPage = parseInt(searchParams.get('perPage') || '50'); + const page = Math.max(1, parseInt(searchParams.get('page') || '1')); + const perPage = Math.min(100, Math.max(1, parseInt(searchParams.get('perPage') || '50'))); + const sortParam = searchParams.get('sort') || '-created'; const filter = searchParams.get('filter') || undefined; - const sort = searchParams.get('sort') || '-created'; - const pb = createPocketBaseClient(); - const result = await pb.collection('reports').getList(page, perPage, { - ...(filter ? { filter } : {}), - sort, - }); + const sortDir = sortParam.startsWith('-') ? 'desc' : 'asc'; + const sortField = sortParam.replace(/^-/, ''); + const sortColumns: Record = { + title: reports.title, + report_type: reports.reportType, + domain: reports.domain, + created: reports.createdAt, + updated: reports.updatedAt, + }; + const orderBy = sortDir === 'asc' + ? asc(sortColumns[sortField] || reports.createdAt) + : desc(sortColumns[sortField] || reports.createdAt); + + const conditions: any[] = []; + if (filter) { + conditions.push( + or( + ilike(reports.title, `%${filter}%`), + )! + ); + } + + const offset = (page - 1) * perPage; + + const [items, countResult] = await Promise.all([ + db.select() + .from(reports) + .where(and(...conditions)) + .orderBy(orderBy) + .limit(perPage) + .offset(offset), + db.select({ count: sql`count(*)` }) + .from(reports) + .where(and(...conditions)), + ]); + + const totalItems = Number(countResult[0]?.count || 0); return NextResponse.json({ - items: result.items, - totalItems: result.totalItems, - totalPages: result.totalPages, - page: result.page, - perPage: result.perPage, + items, + totalItems, + totalPages: Math.ceil(totalItems / perPage), + page, + perPage, }); }); @@ -38,8 +74,16 @@ export const POST = withAuth(async (request: NextRequest, _user) => { const body = await request.json(); const data = createReportSchema.parse(body); - const pb = createPocketBaseClient(); - const report = await pb.collection('reports').create(data); + const [report] = await db.insert(reports) + .values({ + title: data.title, + content: data.content || '', + reportType: data.report_type, + dateRangeStart: data.date_range_start ? new Date(data.date_range_start) : null, + dateRangeEnd: data.date_range_end ? new Date(data.date_range_end) : null, + domain: data.domain, + }) + .returning(); return NextResponse.json(report, { status: 201 }); } catch (error) { diff --git a/apps/web/components/notes/note-editor.tsx b/apps/web/components/notes/note-editor.tsx index e3a1244..4b0a063 100644 --- a/apps/web/components/notes/note-editor.tsx +++ b/apps/web/components/notes/note-editor.tsx @@ -1,7 +1,8 @@ 'use client'; import { useEditor, EditorContent } from '@tiptap/react'; -import StarterKit from '@tiptap/starter-kit'; +import StarterKitRaw from "@tiptap/starter-kit"; +const StarterKit = StarterKitRaw as any; import Link from '@tiptap/extension-link'; import Placeholder from '@tiptap/extension-placeholder'; import { useEffect } from 'react'; @@ -60,28 +61,28 @@ export function NoteEditor({ content, onChange, onBlur }: NoteEditorProps) { {/* Toolbar */}
editor.chain().focus().toggleBold().run()} + onClick={() => (editor.chain().focus() as any).toggleBold().run()} active={editor.isActive('bold')} label="Bold" > editor.chain().focus().toggleItalic().run()} + onClick={() => (editor.chain().focus() as any).toggleItalic().run()} active={editor.isActive('italic')} label="Italic" > editor.chain().focus().toggleStrike().run()} + onClick={() => (editor.chain().focus() as any).toggleStrike().run()} active={editor.isActive('strike')} label="Strikethrough" > editor.chain().focus().toggleCode().run()} + onClick={() => (editor.chain().focus() as any).toggleCode().run()} active={editor.isActive('code')} label="Code" > @@ -89,21 +90,21 @@ export function NoteEditor({ content, onChange, onBlur }: NoteEditorProps) {
editor.chain().focus().toggleBulletList().run()} + onClick={() => (editor.chain().focus() as any).toggleBulletList().run()} active={editor.isActive('bulletList')} label="Bullet list" > editor.chain().focus().toggleOrderedList().run()} + onClick={() => (editor.chain().focus() as any).toggleOrderedList().run()} active={editor.isActive('orderedList')} label="Ordered list" > editor.chain().focus().toggleBlockquote().run()} + onClick={() => (editor.chain().focus() as any).toggleBlockquote().run()} active={editor.isActive('blockquote')} label="Blockquote" > @@ -111,14 +112,14 @@ export function NoteEditor({ content, onChange, onBlur }: NoteEditorProps) {
editor.chain().focus().undo().run()} + onClick={() => (editor.chain().focus() as any).undo().run()} disabled={!editor.can().undo()} label="Undo" > editor.chain().focus().redo().run()} + onClick={() => (editor.chain().focus() as any).redo().run()} disabled={!editor.can().redo()} label="Redo" > diff --git a/apps/web/components/reports/report-editor.tsx b/apps/web/components/reports/report-editor.tsx index 77ac7e4..344eeb1 100644 --- a/apps/web/components/reports/report-editor.tsx +++ b/apps/web/components/reports/report-editor.tsx @@ -1,7 +1,8 @@ 'use client'; import { useEditor, EditorContent } from '@tiptap/react'; -import StarterKit from '@tiptap/starter-kit'; +import StarterKitRaw from '@tiptap/starter-kit'; +const StarterKit = StarterKitRaw as any; import Link from '@tiptap/extension-link'; import Placeholder from '@tiptap/extension-placeholder'; import { useEffect } from 'react'; @@ -90,14 +91,14 @@ export function ReportEditor({ content, onChange, onBlur }: ReportEditorProps) { {/* Toolbar */}
editor.chain().focus().toggleHeading({ level: 1 }).run()} + onClick={() => (editor.chain().focus() as any).toggleHeading({ level: 1 }).run()} active={editor.isActive('heading', { level: 1 })} label="Heading 1" > editor.chain().focus().toggleHeading({ level: 2 }).run()} + onClick={() => (editor.chain().focus() as any).toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })} label="Heading 2" > @@ -105,28 +106,28 @@ export function ReportEditor({ content, onChange, onBlur }: ReportEditorProps) {
editor.chain().focus().toggleBold().run()} + onClick={() => (editor.chain().focus() as any).toggleBold().run()} active={editor.isActive('bold')} label="Bold" > editor.chain().focus().toggleItalic().run()} + onClick={() => (editor.chain().focus() as any).toggleItalic().run()} active={editor.isActive('italic')} label="Italic" > editor.chain().focus().toggleStrike().run()} + onClick={() => (editor.chain().focus() as any).toggleStrike().run()} active={editor.isActive('strike')} label="Strikethrough" > editor.chain().focus().toggleCode().run()} + onClick={() => (editor.chain().focus() as any).toggleCode().run()} active={editor.isActive('code')} label="Code" > @@ -134,21 +135,21 @@ export function ReportEditor({ content, onChange, onBlur }: ReportEditorProps) {
editor.chain().focus().toggleBulletList().run()} + onClick={() => (editor.chain().focus() as any).toggleBulletList().run()} active={editor.isActive('bulletList')} label="Bullet list" > editor.chain().focus().toggleOrderedList().run()} + onClick={() => (editor.chain().focus() as any).toggleOrderedList().run()} active={editor.isActive('orderedList')} label="Ordered list" > editor.chain().focus().toggleBlockquote().run()} + onClick={() => (editor.chain().focus() as any).toggleBlockquote().run()} active={editor.isActive('blockquote')} label="Blockquote" > @@ -156,14 +157,14 @@ export function ReportEditor({ content, onChange, onBlur }: ReportEditorProps) {
editor.chain().focus().undo().run()} + onClick={() => (editor.chain().focus() as any).undo().run()} disabled={!editor.can().undo()} label="Undo" > editor.chain().focus().redo().run()} + onClick={() => (editor.chain().focus() as any).redo().run()} disabled={!editor.can().redo()} label="Redo" > diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 5ebf1a5..370822a 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -506,6 +506,32 @@ export const webhookDeliveries = pgTable( // ── API Keys ───────────────────────────────────────────────────────────────────── + + +// ── Reports ────────────────────────────────────────────────────────────────── + +export const reports = pgTable( + 'reports', + { + id: uuid('id').defaultRandom().primaryKey(), + title: text('title').notNull().default('Untitled report'), + content: text('content'), + reportType: text('report_type').notNull().default('custom'), + dateRangeStart: timestamp('date_range_start', { withTimezone: true }), + dateRangeEnd: timestamp('date_range_end', { withTimezone: true }), + domain: text('domain').notNull().default('personal'), + projectId: uuid('project_id').references((): any => projects.id, { onDelete: 'set null' }), + isDraft: boolean('is_draft').default(true), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('reports_domain_idx').on(table.domain), + index('reports_type_idx').on(table.reportType), + index('reports_created_at_idx').on(table.createdAt), + ] +); + export const apiKeys = pgTable( 'api_keys', { diff --git a/packages/shared/src/schemas/report.ts b/packages/shared/src/schemas/report.ts index da96b1c..15a24b3 100644 --- a/packages/shared/src/schemas/report.ts +++ b/packages/shared/src/schemas/report.ts @@ -27,36 +27,24 @@ export const createReportTemplateSchema = reportTemplateSchema.omit({ updated: true, }); -// ── Report Schema ──────────────────────────────────────────────────────────── +// ── Report Schema (matches frontend field names) ──────────────────────────── export const reportSchema = z.object({ id: z.string(), - title: z.string().min(1, 'Report title is required'), - type: reportTypeEnum, - template_id: z.string().optional(), - domain: z.string(), - date_range: z.object({ - start: z.string().datetime(), - end: z.string().datetime(), - }), - sections: z.array(z.object({ - title: z.string(), - content: z.string().optional(), - data: z.record(z.unknown()).optional(), - sort_order: z.number().int().nonnegative().default(0), - })).default([]), - summary: z.string().optional(), + title: z.string().min(1, 'Report title is required').default('Untitled report'), + content: z.string().optional().default(''), + report_type: reportTypeEnum.default('custom'), + date_range_start: z.string().datetime().optional(), + date_range_end: z.string().datetime().optional(), + domain: z.string().default('personal'), + project_id: z.string().optional(), is_draft: z.boolean().default(true), - generated_at: z.string().datetime().optional(), - tags: z.array(z.string()).default([]), - custom_fields: z.record(z.unknown()).optional(), created: z.string().datetime(), updated: z.string().datetime(), }); export const createReportSchema = reportSchema.omit({ id: true, - generated_at: true, created: true, updated: true, }); @@ -69,4 +57,4 @@ export type Report = z.infer; export type CreateReport = z.infer; export type UpdateReport = z.infer; export type ReportTemplate = z.infer; -export type CreateReportTemplate = z.infer; +export type CreateReportTemplate = z.infer; \ No newline at end of file