gongxiangp2/lib/organization-material-permissions.ts

430 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

export type MaterialPermissionFlags = {
canView: boolean
canDownload: boolean
canDownloadSource: boolean
canShare: boolean
/** 任务-详情单图编辑(替换/改名/删除):仅对「任务-详情」类型生效,其余类型忽略 */
canEditAssets: boolean
/** 私密任务-详情查看/下载:仅对「任务-详情」类型生效,其余类型忽略 */
canAccessPrivate: boolean
}
export type PermissionPackMap = Record<string, MaterialPermissionFlags>
type TaskDetailOnlyFlagInput = {
canEditAssets?: boolean
canAccessPrivate?: boolean
}
/** 任务-详情以外的素材类型,不允许带出这两个组级特权位。 */
export function normalizeTaskDetailOnlyFlags(
materialType: string,
flags: TaskDetailOnlyFlagInput = {},
): Pick<MaterialPermissionFlags, 'canEditAssets' | 'canAccessPrivate'> {
const isTaskDetail = materialType === '任务-详情'
return {
canEditAssets: isTaskDetail && flags.canEditAssets === true,
canAccessPrivate: isTaskDetail && flags.canAccessPrivate === true,
}
}
/** 部门是底子,用户继承是加餐。同一动作只要有一边允许就算允许。 */
export function mergeDepartmentAndInheritPacks(
department: PermissionPackMap | null | undefined,
inherit: PermissionPackMap | null | undefined,
): PermissionPackMap {
const merged: PermissionPackMap = { ...(department || {}) }
if (!inherit) return merged
for (const materialType of Object.keys(inherit)) {
const extra = inherit[materialType]
const base = merged[materialType]
if (!base) {
merged[materialType] = { ...extra }
continue
}
merged[materialType] = {
canView: extra.canView || base.canView,
canDownload: extra.canDownload || base.canDownload,
canDownloadSource: extra.canDownloadSource || base.canDownloadSource,
canShare: extra.canShare || base.canShare,
canEditAssets: extra.canEditAssets || base.canEditAssets,
canAccessPrivate: extra.canAccessPrivate || base.canAccessPrivate,
}
}
return merged
}
/** 多组合并:允许的取并集,不允许的不会把别组已经给的拿掉。 */
export function unionPermissionPacks(
...packs: Array<PermissionPackMap | null | undefined>
): PermissionPackMap {
return packs.reduce<PermissionPackMap>(
(merged, pack) => mergeDepartmentAndInheritPacks(merged, pack),
{},
)
}
export type NearestGroupMemberships<T extends { departmentPath: string }> = {
attachedPath: string
items: T[]
}
/** 从自己往上找,最近一个进了权限组的部门。一个部门只进一组。 */
export function findNearestGroupMemberships<T extends { departmentPath: string }>(
path: string,
memberships: T[],
): NearestGroupMemberships<T> | null {
const chain = organizationPathChain(path)
if (chain.length === 0 || memberships.length === 0) return null
const byPath = new Map<string, T[]>()
for (const row of memberships) {
const key = normalizeOrganizationPath(row.departmentPath)
if (!key) continue
const list = byPath.get(key) || []
list.push(row)
byPath.set(key, list)
}
for (let index = chain.length - 1; index >= 0; index -= 1) {
const attachedPath = chain[index]
const items = byPath.get(attachedPath)
if (items && items.length > 0) {
return { attachedPath, items }
}
}
return null
}
export type DepartmentGroupRef = {
departmentPath: string
slug: string
name: string
}
export type DepartmentGroupStatus = {
kind: 'own' | 'other' | 'inherit' | 'none'
groupName: string | null
groupSlug: string | null
inheritFromName: string | null
label: string
}
/** 分配部门时用的状态:已在本组 / 已在别组 / 随上级 / 未进组。 */
export function describeDepartmentGroupStatus(
path: string,
currentSlug: string | null | undefined,
memberships: DepartmentGroupRef[],
): DepartmentGroupStatus {
const nearest = findNearestGroupMemberships(path, memberships)
if (!nearest) {
return { kind: 'none', groupName: null, groupSlug: null, inheritFromName: null, label: '未进组' }
}
const group = nearest.items[0]
const groupName = group?.name || null
const groupSlug = group?.slug || null
const inheritFromName = lastOrganizationPathSegment(nearest.attachedPath) || nearest.attachedPath
const isOwnNode = nearest.attachedPath === normalizeOrganizationPath(path)
if (isOwnNode) {
if (currentSlug && groupSlug === currentSlug) {
return { kind: 'own', groupName, groupSlug, inheritFromName: null, label: '已在本组' }
}
return {
kind: 'other',
groupName,
groupSlug,
inheritFromName: null,
label: groupName ? `已在「${groupName}」` : '已在别的组',
}
}
return {
kind: 'inherit',
groupName,
groupSlug,
inheritFromName,
label: groupName ? `随 ${inheritFromName} · ${groupName}` : `随 ${inheritFromName}`,
}
}
export type OrganizationPermissionSource = 'own' | 'legacy' | 'inherit' | 'none'
export const LEGACY_DEPARTMENT_ALIASES: Record<string, string> = {
运营中心A: '运营中心',
运营中心B: '运营中心',
运营中心C: '运营中心',
运营中心E: '运营中心',
运营中心G: '运营中心',
卓禾运营: '运营中心',
朴美运营: '运营中心',
亿家达运营: '运营中心',
橡野里运营: '运营中心',
欧意朗运营: '运营中心',
索菲亚运营: '运营中心',
顾家运营: '运营中心',
品牌中心: '视觉中心',
策划中心: '视觉中心',
摄影中心: '视觉中心',
空间视觉部: '视觉中心',
品牌创意A部: '视觉中心',
品牌创意B部: '视觉中心',
品牌设计部: '视觉中心',
商品企划部: '视觉中心',
营销策划中心: '视觉中心',
业务支持: '业务支持-视觉支持',
}
export function organizationPathChain(path: string): string[] {
const parts = String(path || '')
.split('/')
.map((part) => part.trim())
.filter(Boolean)
const chain: string[] = []
let acc = ''
for (const part of parts) {
acc = acc ? `${acc}/${part}` : part
chain.push(acc)
}
return chain
}
export function lastOrganizationPathSegment(path: string): string {
const chain = organizationPathChain(path)
if (chain.length === 0) return ''
const last = chain[chain.length - 1]
const slash = last.lastIndexOf('/')
return slash >= 0 ? last.slice(slash + 1) : last
}
export function normalizeOrganizationPath(path: string | null | undefined): string {
return String(path || '')
.split('/')
.map((part) => part.trim())
.filter(Boolean)
.join('/')
}
/** 人是否在这个部门:本级,或挂在它下面的子部门。 */
export function isUnderOrganizationPath(
employeePath: string | null | undefined,
departmentPath: string | null | undefined,
): boolean {
const employee = normalizeOrganizationPath(employeePath)
const department = normalizeOrganizationPath(departmentPath)
if (!employee || !department) return false
return employee === department || employee.startsWith(`${department}/`)
}
export function collectEmployeeOrganizationPaths(
candidates: Array<string | null | undefined>,
): string[] {
const unique: string[] = []
const seen = new Set<string>()
for (const candidate of candidates) {
const path = normalizeOrganizationPath(candidate)
if (!path || seen.has(path)) continue
seen.add(path)
unique.push(path)
}
return collapseNestedOrganizationPaths(unique)
}
export type EmployeeOrganizationPathSource = {
departmentPath?: string | null
sourcePayload?: unknown
departmentMemberships?: Array<{
sourceDeptId?: string | number | null
departmentPath?: string | null
}>
}
export function collectEmployeeOrganizationSourceDeptIds(
profile: EmployeeOrganizationPathSource | null | undefined,
): string[] {
if (!profile) return []
const synced = ((profile.sourcePayload as { syncedFields?: Record<string, unknown> } | null)?.syncedFields ||
{}) as Record<string, unknown>
return Array.from(new Set([
...(profile.departmentMemberships || []).flatMap((membership) =>
parseOrganizationDepartmentIds(membership.sourceDeptId),
),
...parseOrganizationDepartmentIds(synced.departmentIds),
...parseOrganizationDepartmentIds(synced.employeeDeptIdList),
...parseOrganizationDepartmentIds(synced.registerDeptIds),
...parseOrganizationDepartmentIds(synced.flowerBookDeptId),
]))
}
/** 和数据口径同一套任职路径:主部门 + 兼任 + 花名册部门 ID。 */
export function resolveEmployeeOrganizationPaths(
profile: EmployeeOrganizationPathSource | null | undefined,
pathBySourceDeptId: Map<string, string> | Record<string, string>,
): string[] {
if (!profile) return []
const lookup =
pathBySourceDeptId instanceof Map
? pathBySourceDeptId
: new Map(Object.entries(pathBySourceDeptId))
const memberships = profile.departmentMemberships || []
const departmentIds = collectEmployeeOrganizationSourceDeptIds(profile)
// A known department identity is authoritative. Renamed/moved/retired
// departments must not regain access through stale roster path snapshots.
if (departmentIds.length > 0) {
const currentPaths = collectEmployeeOrganizationPaths(departmentIds.map((id) => lookup.get(id)))
const primary = normalizeOrganizationPath(profile.departmentPath)
return currentPaths.includes(primary)
? [primary, ...currentPaths.filter((path) => path !== primary)] : currentPaths
}
return collectEmployeeOrganizationPaths([
profile.departmentPath,
...memberships.map((membership) => membership.departmentPath),
])
}
/** 花名册常同时给上级和本组。只留最深的任职,避免「品牌创意A部 + 品牌中心」被当成两个职位。 */
export function collapseNestedOrganizationPaths(paths: string[]): string[] {
return paths.filter(
(path) => !paths.some((other) => other !== path && isUnderOrganizationPath(other, path)),
)
}
export function personBelongsToOrganizationPath(
employeePaths: Array<string | null | undefined>,
departmentPath: string | null | undefined,
): boolean {
return employeePaths.some((path) => isUnderOrganizationPath(path, departmentPath))
}
export function parseOrganizationDepartmentIds(value: unknown): string[] {
if (value == null) return []
if (Array.isArray(value)) {
return Array.from(new Set(value.flatMap((item) => parseOrganizationDepartmentIds(item))))
}
const text = String(value).trim()
if (!text) return []
if (text.startsWith('[')) {
try {
const parsed = JSON.parse(text)
if (Array.isArray(parsed)) return parseOrganizationDepartmentIds(parsed)
} catch {
// 花名册有时是半截 JSON,继续按分隔符拆。
}
}
return Array.from(
new Set(
text
.split(/[/>||,,;;\s\[\]]+/)
.map((item) => item.trim())
.filter((item) => /^\d+$/.test(item)),
),
)
}
/**
* 每个部门路径上的人数(含子孙)。
* 一个人只加一次到每个祖先,禁止再按子节点滚加,否则公司节点会被加出三四倍。
*/
export function countPeopleOnOrganizationPaths(
employeePaths: Array<string | null | undefined>,
): Map<string, number> {
return countUniquePeopleOnOrganizationPaths(employeePaths.map((path) => [path]))
}
/**
* 一人多部门时:每个任职部门及其祖先都计他一次;
* 两个部门共用的上级(比如公司)仍然只计一个人。
*/
export function countUniquePeopleOnOrganizationPaths(
peoplePaths: Array<Array<string | null | undefined>>,
): Map<string, number> {
const counts = new Map<string, number>()
for (const paths of peoplePaths) {
const prefixes = new Set<string>()
for (const path of paths) {
for (const prefix of organizationPathChain(path || '')) {
prefixes.add(prefix)
}
}
Array.from(prefixes).forEach((prefix) => {
counts.set(prefix, (counts.get(prefix) ?? 0) + 1)
})
}
return counts
}
export function findOrganizationPermissionStorageKey(
path: string,
storedKeys: Iterable<string>,
): { key: string; source: Exclude<OrganizationPermissionSource, 'inherit' | 'none'> } | null {
const keys = storedKeys instanceof Set ? storedKeys : new Set(storedKeys)
if (keys.has(path)) {
return { key: path, source: 'own' }
}
const name = lastOrganizationPathSegment(path)
const alias = LEGACY_DEPARTMENT_ALIASES[name]
if (alias && keys.has(alias)) {
return { key: alias, source: 'legacy' }
}
if (name && keys.has(name)) {
return { key: name, source: 'legacy' }
}
return null
}
export function resolveOrganizationPermissionKey(
path: string,
storedKeys: Iterable<string>,
): { key: string; source: OrganizationPermissionSource; inheritFrom: string | null } | null {
const chain = organizationPathChain(path)
if (chain.length === 0) return null
const keys = storedKeys instanceof Set ? storedKeys : new Set(storedKeys)
for (let index = chain.length - 1; index >= 0; index -= 1) {
const candidate = chain[index]
const matched = findOrganizationPermissionStorageKey(candidate, keys)
if (!matched) continue
const isOwnNode = candidate === path
return {
key: matched.key,
source: isOwnNode ? matched.source : 'inherit',
inheritFrom: isOwnNode ? null : candidate,
}
}
return { key: path, source: 'none', inheritFrom: null }
}
export type OrganizationTreeNode<T extends { path: string; children: T[] }> = T
export function collectOccupiedOrganizationPaths(paths: Array<string | null | undefined>): Set<string> {
const occupied = new Set<string>()
for (const path of paths) {
for (const prefix of organizationPathChain(path || '')) {
occupied.add(prefix)
}
}
return occupied
}
export function pruneOrganizationTree<T extends { path: string; children: T[] }>(
nodes: T[],
occupiedPaths: Iterable<string>,
): T[] {
const occupied = occupiedPaths instanceof Set ? occupiedPaths : new Set(occupiedPaths)
return nodes.flatMap((node) => {
const children = pruneOrganizationTree(node.children, occupied)
if (children.length === 0 && !occupied.has(node.path)) return []
return [{ ...node, children }]
})
}