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,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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user