mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
876 lines
26 KiB
TypeScript
876 lines
26 KiB
TypeScript
import { Inject, Injectable, Logger, Optional } from "@nestjs/common";
|
|
import * as argon2 from "argon2";
|
|
import { DataSource, EntityManager } from "typeorm";
|
|
|
|
import { DEFAULT_IAM_BASELINE_SEED } from "./iam-baseline.seed";
|
|
import { IAM_SEED_OPTIONS } from "./iam-seed.constants";
|
|
import {
|
|
IamBaselineSeed,
|
|
IamSeedOptions,
|
|
LocalizedName,
|
|
SettingDefault,
|
|
} from "./iam-seed.types";
|
|
import { missingSettings } from "./missing-settings.util";
|
|
|
|
/** Owned by @tria-plc/iamapi-common's migrations; never created here. */
|
|
const SCHEMA = "iam";
|
|
|
|
const DEFAULT_ENABLE_FLAG = "SEED_IAM_BASELINE";
|
|
|
|
/**
|
|
* Advisory lock key, held for the seed transaction. Every app using this package
|
|
* takes the same key, so two services — or two replicas of one — can never seed
|
|
* concurrently. Arbitrary constant; nothing else uses it.
|
|
*/
|
|
const SEED_LOCK_KEY = 748_231_905;
|
|
|
|
/** Postgres caps a statement at 65535 parameters; stay far below it. */
|
|
const INSERT_CHUNK = 500;
|
|
|
|
type KeyedRow = { id: string; key: string };
|
|
|
|
/**
|
|
* Seeds the IAM baseline shared by every app on the `iam` schema, replacing
|
|
* `DataSeeder` from `@tria-plc/iamapi-common`.
|
|
*
|
|
* The schema has more than one writer, rows may already exist partially, and the
|
|
* apps resolve different versions of the IAM package. Every write is built for
|
|
* that:
|
|
*
|
|
* - **Insert-only.** A row that already exists by key is left exactly as it is —
|
|
* no name overwrite, and above all no id rewrite, which would break foreign
|
|
* keys other apps already point at. Drift is logged, not corrected.
|
|
* - **Ids resolved from the database**, never assumed from the seed constants.
|
|
* - **`ON CONFLICT DO NOTHING` on every insert**, so a row appearing between the
|
|
* read and the write is a no-op rather than a crash.
|
|
* - **An advisory lock** around the whole transaction, shared by all consumers.
|
|
* - **Nothing is deleted.** The package seeder wipes every
|
|
* `position_type_permissions` row for the system position types on each run
|
|
* and nulls their `unit_id`; this one does not.
|
|
* - **Never fatal.** A failure is logged and boot continues; missing
|
|
* prerequisites skip that section with a warning.
|
|
*
|
|
* Raw SQL throughout, deliberately: importing the package's entity classes would
|
|
* tie this package to one copy of `@tria-plc/iamapi-common`, and TypeORM matches
|
|
* entity metadata by class identity — the apps would need the exact same
|
|
* resolved version forever. Column names come from the package's own migrations.
|
|
*
|
|
* Apps call `run()` themselves so it can be ordered against their own seeders.
|
|
* Runs unless the enable flag (default SEED_IAM_BASELINE) is explicitly turned
|
|
* off — insert-only makes seeding the safe default.
|
|
*/
|
|
@Injectable()
|
|
export class IamBaselineSeeder {
|
|
private readonly logger = new Logger(IamBaselineSeeder.name);
|
|
private readonly seed: IamBaselineSeed;
|
|
private readonly enableFlag: string;
|
|
|
|
constructor(
|
|
private readonly dataSource: DataSource,
|
|
@Optional()
|
|
@Inject(IAM_SEED_OPTIONS)
|
|
options?: IamSeedOptions,
|
|
) {
|
|
const { enableFlag, ...overrides } = options ?? {};
|
|
this.enableFlag = enableFlag ?? DEFAULT_ENABLE_FLAG;
|
|
this.seed = { ...DEFAULT_IAM_BASELINE_SEED, ...overrides };
|
|
}
|
|
|
|
async run() {
|
|
// Opt-out, not opt-in: an unset flag seeds. Every write is insert-only, so
|
|
// the safe default is "keep the baseline current" — a new environment that
|
|
// forgot the variable gets a working IAM rather than an empty one.
|
|
const flag = process.env[this.enableFlag]?.trim().toLowerCase();
|
|
if (flag === "false" || flag === "0" || flag === "off") {
|
|
this.logger.log(
|
|
`Skipping IAM baseline seed because ${this.enableFlag}=${flag}`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await this.dataSource.transaction(async (manager) => {
|
|
// Never wait forever on a row another service holds: the seed is
|
|
// optional, boot is not.
|
|
await manager.query("SET LOCAL lock_timeout = '15s'");
|
|
|
|
// Try, don't wait — another service seeding right now is a reason to
|
|
// skip, not to queue. Released on commit or rollback.
|
|
const [{ locked }] = (await manager.query(
|
|
"SELECT pg_try_advisory_xact_lock($1) AS locked",
|
|
[SEED_LOCK_KEY],
|
|
)) as [{ locked: boolean }];
|
|
|
|
if (!locked) {
|
|
this.logger.log(
|
|
"Skipping IAM baseline seed: another service holds the seed lock",
|
|
);
|
|
return;
|
|
}
|
|
|
|
await this.seedApplications(manager);
|
|
await this.seedPermissions(manager);
|
|
await this.seedRoles(manager);
|
|
await this.seedRolePermissions(manager);
|
|
await this.seedPositionTypes(manager);
|
|
await this.seedPositionTypePermissions(manager);
|
|
await this.seedOrganizationTypes(manager);
|
|
await this.seedOrganizationSettings(manager);
|
|
await this.seedUnitSettings(manager);
|
|
await this.seedSuperAdmin(manager);
|
|
});
|
|
|
|
this.logger.log("IAM baseline seed complete");
|
|
} catch (error) {
|
|
// Boot must not depend on the seed: the schema is shared, and a lock
|
|
// timeout or a row another service wrote first is not worth an outage.
|
|
this.logger.error(
|
|
`IAM baseline seed failed, continuing boot: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
private async seedApplications(manager: EntityManager) {
|
|
const { applications } = this.seed;
|
|
if (applications.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const existing = await this.loadByKey(
|
|
manager,
|
|
"application",
|
|
applications.map((application) => application.key),
|
|
);
|
|
this.warnOnIdDrift(applications, existing, "applications");
|
|
|
|
const inserted = await this.insertIgnoringConflicts(
|
|
manager,
|
|
"application",
|
|
["id", "key", "name::jsonb"],
|
|
applications
|
|
.filter((application) => !existing.has(application.key))
|
|
.map((application) => [
|
|
application.id,
|
|
application.key,
|
|
JSON.stringify(application.name),
|
|
]),
|
|
);
|
|
|
|
this.logger.log(
|
|
`Applications: ${inserted} inserted, ${applications.length - inserted} already present`,
|
|
);
|
|
}
|
|
|
|
private async seedPermissions(manager: EntityManager) {
|
|
const { permissions } = this.seed;
|
|
if (permissions.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Permissions without an applicationKey stay unlinked (application_id null),
|
|
// which is how the package ships the org/unit/location ones.
|
|
const applicationIdByKey = await this.loadIdsByKey(
|
|
manager,
|
|
"application",
|
|
permissions.flatMap((permission) =>
|
|
permission.applicationKey ? [permission.applicationKey] : [],
|
|
),
|
|
"application",
|
|
);
|
|
|
|
const existing = await this.loadByKey(
|
|
manager,
|
|
"permissions",
|
|
permissions.map((permission) => permission.key),
|
|
);
|
|
this.warnOnIdDrift(permissions, existing, "permissions");
|
|
|
|
const inserted = await this.insertIgnoringConflicts(
|
|
manager,
|
|
"permissions",
|
|
["id", "key", "name::jsonb", "application_id"],
|
|
permissions
|
|
.filter((permission) => !existing.has(permission.key))
|
|
.map((permission) => [
|
|
permission.id,
|
|
permission.key,
|
|
JSON.stringify(permission.name),
|
|
permission.applicationKey
|
|
? (applicationIdByKey.get(permission.applicationKey) ?? null)
|
|
: null,
|
|
]),
|
|
);
|
|
|
|
this.logger.log(
|
|
`Permissions: ${inserted} inserted, ${permissions.length - inserted} already present`,
|
|
);
|
|
}
|
|
|
|
private async seedRoles(manager: EntityManager) {
|
|
const { roles } = this.seed;
|
|
if (roles.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const existing = await this.loadByKey(
|
|
manager,
|
|
"roles",
|
|
roles.map((role) => role.key),
|
|
);
|
|
this.warnOnIdDrift(roles, existing, "roles");
|
|
|
|
const inserted = await this.insertIgnoringConflicts(
|
|
manager,
|
|
"roles",
|
|
["id", "key", "name::jsonb"],
|
|
roles
|
|
.filter((role) => !existing.has(role.key))
|
|
.map((role) => [role.id, role.key, JSON.stringify(role.name)]),
|
|
);
|
|
|
|
this.logger.log(
|
|
`Roles: ${inserted} inserted, ${roles.length - inserted} already present`,
|
|
);
|
|
}
|
|
|
|
private async seedRolePermissions(manager: EntityManager) {
|
|
const { rolePermissions } = this.seed;
|
|
if (rolePermissions.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const roleIdByKey = await this.loadIdsByKey(
|
|
manager,
|
|
"roles",
|
|
rolePermissions.map((mapping) => mapping.roleKey),
|
|
"role",
|
|
);
|
|
const permissionIdByKey = await this.loadIdsByKey(
|
|
manager,
|
|
"permissions",
|
|
rolePermissions.flatMap((mapping) => mapping.permissionKeys),
|
|
"permission",
|
|
);
|
|
|
|
const existingPairs = await this.loadPairs(
|
|
manager,
|
|
"role_permissions",
|
|
"role_id",
|
|
"permission_id",
|
|
[...roleIdByKey.values()],
|
|
);
|
|
|
|
const rows = rolePermissions.flatMap((mapping) => {
|
|
const roleId = roleIdByKey.get(mapping.roleKey);
|
|
if (!roleId) {
|
|
return [];
|
|
}
|
|
|
|
return mapping.permissionKeys.flatMap((key) => {
|
|
const permissionId = permissionIdByKey.get(key);
|
|
if (!permissionId || existingPairs.has(`${roleId}:${permissionId}`)) {
|
|
return [];
|
|
}
|
|
return [[roleId, permissionId]];
|
|
});
|
|
});
|
|
|
|
const inserted = await this.insertIgnoringConflicts(
|
|
manager,
|
|
"role_permissions",
|
|
["role_id", "permission_id"],
|
|
rows,
|
|
);
|
|
if (inserted > 0) {
|
|
this.logger.log(`Granted ${inserted} role permissions`);
|
|
}
|
|
}
|
|
|
|
private async seedPositionTypes(manager: EntityManager) {
|
|
const { positionTypes } = this.seed;
|
|
if (positionTypes.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const existing = await this.loadByKey(
|
|
manager,
|
|
"position_types",
|
|
positionTypes.map((positionType) => positionType.key),
|
|
);
|
|
this.warnOnIdDrift(positionTypes, existing, "position types");
|
|
|
|
// unit_id is intentionally left alone — the package seeder resets it to null.
|
|
const inserted = await this.insertIgnoringConflicts(
|
|
manager,
|
|
"position_types",
|
|
["id", "key", "name::jsonb", "is_system"],
|
|
positionTypes
|
|
.filter((positionType) => !existing.has(positionType.key))
|
|
.map((positionType) => [
|
|
positionType.id,
|
|
positionType.key,
|
|
JSON.stringify(positionType.name),
|
|
positionType.isSystem,
|
|
]),
|
|
);
|
|
|
|
this.logger.log(
|
|
`Position types: ${inserted} inserted, ${positionTypes.length - inserted} already present`,
|
|
);
|
|
}
|
|
|
|
private async seedPositionTypePermissions(manager: EntityManager) {
|
|
const { positionTypePermissions } = this.seed;
|
|
if (positionTypePermissions.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const positionTypeIdByKey = await this.loadIdsByKey(
|
|
manager,
|
|
"position_types",
|
|
positionTypePermissions.map((mapping) => mapping.positionTypeKey),
|
|
"position type",
|
|
);
|
|
const permissionIdByKey = await this.loadIdsByKey(
|
|
manager,
|
|
"permissions",
|
|
positionTypePermissions.flatMap((mapping) => mapping.permissionKeys),
|
|
"permission",
|
|
);
|
|
|
|
const existingPairs = await this.loadPairs(
|
|
manager,
|
|
"position_type_permissions",
|
|
"position_type_id",
|
|
"permission_id",
|
|
[...positionTypeIdByKey.values()],
|
|
);
|
|
|
|
const rows = positionTypePermissions.flatMap((mapping) => {
|
|
const positionTypeId = positionTypeIdByKey.get(mapping.positionTypeKey);
|
|
if (!positionTypeId) {
|
|
return [];
|
|
}
|
|
|
|
return mapping.permissionKeys.flatMap((key) => {
|
|
const permissionId = permissionIdByKey.get(key);
|
|
if (
|
|
!permissionId ||
|
|
existingPairs.has(`${positionTypeId}:${permissionId}`)
|
|
) {
|
|
return [];
|
|
}
|
|
return [[positionTypeId, permissionId]];
|
|
});
|
|
});
|
|
|
|
const inserted = await this.insertIgnoringConflicts(
|
|
manager,
|
|
"position_type_permissions",
|
|
["position_type_id", "permission_id"],
|
|
rows,
|
|
);
|
|
if (inserted > 0) {
|
|
this.logger.log(`Granted ${inserted} position type permissions`);
|
|
}
|
|
}
|
|
|
|
private async seedOrganizationTypes(manager: EntityManager) {
|
|
const { organizationTypes } = this.seed;
|
|
if (organizationTypes.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const existing = await this.loadByKey(
|
|
manager,
|
|
"organization_types",
|
|
organizationTypes.map((organizationType) => organizationType.key),
|
|
);
|
|
|
|
const insertedTypes = await this.insertIgnoringConflicts(
|
|
manager,
|
|
"organization_types",
|
|
["key", "name::jsonb"],
|
|
organizationTypes
|
|
.filter((organizationType) => !existing.has(organizationType.key))
|
|
.map((organizationType) => [
|
|
organizationType.key,
|
|
JSON.stringify(organizationType.name),
|
|
]),
|
|
);
|
|
|
|
// Re-read: ids are database-generated here, unlike the keyed catalogs above.
|
|
const typeIdByKey = await this.loadIdsByKey(
|
|
manager,
|
|
"organization_types",
|
|
organizationTypes.map((organizationType) => organizationType.key),
|
|
"organization type",
|
|
);
|
|
|
|
const existingUnits = await this.loadPairs(
|
|
manager,
|
|
"default_units",
|
|
"organization_type_id",
|
|
"key",
|
|
[...typeIdByKey.values()],
|
|
);
|
|
|
|
const unitRows = organizationTypes.flatMap((organizationType) => {
|
|
const typeId = typeIdByKey.get(organizationType.key);
|
|
if (!typeId) {
|
|
return [];
|
|
}
|
|
|
|
return organizationType.defaultUnits
|
|
.filter((unit) => !existingUnits.has(`${typeId}:${unit.key}`))
|
|
.map((unit) => [
|
|
unit.key,
|
|
JSON.stringify(unit.name),
|
|
unit.description,
|
|
typeId,
|
|
]);
|
|
});
|
|
|
|
const insertedUnits = await this.insertIgnoringConflicts(
|
|
manager,
|
|
"default_units",
|
|
["key", "name::jsonb", "description", "organization_type_id"],
|
|
unitRows,
|
|
);
|
|
|
|
this.logger.log(
|
|
`Organization types: ${insertedTypes} inserted, ${insertedUnits} default units inserted`,
|
|
);
|
|
}
|
|
|
|
private async seedOrganizationSettings(manager: EntityManager) {
|
|
const defaults = this.seed.organizationSettings;
|
|
if (defaults.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const owners = (await manager.query(
|
|
`SELECT id FROM ${SCHEMA}.organizations`,
|
|
)) as { id: string }[];
|
|
|
|
const inserted = await this.insertOwnerSettings(
|
|
manager,
|
|
"organization_settings",
|
|
"organization_id",
|
|
owners,
|
|
defaults,
|
|
);
|
|
|
|
if (inserted > 0) {
|
|
this.logger.log(
|
|
`Seeded ${inserted} organization settings across ${owners.length} organizations`,
|
|
);
|
|
}
|
|
}
|
|
|
|
private async seedUnitSettings(manager: EntityManager) {
|
|
const defaults = this.seed.unitSettings;
|
|
if (defaults.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const owners = (await manager.query(`SELECT id FROM ${SCHEMA}.units`)) as {
|
|
id: string;
|
|
}[];
|
|
|
|
const inserted = await this.insertOwnerSettings(
|
|
manager,
|
|
"unit_settings",
|
|
"unit_id",
|
|
owners,
|
|
defaults,
|
|
);
|
|
|
|
if (inserted > 0) {
|
|
this.logger.log(
|
|
`Seeded ${inserted} unit settings across ${owners.length} units`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Settings rows missing for each owner. Matched on (owner, key), never on id. */
|
|
private async insertOwnerSettings(
|
|
manager: EntityManager,
|
|
table: string,
|
|
ownerColumn: string,
|
|
owners: { id: string }[],
|
|
defaults: SettingDefault[],
|
|
): Promise<number> {
|
|
if (owners.length === 0) {
|
|
return 0;
|
|
}
|
|
|
|
const existing = (await manager.query(
|
|
`SELECT "${ownerColumn}" AS owner, key FROM ${SCHEMA}.${table}`,
|
|
)) as { owner: string; key: string }[];
|
|
const existingPairs = new Set(
|
|
existing.map((row) => `${row.owner}:${row.key}`),
|
|
);
|
|
|
|
const rows = owners.flatMap((owner) =>
|
|
missingSettings(defaults, existingPairs, owner.id).map((setting) => [
|
|
owner.id,
|
|
setting.key,
|
|
setting.displayName,
|
|
setting.type,
|
|
setting.value ?? null,
|
|
]),
|
|
);
|
|
|
|
return this.insertIgnoringConflicts(
|
|
manager,
|
|
table,
|
|
[ownerColumn, "key", "display_name", "type", "value"],
|
|
rows,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* User + credential + employee + role grant. Every step is skip-if-present: an
|
|
* existing account keeps its password, its organizations and any extra roles it
|
|
* was given through the IAM UI. Apps sharing this schema share the account and
|
|
* each attach their own employee row.
|
|
*/
|
|
private async seedSuperAdmin(manager: EntityManager) {
|
|
const seed = this.seed.superAdmin;
|
|
if (!seed) {
|
|
return;
|
|
}
|
|
|
|
const email = process.env.SUPER_ADMIN_EMAIL?.trim() || seed.fallbackEmail;
|
|
// SUPER_ADMIN_DEFAULT_PASSWORD wins, DEFAULT_PASSWORD is the shared fallback. There
|
|
// is deliberately no hardcoded default — see SeedSuperAdmin.
|
|
const password =
|
|
process.env.SUPER_ADMIN_DEFAULT_PASSWORD?.trim() ||
|
|
process.env.DEFAULT_PASSWORD?.trim();
|
|
const phoneNumber = process.env.SUPER_ADMIN_PHONE?.trim() || null;
|
|
|
|
const roleId = (
|
|
await this.loadIdsByKey(manager, "roles", [seed.roleKey], "role")
|
|
).get(seed.roleKey);
|
|
if (!roleId) {
|
|
this.logger.warn(
|
|
`Skipping super admin: role '${seed.roleKey}' is not in the database`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// The organization is optional. Access rides on the role grant, whose
|
|
// organization_id is nullable, and login does not require an employee — so
|
|
// the account is created either way and the employee row attaches on a later
|
|
// run, once the app's own org seeder (gated on its own flag) has run.
|
|
const [organization] = (await manager.query(
|
|
`SELECT id FROM ${SCHEMA}.organizations WHERE key = $1 LIMIT 1`,
|
|
[seed.organizationKey],
|
|
)) as { id: string }[];
|
|
|
|
const organizationId = organization?.id ?? null;
|
|
if (!organizationId) {
|
|
this.logger.warn(
|
|
`Organization '${seed.organizationKey}' does not exist yet: seeding the super admin without an employee record`,
|
|
);
|
|
}
|
|
|
|
let unitId: string | null = null;
|
|
if (seed.unitKey && organizationId) {
|
|
const [unit] = (await manager.query(
|
|
`SELECT id FROM ${SCHEMA}.units WHERE key = $1 AND organization_id = $2 LIMIT 1`,
|
|
[seed.unitKey, organizationId],
|
|
)) as { id: string }[];
|
|
|
|
if (!unit) {
|
|
this.logger.warn(
|
|
`Super admin unit '${seed.unitKey}' not found, attaching without a unit`,
|
|
);
|
|
}
|
|
unitId = unit?.id ?? null;
|
|
}
|
|
|
|
const userId = await this.ensureSuperAdminUser(manager, {
|
|
username: seed.username,
|
|
email,
|
|
phoneNumber,
|
|
name: seed.name,
|
|
});
|
|
if (!userId) {
|
|
this.logger.warn("Skipping super admin: could not resolve the user row");
|
|
return;
|
|
}
|
|
|
|
const [credential] = (await manager.query(
|
|
`SELECT id FROM ${SCHEMA}.user_credentials WHERE user_id = $1 LIMIT 1`,
|
|
[userId],
|
|
)) as { id: string }[];
|
|
|
|
if (!credential && !password) {
|
|
this.logger.warn(
|
|
`Super admin '${seed.username}' has no credential: set SUPER_ADMIN_DEFAULT_PASSWORD or DEFAULT_PASSWORD, or set the password through the IAM reset flow`,
|
|
);
|
|
}
|
|
|
|
if (!credential && password) {
|
|
await this.insertIgnoringConflicts(
|
|
manager,
|
|
"user_credentials",
|
|
["user_id", "password", "is_active"],
|
|
[[userId, await argon2.hash(password), true]],
|
|
);
|
|
this.logger.log(`Seeded super admin credential for '${seed.username}'`);
|
|
}
|
|
|
|
const [employee] = organizationId
|
|
? ((await manager.query(
|
|
`SELECT id FROM ${SCHEMA}.employees WHERE user_id = $1 AND organization_id = $2 LIMIT 1`,
|
|
[userId, organizationId],
|
|
)) as { id: string }[])
|
|
: [undefined];
|
|
|
|
if (organizationId && !employee) {
|
|
await this.insertIgnoringConflicts(
|
|
manager,
|
|
"employees",
|
|
[
|
|
"user_id",
|
|
"organization_id",
|
|
"unit_id",
|
|
"is_current",
|
|
"status",
|
|
"name::jsonb",
|
|
],
|
|
[
|
|
[
|
|
userId,
|
|
organizationId,
|
|
unitId,
|
|
true,
|
|
"accepted",
|
|
JSON.stringify(seed.name),
|
|
],
|
|
],
|
|
);
|
|
this.logger.log(
|
|
`Attached super admin to organization '${seed.organizationKey}'`,
|
|
);
|
|
}
|
|
|
|
// user_roles is UNIQUE (user_id, role_id), so a concurrent grant is a no-op.
|
|
await this.insertIgnoringConflicts(
|
|
manager,
|
|
"user_roles",
|
|
["user_id", "role_id", "organization_id", "unit_id"],
|
|
[[userId, roleId, organizationId, unitId]],
|
|
);
|
|
|
|
this.logger.log(`Ensured '${seed.roleKey}' role on '${seed.username}'`);
|
|
}
|
|
|
|
/**
|
|
* The super-admin user row, whether we create it or another service already
|
|
* did. Matches on username OR email because either is enough to make the
|
|
* insert fail on its unique index.
|
|
*/
|
|
private async ensureSuperAdminUser(
|
|
manager: EntityManager,
|
|
account: {
|
|
username: string;
|
|
email: string;
|
|
phoneNumber: string | null;
|
|
name: LocalizedName;
|
|
},
|
|
): Promise<string | undefined> {
|
|
const find = async () => {
|
|
const [row] = (await manager.query(
|
|
`SELECT id FROM ${SCHEMA}.users WHERE username = $1 OR email = $2 LIMIT 1`,
|
|
[account.username, account.email],
|
|
)) as { id: string }[];
|
|
return row?.id;
|
|
};
|
|
|
|
const existing = await find();
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
await this.insertIgnoringConflicts(
|
|
manager,
|
|
"users",
|
|
[
|
|
"username",
|
|
"email",
|
|
"phone_number",
|
|
"name::jsonb",
|
|
"user_type",
|
|
"status",
|
|
"is_active",
|
|
"has_set_password",
|
|
],
|
|
[
|
|
[
|
|
account.username,
|
|
account.email,
|
|
account.phoneNumber,
|
|
JSON.stringify(account.name),
|
|
"employee",
|
|
"accepted",
|
|
true,
|
|
true,
|
|
],
|
|
],
|
|
);
|
|
|
|
// Re-read rather than trusting the insert: the row may have been skipped
|
|
// because another service created it a moment earlier.
|
|
const created = await find();
|
|
if (created) {
|
|
this.logger.log(
|
|
`Seeded super admin user '${account.username}' (${account.email})`,
|
|
);
|
|
}
|
|
|
|
return created;
|
|
}
|
|
|
|
/**
|
|
* Report seed rows whose stored id differs from ours — a sign this environment
|
|
* was seeded by something else, and the reason links are resolved by key.
|
|
*/
|
|
private warnOnIdDrift(
|
|
rows: { id: string; key: string }[],
|
|
existing: Map<string, KeyedRow>,
|
|
label: string,
|
|
) {
|
|
const drifted = rows.filter((row) => {
|
|
const stored = existing.get(row.key);
|
|
return stored && stored.id !== row.id;
|
|
});
|
|
|
|
if (drifted.length > 0) {
|
|
this.logger.warn(
|
|
`${drifted.length} ${label} exist under a different id than the seed (left untouched): ${drifted
|
|
.map((row) => row.key)
|
|
.join(", ")}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `INSERT … ON CONFLICT DO NOTHING`, chunked. Every table here is written by
|
|
* more than one service, so losing a race must cost nothing.
|
|
*
|
|
* A column spec may carry a cast for non-text types — `"name::jsonb"`.
|
|
*/
|
|
private async insertIgnoringConflicts(
|
|
manager: EntityManager,
|
|
table: string,
|
|
columnSpecs: string[],
|
|
rows: unknown[][],
|
|
): Promise<number> {
|
|
if (rows.length === 0) {
|
|
return 0;
|
|
}
|
|
|
|
const columns = columnSpecs.map((spec) => spec.split("::")[0]);
|
|
const casts = columnSpecs.map((spec) => {
|
|
const [, cast] = spec.split("::");
|
|
return cast ? `::${cast}` : "";
|
|
});
|
|
const columnList = columns.map((column) => `"${column}"`).join(", ");
|
|
|
|
let inserted = 0;
|
|
for (let start = 0; start < rows.length; start += INSERT_CHUNK) {
|
|
const chunk = rows.slice(start, start + INSERT_CHUNK);
|
|
const params: unknown[] = [];
|
|
const tuples = chunk.map((row) => {
|
|
const placeholders = row.map((value, columnIndex) => {
|
|
params.push(value);
|
|
return `$${params.length}${casts[columnIndex]}`;
|
|
});
|
|
return `(${placeholders.join(", ")})`;
|
|
});
|
|
|
|
const result = (await manager.query(
|
|
`INSERT INTO ${SCHEMA}.${table} (${columnList}) VALUES ${tuples.join(", ")} ON CONFLICT DO NOTHING RETURNING id`,
|
|
params,
|
|
)) as unknown[];
|
|
|
|
inserted += Array.isArray(result) ? result.length : 0;
|
|
}
|
|
|
|
return inserted;
|
|
}
|
|
|
|
/** Existing rows for the given keys, by key. */
|
|
private async loadByKey(
|
|
manager: EntityManager,
|
|
table: string,
|
|
keys: string[],
|
|
): Promise<Map<string, KeyedRow>> {
|
|
const wanted = [...new Set(keys)];
|
|
if (wanted.length === 0) {
|
|
return new Map();
|
|
}
|
|
|
|
const rows = (await manager.query(
|
|
`SELECT id, key FROM ${SCHEMA}.${table} WHERE key = ANY($1)`,
|
|
[wanted],
|
|
)) as KeyedRow[];
|
|
|
|
return new Map(rows.map((row) => [row.key, row]));
|
|
}
|
|
|
|
/**
|
|
* Resolve `key → id`. Keys with no row are warned about and left out: whatever
|
|
* references them is skipped rather than failing the whole seed, since another
|
|
* service may own that row.
|
|
*/
|
|
private async loadIdsByKey(
|
|
manager: EntityManager,
|
|
table: string,
|
|
keys: string[],
|
|
label: string,
|
|
): Promise<Map<string, string>> {
|
|
const wanted = [...new Set(keys)];
|
|
const rows = await this.loadByKey(manager, table, wanted);
|
|
const idByKey = new Map(
|
|
[...rows.values()].map((row) => [row.key, row.id] as [string, string]),
|
|
);
|
|
|
|
const missing = wanted.filter((key) => !idByKey.has(key));
|
|
if (missing.length > 0) {
|
|
this.logger.warn(
|
|
`Unresolved ${label} keys, anything referencing them is skipped: ${missing.join(", ")}`,
|
|
);
|
|
}
|
|
|
|
return idByKey;
|
|
}
|
|
|
|
/** Existing `left:right` pairs of a link table, for the given left-hand ids. */
|
|
private async loadPairs(
|
|
manager: EntityManager,
|
|
table: string,
|
|
leftColumn: string,
|
|
rightColumn: string,
|
|
leftIds: string[],
|
|
): Promise<Set<string>> {
|
|
if (leftIds.length === 0) {
|
|
return new Set();
|
|
}
|
|
|
|
const rows = (await manager.query(
|
|
`SELECT "${leftColumn}" AS left_value, "${rightColumn}" AS right_value
|
|
FROM ${SCHEMA}.${table} WHERE "${leftColumn}" = ANY($1)`,
|
|
[leftIds],
|
|
)) as { left_value: string; right_value: string }[];
|
|
|
|
return new Set(rows.map((row) => `${row.left_value}:${row.right_value}`));
|
|
}
|
|
}
|