Reported missing by the owner: in the reference an expert could open any
of their referred customers and use the app AS them — browse, see
balance and debt, place orders — «یکجورایی انگار با اکانت اون ها لاگین
کرده». D21 had deliberately left it out as a session-hijack primitive.
The owner asked for the capability back with the implementation left to
judgement, so the scope is the reference's and the mechanism is not.
Scope: everything, as the reference had it. Its axios interceptor
swapped the bearer token, so every request went as the customer,
document upload included. No route is closed in act-as mode.
Mechanism: the reference minted and handed over the customer's own
permanent api_token. Here the expert keeps their own token and sends
X-Act-As-Customer, resolved in one place — CustomerJwtStrategy
.validateExpert. That single point is why all 17 customer routes work
unchanged: validate() already returns a principal whose `id` is a
Customer id and every customer route reads it. It also fixes four real
defects of the original:
- ownership is re-checked on EVERY request, with referredByAgentId in
the same where clause as the id, so another expert's customer and a
non-existent id both answer 403, and a customer moved to another
expert loses access at once rather than at token expiry;
- nothing durable is handed out — the reference's token sat in
localStorage with no expiry and kept working after the expert left;
- adminUserId survives, so an order placed this way is attributable;
in the reference the request was literally the customer's;
- 403 rather than 401, so the app clears the act-as state instead of
logging a perfectly valid session out.
Performance: one findFirst on the primary key, only when the header is
present, and it replaces the trading-account lookup rather than adding
to it.
Three things the reference never needed, because it swapped the whole
session. queryCache's `me:` keys, favouritesStore and wageQuoteStore all
dropped their data on a TOKEN change, which act-as does not cause — so
an expert would have seen their own profile, orders, balance, ledger and
favourites inside the customer's account. The wage-quote one is the
money case: making charge is per customer, so the figure on screen would
not have been the one the order was priced at. And uploadDocument builds
its own headers, so an expert's upload would have been filed against
their own account, silently.
Added with no reference equivalent: a permanent «در حال مشاهده بهعنوان
…» bar. There, impersonation was invisible once the token had been
swapped, and placing an order on the wrong customer's gold debt was one
tap away. It is also the exit — the reference's only way out was a
logout that ended the expert's own session too. The cart is cleared in
both directions.
CustomerJwtStrategy had no test coverage at all and is now where this
feature's whole authorisation boundary lives: act-as-customer.spec.ts
covers 11 cases, including the header being ignored on a customer token,
the no-header path asserting the extra query does not run, and a revoked
session short-circuiting before the header is read.
api: 308/308 tests, 28 suites; both apps vue-tsc clean.
اپ: کارشناس فروش میتواند وارد حساب مشتریان خود شود، وضعیتشان را ببیند و از طرفشان سفارش ثبت کند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
490 lines
16 KiB
TypeScript
490 lines
16 KiB
TypeScript
import type { CreateOrderDto } from '@gold-bank/shared-types';
|
|
|
|
const BASE = '/api';
|
|
const TOKEN_KEY = 'gold-bank:token';
|
|
/** Must match stores/session.ts — the act-as target, if any. */
|
|
const ACTING_AS_KEY = 'gold-bank:actingAs';
|
|
|
|
/**
|
|
* Called when the API rejects our token. Wired up in main.ts rather than
|
|
* imported from the store, so this module stays free of a Pinia dependency
|
|
* (the client is used before Pinia is active during app bootstrap).
|
|
*/
|
|
let onUnauthorized: (() => void) | null = null;
|
|
export function setUnauthorizedHandler(fn: () => void) {
|
|
onUnauthorized = fn;
|
|
}
|
|
|
|
/** Thrown for a 401 so callers can tell "session gone" from any other failure. */
|
|
export class UnauthorizedError extends Error {
|
|
constructor(path: string) {
|
|
super(`API ${path} rejected the session (401)`);
|
|
this.name = 'UnauthorizedError';
|
|
}
|
|
}
|
|
|
|
function readToken(): string | null {
|
|
try {
|
|
return localStorage.getItem(TOKEN_KEY);
|
|
} catch {
|
|
// Private mode / blocked storage — treat as logged out rather than throwing.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* «ورود به حساب مشتری» — the customer a sales expert is working inside.
|
|
*
|
|
* Read from storage rather than the store for the same reason as the token: this
|
|
* module must not depend on Pinia. The server treats this as a request, not a
|
|
* fact: it re-checks on every call that the customer is actually referred by
|
|
* this expert, so a forged value is a 403 and reveals nothing.
|
|
*/
|
|
function readActingAsId(): string | null {
|
|
try {
|
|
const raw = sessionStorage.getItem(ACTING_AS_KEY);
|
|
if (!raw) return null;
|
|
const parsed = JSON.parse(raw) as { id?: unknown };
|
|
return typeof parsed?.id === 'string' && parsed.id ? parsed.id : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
|
const token = readToken();
|
|
const actingAs = readActingAsId();
|
|
|
|
const res = await fetch(`${BASE}${path}`, {
|
|
...options,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
// Customer JWT from POST /customer/auth/verify-otp. The API derives the
|
|
// customer from this, never from an id in the URL.
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
// Sent on every request while impersonating, which is what makes the whole
|
|
// app render from the customer's point of view — the reference achieved
|
|
// the same by swapping the bearer token in its axios interceptor
|
|
// (henza_pwa plugins/axios.ts:18 via getUserToken()).
|
|
...(actingAs ? { 'X-Act-As-Customer': actingAs } : {}),
|
|
...options?.headers,
|
|
},
|
|
});
|
|
|
|
if (res.status === 401) {
|
|
// Expired or revoked: drop the session and send the user to login, so no
|
|
// stale personal data stays on screen behind a dead token.
|
|
onUnauthorized?.();
|
|
throw new UnauthorizedError(path);
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const body = await res.text();
|
|
throw new Error(`API ${path} failed (${res.status}): ${body}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
export interface ProductVariantSummary {
|
|
id: string;
|
|
title: string;
|
|
rashWeightGrams: string;
|
|
defaultWagePercent: string;
|
|
/// On-hand stock and the "orderable at zero stock" flag. Both exist on
|
|
/// ProductVariant and are already returned by GET /catalog/products (the
|
|
/// catalog service includes every variant field) — declared here so the UI
|
|
/// can read them.
|
|
qty?: number;
|
|
isPreOrder?: boolean;
|
|
}
|
|
|
|
export interface CategorySummary {
|
|
id: string;
|
|
title: string;
|
|
parentId: string | null;
|
|
}
|
|
|
|
export interface BrandSummary {
|
|
id: string;
|
|
title: string;
|
|
}
|
|
|
|
export interface CollectionSummary {
|
|
id: string;
|
|
title: string;
|
|
description: string | null;
|
|
isActive: boolean;
|
|
}
|
|
|
|
export interface ProductSummary {
|
|
id: string;
|
|
title: string;
|
|
description: string | null;
|
|
/// Legacy Product.product_code — the human-facing code. Already returned by
|
|
/// GET /catalog/products.
|
|
productCode?: string | null;
|
|
variants: ProductVariantSummary[];
|
|
images: Array<{ url: string }>;
|
|
category: CategorySummary | null;
|
|
brand: BrandSummary | null;
|
|
collections: Array<{ collectionId: string; collection: CollectionSummary }>;
|
|
}
|
|
|
|
export interface LedgerEntrySummary {
|
|
id: string;
|
|
currency: string;
|
|
direction: string;
|
|
sourceType: string;
|
|
amount: string;
|
|
confirmed: boolean;
|
|
createdAt: string;
|
|
note: string | null;
|
|
}
|
|
|
|
export interface OrderSummary {
|
|
id: string;
|
|
orderNumber: string;
|
|
status: string;
|
|
totalGoldWeightGrams: string;
|
|
isSuspendedOverCreditLimit: boolean;
|
|
}
|
|
|
|
export interface OrderItemDetail {
|
|
id: string;
|
|
productVariantId: string;
|
|
quantity: number;
|
|
rashWeightGramsSnapshot: string;
|
|
wagePercentSnapshot: string;
|
|
discountPercentSnapshot: string;
|
|
computedWeightGrams: string;
|
|
flatRialCharge: string;
|
|
}
|
|
|
|
export interface OrderDetail {
|
|
id: string;
|
|
orderNumber: string;
|
|
status: string;
|
|
totalGoldWeightGrams: string;
|
|
wageWeightGrams: string;
|
|
flatRialCharge: string;
|
|
isSuspendedOverCreditLimit: boolean;
|
|
deliveredAt: string | null;
|
|
createdAt: string;
|
|
items: OrderItemDetail[];
|
|
}
|
|
|
|
export interface CustomerDetail {
|
|
id: string;
|
|
fullName: string;
|
|
mobile: string;
|
|
nationalCode: string | null;
|
|
cityId: string | null;
|
|
goldCreditLimitGrams: string;
|
|
goldDebtGrams: string;
|
|
fiatDebtRial: string;
|
|
verificationStep: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface GoldPriceQuote {
|
|
id: string;
|
|
karatPerMille: number;
|
|
pricePerGramRial: string;
|
|
source: string;
|
|
quotedAt: string;
|
|
}
|
|
|
|
/** One admin-uploaded home banner slide — raw shape as saved by the admin
|
|
* panel's manage/Const.vue "اسلایدر موبایل" tab (image + optional link). */
|
|
export interface AdminSlide {
|
|
image: string;
|
|
link: string;
|
|
}
|
|
|
|
|
|
/**
|
|
* D21 — a sales expert signed into the app. Ordinary customers get
|
|
* `isSalesExpert: false` and neither expert screen is shown to them.
|
|
*/
|
|
export interface ExpertIdentity {
|
|
isSalesExpert: boolean;
|
|
type: 'customer' | 'sales-expert';
|
|
}
|
|
|
|
/** One of the expert's referred customers ("انتخاب مشتری"). */
|
|
export interface ExpertCustomer {
|
|
id: string;
|
|
/** Legacy key the reference's card bound to. */
|
|
name: string;
|
|
fullName: string;
|
|
mobile: string;
|
|
phone: string | null;
|
|
store_name: string | null;
|
|
pasazh_name: string | null;
|
|
gram_level: string;
|
|
remain: string;
|
|
rial_remain: string;
|
|
active: number;
|
|
createdAt: string;
|
|
}
|
|
|
|
/** One row of "سفارش مشتریان". */
|
|
export interface ExpertChildOrder {
|
|
id: string;
|
|
orderNumber: string;
|
|
status: string;
|
|
/** Total order weight — the reference's `item.w` (ChildOrders.vue:55). */
|
|
w: string;
|
|
totalGoldWeightGrams: string;
|
|
createdAt: string;
|
|
items: OrderItemDetail[];
|
|
customer: { id: string; name: string; mobile: string } | null;
|
|
}
|
|
/**
|
|
* D24 — "گزارش", the account statement.
|
|
*
|
|
* The field names are the reference's (henza_pwa/src/type/accounting.ts), so
|
|
* the ported components bind exactly as they did. Money and weights travel as
|
|
* decimal STRINGS, never as JS numbers, so no figure is rounded in transit.
|
|
*/
|
|
export interface ReportRow {
|
|
id: number;
|
|
/** The ledger entry id; what the detail call is keyed on. */
|
|
factor_Code: string;
|
|
sh_factor: string;
|
|
tarikh: string;
|
|
/** Gold in / out, as separate positive columns. */
|
|
geram_v: string;
|
|
geram_kh: string;
|
|
rial_v: string;
|
|
rial_kh: string;
|
|
/** Running balances AFTER this row. Signed: negative means in credit. */
|
|
tahesab_T: string;
|
|
tahesab_P: string;
|
|
/** No foreign-currency ledger in the target — always zero (REP-01). */
|
|
rial_V2: string;
|
|
rial_Kh2: string;
|
|
tahesabP2: string;
|
|
sourceType: string;
|
|
note: string | null;
|
|
}
|
|
|
|
export interface ReportPage {
|
|
result: ReportRow[];
|
|
count: number;
|
|
page: number;
|
|
limit: number;
|
|
}
|
|
|
|
/** One document's detail. Fields the target cannot know are null, not invented. */
|
|
export interface ReportDetailRow {
|
|
factor_Code: string;
|
|
sh_factor: string;
|
|
tarikh: string;
|
|
vazn: string;
|
|
ayar: number | null;
|
|
no: string;
|
|
sh_sharti: string | null;
|
|
name_az: string | null;
|
|
tabdil750_v: string;
|
|
tabdil750_kh: string;
|
|
fi: string | null;
|
|
fiF: string | null;
|
|
Darsad_Talayee: string | null;
|
|
accountName: string | null;
|
|
bankName: string | null;
|
|
transferTo: string | null;
|
|
receivedAt: string | null;
|
|
note: string | null;
|
|
nO_kar: null;
|
|
jens_felez: null;
|
|
Darsad: null;
|
|
maliatDarsad: null;
|
|
}
|
|
|
|
export interface ReportBalance {
|
|
arz: string;
|
|
mandeh: string;
|
|
}
|
|
/** GET /customer/me/accounting-balance. Signed like the app: positive = owes. */
|
|
export interface AccountingBalance {
|
|
source: 'accounting' | 'stored' | 'none'
|
|
gold: string | null
|
|
rial: string | null
|
|
at: string | null
|
|
provider: string | null
|
|
}
|
|
|
|
/** POST /customer/me/wage-quote — one variant's making charge for the signed-in customer. */
|
|
export interface WageQuote {
|
|
variantId: string
|
|
variantPercent: string
|
|
customerPercent: string
|
|
promotionPercent: string
|
|
finalPercent: string
|
|
}
|
|
|
|
function iamAppUrl(path: string) {
|
|
const base = import.meta.env.BASE_URL || '/'
|
|
const prefix = base.endsWith('/') ? base : `${base}/`
|
|
return `${window.location.origin}${prefix}${path.replace(/^\//, '')}`
|
|
}
|
|
|
|
export const api = {
|
|
listProducts: () => request<ProductSummary[]>('/catalog/products'),
|
|
/**
|
|
* Step 1 of login — ports henza_server ApiMobileController::userAuth.
|
|
* Always resolves `{ sent: true }`, even for an unregistered number, so the
|
|
* endpoint cannot be used to discover which numbers are customers.
|
|
*/
|
|
requestOtp: (username: string) =>
|
|
request<{ sent: boolean }>('/customer/auth/request-otp', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ username }),
|
|
}),
|
|
|
|
/**
|
|
* Step 2 — ports ApiMobileController::userVerfiy. The server compares the
|
|
* code and returns a token plus that one customer. A wrong, expired or
|
|
* already-used code is a 401.
|
|
*/
|
|
verifyOtp: (username: string, password: string) =>
|
|
request<{ token: string; customer: { id: string; mobile: string }; iamBootstrapUrl?: string }>(
|
|
'/customer/auth/verify-otp',
|
|
{ method: 'POST', body: JSON.stringify({ username, password }) },
|
|
),
|
|
|
|
ssoStart: () =>
|
|
request<{ enabled: boolean; authorize_url: string | null }>(
|
|
`/customer/auth/sso/start?redirect_uri=${encodeURIComponent(iamAppUrl('auth/iam/callback'))}`,
|
|
),
|
|
|
|
ssoCallback: (code: string, state: string) =>
|
|
request<{ token: string; customer: { id: string; mobile: string } }>('/customer/auth/sso/callback', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ code, state }),
|
|
}),
|
|
|
|
ssoLogoutUrl: () =>
|
|
request<{ logout_url: string | null }>(
|
|
`/customer/auth/sso/logout-url?return_url=${encodeURIComponent(iamAppUrl(''))}`,
|
|
),
|
|
|
|
endSharedSession: () =>
|
|
request<{ ended: number }>('/customer/auth/logout', { method: 'POST', body: '{}' }),
|
|
|
|
// The three reads below identify the customer by TOKEN. They replaced
|
|
// /customers/:id, /:id/orders and /:id/ledger-entries, which took the id from
|
|
// the URL and let anyone read anyone (SEC-08).
|
|
getMe: () => request<CustomerDetail>('/customer/me'),
|
|
/** D27 §12 — this customer's balance, read from the accounting software on every call. */
|
|
getAccountingBalance: () => request<AccountingBalance>('/customer/me/accounting-balance'),
|
|
getMyOrders: () => request<OrderDetail[]>('/customer/me/orders'),
|
|
getMyLedgerEntries: () => request<LedgerEntrySummary[]>('/customer/me/ledger-entries'),
|
|
/** The making charge the server will charge for these variants (MON-08). */
|
|
getWageQuotes: (variantIds: string[]) =>
|
|
request<WageQuote[]>('/customer/me/wage-quote', { method: 'POST', body: JSON.stringify({ variantIds }) }),
|
|
/** Favourite product ids, newest first — legacy get-favorites (FAV-01). */
|
|
getFavorites: () => request<{ ids: string[] }>('/customer/me/favorites'),
|
|
/** Legacy add-to-fav: adds or removes; answers the new state. */
|
|
toggleFavorite: (productId: string) =>
|
|
request<{ favorite: boolean }>('/customer/me/favorites/toggle', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ productId }),
|
|
}),
|
|
createOrder: (dto: CreateOrderDto) => request<OrderSummary>('/orders', { method: 'POST', body: JSON.stringify(dto) }),
|
|
getLatestGoldPrice: (karatPerMille = 750) => request<GoldPriceQuote>(`/pricing/latest?karatPerMille=${karatPerMille}`),
|
|
/** GET /home/slider — public, no auth. Empty array when the admin hasn't
|
|
* configured any slides (the caller falls back to a derived hero). */
|
|
getHomeSlider: () => request<AdminSlide[]>('/home/slider'),
|
|
|
|
// ── D21: sales-expert screens ────────────────────────────────────────
|
|
// All three scope themselves on the AdminUser id inside the token; no id
|
|
// ever travels from the client. An ordinary customer gets 403 on the first
|
|
// two, and `isSalesExpert: false` from whoami so the menu entries stay hidden.
|
|
getExpertIdentity: () => request<ExpertIdentity>('/expert/whoami'),
|
|
getExpertCustomers: (search?: string | null) =>
|
|
request<ExpertCustomer[]>('/expert/my-customers', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ search: search ?? null }),
|
|
}),
|
|
getExpertChildOrders: (page = 1, search?: string | null) =>
|
|
request<{ rows: ExpertChildOrder[]; total: number; page: number; limit: number }>(
|
|
'/expert/child-orders',
|
|
{ method: 'POST', body: JSON.stringify({ page, search: search ?? null }) },
|
|
),
|
|
|
|
// ── complete_profile ─────────────────────────────────────────────────
|
|
// Ports POST /major/complete (henza_server Major/CustomerController::complete)
|
|
// and the PWA's document uploads, which went through the same file/create the
|
|
// admin used but behind the customer guard (routes/api.php:95).
|
|
/**
|
|
* Uploads one document and resolves its stored id, which is what
|
|
* completeProfile sends. multipart/form-data, so it bypasses `request()` —
|
|
* that helper forces a JSON content-type, and setting it here would break
|
|
* the multipart boundary.
|
|
*/
|
|
uploadDocument: async (file: File, purpose: string): Promise<{ id: string; url: string }> => {
|
|
const body = new FormData();
|
|
body.append('file', file);
|
|
body.append('purpose', purpose);
|
|
|
|
const token = readToken();
|
|
// This route bypasses `request()`, so the act-as header has to be repeated
|
|
// here — without it an expert uploading a customer's documents would file
|
|
// them against their OWN account, silently and with no error.
|
|
const actingAs = readActingAsId();
|
|
const res = await fetch(`${BASE}/customer/me/documents`, {
|
|
method: 'POST',
|
|
// No Content-Type: the browser sets it with the boundary.
|
|
headers: {
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
...(actingAs ? { 'X-Act-As-Customer': actingAs } : {}),
|
|
},
|
|
body,
|
|
});
|
|
|
|
if (res.status === 401) {
|
|
onUnauthorized?.();
|
|
throw new UnauthorizedError('/customer/me/documents');
|
|
}
|
|
if (!res.ok) {
|
|
throw new Error(`آپلود فایل ناموفق بود (${res.status})`);
|
|
}
|
|
return res.json();
|
|
},
|
|
|
|
/**
|
|
* The three ids keep the reference's snake_case keys so the payload matches
|
|
* what henza_pwa sent (completeProfile.vue:136-140).
|
|
*/
|
|
completeProfile: (payload: {
|
|
business_license_image_id: string;
|
|
shop_image_id: string;
|
|
ncode_image_id: string;
|
|
}) =>
|
|
request<{ ok: boolean }>('/customer/me/complete-profile', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
// ── report / report detail (D24) ─────────────────────────────────────
|
|
// The reference proxied an external accounting system
|
|
// (henza_server Customer/TahesabController). These read the target's own
|
|
// ledger instead — no external call — and the customer comes from the token.
|
|
getReport: (page = 1) =>
|
|
request<ReportPage>(`/customer/me/report?page=${page}`),
|
|
getReportDetailList: (page = 1) =>
|
|
request<{ result: ReportDetailRow[]; page: number; limit: number }>(
|
|
`/customer/me/report/detail?page=${page}`,
|
|
),
|
|
getReportBalances: () =>
|
|
request<{ status: number; message: string; result: ReportBalance[] }>(
|
|
'/customer/me/report/balances',
|
|
),
|
|
getReportDocument: (id: string) =>
|
|
request<{ result: ReportDetailRow[]; page: number; limit: number }>(
|
|
`/customer/me/report/${encodeURIComponent(id)}`,
|
|
),
|
|
};
|