fix(comment): align mention audience with entity visibility
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
import { createTRPCRouter, protectedProcedure, type TRPCContext } from "../trpc.js";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
|
||||
import { createNotification } from "../lib/create-notification.js";
|
||||
import { createAuditEntry } from "../lib/audit.js";
|
||||
import { assertCommentEntityAccess, CommentEntityTypeSchema } from "../lib/comment-entity-registry.js";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -22,74 +22,6 @@ function parseMentions(body: string): string[] {
|
||||
return Array.from(ids);
|
||||
}
|
||||
|
||||
const COMMENT_ENTITY_TYPE_VALUES = ["estimate"] as const;
|
||||
const CommentEntityTypeSchema = z.enum(COMMENT_ENTITY_TYPE_VALUES);
|
||||
type CommentEntityType = z.infer<typeof CommentEntityTypeSchema>;
|
||||
|
||||
type CommentEntityPolicy = {
|
||||
assertAccess: (ctx: Pick<TRPCContext, "db" | "dbUser">, entityId: string) => Promise<void>;
|
||||
buildLink: (entityId: string) => string;
|
||||
};
|
||||
|
||||
const CONTROLLER_COMMENT_ROLES = new Set<SystemRole>([
|
||||
SystemRole.ADMIN,
|
||||
SystemRole.MANAGER,
|
||||
SystemRole.CONTROLLER,
|
||||
]);
|
||||
|
||||
async function assertEstimateCommentAccess(ctx: Pick<TRPCContext, "db" | "dbUser">, entityId: string) {
|
||||
const role = ctx.dbUser?.systemRole as SystemRole | undefined;
|
||||
if (!role || !CONTROLLER_COMMENT_ROLES.has(role)) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Controller access required",
|
||||
});
|
||||
}
|
||||
|
||||
const estimate = await ctx.db.estimate.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!estimate) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Estimate not found",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const COMMENT_ENTITY_POLICIES: Record<CommentEntityType, CommentEntityPolicy> = {
|
||||
estimate: {
|
||||
assertAccess: assertEstimateCommentAccess,
|
||||
buildLink: (entityId) => `/estimates/${entityId}?tab=comments`,
|
||||
},
|
||||
};
|
||||
|
||||
function isSupportedCommentEntityType(value: string): value is CommentEntityType {
|
||||
return (COMMENT_ENTITY_TYPE_VALUES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function getCommentEntityPolicy(entityType: string): CommentEntityPolicy {
|
||||
if (!isSupportedCommentEntityType(entityType)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Comments are not supported for entity type: ${entityType}`,
|
||||
});
|
||||
}
|
||||
|
||||
return COMMENT_ENTITY_POLICIES[entityType];
|
||||
}
|
||||
|
||||
async function assertCommentEntityAccess(
|
||||
ctx: Pick<TRPCContext, "db" | "dbUser">,
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
) {
|
||||
const policy = getCommentEntityPolicy(entityType);
|
||||
await policy.assertAccess(ctx, entityId);
|
||||
return policy;
|
||||
}
|
||||
|
||||
// ─── Router ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const commentRouter = createTRPCRouter({
|
||||
@@ -123,6 +55,21 @@ export const commentRouter = createTRPCRouter({
|
||||
});
|
||||
}),
|
||||
|
||||
/** List mention candidates that match the audience of the backing comment entity */
|
||||
listMentionCandidates: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
entityType: CommentEntityTypeSchema,
|
||||
entityId: z.string(),
|
||||
query: z.string().trim().max(100).optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const normalizedQuery = input.query && input.query.length > 0 ? input.query : undefined;
|
||||
const policy = await assertCommentEntityAccess(ctx, input.entityType, input.entityId);
|
||||
return policy.listMentionCandidates(ctx, input.entityId, normalizedQuery);
|
||||
}),
|
||||
|
||||
/** Count comments for a given entity (used for badge) */
|
||||
count: protectedProcedure
|
||||
.input(
|
||||
@@ -253,7 +200,7 @@ export const commentRouter = createTRPCRouter({
|
||||
await assertCommentEntityAccess(ctx, existing.entityType, existing.entityId);
|
||||
|
||||
// Only the author or an admin can resolve
|
||||
const isAdmin = dbUser?.systemRole === SystemRole.ADMIN;
|
||||
const isAdmin = dbUser?.systemRole === "ADMIN";
|
||||
if (existing.authorId !== userId && !isAdmin) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
@@ -302,7 +249,7 @@ export const commentRouter = createTRPCRouter({
|
||||
|
||||
await assertCommentEntityAccess(ctx, existing.entityType, existing.entityId);
|
||||
|
||||
const isAdmin = dbUser?.systemRole === SystemRole.ADMIN;
|
||||
const isAdmin = dbUser?.systemRole === "ADMIN";
|
||||
if (existing.authorId !== userId && !isAdmin) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
|
||||
Reference in New Issue
Block a user