feat: host detail page + FM host context
Add /hosts/:id detail page with unified timeline (HostEvents + FMs + Repairs + part arrivals/departures) and a deployed-parts table. Hosts list rows now link to the page. FM list + detail surface inline State/Stack badges next to the asset ID, with the asset ID linking to the host page. HostEvent audit model added; create/update in the hosts service now diff and log state, stack, and field changes the same way parts.ts does. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { prisma } from '@vector/db';
|
||||
import type {
|
||||
CreateHostRequest,
|
||||
HostListQuery,
|
||||
HostTimelineQuery,
|
||||
UpdateHostRequest,
|
||||
} from '@vector/shared';
|
||||
import * as svc from '../services/hosts.js';
|
||||
@@ -31,7 +32,7 @@ export async function get(req: Request<{ id: string }>, res: Response, next: Nex
|
||||
export async function create(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const input = req.validated!.body as CreateHostRequest;
|
||||
const host = await prisma.$transaction((tx) => svc.create(tx, input));
|
||||
const host = await prisma.$transaction((tx) => svc.create(tx, input, req.user ?? null));
|
||||
res.status(201).json(host);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -41,7 +42,9 @@ export async function create(req: Request, res: Response, next: NextFunction) {
|
||||
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const input = req.validated!.body as UpdateHostRequest;
|
||||
const host = await prisma.$transaction((tx) => svc.update(tx, req.params.id, input));
|
||||
const host = await prisma.$transaction((tx) =>
|
||||
svc.update(tx, req.params.id, input, req.user ?? null),
|
||||
);
|
||||
res.json(host);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -63,6 +66,20 @@ export async function listDeployedParts(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTimeline(
|
||||
req: Request<{ id: string }>,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
try {
|
||||
const q = req.validated!.query as HostTimelineQuery;
|
||||
const result = await prisma.$transaction((tx) => svc.getTimeline(tx, req.params.id, q));
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await prisma.$transaction((tx) => svc.remove(tx, req.params.id));
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
||||
import {
|
||||
CreateHostRequest,
|
||||
HostListQuery,
|
||||
HostTimelineQuery,
|
||||
UpdateHostRequest,
|
||||
} from '@vector/shared';
|
||||
import * as ctrl from '../controllers/hosts.js';
|
||||
@@ -14,6 +15,7 @@ router.get('/', requireAuth, validate('query', HostListQuery), ctrl.list);
|
||||
router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateHostRequest), ctrl.create);
|
||||
router.get('/:id', requireAuth, ctrl.get);
|
||||
router.get('/:id/deployed-parts', requireAuth, ctrl.listDeployedParts);
|
||||
router.get('/:id/timeline', requireAuth, validate('query', HostTimelineQuery), ctrl.getTimeline);
|
||||
router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateHostRequest), ctrl.update);
|
||||
router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Tx } from './types.js';
|
||||
import { create, update } from './hosts.js';
|
||||
import type { Actor, Tx } from './types.js';
|
||||
import { create, getTimeline, update } from './hosts.js';
|
||||
|
||||
interface HostRow {
|
||||
id: string;
|
||||
@@ -12,8 +12,20 @@ interface HostRow {
|
||||
stack: string;
|
||||
}
|
||||
|
||||
interface HostEventRow {
|
||||
hostId: string;
|
||||
userId: string | null;
|
||||
type: string;
|
||||
field: string | null;
|
||||
oldValue: string | null;
|
||||
newValue: string | null;
|
||||
}
|
||||
|
||||
const ACTOR: Actor = { id: 'user-1', username: 'admin', role: 'ADMIN' };
|
||||
|
||||
function buildTx(seed: HostRow[] = []) {
|
||||
const registry = new Map(seed.map((h) => [h.id, h]));
|
||||
const hostEvents: HostEventRow[] = [];
|
||||
|
||||
const tx = {
|
||||
host: {
|
||||
@@ -30,6 +42,10 @@ function buildTx(seed: HostRow[] = []) {
|
||||
registry.set(row.id, row);
|
||||
return row;
|
||||
}),
|
||||
findUnique: vi.fn(async (args: { where: { id: string } }) => {
|
||||
const row = registry.get(args.where.id);
|
||||
return row ? { ...row } : null;
|
||||
}),
|
||||
update: vi.fn(async (args: { where: { id: string }; data: Record<string, unknown> }) => {
|
||||
const current = registry.get(args.where.id);
|
||||
if (!current) throw new Error(`No host ${args.where.id}`);
|
||||
@@ -43,30 +59,68 @@ function buildTx(seed: HostRow[] = []) {
|
||||
return current;
|
||||
}),
|
||||
},
|
||||
hostEvent: {
|
||||
create: vi.fn(async (args: { data: HostEventRow }) => {
|
||||
hostEvents.push({
|
||||
hostId: args.data.hostId,
|
||||
userId: args.data.userId ?? null,
|
||||
type: args.data.type,
|
||||
field: args.data.field ?? null,
|
||||
oldValue: args.data.oldValue ?? null,
|
||||
newValue: args.data.newValue ?? null,
|
||||
});
|
||||
return args.data;
|
||||
}),
|
||||
createMany: vi.fn(async (args: { data: HostEventRow[] }) => {
|
||||
for (const row of args.data) {
|
||||
hostEvents.push({
|
||||
hostId: row.hostId,
|
||||
userId: row.userId ?? null,
|
||||
type: row.type,
|
||||
field: row.field ?? null,
|
||||
oldValue: row.oldValue ?? null,
|
||||
newValue: row.newValue ?? null,
|
||||
});
|
||||
}
|
||||
return { count: args.data.length };
|
||||
}),
|
||||
},
|
||||
} as unknown as Tx;
|
||||
|
||||
return { tx, registry };
|
||||
return { tx, registry, hostEvents };
|
||||
}
|
||||
|
||||
describe('hosts.create', () => {
|
||||
it('defaults state=DEPLOYED and stack=PRODUCTION when not supplied', async () => {
|
||||
const { tx } = buildTx();
|
||||
|
||||
const host = await create(tx, { assetId: 'A-1', name: 'rack-1' });
|
||||
const host = await create(tx, { assetId: 'A-1', name: 'rack-1' }, ACTOR);
|
||||
|
||||
expect(host.state).toBe('DEPLOYED');
|
||||
expect(host.stack).toBe('PRODUCTION');
|
||||
});
|
||||
|
||||
it('emits a CREATED HostEvent', async () => {
|
||||
const { tx, hostEvents } = buildTx();
|
||||
|
||||
await create(tx, { assetId: 'A-1', name: 'rack-1' }, ACTOR);
|
||||
|
||||
expect(hostEvents).toHaveLength(1);
|
||||
expect(hostEvents[0]).toMatchObject({
|
||||
type: 'CREATED',
|
||||
newValue: 'A-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('persists explicit state and stack', async () => {
|
||||
const { tx } = buildTx();
|
||||
|
||||
const host = await create(tx, {
|
||||
assetId: 'A-2',
|
||||
name: 'rack-2',
|
||||
state: 'TESTING',
|
||||
stack: 'VETTING',
|
||||
});
|
||||
const host = await create(
|
||||
tx,
|
||||
{ assetId: 'A-2', name: 'rack-2', state: 'TESTING', stack: 'VETTING' },
|
||||
ACTOR,
|
||||
);
|
||||
|
||||
expect(host.state).toBe('TESTING');
|
||||
expect(host.stack).toBe('VETTING');
|
||||
@@ -74,40 +128,61 @@ describe('hosts.create', () => {
|
||||
});
|
||||
|
||||
describe('hosts.update', () => {
|
||||
it('updates state and stack when provided', async () => {
|
||||
const { tx, registry } = buildTx([
|
||||
{
|
||||
id: 'host-1',
|
||||
assetId: 'A-1',
|
||||
name: 'rack-1',
|
||||
location: null,
|
||||
notes: null,
|
||||
state: 'DEPLOYED',
|
||||
stack: 'PRODUCTION',
|
||||
},
|
||||
]);
|
||||
const seedHost: HostRow = {
|
||||
id: 'host-1',
|
||||
assetId: 'A-1',
|
||||
name: 'rack-1',
|
||||
location: null,
|
||||
notes: null,
|
||||
state: 'DEPLOYED',
|
||||
stack: 'PRODUCTION',
|
||||
};
|
||||
|
||||
await update(tx, 'host-1', { state: 'DEGRADED', stack: 'VETTING' });
|
||||
it('updates state and stack when provided', async () => {
|
||||
const { tx, registry } = buildTx([{ ...seedHost }]);
|
||||
|
||||
await update(tx, 'host-1', { state: 'DEGRADED', stack: 'VETTING' }, ACTOR);
|
||||
|
||||
const row = registry.get('host-1')!;
|
||||
expect(row.state).toBe('DEGRADED');
|
||||
expect(row.stack).toBe('VETTING');
|
||||
});
|
||||
|
||||
it('emits one HostEvent per changed field', async () => {
|
||||
const { tx, hostEvents } = buildTx([{ ...seedHost }]);
|
||||
|
||||
await update(
|
||||
tx,
|
||||
'host-1',
|
||||
{ state: 'DEGRADED', stack: 'VETTING', name: 'rack-renamed' },
|
||||
ACTOR,
|
||||
);
|
||||
|
||||
const types = hostEvents.map((e) => ({ type: e.type, field: e.field }));
|
||||
expect(types).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ type: 'STATE_CHANGED', field: 'state' },
|
||||
{ type: 'STACK_CHANGED', field: 'stack' },
|
||||
{ type: 'FIELD_UPDATED', field: 'name' },
|
||||
]),
|
||||
);
|
||||
expect(hostEvents).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('emits no HostEvent when values match current', async () => {
|
||||
const { tx, hostEvents } = buildTx([{ ...seedHost }]);
|
||||
|
||||
await update(tx, 'host-1', { state: 'DEPLOYED', stack: 'PRODUCTION' }, ACTOR);
|
||||
|
||||
expect(hostEvents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('leaves state/stack untouched when not provided', async () => {
|
||||
const { tx, registry } = buildTx([
|
||||
{
|
||||
id: 'host-1',
|
||||
assetId: 'A-1',
|
||||
name: 'rack-1',
|
||||
location: null,
|
||||
notes: null,
|
||||
state: 'TESTING',
|
||||
stack: 'VETTING',
|
||||
},
|
||||
{ ...seedHost, state: 'TESTING', stack: 'VETTING' },
|
||||
]);
|
||||
|
||||
await update(tx, 'host-1', { name: 'rack-1-renamed' });
|
||||
await update(tx, 'host-1', { name: 'rack-1-renamed' }, ACTOR);
|
||||
|
||||
const row = registry.get('host-1')!;
|
||||
expect(row.state).toBe('TESTING');
|
||||
@@ -115,3 +190,150 @@ describe('hosts.update', () => {
|
||||
expect(row.name).toBe('rack-1-renamed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hosts.getTimeline', () => {
|
||||
it('merges HostEvents, Fms, Repairs, PartEvents in reverse-chronological order', async () => {
|
||||
const hostId = 'host-1';
|
||||
const hostName = 'Vela';
|
||||
|
||||
const now = Date.now();
|
||||
const t = (offsetMin: number) => new Date(now - offsetMin * 60_000);
|
||||
|
||||
const tx = {
|
||||
host: {
|
||||
findUnique: vi.fn(async () => ({ id: hostId, name: hostName })),
|
||||
},
|
||||
hostEvent: {
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
id: 'he-1',
|
||||
type: 'STATE_CHANGED',
|
||||
field: 'state',
|
||||
oldValue: 'DEPLOYED',
|
||||
newValue: 'DEGRADED',
|
||||
createdAt: t(10),
|
||||
user: { username: 'alice' },
|
||||
},
|
||||
]),
|
||||
},
|
||||
fm: {
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
id: 'fm-1',
|
||||
status: 'OPEN',
|
||||
problem: 'bad disk',
|
||||
openedAt: t(30),
|
||||
closedAt: null,
|
||||
},
|
||||
]),
|
||||
},
|
||||
repair: {
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
id: 'r-1',
|
||||
performedAt: t(20),
|
||||
brokenPart: {
|
||||
id: 'p-1',
|
||||
serialNumber: 'CPU1',
|
||||
partModel: { mpn: 'MPN-A' },
|
||||
},
|
||||
replacement: {
|
||||
id: 'p-2',
|
||||
serialNumber: 'CPU2',
|
||||
partModel: { mpn: 'MPN-A' },
|
||||
},
|
||||
performedBy: { username: 'bob' },
|
||||
},
|
||||
]),
|
||||
},
|
||||
partEvent: {
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
} as unknown as Tx;
|
||||
|
||||
const result = await getTimeline(tx, hostId, { page: 1, pageSize: 20 });
|
||||
|
||||
expect(result.total).toBe(3);
|
||||
expect(result.data.map((e) => e.type)).toEqual([
|
||||
'HOST_EVENT',
|
||||
'REPAIR',
|
||||
'FM_OPENED',
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits FM_CLOSED only when closedAt is set', async () => {
|
||||
const hostId = 'host-1';
|
||||
const hostName = 'Vela';
|
||||
const now = Date.now();
|
||||
|
||||
const tx = {
|
||||
host: {
|
||||
findUnique: vi.fn(async () => ({ id: hostId, name: hostName })),
|
||||
},
|
||||
hostEvent: { findMany: vi.fn(async () => []) },
|
||||
fm: {
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
id: 'fm-1',
|
||||
status: 'CLOSED',
|
||||
problem: 'p',
|
||||
openedAt: new Date(now - 60_000),
|
||||
closedAt: new Date(now - 30_000),
|
||||
},
|
||||
]),
|
||||
},
|
||||
repair: { findMany: vi.fn(async () => []) },
|
||||
partEvent: { findMany: vi.fn(async () => []) },
|
||||
} as unknown as Tx;
|
||||
|
||||
const result = await getTimeline(tx, hostId, { page: 1, pageSize: 20 });
|
||||
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.data.map((e) => e.type)).toEqual(['FM_CLOSED', 'FM_OPENED']);
|
||||
});
|
||||
|
||||
it('classifies PartEvents as ARRIVED/DEPARTED by host name match', async () => {
|
||||
const hostId = 'host-1';
|
||||
const hostName = 'Vela';
|
||||
const now = Date.now();
|
||||
|
||||
const tx = {
|
||||
host: {
|
||||
findUnique: vi.fn(async () => ({ id: hostId, name: hostName })),
|
||||
},
|
||||
hostEvent: { findMany: vi.fn(async () => []) },
|
||||
fm: { findMany: vi.fn(async () => []) },
|
||||
repair: { findMany: vi.fn(async () => []) },
|
||||
partEvent: {
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
id: 'pe-1',
|
||||
createdAt: new Date(now - 60_000),
|
||||
oldValue: null,
|
||||
newValue: hostName,
|
||||
part: {
|
||||
id: 'p-1',
|
||||
serialNumber: 'CPU1',
|
||||
partModel: { mpn: 'MPN-A' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pe-2',
|
||||
createdAt: new Date(now - 30_000),
|
||||
oldValue: hostName,
|
||||
newValue: null,
|
||||
part: {
|
||||
id: 'p-1',
|
||||
serialNumber: 'CPU1',
|
||||
partModel: { mpn: 'MPN-A' },
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
} as unknown as Tx;
|
||||
|
||||
const result = await getTimeline(tx, hostId, { page: 1, pageSize: 20 });
|
||||
|
||||
expect(result.data.map((e) => e.type)).toEqual(['PART_DEPARTED', 'PART_ARRIVED']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,10 +2,11 @@ import { Prisma } from '@vector/db';
|
||||
import type {
|
||||
CreateHostRequest,
|
||||
HostListQuery,
|
||||
HostTimelineQuery,
|
||||
UpdateHostRequest,
|
||||
} from '@vector/shared';
|
||||
import { errors } from '../lib/http-error.js';
|
||||
import type { Tx } from './types.js';
|
||||
import type { Actor, Tx } from './types.js';
|
||||
|
||||
function mapUniqueViolation(target: unknown): string {
|
||||
if (Array.isArray(target) && target.includes('assetId')) return 'Asset ID already in use';
|
||||
@@ -48,9 +49,10 @@ export function listDeployedParts(tx: Tx, hostId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function create(tx: Tx, input: CreateHostRequest) {
|
||||
export async function create(tx: Tx, input: CreateHostRequest, actor: Actor | null) {
|
||||
let host;
|
||||
try {
|
||||
return await tx.host.create({
|
||||
host = await tx.host.create({
|
||||
data: {
|
||||
assetId: input.assetId,
|
||||
name: input.name,
|
||||
@@ -66,9 +68,28 @@ export async function create(tx: Tx, input: CreateHostRequest) {
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
await tx.hostEvent.create({
|
||||
data: {
|
||||
hostId: host.id,
|
||||
userId: actor?.id ?? null,
|
||||
type: 'CREATED',
|
||||
newValue: host.assetId,
|
||||
},
|
||||
});
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
export async function update(tx: Tx, id: string, input: UpdateHostRequest) {
|
||||
export async function update(
|
||||
tx: Tx,
|
||||
id: string,
|
||||
input: UpdateHostRequest,
|
||||
actor: Actor | null,
|
||||
) {
|
||||
const current = await tx.host.findUnique({ where: { id } });
|
||||
if (!current) throw errors.notFound('Host');
|
||||
|
||||
const data: Prisma.HostUpdateInput = {};
|
||||
if (input.assetId !== undefined) data.assetId = input.assetId;
|
||||
if (input.name !== undefined) data.name = input.name;
|
||||
@@ -76,8 +97,10 @@ export async function update(tx: Tx, id: string, input: UpdateHostRequest) {
|
||||
if (input.notes !== undefined) data.notes = input.notes;
|
||||
if (input.state !== undefined) data.state = input.state;
|
||||
if (input.stack !== undefined) data.stack = input.stack;
|
||||
|
||||
let host;
|
||||
try {
|
||||
return await tx.host.update({ where: { id }, data });
|
||||
host = await tx.host.update({ where: { id }, data });
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (err.code === 'P2025') throw errors.notFound('Host');
|
||||
@@ -85,6 +108,74 @@ export async function update(tx: Tx, id: string, input: UpdateHostRequest) {
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const userId = actor?.id ?? null;
|
||||
const events: Prisma.HostEventCreateManyInput[] = [];
|
||||
|
||||
if (input.state !== undefined && input.state !== current.state) {
|
||||
events.push({
|
||||
hostId: host.id,
|
||||
userId,
|
||||
type: 'STATE_CHANGED',
|
||||
field: 'state',
|
||||
oldValue: current.state,
|
||||
newValue: host.state,
|
||||
});
|
||||
}
|
||||
if (input.stack !== undefined && input.stack !== current.stack) {
|
||||
events.push({
|
||||
hostId: host.id,
|
||||
userId,
|
||||
type: 'STACK_CHANGED',
|
||||
field: 'stack',
|
||||
oldValue: current.stack,
|
||||
newValue: host.stack,
|
||||
});
|
||||
}
|
||||
if (input.assetId !== undefined && input.assetId !== current.assetId) {
|
||||
events.push({
|
||||
hostId: host.id,
|
||||
userId,
|
||||
type: 'FIELD_UPDATED',
|
||||
field: 'assetId',
|
||||
oldValue: current.assetId,
|
||||
newValue: host.assetId,
|
||||
});
|
||||
}
|
||||
if (input.name !== undefined && input.name !== current.name) {
|
||||
events.push({
|
||||
hostId: host.id,
|
||||
userId,
|
||||
type: 'FIELD_UPDATED',
|
||||
field: 'name',
|
||||
oldValue: current.name,
|
||||
newValue: host.name,
|
||||
});
|
||||
}
|
||||
if (input.location !== undefined && (input.location ?? null) !== (current.location ?? null)) {
|
||||
events.push({
|
||||
hostId: host.id,
|
||||
userId,
|
||||
type: 'FIELD_UPDATED',
|
||||
field: 'location',
|
||||
oldValue: current.location ?? null,
|
||||
newValue: host.location ?? null,
|
||||
});
|
||||
}
|
||||
if (input.notes !== undefined && (input.notes ?? null) !== (current.notes ?? null)) {
|
||||
events.push({
|
||||
hostId: host.id,
|
||||
userId,
|
||||
type: 'FIELD_UPDATED',
|
||||
field: 'notes',
|
||||
oldValue: current.notes ?? null,
|
||||
newValue: host.notes ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
if (events.length > 0) await tx.hostEvent.createMany({ data: events });
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
export async function remove(tx: Tx, id: string) {
|
||||
@@ -98,3 +189,181 @@ export async function remove(tx: Tx, id: string) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Unified host timeline. Merges four sources:
|
||||
// - HostEvents (state/stack/field changes on the host)
|
||||
// - Fms (FM_OPENED at openedAt, FM_CLOSED at closedAt when present)
|
||||
// - Repairs on this host (captures broken/replacement part swaps)
|
||||
// - PartEvents where a part's host field changed to or from this host
|
||||
// (covers ad-hoc arrivals/departures outside the repair flow).
|
||||
//
|
||||
// The four sources are merged in memory and paginated after the sort; the resulting page
|
||||
// will be small because we cap each source fetch at a safe upper bound. This avoids the
|
||||
// complexity of a UNION query while still giving correct reverse-chronological ordering.
|
||||
export type HostTimelineEntry =
|
||||
| { type: 'HOST_EVENT'; at: Date; hostEvent: HostEventPayload }
|
||||
| { type: 'FM_OPENED'; at: Date; fm: FmSummary }
|
||||
| { type: 'FM_CLOSED'; at: Date; fm: FmSummary }
|
||||
| { type: 'REPAIR'; at: Date; repair: RepairSummary }
|
||||
| { type: 'PART_ARRIVED'; at: Date; part: PartRef; partEventId: string }
|
||||
| { type: 'PART_DEPARTED'; at: Date; part: PartRef; partEventId: string };
|
||||
|
||||
interface HostEventPayload {
|
||||
id: string;
|
||||
type: string;
|
||||
field: string | null;
|
||||
oldValue: string | null;
|
||||
newValue: string | null;
|
||||
createdAt: Date;
|
||||
user: { username: string } | null;
|
||||
}
|
||||
|
||||
interface FmSummary {
|
||||
id: string;
|
||||
status: string;
|
||||
problem: string;
|
||||
openedAt: Date;
|
||||
closedAt: Date | null;
|
||||
}
|
||||
|
||||
interface RepairSummary {
|
||||
id: string;
|
||||
performedAt: Date;
|
||||
brokenPart: { id: string; serialNumber: string; mpn: string };
|
||||
replacement: { id: string; serialNumber: string; mpn: string };
|
||||
performedBy: { username: string } | null;
|
||||
}
|
||||
|
||||
interface PartRef {
|
||||
id: string;
|
||||
serialNumber: string;
|
||||
mpn: string;
|
||||
}
|
||||
|
||||
const TIMELINE_SOURCE_CAP = 500;
|
||||
|
||||
export async function getTimeline(tx: Tx, hostId: string, q: HostTimelineQuery) {
|
||||
const { page, pageSize } = q;
|
||||
|
||||
const host = await tx.host.findUnique({
|
||||
where: { id: hostId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (!host) throw errors.notFound('Host');
|
||||
|
||||
// PartEvent stores the host's name in oldValue/newValue (see parts.ts), not the id.
|
||||
const [hostEvents, fms, repairs, partEventRows] = await Promise.all([
|
||||
tx.hostEvent.findMany({
|
||||
where: { hostId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: TIMELINE_SOURCE_CAP,
|
||||
include: { user: { select: { username: true } } },
|
||||
}),
|
||||
tx.fm.findMany({
|
||||
where: { hostId },
|
||||
orderBy: { openedAt: 'desc' },
|
||||
take: TIMELINE_SOURCE_CAP,
|
||||
}),
|
||||
tx.repair.findMany({
|
||||
where: { hostId },
|
||||
orderBy: { performedAt: 'desc' },
|
||||
take: TIMELINE_SOURCE_CAP,
|
||||
include: {
|
||||
brokenPart: { include: { partModel: { select: { mpn: true } } } },
|
||||
replacement: { include: { partModel: { select: { mpn: true } } } },
|
||||
performedBy: { select: { username: true } },
|
||||
},
|
||||
}),
|
||||
tx.partEvent.findMany({
|
||||
where: {
|
||||
type: 'LOCATION_CHANGED',
|
||||
field: 'host',
|
||||
OR: [{ oldValue: host.name }, { newValue: host.name }],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: TIMELINE_SOURCE_CAP,
|
||||
include: {
|
||||
part: {
|
||||
select: { id: true, serialNumber: true, partModel: { select: { mpn: true } } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const entries: HostTimelineEntry[] = [];
|
||||
|
||||
for (const e of hostEvents) {
|
||||
entries.push({
|
||||
type: 'HOST_EVENT',
|
||||
at: e.createdAt,
|
||||
hostEvent: {
|
||||
id: e.id,
|
||||
type: e.type,
|
||||
field: e.field,
|
||||
oldValue: e.oldValue,
|
||||
newValue: e.newValue,
|
||||
createdAt: e.createdAt,
|
||||
user: e.user,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const f of fms) {
|
||||
const summary: FmSummary = {
|
||||
id: f.id,
|
||||
status: f.status,
|
||||
problem: f.problem,
|
||||
openedAt: f.openedAt,
|
||||
closedAt: f.closedAt,
|
||||
};
|
||||
entries.push({ type: 'FM_OPENED', at: f.openedAt, fm: summary });
|
||||
if (f.closedAt) entries.push({ type: 'FM_CLOSED', at: f.closedAt, fm: summary });
|
||||
}
|
||||
for (const r of repairs) {
|
||||
entries.push({
|
||||
type: 'REPAIR',
|
||||
at: r.performedAt,
|
||||
repair: {
|
||||
id: r.id,
|
||||
performedAt: r.performedAt,
|
||||
brokenPart: {
|
||||
id: r.brokenPart.id,
|
||||
serialNumber: r.brokenPart.serialNumber,
|
||||
mpn: r.brokenPart.partModel.mpn,
|
||||
},
|
||||
replacement: {
|
||||
id: r.replacement.id,
|
||||
serialNumber: r.replacement.serialNumber,
|
||||
mpn: r.replacement.partModel.mpn,
|
||||
},
|
||||
performedBy: r.performedBy ? { username: r.performedBy.username } : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const pe of partEventRows) {
|
||||
if (!pe.part) continue;
|
||||
const partRef: PartRef = {
|
||||
id: pe.part.id,
|
||||
serialNumber: pe.part.serialNumber,
|
||||
mpn: pe.part.partModel.mpn,
|
||||
};
|
||||
// newValue = this host's name → arrival; oldValue = this host's name → departure.
|
||||
if (pe.newValue === host.name) {
|
||||
entries.push({ type: 'PART_ARRIVED', at: pe.createdAt, part: partRef, partEventId: pe.id });
|
||||
}
|
||||
if (pe.oldValue === host.name) {
|
||||
entries.push({
|
||||
type: 'PART_DEPARTED',
|
||||
at: pe.createdAt,
|
||||
part: partRef,
|
||||
partEventId: pe.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort((a, b) => b.at.getTime() - a.at.getTime());
|
||||
const total = entries.length;
|
||||
const start = (page - 1) * pageSize;
|
||||
const data = entries.slice(start, start + pageSize);
|
||||
return { data, page, pageSize, total };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user