ADM Demo Seed Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Refactor the backend demo seed so bun run seed:demo creates both the existing Bến Tre tenant and a second usable ADM tenant with deterministic, rerunnable data for admin, owner, and staff.

Architecture: Keep backend/src/database/seeds/demo-data.seed.ts as the single seed entrypoint, but split its inline data into two local scenario definitions and a few practical helper functions: scenario definition, core org/user/farm seeding, and minimal operational data seeding. Add one focused Jest spec that drives seedDemoData() through a fake DataSource/QueryRunner harness so multi-scenario behavior, deterministic IDs, rerun safety, and summary output are verified without booting Nest or creating a new seed subsystem.

Tech Stack: Bun workspace scripts, NestJS backend, TypeORM DataSource/QueryRunner, Jest, Biome, PostgreSQL-oriented seed semantics.


File Structure (planned changes)

Create

  • backend/src/database/seeds/demo-data.seed.spec.ts — focused seed orchestration spec with a fake QueryRunner harness that records saved entities, updates, raw SQL assignments, and console output.

Modify

  • backend/src/database/seeds/demo-data.seed.ts:1-1299 — refactor the monolithic inline Bến Tre flow into reusable scenario helpers, add the ADM scenario, make assignment inserts rerunnable, and update seed summary output.
  • backend/src/database/seeds/README.md:232-309 — document the new ADM accounts, rerun expectations, and updated manual test credentials.

Validate / reference while implementing

  • backend/src/database/seeds/run-seed.ts — keep the command entrypoint and transaction ownership unchanged.
  • backend/package.json:8-29 — keep using existing seed:demo and Jest scripts.
  • backend/src/user-roles/entities/user-role.entity.ts — confirm assigned_resources shape remains jsonb.
  • backend/src/aquaculture/entities/daily-log.entity.ts — note the (pondId, logDate) uniqueness rule when making daily logs rerunnable.
  • backend/src/devices/entities/device-telemetry.entity.ts — note the composite primary key (time, deviceId) when making telemetry rerunnable.

Task 1: Establish a test seam for scenario-based seeding

Files:

  • Create: backend/src/database/seeds/demo-data.seed.spec.ts

  • Modify: backend/src/database/seeds/demo-data.seed.ts:1-120

  • Test: backend/src/database/seeds/demo-data.seed.spec.ts

  • Step 1: Write the failing scenario-definition spec first

// backend/src/database/seeds/demo-data.seed.spec.ts
import type { DataSource, QueryRunner } from 'typeorm';
import { Role } from '@/roles/entities/role.entity';
import { Species } from '@/aquaculture/entities/species.entity';
import {
  buildDemoScenarios,
  seedDemoData,
  seedId,
} from '@/database/seeds/demo-data.seed';

type SavedEntityRecord = Record<string, unknown> & {
  __entityName: string;
  id?: string;
  deviceId?: string;
  time?: Date;
  pondId?: string;
  logDate?: Date;
};

interface SeedHarness {
  dataSource: DataSource;
  queryRunner: jest.Mocked<QueryRunner>;
  saved: Map<string, Map<string, SavedEntityRecord>>;
  queries: Array<{ sql: string; params: unknown[] }>;
  updates: Array<{ entityName: string; criteria: unknown; partial: unknown }>;
}

function getEntityKey(entity: SavedEntityRecord): string {
  if (entity.id) {
    return entity.id;
  }

  if (entity.__entityName === 'DeviceTelemetry' && entity.deviceId && entity.time) {
    return `${entity.deviceId}:${entity.time.toISOString()}`;
  }

  if (entity.__entityName === 'DailyLog' && entity.pondId && entity.logDate) {
    return `${entity.pondId}:${entity.logDate.toISOString().slice(0, 10)}`;
  }

  throw new Error(`Missing deterministic key for ${entity.__entityName}`);
}

function createSeedHarness(): SeedHarness {
  const saved = new Map<string, Map<string, SavedEntityRecord>>();
  const queries: Array<{ sql: string; params: unknown[] }> = [];
  const updates: Array<{ entityName: string; criteria: unknown; partial: unknown }> = [];

  const rolesByName: Record<string, { id: string; name: string }> = {
    SUPER_ADMIN: { id: seedId('role:super-admin'), name: 'SUPER_ADMIN' },
    ADMIN: { id: seedId('role:admin'), name: 'ADMIN' },
    OWNER: { id: seedId('role:owner'), name: 'OWNER' },
    STAFF: { id: seedId('role:staff'), name: 'STAFF' },
    TECHNICAL: { id: seedId('role:technical'), name: 'TECHNICAL' },
    'Super Admin': { id: seedId('role:super-admin'), name: 'Super Admin' },
    Admin: { id: seedId('role:admin'), name: 'Admin' },
    Owner: { id: seedId('role:owner'), name: 'Owner' },
    Staff: { id: seedId('role:staff'), name: 'Staff' },
    Technical: { id: seedId('role:technical'), name: 'Technical' },
  };

  const speciesByName: Record<string, { id: string; name: string }> = {
    'Tôm thẻ chân trắng': { id: seedId('species:shrimp'), name: 'Tôm thẻ chân trắng' },
    'Cá tra': { id: seedId('species:pangasius'), name: 'Cá tra' },
  };

  const manager = {
    getRepository: jest.fn((entity: unknown) => {
      if (entity === Role) {
        return {
          findOne: jest.fn(async (input: { where: { name: string } }) => {
            return rolesByName[input.where.name] ?? null;
          }),
        };
      }

      if (entity === Species) {
        return {
          findOne: jest.fn(async (input: { where: { name: string } }) => {
            return speciesByName[input.where.name] ?? null;
          }),
        };
      }

      throw new Error('Unexpected repository request');
    }),
    create: jest.fn(<T extends Record<string, unknown>>(
      entity: { name: string },
      value: T,
    ) => {
      return {
        __entityName: entity.name,
        ...value,
      } as T & SavedEntityRecord;
    }),
    save: jest.fn(async <T extends SavedEntityRecord>(entity: T) => {
      const bucket = saved.get(entity.__entityName) ?? new Map<string, SavedEntityRecord>();
      bucket.set(getEntityKey(entity), entity);
      saved.set(entity.__entityName, bucket);
      return entity;
    }),
    query: jest.fn(async (sql: string, params: unknown[]) => {
      queries.push({ sql, params });
      return [];
    }),
    update: jest.fn(async (entity: { name: string }, criteria: unknown, partial: unknown) => {
      updates.push({ entityName: entity.name, criteria, partial });
      return { affected: 1 };
    }),
  };

  const queryRunner = {
    connect: jest.fn(),
    startTransaction: jest.fn(),
    commitTransaction: jest.fn(),
    rollbackTransaction: jest.fn(),
    release: jest.fn(),
    manager,
  } as unknown as jest.Mocked<QueryRunner>;

  const dataSource = {
    createQueryRunner: jest.fn(() => queryRunner),
  } as unknown as DataSource;

  return { dataSource, queryRunner, saved, queries, updates };
}

