feat(sse): scope timeline events to affected audiences
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { loadRoleDefaults } from "@capakraken/api";
|
import { loadRoleDefaults } from "@capakraken/api";
|
||||||
import { eventBus, permissionAudience, roleAudience, userAudience } from "@capakraken/api/sse";
|
import { eventBus, permissionAudience, resourceAudience, roleAudience, userAudience } from "@capakraken/api/sse";
|
||||||
import { startReminderScheduler } from "@capakraken/api/lib/reminder-scheduler";
|
import { startReminderScheduler } from "@capakraken/api/lib/reminder-scheduler";
|
||||||
import { prisma } from "@capakraken/db";
|
import { prisma } from "@capakraken/db";
|
||||||
import { resolvePermissions, SSE_EVENT_TYPES, SystemRole, type PermissionOverrides } from "@capakraken/shared";
|
import { resolvePermissions, SSE_EVENT_TYPES, SystemRole, type PermissionOverrides } from "@capakraken/shared";
|
||||||
@@ -29,6 +29,11 @@ export async function GET() {
|
|||||||
id: true,
|
id: true,
|
||||||
systemRole: true,
|
systemRole: true,
|
||||||
permissionOverrides: true,
|
permissionOverrides: true,
|
||||||
|
resource: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,6 +51,9 @@ export async function GET() {
|
|||||||
userAudience(dbUser.id),
|
userAudience(dbUser.id),
|
||||||
roleAudience(dbUser.systemRole),
|
roleAudience(dbUser.systemRole),
|
||||||
]);
|
]);
|
||||||
|
if (dbUser.resource?.id) {
|
||||||
|
audiences.add(resourceAudience(dbUser.resource.id));
|
||||||
|
}
|
||||||
for (const permission of permissions) {
|
for (const permission of permissions) {
|
||||||
audiences.add(permissionAudience(permission));
|
audiences.add(permissionAudience(permission));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export function useTimelineSSE() {
|
|||||||
case SSE_EVENT_TYPES.ALLOCATION_CREATED:
|
case SSE_EVENT_TYPES.ALLOCATION_CREATED:
|
||||||
case SSE_EVENT_TYPES.ALLOCATION_UPDATED:
|
case SSE_EVENT_TYPES.ALLOCATION_UPDATED:
|
||||||
case SSE_EVENT_TYPES.ALLOCATION_DELETED:
|
case SSE_EVENT_TYPES.ALLOCATION_DELETED:
|
||||||
|
case SSE_EVENT_TYPES.VACATION_CREATED:
|
||||||
|
case SSE_EVENT_TYPES.VACATION_UPDATED:
|
||||||
|
case SSE_EVENT_TYPES.VACATION_DELETED:
|
||||||
void queryClient.invalidateQueries({ queryKey: [["timeline", "getEntries"]] });
|
void queryClient.invalidateQueries({ queryKey: [["timeline", "getEntries"]] });
|
||||||
void queryClient.invalidateQueries({ queryKey: [["timeline", "getEntriesView"]] });
|
void queryClient.invalidateQueries({ queryKey: [["timeline", "getEntriesView"]] });
|
||||||
void queryClient.invalidateQueries({ queryKey: [["timeline", "getMyEntriesView"]] });
|
void queryClient.invalidateQueries({ queryKey: [["timeline", "getMyEntriesView"]] });
|
||||||
|
|||||||
@@ -113,6 +113,15 @@ Routes in [timeline.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/
|
|||||||
| `getShiftPreviewDetail` | `controllerProcedure` | detail variant includes project metadata plus cost/conflict preview |
|
| `getShiftPreviewDetail` | `controllerProcedure` | detail variant includes project metadata plus cost/conflict preview |
|
||||||
| `getBudgetStatus` | `controllerProcedure` | budget burn/remaining exposure is commercial data |
|
| `getBudgetStatus` | `controllerProcedure` | budget burn/remaining exposure is commercial data |
|
||||||
|
|
||||||
|
### Timeline SSE
|
||||||
|
|
||||||
|
The live-update path in [event-bus.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/sse/event-bus.ts) and [route.ts](/home/hartmut/Documents/Copilot/capakraken/apps/web/src/app/api/sse/timeline/route.ts) now follows the same audience model as the timeline reads:
|
||||||
|
|
||||||
|
- planning staff subscribe through role/permission audiences
|
||||||
|
- linked users additionally subscribe to their own `resource:<id>` audience
|
||||||
|
- allocation, vacation, and project-shift events fan out to both staff planning audiences and the affected resource audiences
|
||||||
|
- self-service timeline clients invalidate both personal entries and personal holiday overlays on allocation and vacation events
|
||||||
|
|
||||||
## Review Standard
|
## Review Standard
|
||||||
|
|
||||||
- Any new sensitive read route must document one of:
|
- Any new sensitive read route must document one of:
|
||||||
|
|||||||
@@ -999,7 +999,7 @@ describe("allocation entry resolution router", () => {
|
|||||||
expect(db.assignment.delete).toHaveBeenCalledWith({
|
expect(db.assignment.delete).toHaveBeenCalledWith({
|
||||||
where: { id: "assignment_explicit_1" },
|
where: { id: "assignment_explicit_1" },
|
||||||
});
|
});
|
||||||
expect(emitAllocationDeleted).toHaveBeenCalledWith("assignment_explicit_1", "project_1");
|
expect(emitAllocationDeleted).toHaveBeenCalledWith("assignment_explicit_1", "project_1", "resource_1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates an explicit demand row through allocation.update", async () => {
|
it("updates an explicit demand row through allocation.update", async () => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
eventBus,
|
eventBus,
|
||||||
flushPendingEvents,
|
flushPendingEvents,
|
||||||
permissionAudience,
|
permissionAudience,
|
||||||
|
resourceAudience,
|
||||||
type SseEvent,
|
type SseEvent,
|
||||||
userAudience,
|
userAudience,
|
||||||
} from "../sse/event-bus.js";
|
} from "../sse/event-bus.js";
|
||||||
@@ -231,4 +232,37 @@ describe("event-bus debounce", () => {
|
|||||||
unsubscribeFirst();
|
unsubscribeFirst();
|
||||||
unsubscribeSecond();
|
unsubscribeSecond();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("delivers planning events to both staff and the affected resource audience", () => {
|
||||||
|
const managerReceived: SseEvent[] = [];
|
||||||
|
const resourceReceived: SseEvent[] = [];
|
||||||
|
const unsubscribeManager = eventBus.subscribe((event) => {
|
||||||
|
managerReceived.push(event);
|
||||||
|
}, {
|
||||||
|
audiences: [permissionAudience("manageAllocations")],
|
||||||
|
includeUnscoped: false,
|
||||||
|
});
|
||||||
|
const unsubscribeResource = eventBus.subscribe((event) => {
|
||||||
|
resourceReceived.push(event);
|
||||||
|
}, {
|
||||||
|
audiences: [resourceAudience("res_1")],
|
||||||
|
includeUnscoped: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
eventBus.emit(
|
||||||
|
SSE_EVENT_TYPES.ALLOCATION_UPDATED,
|
||||||
|
{ id: "a1", resourceId: "res_1" },
|
||||||
|
[permissionAudience("manageAllocations"), resourceAudience("res_1")],
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
|
||||||
|
expect(managerReceived).toHaveLength(1);
|
||||||
|
expect(resourceReceived).toHaveLength(1);
|
||||||
|
expect(managerReceived[0]!.type).toBe(SSE_EVENT_TYPES.ALLOCATION_UPDATED);
|
||||||
|
expect(resourceReceived[0]!.payload).toEqual({ id: "a1", resourceId: "res_1" });
|
||||||
|
|
||||||
|
unsubscribeManager();
|
||||||
|
unsubscribeResource();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1046,6 +1046,13 @@ export const allocationRouter = createTRPCRouter({
|
|||||||
.input(z.object({ id: z.string(), data: UpdateAssignmentSchema }))
|
.input(z.object({ id: z.string(), data: UpdateAssignmentSchema }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||||
|
const existing = await findUniqueOrThrow(
|
||||||
|
ctx.db.assignment.findUnique({
|
||||||
|
where: { id: input.id },
|
||||||
|
select: { resourceId: true },
|
||||||
|
}),
|
||||||
|
"Assignment",
|
||||||
|
);
|
||||||
|
|
||||||
const updated = await ctx.db.$transaction(async (tx) => {
|
const updated = await ctx.db.$transaction(async (tx) => {
|
||||||
return updateAssignment(
|
return updateAssignment(
|
||||||
@@ -1059,6 +1066,7 @@ export const allocationRouter = createTRPCRouter({
|
|||||||
id: updated.id,
|
id: updated.id,
|
||||||
projectId: updated.projectId,
|
projectId: updated.projectId,
|
||||||
resourceId: updated.resourceId,
|
resourceId: updated.resourceId,
|
||||||
|
resourceIds: [existing.resourceId, updated.resourceId],
|
||||||
});
|
});
|
||||||
dispatchAllocationWebhookInBackground(ctx.db, "allocation.updated", {
|
dispatchAllocationWebhookInBackground(ctx.db, "allocation.updated", {
|
||||||
id: updated.id,
|
id: updated.id,
|
||||||
@@ -1192,6 +1200,7 @@ export const allocationRouter = createTRPCRouter({
|
|||||||
id: updated.id,
|
id: updated.id,
|
||||||
projectId: updated.projectId,
|
projectId: updated.projectId,
|
||||||
resourceId: updated.resourceId,
|
resourceId: updated.resourceId,
|
||||||
|
resourceIds: [existing.entry.resourceId, updated.resourceId],
|
||||||
});
|
});
|
||||||
invalidateDashboardCacheInBackground();
|
invalidateDashboardCacheInBackground();
|
||||||
checkBudgetThresholdsInBackground(ctx.db, updated.projectId);
|
checkBudgetThresholdsInBackground(ctx.db, updated.projectId);
|
||||||
@@ -1228,7 +1237,7 @@ export const allocationRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
emitAllocationDeleted(existing.id, existing.projectId);
|
emitAllocationDeleted(existing.id, existing.projectId, existing.resourceId);
|
||||||
invalidateDashboardCacheInBackground();
|
invalidateDashboardCacheInBackground();
|
||||||
checkBudgetThresholdsInBackground(ctx.db, existing.projectId);
|
checkBudgetThresholdsInBackground(ctx.db, existing.projectId);
|
||||||
|
|
||||||
@@ -1257,7 +1266,7 @@ export const allocationRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
emitAllocationDeleted(existing.entry.id, existing.projectId);
|
emitAllocationDeleted(existing.entry.id, existing.projectId, existing.entry.resourceId);
|
||||||
invalidateDashboardCacheInBackground();
|
invalidateDashboardCacheInBackground();
|
||||||
checkBudgetThresholdsInBackground(ctx.db, existing.projectId);
|
checkBudgetThresholdsInBackground(ctx.db, existing.projectId);
|
||||||
|
|
||||||
@@ -1292,7 +1301,7 @@ export const allocationRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const a of existing) {
|
for (const a of existing) {
|
||||||
emitAllocationDeleted(a.entry.id, a.projectId);
|
emitAllocationDeleted(a.entry.id, a.projectId, a.entry.resourceId);
|
||||||
}
|
}
|
||||||
invalidateDashboardCacheInBackground();
|
invalidateDashboardCacheInBackground();
|
||||||
// Check budget thresholds for each affected project
|
// Check budget thresholds for each affected project
|
||||||
|
|||||||
@@ -1315,6 +1315,7 @@ export const timelineRouter = createTRPCRouter({
|
|||||||
newStartDate: newStartDate.toISOString(),
|
newStartDate: newStartDate.toISOString(),
|
||||||
newEndDate: newEndDate.toISOString(),
|
newEndDate: newEndDate.toISOString(),
|
||||||
costDeltaCents: validation.costImpact.deltaCents,
|
costDeltaCents: validation.costImpact.deltaCents,
|
||||||
|
resourceIds: assignments.map((assignment) => assignment.resourceId),
|
||||||
});
|
});
|
||||||
|
|
||||||
return { project: updatedProject, validation };
|
return { project: updatedProject, validation };
|
||||||
|
|||||||
@@ -68,6 +68,34 @@ function deliverEvent(event: SseEvent): void {
|
|||||||
export const userAudience = (userId: string): SseAudience => `user:${userId}`;
|
export const userAudience = (userId: string): SseAudience => `user:${userId}`;
|
||||||
export const roleAudience = (role: string): SseAudience => `role:${role}`;
|
export const roleAudience = (role: string): SseAudience => `role:${role}`;
|
||||||
export const permissionAudience = (permission: string): SseAudience => `permission:${permission}`;
|
export const permissionAudience = (permission: string): SseAudience => `permission:${permission}`;
|
||||||
|
export const resourceAudience = (resourceId: string): SseAudience => `resource:${resourceId}`;
|
||||||
|
|
||||||
|
function extractScopedResourceIds(payload: Record<string, unknown>): string[] {
|
||||||
|
const resourceIds = new Set<string>();
|
||||||
|
|
||||||
|
const directResourceId = payload["resourceId"];
|
||||||
|
if (typeof directResourceId === "string" && directResourceId.trim().length > 0) {
|
||||||
|
resourceIds.add(directResourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const listedResourceIds = payload["resourceIds"];
|
||||||
|
if (Array.isArray(listedResourceIds)) {
|
||||||
|
for (const resourceId of listedResourceIds) {
|
||||||
|
if (typeof resourceId === "string" && resourceId.trim().length > 0) {
|
||||||
|
resourceIds.add(resourceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...resourceIds];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlanningAudiences(payload: Record<string, unknown>): SseAudience[] {
|
||||||
|
return normalizeAudiences([
|
||||||
|
permissionAudience(PermissionKey.MANAGE_ALLOCATIONS),
|
||||||
|
...extractScopedResourceIds(payload).map((resourceId) => resourceAudience(resourceId)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/** Flush a single event type from the buffer and deliver to subscribers. */
|
/** Flush a single event type from the buffer and deliver to subscribers. */
|
||||||
function flushEventType(type: SseEventType, audience: readonly SseAudience[]): void {
|
function flushEventType(type: SseEventType, audience: readonly SseAudience[]): void {
|
||||||
@@ -250,20 +278,20 @@ setupSubscriber();
|
|||||||
|
|
||||||
// Helper emitters
|
// Helper emitters
|
||||||
export const emitAllocationCreated = (allocation: Record<string, unknown>) =>
|
export const emitAllocationCreated = (allocation: Record<string, unknown>) =>
|
||||||
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_CREATED, allocation, [permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)]);
|
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_CREATED, allocation, buildPlanningAudiences(allocation));
|
||||||
|
|
||||||
export const emitAllocationUpdated = (allocation: Record<string, unknown>) =>
|
export const emitAllocationUpdated = (allocation: Record<string, unknown>) =>
|
||||||
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_UPDATED, allocation, [permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)]);
|
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_UPDATED, allocation, buildPlanningAudiences(allocation));
|
||||||
|
|
||||||
export const emitAllocationDeleted = (allocationId: string, projectId: string) =>
|
export const emitAllocationDeleted = (allocationId: string, projectId: string, resourceId?: string | null) =>
|
||||||
eventBus.emit(
|
eventBus.emit(
|
||||||
SSE_EVENT_TYPES.ALLOCATION_DELETED,
|
SSE_EVENT_TYPES.ALLOCATION_DELETED,
|
||||||
{ allocationId, projectId },
|
{ allocationId, projectId, ...(resourceId ? { resourceId } : {}) },
|
||||||
[permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)],
|
buildPlanningAudiences({ allocationId, projectId, ...(resourceId ? { resourceId } : {}) }),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const emitProjectShifted = (project: Record<string, unknown>) =>
|
export const emitProjectShifted = (project: Record<string, unknown>) =>
|
||||||
eventBus.emit(SSE_EVENT_TYPES.PROJECT_SHIFTED, project, [permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)]);
|
eventBus.emit(SSE_EVENT_TYPES.PROJECT_SHIFTED, project, buildPlanningAudiences(project));
|
||||||
|
|
||||||
export const emitBudgetWarning = (projectId: string, payload: Record<string, unknown>) =>
|
export const emitBudgetWarning = (projectId: string, payload: Record<string, unknown>) =>
|
||||||
eventBus.emit(
|
eventBus.emit(
|
||||||
@@ -273,16 +301,16 @@ export const emitBudgetWarning = (projectId: string, payload: Record<string, unk
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const emitVacationCreated = (vacation: Record<string, unknown>) =>
|
export const emitVacationCreated = (vacation: Record<string, unknown>) =>
|
||||||
eventBus.emit(SSE_EVENT_TYPES.VACATION_CREATED, vacation, [permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)]);
|
eventBus.emit(SSE_EVENT_TYPES.VACATION_CREATED, vacation, buildPlanningAudiences(vacation));
|
||||||
|
|
||||||
export const emitVacationUpdated = (vacation: Record<string, unknown>) =>
|
export const emitVacationUpdated = (vacation: Record<string, unknown>) =>
|
||||||
eventBus.emit(SSE_EVENT_TYPES.VACATION_UPDATED, vacation, [permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)]);
|
eventBus.emit(SSE_EVENT_TYPES.VACATION_UPDATED, vacation, buildPlanningAudiences(vacation));
|
||||||
|
|
||||||
export const emitVacationDeleted = (vacationId: string, resourceId: string) =>
|
export const emitVacationDeleted = (vacationId: string, resourceId: string) =>
|
||||||
eventBus.emit(
|
eventBus.emit(
|
||||||
SSE_EVENT_TYPES.VACATION_DELETED,
|
SSE_EVENT_TYPES.VACATION_DELETED,
|
||||||
{ vacationId, resourceId },
|
{ vacationId, resourceId },
|
||||||
[permissionAudience(PermissionKey.MANAGE_ALLOCATIONS)],
|
buildPlanningAudiences({ vacationId, resourceId }),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const emitRoleCreated = (role: Record<string, unknown>) =>
|
export const emitRoleCreated = (role: Record<string, unknown>) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user