feat: backend MVP (auth/rbac, catalog, orders, fulfillment, invoices) + specs + scaffolds
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
import type {
|
||||
Address,
|
||||
AuthTokens,
|
||||
Cart,
|
||||
Category,
|
||||
Currency,
|
||||
Invoice,
|
||||
InvoiceKind,
|
||||
LocalizedText,
|
||||
Order,
|
||||
OrderStatus,
|
||||
Paged,
|
||||
Product,
|
||||
ProductStatus,
|
||||
Shipment,
|
||||
Shop,
|
||||
Sku,
|
||||
User,
|
||||
} from "./types";
|
||||
|
||||
export interface ApiClientOptions {
|
||||
baseUrl: string;
|
||||
getToken?: () => string | null;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
code: string;
|
||||
status: number;
|
||||
constructor(status: number, code: string, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- request bodies ----
|
||||
|
||||
export interface ProductListQuery {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
category_id?: string;
|
||||
q?: string;
|
||||
shop_id?: string;
|
||||
}
|
||||
|
||||
export interface ProductUpsertBody {
|
||||
category_id?: string | null;
|
||||
slug: string;
|
||||
name: LocalizedText;
|
||||
description?: LocalizedText;
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
export interface SkuUpsertBody {
|
||||
sku_code: string;
|
||||
attributes?: Record<string, string>;
|
||||
price_minor: number;
|
||||
currency: string;
|
||||
stock: number;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface ShipmentItemBody {
|
||||
order_item_id: string;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
export interface CurrencyUpsertBody {
|
||||
code: string;
|
||||
name: LocalizedText;
|
||||
symbol: string;
|
||||
exponent: number;
|
||||
rate_to_base: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ShopProductQuery {
|
||||
page?: number;
|
||||
status?: ProductStatus;
|
||||
}
|
||||
|
||||
export interface ShopOrderQuery {
|
||||
page?: number;
|
||||
status?: OrderStatus;
|
||||
}
|
||||
|
||||
export interface ConvertResult {
|
||||
amount_minor: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
type Query = Record<string, string | number | boolean | undefined>;
|
||||
|
||||
async function request<T>(
|
||||
opts: ApiClientOptions,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
query?: Query,
|
||||
): Promise<T> {
|
||||
const url = new URL(opts.baseUrl + path);
|
||||
if (query) {
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (v !== undefined && v !== null && v !== "") url.searchParams.set(k, String(v));
|
||||
}
|
||||
}
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
const token = opts.getToken?.();
|
||||
if (token) headers.authorization = `Bearer ${token}`;
|
||||
const res = await fetch(url.toString(), {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let code = "UNKNOWN";
|
||||
let message = res.statusText;
|
||||
try {
|
||||
const data = (await res.json()) as { error?: { code?: string; message?: string } };
|
||||
code = data?.error?.code ?? code;
|
||||
message = data?.error?.message ?? message;
|
||||
} catch {
|
||||
/* non-JSON error body */
|
||||
}
|
||||
throw new ApiError(res.status, code, message);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export interface ApiClient {
|
||||
register(email: string, password: string, displayName: string): Promise<AuthTokens>;
|
||||
login(email: string, password: string): Promise<AuthTokens>;
|
||||
me(): Promise<User>;
|
||||
listProducts(q?: ProductListQuery): Promise<Paged<Product>>;
|
||||
getProduct(idOrSlug: string): Promise<Product>;
|
||||
listCategories(): Promise<Category[]>;
|
||||
listCurrencies(): Promise<Currency[]>;
|
||||
convert(amountMinor: number, from: string, to: string): Promise<ConvertResult>;
|
||||
getCart(): Promise<Cart>;
|
||||
addCartItem(skuId: string, qty: number): Promise<Cart>;
|
||||
updateCartItem(skuId: string, qty: number): Promise<Cart>;
|
||||
removeCartItem(skuId: string): Promise<Cart>;
|
||||
checkout(shippingAddress: Address, currency: string): Promise<Order[]>;
|
||||
listMyOrders(page?: number): Promise<Paged<Order>>;
|
||||
getOrder(id: string): Promise<Order>;
|
||||
cancelOrder(id: string): Promise<Order>;
|
||||
confirmDelivered(shipmentId: string): Promise<Shipment>;
|
||||
listMyShipments(): Promise<Shipment[]>;
|
||||
requestInvoice(
|
||||
orderId: string,
|
||||
title: string,
|
||||
taxNo: string | null,
|
||||
kind: InvoiceKind,
|
||||
): Promise<Invoice>;
|
||||
listMyInvoices(): Promise<Invoice[]>;
|
||||
shop: {
|
||||
getMyShop(): Promise<Shop>;
|
||||
listMyProducts(q?: ShopProductQuery): Promise<Paged<Product>>;
|
||||
createProduct(body: ProductUpsertBody): Promise<Product>;
|
||||
updateProduct(id: string, body: ProductUpsertBody): Promise<Product>;
|
||||
publish(id: string): Promise<Product>;
|
||||
unpublish(id: string): Promise<Product>;
|
||||
upsertSku(productId: string, body: SkuUpsertBody): Promise<Sku>;
|
||||
listOrders(q?: ShopOrderQuery): Promise<Paged<Order>>;
|
||||
createShipment(
|
||||
orderId: string,
|
||||
carrier: string,
|
||||
trackingNo: string,
|
||||
items: ShipmentItemBody[],
|
||||
): Promise<Shipment>;
|
||||
listShipments(): Promise<Shipment[]>;
|
||||
markShipped(id: string): Promise<Shipment>;
|
||||
listInvoices(): Promise<Invoice[]>;
|
||||
issueInvoice(id: string): Promise<Invoice>;
|
||||
};
|
||||
admin: {
|
||||
listUsers(page?: number): Promise<Paged<User>>;
|
||||
setUserRole(id: string, role: string, shopId: string | null): Promise<User>;
|
||||
listShops(): Promise<Shop[]>;
|
||||
createShop(name: LocalizedText, slug: string): Promise<Shop>;
|
||||
setShopStatus(id: string, status: "active" | "suspended"): Promise<Shop>;
|
||||
listOrders(page?: number): Promise<Paged<Order>>;
|
||||
listCurrencies(): Promise<Currency[]>;
|
||||
upsertCurrency(body: CurrencyUpsertBody): Promise<Currency>;
|
||||
setRate(code: string, rateToBase: string): Promise<Currency>;
|
||||
};
|
||||
}
|
||||
|
||||
export function createApi(opts: ApiClientOptions): ApiClient {
|
||||
const r = <T>(m: string, p: string, b?: unknown, q?: Query): Promise<T> =>
|
||||
request<T>(opts, m, p, b, q);
|
||||
return {
|
||||
register: (email, password, displayName) =>
|
||||
r("POST", "/auth/register", { email, password, display_name: displayName }),
|
||||
login: (email, password) => r("POST", "/auth/login", { email, password }),
|
||||
me: () => r("GET", "/auth/me"),
|
||||
listProducts: (q = {}) => r("GET", "/products", undefined, { ...q }),
|
||||
getProduct: (idOrSlug) => r("GET", `/products/${idOrSlug}`),
|
||||
listCategories: () => r("GET", "/categories"),
|
||||
listCurrencies: () => r("GET", "/currencies"),
|
||||
convert: (amountMinor, from, to) =>
|
||||
r("GET", "/currencies/convert", undefined, { amount_minor: amountMinor, from, to }),
|
||||
getCart: () => r("GET", "/cart"),
|
||||
addCartItem: (skuId, qty) => r("POST", "/cart/items", { sku_id: skuId, qty }),
|
||||
updateCartItem: (skuId, qty) => r("PUT", `/cart/items/${skuId}`, { qty }),
|
||||
removeCartItem: (skuId) => r("DELETE", `/cart/items/${skuId}`),
|
||||
checkout: (shippingAddress, currency) =>
|
||||
r("POST", "/orders/checkout", { shipping_address: shippingAddress, currency }),
|
||||
listMyOrders: (page = 1) => r("GET", "/orders", undefined, { page }),
|
||||
getOrder: (id) => r("GET", `/orders/${id}`),
|
||||
cancelOrder: (id) => r("POST", `/orders/${id}/cancel`),
|
||||
confirmDelivered: (shipmentId) => r("POST", `/shipments/${shipmentId}/confirm-delivered`),
|
||||
listMyShipments: () => r("GET", "/shipments"),
|
||||
requestInvoice: (orderId, title, taxNo, kind) =>
|
||||
r("POST", `/orders/${orderId}/invoice`, { title, tax_no: taxNo, kind }),
|
||||
listMyInvoices: () => r("GET", "/invoices"),
|
||||
shop: {
|
||||
getMyShop: () => r("GET", "/shop/profile"),
|
||||
listMyProducts: (q = {}) => r("GET", "/shop/products", undefined, { ...q }),
|
||||
createProduct: (body) => r("POST", "/shop/products", body),
|
||||
updateProduct: (id, body) => r("PUT", `/shop/products/${id}`, body),
|
||||
publish: (id) => r("POST", `/shop/products/${id}/publish`),
|
||||
unpublish: (id) => r("POST", `/shop/products/${id}/unpublish`),
|
||||
upsertSku: (productId, body) => r("POST", `/shop/products/${productId}/skus`, body),
|
||||
listOrders: (q = {}) => r("GET", "/shop/orders", undefined, { ...q }),
|
||||
createShipment: (orderId, carrier, trackingNo, items) =>
|
||||
r("POST", `/shop/orders/${orderId}/shipments`, {
|
||||
carrier,
|
||||
tracking_no: trackingNo,
|
||||
items,
|
||||
}),
|
||||
listShipments: () => r("GET", "/shop/shipments"),
|
||||
markShipped: (id) => r("POST", `/shop/shipments/${id}/ship`),
|
||||
listInvoices: () => r("GET", "/shop/invoices"),
|
||||
issueInvoice: (id) => r("POST", `/shop/invoices/${id}/issue`),
|
||||
},
|
||||
admin: {
|
||||
listUsers: (page = 1) => r("GET", "/admin/users", undefined, { page }),
|
||||
setUserRole: (id, role, shopId) =>
|
||||
r("PUT", `/admin/users/${id}/role`, { role, shop_id: shopId }),
|
||||
listShops: () => r("GET", "/admin/shops"),
|
||||
createShop: (name, slug) => r("POST", "/admin/shops", { name, slug }),
|
||||
setShopStatus: (id, status) => r("PUT", `/admin/shops/${id}/status`, { status }),
|
||||
listOrders: (page = 1) => r("GET", "/admin/orders", undefined, { page }),
|
||||
listCurrencies: () => r("GET", "/admin/currencies"),
|
||||
upsertCurrency: (body) => r("POST", "/admin/currencies", body),
|
||||
setRate: (code, rateToBase) => r("PUT", `/admin/currencies/${code}/rate`, {
|
||||
rate_to_base: rateToBase,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user