From 266efddbcfef2187f2c9ef4f8b41b9a402085545 Mon Sep 17 00:00:00 2001 From: Kaito Date: Thu, 14 May 2026 00:08:24 +0700 Subject: [PATCH 1/5] refactor(dashboard): mark notes read with server action --- apps/web/app/dashboard/notes/actions.ts | 83 ++++++++++++++++++++++ apps/web/app/dashboard/notes/note-list.tsx | 72 ++++++++----------- 2 files changed, 114 insertions(+), 41 deletions(-) create mode 100644 apps/web/app/dashboard/notes/actions.ts diff --git a/apps/web/app/dashboard/notes/actions.ts b/apps/web/app/dashboard/notes/actions.ts new file mode 100644 index 0000000..429ffb6 --- /dev/null +++ b/apps/web/app/dashboard/notes/actions.ts @@ -0,0 +1,83 @@ +"use server" + +import { revalidatePath } from "next/cache" + +import { Status } from "@/generated/prisma/enums" +import { invalidateCompanyCache } from "@/lib/api/cache" +import { findReviewReader } from "@/lib/api/review-reader" +import { getSession } from "@/lib/auth" +import { prisma } from "@/lib/prisma" + +type MarkNoteReadResult = + | { ok: true; taskId: string; readBy: string } + | { ok: false; error: string } + +export async function markDoneTaskSummaryReadAction( + taskId: string +): Promise { + const session = await getSession() + + if (!session) { + return { ok: false, error: "Unauthorized" } + } + + const task = await prisma.task.findFirst({ + where: { + id: taskId, + status: Status.done, + archivedAt: null, + note: { not: null }, + project: { company: { userId: session.userId } }, + }, + select: { + id: true, + summaryUpdatedAt: true, + taskUpdatedAt: true, + project: { select: { companyId: true } }, + }, + }) + + if (!task) { + return { ok: false, error: "Done task note not found." } + } + + const reviewReader = await findReviewReader(task.project.companyId) + + if (!reviewReader) { + return { ok: false, error: "Review reader agent not found." } + } + + const readAt = new Date() + + await prisma.$transaction(async (tx) => { + if (!task.summaryUpdatedAt) { + await tx.task.update({ + where: { id: task.id }, + data: { summaryUpdatedAt: task.taskUpdatedAt }, + }) + } + + await tx.taskReadMarker.upsert({ + where: { + taskId_agentId_status: { + taskId: task.id, + agentId: reviewReader.id, + status: Status.done, + }, + }, + create: { + taskId: task.id, + agentId: reviewReader.id, + status: Status.done, + readAt, + }, + update: { readAt }, + }) + }) + + await invalidateCompanyCache(task.project.companyId) + revalidatePath("/dashboard/notes") + revalidatePath(`/dashboard/projects/${task.project.companyId}`) + + return { ok: true, taskId: task.id, readBy: reviewReader.AgentId } +} diff --git a/apps/web/app/dashboard/notes/note-list.tsx b/apps/web/app/dashboard/notes/note-list.tsx index 61a0789..7d99b18 100644 --- a/apps/web/app/dashboard/notes/note-list.tsx +++ b/apps/web/app/dashboard/notes/note-list.tsx @@ -1,9 +1,8 @@ "use client" -import { useState } from "react" +import { useState, useTransition } from "react" import Link from "next/link" import { ChevronDown } from "lucide-react" -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" @@ -14,9 +13,10 @@ import { CardHeader, CardTitle, } from "@/components/ui/card" -import { apiJson } from "@/lib/api/client" import { cn } from "@/lib/utils" +import { markDoneTaskSummaryReadAction } from "./actions" + type NoteTask = { id: string name: string @@ -47,31 +47,11 @@ type NoteListProps = { } export function NoteList({ companyId, reviewReader, initialNotes }: NoteListProps) { - const queryClient = useQueryClient() - const notesQuery = useQuery({ - queryKey: ["notes", companyId], - queryFn: () => - apiJson<{ notes: NoteTask[]; reviewReader: ReviewReader | null }>( - `/api/internal/notes?company=${companyId}` - ), - initialData: { notes: initialNotes, reviewReader }, - }) - const markReadMutation = useMutation({ - mutationFn: (taskId: string) => - apiJson(`/api/internal/notes/${taskId}/read`, { - method: "POST", - }), - onSuccess: (_data, taskId) => { - queryClient.setQueryData<{ notes: NoteTask[] }>(["notes", companyId], (current) => - current - ? { notes: current.notes.filter((task) => task.id !== taskId) } - : current - ) - queryClient.invalidateQueries({ queryKey: ["project"] }) - }, - }) - const notes = notesQuery.data.notes - const currentReviewReader = notesQuery.data.reviewReader ?? reviewReader + const [notes, setNotes] = useState(initialNotes) + const [error, setError] = useState(null) + const [pendingTaskId, setPendingTaskId] = useState(null) + const [isPending, startTransition] = useTransition() + const currentReviewReader = reviewReader const reviewReaderLabel = currentReviewReader ? currentReviewReader.AgentId === "main" ? "Natsuki/main" @@ -80,15 +60,12 @@ export function NoteList({ companyId, reviewReader, initialNotes }: NoteListProp return (
- {notesQuery.isFetching ? ( -

Refreshing notes...

+ {isPending ? ( +

Updating review marker...

) : null} - {notesQuery.error ? ( + {error ? (
-

{notesQuery.error.message}

- +

{error}

) : null} {!currentReviewReader ? ( @@ -129,10 +106,26 @@ export function NoteList({ companyId, reviewReader, initialNotes }: NoteListProp
)} - {markReadMutation.error ? ( -

{markReadMutation.error.message}

- ) : null} ) } From 662adaebdc73df6df1b3445e13bcdcf8f8ec6118 Mon Sep 17 00:00:00 2001 From: Kaito Date: Thu, 14 May 2026 00:34:24 +0700 Subject: [PATCH 2/5] refactor(dashboard): migrate agents summary audit loaders --- apps/web/app/dashboard/agents/actions.ts | 217 ++++++++++++++++++ .../dashboard/agents/agent-row-actions.tsx | 106 ++++----- .../web/app/dashboard/agents/agents-table.tsx | 108 +++------ apps/web/app/dashboard/agents/page.tsx | 15 +- .../dashboard/audit-logs/audit-log-list.tsx | 41 +--- apps/web/app/dashboard/audit-logs/page.tsx | 2 +- apps/web/app/dashboard/dashboard-summary.tsx | 42 +--- apps/web/app/dashboard/page.tsx | 24 +- 8 files changed, 330 insertions(+), 225 deletions(-) create mode 100644 apps/web/app/dashboard/agents/actions.ts diff --git a/apps/web/app/dashboard/agents/actions.ts b/apps/web/app/dashboard/agents/actions.ts new file mode 100644 index 0000000..4735027 --- /dev/null +++ b/apps/web/app/dashboard/agents/actions.ts @@ -0,0 +1,217 @@ +"use server" + +import { revalidatePath } from "next/cache" + +import { Prisma } from "@/generated/prisma/client" +import { createAuditLog, formatChangedFields } from "@/lib/api/audit-log" +import { invalidateCompanyCache } from "@/lib/api/cache" +import { getSession } from "@/lib/auth" +import { prisma } from "@/lib/prisma" + +type AgentActionResult = { + error?: string +} + +async function requireDashboardSession() { + const session = await getSession() + + if (!session) { + throw new Error("Unauthorized") + } + + return session +} + +function revalidateDashboardAgentPaths(companyId?: string | null) { + revalidatePath("/dashboard") + revalidatePath("/dashboard/agents") + revalidatePath("/dashboard/projects") + if (companyId) { + revalidatePath(`/dashboard/agents?company=${companyId}`) + revalidatePath(`/dashboard/projects?company=${companyId}`) + } +} + +export async function createAgentAction(_previousState: AgentActionResult, formData: FormData) { + const session = await requireDashboardSession() + + const companyId = String(formData.get("companyId") ?? "") + const AgentId = String(formData.get("AgentId") ?? "").trim() + const name = String(formData.get("name") ?? "").trim() + const description = String(formData.get("description") ?? "").trim() + const position = String(formData.get("position") ?? "").trim() + + if (!companyId || !AgentId || !name || !position) { + return { error: "Company, AgentId, name, and position are required." } + } + + const company = await prisma.company.findFirst({ + where: { id: companyId, userId: session.userId }, + select: { id: true }, + }) + + if (!company) { + return { error: "Company not found." } + } + + const existingAgent = await prisma.agent.findUnique({ + where: { AgentId }, + select: { id: true }, + }) + + if (existingAgent) { + return { error: "AgentId is already in use." } + } + + const agent = await prisma.agent + .create({ + data: { + companyId, + AgentId, + name, + description, + position, + }, + select: { + id: true, + AgentId: true, + name: true, + description: true, + position: true, + companyId: true, + }, + }) + .catch((error: unknown) => { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + return null + } + + throw error + }) + + if (!agent) { + return { error: "AgentId is already in use." } + } + + await createAuditLog({ + companyId, + action: "agent.created", + target: { type: "agent", id: agent.id, name: agent.name }, + actor: { type: "user", id: session.userId, name: session.username }, + details: `Created AgentId ${agent.AgentId}.`, + }) + await invalidateCompanyCache(companyId) + revalidateDashboardAgentPaths(companyId) + + return {} +} + +export async function updateAgentAction(_previousState: AgentActionResult, formData: FormData) { + const session = await requireDashboardSession() + + const agentId = String(formData.get("agentId") ?? "") + const nextAgentId = String(formData.get("AgentId") ?? "").trim() + const name = String(formData.get("name") ?? "").trim() + const description = String(formData.get("description") ?? "").trim() + const position = String(formData.get("position") ?? "").trim() + + if (!agentId) { + return { error: "Agent is required." } + } + + if (!nextAgentId || !name || !position) { + return { error: "AgentId, name, and position cannot be empty." } + } + + const agent = await prisma.agent.findFirst({ + where: { + id: agentId, + company: { userId: session.userId }, + }, + }) + + if (!agent) { + return { error: "Agent not found." } + } + + if (nextAgentId !== agent.AgentId) { + const existing = await prisma.agent.findUnique({ + where: { AgentId: nextAgentId }, + select: { id: true }, + }) + + if (existing) { + return { error: "AgentId is already in use." } + } + } + + const updatedAgent = await prisma.agent.update({ + where: { id: agent.id }, + data: { + AgentId: nextAgentId, + name, + description, + position, + }, + select: { + id: true, + AgentId: true, + name: true, + description: true, + position: true, + companyId: true, + }, + }) + + await createAuditLog({ + companyId: agent.companyId, + action: "agent.updated", + target: { type: "agent", id: agent.id, name: updatedAgent.name }, + actor: { type: "user", id: session.userId, name: session.username }, + details: formatChangedFields([ + nextAgentId !== agent.AgentId && "AgentId", + name !== agent.name && "name", + description !== agent.description && "description", + position !== agent.position && "position", + ]), + }) + await invalidateCompanyCache(agent.companyId) + revalidateDashboardAgentPaths(agent.companyId) + + return {} +} + +export async function deleteAgentAction(_previousState: AgentActionResult, formData: FormData) { + const session = await requireDashboardSession() + + const agentId = String(formData.get("agentId") ?? "") + + if (!agentId) { + return { error: "Agent is required." } + } + + const agent = await prisma.agent.findFirst({ + where: { + id: agentId, + company: { userId: session.userId }, + }, + }) + + if (!agent) { + return { error: "Agent not found." } + } + + await prisma.agent.delete({ where: { id: agent.id } }) + + await createAuditLog({ + companyId: agent.companyId, + action: "agent.deleted", + target: { type: "agent", id: agent.id, name: agent.name }, + actor: { type: "user", id: session.userId, name: session.username }, + details: `Deleted AgentId ${agent.AgentId}.`, + }) + await invalidateCompanyCache(agent.companyId) + revalidateDashboardAgentPaths(agent.companyId) + + return {} +} diff --git a/apps/web/app/dashboard/agents/agent-row-actions.tsx b/apps/web/app/dashboard/agents/agent-row-actions.tsx index 5d68b97..3bd4205 100644 --- a/apps/web/app/dashboard/agents/agent-row-actions.tsx +++ b/apps/web/app/dashboard/agents/agent-row-actions.tsx @@ -1,7 +1,7 @@ "use client" -import { useState } from "react" -import { useMutation, useQueryClient } from "@tanstack/react-query" +import { useActionState, useState } from "react" +import { useFormStatus } from "react-dom" import { MoreHorizontalIcon } from "lucide-react" import { @@ -33,7 +33,8 @@ import { import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" -import { apiJson } from "@/lib/api/client" + +import { deleteAgentAction, updateAgentAction } from "./actions" type AgentRow = { id: string @@ -45,58 +46,13 @@ type AgentRow = { type AgentRowActionsProps = { agent: AgentRow - companyId: string | null } -export function AgentRowActions({ agent, companyId }: AgentRowActionsProps) { +export function AgentRowActions({ agent }: AgentRowActionsProps) { const [editOpen, setEditOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false) - const queryClient = useQueryClient() - - function invalidateAgentData() { - queryClient.invalidateQueries({ queryKey: ["agents", companyId] }) - queryClient.invalidateQueries({ - queryKey: ["dashboard-summary", companyId], - }) - queryClient.invalidateQueries({ queryKey: ["projects", companyId] }) - queryClient.invalidateQueries({ queryKey: ["project"] }) - } - - const editMutation = useMutation({ - mutationFn: (payload: { - AgentId: string - name: string - description: string - position: string - }) => - apiJson<{ agent: AgentRow }>(`/api/internal/agents/${agent.id}`, { - method: "PATCH", - body: JSON.stringify(payload), - }), - onSuccess: () => { - setEditOpen(false) - invalidateAgentData() - }, - }) - const deleteMutation = useMutation({ - mutationFn: () => - apiJson(`/api/internal/agents/${agent.id}`, { - method: "DELETE", - }), - onSuccess: () => { - setDeleteOpen(false) - invalidateAgentData() - }, - }) - - function editAction(formData: FormData) { - editMutation.mutate({ - AgentId: String(formData.get("AgentId") ?? ""), - name: String(formData.get("name") ?? ""), - description: String(formData.get("description") ?? ""), - position: String(formData.get("position") ?? ""), - }) - } + const [editState, editAction] = useActionState(updateAgentAction, {}) + const [deleteState, deleteAction] = useActionState(deleteAgentAction, {}) return (
@@ -133,6 +89,7 @@ export function AgentRowActions({ agent, companyId }: AgentRowActionsProps) {
+
- {editMutation.error ? ( + {editState.error ? (

- {editMutation.error.message} + {editState.error}

) : null} - + @@ -196,21 +151,16 @@ export function AgentRowActions({ agent, companyId }: AgentRowActionsProps) { other agents. - {deleteMutation.error ? ( + {deleteState.error ? (

- {deleteMutation.error.message} + {deleteState.error}

) : null} Cancel -
deleteMutation.mutate()}> - - {deleteMutation.isPending ? "Deleting..." : "Delete"} - + + +
@@ -218,3 +168,27 @@ export function AgentRowActions({ agent, companyId }: AgentRowActionsProps) {
) } + +function SaveAgentButton() { + const { pending } = useFormStatus() + + return ( + + ) +} + +function DeleteAgentButton() { + const { pending } = useFormStatus() + + return ( + + {pending ? "Deleting..." : "Delete"} + + ) +} diff --git a/apps/web/app/dashboard/agents/agents-table.tsx b/apps/web/app/dashboard/agents/agents-table.tsx index 8df5c08..fb6e834 100644 --- a/apps/web/app/dashboard/agents/agents-table.tsx +++ b/apps/web/app/dashboard/agents/agents-table.tsx @@ -1,6 +1,7 @@ "use client" -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { useActionState, useEffect, useRef } from "react" +import { useFormStatus } from "react-dom" import { Button } from "@/components/ui/button" import { @@ -22,8 +23,8 @@ import { TableRow, } from "@/components/ui/table" import { Textarea } from "@/components/ui/textarea" -import { apiJson } from "@/lib/api/client" +import { createAgentAction } from "./actions" import { AgentRowActions } from "./agent-row-actions" type AgentRow = { @@ -35,49 +36,19 @@ type AgentRow = { } type AgentsTableProps = { + agents: AgentRow[] companyId: string | null } -export function AgentsTable({ companyId }: AgentsTableProps) { - const queryClient = useQueryClient() - const agentsQuery = useQuery({ - queryKey: ["agents", companyId], - queryFn: () => - apiJson<{ agents: AgentRow[] }>(`/api/internal/agents?companyId=${companyId}`), - enabled: Boolean(companyId), - }) - const createMutation = useMutation({ - mutationFn: (payload: { - companyId: string - AgentId: string - name: string - description: string - position: string - }) => - apiJson<{ agent: AgentRow }>("/api/internal/agents", { - method: "POST", - body: JSON.stringify(payload), - }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["agents", companyId] }) - queryClient.invalidateQueries({ queryKey: ["dashboard-summary", companyId] }) - queryClient.invalidateQueries({ queryKey: ["projects", companyId] }) - queryClient.invalidateQueries({ queryKey: ["project"] }) - }, - }) +export function AgentsTable({ agents, companyId }: AgentsTableProps) { + const formRef = useRef(null) + const [state, formAction] = useActionState(createAgentAction, {}) - function action(formData: FormData) { - if (!companyId) return - - createMutation.mutate({ - companyId, - AgentId: String(formData.get("AgentId") ?? ""), - name: String(formData.get("name") ?? ""), - description: String(formData.get("description") ?? ""), - position: String(formData.get("position") ?? ""), - }) - } - const currentAgents = agentsQuery.data?.agents ?? [] + useEffect(() => { + if (!state.error) { + formRef.current?.reset() + } + }, [state]) return (
@@ -101,7 +72,7 @@ export function AgentsTable({ companyId }: AgentsTableProps) { The company bearer token is shared. Enter the AgentId this agent will send in the AgentId header. -
+
@@ -119,26 +90,13 @@ export function AgentsTable({ companyId }: AgentsTableProps) {