@reventlessdev/online-shop-hybrid-ordering 1.0.0-alpha.235 → 1.0.0-alpha.237
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 +15 -0
- package/package.json +11 -11
- package/src/CatalogProduct/StateChange/SyncCatalogProduct.res +3 -14
- package/src/CatalogProduct/StateChange/SyncCatalogProduct_Behavior.res +3 -3
- package/src/CatalogProduct/StateViewStream/AvailableProducts_Projection.res +1 -2
- package/src/Customer/OutboundTranslation/GeocodeCustomerAddress_Translation.res +8 -10
- package/src/Customer/ReadModelStream/Customers_Projections.res +1 -1
- package/src/Extension/Products_Extension.res +2 -4
- package/src/ExtensionPoint/Orders_ExtensionPointMapping.res +12 -12
- package/src/LifecycleModel.res +70 -10
- package/src/Notification/Automation/NotificationIntake.res +11 -11
- package/src/Notification/Automation/NotificationIntake_Automation.res +2 -1
- package/src/Notification/OutboundTranslation/AnnounceRecipientContact_Translation.res +3 -2
- package/src/Notification/OutboundTranslation/SendNotification_Translation.res +22 -14
- package/src/Notification/OutboundTranslation/SendNotification_Translation.res.mjs +2 -5
- package/src/Notification/StateChange/NotificationPreferences_Behavior.res +12 -3
- package/src/Notification/StateViewStream/NotificationDeliveries_Projection.res +35 -14
- package/src/Order/StateChange/CancelOrder.res +1 -4
- package/src/Order/StateChange/PlaceOrder.res +50 -48
- package/src/Order/StateChange/PlaceOrder.res.mjs +2 -1
- package/src/Order/StateChange/PlaceOrder_Behavior.res +6 -7
- package/src/Order/StateChange/ShipOrder.res +2 -4
- package/src/Plugin.res +140 -25
- package/tests/CatalogProduct/StateChange/SyncCatalogProduct_GWT.res +8 -2
- package/tests/Customer/AddressGeocodingConformance_GWT.res +8 -11
- package/tests/Customer/OutboundTranslation/GeocodeCustomerAddress_GWT.res +26 -14
- package/tests/Customer/ReadModelStream/Customers_GWT.res +3 -1
- package/tests/Flow/OrderingFlow_GWT.res +12 -6
- package/tests/Notification/Automation/NotificationIntake_GWT.res +3 -2
- package/tests/Notification/NotificationConformance_GWT.res +94 -84
- package/tests/Notification/OutboundTranslation/SendNotification_GWT.res +30 -8
- package/tests/Notification/OutboundTranslation/SendNotification_GWT.res.mjs +20 -4
- package/tests/Notification/StateViewStream/NotificationDeliveries_GWT.res +9 -10
- package/tests/Order/StateChange/PlaceOrder_GWT.res +24 -22
- package/tests/Order/StateViewStream/Orders_GWT.res +5 -4
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,21 @@
|
|
|
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.237 (2026-09-09)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @reventlessdev/online-shop-hybrid-ordering
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# 1.0.0-alpha.236 (2026-09-08)
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* **spec:** a field declares the value a form opens it on ([1d541fe](https://github.com/ReventlessDev/reventless-core/commit/1d541fe497e1df2ff48b72e26a06a1b8fd3c9e06))
|
|
19
|
+
|
|
20
|
+
|
|
6
21
|
# 1.0.0-alpha.235 (2026-09-08)
|
|
7
22
|
|
|
8
23
|
### 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.237",
|
|
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/
|
|
27
|
-
"@reventlessdev/
|
|
28
|
-
"@reventlessdev/reventless-
|
|
29
|
-
"@reventlessdev/reventless-local": "3.0.0-alpha.
|
|
30
|
-
"@reventlessdev/reventless-spec": "3.0.0-alpha.
|
|
31
|
-
"@reventlessdev/trait-address-geocoding": "1.0.0-alpha.
|
|
32
|
-
"@reventlessdev/trait-notification": "1.0.0-alpha.
|
|
24
|
+
"@reventlessdev/online-shop-hybrid-catalog-spec": "1.0.0-alpha.130",
|
|
25
|
+
"@reventlessdev/online-shop-hybrid-ordering-spec": "1.0.0-alpha.130",
|
|
26
|
+
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.20",
|
|
27
|
+
"@reventlessdev/reventless-gwt": "1.0.0-alpha.208",
|
|
28
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.162",
|
|
29
|
+
"@reventlessdev/reventless-local": "3.0.0-alpha.250",
|
|
30
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.134",
|
|
31
|
+
"@reventlessdev/trait-address-geocoding": "1.0.0-alpha.10",
|
|
32
|
+
"@reventlessdev/trait-notification": "1.0.0-alpha.10"
|
|
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.80"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"rescript": "12.3.0"
|
|
@@ -35,25 +35,14 @@ type error = unit // always succeeds — sync is idempotent
|
|
|
35
35
|
|
|
36
36
|
@schema
|
|
37
37
|
type event =
|
|
38
|
-
| CatalogProductSynced({
|
|
39
|
-
|
|
40
|
-
name: string,
|
|
41
|
-
price: Reventless.Money.t,
|
|
42
|
-
})
|
|
43
|
-
| CatalogProductPriceChanged({
|
|
44
|
-
productId: string,
|
|
45
|
-
price: Reventless.Money.t,
|
|
46
|
-
})
|
|
38
|
+
| CatalogProductSynced({productId: string, name: string, price: Reventless.Money.t})
|
|
39
|
+
| CatalogProductPriceChanged({productId: string, price: Reventless.Money.t})
|
|
47
40
|
| CatalogProductWithdrawn({productId: string})
|
|
48
41
|
// Carries the shadow's name and price so the projection can restore the row
|
|
49
42
|
// without a second read: the fold has them in hand at exactly the moment the
|
|
50
43
|
// decision is made, and an event that states what it caused is what makes the
|
|
51
44
|
// projection a pure mapping.
|
|
52
|
-
| CatalogProductRelisted({
|
|
53
|
-
productId: string,
|
|
54
|
-
name: string,
|
|
55
|
-
price: Reventless.Money.t,
|
|
56
|
-
})
|
|
45
|
+
| CatalogProductRelisted({productId: string, name: string, price: Reventless.Money.t})
|
|
57
46
|
| CatalogProductImageChanged({
|
|
58
47
|
productId: string,
|
|
59
48
|
@storageRef("Catalog.productImages") productImage?: Reventless.UploadableImage.t,
|
|
@@ -24,7 +24,7 @@ let evolve = (state, event) =>
|
|
|
24
24
|
| CatalogProductPriceChanged({price}) => {...state, price: Some(price)}
|
|
25
25
|
| CatalogProductWithdrawn => {...state, withdrawn: true}
|
|
26
26
|
| CatalogProductRelisted => {...state, withdrawn: false}
|
|
27
|
-
| CatalogProductImageChanged({
|
|
27
|
+
| CatalogProductImageChanged({?productImage}) => {...state, productImage}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
let decide = (state, command) =>
|
|
@@ -62,10 +62,10 @@ let decide = (state, command) =>
|
|
|
62
62
|
// ordinary shape of at-least-once delivery, not a change.
|
|
63
63
|
// Compared as options, so "the picture is gone" is recognised as a change and
|
|
64
64
|
// re-announcing the same absence is not.
|
|
65
|
-
| ChangeSyncedProductImage({productId,
|
|
65
|
+
| ChangeSyncedProductImage({productId, ?productImage}) =>
|
|
66
66
|
if state.productImage == productImage {
|
|
67
67
|
Ok([])
|
|
68
68
|
} else {
|
|
69
|
-
Ok([CatalogProductImageChanged({productId,
|
|
69
|
+
Ok([CatalogProductImageChanged({productId, ?productImage})])
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
let project = ({event}) =>
|
|
4
4
|
switch event {
|
|
5
5
|
| CatalogProductSynced({productId, name, price}) => [Set(productId, {productId, name, price})]
|
|
6
|
-
| CatalogProductPriceChanged({productId, price}) =>
|
|
7
|
-
[Update(productId, p => {...p, price})]
|
|
6
|
+
| CatalogProductPriceChanged({productId, price}) => [Update(productId, p => {...p, price})]
|
|
8
7
|
// `Delete`, not a flag. This view answers "what can I order", so a withdrawn
|
|
9
8
|
// product leaves it. Marking it instead — `@retired` on the shopper view —
|
|
10
9
|
// would look tidy and be wrong twice over: a shopper is not elevated, so the
|
|
@@ -21,19 +21,17 @@ let translate = async (_id, item, ~capabilities: Reventless.Capabilities.t) =>
|
|
|
21
21
|
~capabilities,
|
|
22
22
|
~located=(~point, ~resolvedFrom) => SetLocation({location: point, resolvedFrom}),
|
|
23
23
|
// A verdict for a human, not a coordinate.
|
|
24
|
-
~unresolvable=(~subject, ~reason) =>
|
|
25
|
-
MarkAddressUnresolvable({address: subject, reason}),
|
|
24
|
+
~unresolvable=(~subject, ~reason) => MarkAddressUnresolvable({address: subject, reason}),
|
|
26
25
|
)
|
|
27
26
|
)->Result.map(command => Some((item.customerId, command)))
|
|
28
27
|
|
|
29
28
|
// The budget is spent and the geocoder never answered. Recording the verdict
|
|
30
29
|
// beats leaving the TODO pending forever — and it is why the capability must be
|
|
31
30
|
// declared, since an unprovisioned geocoder reaches here every time.
|
|
32
|
-
let onExhausted = (_id, item: outboundItem, ~lastError) =>
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
))
|
|
31
|
+
let onExhausted = (_id, item: outboundItem, ~lastError) => Some((
|
|
32
|
+
item.customerId,
|
|
33
|
+
MarkAddressUnresolvable({
|
|
34
|
+
address: item.address,
|
|
35
|
+
reason: Geocode.exhaustedReason(lastError),
|
|
36
|
+
}),
|
|
37
|
+
))
|
|
@@ -25,10 +25,8 @@ module Mapping = {
|
|
|
25
25
|
| ProductRelisted({productId: theId}) => [
|
|
26
26
|
PublishStateChangeSliceCommand(RelistSyncedProduct({productId: theId})),
|
|
27
27
|
]
|
|
28
|
-
| ProductImageChanged({productId,
|
|
29
|
-
PublishStateChangeSliceCommand(
|
|
30
|
-
ChangeSyncedProductImage({productId, productImage: ?productImage}),
|
|
31
|
-
),
|
|
28
|
+
| ProductImageChanged({productId, ?productImage}) => [
|
|
29
|
+
PublishStateChangeSliceCommand(ChangeSyncedProductImage({productId, ?productImage})),
|
|
32
30
|
]
|
|
33
31
|
}
|
|
34
32
|
|
|
@@ -15,18 +15,18 @@ module Delegate = {
|
|
|
15
15
|
|
|
16
16
|
let mapIncomingCommand = (_id, _command, _meta) => []
|
|
17
17
|
|
|
18
|
-
let mapOutgoingEvent = Some(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
PublishEvent(
|
|
18
|
+
let mapOutgoingEvent = Some(
|
|
19
|
+
(_id, event, _meta, _queryEngine) =>
|
|
20
|
+
switch event {
|
|
21
|
+
| Delegate.OrderPlaced({orderId, customerId, productIds}) =>
|
|
22
|
+
productIds->Array.map(pid => PublishEvent(
|
|
23
23
|
pid,
|
|
24
24
|
OrderingSpec.Orders_ExtensionPoint.ItemOrdered({productId: pid, orderId, customerId}),
|
|
25
|
-
)
|
|
26
|
-
)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
))
|
|
26
|
+
| Delegate.OrderCancelled({orderId, productIds}) =>
|
|
27
|
+
productIds->Array.map(pid => PublishEvent(
|
|
28
|
+
pid,
|
|
29
|
+
OrderingSpec.Orders_ExtensionPoint.ItemOrderCancelled({productId: pid, orderId}),
|
|
30
|
+
))
|
|
31
|
+
},
|
|
32
32
|
)
|
package/src/LifecycleModel.res
CHANGED
|
@@ -7,14 +7,74 @@
|
|
|
7
7
|
// annotation where it is silent.
|
|
8
8
|
|
|
9
9
|
let model: array<Reventless.Plugin.derivedEdge> = [
|
|
10
|
-
{
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
{
|
|
18
|
-
|
|
19
|
-
|
|
10
|
+
{
|
|
11
|
+
component: "CancelOrder",
|
|
12
|
+
command: "CancelOrder",
|
|
13
|
+
level: Reventless.Plugin.Instance,
|
|
14
|
+
allowedStates: ["Placed"],
|
|
15
|
+
targets: ["Cancelled"],
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
component: "CancelOrder",
|
|
19
|
+
command: "ReopenOrder",
|
|
20
|
+
level: Reventless.Plugin.Instance,
|
|
21
|
+
allowedStates: ["Cancelled"],
|
|
22
|
+
targets: ["Placed"],
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
component: "Customer",
|
|
26
|
+
command: "Deactivate",
|
|
27
|
+
level: Reventless.Plugin.Instance,
|
|
28
|
+
allowedStates: ["Active"],
|
|
29
|
+
targets: ["Deactivated"],
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
component: "Customer",
|
|
33
|
+
command: "Reactivate",
|
|
34
|
+
level: Reventless.Plugin.Instance,
|
|
35
|
+
allowedStates: ["Deactivated"],
|
|
36
|
+
targets: ["Active"],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
component: "Customer",
|
|
40
|
+
command: "Register",
|
|
41
|
+
level: Reventless.Plugin.Collection,
|
|
42
|
+
allowedStates: [],
|
|
43
|
+
targets: ["Active"],
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
component: "Customer",
|
|
47
|
+
command: "SetAddressLocation",
|
|
48
|
+
level: Reventless.Plugin.Instance,
|
|
49
|
+
allowedStates: ["Active"],
|
|
50
|
+
targets: [],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
component: "Customer",
|
|
54
|
+
command: "UpdateAddress",
|
|
55
|
+
level: Reventless.Plugin.Instance,
|
|
56
|
+
allowedStates: ["Active"],
|
|
57
|
+
targets: [],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
component: "Customer",
|
|
61
|
+
command: "UpdateEmail",
|
|
62
|
+
level: Reventless.Plugin.Instance,
|
|
63
|
+
allowedStates: ["Active"],
|
|
64
|
+
targets: [],
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
component: "PlaceOrder",
|
|
68
|
+
command: "PlaceOrder",
|
|
69
|
+
level: Reventless.Plugin.Collection,
|
|
70
|
+
allowedStates: [],
|
|
71
|
+
targets: ["Placed"],
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
component: "ShipOrder",
|
|
75
|
+
command: "ShipOrder",
|
|
76
|
+
level: Reventless.Plugin.Instance,
|
|
77
|
+
allowedStates: ["Placed"],
|
|
78
|
+
targets: ["Shipped"],
|
|
79
|
+
},
|
|
20
80
|
]
|
|
@@ -23,17 +23,17 @@ type todoItem = {ruleId: string, recipientId: string, orderId: string}
|
|
|
23
23
|
|
|
24
24
|
@schema
|
|
25
25
|
type command =
|
|
26
|
-
RequestNotification({
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
26
|
+
| RequestNotification({
|
|
27
|
+
recipientId: string,
|
|
28
|
+
category: NotificationPreferences.category,
|
|
29
|
+
reference: string,
|
|
30
|
+
subjectType: string,
|
|
31
|
+
subjectRef: string,
|
|
32
|
+
subject: string,
|
|
33
|
+
body: string,
|
|
34
|
+
sourceId: string,
|
|
35
|
+
origin: NotificationPreferences.origin,
|
|
36
|
+
})
|
|
37
37
|
|
|
38
38
|
let maxRetries = 3
|
|
39
39
|
let heartbeatInterval = 60
|
|
@@ -14,8 +14,9 @@ let collect = (event, ~sourceId) =>
|
|
|
14
14
|
// item completes on this returning `Ok`, which is what makes the directory's own
|
|
15
15
|
// `AnnounceRecipient` free to be idempotent: a re-announced address publishes no
|
|
16
16
|
// event, and no row is left waiting for one.
|
|
17
|
-
let translate = async (_id, item: outboundItem, ~capabilities as _) =>
|
|
18
|
-
|
|
17
|
+
let translate = async (_id, item: outboundItem, ~capabilities as _) => Ok(
|
|
18
|
+
Some((item.recipientId, AnnounceRecipient({recipientId: item.recipientId, email: item.email}))),
|
|
19
|
+
)
|
|
19
20
|
|
|
20
21
|
// Unreachable in practice — `translate` cannot fail — but a slice that stays
|
|
21
22
|
// silent has to say so on purpose. If a publish failure ever did exhaust the
|
|
@@ -20,15 +20,24 @@ let recipientFor = (item: outboundItem) =>
|
|
|
20
20
|
->Reventless.Email.fromString
|
|
21
21
|
->Result.map(email => Reventless.Messaging.ToEmail(email))
|
|
22
22
|
| Sms =>
|
|
23
|
-
item.address
|
|
24
|
-
|
|
23
|
+
item.address
|
|
24
|
+
->Reventless.Phone.fromString
|
|
25
|
+
->Result.map(phone => Reventless.Messaging.ToSms(phone))
|
|
26
|
+
// The directory stores a channel beside one flat address, and no push address is
|
|
27
|
+
// one string: two services issue a token, Web Push issues an endpoint with its
|
|
28
|
+
// encryption keys, and which service issued this one is not recorded either. So
|
|
29
|
+
// the pair cannot be re-fused, and refusing says so once rather than guessing a
|
|
30
|
+
// service and failing at whichever provider. A push transport arrives with the
|
|
31
|
+
// stored shape it needs, in the commit that justifies the columns.
|
|
32
|
+
| Push =>
|
|
33
|
+
Error("a push address names its issuing service, and the directory stores one flat address")
|
|
25
34
|
}
|
|
26
35
|
|
|
27
36
|
let translate = async (_id, item: outboundItem, ~capabilities: Reventless.Capabilities.t) =>
|
|
28
37
|
switch recipientFor(item) {
|
|
29
|
-
// An address the directory holds that its own channel
|
|
30
|
-
//
|
|
31
|
-
//
|
|
38
|
+
// An address the directory holds that its own channel cannot use. Not retryable
|
|
39
|
+
// and not the provider's fault, so it is recorded as a failure rather than
|
|
40
|
+
// raised: the recipient's row is what needs fixing.
|
|
32
41
|
| Error(why) =>
|
|
33
42
|
Ok(
|
|
34
43
|
Some((
|
|
@@ -78,12 +87,11 @@ let translate = async (_id, item: outboundItem, ~capabilities: Reventless.Capabi
|
|
|
78
87
|
// The budget is spent and the provider never answered. Recording it beats leaving
|
|
79
88
|
// the row pending forever — and it is the second reason the capability must be
|
|
80
89
|
// declared, since an unprovisioned sender reaches here every single time.
|
|
81
|
-
let onExhausted = (_id, item: outboundItem, ~lastError) =>
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
))
|
|
90
|
+
let onExhausted = (_id, item: outboundItem, ~lastError) => Some((
|
|
91
|
+
item.recipientId,
|
|
92
|
+
RecordDeliveryFailure({
|
|
93
|
+
recipientId: item.recipientId,
|
|
94
|
+
reference: item.reference,
|
|
95
|
+
reason: lastError->Option.getOr("the messaging provider never answered"),
|
|
96
|
+
}),
|
|
97
|
+
))
|
|
@@ -36,11 +36,8 @@ function recipientFor(item) {
|
|
|
36
36
|
}));
|
|
37
37
|
case "Push" :
|
|
38
38
|
return {
|
|
39
|
-
TAG: "
|
|
40
|
-
_0:
|
|
41
|
-
TAG: "ToPush",
|
|
42
|
-
deviceToken: item.address
|
|
43
|
-
}
|
|
39
|
+
TAG: "Error",
|
|
40
|
+
_0: "a push address names its issuing service, and the directory stores one flat address"
|
|
44
41
|
};
|
|
45
42
|
}
|
|
46
43
|
}
|
|
@@ -82,7 +82,8 @@ let evolve = (state, event: consumedEvent) =>
|
|
|
82
82
|
let named = (recipientId, fact: Rules.fact) =>
|
|
83
83
|
switch fact {
|
|
84
84
|
| Claimed(_)
|
|
85
|
-
| Released(_) =>
|
|
85
|
+
| Released(_) =>
|
|
86
|
+
None
|
|
86
87
|
| Deferred({reference, source}) =>
|
|
87
88
|
Some(NotificationDeferred({recipientId, reference, sourceKey: source}))
|
|
88
89
|
| Announced({address}) => Some(RecipientAnnounced({recipientId, email: address}))
|
|
@@ -155,9 +156,17 @@ let decide = (state, command) =>
|
|
|
155
156
|
| AnnounceRecipient({recipientId, email}) =>
|
|
156
157
|
through(state, recipientId, Announce({channel: Email, address: email}))
|
|
157
158
|
| Subscribe({recipientId, category, channel}) =>
|
|
158
|
-
through(
|
|
159
|
+
through(
|
|
160
|
+
state,
|
|
161
|
+
recipientId,
|
|
162
|
+
Subscribe({category: categoryKey(category), channel: channelKey(channel)}),
|
|
163
|
+
)
|
|
159
164
|
| Unsubscribe({recipientId, category, channel}) =>
|
|
160
|
-
through(
|
|
165
|
+
through(
|
|
166
|
+
state,
|
|
167
|
+
recipientId,
|
|
168
|
+
Unsubscribe({category: categoryKey(category), channel: channelKey(channel)}),
|
|
169
|
+
)
|
|
161
170
|
|
|
162
171
|
// The one arm the mapping cannot be pure about: the words and the subject
|
|
163
172
|
// belong to the requester, and the trait's facts carry neither, because a trait
|
|
@@ -21,7 +21,15 @@ let channelName = (channel: NotificationPreferences.channel) =>
|
|
|
21
21
|
// dropping it is what makes that visible instead of fabricating a half-row.
|
|
22
22
|
let project = ({event, meta}) =>
|
|
23
23
|
switch event {
|
|
24
|
-
| NotificationRequested({
|
|
24
|
+
| NotificationRequested({
|
|
25
|
+
recipientId,
|
|
26
|
+
category,
|
|
27
|
+
reference,
|
|
28
|
+
channel,
|
|
29
|
+
subjectType,
|
|
30
|
+
subjectRef,
|
|
31
|
+
origin,
|
|
32
|
+
}) => [
|
|
25
33
|
Set(
|
|
26
34
|
reference,
|
|
27
35
|
{
|
|
@@ -60,7 +68,14 @@ let project = ({event, meta}) =>
|
|
|
60
68
|
},
|
|
61
69
|
),
|
|
62
70
|
]
|
|
63
|
-
| NotificationUndeliverable({
|
|
71
|
+
| NotificationUndeliverable({
|
|
72
|
+
recipientId,
|
|
73
|
+
category,
|
|
74
|
+
reference,
|
|
75
|
+
subjectType,
|
|
76
|
+
subjectRef,
|
|
77
|
+
origin,
|
|
78
|
+
}) => [
|
|
64
79
|
Set(
|
|
65
80
|
reference,
|
|
66
81
|
{
|
|
@@ -79,19 +94,25 @@ let project = ({event, meta}) =>
|
|
|
79
94
|
),
|
|
80
95
|
]
|
|
81
96
|
| NotificationDelivered({reference, providerRef}) => [
|
|
82
|
-
Update(
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
97
|
+
Update(
|
|
98
|
+
reference,
|
|
99
|
+
state => {
|
|
100
|
+
...state,
|
|
101
|
+
outcome: Delivered,
|
|
102
|
+
detail: providerRef,
|
|
103
|
+
settledAt: Some(meta.time),
|
|
104
|
+
},
|
|
105
|
+
),
|
|
88
106
|
]
|
|
89
107
|
| NotificationFailed({reference, reason}) => [
|
|
90
|
-
Update(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
108
|
+
Update(
|
|
109
|
+
reference,
|
|
110
|
+
state => {
|
|
111
|
+
...state,
|
|
112
|
+
outcome: Failed,
|
|
113
|
+
detail: reason,
|
|
114
|
+
settledAt: Some(meta.time),
|
|
115
|
+
},
|
|
116
|
+
),
|
|
96
117
|
]
|
|
97
118
|
}
|
|
@@ -23,10 +23,7 @@ type error =
|
|
|
23
23
|
|
|
24
24
|
@schema
|
|
25
25
|
type event =
|
|
26
|
-
| OrderCancelled({
|
|
27
|
-
orderId: string,
|
|
28
|
-
productIds: array<string>,
|
|
29
|
-
})
|
|
26
|
+
| OrderCancelled({orderId: string, productIds: array<string>})
|
|
30
27
|
| OrderReopened({orderId: string})
|
|
31
28
|
|
|
32
29
|
// `ReopenOrder` is the way back out of `Cancelled`, and a real edge of the
|
|
@@ -47,28 +47,30 @@ type shippingMethod =
|
|
|
47
47
|
@schema
|
|
48
48
|
type lineItem = {
|
|
49
49
|
@ref("AvailableProducts") productId: string,
|
|
50
|
-
|
|
50
|
+
// A line is for one of the thing unless the shopper says otherwise. Without
|
|
51
|
+
// it a checkout seeded from a picked shelf opens each line blank.
|
|
52
|
+
@default(1) quantity: int,
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
@schema
|
|
54
56
|
type command =
|
|
55
|
-
PlaceOrder({
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
57
|
+
| PlaceOrder({
|
|
58
|
+
@partitionTag orderId: string,
|
|
59
|
+
// customerId is payload, not a query key — @noDcbTag stops it auto-tagging.
|
|
60
|
+
// It is also the order's owner: the resolver overwrites this with the
|
|
61
|
+
// authenticated caller's id before the command is published, so what a
|
|
62
|
+
// client sends here is ignored rather than trusted. An operator placing an
|
|
63
|
+
// order on someone's behalf is exempt and keeps the value they sent.
|
|
64
|
+
@noDcbTag @owner customerId: string,
|
|
65
|
+
lineItems: array<lineItem>,
|
|
66
|
+
shippingMethod: shippingMethod,
|
|
67
|
+
// A requested delivery slot, chosen at checkout. An optional field — a
|
|
68
|
+
// Pickup order (or a caller that names no preference) simply omits it, and
|
|
69
|
+
// an order placed before this field existed carries no key, so adding it
|
|
70
|
+
// costs the log nothing (the additive path in the plan's adoption table).
|
|
71
|
+
// One `DateRange.t`, not a guessed `start*`/`end*` name pair.
|
|
72
|
+
deliveryWindow?: Reventless.DateRange.t,
|
|
73
|
+
})
|
|
72
74
|
|
|
73
75
|
@schema
|
|
74
76
|
type error =
|
|
@@ -100,33 +102,33 @@ type orderLine = {
|
|
|
100
102
|
|
|
101
103
|
@schema
|
|
102
104
|
type event =
|
|
103
|
-
OrderPlaced({
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
105
|
+
| OrderPlaced({
|
|
106
|
+
@partitionTag orderId: string,
|
|
107
|
+
customerId: string,
|
|
108
|
+
// Redundant against `lines`, and deliberately so. The extension point
|
|
109
|
+
// decomposes this into one published `ItemOrdered` per product and
|
|
110
|
+
// `CancelOrder` folds it, so both keep working untouched — and the public
|
|
111
|
+
// contract in `ordering-spec` does not move, which is what lets the shop
|
|
112
|
+
// show quantities without redeploying Catalog in lockstep.
|
|
113
|
+
productIds: array<string>,
|
|
114
|
+
lines: array<orderLine>,
|
|
115
|
+
total: Reventless.Money.t,
|
|
116
|
+
shippingMethod: shippingMethod,
|
|
117
|
+
deliveryWindow?: Reventless.DateRange.t,
|
|
118
|
+
// What the first product was called when this order was placed.
|
|
119
|
+
//
|
|
120
|
+
// **Captured, not looked up.** An order is a record of what somebody bought,
|
|
121
|
+
// and the catalog goes on changing afterwards — a rename, a withdrawal, a
|
|
122
|
+
// reshoot. Reading the name live would rewrite history every time the shop
|
|
123
|
+
// tidied its shelves, and would leave an order for a withdrawn product with
|
|
124
|
+
// nothing to show at all.
|
|
125
|
+
//
|
|
126
|
+
// Optional because every order placed before this field existed carries no
|
|
127
|
+
// key, which is what makes adding it cost the log nothing. A reader treats
|
|
128
|
+
// absent as "not recorded" rather than as a name.
|
|
129
|
+
firstProductName?: string,
|
|
130
|
+
// The picture as it was when the order was placed, frozen for the reason the
|
|
131
|
+
// name is. A reshoot, a withdrawal or a deletion afterwards leaves this
|
|
132
|
+
// order showing what the shopper actually bought.
|
|
133
|
+
@storageRef("Catalog.productImages") firstProductImage?: Reventless.UploadableImage.t,
|
|
134
|
+
})
|