describe('demo-data.seed scenario definitions', () => {
  it('builds Bến Tre as an explicit scenario definition with deterministic identity', () => {
    const scenarios = buildDemoScenarios(new Date('2026-05-12T00:00:00.000Z'));

    expect(scenarios).toHaveLength(1);
    expect(scenarios[0]).toMatchObject({
      key: 'bentre',
      organization: {
        id: seedId('org:bentre'),
        code: 'BENTRE-SHRIMP-01',
      },
    });
  });
});
  • Step 2: Run the new seed spec to confirm it fails

Run: cd backend && bun run test -- src/database/seeds/demo-data.seed.spec.ts --runInBand
Expected: FAIL with TypeScript/Jest errors because buildDemoScenarios and exported seedId do not exist yet.

  • Step 3: Export the deterministic ID helper and extract the first pure scenario builder
// backend/src/database/seeds/demo-data.seed.ts
export function seedId(name: string): string {
  const h = createHash('md5').update(name).digest('hex');
  const variant = (['8', '9', 'a', 'b'] as const)[parseInt(h[16], 16) & 3];

  return [
    h.slice(0, 8),
    h.slice(8, 12),
    `4${h.slice(13, 16)}`,
    `${variant}${h.slice(17, 20)}`,
    h.slice(20, 32),
  ].join('-');
}

type DemoRoleName = 'SUPER_ADMIN' | 'ADMIN' | 'OWNER' | 'STAFF' | 'TECHNICAL';

interface DemoScenarioUserDefinition {
  key: string;
  id: string;
  email: string;
  fullName: string;
  phone: string;
  roleName: DemoRoleName;
}

interface DemoScenarioDefinition {
  key: string;
  organization: {
    id: string;
    name: string;
    code: string;
    settings: Organization['settings'];
    isActive: boolean;
  };
  users: DemoScenarioUserDefinition[];
}

function buildBenTreScenario(): DemoScenarioDefinition {
  return {
    key: 'bentre',
    organization: {
      id: seedId('org:bentre'),
      name: 'Trang Trại Tôm Bến Tre',
      code: 'BENTRE-SHRIMP-01',
      settings: {
        timezone: 'Asia/Ho_Chi_Minh',
        currency: 'VND',
        language: 'vi',
        features: {
          iotMonitoring: true,
          alerts: true,
          reports: true,
          aiPredictions: false,
        },
      },
      isActive: true,
    },
    users: [
      {
        key: 'super-admin',
        id: seedId('user:superadmin'),
        email: 'superadmin@hmpiot.com',
        fullName: 'Trần Quốc Siêu',
        phone: '+84912345000',
        roleName: 'SUPER_ADMIN',
      },
      {
        key: 'admin',
        id: seedId('user:admin'),
        email: 'admin@bentre-shrimp.vn',
        fullName: 'Nguyễn Văn Quản',
        phone: '+84912345001',
        roleName: 'ADMIN',
      },
    ],
  };
}

export function buildDemoScenarios(_now: Date = new Date()): DemoScenarioDefinition[] {
  return [buildBenTreScenario()];
}
  • Step 4: Point seedDemoData() at the scenario builder without changing behavior yet
// backend/src/database/seeds/demo-data.seed.ts
export async function seedDemoData(dataSource: DataSource): Promise<void> {
  const queryRunner = dataSource.createQueryRunner();
  await queryRunner.connect();
  await queryRunner.startTransaction();

  try {
    console.log('🌱 Starting demo data seed...');

    const scenarios = buildDemoScenarios();
    const bentreScenario = scenarios[0];

    if (!bentreScenario) {
      throw new Error('Demo scenario definitions are empty.');
    }

    const organization = queryRunner.manager.create(Organization, bentreScenario.organization);
    await queryRunner.manager.save(organization);

    const password = await hash('Demo@123456', 10);

    for (const userDefinition of bentreScenario.users) {
      const user = queryRunner.manager.create(User, {
        id: userDefinition.id,
        email: userDefinition.email,
        password,
        fullName: userDefinition.fullName,
        phone: userDefinition.phone,
        status: UserStatus.ACTIVE,
        organizationId: organization.id,
        lastLogin: new Date(),
        notificationPreferences: { email: true, sms: true, push: true },
      });
      await queryRunner.manager.save(user);
    }
  } catch (error) {
    await queryRunner.rollbackTransaction();
    throw error;
  } finally {
    await queryRunner.release();
  }
}
  • Step 5: Run the focused seed spec again

Run: cd backend && bun run test -- src/database/seeds/demo-data.seed.spec.ts --runInBand
Expected: PASS with 1 passed.

  • Step 6: Commit
git add backend/src/database/seeds/demo-data.seed.ts backend/src/database/seeds/demo-data.seed.spec.ts
git commit -m "test(seed): add scenario-definition seam for demo seed"

Task 2: Refactor the entrypoint to seed multiple scenarios in one transaction

Files:

  • Modify: backend/src/database/seeds/demo-data.seed.spec.ts

  • Modify: backend/src/database/seeds/demo-data.seed.ts:57-420

  • Test: backend/src/database/seeds/demo-data.seed.spec.ts

  • Step 1: Extend the spec with a failing multi-scenario orchestration test

// backend/src/database/seeds/demo-data.seed.spec.ts
function getSavedEntities(
  harness: SeedHarness,
  entityName: string,
): SavedEntityRecord[] {
  return [...(harness.saved.get(entityName)?.values() ?? [])];
}

