feat: dashboard overhaul, chargeability reports, dispo import enhancements, UI polish
Dashboard: expanded chargeability widget, resource/project table widgets with sorting and filters, stat cards with formatMoney integration. Chargeability: new report client with filtering, chargeability-bookings use case, updated dashboard overview logic. Dispo import: TBD project handling, parse-dispo-matrix improvements, stage-dispo-projects resource value scores, new tests. Estimates: CommercialTermsEditor component, commercial-terms engine module, expanded estimate schemas and types. UI: AppShell navigation updates, timeline filter/toolbar enhancements, role management improvements, signin page redesign, Tailwind/globals polish, SystemSettings SMTP section, anonymization support. Tests: new router tests (anonymization, chargeability, effort-rule, entitlement, estimate, experience-multiplier, notification, resource, staffing, vacation). Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,841 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { PrismaClient } from "@planarchy/db";
|
||||
|
||||
const DEFAULT_ANONYMIZATION_DOMAIN = "superhartmut.de";
|
||||
const DEFAULT_ANONYMIZATION_SEED = "planarchy-superhartmut-global";
|
||||
|
||||
type AliasEntry = {
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
type ResourceIdentity = {
|
||||
id: string;
|
||||
eid?: string | null;
|
||||
displayName?: string | null;
|
||||
email?: string | null;
|
||||
};
|
||||
|
||||
type UserIdentity = {
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
};
|
||||
|
||||
type DirectoryResource = {
|
||||
id: string;
|
||||
eid: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
lcrCents: number;
|
||||
};
|
||||
|
||||
type DirectoryIdentity = Pick<DirectoryResource, "id" | "eid" | "displayName" | "email">;
|
||||
|
||||
export type AnonymizationConfig = {
|
||||
enabled: boolean;
|
||||
domain: string;
|
||||
seed: string;
|
||||
mode: "global";
|
||||
};
|
||||
|
||||
export type ResourceAlias = {
|
||||
displayName: string;
|
||||
eid: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
type AnonymizationDirectory = {
|
||||
config: AnonymizationConfig;
|
||||
byResourceId: Map<string, ResourceAlias>;
|
||||
byAliasEid: Map<string, string>;
|
||||
};
|
||||
|
||||
type StoredAliasEntry = {
|
||||
displayName: string;
|
||||
eid: string;
|
||||
};
|
||||
|
||||
type StoredAliasMap = Record<string, StoredAliasEntry>;
|
||||
|
||||
const ALIAS_NAME_RE = /^[A-Za-z]+(?: [A-Za-z]+)*$/;
|
||||
const ALIAS_SLUG_RE = /^[a-z]+(?:\.[a-z]+)*$/;
|
||||
|
||||
const ICONIC_ALIAS_NAMES = [
|
||||
"Iron Man",
|
||||
"Spider Man",
|
||||
"Captain America",
|
||||
"Thor",
|
||||
"Hulk",
|
||||
"Black Widow",
|
||||
"Black Panther",
|
||||
"Doctor Strange",
|
||||
"Scarlet Witch",
|
||||
"Captain Marvel",
|
||||
"Wolverine",
|
||||
"Batman",
|
||||
"Superman",
|
||||
"Wonder Woman",
|
||||
"Flash",
|
||||
"Aquaman",
|
||||
"Deadpool",
|
||||
"Loki",
|
||||
"Thanos",
|
||||
"Joker",
|
||||
"Darth Vader",
|
||||
"Harley Quinn",
|
||||
"Doctor Doom",
|
||||
"Magneto",
|
||||
"Venom",
|
||||
"Elsa",
|
||||
"Mickey Mouse",
|
||||
"Moana",
|
||||
"Mulan",
|
||||
"Simba",
|
||||
"Stitch",
|
||||
"Jack Sparrow",
|
||||
"Maleficent",
|
||||
"Cruella",
|
||||
"Hades",
|
||||
"Ursula",
|
||||
"Luke Skywalker",
|
||||
"Leia Organa",
|
||||
"Han Solo",
|
||||
"Obi Wan Kenobi",
|
||||
"Yoda",
|
||||
"Darth Maul",
|
||||
"Buzz Lightyear",
|
||||
"Woody",
|
||||
"Belle",
|
||||
"Green Goblin",
|
||||
"Mystique",
|
||||
"Poison Ivy",
|
||||
"Ultron",
|
||||
"Robert Downey",
|
||||
"Scarlett Johansson",
|
||||
"Chris Hemsworth",
|
||||
"Ryan Reynolds",
|
||||
"Hugh Jackman",
|
||||
"Harrison Ford",
|
||||
];
|
||||
|
||||
const LEGENDARY_ALIAS_NAMES = [
|
||||
"Hawkeye",
|
||||
"Falcon",
|
||||
"Winter Soldier",
|
||||
"Vision",
|
||||
"Ant Man",
|
||||
"Wasp",
|
||||
"War Machine",
|
||||
"Shuri",
|
||||
"Shang Chi",
|
||||
"Gamora",
|
||||
"Star Lord",
|
||||
"Rocket",
|
||||
"Groot",
|
||||
"Nebula",
|
||||
"Daredevil",
|
||||
"Punisher",
|
||||
"Moon Knight",
|
||||
"She Hulk",
|
||||
"Ms Marvel",
|
||||
"Kate Bishop",
|
||||
"Yelena Belova",
|
||||
"Pepper Potts",
|
||||
"Nick Fury",
|
||||
"Phil Coulson",
|
||||
"Korg",
|
||||
"Mantis",
|
||||
"Drax",
|
||||
"Sif",
|
||||
"Professor X",
|
||||
"Jean Grey",
|
||||
"Cyclops",
|
||||
"Rogue",
|
||||
"Gambit",
|
||||
"Silver Surfer",
|
||||
"Ghost Rider",
|
||||
"Blade",
|
||||
"Killmonger",
|
||||
"Taskmaster",
|
||||
"Red Skull",
|
||||
"Kingpin",
|
||||
"Carnage",
|
||||
"Sandman",
|
||||
"Mysterio",
|
||||
"Sabretooth",
|
||||
"Abomination",
|
||||
"Ronan",
|
||||
"Scar",
|
||||
"Jafar",
|
||||
"Ursula",
|
||||
"Gaston",
|
||||
"Captain Hook",
|
||||
"Cruella",
|
||||
"Tiana",
|
||||
"Rapunzel",
|
||||
"Merida",
|
||||
"Aladdin",
|
||||
"Jasmine",
|
||||
"Nala",
|
||||
"Olaf",
|
||||
"Kristoff",
|
||||
"Maui",
|
||||
"Baymax",
|
||||
"Hercules",
|
||||
"Megara",
|
||||
"Tinker Bell",
|
||||
"Baloo",
|
||||
"Timon",
|
||||
"Pumbaa",
|
||||
"Ariel",
|
||||
"Aurora",
|
||||
"Pocahontas",
|
||||
"Rey Skywalker",
|
||||
"Kylo Ren",
|
||||
"Ahsoka Tano",
|
||||
"Chewbacca",
|
||||
"Lando Calrissian",
|
||||
"Mace Windu",
|
||||
"Anakin Skywalker",
|
||||
"Padme Amidala",
|
||||
"Chris Evans",
|
||||
"Tom Holland",
|
||||
"Zendaya",
|
||||
"Benedict Cumberbatch",
|
||||
"Tom Hiddleston",
|
||||
"Emma Stone",
|
||||
"Margot Robbie",
|
||||
"Christian Bale",
|
||||
"Gal Gadot",
|
||||
"Jason Momoa",
|
||||
"Pedro Pascal",
|
||||
"Keanu Reeves",
|
||||
"Ryan Reynolds",
|
||||
"Samuel Jackson",
|
||||
"Brie Larson",
|
||||
"Paul Rudd",
|
||||
];
|
||||
|
||||
const EXTENDED_ALIAS_NAMES = [
|
||||
"Okoye",
|
||||
"Mbaku",
|
||||
"Wong",
|
||||
"Monica Rambeau",
|
||||
"America Chavez",
|
||||
"Kate Pryde",
|
||||
"Nightcrawler",
|
||||
"Beast",
|
||||
"Elektra",
|
||||
"Jessica Jones",
|
||||
"Jessica Drew",
|
||||
"Colossus",
|
||||
"Domino",
|
||||
"Cable",
|
||||
"Nova",
|
||||
"Adam Warlock",
|
||||
"Clea",
|
||||
"Ancient One",
|
||||
"General Zod",
|
||||
"Lex Luthor",
|
||||
"Darkseid",
|
||||
"Bane",
|
||||
"Riddler",
|
||||
"Two Face",
|
||||
"Penguin",
|
||||
"Catwoman",
|
||||
"Mister Freeze",
|
||||
"Red Hood",
|
||||
"Nightwing",
|
||||
"Robin",
|
||||
"Supergirl",
|
||||
"Batgirl",
|
||||
"Green Lantern",
|
||||
"Black Adam",
|
||||
"Shazam",
|
||||
"Raven",
|
||||
"Starfire",
|
||||
"Beast Boy",
|
||||
"Doctor Octopus",
|
||||
"Sandman",
|
||||
"Mysterio",
|
||||
"Sabretooth",
|
||||
"Abomination",
|
||||
"Ronan",
|
||||
"Doctor Facilier",
|
||||
"Mother Gothel",
|
||||
"Prince Eric",
|
||||
"Snow White",
|
||||
"Cinderella",
|
||||
"Pinocchio",
|
||||
"Peter Pan",
|
||||
"Winnie Pooh",
|
||||
"Minnie Mouse",
|
||||
"Donald Duck",
|
||||
"Daisy Duck",
|
||||
"Goofy",
|
||||
"Pluto",
|
||||
"Milo Thatch",
|
||||
"Kida Nedakh",
|
||||
"Li Shang",
|
||||
"Genie",
|
||||
"Flynn Rider",
|
||||
"Prince Naveen",
|
||||
"Qui Gon",
|
||||
"Finn",
|
||||
"Poe Dameron",
|
||||
"Elizabeth Olsen",
|
||||
"Mark Ruffalo",
|
||||
"Natalie Portman",
|
||||
"Emma Watson",
|
||||
"Robert Pattinson",
|
||||
"Angelina Jolie",
|
||||
"Johnny Depp",
|
||||
"Jeremy Renner",
|
||||
"Tom Cruise",
|
||||
"Will Smith",
|
||||
"Dwayne Johnson",
|
||||
"Jennifer Lawrence",
|
||||
"Anne Hathaway",
|
||||
"Meryl Streep",
|
||||
"Charlize Theron",
|
||||
"Brad Pitt",
|
||||
];
|
||||
|
||||
const COMPOSITE_GIVEN_NAMES = [
|
||||
"Tony",
|
||||
"Peter",
|
||||
"Steve",
|
||||
"Natasha",
|
||||
"Carol",
|
||||
"Wanda",
|
||||
"Logan",
|
||||
"Bruce",
|
||||
"Diana",
|
||||
"Clark",
|
||||
"Barry",
|
||||
"Arthur",
|
||||
"Selina",
|
||||
"Harley",
|
||||
"Loki",
|
||||
"Thor",
|
||||
"Shuri",
|
||||
"Stephen",
|
||||
"Leia",
|
||||
"Luke",
|
||||
"Anakin",
|
||||
"Padme",
|
||||
"Elsa",
|
||||
"Moana",
|
||||
"Mulan",
|
||||
"Belle",
|
||||
"Ariel",
|
||||
"Simba",
|
||||
"Tiana",
|
||||
"Rapunzel",
|
||||
"Mickey",
|
||||
"Wade",
|
||||
"Victor",
|
||||
"Rey",
|
||||
"Robert",
|
||||
"Scarlett",
|
||||
"Chris",
|
||||
"Tom",
|
||||
"Zendaya",
|
||||
"Ryan",
|
||||
"Keanu",
|
||||
"Hugh",
|
||||
"Emma",
|
||||
"Pedro",
|
||||
"Angelina",
|
||||
"Brie",
|
||||
"Paul",
|
||||
"Jeremy",
|
||||
"Samuel",
|
||||
"Benedict",
|
||||
"Gal",
|
||||
"Jason",
|
||||
"Margot",
|
||||
"Christian",
|
||||
"Harrison",
|
||||
"Natalie",
|
||||
"Mark",
|
||||
"Elizabeth",
|
||||
"Anne",
|
||||
"Jennifer",
|
||||
"Charlize",
|
||||
];
|
||||
|
||||
const COMPOSITE_SURNAMES = [
|
||||
"Stark",
|
||||
"Parker",
|
||||
"Rogers",
|
||||
"Romanoff",
|
||||
"Danvers",
|
||||
"Maximoff",
|
||||
"Howlett",
|
||||
"Banner",
|
||||
"Wayne",
|
||||
"Kent",
|
||||
"Allen",
|
||||
"Curry",
|
||||
"Prince",
|
||||
"Quinn",
|
||||
"Wilson",
|
||||
"Odinson",
|
||||
"Strange",
|
||||
"Mouse",
|
||||
"Lightyear",
|
||||
"Skywalker",
|
||||
"Solo",
|
||||
"Kenobi",
|
||||
"Vader",
|
||||
"Ren",
|
||||
"Palmer",
|
||||
"Rambeau",
|
||||
"Dameron",
|
||||
"Tano",
|
||||
"Downey",
|
||||
"Evans",
|
||||
"Johansson",
|
||||
"Hemsworth",
|
||||
"Holland",
|
||||
"Reynolds",
|
||||
"Jackman",
|
||||
"Reeves",
|
||||
"Stone",
|
||||
"Watson",
|
||||
"Pascal",
|
||||
"Jolie",
|
||||
"Larson",
|
||||
"Rudd",
|
||||
"Renner",
|
||||
"Jackson",
|
||||
"Cumberbatch",
|
||||
"Hiddleston",
|
||||
"Gadot",
|
||||
"Momoa",
|
||||
"Robbie",
|
||||
"Bale",
|
||||
"Ford",
|
||||
"Portman",
|
||||
"Ruffalo",
|
||||
"Olsen",
|
||||
"Lawrence",
|
||||
"Hathaway",
|
||||
"Theron",
|
||||
"Pitt",
|
||||
];
|
||||
|
||||
function normalizeAliasName(name: string): string {
|
||||
return name.replace(/[^A-Za-z\s]+/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function slugifyAliasName(name: string): string {
|
||||
return normalizeAliasName(name).toLowerCase().split(" ").filter(Boolean).join(".");
|
||||
}
|
||||
|
||||
function buildAliasEntries(names: string[]): AliasEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const entries: AliasEntry[] = [];
|
||||
|
||||
for (const rawName of names) {
|
||||
const name = normalizeAliasName(rawName);
|
||||
const slug = slugifyAliasName(name);
|
||||
if (!name || !slug || seen.has(slug)) {
|
||||
continue;
|
||||
}
|
||||
if (!ALIAS_NAME_RE.test(name) || !ALIAS_SLUG_RE.test(slug)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(slug);
|
||||
entries.push({ name, slug });
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function buildCompositeAliasEntries(givenNames: string[], surnames: string[]): AliasEntry[] {
|
||||
const names: string[] = [];
|
||||
for (const givenName of givenNames) {
|
||||
for (const surname of surnames) {
|
||||
if (givenName === surname) {
|
||||
continue;
|
||||
}
|
||||
names.push(`${givenName} ${surname}`);
|
||||
}
|
||||
}
|
||||
return buildAliasEntries(names);
|
||||
}
|
||||
|
||||
const ICONIC_CHARACTERS = buildAliasEntries(ICONIC_ALIAS_NAMES);
|
||||
const WELL_KNOWN_CHARACTERS = buildAliasEntries(LEGENDARY_ALIAS_NAMES);
|
||||
const COMPOSITE_CHARACTERS = buildCompositeAliasEntries(COMPOSITE_GIVEN_NAMES, COMPOSITE_SURNAMES);
|
||||
const SUPPORTING_CHARACTERS = buildAliasEntries([
|
||||
...EXTENDED_ALIAS_NAMES,
|
||||
...COMPOSITE_CHARACTERS.map((entry) => entry.name),
|
||||
]);
|
||||
|
||||
const USER_CHARACTER_POOL = buildAliasEntries([
|
||||
...ICONIC_ALIAS_NAMES,
|
||||
...LEGENDARY_ALIAS_NAMES,
|
||||
...EXTENDED_ALIAS_NAMES,
|
||||
]);
|
||||
|
||||
function hashInt(input: string): number {
|
||||
const hex = createHash("sha256").update(input).digest("hex").slice(0, 8);
|
||||
return Number.parseInt(hex, 16);
|
||||
}
|
||||
|
||||
function normalize(value: string | null | undefined): string {
|
||||
return (value ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isStoredAliasEntryValid(alias: StoredAliasEntry): boolean {
|
||||
const displayName = normalizeAliasName(alias.displayName);
|
||||
const eid = slugifyAliasName(alias.displayName);
|
||||
return (
|
||||
alias.displayName === displayName &&
|
||||
alias.eid === eid &&
|
||||
ALIAS_NAME_RE.test(displayName) &&
|
||||
ALIAS_SLUG_RE.test(alias.eid) &&
|
||||
!/\d/.test(alias.displayName) &&
|
||||
!/\d/.test(alias.eid)
|
||||
);
|
||||
}
|
||||
|
||||
function parseStoredAliases(value: unknown): StoredAliasMap {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const entries = Object.entries(value);
|
||||
const parsed: StoredAliasMap = {};
|
||||
|
||||
for (const [resourceId, alias] of entries) {
|
||||
if (!alias || typeof alias !== "object" || Array.isArray(alias)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const displayName =
|
||||
typeof (alias as { displayName?: unknown }).displayName === "string"
|
||||
? (alias as { displayName: string }).displayName.trim()
|
||||
: "";
|
||||
const eid =
|
||||
typeof (alias as { eid?: unknown }).eid === "string"
|
||||
? (alias as { eid: string }).eid.trim()
|
||||
: "";
|
||||
|
||||
if (!displayName || !eid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
parsed[resourceId] = { displayName, eid };
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function toAlias(entry: StoredAliasEntry, domain: string): ResourceAlias {
|
||||
return {
|
||||
displayName: entry.displayName,
|
||||
eid: entry.eid,
|
||||
email: `${entry.eid}@${domain}`,
|
||||
};
|
||||
}
|
||||
|
||||
function getCharacterPool(lcrCents: number): AliasEntry[] {
|
||||
if (lcrCents >= 12000) return ICONIC_CHARACTERS;
|
||||
if (lcrCents >= 9000) return WELL_KNOWN_CHARACTERS;
|
||||
return SUPPORTING_CHARACTERS;
|
||||
}
|
||||
|
||||
function pickUniqueAlias(
|
||||
resource: DirectoryResource,
|
||||
config: AnonymizationConfig,
|
||||
usedSlugs: Set<string>,
|
||||
): AliasEntry {
|
||||
const primaryPool = getCharacterPool(resource.lcrCents);
|
||||
const orderedPools = [primaryPool, ICONIC_CHARACTERS, WELL_KNOWN_CHARACTERS, SUPPORTING_CHARACTERS];
|
||||
const offset = hashInt(`${config.seed}:${resource.id}`);
|
||||
|
||||
for (const pool of orderedPools) {
|
||||
for (let i = 0; i < pool.length; i += 1) {
|
||||
const candidate = pool[(offset + i) % pool.length]!;
|
||||
if (!usedSlugs.has(candidate.slug)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of COMPOSITE_CHARACTERS) {
|
||||
if (!usedSlugs.has(candidate.slug)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackWords = [...COMPOSITE_GIVEN_NAMES, ...COMPOSITE_SURNAMES];
|
||||
const firstOffset = hashInt(`${config.seed}:${resource.id}:fallback:first`);
|
||||
const secondOffset = hashInt(`${config.seed}:${resource.id}:fallback:second`);
|
||||
for (let i = 0; i < fallbackWords.length; i += 1) {
|
||||
for (let j = 0; j < fallbackWords.length; j += 1) {
|
||||
const first = fallbackWords[(firstOffset + i) % fallbackWords.length]!;
|
||||
const second = fallbackWords[(secondOffset + j) % fallbackWords.length]!;
|
||||
if (first === second) {
|
||||
continue;
|
||||
}
|
||||
const candidate = buildAliasEntries([`${first} ${second}`])[0];
|
||||
if (candidate && !usedSlugs.has(candidate.slug)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Unable to generate a unique anonymization alias without digits");
|
||||
}
|
||||
|
||||
async function loadDirectoryResources(
|
||||
db: Pick<PrismaClient, "resource">,
|
||||
): Promise<DirectoryResource[]> {
|
||||
if (!("resource" in db) || typeof db.resource?.findMany !== "function") {
|
||||
return [];
|
||||
}
|
||||
const resources = await db.resource.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
eid: true,
|
||||
displayName: true,
|
||||
email: true,
|
||||
lcrCents: true,
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
|
||||
return resources.map((resource) => ({
|
||||
...resource,
|
||||
lcrCents: resource.lcrCents ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getAnonymizationConfig(
|
||||
db: Pick<PrismaClient, "systemSettings">,
|
||||
): Promise<AnonymizationConfig> {
|
||||
if (!("systemSettings" in db) || typeof db.systemSettings?.findUnique !== "function") {
|
||||
return {
|
||||
enabled: false,
|
||||
domain: DEFAULT_ANONYMIZATION_DOMAIN,
|
||||
seed: DEFAULT_ANONYMIZATION_SEED,
|
||||
mode: "global",
|
||||
};
|
||||
}
|
||||
|
||||
const settings = await db.systemSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
anonymizationEnabled: true,
|
||||
anonymizationDomain: true,
|
||||
anonymizationSeed: true,
|
||||
anonymizationMode: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
enabled: settings?.anonymizationEnabled ?? false,
|
||||
domain: settings?.anonymizationDomain?.trim() || DEFAULT_ANONYMIZATION_DOMAIN,
|
||||
seed: settings?.anonymizationSeed?.trim() || DEFAULT_ANONYMIZATION_SEED,
|
||||
mode: "global",
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAnonymizationDirectory(
|
||||
db: Pick<PrismaClient, "systemSettings" | "resource">,
|
||||
): Promise<AnonymizationDirectory | null> {
|
||||
if (!("systemSettings" in db) || typeof db.systemSettings?.findUnique !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const settings = await db.systemSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
anonymizationEnabled: true,
|
||||
anonymizationDomain: true,
|
||||
anonymizationSeed: true,
|
||||
anonymizationMode: true,
|
||||
anonymizationAliases: true,
|
||||
},
|
||||
});
|
||||
|
||||
const config: AnonymizationConfig = {
|
||||
enabled: settings?.anonymizationEnabled ?? false,
|
||||
domain: settings?.anonymizationDomain?.trim() || DEFAULT_ANONYMIZATION_DOMAIN,
|
||||
seed: settings?.anonymizationSeed?.trim() || DEFAULT_ANONYMIZATION_SEED,
|
||||
mode: "global",
|
||||
};
|
||||
|
||||
if (!config.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resources = await loadDirectoryResources(db);
|
||||
const usedSlugs = new Set<string>();
|
||||
const byResourceId = new Map<string, ResourceAlias>();
|
||||
const byAliasEid = new Map<string, string>();
|
||||
const storedAliases = parseStoredAliases(settings?.anonymizationAliases);
|
||||
let aliasesChanged = false;
|
||||
|
||||
for (const [resourceId, storedAlias] of Object.entries(storedAliases)) {
|
||||
const normalizedEid = normalize(storedAlias.eid);
|
||||
if (!normalizedEid || usedSlugs.has(normalizedEid) || !isStoredAliasEntryValid(storedAlias)) {
|
||||
delete storedAliases[resourceId];
|
||||
aliasesChanged = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
usedSlugs.add(normalizedEid);
|
||||
byResourceId.set(resourceId, toAlias(storedAlias, config.domain));
|
||||
byAliasEid.set(normalizedEid, resourceId);
|
||||
}
|
||||
|
||||
const rankedResources = [...resources].sort((left, right) => {
|
||||
if (right.lcrCents !== left.lcrCents) {
|
||||
return right.lcrCents - left.lcrCents;
|
||||
}
|
||||
return left.id.localeCompare(right.id);
|
||||
});
|
||||
|
||||
for (const resource of rankedResources) {
|
||||
const existing = byResourceId.get(resource.id);
|
||||
if (existing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const alias = pickUniqueAlias(resource, config, usedSlugs);
|
||||
const entry = {
|
||||
displayName: alias.name,
|
||||
eid: alias.slug,
|
||||
email: `${alias.slug}@${config.domain}`,
|
||||
};
|
||||
|
||||
usedSlugs.add(normalize(alias.slug));
|
||||
byResourceId.set(resource.id, entry);
|
||||
byAliasEid.set(normalize(alias.slug), resource.id);
|
||||
storedAliases[resource.id] = {
|
||||
displayName: alias.name,
|
||||
eid: alias.slug,
|
||||
};
|
||||
aliasesChanged = true;
|
||||
}
|
||||
|
||||
if (aliasesChanged && typeof db.systemSettings.update === "function") {
|
||||
await db.systemSettings.update({
|
||||
where: { id: "singleton" },
|
||||
data: { anonymizationAliases: storedAliases },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
byResourceId,
|
||||
byAliasEid,
|
||||
};
|
||||
}
|
||||
|
||||
export function anonymizeResource<T extends ResourceIdentity>(
|
||||
resource: T,
|
||||
directory: AnonymizationDirectory | null,
|
||||
): T {
|
||||
if (!directory) {
|
||||
return resource;
|
||||
}
|
||||
|
||||
const alias = directory.byResourceId.get(resource.id);
|
||||
if (!alias) {
|
||||
return resource;
|
||||
}
|
||||
|
||||
return {
|
||||
...resource,
|
||||
displayName: alias.displayName,
|
||||
eid: alias.eid,
|
||||
...(Object.prototype.hasOwnProperty.call(resource, "email") ? { email: alias.email } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function anonymizeResources<T extends ResourceIdentity>(
|
||||
resources: T[],
|
||||
directory: AnonymizationDirectory | null,
|
||||
): T[] {
|
||||
if (!directory) {
|
||||
return resources;
|
||||
}
|
||||
return resources.map((resource) => anonymizeResource(resource, directory));
|
||||
}
|
||||
|
||||
export function anonymizeUser<T extends UserIdentity>(
|
||||
user: T,
|
||||
directory: AnonymizationDirectory | null,
|
||||
): T {
|
||||
if (!directory) {
|
||||
return user;
|
||||
}
|
||||
|
||||
const stableKey = normalize(user.id) || normalize(user.email) || normalize(user.name);
|
||||
if (!stableKey) {
|
||||
return user;
|
||||
}
|
||||
|
||||
const index = hashInt(`${directory.config.seed}:user:${stableKey}`) % USER_CHARACTER_POOL.length;
|
||||
const alias = USER_CHARACTER_POOL[index]!;
|
||||
|
||||
return {
|
||||
...user,
|
||||
name: alias.name,
|
||||
...(Object.prototype.hasOwnProperty.call(user, "email")
|
||||
? { email: `${alias.slug}@${directory.config.domain}` }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function anonymizeSearchMatches(
|
||||
resource: DirectoryIdentity,
|
||||
alias: ResourceAlias | undefined,
|
||||
search: string,
|
||||
): boolean {
|
||||
const query = normalize(search);
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const haystack = [
|
||||
resource.displayName,
|
||||
resource.eid,
|
||||
resource.email,
|
||||
alias?.displayName,
|
||||
alias?.eid,
|
||||
alias?.email,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map((value) => normalize(value));
|
||||
|
||||
return haystack.some((value) => value.includes(query));
|
||||
}
|
||||
|
||||
export function resolveResourceIdsByDisplayedEids(
|
||||
resources: DirectoryIdentity[],
|
||||
directory: AnonymizationDirectory | null,
|
||||
requestedEids: string[],
|
||||
): string[] {
|
||||
if (requestedEids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const realByEid = new Map(resources.map((resource) => [normalize(resource.eid), resource.id]));
|
||||
const ids = requestedEids
|
||||
.map((value) => {
|
||||
const normalized = normalize(value);
|
||||
return directory?.byAliasEid.get(normalized) ?? realByEid.get(normalized) ?? null;
|
||||
})
|
||||
.filter((value): value is string => value !== null);
|
||||
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
Reference in New Issue
Block a user