feat(server): add per-user document stars

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-21 13:43:16 +00:00
1 parent 59eee4829c
commit 891c33f48c
16 files changed
+460 -18

No files matched your search

@@ -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");
@@ -1261,6 +1261,13 @@
"when": 1784231633059,
"tag": "0181_decision_training_retention_policy",
"breakpoints": true
},
{
"idx": 182,
"version": "7",
"when": 1784640927793,
"tag": "0182_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,
),
}),
);
+1
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";
+2
View File
@@ -1305,7 +1305,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()
+2
View File
@@ -91,7 +91,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,83 @@ 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);
});
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,63 @@ 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 = await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/documents/${documentId}`)
.send({ starred: true });
const second = await 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);
await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/documents/${documentId}`)
.send({ starred: false })
.expect(200);
await request(app)
.put(`/api/companies/${companyId}/resource-memberships/me/documents/${documentId}`)
.send({ starred: false })
.expect(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);
});
});
+3 -1
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) => {
+37 -2
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;
}
+43 -11
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,20 +345,32 @@ 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") {
if (query.starred || query.kind === "all" || query.kind === "document") {
const createdAgent = alias(agents, "document_created_agent");
const updatedAgent = alias(agents, "document_updated_agent");
const documentArtifactId = sql<string>`concat('document:', ${documents.id})`;
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 };
+123 -1
View File
@@ -3,6 +3,8 @@ 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,102 @@ 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: now,
updatedAt: now,
},
})
.returning();
return {
resourceType: "document",
resourceId: input.documentId,
state: "joined",
starredAt: row?.starredAt ?? now,
updatedAt: row?.updatedAt ?? now,
changed: true,
changeKind: "starred",
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",
};
}
await db.delete(documentMemberships).where(eq(documentMemberships.id, existing.id));
return {
resourceType: "document",
resourceId: input.documentId,
state: "joined",
starredAt: null,
updatedAt: new Date(),
changed: true,
changeKind: "unstarred",
policySource: decision.source ?? "oss_default",
};
},
};
}