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...',
|
||||
notifications: 'Notifications',
|
||||
notificationsEmpty: 'No new notifications',
|
||||
notificationNewOrder: 'New order #{{orderNumber}}',
|
||||
account: 'Admin account',
|
||||
tenantSelector: 'Store',
|
||||
tenantSelectorComingSoon: 'Switching between stores is coming soon',
|
||||
|
||||
@@ -2069,6 +2069,7 @@ export const hy: Translations = {
|
||||
searchPlaceholder: 'Փնտրել ապրանքներ, պատվերներ, էջեր...',
|
||||
notifications: 'Ծանուցումներ',
|
||||
notificationsEmpty: 'Նոր ծանուցումներ չկան',
|
||||
notificationNewOrder: 'Նոր պատվեր #{{orderNumber}}',
|
||||
account: 'Ադմինիստրատորի հաշիվ',
|
||||
tenantSelector: 'Խանութ',
|
||||
tenantSelectorComingSoon: 'Խանութների միջև անցումը շուտով կհասանելի լինի',
|
||||
|
||||
@@ -2069,6 +2069,7 @@ export const ru: Translations = {
|
||||
searchPlaceholder: 'Искать товары, заказы, страницы...',
|
||||
notifications: 'Уведомления',
|
||||
notificationsEmpty: 'Новых уведомлений нет',
|
||||
notificationNewOrder: 'Новый заказ №{{orderNumber}}',
|
||||
account: 'Аккаунт администратора',
|
||||
tenantSelector: 'Магазин',
|
||||
tenantSelectorComingSoon: 'Переключение между магазинами скоро появится',
|
||||
|
||||
@@ -2082,6 +2082,7 @@ export interface Translations {
|
||||
searchPlaceholder: string;
|
||||
notifications: string;
|
||||
notificationsEmpty: string;
|
||||
notificationNewOrder: string;
|
||||
account: string;
|
||||
tenantSelector: string;
|
||||
tenantSelectorComingSoon: string;
|
||||
|
||||
Reference in New Issue
Block a user