227 lines
6.2 KiB
TypeScript
227 lines
6.2 KiB
TypeScript
import { TRPCError } from "@trpc/server";
|
|
import {
|
|
COMMENT_ENTITY_LABELS,
|
|
COMMENT_ENTITY_TYPE_VALUES,
|
|
PermissionKey,
|
|
resolvePermissions,
|
|
SystemRole,
|
|
type PermissionOverrides,
|
|
} from "@capakraken/shared";
|
|
import type { CommentEntityType } from "@capakraken/shared";
|
|
import { z } from "zod";
|
|
import type { TRPCContext } from "../trpc.js";
|
|
import { assertCanReadResource } from "./resource-access.js";
|
|
|
|
export const CommentEntityTypeSchema = z.enum(COMMENT_ENTITY_TYPE_VALUES);
|
|
|
|
export type CommentMentionCandidate = {
|
|
id: string;
|
|
name: string | null;
|
|
email: string;
|
|
};
|
|
|
|
type CommentEntityPolicy = {
|
|
assertAccess: (ctx: Pick<TRPCContext, "db" | "dbUser" | "roleDefaults">, entityId: string) => Promise<void>;
|
|
buildLink: (entityId: string) => string;
|
|
listMentionCandidates: (
|
|
ctx: Pick<TRPCContext, "db" | "roleDefaults">,
|
|
entityId: string,
|
|
query?: string,
|
|
) => Promise<CommentMentionCandidate[]>;
|
|
};
|
|
|
|
const CONTROLLER_COMMENT_ROLES = new Set<SystemRole>([
|
|
SystemRole.ADMIN,
|
|
SystemRole.MANAGER,
|
|
SystemRole.CONTROLLER,
|
|
]);
|
|
const RESOURCE_STAFF_PERMISSION_KEYS = new Set<PermissionKey>([
|
|
PermissionKey.VIEW_ALL_RESOURCES,
|
|
PermissionKey.MANAGE_RESOURCES,
|
|
]);
|
|
const MENTION_CANDIDATE_LIMIT = 20;
|
|
|
|
function buildMentionSearchWhere(query?: string) {
|
|
if (!query) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
OR: [
|
|
{ name: { contains: query, mode: "insensitive" as const } },
|
|
{ email: { contains: query, mode: "insensitive" as const } },
|
|
],
|
|
};
|
|
}
|
|
|
|
async function listEstimateMentionCandidates(
|
|
ctx: Pick<TRPCContext, "db">,
|
|
_entityId: string,
|
|
query?: string,
|
|
): Promise<CommentMentionCandidate[]> {
|
|
return ctx.db.user.findMany({
|
|
where: {
|
|
systemRole: { in: Array.from(CONTROLLER_COMMENT_ROLES) },
|
|
...buildMentionSearchWhere(query),
|
|
},
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
},
|
|
orderBy: [{ name: "asc" }, { email: "asc" }],
|
|
take: MENTION_CANDIDATE_LIMIT,
|
|
});
|
|
}
|
|
|
|
async function listResourceMentionCandidates(
|
|
ctx: Pick<TRPCContext, "db" | "roleDefaults">,
|
|
entityId: string,
|
|
query?: string,
|
|
): Promise<CommentMentionCandidate[]> {
|
|
const resource = await ctx.db.resource.findUnique({
|
|
where: { id: entityId },
|
|
select: { userId: true },
|
|
});
|
|
if (!resource) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "Resource not found",
|
|
});
|
|
}
|
|
|
|
const mentionWhere = buildMentionSearchWhere(query);
|
|
const users = await ctx.db.user.findMany({
|
|
...(mentionWhere ? { where: mentionWhere } : {}),
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
systemRole: true,
|
|
permissionOverrides: true,
|
|
},
|
|
orderBy: [{ name: "asc" }, { email: "asc" }],
|
|
});
|
|
|
|
return users
|
|
.filter((user) => {
|
|
if (user.id === resource.userId) {
|
|
return true;
|
|
}
|
|
|
|
const permissions = resolvePermissions(
|
|
user.systemRole as SystemRole,
|
|
user.permissionOverrides as PermissionOverrides | null,
|
|
ctx.roleDefaults ?? undefined,
|
|
);
|
|
|
|
return Array.from(RESOURCE_STAFF_PERMISSION_KEYS).some((permission) =>
|
|
permissions.has(permission),
|
|
);
|
|
})
|
|
.slice(0, MENTION_CANDIDATE_LIMIT)
|
|
.map(({ id, name, email }) => ({ id, name, email }));
|
|
}
|
|
|
|
async function assertEstimateCommentAccess(
|
|
ctx: Pick<TRPCContext, "db" | "dbUser" | "roleDefaults">,
|
|
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",
|
|
});
|
|
}
|
|
}
|
|
|
|
async function assertResourceCommentAccess(
|
|
ctx: Pick<TRPCContext, "db" | "dbUser" | "roleDefaults">,
|
|
entityId: string,
|
|
) {
|
|
const resource = await ctx.db.resource.findUnique({
|
|
where: { id: entityId },
|
|
select: { id: true },
|
|
});
|
|
if (!resource) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "Resource not found",
|
|
});
|
|
}
|
|
|
|
await assertCanReadResource(
|
|
ctx,
|
|
entityId,
|
|
"You can only comment on your own resource unless you have staff access",
|
|
);
|
|
}
|
|
|
|
const COMMENT_ENTITY_POLICIES: Record<CommentEntityType, CommentEntityPolicy> = {
|
|
estimate: {
|
|
assertAccess: assertEstimateCommentAccess,
|
|
buildLink: (entityId) => `/estimates/${entityId}?tab=comments`,
|
|
listMentionCandidates: listEstimateMentionCandidates,
|
|
},
|
|
resource: {
|
|
assertAccess: assertResourceCommentAccess,
|
|
buildLink: (entityId) => `/resources/${entityId}#comments`,
|
|
listMentionCandidates: listResourceMentionCandidates,
|
|
},
|
|
};
|
|
|
|
export function getSupportedCommentEntityTypes(): CommentEntityType[] {
|
|
return [...COMMENT_ENTITY_TYPE_VALUES];
|
|
}
|
|
|
|
export function getSupportedCommentEntityLabelList(): string {
|
|
return getSupportedCommentEntityTypes()
|
|
.map((entityType) => COMMENT_ENTITY_LABELS[entityType])
|
|
.join(", ");
|
|
}
|
|
|
|
export function getCommentToolEntityDescription(): string {
|
|
return `Supported entity type. One of: ${getSupportedCommentEntityLabelList()}.`;
|
|
}
|
|
|
|
export function getCommentToolScopeSentence(): string {
|
|
return `Supported comment entities: ${getSupportedCommentEntityLabelList()}. Estimate comments require controller/manager/admin visibility; resource comments follow resource detail visibility.`;
|
|
}
|
|
|
|
export function isSupportedCommentEntityType(value: string): value is CommentEntityType {
|
|
return (COMMENT_ENTITY_TYPE_VALUES as readonly string[]).includes(value);
|
|
}
|
|
|
|
export 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];
|
|
}
|
|
|
|
export async function assertCommentEntityAccess(
|
|
ctx: Pick<TRPCContext, "db" | "dbUser" | "roleDefaults">,
|
|
entityType: string,
|
|
entityId: string,
|
|
) {
|
|
const policy = getCommentEntityPolicy(entityType);
|
|
await policy.assertAccess(ctx, entityId);
|
|
return policy;
|
|
}
|