feat: add Pagination component, file upload, fix N+1, remove unused deps
Phase 3 (Web UX Polish) of the execution roadmap: - Add reusable Pagination component for list views - Fix getNoteDetail to avoid fetching all notes when workspaceId is unknown - Add file upload button to ArtifactPanel using uploadArtifact() from blob-client - Remove unused zustand and zod from web dependencies - SSR safety already addressed via existing lazy-init patterns Made-with: Cursor
This commit is contained in:
parent
8d84bcb841
commit
a5b0a89527
@ -105,33 +105,33 @@ cd web && pnpm run typecheck && pnpm test && pnpm run build
|
||||
|
||||
## Phase 2 — Backend Hardening
|
||||
|
||||
- [ ] **2.1** Add integration tests for all route modules using `buildTestApp()` + Fastify `.inject()`
|
||||
- [x] **2.1** Add integration tests for all route modules (already complete) — [`8d84bcb`](https://github.com/saravanakumardb1/learning_ai_notes/commit/8d84bcb) using `buildTestApp()` + Fastify `.inject()`
|
||||
- Current route tests only check handler registration counts
|
||||
- Need real request/response tests: happy path, validation errors, auth enforcement, 404s
|
||||
- Modules needing inject tests: notes, workspaces, note-relationships, note-tasks, note-artifacts, note-agent-actions, saved-views
|
||||
- Files: `backend/src/modules/*/routes.integration.test.ts` (7 new files)
|
||||
|
||||
- [ ] **2.2** Add DELETE endpoints for notes, workspaces, tasks, artifacts, relationships
|
||||
- [x] **2.2** Add DELETE endpoints for notes, workspaces, tasks, artifacts, relationships — [`8d84bcb`](https://github.com/saravanakumardb1/learning_ai_notes/commit/8d84bcb)
|
||||
- Currently only saved-views has DELETE
|
||||
- Notes support archive but not hard delete — add soft-delete with a `deletedAt` timestamp
|
||||
- Files: `backend/src/modules/*/routes.ts` + `repository.ts`
|
||||
|
||||
- [ ] **2.3** Enforce role-based access on REST routes
|
||||
- [x] **2.3** Enforce role-based access on REST routes — [`8d84bcb`](https://github.com/saravanakumardb1/learning_ai_notes/commit/8d84bcb)
|
||||
- `requireRole` is exported and tested but not used on any REST route
|
||||
- At minimum: write routes (POST/PATCH/DELETE) should require `editor` or `admin` role
|
||||
- File: every `routes.ts` file
|
||||
|
||||
- [ ] **2.4** Enforce workspace member authorization
|
||||
- [x] **2.4** Enforce workspace member authorization — [`8d84bcb`](https://github.com/saravanakumardb1/learning_ai_notes/commit/8d84bcb)
|
||||
- `workspace.members` array is stored but never checked
|
||||
- Note CRUD should verify the requesting user is a member of the target workspace
|
||||
- Files: `backend/src/modules/notes/routes.ts`, `backend/src/modules/workspaces/repository.ts`
|
||||
|
||||
- [ ] **2.5** Activate telemetry — call `trackEvent()` on key business actions
|
||||
- [x] **2.5** Activate telemetry — call `trackEvent()` on key business actions — [`8d84bcb`](https://github.com/saravanakumardb1/learning_ai_notes/commit/8d84bcb)
|
||||
- Buffer and flush infrastructure exist but no route handler actually calls `trackEvent()`
|
||||
- Track: note.created, note.updated, note.archived, note.searched, workspace.created, agentAction.approved, agentAction.rejected
|
||||
- Files: all `routes.ts` files + `backend/src/lib/telemetry.ts`
|
||||
|
||||
- [ ] **2.6** Activate feature flags in route logic
|
||||
- [x] **2.6** Activate feature flags in route logic — [`8d84bcb`](https://github.com/saravanakumardb1/learning_ai_notes/commit/8d84bcb)
|
||||
- 6 flags registered with defaults but no route checks `isFeatureEnabled()`
|
||||
- At minimum: gate MCP write tools behind `mcp.enabled`, gate export behind a flag
|
||||
- Files: `backend/src/lib/feature-flags.ts`, relevant route files
|
||||
|
||||
@ -33,9 +33,7 @@
|
||||
"sonner": "^2.0.0",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
"react-dom": "19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { getArtifactReadUrl } from "@/lib/blob-client";
|
||||
import { useRef, useState } from "react";
|
||||
import { getArtifactReadUrl, uploadArtifact } from "@/lib/blob-client";
|
||||
import { toast } from "@/lib/toast";
|
||||
import type { ArtifactSummary } from "@/lib/types";
|
||||
|
||||
export function ArtifactPanel({
|
||||
@ -19,6 +20,8 @@ export function ArtifactPanel({
|
||||
isCreating?: boolean;
|
||||
}) {
|
||||
const [openingId, setOpeningId] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [artifactType, setArtifactType] = useState<"file" | "summary" | "extraction" | "citation" | "export">("file");
|
||||
const [description, setDescription] = useState("");
|
||||
@ -38,6 +41,29 @@ export function ArtifactPanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const path = `artifacts/${crypto.randomUUID()}/${file.name}`;
|
||||
const result = await uploadArtifact(file, path);
|
||||
await onCreate({
|
||||
title: file.name,
|
||||
artifactType: "file",
|
||||
description: `Uploaded file (${(result.sizeBytes / 1024).toFixed(1)} KB)`,
|
||||
blobPath: result.blobPath,
|
||||
});
|
||||
toast.success("File uploaded");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Upload failed");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateArtifact() {
|
||||
if (!title.trim()) {
|
||||
return;
|
||||
@ -101,7 +127,17 @@ export function ArtifactPanel({
|
||||
placeholder="Description"
|
||||
style={{ minHeight: 96, resize: "vertical" }}
|
||||
/>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: "var(--nl-space-2)" }}>
|
||||
<input ref={fileInputRef} type="file" hidden onChange={handleFileUpload} />
|
||||
<button
|
||||
type="button"
|
||||
disabled={uploading}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="surface-muted"
|
||||
style={{ padding: "8px 12px", border: "none", fontSize: "var(--nl-fs-sm)" }}
|
||||
>
|
||||
{uploading ? "Uploading…" : "Upload file"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isCreating}
|
||||
|
||||
39
web/src/components/Pagination.tsx
Normal file
39
web/src/components/Pagination.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
interface PaginationProps {
|
||||
offset: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
onPageChange: (newOffset: number) => void;
|
||||
}
|
||||
|
||||
export function Pagination({ offset, limit, total, onPageChange }: PaginationProps) {
|
||||
const currentPage = Math.floor(offset / limit) + 1;
|
||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "var(--nl-space-3)", padding: "var(--nl-space-4) 0" }}>
|
||||
<button
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => onPageChange(Math.max(0, offset - limit))}
|
||||
className="surface-muted"
|
||||
style={{ padding: "6px 14px", border: "none", fontSize: "var(--nl-fs-sm)" }}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span style={{ fontSize: "var(--nl-fs-sm)", color: "var(--nl-text-secondary)" }}>
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => onPageChange(offset + limit)}
|
||||
className="surface-muted"
|
||||
style={{ padding: "6px 14px", border: "none", fontSize: "var(--nl-fs-sm)" }}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -251,10 +251,14 @@ export async function getNoteDetail(noteId: string, knownWorkspaceId?: string):
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
const noteResponse = await api.fetch<NoteListResponse>("/notes");
|
||||
note = noteResponse.items.find((item) => item.id === noteId);
|
||||
if (!note) return null;
|
||||
workspaceId = note.workspaceId;
|
||||
try {
|
||||
const noteResponse = await api.fetch<NoteListResponse>(`/notes?search=${encodeURIComponent(noteId)}&limit=1`);
|
||||
note = noteResponse.items.find((item) => item.id === noteId);
|
||||
if (!note) return null;
|
||||
workspaceId = note.workspaceId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const wsId = encodeURIComponent(workspaceId);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user