mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-20 04:20:38 +09:00
Persist non-issue inbox dismissals
This commit is contained in:
parent
1de5fb9316
commit
5640d29ab0
23 changed files with 13623 additions and 54 deletions
18
packages/db/src/migrations/0053_sharp_wild_child.sql
Normal file
18
packages/db/src/migrations/0053_sharp_wild_child.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS "inbox_dismissals" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"company_id" uuid NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"item_key" text NOT NULL,
|
||||||
|
"dismissed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "inbox_dismissals" ADD CONSTRAINT "inbox_dismissals_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "inbox_dismissals_company_user_idx" ON "inbox_dismissals" USING btree ("company_id","user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "inbox_dismissals_company_item_idx" ON "inbox_dismissals" USING btree ("company_id","item_key");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "inbox_dismissals_company_user_item_idx" ON "inbox_dismissals" USING btree ("company_id","user_id","item_key");
|
||||||
12979
packages/db/src/migrations/meta/0053_snapshot.json
Normal file
12979
packages/db/src/migrations/meta/0053_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -372,6 +372,13 @@
|
||||||
"when": 1775571715162,
|
"when": 1775571715162,
|
||||||
"tag": "0052_mushy_trauma",
|
"tag": "0052_mushy_trauma",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 53,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775604018515,
|
||||||
|
"tag": "0053_sharp_wild_child",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
24
packages/db/src/schema/inbox_dismissals.ts
Normal file
24
packages/db/src/schema/inbox_dismissals.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||||
|
import { companies } from "./companies.js";
|
||||||
|
|
||||||
|
export const inboxDismissals = pgTable(
|
||||||
|
"inbox_dismissals",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||||
|
userId: text("user_id").notNull(),
|
||||||
|
itemKey: text("item_key").notNull(),
|
||||||
|
dismissedAt: timestamp("dismissed_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
companyUserIdx: index("inbox_dismissals_company_user_idx").on(table.companyId, table.userId),
|
||||||
|
companyItemIdx: index("inbox_dismissals_company_item_idx").on(table.companyId, table.itemKey),
|
||||||
|
companyUserItemUnique: uniqueIndex("inbox_dismissals_company_user_item_idx").on(
|
||||||
|
table.companyId,
|
||||||
|
table.userId,
|
||||||
|
table.itemKey,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
@ -34,6 +34,7 @@ export { issueApprovals } from "./issue_approvals.js";
|
||||||
export { issueComments } from "./issue_comments.js";
|
export { issueComments } from "./issue_comments.js";
|
||||||
export { issueExecutionDecisions } from "./issue_execution_decisions.js";
|
export { issueExecutionDecisions } from "./issue_execution_decisions.js";
|
||||||
export { issueInboxArchives } from "./issue_inbox_archives.js";
|
export { issueInboxArchives } from "./issue_inbox_archives.js";
|
||||||
|
export { inboxDismissals } from "./inbox_dismissals.js";
|
||||||
export { feedbackVotes } from "./feedback_votes.js";
|
export { feedbackVotes } from "./feedback_votes.js";
|
||||||
export { feedbackExports } from "./feedback_exports.js";
|
export { feedbackExports } from "./feedback_exports.js";
|
||||||
export { issueReadStates } from "./issue_read_states.js";
|
export { issueReadStates } from "./issue_read_states.js";
|
||||||
|
|
|
||||||
|
|
@ -288,6 +288,7 @@ export type {
|
||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
ActivityEvent,
|
ActivityEvent,
|
||||||
SidebarBadges,
|
SidebarBadges,
|
||||||
|
InboxDismissal,
|
||||||
CompanyMembership,
|
CompanyMembership,
|
||||||
PrincipalPermissionGrant,
|
PrincipalPermissionGrant,
|
||||||
Invite,
|
Invite,
|
||||||
|
|
|
||||||
9
packages/shared/src/types/inbox-dismissal.ts
Normal file
9
packages/shared/src/types/inbox-dismissal.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export interface InboxDismissal {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
userId: string;
|
||||||
|
itemKey: string;
|
||||||
|
dismissedAt: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
@ -164,6 +164,7 @@ export type { LiveEvent } from "./live.js";
|
||||||
export type { DashboardSummary } from "./dashboard.js";
|
export type { DashboardSummary } from "./dashboard.js";
|
||||||
export type { ActivityEvent } from "./activity.js";
|
export type { ActivityEvent } from "./activity.js";
|
||||||
export type { SidebarBadges } from "./sidebar-badges.js";
|
export type { SidebarBadges } from "./sidebar-badges.js";
|
||||||
|
export type { InboxDismissal } from "./inbox-dismissal.js";
|
||||||
export type {
|
export type {
|
||||||
CompanyMembership,
|
CompanyMembership,
|
||||||
PrincipalPermissionGrant,
|
PrincipalPermissionGrant,
|
||||||
|
|
|
||||||
212
server/src/__tests__/inbox-dismissals.test.ts
Normal file
212
server/src/__tests__/inbox-dismissals.test.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
agents,
|
||||||
|
approvals,
|
||||||
|
companies,
|
||||||
|
createDb,
|
||||||
|
heartbeatRuns,
|
||||||
|
inboxDismissals,
|
||||||
|
invites,
|
||||||
|
joinRequests,
|
||||||
|
} from "@paperclipai/db";
|
||||||
|
import {
|
||||||
|
getEmbeddedPostgresTestSupport,
|
||||||
|
startEmbeddedPostgresTestDatabase,
|
||||||
|
} from "./helpers/embedded-postgres.js";
|
||||||
|
import { inboxDismissalService } from "../services/inbox-dismissals.ts";
|
||||||
|
import { sidebarBadgeService } from "../services/sidebar-badges.ts";
|
||||||
|
|
||||||
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||||
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||||
|
|
||||||
|
if (!embeddedPostgresSupport.supported) {
|
||||||
|
console.warn(
|
||||||
|
`Skipping embedded Postgres inbox dismissal tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describeEmbeddedPostgres("inbox dismissals", () => {
|
||||||
|
let db!: ReturnType<typeof createDb>;
|
||||||
|
let dismissalsSvc!: ReturnType<typeof inboxDismissalService>;
|
||||||
|
let badgesSvc!: ReturnType<typeof sidebarBadgeService>;
|
||||||
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-inbox-dismissals-");
|
||||||
|
db = createDb(tempDb.connectionString);
|
||||||
|
dismissalsSvc = inboxDismissalService(db);
|
||||||
|
badgesSvc = sidebarBadgeService(db);
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await db.delete(inboxDismissals);
|
||||||
|
await db.delete(joinRequests);
|
||||||
|
await db.delete(invites);
|
||||||
|
await db.delete(heartbeatRuns);
|
||||||
|
await db.delete(approvals);
|
||||||
|
await db.delete(agents);
|
||||||
|
await db.delete(companies);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await tempDb?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upserts a single dismissal record per user and inbox item key", async () => {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
const userId = "board-user";
|
||||||
|
const firstDismissedAt = new Date("2026-03-11T01:00:00.000Z");
|
||||||
|
const secondDismissedAt = new Date("2026-03-11T02:00:00.000Z");
|
||||||
|
|
||||||
|
await db.insert(companies).values({
|
||||||
|
id: companyId,
|
||||||
|
name: "Paperclip",
|
||||||
|
issuePrefix: "PAP",
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await dismissalsSvc.dismiss(companyId, userId, "approval:approval-1", firstDismissedAt);
|
||||||
|
await dismissalsSvc.dismiss(companyId, userId, "approval:approval-1", secondDismissedAt);
|
||||||
|
|
||||||
|
const dismissals = await dismissalsSvc.list(companyId, userId);
|
||||||
|
|
||||||
|
expect(dismissals).toHaveLength(1);
|
||||||
|
expect(dismissals[0]?.itemKey).toBe("approval:approval-1");
|
||||||
|
expect(new Date(dismissals[0]?.dismissedAt ?? 0).toISOString()).toBe(secondDismissedAt.toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors dismissal timestamps and resurfaces approvals with newer activity", async () => {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
const userId = "board-user";
|
||||||
|
const primaryAgentId = randomUUID();
|
||||||
|
const secondaryAgentId = randomUUID();
|
||||||
|
const hiddenApprovalId = randomUUID();
|
||||||
|
const resurfacedApprovalId = randomUUID();
|
||||||
|
const inviteId = randomUUID();
|
||||||
|
const hiddenJoinRequestId = randomUUID();
|
||||||
|
const hiddenRunId = randomUUID();
|
||||||
|
const visibleRunId = randomUUID();
|
||||||
|
|
||||||
|
await db.insert(companies).values({
|
||||||
|
id: companyId,
|
||||||
|
name: "Paperclip",
|
||||||
|
issuePrefix: "PAP",
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(agents).values([
|
||||||
|
{
|
||||||
|
id: primaryAgentId,
|
||||||
|
companyId,
|
||||||
|
name: "Primary",
|
||||||
|
role: "engineer",
|
||||||
|
status: "active",
|
||||||
|
adapterType: "codex_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {},
|
||||||
|
permissions: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: secondaryAgentId,
|
||||||
|
companyId,
|
||||||
|
name: "Secondary",
|
||||||
|
role: "engineer",
|
||||||
|
status: "active",
|
||||||
|
adapterType: "codex_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {},
|
||||||
|
permissions: {},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.insert(approvals).values([
|
||||||
|
{
|
||||||
|
id: hiddenApprovalId,
|
||||||
|
companyId,
|
||||||
|
type: "hire_agent",
|
||||||
|
status: "pending",
|
||||||
|
payload: {},
|
||||||
|
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: resurfacedApprovalId,
|
||||||
|
companyId,
|
||||||
|
type: "hire_agent",
|
||||||
|
status: "revision_requested",
|
||||||
|
payload: {},
|
||||||
|
updatedAt: new Date("2026-03-11T03:00:00.000Z"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.insert(invites).values({
|
||||||
|
id: inviteId,
|
||||||
|
companyId,
|
||||||
|
inviteType: "company_join",
|
||||||
|
tokenHash: "hash-1",
|
||||||
|
allowedJoinTypes: "both",
|
||||||
|
expiresAt: new Date("2026-03-12T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(joinRequests).values({
|
||||||
|
id: hiddenJoinRequestId,
|
||||||
|
inviteId,
|
||||||
|
companyId,
|
||||||
|
requestType: "human",
|
||||||
|
status: "pending_approval",
|
||||||
|
requestIp: "127.0.0.1",
|
||||||
|
createdAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(heartbeatRuns).values([
|
||||||
|
{
|
||||||
|
id: hiddenRunId,
|
||||||
|
companyId,
|
||||||
|
agentId: primaryAgentId,
|
||||||
|
invocationSource: "assignment",
|
||||||
|
status: "failed",
|
||||||
|
createdAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: visibleRunId,
|
||||||
|
companyId,
|
||||||
|
agentId: secondaryAgentId,
|
||||||
|
invocationSource: "assignment",
|
||||||
|
status: "timed_out",
|
||||||
|
createdAt: new Date("2026-03-11T04:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-11T04:00:00.000Z"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await dismissalsSvc.dismiss(companyId, userId, `approval:${hiddenApprovalId}`, new Date("2026-03-11T02:00:00.000Z"));
|
||||||
|
await dismissalsSvc.dismiss(companyId, userId, `approval:${resurfacedApprovalId}`, new Date("2026-03-11T02:00:00.000Z"));
|
||||||
|
await dismissalsSvc.dismiss(companyId, userId, `join:${hiddenJoinRequestId}`, new Date("2026-03-11T02:00:00.000Z"));
|
||||||
|
await dismissalsSvc.dismiss(companyId, userId, `run:${hiddenRunId}`, new Date("2026-03-11T02:00:00.000Z"));
|
||||||
|
|
||||||
|
const dismissedAtByKey = new Map(
|
||||||
|
(await dismissalsSvc.list(companyId, userId)).map((dismissal) => [
|
||||||
|
dismissal.itemKey,
|
||||||
|
new Date(dismissal.dismissedAt).getTime(),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const badges = await badgesSvc.get(companyId, {
|
||||||
|
dismissals: dismissedAtByKey,
|
||||||
|
joinRequests: [{
|
||||||
|
id: hiddenJoinRequestId,
|
||||||
|
createdAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||||
|
}],
|
||||||
|
unreadTouchedIssues: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(badges).toEqual({
|
||||||
|
inbox: 3,
|
||||||
|
approvals: 1,
|
||||||
|
failedRuns: 1,
|
||||||
|
joinRequests: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -24,6 +24,7 @@ import { costRoutes } from "./routes/costs.js";
|
||||||
import { activityRoutes } from "./routes/activity.js";
|
import { activityRoutes } from "./routes/activity.js";
|
||||||
import { dashboardRoutes } from "./routes/dashboard.js";
|
import { dashboardRoutes } from "./routes/dashboard.js";
|
||||||
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
||||||
|
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
|
||||||
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
|
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
|
||||||
import { llmRoutes } from "./routes/llms.js";
|
import { llmRoutes } from "./routes/llms.js";
|
||||||
import { assetRoutes } from "./routes/assets.js";
|
import { assetRoutes } from "./routes/assets.js";
|
||||||
|
|
@ -166,6 +167,7 @@ export async function createApp(
|
||||||
api.use(activityRoutes(db));
|
api.use(activityRoutes(db));
|
||||||
api.use(dashboardRoutes(db));
|
api.use(dashboardRoutes(db));
|
||||||
api.use(sidebarBadgeRoutes(db));
|
api.use(sidebarBadgeRoutes(db));
|
||||||
|
api.use(inboxDismissalRoutes(db));
|
||||||
api.use(instanceSettingsRoutes(db));
|
api.use(instanceSettingsRoutes(db));
|
||||||
const hostServicesDisposers = new Map<string, () => void>();
|
const hostServicesDisposers = new Map<string, () => void>();
|
||||||
const workerManager = createPluginWorkerManager();
|
const workerManager = createPluginWorkerManager();
|
||||||
|
|
|
||||||
69
server/src/routes/inbox-dismissals.ts
Normal file
69
server/src/routes/inbox-dismissals.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { Db } from "@paperclipai/db";
|
||||||
|
import { validate } from "../middleware/validate.js";
|
||||||
|
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||||
|
import { inboxDismissalService, logActivity } from "../services/index.js";
|
||||||
|
|
||||||
|
const inboxDismissalSchema = z.object({
|
||||||
|
itemKey: z.string().trim().min(1).regex(/^(approval|join|run):.+$/, "Unsupported inbox item key"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function inboxDismissalRoutes(db: Db) {
|
||||||
|
const router = Router();
|
||||||
|
const svc = inboxDismissalService(db);
|
||||||
|
|
||||||
|
router.get("/companies/:companyId/inbox-dismissals", async (req, res) => {
|
||||||
|
const companyId = req.params.companyId as string;
|
||||||
|
assertCompanyAccess(req, companyId);
|
||||||
|
if (req.actor.type !== "board") {
|
||||||
|
res.status(403).json({ error: "Board authentication required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!req.actor.userId) {
|
||||||
|
res.status(403).json({ error: "Board user context required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dismissals = await svc.list(companyId, req.actor.userId);
|
||||||
|
res.json(dismissals);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/companies/:companyId/inbox-dismissals",
|
||||||
|
validate(inboxDismissalSchema),
|
||||||
|
async (req, res) => {
|
||||||
|
const companyId = req.params.companyId as string;
|
||||||
|
assertCompanyAccess(req, companyId);
|
||||||
|
if (req.actor.type !== "board") {
|
||||||
|
res.status(403).json({ error: "Board authentication required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!req.actor.userId) {
|
||||||
|
res.status(403).json({ error: "Board user context required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dismissal = await svc.dismiss(companyId, req.actor.userId, req.body.itemKey, new Date());
|
||||||
|
const actor = getActorInfo(req);
|
||||||
|
await logActivity(db, {
|
||||||
|
companyId,
|
||||||
|
actorType: actor.actorType,
|
||||||
|
actorId: actor.actorId,
|
||||||
|
agentId: actor.agentId,
|
||||||
|
runId: actor.runId,
|
||||||
|
action: "inbox.dismissed",
|
||||||
|
entityType: "company",
|
||||||
|
entityId: companyId,
|
||||||
|
details: {
|
||||||
|
userId: req.actor.userId,
|
||||||
|
itemKey: dismissal.itemKey,
|
||||||
|
dismissedAt: dismissal.dismissedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(dismissal);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ export { costRoutes } from "./costs.js";
|
||||||
export { activityRoutes } from "./activity.js";
|
export { activityRoutes } from "./activity.js";
|
||||||
export { dashboardRoutes } from "./dashboard.js";
|
export { dashboardRoutes } from "./dashboard.js";
|
||||||
export { sidebarBadgeRoutes } from "./sidebar-badges.js";
|
export { sidebarBadgeRoutes } from "./sidebar-badges.js";
|
||||||
|
export { inboxDismissalRoutes } from "./inbox-dismissals.js";
|
||||||
export { llmRoutes } from "./llms.js";
|
export { llmRoutes } from "./llms.js";
|
||||||
export { accessRoutes } from "./access.js";
|
export { accessRoutes } from "./access.js";
|
||||||
export { instanceSettingsRoutes } from "./instance-settings.js";
|
export { instanceSettingsRoutes } from "./instance-settings.js";
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,20 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import { and, eq, sql } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { joinRequests } from "@paperclipai/db";
|
import { inboxDismissals, joinRequests } from "@paperclipai/db";
|
||||||
import { sidebarBadgeService } from "../services/sidebar-badges.js";
|
import { sidebarBadgeService } from "../services/sidebar-badges.js";
|
||||||
import { accessService } from "../services/access.js";
|
import { accessService } from "../services/access.js";
|
||||||
import { dashboardService } from "../services/dashboard.js";
|
import { dashboardService } from "../services/dashboard.js";
|
||||||
import { assertCompanyAccess } from "./authz.js";
|
import { assertCompanyAccess } from "./authz.js";
|
||||||
|
|
||||||
|
function buildDismissedAtByKey(
|
||||||
|
dismissals: Array<{ itemKey: string; dismissedAt: Date | string }>,
|
||||||
|
): Map<string, number> {
|
||||||
|
return new Map(
|
||||||
|
dismissals.map((dismissal) => [dismissal.itemKey, new Date(dismissal.dismissedAt).getTime()]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function sidebarBadgeRoutes(db: Db) {
|
export function sidebarBadgeRoutes(db: Db) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const svc = sidebarBadgeService(db);
|
const svc = sidebarBadgeService(db);
|
||||||
|
|
@ -26,23 +34,36 @@ export function sidebarBadgeRoutes(db: Db) {
|
||||||
canApproveJoins = await access.hasPermission(companyId, "agent", req.actor.agentId, "joins:approve");
|
canApproveJoins = await access.hasPermission(companyId, "agent", req.actor.agentId, "joins:approve");
|
||||||
}
|
}
|
||||||
|
|
||||||
const joinRequestCount = canApproveJoins
|
const visibleJoinRequests = canApproveJoins
|
||||||
? await db
|
? await db
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({
|
||||||
|
id: joinRequests.id,
|
||||||
|
updatedAt: joinRequests.updatedAt,
|
||||||
|
createdAt: joinRequests.createdAt,
|
||||||
|
})
|
||||||
.from(joinRequests)
|
.from(joinRequests)
|
||||||
.where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.status, "pending_approval")))
|
.where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.status, "pending_approval")))
|
||||||
.then((rows) => Number(rows[0]?.count ?? 0))
|
: [];
|
||||||
: 0;
|
|
||||||
|
const dismissedAtByKey =
|
||||||
|
req.actor.type === "board" && req.actor.userId
|
||||||
|
? await db
|
||||||
|
.select({ itemKey: inboxDismissals.itemKey, dismissedAt: inboxDismissals.dismissedAt })
|
||||||
|
.from(inboxDismissals)
|
||||||
|
.where(and(eq(inboxDismissals.companyId, companyId), eq(inboxDismissals.userId, req.actor.userId)))
|
||||||
|
.then(buildDismissedAtByKey)
|
||||||
|
: new Map<string, number>();
|
||||||
|
|
||||||
const badges = await svc.get(companyId, {
|
const badges = await svc.get(companyId, {
|
||||||
joinRequests: joinRequestCount,
|
dismissals: dismissedAtByKey,
|
||||||
|
joinRequests: visibleJoinRequests,
|
||||||
});
|
});
|
||||||
const summary = await dashboard.summary(companyId);
|
const summary = await dashboard.summary(companyId);
|
||||||
const hasFailedRuns = badges.failedRuns > 0;
|
const hasFailedRuns = badges.failedRuns > 0;
|
||||||
const alertsCount =
|
const alertsCount =
|
||||||
(summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) +
|
(summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) +
|
||||||
(summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0);
|
(summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0);
|
||||||
badges.inbox = badges.failedRuns + alertsCount + joinRequestCount + badges.approvals;
|
badges.inbox = badges.failedRuns + alertsCount + badges.joinRequests + badges.approvals;
|
||||||
|
|
||||||
res.json(badges);
|
res.json(badges);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
41
server/src/services/inbox-dismissals.ts
Normal file
41
server/src/services/inbox-dismissals.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
|
import type { Db } from "@paperclipai/db";
|
||||||
|
import { inboxDismissals } from "@paperclipai/db";
|
||||||
|
|
||||||
|
export function inboxDismissalService(db: Db) {
|
||||||
|
return {
|
||||||
|
list: async (companyId: string, userId: string) =>
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(inboxDismissals)
|
||||||
|
.where(and(eq(inboxDismissals.companyId, companyId), eq(inboxDismissals.userId, userId)))
|
||||||
|
.orderBy(desc(inboxDismissals.updatedAt)),
|
||||||
|
|
||||||
|
dismiss: async (
|
||||||
|
companyId: string,
|
||||||
|
userId: string,
|
||||||
|
itemKey: string,
|
||||||
|
dismissedAt: Date = new Date(),
|
||||||
|
) => {
|
||||||
|
const now = new Date();
|
||||||
|
const [row] = await db
|
||||||
|
.insert(inboxDismissals)
|
||||||
|
.values({
|
||||||
|
companyId,
|
||||||
|
userId,
|
||||||
|
itemKey,
|
||||||
|
dismissedAt,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [inboxDismissals.companyId, inboxDismissals.userId, inboxDismissals.itemKey],
|
||||||
|
set: {
|
||||||
|
dismissedAt,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ export { financeService } from "./finance.js";
|
||||||
export { heartbeatService } from "./heartbeat.js";
|
export { heartbeatService } from "./heartbeat.js";
|
||||||
export { dashboardService } from "./dashboard.js";
|
export { dashboardService } from "./dashboard.js";
|
||||||
export { sidebarBadgeService } from "./sidebar-badges.js";
|
export { sidebarBadgeService } from "./sidebar-badges.js";
|
||||||
|
export { inboxDismissalService } from "./inbox-dismissals.js";
|
||||||
export { accessService } from "./access.js";
|
export { accessService } from "./access.js";
|
||||||
export { boardAuthService } from "./board-auth.js";
|
export { boardAuthService } from "./board-auth.js";
|
||||||
export { instanceSettingsService } from "./instance-settings.js";
|
export { instanceSettingsService } from "./instance-settings.js";
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
|
import { and, desc, eq, inArray, not } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import { agents, approvals, heartbeatRuns } from "@paperclipai/db";
|
import { agents, approvals, heartbeatRuns } from "@paperclipai/db";
|
||||||
import type { SidebarBadges } from "@paperclipai/shared";
|
import type { SidebarBadges } from "@paperclipai/shared";
|
||||||
|
|
@ -6,14 +6,34 @@ import type { SidebarBadges } from "@paperclipai/shared";
|
||||||
const ACTIONABLE_APPROVAL_STATUSES = ["pending", "revision_requested"];
|
const ACTIONABLE_APPROVAL_STATUSES = ["pending", "revision_requested"];
|
||||||
const FAILED_HEARTBEAT_STATUSES = ["failed", "timed_out"];
|
const FAILED_HEARTBEAT_STATUSES = ["failed", "timed_out"];
|
||||||
|
|
||||||
|
function normalizeTimestamp(value: Date | string | null | undefined): number {
|
||||||
|
if (!value) return 0;
|
||||||
|
const timestamp = new Date(value).getTime();
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDismissed(
|
||||||
|
dismissedAtByKey: ReadonlyMap<string, number>,
|
||||||
|
itemKey: string,
|
||||||
|
activityAt: Date | string | null | undefined,
|
||||||
|
) {
|
||||||
|
const dismissedAt = dismissedAtByKey.get(itemKey);
|
||||||
|
if (dismissedAt == null) return false;
|
||||||
|
return dismissedAt >= normalizeTimestamp(activityAt);
|
||||||
|
}
|
||||||
|
|
||||||
export function sidebarBadgeService(db: Db) {
|
export function sidebarBadgeService(db: Db) {
|
||||||
return {
|
return {
|
||||||
get: async (
|
get: async (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
extra?: { joinRequests?: number; unreadTouchedIssues?: number },
|
extra?: {
|
||||||
|
dismissals?: ReadonlyMap<string, number>;
|
||||||
|
joinRequests?: Array<{ id: string; updatedAt: Date | string | null; createdAt: Date | string }>;
|
||||||
|
unreadTouchedIssues?: number;
|
||||||
|
},
|
||||||
): Promise<SidebarBadges> => {
|
): Promise<SidebarBadges> => {
|
||||||
const actionableApprovals = await db
|
const actionableApprovals = await db
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ id: approvals.id, updatedAt: approvals.updatedAt })
|
||||||
.from(approvals)
|
.from(approvals)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
|
|
@ -21,11 +41,15 @@ export function sidebarBadgeService(db: Db) {
|
||||||
inArray(approvals.status, ACTIONABLE_APPROVAL_STATUSES),
|
inArray(approvals.status, ACTIONABLE_APPROVAL_STATUSES),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.then((rows) => Number(rows[0]?.count ?? 0));
|
.then((rows) =>
|
||||||
|
rows.filter((row) => !isDismissed(extra?.dismissals ?? new Map(), `approval:${row.id}`, row.updatedAt)).length
|
||||||
|
);
|
||||||
|
|
||||||
const latestRunByAgent = await db
|
const latestRunByAgent = await db
|
||||||
.selectDistinctOn([heartbeatRuns.agentId], {
|
.selectDistinctOn([heartbeatRuns.agentId], {
|
||||||
|
id: heartbeatRuns.id,
|
||||||
runStatus: heartbeatRuns.status,
|
runStatus: heartbeatRuns.status,
|
||||||
|
createdAt: heartbeatRuns.createdAt,
|
||||||
})
|
})
|
||||||
.from(heartbeatRuns)
|
.from(heartbeatRuns)
|
||||||
.innerJoin(agents, eq(heartbeatRuns.agentId, agents.id))
|
.innerJoin(agents, eq(heartbeatRuns.agentId, agents.id))
|
||||||
|
|
@ -39,10 +63,17 @@ export function sidebarBadgeService(db: Db) {
|
||||||
.orderBy(heartbeatRuns.agentId, desc(heartbeatRuns.createdAt));
|
.orderBy(heartbeatRuns.agentId, desc(heartbeatRuns.createdAt));
|
||||||
|
|
||||||
const failedRuns = latestRunByAgent.filter((row) =>
|
const failedRuns = latestRunByAgent.filter((row) =>
|
||||||
FAILED_HEARTBEAT_STATUSES.includes(row.runStatus),
|
FAILED_HEARTBEAT_STATUSES.includes(row.runStatus)
|
||||||
|
&& !isDismissed(extra?.dismissals ?? new Map(), `run:${row.id}`, row.createdAt),
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
const joinRequests = extra?.joinRequests ?? 0;
|
const joinRequests = (extra?.joinRequests ?? []).filter((row) =>
|
||||||
|
!isDismissed(
|
||||||
|
extra?.dismissals ?? new Map(),
|
||||||
|
`join:${row.id}`,
|
||||||
|
row.updatedAt ?? row.createdAt,
|
||||||
|
)
|
||||||
|
).length;
|
||||||
const unreadTouchedIssues = extra?.unreadTouchedIssues ?? 0;
|
const unreadTouchedIssues = extra?.unreadTouchedIssues ?? 0;
|
||||||
return {
|
return {
|
||||||
inbox: actionableApprovals + failedRuns + joinRequests + unreadTouchedIssues,
|
inbox: actionableApprovals + failedRuns + joinRequests + unreadTouchedIssues,
|
||||||
|
|
|
||||||
8
ui/src/api/inboxDismissals.ts
Normal file
8
ui/src/api/inboxDismissals.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import type { InboxDismissal } from "@paperclipai/shared";
|
||||||
|
import { api } from "./client";
|
||||||
|
|
||||||
|
export const inboxDismissalsApi = {
|
||||||
|
list: (companyId: string) => api.get<InboxDismissal[]>(`/companies/${companyId}/inbox-dismissals`),
|
||||||
|
dismiss: (companyId: string, itemKey: string) =>
|
||||||
|
api.post<InboxDismissal>(`/companies/${companyId}/inbox-dismissals`, { itemKey }),
|
||||||
|
};
|
||||||
|
|
@ -15,4 +15,5 @@ export { dashboardApi } from "./dashboard";
|
||||||
export { heartbeatsApi } from "./heartbeats";
|
export { heartbeatsApi } from "./heartbeats";
|
||||||
export { instanceSettingsApi } from "./instanceSettings";
|
export { instanceSettingsApi } from "./instanceSettings";
|
||||||
export { sidebarBadgesApi } from "./sidebarBadges";
|
export { sidebarBadgesApi } from "./sidebarBadges";
|
||||||
|
export { inboxDismissalsApi } from "./inboxDismissals";
|
||||||
export { companySkillsApi } from "./companySkills";
|
export { companySkillsApi } from "./companySkills";
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { accessApi } from "../api/access";
|
import { accessApi } from "../api/access";
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
|
import { inboxDismissalsApi } from "../api/inboxDismissals";
|
||||||
import { approvalsApi } from "../api/approvals";
|
import { approvalsApi } from "../api/approvals";
|
||||||
import { dashboardApi } from "../api/dashboard";
|
import { dashboardApi } from "../api/dashboard";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import {
|
import {
|
||||||
|
buildInboxDismissedAtByKey,
|
||||||
computeInboxBadgeData,
|
computeInboxBadgeData,
|
||||||
getRecentTouchedIssues,
|
getRecentTouchedIssues,
|
||||||
loadDismissedInboxItems,
|
loadDismissedInboxAlerts,
|
||||||
saveDismissedInboxItems,
|
saveDismissedInboxAlerts,
|
||||||
loadReadInboxItems,
|
loadReadInboxItems,
|
||||||
saveReadInboxItems,
|
saveReadInboxItems,
|
||||||
READ_ITEMS_KEY,
|
READ_ITEMS_KEY,
|
||||||
|
|
@ -19,13 +21,13 @@ import {
|
||||||
|
|
||||||
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
|
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
|
||||||
|
|
||||||
export function useDismissedInboxItems() {
|
export function useDismissedInboxAlerts() {
|
||||||
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissedInboxItems);
|
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissedInboxAlerts);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleStorage = (event: StorageEvent) => {
|
const handleStorage = (event: StorageEvent) => {
|
||||||
if (event.key !== "paperclip:inbox:dismissed") return;
|
if (event.key !== "paperclip:inbox:dismissed") return;
|
||||||
setDismissed(loadDismissedInboxItems());
|
setDismissed(loadDismissedInboxAlerts());
|
||||||
};
|
};
|
||||||
window.addEventListener("storage", handleStorage);
|
window.addEventListener("storage", handleStorage);
|
||||||
return () => window.removeEventListener("storage", handleStorage);
|
return () => window.removeEventListener("storage", handleStorage);
|
||||||
|
|
@ -35,7 +37,7 @@ export function useDismissedInboxItems() {
|
||||||
setDismissed((prev) => {
|
setDismissed((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.add(id);
|
next.add(id);
|
||||||
saveDismissedInboxItems(next);
|
saveDismissedInboxAlerts(next);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -43,6 +45,63 @@ export function useDismissedInboxItems() {
|
||||||
return { dismissed, dismiss };
|
return { dismissed, dismiss };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useInboxDismissals(companyId: string | null | undefined) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const queryKey = companyId
|
||||||
|
? queryKeys.inboxDismissals(companyId)
|
||||||
|
: ["inbox-dismissals", "__disabled__"] as const;
|
||||||
|
|
||||||
|
const { data: dismissals = [] } = useQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn: () => inboxDismissalsApi.list(companyId!),
|
||||||
|
enabled: !!companyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dismissMutation = useMutation({
|
||||||
|
mutationFn: ({ itemKey }: { itemKey: string }) => inboxDismissalsApi.dismiss(companyId!, itemKey),
|
||||||
|
onMutate: async ({ itemKey }) => {
|
||||||
|
if (!companyId) return { previous: [] as typeof dismissals };
|
||||||
|
await queryClient.cancelQueries({ queryKey });
|
||||||
|
const previous = queryClient.getQueryData<typeof dismissals>(queryKey) ?? [];
|
||||||
|
const now = new Date();
|
||||||
|
queryClient.setQueryData(queryKey, [
|
||||||
|
{
|
||||||
|
id: `optimistic:${itemKey}`,
|
||||||
|
companyId,
|
||||||
|
userId: "me",
|
||||||
|
itemKey,
|
||||||
|
dismissedAt: now,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
...previous.filter((dismissal) => dismissal.itemKey !== itemKey),
|
||||||
|
]);
|
||||||
|
return { previous };
|
||||||
|
},
|
||||||
|
onError: (_error, _variables, context) => {
|
||||||
|
if (!context) return;
|
||||||
|
queryClient.setQueryData(queryKey, context.previous);
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
if (!companyId) return;
|
||||||
|
queryClient.invalidateQueries({ queryKey });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dismissedAtByKey = useMemo(
|
||||||
|
() => buildInboxDismissedAtByKey(dismissals),
|
||||||
|
[dismissals],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dismissals,
|
||||||
|
dismissedAtByKey,
|
||||||
|
dismiss: (itemKey: string) => dismissMutation.mutate({ itemKey }),
|
||||||
|
isPending: dismissMutation.isPending,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function useReadInboxItems() {
|
export function useReadInboxItems() {
|
||||||
const [readItems, setReadItems] = useState<Set<string>>(loadReadInboxItems);
|
const [readItems, setReadItems] = useState<Set<string>>(loadReadInboxItems);
|
||||||
|
|
||||||
|
|
@ -77,7 +136,8 @@ export function useReadInboxItems() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useInboxBadge(companyId: string | null | undefined) {
|
export function useInboxBadge(companyId: string | null | undefined) {
|
||||||
const { dismissed } = useDismissedInboxItems();
|
const { dismissed: dismissedAlerts } = useDismissedInboxAlerts();
|
||||||
|
const { dismissedAtByKey } = useInboxDismissals(companyId);
|
||||||
|
|
||||||
const { data: approvals = [] } = useQuery({
|
const { data: approvals = [] } = useQuery({
|
||||||
queryKey: queryKeys.approvals.list(companyId!),
|
queryKey: queryKeys.approvals.list(companyId!),
|
||||||
|
|
@ -134,8 +194,9 @@ export function useInboxBadge(companyId: string | null | undefined) {
|
||||||
dashboard,
|
dashboard,
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
mineIssues,
|
mineIssues,
|
||||||
dismissed,
|
dismissedAlerts,
|
||||||
|
dismissedAtByKey,
|
||||||
}),
|
}),
|
||||||
[approvals, joinRequests, dashboard, heartbeatRuns, mineIssues, dismissed],
|
[approvals, joinRequests, dashboard, heartbeatRuns, mineIssues, dismissedAlerts, dismissedAtByKey],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import type {
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import {
|
import {
|
||||||
DEFAULT_INBOX_ISSUE_COLUMNS,
|
DEFAULT_INBOX_ISSUE_COLUMNS,
|
||||||
|
buildInboxDismissedAtByKey,
|
||||||
computeInboxBadgeData,
|
computeInboxBadgeData,
|
||||||
getAvailableInboxIssueColumns,
|
getAvailableInboxIssueColumns,
|
||||||
getApprovalsForTab,
|
getApprovalsForTab,
|
||||||
|
|
@ -19,6 +20,7 @@ import {
|
||||||
getInboxKeyboardSelectionIndex,
|
getInboxKeyboardSelectionIndex,
|
||||||
getRecentTouchedIssues,
|
getRecentTouchedIssues,
|
||||||
getUnreadTouchedIssues,
|
getUnreadTouchedIssues,
|
||||||
|
isInboxEntityDismissed,
|
||||||
isMineInboxTab,
|
isMineInboxTab,
|
||||||
loadInboxIssueColumns,
|
loadInboxIssueColumns,
|
||||||
loadLastInboxTab,
|
loadLastInboxTab,
|
||||||
|
|
@ -286,7 +288,8 @@ describe("inbox helpers", () => {
|
||||||
makeRun("run-other-agent", "failed", "2026-03-11T02:00:00.000Z", "agent-2"),
|
makeRun("run-other-agent", "failed", "2026-03-11T02:00:00.000Z", "agent-2"),
|
||||||
],
|
],
|
||||||
mineIssues: [makeIssue("1", true)],
|
mineIssues: [makeIssue("1", true)],
|
||||||
dismissed: new Set<string>(),
|
dismissedAlerts: new Set<string>(),
|
||||||
|
dismissedAtByKey: new Map<string, number>(),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
|
|
@ -306,7 +309,8 @@ describe("inbox helpers", () => {
|
||||||
dashboard,
|
dashboard,
|
||||||
heartbeatRuns: [makeRun("run-1", "failed", "2026-03-11T00:00:00.000Z")],
|
heartbeatRuns: [makeRun("run-1", "failed", "2026-03-11T00:00:00.000Z")],
|
||||||
mineIssues: [],
|
mineIssues: [],
|
||||||
dismissed: new Set<string>(["run:run-1", "alert:budget", "alert:agent-errors"]),
|
dismissedAlerts: new Set<string>(["alert:budget", "alert:agent-errors"]),
|
||||||
|
dismissedAtByKey: new Map<string, number>([["run:run-1", new Date("2026-03-11T00:00:00.000Z").getTime()]]),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
|
|
@ -326,7 +330,7 @@ describe("inbox helpers", () => {
|
||||||
dashboard,
|
dashboard,
|
||||||
heartbeatRuns: [],
|
heartbeatRuns: [],
|
||||||
mineIssues: [makeIssue("1", false), makeIssue("2", false), makeIssue("3", true)],
|
mineIssues: [makeIssue("1", false), makeIssue("2", false), makeIssue("3", true)],
|
||||||
dismissed: new Set<string>(),
|
dismissedAtByKey: new Map(),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.mineIssues).toBe(1);
|
expect(result.mineIssues).toBe(1);
|
||||||
|
|
@ -334,6 +338,35 @@ describe("inbox helpers", () => {
|
||||||
expect(result.inbox).toBe(3);
|
expect(result.inbox).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resurfaces non-issue items when they change after dismissal", () => {
|
||||||
|
const dismissedAtByKey = buildInboxDismissedAtByKey([
|
||||||
|
{
|
||||||
|
id: "dismissal-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
userId: "user-1",
|
||||||
|
itemKey: "approval:approval-1",
|
||||||
|
dismissedAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||||
|
createdAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isInboxEntityDismissed(
|
||||||
|
dismissedAtByKey,
|
||||||
|
"approval:approval-1",
|
||||||
|
new Date("2026-03-11T00:30:00.000Z"),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isInboxEntityDismissed(
|
||||||
|
dismissedAtByKey,
|
||||||
|
"approval:approval-1",
|
||||||
|
new Date("2026-03-11T01:30:00.000Z"),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps read issues in the touched list but excludes them from unread counts", () => {
|
it("keeps read issues in the touched list but excludes them from unread counts", () => {
|
||||||
const issues = [makeIssue("1", true), makeIssue("2", false)];
|
const issues = [makeIssue("1", true), makeIssue("2", false)];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,11 @@
|
||||||
import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
import type {
|
||||||
|
Approval,
|
||||||
|
DashboardSummary,
|
||||||
|
HeartbeatRun,
|
||||||
|
InboxDismissal,
|
||||||
|
Issue,
|
||||||
|
JoinRequest,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
|
||||||
export const RECENT_ISSUES_LIMIT = 100;
|
export const RECENT_ISSUES_LIMIT = 100;
|
||||||
export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
|
export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
|
||||||
|
|
@ -43,16 +50,19 @@ export interface InboxBadgeData {
|
||||||
alerts: number;
|
alerts: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadDismissedInboxItems(): Set<string> {
|
export function loadDismissedInboxAlerts(): Set<string> {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(DISMISSED_KEY);
|
const raw = localStorage.getItem(DISMISSED_KEY);
|
||||||
return raw ? new Set(JSON.parse(raw)) : new Set();
|
if (!raw) return new Set();
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return new Set();
|
||||||
|
return new Set(parsed.filter((value): value is string => typeof value === "string" && value.startsWith("alert:")));
|
||||||
} catch {
|
} catch {
|
||||||
return new Set();
|
return new Set();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveDismissedInboxItems(ids: Set<string>) {
|
export function saveDismissedInboxAlerts(ids: Set<string>) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids]));
|
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids]));
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -60,6 +70,22 @@ export function saveDismissedInboxItems(ids: Set<string>) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildInboxDismissedAtByKey(dismissals: InboxDismissal[]): Map<string, number> {
|
||||||
|
return new Map(
|
||||||
|
dismissals.map((dismissal) => [dismissal.itemKey, normalizeTimestamp(dismissal.dismissedAt)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInboxEntityDismissed(
|
||||||
|
dismissedAtByKey: ReadonlyMap<string, number>,
|
||||||
|
itemKey: string,
|
||||||
|
activityAt: string | Date | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
const dismissedAt = dismissedAtByKey.get(itemKey);
|
||||||
|
if (dismissedAt == null) return false;
|
||||||
|
return dismissedAt >= normalizeTimestamp(activityAt);
|
||||||
|
}
|
||||||
|
|
||||||
export function loadReadInboxItems(): Set<string> {
|
export function loadReadInboxItems(): Set<string> {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(READ_ITEMS_KEY);
|
const raw = localStorage.getItem(READ_ITEMS_KEY);
|
||||||
|
|
@ -342,25 +368,27 @@ export function computeInboxBadgeData({
|
||||||
dashboard,
|
dashboard,
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
mineIssues,
|
mineIssues,
|
||||||
dismissed,
|
dismissedAlerts,
|
||||||
|
dismissedAtByKey,
|
||||||
}: {
|
}: {
|
||||||
approvals: Approval[];
|
approvals: Approval[];
|
||||||
joinRequests: JoinRequest[];
|
joinRequests: JoinRequest[];
|
||||||
dashboard: DashboardSummary | undefined;
|
dashboard: DashboardSummary | undefined;
|
||||||
heartbeatRuns: HeartbeatRun[];
|
heartbeatRuns: HeartbeatRun[];
|
||||||
mineIssues: Issue[];
|
mineIssues: Issue[];
|
||||||
dismissed: Set<string>;
|
dismissedAlerts: Set<string>;
|
||||||
|
dismissedAtByKey: ReadonlyMap<string, number>;
|
||||||
}): InboxBadgeData {
|
}): InboxBadgeData {
|
||||||
const actionableApprovals = approvals.filter(
|
const actionableApprovals = approvals.filter(
|
||||||
(approval) =>
|
(approval) =>
|
||||||
ACTIONABLE_APPROVAL_STATUSES.has(approval.status) &&
|
ACTIONABLE_APPROVAL_STATUSES.has(approval.status) &&
|
||||||
!dismissed.has(`approval:${approval.id}`),
|
!isInboxEntityDismissed(dismissedAtByKey, `approval:${approval.id}`, approval.updatedAt),
|
||||||
).length;
|
).length;
|
||||||
const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter(
|
const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter(
|
||||||
(run) => !dismissed.has(`run:${run.id}`),
|
(run) => !isInboxEntityDismissed(dismissedAtByKey, `run:${run.id}`, run.createdAt),
|
||||||
).length;
|
).length;
|
||||||
const visibleJoinRequests = joinRequests.filter(
|
const visibleJoinRequests = joinRequests.filter(
|
||||||
(jr) => !dismissed.has(`join:${jr.id}`),
|
(jr) => !isInboxEntityDismissed(dismissedAtByKey, `join:${jr.id}`, jr.updatedAt ?? jr.createdAt),
|
||||||
).length;
|
).length;
|
||||||
const visibleMineIssues = mineIssues.filter((issue) => issue.isUnreadForMe).length;
|
const visibleMineIssues = mineIssues.filter((issue) => issue.isUnreadForMe).length;
|
||||||
const agentErrorCount = dashboard?.agents.error ?? 0;
|
const agentErrorCount = dashboard?.agents.error ?? 0;
|
||||||
|
|
@ -369,11 +397,11 @@ export function computeInboxBadgeData({
|
||||||
const showAggregateAgentError =
|
const showAggregateAgentError =
|
||||||
agentErrorCount > 0 &&
|
agentErrorCount > 0 &&
|
||||||
failedRuns === 0 &&
|
failedRuns === 0 &&
|
||||||
!dismissed.has("alert:agent-errors");
|
!dismissedAlerts.has("alert:agent-errors");
|
||||||
const showBudgetAlert =
|
const showBudgetAlert =
|
||||||
monthBudgetCents > 0 &&
|
monthBudgetCents > 0 &&
|
||||||
monthUtilizationPercent >= 80 &&
|
monthUtilizationPercent >= 80 &&
|
||||||
!dismissed.has("alert:budget");
|
!dismissedAlerts.has("alert:budget");
|
||||||
const alerts = Number(showAggregateAgentError) + Number(showBudgetAlert);
|
const alerts = Number(showAggregateAgentError) + Number(showBudgetAlert);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ export const queryKeys = {
|
||||||
},
|
},
|
||||||
dashboard: (companyId: string) => ["dashboard", companyId] as const,
|
dashboard: (companyId: string) => ["dashboard", companyId] as const,
|
||||||
sidebarBadges: (companyId: string) => ["sidebar-badges", companyId] as const,
|
sidebarBadges: (companyId: string) => ["sidebar-badges", companyId] as const,
|
||||||
|
inboxDismissals: (companyId: string) => ["inbox-dismissals", companyId] as const,
|
||||||
activity: (companyId: string) => ["activity", companyId] as const,
|
activity: (companyId: string) => ["activity", companyId] as const,
|
||||||
costs: (companyId: string, from?: string, to?: string) =>
|
costs: (companyId: string, from?: string, to?: string) =>
|
||||||
["costs", companyId, from, to] as const,
|
["costs", companyId, from, to] as const,
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ import {
|
||||||
getInboxKeyboardSelectionIndex,
|
getInboxKeyboardSelectionIndex,
|
||||||
getLatestFailedRunsByAgent,
|
getLatestFailedRunsByAgent,
|
||||||
getRecentTouchedIssues,
|
getRecentTouchedIssues,
|
||||||
|
isInboxEntityDismissed,
|
||||||
isMineInboxTab,
|
isMineInboxTab,
|
||||||
loadInboxIssueColumns,
|
loadInboxIssueColumns,
|
||||||
normalizeInboxIssueColumns,
|
normalizeInboxIssueColumns,
|
||||||
|
|
@ -99,7 +100,7 @@ import {
|
||||||
type InboxTab,
|
type InboxTab,
|
||||||
type InboxWorkItem,
|
type InboxWorkItem,
|
||||||
} from "../lib/inbox";
|
} from "../lib/inbox";
|
||||||
import { useDismissedInboxItems, useReadInboxItems } from "../hooks/useInboxBadge";
|
import { useDismissedInboxAlerts, useInboxDismissals, useReadInboxItems } from "../hooks/useInboxBadge";
|
||||||
|
|
||||||
type InboxCategoryFilter =
|
type InboxCategoryFilter =
|
||||||
| "everything"
|
| "everything"
|
||||||
|
|
@ -826,7 +827,8 @@ export function Inbox() {
|
||||||
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
|
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
|
||||||
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
|
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
|
||||||
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns);
|
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns);
|
||||||
const { dismissed, dismiss } = useDismissedInboxItems();
|
const { dismissed: dismissedAlerts, dismiss: dismissAlert } = useDismissedInboxAlerts();
|
||||||
|
const { dismissedAtByKey, dismiss: dismissInboxItem } = useInboxDismissals(selectedCompanyId);
|
||||||
const { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems();
|
const { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems();
|
||||||
|
|
||||||
const pathSegment = location.pathname.split("/").pop() ?? "mine";
|
const pathSegment = location.pathname.split("/").pop() ?? "mine";
|
||||||
|
|
@ -1033,8 +1035,11 @@ export function Inbox() {
|
||||||
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
|
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
|
||||||
|
|
||||||
const failedRuns = useMemo(
|
const failedRuns = useMemo(
|
||||||
() => getLatestFailedRunsByAgent(heartbeatRuns ?? []).filter((r) => !dismissed.has(`run:${r.id}`)),
|
() =>
|
||||||
[heartbeatRuns, dismissed],
|
getLatestFailedRunsByAgent(heartbeatRuns ?? []).filter(
|
||||||
|
(r) => !isInboxEntityDismissed(dismissedAtByKey, `run:${r.id}`, r.createdAt),
|
||||||
|
),
|
||||||
|
[heartbeatRuns, dismissedAtByKey],
|
||||||
);
|
);
|
||||||
const liveIssueIds = useMemo(() => {
|
const liveIssueIds = useMemo(() => {
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
|
|
@ -1049,10 +1054,12 @@ export function Inbox() {
|
||||||
const approvalsToRender = useMemo(() => {
|
const approvalsToRender = useMemo(() => {
|
||||||
let filtered = getApprovalsForTab(approvals ?? [], tab, allApprovalFilter);
|
let filtered = getApprovalsForTab(approvals ?? [], tab, allApprovalFilter);
|
||||||
if (tab === "mine") {
|
if (tab === "mine") {
|
||||||
filtered = filtered.filter((a) => !dismissed.has(`approval:${a.id}`));
|
filtered = filtered.filter(
|
||||||
|
(a) => !isInboxEntityDismissed(dismissedAtByKey, `approval:${a.id}`, a.updatedAt),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return filtered;
|
return filtered;
|
||||||
}, [approvals, tab, allApprovalFilter, dismissed]);
|
}, [approvals, tab, allApprovalFilter, dismissedAtByKey]);
|
||||||
const showJoinRequestsCategory =
|
const showJoinRequestsCategory =
|
||||||
allCategoryFilter === "everything" || allCategoryFilter === "join_requests";
|
allCategoryFilter === "everything" || allCategoryFilter === "join_requests";
|
||||||
const showTouchedCategory =
|
const showTouchedCategory =
|
||||||
|
|
@ -1069,9 +1076,13 @@ export function Inbox() {
|
||||||
|
|
||||||
const joinRequestsForTab = useMemo(() => {
|
const joinRequestsForTab = useMemo(() => {
|
||||||
if (tab === "all" && !showJoinRequestsCategory) return [];
|
if (tab === "all" && !showJoinRequestsCategory) return [];
|
||||||
if (tab === "mine") return joinRequests.filter((jr) => !dismissed.has(`join:${jr.id}`));
|
if (tab === "mine") {
|
||||||
|
return joinRequests.filter(
|
||||||
|
(jr) => !isInboxEntityDismissed(dismissedAtByKey, `join:${jr.id}`, jr.updatedAt ?? jr.createdAt),
|
||||||
|
);
|
||||||
|
}
|
||||||
return joinRequests;
|
return joinRequests;
|
||||||
}, [joinRequests, tab, showJoinRequestsCategory, dismissed]);
|
}, [joinRequests, tab, showJoinRequestsCategory, dismissedAtByKey]);
|
||||||
|
|
||||||
const workItemsToRender = useMemo(
|
const workItemsToRender = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -1385,14 +1396,18 @@ export function Inbox() {
|
||||||
const handleArchiveNonIssue = useCallback((key: string) => {
|
const handleArchiveNonIssue = useCallback((key: string) => {
|
||||||
setArchivingNonIssueIds((prev) => new Set(prev).add(key));
|
setArchivingNonIssueIds((prev) => new Set(prev).add(key));
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
dismiss(key);
|
if (key.startsWith("alert:")) {
|
||||||
|
dismissAlert(key);
|
||||||
|
} else {
|
||||||
|
dismissInboxItem(key);
|
||||||
|
}
|
||||||
setArchivingNonIssueIds((prev) => {
|
setArchivingNonIssueIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.delete(key);
|
next.delete(key);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, 200);
|
}, 200);
|
||||||
}, [dismiss]);
|
}, [dismissAlert, dismissInboxItem]);
|
||||||
|
|
||||||
const nonIssueUnreadState = (key: string): NonIssueUnreadState => {
|
const nonIssueUnreadState = (key: string): NonIssueUnreadState => {
|
||||||
if (!canArchiveFromTab) return null;
|
if (!canArchiveFromTab) return null;
|
||||||
|
|
@ -1575,12 +1590,16 @@ export function Inbox() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasRunFailures = failedRuns.length > 0;
|
const hasRunFailures = failedRuns.length > 0;
|
||||||
const showAggregateAgentError = !!dashboard && dashboard.agents.error > 0 && !hasRunFailures && !dismissed.has("alert:agent-errors");
|
const showAggregateAgentError =
|
||||||
|
!!dashboard &&
|
||||||
|
dashboard.agents.error > 0 &&
|
||||||
|
!hasRunFailures &&
|
||||||
|
!dismissedAlerts.has("alert:agent-errors");
|
||||||
const showBudgetAlert =
|
const showBudgetAlert =
|
||||||
!!dashboard &&
|
!!dashboard &&
|
||||||
dashboard.costs.monthBudgetCents > 0 &&
|
dashboard.costs.monthBudgetCents > 0 &&
|
||||||
dashboard.costs.monthUtilizationPercent >= 80 &&
|
dashboard.costs.monthUtilizationPercent >= 80 &&
|
||||||
!dismissed.has("alert:budget");
|
!dismissedAlerts.has("alert:budget");
|
||||||
const hasAlerts = showAggregateAgentError || showBudgetAlert;
|
const hasAlerts = showAggregateAgentError || showBudgetAlert;
|
||||||
const showWorkItemsSection = filteredWorkItems.length > 0;
|
const showWorkItemsSection = filteredWorkItems.length > 0;
|
||||||
const showAlertsSection = shouldShowInboxSection({
|
const showAlertsSection = shouldShowInboxSection({
|
||||||
|
|
@ -1891,7 +1910,7 @@ export function Inbox() {
|
||||||
issueById={issueById}
|
issueById={issueById}
|
||||||
agentName={agentName(item.run.agentId)}
|
agentName={agentName(item.run.agentId)}
|
||||||
issueLinkState={issueLinkState}
|
issueLinkState={issueLinkState}
|
||||||
onDismiss={() => dismiss(runKey)}
|
onDismiss={() => dismissInboxItem(runKey)}
|
||||||
onRetry={() => retryRunMutation.mutate(item.run)}
|
onRetry={() => retryRunMutation.mutate(item.run)}
|
||||||
isRetrying={retryingRunIds.has(item.run.id)}
|
isRetrying={retryingRunIds.has(item.run.id)}
|
||||||
unreadState={nonIssueUnreadState(runKey)}
|
unreadState={nonIssueUnreadState(runKey)}
|
||||||
|
|
@ -2049,7 +2068,7 @@ export function Inbox() {
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => dismiss("alert:agent-errors")}
|
onClick={() => dismissAlert("alert:agent-errors")}
|
||||||
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/alert:opacity-100"
|
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/alert:opacity-100"
|
||||||
aria-label="Dismiss"
|
aria-label="Dismiss"
|
||||||
>
|
>
|
||||||
|
|
@ -2072,7 +2091,7 @@ export function Inbox() {
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => dismiss("alert:budget")}
|
onClick={() => dismissAlert("alert:budget")}
|
||||||
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/alert:opacity-100"
|
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/alert:opacity-100"
|
||||||
aria-label="Dismiss"
|
aria-label="Dismiss"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue