feat: AdminOrderWatcherService polls for new orders and toasts
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -2069,6 +2069,7 @@ export const hy: Translations = {
|
|||||||
searchPlaceholder: 'Փնտրել ապրանքներ, պատվերներ, էջեր...',
|
searchPlaceholder: 'Փնտրել ապրանքներ, պատվերներ, էջեր...',
|
||||||
notifications: 'Ծանուցումներ',
|
notifications: 'Ծանուցումներ',
|
||||||
notificationsEmpty: 'Նոր ծանուցումներ չկան',
|
notificationsEmpty: 'Նոր ծանուցումներ չկան',
|
||||||
|
notificationNewOrder: 'Նոր պատվեր #{{orderNumber}}',
|
||||||
account: 'Ադմինիստրատորի հաշիվ',
|
account: 'Ադմինիստրատորի հաշիվ',
|
||||||
tenantSelector: 'Խանութ',
|
tenantSelector: 'Խանութ',
|
||||||
tenantSelectorComingSoon: 'Խանութների միջև անցումը շուտով կհասանելի լինի',
|
tenantSelectorComingSoon: 'Խանութների միջև անցումը շուտով կհասանելի լինի',
|
||||||
|
|||||||
@@ -2069,6 +2069,7 @@ export const ru: Translations = {
|
|||||||
searchPlaceholder: 'Искать товары, заказы, страницы...',
|
searchPlaceholder: 'Искать товары, заказы, страницы...',
|
||||||
notifications: 'Уведомления',
|
notifications: 'Уведомления',
|
||||||
notificationsEmpty: 'Новых уведомлений нет',
|
notificationsEmpty: 'Новых уведомлений нет',
|
||||||
|
notificationNewOrder: 'Новый заказ №{{orderNumber}}',
|
||||||
account: 'Аккаунт администратора',
|
account: 'Аккаунт администратора',
|
||||||
tenantSelector: 'Магазин',
|
tenantSelector: 'Магазин',
|
||||||
tenantSelectorComingSoon: 'Переключение между магазинами скоро появится',
|
tenantSelectorComingSoon: 'Переключение между магазинами скоро появится',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user