@stacksjs/defaults 0.70.294 → 0.70.297

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/ai/skills/stacks-auth/SKILL.md +1 -1
  2. package/ai/skills/stacks-development/SKILL.md +3 -3
  3. package/ai/skills/stacks-stx/SKILL.md +3 -3
  4. package/ai/skills/stacks-ui/SKILL.md +3 -3
  5. package/app/Actions/Auth/LoginAction.ts +8 -2
  6. package/app/Actions/Auth/RegisterAction.ts +3 -2
  7. package/app/Actions/Auth/SocialCallbackAction.ts +75 -0
  8. package/app/Actions/Auth/SocialRedirectAction.ts +37 -0
  9. package/app/Actions/Dashboard/Analytics/request-analytics.ts +6 -1
  10. package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +7 -0
  11. package/app/Actions/Dashboard/Commerce/commerce-product-records.ts +3 -1
  12. package/app/Actions/Dashboard/Commerce/commerce-record.ts +13 -0
  13. package/app/Actions/Dashboard/Infrastructure/InsightsAction.ts +3 -3
  14. package/app/Actions/Dashboard/Jobs/job-records.ts +5 -1
  15. package/app/Actions/Dashboard/Kanban/BoardShowAction.ts +9 -7
  16. package/app/Actions/Dashboard/Kanban/BoardStoreAction.ts +1 -1
  17. package/app/Actions/Dashboard/Kanban/BoardsIndexAction.ts +6 -4
  18. package/app/Actions/Dashboard/Kanban/CardShowAction.ts +4 -2
  19. package/app/Actions/Dashboard/Library/GetAverageReleaseTime.ts +1 -1
  20. package/app/Actions/Dashboard/Marketing/CampaignIndexAction.ts +2 -2
  21. package/app/Actions/Dashboard/Marketing/ListIndexAction.ts +1 -1
  22. package/app/Actions/Dashboard/Queries/query-dashboard.ts +5 -3
  23. package/app/Actions/Password/PasswordResetAction.ts +29 -7
  24. package/app/Middleware/Auth.ts +4 -2
  25. package/app/Middleware/Can.ts +58 -5
  26. package/app/Models/SocialAccount.ts +70 -0
  27. package/app/Models/User.ts +7 -4
  28. package/app/Models/commerce/DeliveryRoute.ts +34 -0
  29. package/app/Models/commerce/DeliveryStop.ts +166 -0
  30. package/app/Models/commerce/Driver.ts +72 -2
  31. package/app/Models/commerce/DriverPing.ts +103 -0
  32. package/app/Models/commerce/Order.ts +44 -3
  33. package/app/password-policy.ts +46 -0
  34. package/bootstrap.ts +67 -19
  35. package/ide/vscode/package.json +1 -1
  36. package/package.json +1 -1
  37. package/routes/auth.ts +83 -0
  38. package/routes/dashboard.ts +0 -61
  39. package/routes/socials.ts +32 -0
  40. package/vcs/github/CONTRIBUTING.md +1 -1
  41. package/vcs/github/workflows/README.md +8 -9
  42. package/vcs/github/workflows/release.yml +4 -181
  43. package/vcs/github/workflows/export-size.yml +0 -25
@@ -1,6 +1,43 @@
1
1
  import { AuthorizationException, authorize } from '@stacksjs/auth'
2
2
  import { HttpError } from '@stacksjs/error-handling'