describe('seedDemoData', () => {
  it('seeds both Bến Tre and ADM organizations in one transaction', async () => {
    const harness = createSeedHarness();

    await seedDemoData(harness.dataSource);

    const organizations = getSavedEntities(harness, 'Organization');
    const users = getSavedEntities(harness, 'User');

    expect(organizations.map((org) => org.code).sort()).toEqual([
      'ADM',
      'BENTRE-SHRIMP-01',
    ]);
    expect(
      users.filter((user) => user.organizationId === seedId('org:adm')).map((user) => user.email),
    ).toEqual([
      'admin@adm-demo.vn',
      'owner@adm-demo.vn',
      'staff@adm-demo.vn',
    ]);

    expect(harness.queryRunner.startTransaction).toHaveBeenCalledTimes(1);
    expect(harness.queryRunner.commitTransaction).toHaveBeenCalledTimes(1);
    expect(harness.queryRunner.rollbackTransaction).not.toHaveBeenCalled();
  });
});
  • Step 2: Run the seed spec and verify the ADM assertions fail

Run: cd backend && bun run test -- src/database/seeds/demo-data.seed.spec.ts --runInBand
Expected: FAIL with Expected: ["ADM", "BENTRE-SHRIMP-01"] and Received: ["BENTRE-SHRIMP-01"].

  • Step 3: Add the ADM scenario definition and introduce a small scenario seeding helper
// backend/src/database/seeds/demo-data.seed.ts
interface DemoScenarioSeedResult {
  summary: {
    organizationId: string;
    organizationName: string;
    loginAccounts: Array<{ roleLabel: string; email: string }>;
  };
  runtime: {
    organization: Organization;
    usersByKey: Record<string, User>;
  };
}

function buildAdmScenario(): DemoScenarioDefinition {
  return {
    key: 'adm',
    organization: {
      id: seedId('org:adm'),
      name: 'ADM',
      code: 'ADM',
      settings: {
        timezone: 'Asia/Ho_Chi_Minh',
        currency: 'VND',
        language: 'vi',
        features: {
          iotMonitoring: true,
          alerts: true,
          reports: true,
          aiPredictions: false,
        },
      },
      isActive: true,
    },
    users: [
      {
        key: 'admin',
        id: seedId('adm:user:admin'),
        email: 'admin@adm-demo.vn',
        fullName: 'ADM Admin',
        phone: '+84912001001',
        roleName: 'ADMIN',
      },
      {
        key: 'owner',
        id: seedId('adm:user:owner'),
        email: 'owner@adm-demo.vn',
        fullName: 'ADM Owner',
        phone: '+84912001002',
        roleName: 'OWNER',
      },
      {
        key: 'staff',
        id: seedId('adm:user:staff'),
        email: 'staff@adm-demo.vn',
        fullName: 'ADM Staff',
        phone: '+84912001003',
        roleName: 'STAFF',
      },
    ],
  };
}

export function buildDemoScenarios(_now: Date = new Date()): DemoScenarioDefinition[] {
  return [buildBenTreScenario(), buildAdmScenario()];
}

async function seedScenarioCore(
  queryRunner: QueryRunner,
  scenario: DemoScenarioDefinition,
  password: string,
  rolesByName: Record<DemoRoleName, Role>,
): Promise<DemoScenarioSeedResult> {
  const organization = queryRunner.manager.create(Organization, scenario.organization);
  await queryRunner.manager.save(organization);
  const usersByKey: Record<string, User> = {};

  for (const userDefinition of scenario.users) {
    const user = queryRunner.manager.create(User, {
      id: userDefinition.id,
      email: userDefinition.email,
      password,
      fullName: userDefinition.fullName,
      phone: userDefinition.phone,
      status: UserStatus.ACTIVE,
      organizationId: organization.id,
      lastLogin: new Date(),
      notificationPreferences: { email: true, sms: true, push: true },
    });
    await queryRunner.manager.save(user);

    const userRole = queryRunner.manager.create(UserRole, {
      userId: user.id,
      roleId: rolesByName[userDefinition.roleName].id,
      organizationId: organization.id,
      assignedResources: [],
    });
    await queryRunner.manager.save(userRole);
    usersByKey[userDefinition.key] = user;
  }

  return {
    summary: {
      organizationId: organization.id,
      organizationName: organization.name,
      loginAccounts: scenario.users.map((user) => ({
        roleLabel: user.roleName,
        email: user.email,
      })),
    },
    runtime: {
      organization,
      usersByKey,
    },
  };
}
  • Step 4: Replace the single-scenario organization/user block with a loop
// backend/src/database/seeds/demo-data.seed.ts
const password = await hash('Demo@123456', 10);
const scenarios = buildDemoScenarios();
const seededScenarioSummaries: Array<DemoScenarioSeedResult['summary']> = [];
const seededScenarioRuntimes: Record<string, DemoScenarioSeedResult['runtime']> = {};

for (const scenario of scenarios) {
  const seededScenario = await seedScenarioCore(
    queryRunner,
    scenario,
    password,
    rolesByName,
  );
  seededScenarioSummaries.push(seededScenario.summary);
  seededScenarioRuntimes[scenario.key] = seededScenario.runtime;
}

const bentreRuntime = seededScenarioRuntimes.bentre;

if (!bentreRuntime) {
  throw new Error('Missing seeded runtime for Bến Tre scenario.');
}

const organization = bentreRuntime.organization;
const superAdminUser = bentreRuntime.usersByKey['super-admin'];
const adminUser = bentreRuntime.usersByKey.admin;

if (!superAdminUser || !adminUser) {
  throw new Error('Missing seeded Bến Tre users required by downstream seed steps.');
}
  • Step 5: Run the seed spec again

Run: cd backend && bun run test -- src/database/seeds/demo-data.seed.spec.ts --runInBand
Expected: PASS with 2 passed.

  • Step 6: Commit
git add backend/src/database/seeds/demo-data.seed.ts backend/src/database/seeds/demo-data.seed.spec.ts
git commit -m "refactor(seed): support multi-organization demo scenarios"

Task 3: Make scenario reruns deterministic and assignment-safe

