From 0b29e706b06bcfe40cffa43c5e914f4c6d0d55f8 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 17 Apr 2026 14:30:24 -0400 Subject: [PATCH] feat(hosts): generate unique 8-digit asset ID Add a Generate button to the host create dialog that fetches a random 8-digit asset ID from the new GET /hosts/generate-asset-id endpoint. The service retries against the unique index so the returned ID is guaranteed unused. Edit mode hides the button. Co-Authored-By: Claude Opus 4.7 --- apps/api/src/controllers/hosts.ts | 9 +++++ apps/api/src/routes/hosts.ts | 1 + apps/api/src/services/hosts.ts | 12 ++++++ .../src/components/hosts/HostFormDialog.tsx | 37 ++++++++++++++++--- apps/web/src/lib/api/hosts.ts | 5 +++ 5 files changed, 59 insertions(+), 5 deletions(-) diff --git a/apps/api/src/controllers/hosts.ts b/apps/api/src/controllers/hosts.ts index a9150c8..cda806e 100644 --- a/apps/api/src/controllers/hosts.ts +++ b/apps/api/src/controllers/hosts.ts @@ -29,6 +29,15 @@ export async function get(req: Request<{ id: string }>, res: Response, next: Nex } } +export async function generateAssetId(_req: Request, res: Response, next: NextFunction) { + try { + const result = await prisma.$transaction((tx) => svc.generateAssetId(tx)); + res.json(result); + } catch (err) { + next(err); + } +} + export async function create(req: Request, res: Response, next: NextFunction) { try { const input = req.validated!.body as CreateHostRequest; diff --git a/apps/api/src/routes/hosts.ts b/apps/api/src/routes/hosts.ts index 2dc579d..04ffc65 100644 --- a/apps/api/src/routes/hosts.ts +++ b/apps/api/src/routes/hosts.ts @@ -13,6 +13,7 @@ const router = Router(); router.get('/', requireAuth, validate('query', HostListQuery), ctrl.list); router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateHostRequest), ctrl.create); +router.get('/generate-asset-id', requireAuth, requireRole('ADMIN'), ctrl.generateAssetId); router.get('/:id', requireAuth, ctrl.get); router.get('/:id/deployed-parts', requireAuth, ctrl.listDeployedParts); router.get('/:id/timeline', requireAuth, validate('query', HostTimelineQuery), ctrl.getTimeline); diff --git a/apps/api/src/services/hosts.ts b/apps/api/src/services/hosts.ts index 56c257d..136c130 100644 --- a/apps/api/src/services/hosts.ts +++ b/apps/api/src/services/hosts.ts @@ -41,6 +41,18 @@ export function get(tx: Tx, id: string) { return tx.host.findUnique({ where: { id } }); } +// Random 8-digit asset ID (zero-padded) that isn't already taken. With ~100M +// possible values and only hundreds of hosts in practice, collisions are rare +// — we still retry a few times to be safe, then bail instead of looping forever. +export async function generateAssetId(tx: Tx): Promise<{ assetId: string }> { + for (let attempt = 0; attempt < 20; attempt++) { + const candidate = String(Math.floor(Math.random() * 100_000_000)).padStart(8, '0'); + const existing = await tx.host.findUnique({ where: { assetId: candidate }, select: { id: true } }); + if (!existing) return { assetId: candidate }; + } + throw errors.conflict('Could not generate a unique asset ID'); +} + export function listDeployedParts(tx: Tx, hostId: string) { return tx.part.findMany({ where: { hostId, state: 'DEPLOYED' }, diff --git a/apps/web/src/components/hosts/HostFormDialog.tsx b/apps/web/src/components/hosts/HostFormDialog.tsx index 1c201e7..59e2ac1 100644 --- a/apps/web/src/components/hosts/HostFormDialog.tsx +++ b/apps/web/src/components/hosts/HostFormDialog.tsx @@ -3,7 +3,7 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { z } from 'zod'; -import { Loader2 } from 'lucide-react'; +import { Loader2, Sparkles } from 'lucide-react'; import { toast } from 'sonner'; import { HostStack, HostState } from '@vector/shared'; import { @@ -28,7 +28,7 @@ import { SelectValue, Textarea, } from '@vector/ui'; -import { createHost, updateHost } from '../../lib/api/hosts.js'; +import { createHost, generateHostAssetId, updateHost } from '../../lib/api/hosts.js'; import { ApiRequestError } from '../../lib/api/client.js'; import { queryKeys } from '../../lib/queryKeys.js'; import type { Host } from '../../lib/api/types.js'; @@ -88,6 +88,15 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps }); }, [open, host, form]); + const generateMutation = useMutation({ + mutationFn: () => generateHostAssetId(), + onSuccess: ({ assetId }) => { + form.setValue('assetId', assetId, { shouldDirty: true, shouldValidate: true }); + }, + onError: (err) => + toast.error(err instanceof ApiRequestError ? err.body.message : 'Generate failed'), + }); + const mutation = useMutation({ mutationFn: async (values: Values) => { if (editing && host) { @@ -136,9 +145,27 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps render={({ field }) => ( Asset ID - - - +
+ + + + {!editing && ( + + )} +
)} diff --git a/apps/web/src/lib/api/hosts.ts b/apps/web/src/lib/api/hosts.ts index 03e4a1b..1b4dc59 100644 --- a/apps/web/src/lib/api/hosts.ts +++ b/apps/web/src/lib/api/hosts.ts @@ -42,3 +42,8 @@ export async function updateHost(id: string, input: UpdateHostRequest): Promise< export async function deleteHost(id: string): Promise { await api.delete(`/hosts/${id}`); } + +export async function generateHostAssetId(): Promise<{ assetId: string }> { + const res = await api.get<{ assetId: string }>('/hosts/generate-asset-id'); + return res.data; +}