mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
Persist sidebar order preferences
This commit is contained in:
1 parent
84f0ffb9dd
commit
4694a15217
26 files changed
+14004
-145
No files matched your search
@@ -0,0 +1,166 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { sidebarPreferenceRoutes } from "../routes/sidebar-preferences.js";
|
||||
|
||||
const mockSidebarPreferenceService = vi.hoisted(() => ({
|
||||
getCompanyOrder: vi.fn(),
|
||||
upsertCompanyOrder: vi.fn(),
|
||||
getProjectOrder: vi.fn(),
|
||||
upsertProjectOrder: vi.fn(),
|
||||
}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
sidebarPreferenceService: () => mockSidebarPreferenceService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
function createApp(actor: Record<string, unknown>) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = actor as never;
|
||||
next();
|
||||
});
|
||||
app.use("/api", sidebarPreferenceRoutes({} as never));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
const ORDERED_IDS = [
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
"22222222-2222-4222-8222-222222222222",
|
||||
];
|
||||
|
||||
describe("sidebar preference routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSidebarPreferenceService.getCompanyOrder.mockResolvedValue({
|
||||
orderedIds: ORDERED_IDS,
|
||||
updatedAt: null,
|
||||
});
|
||||
mockSidebarPreferenceService.upsertCompanyOrder.mockResolvedValue({
|
||||
orderedIds: ORDERED_IDS,
|
||||
updatedAt: null,
|
||||
});
|
||||
mockSidebarPreferenceService.getProjectOrder.mockResolvedValue({
|
||||
orderedIds: ORDERED_IDS,
|
||||
updatedAt: null,
|
||||
});
|
||||
mockSidebarPreferenceService.upsertProjectOrder.mockResolvedValue({
|
||||
orderedIds: ORDERED_IDS,
|
||||
updatedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns company rail order for board users", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["company-1"],
|
||||
});
|
||||
|
||||
const res = await request(app).get("/api/sidebar-preferences/me");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
orderedIds: ORDERED_IDS,
|
||||
updatedAt: null,
|
||||
});
|
||||
expect(mockSidebarPreferenceService.getCompanyOrder).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("updates company rail order for board users", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: ["company-1"],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.put("/api/sidebar-preferences/me")
|
||||
.send({ orderedIds: ORDERED_IDS });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockSidebarPreferenceService.upsertCompanyOrder).toHaveBeenCalledWith("user-1", ORDERED_IDS);
|
||||
});
|
||||
|
||||
it("returns project order for companies the board user can access", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["company-1"],
|
||||
});
|
||||
|
||||
const res = await request(app).get("/api/companies/company-1/sidebar-preferences/me");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockSidebarPreferenceService.getProjectOrder).toHaveBeenCalledWith("company-1", "user-1");
|
||||
});
|
||||
|
||||
it("logs project order updates for company-scoped writes", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["company-1"],
|
||||
runId: "run-1",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.put("/api/companies/company-1/sidebar-preferences/me")
|
||||
.send({ orderedIds: ORDERED_IDS });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockSidebarPreferenceService.upsertProjectOrder).toHaveBeenCalledWith("company-1", "user-1", ORDERED_IDS);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
{} as never,
|
||||
expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
action: "sidebar_preferences.project_order_updated",
|
||||
details: expect.objectContaining({
|
||||
userId: "user-1",
|
||||
orderedIds: ORDERED_IDS,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects company-scoped reads when the board user lacks company access", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["company-2"],
|
||||
});
|
||||
|
||||
const res = await request(app).get("/api/companies/company-1/sidebar-preferences/me");
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockSidebarPreferenceService.getProjectOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects agent callers", async () => {
|
||||
const app = createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
});
|
||||
|
||||
const res = await request(app).get("/api/sidebar-preferences/me");
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockSidebarPreferenceService.getCompanyOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import { costRoutes } from "./routes/costs.js";
|
||||
import { activityRoutes } from "./routes/activity.js";
|
||||
import { dashboardRoutes } from "./routes/dashboard.js";
|
||||
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
||||
import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js";
|
||||
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
|
||||
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
|
||||
import { llmRoutes } from "./routes/llms.js";
|
||||
@@ -167,6 +168,7 @@ export async function createApp(
|
||||
api.use(activityRoutes(db));
|
||||
api.use(dashboardRoutes(db));
|
||||
api.use(sidebarBadgeRoutes(db));
|
||||
api.use(sidebarPreferenceRoutes(db));
|
||||
api.use(inboxDismissalRoutes(db));
|
||||
api.use(instanceSettingsRoutes(db));
|
||||
const hostServicesDisposers = new Map<string, () => void>();
|
||||
|
||||
@@ -12,6 +12,7 @@ export { costRoutes } from "./costs.js";
|
||||
export { activityRoutes } from "./activity.js";
|
||||
export { dashboardRoutes } from "./dashboard.js";
|
||||
export { sidebarBadgeRoutes } from "./sidebar-badges.js";
|
||||
export { sidebarPreferenceRoutes } from "./sidebar-preferences.js";
|
||||
export { inboxDismissalRoutes } from "./inbox-dismissals.js";
|
||||
export { llmRoutes } from "./llms.js";
|
||||
export { accessRoutes } from "./access.js";
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { upsertSidebarOrderPreferenceSchema } from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { logActivity, sidebarPreferenceService } from "../services/index.js";
|
||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
|
||||
function requireBoardUserId(req: Request, res: Response): string | null {
|
||||
assertBoard(req);
|
||||
if (!req.actor.userId) {
|
||||
res.status(403).json({ error: "Board user context required" });
|
||||
return null;
|
||||
}
|
||||
return req.actor.userId;
|
||||
}
|
||||
|
||||
export function sidebarPreferenceRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = sidebarPreferenceService(db);
|
||||
|
||||
router.get("/sidebar-preferences/me", async (req, res) => {
|
||||
const userId = requireBoardUserId(req, res);
|
||||
if (!userId) return;
|
||||
res.json(await svc.getCompanyOrder(userId));
|
||||
});
|
||||
|
||||
router.put("/sidebar-preferences/me", validate(upsertSidebarOrderPreferenceSchema), async (req, res) => {
|
||||
const userId = requireBoardUserId(req, res);
|
||||
if (!userId) return;
|
||||
res.json(await svc.upsertCompanyOrder(userId, req.body.orderedIds));
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/sidebar-preferences/me", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const userId = requireBoardUserId(req, res);
|
||||
if (!userId) return;
|
||||
res.json(await svc.getProjectOrder(companyId, userId));
|
||||
});
|
||||
|
||||
router.put(
|
||||
"/companies/:companyId/sidebar-preferences/me",
|
||||
validate(upsertSidebarOrderPreferenceSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const userId = requireBoardUserId(req, res);
|
||||
if (!userId) return;
|
||||
|
||||
const result = await svc.upsertProjectOrder(companyId, userId, req.body.orderedIds);
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "sidebar_preferences.project_order_updated",
|
||||
entityType: "company",
|
||||
entityId: companyId,
|
||||
details: {
|
||||
userId,
|
||||
orderedIds: result.orderedIds,
|
||||
},
|
||||
});
|
||||
res.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export { financeService } from "./finance.js";
|
||||
export { heartbeatService } from "./heartbeat.js";
|
||||
export { dashboardService } from "./dashboard.js";
|
||||
export { sidebarBadgeService } from "./sidebar-badges.js";
|
||||
export { sidebarPreferenceService } from "./sidebar-preferences.js";
|
||||
export { inboxDismissalService } from "./inbox-dismissals.js";
|
||||
export { accessService } from "./access.js";
|
||||
export { boardAuthService } from "./board-auth.js";
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
companyUserSidebarPreferences,
|
||||
userSidebarPreferences,
|
||||
} from "@paperclipai/db";
|
||||
import type { SidebarOrderPreference } from "@paperclipai/shared";
|
||||
|
||||
function normalizeOrderedIds(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
|
||||
const orderedIds: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of value) {
|
||||
if (typeof item !== "string") continue;
|
||||
const trimmed = item.trim();
|
||||
if (!trimmed || seen.has(trimmed)) continue;
|
||||
seen.add(trimmed);
|
||||
orderedIds.push(trimmed);
|
||||
}
|
||||
return orderedIds;
|
||||
}
|
||||
|
||||
function toPreference(orderedIds: unknown, updatedAt: Date | null): SidebarOrderPreference {
|
||||
return {
|
||||
orderedIds: normalizeOrderedIds(orderedIds),
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function sidebarPreferenceService(db: Db) {
|
||||
return {
|
||||
async getCompanyOrder(userId: string): Promise<SidebarOrderPreference> {
|
||||
const row = await db.query.userSidebarPreferences.findFirst({
|
||||
where: eq(userSidebarPreferences.userId, userId),
|
||||
});
|
||||
return toPreference(row?.companyOrder ?? [], row?.updatedAt ?? null);
|
||||
},
|
||||
|
||||
async upsertCompanyOrder(userId: string, orderedIds: string[]): Promise<SidebarOrderPreference> {
|
||||
const now = new Date();
|
||||
const normalized = normalizeOrderedIds(orderedIds);
|
||||
const [row] = await db
|
||||
.insert(userSidebarPreferences)
|
||||
.values({
|
||||
userId,
|
||||
companyOrder: normalized,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [userSidebarPreferences.userId],
|
||||
set: {
|
||||
companyOrder: normalized,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
return toPreference(row?.companyOrder ?? normalized, row?.updatedAt ?? now);
|
||||
},
|
||||
|
||||
async getProjectOrder(companyId: string, userId: string): Promise<SidebarOrderPreference> {
|
||||
const row = await db.query.companyUserSidebarPreferences.findFirst({
|
||||
where: and(
|
||||
eq(companyUserSidebarPreferences.companyId, companyId),
|
||||
eq(companyUserSidebarPreferences.userId, userId),
|
||||
),
|
||||
});
|
||||
return toPreference(row?.projectOrder ?? [], row?.updatedAt ?? null);
|
||||
},
|
||||
|
||||
async upsertProjectOrder(
|
||||
companyId: string,
|
||||
userId: string,
|
||||
orderedIds: string[],
|
||||
): Promise<SidebarOrderPreference> {
|
||||
const now = new Date();
|
||||
const normalized = normalizeOrderedIds(orderedIds);
|
||||
const [row] = await db
|
||||
.insert(companyUserSidebarPreferences)
|
||||
.values({
|
||||
companyId,
|
||||
userId,
|
||||
projectOrder: normalized,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [companyUserSidebarPreferences.companyId, companyUserSidebarPreferences.userId],
|
||||
set: {
|
||||
projectOrder: normalized,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
return toPreference(row?.projectOrder ?? normalized, row?.updatedAt ?? now);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user