Files:

  • Modify: backend/src/database/seeds/demo-data.seed.spec.ts

  • Modify: backend/src/database/seeds/demo-data.seed.ts:237-1271

  • Test: backend/src/database/seeds/demo-data.seed.spec.ts

  • Step 1: Add a failing rerun-safety test before changing production code

// backend/src/database/seeds/demo-data.seed.spec.ts
function getEntityCounts(harness: SeedHarness): Record<string, number> {
  return Object.fromEntries(
    [...harness.saved.entries()].map(([entityName, bucket]) => [entityName, bucket.size]),
  );
}

it('re-runs without identity drift for assignments and operational records', async () => {
  const harness = createSeedHarness();

  await seedDemoData(harness.dataSource);
  const firstCounts = getEntityCounts(harness);

  await seedDemoData(harness.dataSource);

  expect(getEntityCounts(harness)).toEqual(firstCounts);
  expect(
    harness.queries.some(
      ({ sql }) =>
        sql.includes('INSERT INTO area_users') && sql.includes('ON CONFLICT DO NOTHING'),
    ),
  ).toBe(true);
  expect(
    harness.queries.some(
      ({ sql }) =>
        sql.includes('INSERT INTO pond_users') && sql.includes('ON CONFLICT DO NOTHING'),
    ),
  ).toBe(true);
});
  • Step 2: Run the spec and confirm the rerun test fails

Run: cd backend && bun run test -- src/database/seeds/demo-data.seed.spec.ts --runInBand
Expected: FAIL because repeated runs currently create duplicate generated rows and raw assignment inserts are not conflict-safe.

  • Step 3: Add deterministic IDs and small assignment helpers inside the seed file
// backend/src/database/seeds/demo-data.seed.ts
function seedDateId(prefix: string, value: Date | string): string {
  const normalized = typeof value === 'string' ? value : value.toISOString();
  return seedId(`${prefix}:${normalized}`);
}

async function assignAreaOwner(
  queryRunner: QueryRunner,
  areaId: string,
  userId: string,
): Promise<void> {
  await queryRunner.manager.query(
    `INSERT INTO area_users (area_id, user_id)
     VALUES ($1, $2)
     ON CONFLICT DO NOTHING`,
    [areaId, userId],
  );
}

async function assignPondUser(
  queryRunner: QueryRunner,
  pondId: string,
  userId: string,
): Promise<void> {
  await queryRunner.manager.query(
    `INSERT INTO pond_users (pond_id, user_id)
     VALUES ($1, $2)
     ON CONFLICT DO NOTHING`,
    [pondId, userId],
  );
}
// backend/src/database/seeds/demo-data.seed.ts
const growthRecord = queryRunner.manager.create(GrowthRecord, {
  id: seedDateId(`growth:${scenario.key}:${cycle.id}:week:${week}`, recordDate),
  cycleId: cycle.id,
  recordDate,
  doc,
  sampleSize: 30,
  averageWeight,
  averageLength: 4 + week * 0.8,
  growthRate,
  estimatedBiomass,
  notes: week === weeklyMeasurements ? 'Đo lường gần nhất' : undefined,
  createdBy: recordStaff,
});

const dailyLog = queryRunner.manager.create(DailyLog, {
  id: seedDateId(`daily-log:${scenario.key}:${pond.id}`, logDate),
  pondId: pond.id,
  cycleId: cycle?.id ?? null,
  logDate,
  doc,
  waterQuality,
  feeding,
  observations,
  treatments,
  source: 'mixed',
  createdBy: createdByUserId,
});

const alertConfig = queryRunner.manager.create(AlertConfig, {
  id: seedId(`alert-config:${scenario.key}:${device.id}:${config.parameter}`),
  deviceId: device.id,
  pondId: device.pondId,
  organizationId: organization.id,
  parameter: config.parameter,
  thresholds: config.thresholds,
  notifications: config.notifications,
  isActive: true,
});

const alert = queryRunner.manager.create(DeviceAlert, {
  id: seedId(`device-alert:${scenario.key}:${alertDefinition.key}`),
  ...alertDefinition,
});

const ticket = queryRunner.manager.create(Ticket, {
  id: seedId(`ticket:${scenario.key}:${ticketDefinition.key}`),
  ...ticketDefinition,
});

const notification = queryRunner.manager.create(Notification, {
  id: seedId(`notification:${scenario.key}:${notificationDefinition.key}`),
  ...notificationDefinition,
});
  • Step 4: Replace the multi-row raw SQL inserts with the new per-assignment helpers and rerun-safe user-role updates
// backend/src/database/seeds/demo-data.seed.ts
for (const areaAssignment of scenario.areaAssignments) {
  await assignAreaOwner(
    queryRunner,
    resolvedAreas[areaAssignment.areaKey].id,
    resolvedUsers[areaAssignment.userKey].id,
  );
}

for (const pondAssignment of scenario.pondAssignments) {
  await assignPondUser(
    queryRunner,
    resolvedPonds[pondAssignment.pondKey].id,
    resolvedUsers[pondAssignment.userKey].id,
  );
}

for (const resourceAssignment of scenario.assignedResourceRules) {
  const assignedPondIds = resourceAssignment.pondKeys.map(
    (pondKey) => resolvedPonds[pondKey].id,
  );

  await queryRunner.manager.query(
    `UPDATE user_roles
     SET assigned_resources = $1::jsonb
     WHERE user_id = $2 AND organization_id = $3`,
    [
      JSON.stringify(assignedPondIds),
      resolvedUsers[resourceAssignment.userKey].id,
      organization.id,
    ],
  );
}
  • Step 5: Run the seed spec again

Run: cd backend && bun run test -- src/database/seeds/demo-data.seed.spec.ts --runInBand
Expected: PASS with the rerun-safety assertion green.

  • Step 6: Commit
git add backend/src/database/seeds/demo-data.seed.ts backend/src/database/seeds/demo-data.seed.spec.ts
git commit -m "refactor(seed): make demo scenario data deterministic and rerunnable"

Task 4: Add the ADM farm structure, role assignments, and minimal operational data

