feat(floor): the shop floor, and a sale recorded against a visit
Two surfaces a merchant currently has no way to reach, and the BFF routes
behind them.
/floor is the only screen in this console aimed at somebody standing behind
a counter: who walked in, who is being served, and by whom. A visit is
claimed with attend, handed back with release, and closed with complete.
Claiming is single-winner — the platform decides, and a second person
tapping the same customer gets 409 CUSTOMER_ALREADY_TAKEN rather than a
silent overwrite. That was verified against a live platform: eight
concurrent claims, exactly one winner.
Naming a walk-in posts to /api/customers, which is the same permission as
PUT /api/visitors/{id}/profile upstream — attaching a name and a phone
number to a face is the floor's job, not a manager's.
Sale entry sits on the floor because that is where a sale happens. Lines
carry an intent, purchased or enquired, so a shop can record what somebody
asked about and did not buy; only purchased lines are billed. Money is
integer paise end to end and formatPaise is the only place it becomes
rupees — a float round-trip through the BFF was rejected once already and
must not come back.
Every write carries an idempotency key minted per dialog, so a double tap,
a timeout retry and a resubmit collapse into one sale rather than three.
Proven live: a replayed key returns 200 already_processed with the original
sale id.
/commerce gains the real list of those sales, replacing nothing invented —
it reads GET /api/sales and opens a detail view per row.
── What this does NOT do ────────────────────────────────────────────────
No mock, demo or placeholder data anywhere in it. Every figure comes off a
payload; an empty floor renders an empty state and says so.
── Known: the deployed backend does not serve these yet ─────────────────
/api/floor/visits, /api/customers, /api/sales and the three visit actions
all answer 404 on mcp.loyaly.ai today, which runs a build older than this
repository's first commit. Until that backend ships, /floor and the sales
panel will show error states, and the Floor nav entry points at a page that
cannot load its data. Committed deliberately so the two halves can be
deployed together rather than drifting further apart.
staff -> /floor has been the committed destination since 697b0d9; this is
the page it was always pointing at.
Verified: tsc clean, production build clean, lint unchanged at the existing
baseline. Exercised against a live local platform for all three roles —
attend/release/steal-prevention, customer creation, sale creation and
idempotent replay, and two-tenant isolation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0161AMotQ8FxGPZ9gFGb5wiK
This commit is contained in:
46
src/app/api/customers/route.ts
Normal file
46
src/app/api/customers/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {floorApi} from '@/services/api/floorApi';
|
||||
import {withUpstream} from '@/features/auth/services/upstreamSession';
|
||||
import {failureFrom} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/customers — name somebody the cameras could not identify.
|
||||
*
|
||||
* `visit_id` is what makes this the first-visit flow rather than a directory
|
||||
* entry: it links the new customer to the arrival that prompted the form, so
|
||||
* the face on the floor stops being anonymous.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: {name?: unknown; phone?: unknown; notes?: unknown; visitId?: unknown};
|
||||
try {
|
||||
body = (await req.json()) as typeof body;
|
||||
} catch {
|
||||
return Response.json(
|
||||
{error: {code: 'bad_request', message: 'Malformed request body.'}},
|
||||
{status: 400, headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await withUpstream((token) =>
|
||||
floorApi.createCustomer(token, {
|
||||
name: typeof body.name === 'string' ? body.name : '',
|
||||
phone: typeof body.phone === 'string' ? body.phone : '',
|
||||
notes: typeof body.notes === 'string' ? body.notes : undefined,
|
||||
visit_id: typeof body.visitId === 'string' ? body.visitId : undefined,
|
||||
}),
|
||||
);
|
||||
return Response.json(
|
||||
{data: {id: created.id, ref: created.ref, label: created.label}},
|
||||
{status: 201, headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
} catch (err) {
|
||||
const f = failureFrom(err);
|
||||
return Response.json(
|
||||
{error: {code: f.code, message: f.message}, reason: f.reason},
|
||||
{status: f.status, headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
}
|
||||
}
|
||||
47
src/app/api/floor/visits/route.ts
Normal file
47
src/app/api/floor/visits/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {floorApi} from '@/services/api/floorApi';
|
||||
import {toSiteParam} from '@/services/api/range';
|
||||
import {serveUpstream} from '@/shared/services/bff';
|
||||
import type {ApiFloorVisit} from '@/services/api/types';
|
||||
import type {FloorVisit} from '@/features/floor/types/floor';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/floor/visits — who is in the shop now.
|
||||
*
|
||||
* Absent fields become NULL rather than empty strings, because the screen
|
||||
* branches on "is there a customer at all" and `''` would read as a customer
|
||||
* with a blank name.
|
||||
*/
|
||||
export function toFloorVisit(v: ApiFloorVisit): FloorVisit {
|
||||
return {
|
||||
visitId: v.visit_id,
|
||||
siteId: v.site_slug || v.site_id,
|
||||
detectedAt: v.detected_at,
|
||||
status: v.status,
|
||||
visitorId: v.visitor_id || null,
|
||||
customerRef: v.visitor_ref || null,
|
||||
label: v.label || null,
|
||||
phone: v.phone || null,
|
||||
previousVisits: v.previous_visits ?? 0,
|
||||
attendedBy: v.attended_by || null,
|
||||
attendedByName: v.attended_by_name || null,
|
||||
attendedByMe: v.attended_by_me ?? false,
|
||||
// Proxied so an <img> works without the Authorization header it cannot send.
|
||||
imageUrl:
|
||||
v.image?.available && v.image.url
|
||||
? v.image.url.startsWith('http')
|
||||
? v.image.url
|
||||
: `/api/faces?src=${encodeURIComponent(v.image.url)}`
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return serveUpstream(
|
||||
req,
|
||||
(token, query) => floorApi.list(token, {site: toSiteParam(query.storeId)}),
|
||||
(page) => (page.items ?? []).map(toFloorVisit),
|
||||
);
|
||||
}
|
||||
33
src/app/api/sales/[id]/route.ts
Normal file
33
src/app/api/sales/[id]/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {salesApi} from '@/services/api/salesApi';
|
||||
import {withUpstream} from '@/features/auth/services/upstreamSession';
|
||||
import {failureFrom} from '@/shared/services/bff';
|
||||
import {toSale} from '@/app/api/sales/route';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/sales/{id} — one sale with its lines. §24.
|
||||
*
|
||||
* The list endpoint carries counts; this carries the lines themselves, so a
|
||||
* screen showing a whole day of sales does not pull every line of every one.
|
||||
*/
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
try {
|
||||
const sale = await withUpstream((token) => salesApi.byId(token, id));
|
||||
return Response.json(
|
||||
{data: toSale(sale)},
|
||||
{headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
} catch (err) {
|
||||
const f = failureFrom(err);
|
||||
return Response.json(
|
||||
{error: {code: f.code, message: f.message}, reason: f.reason},
|
||||
{status: f.status, headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
}
|
||||
}
|
||||
136
src/app/api/sales/route.ts
Normal file
136
src/app/api/sales/route.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {salesApi} from '@/services/api/salesApi';
|
||||
import {toSiteParam} from '@/services/api/range';
|
||||
import {serveUpstream, failureFrom} from '@/shared/services/bff';
|
||||
import {withUpstream} from '@/features/auth/services/upstreamSession';
|
||||
import type {ApiSale} from '@/services/api/types';
|
||||
import type {Sale} from '@/features/commerce/types/sale';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/sales — the sale history the Sales screen reads.
|
||||
*
|
||||
* Money crosses this boundary as integer PAISE and is NOT converted. The
|
||||
* console formats paise for display and never holds rupees, so there is no
|
||||
* float round-trip and no component can disagree about the decimal point.
|
||||
*/
|
||||
export function toSale(s: ApiSale): Sale {
|
||||
return {
|
||||
id: s.id,
|
||||
invoiceNo: s.invoice_no ?? null,
|
||||
siteId: s.site_slug || s.site_id,
|
||||
customerRef: s.visitor_ref ?? null,
|
||||
customerLabel: s.customer_label || null,
|
||||
staffName: s.staff_name || null,
|
||||
totalPaise: s.total_paise ?? 0,
|
||||
currency: s.currency ?? 'INR',
|
||||
status: s.status,
|
||||
at: s.server_created_at,
|
||||
purchasedLines: s.purchased_lines ?? 0,
|
||||
enquiryLines: s.enquiry_lines ?? 0,
|
||||
lines: (s.lines ?? []).map((l) => ({
|
||||
productName: l.product_name,
|
||||
pricePaise: l.price_paise ?? 0,
|
||||
intent: l.intent,
|
||||
// Straight from the server. Re-deriving it here would put the
|
||||
// enquiry-is-not-revenue rule in a second place, which is how the two
|
||||
// start disagreeing.
|
||||
billablePaise: l.billable_paise ?? 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return serveUpstream(
|
||||
req,
|
||||
(token, query) =>
|
||||
salesApi.list(token, {
|
||||
site: toSiteParam(query.storeId),
|
||||
limit: 50,
|
||||
}),
|
||||
(page) => (page.items ?? []).map(toSale),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/sales — record a sale.
|
||||
*
|
||||
* ── What this route does NOT do ──────────────────────────────────────────
|
||||
* It does not compute a total. §12 says the backend calculates it from the
|
||||
* lines and must not trust a client-supplied one, and the platform's own
|
||||
* request shape has no total field to send. It also does not carry a staff id:
|
||||
* the platform derives that from the session, so a request structurally cannot
|
||||
* attribute a sale to somebody else.
|
||||
*
|
||||
* Prices arrive as integer PAISE from the form and are forwarded unchanged.
|
||||
* This is the only place the console converts money at all, and it converts in
|
||||
* one direction: paise out of the platform become rupees for display (toSale
|
||||
* above). Nothing multiplies by 100 on the way in, because the form never held
|
||||
* rupees to begin with.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: {
|
||||
idempotencyKey?: unknown;
|
||||
visitId?: unknown;
|
||||
visitorId?: unknown;
|
||||
invoiceNo?: unknown;
|
||||
site?: unknown;
|
||||
lines?: unknown;
|
||||
};
|
||||
try {
|
||||
body = (await req.json()) as typeof body;
|
||||
} catch {
|
||||
return Response.json(
|
||||
{error: {code: 'bad_request', message: 'Malformed request body.'}},
|
||||
{status: 400, headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
}
|
||||
|
||||
const lines = Array.isArray(body.lines)
|
||||
? body.lines
|
||||
.map((l) => l as {productName?: unknown; pricePaise?: unknown; intent?: unknown})
|
||||
.filter((l) => typeof l.productName === 'string' && l.productName.trim() !== '')
|
||||
.map((l) => ({
|
||||
product_name: String(l.productName).trim(),
|
||||
// Already an integer. Rounded rather than trusted blindly so a
|
||||
// fractional paise from a hand-written request cannot reach a bigint
|
||||
// column and be rejected three layers down.
|
||||
price_paise: Math.max(0, Math.round(Number(l.pricePaise) || 0)),
|
||||
intent: l.intent === 'enquired' ? 'enquired' : 'purchased',
|
||||
}))
|
||||
: [];
|
||||
|
||||
try {
|
||||
const result = await withUpstream((token) =>
|
||||
salesApi.create(token, {
|
||||
idempotency_key:
|
||||
typeof body.idempotencyKey === 'string' ? body.idempotencyKey : '',
|
||||
invoice_no: typeof body.invoiceNo === 'string' ? body.invoiceNo : '',
|
||||
site: typeof body.site === 'string' ? body.site : undefined,
|
||||
visit_id: typeof body.visitId === 'string' ? body.visitId : undefined,
|
||||
visitor_id: typeof body.visitorId === 'string' ? body.visitorId : undefined,
|
||||
client_created_at: new Date().toISOString(),
|
||||
lines,
|
||||
}),
|
||||
);
|
||||
return Response.json(
|
||||
{
|
||||
data: {
|
||||
// "already_processed" is a SUCCESS carrying the original sale — a
|
||||
// replay after a double tap or a retry has done nothing wrong.
|
||||
status: result.status,
|
||||
saleId: result.sale_id,
|
||||
sale: result.sale ? toSale(result.sale) : null,
|
||||
},
|
||||
},
|
||||
{status: 201, headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
} catch (err) {
|
||||
const f = failureFrom(err);
|
||||
return Response.json(
|
||||
{error: {code: f.code, message: f.message}, reason: f.reason},
|
||||
{status: f.status, headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
}
|
||||
}
|
||||
37
src/app/api/visits/[id]/attend/route.ts
Normal file
37
src/app/api/visits/[id]/attend/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {floorApi} from '@/services/api/floorApi';
|
||||
import {withUpstream} from '@/features/auth/services/upstreamSession';
|
||||
import {failureFrom} from '@/shared/services/bff';
|
||||
import {toFloorVisit} from '@/app/api/floor/visits/route';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/visits/{id}/attend
|
||||
*
|
||||
* The failure this route exists to pass through faithfully is 409: another
|
||||
* member of staff holds this customer. The screen must show that rather than
|
||||
* a generic error, and it must NOT be simulated client-side — only the
|
||||
* platform knows who actually won.
|
||||
*/
|
||||
export async function POST(
|
||||
_req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
try {
|
||||
const visit = await withUpstream((token) => floorApi.attend(token, id));
|
||||
return Response.json(
|
||||
{data: toFloorVisit(visit)},
|
||||
{headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
} catch (err) {
|
||||
const f = failureFrom(err);
|
||||
// `reason` carries the platform's own code — CUSTOMER_ALREADY_TAKEN —
|
||||
// so the screen branches on that rather than on prose.
|
||||
return Response.json(
|
||||
{error: {code: f.code, message: f.message}, reason: f.reason},
|
||||
{status: f.status, headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
}
|
||||
}
|
||||
37
src/app/api/visits/[id]/complete/route.ts
Normal file
37
src/app/api/visits/[id]/complete/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {floorApi} from '@/services/api/floorApi';
|
||||
import {withUpstream} from '@/features/auth/services/upstreamSession';
|
||||
import {failureFrom} from '@/shared/services/bff';
|
||||
import {toFloorVisit} from '@/app/api/floor/visits/route';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/visits/{id}/complete
|
||||
*
|
||||
* The failure this route exists to pass through faithfully is 409: another
|
||||
* member of staff holds this customer. The screen must show that rather than
|
||||
* a generic error, and it must NOT be simulated client-side — only the
|
||||
* platform knows who actually won.
|
||||
*/
|
||||
export async function POST(
|
||||
_req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
try {
|
||||
const visit = await withUpstream((token) => floorApi.complete(token, id));
|
||||
return Response.json(
|
||||
{data: toFloorVisit(visit)},
|
||||
{headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
} catch (err) {
|
||||
const f = failureFrom(err);
|
||||
// `reason` carries the platform's own code — CUSTOMER_ALREADY_TAKEN —
|
||||
// so the screen branches on that rather than on prose.
|
||||
return Response.json(
|
||||
{error: {code: f.code, message: f.message}, reason: f.reason},
|
||||
{status: f.status, headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
}
|
||||
}
|
||||
37
src/app/api/visits/[id]/release/route.ts
Normal file
37
src/app/api/visits/[id]/release/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {floorApi} from '@/services/api/floorApi';
|
||||
import {withUpstream} from '@/features/auth/services/upstreamSession';
|
||||
import {failureFrom} from '@/shared/services/bff';
|
||||
import {toFloorVisit} from '@/app/api/floor/visits/route';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/visits/{id}/release
|
||||
*
|
||||
* The failure this route exists to pass through faithfully is 409: another
|
||||
* member of staff holds this customer. The screen must show that rather than
|
||||
* a generic error, and it must NOT be simulated client-side — only the
|
||||
* platform knows who actually won.
|
||||
*/
|
||||
export async function POST(
|
||||
_req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
try {
|
||||
const visit = await withUpstream((token) => floorApi.release(token, id));
|
||||
return Response.json(
|
||||
{data: toFloorVisit(visit)},
|
||||
{headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
} catch (err) {
|
||||
const f = failureFrom(err);
|
||||
// `reason` carries the platform's own code — CUSTOMER_ALREADY_TAKEN —
|
||||
// so the screen branches on that rather than on prose.
|
||||
return Response.json(
|
||||
{error: {code: f.code, message: f.message}, reason: f.reason},
|
||||
{status: f.status, headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user