@openora/core 0.4.1-canary.112 → 0.4.1-canary.113

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/admin-console/plugin.d.ts +6 -0
  3. package/dist/admin-console/plugin.d.ts.map +1 -1
  4. package/dist/analytics/plugin.d.ts +6 -0
  5. package/dist/analytics/plugin.d.ts.map +1 -1
  6. package/dist/audit/plugin.d.ts +6 -0
  7. package/dist/audit/plugin.d.ts.map +1 -1
  8. package/dist/casino/gaming/plugin.d.ts +6 -0
  9. package/dist/casino/gaming/plugin.d.ts.map +1 -1
  10. package/dist/casino/lobby/plugin.d.ts +6 -0
  11. package/dist/casino/lobby/plugin.d.ts.map +1 -1
  12. package/dist/cms/plugin.d.ts +6 -0
  13. package/dist/cms/plugin.d.ts.map +1 -1
  14. package/dist/compliance/plugin.d.ts +6 -0
  15. package/dist/compliance/plugin.d.ts.map +1 -1
  16. package/dist/contracts/schemas/platform-config.d.ts +19 -0
  17. package/dist/contracts/schemas/platform-config.d.ts.map +1 -1
  18. package/dist/contracts/schemas/platform-config.js +17 -0
  19. package/dist/contracts/schemas/platform-config.js.map +1 -1
  20. package/dist/engagement/chat/__tests__/chat.service.int.test.js +117 -0
  21. package/dist/engagement/chat/__tests__/chat.service.int.test.js.map +1 -1
  22. package/dist/engagement/chat/plugin.d.ts +6 -0
  23. package/dist/engagement/chat/plugin.d.ts.map +1 -1
  24. package/dist/engagement/chat/service/chat.service.d.ts +2 -2
  25. package/dist/engagement/chat/service/chat.service.d.ts.map +1 -1
  26. package/dist/engagement/chat/service/chat.service.js +61 -1
  27. package/dist/engagement/chat/service/chat.service.js.map +1 -1
  28. package/dist/engagement/chat-commands/plugin.d.ts +6 -0
  29. package/dist/engagement/chat-commands/plugin.d.ts.map +1 -1
  30. package/dist/engagement/notifications/__tests__/notification-event-map.test.d.ts +2 -0
  31. package/dist/engagement/notifications/__tests__/notification-event-map.test.d.ts.map +1 -0
  32. package/dist/engagement/notifications/__tests__/notification-event-map.test.js +235 -0
  33. package/dist/engagement/notifications/__tests__/notification-event-map.test.js.map +1 -0
  34. package/dist/engagement/notifications/__tests__/notifications.router.int.test.d.ts +2 -0
  35. package/dist/engagement/notifications/__tests__/notifications.router.int.test.d.ts.map +1 -0
  36. package/dist/engagement/notifications/__tests__/notifications.router.int.test.js +142 -0
  37. package/dist/engagement/notifications/__tests__/notifications.router.int.test.js.map +1 -0
  38. package/dist/engagement/notifications/__tests__/notifications.service.int.test.js +113 -7
  39. package/dist/engagement/notifications/__tests__/notifications.service.int.test.js.map +1 -1
  40. package/dist/engagement/notifications/contract/index.d.ts +98 -17
  41. package/dist/engagement/notifications/contract/index.d.ts.map +1 -1
  42. package/dist/engagement/notifications/contract/index.js +34 -4
  43. package/dist/engagement/notifications/contract/index.js.map +1 -1
  44. package/dist/engagement/notifications/drizzle/migrations/0001_classy_pixie.sql +1 -0
  45. package/dist/engagement/notifications/drizzle/migrations/0002_open_madame_web.sql +2 -0
  46. package/dist/engagement/notifications/drizzle/migrations/meta/0001_snapshot.json +98 -0
  47. package/dist/engagement/notifications/drizzle/migrations/meta/0002_snapshot.json +119 -0
  48. package/dist/engagement/notifications/drizzle/migrations/meta/_journal.json +14 -0
  49. package/dist/engagement/notifications/plugin.d.ts +20 -3
  50. package/dist/engagement/notifications/plugin.d.ts.map +1 -1
  51. package/dist/engagement/notifications/plugin.js +219 -75
  52. package/dist/engagement/notifications/plugin.js.map +1 -1
  53. package/dist/engagement/notifications/react/notifications.d.ts +20 -0
  54. package/dist/engagement/notifications/react/notifications.d.ts.map +1 -0
  55. package/dist/engagement/notifications/react/notifications.js +39 -0
  56. package/dist/engagement/notifications/react/notifications.js.map +1 -0
  57. package/dist/engagement/notifications/router/index.d.ts +70 -17
  58. package/dist/engagement/notifications/router/index.d.ts.map +1 -1
  59. package/dist/engagement/notifications/router/index.js +37 -16
  60. package/dist/engagement/notifications/router/index.js.map +1 -1
  61. package/dist/engagement/notifications/schema/index.d.ts +36 -0
  62. package/dist/engagement/notifications/schema/index.d.ts.map +1 -1
  63. package/dist/engagement/notifications/schema/index.js +7 -2
  64. package/dist/engagement/notifications/schema/index.js.map +1 -1
  65. package/dist/engagement/notifications/service/notifications.service.d.ts +27 -19
  66. package/dist/engagement/notifications/service/notifications.service.d.ts.map +1 -1
  67. package/dist/engagement/notifications/service/notifications.service.js +41 -11
  68. package/dist/engagement/notifications/service/notifications.service.js.map +1 -1
  69. package/dist/engagement/react.d.ts +2 -0
  70. package/dist/engagement/react.d.ts.map +1 -0
  71. package/dist/engagement/react.js +3 -0
  72. package/dist/engagement/react.js.map +1 -0
  73. package/dist/engagement/social/plugin.d.ts +6 -0
  74. package/dist/engagement/social/plugin.d.ts.map +1 -1
  75. package/dist/iam/plugin.d.ts +6 -0
  76. package/dist/iam/plugin.d.ts.map +1 -1
  77. package/dist/pam/identity/plugin.d.ts +6 -0
  78. package/dist/pam/identity/plugin.d.ts.map +1 -1
  79. package/dist/pam/player-management/plugin.d.ts +6 -0
  80. package/dist/pam/player-management/plugin.d.ts.map +1 -1
  81. package/dist/pam/player-note/plugin.d.ts +6 -0
  82. package/dist/pam/player-note/plugin.d.ts.map +1 -1
  83. package/dist/pam/profile/plugin.d.ts +6 -0
  84. package/dist/pam/profile/plugin.d.ts.map +1 -1
  85. package/dist/pam/tag/plugin.d.ts +6 -0
  86. package/dist/pam/tag/plugin.d.ts.map +1 -1
  87. package/dist/server/runtime/core-token-catalog.d.ts +6 -0
  88. package/dist/server/runtime/core-token-catalog.d.ts.map +1 -1
  89. package/dist/wallet/plugin.d.ts +6 -0
  90. package/dist/wallet/plugin.d.ts.map +1 -1
  91. package/package.json +6 -1
