Files
kooltab-backoffice/apps/dashboard/src/lib/api/client.ts
T
brammie15 e7f59f7583
CI / build-and-test (push) Canceled after 0s
feat: make products server managed
2026-10-20 23:55:40 +02:00

374 lines
12 KiB
TypeScript

export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1';
export interface ApiError {
code: string;
message: string;
}
export type Role = 'admin' | 'viewer';
export type TabLine = { productName: string; quantity: number; unitPriceInCents: number };
/** An open tab as the server holds it: every terminal of the venue shows the same. */
export interface Tab {
tabId: string;
customerName: string;
version: number;
totalInCents: number;
itemCount: number;
items: TabLine[] | null;
lastChangedAt: string;
plannedTabId: string | null;
email: string | null;
}
export type CommandStatus = 'pending' | 'completed' | 'failed' | 'conflict' | 'expired';
export interface Command {
id: string;
tabId: string;
customerName: string | null;
amountInCents: number | null;
type: string;
status: CommandStatus;
paymentMethod: string;
requestedBy: string;
expectedVersion: number;
createdAt: string;
processedAt: string | null;
result: string | null;
/** Null for a tab the server settled itself */
deviceId: string | null;
}
export interface Device {
id: string;
name: string;
lastSeenAt: string;
createdAt: string;
}
export interface User {
id: string;
email: string;
name: string;
role: Role;
createdAt: string;
}
export interface PlannedTab {
id: string;
customerName: string;
email: string | null;
position: number;
createdAt: string;
deliveredAt: string | null;
}
export interface ReminderCandidate {
tabId: string;
customerName: string;
/** Null when the customer has no email address on file */
email: string | null;
amountInCents: number;
itemCount: number;
items: TabLine[] | null;
reminderCount: number;
lastReminderAt: string | null;
nextEligibleAt: string | null;
/** Has an email and is past the cooldown */
eligible: boolean;
}
export interface Reminder {
id: string;
tabId: string;
customerName: string;
email: string;
amountInCents: number;
sentAt: string;
status: string;
}
export type StockAdjustmentType = 'add' | 'set';
export type StockAdjustmentStatus = 'pending' | 'applied' | 'rejected' | 'cancelled';
export interface StockAdjustment {
id: string;
productId: string;
productName: string;
/** add: the stock changes by quantity (negative takes stock out); set: the stock is now quantity */
type: StockAdjustmentType;
quantity: number;
note: string | null;
requestedBy: string;
status: StockAdjustmentStatus;
/** The terminal's stock right after applying it */
stockAfter: number | null;
/** Why the terminal rejected it */
result: string | null;
createdAt: string;
processedAt: string | null;
}
export interface Product {
productId: string;
name: string;
category: string | null;
priceInCents: number;
/** Null when stock is not tracked for this product; can be negative */
stock: number | null;
lowStockThreshold: number | null;
/** The latest count, on the dashboard or a terminal; null when never counted */
countedAt: string | null;
/** When it was created or last changed */
reportedAt: string;
/** Archived (deleted): hidden on the terminals */
archivedAt: string | null;
}
export type ProductFields = { name: string; category: string | null; priceInCents: number; lowStockThreshold: number | null };
export type NewStockAdjustment = { productId: string; type: StockAdjustmentType; quantity: number; note?: string };
export function getCookie(name: string): string | null {
if (typeof document === 'undefined') return null;
return document.cookie
.split('; ')
.find((row) => row.startsWith(`${name}=`))
?.split('=')[1] ?? null;
}
function setTokens(accessToken: string, refreshToken: string) {
// The access cookie outlives the JWT on purpose: pages decode it for venue/role, and an
// expired JWT triggers a 401 which is transparently refreshed below.
const attributes = `path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax${location.protocol === 'https:' ? '; Secure' : ''}`;
document.cookie = `access_token=${accessToken}; ${attributes}`;
document.cookie = `refresh_token=${refreshToken}; ${attributes}`;
}
function clearTokens() {
document.cookie = 'access_token=; path=/; max-age=0';
document.cookie = 'refresh_token=; path=/; max-age=0';
}
// Refresh tokens rotate on use, so concurrent 401s must share a single refresh request
let refreshInFlight: Promise<string | null> | null = null;
function refreshAccessToken(): Promise<string | null> {
if (!refreshInFlight) {
refreshInFlight = doRefreshAccessToken().finally(() => {
refreshInFlight = null;
});
}
return refreshInFlight;
}
async function doRefreshAccessToken(): Promise<string | null> {
const refreshToken = getCookie('refresh_token');
if (!refreshToken) return null;
try {
const res = await fetch(`${API_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!res.ok) return null;
const data = await res.json();
setTokens(data.accessToken, data.refreshToken);
return data.accessToken;
} catch {
return null;
}
}
async function request(endpoint: string, options: RequestInit, headers: Record<string, string>): Promise<Response> {
try {
return await fetch(`${API_URL}${endpoint}`, { ...options, headers });
} catch (error) {
throw { code: 'network_error', message: String(error) } satisfies ApiError;
}
}
export async function api<T = unknown>(
endpoint: string,
options: RequestInit = {},
): Promise<T> {
const accessToken = getCookie('access_token');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
let res = await request(endpoint, options, headers);
if (res.status === 401 && accessToken) {
const newToken = await refreshAccessToken();
if (newToken) {
headers['Authorization'] = `Bearer ${newToken}`;
res = await request(endpoint, options, headers);
} else {
clearTokens();
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
throw { code: 'session_expired', message: 'Session expired' } satisfies ApiError;
}
}
if (!res.ok) {
const errorBody = await res.json().catch(() => null);
const error: ApiError = errorBody?.error || {
code: 'unknown',
message: `Request failed with status ${res.status}`,
};
throw error;
}
return res.json();
}
// Auth API
export const authApi = {
login: (email: string, password: string) =>
api<{ accessToken: string; refreshToken: string }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
}),
logout: () => {
const refreshToken = getCookie('refresh_token');
clearTokens();
if (refreshToken) {
api('/auth/logout', {
method: 'POST',
body: JSON.stringify({ refreshToken }),
}).catch(() => {});
}
},
};
// Tabs API
export const tabsApi = {
list: (venueId: string) =>
api<{ tabs: Tab[] }>(`/venues/${venueId}/tabs`),
};
// Devices API
export const devicesApi = {
list: (venueId: string) =>
api<{ devices: Device[] }>(`/venues/${venueId}/devices`),
unpair: (venueId: string, deviceId: string) =>
api(`/venues/${venueId}/commands/devices/${deviceId}/revoke`, { method: 'POST' }),
generateCode: () => api<{ code: string; expiresAt: string }>('/devices/generation-code'),
};
// Users API
export const usersApi = {
list: (venueId: string) =>
api<{ users: User[] }>(`/venues/${venueId}/users`),
create: (venueId: string, data: { email: string; password: string; name: string; role: string }) =>
api(`/venues/${venueId}/users`, { method: 'POST', body: JSON.stringify(data) }),
};
// Commands API
export const commandsApi = {
list: (venueId: string, params?: { status?: string; limit?: number; offset?: number }) => {
const searchParams = new URLSearchParams();
if (params?.status) searchParams.set('status', params.status);
if (params?.limit) searchParams.set('limit', String(params.limit));
if (params?.offset) searchParams.set('offset', String(params.offset));
const qs = searchParams.toString();
return api<{ commands: Command[]; total: number }>(
`/venues/${venueId}/commands${qs ? `?${qs}` : ''}`,
);
},
create: (venueId: string, data: { tabId: string; type: string; expectedVersion: number; paymentMethod: string }) =>
api<{ id: string; status: string; amountInCents: number }>(`/venues/${venueId}/commands`, {
method: 'POST',
body: JSON.stringify(data),
}),
};
// Venues API
export const venuesApi = {
get: (id: string) => api<{ id: string; name: string }>(`/venues/${id}`),
};
// Planned Tabs API
export const plannedTabsApi = {
list: (venueId: string) => api<{ plannedTabs: PlannedTab[] }>(`/venues/${venueId}/planned-tabs`),
create: (venueId: string, customerName: string, email?: string) =>
api(`/venues/${venueId}/planned-tabs`, { method: 'POST', body: JSON.stringify({ customerName, email }) }),
update: (venueId: string, id: string, customerName: string, email?: string) =>
api(`/venues/${venueId}/planned-tabs/${id}`, { method: 'PUT', body: JSON.stringify({ customerName, email }) }),
remove: (venueId: string, id: string) =>
api(`/venues/${venueId}/planned-tabs/${id}`, { method: 'DELETE' }),
import: (venueId: string, entries: { customerName: string; email?: string }[], mode: 'merge' | 'replace') =>
api<{ created: number; updated: number; unchanged: number; removed: number }>(`/venues/${venueId}/planned-tabs/import`, {
method: 'POST',
body: JSON.stringify({ entries, mode }),
}),
};
// Reminders API
export const remindersApi = {
candidates: (venueId: string) =>
api<{ candidates: ReminderCandidate[]; cooldownDays: number }>(`/venues/${venueId}/reminders/candidates`),
send: (venueId: string, tabIds: string[]) =>
api<{ sent: number; total: number; errors?: { customerName: string; error: string }[] }>(`/venues/${venueId}/reminders/send`, {
method: 'POST',
body: JSON.stringify({ tabIds }),
}),
history: (venueId: string, customerName?: string) =>
api<{ reminders: Reminder[] }>(
`/venues/${venueId}/reminders${customerName ? `?customerName=${encodeURIComponent(customerName)}` : ''}`,
),
sendTest: (venueId: string, plannedTabId: string) =>
api<{ ok: boolean; email: string; customerName: string }>(`/venues/${venueId}/reminders/test/${plannedTabId}`, { method: 'POST' }),
};
// Stock API
export const stockApi = {
list: (venueId: string) =>
api<{ products: Product[]; reportedAt: string | null }>(`/venues/${venueId}/stock`),
history: (venueId: string, params?: { productId?: string; limit?: number; offset?: number }) => {
const searchParams = new URLSearchParams();
if (params?.productId) searchParams.set('productId', params.productId);
if (params?.limit) searchParams.set('limit', String(params.limit));
if (params?.offset) searchParams.set('offset', String(params.offset));
const qs = searchParams.toString();
return api<{ adjustments: StockAdjustment[]; total: number }>(`/venues/${venueId}/stock/adjustments${qs ? `?${qs}` : ''}`);
},
adjust: (venueId: string, adjustments: NewStockAdjustment[]) =>
api<{ adjustments: StockAdjustment[] }>(`/venues/${venueId}/stock/adjustments`, {
method: 'POST',
body: JSON.stringify({ adjustments }),
}),
/** [stock] is the starting count; null: not tracked */
createProduct: (venueId: string, product: ProductFields & { stock: number | null }) =>
api<Product>(`/venues/${venueId}/stock/products`, {
method: 'POST',
body: JSON.stringify(product),
}),
/** Only the fields that are sent change; archived: true deletes (hides) the product */
updateProduct: (venueId: string, productId: string, changes: Partial<ProductFields> & { archived?: boolean }) =>
api<Product>(`/venues/${venueId}/stock/products/${encodeURIComponent(productId)}`, {
method: 'PATCH',
body: JSON.stringify(changes),
}),
};
export { setTokens, clearTokens, refreshAccessToken };
export function getAccessToken(): string | null {
return getCookie('access_token');
}