Files:

  • Modify: backend/src/database/seeds/demo-data.seed.spec.ts

  • Modify: backend/src/database/seeds/demo-data.seed.ts:121-1271

  • Test: backend/src/database/seeds/demo-data.seed.spec.ts

  • Step 1: Extend the spec with failing ADM-specific behavior assertions

// backend/src/database/seeds/demo-data.seed.spec.ts
it('creates the ADM farm structure, assignments, and non-empty role-facing operational data', async () => {
  const harness = createSeedHarness();

  await seedDemoData(harness.dataSource);

  const admOrganizationId = seedId('org:adm');
  const admAreas = getSavedEntities(harness, 'Area').filter(
    (area) => area.organizationId === admOrganizationId,
  );
  const admPonds = getSavedEntities(harness, 'Pond').filter(
    (pond) => pond.organizationId === admOrganizationId,
  );
  const admDevices = getSavedEntities(harness, 'Device').filter(
    (device) => device.organizationId === admOrganizationId,
  );
  const admAlerts = getSavedEntities(harness, 'DeviceAlert').filter(
    (alert) => alert.pondId === seedId('adm:pond:a') || alert.pondId === seedId('adm:pond:b'),
  );
  const admNotifications = getSavedEntities(harness, 'Notification').filter(
    (notification) => notification.organizationId === admOrganizationId,
  );
  const admTelemetry = getSavedEntities(harness, 'DeviceTelemetry').filter(
    (telemetry) => telemetry.deviceId === seedId('adm:device:sensor:a'),
  );
  const admUserRoles = getSavedEntities(harness, 'UserRole').filter(
    (userRole) => userRole.organizationId === admOrganizationId,
  );

  expect(admAreas).toHaveLength(1);
  expect(admPonds.filter((pond) => pond.status === 'active')).toHaveLength(2);
  expect(admDevices.map((device) => device.type).sort()).toEqual(['feeder', 'sensor']);
  expect(admTelemetry.length).toBeGreaterThan(0);
  expect(admAlerts).toHaveLength(1);
  expect(admNotifications.length).toBeGreaterThanOrEqual(1);
  expect(admUserRoles).toEqual(
    expect.arrayContaining([
      expect.objectContaining({
        userId: seedId('adm:user:owner'),
        assignedResources: [seedId('adm:pond:a'), seedId('adm:pond:b')],
      }),
      expect.objectContaining({
        userId: seedId('adm:user:staff'),
        assignedResources: [seedId('adm:pond:a'), seedId('adm:pond:b')],
      }),
    ]),
  );
});
  • Step 2: Run the spec and confirm the ADM operational assertions fail

Run: cd backend && bun run test -- src/database/seeds/demo-data.seed.spec.ts --runInBand
Expected: FAIL because the ADM scenario exists only at the organization/user layer and does not yet seed areas, ponds, devices, telemetry, alerts, or notifications.

  • Step 3: Expand the scenario definition types and add the concrete ADM scenario data
// backend/src/database/seeds/demo-data.seed.ts
interface DemoScenarioAreaDefinition {
  key: string;
  id: string;
  name: string;
  code: string;
  address: string;
  coordinates: { lat: number; lng: number };
  ownerKeys: string[];
}

interface DemoScenarioPondDefinition {
  key: string;
  id: string;
  areaKey: string;
  name: string;
  code: string;
  surfaceArea: number;
  depth: number;
  type: string;
  status: Pond['status'];
  ownerKeys: string[];
}

interface DemoScenarioDeviceDefinition {
  key: string;
  id: string;
  serialNumber: string;
  name: string;
  type: Device['type'];
  model: string;
  capabilities: Record<string, boolean>;
  pondKey: string;
  status: Device['status'];
  ownershipType: Device['ownershipType'];
}

interface DemoScenarioCycleDefinition {
  key: string;
  id: string;
  pondKey: string;
  speciesName: Species['name'];
  name: string;
  initialStock: number;
  stockingDensity: number;
  stockingDate: Date;
  expectedHarvestDate: Date;
  status: FarmingCycle['status'];
  notes: string;
}

interface DemoScenarioAssignedResourceRule {
  userKey: string;
  pondKeys: string[];
}

interface DemoScenarioAlertDefinition {
  key: string;
  id: string;
  deviceKey: string;
  pondKey: string;
  alertType: string;
  severity: string;
  parameter: string;
  thresholdData: Record<string, unknown>;
  message: string;
}

interface DemoScenarioNotificationDefinition {
  key: string;
  id: string;
  userKey: string;
  alertKey?: string;
  type: string;
  title: string;
  message: string;
}

interface DemoScenarioDefinition {
  key: string;
  organization: {
    id: string;
    name: string;
    code: string;
    settings: Organization['settings'];
    isActive: boolean;
  };
  users: DemoScenarioUserDefinition[];
  areas: DemoScenarioAreaDefinition[];
  ponds: DemoScenarioPondDefinition[];
  devices: DemoScenarioDeviceDefinition[];
  cycles: DemoScenarioCycleDefinition[];
  assignedResourceRules: DemoScenarioAssignedResourceRule[];
  alertDefinitions: DemoScenarioAlertDefinition[];
  notificationDefinitions: DemoScenarioNotificationDefinition[];
}

function buildAdmScenario(now: Date): DemoScenarioDefinition {
  return {
    key: 'adm',
    organization: {
      id: seedId('org:adm'),
      name: 'ADM',
      code: 'ADM',
      settings: {
        timezone: 'Asia/Ho_Chi_Minh',
        currency: 'VND',
        language: 'vi',
        features: {
          iotMonitoring: true,
          alerts: true,
          reports: true,
          aiPredictions: false,
        },
      },
      isActive: true,
    },
    users: [
      {
        key: 'admin',
        id: seedId('adm:user:admin'),
        email: 'admin@adm-demo.vn',
        fullName: 'ADM Admin',
        phone: '+84912001001',
        roleName: 'ADMIN',
      },
      {
        key: 'owner',
        id: seedId('adm:user:owner'),
        email: 'owner@adm-demo.vn',
        fullName: 'ADM Owner',
        phone: '+84912001002',
        roleName: 'OWNER',
      },
      {
        key: 'staff',
        id: seedId('adm:user:staff'),
        email: 'staff@adm-demo.vn',
        fullName: 'ADM Staff',
        phone: '+84912001003',
        roleName: 'STAFF',
      },
    ],
    areas: [
      {
        key: 'main',
        id: seedId('adm:area:main'),
        name: 'ADM Main Area',
        code: 'ADM-AREA-01',
        address: 'Khu nuôi ADM, Đồng bằng sông Cửu Long',
        coordinates: { lat: 10.3812, lng: 105.4351 },
        ownerKeys: ['owner'],
      },
    ],
    ponds: [
      {
        key: 'pond-a',
        id: seedId('adm:pond:a'),
        areaKey: 'main',
        name: 'ADM Pond A',
        code: 'ADM-POND-A',
        surfaceArea: 3200,
        depth: 1.4,
        type: 'earthen',
        status: 'active',
        ownerKeys: ['owner'],
      },
      {
        key: 'pond-b',
        id: seedId('adm:pond:b'),
        areaKey: 'main',
        name: 'ADM Pond B',
        code: 'ADM-POND-B',
        surfaceArea: 3000,
        depth: 1.3,
        type: 'lined',
        status: 'active',
        ownerKeys: ['owner'],
      },
    ],
    devices: [
      {
        key: 'sensor-a',
        id: seedId('adm:device:sensor:a'),
        serialNumber: 'SNS-ADM-001-A',
        name: 'Cảm biến ADM Pond A',
        type: 'sensor',
        model: 'AquaSense Pro 3000',
        capabilities: {
          temperature: true,
          ph: true,
          dissolvedOxygen: true,
          salinity: true,
        },
        pondKey: 'pond-a',
        status: 'online',
        ownershipType: 'sold',
      },
      {
        key: 'feeder-b',
        id: seedId('adm:device:feeder:b'),
        serialNumber: 'FEED-ADM-001-B',
        name: 'Máng ăn ADM Pond B',
        type: 'feeder',
        model: 'SmartFeed 200',
        capabilities: { feedLevel: true, feedRate: true },
        pondKey: 'pond-b',
        status: 'online',
        ownershipType: 'rented',
      },
    ],
    cycles: [
      {
        key: 'pond-a-q2-2026',
        id: seedId('adm:cycle:pond-a:q2-2026'),
        pondKey: 'pond-a',
        speciesName: 'Tôm thẻ chân trắng',
        name: 'ADM Vụ 1 - Q2/2026',
        initialStock: 150000,
        stockingDensity: 47,
        stockingDate: new Date(new Date(now).setDate(now.getDate() - 28)),
        expectedHarvestDate: new Date(new Date(now).setDate(now.getDate() + 62)),
        status: 'active',
        notes: 'Kịch bản demo tối thiểu cho owner và staff.',
      },
    ],
    assignedResourceRules: [
      { userKey: 'owner', pondKeys: ['pond-a', 'pond-b'] },
      { userKey: 'staff', pondKeys: ['pond-a', 'pond-b'] },
    ],
    alertDefinitions: [
      {
        key: 'do-low-pond-a',
        id: seedId('adm:alert:do-low:pond-a'),
        deviceKey: 'sensor-a',
        pondKey: 'pond-a',
        alertType: 'do_low',
        severity: 'warning',
        parameter: 'dissolvedOxygen',
        thresholdData: {
          parameter: 'dissolvedOxygen',
          threshold: 4.5,
          actual: 4.1,
          severity: 'warning',
        },
        message: 'ADM Pond A có dấu hiệu oxy hòa tan giảm.',
      },
    ],
    notificationDefinitions: [
      {
        key: 'owner-do-low-pond-a',
        id: seedId('adm:notification:owner:do-low'),
        userKey: 'owner',
        alertKey: 'do-low-pond-a',
        type: 'alert',
        title: 'ADM Pond A cần kiểm tra oxy hòa tan',
        message: 'Cảnh báo demo cho owner sau khi đăng nhập.',
      },
      {
        key: 'admin-welcome',
        id: seedId('adm:notification:admin:welcome'),
        userKey: 'admin',
        type: 'system',
        title: 'ADM demo đã sẵn sàng',
        message: 'Tổ chức ADM có đủ dữ liệu mẫu để kiểm tra dashboard và vận hành.',
      },
    ],
  };
}
  • Step 4: Move the current Bến Tre farm/device/operational logic behind the same helper flow and wire ADM into it
// backend/src/database/seeds/demo-data.seed.ts
interface DemoScenarioRuntime {
  organization: Organization;
  usersByKey: Record<string, User>;
  areasByKey: Record<string, Area>;
  pondsByKey: Record<string, Pond>;
  devicesByKey: Record<string, Device>;
  cyclesByKey: Record<string, FarmingCycle>;
}