@@ -0,0 +1,98 @@
1
+ {
2
+ "id": "fa9988e3-5040-4f86-b921-4320fa27eeaa",
3
+ "prevId": "c5045022-6772-41f6-b8cc-54dc6f58645d",
4
+ "version": "7",
5
+ "dialect": "postgresql",
6
+ "tables": {
7
+ "public.notification": {
8
+ "name": "notification",
9
+ "schema": "",
10
+ "columns": {
11
+ "id": {
12
+ "name": "id",
13
+ "type": "uuid",
14
+ "primaryKey": true,
15
+ "notNull": true,
16
+ "default": "gen_random_uuid()"
17
+ },
18
+ "user_id": {
19
+ "name": "user_id",
20
+ "type": "uuid",
21
+ "primaryKey": false,
22
+ "notNull": true
23
+ },
24
+ "type": {
25
+ "name": "type",
26
+ "type": "text",
27
+ "primaryKey": false,
28
+ "notNull": true
29
+ },
30
+ "title": {
31
+ "name": "title",
32
+ "type": "text",
33
+ "primaryKey": false,
34
+ "notNull": true
35
+ },
36
+ "body": {
37
+ "name": "body",
38
+ "type": "text",
39
+ "primaryKey": false,
40
+ "notNull": true
41
+ },
42
+ "data": {
43
+ "name": "data",
44
+ "type": "jsonb",
45
+ "primaryKey": false,
46
+ "notNull": false
47
+ },
48
+ "read_at": {
49
+ "name": "read_at",
50
+ "type": "timestamp with time zone",
51
+ "primaryKey": false,
52
+ "notNull": false
53
+ },
54
+ "created_at": {
55
+ "name": "created_at",
56
+ "type": "timestamp with time zone",
57
+ "primaryKey": false,
58
+ "notNull": true,
59
+ "default": "now()"
60
+ }
61
+ },
62
+ "indexes": {
63
+ "notification_user_id_idx": {
64
+ "name": "notification_user_id_idx",
65
+ "columns": [
66
+ {
67
+ "expression": "user_id",
68
+ "isExpression": false,
69
+ "asc": true,
70
+ "nulls": "last"
71
+ }
72
+ ],
73
+ "isUnique": false,
74
+ "concurrently": false,
75
+ "method": "btree",
76
+ "with": {}
77
+ }
78
+ },
79
+ "foreignKeys": {},
80
+ "compositePrimaryKeys": {},
81
+ "uniqueConstraints": {},
82
+ "policies": {},
83
+ "checkConstraints": {},
84
+ "isRLSEnabled": false
85
+ }
86
+ },
87
+ "enums": {},
88
+ "schemas": {},
89
+ "sequences": {},
90
+ "roles": {},
91
+ "policies": {},
92
+ "views": {},
93
+ "_meta": {
94
+ "columns": {},
95
+ "schemas": {},
96
+ "tables": {}
97
+ }
98
+ }
@@ -0,0 +1,119 @@
1
+ {
2
+ "id": "a7e44828-dab8-4c21-ad56-ea8e67992ea5",
3
+ "prevId": "fa9988e3-5040-4f86-b921-4320fa27eeaa",
4
+ "version": "7",
5
+ "dialect": "postgresql",
6
+ "tables": {
7
+ "public.notification": {
8
+ "name": "notification",
9
+ "schema": "",
10
+ "columns": {
11
+ "id": {
12
+ "name": "id",
13
+ "type": "uuid",
14
+ "primaryKey": true,
15
+ "notNull": true,
16
+ "default": "gen_random_uuid()"
17
+ },
18
+ "user_id": {
19
+ "name": "user_id",
20
+ "type": "uuid",
21
+ "primaryKey": false,
22
+ "notNull": true
23
+ },
24
+ "type": {
25
+ "name": "type",
26
+ "type": "text",
27
+ "primaryKey": false,
28
+ "notNull": true
29
+ },
30
+ "title": {
31
+ "name": "title",
32
+ "type": "text",
33
+ "primaryKey": false,
34
+ "notNull": true
35
+ },
36
+ "body": {
37
+ "name": "body",
38
+ "type": "text",
39
+ "primaryKey": false,
40
+ "notNull": true
41
+ },
42
+ "data": {
43
+ "name": "data",
44
+ "type": "jsonb",
45
+ "primaryKey": false,
46
+ "notNull": false
47
+ },
48
+ "read_at": {
49
+ "name": "read_at",
50
+ "type": "timestamp with time zone",
51
+ "primaryKey": false,
52
+ "notNull": false
53
+ },
54
+ "created_at": {
55
+ "name": "created_at",
56
+ "type": "timestamp with time zone",
57
+ "primaryKey": false,
58
+ "notNull": true,
59
+ "default": "now()"
60
+ },
61
+ "event_id": {
62
+ "name": "event_id",
63
+ "type": "uuid",
64
+ "primaryKey": false,
65
+ "notNull": false
66
+ }
67
+ },
68
+ "indexes": {
69
+ "notification_user_id_idx": {
70
+ "name": "notification_user_id_idx",
71
+ "columns": [
72
+ {
73
+ "expression": "user_id",
74
+ "isExpression": false,
75
+ "asc": true,
76
+ "nulls": "last"
77
+ }
78
+ ],
79
+ "isUnique": false,
80
+ "concurrently": false,
81
+ "method": "btree",
82
+ "with": {}
83
+ },
84
+ "notification_event_id_idx": {
85
+ "name": "notification_event_id_idx",
86
+ "columns": [
87
+ {
88
+ "expression": "event_id",
89
+ "isExpression": false,
90
+ "asc": true,
91
+ "nulls": "last"
92
+ }
93
+ ],
94
+ "isUnique": true,
95
+ "concurrently": false,
96
+ "method": "btree",
97
+ "with": {}
98
+ }
99
+ },
100
+ "foreignKeys": {},
101
+ "compositePrimaryKeys": {},
102
+ "uniqueConstraints": {},
103
+ "policies": {},
104
+ "checkConstraints": {},
105
+ "isRLSEnabled": false
106
+ }
107
+ },
108
+ "enums": {},
109
+ "schemas": {},
110
+ "sequences": {},
111
+ "roles": {},
112
+ "policies": {},
113
+ "views": {},
114
+ "_meta": {
115
+ "columns": {},
116
+ "schemas": {},
117
+ "tables": {}
118
+ }
119
+ }
@@ -8,6 +8,20 @@
8
8
  "when": 1783555224659,
