chore: initial Vector 2.0 monorepo
Ground-up TypeScript rewrite of the Vector hardware parts inventory
system. Ships the full roadmap (Phases 0-8) in one initial commit:
- pnpm + Turbo monorepo: apps/{api,web,e2e}, packages/{db,shared,ui,config}
- Express 5 + Prisma 5 + zod validation + JWT w/ refresh-token rotation
- React 19 + Vite + shadcn/ui + TanStack Query/Table + nuqs URL state
- Repair/RMA, tags, bulk ops, saved views, CSV audit export
- Analytics dashboard on Recharts + EOL tracking
- Signed webhook subscriptions (HMAC-SHA256) with in-process emitter
- Vitest unit tests (shared schemas, api services/helpers) + Playwright skeleton
- Gitea Actions CI (lint, typecheck, test+coverage, build) + Renovate
Deferred follow-ups: Postgres cutover (data-migration script ready),
BullMQ worker for webhook delivery, @react-pdf PDF export, CSV import wizard.
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { z } from 'zod';
|
||||
import { PartState } from './enums.js';
|
||||
|
||||
export interface StateCount {
|
||||
state: z.infer<typeof PartState>;
|
||||
count: number;
|
||||
totalPrice: number;
|
||||
}
|
||||
|
||||
export interface AgeBucket {
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface BinCount {
|
||||
binId: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ManufacturerEolSummary {
|
||||
manufacturerId: string;
|
||||
name: string;
|
||||
eolDate: string | null;
|
||||
deployedCount: number;
|
||||
}
|
||||
|
||||
export interface DashboardAnalytics {
|
||||
totalParts: number;
|
||||
byState: StateCount[];
|
||||
ageBuckets: AgeBucket[];
|
||||
topBins: BinCount[];
|
||||
deployedPastEol: ManufacturerEolSummary[];
|
||||
openRepairs: number;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { LoginRequest, UserPublic } from './auth.js';
|
||||
|
||||
describe('LoginRequest', () => {
|
||||
it('accepts valid credentials', () => {
|
||||
expect(LoginRequest.safeParse({ username: 'u', password: 'p' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty username or password', () => {
|
||||
expect(LoginRequest.safeParse({ username: '', password: 'p' }).success).toBe(false);
|
||||
expect(LoginRequest.safeParse({ username: 'u', password: '' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('caps username at 64 and password at 256', () => {
|
||||
expect(
|
||||
LoginRequest.safeParse({ username: 'a'.repeat(65), password: 'p' }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
LoginRequest.safeParse({ username: 'u', password: 'a'.repeat(257) }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserPublic', () => {
|
||||
it('accepts an ISO string createdAt', () => {
|
||||
const r = UserPublic.safeParse({
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
username: 'u',
|
||||
email: 'u@x.dev',
|
||||
role: 'ADMIN',
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid role', () => {
|
||||
expect(
|
||||
UserPublic.safeParse({
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
username: 'u',
|
||||
email: 'u@x.dev',
|
||||
role: 'SUPER_ADMIN',
|
||||
createdAt: new Date().toISOString(),
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
import { Role } from './enums.js';
|
||||
|
||||
export const LoginRequest = z.object({
|
||||
username: z.string().min(1).max(64),
|
||||
password: z.string().min(1).max(256),
|
||||
});
|
||||
export type LoginRequest = z.infer<typeof LoginRequest>;
|
||||
|
||||
export const UserPublic = z.object({
|
||||
id: z.string().uuid(),
|
||||
username: z.string(),
|
||||
email: z.string().email(),
|
||||
role: Role,
|
||||
createdAt: z.union([z.string(), z.date()]),
|
||||
});
|
||||
export type UserPublic = z.infer<typeof UserPublic>;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod';
|
||||
import { PaginationQuery } from './pagination.js';
|
||||
|
||||
export const CreateCategoryRequest = z.object({
|
||||
name: z.string().min(1).max(64),
|
||||
description: z.string().max(512).optional().nullable(),
|
||||
});
|
||||
export type CreateCategoryRequest = z.infer<typeof CreateCategoryRequest>;
|
||||
|
||||
export const UpdateCategoryRequest = z
|
||||
.object({
|
||||
name: z.string().min(1).max(64).optional(),
|
||||
description: z.string().max(512).nullable().optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||
export type UpdateCategoryRequest = z.infer<typeof UpdateCategoryRequest>;
|
||||
|
||||
export const CategoryListQuery = PaginationQuery;
|
||||
export type CategoryListQuery = z.infer<typeof CategoryListQuery>;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
import { CsvImportStatus } from './enums.js';
|
||||
import { PaginationQuery } from './pagination.js';
|
||||
|
||||
export const CsvImportResource = z.enum(['parts']);
|
||||
export type CsvImportResource = z.infer<typeof CsvImportResource>;
|
||||
|
||||
export const CreateCsvImportJobRequest = z.object({
|
||||
resource: CsvImportResource,
|
||||
// Presigned-upload semantics are deferred; for now the API accepts a filename + row count hint.
|
||||
filename: z.string().min(1).max(256),
|
||||
});
|
||||
export type CreateCsvImportJobRequest = z.infer<typeof CreateCsvImportJobRequest>;
|
||||
|
||||
export const CsvImportJobListQuery = PaginationQuery.extend({
|
||||
status: CsvImportStatus.optional(),
|
||||
resource: CsvImportResource.optional(),
|
||||
});
|
||||
export type CsvImportJobListQuery = z.infer<typeof CsvImportJobListQuery>;
|
||||
|
||||
// Shape of each entry in the errors JSON column; consumed by the importer UI.
|
||||
export const CsvImportRowError = z.object({
|
||||
row: z.number().int().nonnegative(),
|
||||
column: z.string().max(64).optional(),
|
||||
code: z.string().max(64),
|
||||
message: z.string().max(512),
|
||||
});
|
||||
export type CsvImportRowError = z.infer<typeof CsvImportRowError>;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PartState = z.enum(['SPARE', 'DEPLOYED', 'BROKEN', 'PENDING_DESTRUCTION']);
|
||||
export type PartState = z.infer<typeof PartState>;
|
||||
|
||||
export const Role = z.enum(['ADMIN', 'TECHNICIAN']);
|
||||
export type Role = z.infer<typeof Role>;
|
||||
|
||||
export const PartEventType = z.enum([
|
||||
'CREATED',
|
||||
'STATE_CHANGED',
|
||||
'LOCATION_CHANGED',
|
||||
'FIELD_UPDATED',
|
||||
'REPAIR_STARTED',
|
||||
'REPAIR_COMPLETED',
|
||||
'TAG_ADDED',
|
||||
'TAG_REMOVED',
|
||||
]);
|
||||
export type PartEventType = z.infer<typeof PartEventType>;
|
||||
|
||||
export const RepairStatus = z.enum(['PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED']);
|
||||
export type RepairStatus = z.infer<typeof RepairStatus>;
|
||||
|
||||
export const CsvImportStatus = z.enum([
|
||||
'PENDING',
|
||||
'STAGED',
|
||||
'COMMITTED',
|
||||
'FAILED',
|
||||
'CANCELLED',
|
||||
]);
|
||||
export type CsvImportStatus = z.infer<typeof CsvImportStatus>;
|
||||
|
||||
// Catalog of webhook event names the system will emit. Stored per subscription as a JSON array.
|
||||
export const WebhookEventName = z.enum([
|
||||
'part.created',
|
||||
'part.updated',
|
||||
'part.deleted',
|
||||
'part.state_changed',
|
||||
'part.location_changed',
|
||||
'repair.started',
|
||||
'repair.completed',
|
||||
'repair.cancelled',
|
||||
'tag.assigned',
|
||||
'tag.removed',
|
||||
]);
|
||||
export type WebhookEventName = z.infer<typeof WebhookEventName>;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const defaultSecret = /change[-_ ]?me/i;
|
||||
|
||||
export const ApiEnv = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
PORT: z.coerce.number().int().positive().default(3001),
|
||||
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
|
||||
JWT_SECRET: z
|
||||
.string()
|
||||
.min(32, 'JWT_SECRET must be at least 32 characters')
|
||||
.refine((v) => !defaultSecret.test(v), {
|
||||
message: 'JWT_SECRET still matches the default placeholder — generate a real secret',
|
||||
}),
|
||||
CLIENT_ORIGIN: z.string().url().default('http://localhost:5173'),
|
||||
});
|
||||
export type ApiEnv = z.infer<typeof ApiEnv>;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod';
|
||||
import { PaginationQuery } from './pagination.js';
|
||||
|
||||
export const CreateHostRequest = z.object({
|
||||
name: z.string().min(1).max(128),
|
||||
location: z.string().max(256).optional().nullable(),
|
||||
notes: z.string().max(4096).optional().nullable(),
|
||||
});
|
||||
export type CreateHostRequest = z.infer<typeof CreateHostRequest>;
|
||||
|
||||
export const UpdateHostRequest = z
|
||||
.object({
|
||||
name: z.string().min(1).max(128).optional(),
|
||||
location: z.string().max(256).nullable().optional(),
|
||||
notes: z.string().max(4096).nullable().optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||
export type UpdateHostRequest = z.infer<typeof UpdateHostRequest>;
|
||||
|
||||
export const HostListQuery = PaginationQuery.extend({
|
||||
q: z.string().max(128).optional(),
|
||||
});
|
||||
export type HostListQuery = z.infer<typeof HostListQuery>;
|
||||
@@ -0,0 +1,16 @@
|
||||
export * from './enums.js';
|
||||
export * from './auth.js';
|
||||
export * from './users.js';
|
||||
export * from './manufacturers.js';
|
||||
export * from './locations.js';
|
||||
export * from './parts.js';
|
||||
export * from './env.js';
|
||||
export * from './pagination.js';
|
||||
export * from './hosts.js';
|
||||
export * from './repairs.js';
|
||||
export * from './tags.js';
|
||||
export * from './categories.js';
|
||||
export * from './webhooks.js';
|
||||
export * from './saved-views.js';
|
||||
export * from './csv-imports.js';
|
||||
export * from './analytics.js';
|
||||
@@ -0,0 +1,35 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CreateSiteRequest = z.object({
|
||||
name: z.string().min(1).max(128),
|
||||
});
|
||||
export type CreateSiteRequest = z.infer<typeof CreateSiteRequest>;
|
||||
|
||||
export const UpdateSiteRequest = z.object({
|
||||
name: z.string().min(1).max(128),
|
||||
});
|
||||
export type UpdateSiteRequest = z.infer<typeof UpdateSiteRequest>;
|
||||
|
||||
export const CreateRoomRequest = z.object({
|
||||
name: z.string().min(1).max(128),
|
||||
siteId: z.string().uuid(),
|
||||
});
|
||||
export type CreateRoomRequest = z.infer<typeof CreateRoomRequest>;
|
||||
|
||||
export const UpdateRoomRequest = z.object({
|
||||
name: z.string().min(1).max(128).optional(),
|
||||
siteId: z.string().uuid().optional(),
|
||||
});
|
||||
export type UpdateRoomRequest = z.infer<typeof UpdateRoomRequest>;
|
||||
|
||||
export const CreateBinRequest = z.object({
|
||||
name: z.string().min(1).max(128),
|
||||
roomId: z.string().uuid(),
|
||||
});
|
||||
export type CreateBinRequest = z.infer<typeof CreateBinRequest>;
|
||||
|
||||
export const UpdateBinRequest = z.object({
|
||||
name: z.string().min(1).max(128).optional(),
|
||||
roomId: z.string().uuid().optional(),
|
||||
});
|
||||
export type UpdateBinRequest = z.infer<typeof UpdateBinRequest>;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// ISO datetime string (e.g. "2027-12-31T00:00:00.000Z"). Clients may send date-only "2027-12-31";
|
||||
// API layer is expected to coerce to Date.
|
||||
const IsoDate = z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/));
|
||||
|
||||
export const CreateManufacturerRequest = z.object({
|
||||
name: z.string().min(1).max(128),
|
||||
eolDate: IsoDate.optional().nullable(),
|
||||
});
|
||||
export type CreateManufacturerRequest = z.infer<typeof CreateManufacturerRequest>;
|
||||
|
||||
export const UpdateManufacturerRequest = z
|
||||
.object({
|
||||
name: z.string().min(1).max(128).optional(),
|
||||
eolDate: IsoDate.nullable().optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||
export type UpdateManufacturerRequest = z.infer<typeof UpdateManufacturerRequest>;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PAGINATION_MAX = 100;
|
||||
export const PAGINATION_DEFAULT = 20;
|
||||
|
||||
export const PaginationQuery = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(PAGINATION_MAX).default(PAGINATION_DEFAULT),
|
||||
});
|
||||
export type PaginationQuery = z.infer<typeof PaginationQuery>;
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BulkPartsRequest, CreatePartRequest, PartListQuery, UpdatePartRequest } from './parts.js';
|
||||
|
||||
const mfgId = '11111111-1111-4111-8111-111111111111';
|
||||
const binId = '22222222-2222-4222-8222-222222222222';
|
||||
|
||||
describe('CreatePartRequest', () => {
|
||||
it('accepts a minimal valid payload', () => {
|
||||
const r = CreatePartRequest.parse({
|
||||
serialNumber: 'SN-1',
|
||||
mpn: 'MPN-1',
|
||||
manufacturerId: mfgId,
|
||||
});
|
||||
expect(r.serialNumber).toBe('SN-1');
|
||||
});
|
||||
|
||||
it('rejects empty serial / mpn', () => {
|
||||
expect(
|
||||
CreatePartRequest.safeParse({ serialNumber: '', mpn: 'X', manufacturerId: mfgId }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
CreatePartRequest.safeParse({ serialNumber: 'X', mpn: '', manufacturerId: mfgId }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects negative price', () => {
|
||||
const res = CreatePartRequest.safeParse({
|
||||
serialNumber: 'X',
|
||||
mpn: 'Y',
|
||||
manufacturerId: mfgId,
|
||||
price: -1,
|
||||
});
|
||||
expect(res.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-uuid manufacturer id', () => {
|
||||
expect(
|
||||
CreatePartRequest.safeParse({ serialNumber: 'X', mpn: 'Y', manufacturerId: 'not-uuid' })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('caps tagIds at 32', () => {
|
||||
const tagIds = Array.from({ length: 33 }, () => '33333333-3333-4333-8333-333333333333');
|
||||
expect(
|
||||
CreatePartRequest.safeParse({
|
||||
serialNumber: 'X',
|
||||
mpn: 'Y',
|
||||
manufacturerId: mfgId,
|
||||
tagIds,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdatePartRequest', () => {
|
||||
it('requires at least one field', () => {
|
||||
expect(UpdatePartRequest.safeParse({}).success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a single field', () => {
|
||||
expect(UpdatePartRequest.safeParse({ notes: 'hi' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('permits nullable binId to clear location', () => {
|
||||
const r = UpdatePartRequest.parse({ binId: null });
|
||||
expect(r.binId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PartListQuery', () => {
|
||||
it('defaults page=1, pageSize=20', () => {
|
||||
const r = PartListQuery.parse({});
|
||||
expect(r.page).toBe(1);
|
||||
expect(r.pageSize).toBe(20);
|
||||
});
|
||||
|
||||
it('coerces string numbers from query strings', () => {
|
||||
const r = PartListQuery.parse({ page: '3', pageSize: '50' });
|
||||
expect(r.page).toBe(3);
|
||||
expect(r.pageSize).toBe(50);
|
||||
});
|
||||
|
||||
it('clamps pageSize to the 100 max', () => {
|
||||
expect(PartListQuery.safeParse({ pageSize: '500' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('parses eolOnly from string and boolean', () => {
|
||||
expect(PartListQuery.parse({ eolOnly: 'true' }).eolOnly).toBe(true);
|
||||
expect(PartListQuery.parse({ eolOnly: 'false' }).eolOnly).toBe(false);
|
||||
expect(PartListQuery.parse({ eolOnly: true }).eolOnly).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BulkPartsRequest', () => {
|
||||
it('requires at least one mutation field', () => {
|
||||
expect(BulkPartsRequest.safeParse({ ids: [mfgId] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts state mutation', () => {
|
||||
expect(BulkPartsRequest.safeParse({ ids: [mfgId], state: 'SPARE' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts binId=null to unassign', () => {
|
||||
const r = BulkPartsRequest.parse({ ids: [mfgId], binId: null });
|
||||
expect(r.binId).toBeNull();
|
||||
});
|
||||
|
||||
it('caps ids at 500', () => {
|
||||
const ids = Array.from({ length: 501 }, () => binId);
|
||||
expect(BulkPartsRequest.safeParse({ ids, state: 'SPARE' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { z } from 'zod';
|
||||
import { PartState } from './enums.js';
|
||||
import { PaginationQuery } from './pagination.js';
|
||||
|
||||
export const CreatePartRequest = z.object({
|
||||
serialNumber: z.string().min(1).max(128),
|
||||
mpn: z.string().min(1).max(128),
|
||||
manufacturerId: z.string().uuid(),
|
||||
price: z.number().nonnegative().optional().nullable(),
|
||||
state: PartState.optional(),
|
||||
binId: z.string().uuid().optional().nullable(),
|
||||
notes: z.string().max(4096).optional().nullable(),
|
||||
categoryId: z.string().uuid().optional().nullable(),
|
||||
replacementPartId: z.string().uuid().optional().nullable(),
|
||||
tagIds: z.array(z.string().uuid()).max(32).optional(),
|
||||
});
|
||||
export type CreatePartRequest = z.infer<typeof CreatePartRequest>;
|
||||
|
||||
export const UpdatePartRequest = z
|
||||
.object({
|
||||
serialNumber: z.string().min(1).max(128).optional(),
|
||||
mpn: z.string().min(1).max(128).optional(),
|
||||
manufacturerId: z.string().uuid().optional(),
|
||||
price: z.number().nonnegative().nullable().optional(),
|
||||
state: PartState.optional(),
|
||||
binId: z.string().uuid().nullable().optional(),
|
||||
notes: z.string().max(4096).nullable().optional(),
|
||||
categoryId: z.string().uuid().nullable().optional(),
|
||||
replacementPartId: z.string().uuid().nullable().optional(),
|
||||
tagIds: z.array(z.string().uuid()).max(32).optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||
export type UpdatePartRequest = z.infer<typeof UpdatePartRequest>;
|
||||
|
||||
export const PartListQuery = PaginationQuery.extend({
|
||||
state: PartState.optional(),
|
||||
binId: z.string().uuid().optional(),
|
||||
manufacturerId: z.string().uuid().optional(),
|
||||
mpn: z.string().max(128).optional(),
|
||||
serialNumber: z.string().max(128).optional(),
|
||||
q: z.string().max(128).optional(),
|
||||
categoryId: z.string().uuid().optional(),
|
||||
tagId: z.string().uuid().optional(),
|
||||
eolOnly: z
|
||||
.union([z.literal('true'), z.literal('false'), z.boolean()])
|
||||
.transform((v) => v === true || v === 'true')
|
||||
.optional(),
|
||||
});
|
||||
export type PartListQuery = z.infer<typeof PartListQuery>;
|
||||
|
||||
export const PartEventsQuery = PaginationQuery;
|
||||
export type PartEventsQuery = z.infer<typeof PartEventsQuery>;
|
||||
|
||||
export const BulkPartsRequest = z
|
||||
.object({
|
||||
ids: z.array(z.string().uuid()).min(1).max(500),
|
||||
state: PartState.optional(),
|
||||
binId: z.string().uuid().nullable().optional(),
|
||||
addTagIds: z.array(z.string().uuid()).max(32).optional(),
|
||||
removeTagIds: z.array(z.string().uuid()).max(32).optional(),
|
||||
})
|
||||
.refine(
|
||||
(v) =>
|
||||
v.state !== undefined ||
|
||||
v.binId !== undefined ||
|
||||
(v.addTagIds && v.addTagIds.length > 0) ||
|
||||
(v.removeTagIds && v.removeTagIds.length > 0),
|
||||
{ message: 'At least one mutation field is required' },
|
||||
);
|
||||
export type BulkPartsRequest = z.infer<typeof BulkPartsRequest>;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from 'zod';
|
||||
import { RepairStatus } from './enums.js';
|
||||
import { PaginationQuery } from './pagination.js';
|
||||
|
||||
export const CreateRepairJobRequest = z.object({
|
||||
partId: z.string().uuid(),
|
||||
hostId: z.string().uuid().optional().nullable(),
|
||||
assigneeId: z.string().uuid().optional().nullable(),
|
||||
notes: z.string().max(4096).optional().nullable(),
|
||||
});
|
||||
export type CreateRepairJobRequest = z.infer<typeof CreateRepairJobRequest>;
|
||||
|
||||
export const UpdateRepairJobRequest = z
|
||||
.object({
|
||||
status: RepairStatus.optional(),
|
||||
hostId: z.string().uuid().nullable().optional(),
|
||||
assigneeId: z.string().uuid().nullable().optional(),
|
||||
notes: z.string().max(4096).nullable().optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||
export type UpdateRepairJobRequest = z.infer<typeof UpdateRepairJobRequest>;
|
||||
|
||||
export const RepairJobListQuery = PaginationQuery.extend({
|
||||
status: RepairStatus.optional(),
|
||||
partId: z.string().uuid().optional(),
|
||||
hostId: z.string().uuid().optional(),
|
||||
assigneeId: z.string().uuid().optional(),
|
||||
openOnly: z
|
||||
.union([z.literal('true'), z.literal('false'), z.boolean()])
|
||||
.transform((v) => v === true || v === 'true')
|
||||
.optional(),
|
||||
});
|
||||
export type RepairJobListQuery = z.infer<typeof RepairJobListQuery>;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { z } from 'zod';
|
||||
import { PaginationQuery } from './pagination.js';
|
||||
|
||||
export const SavedViewResource = z.enum(['parts', 'repairs', 'hosts', 'manufacturers']);
|
||||
export type SavedViewResource = z.infer<typeof SavedViewResource>;
|
||||
|
||||
// filterJson is stored as a JSON string in the DB. API accepts/returns a structured object.
|
||||
export const SavedViewFilter = z
|
||||
.object({
|
||||
filters: z.record(z.unknown()).optional(),
|
||||
sort: z
|
||||
.object({
|
||||
field: z.string().max(64),
|
||||
direction: z.enum(['asc', 'desc']),
|
||||
})
|
||||
.optional(),
|
||||
columns: z.array(z.string().max(64)).optional(),
|
||||
search: z.string().max(128).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
export type SavedViewFilter = z.infer<typeof SavedViewFilter>;
|
||||
|
||||
export const CreateSavedViewRequest = z.object({
|
||||
resource: SavedViewResource,
|
||||
name: z.string().min(1).max(64),
|
||||
filter: SavedViewFilter,
|
||||
});
|
||||
export type CreateSavedViewRequest = z.infer<typeof CreateSavedViewRequest>;
|
||||
|
||||
export const UpdateSavedViewRequest = z
|
||||
.object({
|
||||
name: z.string().min(1).max(64).optional(),
|
||||
filter: SavedViewFilter.optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||
export type UpdateSavedViewRequest = z.infer<typeof UpdateSavedViewRequest>;
|
||||
|
||||
export const SavedViewListQuery = PaginationQuery.extend({
|
||||
resource: SavedViewResource.optional(),
|
||||
});
|
||||
export type SavedViewListQuery = z.infer<typeof SavedViewListQuery>;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { z } from 'zod';
|
||||
import { PaginationQuery } from './pagination.js';
|
||||
|
||||
const HexColor = z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/, 'Expected hex color like #rrggbb');
|
||||
|
||||
export const CreateTagRequest = z.object({
|
||||
name: z.string().min(1).max(64),
|
||||
color: HexColor.optional().nullable(),
|
||||
});
|
||||
export type CreateTagRequest = z.infer<typeof CreateTagRequest>;
|
||||
|
||||
export const UpdateTagRequest = z
|
||||
.object({
|
||||
name: z.string().min(1).max(64).optional(),
|
||||
color: HexColor.nullable().optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||
export type UpdateTagRequest = z.infer<typeof UpdateTagRequest>;
|
||||
|
||||
export const TagListQuery = PaginationQuery.extend({
|
||||
q: z.string().max(64).optional(),
|
||||
});
|
||||
export type TagListQuery = z.infer<typeof TagListQuery>;
|
||||
|
||||
export const AssignTagsRequest = z.object({
|
||||
tagIds: z.array(z.string().uuid()).min(1).max(32),
|
||||
});
|
||||
export type AssignTagsRequest = z.infer<typeof AssignTagsRequest>;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from 'zod';
|
||||
import { Role } from './enums.js';
|
||||
|
||||
export const CreateUserRequest = z.object({
|
||||
username: z.string().min(1).max(64),
|
||||
email: z.string().email().max(256),
|
||||
password: z.string().min(6).max(256),
|
||||
role: Role.optional(),
|
||||
});
|
||||
export type CreateUserRequest = z.infer<typeof CreateUserRequest>;
|
||||
|
||||
export const UpdateUserRequest = z
|
||||
.object({
|
||||
username: z.string().min(1).max(64).optional(),
|
||||
email: z.string().email().max(256).optional(),
|
||||
password: z.string().min(6).max(256).optional(),
|
||||
role: Role.optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||
export type UpdateUserRequest = z.infer<typeof UpdateUserRequest>;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CreateWebhookSubscriptionRequest,
|
||||
UpdateWebhookSubscriptionRequest,
|
||||
WebhookSubscriptionListQuery,
|
||||
} from './webhooks.js';
|
||||
|
||||
describe('CreateWebhookSubscriptionRequest', () => {
|
||||
it('accepts a minimal valid payload', () => {
|
||||
const r = CreateWebhookSubscriptionRequest.parse({
|
||||
url: 'https://receiver.example.com/hook',
|
||||
events: ['part.created'],
|
||||
});
|
||||
expect(r.active).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects non-URL endpoints', () => {
|
||||
expect(
|
||||
CreateWebhookSubscriptionRequest.safeParse({
|
||||
url: 'not-a-url',
|
||||
events: ['part.created'],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('requires at least one event', () => {
|
||||
expect(
|
||||
CreateWebhookSubscriptionRequest.safeParse({ url: 'https://a.b/c', events: [] }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects unknown event names', () => {
|
||||
expect(
|
||||
CreateWebhookSubscriptionRequest.safeParse({
|
||||
url: 'https://a.b/c',
|
||||
events: ['part.exploded'],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateWebhookSubscriptionRequest', () => {
|
||||
it('requires at least one field', () => {
|
||||
expect(UpdateWebhookSubscriptionRequest.safeParse({}).success).toBe(false);
|
||||
});
|
||||
|
||||
it('allows toggling active alone', () => {
|
||||
expect(UpdateWebhookSubscriptionRequest.safeParse({ active: false }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WebhookSubscriptionListQuery', () => {
|
||||
it('normalizes active=\"true\" to boolean', () => {
|
||||
const r = WebhookSubscriptionListQuery.parse({ active: 'true' });
|
||||
expect(r.active).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes active=\"false\" to boolean', () => {
|
||||
const r = WebhookSubscriptionListQuery.parse({ active: 'false' });
|
||||
expect(r.active).toBe(false);
|
||||
});
|
||||
|
||||
it('omits active when unset', () => {
|
||||
const r = WebhookSubscriptionListQuery.parse({});
|
||||
expect(r.active).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from 'zod';
|
||||
import { WebhookEventName } from './enums.js';
|
||||
import { PaginationQuery } from './pagination.js';
|
||||
|
||||
export const CreateWebhookSubscriptionRequest = z.object({
|
||||
url: z.string().url().max(2048),
|
||||
events: z.array(WebhookEventName).min(1),
|
||||
active: z.boolean().optional(),
|
||||
});
|
||||
export type CreateWebhookSubscriptionRequest = z.infer<typeof CreateWebhookSubscriptionRequest>;
|
||||
|
||||
export const UpdateWebhookSubscriptionRequest = z
|
||||
.object({
|
||||
url: z.string().url().max(2048).optional(),
|
||||
events: z.array(WebhookEventName).min(1).optional(),
|
||||
active: z.boolean().optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||
export type UpdateWebhookSubscriptionRequest = z.infer<typeof UpdateWebhookSubscriptionRequest>;
|
||||
|
||||
export const WebhookSubscriptionListQuery = PaginationQuery.extend({
|
||||
active: z
|
||||
.union([z.literal('true'), z.literal('false'), z.boolean()])
|
||||
.transform((v) => v === true || v === 'true')
|
||||
.optional(),
|
||||
});
|
||||
export type WebhookSubscriptionListQuery = z.infer<typeof WebhookSubscriptionListQuery>;
|
||||
Reference in New Issue
Block a user