3
- import { Middleware } from '@stacksjs/router'
3
+ import { Middleware, resolveRouteModel, setRouteModelFallback } from '@stacksjs/router'
4
+
5
+ /**
6
+ * Convention binding: parameter `site` resolves through the `Site` model
7
+ * (stacksjs/stacks#2231).
8
+ *
9
+ * Registered as the FALLBACK, not as a binding per name, so an app's own
10
+ * `defineRouteModelBinding('site', …)` still wins — scoped lookups, soft
11
+ * deletes and slug-instead-of-id all need to be expressible.
12
+ *
13
+ * Lives here rather than in `@stacksjs/router` because the router importing
14
+ * `@stacksjs/orm` would close a dependency cycle. This file is app-land, where
15
+ * importing the ORM is ordinary.
16
+ *
17
+ * Returning `undefined` means "no model of that name exists" and leaves the raw
18
+ * string to pass through exactly as before. That is what keeps this from
19
+ * changing the meaning of every existing `can:ability,param` route at once.
20
+ */
21
+ setRouteModelFallback(async (value, { param }) => {
22
+ // `site` -> `Site`, `blogPost` -> `BlogPost`. Only the first character is
23
+ // touched: lowercasing the rest would turn `blogPost` into `Blogpost`.
24
+ const modelName = param.charAt(0).toUpperCase() + param.slice(1)
25
+
26
+ const orm = await import('@stacksjs/orm') as Record<string, any>
27
+ const model = orm[modelName]
28
+
29
+ // No model of that name — decline, so the raw string passes through exactly
30
+ // as it did before this existed.
31
+ if (!model || typeof model.find !== 'function')
32
+ return undefined
33
+
34
+ // `null` when the row is missing, which is deliberately NOT the same as
35
+ // declining: the parameter IS bound, there is simply nothing there. `Can`
36
+ // then authorizes with no model, which denies — the same 403 this route
37
+ // produced before, rather than a 404 that would tell an anonymous caller
38
+ // which ids exist.
39
+ return await model.find(Number.isNaN(Number(value)) ? value : Number(value)) ?? null
40
+ })
4
41
 
5
42
  /**
6
43
  * Authorization Gate Middleware
@@ -44,13 +81,29 @@ export default new Middleware({
44
81
  // Prepare arguments for the gate check
45
82
  const args: any[] = []
46
83
 
47
- // If a model parameter is specified, get it from route params
84
+ // If a model parameter is specified, resolve it to a MODEL before handing
85
+ // it to the gate. Passing the raw path string — which is what happened
86
+ // before — meant `resolveAbility()` saw a `String`, found no policy
87
+ // registered under that name, and fell through to the default deny. A
88
+ // declarative `can:view,site` could therefore never reach
89
+ // `SitePolicy.view(user, site)` (#2231).
48
90
  if (modelParam) {
49
91
  const routeParams = request.params || {}
50
- const model = routeParams[modelParam]
92
+ const raw = routeParams[modelParam]
93
+
94
+ if (raw !== undefined && raw !== null && raw !== '') {
95
+ const resolution = await resolveRouteModel(modelParam, String(raw), request)
51
96
 
52
- if (model) {
53
- args.push(model)
97
+ if (!resolution.bound) {
98
+ // Nobody claims this parameter. Push the raw value, exactly as
99
+ // before — an app that authorizes on an id string keeps working.
100
+ args.push(raw)
101
+ }
102
+ else if (resolution.model !== undefined) {
103
+ args.push(resolution.model)
104
+ }
105
+ // Bound but absent: authorize with no model, which denies. Same 403
106
+ // this produced before, and it does not disclose which ids exist.
54
107
  }
55
108
  }
56
109
 
@@ -0,0 +1,70 @@
1
+ import { defineModel } from '@stacksjs/orm'
2
+ import { schema } from '@stacksjs/validation'
3
+
4
+ /**
5
+ * A social provider identity linked to a local user (stacksjs/stacks#2276).
6
+ *
7
+ * One row per (provider, provider account): a link table rather than columns
8
+ * on `users`, so one user can attach several providers and a provider-side
9
+ * email change cannot orphan the link. The unique index on
10
+ * `(provider, provider_user_id)` is what makes "sign in with the same GitHub
11
+ * account" resolve to the same local user forever.
12
+ *
13
+ * Rows are only ever written by the framework's social sign-in policy
14
+ * (`resolveSocialSignIn` in @stacksjs/auth) — the takeover guard lives there,
15
+ * not here.
16
+ */
17
+ export default defineModel({
18
+ name: 'SocialAccount',
19
+ table: 'social_accounts',
20
+ primaryKey: 'id',
21
+ autoIncrement: true,
22
+
23
+ indexes: [
24
+ {
25
+ name: 'social_accounts_provider_provider_user_id_unique',
26
+ columns: ['provider', 'provider_user_id'],
27
+ unique: true,
28
+ },
29
+ {
30
+ name: 'social_accounts_user_id_index',
31
+ columns: ['user_id'],
32
+ },
33
+ ],
34
+
35
+ traits: {
36
+ useTimestamps: true,
37
+ },
38
+
39
+ belongsTo: ['User'],
40
+
41
+ attributes: {
42
+ provider: {
43
+ required: true,
44
+ fillable: true,
45
+ validation: {
46
+ rule: schema.string().max(50),
47
+ message: { max: 'Provider must not exceed 50 characters' },
48
+ },
49
+ },
50
+
51
+ providerUserId: {
52
+ required: true,
53
+ fillable: true,
54
+ validation: {
55
+ rule: schema.string().max(255),
56
+ message: { max: 'Provider user id must not exceed 255 characters' },
57
+ },
58
+ },
59
+
60
+ // The address the provider reported at link time. Informational — sign-in
61
+ // resolves through (provider, provider_user_id), never through this.
62
+ providerEmail: {
63
+ fillable: true,
64
+ validation: {
65
+ rule: schema.string().max(255),
66
+ message: { max: 'Provider email must not exceed 255 characters' },
67
+ },
68
+ },
69
+ },
70
+ })
@@ -3,6 +3,7 @@ import { defineModel } from '@stacksjs/orm'
3
3
  import { makeHash } from '@stacksjs/security'