9
9
  "tag": "0000_sparkling_kulan_gath",
10
10
  "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "7",
15
+ "when": 1787595667142,
16
+ "tag": "0001_classy_pixie",
17
+ "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "7",
22
+ "when": 1787911988004,
23
+ "tag": "0002_open_madame_web",
24
+ "breakpoints": true
11
25
  }
12
26
  ]
13
27
  }
@@ -1,4 +1,15 @@
1
- import { type AdminUserDirectory, type JobQueueAdapter, type NotificationDeliveryAdapter } from '@openora/core/contracts';
1
+ import { type AdminUserDirectory, type DomainEventName, type JobQueueAdapter, type NotificationDeliveryAdapter, type RealtimeTransport } from '@openora/core/contracts';
2
+ import { type CreateNotificationInput } from './contract/index.js';
3
+ type NotificationMapEntry = {
4
+ event: DomainEventName;
5
+ sendEmail: boolean;
6
+ handle: (payload: unknown) => CreateNotificationInput | null;
7
+ };
8
+ export declare const notificationEventMap: NotificationMapEntry[];
9
+ export declare function buildKycResubmissionNotification(payload: {
10
+ userId: string;
11
+ reason: string | null;
12
+ }): CreateNotificationInput;
2
13
  declare const _default: {
3
14
  readonly id: "notifications";
4
15
  readonly dependsOn: readonly ["identity"];
@@ -16,7 +27,7 @@ declare const _default: {
16
27
  CHAT_BLOCK_WRITER: import("@openora/core/contracts").Token<import("@openora/core/contracts").ChatBlockWriter>;
17
28
  CHAT_MODERATION: import("@openora/core/contracts").Token<import("@openora/core/contracts").ChatModeration, string>;
18
29
  CHAT_REALTIME_CLIENT_AUTHORIZER: import("@openora/core/contracts").Token<import("@openora/core/contracts").RealtimeClientAuthorizer>;
19
- CHAT_REALTIME_TRANSPORT: import("@openora/core/contracts").Token<import("@openora/core/contracts").RealtimeTransport>;
30
+ CHAT_REALTIME_TRANSPORT: import("@openora/core/contracts").Token<RealtimeTransport>;
20
31
  CHAT_ROOM_ACCESS: import("@openora/core/contracts").Token<import("@openora/core/contracts").ChatRoomAccess>;
21
32
  CHAT_SYSTEM_WRITER: import("@openora/core/contracts").Token<import("@openora/core/contracts").ChatSystemWriter, string>;
22
33
  DRIZZLE: import("@openora/core/contracts").Token<import("@openora/core/server").DrizzleService>;
@@ -118,6 +129,12 @@ declare const _default: {
118
129
  termsVersion: string;
119
130
  requireEmailVerification: boolean;
120
131
  } | undefined;
132
+ notifications?: {
133
+ retention?: {
134
+ days: number;
135
+ cron: string;
136
+ } | undefined;
137
+ } | undefined;
121
138
  supportedLanguages?: string[] | undefined;
122
139
  }, string>;
123
140
  PLAYER_ACTIVITY_TRACKER: import("@openora/core/contracts").Token<import("@openora/core/contracts").PlayerActivityTracker>;
@@ -126,7 +143,7 @@ declare const _default: {
126
143
  PLAY_ELIGIBILITY: import("@openora/core/contracts").Token<import("@openora/core/contracts").PlayEligibilityPort>;
127
144
  RATE_LIMITER: import("@openora/core/contracts").Token<import("@openora/core/contracts").RateLimiterAdapter<`register:${string}` | `login:${string}` | `enable2fa:${string}` | `verify2fa:${string}` | `disable2fa:${string}` | `pwreset-req:${string}` | `pwreset-verify:${string}` | `pwreset:${string}` | `change-password:${string}` | `email-verify:${string}` | `verify-email:${string}` | `wallet-mutation:${string}` | `wallet-webhook:${string}` | `chat-room-join:${string}` | `chat-send:${string}` | `report-access-denied:${string}`>>;
128
145
  REALTIME_CLIENT_AUTHORIZER: import("@openora/core/contracts").Token<import("@openora/core/contracts").RealtimeClientAuthorizer>;
129
- REALTIME_TRANSPORT: import("@openora/core/contracts").Token<import("@openora/core/contracts").RealtimeTransport>;
146
+ REALTIME_TRANSPORT: import("@openora/core/contracts").Token<RealtimeTransport>;
130
147
  RNG_ADAPTER: import("@openora/core/contracts").Token<import("@openora/core/contracts").RngAdapter>;
131
148
  SEND_EMAIL: import("@openora/core/contracts").Token<import("@openora/core/contracts").SendEmailPort>;
132
149
  SESSION_COMMANDS: import("@openora/core/contracts").Token<import("@openora/core/contracts").SessionCommands>;
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../../src/engagement/notifications/plugin.ts"],"names":[],"mappings":"AACA,OAAO,EAOL,KAAK,kBAAkB,EACvB,KAAK,eAAe,EACpB,KAAK,2BAA2B,EACjC,MAAM,yBAAyB,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAcjC,wBA2J8C"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../../src/engagement/notifications/plugin.ts"],"names":[],"mappings":"AACA,OAAO,EASL,KAAK,kBAAkB,EACvB,KAAK,eAAe,EAEpB,KAAK,eAAe,EACpB,KAAK,2BAA2B,EAChC,KAAK,iBAAiB,EACvB,MAAM,yBAAyB,CAAC;AAUjC,OAAO,EAAiC,KAAK,uBAAuB,EAAE,MAAM,qBAAqB,CAAC;AAgClG,KAAK,oBAAoB,GAAG;IAC1B,KAAK,EAAE,eAAe,CAAC;IACvB,SAAS,EAAE,OAAO,CAAC;IACnB,MAAM,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,uBAAuB,GAAG,IAAI,CAAC;CAC9D,CAAC;AAqCF,eAAO,MAAM,oBAAoB,EAAE,oBAAoB,EAuItD,CAAC;AAEF,wBAAgB,gCAAgC,CAAC,OAAO,EAAE;IACxD,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB,GAAG,uBAAuB,CAQ1B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAED,wBAmL8C"}
@@ -1,18 +1,155 @@
1
1
  import * as z from 'zod';
