feat: AdminOrderWatcherService polls for new orders and toasts
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-08-15 03:52:55 +04:00
parent 5ed4936898
commit 28f39a31f6
6 changed files with 258 additions and 0 deletions

View File

@@ -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);
});
});

View File

@@ -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<AdminOrder[]>([]);
readonly recentOrders: Signal<AdminOrder[]> = this.recentOrdersSignal.asReadonly();
private readonly lastAcknowledgedOrderIdSignal = signal<string | null>(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<number>(this.readStoredIntervalMs());
readonly intervalMs: Signal<number> = this.intervalMsSignal.asReadonly();
private lastNotifiedOrderId: string | null = this.storage.getItem(LAST_NOTIFIED_KEY);
private timerId: ReturnType<typeof setInterval> | 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;
}
}

View File

@@ -2074,6 +2074,7 @@ export const en: Translations = {
searchPlaceholder: 'Search products, orders, pages...', searchPlaceholder: 'Search products, orders, pages...',
notifications: 'Notifications', notifications: 'Notifications',
notificationsEmpty: 'No new notifications', notificationsEmpty: 'No new notifications',
notificationNewOrder: 'New order #{{orderNumber}}',
account: 'Admin account', account: 'Admin account',
tenantSelector: 'Store', tenantSelector: 'Store',
tenantSelectorComingSoon: 'Switching between stores is coming soon', tenantSelectorComingSoon: 'Switching between stores is coming soon',

View File

@@ -2069,6 +2069,7 @@ export const hy: Translations = {
searchPlaceholder: 'Փնտրել ապրանքներ, պատվերներ, էջեր...', searchPlaceholder: 'Փնտրել ապրանքներ, պատվերներ, էջեր...',
notifications: 'Ծանուցումներ', notifications: 'Ծանուցումներ',
notificationsEmpty: 'Նոր ծանուցումներ չկան', notificationsEmpty: 'Նոր ծանուցումներ չկան',
notificationNewOrder: 'Նոր պատվեր #{{orderNumber}}',
account: 'Ադմինիստրատորի հաշիվ', account: 'Ադմինիստրատորի հաշիվ',
tenantSelector: 'Խանութ', tenantSelector: 'Խանութ',
tenantSelectorComingSoon: 'Խանութների միջև անցումը շուտով կհասանելի լինի', tenantSelectorComingSoon: 'Խանութների միջև անցումը շուտով կհասանելի լինի',

View File

@@ -2069,6 +2069,7 @@ export const ru: Translations = {
searchPlaceholder: 'Искать товары, заказы, страницы...', searchPlaceholder: 'Искать товары, заказы, страницы...',
notifications: 'Уведомления', notifications: 'Уведомления',
notificationsEmpty: 'Новых уведомлений нет', notificationsEmpty: 'Новых уведомлений нет',
notificationNewOrder: 'Новый заказ №{{orderNumber}}',
account: 'Аккаунт администратора', account: 'Аккаунт администратора',
tenantSelector: 'Магазин', tenantSelector: 'Магазин',
tenantSelectorComingSoon: 'Переключение между магазинами скоро появится', tenantSelectorComingSoon: 'Переключение между магазинами скоро появится',

View File

@@ -2082,6 +2082,7 @@ export interface Translations {
searchPlaceholder: string; searchPlaceholder: string;
notifications: string; notifications: string;
notificationsEmpty: string; notificationsEmpty: string;
notificationNewOrder: string;
account: string; account: string;
tenantSelector: string; tenantSelector: string;
tenantSelectorComingSoon: string; tenantSelectorComingSoon: string;