fix(config): require a real API base, ship the env template, show the real team
Three configuration defects and one screen of invented people. API base. The client defaulted to https://platform.loyaly.ai when LOYALY_API_BASE was unset, and that host serves THIS console, not the Behavision API - verified live: it answers /api/auth/me with the console's own 404 HTML and a login POST with the console's own BFF envelope. So an unset variable in production made the BFF call its own origin, which fails looking like a broken login form rather than a misconfiguration. There is now no remote fallback: development defaults to 127.0.0.1:8088 and production throws, naming the variable, the way tokenStore already refuses to run without AUTH_SECRET. A wrong host that appears to work is worse than a startup failure that says what is missing. The template. .env.example documented that same wrong host, and .gitignore's `.env*` matched the template itself, so it was never committed - a fresh clone got no template at all, for an app that cannot start in production without AUTH_SECRET. Added `!.env.example` after the ignore rule; .env.local and every other .env* stay ignored. The template carries placeholders only, no values. Team. /settings/team listed five invented people - aravind@nearle.in, Vikram Seth, Priya Sharma - with store names no endpoint supplies and roles that do not exist upstream, behind four controls that mutated local state and were lost on refresh. A merchant could not tell any of it from the real thing. It now reads GET /api/team, which the platform already serves and scopes by session, and renders what actually comes back: name, email, role, whether the account is still active, and last sign-in (or "Never", which is a fact worth seeing). The route used to map each row through toAuthUser, which reads client_name - a field GET /api/team does not send - so organisation was undefined on every row while active, last_login_at and created_at were discarded. ApiTeamMember now describes that payload properly and ApiUser is left to authentication. The screen is READ-ONLY on purpose. Accounts are born from invitations, and that flow already exists in the platform's own web app: a manager mints a code, the holder redeems it and chooses their own password. A second way to create a login does not belong here, least of all on the screen that lists them. Role changes and deactivation are supported upstream by PATCH /api/team/{id} and are deliberately not wired: deactivating revokes every session that person holds immediately, so it wants a confirmation step and 409 last_owner handling, neither of which belongs in a change whose purpose is removing invented data. types.ts also gains the Sales/Floor/Customer interfaces. They are inert here - nothing imports them yet - and land with this commit so the screens that consume them arrive as one reviewable change. Verified against the live local platform: two tenants, correct member lists for each, and no cross-tenant leakage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0161AMotQ8FxGPZ9gFGb5wiK
This commit is contained in:
21
.env.example
Normal file
21
.env.example
Normal file
@@ -0,0 +1,21 @@
|
||||
# The one shared Loyaly platform API (Behavision). Server-side only and
|
||||
# deliberately NOT NEXT_PUBLIC: publishing the host would let a browser bypass
|
||||
# the BFF, which is what keeps the access token out of JavaScript.
|
||||
#
|
||||
# local dev http://127.0.0.1:8088
|
||||
# production https://mcp.loyaly.ai
|
||||
#
|
||||
# NOT platform.loyaly.ai — that host serves THIS console, not the API. Pointing
|
||||
# the variable there makes the BFF call its own origin, which fails in a way
|
||||
# that looks like a broken login form rather than a misconfiguration.
|
||||
#
|
||||
# There is no fallback: production refuses to start without this set.
|
||||
LOYALY_API_BASE=http://127.0.0.1:8088
|
||||
|
||||
# Signs the session cookie and encrypts the platform token bundle.
|
||||
# Required in production — the app refuses to start signing sessions with the
|
||||
# development key. Generate with: openssl rand -base64 48
|
||||
AUTH_SECRET=
|
||||
|
||||
# Browser → this app's own BFF routes. Same origin, so normally left empty.
|
||||
NEXT_PUBLIC_API_BASE=
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -33,6 +33,11 @@ yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
# ...except the template, which carries no secret and is the only record of
|
||||
# which variables the app needs. `.env*` was swallowing it too, so a fresh
|
||||
# clone got no guidance at all — while AUTH_SECRET and LOYALY_API_BASE are both
|
||||
# mandatory in production and the app refuses to start without them.
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
@@ -1,26 +1,43 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {teamApi} from '@/services/api/teamApi';
|
||||
import {serveUpstream} from '@/shared/services/bff';
|
||||
import {toAuthUser} from '@/features/auth/services/userMapper';
|
||||
import type {ApiUser} from '@/services/api/types';
|
||||
import type {ApiTeamMember} from '@/services/api/types';
|
||||
import type {UserRole} from '@/features/auth/types/auth';
|
||||
import type {TeamMember} from '@/features/team/types/team';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** GET /api/team — console accounts for this company. */
|
||||
function toMember(u: ApiUser): TeamMember {
|
||||
const user = toAuthUser(u);
|
||||
/**
|
||||
* GET /api/team — console accounts for this company.
|
||||
*
|
||||
* The tenant is NOT a parameter. The platform takes it from the session and
|
||||
* scopes the query itself, so this route has nothing to filter by and must not
|
||||
* pretend otherwise — a client-side tenant filter is a check an attacker skips.
|
||||
*
|
||||
* ── What this used to drop ───────────────────────────────────────────────
|
||||
* It ran each row through `toAuthUser`, which reads `client_name` — a field
|
||||
* `GET /api/team` does not send. So `organisation` was `undefined` on every
|
||||
* row, while `active`, `last_login_at` and `created_at` were discarded. The
|
||||
* one field the team screen needs — who still has access — never arrived.
|
||||
*/
|
||||
function toMember(m: ApiTeamMember): TeamMember {
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
organisation: user.organisation,
|
||||
id: m.id,
|
||||
// Falls back to the address rather than rendering a blank cell: somebody
|
||||
// invited but not yet named still has to be identifiable.
|
||||
name: m.full_name || m.email,
|
||||
email: m.email,
|
||||
role: m.role as UserRole,
|
||||
active: m.active,
|
||||
// Null rather than '' — "has never signed in" and "signed in at an unknown
|
||||
// time" are different facts, and the screen says so.
|
||||
lastLoginAt: m.last_login_at || null,
|
||||
createdAt: m.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return serveUpstream(req, (token) => teamApi.list(token), (users) =>
|
||||
users.map(toMember),
|
||||
return serveUpstream(req, (token) => teamApi.list(token), (members) =>
|
||||
members.map(toMember),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,142 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {proportional, pixel} from '@astryxdesign/core/Table';
|
||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||
import {proportional} from '@astryxdesign/core/Table';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text, Heading} from '@astryxdesign/core/Text';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||
import {Avatar} from '@astryxdesign/core/Avatar';
|
||||
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {Selector} from '@astryxdesign/core/Selector';
|
||||
import {useToast} from '@astryxdesign/core/Toast';
|
||||
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
|
||||
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {useTeam} from '@/features/team/hooks/useTeam';
|
||||
import type {TeamMember} from '@/features/team/types/team';
|
||||
|
||||
export interface StaffUser extends Record<string, unknown> {
|
||||
/**
|
||||
* Who has access to this console.
|
||||
*
|
||||
* ── What this replaced ───────────────────────────────────────────────────
|
||||
* Five invented people — `aravind@nearle.in`, `Vikram Seth`, `Priya Sharma` —
|
||||
* held in `useState`, with store names (`Indiranagar Flagship`,
|
||||
* `Koramangala 80ft`) that no endpoint supplies, roles (`Cashier`,
|
||||
* `Marketing`, `Support`) that do not exist upstream, and four controls that
|
||||
* changed nothing: Add Staff, Suspend, Reset password and Remove all mutated
|
||||
* local state and were lost on refresh. A merchant could not tell any of it
|
||||
* from the real thing.
|
||||
*
|
||||
* ── Why this screen is read-only ─────────────────────────────────────────
|
||||
* Accounts are created by INVITATION, and that flow already exists in the
|
||||
* platform's own web app: a manager mints a code, the holder redeems it and
|
||||
* chooses their own password. Adding a second way to create an account here
|
||||
* would be a second place a login can be born, on the one surface where that
|
||||
* matters most. So this lists the team and does not mint anyone.
|
||||
*
|
||||
* Role changes and deactivation are supported upstream by
|
||||
* `PATCH /api/team/{id}` and are NOT wired here — see the report. Deactivating
|
||||
* revokes every session that person holds immediately, so it wants a
|
||||
* confirmation step and `409 last_owner` handling, neither of which belongs in
|
||||
* a change whose purpose is removing invented data.
|
||||
*/
|
||||
|
||||
interface TeamRow extends Record<string, unknown> {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: 'Owner' | 'Manager' | 'Cashier' | 'Marketing' | 'Support';
|
||||
storeName: string;
|
||||
status: 'active' | 'suspended' | 'invited';
|
||||
role: string;
|
||||
status: string;
|
||||
lastLogin: string;
|
||||
}
|
||||
|
||||
const INITIAL_STAFF: StaffUser[] = [
|
||||
{
|
||||
id: 'usr-1',
|
||||
name: 'Aravind',
|
||||
email: 'aravind@nearle.in',
|
||||
role: 'Owner',
|
||||
storeName: 'All stores',
|
||||
status: 'active',
|
||||
lastLogin: 'Just now',
|
||||
},
|
||||
{
|
||||
id: 'usr-2',
|
||||
name: 'Vikram Seth',
|
||||
email: 'vikram@nearle.in',
|
||||
role: 'Manager',
|
||||
storeName: 'Indiranagar Flagship',
|
||||
status: 'active',
|
||||
lastLogin: 'Today, 14:20',
|
||||
},
|
||||
{
|
||||
id: 'usr-3',
|
||||
name: 'Priya Sharma',
|
||||
email: 'priya@nearle.in',
|
||||
role: 'Cashier',
|
||||
storeName: 'Koramangala 80ft',
|
||||
status: 'active',
|
||||
lastLogin: 'Today, 09:15',
|
||||
},
|
||||
{
|
||||
id: 'usr-4',
|
||||
name: 'Rahul Verma',
|
||||
email: 'rahul@nearle.in',
|
||||
role: 'Marketing',
|
||||
storeName: 'All stores',
|
||||
status: 'invited',
|
||||
lastLogin: 'Pending accept',
|
||||
},
|
||||
{
|
||||
id: 'usr-5',
|
||||
name: 'Deepa Nair',
|
||||
email: 'deepa@nearle.in',
|
||||
role: 'Cashier',
|
||||
storeName: 'Whitefield Main',
|
||||
status: 'suspended',
|
||||
lastLogin: '3 days ago',
|
||||
},
|
||||
];
|
||||
|
||||
export function TeamManagement() {
|
||||
const toast = useToast();
|
||||
const [staffList, setStaffList] = useState<StaffUser[]>(INITIAL_STAFF);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [role, setRole] = useState<'Owner' | 'Manager' | 'Cashier' | 'Marketing' | 'Support'>('Manager');
|
||||
const [storeName, setStoreName] = useState('Indiranagar Flagship');
|
||||
|
||||
const handleAddStaff = () => {
|
||||
if (!name.trim() || !email.trim()) {
|
||||
toast({type: 'error', body: 'Name and Email are required'});
|
||||
return;
|
||||
/**
|
||||
* Read off the payload, never inferred. The platform sends a boolean; there is
|
||||
* no "invited" or "suspended" state on this endpoint, so the screen does not
|
||||
* invent one.
|
||||
*/
|
||||
function toRow(m: TeamMember): TeamRow {
|
||||
return {
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
email: m.email,
|
||||
role: m.role,
|
||||
status: m.active ? 'Active' : 'Inactive',
|
||||
// "Never" is a fact worth showing: an account created and never used is
|
||||
// exactly the row a manager wants to notice.
|
||||
lastLogin: m.lastLoginAt ? new Date(m.lastLoginAt).toLocaleDateString() : 'Never',
|
||||
};
|
||||
}
|
||||
const newUser: StaffUser = {
|
||||
id: `usr-${Date.now()}`,
|
||||
name,
|
||||
email,
|
||||
role,
|
||||
storeName,
|
||||
status: 'invited',
|
||||
lastLogin: 'Pending accept',
|
||||
};
|
||||
setStaffList((prev) => [newUser, ...prev]);
|
||||
setName('');
|
||||
setEmail('');
|
||||
setIsAdding(false);
|
||||
toast({body: `Invitation sent to ${email}`});
|
||||
};
|
||||
|
||||
const handleToggleStatus = (id: string) => {
|
||||
setStaffList((prev) =>
|
||||
prev.map((s) => {
|
||||
if (s.id !== id) return s;
|
||||
const nextStatus = s.status === 'suspended' ? 'active' : 'suspended';
|
||||
toast({body: `${s.name} status updated to ${nextStatus}`});
|
||||
return {...s, status: nextStatus};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleResetPassword = (emailStr: string) => {
|
||||
toast({body: `Password reset link sent to ${emailStr}`});
|
||||
};
|
||||
|
||||
const handleRemove = (id: string, nameStr: string) => {
|
||||
setStaffList((prev) => prev.filter((s) => s.id !== id));
|
||||
toast({body: `${nameStr} removed from team`});
|
||||
};
|
||||
|
||||
const columns: TableColumn<StaffUser>[] = [
|
||||
const COLUMNS: TableColumn<TeamRow>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Staff Member',
|
||||
header: 'Member',
|
||||
width: proportional(2),
|
||||
renderCell: (row) => (
|
||||
<HStack gap={3} vAlign="center">
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Avatar name={row.name} size="sm" tooltip={false} />
|
||||
<VStack gap={0}>
|
||||
<Text size="sm" weight="medium">
|
||||
{row.name}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
<Text size="xsm" color="secondary">
|
||||
{row.email}
|
||||
</Text>
|
||||
</VStack>
|
||||
@@ -147,154 +90,62 @@ export function TeamManagement() {
|
||||
key: 'role',
|
||||
header: 'Role',
|
||||
width: proportional(1),
|
||||
renderCell: (row) => (
|
||||
<Badge
|
||||
variant={row.role === 'Owner' ? 'info' : 'neutral'}
|
||||
label={row.role}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'storeName',
|
||||
header: 'Assigned Store',
|
||||
width: proportional(1.5),
|
||||
renderCell: (row) => <Text size="sm">{row.storeName}</Text>,
|
||||
renderCell: (row) => <Badge variant="neutral" label={row.role} />,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
width: pixel(110),
|
||||
renderCell: (row) => {
|
||||
const v =
|
||||
row.status === 'active'
|
||||
? 'success'
|
||||
: row.status === 'suspended'
|
||||
? 'error'
|
||||
: 'warning';
|
||||
return <Badge variant={v} label={row.status} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'lastLogin',
|
||||
header: 'Last Login',
|
||||
width: proportional(1.2),
|
||||
header: 'Access',
|
||||
width: proportional(1),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" color="secondary">
|
||||
{row.lastLogin}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Actions',
|
||||
align: 'center',
|
||||
width: pixel(160),
|
||||
renderCell: (row) =>
|
||||
row.role === 'Owner' ? (
|
||||
<HStack hAlign="center">
|
||||
<Text size="sm" color="secondary">
|
||||
Primary Owner
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<HStack hAlign="center">
|
||||
<DropdownMenu
|
||||
button={{
|
||||
variant: 'secondary',
|
||||
size: 'sm',
|
||||
label: 'Manage',
|
||||
icon: <Icon icon="moreHorizontal" size="sm" />,
|
||||
}}
|
||||
menuWidth={180}
|
||||
items={[
|
||||
{
|
||||
label: row.status === 'suspended' ? 'Reactivate staff' : 'Suspend staff',
|
||||
onClick: () => handleToggleStatus(row.id),
|
||||
},
|
||||
{
|
||||
label: 'Reset password',
|
||||
onClick: () => handleResetPassword(row.email),
|
||||
},
|
||||
{type: 'divider'},
|
||||
{
|
||||
label: 'Remove from team',
|
||||
onClick: () => handleRemove(row.id, row.name),
|
||||
},
|
||||
]}
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<StatusDot
|
||||
variant={row.status === 'Active' ? 'success' : 'error'}
|
||||
label={row.status}
|
||||
/>
|
||||
<Text size="sm" color="secondary">
|
||||
{row.status}
|
||||
</Text>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
{key: 'lastLogin', header: 'Last sign-in', width: proportional(1), align: 'end'},
|
||||
];
|
||||
|
||||
export function TeamManagement() {
|
||||
const team = useTeam();
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<StaticPanel
|
||||
title="Team Directory & Staff Access"
|
||||
subtitle="Manage store assignments, role permissions, password resets and account statuses."
|
||||
actions={
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
label={isAdding ? 'Cancel' : 'Add Staff Member'}
|
||||
onClick={() => setIsAdding(!isAdding)}
|
||||
<PanelCard
|
||||
title="Console access"
|
||||
subtitle="Everybody who can sign in to this company's dashboard."
|
||||
resource={team}
|
||||
loading={<SkeletonRows count={4} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="staff"
|
||||
title="No team members yet"
|
||||
description="People appear here once they accept an invitation."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(members) => (
|
||||
<VStack gap={4}>
|
||||
{isAdding ? (
|
||||
<VStack gap={3} padding={4}>
|
||||
<Heading level={3}>Invite New Staff Member</Heading>
|
||||
<HStack gap={3} vAlign="end" wrap="wrap">
|
||||
<TextInput
|
||||
label="Full Name"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
placeholder="e.g. Ananya Rao"
|
||||
/>
|
||||
<TextInput
|
||||
label="Work Email"
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
placeholder="ananya@nearle.in"
|
||||
/>
|
||||
<Selector
|
||||
label="Role"
|
||||
value={role}
|
||||
onChange={(v) =>
|
||||
setRole(v as 'Owner' | 'Manager' | 'Cashier' | 'Marketing' | 'Support')
|
||||
}
|
||||
options={['Manager', 'Cashier', 'Marketing', 'Support']}
|
||||
/>
|
||||
<Selector
|
||||
label="Assigned Store"
|
||||
value={storeName}
|
||||
onChange={(v) => setStoreName(v)}
|
||||
options={[
|
||||
'All stores',
|
||||
'Indiranagar Flagship',
|
||||
'Koramangala 80ft',
|
||||
'Whitefield Main',
|
||||
'Jayanagar 4th Block',
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
label="Send Invite"
|
||||
onClick={handleAddStaff}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
<ResponsiveTable
|
||||
columns={columns}
|
||||
data={staffList}
|
||||
data={members.map(toRow)}
|
||||
idKey="id"
|
||||
primaryKey="name"
|
||||
summaryKeys={['role', 'status']}
|
||||
columns={COLUMNS}
|
||||
/>
|
||||
{/* Says where accounts come from, because the button that used to
|
||||
claim to create one has gone. Without this the screen reads as
|
||||
missing a feature rather than pointing at the right place. */}
|
||||
<Text size="xsm" color="secondary">
|
||||
New members are added by invitation, and choose their own password
|
||||
when they redeem it. Roles and access are managed on the platform.
|
||||
</Text>
|
||||
</VStack>
|
||||
</StaticPanel>
|
||||
</VStack>
|
||||
)}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,11 +6,21 @@ import type {UserRole} from '@/features/auth/types/auth';
|
||||
* Not a shop-floor employee. `active: false` means signed out immediately and
|
||||
* unable to sign back in; reactivating restores the account but not their old
|
||||
* sessions.
|
||||
*
|
||||
* `organisation` was removed: it mapped from `client_name`, which
|
||||
* `GET /api/team` does not send, so it was `undefined` on every row. Every
|
||||
* member of this list belongs to the caller's own company by definition — the
|
||||
* backend scopes the query by the session's tenant — so the field said nothing
|
||||
* even when it was populated.
|
||||
*/
|
||||
export interface TeamMember {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
organisation: string;
|
||||
/** false = signed out immediately and unable to sign back in. */
|
||||
active: boolean;
|
||||
/** Absent for somebody who has never signed in — that is data, not a gap. */
|
||||
lastLoginAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -24,11 +24,40 @@ import type {ApiRole} from './types';
|
||||
/**
|
||||
* Server-side only — deliberately NOT NEXT_PUBLIC. Publishing the platform
|
||||
* host would let a browser bypass the BFF, which is the whole point of it.
|
||||
*
|
||||
* ── Why there is no remote fallback ──────────────────────────────────────
|
||||
* This used to default to `https://platform.loyaly.ai`, which is NOT the
|
||||
* Behavision API — that host serves this very console. Measured: it answers
|
||||
* `GET /api/auth/me` with the console's own 404 HTML page, and a login POST
|
||||
* with the console's own `{error:{code,message}}` envelope rather than the
|
||||
* platform's flat `{error,message}`. So an unset variable did not fail; it
|
||||
* quietly pointed the BFF at its own origin, and every upstream call became a
|
||||
* request the console made to itself.
|
||||
*
|
||||
* A wrong host that *works* is worse than a startup failure, so production now
|
||||
* refuses to run without the variable — the same stance `tokenStore.ts` takes
|
||||
* on AUTH_SECRET, and for the same reason. Development falls back to the local
|
||||
* backend, which is the only host a dev machine can usefully mean.
|
||||
*
|
||||
* local http://127.0.0.1:8088
|
||||
* production https://mcp.loyaly.ai
|
||||
*/
|
||||
const BASE = (process.env.LOYALY_API_BASE ?? 'https://platform.loyaly.ai').replace(
|
||||
/\/+$/,
|
||||
'',
|
||||
const DEV_API_BASE = 'http://127.0.0.1:8088';
|
||||
|
||||
function resolveBase(): string {
|
||||
const configured = process.env.LOYALY_API_BASE?.trim();
|
||||
if (configured) return configured.replace(/\/+$/, '');
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error(
|
||||
'LOYALY_API_BASE is required in production — refusing to guess the ' +
|
||||
'Loyaly platform host. Set it to https://mcp.loyaly.ai.',
|
||||
);
|
||||
}
|
||||
return DEV_API_BASE;
|
||||
}
|
||||
|
||||
const BASE = resolveBase();
|
||||
|
||||
export {BASE as UPSTREAM_BASE};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'server-only';
|
||||
import {upstreamRequest} from './apiClient';
|
||||
import type {ApiRole, ApiUser} from './types';
|
||||
import type {ApiRole, ApiTeamMember} from './types';
|
||||
|
||||
/**
|
||||
* The people with console accounts.
|
||||
@@ -17,14 +17,19 @@ import type {ApiRole, ApiUser} from './types';
|
||||
*/
|
||||
export const teamApi = {
|
||||
list: (accessToken: string) =>
|
||||
upstreamRequest<ApiUser[]>({path: '/api/team', accessToken}),
|
||||
upstreamRequest<ApiTeamMember[]>({path: '/api/team', accessToken}),
|
||||
|
||||
update: (
|
||||
accessToken: string,
|
||||
id: string,
|
||||
patch: {role?: ApiRole; active?: boolean},
|
||||
) =>
|
||||
upstreamRequest<ApiUser>({
|
||||
// Returns the UPDATED member, not an ApiUser — the handler writes back the
|
||||
// same TeamMember shape `list` returns. Mis-typed as ApiUser, which has no
|
||||
// `active`, so a caller reading the result could not see the change it had
|
||||
// just made. Nothing calls this yet; correcting it now keeps the file
|
||||
// honest for whoever wires the role/deactivate controls.
|
||||
upstreamRequest<ApiTeamMember>({
|
||||
path: `/api/team/${encodeURIComponent(id)}`,
|
||||
method: 'PATCH',
|
||||
body: patch,
|
||||
|
||||
@@ -6,8 +6,11 @@
|
||||
* would prefer it. Where the UI wants a different shape, the adapter in the
|
||||
* BFF route does the translation; nothing here bends to suit a screen.
|
||||
*
|
||||
* Reference: platform.loyaly.ai. Times are RFC 3339 UTC unless a field says
|
||||
* otherwise. Report bucket labels are the documented exception — see Bucket.
|
||||
* Reference: the Behavision platform — mcp.loyaly.ai in production, and its
|
||||
* own API.md. (Not platform.loyaly.ai, which serves this console rather than
|
||||
* the API; naming it here is what invited that mistake into apiClient.)
|
||||
* Times are RFC 3339 UTC unless a field says otherwise. Report bucket labels
|
||||
* are the documented exception — see Bucket.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -33,6 +36,29 @@ export interface ApiUser {
|
||||
client_name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One person on `GET /api/team`.
|
||||
*
|
||||
* NOT `ApiUser`. The team list was typed as `ApiUser[]`, which claims
|
||||
* `client_id` and `client_name` — neither of which the platform sends here, so
|
||||
* `organisation` mapped to `undefined` on every row. It also meant `active`,
|
||||
* `last_login_at` and `created_at` were dropped on the floor: the one field the
|
||||
* team screen needs to show who still has access never reached the console.
|
||||
*
|
||||
* `ApiUser` remains correct for auth, where login genuinely does return the
|
||||
* signed-in user's company.
|
||||
*/
|
||||
export interface ApiTeamMember {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
role: ApiRole;
|
||||
/** false = signed out immediately and unable to sign back in. */
|
||||
active: boolean;
|
||||
last_login_at?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** What login, refresh and register all return. */
|
||||
export interface ApiTokenBundle {
|
||||
access_token: string;
|
||||
@@ -316,3 +342,101 @@ export interface ApiAssistantAnswer {
|
||||
/** Tool names, so a wrong answer is traceable rather than mysterious. */
|
||||
used?: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sales (the merchant application's domain — LOYALY.md)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One line of a sale. `intent` is the load-bearing field: an enquiry keeps the
|
||||
* price it was quoted at and contributes NOTHING to the bill, which is why
|
||||
* `billable_paise` ships separately rather than being re-derived per client.
|
||||
*/
|
||||
export interface ApiSaleLine {
|
||||
product_id?: string;
|
||||
product_name: string;
|
||||
price_paise: number;
|
||||
intent: 'purchased' | 'enquired';
|
||||
billable_paise: number;
|
||||
}
|
||||
|
||||
/** Money is integer PAISE on this path, never a float. */
|
||||
export interface ApiSale {
|
||||
id: string;
|
||||
invoice_no?: string;
|
||||
site_id: string;
|
||||
site_slug?: string;
|
||||
visit_id?: string;
|
||||
visitor_id?: string;
|
||||
visitor_ref?: string;
|
||||
customer_label?: string;
|
||||
staff_id?: string;
|
||||
staff_name?: string;
|
||||
terminal_id?: string;
|
||||
total_paise: number;
|
||||
currency: string;
|
||||
status: string;
|
||||
lines?: ApiSaleLine[];
|
||||
/** Counts, so the history list need not carry every line of every sale. */
|
||||
purchased_lines?: number;
|
||||
enquiry_lines?: number;
|
||||
client_created_at?: string;
|
||||
server_created_at: string;
|
||||
}
|
||||
|
||||
export interface ApiDashboardSummary {
|
||||
date: string;
|
||||
from: string;
|
||||
to: string;
|
||||
timezone: string;
|
||||
total_revenue_paise: number;
|
||||
currency: string;
|
||||
sales_count: number;
|
||||
enquiries_count: number;
|
||||
active_customer_count: number;
|
||||
visitors_count: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The shop floor (LOYALY.md §6/§7/§20)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One person the shop is dealing with right now.
|
||||
*
|
||||
* `visitor_id` ABSENT is the load-bearing case: the cameras saw a face and
|
||||
* matched nothing, and that is exactly when the app offers to name them.
|
||||
* It must stay distinguishable from a known customer who has no name yet.
|
||||
*/
|
||||
export interface ApiFloorVisit {
|
||||
visit_id: string;
|
||||
site_id: string;
|
||||
site_slug?: string;
|
||||
detected_at: string;
|
||||
status: 'waiting' | 'attending' | 'completed' | 'cancelled';
|
||||
visitor_id?: string;
|
||||
visitor_ref?: string;
|
||||
label?: string;
|
||||
phone?: string;
|
||||
previous_visits: number;
|
||||
attended_by?: string;
|
||||
attended_by_name?: string;
|
||||
attended_by_me: boolean;
|
||||
attended_at?: string;
|
||||
image?: {available: boolean; url?: string | null; reason?: string};
|
||||
}
|
||||
|
||||
export interface ApiCustomerCreated {
|
||||
id: string;
|
||||
ref: string;
|
||||
label: string;
|
||||
full_name?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
/** §16 — "processed" for a new sale, "already_processed" for a replay. */
|
||||
export interface ApiSaleResult {
|
||||
status: 'processed' | 'already_processed';
|
||||
sale_id: string;
|
||||
sale?: ApiSale;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user