2
- import { ADMIN_USER_DIRECTORY, JOB_QUEUE, NOTIFICATION_DELIVERY_ADAPTER, UuidSchema, domainEventSchemas, queue, } from '@openora/core/contracts';
2
+ import { ADMIN_USER_DIRECTORY, JOB_QUEUE, NOTIFICATION_DELIVERY_ADAPTER, PLATFORM_CONFIG, REALTIME_TRANSPORT, UuidSchema, domainEventSchemas, queue, } from '@openora/core/contracts';
3
3
  import { createLogger, EVENT_BUS, DRIZZLE } from '@openora/core/server';
4
4
  import { MockNotificationDeliveryAdapter } from './adapters/mock/mock-notification-adapter.js';
5
- import { createNotificationsRouter } from './router/index.js';
5
+ import { createNotificationsRouter, notificationsChannel, toNotificationDto, } from './router/index.js';
6
6
  import { NotificationsService } from './service/notifications.service.js';
7
+ import { CreateNotificationInputSchema } from './contract/index.js';
7
8
  const KYC_RESUBMISSION_NOTIFY_QUEUE = queue('kyc-resubmission-notify');
9
+ const NOTIFICATIONS_RETENTION_PURGE_QUEUE = queue('notifications-retention-purge');
10
+ const NOTIFICATIONS_DISPATCH_QUEUE = queue('notifications-dispatch');
11
+ const DEFAULT_NOTIFICATIONS_RETENTION_DAYS = 30;
12
+ const DEFAULT_NOTIFICATIONS_RETENTION_CRON = '0 3 * * *';
8
13
  const KycResubmissionNotifyJobSchema = z.object({
9
14
  userId: UuidSchema,
10
15
  reason: z.string().nullable(),
11
16
  });
