Persist sidebar order preferences

This commit is contained in:
Dotta committed 2026-04-14 11:51:38 -05:00
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();
});
});
+2
View File
@@ -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>();
+1
View File
@@ -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";
+71
View File
@@ -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;
}
+1
View File
@@ -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);
},
};
}