@reventlessdev/online-shop-hybrid-ordering 1.0.0-alpha.230 → 1.0.0-alpha.231
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/CHANGELOG.md +10 -0
- package/package.json +10 -10
- package/src/Notification/AutomationSlice/NotificationIntake.res +11 -10
- package/src/Notification/AutomationSlice/NotificationIntake.res.mjs +8 -10
- package/src/Notification/AutomationSlice/NotificationIntake_Automation.res +107 -54
- package/src/Notification/AutomationSlice/NotificationIntake_Automation.res.mjs +90 -52
- package/src/Notification/StateChangeSlice/NotificationPreferences.res +74 -5
- package/src/Notification/StateChangeSlice/NotificationPreferences.res.mjs +42 -4
- package/src/Notification/StateChangeSlice/NotificationPreferences_Behavior.res +111 -39
- package/src/Notification/StateChangeSlice/NotificationPreferences_Behavior.res.mjs +80 -18
- package/src/Notification/StateChangeSlice/NotificationSourceClaims.res +48 -0
- package/src/Notification/StateChangeSlice/NotificationSourceClaims.res.mjs +82 -0
- package/src/Notification/StateChangeSlice/NotificationSourceClaims_Behavior.res +43 -0
- package/src/Notification/StateChangeSlice/NotificationSourceClaims_Behavior.res.mjs +92 -0
- package/src/Notification/StateViewSliceStream/NotificationDeliveries.res +40 -13
- package/src/Notification/StateViewSliceStream/NotificationDeliveries.res.mjs +16 -6
- package/src/Notification/StateViewSliceStream/NotificationDeliveries_Projection.res +20 -6
- package/src/Notification/StateViewSliceStream/NotificationDeliveries_Projection.res.mjs +24 -5
- package/src/Plugin.res +5 -3
- package/src/Plugin.res.mjs +33 -0
- package/tests/Flow/OrderingFlow_GWT.res +8 -0
- package/tests/Flow/OrderingFlow_GWT.res.mjs +10 -3
- package/tests/Notification/AutomationSlice/NotificationIntake_GWT.res +153 -0
- package/tests/Notification/AutomationSlice/NotificationIntake_GWT.res.mjs +248 -0
- package/tests/Notification/NotificationConformance_GWT.res +61 -5
- package/tests/Notification/NotificationConformance_GWT.res.mjs +86 -5
- package/tests/Notification/StateChangeSlice/NotificationPreferences_GWT.res +42 -5
- package/tests/Notification/StateChangeSlice/NotificationPreferences_GWT.res.mjs +30 -3
- package/tests/Notification/StateChangeSlice/NotificationSourceClaims_GWT.res +47 -0
- package/tests/Notification/StateChangeSlice/NotificationSourceClaims_GWT.res.mjs +106 -0
- package/tests/Notification/StateViewSliceStream/NotificationDeliveries_GWT.res +99 -0
- package/tests/Notification/StateViewSliceStream/NotificationDeliveries_GWT.res.mjs +148 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# 1.0.0-alpha.231 (2026-09-04)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **notifications:** a delivery row names what it was about, not where it was sent ([65c01c6](https://github.com/ReventlessDev/reventless-core/commit/65c01c6b1b982880364c952266eab4294a824108))
|
|
11
|
+
* **notifications:** a rule says how it goes out, and the relay sends only its half ([cbcb992](https://github.com/ReventlessDev/reventless-core/commit/cbcb99207597830aa955154a0833aac2205a8e2f))
|
|
12
|
+
* **notifications:** a second producer can take over one stream of occurrences ([3050637](https://github.com/ReventlessDev/reventless-core/commit/3050637c8af69083c528030f2c59b04065ab8c2f))
|
|
13
|
+
* **notifications:** the wording is a table of values, not a switch ([bfcc939](https://github.com/ReventlessDev/reventless-core/commit/bfcc93940b81060d201fd231447fb14dcf48d80e))
|
|
14
|
+
|
|
15
|
+
|
|
6
16
|
# 1.0.0-alpha.230 (2026-09-02)
|
|
7
17
|
|
|
8
18
|
### Features
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/online-shop-hybrid-ordering",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.231",
|
|
4
4
|
"description": "Online Shop Hybrid example — Ordering plugin (Customer aggregate + Order/CatalogProduct DCB)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"jest": {
|
|
@@ -21,20 +21,20 @@
|
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"sury": "11.0.0-rc.2",
|
|
24
|
-
"@reventlessdev/online-shop-hybrid-catalog-spec": "1.0.0-alpha.
|
|
25
|
-
"@reventlessdev/online-shop-hybrid-ordering-spec": "1.0.0-alpha.
|
|
26
|
-
"@reventlessdev/reventless-gwt": "1.0.0-alpha.201",
|
|
27
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.155",
|
|
24
|
+
"@reventlessdev/online-shop-hybrid-catalog-spec": "1.0.0-alpha.124",
|
|
25
|
+
"@reventlessdev/online-shop-hybrid-ordering-spec": "1.0.0-alpha.124",
|
|
28
26
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
|
|
29
|
-
"@reventlessdev/reventless-
|
|
30
|
-
"@reventlessdev/
|
|
31
|
-
"@reventlessdev/trait-
|
|
32
|
-
"@reventlessdev/reventless-
|
|
27
|
+
"@reventlessdev/reventless-gwt": "1.0.0-alpha.202",
|
|
28
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.156",
|
|
29
|
+
"@reventlessdev/trait-address-geocoding": "1.0.0-alpha.4",
|
|
30
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.128",
|
|
31
|
+
"@reventlessdev/trait-notification": "1.0.0-alpha.4",
|
|
32
|
+
"@reventlessdev/reventless-local": "3.0.0-alpha.244"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"rescript": "12.3.0",
|
|
36
36
|
"sury-ppx": "11.0.0-rc.2",
|
|
37
|
-
"@reventlessdev/reventless-ppx": "1.0.0-alpha.
|
|
37
|
+
"@reventlessdev/reventless-ppx": "1.0.0-alpha.77"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"rescript": "12.3.0"
|
|
@@ -12,17 +12,14 @@
|
|
|
12
12
|
|
|
13
13
|
@@reventless.spec
|
|
14
14
|
|
|
15
|
-
//
|
|
16
|
-
// occurrence
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
| Placed
|
|
22
|
-
| Shipped
|
|
23
|
-
|
|
15
|
+
// The row a rule composes from. It carries the rule's id rather than a kind of
|
|
16
|
+
// occurrence: the rule holds the category, the subject and the wording, so a
|
|
17
|
+
// second notifiable event is a second entry in the table next door.
|
|
18
|
+
//
|
|
19
|
+
// It is also the payload the wording is rendered against, so every field here is
|
|
20
|
+
// a path a template may name — `{{ orderId }}`.
|
|
24
21
|
@schema
|
|
25
|
-
type todoItem = {
|
|
22
|
+
type todoItem = {ruleId: string, recipientId: string, orderId: string}
|
|
26
23
|
|
|
27
24
|
@schema
|
|
28
25
|
type command =
|
|
@@ -30,8 +27,12 @@ type command =
|
|
|
30
27
|
recipientId: string,
|
|
31
28
|
category: NotificationPreferences.category,
|
|
32
29
|
reference: string,
|
|
30
|
+
subjectType: string,
|
|
31
|
+
subjectRef: string,
|
|
33
32
|
subject: string,
|
|
34
33
|
body: string,
|
|
34
|
+
sourceId: string,
|
|
35
|
+
origin: NotificationPreferences.origin,
|
|
35
36
|
})
|
|
36
37
|
|
|
37
38
|
let maxRetries = 3
|
|
@@ -4,15 +4,10 @@ import * as Sury from "sury";
|
|
|
4
4
|
import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
|
|
5
5
|
import * as NotificationPreferences$OrderingPlugin from "../StateChangeSlice/NotificationPreferences.res.mjs";
|
|
6
6
|
|
|
7
|
-
let occurrenceSchema = Sury.union([
|
|
8
|
-
Sury.literal("Placed"),
|
|
9
|
-
Sury.literal("Shipped")
|
|
10
|
-
]);
|
|
11
|
-
|
|
12
7
|
let todoItemSchema = Sury.$schema(s => ({
|
|
8
|
+
ruleId: s.m(Sury.string),
|
|
13
9
|
recipientId: s.m(Sury.string),
|
|
14
|
-
orderId: s.m(Sury.string)
|
|
15
|
-
occurrence: s.m(occurrenceSchema)
|
|
10
|
+
orderId: s.m(Sury.string)
|
|
16
11
|
}));
|
|
17
12
|
|
|
18
13
|
let commandSchema = Sury.$schema(s => ({
|
|
@@ -20,8 +15,12 @@ let commandSchema = Sury.$schema(s => ({
|
|
|
20
15
|
recipientId: s.m(DcbTag$Reventless.string),
|
|
21
16
|
category: s.m(NotificationPreferences$OrderingPlugin.categorySchema),
|
|
22
17
|
reference: s.m(Sury.string),
|
|
18
|
+
subjectType: s.m(Sury.string),
|
|
19
|
+
subjectRef: s.m(Sury.string),
|
|
23
20
|
subject: s.m(Sury.string),
|
|
24
|
-
body: s.m(Sury.string)
|
|
21
|
+
body: s.m(Sury.string),
|
|
22
|
+
sourceId: s.m(DcbTag$Reventless.string),
|
|
23
|
+
origin: s.m(NotificationPreferences$OrderingPlugin.originSchema)
|
|
25
24
|
}));
|
|
26
25
|
|
|
27
26
|
function commandAuthorization(param) {
|
|
@@ -49,7 +48,6 @@ let moduleUrl = "@reventlessdev/online-shop-hybrid-ordering/src/Notification/Aut
|
|
|
49
48
|
export {
|
|
50
49
|
name,
|
|
51
50
|
Id,
|
|
52
|
-
occurrenceSchema,
|
|
53
51
|
todoItemSchema,
|
|
54
52
|
commandSchema,
|
|
55
53
|
maxRetries,
|
|
@@ -60,4 +58,4 @@ export {
|
|
|
60
58
|
commandTransition,
|
|
61
59
|
traits,
|
|
62
60
|
}
|
|
63
|
-
/*
|
|
61
|
+
/* todoItemSchema Not a pure module */
|
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
@@reventless.automation
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
// echoes back exactly what resolves the row. One key per occurrence, not per
|
|
5
|
-
// order: an order that is placed and then ships is two notifications, and a
|
|
6
|
-
// shared key would let the first one's outcome close the second one's row.
|
|
7
|
-
let confirmationKey = orderId => `confirm:${orderId}`
|
|
8
|
-
let shippingKey = orderId => `ship:${orderId}`
|
|
3
|
+
module Rule = TraitNotification.Notification_Rule
|
|
9
4
|
|
|
10
5
|
// One source: this plugin's DCB log, which is where the occurrence is and where
|
|
11
6
|
// every outcome lands. Both order events name their own customer, which is what
|
|
@@ -18,14 +13,79 @@ module OrderingDcbSource = {
|
|
|
18
13
|
type event =
|
|
19
14
|
| OrderPlaced({orderId: string, customerId: string})
|
|
20
15
|
| OrderShipped({orderId: string, customerId: string})
|
|
21
|
-
// Every way a request can end. All
|
|
16
|
+
// Every way a request can end. All four resolve the row, because all four
|
|
22
17
|
// mean the decision was made — a suppressed notification is a finished piece
|
|
23
|
-
// of work, not a failed one.
|
|
18
|
+
// of work, not a failed one, and a deferred one is somebody else's work.
|
|
19
|
+
//
|
|
20
|
+
// Missing the fourth is not a missing nicety: an unresolved row retries its
|
|
21
|
+
// whole budget and lands in `onExhausted`, so a handover would look like a
|
|
22
|
+
// slow failure with nothing in the log to explain it.
|
|
24
23
|
| NotificationRequested({reference: string})
|
|
25
24
|
| NotificationSuppressed({reference: string})
|
|
26
25
|
| NotificationUndeliverable({reference: string})
|
|
26
|
+
| NotificationDeferred({reference: string})
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// The one place this host's occurrences are given a kind and a wording, and it
|
|
30
|
+
// is a table of values rather than a switch. A trait cannot write this: the
|
|
31
|
+
// categories are ones the trait declares, but which of the host's events earns
|
|
32
|
+
// which — and what the sentence says — is the host's.
|
|
33
|
+
//
|
|
34
|
+
// A rule's id is also the namespace of the references it writes, so an order
|
|
35
|
+
// that is placed and then ships is two notifications and two delivery rows.
|
|
36
|
+
let defaultRules: array<Rule.t> = [
|
|
37
|
+
{
|
|
38
|
+
id: "confirm",
|
|
39
|
+
version: "1",
|
|
40
|
+
source: {log: OrderingDcbSource.name, eventType: "OrderPlaced"},
|
|
41
|
+
filter: Always,
|
|
42
|
+
category: "OrderConfirmation",
|
|
43
|
+
delivery: Immediate,
|
|
44
|
+
recipientPath: "recipientId",
|
|
45
|
+
subjectType: "Order",
|
|
46
|
+
subjectPath: "orderId",
|
|
47
|
+
content: [
|
|
48
|
+
{
|
|
49
|
+
locale: "en",
|
|
50
|
+
subject: "Your order {{ orderId }} is confirmed",
|
|
51
|
+
body: "Thanks — we have your order {{ orderId }} and will let you know when it ships.",
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "ship",
|
|
57
|
+
version: "1",
|
|
58
|
+
source: {log: OrderingDcbSource.name, eventType: "OrderShipped"},
|
|
59
|
+
filter: Always,
|
|
60
|
+
category: "ShippingUpdate",
|
|
61
|
+
delivery: Immediate,
|
|
62
|
+
recipientPath: "recipientId",
|
|
63
|
+
subjectType: "Order",
|
|
64
|
+
subjectPath: "orderId",
|
|
65
|
+
content: [
|
|
66
|
+
{
|
|
67
|
+
locale: "en",
|
|
68
|
+
subject: "Your order {{ orderId }} is on its way",
|
|
69
|
+
body: "Good news — order {{ orderId }} has shipped.",
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
// The dispatch is the table's, so the switch below only takes an event apart.
|
|
76
|
+
// Two rules on one event type are two notifications, with no arm to add.
|
|
77
|
+
//
|
|
78
|
+
// Only the immediate half of the table: a digest rule's occurrences are gathered
|
|
79
|
+
// by the component that sends the digest, so no row is opened for one here.
|
|
80
|
+
let todosFor = (~eventType, ~recipientId, ~orderId) =>
|
|
81
|
+
defaultRules
|
|
82
|
+
->Rule.forEvent(~log=OrderingDcbSource.name, ~eventType)
|
|
83
|
+
->Array.filter(Rule.isImmediate)
|
|
84
|
+
->Array.map(rule => (
|
|
85
|
+
Rule.reference(rule, ~subject=orderId),
|
|
86
|
+
({ruleId: rule.id, recipientId, orderId}: NotificationIntake.todoItem),
|
|
87
|
+
))
|
|
88
|
+
|
|
29
89
|
module FromOrderingDcb = Mapping.Make(
|
|
30
90
|
OrderingDcbSource,
|
|
31
91
|
NotificationIntake,
|
|
@@ -34,28 +94,22 @@ module FromOrderingDcb = Mapping.Make(
|
|
|
34
94
|
|
|
35
95
|
let collect = (event, ~sourceId as _, _ctx) =>
|
|
36
96
|
switch event {
|
|
37
|
-
| OrderPlaced({orderId, customerId}) =>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
),
|
|
42
|
-
]
|
|
43
|
-
| OrderShipped({orderId, customerId}) => [
|
|
44
|
-
(
|
|
45
|
-
shippingKey(orderId),
|
|
46
|
-
({recipientId: customerId, orderId, occurrence: Shipped}: NotificationIntake.todoItem),
|
|
47
|
-
),
|
|
48
|
-
]
|
|
97
|
+
| OrderPlaced({orderId, customerId}) =>
|
|
98
|
+
todosFor(~eventType="OrderPlaced", ~recipientId=customerId, ~orderId)
|
|
99
|
+
| OrderShipped({orderId, customerId}) =>
|
|
100
|
+
todosFor(~eventType="OrderShipped", ~recipientId=customerId, ~orderId)
|
|
49
101
|
| NotificationRequested(_)
|
|
50
102
|
| NotificationSuppressed(_)
|
|
51
|
-
| NotificationUndeliverable(_)
|
|
103
|
+
| NotificationUndeliverable(_)
|
|
104
|
+
| NotificationDeferred(_) => []
|
|
52
105
|
}
|
|
53
106
|
|
|
54
107
|
let resolve = event =>
|
|
55
108
|
switch event {
|
|
56
109
|
| NotificationRequested({reference})
|
|
57
110
|
| NotificationSuppressed({reference})
|
|
58
|
-
| NotificationUndeliverable({reference})
|
|
111
|
+
| NotificationUndeliverable({reference})
|
|
112
|
+
| NotificationDeferred({reference}) =>
|
|
59
113
|
Some(reference)
|
|
60
114
|
| OrderPlaced(_)
|
|
61
115
|
| OrderShipped(_) => None
|
|
@@ -65,40 +119,39 @@ module FromOrderingDcb = Mapping.Make(
|
|
|
65
119
|
|
|
66
120
|
let mappings: array<module(Mapping)> = [module(FromOrderingDcb)]
|
|
67
121
|
|
|
68
|
-
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
122
|
+
let process = (id, item: NotificationIntake.todoItem) =>
|
|
123
|
+
// Two ways the table can have moved under a row that was already open: the rule
|
|
124
|
+
// is gone, or it has become a digest's and is no longer this relay's to send.
|
|
125
|
+
// Neither publishes anything, and either leaves the row Pending — a `None` from
|
|
126
|
+
// `process` spends no retry budget, so it is re-swept on every batch and never
|
|
127
|
+
// reaches `onExhausted`. Inert, but not self-clearing.
|
|
128
|
+
switch defaultRules->Rule.byId(item.ruleId)->Option.filter(Rule.isImmediate) {
|
|
129
|
+
| None => None
|
|
130
|
+
| Some(rule) =>
|
|
131
|
+
let payload = item->Reventless.Util_Sury.toJson(NotificationIntake.todoItemSchema)
|
|
132
|
+
switch (Rule.matches(rule.filter, ~payload), Rule.recipientOf(rule, ~payload)) {
|
|
133
|
+
| (false, _) | (_, None) => None
|
|
134
|
+
| (true, Some(recipientId)) =>
|
|
135
|
+
let (subject, body) = Rule.compose(rule, ~payload, ~schema=NotificationIntake.todoItemSchema)
|
|
136
|
+
Some((
|
|
137
|
+
recipientId,
|
|
138
|
+
NotificationIntake.RequestNotification({
|
|
139
|
+
recipientId,
|
|
140
|
+
category: NotificationPreferences_Behavior.categoryOf(rule.category),
|
|
141
|
+
reference: id,
|
|
142
|
+
subjectType: rule.subjectType,
|
|
143
|
+
subjectRef: Rule.subjectOf(rule, ~payload),
|
|
144
|
+
subject,
|
|
145
|
+
body,
|
|
146
|
+
sourceId: Rule.sourceId(rule),
|
|
147
|
+
// This relay IS the compiled table, so every request it makes is the
|
|
148
|
+
// default one — the arm that yields when somebody else claims the source.
|
|
149
|
+
origin: Default,
|
|
150
|
+
}),
|
|
151
|
+
))
|
|
152
|
+
}
|
|
86
153
|
}
|
|
87
154
|
|
|
88
|
-
let process = (id, item: NotificationIntake.todoItem) => {
|
|
89
|
-
let (category, subject, body) = compose(item)
|
|
90
|
-
Some((
|
|
91
|
-
item.recipientId,
|
|
92
|
-
NotificationIntake.RequestNotification({
|
|
93
|
-
recipientId: item.recipientId,
|
|
94
|
-
category,
|
|
95
|
-
reference: id,
|
|
96
|
-
subject,
|
|
97
|
-
body,
|
|
98
|
-
}),
|
|
99
|
-
))
|
|
100
|
-
}
|
|
101
|
-
|
|
102
155
|
// Nothing to say. A relay that gave up published no command, so the notification
|
|
103
156
|
// competency never heard of the occurrence — and a delivery-failed fact for a
|
|
104
157
|
// notification nobody requested would put a row in the log for something that
|
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import * as Sury from "sury";
|
|
4
4
|
import * as Id$Reventless from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
|
|
5
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
6
|
import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
|
|
7
|
+
import * as Util_Sury$Reventless from "@reventlessdev/reventless-spec/src/util/Util_Sury.res.mjs";
|
|
6
8
|
import * as AutomationSlice$Reventless from "@reventlessdev/reventless-spec/src/components/AutomationSlice.res.mjs";
|
|
7
9
|
import * as NotificationIntake$OrderingPlugin from "./NotificationIntake.res.mjs";
|
|
10
|
+
import * as Notification_Rule$TraitNotification from "@reventlessdev/trait-notification/src/Notification_Rule.res.mjs";
|
|
11
|
+
import * as NotificationPreferences_Behavior$OrderingPlugin from "../StateChangeSlice/NotificationPreferences_Behavior.res.mjs";
|
|
8
12
|
|
|
9
13
|
let M = AutomationSlice$Reventless.Mappings.Make({
|
|
10
14
|
name: NotificationIntake$OrderingPlugin.name,
|
|
@@ -16,14 +20,6 @@ let M = AutomationSlice$Reventless.Mappings.Make({
|
|
|
16
20
|
targetName: NotificationIntake$OrderingPlugin.targetName
|
|
17
21
|
});
|
|
18
22
|
|
|
19
|
-
function confirmationKey(orderId) {
|
|
20
|
-
return `confirm:` + orderId;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function shippingKey(orderId) {
|
|
24
|
-
return `ship:` + orderId;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
23
|
let name = "OrderingDcbEventLog";
|
|
28
24
|
|
|
29
25
|
let eventSchema = Sury.union([
|
|
@@ -48,6 +44,10 @@ let eventSchema = Sury.union([
|
|
|
48
44
|
Sury.$schema(s => ({
|
|
49
45
|
TAG: "NotificationUndeliverable",
|
|
50
46
|
reference: s.m(Sury.string)
|
|
47
|
+
})),
|
|
48
|
+
Sury.$schema(s => ({
|
|
49
|
+
TAG: "NotificationDeferred",
|
|
50
|
+
reference: s.m(Sury.string)
|
|
51
51
|
}))
|
|
52
52
|
]);
|
|
53
53
|
|
|
@@ -57,28 +57,64 @@ let OrderingDcbSource = {
|
|
|
57
57
|
eventSchema: eventSchema
|
|
58
58
|
};
|
|
59
59
|
|
|
60
|
+
let defaultRules = [
|
|
61
|
+
{
|
|
62
|
+
id: "confirm",
|
|
63
|
+
version: "1",
|
|
64
|
+
source: {
|
|
65
|
+
log: name,
|
|
66
|
+
eventType: "OrderPlaced"
|
|
67
|
+
},
|
|
68
|
+
filter: "Always",
|
|
69
|
+
category: "OrderConfirmation",
|
|
70
|
+
delivery: "Immediate",
|
|
71
|
+
recipientPath: "recipientId",
|
|
72
|
+
subjectType: "Order",
|
|
73
|
+
subjectPath: "orderId",
|
|
74
|
+
content: [{
|
|
75
|
+
locale: "en",
|
|
76
|
+
subject: "Your order {{ orderId }} is confirmed",
|
|
77
|
+
body: "Thanks — we have your order {{ orderId }} and will let you know when it ships."
|
|
78
|
+
}]
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: "ship",
|
|
82
|
+
version: "1",
|
|
83
|
+
source: {
|
|
84
|
+
log: name,
|
|
85
|
+
eventType: "OrderShipped"
|
|
86
|
+
},
|
|
87
|
+
filter: "Always",
|
|
88
|
+
category: "ShippingUpdate",
|
|
89
|
+
delivery: "Immediate",
|
|
90
|
+
recipientPath: "recipientId",
|
|
91
|
+
subjectType: "Order",
|
|
92
|
+
subjectPath: "orderId",
|
|
93
|
+
content: [{
|
|
94
|
+
locale: "en",
|
|
95
|
+
subject: "Your order {{ orderId }} is on its way",
|
|
96
|
+
body: "Good news — order {{ orderId }} has shipped."
|
|
97
|
+
}]
|
|
98
|
+
}
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
function todosFor(eventType, recipientId, orderId) {
|
|
102
|
+
return Notification_Rule$TraitNotification.forEvent(defaultRules, name, eventType).filter(Notification_Rule$TraitNotification.isImmediate).map(rule => [
|
|
103
|
+
Notification_Rule$TraitNotification.reference(rule, orderId),
|
|
104
|
+
{
|
|
105
|
+
ruleId: rule.id,
|
|
106
|
+
recipientId: recipientId,
|
|
107
|
+
orderId: orderId
|
|
108
|
+
}
|
|
109
|
+
]);
|
|
110
|
+
}
|
|
111
|
+
|
|
60
112
|
function collect(event, param, _ctx) {
|
|
61
113
|
switch (event.TAG) {
|
|
62
114
|
case "OrderPlaced" :
|
|
63
|
-
|
|
64
|
-
return [[
|
|
65
|
-
confirmationKey(orderId),
|
|
66
|
-
{
|
|
67
|
-
recipientId: event.customerId,
|
|
68
|
-
orderId: orderId,
|
|
69
|
-
occurrence: "Placed"
|
|
70
|
-
}
|
|
71
|
-
]];
|
|
115
|
+
return todosFor("OrderPlaced", event.customerId, event.orderId);
|
|
72
116
|
case "OrderShipped" :
|
|
73
|
-
|
|
74
|
-
return [[
|
|
75
|
-
shippingKey(orderId$1),
|
|
76
|
-
{
|
|
77
|
-
recipientId: event.customerId,
|
|
78
|
-
orderId: orderId$1,
|
|
79
|
-
occurrence: "Shipped"
|
|
80
|
-
}
|
|
81
|
-
]];
|
|
117
|
+
return todosFor("OrderShipped", event.customerId, event.orderId);
|
|
82
118
|
default:
|
|
83
119
|
return [];
|
|
84
120
|
}
|
|
@@ -113,34 +149,34 @@ let FromOrderingDcb = AutomationSlice$Reventless.Mapping.Make({
|
|
|
113
149
|
|
|
114
150
|
let mappings = [FromOrderingDcb];
|
|
115
151
|
|
|
116
|
-
function compose(item) {
|
|
117
|
-
let match = item.occurrence;
|
|
118
|
-
if (match === "Placed") {
|
|
119
|
-
return [
|
|
120
|
-
"OrderConfirmation",
|
|
121
|
-
`Your order ` + item.orderId + ` is confirmed`,
|
|
122
|
-
`Thanks — we have your order ` + item.orderId + ` and will let you know when it ships.`
|
|
123
|
-
];
|
|
124
|
-
} else {
|
|
125
|
-
return [
|
|
126
|
-
"ShippingUpdate",
|
|
127
|
-
`Your order ` + item.orderId + ` is on its way`,
|
|
128
|
-
`Good news — order ` + item.orderId + ` has shipped.`
|
|
129
|
-
];
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
152
|
function process(id, item) {
|
|
134
|
-
let
|
|
153
|
+
let rule = Stdlib_Option.filter(Notification_Rule$TraitNotification.byId(defaultRules, item.ruleId), Notification_Rule$TraitNotification.isImmediate);
|
|
154
|
+
if (rule === undefined) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
let payload = Util_Sury$Reventless.toJson(item, NotificationIntake$OrderingPlugin.todoItemSchema);
|
|
158
|
+
let match = Notification_Rule$TraitNotification.matches(rule.filter, payload);
|
|
159
|
+
let match$1 = Notification_Rule$TraitNotification.recipientOf(rule, payload);
|
|
160
|
+
if (!match) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (match$1 === undefined) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
let match$2 = Notification_Rule$TraitNotification.compose(rule, payload, NotificationIntake$OrderingPlugin.todoItemSchema, undefined);
|
|
135
167
|
return [
|
|
136
|
-
|
|
168
|
+
match$1,
|
|
137
169
|
{
|
|
138
170
|
TAG: "RequestNotification",
|
|
139
|
-
recipientId:
|
|
140
|
-
category:
|
|
171
|
+
recipientId: match$1,
|
|
172
|
+
category: NotificationPreferences_Behavior$OrderingPlugin.categoryOf(rule.category),
|
|
141
173
|
reference: id,
|
|
142
|
-
|
|
143
|
-
|
|
174
|
+
subjectType: rule.subjectType,
|
|
175
|
+
subjectRef: Notification_Rule$TraitNotification.subjectOf(rule, payload),
|
|
176
|
+
subject: match$2[0],
|
|
177
|
+
body: match$2[1],
|
|
178
|
+
sourceId: Notification_Rule$TraitNotification.sourceId(rule),
|
|
179
|
+
origin: "Default"
|
|
144
180
|
}
|
|
145
181
|
];
|
|
146
182
|
}
|
|
@@ -151,17 +187,19 @@ function onExhausted(_id, _item) {
|
|
|
151
187
|
|
|
152
188
|
let Spec;
|
|
153
189
|
|
|
190
|
+
let Rule;
|
|
191
|
+
|
|
154
192
|
let moduleUrl = "@reventlessdev/online-shop-hybrid-ordering/src/Notification/AutomationSlice/NotificationIntake_Automation.res.mjs";
|
|
155
193
|
|
|
156
194
|
export {
|
|
157
195
|
Spec,
|
|
158
196
|
M,
|
|
159
|
-
|
|
160
|
-
shippingKey,
|
|
197
|
+
Rule,
|
|
161
198
|
OrderingDcbSource,
|
|
199
|
+
defaultRules,
|
|
200
|
+
todosFor,
|
|
162
201
|
FromOrderingDcb,
|
|
163
202
|
mappings,
|
|
164
|
-
compose,
|
|
165
203
|
process,
|
|
166
204
|
onExhausted,
|
|
167
205
|
moduleUrl,
|
|
@@ -37,14 +37,24 @@ type channel =
|
|
|
37
37
|
| Sms
|
|
38
38
|
| Push
|
|
39
39
|
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
40
|
+
// Why a request exists: the compiled table in `NotificationIntake`, or a rule
|
|
41
|
+
// somebody configured on top of it. Recorded on the request that goes out, so
|
|
42
|
+
// "which wording was in force when this was sent" has an answer.
|
|
43
|
+
@schema
|
|
44
|
+
type origin =
|
|
45
|
+
| Default
|
|
46
|
+
| Configured({ruleId: string, ruleVersion: string})
|
|
47
|
+
|
|
48
|
+
// Its own past facts, plus the claim set from `NotificationSourceClaims`. The
|
|
49
|
+
// claims are per source and this slice is per recipient, so they come from
|
|
50
|
+
// another partition — see `RequestNotification` for what makes that read happen.
|
|
43
51
|
@schema
|
|
44
52
|
type consumedEvent =
|
|
45
53
|
| RecipientAnnounced({recipientId: string, email: string})
|
|
46
54
|
| NotificationSubscribed({recipientId: string, category: category, channel: channel})
|
|
47
55
|
| NotificationUnsubscribed({recipientId: string, category: category, channel: channel})
|
|
56
|
+
| NotificationSourceClaimed({sourceId: string, by: string})
|
|
57
|
+
| NotificationSourceReleased({sourceId: string})
|
|
48
58
|
|
|
49
59
|
@schema
|
|
50
60
|
type command =
|
|
@@ -67,8 +77,29 @@ type command =
|
|
|
67
77
|
recipientId: string,
|
|
68
78
|
category: category,
|
|
69
79
|
reference: string,
|
|
80
|
+
// What the notification is about. `reference` is a correlation key whose
|
|
81
|
+
// format is the requester's own business; these two name the thing plainly,
|
|
82
|
+
// so a row can be read back without decoding somebody else's string.
|
|
83
|
+
// `subjectType` is the referenced component's name as the deployment
|
|
84
|
+
// registers it, `subjectRef` that row's own id. Empty is legal — a
|
|
85
|
+
// notification about nothing in particular is a real case, and a fabricated
|
|
86
|
+
// subject is worse than an absent one.
|
|
87
|
+
subjectType: string,
|
|
88
|
+
subjectRef: string,
|
|
70
89
|
subject: string,
|
|
71
90
|
body: string,
|
|
91
|
+
// Which stream this came from, `"<log>:<eventType>"`, and who is asking.
|
|
92
|
+
// Together they are the handover: a `Default` request for a source another
|
|
93
|
+
// producer has claimed yields, a `Configured` one always goes through.
|
|
94
|
+
//
|
|
95
|
+
// `sourceId` is a DCB tag, and it has to be — the decision must read the
|
|
96
|
+
// claim facts, which live in another partition (they are per source, this
|
|
97
|
+
// slice is per recipient). Because the slice consumes `sourceId` without
|
|
98
|
+
// producing it, the framework infers the cross-partition read from the
|
|
99
|
+
// slice graph, and the claim write fences in-flight requests for that
|
|
100
|
+
// source. Renaming it off the `*Id` convention would silently drop both.
|
|
101
|
+
sourceId: string,
|
|
102
|
+
origin: origin,
|
|
72
103
|
})
|
|
73
104
|
// Reported by the send slice once the provider has settled. Trait-owned state,
|
|
74
105
|
// so this is not a host side effect — the host is never written to at all.
|
|
@@ -94,16 +125,54 @@ type event =
|
|
|
94
125
|
category: category,
|
|
95
126
|
reference: string,
|
|
96
127
|
channel: channel,
|
|
128
|
+
// Kept on the event and deliberately not on the delivery view: the send
|
|
129
|
+
// slice cannot deliver without it, and this is the record of the address a
|
|
130
|
+
// message actually went to, as opposed to the address of record the
|
|
131
|
+
// directory holds. Answering "where was this confirmation sent" therefore
|
|
132
|
+
// means reading the log, which is the right reach for personal data —
|
|
133
|
+
// available to an investigation, not to every reader of a view.
|
|
97
134
|
address: string,
|
|
135
|
+
subjectType: string,
|
|
136
|
+
subjectRef: string,
|
|
98
137
|
subject: string,
|
|
99
138
|
body: string,
|
|
139
|
+
// What was in force when this went out. A message should record why it was
|
|
140
|
+
// sent, and until this field existed nothing did.
|
|
141
|
+
origin: origin,
|
|
100
142
|
})
|
|
101
143
|
// Both of the ways not to send, kept apart because they mean opposite things.
|
|
102
144
|
// A recipient who declined is the system working; a recipient with no address
|
|
103
145
|
// is the system missing something, and one fact for both would hide every
|
|
104
146
|
// delivery gap behind a legitimate preference.
|
|
105
|
-
| NotificationSuppressed({
|
|
106
|
-
|
|
147
|
+
| NotificationSuppressed({
|
|
148
|
+
recipientId: string,
|
|
149
|
+
category: category,
|
|
150
|
+
reference: string,
|
|
151
|
+
subjectType: string,
|
|
152
|
+
subjectRef: string,
|
|
153
|
+
origin: origin,
|
|
154
|
+
})
|
|
155
|
+
| NotificationUndeliverable({
|
|
156
|
+
recipientId: string,
|
|
157
|
+
category: category,
|
|
158
|
+
reference: string,
|
|
159
|
+
subjectType: string,
|
|
160
|
+
subjectRef: string,
|
|
161
|
+
origin: origin,
|
|
162
|
+
})
|
|
163
|
+
// The third way of not sending: another producer owns this source. Its own
|
|
164
|
+
// fact for the same reason the two above are two — and load-bearing besides,
|
|
165
|
+
// because the relay resolves its TODO row on an outcome, and without this one a
|
|
166
|
+
// deferred request retries its whole budget and is abandoned with nothing in
|
|
167
|
+
// the log to say why.
|
|
168
|
+
//
|
|
169
|
+
// `sourceKey` and not `sourceId`, deliberately, and this is the one place the
|
|
170
|
+
// two names differ: the slice's partition is derived from the `*Id` fields its
|
|
171
|
+
// own events declare, so a produced `sourceId` here would give it two candidate
|
|
172
|
+
// partitions, resolve to neither, and — worse — stop `sourceId` counting as a
|
|
173
|
+
// consumed-but-not-produced key, which is what makes the claim read cross
|
|
174
|
+
// partitions at all. The value is the same; only the fact is descriptive.
|
|
175
|
+
| NotificationDeferred({recipientId: string, reference: string, sourceKey: string})
|
|
107
176
|
| NotificationDelivered({recipientId: string, reference: string, providerRef: string})
|
|
108
177
|
| NotificationFailed({recipientId: string, reference: string, reason: string})
|
|
109
178
|
|