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:
Hermes
2026-08-02 02:22:31 +00:00
parent 0df6d1c1c8
commit 7297b7075e
6 changed files with 171 additions and 79 deletions
+45 -13
View File
@@ -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 });
});
+67 -23
View File
@@ -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) {
+11 -10
View File
@@ -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"
>
+13 -12
View File
@@ -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"
>
+26
View File
@@ -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',
{
+9 -21
View File
@@ -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<typeof reportSchema>;
export type CreateReport = z.infer<typeof createReportSchema>;
export type UpdateReport = z.infer<typeof updateReportSchema>;
export type ReportTemplate = z.infer<typeof reportTemplateSchema>;
export type CreateReportTemplate = z.infer<typeof createReportTemplateSchema>;
export type CreateReportTemplate = z.infer<typeof createReportTemplateSchema>;