feat(server): add per-user document stars (#9952)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-27 19:13:35 -05:00 committed by GitHub
parent 3b0156238a
commit c111ee4cb3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 513 additions and 24 deletions

View File

@ -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");

View File

@ -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
}
]
}

View File

@ -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,
),
}),
);

View File

@ -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";

View File

@ -1320,7 +1320,9 @@ export {
} from "./validators/sidebar-preferences.js";
export {
resourceMembershipStateSchema,
updateDocumentResourceMembershipSchema,
updateResourceMembershipSchema,
type UpdateDocumentResourceMembership,
type UpdateResourceMembership,
} from "./validators/resource-memberships.js";
export {

View File

@ -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();
});
});

View File

@ -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<string, ResourceMembershipState>;
agentMemberships: Record<string, ResourceMembershipState>;
starredProjectIds?: string[];
starredAgentIds?: string[];
starredDocumentIds: string[];
projectStarredAt?: Record<string, Date>;
agentStarredAt?: Record<string, Date>;
documentStarredAt: Record<string, string>;
updatedAt: Date | null;
}

View File

@ -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()

View File

@ -93,7 +93,9 @@ export {
} from "./sidebar-preferences.js";
export {
resourceMembershipStateSchema,
updateDocumentResourceMembershipSchema,
updateResourceMembershipSchema,
type UpdateDocumentResourceMembership,
type UpdateResourceMembership,
} from "./resource-memberships.js";
export {

View File

@ -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<typeof updateResourceMembershipSchema>;
export type UpdateDocumentResourceMembership = z.infer<typeof updateDocumentResourceMembershipSchema>;

View File

@ -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.") });

View File

@ -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);
});
});

View File

@ -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) => {

View File

@ -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"],

View File

@ -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;
}

View File

@ -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<string, string>()) {
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<CompanyArtifactsQuery> = {},
options: { issueConditions?: SQL[] } = {},
options: { issueConditions?: SQL[]; userId?: string } = {},
): Promise<CompanyArtifactsResponse> => {
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<string, string>();
const workProductAttachmentIds = new Set<string>();
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<Date>`${documents.updatedAt}`, documentArtifactId, cursor);
const documentSortDate = query.starred
? sql<Date>`${documentMemberships.starredAt}`
: sql<Date>`${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<string | null>`coalesce(${createdAgent.id}, ${updatedAgent.id})`,
createdByAgentName: sql<string | null>`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<string>`concat('work_product:', ${issueWorkProducts.id})`;
const workProductContentType = sql<string>`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 };

View File

@ -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<ResourceMemberships> {
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<MembershipUpdateResult> {
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<boolean>`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",
};
},
};
}

View File

@ -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,
});
});

View File

@ -297,6 +297,8 @@ describe("SidebarProjects", () => {
memberships = {
projectMemberships: {},
agentMemberships: {},
starredDocumentIds: [],
documentStarredAt: {},
updatedAt: null,
};
mockResourceMembershipsApi.listMine.mockImplementation(() => Promise.resolve(memberships));

View File

@ -135,8 +135,10 @@ describe("SidebarStarredProjects", () => {
agentMemberships: {},
starredProjectIds: [],
starredAgentIds: [],
starredDocumentIds: [],
projectStarredAt: {},
agentStarredAt: {},
documentStarredAt: {},
updatedAt: null,
};
mockResourceMembershipsApi.listMine.mockImplementation(() => Promise.resolve(memberships));

View File

@ -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<ResourceMembershipResourceType, "document">;
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 : [];
}