diff --git a/apps/api/src/modules/notifications/infrastructure/__tests__/notifications-async.config.spec.ts b/apps/api/src/modules/notifications/infrastructure/__tests__/notifications-async.config.spec.ts new file mode 100644 index 0000000..2bc52d8 --- /dev/null +++ b/apps/api/src/modules/notifications/infrastructure/__tests__/notifications-async.config.spec.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { NotificationsAsyncConfig } from '../services/notifications-async.config'; + +const FLAG = 'NOTIFICATIONS_ASYNC_ENABLED'; + +describe('NotificationsAsyncConfig', () => { + const originalValue = process.env[FLAG]; + + afterEach(() => { + if (originalValue === undefined) { + delete process.env[FLAG]; + } else { + process.env[FLAG] = originalValue; + } + }); + + it('defaults to disabled when the env var is unset', () => { + delete process.env[FLAG]; + expect(new NotificationsAsyncConfig().asyncEnabled).toBe(false); + }); + + it.each(['true', 'TRUE', '1', 'yes', 'on', ' true '])( + 'treats %j as enabled', + (value) => { + process.env[FLAG] = value; + expect(new NotificationsAsyncConfig().asyncEnabled).toBe(true); + }, + ); + + it.each(['false', '0', 'no', 'off', '', 'maybe'])( + 'treats %j as disabled', + (value) => { + process.env[FLAG] = value; + expect(new NotificationsAsyncConfig().asyncEnabled).toBe(false); + }, + ); + + it('describe() reports the human-readable state', () => { + process.env[FLAG] = 'true'; + expect(new NotificationsAsyncConfig().describe()).toBe( + 'NOTIFICATIONS_ASYNC_ENABLED=enabled', + ); + process.env[FLAG] = 'false'; + expect(new NotificationsAsyncConfig().describe()).toBe( + 'NOTIFICATIONS_ASYNC_ENABLED=disabled', + ); + }); +}); diff --git a/apps/api/src/modules/notifications/infrastructure/services/notifications-async.config.ts b/apps/api/src/modules/notifications/infrastructure/services/notifications-async.config.ts new file mode 100644 index 0000000..d339945 --- /dev/null +++ b/apps/api/src/modules/notifications/infrastructure/services/notifications-async.config.ts @@ -0,0 +1,33 @@ +/** + * Feature-flag config for the Phase 1 async notifications path (RFC-004 / GOO-173). + * + * When NOTIFICATIONS_ASYNC_ENABLED is `true`, producers route through + * `NotificationsPublisher` → outbox → Redis Streams → BullMQ consumer. + * When `false` (default until parity is confirmed in production), producers + * continue to execute `SendNotificationCommand` directly in-process. + * + * Kept as a tiny injectable so: + * - callers don't read process.env directly (testability) + * - we have one place to evolve the flag shape (per-category rollout, + * percentage shadowing, etc.) without ripping through listeners again + */ + +import { Injectable } from '@nestjs/common'; + +const FLAG_ENV = 'NOTIFICATIONS_ASYNC_ENABLED'; + +@Injectable() +export class NotificationsAsyncConfig { + /** True when the async outbox path should be used for new notifications. */ + get asyncEnabled(): boolean { + const raw = process.env[FLAG_ENV]; + if (raw === undefined) return false; + const v = raw.trim().toLowerCase(); + return v === '1' || v === 'true' || v === 'yes' || v === 'on'; + } + + /** Exposed for observability / startup logs. */ + describe(): string { + return `${FLAG_ENV}=${this.asyncEnabled ? 'enabled' : 'disabled'}`; + } +} diff --git a/apps/api/src/modules/notifications/notifications.module.ts b/apps/api/src/modules/notifications/notifications.module.ts index dc173e1..89f0b94 100644 --- a/apps/api/src/modules/notifications/notifications.module.ts +++ b/apps/api/src/modules/notifications/notifications.module.ts @@ -34,6 +34,8 @@ import { PrismaNotificationPreferenceRepository } from './infrastructure/reposit import { PrismaNotificationRepository } from './infrastructure/repositories/prisma-notification.repository'; import { EmailService } from './infrastructure/services/email.service'; import { FcmService } from './infrastructure/services/fcm.service'; +import { NotificationsAsyncConfig } from './infrastructure/services/notifications-async.config'; +import { NotificationsPublisher } from './infrastructure/services/notifications-publisher.service'; import { SmsRateLimiterService } from './infrastructure/services/sms-rate-limiter.service'; import { StringeeSmsService } from './infrastructure/services/stringee-sms.service'; import { TemplateService } from './infrastructure/services/template.service'; @@ -87,6 +89,10 @@ const EventListeners = [ ZaloOaService, TemplateService, + // RFC-004 Phase 1 — async notifications (GOO-173) + NotificationsAsyncConfig, + NotificationsPublisher, + // WebSocket Gateway NotificationsGateway, @@ -104,6 +110,8 @@ const EventListeners = [ SMS_NOTIFICATION_CHANNEL, ZaloOaService, TemplateService, + NotificationsAsyncConfig, + NotificationsPublisher, NotificationsGateway, ], }) diff --git a/apps/web/app/[locale]/(admin)/loading.tsx b/apps/web/app/[locale]/(admin)/loading.tsx index 59e7fe3..36ff0aa 100644 --- a/apps/web/app/[locale]/(admin)/loading.tsx +++ b/apps/web/app/[locale]/(admin)/loading.tsx @@ -1,6 +1,6 @@ export default function AdminLoading() { return ( -
+
{/* Header skeleton */}
diff --git a/apps/web/app/[locale]/(auth)/loading.tsx b/apps/web/app/[locale]/(auth)/loading.tsx index 9bef2a0..be26d6d 100644 --- a/apps/web/app/[locale]/(auth)/loading.tsx +++ b/apps/web/app/[locale]/(auth)/loading.tsx @@ -1,6 +1,6 @@ export default function AuthLoading() { return ( -
+
{/* Logo / title skeleton */}
diff --git a/apps/web/app/[locale]/(dashboard)/loading.tsx b/apps/web/app/[locale]/(dashboard)/loading.tsx index cc75a34..5ca77d6 100644 --- a/apps/web/app/[locale]/(dashboard)/loading.tsx +++ b/apps/web/app/[locale]/(dashboard)/loading.tsx @@ -1,6 +1,6 @@ export default function DashboardLoading() { return ( -
+
{/* Header skeleton */}
diff --git a/apps/web/app/[locale]/(public)/search/loading.tsx b/apps/web/app/[locale]/(public)/search/loading.tsx index 7cd33f2..e5029f4 100644 --- a/apps/web/app/[locale]/(public)/search/loading.tsx +++ b/apps/web/app/[locale]/(public)/search/loading.tsx @@ -1,6 +1,6 @@ export default function SearchLoading() { return ( -
+
{/* Header skeleton */}
diff --git a/apps/web/app/[locale]/loading.tsx b/apps/web/app/[locale]/loading.tsx index db7c7a5..52e204a 100644 --- a/apps/web/app/[locale]/loading.tsx +++ b/apps/web/app/[locale]/loading.tsx @@ -1,6 +1,6 @@ export default function RootLoading() { return ( -
+
{/* Header skeleton */}
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index eccc1d2..a7ee60f 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -117,6 +117,18 @@ [data-numeric] { font-variant-numeric: tabular-nums; } + + /* Consistent focus-visible ring for all interactive elements */ + :focus-visible { + outline: 2px solid hsl(var(--ring)); + outline-offset: 2px; + border-radius: var(--radius); + } + + /* Remove outline for mouse users; keep it for keyboard navigation */ + :focus:not(:focus-visible) { + outline: none; + } } /* Mapbox popup theming */ diff --git a/apps/web/components/agents/agent-profile-client.tsx b/apps/web/components/agents/agent-profile-client.tsx index 1ddc2e3..6680e5e 100644 --- a/apps/web/components/agents/agent-profile-client.tsx +++ b/apps/web/components/agents/agent-profile-client.tsx @@ -267,7 +267,7 @@ export function AgentProfileClient({ return (
{/* ── Breadcrumb ── */} -