@reventlessdev/online-shop-hybrid-ordering 1.0.0-alpha.229 → 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 +17 -0
- package/package.json +10 -10
- package/src/Notification/AutomationSlice/NotificationIntake.res +17 -7
- package/src/Notification/AutomationSlice/NotificationIntake.res.mjs +6 -1
- package/src/Notification/AutomationSlice/NotificationIntake_Automation.res +113 -30
- package/src/Notification/AutomationSlice/NotificationIntake_Automation.res.mjs +106 -24
- 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/Order/StateChangeSlice/ShipOrder.res +8 -2
- package/src/Order/StateChangeSlice/ShipOrder.res.mjs +4 -2
- package/src/Order/StateChangeSlice/ShipOrder_Behavior.res +17 -10
- package/src/Order/StateChangeSlice/ShipOrder_Behavior.res.mjs +25 -8
- package/src/Plugin.res +5 -3
- package/src/Plugin.res.mjs +33 -0
- package/tests/Flow/OrderingFlow_GWT.res +28 -7
- package/tests/Flow/OrderingFlow_GWT.res.mjs +29 -10
- 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/tests/Order/StateChangeSlice/ShipOrder_GWT.res +10 -6
- package/tests/Order/StateChangeSlice/ShipOrder_GWT.res.mjs +12 -6
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,23 @@
|
|
|
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
|
+
|
|
16
|
+
# 1.0.0-alpha.230 (2026-09-02)
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* **notifications:** a shipped order notifies the customer who placed it ([4280274](https://github.com/ReventlessDev/reventless-core/commit/428027471fc98cc60c1ab12dbcd5c70b9f33e545))
|
|
21
|
+
|
|
22
|
+
|
|
6
23
|
# 1.0.0-alpha.229 (2026-09-02)
|
|
7
24
|
|
|
8
25
|
### 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/
|
|
32
|
-
"@reventlessdev/
|
|
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"
|
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
// NotificationIntake AutomationSlice.
|
|
2
2
|
//
|
|
3
|
-
// Half of the graft point: it turns
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// Half of the graft point: it turns this host's occurrences into requests to
|
|
4
|
+
// notify somebody about them. The other half is `AnnounceRecipientContact`,
|
|
5
|
+
// which relays who that somebody is reachable at.
|
|
6
6
|
//
|
|
7
7
|
// Everything downstream is host-free — `NotificationPreferences` decides and
|
|
8
8
|
// `SendNotification` delivers, neither knowing what an order is. This file is
|
|
9
|
-
// where "a placed order is an OrderConfirmation,
|
|
10
|
-
//
|
|
11
|
-
// handed whole.
|
|
9
|
+
// where "a placed order is an OrderConfirmation, a shipped one is a
|
|
10
|
+
// ShippingUpdate, and here is what each says" is written down, which is why it
|
|
11
|
+
// is a file a graft has to be given rather than handed whole.
|
|
12
12
|
|
|
13
13
|
@@reventless.spec
|
|
14
14
|
|
|
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 }}`.
|
|
15
21
|
@schema
|
|
16
|
-
type todoItem = {recipientId: string, orderId: string}
|
|
22
|
+
type todoItem = {ruleId: string, recipientId: string, orderId: string}
|
|
17
23
|
|
|
18
24
|
@schema
|
|
19
25
|
type command =
|
|
@@ -21,8 +27,12 @@ type command =
|
|
|
21
27
|
recipientId: string,
|
|
22
28
|
category: NotificationPreferences.category,
|
|
23
29
|
reference: string,
|
|
30
|
+
subjectType: string,
|
|
31
|
+
subjectRef: string,
|
|
24
32
|
subject: string,
|
|
25
33
|
body: string,
|
|
34
|
+
sourceId: string,
|
|
35
|
+
origin: NotificationPreferences.origin,
|
|
26
36
|
})
|
|
27
37
|
|
|
28
38
|
let maxRetries = 3
|
|
@@ -5,6 +5,7 @@ import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/component
|
|
|
5
5
|
import * as NotificationPreferences$OrderingPlugin from "../StateChangeSlice/NotificationPreferences.res.mjs";
|
|
6
6
|
|
|
7
7
|
let todoItemSchema = Sury.$schema(s => ({
|
|
8
|
+
ruleId: s.m(Sury.string),
|
|
8
9
|
recipientId: s.m(Sury.string),
|
|
9
10
|
orderId: s.m(Sury.string)
|
|
10
11
|
}));
|
|
@@ -14,8 +15,12 @@ let commandSchema = Sury.$schema(s => ({
|
|
|
14
15
|
recipientId: s.m(DcbTag$Reventless.string),
|
|
15
16
|
category: s.m(NotificationPreferences$OrderingPlugin.categorySchema),
|
|
16
17
|
reference: s.m(Sury.string),
|
|
18
|
+
subjectType: s.m(Sury.string),
|
|
19
|
+
subjectRef: s.m(Sury.string),
|
|
17
20
|
subject: s.m(Sury.string),
|
|
18
|
-
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)
|
|
19
24
|
}));
|
|
20
25
|
|
|
21
26
|
function commandAuthorization(param) {
|
|
@@ -1,27 +1,91 @@
|
|
|
1
1
|
@@reventless.automation
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
// echoes back exactly what resolves the row.
|
|
5
|
-
let confirmationKey = orderId => `confirm:${orderId}`
|
|
3
|
+
module Rule = TraitNotification.Notification_Rule
|
|
6
4
|
|
|
7
5
|
// One source: this plugin's DCB log, which is where the occurrence is and where
|
|
8
|
-
// every outcome lands.
|
|
9
|
-
// an automation read
|
|
10
|
-
// that do not.
|
|
6
|
+
// every outcome lands. Both order events name their own customer, which is what
|
|
7
|
+
// lets an automation read them at all — see the sibling contact relay for the
|
|
8
|
+
// events that do not.
|
|
11
9
|
module OrderingDcbSource = {
|
|
12
10
|
let name = "OrderingDcbEventLog"
|
|
13
11
|
|
|
14
12
|
@schema
|
|
15
13
|
type event =
|
|
16
14
|
| OrderPlaced({orderId: string, customerId: string})
|
|
17
|
-
|
|
15
|
+
| OrderShipped({orderId: string, customerId: string})
|
|
16
|
+
// Every way a request can end. All four resolve the row, because all four
|
|
18
17
|
// mean the decision was made — a suppressed notification is a finished piece
|
|
19
|
-
// 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.
|
|
20
23
|
| NotificationRequested({reference: string})
|
|
21
24
|
| NotificationSuppressed({reference: string})
|
|
22
25
|
| NotificationUndeliverable({reference: string})
|
|
26
|
+
| NotificationDeferred({reference: string})
|
|
23
27
|
}
|
|
24
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
|
+
|
|
25
89
|
module FromOrderingDcb = Mapping.Make(
|
|
26
90
|
OrderingDcbSource,
|
|
27
91
|
NotificationIntake,
|
|
@@ -30,44 +94,63 @@ module FromOrderingDcb = Mapping.Make(
|
|
|
30
94
|
|
|
31
95
|
let collect = (event, ~sourceId as _, _ctx) =>
|
|
32
96
|
switch event {
|
|
33
|
-
| OrderPlaced({orderId, customerId}) =>
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
),
|
|
38
|
-
]
|
|
97
|
+
| OrderPlaced({orderId, customerId}) =>
|
|
98
|
+
todosFor(~eventType="OrderPlaced", ~recipientId=customerId, ~orderId)
|
|
99
|
+
| OrderShipped({orderId, customerId}) =>
|
|
100
|
+
todosFor(~eventType="OrderShipped", ~recipientId=customerId, ~orderId)
|
|
39
101
|
| NotificationRequested(_)
|
|
40
102
|
| NotificationSuppressed(_)
|
|
41
|
-
| NotificationUndeliverable(_)
|
|
103
|
+
| NotificationUndeliverable(_)
|
|
104
|
+
| NotificationDeferred(_) => []
|
|
42
105
|
}
|
|
43
106
|
|
|
44
107
|
let resolve = event =>
|
|
45
108
|
switch event {
|
|
46
109
|
| NotificationRequested({reference})
|
|
47
110
|
| NotificationSuppressed({reference})
|
|
48
|
-
| NotificationUndeliverable({reference})
|
|
111
|
+
| NotificationUndeliverable({reference})
|
|
112
|
+
| NotificationDeferred({reference}) =>
|
|
49
113
|
Some(reference)
|
|
50
|
-
| OrderPlaced(_)
|
|
114
|
+
| OrderPlaced(_)
|
|
115
|
+
| OrderShipped(_) => None
|
|
51
116
|
}
|
|
52
117
|
},
|
|
53
118
|
)
|
|
54
119
|
|
|
55
120
|
let mappings: array<module(Mapping)> = [module(FromOrderingDcb)]
|
|
56
121
|
|
|
57
|
-
// The one place this host's occurrences are given a kind and a wording. A trait
|
|
58
|
-
// cannot write this: `OrderConfirmation` is a category the trait declares, but
|
|
59
|
-
// which of the host's events earns it, and what the sentence says, is the host's.
|
|
60
122
|
let process = (id, item: NotificationIntake.todoItem) =>
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
+
}
|
|
153
|
+
}
|
|
71
154
|
|
|
72
155
|
// Nothing to say. A relay that gave up published no command, so the notification
|
|
73
156
|
// competency never heard of the occurrence — and a delivery-failed fact for a
|
|
@@ -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,10 +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
23
|
let name = "OrderingDcbEventLog";
|
|
24
24
|
|
|
25
25
|
let eventSchema = Sury.union([
|
|
@@ -28,6 +28,11 @@ let eventSchema = Sury.union([
|
|
|
28
28
|
orderId: s.m(DcbTag$Reventless.string),
|
|
29
29
|
customerId: s.m(DcbTag$Reventless.string)
|
|
30
30
|
})),
|
|
31
|
+
Sury.$schema(s => ({
|
|
32
|
+
TAG: "OrderShipped",
|
|
33
|
+
orderId: s.m(DcbTag$Reventless.string),
|
|
34
|
+
customerId: s.m(DcbTag$Reventless.string)
|
|
35
|
+
})),
|
|
31
36
|
Sury.$schema(s => ({
|
|
32
37
|
TAG: "NotificationRequested",
|
|
33
38
|
reference: s.m(Sury.string)
|
|
@@ -39,6 +44,10 @@ let eventSchema = Sury.union([
|
|
|
39
44
|
Sury.$schema(s => ({
|
|
40
45
|
TAG: "NotificationUndeliverable",
|
|
41
46
|
reference: s.m(Sury.string)
|
|
47
|
+
})),
|
|
48
|
+
Sury.$schema(s => ({
|
|
49
|
+
TAG: "NotificationDeferred",
|
|
50
|
+
reference: s.m(Sury.string)
|
|
42
51
|
}))
|
|
43
52
|
]);
|
|
44
53
|
|
|
@@ -48,25 +57,76 @@ let OrderingDcbSource = {
|
|
|
48
57
|
eventSchema: eventSchema
|
|
49
58
|
};
|
|
50
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
|
+
|
|
51
112
|
function collect(event, param, _ctx) {
|
|
52
|
-
|
|
53
|
-
|
|
113
|
+
switch (event.TAG) {
|
|
114
|
+
case "OrderPlaced" :
|
|
115
|
+
return todosFor("OrderPlaced", event.customerId, event.orderId);
|
|
116
|
+
case "OrderShipped" :
|
|
117
|
+
return todosFor("OrderShipped", event.customerId, event.orderId);
|
|
118
|
+
default:
|
|
119
|
+
return [];
|
|
54
120
|
}
|
|
55
|
-
let orderId = event.orderId;
|
|
56
|
-
return [[
|
|
57
|
-
confirmationKey(orderId),
|
|
58
|
-
{
|
|
59
|
-
recipientId: event.customerId,
|
|
60
|
-
orderId: orderId
|
|
61
|
-
}
|
|
62
|
-
]];
|
|
63
121
|
}
|
|
64
122
|
|
|
65
123
|
function resolve(event) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
124
|
+
switch (event.TAG) {
|
|
125
|
+
case "OrderPlaced" :
|
|
126
|
+
case "OrderShipped" :
|
|
127
|
+
return;
|
|
128
|
+
default:
|
|
129
|
+
return event.reference;
|
|
70
130
|
}
|
|
71
131
|
}
|
|
72
132
|
|
|
@@ -90,15 +150,33 @@ let FromOrderingDcb = AutomationSlice$Reventless.Mapping.Make({
|
|
|
90
150
|
let mappings = [FromOrderingDcb];
|
|
91
151
|
|
|
92
152
|
function process(id, item) {
|
|
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);
|
|
93
167
|
return [
|
|
94
|
-
|
|
168
|
+
match$1,
|
|
95
169
|
{
|
|
96
170
|
TAG: "RequestNotification",
|
|
97
|
-
recipientId:
|
|
98
|
-
category:
|
|
171
|
+
recipientId: match$1,
|
|
172
|
+
category: NotificationPreferences_Behavior$OrderingPlugin.categoryOf(rule.category),
|
|
99
173
|
reference: id,
|
|
100
|
-
|
|
101
|
-
|
|
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"
|
|
102
180
|
}
|
|
103
181
|
];
|
|
104
182
|
}
|
|
@@ -109,13 +187,17 @@ function onExhausted(_id, _item) {
|
|
|
109
187
|
|
|
110
188
|
let Spec;
|
|
111
189
|
|
|
190
|
+
let Rule;
|
|
191
|
+
|
|
112
192
|
let moduleUrl = "@reventlessdev/online-shop-hybrid-ordering/src/Notification/AutomationSlice/NotificationIntake_Automation.res.mjs";
|
|
113
193
|
|
|
114
194
|
export {
|
|
115
195
|
Spec,
|
|
116
196
|
M,
|
|
117
|
-
|
|
197
|
+
Rule,
|
|
118
198
|
OrderingDcbSource,
|
|
199
|
+
defaultRules,
|
|
200
|
+
todosFor,
|
|
119
201
|
FromOrderingDcb,
|
|
120
202
|
mappings,
|
|
121
203
|
process,
|
|
@@ -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
|
|