Add token auto-refresh, single-transaction endpoint, shift summary, and CI

- client/api/client.ts: shared refreshPromise prevents concurrent refresh races;
  dispatches auth:logout event when refresh fails
- client/context/AuthContext.tsx: listen for auth:logout to clear user state
- server/routes/transactions.ts: POST / real-time single transaction through
  payment abstraction (201 completed, 202 pending); GET /reports/shift shift
  window totals with averageTransaction, shiftOpen/shiftClose timestamps
- .github/workflows/ci.yml: server typecheck+build, client typecheck+build,
  Docker smoke-test on push/PR to main

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 07:06:59 -05:00
parent 2aa041d45e
commit c426b19b7c
5 changed files with 303 additions and 24 deletions

75
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,75 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
server:
name: Server — typecheck & build
runs-on: ubuntu-latest
defaults:
run:
working-directory: server
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: server/package-lock.json
- name: Install dependencies
run: npm ci
- name: Generate Prisma client
run: npx prisma generate
- name: Typecheck
run: npx tsc --noEmit
- name: Build
run: npm run build
client:
name: Client — typecheck & build
runs-on: ubuntu-latest
defaults:
run:
working-directory: client
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: client/package-lock.json
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npx tsc --noEmit
- name: Build
run: npm run build
docker:
name: Docker build (smoke test)
runs-on: ubuntu-latest
needs: [server, client]
steps:
- uses: actions/checkout@v4
- name: Build image
run: |
docker build \
--build-arg NODE_ENV=production \
-t vendor-pos:ci .

View File

@@ -91,6 +91,7 @@ All endpoints live under `/api/v1`.
| POST | /transactions/:id/refund | manager+ | Refund a completed transaction |
| GET | /transactions/:id/receipt | Bearer | Structured receipt payload |
| GET | /transactions/reports/summary | manager+ | Revenue/tax/top-product summary |
| GET | /transactions/reports/shift | manager+ | Shift window totals + avg tx value |
---

View File

