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:
parent
3b0156238a
commit
c111ee4cb3
|
|
@ -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");
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -1320,7 +1320,9 @@ export {
|
|||
} from "./validators/sidebar-preferences.js";
|
||||
export {
|
||||
resourceMembershipStateSchema,
|
||||
updateDocumentResourceMembershipSchema,
|
||||
updateResourceMembershipSchema,
|
||||
type UpdateDocumentResourceMembership,
|
||||
type UpdateResourceMembership,
|
||||
} from "./validators/resource-memberships.js";
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -93,7 +93,9 @@ export {
|
|||
} from "./sidebar-preferences.js";
|
||||
export {
|
||||
resourceMembershipStateSchema,
|
||||
updateDocumentResourceMembershipSchema,
|
||||
updateResourceMembershipSchema,
|
||||
type UpdateDocumentResourceMembership,
|
||||
type UpdateResourceMembership,
|
||||
} from "./resource-memberships.js";
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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.") });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -297,6 +297,8 @@ describe("SidebarProjects", () => {
|
|||
memberships = {
|
||||
projectMemberships: {},
|
||||
agentMemberships: {},
|
||||
starredDocumentIds: [],
|
||||
documentStarredAt: {},
|
||||
updatedAt: null,
|
||||
};
|
||||
mockResourceMembershipsApi.listMine.mockImplementation(() => Promise.resolve(memberships));
|
||||
|
|
|
|||
|
|
@ -135,8 +135,10 @@ describe("SidebarStarredProjects", () => {
|
|||
agentMemberships: {},
|
||||
starredProjectIds: [],
|
||||
starredAgentIds: [],
|
||||
starredDocumentIds: [],
|
||||
projectStarredAt: {},
|
||||
agentStarredAt: {},
|
||||
documentStarredAt: {},
|
||||
updatedAt: null,
|
||||
};
|
||||
mockResourceMembershipsApi.listMine.mockImplementation(() => Promise.resolve(memberships));
|
||||
|
|
|
|||
|
|
@ -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 : [];
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue