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 fakeQueryRunnerharness 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 existingseed:demoand Jest scripts.backend/src/user-roles/entities/user-role.entity.ts— confirmassigned_resourcesshape remainsjsonb.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.tsModify:
backend/src/database/seeds/demo-data.seed.ts:1-120Test:
backend/src/database/seeds/demo-data.seed.spec.tsStep 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.tsModify:
backend/src/database/seeds/demo-data.seed.ts:57-420Test:
backend/src/database/seeds/demo-data.seed.spec.tsStep 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.tsModify:
backend/src/database/seeds/demo-data.seed.ts:237-1271Test:
backend/src/database/seeds/demo-data.seed.spec.tsStep 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.tsModify:
backend/src/database/seeds/demo-data.seed.ts:121-1271Test:
backend/src/database/seeds/demo-data.seed.spec.tsStep 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.tsModify:
backend/src/database/seeds/demo-data.seed.ts:1273-1291Modify:
backend/src/database/seeds/README.md:232-309Test:
backend/src/database/seeds/demo-data.seed.spec.tsStep 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 TreandADM. - Confirm login works for
admin@adm-demo.vn,owner@adm-demo.vn, andstaff@adm-demo.vn. - Confirm owner/staff land on non-empty dashboards and pond/device surfaces.
- Run:
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:demoentrypoint and transaction behavior: Task 2 and Final Verification. - Update logs and seed docs: Task 5.