基线:鉴权会话+工作组+工作台三区修复+编排工作区+产品设计文档
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* org 的仓储 adapter —— Drizzle 只准出现在 adapter 与 app(编码规范 §5.3 / §11.4)。
|
||||
*/
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import type { Grant, GrantObject, Position, PositionId, Staff, StaffId } from '@contracts';
|
||||
import { grantSchema, positionIdSchema, positionSchema, staffSchema } from '@contracts';
|
||||
import { and, eq, gt } from 'drizzle-orm';
|
||||
import type { Grant, GrantObject, Position, PositionId, Staff, StaffId, WorkGroup } from '@contracts';
|
||||
import { grantSchema, positionIdSchema, positionSchema, staffIdSchema, staffSchema, workGroupSchema } from '@contracts';
|
||||
import type { Db } from '@shared/db.ts';
|
||||
import type { GrantRepository, PositionRepository, StaffRepository } from '../../port.ts';
|
||||
import { grantTable, positionTable, staffTable } from './org-schema.ts';
|
||||
import type { GrantRepository, PositionRepository, SessionRepository, StaffRepository, WorkGroupRepository } from '../../port.ts';
|
||||
import { grantTable, positionTable, sessionTable, staffTable, workGroupTable } from './org-schema.ts';
|
||||
|
||||
/** 授权客体的规范化串 —— 主键的一部分,保证同一客体不重复授权 */
|
||||
export function objectKeyOf(object: GrantObject): string {
|
||||
@@ -93,6 +93,41 @@ export function createStaffRepository(db: Db): StaffRepository {
|
||||
async setState(id: StaffId, state) {
|
||||
await db.update(staffTable).set({ state }).where(eq(staffTable.id, id));
|
||||
},
|
||||
async passwordHashOf(account: string) {
|
||||
const rows = await db
|
||||
.select({ hash: staffTable.passwordHash })
|
||||
.from(staffTable)
|
||||
.where(eq(staffTable.account, account))
|
||||
.limit(1);
|
||||
return rows[0]?.hash ?? undefined;
|
||||
},
|
||||
async setPasswordHash(id: StaffId, hash: string) {
|
||||
await db.update(staffTable).set({ passwordHash: hash }).where(eq(staffTable.id, id));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 会话仓储 —— 过期由 `expires_at` 判定,读时顺带滑动续期(ADR-010) */
|
||||
export function createSessionRepository(db: Db): SessionRepository {
|
||||
return {
|
||||
async issue({ token, staffId, expiresAt }) {
|
||||
await db.insert(sessionTable).values({ id: token, staffId, expiresAt });
|
||||
},
|
||||
async touch(token: string, now: Date, expiresAt: Date) {
|
||||
const rows = await db
|
||||
.update(sessionTable)
|
||||
.set({ lastSeenAt: now, expiresAt })
|
||||
.where(and(eq(sessionTable.id, token), gt(sessionTable.expiresAt, now)))
|
||||
.returning({ staffId: sessionTable.staffId });
|
||||
const row = rows[0];
|
||||
return row === undefined ? undefined : { staffId: staffIdSchema.parse(row.staffId) };
|
||||
},
|
||||
async revoke(token: string) {
|
||||
await db.delete(sessionTable).where(eq(sessionTable.id, token));
|
||||
},
|
||||
async revokeAllOf(staffId: StaffId) {
|
||||
await db.delete(sessionTable).where(eq(sessionTable.staffId, staffId));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,3 +165,28 @@ export function createGrantRepository(db: Db): GrantRepository {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 工作组仓储(ADR-011)—— 成员表整块存 jsonb,键是岗位 code */
|
||||
export function createWorkGroupRepository(db: Db): WorkGroupRepository {
|
||||
const toGroup = (row: typeof workGroupTable.$inferSelect): WorkGroup =>
|
||||
workGroupSchema.parse({ id: row.id, name: row.name, ownerId: row.ownerId, members: row.members });
|
||||
|
||||
return {
|
||||
async listByOwner(ownerId: StaffId) {
|
||||
const rows = await db.select().from(workGroupTable).where(eq(workGroupTable.ownerId, ownerId));
|
||||
return rows.map(toGroup);
|
||||
},
|
||||
async upsert(group: WorkGroup) {
|
||||
const [row] = await db
|
||||
.insert(workGroupTable)
|
||||
.values({ id: group.id, name: group.name, ownerId: group.ownerId, members: group.members })
|
||||
.onConflictDoUpdate({
|
||||
target: workGroupTable.id,
|
||||
set: { name: group.name, ownerId: group.ownerId, members: group.members },
|
||||
})
|
||||
.returning();
|
||||
if (row === undefined) throw new Error('工作组写入未返回行');
|
||||
return toGroup(row);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,11 +27,31 @@ export const staffTable = sys.table(
|
||||
.notNull()
|
||||
.references(() => positionTable.id),
|
||||
state: text('state').notNull().default('active'),
|
||||
/** 口令散列(ADR-010):`scrypt$N$r$p$salt$hash`,明文永不落库 */
|
||||
passwordHash: text('password_hash'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [uniqueIndex('staff_account_key').on(t.account), index('staff_position_idx').on(t.positionId)],
|
||||
);
|
||||
|
||||
/**
|
||||
* 会话(ADR-010)—— 服务端表而非签名 cookie:**登出必须立刻失效**,
|
||||
* 且停用员工时要能一并踢掉在途会话。id 即 token,不透明随机串。
|
||||
*/
|
||||
export const sessionTable = sys.table(
|
||||
'session',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
staffId: text('staff_id')
|
||||
.notNull()
|
||||
.references(() => staffTable.id, { onDelete: 'cascade' }),
|
||||
issuedAt: timestamp('issued_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index('session_staff_idx').on(t.staffId), index('session_expires_idx').on(t.expiresAt)],
|
||||
);
|
||||
|
||||
export const grantTable = sys.table(
|
||||
'position_grant',
|
||||
{
|
||||
@@ -46,3 +66,21 @@ export const grantTable = sys.table(
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.positionId, t.objectKey, t.action] })],
|
||||
);
|
||||
|
||||
/**
|
||||
* 工作组(ADR-011)—— 一组「岗位 code → 员工 id」的绑定。
|
||||
* 起单时按来源人取其组、快照进工作流;这里存的是**当前**成员表,可改。
|
||||
*/
|
||||
export const workGroupTable = sys.table(
|
||||
'work_group',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
ownerId: text('owner_id')
|
||||
.notNull()
|
||||
.references(() => staffTable.id, { onDelete: 'cascade' }),
|
||||
members: jsonb('members').notNull().default({}),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index('work_group_owner_idx').on(t.ownerId)],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 口令散列(ADR-010)—— Node 内置 scrypt,零第三方依赖。
|
||||
*
|
||||
* 串格式自描述:`scrypt$N$r$p$<salt-b64url>$<hash-b64url>`。
|
||||
* 参数写进串里,日后调参不必迁移旧记录:比对时按**该记录自己的**参数重算。
|
||||
* 明文永不落库,比对走 `timingSafeEqual`(等长常数时间,不因前缀相同而早退)。
|
||||
*/
|
||||
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
/** 默认代价参数:N=16384 约 16MB 内存、单次 ~50ms —— 内网 ≤50 人足够 */
|
||||
const N = 16_384;
|
||||
const R = 8;
|
||||
const P = 1;
|
||||
const KEY_LEN = 32;
|
||||
const SALT_LEN = 16;
|
||||
|
||||
const b64 = (buf: Buffer) => buf.toString('base64url');
|
||||
|
||||
function derive(password: string, salt: Buffer, n: number, r: number, p: number): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// maxmem 需随 N 抬高:默认 32MB 挡不住 N=16384(约需 128*N*r ≈ 16MB,留两倍余量)
|
||||
scrypt(password, salt, KEY_LEN, { N: n, r, p, maxmem: 256 * n * r }, (error, key) => {
|
||||
if (error !== null) reject(error);
|
||||
else resolve(key);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(SALT_LEN);
|
||||
const hash = await derive(password, salt, N, R, P);
|
||||
return `scrypt$${N}$${R}$${P}$${b64(salt)}$${b64(hash)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 比对。存的串不合格式 / 员工尚未设口令 → 一律 false,不抛错:
|
||||
* 登录失败的原因不外泄(账号不存在、未设口令、口令错,对外都是同一句话)。
|
||||
*/
|
||||
export async function verifyPassword(password: string, stored: string | null | undefined): Promise<boolean> {
|
||||
if (stored === null || stored === undefined) return false;
|
||||
const parts = stored.split('$');
|
||||
if (parts.length !== 6 || parts[0] !== 'scrypt') return false;
|
||||
|
||||
const [, nRaw, rRaw, pRaw, saltRaw, hashRaw] = parts;
|
||||
const n = Number(nRaw);
|
||||
const r = Number(rRaw);
|
||||
const p = Number(pRaw);
|
||||
if (!Number.isInteger(n) || !Number.isInteger(r) || !Number.isInteger(p)) return false;
|
||||
|
||||
const expected = Buffer.from(hashRaw ?? '', 'base64url');
|
||||
if (expected.length === 0) return false;
|
||||
|
||||
const actual = await derive(password, Buffer.from(saltRaw ?? '', 'base64url'), n, r, p).catch(() => undefined);
|
||||
if (actual === undefined || actual.length !== expected.length) return false;
|
||||
return timingSafeEqual(actual, expected);
|
||||
}
|
||||
|
||||
/** 会话 token —— 32 字节密码学随机,不透明(不编码任何身份信息) */
|
||||
export function newSessionToken(): string {
|
||||
return b64(randomBytes(32));
|
||||
}
|
||||
@@ -9,6 +9,12 @@ export const POSITION_SEED = [
|
||||
{ code: 'FAE', name: '现场应用', note: '替代选型' },
|
||||
{ code: 'PRM', name: '采购/寻源', note: '渠道寻源' },
|
||||
{ code: 'OP', name: '运营', note: '运营支持' },
|
||||
/*
|
||||
* 测试岗(ADR-011):测试账号单独占一个岗位,**不混进五个业务岗**。
|
||||
* 混进去会让轮值把真单派到测试账号头上;单列则彼此不打扰 ——
|
||||
* 测试账号靠**工作组绑定**接管整条流的每个岗位,不靠自己占岗。
|
||||
*/
|
||||
{ code: 'TEST', name: '测试', note: '测试账号专用岗;整条流经工作组绑定落到本人' },
|
||||
] as const;
|
||||
|
||||
export const STAFF_SEED = [
|
||||
@@ -17,6 +23,9 @@ export const STAFF_SEED = [
|
||||
{ account: 'wangziming', name: '王梓名', position: 'FAE' },
|
||||
{ account: 'linpeixuan', name: '林佩萱', position: 'PRM' },
|
||||
{ account: 'liyuanchu', name: '李元初', position: 'OP' },
|
||||
// 两个测试账号:各自一个「全岗位指向自己」的工作组,起单即整条流归自己
|
||||
{ account: 'AI', name: 'AI', position: 'TEST' },
|
||||
{ account: 'Chaihao', name: '柴浩', position: 'TEST' },
|
||||
] as const;
|
||||
|
||||
/** 登录者 = 冯兵(BD);MVP 单组织 cookie session(编码规范 §4 鉴权定调) */
|
||||
@@ -30,6 +39,18 @@ export type StaffSeed = (typeof STAFF_SEED)[number];
|
||||
* 任务负责人与被征答人对所涉内容**天然可见**,不靠这里授权(OS 02)。
|
||||
*/
|
||||
export const POSITION_GRANT_SEED: Readonly<Record<string, ReadonlyArray<{ fn: string; action: 'read' | 'write' | 'govern' }>>> = {
|
||||
/** 测试岗与 PM 同权:一个人要走完整条流,还得在治理台看过程 */
|
||||
TEST: [
|
||||
{ fn: 'task', action: 'govern' },
|
||||
{ fn: 'staff', action: 'govern' },
|
||||
{ fn: 'process', action: 'govern' },
|
||||
{ fn: 'trigger', action: 'govern' },
|
||||
{ fn: 'activity', action: 'govern' },
|
||||
{ fn: 'tool', action: 'govern' },
|
||||
{ fn: 'data', action: 'govern' },
|
||||
{ fn: 'memory', action: 'govern' },
|
||||
{ fn: 'system', action: 'govern' },
|
||||
],
|
||||
PM: [
|
||||
{ fn: 'task', action: 'govern' },
|
||||
{ fn: 'staff', action: 'govern' },
|
||||
@@ -59,3 +80,12 @@ export const POSITION_GRANT_SEED: Readonly<Record<string, ReadonlyArray<{ fn: st
|
||||
{ fn: 'memory', action: 'read' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 测试账号的**独立工作组**(ADR-011)—— 所有岗位都指向本人。
|
||||
* 起单即整条流(A1…A7 + 审批点)全部落到自己工位,一个人从头走到尾。
|
||||
*/
|
||||
export const SOLO_GROUP_ACCOUNTS = ['AI', 'Chaihao'] as const;
|
||||
|
||||
/** 组要覆盖的岗位 = 业务五岗(测试岗自身不出现在流程包里) */
|
||||
export const SOLO_GROUP_POSITIONS = ['PM', 'BD', 'FAE', 'PRM', 'OP'] as const;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Grant, GrantAction, GrantObject, Position, PositionId, Session, St
|
||||
import { governanceFunctionSchema, positionIdSchema, staffIdSchema } from '@contracts';
|
||||
import type {
|
||||
CreateStaffInput,
|
||||
IssuedSession,
|
||||
OrgService,
|
||||
StaffWithPosition,
|
||||
} from '../../contract.ts';
|
||||
@@ -15,24 +16,37 @@ import {
|
||||
StaffNotFoundError,
|
||||
} from '../../contract.ts';
|
||||
import type {
|
||||
Clock,
|
||||
GrantRepository,
|
||||
IdGenerator,
|
||||
InFlightWorkProbe,
|
||||
PasswordHasher,
|
||||
PositionRepository,
|
||||
SessionRepository,
|
||||
StaffRepository,
|
||||
WorkGroupRepository,
|
||||
} from '../../port.ts';
|
||||
import { isAuthorized } from '../domain/authorization.ts';
|
||||
import { buildDisableCheck } from '../domain/disable-check.ts';
|
||||
import { POSITION_GRANT_SEED, POSITION_SEED, STAFF_SEED } from '../domain/seed-data.ts';
|
||||
import { POSITION_GRANT_SEED, POSITION_SEED, SOLO_GROUP_ACCOUNTS, SOLO_GROUP_POSITIONS, STAFF_SEED } from '../domain/seed-data.ts';
|
||||
|
||||
export type OrgServiceDeps = {
|
||||
readonly positions: PositionRepository;
|
||||
readonly staff: StaffRepository;
|
||||
readonly grants: GrantRepository;
|
||||
readonly sessions: SessionRepository;
|
||||
readonly workGroups: WorkGroupRepository;
|
||||
readonly passwords: PasswordHasher;
|
||||
readonly inFlight: InFlightWorkProbe;
|
||||
readonly clock: Clock;
|
||||
readonly ids: IdGenerator;
|
||||
/** 种子口令 —— 由 app 从环境读入,源码不写任何真实口令(ADR-010) */
|
||||
readonly seedPassword: string;
|
||||
};
|
||||
|
||||
/** 会话有效期 12 小时,每次命中滑动续期 */
|
||||
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
export function createOrgService(deps: OrgServiceDeps): OrgService {
|
||||
async function positionMap(): Promise<Map<string, Position>> {
|
||||
const all = await deps.positions.list();
|
||||
@@ -78,13 +92,16 @@ export function createOrgService(deps: OrgServiceDeps): OrgService {
|
||||
const positionId = positionIdSchema.parse(input.positionId);
|
||||
const position = await deps.positions.findById(positionId);
|
||||
if (!position) throw new PositionNotFoundError(input.positionId);
|
||||
return deps.staff.insert({
|
||||
const created = await deps.staff.insert({
|
||||
id: staffIdSchema.parse(deps.ids.next()),
|
||||
account: input.account,
|
||||
name: input.name,
|
||||
positionId,
|
||||
state: 'active',
|
||||
});
|
||||
// 新员工带初始口令建档 —— 不留「有账号但登不进」的半成品
|
||||
await deps.staff.setPasswordHash(created.id, await deps.passwords.hash(deps.seedPassword));
|
||||
return created;
|
||||
},
|
||||
|
||||
async checkDisable(staffId) {
|
||||
@@ -102,6 +119,8 @@ export function createOrgService(deps: OrgServiceDeps): OrgService {
|
||||
const check = await this.checkDisable(staffId);
|
||||
if (check.blocked) throw new DisableBlockedError(check);
|
||||
await deps.staff.setState(staffId, 'disabled');
|
||||
// 停用即踢会话:否则被停用的人凭旧 cookie 还能继续办事(ADR-010)
|
||||
await deps.sessions.revokeAllOf(staffId);
|
||||
},
|
||||
|
||||
async listGrants(positionId) {
|
||||
@@ -128,6 +147,73 @@ export function createOrgService(deps: OrgServiceDeps): OrgService {
|
||||
return { staff, position, grants };
|
||||
},
|
||||
|
||||
/**
|
||||
* 登录 —— 失败一律 undefined,**不区分原因**:
|
||||
* 账号不存在 / 已停用 / 未设口令 / 口令错,对外都是同一句话,不外泄账号是否存在。
|
||||
*/
|
||||
async login(account, password): Promise<IssuedSession | undefined> {
|
||||
const staff = await deps.staff.findByAccount(account);
|
||||
const stored = await deps.staff.passwordHashOf(account);
|
||||
// 无论账号是否存在都走一遍散列比对:否则响应时间会泄漏账号是否存在
|
||||
const ok = await deps.passwords.verify(password, stored);
|
||||
if (!ok || staff === undefined || staff.state !== 'active') return undefined;
|
||||
|
||||
const token = deps.passwords.newToken();
|
||||
const expiresAt = new Date(deps.clock.now().getTime() + SESSION_TTL_MS);
|
||||
await deps.sessions.issue({ token, staffId: staff.id, expiresAt });
|
||||
return { token, expiresAt: expiresAt.toISOString() };
|
||||
},
|
||||
|
||||
async logout(token) {
|
||||
await deps.sessions.revoke(token);
|
||||
},
|
||||
|
||||
async resolveSession(token): Promise<Session | undefined> {
|
||||
const now = deps.clock.now();
|
||||
const hit = await deps.sessions.touch(token, now, new Date(now.getTime() + SESSION_TTL_MS));
|
||||
if (hit === undefined) return undefined;
|
||||
const staff = await deps.staff.findById(hit.staffId);
|
||||
// 会话还在但人已停用 —— 立刻作废,不给旧 cookie 留缝
|
||||
if (staff === undefined || staff.state !== 'active') {
|
||||
await deps.sessions.revoke(token);
|
||||
return undefined;
|
||||
}
|
||||
const position = await deps.positions.findById(staff.positionId);
|
||||
if (!position) throw new PositionNotFoundError(staff.positionId);
|
||||
const grants = await deps.grants.listByPosition(staff.positionId);
|
||||
return { staff, position, grants };
|
||||
},
|
||||
|
||||
async setPassword(staffId, password) {
|
||||
const staff = await deps.staff.findById(staffId);
|
||||
if (!staff) throw new StaffNotFoundError(staffId);
|
||||
await deps.staff.setPasswordHash(staffId, await deps.passwords.hash(password));
|
||||
},
|
||||
|
||||
/* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────── */
|
||||
|
||||
async saveWorkGroup(input) {
|
||||
const staff = await deps.staff.findById(input.ownerId);
|
||||
if (!staff) throw new StaffNotFoundError(input.ownerId);
|
||||
return deps.workGroups.upsert({
|
||||
id: input.id ?? deps.ids.next(),
|
||||
name: input.name,
|
||||
ownerId: input.ownerId,
|
||||
members: { ...input.members },
|
||||
});
|
||||
},
|
||||
|
||||
async workGroupOf(staffId) {
|
||||
// 一人一组:多组时取稳定的第一条(选组这一步 MVP 不做,见 debt)
|
||||
const groups = await deps.workGroups.listByOwner(staffId);
|
||||
return [...groups].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))[0];
|
||||
},
|
||||
|
||||
async bindingOf(staffId) {
|
||||
const group = await this.workGroupOf(staffId);
|
||||
return group === undefined ? undefined : { ...group.members };
|
||||
},
|
||||
|
||||
async staffOfPosition(positionCode) {
|
||||
const position = await deps.positions.findByCode(positionCode);
|
||||
if (!position) throw new PositionNotFoundError(positionCode);
|
||||
@@ -152,16 +238,39 @@ export function createOrgService(deps: OrgServiceDeps): OrgService {
|
||||
|
||||
for (const seed of STAFF_SEED) {
|
||||
const existing = await deps.staff.findByAccount(seed.account);
|
||||
if (existing) continue;
|
||||
if (existing) {
|
||||
// 补种口令:ADR-010 之前建的员工没有 password_hash,不补就永远登不进
|
||||
const hash = await deps.staff.passwordHashOf(seed.account);
|
||||
if (hash === undefined) {
|
||||
await deps.staff.setPasswordHash(existing.id, await deps.passwords.hash(deps.seedPassword));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const positionId = byCode.get(seed.position);
|
||||
if (!positionId) throw new PositionNotFoundError(seed.position);
|
||||
await deps.staff.insert({
|
||||
const created = await deps.staff.insert({
|
||||
id: staffIdSchema.parse(deps.ids.next()),
|
||||
account: seed.account,
|
||||
name: seed.name,
|
||||
positionId,
|
||||
state: 'active',
|
||||
});
|
||||
// 新员工一律带初始口令入库 —— 没有「无口令即可登录」的窗口
|
||||
await deps.staff.setPasswordHash(created.id, await deps.passwords.hash(deps.seedPassword));
|
||||
}
|
||||
|
||||
// 测试账号的独立工作组(ADR-011):所有业务岗都指向本人,起单即整条流归自己
|
||||
for (const account of SOLO_GROUP_ACCOUNTS) {
|
||||
const staff = await deps.staff.findByAccount(account);
|
||||
if (staff === undefined) continue;
|
||||
const existing = await deps.workGroups.listByOwner(staff.id);
|
||||
if (existing.length > 0) continue;
|
||||
await deps.workGroups.upsert({
|
||||
id: `wg-solo-${account.toLowerCase()}`,
|
||||
name: `${staff.name} 独立测试组`,
|
||||
ownerId: staff.id,
|
||||
members: Object.fromEntries(SOLO_GROUP_POSITIONS.map((code) => [code, staff.id])),
|
||||
});
|
||||
}
|
||||
|
||||
for (const [code, entries] of Object.entries(POSITION_GRANT_SEED)) {
|
||||
|
||||
Reference in New Issue
Block a user