296 lines
8.6 KiB
TypeScript
296 lines
8.6 KiB
TypeScript
import type { AppDatabase } from "./database";
|
|
import type { TaskRecord } from "@shared/types";
|
|
|
|
function safeGetAgent(db: AppDatabase, agentId: string | null | undefined) {
|
|
if (!agentId) {
|
|
return null;
|
|
}
|
|
try {
|
|
return db.getAgent(agentId);
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message.startsWith("Agent not found:")) {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const MANAGEMENT_KEYWORDS = [
|
|
"chief",
|
|
"cto",
|
|
"cmo",
|
|
"coo",
|
|
"cfo",
|
|
"vp",
|
|
"vice president",
|
|
"head",
|
|
"director",
|
|
"manager",
|
|
"lead",
|
|
];
|
|
|
|
/** Regex union for MANAGEMENT_KEYWORDS with `\b`-anchored matching so short
|
|
* tokens like "vp" or "cmo" do not false-match inside "vpc" or "economical". */
|
|
const MANAGEMENT_KEYWORD_PATTERN = new RegExp(
|
|
`\\b(?:${MANAGEMENT_KEYWORDS.map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b`,
|
|
"i",
|
|
);
|
|
|
|
export function hasManagementAuthority(agent: { role: string; title: string; department: string | null }) {
|
|
if (agent.department === "hr") {
|
|
return true;
|
|
}
|
|
|
|
const text = `${agent.role} ${agent.title}`;
|
|
return MANAGEMENT_KEYWORD_PATTERN.test(text);
|
|
}
|
|
|
|
function hasOrganizationPermission(db: AppDatabase, agentId: string) {
|
|
return db.hasAgentPermission(agentId, "organization_manage");
|
|
}
|
|
|
|
function managesAgent(db: AppDatabase, managerId: string, targetAgentId: string): boolean {
|
|
if (managerId === targetAgentId) {
|
|
return true;
|
|
}
|
|
return db.getChainOfCommand(targetAgentId).some((agent) => agent.id === managerId);
|
|
}
|
|
|
|
export function canControlAgent(db: AppDatabase, companyId: string, actorAgentId: string, targetAgentId: string): boolean {
|
|
const actor = safeGetAgent(db, actorAgentId);
|
|
const target = safeGetAgent(db, targetAgentId);
|
|
if (!actor || !target) {
|
|
return false;
|
|
}
|
|
if (actor.companyId !== companyId || target.companyId !== companyId) {
|
|
return false;
|
|
}
|
|
if (actor.id === target.id) {
|
|
return true;
|
|
}
|
|
if (canManageOrganization(db, companyId, actorAgentId)) {
|
|
return true;
|
|
}
|
|
return managesAgent(db, actorAgentId, targetAgentId);
|
|
}
|
|
|
|
export function canManageOrganization(db: AppDatabase, companyId: string, agentId: string): boolean {
|
|
const agent = safeGetAgent(db, agentId);
|
|
if (!agent || agent.companyId !== companyId) {
|
|
return false;
|
|
}
|
|
if (!agent.reportsTo) {
|
|
return true;
|
|
}
|
|
if (hasOrganizationPermission(db, agentId)) {
|
|
return true;
|
|
}
|
|
return agent.department === "hr";
|
|
}
|
|
|
|
export function canRequestHire(db: AppDatabase, companyId: string, agentId: string): boolean {
|
|
const agent = safeGetAgent(db, agentId);
|
|
if (!agent || agent.companyId !== companyId) {
|
|
return false;
|
|
}
|
|
if (canManageOrganization(db, companyId, agentId)) {
|
|
return true;
|
|
}
|
|
if (db.getDirectReports(agentId, companyId).length > 0) {
|
|
return true;
|
|
}
|
|
return hasManagementAuthority(agent);
|
|
}
|
|
|
|
function getDepartmentLeadCandidateId(
|
|
db: AppDatabase,
|
|
companyId: string,
|
|
department: string,
|
|
excludeAgentId: string | null,
|
|
): string | null {
|
|
const candidates = db.queryAll<{
|
|
id: string;
|
|
role: string;
|
|
title: string;
|
|
department: string | null;
|
|
createdAt: string;
|
|
}>(
|
|
`select id, role, title, department, created_at as createdAt
|
|
from agents
|
|
where company_id = ?
|
|
and department = ?
|
|
and status not in ('terminated', 'pending_approval')`,
|
|
companyId,
|
|
department,
|
|
);
|
|
|
|
const ranked = candidates
|
|
.filter((candidate) => candidate.id !== excludeAgentId)
|
|
.map((candidate) => ({
|
|
id: candidate.id,
|
|
hasReports: db.getDirectReports(candidate.id, companyId).length > 0,
|
|
leadershipScore: hasManagementAuthority(candidate) ? 1 : 0,
|
|
chainDepth: db.getChainOfCommand(candidate.id).length,
|
|
createdAt: candidate.createdAt,
|
|
}))
|
|
.filter((candidate) => candidate.hasReports || candidate.leadershipScore > 0)
|
|
.sort((left, right) => {
|
|
if (left.hasReports !== right.hasReports) return left.hasReports ? -1 : 1;
|
|
if (left.leadershipScore !== right.leadershipScore) return right.leadershipScore - left.leadershipScore;
|
|
if (left.chainDepth !== right.chainDepth) return left.chainDepth - right.chainDepth;
|
|
return left.createdAt.localeCompare(right.createdAt);
|
|
});
|
|
|
|
return ranked[0]?.id ?? null;
|
|
}
|
|
|
|
export function resolveHireReportsTo(
|
|
db: AppDatabase,
|
|
companyId: string,
|
|
requestedByAgentId: string | null | undefined,
|
|
explicitReportsTo: string | null | undefined,
|
|
department: string | null | undefined,
|
|
): string | null {
|
|
if (explicitReportsTo !== undefined) {
|
|
return explicitReportsTo;
|
|
}
|
|
|
|
const requester = requestedByAgentId ? safeGetAgent(db, requestedByAgentId) : null;
|
|
if (requester && requester.companyId !== companyId) {
|
|
return null;
|
|
}
|
|
|
|
if (!department) {
|
|
return requester?.id ?? db.getCeoForCompany(companyId)?.id ?? null;
|
|
}
|
|
|
|
if (requester && (requester.department === department || !requester.reportsTo)) {
|
|
return requester.id;
|
|
}
|
|
|
|
const departmentLeadId = getDepartmentLeadCandidateId(db, companyId, department, requester?.id ?? null);
|
|
if (departmentLeadId) {
|
|
return departmentLeadId;
|
|
}
|
|
|
|
if (requester) {
|
|
return requester.id;
|
|
}
|
|
|
|
return db.getCeoForCompany(companyId)?.id ?? null;
|
|
}
|
|
|
|
function normalizeComparableText(value: string): string {
|
|
return value
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[\s_\-()[\]{}]+/g, " ")
|
|
.replace(/\s+/g, " ");
|
|
}
|
|
|
|
function isAllowedRoleAcronym(value: string): boolean {
|
|
return /^[A-Z]{2,5}$/.test(value.trim());
|
|
}
|
|
|
|
export function getHireNameValidationError(input: {
|
|
name: string;
|
|
role: string;
|
|
title: string;
|
|
}): string | null {
|
|
const normalizedName = normalizeComparableText(input.name);
|
|
if (!normalizedName) {
|
|
return "Hire requests require a real display name.";
|
|
}
|
|
const normalizedRole = normalizeComparableText(input.role);
|
|
const normalizedTitle = normalizeComparableText(input.title);
|
|
if (normalizedName === normalizedRole && !isAllowedRoleAcronym(input.name)) {
|
|
return "Hire name must be a real display name, not just the role.";
|
|
}
|
|
if (
|
|
normalizedTitle.length > 0 &&
|
|
normalizedName === normalizedTitle &&
|
|
!isAllowedRoleAcronym(input.name)
|
|
) {
|
|
return "Hire name must be a real display name, not just the title.";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function getHireRoutingValidationError(
|
|
db: AppDatabase,
|
|
companyId: string,
|
|
requestedByAgentId: string | null | undefined,
|
|
reportsTo: string | null | undefined,
|
|
department: string | null | undefined,
|
|
): string | null {
|
|
const requester = requestedByAgentId ? safeGetAgent(db, requestedByAgentId) : null;
|
|
const manager = reportsTo ? safeGetAgent(db, reportsTo) : null;
|
|
const requestedDepartment = department ?? null;
|
|
|
|
if (requester && requester.companyId !== companyId) {
|
|
return "Requesting agent does not belong to the current company.";
|
|
}
|
|
if (!requestedDepartment) {
|
|
return null;
|
|
}
|
|
if (reportsTo != null && !manager) {
|
|
return "Selected manager not found.";
|
|
}
|
|
if (manager && manager.companyId !== companyId) {
|
|
return "Selected manager does not belong to the current company.";
|
|
}
|
|
|
|
if (
|
|
manager &&
|
|
manager.reportsTo != null &&
|
|
manager.department !== requestedDepartment
|
|
) {
|
|
return "Selected manager must belong to the same department as the new hire, unless they are a top-level executive.";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function canAssignTask(db: AppDatabase, companyId: string, actorAgentId: string, targetAgentId: string | null): boolean {
|
|
if (!targetAgentId || targetAgentId === actorAgentId) {
|
|
return true;
|
|
}
|
|
if (!db.belongsToCompany("agents", targetAgentId, companyId)) {
|
|
return false;
|
|
}
|
|
if (canManageOrganization(db, companyId, actorAgentId)) {
|
|
return true;
|
|
}
|
|
return managesAgent(db, actorAgentId, targetAgentId);
|
|
}
|
|
|
|
export function canUpdateTask(db: AppDatabase, companyId: string, actorAgentId: string, task: TaskRecord): boolean {
|
|
if (task.companyId !== companyId) {
|
|
return false;
|
|
}
|
|
if (task.assigneeAgentId === actorAgentId) {
|
|
return true;
|
|
}
|
|
if (!task.assigneeAgentId) {
|
|
return canManageOrganization(db, companyId, actorAgentId);
|
|
}
|
|
if (canManageOrganization(db, companyId, actorAgentId)) {
|
|
return true;
|
|
}
|
|
return managesAgent(db, actorAgentId, task.assigneeAgentId);
|
|
}
|
|
|
|
export function canManageGoal(db: AppDatabase, companyId: string, actorAgentId: string, ownerAgentId: string | null): boolean {
|
|
if (canManageOrganization(db, companyId, actorAgentId)) {
|
|
return true;
|
|
}
|
|
return Boolean(ownerAgentId && ownerAgentId === actorAgentId);
|
|
}
|
|
|
|
export function canManageProject(db: AppDatabase, companyId: string, actorAgentId: string, leadAgentId: string | null): boolean {
|
|
if (canManageOrganization(db, companyId, actorAgentId)) {
|
|
return true;
|
|
}
|
|
return Boolean(leadAgentId && leadAgentId === actorAgentId);
|
|
}
|