17
+ const NotificationDispatchJobSchema = z.object({
18
+ event: z.string(),
19
+ input: CreateNotificationInputSchema,
20
+ sendEmail: z.boolean(),
21
+ });
22
+ function formatMoneyAmount(amount) {
23
+ const match = /^(-)?(\d+)(?:\.(\d+))?$/.exec(amount);
24
+ if (!match) {
25
+ return amount;
26
+ }
27
+ const [, sign, integerPart, fractionPart] = match;
28
+ const groupedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
29
+ const trimmedFraction = fractionPart ? fractionPart.replace(/0+$/, '') : '';
30
+ const magnitude = trimmedFraction ? `${groupedInteger}.${trimmedFraction}` : groupedInteger;
31
+ return sign ? `${sign}${magnitude}` : magnitude;
32
+ }
33
+ // Drops null/undefined entity refs (eg a nullable roomId) so `data` only ever carries
34
+ // fields the source event actually had a value for; an all-null payload becomes `null`.
35
+ function compactData(fields) {
36
+ const entries = Object.entries(fields).filter((entry) => entry[1] !== null && entry[1] !== undefined);
37
+ return entries.length > 0 ? Object.fromEntries(entries) : null;
38
+ }
39
+ // A plain array literal would widen every entry's `event` to DomainEventName and lose the
40
+ // payload type tied to it; this generic keeps K bound per entry so buildNotification stays
41
+ // checked against that one event's actual payload shape.
42
+ function mapEvent(event, buildNotification, options) {
43
+ return {
44
+ event,
45
+ sendEmail: options.sendEmail,
46
+ handle: (payload) => {
47
+ const parsed = domainEventSchemas[event].safeParse(payload);
48
+ if (!parsed.success) {
49
+ return null;
50
+ }
51
+ // Library boundary: TS cannot narrow a domainEventSchemas lookup keyed by this
52
+ // function's own generic K to that one event's payload type; the pairing holds
53
+ // by domainEventSchemas' own definition (DomainEventPayload<K> is inferred from it).
54
+ return buildNotification(parsed.data);
55
+ },
56
+ };
57
+ }
58
+ export const notificationEventMap = [
59
+ mapEvent('wallet.withdrawal.approved', (p) => ({
60
+ userId: p.userId,
61
+ type: 'withdrawal.approved',
62
+ title: 'Withdrawal approved',
63
+ body: `Your withdrawal of ${formatMoneyAmount(p.amount)} ${p.currency} has been approved and is being processed.`,
64
+ data: { transactionId: p.transactionId },
65
+ }), { sendEmail: true }),
66
+ mapEvent('wallet.withdrawal.rejected', (p) => ({
67
+ userId: p.userId,
68
+ type: 'withdrawal.rejected',
69
+ title: 'Withdrawal rejected',
70
+ body: `Your withdrawal of ${formatMoneyAmount(p.amount)} ${p.currency} was rejected and the funds were returned to your balance.${p.reason ? ` Reason: ${p.reason}.` : ''}`,
71
+ data: { transactionId: p.transactionId },
72
+ }), { sendEmail: true }),
73
+ mapEvent('wallet.withdrawal.requested', (p) => ({
74
+ userId: p.userId,
75
+ type: 'withdrawal.requested',
76
+ title: 'Withdrawal requested',
77
+ body: `Your withdrawal request of ${formatMoneyAmount(p.amount)} ${p.currency} has been received and is pending review.`,
78
+ data: { transactionId: p.transactionId },
79
+ }), { sendEmail: false }),
80
+ mapEvent('wallet.withdrawal.completed', (p) => ({
81
+ userId: p.userId,
82
+ type: 'withdrawal.completed',
83
+ title: 'Withdrawal completed',
84
+ body: `Your withdrawal of ${formatMoneyAmount(p.amount)} ${p.currency} has been completed.`,
85
+ data: { transactionId: p.transactionId },
86
+ }), { sendEmail: false }),
87
+ mapEvent('wallet.withdrawal.failed', (p) => ({
88
+ userId: p.userId,
89
+ type: 'withdrawal.failed',
90
+ title: 'Withdrawal failed',
91
+ body: `Your withdrawal of ${formatMoneyAmount(p.amount)} ${p.currency} failed and the funds were returned to your balance.`,
92
+ data: { transactionId: p.transactionId },
93
+ }), { sendEmail: false }),
94
+ mapEvent('wallet.deposit.completed', (p) => ({
95
+ userId: p.userId,
96
+ type: 'deposit.completed',
97
+ title: 'Deposit completed',
98
+ body: `Your deposit of ${formatMoneyAmount(p.amount)} ${p.currency} has been completed.`,
99
+ data: { transactionId: p.transactionId },
100
+ }), { sendEmail: false }),
101
+ mapEvent('wallet.manual_adjustment.created', (p) => ({
102
+ userId: p.userId,
103
+ type: 'balance.adjusted',
104
+ title: 'Balance adjusted',
105
+ body: `Your balance was ${p.direction === 'credit' ? 'credited' : 'debited'} ${formatMoneyAmount(p.amount)} ${p.currency}. Reason: ${p.reason}.`,
106
+ data: { transactionId: p.transactionId },
107
+ }), { sendEmail: false }),
108
+ mapEvent('wallet.bonus_rollover.completed', (p) => ({
109
+ userId: p.userId,
110
+ type: 'wallet.bonus_rollover.completed',
111
+ title: 'Bonus unlocked',
112
+ body: `Your ${formatMoneyAmount(p.creditedAmount)} ${p.currency} bonus credit has cleared its rollover requirement and is now fully withdrawable.`,
113
+ data: null,
114
+ }), { sendEmail: false }),
115
+ mapEvent('chat.user.mentioned', (p) => ({
116
+ userId: p.mentionedUserId,
117
+ type: 'chat.mention',
118
+ title: 'You were mentioned',
119
+ body: 'You were mentioned in a chat message.',
120
+ data: compactData({ roomId: p.roomId, messageId: p.messageId }),
121
+ }), { sendEmail: false }),
122
+ // Pre-existing types: in-app only, unchanged from before the declarative-map refactor.
123
+ mapEvent('social.friend_request.sent', (p) => ({
124
+ userId: p.addresseeId,
125
+ type: 'social.friend_request.received',
126
+ title: 'New friend request',
127
+ body: `${p.requesterUsername} sent you a friend request.`,
128
+ data: { requesterId: p.requesterId },
129
+ }), { sendEmail: false }),
130
+ mapEvent('social.friend_request.accepted', (p) => ({
131
+ userId: p.requesterId,
132
+ type: 'social.friend_request.accepted',
133
+ title: 'Friend request accepted',
134
+ body: `${p.accepterUsername} accepted your friend request.`,
135
+ // accepterId, not requesterId: the recipient of THIS notification is the
136
+ // requester, so the linkable "other party" is whoever just accepted.
137
+ data: { accepterId: p.accepterId },
138
+ }), { sendEmail: false }),
139
+ ];
140
+ export function buildKycResubmissionNotification(payload) {
141
+ return {
142
+ userId: payload.userId,
143
+ type: 'kyc.resubmission_requested',
144
+ title: 'Document resubmission required',
145
+ body: `An admin has requested you resubmit your verification documents.${payload.reason ? ` Reason: ${payload.reason}.` : ''}`,
146
+ data: null,
147
+ };
148
+ }
12
149
  export default {
13
150
  id: 'notifications',
14
151
  // ADMIN_USER_DIRECTORY (owned by identity) resolves the player's email for the
15
- // withdrawal delivery emails; pin load order so a split still finds the port. See ADR-0017.
152
+ // delivery emails; pin load order so a split still finds the port. See ADR-0017.
16
153
  dependsOn: ['identity'],
17
154
  register(ctx) {
18
155
  ctx.provide(NOTIFICATION_DELIVERY_ADAPTER, () => new MockNotificationDeliveryAdapter());
@@ -23,6 +160,8 @@ export default {
23
160
  let deliveryRef = null;
24
161
  let directoryRef = null;
25
162
  let jobQueueRef = null;
163
+ let realtimeRef = null;
164
+ let retentionDaysRef = DEFAULT_NOTIFICATIONS_RETENTION_DAYS;
26
165
  // Best-effort email alongside the in-app notification; a missing user or delivery
27
166
  // failure is logged, never thrown - the in-app notification already landed.
28
167
  const sendEmail = (userId, title, body) => {
@@ -41,69 +180,34 @@ export default {
41
180
  })
42
181
  .catch((err) => logger.error({ err }, 'notification email delivery failed'));
43
182
  };
44
- ctx.events.on('wallet.withdrawal.approved', (payload) => {
45
- const parsed = domainEventSchemas['wallet.withdrawal.approved'].safeParse(payload);
46
- if (!parsed.success || !svcRef) {
47
- return;
48
- }
49
- const p = parsed.data;
50
- const title = 'Withdrawal approved';
51
- const body = `Your withdrawal of ${p.amount} ${p.currency} has been approved and is being processed.`;
52
- svcRef
53
- .create({ userId: p.userId, type: 'withdrawal.approved', title, body })
54
- .catch((err) => logger.error({ err }, 'withdrawal.approved notification failed'));
55
- sendEmail(p.userId, title, body);
56
- });
57
- ctx.events.on('wallet.withdrawal.rejected', (payload) => {
58
- const parsed = domainEventSchemas['wallet.withdrawal.rejected'].safeParse(payload);
59
- if (!parsed.success || !svcRef) {
60
- return;
61
- }
62
- const p = parsed.data;
63
- const reason = p.reason ? ` Reason: ${p.reason}.` : '';
64
- const title = 'Withdrawal rejected';
65
- const body = `Your withdrawal of ${p.amount} ${p.currency} was rejected and the funds were returned to your balance.${reason}`;
66
- svcRef
67
- .create({ userId: p.userId, type: 'withdrawal.rejected', title, body })
68
- .catch((err) => logger.error({ err }, 'withdrawal.rejected notification failed'));
69
- sendEmail(p.userId, title, body);
70
- });
71
- ctx.events.on('wallet.bonus_rollover.completed', (payload) => {
72
- const parsed = domainEventSchemas['wallet.bonus_rollover.completed'].safeParse(payload);
73
- if (!parsed.success || !svcRef) {
74
- return;
75
- }
76
- const p = parsed.data;
77
- const title = 'Bonus unlocked';
78
- const body = `Your ${p.creditedAmount} ${p.currency} bonus credit has cleared its rollover requirement and is now fully withdrawable.`;
79
- svcRef
80
- .create({ userId: p.userId, type: 'wallet.bonus_rollover.completed', title, body })
81
- .catch((err) => logger.error({ err }, 'wallet.bonus_rollover.completed notification failed'));
82
- });
83
- ctx.events.on('social.friend_request.sent', (payload) => {
84
- const parsed = domainEventSchemas['social.friend_request.sent'].safeParse(payload);
85
- if (!parsed.success || !svcRef) {
183
+ const publishNotification = (record) => {
184
+ if (!realtimeRef) {
86
185
  return;
87
186
  }
88
- const p = parsed.data;
89
- const title = 'New friend request';
90
- const body = `${p.requesterUsername} sent you a friend request.`;
91
- svcRef
92
- .create({ userId: p.addresseeId, type: 'social.friend_request.received', title, body })
93
- .catch((err) => logger.error({ err }, 'social.friend_request.sent notification failed'));
94
- });
95
- ctx.events.on('social.friend_request.accepted', (payload) => {
96
- const parsed = domainEventSchemas['social.friend_request.accepted'].safeParse(payload);
97
- if (!parsed.success || !svcRef) {
98
- return;
187
+ const dto = toNotificationDto(record);
188
+ if (dto) {
189
+ void Promise.resolve(realtimeRef.publish(notificationsChannel(record.userId), dto)).catch((err) => logger.error({ err }, 'notification realtime publish failed'));
99
190
  }
100
- const p = parsed.data;
101
- const title = 'Friend request accepted';
102
- const body = `${p.accepterUsername} accepted your friend request.`;
103
- svcRef
104
- .create({ userId: p.requesterId, type: 'social.friend_request.accepted', title, body })
105
- .catch((err) => logger.error({ err }, 'social.friend_request.accepted notification failed'));
106
- });
191
+ };
192
+ for (const entry of notificationEventMap) {
193
+ ctx.events.on(entry.event, (payload, envelope) => {
194
+ const input = entry.handle(payload);
195
+ if (!input || !jobQueueRef || !envelope) {
196
+ return;
197
+ }
198
+ jobQueueRef
199
+ .enqueue(NOTIFICATIONS_DISPATCH_QUEUE, {
200
+ event: entry.event,
201
+ input: { ...input, eventId: envelope.eventId },
202
+ sendEmail: entry.sendEmail,
203
+ }, {
204
+ idempotencyKey: `notifications-dispatch:${envelope.eventId}`,
205
+ attempts: 5,
206
+ backoff: { type: 'exponential', delayMs: 1000 },
207
+ })
208
+ .catch((err) => logger.error({ err, event: entry.event }, 'notification dispatch enqueue failed'));
209
+ });
210
+ }
107
211
  ctx.events.on('compliance.kyc.updated', (payload, envelope) => {
108
212
  const parsed = domainEventSchemas['compliance.kyc.updated'].safeParse(payload);
109
213
  if (!parsed.success || !jobQueueRef) {
@@ -113,8 +217,12 @@ export default {
113
217
  if (p.status !== 'resubmission_requested' || p.source !== 'manual') {
114
218
  return;
115
219
  }
220
+ if (!envelope?.eventId) {
221
+ logger.warn({ userId: p.userId }, 'kyc-resubmission-notify enqueue skipped: missing eventId');
222
+ return;
223
+ }
116
224
  jobQueueRef
117
- .enqueue(KYC_RESUBMISSION_NOTIFY_QUEUE, { userId: p.userId, reason: p.reason }, { idempotencyKey: `kyc-resubmission-notify:${envelope?.eventId ?? p.userId}` })
225
+ .enqueue(KYC_RESUBMISSION_NOTIFY_QUEUE, { userId: p.userId, reason: p.reason }, { idempotencyKey: `kyc-resubmission-notify:${envelope.eventId}` })
118
226
  .catch((err) => logger.error({ err }, 'kyc-resubmission-notify enqueue failed'));
119
227
  });
120
228
  ctx.jobs.worker({
@@ -124,16 +232,44 @@ export default {
124
232
  if (!svcRef) {
125
233
  return;
126
234
  }
127
- const reasonText = payload.reason ? ` Reason: ${payload.reason}.` : '';
128
- const title = 'Document resubmission required';
129
- const body = `An admin has requested you resubmit your verification documents.${reasonText}`;
130
- await svcRef.create({
131
- userId: payload.userId,
132
- type: 'kyc.resubmission_requested',
133
- title,
134
- body,
135
- });
136
- sendEmail(payload.userId, title, body);
235
+ const input = buildKycResubmissionNotification(payload);
236
+ const record = await svcRef.create(input);
237
+ if (!record) {
238
+ return;
239
+ }
240
+ publishNotification(record);
241
+ sendEmail(input.userId, input.title, input.body);
242
+ },
243
+ });
244
+ ctx.jobs.worker({
245
+ queue: NOTIFICATIONS_DISPATCH_QUEUE,
246
+ schema: NotificationDispatchJobSchema,
247
+ handler: async ({ payload }) => {
248
+ if (!svcRef) {
249
+ return;
250
+ }
251
+ const record = await svcRef.create(payload.input);
252
+ if (!record) {
253
+ return;
254
+ }
255
+ publishNotification(record);
256
+ if (payload.sendEmail) {
257
+ sendEmail(record.userId, record.title, record.body);
258
+ }
259
+ },
260
+ onDeadLetter: (jobCtx, error) => {
261
+ logger.error({ err: error, event: jobCtx.payload.event, jobId: jobCtx.id }, 'notification dispatch exhausted retries');
262
+ },
263
+ });
264
+ ctx.jobs.worker({
265
+ queue: NOTIFICATIONS_RETENTION_PURGE_QUEUE,
266
+ schema: z.object({}),
267
+ handler: async () => {
268
+ if (!svcRef) {
269
+ return;
270
+ }
271
+ const { count } = await svcRef.purgeExpired(retentionDaysRef);
272
+ logger.info({ count }, 'notification retention purge complete');
137
273
  },
138
274
  });
139
275
  ctx.routers.add('notifications', (c) => {
@@ -142,7 +278,15 @@ export default {
142
278
  deliveryRef = c.get(NOTIFICATION_DELIVERY_ADAPTER);
143
279
  directoryRef = c.get(ADMIN_USER_DIRECTORY);
144
280
  jobQueueRef = c.get(JOB_QUEUE);
145
- return createNotificationsRouter(svc);
281
+ realtimeRef = c.get(REALTIME_TRANSPORT);
282
+ const platformConfig = c.has(PLATFORM_CONFIG) ? c.get(PLATFORM_CONFIG) : undefined;
283
+ retentionDaysRef =
284
+ platformConfig?.notifications?.retention?.days ?? DEFAULT_NOTIFICATIONS_RETENTION_DAYS;
285
+ const purgeCron = platformConfig?.notifications?.retention?.cron ?? DEFAULT_NOTIFICATIONS_RETENTION_CRON;
286
+ // Off-peak, one hour after tag's daily-evaluation (0 2 * * *), so the in-process
287
+ // driver doesn't run both sweeps at once in dev/test.
288
+ void jobQueueRef.schedule(NOTIFICATIONS_RETENTION_PURGE_QUEUE, 'notifications.retention-purge.daily', {}, { cron: purgeCron });
289
+ return createNotificationsRouter({ notifications: svc, realtime: realtimeRef });
146
290
  });
147
291
  },
148
292
  };