Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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=""
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
56 changes: 41 additions & 15 deletions apps/web/app/api/internal/agents/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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) {
Expand Down Expand Up @@ -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 })
}
55 changes: 43 additions & 12 deletions apps/web/app/api/internal/dashboard/brief/route.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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,
Expand All @@ -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 },
Expand All @@ -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,
Expand All @@ -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" },
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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}`
Expand Down
62 changes: 43 additions & 19 deletions apps/web/app/api/internal/dashboard/summary/route.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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) },
})
}
3 changes: 3 additions & 0 deletions apps/web/app/api/internal/notes/[taskId]/read/route.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading