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 <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
export async function create(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const input = req.validated!.body as CreateHostRequest;
|
const input = req.validated!.body as CreateHostRequest;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const router = Router();
|
|||||||
|
|
||||||
router.get('/', requireAuth, validate('query', HostListQuery), ctrl.list);
|
router.get('/', requireAuth, validate('query', HostListQuery), ctrl.list);
|
||||||
router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateHostRequest), ctrl.create);
|
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', requireAuth, ctrl.get);
|
||||||
router.get('/:id/deployed-parts', requireAuth, ctrl.listDeployedParts);
|
router.get('/:id/deployed-parts', requireAuth, ctrl.listDeployedParts);
|
||||||
router.get('/:id/timeline', requireAuth, validate('query', HostTimelineQuery), ctrl.getTimeline);
|
router.get('/:id/timeline', requireAuth, validate('query', HostTimelineQuery), ctrl.getTimeline);
|
||||||
|
|||||||
@@ -41,6 +41,18 @@ export function get(tx: Tx, id: string) {
|
|||||||
return tx.host.findUnique({ where: { id } });
|
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) {
|
export function listDeployedParts(tx: Tx, hostId: string) {
|
||||||
return tx.part.findMany({
|
return tx.part.findMany({
|
||||||
where: { hostId, state: 'DEPLOYED' },
|
where: { hostId, state: 'DEPLOYED' },
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2, Sparkles } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { HostStack, HostState } from '@vector/shared';
|
import { HostStack, HostState } from '@vector/shared';
|
||||||
import {
|
import {
|
||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
Textarea,
|
Textarea,
|
||||||
} from '@vector/ui';
|
} 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 { ApiRequestError } from '../../lib/api/client.js';
|
||||||
import { queryKeys } from '../../lib/queryKeys.js';
|
import { queryKeys } from '../../lib/queryKeys.js';
|
||||||
import type { Host } from '../../lib/api/types.js';
|
import type { Host } from '../../lib/api/types.js';
|
||||||
@@ -88,6 +88,15 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
|
|||||||
});
|
});
|
||||||
}, [open, host, form]);
|
}, [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({
|
const mutation = useMutation({
|
||||||
mutationFn: async (values: Values) => {
|
mutationFn: async (values: Values) => {
|
||||||
if (editing && host) {
|
if (editing && host) {
|
||||||
@@ -136,9 +145,27 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Asset ID</FormLabel>
|
<FormLabel>Asset ID</FormLabel>
|
||||||
<FormControl>
|
<div className="flex gap-2">
|
||||||
<Input autoFocus placeholder="e.g. ASSET-001" {...field} />
|
<FormControl>
|
||||||
</FormControl>
|
<Input autoFocus placeholder="e.g. ASSET-001" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
{!editing && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => generateMutation.mutate()}
|
||||||
|
disabled={generateMutation.isPending}
|
||||||
|
title="Generate an unused 8-digit asset ID"
|
||||||
|
>
|
||||||
|
{generateMutation.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Generate
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -42,3 +42,8 @@ export async function updateHost(id: string, input: UpdateHostRequest): Promise<
|
|||||||
export async function deleteHost(id: string): Promise<void> {
|
export async function deleteHost(id: string): Promise<void> {
|
||||||
await api.delete(`/hosts/${id}`);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user