From 28f39a31f6ad141dc9fa8c1fa6d3f0cb2ff80fb3 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sat, 15 Aug 2026 03:52:55 +0400 Subject: [PATCH] feat: AdminOrderWatcherService polls for new orders and toasts Co-Authored-By: Claude Sonnet 5 --- .../admin-order-watcher.service.spec.ts | 122 ++++++++++++++++ .../services/admin-order-watcher.service.ts | 132 ++++++++++++++++++ src/app/i18n/en.ts | 1 + src/app/i18n/hy.ts | 1 + src/app/i18n/ru.ts | 1 + src/app/i18n/translations.ts | 1 + 6 files changed, 258 insertions(+) create mode 100644 src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts create mode 100644 src/app/features/admin/shell/services/admin-order-watcher.service.ts diff --git a/src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts b/src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts new file mode 100644 index 0000000..4873f5c --- /dev/null +++ b/src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts @@ -0,0 +1,122 @@ +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { of } from 'rxjs'; +import { AdminOrderWatcherService } from './admin-order-watcher.service'; +import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway'; +import { AdminOrder, AdminOrdersListResult } from '../../orders/models/admin-order.model'; +import { UserNotificationService } from '../../../website/user-experience/services/user-notification.service'; + +function makeOrder(id: string, orderNumber: string, createdAt: string): AdminOrder { + return { + id, + orderNumber, + status: 'pending', + customer: { name: 'Test Customer', email: 't@example.com', phone: '+70000000000' }, + payment: { method: 'card', status: 'paid', amount: 1000, currency: 'RUB' }, + shipping: { address: '', method: '', trackingNumber: '' }, + items: [], + total: 1000, + currency: 'RUB', + notes: '', + internalNotes: '', + timeline: [], + archived: false, + createdAt, + updatedAt: createdAt, + }; +} + +describe('AdminOrderWatcherService', () => { + let ordersByPoll: AdminOrder[][]; + let pollIndex: number; + let notifications: UserNotificationService; + let service: AdminOrderWatcherService; + + function fakeGateway() { + return { + loadOrders: () => { + const items = ordersByPoll[pollIndex] ?? ordersByPoll[ordersByPoll.length - 1]; + const result: AdminOrdersListResult = { items, total: items.length, page: 1, pageSize: 20 }; + return of(result); + }, + }; + } + + beforeEach(() => { + pollIndex = 0; + ordersByPoll = [ + [makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'), makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z')], + ]; + + localStorage.clear(); + + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { provide: AdminOrdersLocalGateway, useValue: fakeGateway() as unknown as AdminOrdersLocalGateway }, + ], + }); + + notifications = TestBed.inject(UserNotificationService); + service = TestBed.inject(AdminOrderWatcherService); + }); + + it('does not toast on the very first poll and marks everything as acknowledged', fakeAsync(() => { + service.start(); + tick(0); + + expect(notifications.notifications().length).toBe(0); + expect(service.unreadCount()).toBe(0); + expect(service.recentOrders().map((o: AdminOrder) => o.id)).toEqual(['o2', 'o1']); + })); + + it('toasts and increments unreadCount for orders newer than the last-notified one', fakeAsync(() => { + service.start(); + tick(0); + + pollIndex = 1; + ordersByPoll.push([ + makeOrder('o3', '1003', '2026-08-15T11:00:00.000Z'), + makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'), + makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z'), + ]); + + tick(service.intervalMs()); + + expect(notifications.notifications().length).toBe(1); + expect(notifications.notifications()[0].message).toContain('1003'); + expect(notifications.notifications()[0].route).toEqual(['ru', 'backoffice', 'orders', 'o3']); + expect(service.unreadCount()).toBe(1); + })); + + it('markAllSeen resets unreadCount without clearing recentOrders', fakeAsync(() => { + service.start(); + tick(0); + + pollIndex = 1; + ordersByPoll.push([ + makeOrder('o3', '1003', '2026-08-15T11:00:00.000Z'), + makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'), + makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z'), + ]); + tick(service.intervalMs()); + + expect(service.unreadCount()).toBe(1); + + service.markAllSeen(); + + expect(service.unreadCount()).toBe(0); + expect(service.recentOrders().map((o: AdminOrder) => o.id)).toEqual(['o3', 'o2', 'o1']); + })); + + it('setIntervalSeconds updates intervalMs and rejects invalid values', () => { + service.setIntervalSeconds(30); + expect(service.intervalMs()).toBe(30000); + + service.setIntervalSeconds(0); + expect(service.intervalMs()).toBe(30000); + + service.setIntervalSeconds(-5); + expect(service.intervalMs()).toBe(30000); + }); +}); diff --git a/src/app/features/admin/shell/services/admin-order-watcher.service.ts b/src/app/features/admin/shell/services/admin-order-watcher.service.ts new file mode 100644 index 0000000..6a55841 --- /dev/null +++ b/src/app/features/admin/shell/services/admin-order-watcher.service.ts @@ -0,0 +1,132 @@ +import { Injectable, Signal, computed, inject, signal } from '@angular/core'; +import { AdminOrder } from '../../orders/models/admin-order.model'; +import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway'; +import { LocalStorageService } from '../../../../core/storage/local-storage.service'; +import { UserNotificationService } from '../../../website/user-experience/services/user-notification.service'; +import { LanguageService } from '../../../../services/language.service'; +import { TranslateService } from '../../../../i18n/translate.service'; + +const LAST_NOTIFIED_KEY = 'adminOrderWatcher.lastNotifiedOrderId.v1'; +const LAST_ACKNOWLEDGED_KEY = 'adminOrderWatcher.lastAcknowledgedOrderId.v1'; +const POLL_INTERVAL_KEY = 'adminOrderWatcher.pollIntervalMs.v1'; +export const DEFAULT_POLL_INTERVAL_MS = 15000; +const MIN_POLL_INTERVAL_MS = 1000; +const RECENT_ORDERS_LIMIT = 20; +const TOAST_DURATION_MS = 4000; + +@Injectable({ providedIn: 'root' }) +export class AdminOrderWatcherService { + private readonly gateway = inject(AdminOrdersLocalGateway); + private readonly storage = inject(LocalStorageService); + private readonly notifications = inject(UserNotificationService); + private readonly languageService = inject(LanguageService); + private readonly i18n = inject(TranslateService); + + private readonly recentOrdersSignal = signal([]); + readonly recentOrders: Signal = this.recentOrdersSignal.asReadonly(); + + private readonly lastAcknowledgedOrderIdSignal = signal(this.storage.getItem(LAST_ACKNOWLEDGED_KEY)); + + readonly unreadCount = computed(() => { + const orders = this.recentOrdersSignal(); + if (orders.length === 0) { + return 0; + } + const ackId = this.lastAcknowledgedOrderIdSignal(); + if (ackId === null) { + return orders.length; + } + const idx = orders.findIndex(order => order.id === ackId); + return idx === -1 ? orders.length : idx; + }); + + private readonly intervalMsSignal = signal(this.readStoredIntervalMs()); + readonly intervalMs: Signal = this.intervalMsSignal.asReadonly(); + + private lastNotifiedOrderId: string | null = this.storage.getItem(LAST_NOTIFIED_KEY); + private timerId: ReturnType | null = null; + private started = false; + + start(): void { + if (this.started) { + return; + } + this.started = true; + this.poll(); + this.scheduleNext(); + } + + setIntervalSeconds(seconds: number): void { + if (!Number.isFinite(seconds) || seconds < MIN_POLL_INTERVAL_MS / 1000) { + return; + } + const ms = Math.round(seconds * 1000); + this.intervalMsSignal.set(ms); + this.storage.setItem(POLL_INTERVAL_KEY, String(ms)); + if (this.started) { + this.scheduleNext(); + } + } + + markAllSeen(): void { + const newestId = this.recentOrdersSignal()[0]?.id ?? null; + this.lastAcknowledgedOrderIdSignal.set(newestId); + if (newestId) { + this.storage.setItem(LAST_ACKNOWLEDGED_KEY, newestId); + } + } + + private scheduleNext(): void { + if (this.timerId !== null) { + clearInterval(this.timerId); + } + this.timerId = setInterval(() => this.poll(), this.intervalMsSignal()); + } + + private poll(): void { + this.gateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: RECENT_ORDERS_LIMIT }).subscribe({ + next: result => this.handleOrders(result.items), + error: err => console.error('Error polling for new orders:', err), + }); + } + + private handleOrders(items: AdminOrder[]): void { + this.recentOrdersSignal.set(items); + + if (items.length === 0) { + return; + } + + const isFirstPoll = this.lastNotifiedOrderId === null; + const notifyIndex = isFirstPoll ? -1 : items.findIndex(order => order.id === this.lastNotifiedOrderId); + const newOrders = isFirstPoll ? [] : (notifyIndex === -1 ? items : items.slice(0, notifyIndex)); + + this.lastNotifiedOrderId = items[0].id; + this.storage.setItem(LAST_NOTIFIED_KEY, this.lastNotifiedOrderId); + + if (isFirstPoll) { + // Nothing existed to compare against yet - treat current orders as already + // acknowledged so a fresh admin session doesn't see the whole history as unread. + if (this.lastAcknowledgedOrderIdSignal() === null) { + this.lastAcknowledgedOrderIdSignal.set(items[0].id); + this.storage.setItem(LAST_ACKNOWLEDGED_KEY, items[0].id); + } + return; + } + + for (let i = newOrders.length - 1; i >= 0; i--) { + const order = newOrders[i]; + this.notifications.show( + this.i18n.t('adminShell.topbar.notificationNewOrder', { orderNumber: order.orderNumber }), + 'info', + TOAST_DURATION_MS, + [this.languageService.currentLanguage(), 'backoffice', 'orders', order.id] + ); + } + } + + private readStoredIntervalMs(): number { + const stored = Number(this.storage.getItem(POLL_INTERVAL_KEY)); + return Number.isFinite(stored) && stored >= MIN_POLL_INTERVAL_MS ? stored : DEFAULT_POLL_INTERVAL_MS; + } +} diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index db16bc2..fd7f88e 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -2074,6 +2074,7 @@ export const en: Translations = { searchPlaceholder: 'Search products, orders, pages...', notifications: 'Notifications', notificationsEmpty: 'No new notifications', + notificationNewOrder: 'New order #{{orderNumber}}', account: 'Admin account', tenantSelector: 'Store', tenantSelectorComingSoon: 'Switching between stores is coming soon', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index 5b76a0b..144e43d 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -2069,6 +2069,7 @@ export const hy: Translations = { searchPlaceholder: 'Փնտրել ապրանքներ, պատվերներ, էջեր...', notifications: 'Ծանուցումներ', notificationsEmpty: 'Նոր ծանուցումներ չկան', + notificationNewOrder: 'Նոր պատվեր #{{orderNumber}}', account: 'Ադմինիստրատորի հաշիվ', tenantSelector: 'Խանութ', tenantSelectorComingSoon: 'Խանութների միջև անցումը շուտով կհասանելի լինի', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 21174c8..4a2a39f 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -2069,6 +2069,7 @@ export const ru: Translations = { searchPlaceholder: 'Искать товары, заказы, страницы...', notifications: 'Уведомления', notificationsEmpty: 'Новых уведомлений нет', + notificationNewOrder: 'Новый заказ №{{orderNumber}}', account: 'Аккаунт администратора', tenantSelector: 'Магазин', tenantSelectorComingSoon: 'Переключение между магазинами скоро появится', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index fd940a0..59f93a2 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -2082,6 +2082,7 @@ export interface Translations { searchPlaceholder: string; notifications: string; notificationsEmpty: string; + notificationNewOrder: string; account: string; tenantSelector: string; tenantSelectorComingSoon: string;