From ddcc8c564930aa160ab490d41f4650e26fa72cb4 Mon Sep 17 00:00:00 2001 From: "s.esmaeili" Date: Mon, 12 Oct 2026 11:48:59 +0330 Subject: [PATCH] feat(apps): a sales expert can work inside their customer's account (D29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../customer-auth/act-as-customer.spec.ts | 184 +++++++++++++++++ .../modules/customer-auth/app-principal.ts | 26 ++- .../customer-auth/customer-jwt.strategy.ts | 76 ++++++- .../src/modules/orders/orders.controller.ts | 16 +- .../src/modules/orders/orders.service.spec.ts | 33 +++ apps/api/src/modules/orders/orders.service.ts | 20 +- .../sales-expert-app.controller.ts | 10 +- apps/user-app-v2/src/App.vue | 12 +- apps/user-app-v2/src/api/client.ts | 36 +++- .../src/components/expert/ActingAsBar.vue | 113 +++++++++++ apps/user-app-v2/src/stores/favoritesStore.ts | 11 +- apps/user-app-v2/src/stores/queryCache.ts | 10 +- apps/user-app-v2/src/stores/session.ts | 58 ++++++ apps/user-app-v2/src/stores/wageQuoteStore.ts | 6 +- .../src/views/expert/CustomerChoiceView.vue | 79 +++++++- apps/user-app/src/App.vue | 12 +- apps/user-app/src/api/client.ts | 36 +++- .../src/components/expert/ActingAsBar.vue | 112 +++++++++++ apps/user-app/src/stores/favoritesStore.ts | 11 +- apps/user-app/src/stores/queryCache.ts | 10 +- apps/user-app/src/stores/session.ts | 58 ++++++ apps/user-app/src/stores/wageQuoteStore.ts | 6 +- .../src/views/expert/CustomerChoiceView.vue | 79 +++++++- docs/EXPERT-ACT-AS-PLAN.md | 188 ++++++++++++++++++ docs/parity/CHANGELOG.md | 94 +++++++++ docs/parity/DECISIONS.md | 62 ++++++ docs/parity/ISSUES.md | 22 ++ docs/parity/PROGRESS.md | 3 +- 28 files changed, 1337 insertions(+), 46 deletions(-) create mode 100644 apps/api/src/modules/customer-auth/act-as-customer.spec.ts create mode 100644 apps/user-app-v2/src/components/expert/ActingAsBar.vue create mode 100644 apps/user-app/src/components/expert/ActingAsBar.vue create mode 100644 docs/EXPERT-ACT-AS-PLAN.md diff --git a/apps/api/src/modules/customer-auth/act-as-customer.spec.ts b/apps/api/src/modules/customer-auth/act-as-customer.spec.ts new file mode 100644 index 0000000..52208eb --- /dev/null +++ b/apps/api/src/modules/customer-auth/act-as-customer.spec.ts @@ -0,0 +1,184 @@ +import { ForbiddenException, UnauthorizedException } from '@nestjs/common'; +import { AdminRole } from '@prisma/client'; + +// passport-jwt reads the secret at construction; give it something to find. +process.env.JWT_CUSTOMER_SECRET = 'test-secret'; + +// @nestjs/jwt v12 is ESM only and this jest setup is CommonJS. The strategy +// pulls it in transitively (customer-auth.service exports customerJwtSecret), +// so it is stubbed here exactly as master-otp.spec.ts does. +jest.mock('@nestjs/jwt', () => ({ JwtService: class {} })); + +import { CustomerJwtStrategy } from './customer-jwt.strategy'; +import { + ACT_AS_CUSTOMER_HEADER, + CUSTOMER_TOKEN_TYPE, + EXPERT_TOKEN_TYPE, +} from './app-principal'; + +/** + * «ورود به حساب مشتری» — a sales expert working inside one of their own + * customers' accounts (owner request 2026-10-12). + * + * The reference did this by handing the expert the customer's own permanent + * `api_token` (ApiMobileController.php:197-201), which meant the request was + * literally the customer's: unauditable, valid forever, and still working after + * the expert left. Here the expert keeps their own token and names the customer + * in a header, and this strategy is the ONLY place that decides whether that is + * allowed — so these cases are the whole security boundary of the feature. + * + * The strategy had no test coverage at all before this file. + */ +describe('CustomerJwtStrategy — a sales expert acting as their customer', () => { + const EXPERT = { + id: 'adm_1', + mobile: '09120000001', + role: AdminRole.SALES_EXPERT, + isActive: true, + salesExpertProfile: { + tradingCustomerId: 'cus_trading', + tradingCustomer: { id: 'cus_trading', isActive: true, isArchived: false }, + }, + }; + + /** The expert's own referred customer, as the scoped lookup would find them. */ + const MINE = { id: 'cus_mine', mobile: '09125555555' }; + + function build(opts: { admin?: unknown; scopedCustomer?: unknown; customer?: unknown } = {}) { + const prisma = { + adminUser: { findUnique: jest.fn().mockResolvedValue('admin' in opts ? opts.admin : EXPERT) }, + customer: { + // The act-as lookup: id AND referredByAgentId in one where clause, so + // "not mine" and "does not exist" are the same answer. + findFirst: jest.fn().mockResolvedValue('scopedCustomer' in opts ? opts.scopedCustomer : MINE), + findUnique: jest.fn().mockResolvedValue( + 'customer' in opts + ? opts.customer + : { id: 'cus_1', mobile: '09121111111', isActive: true, isArchived: false }, + ), + }, + }; + // Session tracking off — isActive() answers true without a query. + const sessions = { isActive: jest.fn().mockResolvedValue(true) }; + const strategy = new CustomerJwtStrategy(prisma as never, sessions as never); + return { strategy, prisma }; + } + + const req = (actAs?: string) => + ({ headers: actAs === undefined ? {} : { [ACT_AS_CUSTOMER_HEADER]: actAs } }) as never; + + const expertToken = { sub: 'adm_1', mobile: '09120000001', typ: EXPERT_TOKEN_TYPE } as never; + const customerToken = { sub: 'cus_1', mobile: '09121111111', typ: CUSTOMER_TOKEN_TYPE } as never; + + it('serves the customer as themselves, keeping the expert as the real actor', async () => { + const { strategy } = build(); + const me = await strategy.validate(req('cus_mine'), expertToken); + + // `id` is what every customer route reads — this is the whole feature. + expect(me.id).toBe('cus_mine'); + expect(me.actingAsCustomerId).toBe('cus_mine'); + // The real actor is never lost, which is what the reference could not do. + expect(me.adminUserId).toBe('adm_1'); + expect(me.type).toBe(EXPERT_TOKEN_TYPE); + }); + + it('scopes the lookup to this expert in the query itself', async () => { + const { strategy, prisma } = build(); + await strategy.validate(req('cus_mine'), expertToken); + expect(prisma.customer.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: 'cus_mine', + referredByAgentId: 'adm_1', + isActive: true, + isArchived: false, + }), + }), + ); + }); + + it("refuses another expert's customer with 403, not 401", async () => { + // The scoped lookup finds nothing — same answer as a non-existent id, so an + // id cannot be probed for existence. + const { strategy } = build({ scopedCustomer: null }); + // 403 and not 401 on purpose: the expert's own session is fine, so the app + // clears the act-as state rather than logging them out. + await expect(strategy.validate(req('cus_someone_else'), expertToken)).rejects.toThrow( + ForbiddenException, + ); + }); + + it('refuses an inactive or archived customer', async () => { + // isActive/isArchived are part of the same where clause, so this is the + // identical "no row" path; asserted separately because it is a distinct + // business case that must not quietly start working. + const { strategy } = build({ scopedCustomer: null }); + await expect(strategy.validate(req('cus_disabled'), expertToken)).rejects.toThrow( + ForbiddenException, + ); + }); + + it('cuts the expert off immediately once they are deactivated', async () => { + const { strategy } = build({ admin: { ...EXPERT, isActive: false } }); + await expect(strategy.validate(req('cus_mine'), expertToken)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('cuts them off immediately once they are no longer a sales expert', async () => { + const { strategy } = build({ admin: { ...EXPERT, role: AdminRole.ACCOUNTANT } }); + await expect(strategy.validate(req('cus_mine'), expertToken)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('ignores the header on an ordinary customer token', async () => { + // Not an error — simply no effect. A customer cannot grant themselves + // anything by sending it, and there is nothing for them to discover. + const { strategy, prisma } = build(); + const me = await strategy.validate(req('cus_mine'), customerToken); + expect(me.id).toBe('cus_1'); + expect(me.actingAsCustomerId).toBeUndefined(); + expect(prisma.customer.findFirst).not.toHaveBeenCalled(); + }); + + it('falls back to the trading account when no header is sent', async () => { + const { strategy, prisma } = build(); + const me = await strategy.validate(req(), expertToken); + expect(me.id).toBe('cus_trading'); + expect(me.actingAsCustomerId).toBeUndefined(); + // No extra query on the ordinary path — the act-as lookup never runs. + expect(prisma.customer.findFirst).not.toHaveBeenCalled(); + }); + + it('treats a blank or whitespace header as absent', async () => { + const { strategy } = build(); + await expect(strategy.validate(req(' '), expertToken)).resolves.toMatchObject({ + id: 'cus_trading', + }); + }); + + it('takes the first value when the header is sent twice', async () => { + const { strategy, prisma } = build(); + const dup = { headers: { [ACT_AS_CUSTOMER_HEADER]: ['cus_mine', 'cus_other'] } } as never; + await strategy.validate(dup, expertToken); + expect(prisma.customer.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ id: 'cus_mine' }) }), + ); + }); + + it('refuses a revoked session before looking at the header at all', async () => { + const prisma = { + adminUser: { findUnique: jest.fn() }, + customer: { findFirst: jest.fn(), findUnique: jest.fn() }, + }; + const strategy = new CustomerJwtStrategy( + prisma as never, + { isActive: jest.fn().mockResolvedValue(false) } as never, + ); + await expect(strategy.validate(req('cus_mine'), expertToken)).rejects.toThrow( + UnauthorizedException, + ); + expect(prisma.adminUser.findUnique).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/customer-auth/app-principal.ts b/apps/api/src/modules/customer-auth/app-principal.ts index 5b483d5..addab53 100644 --- a/apps/api/src/modules/customer-auth/app-principal.ts +++ b/apps/api/src/modules/customer-auth/app-principal.ts @@ -54,13 +54,37 @@ export interface AppJwtPayload { * Customer.referredByAgentId points at the AdminUser, not at the trading row. */ export interface AuthenticatedCustomer { - /** Customer id. For an expert, their own trading account. */ + /** + * Customer id. For an expert: their own trading account, or — while acting as + * one of their customers — that customer. Every customer route reads this, so + * setting it here is what makes the whole app render from the customer's point + * of view without touching a single route. + */ id: string; mobile: string; /** Which kind of principal this is. */ type: AppTokenType; /** The AdminUser id — present only for a sales expert. */ adminUserId?: string; + /** + * Set only while a sales expert is acting as one of their referred customers + * («ورود به حساب مشتری»). Equal to `id` when set. + * + * It exists so the real actor is never lost: `id` says whose data this is, + * `adminUserId` says who is actually driving. The reference had no equivalent + * — it handed the expert the customer's own `api_token`, so a request was + * indistinguishable from the customer's own and nothing could be audited + * afterwards. + */ + actingAsCustomerId?: string; +} + +/** The request header that asks to act as one of the expert's own customers. */ +export const ACT_AS_CUSTOMER_HEADER = 'x-act-as-customer'; + +/** True when this principal is a sales expert acting as one of their customers. */ +export function isActingAsCustomer(me: AuthenticatedCustomer): boolean { + return Boolean(me.actingAsCustomerId); } /** True when this principal is a sales expert. */ diff --git a/apps/api/src/modules/customer-auth/customer-jwt.strategy.ts b/apps/api/src/modules/customer-auth/customer-jwt.strategy.ts index 801a0dd..0dfc642 100644 --- a/apps/api/src/modules/customer-auth/customer-jwt.strategy.ts +++ b/apps/api/src/modules/customer-auth/customer-jwt.strategy.ts @@ -1,10 +1,12 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { AdminRole } from '@prisma/client'; +import type { Request } from 'express'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { PrismaService } from '../../prisma/prisma.service'; import { customerJwtSecret } from './customer-auth.service'; import { + ACT_AS_CUSTOMER_HEADER, CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, type AppJwtPayload, @@ -13,8 +15,10 @@ import { import { AppSessionService } from '../sessions/app-session.service'; export { + ACT_AS_CUSTOMER_HEADER, CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, + isActingAsCustomer, type AuthenticatedCustomer, } from './app-principal'; @@ -50,10 +54,13 @@ export class CustomerJwtStrategy extends PassportStrategy(Strategy, 'customer-jw jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, secretOrKey: customerJwtSecret(), + // Needed only to read the act-as header; the token itself is still the + // only thing that establishes WHO is calling. + passReqToCallback: true, }); } - async validate(payload: AppJwtPayload): Promise { + async validate(req: Request, payload: AppJwtPayload): Promise { // نشست باطل‌شده (خروج سراسری از IdP یا ابطال محلی). در حالت خاموشِ ردیابی، // بدون هیچ کوئری true برمی‌گردد ⇒ هزینه و رفتار دقیقاً مثل قبل. if (!(await this.sessions.isActive(payload?.jti))) { @@ -61,7 +68,7 @@ export class CustomerJwtStrategy extends PassportStrategy(Strategy, 'customer-jw } if (payload?.typ === EXPERT_TOKEN_TYPE) { - return this.validateExpert(payload); + return this.validateExpert(payload, this.actAsHeader(req)); } // A token that is not explicitly a customer token is refused, whatever @@ -84,6 +91,17 @@ export class CustomerJwtStrategy extends PassportStrategy(Strategy, 'customer-jw return { id: customer.id, mobile: customer.mobile, type: CUSTOMER_TOKEN_TYPE }; } + /** + * The act-as header, or null. Deliberately read ONLY for an expert token: on + * a customer's token the header has no effect at all, rather than being an + * error — a customer cannot grant themselves anything by sending it. + */ + private actAsHeader(req: Request): string | null { + const raw = (req?.headers as Record | undefined)?.[ACT_AS_CUSTOMER_HEADER]; + const value = Array.isArray(raw) ? raw[0] : raw; + return typeof value === 'string' && value.trim() ? value.trim() : null; + } + /** * A sales expert. `sub` is their AdminUser id, so the role and active flag * are re-checked on every request: demoting an expert or deactivating them @@ -91,8 +109,28 @@ export class CustomerJwtStrategy extends PassportStrategy(Strategy, 'customer-jw * * The trading Customer row is resolved here rather than stored in the token, * so a token issued before that row existed keeps working. + * + * ACTING AS A CUSTOMER («ورود به حساب مشتری», owner request 2026-10-12) + * + * With `actAs` set, the returned principal's `id` is that customer, so every + * customer route — orders, me, ledger, report, favourites, documents — serves + * the customer's own data with no change to any route. That is the whole + * feature, and it is why this is the only place the behaviour lives. + * + * How this differs from the reference, which did the same thing by handing the + * expert the customer's own `api_token` (ApiMobileController.php:197-201): + * + * - no token is minted, so nothing durable leaks into a device's storage + * and ending the app session ends the impersonation; + * - ownership is re-checked on EVERY request, so moving a customer to + * another expert cuts access off at once instead of at token expiry; + * - `adminUserId` survives, so the real actor is auditable — in the + * reference the request was indistinguishable from the customer's own. */ - private async validateExpert(payload: AppJwtPayload): Promise { + private async validateExpert( + payload: AppJwtPayload, + actAs: string | null, + ): Promise { const admin = await this.prisma.adminUser.findUnique({ where: { id: payload.sub }, select: { @@ -113,6 +151,36 @@ export class CustomerJwtStrategy extends PassportStrategy(Strategy, 'customer-jw throw new UnauthorizedException(); } + if (actAs) { + // Scoped by referredByAgentId in the same query as the id, so another + // expert's customer and a non-existent id are the same answer: no row, + // 403. The lookup cannot be talked out of its scope. + const customer = await this.prisma.customer.findFirst({ + where: { + id: actAs, + referredByAgentId: admin.id, + isActive: true, + isArchived: false, + }, + select: { id: true, mobile: true }, + }); + + if (!customer) { + // Not 401: the expert's own session is perfectly valid, it is this + // particular impersonation that is refused — so the app can clear the + // act-as state instead of logging them out. + throw new ForbiddenException('دسترسی به این مشتری مجاز نیست'); + } + + return { + id: customer.id, + mobile: customer.mobile, + type: EXPERT_TOKEN_TYPE, + adminUserId: admin.id, + actingAsCustomerId: customer.id, + }; + } + const trading = admin.salesExpertProfile?.tradingCustomer; if (!trading || !trading.isActive || trading.isArchived) { // The account is created at sign-in; if it is missing or has been diff --git a/apps/api/src/modules/orders/orders.controller.ts b/apps/api/src/modules/orders/orders.controller.ts index b2eefc9..f007ce8 100644 --- a/apps/api/src/modules/orders/orders.controller.ts +++ b/apps/api/src/modules/orders/orders.controller.ts @@ -22,11 +22,23 @@ import type { AuthenticatedCustomer } from '../customer-auth/customer-jwt.strate export class OrdersController { constructor(private readonly orders: OrdersService) {} - /** The customer comes from the token; the body's `customerId` is ignored. */ + /** + * The customer comes from the token; the body's `customerId` is ignored. + * + * `me.id` is already the right customer when a sales expert is acting as one + * of theirs, so such an order lands on that customer's account and ledger with + * server-side pricing (MON-08) exactly like one they placed themselves — which + * is what the reference did by lending the expert the customer's token. + * `actingAsExpertId` records who actually placed it, which the reference could + * not do. + */ @CustomerAuth() @Post() create(@Body() dto: CreateOrderDto, @CurrentCustomer() me: AuthenticatedCustomer) { - return this.orders.createOrder(dto, me.id, { placedBy: 'customer' }); + return this.orders.createOrder(dto, me.id, { + placedBy: 'customer', + actingAsExpertId: me.actingAsCustomerId ? me.adminUserId : undefined, + }); } /** diff --git a/apps/api/src/modules/orders/orders.service.spec.ts b/apps/api/src/modules/orders/orders.service.spec.ts index 4063c62..37574c9 100644 --- a/apps/api/src/modules/orders/orders.service.spec.ts +++ b/apps/api/src/modules/orders/orders.service.spec.ts @@ -154,6 +154,39 @@ describe('OrdersService.createOrder — server-side making charge', () => { } }); + /** + * «ورود به حساب مشتری»: the order is the customer's in every respect — their + * account, their pricing — and the expert is recorded only in the log, which + * is the audit trail the reference lost by lending out the customer's token. + */ + it('records which expert placed an order on a customer’s behalf', async () => { + const log = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + try { + const honest = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 10 }] } as never; + const { service, created } = build(); + await service.createOrder(honest, 'c1', { placedBy: 'customer', actingAsExpertId: 'adm_9' }); + // Still the customer's order, priced by the server. + expect(created[0].customerId).toBe('c1'); + expect(created[0].totalGoldWeightGrams.toString()).toBe('11'); + const lines = log.mock.calls.map((c) => String(c[0])).join('\n'); + expect(lines).toContain('sales expert adm_9 acting as them'); + } finally { + log.mockRestore(); + } + }); + + it('logs no expert for an ordinary customer order', async () => { + const log = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + try { + const honest = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 10 }] } as never; + await build().service.createOrder(honest, 'c1', { placedBy: 'customer' }); + const lines = log.mock.calls.map((c) => String(c[0])).join('\n'); + expect(lines).not.toContain('acting as'); + } finally { + log.mockRestore(); + } + }); + it('stays quiet when the app sent the right figures', async () => { const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); try { diff --git a/apps/api/src/modules/orders/orders.service.ts b/apps/api/src/modules/orders/orders.service.ts index 8b1d196..3aa4699 100644 --- a/apps/api/src/modules/orders/orders.service.ts +++ b/apps/api/src/modules/orders/orders.service.ts @@ -45,7 +45,16 @@ export class OrdersService { async createOrder( dto: CreateOrderDto, customerId: string, - opts: { placedBy: 'customer' | 'operator' } = { placedBy: 'operator' }, + opts: { + placedBy: 'customer' | 'operator'; + /** + * Set when a sales expert placed this through «ورود به حساب مشتری». The + * order is the customer's in every other respect; this only records who + * actually pressed the button, which the reference lost entirely by + * lending out the customer's own token. + */ + actingAsExpertId?: string; + } = { placedBy: 'operator' }, ) { const customer = await this.prisma.customer.findUnique({ where: { id: customerId } }); if (!customer) throw new NotFoundException(`Customer ${customerId} not found`); @@ -169,6 +178,15 @@ export class OrdersService { // Legacy sent these after the order was committed // (ApiMobileController.php:733-766), so a rolled-back order never texts // anyone. Fire-and-forget per D20. + // The audit trail for «ورود به حساب مشتری». Deliberately logged after the + // commit, so it only ever claims an order that exists. + if (opts.actingAsExpertId) { + this.logger.log( + `Order ${placed.orderNumber} for customer ${customerId} was placed by ` + + `sales expert ${opts.actingAsExpertId} acting as them.`, + ); + } + this.notifyOrderPlaced(placed.orderNumber, customer, totalGoldWeightGrams, isSuspendedOverCreditLimit); return placed; diff --git a/apps/api/src/modules/sales-expert-app/sales-expert-app.controller.ts b/apps/api/src/modules/sales-expert-app/sales-expert-app.controller.ts index 4c45bf6..9fdddd3 100644 --- a/apps/api/src/modules/sales-expert-app/sales-expert-app.controller.ts +++ b/apps/api/src/modules/sales-expert-app/sales-expert-app.controller.ts @@ -57,10 +57,18 @@ export class SalesExpertAppController { * Whether the signed-in principal is a sales expert. The apps call this once * after login to decide whether to show the two expert entries; the answer * comes from the token, so a customer cannot make it say yes. + * + * `actingAsCustomerId` lets an app that was reloaded mid-impersonation render + * its "viewing as …" bar from the server's view of the session rather than + * trusting its own stored state. */ @CustomerAuth() @Get('whoami') whoami(@CurrentCustomer() me: AuthenticatedCustomer) { - return { isSalesExpert: Boolean(me.adminUserId), type: me.type }; + return { + isSalesExpert: Boolean(me.adminUserId), + type: me.type, + actingAsCustomerId: me.actingAsCustomerId ?? null, + }; } } diff --git a/apps/user-app-v2/src/App.vue b/apps/user-app-v2/src/App.vue index 643cb8a..9d5e68f 100644 --- a/apps/user-app-v2/src/App.vue +++ b/apps/user-app-v2/src/App.vue @@ -6,7 +6,8 @@ already commented out in legacy's own App.vue, so neither ran there either. -->