chore: initial Vector 2.0 monorepo
CI / Lint · Typecheck · Test · Build (push) Failing after 5m41s
CI / Playwright (smoke) (push) Has been skipped

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:
2026-04-16 20:52:32 -04:00
commit 7c0d422228
216 changed files with 19393 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@vector/shared",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc -p tsconfig.json --noEmit",
"clean": "rimraf dist .turbo"
},
"dependencies": {
"zod": "^3.24.1"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@vector/config": "workspace:*",
"typescript": "^5.7.2",
"vitest": "^4.1.4"
}
}
+35
View File
@@ -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;
}
+47
View File
@@ -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);
});
});
+17
View File
@@ -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>;
+19
View File
@@ -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>;
+28
View File
@@ -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>;
+46
View File
@@ -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>;
+17
View File
@@ -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>;
+23
View File
@@ -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>;
+16
View File
@@ -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';
+35
View File
@@ -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>;
+19
View File
@@ -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>;
+17
View File
@@ -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;
}
+113
View File
@@ -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);
});
});
+70
View File
@@ -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>;
+33
View File
@@ -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>;
+41
View File
@@ -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>;
+30
View File
@@ -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>;
+20
View File
@@ -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>;
+67
View File
@@ -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();
});
});
+27
View File
@@ -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>;
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@vector/config/tsconfig/node.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["dist", "node_modules"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
environment: 'node',
},
});