Integrate Realtime module and enhance event handling across various services
- Added `RealtimeModule` and `RealtimeApiModule` to the BFF application for improved real-time capabilities. - Updated `CatalogCdcSubscriber` and `OrderCdcSubscriber` to utilize `RealtimeService` for publishing catalog and order updates, ensuring instant notifications across connected clients. - Enhanced `OrderEventsService` to leverage `RealtimeService` for order event subscriptions, improving reliability across multiple BFF instances. - Introduced `AccountEventsListener` in the portal layout to handle real-time account updates. - Removed stale time and garbage collection settings from several hooks to streamline data fetching processes.
This commit is contained in:
parent
9764ccfbad
commit
e1c8b6c15e
@ -15,6 +15,7 @@ import { RateLimitModule } from "@bff/core/rate-limiting/index.js";
|
|||||||
import { PrismaModule } from "@bff/infra/database/prisma.module.js";
|
import { PrismaModule } from "@bff/infra/database/prisma.module.js";
|
||||||
import { RedisModule } from "@bff/infra/redis/redis.module.js";
|
import { RedisModule } from "@bff/infra/redis/redis.module.js";
|
||||||
import { CacheModule } from "@bff/infra/cache/cache.module.js";
|
import { CacheModule } from "@bff/infra/cache/cache.module.js";
|
||||||
|
import { RealtimeModule } from "@bff/infra/realtime/realtime.module.js";
|
||||||
import { QueueModule } from "@bff/infra/queue/queue.module.js";
|
import { QueueModule } from "@bff/infra/queue/queue.module.js";
|
||||||
import { AuditModule } from "@bff/infra/audit/audit.module.js";
|
import { AuditModule } from "@bff/infra/audit/audit.module.js";
|
||||||
import { EmailModule } from "@bff/infra/email/email.module.js";
|
import { EmailModule } from "@bff/infra/email/email.module.js";
|
||||||
@ -33,6 +34,7 @@ import { InvoicesModule } from "@bff/modules/invoices/invoices.module.js";
|
|||||||
import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.module.js";
|
import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.module.js";
|
||||||
import { CurrencyModule } from "@bff/modules/currency/currency.module.js";
|
import { CurrencyModule } from "@bff/modules/currency/currency.module.js";
|
||||||
import { SupportModule } from "@bff/modules/support/support.module.js";
|
import { SupportModule } from "@bff/modules/support/support.module.js";
|
||||||
|
import { RealtimeApiModule } from "@bff/modules/realtime/realtime.module.js";
|
||||||
|
|
||||||
// System Modules
|
// System Modules
|
||||||
import { HealthModule } from "@bff/modules/health/health.module.js";
|
import { HealthModule } from "@bff/modules/health/health.module.js";
|
||||||
@ -63,6 +65,7 @@ import { HealthModule } from "@bff/modules/health/health.module.js";
|
|||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisModule,
|
RedisModule,
|
||||||
CacheModule,
|
CacheModule,
|
||||||
|
RealtimeModule,
|
||||||
QueueModule,
|
QueueModule,
|
||||||
AuditModule,
|
AuditModule,
|
||||||
EmailModule,
|
EmailModule,
|
||||||
@ -81,6 +84,7 @@ import { HealthModule } from "@bff/modules/health/health.module.js";
|
|||||||
SubscriptionsModule,
|
SubscriptionsModule,
|
||||||
CurrencyModule,
|
CurrencyModule,
|
||||||
SupportModule,
|
SupportModule,
|
||||||
|
RealtimeApiModule,
|
||||||
|
|
||||||
// === SYSTEM MODULES ===
|
// === SYSTEM MODULES ===
|
||||||
HealthModule,
|
HealthModule,
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.mo
|
|||||||
import { CurrencyModule } from "@bff/modules/currency/currency.module.js";
|
import { CurrencyModule } from "@bff/modules/currency/currency.module.js";
|
||||||
import { SecurityModule } from "@bff/core/security/security.module.js";
|
import { SecurityModule } from "@bff/core/security/security.module.js";
|
||||||
import { SupportModule } from "@bff/modules/support/support.module.js";
|
import { SupportModule } from "@bff/modules/support/support.module.js";
|
||||||
|
import { RealtimeApiModule } from "@bff/modules/realtime/realtime.module.js";
|
||||||
|
|
||||||
export const apiRoutes: Routes = [
|
export const apiRoutes: Routes = [
|
||||||
{
|
{
|
||||||
@ -24,6 +25,7 @@ export const apiRoutes: Routes = [
|
|||||||
{ path: "", module: CurrencyModule },
|
{ path: "", module: CurrencyModule },
|
||||||
{ path: "", module: SupportModule },
|
{ path: "", module: SupportModule },
|
||||||
{ path: "", module: SecurityModule },
|
{ path: "", module: SecurityModule },
|
||||||
|
{ path: "", module: RealtimeApiModule },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
10
apps/bff/src/infra/realtime/realtime.module.ts
Normal file
10
apps/bff/src/infra/realtime/realtime.module.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { Global, Module } from "@nestjs/common";
|
||||||
|
import { RealtimePubSubService } from "./realtime.pubsub.js";
|
||||||
|
import { RealtimeService } from "./realtime.service.js";
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [RealtimePubSubService, RealtimeService],
|
||||||
|
exports: [RealtimeService],
|
||||||
|
})
|
||||||
|
export class RealtimeModule {}
|
||||||
88
apps/bff/src/infra/realtime/realtime.pubsub.ts
Normal file
88
apps/bff/src/infra/realtime/realtime.pubsub.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { Inject, Injectable, Logger, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
|
||||||
|
import { Redis } from "ioredis";
|
||||||
|
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
|
import type { RealtimePubSubMessage } from "./realtime.types.js";
|
||||||
|
|
||||||
|
type Handler = (message: RealtimePubSubMessage) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis Pub/Sub wrapper for realtime events.
|
||||||
|
*
|
||||||
|
* - Uses a dedicated subscriber connection (required by Redis pub/sub semantics)
|
||||||
|
* - Publishes JSON messages to a single channel
|
||||||
|
* - Dispatches messages to in-process handlers
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RealtimePubSubService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(RealtimePubSubService.name);
|
||||||
|
private readonly CHANNEL = "realtime:events";
|
||||||
|
|
||||||
|
private subscriber: Redis | null = null;
|
||||||
|
private handlers = new Set<Handler>();
|
||||||
|
|
||||||
|
constructor(@Inject("REDIS_CLIENT") private readonly redis: Redis) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
// Create a dedicated connection for subscriptions
|
||||||
|
this.subscriber = this.redis.duplicate();
|
||||||
|
|
||||||
|
this.subscriber.on("error", err => {
|
||||||
|
this.logger.warn("Realtime Redis subscriber error", { error: getErrorMessage(err) });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.subscriber.on("end", () => {
|
||||||
|
this.logger.warn("Realtime Redis subscriber connection ended");
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.subscriber.subscribe(this.CHANNEL);
|
||||||
|
|
||||||
|
this.subscriber.on("message", (_channel, raw) => {
|
||||||
|
const msg = this.safeParse(raw);
|
||||||
|
if (!msg) return;
|
||||||
|
for (const handler of this.handlers) {
|
||||||
|
try {
|
||||||
|
handler(msg);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn("Realtime handler threw", { error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log("Realtime Pub/Sub initialized", { channel: this.CHANNEL });
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
if (!this.subscriber) return;
|
||||||
|
try {
|
||||||
|
await this.subscriber.unsubscribe(this.CHANNEL);
|
||||||
|
await this.subscriber.quit();
|
||||||
|
} catch {
|
||||||
|
this.subscriber.disconnect();
|
||||||
|
} finally {
|
||||||
|
this.subscriber = null;
|
||||||
|
this.handlers.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(message: RealtimePubSubMessage): Promise<number> {
|
||||||
|
return this.redis.publish(this.CHANNEL, JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
addHandler(handler: Handler): () => void {
|
||||||
|
this.handlers.add(handler);
|
||||||
|
return () => {
|
||||||
|
this.handlers.delete(handler);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private safeParse(raw: string): RealtimePubSubMessage | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as RealtimePubSubMessage;
|
||||||
|
if (!parsed || typeof parsed !== "object") return null;
|
||||||
|
if (typeof parsed.topic !== "string" || typeof parsed.event !== "string") return null;
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
132
apps/bff/src/infra/realtime/realtime.service.ts
Normal file
132
apps/bff/src/infra/realtime/realtime.service.ts
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import type { MessageEvent } from "@nestjs/common";
|
||||||
|
import { Observable } from "rxjs";
|
||||||
|
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
|
import type {
|
||||||
|
RealtimeEventEnvelope,
|
||||||
|
RealtimePubSubMessage,
|
||||||
|
RealtimeStreamOptions,
|
||||||
|
} from "./realtime.types.js";
|
||||||
|
import { RealtimePubSubService } from "./realtime.pubsub.js";
|
||||||
|
|
||||||
|
interface InternalObserver {
|
||||||
|
next: (event: MessageEvent) => void;
|
||||||
|
complete: () => void;
|
||||||
|
error: (error: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Production-ready realtime event hub.
|
||||||
|
*
|
||||||
|
* - Subscriptions are in-memory per instance
|
||||||
|
* - Publishes and receives via Redis Pub/Sub for multi-instance delivery
|
||||||
|
* - Provides consistent "ready" + "heartbeat" conventions
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RealtimeService {
|
||||||
|
private readonly logger = new Logger(RealtimeService.name);
|
||||||
|
private readonly observersByTopic = new Map<string, Set<InternalObserver>>();
|
||||||
|
|
||||||
|
constructor(private readonly pubsub: RealtimePubSubService) {
|
||||||
|
// Fan-in all Redis events and deliver to local subscribers
|
||||||
|
this.pubsub.addHandler(msg => this.deliver(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(topic: string, options: RealtimeStreamOptions = {}): Observable<MessageEvent> {
|
||||||
|
const heartbeatMs = options.heartbeatMs ?? 30000;
|
||||||
|
const readyEvent = options.readyEvent === undefined ? "stream.ready" : options.readyEvent;
|
||||||
|
const heartbeatEvent =
|
||||||
|
options.heartbeatEvent === undefined ? "stream.heartbeat" : options.heartbeatEvent;
|
||||||
|
|
||||||
|
return new Observable<MessageEvent>(subscriber => {
|
||||||
|
const wrappedObserver: InternalObserver = {
|
||||||
|
next: value => subscriber.next(value),
|
||||||
|
complete: () => subscriber.complete(),
|
||||||
|
error: error => subscriber.error(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
const set = this.observersByTopic.get(topic) ?? new Set<InternalObserver>();
|
||||||
|
set.add(wrappedObserver);
|
||||||
|
this.observersByTopic.set(topic, set);
|
||||||
|
|
||||||
|
this.logger.debug("Realtime stream connected", { topic, listeners: set.size });
|
||||||
|
|
||||||
|
if (readyEvent) {
|
||||||
|
wrappedObserver.next(
|
||||||
|
this.buildMessage(readyEvent, {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const heartbeat =
|
||||||
|
heartbeatMs > 0 && Boolean(heartbeatEvent)
|
||||||
|
? setInterval(() => {
|
||||||
|
wrappedObserver.next(
|
||||||
|
this.buildMessage(heartbeatEvent as string, {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, heartbeatMs)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (heartbeat) {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = this.observersByTopic.get(topic);
|
||||||
|
if (current) {
|
||||||
|
current.delete(wrappedObserver);
|
||||||
|
if (current.size === 0) {
|
||||||
|
this.observersByTopic.delete(topic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug("Realtime stream disconnected", {
|
||||||
|
topic,
|
||||||
|
listeners: current?.size ?? 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
publish<TEvent extends string, TData>(topic: string, event: TEvent, data: TData): void {
|
||||||
|
const message: RealtimePubSubMessage<TEvent, TData> = { topic, event, data };
|
||||||
|
void this.pubsub.publish(message).catch(error => {
|
||||||
|
this.logger.warn("Failed to publish realtime event", {
|
||||||
|
topic,
|
||||||
|
event,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private deliver(message: RealtimePubSubMessage): void {
|
||||||
|
const set = this.observersByTopic.get(message.topic);
|
||||||
|
if (!set || set.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const evt = this.buildMessage(message.event, message.data);
|
||||||
|
set.forEach(observer => {
|
||||||
|
try {
|
||||||
|
observer.next(evt);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn("Failed to notify realtime listener", {
|
||||||
|
topic: message.topic,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildMessage<TEvent extends string>(event: TEvent, data: unknown): MessageEvent {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
event,
|
||||||
|
data,
|
||||||
|
} satisfies RealtimeEventEnvelope<TEvent, unknown>,
|
||||||
|
} satisfies MessageEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
apps/bff/src/infra/realtime/realtime.types.ts
Normal file
35
apps/bff/src/infra/realtime/realtime.types.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import type { MessageEvent } from "@nestjs/common";
|
||||||
|
|
||||||
|
export interface RealtimeEventEnvelope<TEvent extends string = string, TData = unknown> {
|
||||||
|
event: TEvent;
|
||||||
|
data: TData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RealtimePubSubMessage<TEvent extends string = string, TData = unknown> {
|
||||||
|
/**
|
||||||
|
* Topic identifies which logical stream this event belongs to.
|
||||||
|
* Examples:
|
||||||
|
* - orders:sf:801xx0000001234
|
||||||
|
* - catalog:eligibility:001xx000000abcd
|
||||||
|
*/
|
||||||
|
topic: string;
|
||||||
|
event: TEvent;
|
||||||
|
data: TData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RealtimeStreamOptions {
|
||||||
|
/**
|
||||||
|
* Event emitted immediately after subscription is established.
|
||||||
|
*/
|
||||||
|
readyEvent?: string | null;
|
||||||
|
/**
|
||||||
|
* Heartbeat interval (ms). Set to 0 to disable.
|
||||||
|
*/
|
||||||
|
heartbeatMs?: number;
|
||||||
|
/**
|
||||||
|
* Heartbeat event name.
|
||||||
|
*/
|
||||||
|
heartbeatEvent?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RealtimeMessageEvent = MessageEvent<RealtimeEventEnvelope>;
|
||||||
@ -5,6 +5,7 @@ import { Logger } from "nestjs-pino";
|
|||||||
import PubSubApiClientPkg from "salesforce-pubsub-api-client";
|
import PubSubApiClientPkg from "salesforce-pubsub-api-client";
|
||||||
import { SalesforceConnection } from "../services/salesforce-connection.service.js";
|
import { SalesforceConnection } from "../services/salesforce-connection.service.js";
|
||||||
import { CatalogCacheService } from "@bff/modules/catalog/services/catalog-cache.service.js";
|
import { CatalogCacheService } from "@bff/modules/catalog/services/catalog-cache.service.js";
|
||||||
|
import { RealtimeService } from "@bff/infra/realtime/realtime.service.js";
|
||||||
|
|
||||||
type PubSubCallback = (
|
type PubSubCallback = (
|
||||||
subscription: { topicName?: string },
|
subscription: { topicName?: string },
|
||||||
@ -38,6 +39,7 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
|||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly sfConnection: SalesforceConnection,
|
private readonly sfConnection: SalesforceConnection,
|
||||||
private readonly catalogCache: CatalogCacheService,
|
private readonly catalogCache: CatalogCacheService,
|
||||||
|
private readonly realtime: RealtimeService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {
|
) {
|
||||||
this.numRequested = this.resolveNumRequested();
|
this.numRequested = this.resolveNumRequested();
|
||||||
@ -180,7 +182,19 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
await this.invalidateAllCatalogs();
|
await this.invalidateAllCatalogs();
|
||||||
|
// Full invalidation already implies all clients should refetch catalog
|
||||||
|
this.realtime.publish("global:catalog", "catalog.changed", {
|
||||||
|
reason: "product.cdc.fallback_full_invalidation",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Product changes can affect catalog results for all users
|
||||||
|
this.realtime.publish("global:catalog", "catalog.changed", {
|
||||||
|
reason: "product.cdc",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handlePricebookEvent(
|
private async handlePricebookEvent(
|
||||||
@ -222,7 +236,17 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
await this.invalidateAllCatalogs();
|
await this.invalidateAllCatalogs();
|
||||||
|
this.realtime.publish("global:catalog", "catalog.changed", {
|
||||||
|
reason: "pricebook.cdc.fallback_full_invalidation",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.realtime.publish("global:catalog", "catalog.changed", {
|
||||||
|
reason: "pricebook.cdc",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleAccountEvent(
|
private async handleAccountEvent(
|
||||||
@ -255,6 +279,11 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
await this.catalogCache.invalidateEligibility(accountId);
|
await this.catalogCache.invalidateEligibility(accountId);
|
||||||
await this.catalogCache.setEligibilityValue(accountId, eligibility ?? null);
|
await this.catalogCache.setEligibilityValue(accountId, eligibility ?? null);
|
||||||
|
|
||||||
|
// Notify connected portals immediately (multi-instance safe via Redis pub/sub)
|
||||||
|
this.realtime.publish(`account:sf:${accountId}`, "catalog.eligibility.changed", {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async invalidateAllCatalogs(): Promise<void> {
|
private async invalidateAllCatalogs(): Promise<void> {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import PubSubApiClientPkg from "salesforce-pubsub-api-client";
|
|||||||
import { SalesforceConnection } from "../services/salesforce-connection.service.js";
|
import { SalesforceConnection } from "../services/salesforce-connection.service.js";
|
||||||
import { OrdersCacheService } from "@bff/modules/orders/services/orders-cache.service.js";
|
import { OrdersCacheService } from "@bff/modules/orders/services/orders-cache.service.js";
|
||||||
import { ProvisioningQueueService } from "@bff/modules/orders/queue/provisioning.queue.js";
|
import { ProvisioningQueueService } from "@bff/modules/orders/queue/provisioning.queue.js";
|
||||||
|
import { RealtimeService } from "@bff/infra/realtime/realtime.service.js";
|
||||||
|
|
||||||
type PubSubCallback = (
|
type PubSubCallback = (
|
||||||
subscription: { topicName?: string },
|
subscription: { topicName?: string },
|
||||||
@ -79,6 +80,7 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
|||||||
private readonly sfConnection: SalesforceConnection,
|
private readonly sfConnection: SalesforceConnection,
|
||||||
private readonly ordersCache: OrdersCacheService,
|
private readonly ordersCache: OrdersCacheService,
|
||||||
private readonly provisioningQueue: ProvisioningQueueService,
|
private readonly provisioningQueue: ProvisioningQueueService,
|
||||||
|
private readonly realtime: RealtimeService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {
|
) {
|
||||||
this.numRequested = this.resolveNumRequested();
|
this.numRequested = this.resolveNumRequested();
|
||||||
@ -304,6 +306,17 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
|||||||
if (accountId) {
|
if (accountId) {
|
||||||
await this.ordersCache.invalidateAccountOrders(accountId);
|
await this.ordersCache.invalidateAccountOrders(accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notify portals for instant refresh (account-scoped + order-scoped)
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
if (accountId) {
|
||||||
|
this.realtime.publish(`account:sf:${accountId}`, "orders.changed", {
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.realtime.publish(`orders:sf:${orderId}`, "order.cdc.changed", {
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn("Failed to invalidate order cache from CDC event", {
|
this.logger.warn("Failed to invalidate order cache from CDC event", {
|
||||||
orderId,
|
orderId,
|
||||||
@ -396,6 +409,7 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
|||||||
const changedFields = this.extractChangedFields(payload);
|
const changedFields = this.extractChangedFields(payload);
|
||||||
|
|
||||||
const orderId = this.extractStringField(payload, ["OrderId"]);
|
const orderId = this.extractStringField(payload, ["OrderId"]);
|
||||||
|
const accountId = this.extractStringField(payload, ["AccountId"]);
|
||||||
if (!orderId) {
|
if (!orderId) {
|
||||||
this.logger.warn("OrderItem CDC event missing OrderId; skipping", { channel });
|
this.logger.warn("OrderItem CDC event missing OrderId; skipping", { channel });
|
||||||
return;
|
return;
|
||||||
@ -421,6 +435,16 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await this.ordersCache.invalidateOrder(orderId);
|
await this.ordersCache.invalidateOrder(orderId);
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
if (accountId) {
|
||||||
|
this.realtime.publish(`account:sf:${accountId}`, "orders.changed", {
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.realtime.publish(`orders:sf:${orderId}`, "orderitem.cdc.changed", {
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn("Failed to invalidate order cache from OrderItem CDC event", {
|
this.logger.warn("Failed to invalidate order cache from OrderItem CDC event", {
|
||||||
orderId,
|
orderId,
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Request,
|
Request,
|
||||||
@ -88,7 +89,17 @@ export class OrdersController {
|
|||||||
|
|
||||||
@Sse(":sfOrderId/events")
|
@Sse(":sfOrderId/events")
|
||||||
@UsePipes(new ZodValidationPipe(sfOrderIdParamSchema))
|
@UsePipes(new ZodValidationPipe(sfOrderIdParamSchema))
|
||||||
streamOrderUpdates(@Param() params: SfOrderIdParam): Observable<MessageEvent> {
|
@UseGuards(SalesforceReadThrottleGuard)
|
||||||
|
async streamOrderUpdates(
|
||||||
|
@Request() req: RequestWithUser,
|
||||||
|
@Param() params: SfOrderIdParam
|
||||||
|
): Promise<Observable<MessageEvent>> {
|
||||||
|
// Ensure caller is allowed to access this order stream (avoid leaking existence)
|
||||||
|
try {
|
||||||
|
await this.orderOrchestrator.getOrderForUser(params.sfOrderId, req.user.id);
|
||||||
|
} catch {
|
||||||
|
throw new NotFoundException("Order not found");
|
||||||
|
}
|
||||||
return this.orderEvents.subscribe(params.sfOrderId);
|
return this.orderEvents.subscribe(params.sfOrderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,95 +1,33 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import type { MessageEvent } from "@nestjs/common";
|
import type { MessageEvent } from "@nestjs/common";
|
||||||
import { Observable } from "rxjs";
|
import { Observable } from "rxjs";
|
||||||
import type { OrderUpdateEventPayload } from "@customer-portal/domain/orders";
|
import type { OrderUpdateEventPayload } from "@customer-portal/domain/orders";
|
||||||
|
import { RealtimeService } from "@bff/infra/realtime/realtime.service.js";
|
||||||
|
|
||||||
interface InternalObserver {
|
/**
|
||||||
next: (event: MessageEvent) => void;
|
* Order SSE publisher/subscriber adapter.
|
||||||
complete: () => void;
|
*
|
||||||
error: (error: unknown) => void;
|
* Uses the shared RealtimeService (Redis-backed Pub/Sub) so updates are reliable
|
||||||
}
|
* across multiple BFF instances, while keeping the existing event names stable.
|
||||||
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrderEventsService {
|
export class OrderEventsService {
|
||||||
private readonly logger = new Logger(OrderEventsService.name);
|
constructor(private readonly realtime: RealtimeService) {}
|
||||||
|
|
||||||
private readonly observers = new Map<string, Set<InternalObserver>>();
|
|
||||||
|
|
||||||
subscribe(orderId: string): Observable<MessageEvent> {
|
subscribe(orderId: string): Observable<MessageEvent> {
|
||||||
return new Observable<MessageEvent>(subscriber => {
|
const topic = this.topic(orderId);
|
||||||
const wrappedObserver: InternalObserver = {
|
return this.realtime.subscribe(topic, {
|
||||||
next: value => subscriber.next(value),
|
readyEvent: "order.stream.ready",
|
||||||
complete: () => subscriber.complete(),
|
heartbeatEvent: "order.stream.heartbeat",
|
||||||
error: error => subscriber.error(error),
|
heartbeatMs: 30000,
|
||||||
};
|
|
||||||
|
|
||||||
const orderObservers = this.observers.get(orderId) ?? new Set<InternalObserver>();
|
|
||||||
orderObservers.add(wrappedObserver);
|
|
||||||
this.observers.set(orderId, orderObservers);
|
|
||||||
|
|
||||||
this.logger.debug(`Order stream connected`, { orderId, listeners: orderObservers.size });
|
|
||||||
|
|
||||||
// Immediately notify client that stream is ready
|
|
||||||
wrappedObserver.next(
|
|
||||||
this.buildEvent("order.stream.ready", {
|
|
||||||
orderId,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const heartbeat = setInterval(() => {
|
|
||||||
wrappedObserver.next(
|
|
||||||
this.buildEvent("order.stream.heartbeat", {
|
|
||||||
orderId,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(heartbeat);
|
|
||||||
const currentObservers = this.observers.get(orderId);
|
|
||||||
if (currentObservers) {
|
|
||||||
currentObservers.delete(wrappedObserver);
|
|
||||||
if (currentObservers.size === 0) {
|
|
||||||
this.observers.delete(orderId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.logger.debug(`Order stream disconnected`, {
|
|
||||||
orderId,
|
|
||||||
listeners: currentObservers?.size ?? 0,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
publish(orderId: string, update: OrderUpdateEventPayload): void {
|
publish(orderId: string, update: OrderUpdateEventPayload): void {
|
||||||
const currentObservers = this.observers.get(orderId);
|
this.realtime.publish(this.topic(orderId), "order.update", update);
|
||||||
if (!currentObservers || currentObservers.size === 0) {
|
|
||||||
this.logger.debug("No active listeners for order update", { orderId });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = this.buildEvent("order.update", update);
|
|
||||||
|
|
||||||
currentObservers.forEach(observer => {
|
|
||||||
try {
|
|
||||||
observer.next(event);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn("Failed to notify order update listener", {
|
|
||||||
orderId,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildEvent<T extends object>(event: string, data: T): MessageEvent {
|
private topic(orderId: string): string {
|
||||||
return {
|
return `orders:sf:${orderId}`;
|
||||||
data: {
|
|
||||||
event,
|
|
||||||
data,
|
|
||||||
},
|
|
||||||
} satisfies MessageEvent;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
47
apps/bff/src/modules/realtime/realtime.controller.ts
Normal file
47
apps/bff/src/modules/realtime/realtime.controller.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { Controller, Header, Request, Sse } from "@nestjs/common";
|
||||||
|
import type { MessageEvent } from "@nestjs/common";
|
||||||
|
import { Observable } from "rxjs";
|
||||||
|
import { merge } from "rxjs";
|
||||||
|
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||||
|
import { RealtimeService } from "@bff/infra/realtime/realtime.service.js";
|
||||||
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||||
|
|
||||||
|
@Controller("events")
|
||||||
|
export class RealtimeController {
|
||||||
|
constructor(
|
||||||
|
private readonly realtime: RealtimeService,
|
||||||
|
private readonly mappings: MappingsService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Account-scoped realtime events stream.
|
||||||
|
*
|
||||||
|
* Single predictable SSE entrypoint for the portal.
|
||||||
|
* Backed by Redis pub/sub for multi-instance delivery.
|
||||||
|
*/
|
||||||
|
@Sse()
|
||||||
|
@Header("Cache-Control", "no-cache")
|
||||||
|
async stream(@Request() req: RequestWithUser): Promise<Observable<MessageEvent>> {
|
||||||
|
const mapping = await this.mappings.findByUserId(req.user.id);
|
||||||
|
const sfAccountId = mapping?.sfAccountId;
|
||||||
|
|
||||||
|
const accountStream = this.realtime.subscribe(
|
||||||
|
sfAccountId ? `account:sf:${sfAccountId}` : "account:unknown",
|
||||||
|
{
|
||||||
|
// Always provide a single predictable ready + heartbeat for the main account stream.
|
||||||
|
readyEvent: "account.stream.ready",
|
||||||
|
heartbeatEvent: "account.stream.heartbeat",
|
||||||
|
heartbeatMs: 30000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const globalCatalogStream = this.realtime.subscribe("global:catalog", {
|
||||||
|
// Avoid duplicate ready/heartbeat noise on the combined stream.
|
||||||
|
readyEvent: null,
|
||||||
|
heartbeatEvent: null,
|
||||||
|
heartbeatMs: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return merge(accountStream, globalCatalogStream);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/bff/src/modules/realtime/realtime.module.ts
Normal file
9
apps/bff/src/modules/realtime/realtime.module.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
||||||
|
import { RealtimeController } from "./realtime.controller.js";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [MappingsModule],
|
||||||
|
controllers: [RealtimeController],
|
||||||
|
})
|
||||||
|
export class RealtimeApiModule {}
|
||||||
@ -1,6 +1,12 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { AppShell } from "@/components/organisms";
|
import { AppShell } from "@/components/organisms";
|
||||||
|
import { AccountEventsListener } from "@/features/realtime/components/AccountEventsListener";
|
||||||
|
|
||||||
export default function PortalLayout({ children }: { children: ReactNode }) {
|
export default function PortalLayout({ children }: { children: ReactNode }) {
|
||||||
return <AppShell>{children}</AppShell>;
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<AccountEventsListener />
|
||||||
|
{children}
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,8 +15,6 @@ export function useInternetCatalog() {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.catalog.internet.combined(),
|
queryKey: queryKeys.catalog.internet.combined(),
|
||||||
queryFn: () => catalogService.getInternetCatalog(),
|
queryFn: () => catalogService.getInternetCatalog(),
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
gcTime: 10 * 60 * 1000,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,8 +26,6 @@ export function useSimCatalog() {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.catalog.sim.combined(),
|
queryKey: queryKeys.catalog.sim.combined(),
|
||||||
queryFn: () => catalogService.getSimCatalog(),
|
queryFn: () => catalogService.getSimCatalog(),
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
gcTime: 10 * 60 * 1000,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,8 +37,6 @@ export function useVpnCatalog() {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.catalog.vpn.combined(),
|
queryKey: queryKeys.catalog.vpn.combined(),
|
||||||
queryFn: () => catalogService.getVpnCatalog(),
|
queryFn: () => catalogService.getVpnCatalog(),
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
gcTime: 10 * 60 * 1000,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -68,8 +68,6 @@ export function useDashboardSummary() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
|
||||||
gcTime: 5 * 60 * 1000, // 5 minutes (formerly cacheTime)
|
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
retry: (failureCount, error) => {
|
retry: (failureCount, error) => {
|
||||||
// Don't retry authentication errors
|
// Don't retry authentication errors
|
||||||
|
|||||||
@ -6,9 +6,6 @@ import { queryKeys } from "@/lib/api";
|
|||||||
import { ordersService } from "@/features/orders/services/orders.service";
|
import { ordersService } from "@/features/orders/services/orders.service";
|
||||||
import type { OrderSummary } from "@customer-portal/domain/orders";
|
import type { OrderSummary } from "@customer-portal/domain/orders";
|
||||||
|
|
||||||
const STALE_TIME_MS = 2 * 60 * 1000;
|
|
||||||
const GC_TIME_MS = 10 * 60 * 1000;
|
|
||||||
|
|
||||||
export function useOrdersList() {
|
export function useOrdersList() {
|
||||||
const { isAuthenticated } = useAuthSession();
|
const { isAuthenticated } = useAuthSession();
|
||||||
|
|
||||||
@ -16,8 +13,6 @@ export function useOrdersList() {
|
|||||||
queryKey: queryKeys.orders.list(),
|
queryKey: queryKeys.orders.list(),
|
||||||
queryFn: () => ordersService.getMyOrders(),
|
queryFn: () => ordersService.getMyOrders(),
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
staleTime: STALE_TIME_MS,
|
|
||||||
gcTime: GC_TIME_MS,
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { resolveBaseUrl, queryKeys } from "@/lib/api";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { useAuthSession } from "@/features/auth/services/auth.store";
|
||||||
|
|
||||||
|
type RealtimeEventEnvelope<TEvent extends string = string, TData = unknown> = {
|
||||||
|
event: TEvent;
|
||||||
|
data: TData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AccountEventsListener() {
|
||||||
|
const { isAuthenticated } = useAuthSession();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const clientRef = useRef<{ close: () => void } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
clientRef.current?.close();
|
||||||
|
clientRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let es: EventSource | null = null;
|
||||||
|
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const baseUrl = resolveBaseUrl();
|
||||||
|
const url = new URL("/api/events", baseUrl).toString();
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
logger.debug("Connecting to account events stream", { url });
|
||||||
|
es = new EventSource(url, { withCredentials: true });
|
||||||
|
|
||||||
|
const onMessage = (event: MessageEvent<string>) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(event.data) as RealtimeEventEnvelope;
|
||||||
|
if (!parsed || typeof parsed !== "object") return;
|
||||||
|
|
||||||
|
if (parsed.event === "catalog.eligibility.changed") {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.catalog.all() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.event === "catalog.changed") {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.catalog.all() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.event === "orders.changed") {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.orders.list() });
|
||||||
|
// Dashboard summary often depends on orders/subscriptions; cheap to keep in sync.
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.summary() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("Failed to parse account event", { error });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = (error: Event) => {
|
||||||
|
logger.warn("Account events stream disconnected", { error });
|
||||||
|
es?.close();
|
||||||
|
es = null;
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
reconnectTimeout = setTimeout(connect, 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
es.addEventListener("message", onMessage as EventListener);
|
||||||
|
es.onerror = onError;
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
clientRef.current = {
|
||||||
|
close: () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (es) es.close();
|
||||||
|
if (reconnectTimeout) clearTimeout(reconnectTimeout);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clientRef.current?.close();
|
||||||
|
clientRef.current = null;
|
||||||
|
};
|
||||||
|
}, [isAuthenticated, queryClient]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@ -34,8 +34,6 @@ export function useSubscriptions(options: UseSubscriptionsOptions = {}) {
|
|||||||
);
|
);
|
||||||
return getDataOrThrow<SubscriptionList>(response, "Failed to load subscriptions");
|
return getDataOrThrow<SubscriptionList>(response, "Failed to load subscriptions");
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
gcTime: 10 * 60 * 1000,
|
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -52,8 +50,6 @@ export function useActiveSubscriptions() {
|
|||||||
const response = await apiClient.GET<Subscription[]>("/api/subscriptions/active");
|
const response = await apiClient.GET<Subscription[]>("/api/subscriptions/active");
|
||||||
return getDataOrThrow<Subscription[]>(response, "Failed to load active subscriptions");
|
return getDataOrThrow<Subscription[]>(response, "Failed to load active subscriptions");
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
gcTime: 10 * 60 * 1000,
|
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -73,8 +69,6 @@ export function useSubscriptionStats() {
|
|||||||
}
|
}
|
||||||
return subscriptionStatsSchema.parse(response.data);
|
return subscriptionStatsSchema.parse(response.data);
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
gcTime: 10 * 60 * 1000,
|
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -93,8 +87,6 @@ export function useSubscription(subscriptionId: number) {
|
|||||||
});
|
});
|
||||||
return getDataOrThrow<Subscription>(response, "Failed to load subscription details");
|
return getDataOrThrow<Subscription>(response, "Failed to load subscription details");
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
gcTime: 10 * 60 * 1000,
|
|
||||||
enabled: isAuthenticated && subscriptionId > 0,
|
enabled: isAuthenticated && subscriptionId > 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -120,8 +112,6 @@ export function useSubscriptionInvoices(
|
|||||||
});
|
});
|
||||||
return getDataOrThrow<InvoiceList>(response, "Failed to load subscription invoices");
|
return getDataOrThrow<InvoiceList>(response, "Failed to load subscription invoices");
|
||||||
},
|
},
|
||||||
staleTime: 60 * 1000,
|
|
||||||
gcTime: 5 * 60 * 1000,
|
|
||||||
enabled: isAuthenticated && subscriptionId > 0,
|
enabled: isAuthenticated && subscriptionId > 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,5 @@ export function useSupportCase(caseId: string | undefined) {
|
|||||||
return getDataOrThrow(response, "Failed to load support case");
|
return getDataOrThrow(response, "Failed to load support case");
|
||||||
},
|
},
|
||||||
enabled: isAuthenticated && !!caseId,
|
enabled: isAuthenticated && !!caseId,
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,5 @@ export function useSupportCases(filters?: SupportCaseFilter) {
|
|||||||
return getDataOrThrow(response, "Failed to load support cases");
|
return getDataOrThrow(response, "Failed to load support cases");
|
||||||
},
|
},
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,8 +9,6 @@ export function useCurrency() {
|
|||||||
const { data, isLoading, isError, error } = useQuery<WhmcsCurrency>({
|
const { data, isLoading, isError, error } = useQuery<WhmcsCurrency>({
|
||||||
queryKey: queryKeys.currency.default(),
|
queryKey: queryKeys.currency.default(),
|
||||||
queryFn: () => currencyService.getDefaultCurrency(),
|
queryFn: () => currencyService.getDefaultCurrency(),
|
||||||
staleTime: 60 * 60 * 1000, // cache currency for 1 hour
|
|
||||||
gcTime: 2 * 60 * 60 * 1000,
|
|
||||||
retry: 2,
|
retry: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -17,3 +17,4 @@ export * as Auth from "./auth/index.js";
|
|||||||
export * as Customer from "./customer/index.js";
|
export * as Customer from "./customer/index.js";
|
||||||
export * as Mappings from "./mappings/index.js";
|
export * as Mappings from "./mappings/index.js";
|
||||||
export * as Dashboard from "./dashboard/index.js";
|
export * as Dashboard from "./dashboard/index.js";
|
||||||
|
export * as Realtime from "./realtime/index.js";
|
||||||
|
|||||||
@ -87,6 +87,14 @@
|
|||||||
"import": "./dist/payments/*.js",
|
"import": "./dist/payments/*.js",
|
||||||
"types": "./dist/payments/*.d.ts"
|
"types": "./dist/payments/*.d.ts"
|
||||||
},
|
},
|
||||||
|
"./realtime": {
|
||||||
|
"import": "./dist/realtime/index.js",
|
||||||
|
"types": "./dist/realtime/index.d.ts"
|
||||||
|
},
|
||||||
|
"./realtime/*": {
|
||||||
|
"import": "./dist/realtime/*.js",
|
||||||
|
"types": "./dist/realtime/*.d.ts"
|
||||||
|
},
|
||||||
"./sim": {
|
"./sim": {
|
||||||
"import": "./dist/sim/index.js",
|
"import": "./dist/sim/index.js",
|
||||||
"types": "./dist/sim/index.d.ts"
|
"types": "./dist/sim/index.d.ts"
|
||||||
|
|||||||
21
packages/domain/realtime/events.ts
Normal file
21
packages/domain/realtime/events.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Realtime Events - Shared Contracts
|
||||||
|
*
|
||||||
|
* Shared SSE payload shapes for portal + BFF.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface RealtimeEventEnvelope<TEvent extends string = string, TData = unknown> {
|
||||||
|
event: TEvent;
|
||||||
|
data: TData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogEligibilityChangedPayload {
|
||||||
|
accountId: string;
|
||||||
|
eligibility: string | null;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AccountStreamEvent =
|
||||||
|
| RealtimeEventEnvelope<"account.stream.ready", { topic: string; timestamp: string }>
|
||||||
|
| RealtimeEventEnvelope<"account.stream.heartbeat", { topic: string; timestamp: string }>
|
||||||
|
| RealtimeEventEnvelope<"catalog.eligibility.changed", CatalogEligibilityChangedPayload>;
|
||||||
1
packages/domain/realtime/index.ts
Normal file
1
packages/domain/realtime/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./events.js";
|
||||||
@ -20,6 +20,7 @@
|
|||||||
"orders/**/*",
|
"orders/**/*",
|
||||||
"payments/**/*",
|
"payments/**/*",
|
||||||
"providers/**/*",
|
"providers/**/*",
|
||||||
|
"realtime/**/*",
|
||||||
"sim/**/*",
|
"sim/**/*",
|
||||||
"subscriptions/**/*",
|
"subscriptions/**/*",
|
||||||
"support/**/*",
|
"support/**/*",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user