@almadar/std 16.216.0 → 16.218.0
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.
- package/behaviors/lolo/infra/atoms/std-data-import.lolo +272 -0
- package/behaviors/lolo/infra/atoms/std-global-search.lolo +248 -0
- package/behaviors/lolo/infra/atoms/std-notification-preferences.lolo +526 -0
- package/behaviors/lolo/infra/atoms/std-permission-matrix.lolo +522 -0
- package/behaviors/lolo/infra/atoms/std-reminder-scheduler.lolo +11 -3
- package/behaviors/registry/infra/atoms/std-data-import.orb +1164 -0
- package/behaviors/registry/infra/atoms/std-global-search.orb +1417 -0
- package/behaviors/registry/infra/atoms/std-notification-preferences.orb +2404 -0
- package/behaviors/registry/infra/atoms/std-permission-matrix.orb +2229 -0
- package/behaviors/registry/infra/atoms/std-reminder-scheduler.orb +144 -43
- package/dist/behaviors/exports-reader.js +12813 -5636
- package/dist/behaviors/functions/index.d.ts +756 -8
- package/dist/behaviors/functions/index.js +12772 -5637
- package/dist/behaviors/index.d.ts +1 -1
- package/dist/behaviors/index.js +12814 -5637
- package/dist/behaviors/query.js +12813 -5636
- package/dist/behaviors-embeddings.hash.json +3 -3
- package/dist/behaviors-embeddings.json +19 -3
- package/dist/behaviors-registry.json +3534 -683
- package/dist/index.d.ts +1 -1
- package/dist/index.js +12814 -5637
- package/dist/knob-embeddings.hash.json +3 -3
- package/dist/knob-embeddings.json +1 -1
- package/dist/registry/factory-signatures.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
app std-notification-preferences "1.0.0"
|
|
2
|
+
"std-notification-preferences — per-person notification preference store plus a consultable gate. Owns NotificationPreference rows (one per person + event key: channel override in_app/email/push/slack, mode instant/digest, quiet-hours window 'HH:MM-HH:MM' such as 22:00-07:00). The PreferencesManage trait is standard CRUD over the store (list / create / update / delete, persisted, success/failure cascade arms): a data-grid browse plus ModalRecordModal create/edit forms whose SAVE carries the full row payload (std-list daisy-chain idiom). The PreferenceGate trait is the consult half other notify traits call: emit CHECK_PREFERENCE { personId, eventKey } and the gate fetches the matching row (a row whose eventKey is blank applies to every event) and answers PREFERENCE_RESOLVED { personId, eventKey, channel, mode, suppressed } — suppressed is true only when the row's quietHours window covers the current hour; mode 'digest' rides on the verdict for the caller to defer. Default policy: no matching row — or a failed lookup — resolves fail-open to { channel: in_app, mode: instant, suppressed: false }; the absence of a preference never silences a notification."
|
|
3
|
+
orbital NotificationPreferencesOrbital {
|
|
4
|
+
uses Modal from "std/behaviors/std-modal"
|
|
5
|
+
|
|
6
|
+
type CHECK_PREFERENCE = Event { personId : string, eventKey : string } "Consult the gate: which channel/mode applies to this person for this event, and is delivery suppressed right now? Blank eventKey falls back to the gate's configured eventKey."
|
|
7
|
+
|
|
8
|
+
type PREFERENCE_RESOLVED = Event { personId : string, eventKey : string, channel : string, mode : string, suppressed : boolean } "The gate's verdict. Fail-open defaults: no matching row or a failed lookup resolves { channel: in_app, mode: instant, suppressed: false } — absence of a preference never silences a notification."
|
|
9
|
+
|
|
10
|
+
entity NotificationPreference [persistent: notification_preferences] {
|
|
11
|
+
id : string!
|
|
12
|
+
personId : string! @description "The person this preference row belongs to." @synonyms "user, recipient, owner, account"
|
|
13
|
+
eventKey : string! @description "Event key this preference applies to (matches a notify listener's eventKey); blank = all events." @synonyms "event, trigger, topic, subscription"
|
|
14
|
+
channel : "in_app" | "email" | "push" | "slack" @description "Delivery channel override for this person." @synonyms "preferred channel, delivery method" = in_app
|
|
15
|
+
mode : "instant" | "digest" @description "Delivery mode: instant (default) or digest (deferred to a batch cycle)." @synonyms "delivery mode, frequency, cadence, defer" = instant
|
|
16
|
+
quietHours : string @description "Quiet-hours window as \"HH:MM-HH:MM\" (e.g. 22:00-07:00); blank or equal start/end = disabled." @synonyms "do not disturb, quiet window, night mode" = ""
|
|
17
|
+
createdAt : number @description "Timestamp indicating when this preference row was created." @synonyms "creation time, created" = 0
|
|
18
|
+
updatedAt : number @description "Timestamp indicating when this preference row was last updated." @synonyms "modified, last updated" = 0
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
entity GateView [runtime] {
|
|
22
|
+
id : string!
|
|
23
|
+
personId : string @description "Scratch: personId of the consult currently in flight." @synonyms "current person" = ""
|
|
24
|
+
eventKey : string @description "Scratch: effective event key of the consult currently in flight (payload eventKey, or the configured eventKey when the payload is blank)." @synonyms "current event key" = ""
|
|
25
|
+
channel : string @description "Scratch: resolved channel for the current consult." @synonyms "resolved channel" = ""
|
|
26
|
+
mode : string @description "Scratch: resolved delivery mode for the current consult." @synonyms "resolved mode" = ""
|
|
27
|
+
quietHours : string @description "Scratch: quiet-hours window of the matched row for the current consult; blank = none." @synonyms "resolved quiet hours" = ""
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
;; Browse + coordinator in one trait (std-notification-center's
|
|
31
|
+
;; PreferenceEditor shape): the grid owns list/delete, the modal trait refs
|
|
32
|
+
;; own the create/edit forms, and the DO_CREATE/DO_UPDATE arms own the
|
|
33
|
+
;; persist side-effects triggered by the modals' payload-carrying SAVE
|
|
34
|
+
;; (renamed PREFERENCE_CREATED/PREFERENCE_UPDATED so create and edit stay
|
|
35
|
+
;; distinguishable without source-scoped listens — the std-list idiom).
|
|
36
|
+
trait PreferencesManage -> NotificationPreference [interaction, collection] {
|
|
37
|
+
initial: loading
|
|
38
|
+
|
|
39
|
+
state loading {
|
|
40
|
+
INIT -> loading
|
|
41
|
+
(fetch NotificationPreference {
|
|
42
|
+
emit: { failure: PreferencesLoadFailed, success: PreferencesLoaded }
|
|
43
|
+
})
|
|
44
|
+
(render-ui main {
|
|
45
|
+
align: center
|
|
46
|
+
children: [{ type: spinner }, {
|
|
47
|
+
color: muted
|
|
48
|
+
content: "Loading notification preferences…"
|
|
49
|
+
type: typography
|
|
50
|
+
variant: caption
|
|
51
|
+
}]
|
|
52
|
+
className: py-12
|
|
53
|
+
direction: vertical
|
|
54
|
+
gap: md
|
|
55
|
+
type: stack
|
|
56
|
+
})
|
|
57
|
+
PreferencesLoaded -> browsing
|
|
58
|
+
(render-ui main {
|
|
59
|
+
children: [{
|
|
60
|
+
children: [{
|
|
61
|
+
children: [{ name: bell, size: lg, type: icon }, {
|
|
62
|
+
content: @config.title
|
|
63
|
+
type: typography
|
|
64
|
+
variant: h3
|
|
65
|
+
}]
|
|
66
|
+
direction: horizontal
|
|
67
|
+
gap: sm
|
|
68
|
+
type: stack
|
|
69
|
+
}, {
|
|
70
|
+
action: CREATE
|
|
71
|
+
icon: plus
|
|
72
|
+
label: "New preference"
|
|
73
|
+
type: button
|
|
74
|
+
variant: primary
|
|
75
|
+
}]
|
|
76
|
+
direction: horizontal
|
|
77
|
+
gap: sm
|
|
78
|
+
justify: between
|
|
79
|
+
type: stack
|
|
80
|
+
}, { type: divider }, {
|
|
81
|
+
cols: 1
|
|
82
|
+
entity: ?data
|
|
83
|
+
fields: [{
|
|
84
|
+
label: Person
|
|
85
|
+
name: personId
|
|
86
|
+
variant: h4
|
|
87
|
+
}, {
|
|
88
|
+
label: Event
|
|
89
|
+
name: eventKey
|
|
90
|
+
variant: caption
|
|
91
|
+
}, {
|
|
92
|
+
label: Channel
|
|
93
|
+
name: channel
|
|
94
|
+
variant: badge
|
|
95
|
+
}, {
|
|
96
|
+
label: Mode
|
|
97
|
+
name: mode
|
|
98
|
+
variant: badge
|
|
99
|
+
}, {
|
|
100
|
+
label: "Quiet hours"
|
|
101
|
+
name: quietHours
|
|
102
|
+
variant: caption
|
|
103
|
+
}]
|
|
104
|
+
gap: sm
|
|
105
|
+
itemActions: [{
|
|
106
|
+
event: EDIT
|
|
107
|
+
icon: pencil
|
|
108
|
+
label: Edit
|
|
109
|
+
variant: secondary
|
|
110
|
+
}, {
|
|
111
|
+
event: DELETE
|
|
112
|
+
icon: trash-2
|
|
113
|
+
label: Delete
|
|
114
|
+
variant: danger
|
|
115
|
+
}]
|
|
116
|
+
look: @config.tableLook
|
|
117
|
+
type: data-grid
|
|
118
|
+
}]
|
|
119
|
+
direction: vertical
|
|
120
|
+
gap: md
|
|
121
|
+
type: stack
|
|
122
|
+
})
|
|
123
|
+
PreferencesLoadFailed -> error
|
|
124
|
+
(render-ui main {
|
|
125
|
+
align: center
|
|
126
|
+
children: [{
|
|
127
|
+
message: ?error
|
|
128
|
+
type: alert
|
|
129
|
+
variant: error
|
|
130
|
+
}, {
|
|
131
|
+
action: RETRY
|
|
132
|
+
icon: rotate-ccw
|
|
133
|
+
label: Retry
|
|
134
|
+
type: button
|
|
135
|
+
variant: primary
|
|
136
|
+
}]
|
|
137
|
+
className: py-12
|
|
138
|
+
direction: vertical
|
|
139
|
+
gap: md
|
|
140
|
+
type: stack
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
state browsing {
|
|
145
|
+
INIT -> loading
|
|
146
|
+
(fetch NotificationPreference {
|
|
147
|
+
emit: { failure: PreferencesLoadFailed, success: PreferencesLoaded }
|
|
148
|
+
})
|
|
149
|
+
(render-ui main { size: sm, type: spinner })
|
|
150
|
+
;; CREATE/EDIT open the modal trait refs (modal slot) — the grid stays
|
|
151
|
+
;; mounted behind them, so no arm here.
|
|
152
|
+
DELETE -> browsing
|
|
153
|
+
(persist delete NotificationPreference ?id {
|
|
154
|
+
emit: { failure: PreferenceSaveFailed, success: PreferenceDeleted }
|
|
155
|
+
})
|
|
156
|
+
;; The modal SAVE payload carries the whole form row (std-list
|
|
157
|
+
;; persistor shape): the bare ?data pointer is what lets a walk bind the
|
|
158
|
+
;; target row from this step's own payload — a literal field map with a
|
|
159
|
+
;; nested (object/get ?data id) is deliberately not that shape.
|
|
160
|
+
;; createdAt/updatedAt ride the payload when the edit prefill carries
|
|
161
|
+
;; them; the modal idiom cannot stamp @now at submit time.
|
|
162
|
+
DO_CREATE -> browsing
|
|
163
|
+
(persist create NotificationPreference ?data {
|
|
164
|
+
emit: { failure: PreferenceSaveFailed, success: PreferenceSaved }
|
|
165
|
+
})
|
|
166
|
+
DO_UPDATE -> browsing
|
|
167
|
+
(persist update NotificationPreference ?data {
|
|
168
|
+
emit: { failure: PreferenceSaveFailed, success: PreferenceSaved }
|
|
169
|
+
})
|
|
170
|
+
PreferenceSaved -> loading
|
|
171
|
+
(notify success "Notification preference saved")
|
|
172
|
+
(fetch NotificationPreference {
|
|
173
|
+
emit: { failure: PreferencesLoadFailed, success: PreferencesLoaded }
|
|
174
|
+
})
|
|
175
|
+
PreferenceDeleted -> loading
|
|
176
|
+
(notify info "Notification preference removed")
|
|
177
|
+
(fetch NotificationPreference {
|
|
178
|
+
emit: { failure: PreferencesLoadFailed, success: PreferencesLoaded }
|
|
179
|
+
})
|
|
180
|
+
PreferenceSaveFailed -> browsing
|
|
181
|
+
(notify warning "Could not save the notification preference")
|
|
182
|
+
;; A load failure lands on the error state (std-saved-search shape), not
|
|
183
|
+
;; a stay-in-browsing toast: `error` must be reachable from the stable
|
|
184
|
+
;; browsing state — `loading` is transient (the fetch cascade leaves it
|
|
185
|
+
;; immediately), so no walk can park the runtime there to dispatch the
|
|
186
|
+
;; failure event from.
|
|
187
|
+
PreferencesLoadFailed -> error
|
|
188
|
+
(render-ui main {
|
|
189
|
+
align: center
|
|
190
|
+
children: [{
|
|
191
|
+
message: ?error
|
|
192
|
+
type: alert
|
|
193
|
+
variant: error
|
|
194
|
+
}, {
|
|
195
|
+
action: RETRY
|
|
196
|
+
icon: rotate-ccw
|
|
197
|
+
label: Retry
|
|
198
|
+
type: button
|
|
199
|
+
variant: primary
|
|
200
|
+
}]
|
|
201
|
+
className: py-12
|
|
202
|
+
direction: vertical
|
|
203
|
+
gap: md
|
|
204
|
+
type: stack
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
state error {
|
|
209
|
+
INIT -> loading
|
|
210
|
+
(fetch NotificationPreference {
|
|
211
|
+
emit: { failure: PreferencesLoadFailed, success: PreferencesLoaded }
|
|
212
|
+
})
|
|
213
|
+
(render-ui main { size: sm, type: spinner })
|
|
214
|
+
;; the error screen's Retry button: INIT is lifecycle-only and the bus
|
|
215
|
+
;; never delivers it, so the affordance must target a first-class event
|
|
216
|
+
RETRY -> loading
|
|
217
|
+
(fetch NotificationPreference {
|
|
218
|
+
emit: { failure: PreferencesLoadFailed, success: PreferencesLoaded }
|
|
219
|
+
})
|
|
220
|
+
(render-ui main { size: sm, type: spinner })
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
emits {
|
|
224
|
+
CREATE
|
|
225
|
+
@description "Opens the blank preference modal."
|
|
226
|
+
@synonyms "add, new, insert"
|
|
227
|
+
@tier "domain"
|
|
228
|
+
EDIT {
|
|
229
|
+
id : string!
|
|
230
|
+
row : NotificationPreference
|
|
231
|
+
}
|
|
232
|
+
@description "Opens a preference row in the edit modal."
|
|
233
|
+
@synonyms "modify, update, change"
|
|
234
|
+
@tier "domain"
|
|
235
|
+
DELETE {
|
|
236
|
+
id : string!
|
|
237
|
+
row : NotificationPreference
|
|
238
|
+
}
|
|
239
|
+
@description "Removes a preference row."
|
|
240
|
+
@synonyms "remove, discard, clear"
|
|
241
|
+
@tier "domain"
|
|
242
|
+
PreferencesLoaded {
|
|
243
|
+
data : [NotificationPreference]
|
|
244
|
+
}
|
|
245
|
+
@description "The preference collection finished loading."
|
|
246
|
+
@synonyms "loaded, fetched, retrieved"
|
|
247
|
+
@tier "domain"
|
|
248
|
+
PreferencesLoadFailed {
|
|
249
|
+
error : string
|
|
250
|
+
code : string
|
|
251
|
+
}
|
|
252
|
+
@description "The preference collection failed to load."
|
|
253
|
+
@synonyms "error, failure, load error"
|
|
254
|
+
@tier "internal"
|
|
255
|
+
PreferenceSaved {
|
|
256
|
+
id : string!
|
|
257
|
+
}
|
|
258
|
+
@description "A preference row persisted (create or update)."
|
|
259
|
+
@synonyms "created, updated, persisted"
|
|
260
|
+
@tier "domain"
|
|
261
|
+
PreferenceSaveFailed {
|
|
262
|
+
error : string
|
|
263
|
+
code : string
|
|
264
|
+
}
|
|
265
|
+
@description "A preference persist failed."
|
|
266
|
+
@synonyms "failed, error, unsuccessful"
|
|
267
|
+
@tier "internal"
|
|
268
|
+
PreferenceDeleted {
|
|
269
|
+
id : string!
|
|
270
|
+
}
|
|
271
|
+
@description "A preference row was deleted."
|
|
272
|
+
@synonyms "removed, purged, discarded"
|
|
273
|
+
@tier "domain"
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
listens {
|
|
277
|
+
DO_CREATE {
|
|
278
|
+
data : NotificationPreference!
|
|
279
|
+
}
|
|
280
|
+
@description "Create persist requested; the modal's SAVE payload carries the full form row."
|
|
281
|
+
@synonyms "add, new, insert"
|
|
282
|
+
@tier "internal"
|
|
283
|
+
DO_UPDATE {
|
|
284
|
+
data : NotificationPreference!
|
|
285
|
+
}
|
|
286
|
+
@description "Update persist requested; the modal's SAVE payload carries the full form row."
|
|
287
|
+
@synonyms "modified, changed, refreshed"
|
|
288
|
+
@tier "internal"
|
|
289
|
+
PreferenceCreate.PREFERENCE_CREATED -> DO_CREATE
|
|
290
|
+
PreferenceEdit.PREFERENCE_UPDATED -> DO_UPDATE
|
|
291
|
+
DELETE {
|
|
292
|
+
id : string!
|
|
293
|
+
row : NotificationPreference
|
|
294
|
+
}
|
|
295
|
+
@description "Removes a preference row."
|
|
296
|
+
@synonyms "remove, discard, clear"
|
|
297
|
+
@tier "domain"
|
|
298
|
+
RETRY
|
|
299
|
+
@description "Retries loading the preference collection after a failure."
|
|
300
|
+
@synonyms "try again, reload"
|
|
301
|
+
@tier "presentation"
|
|
302
|
+
PreferencesLoaded {
|
|
303
|
+
data : [NotificationPreference]
|
|
304
|
+
}
|
|
305
|
+
@description "The preference collection finished loading."
|
|
306
|
+
@synonyms "loaded, fetched, retrieved"
|
|
307
|
+
@tier "domain"
|
|
308
|
+
PreferencesLoadFailed {
|
|
309
|
+
error : string
|
|
310
|
+
code : string
|
|
311
|
+
}
|
|
312
|
+
@description "The preference collection failed to load."
|
|
313
|
+
@synonyms "error, failure, load error"
|
|
314
|
+
@tier "internal"
|
|
315
|
+
PreferenceSaved {
|
|
316
|
+
id : string!
|
|
317
|
+
}
|
|
318
|
+
@description "A preference row persisted (create or update)."
|
|
319
|
+
@synonyms "created, updated, persisted"
|
|
320
|
+
@tier "domain"
|
|
321
|
+
PreferenceSaveFailed {
|
|
322
|
+
error : string
|
|
323
|
+
code : string
|
|
324
|
+
}
|
|
325
|
+
@description "A preference persist failed."
|
|
326
|
+
@synonyms "failed, error, unsuccessful"
|
|
327
|
+
@tier "internal"
|
|
328
|
+
PreferenceDeleted {
|
|
329
|
+
id : string!
|
|
330
|
+
}
|
|
331
|
+
@description "A preference row was deleted."
|
|
332
|
+
@synonyms "removed, purged, discarded"
|
|
333
|
+
@tier "domain"
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
config {
|
|
337
|
+
title : string = "Notification preferences"
|
|
338
|
+
@label "Section title"
|
|
339
|
+
@description "Heading shown above the preference list"
|
|
340
|
+
@tier "presentation"
|
|
341
|
+
tableLook : "dense" | "spacious" | "striped" | "borderless" | "card-rows" = dense
|
|
342
|
+
@label "Table look"
|
|
343
|
+
@description "Layer 2 visual treatment for the preference data table rendered by this atom."
|
|
344
|
+
@tier "presentation"
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
;; Create modal: configured via `config`, no effects overrides. SAVE is
|
|
349
|
+
;; renamed to PREFERENCE_CREATED so PreferencesManage distinguishes
|
|
350
|
+
;; Create-SAVE from Edit-SAVE without source-scoped listens. Form field
|
|
351
|
+
;; types/options/required-ness are entity-schema-driven (channel/mode render
|
|
352
|
+
;; as selects from the entity enums).
|
|
353
|
+
trait PreferenceCreate = Modal.traits.ModalRecordModal -> NotificationPreference {
|
|
354
|
+
events {
|
|
355
|
+
OPEN: CREATE
|
|
356
|
+
SAVE: PREFERENCE_CREATED
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
config {
|
|
360
|
+
icon: plus-circle
|
|
361
|
+
title: "New notification preference"
|
|
362
|
+
fields: (personId eventKey channel mode quietHours)
|
|
363
|
+
mode: create
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
listens {
|
|
367
|
+
PreferencesManage.CREATE -> CREATE
|
|
368
|
+
CREATE {
|
|
369
|
+
id : string
|
|
370
|
+
row : NotificationPreference
|
|
371
|
+
}
|
|
372
|
+
@description "Opens the blank preference modal."
|
|
373
|
+
@synonyms "add, new, insert"
|
|
374
|
+
@tier "domain"
|
|
375
|
+
SAVE {
|
|
376
|
+
data : @entity!
|
|
377
|
+
}
|
|
378
|
+
@description "The preference form was submitted; the payload carries the full row."
|
|
379
|
+
@synonyms "persist, store, commit"
|
|
380
|
+
@tier "domain"
|
|
381
|
+
ModalRecordLoadFailed {
|
|
382
|
+
error : string
|
|
383
|
+
code : string
|
|
384
|
+
}
|
|
385
|
+
@description "Indicates failure to load a record for a modal."
|
|
386
|
+
@synonyms "error, failure, problem, unsuccessful"
|
|
387
|
+
@tier "internal"
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
;; Edit modal: same composition, mode edit — OPEN pre-fills from the grid
|
|
392
|
+
;; row payload and the row id rides into the SAVE payload so the update
|
|
393
|
+
;; persist finds the right row.
|
|
394
|
+
trait PreferenceEdit = Modal.traits.ModalRecordModal -> NotificationPreference {
|
|
395
|
+
events {
|
|
396
|
+
OPEN: EDIT
|
|
397
|
+
SAVE: PREFERENCE_UPDATED
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
config {
|
|
401
|
+
icon: pencil
|
|
402
|
+
title: "Edit notification preference"
|
|
403
|
+
fields: (personId eventKey channel mode quietHours)
|
|
404
|
+
mode: edit
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
listens {
|
|
408
|
+
PreferencesManage.EDIT -> EDIT
|
|
409
|
+
EDIT {
|
|
410
|
+
id : string
|
|
411
|
+
row : NotificationPreference
|
|
412
|
+
}
|
|
413
|
+
@description "Opens a preference row in the edit modal."
|
|
414
|
+
@synonyms "modify, update, change"
|
|
415
|
+
@tier "domain"
|
|
416
|
+
SAVE {
|
|
417
|
+
data : @entity!
|
|
418
|
+
}
|
|
419
|
+
@description "The preference form was submitted; the payload carries the full row."
|
|
420
|
+
@synonyms "persist, store, commit, update"
|
|
421
|
+
@tier "domain"
|
|
422
|
+
ModalRecordLoadFailed {
|
|
423
|
+
error : string
|
|
424
|
+
code : string
|
|
425
|
+
}
|
|
426
|
+
@description "Indicates failure to load a record for a modal."
|
|
427
|
+
@synonyms "error, failure, problem, unsuccessful"
|
|
428
|
+
@tier "internal"
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
trait PreferenceGate -> GateView [lifecycle, instance] {
|
|
433
|
+
initial: idle
|
|
434
|
+
|
|
435
|
+
state idle {
|
|
436
|
+
CHECK_PREFERENCE -> idle
|
|
437
|
+
(set @entity.personId ?personId)
|
|
438
|
+
(set @entity.eventKey (if ["=", ?eventKey, ""] @config.eventKey ?eventKey))
|
|
439
|
+
(fetch NotificationPreference {
|
|
440
|
+
emit: { failure: PREFERENCE_CHECK_FAILED, success: PREFERENCE_ROWS_LOADED }
|
|
441
|
+
})
|
|
442
|
+
PREFERENCE_ROWS_LOADED -> idle
|
|
443
|
+
;; Fail-open default policy: no matching row resolves channel in_app /
|
|
444
|
+
;; mode instant / suppressed false — absence of a preference never
|
|
445
|
+
;; silences a notification.
|
|
446
|
+
(set @entity.channel (if ["=", (array/len (array/filter ?data (fn p (and ["=", (object/get @p personId), @entity.personId] (or ["=", (object/get @p eventKey), ""] ["=", (object/get @p eventKey), @entity.eventKey]))))), 0] in_app (object/get (array/first (array/filter ?data (fn p (and ["=", (object/get @p personId), @entity.personId] (or ["=", (object/get @p eventKey), ""] ["=", (object/get @p eventKey), @entity.eventKey]))))) channel)))
|
|
447
|
+
(set @entity.mode (if ["=", (array/len (array/filter ?data (fn p (and ["=", (object/get @p personId), @entity.personId] (or ["=", (object/get @p eventKey), ""] ["=", (object/get @p eventKey), @entity.eventKey]))))), 0] instant (object/get (array/first (array/filter ?data (fn p (and ["=", (object/get @p personId), @entity.personId] (or ["=", (object/get @p eventKey), ""] ["=", (object/get @p eventKey), @entity.eventKey]))))) mode)))
|
|
448
|
+
(set @entity.quietHours (if ["=", (array/len (array/filter ?data (fn p (and ["=", (object/get @p personId), @entity.personId] (or ["=", (object/get @p eventKey), ""] ["=", (object/get @p eventKey), @entity.eventKey]))))), 0] "" (object/get (array/first (array/filter ?data (fn p (and ["=", (object/get @p personId), @entity.personId] (or ["=", (object/get @p eventKey), ""] ["=", (object/get @p eventKey), @entity.eventKey]))))) quietHours)))
|
|
449
|
+
(emit PREFERENCE_RESOLVED {
|
|
450
|
+
channel: @entity.channel
|
|
451
|
+
eventKey: @entity.eventKey
|
|
452
|
+
mode: @entity.mode
|
|
453
|
+
personId: @entity.personId
|
|
454
|
+
suppressed: (and (str/includes @entity.quietHours "-") ["!=", (str/slice (array/first (str/split @entity.quietHours "-")) 0 2), (str/slice (array/nth (str/split @entity.quietHours "-") 1) 0 2)] (if ["<", (str/slice (array/first (str/split @entity.quietHours "-")) 0 2), (str/slice (array/nth (str/split @entity.quietHours "-") 1) 0 2)] (and [">=", (time/hour @now), (str/slice (array/first (str/split @entity.quietHours "-")) 0 2)] ["<", (time/hour @now), (str/slice (array/nth (str/split @entity.quietHours "-") 1) 0 2)]) (or [">=", (time/hour @now), (str/slice (array/first (str/split @entity.quietHours "-")) 0 2)] ["<", (time/hour @now), (str/slice (array/nth (str/split @entity.quietHours "-") 1) 0 2)])))
|
|
455
|
+
})
|
|
456
|
+
;; Lookup failure is fail-open too: a broken preference store must not
|
|
457
|
+
;; mute notifications.
|
|
458
|
+
PREFERENCE_CHECK_FAILED -> idle
|
|
459
|
+
(emit PREFERENCE_RESOLVED {
|
|
460
|
+
channel: in_app
|
|
461
|
+
eventKey: @entity.eventKey
|
|
462
|
+
mode: instant
|
|
463
|
+
personId: @entity.personId
|
|
464
|
+
suppressed: false
|
|
465
|
+
})
|
|
466
|
+
(render-ui toast {
|
|
467
|
+
message: ?error
|
|
468
|
+
type: alert
|
|
469
|
+
variant: error
|
|
470
|
+
})
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
emits {
|
|
474
|
+
PREFERENCE_RESOLVED -> external {
|
|
475
|
+
personId : string
|
|
476
|
+
eventKey : string
|
|
477
|
+
channel : string
|
|
478
|
+
mode : string
|
|
479
|
+
suppressed : boolean
|
|
480
|
+
}
|
|
481
|
+
@description "The gate's verdict for a CHECK_PREFERENCE consult: resolved channel and mode, and whether quiet hours suppress delivery right now. Fail-open: no row or failed lookup = { channel: in_app, mode: instant, suppressed: false }."
|
|
482
|
+
@synonyms "verdict, preference answer, gate result, resolved preference"
|
|
483
|
+
@tier "domain"
|
|
484
|
+
PREFERENCE_ROWS_LOADED {
|
|
485
|
+
data : [NotificationPreference]
|
|
486
|
+
}
|
|
487
|
+
@description "NotificationPreference rows loaded; the arm filters to the (personId, eventKey) match and resolves the verdict"
|
|
488
|
+
@tier "internal"
|
|
489
|
+
PREFERENCE_CHECK_FAILED {
|
|
490
|
+
error : string!
|
|
491
|
+
}
|
|
492
|
+
@description "The preference fetch failed — the arm emits the fail-open verdict"
|
|
493
|
+
@tier "internal"
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
listens {
|
|
497
|
+
CHECK_PREFERENCE {
|
|
498
|
+
personId : string
|
|
499
|
+
eventKey : string
|
|
500
|
+
}
|
|
501
|
+
@description "Consult the gate for a person's effective notification preference."
|
|
502
|
+
@synonyms "check preference, resolve preference, should notify, gate notification"
|
|
503
|
+
@tier "domain"
|
|
504
|
+
PREFERENCE_ROWS_LOADED {
|
|
505
|
+
data : [NotificationPreference]
|
|
506
|
+
}
|
|
507
|
+
@description "NotificationPreference rows loaded; the arm resolves the verdict"
|
|
508
|
+
@tier "internal"
|
|
509
|
+
PREFERENCE_CHECK_FAILED {
|
|
510
|
+
error : string!
|
|
511
|
+
}
|
|
512
|
+
@description "The preference fetch failed"
|
|
513
|
+
@tier "internal"
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
config {
|
|
517
|
+
eventKey : string = ""
|
|
518
|
+
@label "Event key"
|
|
519
|
+
@description "Stable key naming the event this gate checks preferences for (e.g. 'BudgetThreshold' — the std-finance-tracker NotifyOnEventListener convention). A blank CHECK_PREFERENCE payload eventKey falls back to this value. Preference rows whose eventKey is blank apply to every event."
|
|
520
|
+
@synonyms "preference key, event name, subscription key, notification topic"
|
|
521
|
+
@tier "policy"
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
page "/notification-preferences" as NotificationPreferencesPage -> PreferencesManage, PreferenceCreate, PreferenceEdit, PreferenceGate
|
|
526
|
+
}
|