mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02: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,22 @@
|
||||
CREATE TABLE "company_user_sidebar_preferences" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"project_order" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user_sidebar_preferences" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"company_order" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "company_user_sidebar_preferences" ADD CONSTRAINT "company_user_sidebar_preferences_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "company_user_sidebar_preferences_company_idx" ON "company_user_sidebar_preferences" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE INDEX "company_user_sidebar_preferences_user_idx" ON "company_user_sidebar_preferences" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "company_user_sidebar_preferences_company_user_uq" ON "company_user_sidebar_preferences" USING btree ("company_id","user_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "user_sidebar_preferences_user_uq" ON "user_sidebar_preferences" USING btree ("user_id");
|
||||
File diff suppressed because it is too large.
Load diff
@@ -393,6 +393,13 @@
|
||||
"when": 1775825256196,
|
||||
"tag": "0055_kind_weapon_omega",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 56,
|
||||
"version": "7",
|
||||
"when": 1776084034244,
|
||||
"tag": "0056_spooky_ultragirl",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex, index } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
|
||||
export const companyUserSidebarPreferences = pgTable(
|
||||
"company_user_sidebar_preferences",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id").notNull(),
|
||||
projectOrder: jsonb("project_order").$type<string[]>().notNull().default([]),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyIdx: index("company_user_sidebar_preferences_company_idx").on(table.companyId),
|
||||
userIdx: index("company_user_sidebar_preferences_user_idx").on(table.userId),
|
||||
companyUserUq: uniqueIndex("company_user_sidebar_preferences_company_user_uq").on(
|
||||
table.companyId,
|
||||
table.userId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -3,10 +3,12 @@ export { companyLogos } from "./company_logos.js";
|
||||
export { authUsers, authSessions, authAccounts, authVerifications } from "./auth.js";
|
||||
export { instanceSettings } from "./instance_settings.js";
|
||||
export { instanceUserRoles } from "./instance_user_roles.js";
|
||||
export { userSidebarPreferences } from "./user_sidebar_preferences.js";
|
||||
export { agents } from "./agents.js";
|
||||
export { boardApiKeys } from "./board_api_keys.js";
|
||||
export { cliAuthChallenges } from "./cli_auth_challenges.js";
|
||||
export { companyMemberships } from "./company_memberships.js";
|
||||
export { companyUserSidebarPreferences } from "./company_user_sidebar_preferences.js";
|
||||
export { principalPermissionGrants } from "./principal_permission_grants.js";
|
||||
export { invites } from "./invites.js";
|
||||
export { joinRequests } from "./join_requests.js";
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
|
||||
export const userSidebarPreferences = pgTable(
|
||||
"user_sidebar_preferences",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: text("user_id").notNull(),
|
||||
companyOrder: jsonb("company_order").$type<string[]>().notNull().default([]),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
userUq: uniqueIndex("user_sidebar_preferences_user_uq").on(table.userId),
|
||||
}),
|
||||
);
|
||||
@@ -13,6 +13,7 @@ export const API = {
|
||||
activity: `${API_PREFIX}/activity`,
|
||||
dashboard: `${API_PREFIX}/dashboard`,
|
||||
sidebarBadges: `${API_PREFIX}/sidebar-badges`,
|
||||
sidebarPreferences: `${API_PREFIX}/sidebar-preferences`,
|
||||
invites: `${API_PREFIX}/invites`,
|
||||
joinRequests: `${API_PREFIX}/join-requests`,
|
||||
members: `${API_PREFIX}/members`,
|
||||
|
||||
@@ -305,6 +305,7 @@ export type {
|
||||
DashboardSummary,
|
||||
ActivityEvent,
|
||||
SidebarBadges,
|
||||
SidebarOrderPreference,
|
||||
InboxDismissal,
|
||||
CompanyMembership,
|
||||
PrincipalPermissionGrant,
|
||||
@@ -378,6 +379,12 @@ export type {
|
||||
ProviderQuotaResult,
|
||||
} from "./types/index.js";
|
||||
|
||||
export {
|
||||
sidebarOrderPreferenceSchema,
|
||||
upsertSidebarOrderPreferenceSchema,
|
||||
type UpsertSidebarOrderPreference,
|
||||
} from "./validators/sidebar-preferences.js";
|
||||
|
||||
export { workspaceRuntimeControlTargetSchema } from "./validators/execution-workspace.js";
|
||||
export {
|
||||
findWorkspaceCommandDefinition,
|
||||
|
||||
@@ -169,6 +169,7 @@ export type { LiveEvent } from "./live.js";
|
||||
export type { DashboardSummary } from "./dashboard.js";
|
||||
export type { ActivityEvent } from "./activity.js";
|
||||
export type { SidebarBadges } from "./sidebar-badges.js";
|
||||
export type { SidebarOrderPreference } from "./sidebar-preferences.js";
|
||||
export type { InboxDismissal } from "./inbox-dismissal.js";
|
||||
export type {
|
||||
CompanyMembership,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface SidebarOrderPreference {
|
||||
orderedIds: string[];
|
||||
updatedAt: Date | null;
|
||||
}
|
||||
@@ -32,6 +32,11 @@ export {
|
||||
upsertIssueFeedbackVoteSchema,
|
||||
type UpsertIssueFeedbackVote,
|
||||
} from "./feedback.js";
|
||||
export {
|
||||
sidebarOrderPreferenceSchema,
|
||||
upsertSidebarOrderPreferenceSchema,
|
||||
type UpsertSidebarOrderPreference,
|
||||
} from "./sidebar-preferences.js";
|
||||
export {
|
||||
companySkillSourceTypeSchema,
|
||||
companySkillTrustLevelSchema,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const sidebarOrderedIdSchema = z.string().uuid();
|
||||
|
||||
export const sidebarOrderPreferenceSchema = z.object({
|
||||
orderedIds: z.array(sidebarOrderedIdSchema),
|
||||
updatedAt: z.coerce.date().nullable(),
|
||||
});
|
||||
|
||||
export const upsertSidebarOrderPreferenceSchema = z.object({
|
||||
orderedIds: z.array(sidebarOrderedIdSchema),
|
||||
});
|
||||
|
||||
export type UpsertSidebarOrderPreference = z.infer<typeof upsertSidebarOrderPreferenceSchema>;
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -15,5 +15,6 @@ export { dashboardApi } from "./dashboard";
|
||||
export { heartbeatsApi } from "./heartbeats";
|
||||
export { instanceSettingsApi } from "./instanceSettings";
|
||||
export { sidebarBadgesApi } from "./sidebarBadges";
|
||||
export { sidebarPreferencesApi } from "./sidebarPreferences";
|
||||
export { inboxDismissalsApi } from "./inboxDismissals";
|
||||
export { companySkillsApi } from "./companySkills";
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { SidebarOrderPreference, UpsertSidebarOrderPreference } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const sidebarPreferencesApi = {
|
||||
getCompanyOrder: () => api.get<SidebarOrderPreference>("/sidebar-preferences/me"),
|
||||
updateCompanyOrder: (data: UpsertSidebarOrderPreference) =>
|
||||
api.put<SidebarOrderPreference>("/sidebar-preferences/me", data),
|
||||
getProjectOrder: (companyId: string) =>
|
||||
api.get<SidebarOrderPreference>(`/companies/${companyId}/sidebar-preferences/me`),
|
||||
updateProjectOrder: (companyId: string, data: UpsertSidebarOrderPreference) =>
|
||||
api.put<SidebarOrderPreference>(`/companies/${companyId}/sidebar-preferences/me`, data),
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Paperclip, Plus } from "lucide-react";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -22,6 +22,8 @@ import { cn } from "../lib/utils";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { sidebarBadgesApi } from "../api/sidebarBadges";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { authApi } from "../api/auth";
|
||||
import { useCompanyOrder } from "../hooks/useCompanyOrder";
|
||||
import { useLocation, useNavigate } from "@/lib/router";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -31,42 +33,6 @@ import {
|
||||
import type { Company } from "@paperclipai/shared";
|
||||
import { CompanyPatternIcon } from "./CompanyPatternIcon";
|
||||
|
||||
const ORDER_STORAGE_KEY = "paperclip.companyOrder";
|
||||
|
||||
function getStoredOrder(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(ORDER_STORAGE_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch { /* ignore */ }
|
||||
return [];
|
||||
}
|
||||
|
||||
function saveOrder(ids: string[]) {
|
||||
localStorage.setItem(ORDER_STORAGE_KEY, JSON.stringify(ids));
|
||||
}
|
||||
|
||||
/** Sort companies by stored order, appending any new ones at the end. */
|
||||
function sortByStoredOrder(companies: Company[]): Company[] {
|
||||
const order = getStoredOrder();
|
||||
if (order.length === 0) return companies;
|
||||
|
||||
const byId = new Map(companies.map((c) => [c.id, c]));
|
||||
const sorted: Company[] = [];
|
||||
|
||||
for (const id of order) {
|
||||
const c = byId.get(id);
|
||||
if (c) {
|
||||
sorted.push(c);
|
||||
byId.delete(id);
|
||||
}
|
||||
}
|
||||
// Append any companies not in stored order
|
||||
for (const c of byId.values()) {
|
||||
sorted.push(c);
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function SortableCompanyItem({
|
||||
company,
|
||||
isSelected,
|
||||
@@ -103,6 +69,10 @@ function SortableCompanyItem({
|
||||
<a
|
||||
href={`/${company.issuePrefix}/dashboard`}
|
||||
onClick={(e) => {
|
||||
if (isDragging) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}}
|
||||
@@ -164,6 +134,11 @@ export function CompanyRail() {
|
||||
() => companies.filter((company) => company.status !== "archived"),
|
||||
[companies],
|
||||
);
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
const companyIds = useMemo(() => sidebarCompanies.map((company) => company.id), [sidebarCompanies]);
|
||||
|
||||
const liveRunsQueries = useQueries({
|
||||
@@ -195,52 +170,10 @@ export function CompanyRail() {
|
||||
return result;
|
||||
}, [companyIds, sidebarBadgeQueries]);
|
||||
|
||||
// Maintain sorted order in local state, synced from companies + localStorage
|
||||
const [orderedIds, setOrderedIds] = useState<string[]>(() =>
|
||||
sortByStoredOrder(sidebarCompanies).map((c) => c.id)
|
||||
);
|
||||
|
||||
// Re-sync orderedIds from localStorage whenever companies changes.
|
||||
// Handles initial data load (companies starts as [] before query resolves)
|
||||
// and subsequent refetches triggered by live updates.
|
||||
useEffect(() => {
|
||||
if (sidebarCompanies.length === 0) {
|
||||
setOrderedIds([]);
|
||||
return;
|
||||
}
|
||||
setOrderedIds(sortByStoredOrder(sidebarCompanies).map((c) => c.id));
|
||||
}, [sidebarCompanies]);
|
||||
|
||||
// Sync order across tabs via the native storage event
|
||||
useEffect(() => {
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
if (e.key !== ORDER_STORAGE_KEY) return;
|
||||
try {
|
||||
const ids: string[] = e.newValue ? JSON.parse(e.newValue) : [];
|
||||
setOrderedIds(ids);
|
||||
} catch { /* ignore malformed data */ }
|
||||
};
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
}, []);
|
||||
|
||||
// Re-derive when companies change (new company added/removed)
|
||||
const orderedCompanies = useMemo(() => {
|
||||
const byId = new Map(sidebarCompanies.map((c) => [c.id, c]));
|
||||
const result: Company[] = [];
|
||||
for (const id of orderedIds) {
|
||||
const c = byId.get(id);
|
||||
if (c) {
|
||||
result.push(c);
|
||||
byId.delete(id);
|
||||
}
|
||||
}
|
||||
// Append any new companies not yet in our order
|
||||
for (const c of byId.values()) {
|
||||
result.push(c);
|
||||
}
|
||||
return result;
|
||||
}, [sidebarCompanies, orderedIds]);
|
||||
const { orderedCompanies, persistOrder } = useCompanyOrder({
|
||||
companies: sidebarCompanies,
|
||||
userId: currentUserId,
|
||||
});
|
||||
|
||||
// Require 8px of movement before starting a drag to avoid interfering with clicks
|
||||
const sensors = useSensors(
|
||||
@@ -260,11 +193,9 @@ export function CompanyRail() {
|
||||
const newIndex = ids.indexOf(over.id as string);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
const newIds = arrayMove(ids, oldIndex, newIndex);
|
||||
setOrderedIds(newIds);
|
||||
saveOrder(newIds);
|
||||
persistOrder(arrayMove(ids, oldIndex, newIndex));
|
||||
},
|
||||
[orderedCompanies]
|
||||
[orderedCompanies, persistOrder]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -76,7 +76,11 @@ function SortableProjectItem({
|
||||
<NavLink
|
||||
to={`/projects/${routeRef}/issues`}
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
if (isDragging) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Company } from "@paperclipai/shared";
|
||||
import { sidebarPreferencesApi } from "../api/sidebarPreferences";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
function areEqual(a: string[], b: string[]) {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function sortCompaniesByOrder(companies: Company[], orderedIds: string[]): Company[] {
|
||||
if (companies.length === 0) return [];
|
||||
if (orderedIds.length === 0) return companies;
|
||||
|
||||
const byId = new Map(companies.map((company) => [company.id, company]));
|
||||
const sorted: Company[] = [];
|
||||
|
||||
for (const id of orderedIds) {
|
||||
const company = byId.get(id);
|
||||
if (!company) continue;
|
||||
sorted.push(company);
|
||||
byId.delete(id);
|
||||
}
|
||||
for (const company of byId.values()) {
|
||||
sorted.push(company);
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function buildOrderIds(companies: Company[], orderedIds: string[]) {
|
||||
return sortCompaniesByOrder(companies, orderedIds).map((company) => company.id);
|
||||
}
|
||||
|
||||
type UseCompanyOrderParams = {
|
||||
companies: Company[];
|
||||
userId: string | null | undefined;
|
||||
};
|
||||
|
||||
export function useCompanyOrder({ companies, userId }: UseCompanyOrderParams) {
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = useMemo(
|
||||
() => queryKeys.sidebarPreferences.companyOrder(userId ?? "__anon__"),
|
||||
[userId],
|
||||
);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => sidebarPreferencesApi.getCompanyOrder(),
|
||||
enabled: Boolean(userId),
|
||||
});
|
||||
|
||||
const [orderedIds, setOrderedIds] = useState<string[]>(() => buildOrderIds(companies, []));
|
||||
|
||||
useEffect(() => {
|
||||
const nextIds = buildOrderIds(companies, data?.orderedIds ?? []);
|
||||
setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds));
|
||||
}, [companies, data?.orderedIds]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (nextIds: string[]) => sidebarPreferencesApi.updateCompanyOrder({ orderedIds: nextIds }),
|
||||
onSuccess: (preference) => {
|
||||
queryClient.setQueryData(queryKey, preference);
|
||||
},
|
||||
});
|
||||
|
||||
const orderedCompanies = useMemo(
|
||||
() => sortCompaniesByOrder(companies, orderedIds),
|
||||
[companies, orderedIds],
|
||||
);
|
||||
|
||||
const persistOrder = useCallback(
|
||||
(ids: string[]) => {
|
||||
const idSet = new Set(companies.map((company) => company.id));
|
||||
const filtered = ids.filter((id) => idSet.has(id));
|
||||
for (const company of companies) {
|
||||
if (!filtered.includes(company.id)) filtered.push(company.id);
|
||||
}
|
||||
|
||||
setOrderedIds((current) => (areEqual(current, filtered) ? current : filtered));
|
||||
if (!userId) return;
|
||||
|
||||
queryClient.setQueryData(queryKey, (current: { orderedIds?: string[]; updatedAt?: Date | null } | undefined) => ({
|
||||
orderedIds: filtered,
|
||||
updatedAt: current?.updatedAt ?? null,
|
||||
}));
|
||||
mutation.mutate(filtered);
|
||||
},
|
||||
[companies, mutation, queryClient, queryKey, userId],
|
||||
);
|
||||
|
||||
return {
|
||||
orderedCompanies,
|
||||
orderedIds,
|
||||
persistOrder,
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Project } from "@paperclipai/shared";
|
||||
import {
|
||||
getProjectOrderStorageKey,
|
||||
PROJECT_ORDER_UPDATED_EVENT,
|
||||
readProjectOrder,
|
||||
sortProjectsByStoredOrder,
|
||||
writeProjectOrder,
|
||||
} from "../lib/project-order";
|
||||
import { sidebarPreferencesApi } from "../api/sidebarPreferences";
|
||||
import { sortProjectsByStoredOrder } from "../lib/project-order";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
type UseProjectOrderParams = {
|
||||
projects: Project[];
|
||||
@@ -14,11 +11,6 @@ type UseProjectOrderParams = {
|
||||
userId: string | null | undefined;
|
||||
};
|
||||
|
||||
type ProjectOrderUpdatedDetail = {
|
||||
storageKey: string;
|
||||
orderedIds: string[];
|
||||
};
|
||||
|
||||
function areEqual(a: string[], b: string[]) {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
@@ -32,48 +24,33 @@ function buildOrderIds(projects: Project[], orderedIds: string[]) {
|
||||
}
|
||||
|
||||
export function useProjectOrder({ projects, companyId, userId }: UseProjectOrderParams) {
|
||||
const storageKey = useMemo(() => {
|
||||
if (!companyId) return null;
|
||||
return getProjectOrderStorageKey(companyId, userId);
|
||||
}, [companyId, userId]);
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = useMemo(
|
||||
() => queryKeys.sidebarPreferences.projectOrder(companyId ?? "__none__", userId ?? "__anon__"),
|
||||
[companyId, userId],
|
||||
);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => sidebarPreferencesApi.getProjectOrder(companyId!),
|
||||
enabled: Boolean(companyId && userId),
|
||||
});
|
||||
|
||||
const [orderedIds, setOrderedIds] = useState<string[]>(() => {
|
||||
if (!storageKey) return projects.map((project) => project.id);
|
||||
return buildOrderIds(projects, readProjectOrder(storageKey));
|
||||
return buildOrderIds(projects, []);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const nextIds = storageKey
|
||||
? buildOrderIds(projects, readProjectOrder(storageKey))
|
||||
: projects.map((project) => project.id);
|
||||
const nextIds = buildOrderIds(projects, data?.orderedIds ?? []);
|
||||
setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds));
|
||||
}, [projects, storageKey]);
|
||||
}, [data?.orderedIds, projects]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!storageKey) return;
|
||||
|
||||
const syncFromIds = (ids: string[]) => {
|
||||
const nextIds = buildOrderIds(projects, ids);
|
||||
setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds));
|
||||
};
|
||||
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key !== storageKey) return;
|
||||
syncFromIds(readProjectOrder(storageKey));
|
||||
};
|
||||
const onCustomEvent = (event: Event) => {
|
||||
const detail = (event as CustomEvent<ProjectOrderUpdatedDetail>).detail;
|
||||
if (!detail || detail.storageKey !== storageKey) return;
|
||||
syncFromIds(detail.orderedIds);
|
||||
};
|
||||
|
||||
window.addEventListener("storage", onStorage);
|
||||
window.addEventListener(PROJECT_ORDER_UPDATED_EVENT, onCustomEvent);
|
||||
return () => {
|
||||
window.removeEventListener("storage", onStorage);
|
||||
window.removeEventListener(PROJECT_ORDER_UPDATED_EVENT, onCustomEvent);
|
||||
};
|
||||
}, [projects, storageKey]);
|
||||
const mutation = useMutation({
|
||||
mutationFn: (nextIds: string[]) => sidebarPreferencesApi.updateProjectOrder(companyId!, { orderedIds: nextIds }),
|
||||
onSuccess: (preference) => {
|
||||
queryClient.setQueryData(queryKey, preference);
|
||||
},
|
||||
});
|
||||
|
||||
const orderedProjects = useMemo(
|
||||
() => sortProjectsByStoredOrder(projects, orderedIds),
|
||||
@@ -89,11 +66,15 @@ export function useProjectOrder({ projects, companyId, userId }: UseProjectOrder
|
||||
}
|
||||
|
||||
setOrderedIds((current) => (areEqual(current, filtered) ? current : filtered));
|
||||
if (storageKey) {
|
||||
writeProjectOrder(storageKey, filtered);
|
||||
}
|
||||
if (!companyId || !userId) return;
|
||||
|
||||
queryClient.setQueryData(queryKey, (current: { orderedIds?: string[]; updatedAt?: Date | null } | undefined) => ({
|
||||
orderedIds: filtered,
|
||||
updatedAt: current?.updatedAt ?? null,
|
||||
}));
|
||||
mutation.mutate(filtered);
|
||||
},
|
||||
[projects, storageKey],
|
||||
[companyId, mutation, projects, queryClient, queryKey, userId],
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -102,4 +83,3 @@ export function useProjectOrder({ projects, companyId, userId }: UseProjectOrder
|
||||
persistOrder,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,6 +95,11 @@ export const queryKeys = {
|
||||
auth: {
|
||||
session: ["auth", "session"] as const,
|
||||
},
|
||||
sidebarPreferences: {
|
||||
companyOrder: (userId: string) => ["sidebar-preferences", "company-order", userId] as const,
|
||||
projectOrder: (companyId: string, userId: string) =>
|
||||
["sidebar-preferences", "project-order", companyId, userId] as const,
|
||||
},
|
||||
instance: {
|
||||
generalSettings: ["instance", "general-settings"] as const,
|
||||
schedulerHeartbeats: ["instance", "scheduler-heartbeats"] as const,
|
||||
|
||||
@@ -13,9 +13,9 @@ import { useToast } from "../context/ToastContext";
|
||||
import { authApi } from "../api/auth";
|
||||
import { companiesApi } from "../api/companies";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { sidebarPreferencesApi } from "../api/sidebarPreferences";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { getAgentOrderStorageKey, writeAgentOrder } from "../lib/agent-order";
|
||||
import { getProjectOrderStorageKey, writeProjectOrder } from "../lib/project-order";
|
||||
import { MarkdownBody } from "../components/MarkdownBody";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
@@ -346,7 +346,7 @@ function prefixedName(prefix: string | null, originalName: string): string {
|
||||
return `${prefix}-${originalName}`;
|
||||
}
|
||||
|
||||
function applyImportedSidebarOrder(
|
||||
async function applyImportedSidebarOrder(
|
||||
preview: CompanyPortabilityPreviewResult | null,
|
||||
result: {
|
||||
company: { id: string };
|
||||
@@ -381,7 +381,7 @@ function applyImportedSidebarOrder(
|
||||
writeAgentOrder(getAgentOrderStorageKey(result.company.id, userId), orderedAgentIds);
|
||||
}
|
||||
if (orderedProjectIds.length > 0) {
|
||||
writeProjectOrder(getProjectOrderStorageKey(result.company.id, userId), orderedProjectIds);
|
||||
await sidebarPreferencesApi.updateProjectOrder(result.company.id, { orderedIds: orderedProjectIds });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -859,7 +859,7 @@ export function CompanyImport() {
|
||||
?? refreshedSession?.user?.id
|
||||
?? refreshedSession?.session?.userId
|
||||
?? null;
|
||||
applyImportedSidebarOrder(importPreview, result, sidebarOrderUserId);
|
||||
await applyImportedSidebarOrder(importPreview, result, sidebarOrderUserId);
|
||||
setSelectedCompanyId(importedCompany.id);
|
||||
pushToast({
|
||||
tone: "success",
|
||||
|
||||
Reference in new issue
Block a user