async function seedScenarioFarmAndOperations(
  queryRunner: QueryRunner,
  scenario: DemoScenarioDefinition,
  runtime: DemoScenarioRuntime,
  rolesByName: Record<DemoRoleName, Role>,
  speciesByName: Record<string, Species>,
  now: Date,
): Promise<void> {
  for (const areaDefinition of scenario.areas) {
    const area = queryRunner.manager.create(Area, {
      id: areaDefinition.id,
      organizationId: runtime.organization.id,
      name: areaDefinition.name,
      code: areaDefinition.code,
      address: areaDefinition.address,
      coordinates: areaDefinition.coordinates,
      isActive: true,
    });
    await queryRunner.manager.save(area);
    runtime.areasByKey[areaDefinition.key] = area;

    for (const ownerKey of areaDefinition.ownerKeys) {
      await assignAreaOwner(queryRunner, area.id, runtime.usersByKey[ownerKey].id);
    }
  }

  for (const pondDefinition of scenario.ponds) {
    const pond = queryRunner.manager.create(Pond, {
      id: pondDefinition.id,
      organizationId: runtime.organization.id,
      areaId: runtime.areasByKey[pondDefinition.areaKey].id,
      name: pondDefinition.name,
      code: pondDefinition.code,
      surfaceArea: pondDefinition.surfaceArea,
      depth: pondDefinition.depth,
      type: pondDefinition.type,
      status: pondDefinition.status,
    });
    await queryRunner.manager.save(pond);
    runtime.pondsByKey[pondDefinition.key] = pond;

    for (const ownerKey of pondDefinition.ownerKeys) {
      await assignPondUser(queryRunner, pond.id, runtime.usersByKey[ownerKey].id);
    }
  }

  for (const resourceRule of scenario.assignedResourceRules) {
    const assignedPondIds = resourceRule.pondKeys.map(
      (pondKey) => runtime.pondsByKey[pondKey].id,
    );

    await queryRunner.manager.query(
      `UPDATE user_roles
       SET assigned_resources = $1::jsonb
       WHERE user_id = $2 AND organization_id = $3`,
      [
        JSON.stringify(assignedPondIds),
        runtime.usersByKey[resourceRule.userKey].id,
        runtime.organization.id,
      ],
    );
  }

  for (const deviceDefinition of scenario.devices) {
    const device = queryRunner.manager.create(Device, {
      id: deviceDefinition.id,
      serialNumber: deviceDefinition.serialNumber,
      name: deviceDefinition.name,
      type: deviceDefinition.type,
      model: deviceDefinition.model,
      capabilities: deviceDefinition.capabilities,
      pondId: runtime.pondsByKey[deviceDefinition.pondKey].id,
      status: deviceDefinition.status,
      ownershipType: deviceDefinition.ownershipType,
      organizationId: runtime.organization.id,
      installationDate: new Date(new Date(now).setMonth(now.getMonth() - 2)),
      activationDate: new Date(new Date(now).setMonth(now.getMonth() - 2)),
      warrantyMonths: 12,
      warrantyExpiryDate: new Date(new Date(now).setMonth(now.getMonth() + 10)),
      lastSeenAt: now,
      mqttTopic: `devices/${deviceDefinition.serialNumber}/telemetry`,
      accessToken: `token_${deviceDefinition.serialNumber}`,
      secret: `secret_${deviceDefinition.serialNumber}`,
      isActive: true,
    });
    await queryRunner.manager.save(device);
    runtime.devicesByKey[deviceDefinition.key] = device;
  }

  for (const cycleDefinition of scenario.cycles) {
    const cycle = queryRunner.manager.create(FarmingCycle, {
      id: cycleDefinition.id,
      organizationId: runtime.organization.id,
      pondId: runtime.pondsByKey[cycleDefinition.pondKey].id,
      speciesId: speciesByName[cycleDefinition.speciesName].id,
      name: cycleDefinition.name,
      initialStock: cycleDefinition.initialStock,
      stockingDensity: cycleDefinition.stockingDensity,
      stockingDate: cycleDefinition.stockingDate,
      expectedHarvestDate: cycleDefinition.expectedHarvestDate,
      status: cycleDefinition.status,
      notes: cycleDefinition.notes,
      createdBy: runtime.usersByKey.admin?.id ?? runtime.usersByKey.owner.id,
    });
    await queryRunner.manager.save(cycle);
    runtime.cyclesByKey[cycleDefinition.key] = cycle;

    await queryRunner.manager.update(Pond, cycle.pondId, {
      currentCycleId: cycle.id,
    });

    await queryRunner.manager.save(
      queryRunner.manager.create(GrowthRecord, {
        id: seedId(`growth:${scenario.key}:${cycle.id}:week-4`),
        cycleId: cycle.id,
        recordDate: new Date(new Date(now).setDate(now.getDate() - 7)),
        doc: 21,
        sampleSize: 30,
        averageWeight: 6.8,
        averageLength: 8.7,
        growthRate: 0.22,
        estimatedBiomass: 816,
        createdBy: runtime.usersByKey.staff?.id ?? runtime.usersByKey.owner.id,
      }),
    );

    await queryRunner.manager.save(
      queryRunner.manager.create(DailyLog, {
        id: seedId(`daily-log:${scenario.key}:${cycle.pondId}:${now.toISOString().slice(0, 10)}`),
        pondId: cycle.pondId,
        cycleId: cycle.id,
        logDate: new Date(now.toISOString().slice(0, 10)),
        doc: 28,
        waterQuality: {
          temperature: 28.1,
          ph: 7.9,
          dissolvedOxygen: 4.8,
          salinity: 18.4,
        },
        feeding: {
          feedType: 'Pellet 2.0mm',
          quantity: 48,
          times: 3,
        },
        observations: {
          weather: 'Nắng nhẹ',
          shrimpBehavior: 'Bình thường',
        },
        treatments: [],
        source: 'mixed',
        createdBy: runtime.usersByKey.staff?.id ?? runtime.usersByKey.owner.id,
      }),
    );
  }

  const sensorDevices = Object.values(runtime.devicesByKey).filter(
    (device) => device.type === 'sensor' && device.status === 'online',
  );
  for (const device of sensorDevices) {
    for (let hour = 23; hour >= 0; hour -= 1) {
      const time = new Date(now);
      time.setHours(time.getHours() - hour);

      await queryRunner.manager.save(
        queryRunner.manager.create(DeviceTelemetry, {
          time,
          deviceId: device.id,
          data: {
            temperature: 28.1,
            ph: 7.9,
            dissolvedOxygen: hour === 0 ? 4.1 : 5.2,
            salinity: 18.4,
          },
          signalStrength: -48,
          batteryLevel: 93,
        }),
      );
    }
  }

  for (const device of sensorDevices) {
    await queryRunner.manager.save(
      queryRunner.manager.create(AlertConfig, {
        id: seedId(`alert-config:${scenario.key}:${device.id}:dissolvedOxygen`),
        deviceId: device.id,
        pondId: device.pondId,
        organizationId: runtime.organization.id,
        parameter: 'dissolvedOxygen',
        thresholds: {
          warning_min: 4.5,
          warning_max: 8.0,
          critical_min: 3.5,
          critical_max: 10.0,
        },
        notifications: {
          channels: ['email'],
          recipients: Object.values(runtime.usersByKey).map((user) => user.email),
          enabled: true,
        },
        isActive: true,
      }),
    );
  }

  const alertsByKey: Record<string, DeviceAlert> = {};
  for (const alertDefinition of scenario.alertDefinitions) {
    const alert = queryRunner.manager.create(DeviceAlert, {
      id: alertDefinition.id,
      deviceId: runtime.devicesByKey[alertDefinition.deviceKey].id,
      pondId: runtime.pondsByKey[alertDefinition.pondKey].id,
      alertType: alertDefinition.alertType,
      severity: alertDefinition.severity,
      parameter: alertDefinition.parameter,
      thresholdData: alertDefinition.thresholdData,
      message: alertDefinition.message,
      status: 'active',
      createdAt: new Date(now.getTime() - 30 * 60 * 1000),
    });
    await queryRunner.manager.save(alert);
    alertsByKey[alertDefinition.key] = alert;
  }

  for (const notificationDefinition of scenario.notificationDefinitions) {
    await queryRunner.manager.save(
      queryRunner.manager.create(Notification, {
        id: notificationDefinition.id,
        userId: runtime.usersByKey[notificationDefinition.userKey].id,
        organizationId: runtime.organization.id,
        alertId: notificationDefinition.alertKey
          ? alertsByKey[notificationDefinition.alertKey].id
          : null,
        type: notificationDefinition.type,
        title: notificationDefinition.title,
        message: notificationDefinition.message,
        isRead: false,
        createdAt: now,
      }),
    );
  }
}

