mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
feat(server): add per-user document stars
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -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";
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,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 };
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user