4
4
  // soon, these will be auto-imported
5
5
  import { schema } from '@stacksjs/validation'
6
+ import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH } from '../password-policy'
6
7
 
7
8
  export default defineModel({
8
9
  name: 'User', // defaults to the sanitized file name
@@ -98,15 +99,17 @@ export default defineModel({
98
99
  hidden: true,
99
100
  fillable: true,
100
101
  validation: {
101
- rule: schema.string().required().min(6).max(255),
102
+ rule: schema.string().required().min(PASSWORD_MIN_LENGTH).max(PASSWORD_MAX_LENGTH),
102
103
  message: {
103
104
  required: 'Password is required',
104
- min: 'Password must have a minimum of 6 characters',
105
- max: 'Password must have a maximum of 255 characters',
105
+ min: `Password must have a minimum of ${PASSWORD_MIN_LENGTH} characters`,
106
+ max: `Password must have a maximum of ${PASSWORD_MAX_LENGTH} characters`,
106
107
  },
107
108
  },
108
109
 
109
- factory: () => '123456',
110
+ // Must satisfy the rule above, or seeded users fail their own model's
111
+ // validation. `123456` did once the minimum moved to 8 (#2226).
112
+ factory: () => 'password1234',
110
113
  },
111
114
 
112
115
  avatar: {
@@ -30,6 +30,7 @@ export default defineModel({
30
30
  },
31
31
 
32
32
  belongsTo: ['Driver'],
33
+ hasMany: ['DeliveryStop', 'DriverPing'],
33
34
 
34
35
  attributes: {
35
36
  driver: {
@@ -85,6 +86,39 @@ export default defineModel({
85
86
  },
86
87
  factory: faker => faker.date.recent().getTime(),
87
88
  },
89
+
90
+ /*
91
+ * Route lifecycle. `stops` and `totalDistance` describe a route that has
92
+ * already run; a route being followed right now needs to say so, because
93
+ * that is the difference between a tracking map that draws a moving
94
+ * vehicle and one that draws yesterday's.
95
+ */
96
+ status: {
97
+ order: 7,
98
+ fillable: true,
99
+ default: 'planned',
100
+ validation: {
101
+ rule: schema.enum(['planned', 'active', 'completed', 'cancelled']),
102
+ message: {
103
+ enum: 'Status must be one of: planned, active, completed, cancelled',
104
+ },
105
+ },
106
+ factory: faker => faker.helpers.arrayElement(['planned', 'active', 'completed']),
107
+ },
108
+
109
+ startedAt: {
110
+ order: 8,
111
+ fillable: true,
112
+ validation: { rule: schema.timestamp() },
113
+ factory: () => null,
114
+ },
115
+
116
+ completedAt: {
117
+ order: 9,
118
+ fillable: true,
119
+ validation: { rule: schema.timestamp() },
120
+ factory: () => null,
121
+ },
88
122
  },
89
123
 
90
124
  dashboard: {
@@ -0,0 +1,166 @@
1
+ import { defineModel } from '@stacksjs/orm'
2
+ import { schema } from '@stacksjs/validation'
3
+
4
+ /**
5
+ * One address on a delivery route.
6
+ *
7
+ * `DeliveryRoute` records that a route happened and how far it went; this is
8
+ * the route itself. Without it `stops` is an integer, which is enough to bill
9
+ * a driver and not nearly enough to tell a customer where their order is: the
10
+ * link from an order to the vehicle carrying it runs through here.
11
+ *
12
+ * Sequence is the planned order. `status` is what actually happened, which
13
+ * diverges the moment a driver skips a building and comes back to it.
14
+ */
15
+ export default defineModel({
16
+ name: 'DeliveryStop',
17
+ table: 'delivery_stops',
18
+ primaryKey: 'id',
19
+ autoIncrement: true,
20
+
21
+ traits: {
22
+ useUuid: true,
23
+ useTimestamps: true,
24
+
25
+ useSearch: {
26
+ displayable: ['id', 'sequence', 'status', 'address', 'etaAt'],
27
+ searchable: ['address', 'recipientName'],
28
+ sortable: ['sequence', 'etaAt', 'createdAt'],
29
+ filterable: ['status', 'deliveryRouteId'],
30
+ },
31
+
32
+ useSeeder: { count: 0 },
33
+
34
+ useApi: {
35
+ // A stop names a customer and their address. Staff only, both ways.
36
+ // Customers reach their own stop through the order tracking route,
37
+ // which authorises on the order's tracking token instead.
38
+ middleware: ['auth'],
39
+ uri: 'delivery-stops',
40
+ },
41
+
42
+ observe: true,
43
+ },
44
+
45
+ belongsTo: ['DeliveryRoute', 'Order'],
46
+
47
+ attributes: {
48
+ /** Position in the planned run, 1-based. */
49
+ sequence: {
50
+ order: 1,
51
+ required: true,
52
+ fillable: true,
53
+ default: 1,
54
+ validation: {
55
+ rule: schema.number().required().min(1),
56
+ message: { min: 'Sequence starts at 1' },
57
+ },
58
+ factory: faker => faker.number.int({ min: 1, max: 20 }),
59
+ },
60
+
61
+ status: {
62
+ order: 2,
63
+ required: true,
64
+ fillable: true,
65
+ default: 'pending',
66
+ validation: {
67
+ rule: schema.enum(['pending', 'en_route', 'arrived', 'completed', 'failed', 'skipped']),
68
+ message: {
69
+ enum: 'Status must be one of: pending, en_route, arrived, completed, failed, skipped',
70
+ },
71
+ },
72
+ factory: faker => faker.helpers.arrayElement(['pending', 'en_route', 'completed']),
73
+ },
74
+
75
+ address: {
76
+ order: 3,
77
+ required: true,
78
+ fillable: true,
79
+ validation: { rule: schema.string().required().max(255) },
80
+ factory: faker => faker.location.streetAddress(true),
81
+ },
82
+
83
+ /**
84
+ * Geocoded destination. Nullable because an address can arrive before it
85
+ * resolves, and a stop with no coordinates is still a stop the driver can
86
+ * complete; it just cannot be drawn.
87
+ */
88
+ latitude: {
89
+ order: 4,
90
+ fillable: true,
91
+ validation: { rule: schema.number().min(-90).max(90) },
92
+ factory: faker => faker.location.latitude(),
93
+ },
94
+
95
+ longitude: {
96
+ order: 5,
97
+ fillable: true,
98
+ validation: { rule: schema.number().min(-180).max(180) },
99
+ factory: faker => faker.location.longitude(),
100
+ },
101
+
102
+ recipientName: {
103
+ order: 6,
104
+ fillable: true,
105
+ validation: { rule: schema.string().max(255) },
106
+ factory: faker => faker.person.fullName(),
107
+ },
108
+
109
+ recipientPhone: {
110
+ order: 7,
111
+ fillable: true,
112
+ validation: { rule: schema.string().max(40) },
113
+ factory: faker => faker.phone.number(),
114
+ },
115
+
116
+ /** Current estimate, rewritten as the driver moves. */
117
+ etaAt: {
118
+ order: 8,
119
+ fillable: true,
120
+ validation: { rule: schema.timestamp() },
121
+ factory: () => new Date(Date.now() + 25 * 60_000).toISOString(),
122
+ },
123
+
124
+ /**
125
+ * When the customer was told the driver was close.
126
+ *
127
+ * The latch that makes "nearly there" fire once. Without it every ping
128
+ * inside the radius sends another text, which is roughly one text every
129
+ * four seconds for the last four hundred metres.
130
+ */
131
+ notifiedNearbyAt: {
132
+ order: 9,
133
+ fillable: true,
134
+ validation: { rule: schema.timestamp() },
135
+ factory: () => null,
136
+ },
137
+
138
+ /** When the driver crossed the arrival radius. */
139
+ arrivedAt: {
140
+ order: 10,
141
+ fillable: true,
142
+ validation: { rule: schema.timestamp() },
143
+ factory: () => null,
144
+ },
145
+
146
+ /** When the handover finished. Together with `arrivedAt`, dwell time. */
147
+ completedAt: {
148
+ order: 11,
149
+ fillable: true,
150
+ validation: { rule: schema.timestamp() },
151
+ factory: () => null,
152
+ },
153
+
154
+ /** Why a stop failed, or anything the driver needs to record. */
155
+ notes: {
156
+ order: 12,
157
+ fillable: true,
158
+ validation: { rule: schema.string().max(1000) },
159
+ factory: () => '',
160
+ },
161
+ },
162
+
163
+ dashboard: {
164
+ highlight: true,
165
+ },
166
+ } as const)
@@ -8,7 +8,7 @@ export default defineModel({
8
8
  autoIncrement: true,
9
9
 
10
10
  belongsTo: ['User'],
11
- hasMany: ['DeliveryRoute'],
11
+ hasMany: ['DeliveryRoute', 'DriverPing'],
12
12
 
13
13
  traits: {
14
14
  useUuid: true,
@@ -86,10 +86,80 @@ export default defineModel({
86
86
  order: 5,
87
87
  fillable: true,
88
88
  validation: {
89
- rule: schema.enum(['active', 'on_delivery', 'on_break']),
89
+ rule: schema.enum(['active', 'on_delivery', 'on_break', 'offline']),
90
90
  },
91
91
  factory: faker => faker.helpers.arrayElement(['active', 'on_delivery', 'on_break']),
92
92
  },
93
+
94
+ /*
95
+ * Last known position.
96
+ *
97
+ * Denormalised from the `driver_pings` series on purpose: "where is this
98
+ * driver right now" is asked on every map frame and by every tracking
99
+ * page, and answering it with a MAX(recorded_at) subquery over a table
100
+ * that grows by a row every few seconds is the wrong shape. The series is
101
+ * the history; these three columns are the present.
102
+ */
103
+ latitude: {
104
+ order: 6,
105
+ fillable: true,
106
+ validation: {
107
+ rule: schema.number().min(-90).max(90),
108
+ message: {
109
+ min: 'Latitude must be between -90 and 90',
110
+ max: 'Latitude must be between -90 and 90',
111
+ },
112
+ },
113
+ factory: faker => faker.location.latitude(),
114
+ },
115
+
116
+ longitude: {
117
+ order: 7,
118
+ fillable: true,
119
+ validation: {
120
+ rule: schema.number().min(-180).max(180),
121
+ message: {
122
+ min: 'Longitude must be between -180 and 180',
123
+ max: 'Longitude must be between -180 and 180',
124
+ },
125
+ },
126
+ factory: faker => faker.location.longitude(),
127
+ },
128
+
129
+ /** Degrees clockwise from true north, so a map can rotate the marker. */
130
+ heading: {
131
+ order: 8,
132
+ fillable: true,
133
+ validation: {
134
+ rule: schema.number().min(0).max(360),
135
+ },
136
+ factory: faker => faker.number.int({ min: 0, max: 359 }),
137
+ },
138
+
139
+ /** Metres per second, as reported by the device. */
140
+ speed: {
141
+ order: 9,
142
+ fillable: true,
143
+ default: 0,
144
+ validation: {
145
+ rule: schema.number().min(0),
146
+ },
147
+ factory: () => 0,
148
+ },
149
+
150
+ /*
151
+ * When the last fix landed. A tracking map needs this to say "updated 4
152
+ * seconds ago" and, more importantly, to stop claiming a driver is at a
153
+ * position that is ten minutes stale.
154
+ */
155
+ lastPingAt: {
156
+ order: 10,
157
+ fillable: true,
158
+ validation: {
159
+ rule: schema.timestamp(),
160
+ },
161
+ factory: () => new Date().toISOString(),
162
+ },
93
163
  },
94
164
 
95
165
  dashboard: {
@@ -0,0 +1,103 @@
1
+ import { defineModel } from '@stacksjs/orm'
2
+ import { schema } from '@stacksjs/validation'
3
+
4
+ /**
5
+ * One position fix from a driver's device.
6
+ *
7
+ * The append-only history behind `drivers.latitude/longitude`. A tracking map
8
+ * needs the present position, but everything else about a delivery needs the
9
+ * series: drawing the path already travelled, replaying a disputed drop,
10
+ * measuring how long a stop actually took, and deriving an ETA from recent
11
+ * speed rather than straight-line distance.
12
+ *
13
+ * Deliberately not `useSearch` (indexing a row every few seconds is pointless
14
+ * and expensive) and not `useApi` (writes come through
15
+ * `recordDriverPing`, which does the fan-out, and reads are scoped to a
16
+ * route). `usePrunable` keeps the table from growing without bound.
17
+ */
18
+ export default defineModel({
19
+ name: 'DriverPing',
20
+ table: 'driver_pings',
21
+ primaryKey: 'id',
22
+ autoIncrement: true,
23
+
24
+ traits: {
25
+ useUuid: true,
26
+ useTimestamps: true,
27
+ useSeeder: { count: 0 },
28
+ },
29
+
30
+ belongsTo: ['Driver', 'DeliveryRoute'],
31
+
32
+ attributes: {
33
+ latitude: {
34
+ order: 1,
35
+ required: true,
36
+ fillable: true,
37
+ validation: {
38
+ rule: schema.number().required().min(-90).max(90),
39
+ message: {
40
+ min: 'Latitude must be between -90 and 90',
41
+ max: 'Latitude must be between -90 and 90',
42
+ },
43
+ },
44
+ factory: faker => faker.location.latitude(),
45
+ },
46
+
47
+ longitude: {
48
+ order: 2,
49
+ required: true,
50
+ fillable: true,
51
+ validation: {
52
+ rule: schema.number().required().min(-180).max(180),
53
+ message: {
54
+ min: 'Longitude must be between -180 and 180',
55
+ max: 'Longitude must be between -180 and 180',
56
+ },
57
+ },
58
+ factory: faker => faker.location.longitude(),
59
+ },
60
+
61
+ /** Degrees clockwise from true north. */
62
+ heading: {
63
+ order: 3,
64
+ fillable: true,
65
+ validation: { rule: schema.number().min(0).max(360) },
66
+ factory: faker => faker.number.int({ min: 0, max: 359 }),
67
+ },
68
+
69
+ /** Metres per second. */
70
+ speed: {
71
+ order: 4,
72
+ fillable: true,
73
+ default: 0,
74
+ validation: { rule: schema.number().min(0) },
75
+ factory: faker => faker.number.int({ min: 0, max: 30 }),
76
+ },
77
+
78
+ /**
79
+ * Reported horizontal accuracy in metres. Worth keeping: a fix with 800m
80
+ * of error should move the marker differently than one with 5m, and
81
+ * without this the map cannot tell them apart.
82
+ */
83
+ accuracy: {
84
+ order: 5,
85
+ fillable: true,
86
+ validation: { rule: schema.number().min(0) },
87
+ factory: faker => faker.number.int({ min: 3, max: 60 }),
88
+ },
89
+
90
+ /**
91
+ * When the device took the fix, which is not when the server received it.
92
+ * A driver through a tunnel sends five fixes at once on the far side, and
93
+ * ordering them by arrival draws a path that never happened.
94
+ */
95
+ recordedAt: {
96
+ order: 6,
97
+ required: true,
98
+ fillable: true,
99
+ validation: { rule: schema.timestamp().required() },
100
+ factory: () => new Date().toISOString(),
101
+ },
102
+ },
103
+ } as const)
@@ -29,7 +29,7 @@ export default defineModel({
29
29
  observe: true,
30
30
  },
31
31
 
32
- hasMany: ['OrderItem', 'Payment', 'LicenseKey'],
32
+ hasMany: ['OrderItem', 'Payment', 'LicenseKey', 'DeliveryStop'],
33
33
  belongsTo: ['Customer', 'Coupon'],
34
34
 
35
35
  attributes: {
@@ -39,7 +39,18 @@ export default defineModel({
39
39
  validation: {
40
40
  rule: schema.string().required(),
41
41
  },
42
- factory: faker => faker.helpers.arrayElement(['PENDING', 'PREPARING', 'READY', 'DELIVERED', 'CANCELED']),
42
+ /*
43
+ * The canonical vocabulary is `OrderStatus` in
44
+ * `commerce/src/orders/events.ts`, which is what `canTransition` and
45
+ * `emitForStatus` are keyed on. The factory used to generate
46
+ * PREPARING / READY / CANCELED, none of which are in that union, so
47
+ * seeded orders could not legally transition anywhere.
48
+ *
49
+ * The column stays a free string rather than an enum: existing
50
+ * databases hold the old spellings, and adding a CHECK constraint here
51
+ * would fail their next migration rather than fix their data.
52
+ */
53
+ factory: faker => faker.helpers.arrayElement(['PENDING', 'PROCESSING', 'SHIPPED', 'OUT_FOR_DELIVERY', 'DELIVERED']),
43
54
  },
44
55
 
45
56
  totalAmount: {
@@ -141,8 +152,38 @@ export default defineModel({
141
152
  },
142
153
  },
143
154
 
144
- appliedCouponId: {
155
+ /**
156
+ * Unguessable handle for the customer-facing tracking page.
157
+ *
158
+ * A tracking URL is opened from an SMS, on a phone, by someone who is not
159
+ * signed in, so it authorises on possession of this token. Sequential
160
+ * order ids would let anyone walk the table.
161
+ */
162
+ trackingToken: {
145
163
  order: 13,
164
+ unique: true,
165
+ fillable: true,
166
+ validation: { rule: schema.string().max(64) },
167
+ factory: faker => faker.string.alphanumeric({ length: 32 }),
168
+ },
169
+
170
+ /** Geocoded delivery destination, so the map has somewhere to point. */
171
+ deliveryLatitude: {
172
+ order: 14,
173
+ fillable: true,
174
+ validation: { rule: schema.number().min(-90).max(90) },
175
+ factory: faker => faker.location.latitude(),
176
+ },
177
+
178
+ deliveryLongitude: {
179
+ order: 15,
180
+ fillable: true,
181
+ validation: { rule: schema.number().min(-180).max(180) },
182
+ factory: faker => faker.location.longitude(),
183
+ },
184
+
185
+ appliedCouponId: {
186
+ order: 16,
146
187
  fillable: true,
147
188
  validation: {
148
189
  rule: schema.string(),