for (const scenario of buildDemoScenarios(now)) {
  const core = await seedScenarioCore(queryRunner, scenario, password, rolesByName);
  await seedScenarioFarmAndOperations(
    queryRunner,
    scenario,
    core.runtime,
    rolesByName,
    speciesByName,
    now,
  );
  seededScenarioSummaries.push(core.summary);
}
  • Step 5: Run the focused spec again

Run: cd backend && bun run test -- src/database/seeds/demo-data.seed.spec.ts --runInBand
Expected: PASS with the ADM-specific assertions green.

  • Step 6: Commit
git add backend/src/database/seeds/demo-data.seed.ts backend/src/database/seeds/demo-data.seed.spec.ts
git commit -m "feat(seed): add ADM demo farm assignments and operational data"

Task 5: Update seed summary output, README, and verification coverage

Files:

  • Modify: backend/src/database/seeds/demo-data.seed.spec.ts

  • Modify: backend/src/database/seeds/demo-data.seed.ts:1273-1291

  • Modify: backend/src/database/seeds/README.md:232-309

  • Test: backend/src/database/seeds/demo-data.seed.spec.ts

  • Step 1: Add a failing summary-output test before touching logs or docs

// backend/src/database/seeds/demo-data.seed.spec.ts
it('logs both demo organizations and the ADM login credentials in the summary output', async () => {
  const harness = createSeedHarness();
  const logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);

  try {
    await seedDemoData(harness.dataSource);
  } finally {
    logSpy.mockRestore();
  }

  const logLines = logSpy.mock.calls.flatMap((call) => call.map(String));

  expect(logLines.some((line) => line.includes('Trang Trại Tôm Bến Tre'))).toBe(true);
  expect(logLines.some((line) => line.includes('ADM'))).toBe(true);
  expect(logLines.some((line) => line.includes('admin@adm-demo.vn'))).toBe(true);
  expect(logLines.some((line) => line.includes('owner@adm-demo.vn'))).toBe(true);
  expect(logLines.some((line) => line.includes('staff@adm-demo.vn'))).toBe(true);
});
  • Step 2: Run the spec and verify the output assertion fails

Run: cd backend && bun run test -- src/database/seeds/demo-data.seed.spec.ts --runInBand
Expected: FAIL because the current summary still prints only the Bến Tre organization and one admin credential.

  • Step 3: Add a small summary formatter and update the README to match the new seed behavior
// backend/src/database/seeds/demo-data.seed.ts
function logSeedSummary(
  seedSummaries: Array<DemoScenarioSeedResult['summary']>,
): void {
  console.log('✅ Demo data seeded successfully!');
  console.log('\n📊 Summary:');

  for (const summary of seedSummaries) {
    console.log(`   - Organization: ${summary.organizationName}`);
  }

  console.log('\n🔐 Demo Login Credentials:');

  for (const summary of seedSummaries) {
    for (const account of summary.loginAccounts) {
      console.log(`   - ${summary.organizationName} ${account.roleLabel}: ${account.email}`);
    }
  }

  console.log('   - Shared Password: Demo@123456\n');
}
<!-- backend/src/database/seeds/README.md -->
## Resetting Demo Data

To re-run the demo seed safely:

```bash
cd backend
bun run migration:run
bun run seed:demo
```

## Testing Different User Roles

```bash
# Bến Tre
admin@bentre-shrimp.vn
owner1@bentre-shrimp.vn
staff1@bentre-shrimp.vn

# ADM
admin@adm-demo.vn
owner@adm-demo.vn
staff@adm-demo.vn

# Shared password
Demo@123456
```
  • Step 4: Run the focused seed spec and repo-wide Biome check

Run: cd backend && bun run test -- src/database/seeds/demo-data.seed.spec.ts --runInBand
Expected: PASS.

Run: bun run check
Expected: PASS with Biome formatting/lint issues fixed or absent.

  • Step 5: Commit
git add backend/src/database/seeds/demo-data.seed.ts backend/src/database/seeds/demo-data.seed.spec.ts backend/src/database/seeds/README.md
git commit -m "docs(seed): report ADM demo credentials and rerun behavior"

Final Verification Checklist

  • Run: cd backend && bun run test -- src/database/seeds/demo-data.seed.spec.ts --runInBand
  • Run: bun run check
  • Optional manual verification:
    • Run: cd backend && bun run seed:demo
    • Confirm console output lists both Trang Trại Tôm Bến Tre and ADM.
    • Confirm login works for admin@adm-demo.vn, owner@adm-demo.vn, and staff@adm-demo.vn.
    • Confirm owner/staff land on non-empty dashboards and pond/device surfaces.

Spec Coverage Check

  • Multi-scenario refactor: Task 1 and Task 2.
  • ADM organization plus admin/owner/staff: Task 2 and Task 4.
  • Area, at least two active ponds, owner assignments, and assigned_resources: Task 4.
  • Minimal device, telemetry, alert, and notification data: Task 4.
  • Deterministic IDs and rerun safety: Task 3.
  • Keep bun run seed:demo entrypoint and transaction behavior: Task 2 and Final Verification.
  • Update logs and seed docs: Task 5.