@@ -1,9 +1,5 @@
const BASE = "/api/v1";
function getToken(): string | null {
return localStorage.getItem("accessToken");
}
export function setTokens(accessToken: string, refreshToken: string) {
localStorage.setItem("accessToken", accessToken);
localStorage.setItem("refreshToken", refreshToken);
@@ -14,22 +10,67 @@ export function clearTokens() {
localStorage.removeItem("refreshToken");
}
async function request<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const token = getToken();
function getToken(): string | null {
return localStorage.getItem("accessToken");
}
// Single in-flight refresh promise so concurrent 401s don't fire multiple refreshes
let refreshPromise: Promise<string | null> | null = null;
async function tryRefresh(): Promise<string | null> {
const refreshToken = localStorage.getItem("refreshToken");
if (!refreshToken) return null;
try {
const res = await fetch(`${BASE}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
if (!res.ok) {
clearTokens();
return null;
}
const data = await res.json() as { accessToken: string; refreshToken: string };
setTokens(data.accessToken, data.refreshToken);
return data.accessToken;
} catch {
clearTokens();
return null;
}
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const doFetch = async (token: string | null) => {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
if (token) headers["Authorization"] = `Bearer ${token}`;
return fetch(`${BASE}${path}`, { ...options, headers });
};
const res = await fetch(`${BASE}${path}`, { ...options, headers });
let res = await doFetch(getToken());
// On 401, attempt one token refresh then retry
if (res.status === 401) {
if (!refreshPromise) {
refreshPromise = tryRefresh().finally(() => { refreshPromise = null; });
}
const newToken = await refreshPromise;
if (!newToken) {
// Refresh failed — signal the app to go to login
window.dispatchEvent(new CustomEvent("auth:logout"));
throw new Error("Session expired. Please sign in again.");
}
res = await doFetch(newToken);
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const message = body?.error?.message ?? `HTTP ${res.status}`;
const message = (body as { error?: { message?: string } })?.error?.message ?? `HTTP ${res.status}`;
throw new Error(message);
}
@@ -38,11 +79,8 @@ async function request<T>(
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) =>
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
put: <T>(path: string, body: unknown) =>
request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
patch: <T>(path: string, body: unknown) =>
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
post: <T>(path: string, body: unknown) => request<T>(path, { method: "POST", body: JSON.stringify(body) }),
put: <T>(path: string, body: unknown) => request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
patch: <T>(path: string, body: unknown) => request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
};

View File

@@ -41,6 +41,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} else {
setState({ user: null, loading: false });
}
// When the API layer exhausts its refresh retry, force logout
const onForcedLogout = () => setState({ user: null, loading: false });
window.addEventListener("auth:logout", onForcedLogout);
return () => window.removeEventListener("auth:logout", onForcedLogout);
}, [fetchMe]);
const login = async (email: string, password: string) => {

View File

@@ -6,6 +6,7 @@ import { AppError } from "../middleware/errorHandler.js";
import { parsePage, paginatedResponse } from "../lib/pagination.js";
import { AuthenticatedRequest } from "../types/index.js";
import { logger } from "../lib/logger.js";
import { processPayment } from "../lib/payments.js";
const router = Router();
const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void;
@@ -131,6 +132,96 @@ router.post("/batch", auth, async (req: Request, res: Response, next: NextFuncti
}
});
// ─── POST /api/v1/transactions (single, real-time) ────────────────────────
// Used by the Android POS for live transactions. Runs through the payment
// abstraction before persisting; returns immediately with success/failure.
const SingleTransactionSchema = z.object({
idempotencyKey: z.string().min(1).max(200),
paymentMethod: z.enum(["cash", "card"]),
subtotal: z.number().min(0),
taxTotal: z.number().min(0),
discountTotal: z.number().min(0),
total: z.number().min(0),
notes: z.string().max(500).optional(),
items: z.array(TransactionItemSchema).min(1),
});
router.post("/", auth, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const { vendorId, userId } = authReq.auth;
const body = SingleTransactionSchema.parse(req.body);
// Idempotency guard
const existing = await prisma.transaction.findUnique({
where: { idempotencyKey: body.idempotencyKey },
});
if (existing) {
res.json(existing);
return;
}
// Run payment through provider abstraction
const paymentResult = await processPayment({
method: body.paymentMethod,
amount: body.total,
currency: process.env.CURRENCY ?? "AUD",
reference: body.idempotencyKey,
});
const status = paymentResult.status === "completed" ? "completed"
: paymentResult.status === "pending" ? "pending"
: "failed";
const tx = await prisma.transaction.create({
data: {
idempotencyKey: body.idempotencyKey,
vendorId,
userId,
status,
paymentMethod: body.paymentMethod,
subtotal: body.subtotal,
taxTotal: body.taxTotal,
discountTotal: body.discountTotal,
total: body.total,
notes: body.notes,
items: {
create: body.items.map((item) => ({
productId: item.productId,
productName: item.productName,
quantity: item.quantity,
unitPrice: item.unitPrice,
taxRate: item.taxRate,
discount: item.discount,
total: item.total,
})),
},
},
include: { items: true },
});
logger.info("transaction.created", {
id: tx.id,
status,
total: tx.total,
providerRef: paymentResult.providerRef,
});
res.status(status === "completed" ? 201 : 202).json({
...tx,
payment: {
status: paymentResult.status,
providerRef: paymentResult.providerRef,
errorCode: paymentResult.errorCode,
errorMessage: paymentResult.errorMessage,
},
});
} catch (err) {
next(err);
}
});
// ─── GET /api/v1/transactions ──────────────────────────────────────────────
router.get("/", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
@@ -259,6 +350,75 @@ router.get("/reports/summary", auth, managerUp, async (req: Request, res: Respon
}
});
// ─── GET /api/v1/transactions/reports/shift ───────────────────────────────
// Totals for a single shift window (e.g. today). Same shape as summary but
// also returns an average transaction value and opening/closing time of the
// first and last completed transaction in the period.
router.get("/reports/shift", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const { vendorId } = authReq.auth;
const { from, to } = req.query as { from?: string; to?: string };
const dateFilter = {
...(from ? { gte: new Date(from) } : {}),
...(to ? { lte: new Date(to) } : {}),
};
const where = {
vendorId,
status: "completed",
...(from || to ? { createdAt: dateFilter } : {}),
};
const [totals, byPayment, firstTx, lastTx] = await Promise.all([
prisma.transaction.aggregate({
where,
_sum: { total: true, taxTotal: true, discountTotal: true, subtotal: true },
_count: { id: true },
_avg: { total: true },
}),
prisma.transaction.groupBy({
by: ["paymentMethod"],
where,
_sum: { total: true },
_count: { id: true },
}),
prisma.transaction.findFirst({
where,
orderBy: { createdAt: "asc" },
select: { createdAt: true },
}),
prisma.transaction.findFirst({
where,
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
]);
res.json({
period: { from: from ?? null, to: to ?? null },
shiftOpen: firstTx?.createdAt ?? null,
shiftClose: lastTx?.createdAt ?? null,
totals: {
revenue: totals._sum.total ?? 0,
subtotal: totals._sum.subtotal ?? 0,
tax: totals._sum.taxTotal ?? 0,
discounts: totals._sum.discountTotal ?? 0,
transactionCount: totals._count.id,
averageTransaction: totals._avg.total ?? 0,
},
byPaymentMethod: byPayment.map((r) => ({
method: r.paymentMethod,
revenue: r._sum.total ?? 0,
count: r._count.id,
})),
});
} catch (err) {
next(err);
}
});
// ─── POST /api/v1/transactions/:id/refund ─────────────────────────────────
// Server-authoritative: only managers/owners can issue refunds.