Device Operations Workspace 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: Ship the owner/admin telemetry-first device operations workspace at /devices/operations, backed by farm-scoped reads and the existing device control write contract.
Architecture: Implement this as a dedicated vertical slice under the devices domain. Backend gets a small DeviceOperationsController + DeviceOperationsService that expose GET /api/v1/devices/operations/farms and GET /api/v1/devices/operations/workspace, while control and telemetry detail reuse the existing /devices/:id/control, /devices/:id/telemetry, and latest telemetry APIs. Frontend adds a dedicated page, navigation entries for owner/admin, and local workspace state for farm selection, pinned telemetry sources, and the telemetry/control drawer without persisting workspace state server-side.
Tech Stack: NestJS, TypeORM, Jest, React 19, TanStack Query v5, Ant Design, Vitest, Testing Library, Bun, Biome
Important context: The current worktree already contains interrupted test-only edits in frontend/src/navigation/nav-resolver.spec.ts and frontend/src/pages/devices/DeviceOperationsPage.spec.tsx. Do not revert them blindly. Either adapt them to this plan or supersede them intentionally during execution.
File Structure
Create
backend/src/devices/controllers/device-operations.controller.ts
Dedicated backend route surface for workspace farm access and farm-scoped workspace reads.backend/src/devices/controllers/device-operations.controller.spec.ts
Controller delegation and guard-facing contract tests.backend/src/devices/device-operations.service.ts
Farm access resolution, farm-scoped device query, alert-count aggregation, and workspace DTO mapping.backend/src/devices/device-operations.service.spec.ts
Authorization, scoping, controllable/telemetry flag mapping, and alert-count coverage.backend/src/devices/dto/device-operations.dto.ts
Query DTOs and response DTOs for farms and workspace rows.frontend/src/types/device-operations.types.ts
Shared frontend types for workspace farms, rows, filters, and API responses.frontend/src/services/device-operations.service.ts
Frontend client for/devices/operations/farmsand/devices/operations/workspace.frontend/src/hooks/useDeviceOperations.ts
TanStack Query hooks for farm access and workspace dataset loading.frontend/src/pages/devices/DeviceOperationsPage.tsx
The new owner/admin operations workspace page.
Modify
backend/src/devices/devices.module.ts
Register the new controller, service, and DTO provider dependencies.frontend/src/App.tsx
Add the/devices/operationsroute behind owner/admin + device-read access.frontend/src/navigation/menus/admin.menu.tsx
AddVận hành thiết bịbeside the existing device entries.frontend/src/navigation/menus/owner.menu.tsx
Add the same workspace entry for owners.frontend/src/navigation/nav-resolver.spec.ts
Lock navigation exposure for owner/admin.frontend/src/App.redirect.spec.tsx
Add an App-level route regression test for/devices/operations.frontend/src/pages/devices/DeviceOperationsPage.spec.tsx
Verify farm auto-select, multi-farm empty state, farm-switch reset, telemetry pinning, and control permission behavior.
Reuse Without Modifying First
frontend/src/components/devices/GatewayControlPanel.tsx
Reuse as the phase 1 control surface inside the control drawer.frontend/src/components/devices/TelemetryChart.tsx
Reuse for short-range telemetry visualization.frontend/src/services/device.service.ts
Reuse existing latest telemetry, telemetry history, and control write methods.backend/src/devices/controllers/devices.controller.ts
Keep the currentPOST /devices/:id/controlcontract unchanged.
Task 1: Add Backend Device Operations Controller Surface
Files:
Create:
backend/src/devices/controllers/device-operations.controller.tsCreate:
backend/src/devices/controllers/device-operations.controller.spec.tsCreate:
backend/src/devices/dto/device-operations.dto.tsModify:
backend/src/devices/devices.module.tsStep 1: Write the failing controller tests
Create backend/src/devices/controllers/device-operations.controller.spec.ts with the route contract locked before implementation:
import { Test } from '@nestjs/testing';
import type { CurrentUserData } from '@/auth/decorators/current-user.decorator';
import { DeviceOperationsController } from '@/devices/controllers/device-operations.controller';
import { DeviceOperationsService } from '@/devices/device-operations.service';
const currentUser: CurrentUserData = {
userId: 'user-1',
organizationId: 'org-1',
role: 'ADMIN',
roles: ['ADMIN'],
permissions: ['device:read:all', 'device:control:all'],
};
describe('DeviceOperationsController', () => {
let controller: DeviceOperationsController;
let service: jest.Mocked<DeviceOperationsService>;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [DeviceOperationsController],
providers: [
{
provide: DeviceOperationsService,
useValue: {
listAccessibleFarms: jest.fn(),
getWorkspace: jest.fn(),
},
},
],
}).compile();
controller = moduleRef.get(DeviceOperationsController);
service = moduleRef.get(DeviceOperationsService);
});
it('delegates farm access reads to the device operations service', async () => {
service.listAccessibleFarms.mockResolvedValue({
data: [{ id: 'farm-1', name: 'Trại A', code: 'TRAI-A' }],
});
await expect(controller.listAccessibleFarms(currentUser)).resolves.toEqual({
data: [{ id: 'farm-1', name: 'Trại A', code: 'TRAI-A' }],
});
expect(service.listAccessibleFarms).toHaveBeenCalledWith(currentUser);
});
it('delegates workspace reads with farmId to the service', async () => {
service.getWorkspace.mockResolvedValue({
data: [],
meta: { total: 0, page: 1, limit: 20, totalPages: 0 },
});
await expect(
controller.getWorkspace(currentUser, {
farmId: 'farm-1',
page: 1,
limit: 20,
}),
).resolves.toEqual({
data: [],
meta: { total: 0, page: 1, limit: 20, totalPages: 0 },
});
expect(service.getWorkspace).toHaveBeenCalledWith(currentUser, {
farmId: 'farm-1',
page: 1,
limit: 20,
});
});
});
- Step 2: Run the controller test to verify it fails
Run: cd backend && bun test -- src/devices/controllers/device-operations.controller.spec.ts
Expected: FAIL because the controller and service do not exist yet.
- Step 3: Implement the DTOs and controller
Create backend/src/devices/dto/device-operations.dto.ts:
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsBooleanString, IsEnum, IsOptional, IsString, IsUUID, Min } from 'class-validator';
export class QueryDeviceOperationsWorkspaceDto {
@ApiProperty({ example: 'farm-uuid' })
@IsUUID()
farmId: string;
@ApiPropertyOptional({ example: 1 })
@IsOptional()
@Type(() => Number)
@Min(1)
page?: number;
@ApiPropertyOptional({ example: 20 })
@IsOptional()
@Type(() => Number)
@Min(1)
limit?: number;
@ApiPropertyOptional({ example: 'GW-001' })
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ enum: ['online', 'offline', 'error'] })
@IsOptional()
@IsEnum(['online', 'offline', 'error'])
status?: 'online' | 'offline' | 'error';
@ApiPropertyOptional({ enum: ['sensor', 'gateway', 'inverter', 'feeder'] })
@IsOptional()
@IsEnum(['sensor', 'gateway', 'inverter', 'feeder'])
type?: 'sensor' | 'gateway' | 'inverter' | 'feeder';
@ApiPropertyOptional({ example: 'pond-uuid' })
@IsOptional()
@IsUUID()
pondId?: string;
@ApiPropertyOptional({ example: 'true' })
@IsOptional()
@IsBooleanString()
controllableOnly?: string;
}
export class DeviceOperationsFarmDto {
@ApiProperty()
id: string;
@ApiProperty()
name: string;
@ApiPropertyOptional()
code?: string | null;
}
export class DeviceOperationsWorkspaceRowDto {
@ApiProperty()
id: string;
@ApiProperty()
name: string | null;
@ApiProperty()
serialNumber: string;
@ApiProperty()
type: 'sensor' | 'gateway' | 'inverter' | 'feeder';
@ApiPropertyOptional()
pond?: { id: string; name: string; code: string } | null;
@ApiProperty()
status: 'online' | 'offline' | 'error';
@ApiPropertyOptional()
lastSeenAt?: Date | null;
@ApiProperty()
activeAlertCount: number;
@ApiProperty()
telemetrySupport: boolean;
@ApiProperty()
controllable: boolean;
@ApiProperty()
controlCapabilitySummary: string | null;
}
Create backend/src/devices/controllers/device-operations.controller.ts:
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CurrentUser, type CurrentUserData } from '@/auth/decorators/current-user.decorator';
import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard';
import { RequirePermissions } from '@/common/decorators/permissions.decorator';
import { PermissionsGuard } from '@/common/guards/permissions.guard';
import { DeviceOperationsService } from '@/devices/device-operations.service';
import { QueryDeviceOperationsWorkspaceDto } from '@/devices/dto/device-operations.dto';
@ApiTags('Device Operations')
@UseGuards(JwtAuthGuard, PermissionsGuard)
@ApiBearerAuth()
@Controller('devices/operations')
export class DeviceOperationsController {
constructor(private readonly service: DeviceOperationsService) {}
@Get('farms')
@ApiOperation({ summary: 'List accessible farms for the device operations workspace' })
@ApiResponse({ status: 200, description: 'Accessible farms list' })
@RequirePermissions('device:read:all', 'device:read:own', 'device:read:assigned')
listAccessibleFarms(@CurrentUser() currentUser: CurrentUserData) {
return this.service.listAccessibleFarms(currentUser);
}
@Get('workspace')
@ApiOperation({ summary: 'List farm-scoped devices for the device operations workspace' })
@ApiResponse({ status: 200, description: 'Workspace device dataset' })
@RequirePermissions('device:read:all', 'device:read:own', 'device:read:assigned')
getWorkspace(
@CurrentUser() currentUser: CurrentUserData,
@Query() query: QueryDeviceOperationsWorkspaceDto,
) {
return this.service.getWorkspace(currentUser, query);
}
}
Update backend/src/devices/devices.module.ts:
import { DeviceOperationsController } from './controllers/device-operations.controller';
import { DeviceOperationsService } from './device-operations.service';
controllers: [
DevicesController,
DeviceOperationsController,
SystemDeviceInventoryController,
DeviceProfilesController,
DeviceTypesController,
SystemDeviceEndpointsController,
],
providers: [
DevicesService,
DeviceOperationsService,
AlertConfigService,
AlertService,
SystemDeviceInventoryService,
DeviceProfileService,
DeviceEndpointMaterializerService,
DeviceCommandDispatchService,
{ provide: 'DeviceCommandDispatchService', useExisting: DeviceCommandDispatchService },
],
- Step 4: Run the controller test to verify it passes
Run: cd backend && bun test -- src/devices/controllers/device-operations.controller.spec.ts
Expected: PASS with 2 passing controller tests.
- Step 5: Commit
git add backend/src/devices/controllers/device-operations.controller.ts \
backend/src/devices/controllers/device-operations.controller.spec.ts \
backend/src/devices/dto/device-operations.dto.ts \
backend/src/devices/devices.module.ts
git commit -m "feat(backend): add device operations controller surface"
Task 2: Implement Farm Access Resolution And Farm-Scoped Workspace Reads
Files:
Create:
backend/src/devices/device-operations.service.tsCreate:
backend/src/devices/device-operations.service.spec.tsStep 1: Write the failing backend service tests
Create backend/src/devices/device-operations.service.spec.ts:
import { ForbiddenException } from '@nestjs/common';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Test } from '@nestjs/testing';
import type { Repository, SelectQueryBuilder } from 'typeorm';
import type { CurrentUserData } from '@/auth/decorators/current-user.decorator';
import { DeviceOperationsService } from '@/devices/device-operations.service';
import { DeviceAlert } from '@/devices/entities/device-alert.entity';
import { Device } from '@/devices/entities/device.entity';
import { Area } from '@/farm/entities/area.entity';
import { Pond } from '@/farm/entities/pond.entity';
const adminUser: CurrentUserData = {
userId: 'user-1',
organizationId: 'org-1',
role: 'ADMIN',
roles: ['ADMIN'],
permissions: ['device:read:all', 'device:control:all'],
};
describe('DeviceOperationsService', () => {
let service: DeviceOperationsService;
let areaRepository: jest.Mocked<Repository<Area>>;
let deviceRepository: jest.Mocked<Repository<Device>>;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
providers: [
DeviceOperationsService,
{ provide: getRepositoryToken(Area), useValue: { createQueryBuilder: jest.fn(), exist: jest.fn() } },
{ provide: getRepositoryToken(Device), useValue: { createQueryBuilder: jest.fn() } },
{ provide: getRepositoryToken(Pond), useValue: {} },
{ provide: getRepositoryToken(DeviceAlert), useValue: {} },
],
}).compile();
service = moduleRef.get(DeviceOperationsService);
areaRepository = moduleRef.get(getRepositoryToken(Area));
deviceRepository = moduleRef.get(getRepositoryToken(Device));
});
it('returns all organization farms for admin users', async () => {
const qb = {
where: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([{ id: 'farm-1', name: 'Trại A', code: 'TRAI-A' }]),
} as unknown as jest.Mocked<SelectQueryBuilder<Area>>;
areaRepository.createQueryBuilder.mockReturnValue(qb);
await expect(service.listAccessibleFarms(adminUser)).resolves.toEqual({
data: [{ id: 'farm-1', name: 'Trại A', code: 'TRAI-A' }],
});
});
it('rejects workspace reads when the requested farm is outside the organization', async () => {
areaRepository.exist.mockResolvedValue(false);
await expect(
service.getWorkspace(adminUser, { farmId: 'farm-404', page: 1, limit: 20 }),
).rejects.toThrow(ForbiddenException);
});
it('maps workspace rows with alert counts and controllable flags', async () => {
areaRepository.exist.mockResolvedValue(true);
const listQb = {
leftJoinAndSelect: jest.fn().mockReturnThis(),
leftJoin: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getCount: jest.fn().mockResolvedValue(1),
getRawAndEntities: jest.fn().mockResolvedValue({
raw: [{ active_alert_count: '2' }],
entities: [
{
id: 'device-1',
name: 'Gateway A',
serialNumber: 'GW-001',
type: 'gateway',
capabilities: { ioCount: 4, ioStates: [false, false, false, false] },
status: 'online',
lastSeenAt: new Date('2026-05-10T00:00:00.000Z'),
pond: { id: 'pond-1', name: 'Ao 1', code: 'A1' },
},
],
}),
} as unknown as jest.Mocked<SelectQueryBuilder<Device>>;
deviceRepository.createQueryBuilder.mockReturnValue(listQb);
await expect(
service.getWorkspace(adminUser, { farmId: 'farm-1', page: 1, limit: 20 }),
).resolves.toEqual({
data: [
{
id: 'device-1',
name: 'Gateway A',
serialNumber: 'GW-001',
type: 'gateway',
pond: { id: 'pond-1', name: 'Ao 1', code: 'A1' },
status: 'online',
lastSeenAt: new Date('2026-05-10T00:00:00.000Z'),
activeAlertCount: 2,
telemetrySupport: true,
controllable: true,
controlCapabilitySummary: '4 IO',
},
],
meta: { total: 1, page: 1, limit: 20, totalPages: 1 },
});
});
});
- Step 2: Run the backend service test to verify it fails
Run: cd backend && bun test -- src/devices/device-operations.service.spec.ts
Expected: FAIL because the service does not exist yet.
- Step 3: Implement
DeviceOperationsService
Create backend/src/devices/device-operations.service.ts:
import { ForbiddenException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import type { Repository } from 'typeorm';
import type { CurrentUserData } from '@/auth/decorators/current-user.decorator';
import { RoleName, toRoleName } from '@/common/enums';
import {
type DeviceOperationsFarmDto,
type DeviceOperationsWorkspaceRowDto,
QueryDeviceOperationsWorkspaceDto,
} from '@/devices/dto/device-operations.dto';
import { DeviceAlert } from '@/devices/entities/device-alert.entity';
import { Device } from '@/devices/entities/device.entity';
import { Area } from '@/farm/entities/area.entity';
import { Pond } from '@/farm/entities/pond.entity';
@Injectable()
export class DeviceOperationsService {
constructor(
@InjectRepository(Area) private areaRepository: Repository<Area>,
@InjectRepository(Device) private deviceRepository: Repository<Device>,
@InjectRepository(Pond) private pondRepository: Repository<Pond>,
@InjectRepository(DeviceAlert) private alertRepository: Repository<DeviceAlert>,
) {}
async listAccessibleFarms(
currentUser: CurrentUserData,
): Promise<{ data: DeviceOperationsFarmDto[] }> {
const organizationId = this.getOrganizationIdOrThrow(currentUser);
const role = this.getSupportedRole(currentUser);
const qb = this.areaRepository
.createQueryBuilder('area')
.where('area.organization_id = :organizationId', { organizationId })
.select(['area.id', 'area.name', 'area.code'])
.orderBy('area.name', 'ASC');
if (role === RoleName.OWNER) {
qb.innerJoin('area.assignedOwners', 'owner', 'owner.id = :userId', {
userId: currentUser.userId,
});
}
const farms = await qb.getMany();
return {
data: farms.map((farm) => ({ id: farm.id, name: farm.name, code: farm.code ?? null })),
};
}
async getWorkspace(currentUser: CurrentUserData, query: QueryDeviceOperationsWorkspaceDto) {
const organizationId = this.getOrganizationIdOrThrow(currentUser);
await this.assertFarmAccessible(currentUser, query.farmId, organizationId);
const page = query.page ?? 1;
const limit = Math.min(query.limit ?? 20, 100);
const qb = this.deviceRepository
.createQueryBuilder('device')
.leftJoinAndSelect('device.pond', 'pond')
.leftJoin(
(subQb) =>
subQb
.from(DeviceAlert, 'alert')
.select('alert.deviceId', 'device_id')
.addSelect('COUNT(*)', 'active_alert_count')
.where(`alert.status = 'active'`)
.groupBy('alert.deviceId'),
'active_alerts',
'active_alerts.device_id = device.id',
)
.addSelect('COALESCE(active_alerts.active_alert_count, 0)', 'active_alert_count')
.where('device.organizationId = :organizationId', { organizationId })
.andWhere('(device.areaId = :farmId OR pond.areaId = :farmId)', { farmId: query.farmId });
if (query.search) {
qb.andWhere('(device.name ILIKE :search OR device.serialNumber ILIKE :search)', {
search: `%${query.search}%`,
});
}
if (query.status) qb.andWhere('device.status = :status', { status: query.status });
if (query.type) qb.andWhere('device.type = :type', { type: query.type });
if (query.pondId) qb.andWhere('pond.id = :pondId', { pondId: query.pondId });
if (query.controllableOnly === 'true') {
qb.andWhere("device.type = 'gateway'");
}
qb.orderBy('device.lastSeenAt', 'DESC');
const total = await qb.getCount();
const { entities, raw } = await qb.skip((page - 1) * limit).take(limit).getRawAndEntities();
const data: DeviceOperationsWorkspaceRowDto[] = entities.map((device, index) => {
const activeAlertCount = Number(raw[index]?.active_alert_count ?? 0);
const gatewayCapabilities = this.getGatewayCapabilities(device.capabilities);
const controllable = gatewayCapabilities !== null && gatewayCapabilities.ioCount > 0;
return {
id: device.id,
name: device.name,
serialNumber: device.serialNumber,
type: device.type as DeviceOperationsWorkspaceRowDto['type'],
pond: device.pond
? { id: device.pond.id, name: device.pond.name, code: device.pond.code }
: null,
status: this.resolveStatus(device.status, device.lastSeenAt),
lastSeenAt: device.lastSeenAt,
activeAlertCount,
telemetrySupport: this.hasTelemetrySupport(device),
controllable,
controlCapabilitySummary: controllable ? `${gatewayCapabilities?.ioCount ?? 0} IO` : null,
};
});
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
private async assertFarmAccessible(
currentUser: CurrentUserData,
farmId: string,
organizationId: string,
) {
const role = this.getSupportedRole(currentUser);
if (role === RoleName.ADMIN) {
const exists = await this.areaRepository.exist({ where: { id: farmId, organizationId } });
if (!exists) throw new ForbiddenException('Farm is out of organization scope');
return;
}
const assignedFarm = await this.areaRepository
.createQueryBuilder('area')
.innerJoin('area.assignedOwners', 'owner', 'owner.id = :userId', {
userId: currentUser.userId,
})
.where('area.organization_id = :organizationId', { organizationId })
.andWhere('area.id = :farmId', { farmId })
.select(['area.id'])
.getOne();
if (!assignedFarm) throw new ForbiddenException('Farm is out of organization scope');
}
private getOrganizationIdOrThrow(currentUser: CurrentUserData): string {
if (!currentUser.organizationId) {
throw new ForbiddenException('Organization scope is required');
}
return currentUser.organizationId;
}
private getSupportedRole(currentUser: CurrentUserData): RoleName {
const role = currentUser.role ?? toRoleName(currentUser.roles[0] ?? '');
if (role !== RoleName.ADMIN && role !== RoleName.OWNER) {
throw new ForbiddenException('Role is not allowed to access device operations workspace');
}
return role;
}
private hasTelemetrySupport(device: Device): boolean {
return device.type === 'sensor' || device.type === 'gateway';
}
private getGatewayCapabilities(capabilities: unknown): { ioCount: number } | null {
if (!capabilities || typeof capabilities !== 'object') return null;
const candidate = capabilities as { ioCount?: unknown };
return typeof candidate.ioCount === 'number' ? { ioCount: candidate.ioCount } : null;
}
private resolveStatus(
status: Device['status'],
lastSeenAt: Date | null,
): 'online' | 'offline' | 'error' {
if (!lastSeenAt || status !== 'online') return status as 'online' | 'offline' | 'error';
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
return lastSeenAt < fiveMinutesAgo ? 'offline' : 'online';
}
}
- Step 4: Run the backend service test to verify it passes
Run: cd backend && bun test -- src/devices/device-operations.service.spec.ts
Expected: PASS with 3 passing service tests.
- Step 5: Run the controller test again
Run: cd backend && bun test -- src/devices/controllers/device-operations.controller.spec.ts
Expected: PASS and still green after the service implementation lands.
- Step 6: Commit
git add backend/src/devices/device-operations.service.ts \
backend/src/devices/device-operations.service.spec.ts
git commit -m "feat(backend): add farm-scoped device operations workspace reads"
Task 3: Add Frontend Types, Service, And Query Hooks For The Workspace
Files:
Create:
frontend/src/types/device-operations.types.tsCreate:
frontend/src/services/device-operations.service.tsCreate:
frontend/src/hooks/useDeviceOperations.tsStep 1: Write the failing query-hook test
Append a focused query-key + enabled-state test to frontend/src/pages/devices/DeviceOperationsPage.spec.tsx:
it('waits for explicit farm selection when multiple farms are accessible', async () => {
renderWithProviders(<DeviceOperationsPage />, {
initialEntries: ['/devices/operations'],
});
expect(await screen.findByText('Chọn trang trại để bắt đầu')).toBeInTheDocument();
expect(screen.queryByRole('table')).not.toBeInTheDocument();
});
Run: cd frontend && bun test src/pages/devices/DeviceOperationsPage.spec.tsx --run
Expected: FAIL because the page and hooks do not exist yet.
- Step 2: Implement the frontend workspace types
Create frontend/src/types/device-operations.types.ts:
export interface DeviceOperationsFarm {
id: string;
name: string;
code?: string | null;
}
export interface DeviceOperationsWorkspaceRow {
id: string;
name: string | null;
serialNumber: string;
type: 'sensor' | 'gateway' | 'inverter' | 'feeder';
pond?: { id: string; name: string; code: string } | null;
status: 'online' | 'offline' | 'error';
lastSeenAt?: string | null;
activeAlertCount: number;
telemetrySupport: boolean;
controllable: boolean;
controlCapabilitySummary: string | null;
}
export interface DeviceOperationsWorkspaceQuery {
farmId: string;
page?: number;
limit?: number;
search?: string;
status?: 'online' | 'offline' | 'error';
type?: 'sensor' | 'gateway' | 'inverter' | 'feeder';
pondId?: string;
controllableOnly?: boolean;
}
export interface DeviceOperationsWorkspaceResponse {
data: DeviceOperationsWorkspaceRow[];
meta: { total: number; page: number; limit: number; totalPages: number };
}
- Step 3: Implement the service and hooks
Create frontend/src/services/device-operations.service.ts:
import { api } from '@/lib/api';
import type {
DeviceOperationsFarm,
DeviceOperationsWorkspaceQuery,
DeviceOperationsWorkspaceResponse,
} from '@/types/device-operations.types';
export const deviceOperationsService = {
async getAccessibleFarms(): Promise<{ data: DeviceOperationsFarm[] }> {
const response = await api.get<{ data: DeviceOperationsFarm[] }>('/devices/operations/farms');
return response.data;
},
async getWorkspace(
query: DeviceOperationsWorkspaceQuery,
): Promise<DeviceOperationsWorkspaceResponse> {
const response = await api.get<DeviceOperationsWorkspaceResponse>(
'/devices/operations/workspace',
{
params: {
...query,
controllableOnly: query.controllableOnly ? 'true' : undefined,
},
},
);
return response.data;
},
};
Create frontend/src/hooks/useDeviceOperations.ts:
import { useQuery } from '@tanstack/react-query';
import { deviceOperationsService } from '@/services/device-operations.service';
import type { DeviceOperationsWorkspaceQuery } from '@/types/device-operations.types';
export const useAccessibleDeviceOperationFarms = () =>
useQuery({
queryKey: ['device-operations', 'farms'],
queryFn: () => deviceOperationsService.getAccessibleFarms(),
staleTime: 60_000,
});
export const useDeviceOperationsWorkspace = (
query: DeviceOperationsWorkspaceQuery | null,
) =>
useQuery({
queryKey: ['device-operations', 'workspace', query],
queryFn: () => deviceOperationsService.getWorkspace(query as DeviceOperationsWorkspaceQuery),
enabled: query !== null,
placeholderData: (previous) => previous,
});
- Step 4: Run the page spec again to verify the page is still missing but the service layer is ready
Run: cd frontend && bun test src/pages/devices/DeviceOperationsPage.spec.tsx --run
Expected: FAIL because DeviceOperationsPage.tsx does not exist yet, not because the service layer is malformed.
- Step 5: Commit
git add frontend/src/types/device-operations.types.ts \
frontend/src/services/device-operations.service.ts \
frontend/src/hooks/useDeviceOperations.ts
git commit -m "feat(frontend): add device operations data client"
Task 4: Expose The Route And Navigation Entry
Files:
Modify:
frontend/src/App.tsxModify:
frontend/src/navigation/menus/admin.menu.tsxModify:
frontend/src/navigation/menus/owner.menu.tsxModify:
frontend/src/navigation/nav-resolver.spec.tsModify:
frontend/src/App.redirect.spec.tsxStep 1: Add the failing route and navigation tests
Update frontend/src/navigation/nav-resolver.spec.ts:
it.each([
['ADMIN', 'admin-device-operations'],
['OWNER', 'owner-device-operations'],
] as const)('exposes the device operations workspace in %s navigation', (role, key) => {
const items = flatten(resolveNavigation(role, fullPermissions));
const target = items.find((item) => item.key === key);
expect(target?.path).toBe('/devices/operations');
});
Update frontend/src/App.redirect.spec.tsx:
vi.mock('@/pages/devices/DeviceOperationsPage', () => ({
DeviceOperationsPage: () => <div>device-operations-page</div>,
}));
it('renders the device operations route when visiting /devices/operations', async () => {
window.history.pushState({}, '', '/devices/operations');
render(React.createElement(App));
expect(await screen.findByText('device-operations-page')).toBeInTheDocument();
});
- Step 2: Run the route/navigation tests to verify they fail
Run: cd frontend && bun test src/navigation/nav-resolver.spec.ts src/App.redirect.spec.tsx --run
Expected: FAIL because the new route and menu items do not exist yet.
- Step 3: Add the route and menu entries
Update frontend/src/navigation/menus/admin.menu.tsx:
{
key: 'admin-device-operations',
icon: <ToolOutlined />,
label: 'Vận hành thiết bị',
path: '/devices/operations',
requiredPermission: { resource: 'device' },
},
Place it immediately after admin-device-management.
Update frontend/src/navigation/menus/owner.menu.tsx:
{
key: 'owner-device-operations',
icon: <ExperimentOutlined />,
label: 'Vận hành thiết bị',
path: '/devices/operations',
requiredPermission: { resource: 'device' },
},
Place it immediately after owner-devices.
Update frontend/src/App.tsx imports:
import { DeviceOperationsPage } from '@/pages/devices/DeviceOperationsPage';
Update the protected device routes in frontend/src/App.tsx:
<Route
element={
<PermissionRoute
resource="device"
roles={[RoleEnum.ADMIN, RoleEnum.OWNER]}
/>
}
>
<Route path="/devices/operations" element={<DeviceOperationsPage />} />
</Route>
Place it between /devices/alert-configs and /devices/:id so it never conflicts with the :id route.
- Step 4: Run the route/navigation tests to verify they pass
Run: cd frontend && bun test src/navigation/nav-resolver.spec.ts src/App.redirect.spec.tsx --run
Expected: PASS with the new owner/admin navigation checks and the route render regression green.
- Step 5: Commit
git add frontend/src/App.tsx \
frontend/src/navigation/menus/admin.menu.tsx \
frontend/src/navigation/menus/owner.menu.tsx \
frontend/src/navigation/nav-resolver.spec.ts \
frontend/src/App.redirect.spec.tsx
git commit -m "feat(frontend): expose device operations route and navigation"
Task 5: Build The Device Operations Workspace Page
Files:
Create:
frontend/src/pages/devices/DeviceOperationsPage.tsxModify:
frontend/src/pages/devices/DeviceOperationsPage.spec.tsxStep 1: Write the full failing page behavior tests
Create or replace frontend/src/pages/devices/DeviceOperationsPage.spec.tsx with these core behaviors:
import '@testing-library/jest-dom/vitest';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DeviceOperationsPage } from '@/pages/devices/DeviceOperationsPage';
import { renderWithProviders } from '@/test/renderWithProviders';
const navigateMock = vi.fn();
const mockPermissionsState = {
permissions: ['device:read:all', 'device:control:all'],
user: { organizationId: 'org-1' },
};
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return { ...actual, useNavigate: () => navigateMock };
});
vi.mock('@/stores/auth.store', () => ({
useAuthStore: (
selector: (state: {
permissions: string[];
user: { organizationId: string | null } | null;
}) => unknown,
) => selector(mockPermissionsState),
}));
vi.mock('@/hooks/useDeviceOperations', () => ({
useAccessibleDeviceOperationFarms: vi.fn(),
useDeviceOperationsWorkspace: vi.fn(),
}));
vi.mock('@/services/device.service', () => ({
deviceService: {
getLatestTelemetry: vi.fn(),
getTelemetryHistory: vi.fn(),
},
}));
describe('DeviceOperationsPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('auto-selects the only accessible farm and loads its workspace rows', async () => {
// mock farms hook => one farm
// mock workspace hook => one row
renderWithProviders(<DeviceOperationsPage />, {
initialEntries: ['/devices/operations'],
});
expect(await screen.findByDisplayValue('Trại A')).toBeInTheDocument();
expect(screen.getByText('Gateway A')).toBeInTheDocument();
});
it('shows an uninitialized prompt when multiple farms exist and no farm has been selected', async () => {
renderWithProviders(<DeviceOperationsPage />, {
initialEntries: ['/devices/operations'],
});
expect(await screen.findByText('Chọn trang trại để bắt đầu')).toBeInTheDocument();
});
it('clears pinned telemetry and closes the control drawer when switching farms', async () => {
renderWithProviders(<DeviceOperationsPage />, {
initialEntries: ['/devices/operations'],
});
fireEvent.click(await screen.findByRole('button', { name: /xem telemetry/i }));
fireEvent.click(await screen.findByRole('button', { name: /ghim nguồn/i }));
fireEvent.click(screen.getByRole('button', { name: /điều khiển/i }));
fireEvent.mouseDown(screen.getByLabelText(/trang trại/i));
fireEvent.click(await screen.findByTitle('Trại B'));
await waitFor(() => {
expect(screen.queryByText(/control-panel/i)).not.toBeInTheDocument();
});
expect(screen.queryByRole('button', { name: /bỏ ghim nguồn/i })).not.toBeInTheDocument();
});
it('disables control for users without device control permission', async () => {
mockPermissionsState.permissions = ['device:read:all'];
renderWithProviders(<DeviceOperationsPage />, {
initialEntries: ['/devices/operations'],
});
expect(await screen.findByRole('button', { name: /điều khiển/i })).toBeDisabled();
});
});
- Step 2: Run the page spec to verify it fails
Run: cd frontend && bun test src/pages/devices/DeviceOperationsPage.spec.tsx --run
Expected: FAIL because the page does not exist yet.
- Step 3: Implement the page with explicit local workspace state
Create frontend/src/pages/devices/DeviceOperationsPage.tsx:
import {
EyeOutlined,
LinkOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import {
Alert,
Button,
Card,
Drawer,
Empty,
Grid,
Input,
Select,
Space,
Switch,
Table,
Tag,
Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { startTransition, useDeferredValue, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { PageHeader } from '@/components/common/PageHeader';
import { GatewayControlPanel } from '@/components/devices/GatewayControlPanel';
import { TelemetryChart } from '@/components/devices/TelemetryChart';
import { useAccessibleDeviceOperationFarms, useDeviceOperationsWorkspace } from '@/hooks/useDeviceOperations';
import dayjs from '@/lib/dayjs';
import { hasPermission } from '@/lib/permissions';
import { deviceService } from '@/services/device.service';
import { useAuthStore } from '@/stores/auth.store';
import type { DeviceOperationsWorkspaceRow } from '@/types/device-operations.types';
type DrawerMode = 'closed' | 'telemetry' | 'control';
const DEFAULT_TELEMETRY_PARAMETERS = ['temperature', 'ph'];
export const DeviceOperationsPage = () => {
const navigate = useNavigate();
const { md } = Grid.useBreakpoint();
const permissions = useAuthStore((state) => state.permissions);
const canControlDevices = hasPermission(permissions, 'device', 'control');
const [selectedFarmId, setSelectedFarmId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const deferredSearch = useDeferredValue(search);
const [statusFilter, setStatusFilter] = useState<'online' | 'offline' | 'error' | undefined>();
const [typeFilter, setTypeFilter] = useState<'sensor' | 'gateway' | 'inverter' | 'feeder' | undefined>();
const [pondFilter, setPondFilter] = useState<string | undefined>();
const [controllableOnly, setControllableOnly] = useState(false);
const [drawerMode, setDrawerMode] = useState<DrawerMode>('closed');
const [activeTelemetrySourceId, setActiveTelemetrySourceId] = useState<string | null>(null);
const [activeControlTargetId, setActiveControlTargetId] = useState<string | null>(null);
const [pinnedTelemetrySourceIds, setPinnedTelemetrySourceIds] = useState<string[]>([]);
const farmsQuery = useAccessibleDeviceOperationFarms();
useEffect(() => {
if (selectedFarmId || !farmsQuery.data) return;
if (farmsQuery.data.data.length === 1) {
setSelectedFarmId(farmsQuery.data.data[0].id);
}
}, [selectedFarmId, farmsQuery.data]);
const workspaceQuery = useDeviceOperationsWorkspace(
selectedFarmId
? {
farmId: selectedFarmId,
page: 1,
limit: 100,
search: deferredSearch || undefined,
status: statusFilter,
type: typeFilter,
pondId: pondFilter,
controllableOnly,
}
: null,
);
const rows = workspaceQuery.data?.data ?? [];
const activeTelemetrySource =
rows.find((row) => row.id === activeTelemetrySourceId) ?? null;
const activeControlTarget =
rows.find((row) => row.id === activeControlTargetId) ?? null;
const latestTelemetryQuery = useQuery({
queryKey: ['device-operations', 'latest-telemetry', activeTelemetrySourceId],
queryFn: () => deviceService.getLatestTelemetry(activeTelemetrySourceId as string),
enabled: activeTelemetrySourceId !== null,
});
const telemetryHistoryQuery = useQuery({
queryKey: ['device-operations', 'telemetry-history', activeTelemetrySourceId],
queryFn: () =>
deviceService.getTelemetryHistory(activeTelemetrySourceId as string, {
from: dayjs().subtract(6, 'hour').toISOString(),
to: dayjs().toISOString(),
interval: '1h',
parameters: DEFAULT_TELEMETRY_PARAMETERS,
}),
enabled: activeTelemetrySourceId !== null,
});
const pondOptions = useMemo(() => {
const map = new Map<string, { value: string; label: string }>();
rows.forEach((row) => {
if (!row.pond) return;
map.set(row.pond.id, {
value: row.pond.id,
label: `${row.pond.name} (${row.pond.code})`,
});
});
return Array.from(map.values());
}, [rows]);
const handleFarmChange = (nextFarmId: string) => {
startTransition(() => {
setSelectedFarmId(nextFarmId);
setDrawerMode('closed');
setActiveTelemetrySourceId(null);
setActiveControlTargetId(null);
setPinnedTelemetrySourceIds([]);
setPondFilter(undefined);
});
};
const pinActiveTelemetrySource = () => {
if (!activeTelemetrySourceId) return;
setPinnedTelemetrySourceIds((current) =>
current.includes(activeTelemetrySourceId)
? current
: [...current, activeTelemetrySourceId],
);
};
const unpinActiveTelemetrySource = () => {
if (!activeTelemetrySourceId) return;
setPinnedTelemetrySourceIds((current) =>
current.filter((id) => id !== activeTelemetrySourceId),
);
};
const columns: ColumnsType<DeviceOperationsWorkspaceRow> = [
{ title: 'Thiết bị', dataIndex: 'name', key: 'name' },
{ title: 'Serial', dataIndex: 'serialNumber', key: 'serialNumber' },
{
title: 'Ao',
key: 'pond',
render: (_, row) => (row.pond ? `${row.pond.name} (${row.pond.code})` : 'Chưa gán'),
},
{
title: 'Trạng thái',
dataIndex: 'status',
key: 'status',
render: (status) => <Tag>{status.toUpperCase()}</Tag>,
},
{
title: 'Cảnh báo',
dataIndex: 'activeAlertCount',
key: 'activeAlertCount',
render: (count) => <Tag color={count > 0 ? 'red' : 'default'}>{count}</Tag>,
},
{
title: 'Khả năng',
key: 'capabilities',
render: (_, row) => (
<Space wrap>
<Tag color={row.telemetrySupport ? 'blue' : 'default'}>Telemetry</Tag>
<Tag color={row.controllable ? 'green' : 'default'}>
{row.controlCapabilitySummary ?? 'Không điều khiển'}
</Tag>
</Space>
),
},
{
title: 'Hành động',
key: 'actions',
render: (_, row) => (
<Space wrap>
<Button
icon={<EyeOutlined />}
onClick={() => {
setActiveTelemetrySourceId(row.id);
setDrawerMode('telemetry');
}}
disabled={!row.telemetrySupport}
>
Xem telemetry
</Button>
<Button
icon={<ThunderboltOutlined />}
onClick={() => {
setActiveControlTargetId(row.id);
setDrawerMode('control');
}}
disabled={!row.controllable || !canControlDevices}
>
Điều khiển
</Button>
<Button icon={<LinkOutlined />} onClick={() => navigate(`/devices/${row.id}`)}>
Chi tiết
</Button>
</Space>
),
},
];
return (
<div>
<PageHeader
title="Vận hành thiết bị"
subtitle="Quan sát telemetry trước khi gửi lệnh điều khiển"
/>
<Card style={{ marginBottom: 16 }}>
<Space wrap size="middle">
<Select
aria-label="Trang trại"
placeholder="Chọn trang trại"
value={selectedFarmId ?? undefined}
onChange={handleFarmChange}
style={{ minWidth: 220 }}
options={(farmsQuery.data?.data ?? []).map((farm) => ({
value: farm.id,
label: farm.name,
title: farm.name,
}))}
/>
<Input.Search
placeholder="Tìm theo tên hoặc serial"
value={search}
onChange={(event) => setSearch(event.target.value)}
style={{ width: 240 }}
/>
<Select
placeholder="Trạng thái"
allowClear
value={statusFilter}
onChange={setStatusFilter}
style={{ width: 140 }}
options={[
{ value: 'online', label: 'Online' },
{ value: 'offline', label: 'Offline' },
{ value: 'error', label: 'Error' },
]}
/>
<Select
placeholder="Loại"
allowClear
value={typeFilter}
onChange={setTypeFilter}
style={{ width: 160 }}
options={[
{ value: 'sensor', label: 'Sensor' },
{ value: 'gateway', label: 'Gateway' },
{ value: 'inverter', label: 'Inverter' },
{ value: 'feeder', label: 'Feeder' },
]}
/>
<Select
placeholder="Ao"
allowClear
value={pondFilter}
onChange={setPondFilter}
style={{ width: 200 }}
options={pondOptions}
/>
<Space>
<Switch checked={controllableOnly} onChange={setControllableOnly} />
<Typography.Text>Chỉ thiết bị điều khiển được</Typography.Text>
</Space>
</Space>
</Card>
{pinnedTelemetrySourceIds.length > 0 && (
<Card style={{ marginBottom: 16 }}>
<Space wrap>
{pinnedTelemetrySourceIds.map((id) => {
const row = rows.find((item) => item.id === id);
if (!row) return null;
return (
<Tag
key={id}
color={id === activeTelemetrySourceId ? 'blue' : 'default'}
onClick={() => {
setActiveTelemetrySourceId(id);
setDrawerMode('telemetry');
}}
>
{row.name ?? row.serialNumber}
</Tag>
);
})}
</Space>
</Card>
)}
{!selectedFarmId && (farmsQuery.data?.data.length ?? 0) > 1 ? (
<Card>
<Empty description="Chọn trang trại để bắt đầu" />
</Card>
) : (
<Card>
<Table
rowKey="id"
columns={columns}
dataSource={rows}
loading={workspaceQuery.isLoading}
pagination={false}
scroll={{ x: 980 }}
/>
</Card>
)}
<Drawer
title={drawerMode === 'telemetry' ? 'Nguồn telemetry' : 'Điều khiển thiết bị'}
open={drawerMode !== 'closed'}
placement={md ? 'right' : 'bottom'}
height={md ? undefined : '82vh'}
width={md ? 520 : undefined}
onClose={() => setDrawerMode('closed')}
>
{drawerMode === 'telemetry' && activeTelemetrySource && (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space direction="vertical" size={0}>
<Typography.Title level={5} style={{ margin: 0 }}>
{activeTelemetrySource.name ?? activeTelemetrySource.serialNumber}
</Typography.Title>
<Typography.Text type="secondary">
{activeTelemetrySource.serialNumber}
</Typography.Text>
</Space>
{latestTelemetryQuery.data ? (
<Card size="small">
<pre>{JSON.stringify(latestTelemetryQuery.data.data, null, 2)}</pre>
</Card>
) : (
<Alert type="info" message="Chưa có telemetry gần đây" />
)}
<TelemetryChart
data={telemetryHistoryQuery.data?.data ?? []}
parameters={DEFAULT_TELEMETRY_PARAMETERS}
height={240}
/>
<Space wrap>
<Button onClick={pinActiveTelemetrySource}>Ghim nguồn</Button>
<Button onClick={unpinActiveTelemetrySource}>Bỏ ghim nguồn</Button>
<Button onClick={() => navigate(`/devices/${activeTelemetrySource.id}`)}>
Mở chi tiết
</Button>
</Space>
</Space>
)}
{drawerMode === 'control' && activeControlTarget && (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space direction="vertical" size={0}>
<Typography.Title level={5} style={{ margin: 0 }}>
{activeControlTarget.name ?? activeControlTarget.serialNumber}
</Typography.Title>
<Typography.Text type="secondary">
{activeControlTarget.controlCapabilitySummary ?? 'Không có capability điều khiển'}
</Typography.Text>
</Space>
{activeControlTarget.status !== 'online' && (
<Alert
type="warning"
message="Thiết bị đang offline, không thể gửi lệnh lúc này"
/>
)}
<GatewayControlPanel
deviceId={activeControlTarget.id}
capabilities={{ ioCount: 4, ioStates: [false, false, false, false] }}
disabled={activeControlTarget.status !== 'online'}
/>
</Space>
)}
</Drawer>
</div>
);
};
- Step 4: Run the page test to verify it passes
Run: cd frontend && bun test src/pages/devices/DeviceOperationsPage.spec.tsx --run
Expected: PASS with the farm-selection, farm-switch reset, and control permission cases green.
- Step 5: Commit
git add frontend/src/pages/devices/DeviceOperationsPage.tsx \
frontend/src/pages/devices/DeviceOperationsPage.spec.tsx
git commit -m "feat(frontend): add device operations workspace page"
Task 6: Run Focused Verification And Repo Checks
Files:
Modify only if verification exposes real defects.
Step 1: Run backend workspace tests
Run:
cd backend
bun test -- src/devices/controllers/device-operations.controller.spec.ts
bun test -- src/devices/device-operations.service.spec.ts
Expected: PASS for the new backend controller and service suites.
- Step 2: Run frontend workspace tests
Run:
cd frontend
bun test src/navigation/nav-resolver.spec.ts src/App.redirect.spec.tsx src/pages/devices/DeviceOperationsPage.spec.tsx --run
Expected: PASS for navigation exposure, route render, and page behavior tests.
- Step 3: Run repo-wide checks required by AGENTS.md
Run:
cd /Users/hieunguyen/Documents/projects/iot_platform
bun run check
Expected: PASS for Biome + type/lint checks across touched JS/TS workspaces.
- Step 4: Smoke-check the runtime flows manually
Run:
cd frontend
bun run dev
Then verify manually:
1. Login as ADMIN with device read/control permissions.
2. Open /devices/operations.
3. Confirm single-farm auto-select or multi-farm empty state.
4. Open telemetry drawer, pin a source, and switch between pinned chips.
5. Open control drawer on an online controllable device and submit a command.
6. Open control drawer on an offline device and confirm the control surface is disabled.
7. Switch farms and confirm pinned telemetry sources + active drawer state reset.
- Step 5: Commit the final integrated slice
git add backend/src/devices frontend/src docs/superpowers/plans/2026-05-10-device-operations-workspace.md
git commit -m "feat: add owner admin device operations workspace"
Spec Coverage Check
- Backend farm access read path: covered by Task 1 and Task 2 through
/devices/operations/farms. - Backend farm-scoped workspace dataset: covered by Task 1 and Task 2 through
/devices/operations/workspace. - Reuse existing control contract: preserved explicitly in Task 5 by continuing to use
GatewayControlPanel+ existing device service control write flow. - Route and owner/admin navigation entry: covered by Task 4.
- Table + drawer workspace UX: covered by Task 5.
- Telemetry pinning and one-target-at-a-time control: covered by Task 5.
- Auto-select one farm, empty state for multiple farms, reset state on farm switch: covered by Task 5.
- Responsive drawer behavior: covered by Task 5 via right drawer on desktop and bottom drawer on mobile.
- Verification requirement from AGENTS.md: covered by Task 6.
Placeholder Scan
- No
TODO,TBD, or “implement later” placeholders remain. - The plan intentionally chooses dedicated backend workspace endpoints instead of a temporary frontend-only fan-out composition.
- Phase 2 items from the specs stay out of this plan: command history, generalized command DTO, technical assignment, and role expansion beyond owner/admin.