fix: reports API - add DB table, rewrite routes with Drizzle, fix TipTap type errors
Added reports table to PostgreSQL and Drizzle schema. Rewrote reports API routes to use Drizzle ORM instead of PocketBase stub. Fixed shared schema to match frontend field names. Fixed TipTap v3 type errors in note-editor.tsx and report-editor.tsx (StarterKit cast+chain as any).
This commit is contained in:
@@ -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<RouteContext>(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<RouteContext>(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<string, any> = {};
|
||||
if (data.title !== undefined) updateValues.title = data.title;
|
||||
if (data.content !== undefined) updateValues.content = data.content;
|
||||
if (data.report_type !== undefined) updateValues.reportType = data.report_type;
|
||||
if (data.date_range_start !== undefined) updateValues.dateRangeStart = new Date(data.date_range_start);
|
||||
if (data.date_range_end !== undefined) updateValues.dateRangeEnd = new Date(data.date_range_end);
|
||||
if (data.domain !== undefined) updateValues.domain = data.domain;
|
||||
if (data.is_draft !== undefined) updateValues.isDraft = data.is_draft;
|
||||
updateValues.updatedAt = new Date();
|
||||
|
||||
const [report] = await db.update(reports)
|
||||
.set(updateValues)
|
||||
.where(eq(reports.id, id))
|
||||
.returning();
|
||||
|
||||
if (!report) {
|
||||
return createErrorResponse('NOT_FOUND', 'Report not found', 404);
|
||||
}
|
||||
|
||||
return NextResponse.json(report);
|
||||
} catch (error) {
|
||||
@@ -44,8 +71,13 @@ export const PATCH = withAuth<RouteContext>(async (request: NextRequest, _user,
|
||||
export const DELETE = withAuth<RouteContext>(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 });
|
||||
});
|
||||
|
||||
@@ -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<string, any> = {
|
||||
title: reports.title,
|
||||
report_type: reports.reportType,
|
||||
domain: reports.domain,
|
||||
created: reports.createdAt,
|
||||
updated: reports.updatedAt,
|
||||
};
|
||||
const orderBy = sortDir === 'asc'
|
||||
? asc(sortColumns[sortField] || reports.createdAt)
|
||||
: desc(sortColumns[sortField] || reports.createdAt);
|
||||
|
||||
const conditions: any[] = [];
|
||||
if (filter) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(reports.title, `%${filter}%`),
|
||||
)!
|
||||
);
|
||||
}
|
||||
|
||||
const offset = (page - 1) * perPage;
|
||||
|
||||
const [items, countResult] = await Promise.all([
|
||||
db.select()
|
||||
.from(reports)
|
||||
.where(and(...conditions))
|
||||
.orderBy(orderBy)
|
||||
.limit(perPage)
|
||||
.offset(offset),
|
||||
db.select({ count: sql<number>`count(*)` })
|
||||
.from(reports)
|
||||
.where(and(...conditions)),
|
||||
]);
|
||||
|
||||
const totalItems = Number(countResult[0]?.count || 0);
|
||||
|
||||
return NextResponse.json({
|
||||
items: 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) {
|
||||
|
||||
@@ -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 */}
|
||||
<div className="mb-4 flex flex-wrap gap-1 border-b pb-2">
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
onClick={() => (editor.chain().focus() as any).toggleBold().run()}
|
||||
active={editor.isActive('bold')}
|
||||
label="Bold"
|
||||
>
|
||||
<Bold className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
onClick={() => (editor.chain().focus() as any).toggleItalic().run()}
|
||||
active={editor.isActive('italic')}
|
||||
label="Italic"
|
||||
>
|
||||
<Italic className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
onClick={() => (editor.chain().focus() as any).toggleStrike().run()}
|
||||
active={editor.isActive('strike')}
|
||||
label="Strikethrough"
|
||||
>
|
||||
<Strikethrough className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => 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) {
|
||||
</ToolbarButton>
|
||||
<div className="mx-1 w-px bg-border" />
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
onClick={() => (editor.chain().focus() as any).toggleBulletList().run()}
|
||||
active={editor.isActive('bulletList')}
|
||||
label="Bullet list"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
onClick={() => (editor.chain().focus() as any).toggleOrderedList().run()}
|
||||
active={editor.isActive('orderedList')}
|
||||
label="Ordered list"
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => 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) {
|
||||
</ToolbarButton>
|
||||
<div className="mx-1 w-px bg-border" />
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
onClick={() => (editor.chain().focus() as any).undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
label="Undo"
|
||||
>
|
||||
<Undo className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
onClick={() => (editor.chain().focus() as any).redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
label="Redo"
|
||||
>
|
||||
|
||||
@@ -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 */}
|
||||
<div className="mb-4 flex flex-wrap gap-1 border-b pb-2">
|
||||
<ToolbarButton
|
||||
onClick={() => 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"
|
||||
>
|
||||
<Heading1 className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => 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) {
|
||||
</ToolbarButton>
|
||||
<div className="mx-1 w-px bg-border" />
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
onClick={() => (editor.chain().focus() as any).toggleBold().run()}
|
||||
active={editor.isActive('bold')}
|
||||
label="Bold"
|
||||
>
|
||||
<Bold className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
onClick={() => (editor.chain().focus() as any).toggleItalic().run()}
|
||||
active={editor.isActive('italic')}
|
||||
label="Italic"
|
||||
>
|
||||
<Italic className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
onClick={() => (editor.chain().focus() as any).toggleStrike().run()}
|
||||
active={editor.isActive('strike')}
|
||||
label="Strikethrough"
|
||||
>
|
||||
<Strikethrough className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => 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) {
|
||||
</ToolbarButton>
|
||||
<div className="mx-1 w-px bg-border" />
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
onClick={() => (editor.chain().focus() as any).toggleBulletList().run()}
|
||||
active={editor.isActive('bulletList')}
|
||||
label="Bullet list"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
onClick={() => (editor.chain().focus() as any).toggleOrderedList().run()}
|
||||
active={editor.isActive('orderedList')}
|
||||
label="Ordered list"
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => 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) {
|
||||
</ToolbarButton>
|
||||
<div className="mx-1 w-px bg-border" />
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
onClick={() => (editor.chain().focus() as any).undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
label="Undo"
|
||||
>
|
||||
<Undo className="h-4 w-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
onClick={() => (editor.chain().focus() as any).redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
label="Redo"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user