feat(platform): harden access scoping and delivery baseline

This commit is contained in:
2026-03-30 00:27:31 +02:00
parent 00b936fa1f
commit 819345acfa
109 changed files with 26142 additions and 8081 deletions
+155
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { PermissionKey, parseTaskAction, resolvePermissions } from "@capakraken/shared";
import { findUniqueOrThrow } from "../db/helpers.js";
import { createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js";
import {
@@ -12,6 +13,7 @@ import {
import { createNotification } from "../lib/create-notification.js";
import { resolveRecipients } from "../lib/notification-targeting.js";
import { sendEmail } from "../lib/email.js";
import { getTaskAction } from "../lib/task-actions.js";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -260,6 +262,49 @@ export const notificationRouter = createTRPCRouter({
};
}),
/** Get one task/approval visible to the current user */
getTaskDetail: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const userId = await resolveUserId(ctx);
const task = await ctx.db.notification.findFirst({
where: {
id: input.id,
OR: [{ userId }, { assigneeId: userId }],
category: { in: ["TASK", "APPROVAL"] },
},
select: {
id: true,
title: true,
body: true,
type: true,
priority: true,
category: true,
taskStatus: true,
taskAction: true,
dueDate: true,
entityId: true,
entityType: true,
completedAt: true,
completedBy: true,
createdAt: true,
userId: true,
assigneeId: true,
sender: { select: { id: true, name: true, email: true } },
},
});
if (!task) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Task not found or you do not have permission",
});
}
return task;
}),
/** Update task status */
updateTaskStatus: protectedProcedure
.input(
@@ -312,6 +357,101 @@ export const notificationRouter = createTRPCRouter({
return updated;
}),
/** Execute the machine-readable action associated with a task */
executeTaskAction: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const userId = await resolveUserId(ctx);
const task = await ctx.db.notification.findFirst({
where: {
id: input.id,
OR: [{ userId }, { assigneeId: userId }],
category: { in: ["TASK", "APPROVAL"] },
},
select: {
id: true,
userId: true,
assigneeId: true,
taskAction: true,
taskStatus: true,
},
});
if (!task) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Task not found or you do not have permission",
});
}
if (!task.taskAction) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "This task has no executable action",
});
}
if (task.taskStatus === "DONE") {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "This task is already completed",
});
}
const parsed = parseTaskAction(task.taskAction);
if (!parsed) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid taskAction format: ${task.taskAction}`,
});
}
const handler = getTaskAction(parsed.action);
if (!handler) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unknown action: ${parsed.action}`,
});
}
const permissions = resolvePermissions(
ctx.dbUser.systemRole as import("@capakraken/shared").SystemRole,
ctx.dbUser.permissionOverrides as import("@capakraken/shared").PermissionOverrides | null,
ctx.roleDefaults ?? undefined,
);
if (handler.permission && !permissions.has(handler.permission as PermissionKey)) {
throw new TRPCError({
code: "FORBIDDEN",
message: `Permission denied: you need "${handler.permission}" to perform this action`,
});
}
const actionResult = await handler.execute(parsed.entityId, ctx.db, userId);
if (!actionResult.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: actionResult.message,
});
}
const completedTask = await ctx.db.notification.update({
where: { id: input.id },
data: {
taskStatus: "DONE",
completedAt: new Date(),
completedBy: userId,
},
});
emitTaskCompleted(task.userId, task.id);
if (task.assigneeId && task.assigneeId !== task.userId) {
emitTaskCompleted(task.assigneeId, task.id);
}
return {
task: completedTask,
actionResult,
};
}),
// ═══════════════════════════════════════════════════════════════════════════
// REMINDERS
// ═══════════════════════════════════════════════════════════════════════════
@@ -542,6 +682,21 @@ export const notificationRouter = createTRPCRouter({
});
}),
/** Get one broadcast with sender context */
getBroadcastById: managerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return findUniqueOrThrow(
ctx.db.notificationBroadcast.findUnique({
where: { id: input.id },
include: {
sender: { select: { id: true, name: true, email: true } },
},
}),
"Broadcast",
);
}),
// ═══════════════════════════════════════════════════════════════════════════
// TASK CREATION (Manager+)
// ═══════════════════════════════════════════════════════════════════════════