From c111ee4cb3312077ce4df7b128002079345c083e Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:13:35 -0500 Subject: [PATCH] feat(server): add per-user document stars (#9952) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source control plane people use to manage AI-agent companies and their work. > - Artifacts and documents are first-class outputs, but users need a personal way to keep important documents easy to find. > - Existing resource memberships already model per-user starred projects and agents with company scoping and activity logging. > - Documents lacked the equivalent membership model, route, and artifact filtering behavior. > - The shared membership contract also needs to remain safe for existing UI project/agent mutation helpers when documents become a recognized resource type. > - This pull request extends the existing resource-membership system with per-user document stars and a starred artifacts view. > - The benefit is a company-scoped, idempotent server foundation for a dedicated starred-documents experience without weakening authorization or artifact filtering semantics. ## Linked Issues or Issue Description ### Problem / Motivation Board users cannot star individual documents, and the company artifacts API cannot return only the current user's starred documents. ### Proposed Solution Add company/user-scoped document memberships, a board-only document star route, document membership data in the shared contract, and a `starred=true` artifacts filter. ### Alternatives Considered A document column was rejected because stars are per-user; a separate star API was rejected because projects and agents already use resource memberships. ### Roadmap Alignment This extends the existing Artifacts & Work Products roadmap area and does not duplicate another open pull request found in the repository search. ## What Changed - Added the `document_memberships` schema and migration with company/user/document uniqueness and starred ordering. - Extended shared resource-membership and artifact-query contracts for documents and `starred=true`. - Added company-scoped document star/unstar service and board-only route behavior with activity logging. - Added starred document artifact filtering, including user-authored documents, document kinds, cursor ordering, and incompatible-kind handling. - Preserved idempotency under concurrent star requests and synchronized UI membership defaults/helpers with the expanded contract. - Added focused shared, route, service, and UI regression coverage. ## Verification - `pnpm exec vitest run packages/shared/src/resource-memberships.test.ts server/src/__tests__/company-artifacts-service.test.ts server/src/__tests__/resource-memberships-routes.test.ts` - `pnpm exec vitest run ui/src/components/SidebarAgents.test.tsx ui/src/components/SidebarProjects.test.tsx ui/src/components/SidebarStarredProjects.test.tsx` - `pnpm --filter @paperclipai/db check:migrations` - `pnpm -r typecheck` - `pnpm test:run` - `pnpm build` ## Risks - The migration adds a new membership table and non-concurrent indexes; migration safety gates pass with the repository's established policy. - The starred artifacts query intentionally returns only documents and relaxes the normal agent-authored/system-kind predicates for documents the current user explicitly starred. - Document membership mutations remain board-user-only; agent callers receive no document-star capability. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex CLI; runtime model ID and context-window size were not exposed to this session. Reasoning, repository tool use, code execution, and test execution were enabled. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../migrations/0193_document_memberships.sql | 15 ++ packages/db/src/migrations/meta/_journal.json | 7 + .../db/src/schema/document_memberships.ts | 28 ++++ packages/db/src/schema/index.ts | 1 + packages/shared/src/index.ts | 2 + .../shared/src/resource-memberships.test.ts | 12 +- .../shared/src/types/resource-memberships.ts | 4 +- packages/shared/src/validators/artifact.ts | 4 + packages/shared/src/validators/index.ts | 2 + .../src/validators/resource-memberships.ts | 5 + .../company-artifacts-service.test.ts | 99 +++++++++++++ .../resource-memberships-routes.test.ts | 87 +++++++++++- server/src/routes/companies.ts | 4 +- server/src/routes/openapi.ts | 10 ++ server/src/routes/resource-memberships.ts | 39 ++++- server/src/services/company-artifacts.ts | 52 +++++-- server/src/services/resource-memberships.ts | 134 +++++++++++++++++- ui/src/components/SidebarAgents.test.tsx | 8 ++ ui/src/components/SidebarProjects.test.tsx | 2 + .../SidebarStarredProjects.test.tsx | 2 + ui/src/hooks/useResourceMemberships.ts | 20 ++- 21 files changed, 513 insertions(+), 24 deletions(-) create mode 100644 packages/db/src/migrations/0193_document_memberships.sql create mode 100644 packages/db/src/schema/document_memberships.ts diff --git a/packages/db/src/migrations/0193_document_memberships.sql b/packages/db/src/migrations/0193_document_memberships.sql new file mode 100644 index 0000000000..5d941002dd --- /dev/null +++ b/packages/db/src/migrations/0193_document_memberships.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS "document_memberships" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL REFERENCES "companies"("id") ON DELETE CASCADE, + "document_id" uuid NOT NULL REFERENCES "documents"("id") ON DELETE CASCADE, + "user_id" text NOT NULL, + "starred_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint +-- paperclip:migration-safety-ignore large-create-index-not-concurrently: Drizzle migrations run transactionally, so CONCURRENTLY is unavailable; this index is created with the new empty membership table for user-scoped starred ordering. +CREATE INDEX IF NOT EXISTS "document_memberships_company_user_starred_idx" + ON "document_memberships" USING btree ("company_id", "user_id", "starred_at");--> statement-breakpoint +-- paperclip:migration-safety-ignore large-create-index-not-concurrently: Drizzle migrations run transactionally, so CONCURRENTLY is unavailable; this uniqueness index is created with the new empty membership table to make star upserts idempotent. +CREATE UNIQUE INDEX IF NOT EXISTS "document_memberships_company_user_document_uq" + ON "document_memberships" USING btree ("company_id", "user_id", "document_id"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index c99cd8db4e..ece0bc9b85 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1338,6 +1338,13 @@ "when": 1784916886226, "tag": "0192_task_watchdog_stop_snapshots", "breakpoints": true + }, + { + "idx": 193, + "version": "7", + "when": 1785170000000, + "tag": "0193_document_memberships", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/document_memberships.ts b/packages/db/src/schema/document_memberships.ts new file mode 100644 index 0000000000..49eb7e2d6e --- /dev/null +++ b/packages/db/src/schema/document_memberships.ts @@ -0,0 +1,28 @@ +import { pgTable, uuid, text, timestamp, uniqueIndex, index } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { documents } from "./documents.js"; + +export const documentMemberships = pgTable( + "document_memberships", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }), + userId: text("user_id").notNull(), + starredAt: timestamp("starred_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyUserStarredIdx: index("document_memberships_company_user_starred_idx").on( + table.companyId, + table.userId, + table.starredAt, + ), + companyUserDocumentUq: uniqueIndex("document_memberships_company_user_document_uq").on( + table.companyId, + table.userId, + table.documentId, + ), + }), +); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 3d5cbb0427..8e558866b7 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -25,6 +25,7 @@ export { agentTaskSessions } from "./agent_task_sessions.js"; export { agentWakeupRequests } from "./agent_wakeup_requests.js"; export { projects } from "./projects.js"; export { projectMemberships } from "./project_memberships.js"; +export { documentMemberships } from "./document_memberships.js"; export { projectWorkspaces } from "./project_workspaces.js"; export { executionWorkspaces } from "./execution_workspaces.js"; export { environments } from "./environments.js"; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1c0241ad4a..4f0bd73228 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1320,7 +1320,9 @@ export { } from "./validators/sidebar-preferences.js"; export { resourceMembershipStateSchema, + updateDocumentResourceMembershipSchema, updateResourceMembershipSchema, + type UpdateDocumentResourceMembership, type UpdateResourceMembership, } from "./validators/resource-memberships.js"; export { diff --git a/packages/shared/src/resource-memberships.test.ts b/packages/shared/src/resource-memberships.test.ts index 012c1db7f0..8b42316899 100644 --- a/packages/shared/src/resource-memberships.test.ts +++ b/packages/shared/src/resource-memberships.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { updateResourceMembershipSchema } from "./validators/resource-memberships.js"; +import { + updateDocumentResourceMembershipSchema, + updateResourceMembershipSchema, +} from "./validators/resource-memberships.js"; describe("resource membership contract", () => { it("accepts legacy state-only membership updates", () => { @@ -18,4 +21,11 @@ describe("resource membership contract", () => { "starred resources must be joined", ); }); + + it("accepts document star updates without join state", () => { + expect(updateDocumentResourceMembershipSchema.parse({ starred: true })).toEqual({ starred: true }); + expect(updateDocumentResourceMembershipSchema.parse({ starred: false })).toEqual({ starred: false }); + expect(() => updateDocumentResourceMembershipSchema.parse({ state: "joined", starred: true })).toThrow(); + expect(() => updateDocumentResourceMembershipSchema.parse({})).toThrow(); + }); }); diff --git a/packages/shared/src/types/resource-memberships.ts b/packages/shared/src/types/resource-memberships.ts index b965c96475..9eab6fad50 100644 --- a/packages/shared/src/types/resource-memberships.ts +++ b/packages/shared/src/types/resource-memberships.ts @@ -1,15 +1,17 @@ export const RESOURCE_MEMBERSHIP_STATES = ["joined", "left"] as const; export type ResourceMembershipState = (typeof RESOURCE_MEMBERSHIP_STATES)[number]; -export type ResourceMembershipResourceType = "project" | "agent"; +export type ResourceMembershipResourceType = "project" | "agent" | "document"; export interface ResourceMemberships { projectMemberships: Record; agentMemberships: Record; starredProjectIds?: string[]; starredAgentIds?: string[]; + starredDocumentIds: string[]; projectStarredAt?: Record; agentStarredAt?: Record; + documentStarredAt: Record; updatedAt: Date | null; } diff --git a/packages/shared/src/validators/artifact.ts b/packages/shared/src/validators/artifact.ts index 4d9f32b2c3..e8b14e8e8b 100644 --- a/packages/shared/src/validators/artifact.ts +++ b/packages/shared/src/validators/artifact.ts @@ -16,6 +16,10 @@ export const companyArtifactsQuerySchema = z.object({ q: z.string().trim().max(COMPANY_ARTIFACTS_MAX_QUERY_LENGTH).optional(), groupBy: companyArtifactGroupBySchema.optional().default("none"), groupIssueId: z.string().uuid().optional(), + starred: z.preprocess( + (value) => value === "true" ? true : value === "false" ? false : value, + z.boolean(), + ).optional().default(false), limit: z.coerce .number() .int() diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index d9c0195094..bd577033e2 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -93,7 +93,9 @@ export { } from "./sidebar-preferences.js"; export { resourceMembershipStateSchema, + updateDocumentResourceMembershipSchema, updateResourceMembershipSchema, + type UpdateDocumentResourceMembership, type UpdateResourceMembership, } from "./resource-memberships.js"; export { diff --git a/packages/shared/src/validators/resource-memberships.ts b/packages/shared/src/validators/resource-memberships.ts index 8f96b97683..b124ac8a8f 100644 --- a/packages/shared/src/validators/resource-memberships.ts +++ b/packages/shared/src/validators/resource-memberships.ts @@ -13,4 +13,9 @@ export const updateResourceMembershipSchema = z.object({ path: ["starred"], }); +export const updateDocumentResourceMembershipSchema = z.object({ + starred: z.boolean(), +}).strict(); + export type UpdateResourceMembership = z.infer; +export type UpdateDocumentResourceMembership = z.infer; diff --git a/server/src/__tests__/company-artifacts-service.test.ts b/server/src/__tests__/company-artifacts-service.test.ts index 174c3306d0..109d0e665d 100644 --- a/server/src/__tests__/company-artifacts-service.test.ts +++ b/server/src/__tests__/company-artifacts-service.test.ts @@ -8,6 +8,7 @@ import { assets, companies, createDb, + documentMemberships, documents, heartbeatRuns, issueAttachments, @@ -65,6 +66,7 @@ describeEmbeddedPostgres("companyArtifactsService", () => { }, 20_000); afterEach(async () => { + await db.delete(documentMemberships); await db.delete(issueWorkProducts); await db.delete(issueAttachments); await db.delete(assets); @@ -171,6 +173,14 @@ describeEmbeddedPostgres("companyArtifactsService", () => { createdByUserId: "user-1", updatedAt: new Date("2026-01-06T00:00:00.000Z"), }, + { + id: "20202020-2020-4020-8020-202020202020", + companyId, + title: "Plan", + latestBody: "Human-approved plan", + createdByUserId: "user-1", + updatedAt: new Date("2026-01-08T00:00:00.000Z"), + }, { id: "ffffffff-ffff-4fff-8fff-ffffffffffff", companyId: otherCompanyId, @@ -199,6 +209,12 @@ describeEmbeddedPostgres("companyArtifactsService", () => { documentId: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", key: "user-notes", }, + { + companyId, + issueId: secondIssueId, + documentId: "20202020-2020-4020-8020-202020202020", + key: "plan", + }, { companyId: otherCompanyId, issueId: otherIssueId, @@ -333,6 +349,89 @@ describeEmbeddedPostgres("companyArtifactsService", () => { return { companyId, otherCompanyId, projectId, issueId, secondIssueId, otherIssueId, otherRunId }; } + it("returns only the caller's starred documents, including plan and human-authored documents, with cursor order", async () => { + const { companyId } = await seedArtifacts(); + await db.insert(documentMemberships).values([ + { + companyId, + documentId: "20202020-2020-4020-8020-202020202020", + userId: "user-1", + starredAt: new Date("2026-02-03T00:00:00.000Z"), + }, + { + companyId, + documentId: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + userId: "user-1", + starredAt: new Date("2026-02-02T00:00:00.000Z"), + }, + { + companyId, + documentId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + userId: "other-user", + starredAt: new Date("2026-02-04T00:00:00.000Z"), + }, + ]); + + const app = express(); + app.use((req, _res, next) => { + req.actor = { + type: "board", + userId: "user-1", + source: "session", + companyIds: [companyId], + memberships: [{ companyId, membershipRole: "viewer", status: "active" }], + }; + next(); + }); + app.use("/api/companies", companyRoutes(db, createStorageService())); + app.use(errorHandler); + + const first = await request(app).get(`/api/companies/${companyId}/artifacts?starred=true&limit=1`); + expect(first.status).toBe(200); + expect(first.body.artifacts.map((artifact: { title: string }) => artifact.title)).toEqual(["Plan"]); + expect(first.body.nextCursor).toEqual(expect.any(String)); + + const second = await request(app).get( + `/api/companies/${companyId}/artifacts?starred=true&limit=10&cursor=${first.body.nextCursor}`, + ); + expect(second.status).toBe(200); + expect(second.body.artifacts.map((artifact: { title: string }) => artifact.title)).toEqual(["User Upload Notes"]); + expect(second.body.artifacts.every((artifact: { source: string }) => artifact.source === "document")).toBe(true); + + const incompatibleKind = await request(app).get( + `/api/companies/${companyId}/artifacts?starred=true&kind=image`, + ); + expect(incompatibleKind.status).toBe(200); + expect(incompatibleKind.body).toEqual({ artifacts: [], nextCursor: null }); + }); + + it("returns an empty starred view for agent callers", async () => { + const { companyId, otherRunId } = await seedArtifacts(); + await db.insert(documentMemberships).values({ + companyId, + documentId: "20202020-2020-4020-8020-202020202020", + userId: "user-1", + }); + const app = express(); + app.use((req, _res, next) => { + req.actor = { + type: "agent", + agentId: "33333333-3333-4333-8333-333333333333", + companyId, + runId: otherRunId, + source: "agent_key", + }; + next(); + }); + app.use("/api/companies", companyRoutes(db, createStorageService())); + app.use(errorHandler); + + const response = await request(app).get(`/api/companies/${companyId}/artifacts?starred=true`); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ artifacts: [], nextCursor: null }); + }); + it("projects agent-created documents, direct attachments, and work products while excluding noisy sources", async () => { const { companyId } = await seedArtifacts(); const storage = createStorageService({ "notes.txt": Buffer.from("Text file preview from an agent output.") }); diff --git a/server/src/__tests__/resource-memberships-routes.test.ts b/server/src/__tests__/resource-memberships-routes.test.ts index 649b5d9ac8..bd57ec2d81 100644 --- a/server/src/__tests__/resource-memberships-routes.test.ts +++ b/server/src/__tests__/resource-memberships-routes.test.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import express from "express"; +import { eq } from "drizzle-orm"; import request from "supertest"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { @@ -8,6 +9,8 @@ import { agents, companies, createDb, + documentMemberships, + documents, projectMemberships, projects, } from "@paperclipai/db"; @@ -62,8 +65,10 @@ describeEmbeddedPostgres("resource membership routes", () => { afterEach(async () => { await db.delete(activityLog); + await db.delete(documentMemberships); await db.delete(projectMemberships); await db.delete(agentMemberships); + await db.delete(documents); await db.delete(projects); await db.delete(agents); await db.delete(companies); @@ -82,6 +87,8 @@ describeEmbeddedPostgres("resource membership routes", () => { const agentId = randomUUID(); const otherAgentId = randomUUID(); const terminatedAgentId = randomUUID(); + const documentId = randomUUID(); + const otherDocumentId = randomUUID(); await db.insert(companies).values([ { id: companyId, @@ -96,6 +103,10 @@ describeEmbeddedPostgres("resource membership routes", () => { requireBoardApprovalForNewAgents: false, }, ]); + await db.insert(documents).values([ + { id: documentId, companyId, title: "Plan", latestBody: "Plan body", createdByUserId: "user-1" }, + { id: otherDocumentId, companyId: otherCompanyId, title: "Other", latestBody: "Other body" }, + ]); await db.insert(projects).values([ { id: projectId, companyId, name: "Growth", status: "in_progress" }, { id: archivedProjectId, companyId, name: "Archived", status: "completed", archivedAt: new Date() }, @@ -136,7 +147,17 @@ describeEmbeddedPostgres("resource membership routes", () => { permissions: {}, }, ]); - return { archivedProjectId, companyId, otherAgentId, otherProjectId, projectId, agentId, terminatedAgentId }; + return { + archivedProjectId, + companyId, + documentId, + otherAgentId, + otherDocumentId, + otherProjectId, + projectId, + agentId, + terminatedAgentId, + }; } it("defaults missing membership rows to joined", async () => { @@ -151,8 +172,10 @@ describeEmbeddedPostgres("resource membership routes", () => { agentMemberships: {}, starredProjectIds: [], starredAgentIds: [], + starredDocumentIds: [], projectStarredAt: {}, agentStarredAt: {}, + documentStarredAt: {}, updatedAt: null, }); }); @@ -401,4 +424,66 @@ describeEmbeddedPostgres("resource membership routes", () => { }), ).rejects.toMatchObject({ status: 403 }); }); + + it("stars and unstars documents idempotently and cascades on document deletion", async () => { + const { companyId, documentId } = await seed(); + const app = createApp(db, boardActor(companyId)); + + const [first, second] = await Promise.all([ + request(app) + .put(`/api/companies/${companyId}/resource-memberships/me/documents/${documentId}`) + .send({ starred: true }), + request(app) + .put(`/api/companies/${companyId}/resource-memberships/me/documents/${documentId}`) + .send({ starred: true }), + ]); + const list = await request(app).get(`/api/companies/${companyId}/resource-memberships/me`); + + expect(first.status).toBe(200); + expect(first.body).toMatchObject({ resourceType: "document", resourceId: documentId, starredAt: expect.any(String) }); + expect(second.status).toBe(200); + expect(second.body.starredAt).toBe(first.body.starredAt); + expect(list.body.starredDocumentIds).toEqual([documentId]); + expect(list.body.documentStarredAt[documentId]).toBe(first.body.starredAt); + await expect(db.select().from(documentMemberships)).resolves.toHaveLength(1); + + const unstars = await Promise.all([ + request(app) + .put(`/api/companies/${companyId}/resource-memberships/me/documents/${documentId}`) + .send({ starred: false }), + request(app) + .put(`/api/companies/${companyId}/resource-memberships/me/documents/${documentId}`) + .send({ starred: false }), + ]); + expect(unstars.map((response) => response.status)).toEqual([200, 200]); + await expect(db.select().from(documentMemberships)).resolves.toHaveLength(0); + + await request(app) + .put(`/api/companies/${companyId}/resource-memberships/me/documents/${documentId}`) + .send({ starred: true }) + .expect(200); + await db.delete(documents).where(eq(documents.id, documentId)); + await expect(db.select().from(documentMemberships)).resolves.toHaveLength(0); + + const activity = await db.select().from(activityLog); + expect(activity.map((entry) => entry.action)).toEqual([ + "resource_membership.starred", + "resource_membership.unstarred", + "resource_membership.starred", + ]); + expect(activity[0]).toMatchObject({ entityType: "document", entityId: documentId }); + }); + + it("returns identical 404s for cross-company documents", async () => { + const { companyId, otherDocumentId } = await seed(); + const app = createApp(db, boardActor(companyId)); + + const response = await request(app) + .put(`/api/companies/${companyId}/resource-memberships/me/documents/${otherDocumentId}`) + .send({ starred: true }); + + expect(response.status).toBe(404); + expect(response.body.error).toBe("Document not found"); + await expect(db.select().from(documentMemberships)).resolves.toHaveLength(0); + }); }); diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index 688662c1fc..ae660703a9 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -142,7 +142,9 @@ export function companyRoutes(db: Db, storage?: StorageService) { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const query = companyArtifactsQuerySchema.parse(req.query); - res.json(await artifacts.list(companyId, query)); + res.json(await artifacts.list(companyId, query, { + userId: query.starred && req.actor.type === "board" ? req.actor.userId : undefined, + })); }); router.get("/:companyId/timeline", async (req, res) => { diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 6db43c6761..35b3a3aec3 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -155,6 +155,7 @@ import { patchInstanceSettingsSchema, issueGraphLivenessAutoRecoveryRequestSchema, // Resource memberships + updateDocumentResourceMembershipSchema, updateResourceMembershipSchema, // Document annotations createDocumentAnnotationCommentSchema, @@ -734,6 +735,7 @@ const BOARD_ONLY_OPERATIONS = new Set([ "POST /api/bootstrap/claim", "GET /api/companies/{companyId}/resource-memberships/me", "PUT /api/companies/{companyId}/resource-memberships/me/agents/{agentId}", + "PUT /api/companies/{companyId}/resource-memberships/me/documents/{documentId}", "PUT /api/companies/{companyId}/resource-memberships/me/projects/{projectId}", "GET /api/companies/{companyId}/secret-provider-configs", "POST /api/companies/{companyId}/secret-provider-configs", @@ -5539,6 +5541,14 @@ registerCurrentRoute({ summary: "Get issue cost summary", }); +registerCurrentRoute({ + method: "put", + path: "/api/companies/{companyId}/resource-memberships/me/documents/{documentId}", + tags: ["resource-memberships"], + summary: "Star or unstar a document resource", + body: updateDocumentResourceMembershipSchema, +}); + for (const route of [ ["get", "/api/companies/{companyId}/resource-memberships/me", "List current user's resource memberships"], ["put", "/api/companies/{companyId}/resource-memberships/me/agents/{agentId}", "Join or leave an agent resource"], diff --git a/server/src/routes/resource-memberships.ts b/server/src/routes/resource-memberships.ts index 9212cdcf1f..05980b52ec 100644 --- a/server/src/routes/resource-memberships.ts +++ b/server/src/routes/resource-memberships.ts @@ -1,6 +1,9 @@ import { Router, type Request, type Response } from "express"; import type { Db } from "@paperclipai/db"; -import { updateResourceMembershipSchema } from "@paperclipai/shared"; +import { + updateDocumentResourceMembershipSchema, + updateResourceMembershipSchema, +} from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; import { getActorInfo } from "./authz.js"; import { logActivity, resourceMembershipService } from "../services/index.js"; @@ -19,7 +22,7 @@ async function logMembershipChange( input: { companyId: string; userId: string; - resourceType: "project" | "agent"; + resourceType: "project" | "agent" | "document"; resourceId: string; state: "joined" | "left"; starredAt: Date | null; @@ -127,5 +130,37 @@ export function resourceMembershipRoutes(db: Db) { }, ); + router.put( + "/companies/:companyId/resource-memberships/me/documents/:documentId", + validate(updateDocumentResourceMembershipSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const documentId = req.params.documentId as string; + const userId = requireBoardUserId(req, res); + if (!userId) return; + const result = await svc.updateDocument({ + companyId, + documentId, + userId, + starred: req.body.starred, + actor: req.actor, + }); + if (result.changed && result.changeKind) { + await logMembershipChange(db, req, { + companyId, + userId, + resourceType: "document", + resourceId: documentId, + state: result.state, + starredAt: result.starredAt, + changeKind: result.changeKind, + policySource: result.policySource, + }); + } + const { changed: _changed, changeKind: _changeKind, policySource: _policySource, ...response } = result; + res.json(response); + }, + ); + return router; } diff --git a/server/src/services/company-artifacts.ts b/server/src/services/company-artifacts.ts index 6c5f0ecae9..8b75a1015f 100644 --- a/server/src/services/company-artifacts.ts +++ b/server/src/services/company-artifacts.ts @@ -6,6 +6,7 @@ import { agents, assets, companies, + documentMemberships, documents, heartbeatRuns, issueAttachments, @@ -171,9 +172,10 @@ async function readTextAttachmentPreview( } } -function sortArtifacts(artifacts: CompanyArtifact[]) { +function sortArtifacts(artifacts: CompanyArtifact[], sortDates = new Map()) { return artifacts.sort((a, b) => { - const dateDiff = Date.parse(b.updatedAt) - Date.parse(a.updatedAt); + const dateDiff = Date.parse(sortDates.get(b.id) ?? b.updatedAt) + - Date.parse(sortDates.get(a.id) ?? a.updatedAt); if (dateDiff !== 0) return dateDiff; return b.id.localeCompare(a.id); }); @@ -318,7 +320,7 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { list: async ( companyId: string, rawQuery: Partial = {}, - options: { issueConditions?: SQL[] } = {}, + options: { issueConditions?: SQL[]; userId?: string } = {}, ): Promise => { const query = companyArtifactsQuerySchema.parse(rawQuery); const cursor = decodeCursor(query.cursor); @@ -330,6 +332,10 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { .then((rows) => rows[0] ?? null); if (!company) throw notFound("Company not found"); + if (query.starred && !options.userId) { + return { artifacts: [], nextCursor: null }; + } + const fetchLimit = Math.min(query.limit + 1, COMPANY_ARTIFACTS_MAX_LIMIT + 1); const sourceFetchLimit = groupBy ? GROUPED_ARTIFACT_FETCH_LIMIT : fetchLimit; const q = query.q ? `%${escapeLikePattern(query.q)}%` : null; @@ -339,6 +345,7 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { ...(options.issueConditions ?? []), ]; const artifacts: CompanyArtifact[] = []; + const artifactSortDates = new Map(); const workProductAttachmentIds = new Set(); if (query.kind === "all" || query.kind === "document") { @@ -348,11 +355,22 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { const documentConditions: SQL[] = [ eq(issueDocuments.companyId, companyId), eq(documents.companyId, companyId), - or(isNotNull(documents.createdByAgentId), isNotNull(documents.updatedByAgentId))!, - notInArray(issueDocuments.key, [...SYSTEM_ISSUE_DOCUMENT_KEYS]), ...issueConditions, + ...(query.starred + ? [ + eq(documentMemberships.companyId, companyId), + eq(documentMemberships.userId, options.userId!), + isNotNull(documentMemberships.starredAt), + ] + : [ + or(isNotNull(documents.createdByAgentId), isNotNull(documents.updatedByAgentId))!, + notInArray(issueDocuments.key, [...SYSTEM_ISSUE_DOCUMENT_KEYS]), + ]), ]; - const documentCursor = groupBy ? undefined : cursorCondition(sql`${documents.updatedAt}`, documentArtifactId, cursor); + const documentSortDate = query.starred + ? sql`${documentMemberships.starredAt}` + : sql`${documents.updatedAt}`; + const documentCursor = groupBy ? undefined : cursorCondition(documentSortDate, documentArtifactId, cursor); if (documentCursor) documentConditions.push(documentCursor); if (groupBy === "task" && query.groupIssueId) documentConditions.push(eq(issues.id, query.groupIssueId)); if (query.projectId) documentConditions.push(eq(issues.projectId, query.projectId)); @@ -380,6 +398,7 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { createdByAgentId: sql`coalesce(${createdAgent.id}, ${updatedAgent.id})`, createdByAgentName: sql`coalesce(${createdAgent.name}, ${updatedAgent.name})`, updatedAt: documents.updatedAt, + starredAt: documentMemberships.starredAt, }) .from(issueDocuments) .innerJoin( @@ -389,6 +408,14 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { eq(documents.companyId, issueDocuments.companyId), ), ) + .leftJoin( + documentMemberships, + and( + eq(documentMemberships.documentId, documents.id), + eq(documentMemberships.companyId, documents.companyId), + eq(documentMemberships.userId, options.userId ?? ""), + ), + ) .innerJoin( issues, and( @@ -418,11 +445,12 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { ), ) .where(and(...documentConditions)) - .orderBy(desc(documents.updatedAt), desc(documentArtifactId)); + .orderBy(desc(documentSortDate), desc(documentArtifactId)); const documentRows = await documentRowsQuery.limit(sourceFetchLimit); for (const row of documentRows) { const identifier = row.issueIdentifier ?? row.issueId; + artifactSortDates.set(row.artifactId, (row.starredAt ?? row.updatedAt).toISOString()); artifacts.push({ id: row.artifactId, source: "document", @@ -444,7 +472,7 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { } } - if (query.kind !== "document") { + if (!query.starred && query.kind !== "document") { const workProductAgent = alias(agents, "work_product_agent"); const workProductArtifactId = sql`concat('work_product:', ${issueWorkProducts.id})`; const workProductContentType = sql`coalesce(${issueWorkProducts.metadata}->>'contentType', '')`; @@ -692,11 +720,15 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { artifacts.push(...attachmentArtifacts.filter((artifact): artifact is CompanyArtifact => artifact !== null)); } - const sorted = sortArtifacts(artifacts); + const sorted = sortArtifacts(artifacts, artifactSortDates); if (!groupBy) { const page = sorted.slice(0, query.limit); + const last = page[page.length - 1]; const nextCursor = sorted.length > query.limit - ? encodeCursor({ id: page[page.length - 1]?.id ?? "", updatedAt: page[page.length - 1]?.updatedAt ?? new Date(0).toISOString() }) + ? encodeCursor({ + id: last?.id ?? "", + updatedAt: last ? artifactSortDates.get(last.id) ?? last.updatedAt : new Date(0).toISOString(), + }) : null; return { artifacts: page, nextCursor }; diff --git a/server/src/services/resource-memberships.ts b/server/src/services/resource-memberships.ts index 0e74123bb5..54d02efe2e 100644 --- a/server/src/services/resource-memberships.ts +++ b/server/src/services/resource-memberships.ts @@ -1,8 +1,10 @@ -import { and, eq } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agentMemberships, agents, + documentMemberships, + documents, projectMemberships, projects, } from "@paperclipai/db"; @@ -173,7 +175,7 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer return { async listForUser(companyId: string, userId: string, actor: BoardActor): Promise { assertBoardSelfMembershipAccess(actor, companyId, userId); - const [projectRows, agentRows] = await Promise.all([ + const [projectRows, agentRows, documentRows] = await Promise.all([ db .select({ projectId: projectMemberships.projectId, @@ -208,19 +210,42 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer eq(agentMemberships.companyId, companyId), eq(agentMemberships.userId, userId), )), + db + .select({ + documentId: documentMemberships.documentId, + starredAt: documentMemberships.starredAt, + updatedAt: documentMemberships.updatedAt, + }) + .from(documentMemberships) + .innerJoin(documents, and( + eq(documents.id, documentMemberships.documentId), + eq(documents.companyId, documentMemberships.companyId), + )) + .where(and( + eq(documentMemberships.companyId, companyId), + eq(documentMemberships.userId, userId), + )), ]); const starEligibleProjectRows = projectRows.filter((row) => row.starredAt && !row.projectArchivedAt); const starEligibleAgentRows = agentRows.filter((row) => row.starredAt && row.agentStatus !== "terminated"); + const starredDocumentRows = documentRows + .filter((row): row is typeof row & { starredAt: Date } => row.starredAt !== null) + .sort((a, b) => b.starredAt.getTime() - a.starredAt.getTime()); return { projectMemberships: defaultJoinedMap(projectRows, "projectId"), agentMemberships: defaultJoinedMap(agentRows, "agentId"), starredProjectIds: starredIds(starEligibleProjectRows, "projectId"), starredAgentIds: starredIds(starEligibleAgentRows, "agentId"), + starredDocumentIds: starredDocumentRows.map((row) => row.documentId), projectStarredAt: starredAtMap(starEligibleProjectRows, "projectId"), agentStarredAt: starredAtMap(starEligibleAgentRows, "agentId"), + documentStarredAt: Object.fromEntries( + starredDocumentRows.map((row) => [row.documentId, row.starredAt.toISOString()]), + ), updatedAt: latestDate( ...projectRows.map((row) => row.updatedAt), ...agentRows.map((row) => row.updatedAt), + ...documentRows.map((row) => row.updatedAt), ), }; }, @@ -410,5 +435,110 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer policySource: decision.source ?? "oss_default", }; }, + + async updateDocument(input: { + companyId: string; + userId: string; + documentId: string; + starred: boolean; + actor: BoardActor; + }): Promise { + const document = await db.query.documents.findFirst({ + where: eq(documents.id, input.documentId), + }); + if (!document || document.companyId !== input.companyId) throw notFound("Document not found"); + + const decision = await assertMutationAllowed({ + actor: input.actor, + companyId: input.companyId, + userId: input.userId, + resourceType: "document", + resourceId: input.documentId, + state: "joined", + starred: input.starred, + }); + const existing = await db.query.documentMemberships.findFirst({ + where: and( + eq(documentMemberships.companyId, input.companyId), + eq(documentMemberships.userId, input.userId), + eq(documentMemberships.documentId, input.documentId), + ), + }); + + if (input.starred) { + if (existing?.starredAt) { + return { + resourceType: "document", + resourceId: input.documentId, + state: "joined", + starredAt: existing.starredAt, + updatedAt: existing.updatedAt, + changed: false, + changeKind: null, + policySource: decision.source ?? "oss_default", + }; + } + const now = new Date(); + const [row] = await db + .insert(documentMemberships) + .values({ + companyId: input.companyId, + documentId: input.documentId, + userId: input.userId, + starredAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [documentMemberships.companyId, documentMemberships.userId, documentMemberships.documentId], + set: { + starredAt: sql`${documentMemberships.starredAt}`, + updatedAt: sql`${documentMemberships.updatedAt}`, + }, + }) + .returning({ + starredAt: documentMemberships.starredAt, + updatedAt: documentMemberships.updatedAt, + inserted: sql`xmax = 0`, + }); + return { + resourceType: "document", + resourceId: input.documentId, + state: "joined", + starredAt: row?.starredAt ?? now, + updatedAt: row?.updatedAt ?? now, + changed: row?.inserted === true, + changeKind: row?.inserted === true ? "starred" : null, + policySource: decision.source ?? "oss_default", + }; + } + + if (!existing) { + return { + resourceType: "document", + resourceId: input.documentId, + state: "joined", + starredAt: null, + updatedAt: new Date(), + changed: false, + changeKind: null, + policySource: decision.source ?? "oss_default", + }; + } + const [deleted] = await db + .delete(documentMemberships) + .where(eq(documentMemberships.id, existing.id)) + .returning({ id: documentMemberships.id }); + const changed = deleted !== undefined; + return { + resourceType: "document", + resourceId: input.documentId, + state: "joined", + starredAt: null, + updatedAt: new Date(), + changed, + changeKind: changed ? "unstarred" : null, + policySource: decision.source ?? "oss_default", + }; + }, }; } diff --git a/ui/src/components/SidebarAgents.test.tsx b/ui/src/components/SidebarAgents.test.tsx index 3ee1aeea3b..89941d58f8 100644 --- a/ui/src/components/SidebarAgents.test.tsx +++ b/ui/src/components/SidebarAgents.test.tsx @@ -230,6 +230,8 @@ describe("SidebarAgents", () => { memberships = { projectMemberships: {}, agentMemberships: {}, + starredDocumentIds: [], + documentStarredAt: {}, updatedAt: null, }; mockResourceMembershipsApi.listMine.mockImplementation(() => Promise.resolve(memberships)); @@ -368,8 +370,10 @@ describe("SidebarAgents", () => { agentMemberships: {}, starredProjectIds: [], starredAgentIds: ["agent-b"], + starredDocumentIds: [], projectStarredAt: {}, agentStarredAt: {}, + documentStarredAt: {}, updatedAt: new Date(), }; @@ -417,8 +421,10 @@ describe("SidebarAgents", () => { agentMemberships: { "agent-b": "joined" }, starredProjectIds: [], starredAgentIds: ["agent-b"], + starredDocumentIds: [], projectStarredAt: {}, agentStarredAt: {}, + documentStarredAt: {}, updatedAt: new Date(), }; mockResourceMembershipsApi.updateAgent.mockRejectedValue(new Error("nope")); @@ -544,6 +550,8 @@ describe("SidebarAgents", () => { resolveMemberships({ projectMemberships: {}, agentMemberships: { "agent-1": "left" }, + starredDocumentIds: [], + documentStarredAt: {}, updatedAt: null, }); }); diff --git a/ui/src/components/SidebarProjects.test.tsx b/ui/src/components/SidebarProjects.test.tsx index 0289a14349..abe3ac11d6 100644 --- a/ui/src/components/SidebarProjects.test.tsx +++ b/ui/src/components/SidebarProjects.test.tsx @@ -297,6 +297,8 @@ describe("SidebarProjects", () => { memberships = { projectMemberships: {}, agentMemberships: {}, + starredDocumentIds: [], + documentStarredAt: {}, updatedAt: null, }; mockResourceMembershipsApi.listMine.mockImplementation(() => Promise.resolve(memberships)); diff --git a/ui/src/components/SidebarStarredProjects.test.tsx b/ui/src/components/SidebarStarredProjects.test.tsx index 8b58d468c2..1a64f1cdea 100644 --- a/ui/src/components/SidebarStarredProjects.test.tsx +++ b/ui/src/components/SidebarStarredProjects.test.tsx @@ -135,8 +135,10 @@ describe("SidebarStarredProjects", () => { agentMemberships: {}, starredProjectIds: [], starredAgentIds: [], + starredDocumentIds: [], projectStarredAt: {}, agentStarredAt: {}, + documentStarredAt: {}, updatedAt: null, }; mockResourceMembershipsApi.listMine.mockImplementation(() => Promise.resolve(memberships)); diff --git a/ui/src/hooks/useResourceMemberships.ts b/ui/src/hooks/useResourceMemberships.ts index c20d06cd55..db0d2a8c93 100644 --- a/ui/src/hooks/useResourceMemberships.ts +++ b/ui/src/hooks/useResourceMemberships.ts @@ -9,7 +9,7 @@ import { useToastActions } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; type MutationVariables = { - resourceType: ResourceMembershipResourceType; + resourceType: JoinableResourceType; resourceId: string; resourceName: string; /** Join / leave transition. Omit to only change the starred flag. */ @@ -18,19 +18,23 @@ type MutationVariables = { starred?: boolean; }; +type JoinableResourceType = Exclude; + function emptyMemberships(): ResourceMemberships { return { projectMemberships: {}, agentMemberships: {}, starredProjectIds: [], starredAgentIds: [], + starredDocumentIds: [], projectStarredAt: {}, agentStarredAt: {}, + documentStarredAt: {}, updatedAt: null, }; } -function starKeys(resourceType: ResourceMembershipResourceType) { +function starKeys(resourceType: JoinableResourceType) { return resourceType === "project" ? { ids: "starredProjectIds", at: "projectStarredAt", state: "projectMemberships" } : { ids: "starredAgentIds", at: "agentStarredAt", state: "agentMemberships" }; @@ -45,7 +49,7 @@ function starKeys(resourceType: ResourceMembershipResourceType) { */ function applyMembershipChange( current: ResourceMemberships | undefined, - resourceType: ResourceMembershipResourceType, + resourceType: JoinableResourceType, resourceId: string, change: { state?: ResourceMembershipState; starred?: boolean }, ): ResourceMemberships { @@ -96,7 +100,7 @@ function applyMembershipChange( export function resourceMembershipState( memberships: ResourceMemberships | undefined, - resourceType: ResourceMembershipResourceType, + resourceType: JoinableResourceType, resourceId: string, ): ResourceMembershipState { const state = resourceType === "project" @@ -113,7 +117,9 @@ export function isStarred( ): boolean { const ids = resourceType === "project" ? memberships?.starredProjectIds - : memberships?.starredAgentIds; + : resourceType === "agent" + ? memberships?.starredAgentIds + : memberships?.starredDocumentIds; return Array.isArray(ids) && ids.includes(resourceId); } @@ -124,7 +130,9 @@ export function starredResourceIds( ): string[] { const ids = resourceType === "project" ? memberships?.starredProjectIds - : memberships?.starredAgentIds; + : resourceType === "agent" + ? memberships?.starredAgentIds + : memberships?.starredDocumentIds; return Array.isArray(ids) ? ids : []; }