From 74682c08509bb69e1caf0fe98226eee12b25446e Mon Sep 17 00:00:00 2001 From: Kaito Date: Wed, 13 May 2026 18:34:35 +0700 Subject: [PATCH] perf(api): cache dashboard reads with redis --- .env.example | 4 + README.md | 16 + apps/web/app/api/internal/agents/route.ts | 56 ++- .../app/api/internal/dashboard/brief/route.ts | 55 ++- .../api/internal/dashboard/summary/route.ts | 62 ++- .../api/internal/notes/[taskId]/read/route.ts | 3 + apps/web/app/api/internal/notes/route.ts | 134 ++++--- .../internal/projects/[projectId]/route.ts | 361 ++++++++++-------- apps/web/app/api/internal/projects/route.ts | 40 +- apps/web/lib/api/audit-log.ts | 8 + apps/web/lib/api/cache.ts | 128 +++++++ apps/web/package.json | 1 + pnpm-lock.yaml | 25 +- 13 files changed, 621 insertions(+), 272 deletions(-) create mode 100644 apps/web/lib/api/cache.ts diff --git a/.env.example b/.env.example index 9744bfd..4babc8c 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,6 @@ DATABASE_URL="postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public" AUTH_SECRET="replace-with-a-long-random-string" + +# Optional Upstash Redis HTTP cache for safe API read endpoints. +UPSTASH_REDIS_REST_URL="" +UPSTASH_REDIS_REST_TOKEN="" diff --git a/README.md b/README.md index 01c471f..6b7cd9f 100644 --- a/README.md +++ b/README.md @@ -390,6 +390,22 @@ See [docs/production-runbook.md](docs/production-runbook.md) for the full deploy Do not commit `.env`, real bearer tokens, database credentials, `.next`, `node_modules`, or generated local logs. +## Optional Redis API response cache + +AgentBridge can use Upstash Redis over HTTP to cache safe dashboard read APIs with +short TTLs. The cache is optional: when these variables are unset, reads fall +back to direct database queries with unchanged response shapes. + +```bash +UPSTASH_REDIS_REST_URL="https://...upstash.io" +UPSTASH_REDIS_REST_TOKEN="..." +``` + +Cache keys include user/company/project identifiers and version counters; raw +bearer tokens, auth cookies, database URLs, and other secrets are never stored in +keys. Task/project/agent/audit mutations bump company/project versions so cached +summary, brief, notes, agents, and project board responses refresh quickly. + ## Contributing Please read [CONTRIBUTING.md](CONTRIBUTING.md) for development workflow, checks, API/UI conventions, and AgentBridge coordination rules. diff --git a/apps/web/app/api/internal/agents/route.ts b/apps/web/app/api/internal/agents/route.ts index 9751f91..826ea51 100644 --- a/apps/web/app/api/internal/agents/route.ts +++ b/apps/web/app/api/internal/agents/route.ts @@ -2,6 +2,14 @@ import { NextRequest, NextResponse } from "next/server" import { Prisma } from "@/generated/prisma/client" import { createAuditLog } from "@/lib/api/audit-log" +import { + cacheJson, + cacheKey, + cacheStatusHeader, + cacheTtl, + getCompanyCacheVersion, + invalidateCompanyCache, +} from "@/lib/api/cache" import { badRequest, requireInternalSession } from "@/lib/api/internal" import { prisma } from "@/lib/prisma" @@ -16,23 +24,40 @@ export async function GET(request: NextRequest) { return badRequest("Company is required.") } - const agents = await prisma.agent.findMany({ - where: { - companyId, - company: { userId: session.userId }, - }, - orderBy: { name: "asc" }, - select: { - id: true, - AgentId: true, - name: true, - description: true, - position: true, - companyId: true, - }, + const company = await prisma.company.findFirst({ + where: { id: companyId, userId: session.userId }, + select: { id: true }, }) - return NextResponse.json({ statusCode: 200, agents }) + if (!company) { + return badRequest("Company not found.") + } + + const companyVersion = await getCompanyCacheVersion(companyId) + const { value, cacheStatus } = await cacheJson( + cacheKey(["internal", "agents", session.userId, companyId, companyVersion]), + cacheTtl.agentList, + async () => { + const agents = await prisma.agent.findMany({ + where: { companyId }, + orderBy: { name: "asc" }, + select: { + id: true, + AgentId: true, + name: true, + description: true, + position: true, + companyId: true, + }, + }) + + return { statusCode: 200, agents } + } + ) + + return NextResponse.json(value, { + headers: { "X-AgentBridge-Cache": cacheStatusHeader(cacheStatus) }, + }) } export async function POST(request: Request) { @@ -112,6 +137,7 @@ export async function POST(request: Request) { actor: { type: "user", id: session.userId, name: session.username }, details: `Created AgentId ${agent.AgentId}.`, }) + await invalidateCompanyCache(companyId) return NextResponse.json({ statusCode: 201, agent }, { status: 201 }) } diff --git a/apps/web/app/api/internal/dashboard/brief/route.ts b/apps/web/app/api/internal/dashboard/brief/route.ts index b37c6e5..bacf8c9 100644 --- a/apps/web/app/api/internal/dashboard/brief/route.ts +++ b/apps/web/app/api/internal/dashboard/brief/route.ts @@ -1,6 +1,14 @@ import { NextRequest, NextResponse } from "next/server" import { Status } from "@/generated/prisma/enums" +import { + cacheJson, + cacheKey, + cacheStatusHeader, + cacheTtl, + getCompanyCacheVersion, + getProjectCacheVersion, +} from "@/lib/api/cache" import { badRequest, requireInternalSession } from "@/lib/api/internal" import { prisma } from "@/lib/prisma" @@ -47,16 +55,33 @@ export async function GET(request: NextRequest) { return badRequest("Project not found.") } - const now = new Date() - const since = getRangeStart(rangeParam, now) - const projectWhere = projectId + const companyVersion = await getCompanyCacheVersion(company.id) + const projectVersion = projectId + ? await getProjectCacheVersion(projectId) + : "none" + const { value, cacheStatus } = await cacheJson( + cacheKey([ + "internal", + "dashboard-brief", + session.userId, + company.id, + projectId, + rangeParam, + companyVersion, + projectVersion, + ]), + cacheTtl.dashboardBrief, + async () => { + const now = new Date() + const since = getRangeStart(rangeParam, now) + const projectWhere = projectId ? { id: projectId, companyId: company.id } : { companyId: company.id } - const taskWhere = { + const taskWhere = { archivedAt: null, project: projectWhere, } - const projectTaskIds = projectId + const projectTaskIds = projectId ? await prisma.task.findMany({ where: { projectId, @@ -65,7 +90,7 @@ export async function GET(request: NextRequest) { select: { id: true }, }) : [] - const auditLogWhere = projectId + const auditLogWhere = projectId ? { companyId: company.id, createdAt: { gte: since }, @@ -81,7 +106,7 @@ export async function GET(request: NextRequest) { companyId: company.id, createdAt: { gte: since }, } - const taskSelect = { + const taskSelect = { id: true, name: true, status: true, @@ -105,14 +130,14 @@ export async function GET(request: NextRequest) { }, } - const [ + const [ projects, changedTasks, blockedTasks, completedTasks, latestNotes, auditLogs, - ] = await Promise.all([ + ] = await Promise.all([ prisma.project.findMany({ where: { companyId: company.id }, orderBy: { name: "asc" }, @@ -180,14 +205,14 @@ export async function GET(request: NextRequest) { }), ]) - const readyForReview = latestNotes + const readyForReview = latestNotes .filter((task) => task.status === Status.done && task.note?.trim()) .slice(0, sectionLimit) .map((task) => ({ ...serializeTask(task), reason: getReviewReason(task.name, task.note ?? ""), })) - const brief = { + const brief = { range: { key: rangeParam, label: rangeParam === "today" ? "Last 24 hours" : "Last 7 days", @@ -251,7 +276,13 @@ export async function GET(request: NextRequest) { }), } - const jsonResponse = NextResponse.json({ statusCode: 200, brief }) + return { statusCode: 200, brief } + } + ) + + const jsonResponse = NextResponse.json(value, { + headers: { "X-AgentBridge-Cache": cacheStatusHeader(cacheStatus) }, + }) jsonResponse.headers.set( "Server-Timing", `dashboard-brief;dur=${Date.now() - startedAt}` diff --git a/apps/web/app/api/internal/dashboard/summary/route.ts b/apps/web/app/api/internal/dashboard/summary/route.ts index 3d98d01..59b5a77 100644 --- a/apps/web/app/api/internal/dashboard/summary/route.ts +++ b/apps/web/app/api/internal/dashboard/summary/route.ts @@ -1,5 +1,12 @@ import { NextRequest, NextResponse } from "next/server" +import { + cacheJson, + cacheKey, + cacheStatusHeader, + cacheTtl, + getCompanyCacheVersion, +} from "@/lib/api/cache" import { badRequest, requireInternalSession } from "@/lib/api/internal" import { prisma } from "@/lib/prisma" @@ -23,26 +30,43 @@ export async function GET(request: NextRequest) { return badRequest("Company not found.") } - const [agents, projects, tasks] = await Promise.all([ - prisma.agent.count({ where: { companyId } }), - prisma.project.count({ where: { companyId } }), - prisma.task.groupBy({ - by: ["status"], - where: { project: { companyId } }, - _count: { _all: true }, - }), - ]) - - const taskCounts = Object.fromEntries( - tasks.map((task) => [task.status, task._count._all]) + const companyVersion = await getCompanyCacheVersion(companyId) + const { value, cacheStatus } = await cacheJson( + cacheKey([ + "internal", + "dashboard-summary", + session.userId, + companyId, + companyVersion, + ]), + cacheTtl.dashboardSummary, + async () => { + const [agents, projects, tasks] = await Promise.all([ + prisma.agent.count({ where: { companyId } }), + prisma.project.count({ where: { companyId } }), + prisma.task.groupBy({ + by: ["status"], + where: { project: { companyId } }, + _count: { _all: true }, + }), + ]) + + const taskCounts = Object.fromEntries( + tasks.map((task) => [task.status, task._count._all]) + ) + + return { + statusCode: 200, + summary: { + agents, + projects, + tasks: taskCounts, + }, + } + } ) - return NextResponse.json({ - statusCode: 200, - summary: { - agents, - projects, - tasks: taskCounts, - }, + return NextResponse.json(value, { + headers: { "X-AgentBridge-Cache": cacheStatusHeader(cacheStatus) }, }) } diff --git a/apps/web/app/api/internal/notes/[taskId]/read/route.ts b/apps/web/app/api/internal/notes/[taskId]/read/route.ts index 77ab0d4..07deb5c 100644 --- a/apps/web/app/api/internal/notes/[taskId]/read/route.ts +++ b/apps/web/app/api/internal/notes/[taskId]/read/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server" import { Status } from "@/generated/prisma/enums" +import { invalidateCompanyCache } from "@/lib/api/cache" import { notFound, requireInternalSession } from "@/lib/api/internal" import { findReviewReader } from "@/lib/api/review-reader" import { prisma } from "@/lib/prisma" @@ -70,6 +71,8 @@ export async function POST(_request: Request, { params }: RouteContext) { }) }) + await invalidateCompanyCache(task.project.companyId) + return NextResponse.json({ statusCode: 200, taskId: task.id, diff --git a/apps/web/app/api/internal/notes/route.ts b/apps/web/app/api/internal/notes/route.ts index 2d687c6..c920569 100644 --- a/apps/web/app/api/internal/notes/route.ts +++ b/apps/web/app/api/internal/notes/route.ts @@ -1,5 +1,12 @@ import { NextRequest, NextResponse } from "next/server" +import { + cacheJson, + cacheKey, + cacheStatusHeader, + cacheTtl, + getCompanyCacheVersion, +} from "@/lib/api/cache" import { badRequest, requireInternalSession } from "@/lib/api/internal" import { findReviewReader } from "@/lib/api/review-reader" import { prisma } from "@/lib/prisma" @@ -24,73 +31,84 @@ export async function GET(request: NextRequest) { return badRequest("Company not found.") } - const reviewReader = await findReviewReader(company.id) + const companyVersion = await getCompanyCacheVersion(company.id) + const { value, cacheStatus } = await cacheJson( + cacheKey(["internal", "notes", session.userId, company.id, companyVersion]), + cacheTtl.notes, + async () => { + const reviewReader = await findReviewReader(company.id) - if (!reviewReader) { - return NextResponse.json({ - statusCode: 200, - notes: [], - reviewReader: null, - }) - } + if (!reviewReader) { + return { + statusCode: 200, + notes: [], + reviewReader: null, + } + } - const notes = await prisma.task.findMany({ - where: { - archivedAt: null, - note: { not: null }, - status: "done", - project: { companyId: company.id }, - }, - orderBy: [{ summaryUpdatedAt: "desc" }, { name: "asc" }], - take: 100, - select: { - id: true, - name: true, - status: true, - note: true, - summaryUpdatedAt: true, - taskUpdatedAt: true, - assigned: { - select: { - id: true, - name: true, - position: true, + const notes = await prisma.task.findMany({ + where: { + archivedAt: null, + note: { not: null }, + status: "done", + project: { companyId: company.id }, }, - }, - project: { + orderBy: [{ summaryUpdatedAt: "desc" }, { name: "asc" }], + take: 100, select: { id: true, name: true, + status: true, + note: true, + summaryUpdatedAt: true, + taskUpdatedAt: true, + assigned: { + select: { + id: true, + name: true, + position: true, + }, + }, + project: { + select: { + id: true, + name: true, + }, + }, + readMarkers: { + where: { + status: "done", + agentId: reviewReader.id, + }, + select: { readAt: true }, + }, }, - }, - readMarkers: { - where: { - status: "done", - agentId: reviewReader.id, - }, - select: { readAt: true }, - }, - }, - }) - const unreadNotes = notes.filter((task) => { - const readAt = task.readMarkers[0]?.readAt + }) + const unreadNotes = notes.filter((task) => { + const readAt = task.readMarkers[0]?.readAt - const summaryUpdatedAt = task.summaryUpdatedAt ?? task.taskUpdatedAt + const summaryUpdatedAt = task.summaryUpdatedAt ?? task.taskUpdatedAt - return !readAt || readAt < summaryUpdatedAt - }) + return !readAt || readAt < summaryUpdatedAt + }) + + return { + statusCode: 200, + reviewReader, + notes: unreadNotes.map((task) => ({ + id: task.id, + name: task.name, + status: task.status, + note: task.note ?? "", + summaryUpdatedAt: (task.summaryUpdatedAt ?? task.taskUpdatedAt).toISOString(), + assigned: task.assigned, + project: task.project, + })), + } + } + ) - return NextResponse.json({ - statusCode: 200, - reviewReader, - notes: unreadNotes.map((task) => ({ - id: task.id, - name: task.name, - status: task.status, - note: task.note ?? "", - summaryUpdatedAt: (task.summaryUpdatedAt ?? task.taskUpdatedAt).toISOString(), - assigned: task.assigned, - project: task.project, - })), + return NextResponse.json(value, { + headers: { "X-AgentBridge-Cache": cacheStatusHeader(cacheStatus) }, }) } diff --git a/apps/web/app/api/internal/projects/[projectId]/route.ts b/apps/web/app/api/internal/projects/[projectId]/route.ts index 1ad34a8..156ece0 100644 --- a/apps/web/app/api/internal/projects/[projectId]/route.ts +++ b/apps/web/app/api/internal/projects/[projectId]/route.ts @@ -2,6 +2,15 @@ import { NextResponse } from "next/server" import { Status } from "@/generated/prisma/enums" import { createAuditLog, formatChangedFields } from "@/lib/api/audit-log" +import { + cacheJson, + cacheKey, + cacheStatusHeader, + cacheTtl, + getCompanyCacheVersion, + getProjectCacheVersion, + invalidateProjectAndCompanyCache, +} from "@/lib/api/cache" import { projectAgentSelect, serializeProjectAgents, @@ -30,7 +39,7 @@ export async function GET(_request: Request, { params }: RouteContext) { const { projectId } = await params const projectQueryTiming = startServerTiming("ab-project-shell", "project shell") - const project = await prisma.project.findFirst({ + const projectShell = await prisma.project.findFirst({ where: { id: projectId, company: { userId: session.userId }, @@ -64,179 +73,211 @@ export async function GET(_request: Request, { params }: RouteContext) { const projectTiming = formatServerTimingMetric(projectQueryTiming) - if (!project) { + if (!projectShell) { return notFound("Project not found.") } - const tasksTiming = startServerTiming("ab-project-tasks", "task cards") - const tasks = await prisma.task.findMany({ - where: { projectId: project.id, archivedAt: null }, - orderBy: { name: "asc" }, - select: { - id: true, - name: true, - note: true, - summaryUpdatedAt: true, - taskUpdatedAt: true, - taskUpdatedById: true, - taskUpdatedByName: true, - taskUpdatedByType: true, - status: true, - blockingReason: true, - readMarkers: { - where: { status: Status.done, agent: { AgentId: "main" } }, - select: { readAt: true }, - }, - assigned: { + const companyVersion = await getCompanyCacheVersion(projectShell.companyId) + const projectVersion = await getProjectCacheVersion(projectShell.id) + const cacheResult = await cacheJson( + cacheKey([ + "internal", + "project-detail", + session.userId, + projectShell.companyId, + projectShell.id, + companyVersion, + projectVersion, + ]), + cacheTtl.projectDetail, + async () => { + const project = projectShell + const tasksTiming = startServerTiming("ab-project-tasks", "task cards") + const tasks = await prisma.task.findMany({ + where: { projectId: project.id, archivedAt: null }, + orderBy: { name: "asc" }, select: { id: true, name: true, - position: true, - }, - }, - }, - }) - const taskIds = tasks.map((task) => task.id) - const tasksHeaderTiming = formatServerTimingMetric(tasksTiming) - - const metadataTiming = startServerTiming("ab-project-task-meta", "read/dependency meta") - const [readCounts, dependencyEdges] = taskIds.length - ? await Promise.all([ - prisma.taskReadMarker.groupBy({ - by: ["taskId", "status"], - where: { taskId: { in: taskIds } }, - _count: { _all: true }, - }), - prisma.taskDependency.findMany({ - where: { - OR: [ - { blockedTaskId: { in: taskIds } }, - { dependencyTaskId: { in: taskIds } }, - ], + note: true, + summaryUpdatedAt: true, + taskUpdatedAt: true, + taskUpdatedById: true, + taskUpdatedByName: true, + taskUpdatedByType: true, + status: true, + blockingReason: true, + readMarkers: { + where: { status: Status.done, agent: { AgentId: "main" } }, + select: { readAt: true }, }, - orderBy: { createdAt: "asc" }, - select: { - blockedTaskId: true, - dependencyTaskId: true, - blockedTask: { - select: { - id: true, - name: true, - status: true, - archivedAt: true, - }, + assigned: { + select: { + id: true, + name: true, + position: true, }, - dependencyTask: { + }, + }, + }) + const taskIds = tasks.map((task) => task.id) + const tasksHeaderTiming = formatServerTimingMetric(tasksTiming) + + const metadataTiming = startServerTiming( + "ab-project-task-meta", + "read/dependency meta" + ) + const [readCounts, dependencyEdges] = taskIds.length + ? await Promise.all([ + prisma.taskReadMarker.groupBy({ + by: ["taskId", "status"], + where: { taskId: { in: taskIds } }, + _count: { _all: true }, + }), + prisma.taskDependency.findMany({ + where: { + OR: [ + { blockedTaskId: { in: taskIds } }, + { dependencyTaskId: { in: taskIds } }, + ], + }, + orderBy: { createdAt: "asc" }, select: { - id: true, - name: true, - status: true, - archivedAt: true, + blockedTaskId: true, + dependencyTaskId: true, + blockedTask: { + select: { + id: true, + name: true, + status: true, + archivedAt: true, + }, + }, + dependencyTask: { + select: { + id: true, + name: true, + status: true, + archivedAt: true, + }, + }, }, - }, - }, - }), - ]) - : [[], []] - const metadataHeaderTiming = formatServerTimingMetric(metadataTiming) - - const readCountByTaskStatus = new Map() - for (const readCount of readCounts) { - readCountByTaskStatus.set( - `${readCount.taskId}:${readCount.status}`, - readCount._count._all - ) - } + }), + ]) + : [[], []] + const metadataHeaderTiming = formatServerTimingMetric(metadataTiming) + + const readCountByTaskStatus = new Map() + for (const readCount of readCounts) { + readCountByTaskStatus.set( + `${readCount.taskId}:${readCount.status}`, + readCount._count._all + ) + } + + const dependenciesByTask = new Map< + string, + Array<{ + dependencyTask: { + id: string + name: string + status: (typeof tasks)[number]["status"] + } + }> + >() + const unblocksByTask = new Map< + string, + Array<{ + blockedTask: { + id: string + name: string + status: (typeof tasks)[number]["status"] + } + }> + >() + for (const edge of dependencyEdges) { + if (edge.blockedTask.archivedAt || edge.dependencyTask.archivedAt) { + continue + } + + const blockedByDependencies = + dependenciesByTask.get(edge.blockedTaskId) ?? [] + blockedByDependencies.push({ dependencyTask: edge.dependencyTask }) + dependenciesByTask.set(edge.blockedTaskId, blockedByDependencies) - const dependenciesByTask = new Map< - string, - Array<{ - dependencyTask: { - id: string - name: string - status: (typeof tasks)[number]["status"] + const unblocksDependencies = + unblocksByTask.get(edge.dependencyTaskId) ?? [] + unblocksDependencies.push({ blockedTask: edge.blockedTask }) + unblocksByTask.set(edge.dependencyTaskId, unblocksDependencies) } - }> - >() - const unblocksByTask = new Map< - string, - Array<{ - blockedTask: { - id: string - name: string - status: (typeof tasks)[number]["status"] + + return { + body: { + statusCode: 200, + project: { + ...project, + projectAgents: serializeProjectAgents(project.agents), + agents: undefined, + tasks: tasks.map((task) => { + const { note, readMarkers, ...taskCard } = task + const dependencies = + dependenciesByTask + .get(task.id) + ?.map((dependency) => dependency.dependencyTask) ?? [] + const unblocks = + unblocksByTask + .get(task.id) + ?.map((dependency) => dependency.blockedTask) ?? [] + const doneReviewReadAt = readMarkers[0]?.readAt + + const notePreview = compactText(note) + const summaryUpdatedAt = getTaskSummaryUpdatedAt({ + note, + summaryUpdatedAt: task.summaryUpdatedAt, + taskUpdatedAt: task.taskUpdatedAt, + }) + + return { + ...taskCard, + notePreview, + summaryUpdatedAt, + blockingReason: null, + readCount: + readCountByTaskStatus.get(`${task.id}:${task.status}`) ?? 0, + blockingReasonPreview: compactText(task.blockingReason), + dependencies: dependencies.slice(0, 3), + dependencyIds: dependencies.map((dependency) => dependency.id), + dependencyCount: dependencies.length, + unblocks: unblocks.slice(0, 3), + unblocksCount: unblocks.length, + isDependencyReady: + dependencies.length > 0 && + dependencies.every( + (dependency) => dependency.status === Status.done + ), + isUnreadDoneSummary: + task.status === Status.done && + Boolean(summaryUpdatedAt) && + (!doneReviewReadAt || + new Date(doneReviewReadAt) < + new Date(summaryUpdatedAt ?? 0)), + } + }), + }, + }, + timings: [tasksHeaderTiming, metadataHeaderTiming], } - }> - >() - for (const edge of dependencyEdges) { - if (edge.blockedTask.archivedAt || edge.dependencyTask.archivedAt) continue - - const blockedByDependencies = - dependenciesByTask.get(edge.blockedTaskId) ?? [] - blockedByDependencies.push({ dependencyTask: edge.dependencyTask }) - dependenciesByTask.set(edge.blockedTaskId, blockedByDependencies) - - const unblocksDependencies = unblocksByTask.get(edge.dependencyTaskId) ?? [] - unblocksDependencies.push({ blockedTask: edge.blockedTask }) - unblocksByTask.set(edge.dependencyTaskId, unblocksDependencies) - } + } + ) - const jsonResponse = NextResponse.json({ - statusCode: 200, - project: { - ...project, - projectAgents: serializeProjectAgents(project.agents), - agents: undefined, - tasks: tasks.map((task) => { - const { note, readMarkers, ...taskCard } = task - const dependencies = - dependenciesByTask - .get(task.id) - ?.map((dependency) => dependency.dependencyTask) ?? [] - const unblocks = - unblocksByTask - .get(task.id) - ?.map((dependency) => dependency.blockedTask) ?? [] - const doneReviewReadAt = readMarkers[0]?.readAt - - const notePreview = compactText(note) - const summaryUpdatedAt = getTaskSummaryUpdatedAt({ - note, - summaryUpdatedAt: task.summaryUpdatedAt, - taskUpdatedAt: task.taskUpdatedAt, - }) - - return { - ...taskCard, - notePreview, - summaryUpdatedAt, - blockingReason: null, - readCount: - readCountByTaskStatus.get(`${task.id}:${task.status}`) ?? 0, - blockingReasonPreview: compactText(task.blockingReason), - dependencies: dependencies.slice(0, 3), - dependencyIds: dependencies.map((dependency) => dependency.id), - dependencyCount: dependencies.length, - unblocks: unblocks.slice(0, 3), - unblocksCount: unblocks.length, - isDependencyReady: - dependencies.length > 0 && - dependencies.every( - (dependency) => dependency.status === Status.done - ), - isUnreadDoneSummary: - task.status === Status.done && - Boolean(summaryUpdatedAt) && - (!doneReviewReadAt || - new Date(doneReviewReadAt) < new Date(summaryUpdatedAt ?? 0)), - } - }), + const jsonResponse = NextResponse.json(cacheResult.value.body, { + headers: { + "X-AgentBridge-Cache": cacheStatusHeader(cacheResult.cacheStatus), }, }) appendServerTiming(jsonResponse.headers, [ projectTiming, - tasksHeaderTiming, - metadataHeaderTiming, + ...cacheResult.value.timings, totalTiming, ]) @@ -284,6 +325,10 @@ export async function PATCH(request: Request, { params }: RouteContext) { project.name !== updatedProject.name && "name", ]), }) + await invalidateProjectAndCompanyCache({ + companyId: project.companyId, + projectId: project.id, + }) return NextResponse.json({ statusCode: 200, project: updatedProject }) } @@ -333,6 +378,10 @@ export async function DELETE(_request: Request, { params }: RouteContext) { actor: { type: "user", id: session.userId, name: session.username }, details: "Project deleted.", }) + await invalidateProjectAndCompanyCache({ + companyId: project.companyId, + projectId: project.id, + }) return NextResponse.json({ statusCode: 200, projectId: project.id }) } diff --git a/apps/web/app/api/internal/projects/route.ts b/apps/web/app/api/internal/projects/route.ts index 854d35f..f972c49 100644 --- a/apps/web/app/api/internal/projects/route.ts +++ b/apps/web/app/api/internal/projects/route.ts @@ -1,6 +1,14 @@ import { NextRequest, NextResponse } from "next/server" import { createAuditLog } from "@/lib/api/audit-log" +import { + cacheJson, + cacheKey, + cacheStatusHeader, + cacheTtl, + getCompanyCacheVersion, + invalidateCompanyCache, +} from "@/lib/api/cache" import { badRequest, requireInternalSession } from "@/lib/api/internal" import { prisma } from "@/lib/prisma" @@ -15,15 +23,32 @@ export async function GET(request: NextRequest) { return badRequest("Company is required.") } - const projects = await prisma.project.findMany({ - where: { - companyId, - company: { userId: session.userId }, - }, - orderBy: { name: "asc" }, + const company = await prisma.company.findFirst({ + where: { id: companyId, userId: session.userId }, + select: { id: true }, }) - return NextResponse.json({ statusCode: 200, projects }) + if (!company) { + return badRequest("Company not found.") + } + + const companyVersion = await getCompanyCacheVersion(companyId) + const { value, cacheStatus } = await cacheJson( + cacheKey(["internal", "projects", session.userId, companyId, companyVersion]), + cacheTtl.projectList, + async () => { + const projects = await prisma.project.findMany({ + where: { companyId }, + orderBy: { name: "asc" }, + }) + + return { statusCode: 200, projects } + } + ) + + return NextResponse.json(value, { + headers: { "X-AgentBridge-Cache": cacheStatusHeader(cacheStatus) }, + }) } export async function POST(request: Request) { @@ -68,6 +93,7 @@ export async function POST(request: Request) { actor: { type: "user", id: session.userId, name: session.username }, details: description ? "Project created with a description." : "Project created.", }) + await invalidateCompanyCache(companyId) return NextResponse.json({ statusCode: 201, project }, { status: 201 }) } diff --git a/apps/web/lib/api/audit-log.ts b/apps/web/lib/api/audit-log.ts index aed5c5e..14a14dc 100644 --- a/apps/web/lib/api/audit-log.ts +++ b/apps/web/lib/api/audit-log.ts @@ -1,3 +1,4 @@ +import { invalidateCompanyCache, invalidateProjectCache } from "@/lib/api/cache" import { prisma } from "@/lib/prisma" type AuditActor = { @@ -40,6 +41,13 @@ export async function createAuditLog({ details: details ?? null, }, }) + + await Promise.all([ + invalidateCompanyCache(companyId), + target.type === "project" && target.id + ? invalidateProjectCache(target.id) + : Promise.resolve(), + ]) } export function formatChangedFields(changes: Array) { diff --git a/apps/web/lib/api/cache.ts b/apps/web/lib/api/cache.ts new file mode 100644 index 0000000..b18bbbd --- /dev/null +++ b/apps/web/lib/api/cache.ts @@ -0,0 +1,128 @@ +import { Redis } from "@upstash/redis" + +const DEFAULT_CACHE_NAMESPACE = "agentbridge" + +const cacheRedis = + process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN + ? new Redis({ + url: process.env.UPSTASH_REDIS_REST_URL, + token: process.env.UPSTASH_REDIS_REST_TOKEN, + }) + : null + +export const cacheTtl = { + dashboardSummary: 20, + dashboardBrief: 20, + projectList: 30, + projectDetail: 15, + agentList: 30, + notes: 15, +} as const + +type CacheResult = { + value: T + cacheStatus: "hit" | "miss" | "disabled" +} + +export function isResponseCacheEnabled() { + return Boolean(cacheRedis) +} + +export async function getCachedJson(key: string): Promise { + if (!cacheRedis) return null + + return await cacheRedis.get(key) +} + +export async function setCachedJson( + key: string, + value: T, + ttlSeconds: number +) { + if (!cacheRedis) return + + await cacheRedis.set(key, value, { ex: ttlSeconds }) +} + +export async function cacheJson( + key: string, + ttlSeconds: number, + load: () => Promise +): Promise> { + if (!isResponseCacheEnabled()) { + return { value: await load(), cacheStatus: "disabled" } + } + + const cached = await getCachedJson(key) + + if (cached) { + return { value: cached, cacheStatus: "hit" } + } + + const value = await load() + await setCachedJson(key, value, ttlSeconds) + + return { value, cacheStatus: "miss" } +} + +export async function getCompanyCacheVersion(companyId: string) { + if (!cacheRedis) return "0" + + return String( + (await cacheRedis.get(companyVersionKey(companyId))) ?? + "0" + ) +} + +export async function getProjectCacheVersion(projectId: string) { + if (!cacheRedis) return "0" + + return String( + (await cacheRedis.get(projectVersionKey(projectId))) ?? + "0" + ) +} + +export async function invalidateCompanyCache(companyId: string) { + if (!cacheRedis) return + + await cacheRedis.incr(companyVersionKey(companyId)) +} + +export async function invalidateProjectCache(projectId: string) { + if (!cacheRedis) return + + await cacheRedis.incr(projectVersionKey(projectId)) +} + +export async function invalidateProjectAndCompanyCache({ + companyId, + projectId, +}: { + companyId: string + projectId?: string | null +}) { + await Promise.all([ + invalidateCompanyCache(companyId), + projectId ? invalidateProjectCache(projectId) : Promise.resolve(), + ]) +} + +export function cacheKey(parts: Array) { + return [ + DEFAULT_CACHE_NAMESPACE, + ...parts.map((part) => String(part ?? "none")), + ].join(":") +} + +export function cacheStatusHeader(cacheStatus: CacheResult["cacheStatus"]) { + return cacheStatus +} + +function companyVersionKey(companyId: string) { + return cacheKey(["v", "company", companyId]) +} + +function projectVersionKey(projectId: string) { + return cacheKey(["v", "project", projectId]) +} diff --git a/apps/web/package.json b/apps/web/package.json index ea82ff0..01ec5de 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,6 +16,7 @@ "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@tanstack/react-query": "^5.100.9", + "@upstash/redis": "^1.38.0", "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf03473..687da89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: '@tanstack/react-query': specifier: ^5.100.9 version: 5.100.9(react@19.2.6) + '@upstash/redis': + specifier: ^1.38.0 + version: 1.38.0 bcryptjs: specifier: ^3.0.3 version: 3.0.3 @@ -1971,6 +1974,9 @@ packages: cpu: [x64] os: [win32] + '@upstash/redis@1.38.0': + resolution: {integrity: sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -4297,6 +4303,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + undici-types@7.19.2: resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} @@ -6278,6 +6287,10 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@upstash/redis@1.38.0': + dependencies: + uncrypto: 0.1.3 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -6862,7 +6875,7 @@ snapshots: '@next/eslint-plugin-next': 16.1.7 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.7.0)) @@ -6885,7 +6898,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -6900,14 +6913,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) transitivePeerDependencies: - supports-color @@ -6922,7 +6935,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) hasown: 2.0.3 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -8851,6 +8864,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + uncrypto@0.1.3: {} + undici-types@7.19.2: {} unicorn-magic@0.3.0: {}