936 lines
33 KiB
TypeScript
936 lines
33 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { Inject, Injectable } from '@nestjs/common';
|
|
import {
|
|
and,
|
|
asc,
|
|
companies,
|
|
eq,
|
|
estates,
|
|
hierarchyGrants,
|
|
inArray,
|
|
platformProjects,
|
|
users,
|
|
workspaces,
|
|
type Db,
|
|
} from '@mosaicstack/db';
|
|
import { DB } from '../database/database.module.js';
|
|
import {
|
|
appendHierarchyEvent,
|
|
buildNodeSnapshot,
|
|
HierarchyAuditIdempotencyConflictError,
|
|
} from './hierarchy-audit.repository.js';
|
|
import {
|
|
evaluateEffectiveRole,
|
|
grantedCompanyIds,
|
|
namespacedHierarchyRole,
|
|
roleAtLeast,
|
|
type GrantTargetKind,
|
|
type HierarchyGrantRole,
|
|
} from './hierarchy-grant-evaluation.js';
|
|
|
|
/**
|
|
* Hierarchy command repository (contract 1 §5, contract 2 §4).
|
|
*
|
|
* The ONLY writer of the hierarchy class tables (companies, estates,
|
|
* platform_projects, hierarchy_grants) — it is the writer-coverage
|
|
* allowlist's sole entry. Every command runs one transaction that
|
|
* authorizes (live grant evaluation inside the same transaction), mutates,
|
|
* and appends the semantic audit event + outbox record via the M4-1b-i
|
|
* machinery, so state, event, and outbox commit or roll back together
|
|
* (REQ-AUD-001).
|
|
*
|
|
* Authorization failure and target-not-found both return `not_found`
|
|
* (contract 1 §6.7: no existence oracle — an unauthorized caller learns
|
|
* nothing a stranger would not). `forbidden` appears only where the caller
|
|
* already knows the surface exists independent of any node: the admin-only
|
|
* visibility change (§5.5). Serialized role strings are namespaced (§4.5).
|
|
*/
|
|
|
|
export type HierarchyCommandFailure =
|
|
| { readonly ok: false; readonly error: 'not_found' }
|
|
| { readonly ok: false; readonly error: 'forbidden'; readonly message: string }
|
|
| { readonly ok: false; readonly error: 'conflict'; readonly message: string };
|
|
|
|
export type HierarchyResult<T> = ({ readonly ok: true } & T) | HierarchyCommandFailure;
|
|
|
|
export interface CompanyView {
|
|
readonly id: string;
|
|
readonly name: string;
|
|
readonly slug: string;
|
|
readonly visibility: string;
|
|
}
|
|
|
|
export interface NodeView {
|
|
readonly id: string;
|
|
readonly name: string;
|
|
readonly slug: string;
|
|
}
|
|
|
|
export interface GrantView {
|
|
readonly id: string;
|
|
readonly userId: string;
|
|
readonly targetKind: GrantTargetKind;
|
|
readonly targetId: string;
|
|
/** Namespaced (§4.5), e.g. `hierarchy:owner`. */
|
|
readonly role: string;
|
|
readonly grantedBy: string;
|
|
}
|
|
|
|
/** Directory rows are closed-field: existence, name, slug — nothing else (§2.8). */
|
|
export interface DirectoryEntry {
|
|
readonly id: string;
|
|
readonly name: string;
|
|
readonly slug: string;
|
|
}
|
|
|
|
type Tx = Pick<Db, 'insert' | 'select' | 'update' | 'delete'>;
|
|
type GrantRow = typeof hierarchyGrants.$inferSelect;
|
|
|
|
const NOT_FOUND: HierarchyCommandFailure = { ok: false, error: 'not_found' };
|
|
|
|
function conflict(message: string): HierarchyCommandFailure {
|
|
return { ok: false, error: 'conflict', message };
|
|
}
|
|
|
|
function grantTarget(row: GrantRow): { kind: GrantTargetKind; id: string } {
|
|
if (row.companyId) return { kind: 'company', id: row.companyId };
|
|
if (row.estateId) return { kind: 'estate', id: row.estateId };
|
|
return { kind: 'platform_project', id: row.platformProjectId as string };
|
|
}
|
|
|
|
/** Grant event snapshot (contract 2 §4.4): subject, target, namespaced role, grantor. */
|
|
function grantSnapshot(row: GrantRow): Record<string, unknown> {
|
|
const target = grantTarget(row);
|
|
return {
|
|
id: row.id,
|
|
subject: { userId: row.userId },
|
|
target: { kind: target.kind, id: target.id },
|
|
role: namespacedHierarchyRole(row.role as HierarchyGrantRole),
|
|
grantedBy: row.grantedBy,
|
|
};
|
|
}
|
|
|
|
function grantView(row: GrantRow): GrantView {
|
|
const target = grantTarget(row);
|
|
return {
|
|
id: row.id,
|
|
userId: row.userId as string,
|
|
targetKind: target.kind,
|
|
targetId: target.id,
|
|
role: namespacedHierarchyRole(row.role as HierarchyGrantRole),
|
|
grantedBy: row.grantedBy,
|
|
};
|
|
}
|
|
|
|
function companyView(row: typeof companies.$inferSelect): CompanyView {
|
|
return { id: row.id, name: row.name, slug: row.slug, visibility: row.visibility };
|
|
}
|
|
|
|
interface CommandContext {
|
|
readonly actorId: string;
|
|
readonly idempotencyKey: string;
|
|
readonly correlationId: string;
|
|
}
|
|
|
|
@Injectable()
|
|
export class HierarchyRepository {
|
|
constructor(@Inject(DB) private readonly db: Db) {}
|
|
|
|
private async run<T>(
|
|
idempotencyKey: string | undefined,
|
|
actorId: string,
|
|
body: (tx: Tx, ctx: CommandContext) => Promise<HierarchyResult<T>>,
|
|
): Promise<HierarchyResult<T>> {
|
|
const ctx: CommandContext = {
|
|
actorId,
|
|
idempotencyKey: idempotencyKey ?? randomUUID(),
|
|
correlationId: randomUUID(),
|
|
};
|
|
try {
|
|
return await this.db.transaction(async (tx) => body(tx, ctx));
|
|
} catch (error) {
|
|
// A key replayed with different content aborts the whole command —
|
|
// the transaction (state change included) has rolled back (§6.4).
|
|
if (error instanceof HierarchyAuditIdempotencyConflictError) {
|
|
return conflict(error.message);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async requireOwner(
|
|
tx: Tx,
|
|
actorId: string,
|
|
kind: GrantTargetKind,
|
|
id: string,
|
|
): Promise<boolean> {
|
|
return roleAtLeast(await evaluateEffectiveRole(tx, actorId, kind, id), 'owner');
|
|
}
|
|
|
|
private async isPlatformAdmin(tx: Tx, actorId: string): Promise<boolean> {
|
|
const rows = await tx
|
|
.select({ role: users.role })
|
|
.from(users)
|
|
.where(eq(users.id, actorId))
|
|
.limit(1);
|
|
return rows[0]?.role === 'admin';
|
|
}
|
|
|
|
// ── companies ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Any authenticated user may create a company; the same audited operation
|
|
* writes the creator's initial owner grant (§4.3), causation-linked to the
|
|
* create event. Visibility is always 'private' — the command takes no
|
|
* visibility input (§5.5).
|
|
*/
|
|
createCompany(input: {
|
|
actorId: string;
|
|
name: string;
|
|
slug: string;
|
|
idempotencyKey?: string;
|
|
}): Promise<HierarchyResult<{ company: CompanyView; grant: GrantView }>> {
|
|
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
|
const inserted = await tx
|
|
.insert(companies)
|
|
.values({ name: input.name, slug: input.slug })
|
|
.onConflictDoNothing()
|
|
.returning();
|
|
const company = inserted[0];
|
|
if (!company) return conflict('company slug already exists');
|
|
|
|
const grantRows = await tx
|
|
.insert(hierarchyGrants)
|
|
.values({
|
|
userId: ctx.actorId,
|
|
companyId: company.id,
|
|
role: 'owner',
|
|
grantedBy: ctx.actorId,
|
|
})
|
|
.returning();
|
|
const grant = grantRows[0] as GrantRow;
|
|
|
|
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
|
|
const created = await appendHierarchyEvent(tx, {
|
|
actorId: ctx.actorId,
|
|
verb: 'create',
|
|
targetKind: 'company',
|
|
targetId: company.id,
|
|
targetSnapshot: { ...snapshot },
|
|
correlationId: ctx.correlationId,
|
|
idempotencyKey: ctx.idempotencyKey,
|
|
});
|
|
await appendHierarchyEvent(tx, {
|
|
actorId: ctx.actorId,
|
|
verb: 'grant_create',
|
|
targetKind: 'grant',
|
|
targetId: grant.id,
|
|
targetSnapshot: grantSnapshot(grant),
|
|
correlationId: ctx.correlationId,
|
|
causationId: created.event.id,
|
|
idempotencyKey: `${ctx.idempotencyKey}:grant`,
|
|
});
|
|
return { ok: true, company: companyView(company), grant: grantView(grant) };
|
|
});
|
|
}
|
|
|
|
renameCompany(input: {
|
|
actorId: string;
|
|
companyId: string;
|
|
name: string;
|
|
idempotencyKey?: string;
|
|
}): Promise<HierarchyResult<{ company: CompanyView }>> {
|
|
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
|
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
|
|
return NOT_FOUND;
|
|
}
|
|
const rows = await tx
|
|
.select()
|
|
.from(companies)
|
|
.where(eq(companies.id, input.companyId))
|
|
.limit(1);
|
|
const previous = rows[0];
|
|
if (!previous) return NOT_FOUND;
|
|
|
|
const updated = await tx
|
|
.update(companies)
|
|
.set({ name: input.name, updatedAt: new Date() })
|
|
.where(eq(companies.id, input.companyId))
|
|
.returning();
|
|
const company = updated[0] as typeof companies.$inferSelect;
|
|
|
|
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
|
|
await appendHierarchyEvent(tx, {
|
|
actorId: ctx.actorId,
|
|
verb: 'rename',
|
|
targetKind: 'company',
|
|
targetId: company.id,
|
|
targetSnapshot: { ...snapshot, previousName: previous.name },
|
|
correlationId: ctx.correlationId,
|
|
idempotencyKey: ctx.idempotencyKey,
|
|
});
|
|
return { ok: true, company: companyView(company) };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Admin-only until the company-CRUD capability ratifies (§5.5) — the one
|
|
* hierarchy mutation a platform admin performs without a grant. A
|
|
* non-admin caller (owner included) gets `forbidden` before any company
|
|
* read: the refusal reveals nothing about the target's existence.
|
|
*/
|
|
changeCompanyVisibility(input: {
|
|
actorId: string;
|
|
companyId: string;
|
|
visibility: string;
|
|
idempotencyKey?: string;
|
|
}): Promise<HierarchyResult<{ company: CompanyView }>> {
|
|
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
|
if (!(await this.isPlatformAdmin(tx, ctx.actorId))) {
|
|
return {
|
|
ok: false,
|
|
error: 'forbidden',
|
|
message: 'visibility change is platform-admin-only',
|
|
};
|
|
}
|
|
const rows = await tx
|
|
.select()
|
|
.from(companies)
|
|
.where(eq(companies.id, input.companyId))
|
|
.limit(1);
|
|
const previous = rows[0];
|
|
if (!previous) return NOT_FOUND;
|
|
|
|
const updated = await tx
|
|
.update(companies)
|
|
.set({ visibility: input.visibility, updatedAt: new Date() })
|
|
.where(eq(companies.id, input.companyId))
|
|
.returning();
|
|
const company = updated[0] as typeof companies.$inferSelect;
|
|
|
|
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
|
|
await appendHierarchyEvent(tx, {
|
|
actorId: ctx.actorId,
|
|
verb: 'visibility_change',
|
|
targetKind: 'company',
|
|
targetId: company.id,
|
|
// Old and new values are event content (§5.5).
|
|
targetSnapshot: {
|
|
...snapshot,
|
|
previousVisibility: previous.visibility,
|
|
visibility: company.visibility,
|
|
},
|
|
correlationId: ctx.correlationId,
|
|
idempotencyKey: ctx.idempotencyKey,
|
|
});
|
|
return { ok: true, company: companyView(company) };
|
|
});
|
|
}
|
|
|
|
deleteCompany(input: {
|
|
actorId: string;
|
|
companyId: string;
|
|
idempotencyKey?: string;
|
|
}): Promise<HierarchyResult<{ deletedId: string }>> {
|
|
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
|
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
|
|
return NOT_FOUND;
|
|
}
|
|
const children = await tx
|
|
.select({ id: estates.id })
|
|
.from(estates)
|
|
.where(eq(estates.companyId, input.companyId))
|
|
.limit(1);
|
|
if (children.length > 0) return conflict('company still has estates');
|
|
|
|
// Snapshot and grants are read before the delete; target FKs cascade
|
|
// the grant rows, and each cascaded deletion is audited (§5.2).
|
|
const snapshot = await buildNodeSnapshot(tx, 'company', input.companyId);
|
|
const grants = await tx
|
|
.select()
|
|
.from(hierarchyGrants)
|
|
.where(eq(hierarchyGrants.companyId, input.companyId));
|
|
|
|
await tx.delete(companies).where(eq(companies.id, input.companyId));
|
|
|
|
const deleted = await appendHierarchyEvent(tx, {
|
|
actorId: ctx.actorId,
|
|
verb: 'delete',
|
|
targetKind: 'company',
|
|
targetId: input.companyId,
|
|
targetSnapshot: { ...snapshot },
|
|
correlationId: ctx.correlationId,
|
|
idempotencyKey: ctx.idempotencyKey,
|
|
});
|
|
for (const grant of grants) {
|
|
await appendHierarchyEvent(tx, {
|
|
actorId: ctx.actorId,
|
|
verb: 'grant_revoke',
|
|
targetKind: 'grant',
|
|
targetId: grant.id,
|
|
targetSnapshot: grantSnapshot(grant),
|
|
correlationId: ctx.correlationId,
|
|
causationId: deleted.event.id,
|
|
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
|
|
});
|
|
}
|
|
return { ok: true, deletedId: input.companyId };
|
|
});
|
|
}
|
|
|
|
// ── estates ──────────────────────────────────────────────────────────────
|
|
|
|
/** Child creation requires owner on the parent and confers no grant (§4.3). */
|
|
createEstate(input: {
|
|
actorId: string;
|
|
companyId: string;
|
|
name: string;
|
|
slug: string;
|
|
idempotencyKey?: string;
|
|
}): Promise<HierarchyResult<{ estate: NodeView }>> {
|
|
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
|
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
|
|
return NOT_FOUND;
|
|
}
|
|
const inserted = await tx
|
|
.insert(estates)
|
|
.values({ companyId: input.companyId, name: input.name, slug: input.slug })
|
|
.onConflictDoNothing()
|
|
.returning();
|
|
const estate = inserted[0];
|
|
if (!estate) return conflict('estate slug already exists in company');
|
|
|
|
const snapshot = await buildNodeSnapshot(tx, 'estate', estate.id);
|
|
await appendHierarchyEvent(tx, {
|
|
actorId: ctx.actorId,
|
|
verb: 'create',
|
|
targetKind: 'estate',
|
|
targetId: estate.id,
|
|
targetSnapshot: { ...snapshot },
|
|
correlationId: ctx.correlationId,
|
|
idempotencyKey: ctx.idempotencyKey,
|
|
});
|
|
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
|
|
});
|
|
}
|
|
|
|
renameEstate(input: {
|
|
actorId: string;
|
|
estateId: string;
|
|
name: string;
|
|
idempotencyKey?: string;
|
|
}): Promise<HierarchyResult<{ estate: NodeView }>> {
|
|
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
|
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
|
|
return NOT_FOUND;
|
|
}
|
|
const rows = await tx.select().from(estates).where(eq(estates.id, input.estateId)).limit(1);
|
|
const previous = rows[0];
|
|
if (!previous) return NOT_FOUND;
|
|
|
|
const updated = await tx
|
|
.update(estates)
|
|
.set({ name: input.name })
|
|
.where(eq(estates.id, input.estateId))
|
|
.returning();
|
|
const estate = updated[0] as typeof estates.$inferSelect;
|
|
|
|
const snapshot = await buildNodeSnapshot(tx, 'estate', estate.id);
|
|
await appendHierarchyEvent(tx, {
|
|
actorId: ctx.actorId,
|
|
verb: 'rename',
|
|
targetKind: 'estate',
|
|
targetId: estate.id,
|
|
targetSnapshot: { ...snapshot, previousName: previous.name },
|
|
correlationId: ctx.correlationId,
|
|
idempotencyKey: ctx.idempotencyKey,
|
|
});
|
|
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
|
|
});
|
|
}
|
|
|
|
/** Transfer requires effective owner on BOTH parents, evaluated in the transfer's own transaction (§5). */
|
|
transferEstate(input: {
|
|
actorId: string;
|
|
estateId: string;
|
|
destinationCompanyId: string;
|
|
idempotencyKey?: string;
|
|
}): Promise<HierarchyResult<{ estate: NodeView }>> {
|
|
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
|
const rows = await tx.select().from(estates).where(eq(estates.id, input.estateId)).limit(1);
|
|
const estate = rows[0];
|
|
if (!estate) return NOT_FOUND;
|
|
if (!(await this.requireOwner(tx, ctx.actorId, 'company', estate.companyId))) {
|
|
return NOT_FOUND;
|
|
}
|
|
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.destinationCompanyId))) {
|
|
return NOT_FOUND;
|
|
}
|
|
if (estate.companyId === input.destinationCompanyId) {
|
|
return conflict('estate already belongs to the destination company');
|
|
}
|
|
const collision = await tx
|
|
.select({ id: estates.id })
|
|
.from(estates)
|
|
.where(
|
|
and(eq(estates.companyId, input.destinationCompanyId), eq(estates.slug, estate.slug)),
|
|
)
|
|
.limit(1);
|
|
if (collision.length > 0) return conflict('destination company already has that estate slug');
|
|
|
|
const parents = await tx
|
|
.select({ id: companies.id, slug: companies.slug })
|
|
.from(companies)
|
|
.where(inArray(companies.id, [estate.companyId, input.destinationCompanyId]));
|
|
const source = parents.find((p) => p.id === estate.companyId);
|
|
const destination = parents.find((p) => p.id === input.destinationCompanyId);
|
|
if (!source || !destination) return NOT_FOUND;
|
|
|
|
await tx
|
|
.update(estates)
|
|
.set({ companyId: input.destinationCompanyId })
|
|
.where(eq(estates.id, input.estateId));
|
|
|
|
const snapshot = await buildNodeSnapshot(tx, 'estate', input.estateId);
|
|
await appendHierarchyEvent(tx, {
|
|
actorId: ctx.actorId,
|
|
verb: 'transfer',
|
|
targetKind: 'estate',
|
|
targetId: input.estateId,
|
|
targetSnapshot: { ...snapshot },
|
|
transferFrom: { kind: 'company', id: source.id, slug: source.slug },
|
|
transferTo: { kind: 'company', id: destination.id, slug: destination.slug },
|
|
correlationId: ctx.correlationId,
|
|
idempotencyKey: ctx.idempotencyKey,
|
|
});
|
|
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
|
|
});
|
|
}
|
|
|
|
deleteEstate(input: {
|
|
actorId: string;
|
|
estateId: string;
|
|
idempotencyKey?: string;
|
|
}): Promise<HierarchyResult<{ deletedId: string }>> {
|
|
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
|
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
|
|
return NOT_FOUND;
|
|
}
|
|
const children = await tx
|
|
.select({ id: platformProjects.id })
|
|
.from(platformProjects)
|
|
.where(eq(platformProjects.estateId, input.estateId))
|
|
.limit(1);
|
|
if (children.length > 0) return conflict('estate still has platform projects');
|
|
|
|
const snapshot = await buildNodeSnapshot(tx, 'estate', input.estateId);
|
|
const grants = await tx
|
|
.select()
|
|
.from(hierarchyGrants)
|
|
.where(eq(hierarchyGrants.estateId, input.estateId));
|
|
|
|
await tx.delete(estates).where(eq(estates.id, input.estateId));
|
|
|
|
const deleted = await appendHierarchyEvent(tx, {
|
|
actorId: ctx.actorId,
|
|
verb: 'delete',
|
|
targetKind: 'estate',
|
|
targetId: input.estateId,
|
|
targetSnapshot: { ...snapshot },
|
|
correlationId: ctx.correlationId,
|
|
idempotencyKey: ctx.idempotencyKey,
|
|
});
|
|
for (const grant of grants) {
|
|
await appendHierarchyEvent(tx, {
|
|
actorId: ctx.actorId,
|
|
verb: 'grant_revoke',
|
|
targetKind: 'grant',
|
|
targetId: grant.id,
|
|
targetSnapshot: grantSnapshot(grant),
|
|
correlationId: ctx.correlationId,
|
|
causationId: deleted.event.id,
|
|
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
|
|
});
|
|
}
|
|
return { ok: true, deletedId: input.estateId };
|
|
});
|
|
}
|
|
|
|
// ── platform projects ────────────────────────────────────────────────────
|
|
|
|
createPlatformProject(input: {
|
|
actorId: string;
|
|
estateId: string;
|
|
name: string;
|
|
slug: string;
|
|
idempotencyKey?: string;
|
|
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
|
|
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
|
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
|
|
return NOT_FOUND;
|
|
}
|
|
const inserted = await tx
|
|
.insert(platformProjects)
|
|
.values({ estateId: input.estateId, name: input.name, slug: input.slug })
|
|
.onConflictDoNothing()
|
|
.returning();
|
|
const project = inserted[0];
|
|
if (!project) return conflict('platform project slug already exists in estate');
|
|
|
|
const snapshot = await buildNodeSnapshot(tx, 'platform_project', project.id);
|
|
await appendHierarchyEvent(tx, {
|
|
actorId: ctx.actorId,
|
|
verb: 'create',
|
|
targetKind: 'platform_project',
|
|
targetId: project.id,
|
|
targetSnapshot: { ...snapshot },
|
|
correlationId: ctx.correlationId,
|
|
idempotencyKey: ctx.idempotencyKey,
|
|
});
|
|
return {
|
|
ok: true,
|
|
platformProject: { id: project.id, name: project.name, slug: project.slug },
|
|
};
|
|
});
|
|
}
|
|
|
|
renamePlatformProject(input: {
|
|
actorId: string;
|
|
platformProjectId: string;
|
|
name: string;
|
|
idempotencyKey?: string;
|
|
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
|
|
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
|
if (
|
|
!(await this.requireOwner(tx, ctx.actorId, 'platform_project', input.platformProjectId))
|
|
) {
|
|
return NOT_FOUND;
|
|
}
|
|
const rows = await tx
|
|
.select()
|
|
.from(platformProjects)
|
|
.where(eq(platformProjects.id, input.platformProjectId))
|
|
.limit(1);
|
|
const previous = rows[0];
|
|
if (!previous) return NOT_FOUND;
|
|
|
|
const updated = await tx
|
|
.update(platformProjects)
|
|
.set({ name: input.name })
|
|
.where(eq(platformProjects.id, input.platformProjectId))
|
|
.returning();
|
|
const project = updated[0] as typeof platformProjects.$inferSelect;
|
|
|
|
const snapshot = await buildNodeSnapshot(tx, 'platform_project', project.id);
|
|
await appendHierarchyEvent(tx, {
|
|
actorId: ctx.actorId,
|
|
verb: 'rename',
|
|
targetKind: 'platform_project',
|
|
targetId: project.id,
|
|
targetSnapshot: { ...snapshot, previousName: previous.name },
|
|
correlationId: ctx.correlationId,
|
|
idempotencyKey: ctx.idempotencyKey,
|
|
});
|
|
return {
|
|
ok: true,
|
|
platformProject: { id: project.id, name: project.name, slug: project.slug },
|
|
};
|
|
});
|
|
}
|
|
|
|
transferPlatformProject(input: {
|
|
actorId: string;
|
|
platformProjectId: string;
|
|
destinationEstateId: string;
|
|
idempotencyKey?: string;
|
|
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
|
|
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
|
const rows = await tx
|
|
.select()
|
|
.from(platformProjects)
|
|
.where(eq(platformProjects.id, input.platformProjectId))
|
|
.limit(1);
|
|
const project = rows[0];
|
|
if (!project) return NOT_FOUND;
|
|
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', project.estateId))) {
|
|
return NOT_FOUND;
|
|
}
|
|
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.destinationEstateId))) {
|
|
return NOT_FOUND;
|
|
}
|
|
if (project.estateId === input.destinationEstateId) {
|
|
return conflict('platform project already belongs to the destination estate');
|
|
}
|
|
const collision = await tx
|
|
.select({ id: platformProjects.id })
|
|
.from(platformProjects)
|
|
.where(
|
|
and(
|
|
eq(platformProjects.estateId, input.destinationEstateId),
|
|
eq(platformProjects.slug, project.slug),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (collision.length > 0) {
|
|
return conflict('destination estate already has that platform project slug');
|
|
}
|
|
|
|
const parents = await tx
|
|
.select({ id: estates.id, slug: estates.slug })
|
|
.from(estates)
|
|
.where(inArray(estates.id, [project.estateId, input.destinationEstateId]));
|
|
const source = parents.find((p) => p.id === project.estateId);
|
|
const destination = parents.find((p) => p.id === input.destinationEstateId);
|
|
if (!source || !destination) return NOT_FOUND;
|
|
|
|
await tx
|
|
.update(platformProjects)
|
|
.set({ estateId: input.destinationEstateId })
|
|
.where(eq(platformProjects.id, input.platformProjectId));
|
|
|
|
const snapshot = await buildNodeSnapshot(tx, 'platform_project', input.platformProjectId);
|
|
await appendHierarchyEvent(tx, {
|
|
actorId: ctx.actorId,
|
|
verb: 'transfer',
|
|
targetKind: 'platform_project',
|
|
targetId: input.platformProjectId,
|
|
targetSnapshot: { ...snapshot },
|
|
transferFrom: { kind: 'estate', id: source.id, slug: source.slug },
|
|
transferTo: { kind: 'estate', id: destination.id, slug: destination.slug },
|
|
correlationId: ctx.correlationId,
|
|
idempotencyKey: ctx.idempotencyKey,
|
|
});
|
|
return {
|
|
ok: true,
|
|
platformProject: { id: project.id, name: project.name, slug: project.slug },
|
|
};
|
|
});
|
|
}
|
|
|
|
deletePlatformProject(input: {
|
|
actorId: string;
|
|
platformProjectId: string;
|
|
idempotencyKey?: string;
|
|
}): Promise<HierarchyResult<{ deletedId: string }>> {
|
|
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
|
if (
|
|
!(await this.requireOwner(tx, ctx.actorId, 'platform_project', input.platformProjectId))
|
|
) {
|
|
return NOT_FOUND;
|
|
}
|
|
const children = await tx
|
|
.select({ id: workspaces.id })
|
|
.from(workspaces)
|
|
.where(eq(workspaces.platformProjectId, input.platformProjectId))
|
|
.limit(1);
|
|
if (children.length > 0) return conflict('platform project still has workspaces');
|
|
|
|
const snapshot = await buildNodeSnapshot(tx, 'platform_project', input.platformProjectId);
|
|
const grants = await tx
|
|
.select()
|
|
.from(hierarchyGrants)
|
|
.where(eq(hierarchyGrants.platformProjectId, input.platformProjectId));
|
|
|
|
await tx.delete(platformProjects).where(eq(platformProjects.id, input.platformProjectId));
|
|
|
|
const deleted = await appendHierarchyEvent(tx, {
|
|
actorId: ctx.actorId,
|
|
verb: 'delete',
|
|
targetKind: 'platform_project',
|
|
targetId: input.platformProjectId,
|
|
targetSnapshot: { ...snapshot },
|
|
correlationId: ctx.correlationId,
|
|
idempotencyKey: ctx.idempotencyKey,
|
|
});
|
|
for (const grant of grants) {
|
|
await appendHierarchyEvent(tx, {
|
|
actorId: ctx.actorId,
|
|
verb: 'grant_revoke',
|
|
targetKind: 'grant',
|
|
targetId: grant.id,
|
|
targetSnapshot: grantSnapshot(grant),
|
|
correlationId: ctx.correlationId,
|
|
causationId: deleted.event.id,
|
|
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
|
|
});
|
|
}
|
|
return { ok: true, deletedId: input.platformProjectId };
|
|
});
|
|
}
|
|
|
|
// ── grants ───────────────────────────────────────────────────────────────
|
|
|
|
/** Grant management requires effective owner on the target (§4.1). */
|
|
createGrant(input: {
|
|
actorId: string;
|
|
userId: string;
|
|
targetKind: GrantTargetKind;
|
|
targetId: string;
|
|
role: HierarchyGrantRole;
|
|
idempotencyKey?: string;
|
|
}): Promise<HierarchyResult<{ grant: GrantView }>> {
|
|
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
|
if (!(await this.requireOwner(tx, ctx.actorId, input.targetKind, input.targetId))) {
|
|
return NOT_FOUND;
|
|
}
|
|
const subject = await tx
|
|
.select({ id: users.id })
|
|
.from(users)
|
|
.where(eq(users.id, input.userId))
|
|
.limit(1);
|
|
if (subject.length === 0) return conflict('subject user does not exist');
|
|
|
|
const inserted = await tx
|
|
.insert(hierarchyGrants)
|
|
.values({
|
|
userId: input.userId,
|
|
companyId: input.targetKind === 'company' ? input.targetId : null,
|
|
estateId: input.targetKind === 'estate' ? input.targetId : null,
|
|
platformProjectId: input.targetKind === 'platform_project' ? input.targetId : null,
|
|
role: input.role,
|
|
grantedBy: ctx.actorId,
|
|
})
|
|
.onConflictDoNothing()
|
|
.returning();
|
|
const grant = inserted[0];
|
|
if (!grant) return conflict('grant already exists');
|
|
|
|
await appendHierarchyEvent(tx, {
|
|
actorId: ctx.actorId,
|
|
verb: 'grant_create',
|
|
targetKind: 'grant',
|
|
targetId: grant.id,
|
|
targetSnapshot: grantSnapshot(grant),
|
|
correlationId: ctx.correlationId,
|
|
idempotencyKey: ctx.idempotencyKey,
|
|
});
|
|
return { ok: true, grant: grantView(grant) };
|
|
});
|
|
}
|
|
|
|
changeGrant(input: {
|
|
actorId: string;
|
|
grantId: string;
|
|
role: HierarchyGrantRole;
|
|
idempotencyKey?: string;
|
|
}): Promise<HierarchyResult<{ grant: GrantView }>> {
|
|
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
|
const rows = await tx
|
|
.select()
|
|
.from(hierarchyGrants)
|
|
.where(eq(hierarchyGrants.id, input.grantId))
|
|
.limit(1);
|
|
const existing = rows[0];
|
|
if (!existing) return NOT_FOUND;
|
|
const target = grantTarget(existing);
|
|
if (!(await this.requireOwner(tx, ctx.actorId, target.kind, target.id))) {
|
|
return NOT_FOUND;
|
|
}
|
|
// Team subjects are suspended (§1.4); the command surface never
|
|
// creates them, so this only fires on out-of-band rows.
|
|
if (!existing.userId) return conflict('team grant subjects are suspended');
|
|
if (existing.role === input.role) return conflict('grant already holds that role');
|
|
|
|
const duplicate = await tx
|
|
.select({ id: hierarchyGrants.id })
|
|
.from(hierarchyGrants)
|
|
.where(
|
|
and(
|
|
eq(hierarchyGrants.userId, existing.userId),
|
|
target.kind === 'company'
|
|
? eq(hierarchyGrants.companyId, target.id)
|
|
: target.kind === 'estate'
|
|
? eq(hierarchyGrants.estateId, target.id)
|
|
: eq(hierarchyGrants.platformProjectId, target.id),
|
|
eq(hierarchyGrants.role, input.role),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (duplicate.length > 0) {
|
|
return conflict('subject already holds that role on the target');
|
|
}
|
|
|
|
const updated = await tx
|
|
.update(hierarchyGrants)
|
|
.set({ role: input.role })
|
|
.where(eq(hierarchyGrants.id, input.grantId))
|
|
.returning();
|
|
const grant = updated[0] as GrantRow;
|
|
|
|
await appendHierarchyEvent(tx, {
|
|
actorId: ctx.actorId,
|
|
verb: 'grant_change',
|
|
targetKind: 'grant',
|
|
targetId: grant.id,
|
|
targetSnapshot: {
|
|
...grantSnapshot(grant),
|
|
previousRole: namespacedHierarchyRole(existing.role as HierarchyGrantRole),
|
|
},
|
|
correlationId: ctx.correlationId,
|
|
idempotencyKey: ctx.idempotencyKey,
|
|
});
|
|
return { ok: true, grant: grantView(grant) };
|
|
});
|
|
}
|
|
|
|
/** Revocation is row deletion (§6): the next evaluation denies, nothing lingers. */
|
|
revokeGrant(input: {
|
|
actorId: string;
|
|
grantId: string;
|
|
idempotencyKey?: string;
|
|
}): Promise<HierarchyResult<{ revokedId: string }>> {
|
|
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
|
const rows = await tx
|
|
.select()
|
|
.from(hierarchyGrants)
|
|
.where(eq(hierarchyGrants.id, input.grantId))
|
|
.limit(1);
|
|
const existing = rows[0];
|
|
if (!existing) return NOT_FOUND;
|
|
const target = grantTarget(existing);
|
|
if (!(await this.requireOwner(tx, ctx.actorId, target.kind, target.id))) {
|
|
return NOT_FOUND;
|
|
}
|
|
|
|
await tx.delete(hierarchyGrants).where(eq(hierarchyGrants.id, input.grantId));
|
|
|
|
await appendHierarchyEvent(tx, {
|
|
actorId: ctx.actorId,
|
|
verb: 'grant_revoke',
|
|
targetKind: 'grant',
|
|
targetId: existing.id,
|
|
targetSnapshot: grantSnapshot(existing),
|
|
correlationId: ctx.correlationId,
|
|
idempotencyKey: ctx.idempotencyKey,
|
|
});
|
|
return { ok: true, revokedId: existing.id };
|
|
});
|
|
}
|
|
|
|
// ── reads ────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* The directory: every directory-class company, closed-field (§2.8). The
|
|
* sole ratified existence-disclosure carve-out (§6.7 / A2 §9.1.2).
|
|
*/
|
|
async listDirectory(): Promise<DirectoryEntry[]> {
|
|
return this.db
|
|
.select({ id: companies.id, name: companies.name, slug: companies.slug })
|
|
.from(companies)
|
|
.where(eq(companies.visibility, 'directory'))
|
|
.orderBy(asc(companies.name));
|
|
}
|
|
|
|
/** Companies the user holds any grant on (company or descendant, §2.8). */
|
|
async listGrantedCompanies(userId: string): Promise<CompanyView[]> {
|
|
const ids = await grantedCompanyIds(this.db, userId);
|
|
if (ids.length === 0) return [];
|
|
const rows = await this.db
|
|
.select()
|
|
.from(companies)
|
|
.where(inArray(companies.id, ids))
|
|
.orderBy(asc(companies.name));
|
|
return rows.map(companyView);
|
|
}
|
|
}
|