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
+7
View File
@@ -0,0 +1,7 @@
import type { DashboardAnalytics } from '@vector/shared';
import { api } from './client.js';
export async function getDashboardAnalytics(): Promise<DashboardAnalytics> {
const res = await api.get<DashboardAnalytics>('/analytics/dashboard');
return res.data;
}
+27
View File
@@ -0,0 +1,27 @@
import { api } from './client.js';
import type { Role } from '@vector/shared';
export interface AuthUser {
id: string;
username: string;
email: string;
role: Role;
}
export async function login(username: string, password: string): Promise<AuthUser> {
const res = await api.post<AuthUser>('/auth/login', { username, password });
return res.data;
}
export async function logout(): Promise<void> {
await api.post('/auth/logout');
}
export async function refresh(): Promise<void> {
await api.post('/auth/refresh');
}
export async function me(): Promise<AuthUser> {
const res = await api.get<AuthUser>('/auth/me');
return res.data;
}
+24
View File
@@ -0,0 +1,24 @@
import type { CreateBinRequest, UpdateBinRequest } from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { Bin, BinWithPath } from './types.js';
export function listBins(
filters: { page?: number; pageSize?: number; roomId?: string; siteId?: string } = {},
) {
return getList<BinWithPath>('/bins', filters);
}
export async function createBin(input: CreateBinRequest): Promise<BinWithPath> {
const res = await api.post<BinWithPath>('/bins', input);
return res.data;
}
export async function updateBin(id: string, input: UpdateBinRequest): Promise<Bin> {
const res = await api.patch<Bin>(`/bins/${id}`, input);
return res.data;
}
export async function deleteBin(id: string): Promise<void> {
await api.delete(`/bins/${id}`);
}
+11
View File
@@ -0,0 +1,11 @@
import type { BulkPartsRequest } from '@vector/shared';
import { api } from './client.js';
export interface BulkPartsResult {
updated: number;
}
export async function bulkUpdateParts(input: BulkPartsRequest): Promise<BulkPartsResult> {
const res = await api.post<BulkPartsResult>('/parts/bulk', input);
return res.data;
}
+25
View File
@@ -0,0 +1,25 @@
import type { CreateCategoryRequest, UpdateCategoryRequest } from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { Category } from './types.js';
export function listCategories(filters: { page?: number; pageSize?: number } = {}) {
return getList<Category>('/categories', filters);
}
export async function createCategory(input: CreateCategoryRequest): Promise<Category> {
const res = await api.post<Category>('/categories', input);
return res.data;
}
export async function updateCategory(
id: string,
input: UpdateCategoryRequest,
): Promise<Category> {
const res = await api.patch<Category>(`/categories/${id}`, input);
return res.data;
}
export async function deleteCategory(id: string): Promise<void> {
await api.delete(`/categories/${id}`);
}
+95
View File
@@ -0,0 +1,95 @@
import axios, { AxiosError, AxiosHeaders, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios';
// Read a non-httpOnly cookie by name. The API sets `csrf` path=/ so we can always grab it.
function readCookie(name: string): string | null {
const prefix = `${name}=`;
for (const part of document.cookie.split(';')) {
const v = part.trim();
if (v.startsWith(prefix)) return decodeURIComponent(v.slice(prefix.length));
}
return null;
}
export interface ApiError {
code: string;
message: string;
requestId?: string;
details?: unknown;
}
export class ApiRequestError extends Error {
constructor(
public readonly status: number,
public readonly body: ApiError,
) {
super(body.message);
this.name = 'ApiRequestError';
}
}
export const api = axios.create({
baseURL: '/api',
withCredentials: true,
});
// Attach CSRF token to mutating requests. GET/HEAD/OPTIONS skip this.
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const method = (config.method ?? 'get').toLowerCase();
if (!['get', 'head', 'options'].includes(method)) {
const token = readCookie('csrf');
if (token) {
const headers = config.headers ?? new AxiosHeaders();
if (headers instanceof AxiosHeaders) headers.set('X-CSRF-Token', token);
else (headers as Record<string, string>)['X-CSRF-Token'] = token;
config.headers = headers;
}
}
return config;
});
// Refresh-token rotation. If a protected call returns 401, attempt one refresh, then retry.
// A single in-flight refresh is shared across concurrent 401s to avoid thrashing the server.
let refreshInflight: Promise<void> | null = null;
const AUTH_UNPROTECTED = ['/auth/login', '/auth/refresh', '/auth/logout'];
function refreshOnce(): Promise<void> {
if (!refreshInflight) {
refreshInflight = axios
.post('/api/auth/refresh', null, { withCredentials: true })
.then(() => undefined)
.finally(() => {
refreshInflight = null;
});
}
return refreshInflight;
}
type RetryableConfig = AxiosRequestConfig & { _retry?: boolean };
api.interceptors.response.use(
(res) => res,
async (error: AxiosError<ApiError>) => {
const original = error.config as RetryableConfig | undefined;
const status = error.response?.status;
const body = error.response?.data;
const url = original?.url ?? '';
// Never try to refresh the unauthenticated auth endpoints themselves.
const isAuthUnprotected = AUTH_UNPROTECTED.some((p) => url.endsWith(p));
if (status === 401 && !isAuthUnprotected && original && !original._retry) {
original._retry = true;
try {
await refreshOnce();
return api.request(original);
} catch {
// Refresh failed — fall through to reject with the original error.
}
}
if (body && typeof body === 'object' && 'code' in body && 'message' in body) {
return Promise.reject(new ApiRequestError(status ?? 0, body));
}
return Promise.reject(error);
},
);
+33
View File
@@ -0,0 +1,33 @@
import type { CreateHostRequest, UpdateHostRequest } from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { Host } from './types.js';
export type HostListFilters = {
page?: number;
pageSize?: number;
q?: string;
};
export function listHosts(filters: HostListFilters = {}) {
return getList<Host>('/hosts', filters);
}
export async function getHost(id: string): Promise<Host> {
const res = await api.get<Host>(`/hosts/${id}`);
return res.data;
}
export async function createHost(input: CreateHostRequest): Promise<Host> {
const res = await api.post<Host>('/hosts', input);
return res.data;
}
export async function updateHost(id: string, input: UpdateHostRequest): Promise<Host> {
const res = await api.patch<Host>(`/hosts/${id}`, input);
return res.data;
}
export async function deleteHost(id: string): Promise<void> {
await api.delete(`/hosts/${id}`);
}
+33
View File
@@ -0,0 +1,33 @@
import type {
CreateManufacturerRequest,
UpdateManufacturerRequest,
} from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { Manufacturer } from './types.js';
export type ManufacturerListFilters = {
page?: number;
pageSize?: number;
};
export function listManufacturers(filters: ManufacturerListFilters = {}) {
return getList<Manufacturer>('/manufacturers', filters);
}
export async function createManufacturer(input: CreateManufacturerRequest): Promise<Manufacturer> {
const res = await api.post<Manufacturer>('/manufacturers', input);
return res.data;
}
export async function updateManufacturer(
id: string,
input: UpdateManufacturerRequest,
): Promise<Manufacturer> {
const res = await api.patch<Manufacturer>(`/manufacturers/${id}`, input);
return res.data;
}
export async function deleteManufacturer(id: string): Promise<void> {
await api.delete(`/manufacturers/${id}`);
}
+21
View File
@@ -0,0 +1,21 @@
import type { PaginatedResponse } from '@vector/shared';
import { api } from './client.js';
// Minimal helper: turn a filter object into a query-string payload, skipping undefined/null/''
// and coercing booleans/numbers cleanly. Reuse across resource fetchers to keep them tiny.
export function toQueryParams(filters: Record<string, unknown> = {}): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(filters)) {
if (v === undefined || v === null || v === '') continue;
out[k] = String(v);
}
return out;
}
export async function getList<T>(
path: string,
filters: Record<string, unknown> = {},
): Promise<PaginatedResponse<T>> {
const res = await api.get<PaginatedResponse<T>>(path, { params: toQueryParams(filters) });
return res.data;
}
+55
View File
@@ -0,0 +1,55 @@
import type {
CreatePartRequest,
PaginatedResponse,
UpdatePartRequest,
} from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { Part, PartEvent } from './types.js';
export type PartListFilters = {
page?: number;
pageSize?: number;
sort?: string;
q?: string;
state?: string;
manufacturerId?: string;
categoryId?: string;
binId?: string;
tagId?: string;
eolOnly?: boolean;
};
export function listParts(filters: PartListFilters) {
return getList<Part>('/parts', filters);
}
export async function getPart(id: string): Promise<Part> {
const res = await api.get<Part>(`/parts/${id}`);
return res.data;
}
export async function createPart(input: CreatePartRequest): Promise<Part> {
const res = await api.post<Part>('/parts', input);
return res.data;
}
export async function updatePart(id: string, input: UpdatePartRequest): Promise<Part> {
const res = await api.patch<Part>(`/parts/${id}`, input);
return res.data;
}
export async function deletePart(id: string): Promise<void> {
await api.delete(`/parts/${id}`);
}
export async function listPartEvents(
partId: string,
page = 1,
pageSize = 20,
): Promise<PaginatedResponse<PartEvent>> {
const res = await api.get<PaginatedResponse<PartEvent>>(`/parts/${partId}/events`, {
params: { page, pageSize },
});
return res.data;
}
+49
View File
@@ -0,0 +1,49 @@
import type {
CreateRepairJobRequest,
RepairStatus,
UpdateRepairJobRequest,
} from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { RepairJob } from './types.js';
export type RepairListFilters = {
page?: number;
pageSize?: number;
status?: RepairStatus;
partId?: string;
hostId?: string;
assigneeId?: string;
openOnly?: boolean;
};
export function listRepairs(filters: RepairListFilters = {}) {
return getList<RepairJob>('/repairs', filters);
}
export async function getRepair(id: string): Promise<RepairJob> {
const res = await api.get<RepairJob>(`/repairs/${id}`);
return res.data;
}
export async function listRepairsForPart(partId: string): Promise<RepairJob[]> {
const res = await api.get<RepairJob[]>(`/parts/${partId}/repairs`);
return res.data;
}
export async function createRepair(input: CreateRepairJobRequest): Promise<RepairJob> {
const res = await api.post<RepairJob>('/repairs', input);
return res.data;
}
export async function updateRepair(
id: string,
input: UpdateRepairJobRequest,
): Promise<RepairJob> {
const res = await api.patch<RepairJob>(`/repairs/${id}`, input);
return res.data;
}
export async function deleteRepair(id: string): Promise<void> {
await api.delete(`/repairs/${id}`);
}
+22
View File
@@ -0,0 +1,22 @@
import type { CreateRoomRequest, UpdateRoomRequest } from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { Room } from './types.js';
export function listRooms(filters: { page?: number; pageSize?: number; siteId?: string } = {}) {
return getList<Room>('/rooms', filters);
}
export async function createRoom(input: CreateRoomRequest): Promise<Room> {
const res = await api.post<Room>('/rooms', input);
return res.data;
}
export async function updateRoom(id: string, input: UpdateRoomRequest): Promise<Room> {
const res = await api.patch<Room>(`/rooms/${id}`, input);
return res.data;
}
export async function deleteRoom(id: string): Promise<void> {
await api.delete(`/rooms/${id}`);
}
+29
View File
@@ -0,0 +1,29 @@
import type {
CreateSavedViewRequest,
SavedViewResource,
UpdateSavedViewRequest,
} from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { SavedView } from './types.js';
export function listSavedViews(resource: SavedViewResource) {
return getList<SavedView>('/saved-views', { resource });
}
export async function createSavedView(input: CreateSavedViewRequest): Promise<SavedView> {
const res = await api.post<SavedView>('/saved-views', input);
return res.data;
}
export async function updateSavedView(
id: string,
input: UpdateSavedViewRequest,
): Promise<SavedView> {
const res = await api.patch<SavedView>(`/saved-views/${id}`, input);
return res.data;
}
export async function deleteSavedView(id: string): Promise<void> {
await api.delete(`/saved-views/${id}`);
}
+22
View File
@@ -0,0 +1,22 @@
import type { CreateSiteRequest, UpdateSiteRequest } from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { Site } from './types.js';
export function listSites(filters: { page?: number; pageSize?: number } = {}) {
return getList<Site>('/sites', filters);
}
export async function createSite(input: CreateSiteRequest): Promise<Site> {
const res = await api.post<Site>('/sites', input);
return res.data;
}
export async function updateSite(id: string, input: UpdateSiteRequest): Promise<Site> {
const res = await api.patch<Site>(`/sites/${id}`, input);
return res.data;
}
export async function deleteSite(id: string): Promise<void> {
await api.delete(`/sites/${id}`);
}
+43
View File
@@ -0,0 +1,43 @@
import type { CreateTagRequest, UpdateTagRequest } from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { Tag } from './types.js';
export type TagListFilters = {
page?: number;
pageSize?: number;
q?: string;
};
export function listTags(filters: TagListFilters = {}) {
return getList<Tag>('/tags', filters);
}
export async function createTag(input: CreateTagRequest): Promise<Tag> {
const res = await api.post<Tag>('/tags', input);
return res.data;
}
export async function updateTag(id: string, input: UpdateTagRequest): Promise<Tag> {
const res = await api.patch<Tag>(`/tags/${id}`, input);
return res.data;
}
export async function deleteTag(id: string): Promise<void> {
await api.delete(`/tags/${id}`);
}
export async function listTagsForPart(partId: string): Promise<Tag[]> {
const res = await api.get<Tag[]>(`/parts/${partId}/tags`);
return res.data;
}
export async function assignTagsToPart(partId: string, tagIds: string[]): Promise<Tag[]> {
const res = await api.post<Tag[]>(`/parts/${partId}/tags`, { tagIds });
return res.data;
}
export async function unassignTagFromPart(partId: string, tagId: string): Promise<Tag[]> {
const res = await api.delete<Tag[]>(`/parts/${partId}/tags/${tagId}`);
return res.data;
}
+128
View File
@@ -0,0 +1,128 @@
import type { PartEventType, PartState, RepairStatus, Role } from '@vector/shared';
// Shapes mirror Prisma rows the API returns (dates serialized as ISO strings).
// Keep these in sync with apps/api/src/services responses.
export interface Manufacturer {
id: string;
name: string;
eolDate: string | null;
createdAt: string;
updatedAt: string;
}
export interface Site {
id: string;
name: string;
createdAt: string;
updatedAt: string;
}
export interface Room {
id: string;
name: string;
siteId: string;
createdAt: string;
updatedAt: string;
}
export interface Bin {
id: string;
name: string;
roomId: string;
createdAt: string;
updatedAt: string;
}
export interface BinWithPath extends Bin {
room: Room & { site: Site };
fullPath?: string;
}
export interface Part {
id: string;
serialNumber: string;
mpn: string;
manufacturerId: string;
price: number | null;
state: PartState;
binId: string | null;
categoryId: string | null;
replacementPartId: string | null;
notes: string | null;
createdAt: string;
updatedAt: string;
manufacturer: Manufacturer;
bin: BinWithPath | null;
}
export interface PartEvent {
id: string;
partId: string;
userId: string | null;
type: PartEventType;
field: string | null;
oldValue: string | null;
newValue: string | null;
createdAt: string;
user: { username: string } | null;
}
export interface User {
id: string;
username: string;
email: string;
role: Role;
createdAt: string;
updatedAt: string;
}
export interface Host {
id: string;
name: string;
location: string | null;
notes: string | null;
createdAt: string;
updatedAt: string;
}
export interface Tag {
id: string;
name: string;
color: string | null;
createdAt: string;
updatedAt: string;
}
export interface Category {
id: string;
name: string;
createdAt: string;
updatedAt: string;
}
export interface RepairJob {
id: string;
partId: string;
hostId: string | null;
assigneeId: string | null;
status: RepairStatus;
notes: string | null;
openedAt: string;
closedAt: string | null;
createdAt: string;
updatedAt: string;
part: Part;
host: Host | null;
assignee: Pick<User, 'id' | 'username' | 'email' | 'role'> | null;
}
export interface SavedView {
id: string;
userId: string;
resource: 'parts' | 'repairs' | 'hosts' | 'manufacturers';
name: string;
filterJson: unknown;
createdAt: string;
updatedAt: string;
}
+22
View File
@@ -0,0 +1,22 @@
import type { CreateUserRequest, UpdateUserRequest } from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { User } from './types.js';
export function listUsers(filters: { page?: number; pageSize?: number } = {}) {
return getList<User>('/users', filters);
}
export async function createUser(input: CreateUserRequest): Promise<User> {
const res = await api.post<User>('/users', input);
return res.data;
}
export async function updateUser(id: string, input: UpdateUserRequest): Promise<User> {
const res = await api.patch<User>(`/users/${id}`, input);
return res.data;
}
export async function deleteUser(id: string): Promise<void> {
await api.delete(`/users/${id}`);
}
+51
View File
@@ -0,0 +1,51 @@
import type {
CreateWebhookSubscriptionRequest,
UpdateWebhookSubscriptionRequest,
WebhookEventName,
} from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
export interface WebhookSubscription {
id: string;
url: string;
events: WebhookEventName[];
active: boolean;
createdAt: string;
updatedAt: string;
secret?: string;
}
export type WebhookListFilters = {
page?: number;
pageSize?: number;
active?: boolean;
};
export function listWebhooks(filters: WebhookListFilters = {}) {
return getList<WebhookSubscription>('/admin/webhooks', filters);
}
export async function createWebhook(
input: CreateWebhookSubscriptionRequest,
): Promise<WebhookSubscription> {
const res = await api.post<WebhookSubscription>('/admin/webhooks', input);
return res.data;
}
export async function updateWebhook(
id: string,
input: UpdateWebhookSubscriptionRequest,
): Promise<WebhookSubscription> {
const res = await api.patch<WebhookSubscription>(`/admin/webhooks/${id}`, input);
return res.data;
}
export async function deleteWebhook(id: string): Promise<void> {
await api.delete(`/admin/webhooks/${id}`);
}
export async function rotateWebhookSecret(id: string): Promise<WebhookSubscription> {
const res = await api.post<WebhookSubscription>(`/admin/webhooks/${id}/rotate-secret`);
return res.data;
}
+80
View File
@@ -0,0 +1,80 @@
// Hierarchical query keys for TanStack Query. Consumers should import this factory rather than
// hard-coding tuples inline so invalidations can be surgical (e.g. invalidate everything under
// `parts.list()` without touching `parts.detail(id)`).
export const queryKeys = {
auth: {
all: ['auth'] as const,
me: () => [...queryKeys.auth.all, 'me'] as const,
},
parts: {
all: ['parts'] as const,
list: (filters?: Record<string, unknown>) =>
[...queryKeys.parts.all, 'list', filters ?? {}] as const,
detail: (id: string) => [...queryKeys.parts.all, 'detail', id] as const,
events: (id: string, filters?: Record<string, unknown>) =>
[...queryKeys.parts.all, 'events', id, filters ?? {}] as const,
},
manufacturers: {
all: ['manufacturers'] as const,
list: (filters?: Record<string, unknown>) =>
[...queryKeys.manufacturers.all, 'list', filters ?? {}] as const,
detail: (id: string) => [...queryKeys.manufacturers.all, 'detail', id] as const,
},
sites: {
all: ['sites'] as const,
list: (filters?: Record<string, unknown>) =>
[...queryKeys.sites.all, 'list', filters ?? {}] as const,
detail: (id: string) => [...queryKeys.sites.all, 'detail', id] as const,
},
rooms: {
all: ['rooms'] as const,
list: (filters?: Record<string, unknown>) =>
[...queryKeys.rooms.all, 'list', filters ?? {}] as const,
},
bins: {
all: ['bins'] as const,
list: (filters?: Record<string, unknown>) =>
[...queryKeys.bins.all, 'list', filters ?? {}] as const,
},
users: {
all: ['users'] as const,
list: (filters?: Record<string, unknown>) =>
[...queryKeys.users.all, 'list', filters ?? {}] as const,
},
hosts: {
all: ['hosts'] as const,
list: (filters?: Record<string, unknown>) =>
[...queryKeys.hosts.all, 'list', filters ?? {}] as const,
detail: (id: string) => [...queryKeys.hosts.all, 'detail', id] as const,
},
repairs: {
all: ['repairs'] as const,
list: (filters?: Record<string, unknown>) =>
[...queryKeys.repairs.all, 'list', filters ?? {}] as const,
detail: (id: string) => [...queryKeys.repairs.all, 'detail', id] as const,
},
tags: {
all: ['tags'] as const,
list: (filters?: Record<string, unknown>) =>
[...queryKeys.tags.all, 'list', filters ?? {}] as const,
},
categories: {
all: ['categories'] as const,
list: (filters?: Record<string, unknown>) =>
[...queryKeys.categories.all, 'list', filters ?? {}] as const,
},
webhooks: {
all: ['webhooks'] as const,
list: (filters?: Record<string, unknown>) =>
[...queryKeys.webhooks.all, 'list', filters ?? {}] as const,
},
savedViews: {
all: ['savedViews'] as const,
list: (resource: string) => [...queryKeys.savedViews.all, resource] as const,
},
analytics: {
all: ['analytics'] as const,
dashboard: () => [...queryKeys.analytics.all, 'dashboard'] as const,
},
} as const;