@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.
- package/ai/skills/stacks-auth/SKILL.md +1 -1
- package/ai/skills/stacks-development/SKILL.md +3 -3
- package/ai/skills/stacks-stx/SKILL.md +3 -3
- package/ai/skills/stacks-ui/SKILL.md +3 -3
- package/app/Actions/Auth/LoginAction.ts +8 -2
- package/app/Actions/Auth/RegisterAction.ts +3 -2
- package/app/Actions/Auth/SocialCallbackAction.ts +75 -0
- package/app/Actions/Auth/SocialRedirectAction.ts +37 -0
- package/app/Actions/Dashboard/Analytics/request-analytics.ts +6 -1
- package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +7 -0
- package/app/Actions/Dashboard/Commerce/commerce-product-records.ts +3 -1
- package/app/Actions/Dashboard/Commerce/commerce-record.ts +13 -0
- package/app/Actions/Dashboard/Infrastructure/InsightsAction.ts +3 -3
- package/app/Actions/Dashboard/Jobs/job-records.ts +5 -1
- package/app/Actions/Dashboard/Kanban/BoardShowAction.ts +9 -7
- package/app/Actions/Dashboard/Kanban/BoardStoreAction.ts +1 -1
- package/app/Actions/Dashboard/Kanban/BoardsIndexAction.ts +6 -4
- package/app/Actions/Dashboard/Kanban/CardShowAction.ts +4 -2
- package/app/Actions/Dashboard/Library/GetAverageReleaseTime.ts +1 -1
- package/app/Actions/Dashboard/Marketing/CampaignIndexAction.ts +2 -2
- package/app/Actions/Dashboard/Marketing/ListIndexAction.ts +1 -1
- package/app/Actions/Dashboard/Queries/query-dashboard.ts +5 -3
- package/app/Actions/Password/PasswordResetAction.ts +29 -7
- package/app/Middleware/Auth.ts +4 -2
- package/app/Middleware/Can.ts +58 -5
- package/app/Models/SocialAccount.ts +70 -0
- package/app/Models/User.ts +7 -4
- package/app/Models/commerce/DeliveryRoute.ts +34 -0
- package/app/Models/commerce/DeliveryStop.ts +166 -0
- package/app/Models/commerce/Driver.ts +72 -2
- package/app/Models/commerce/DriverPing.ts +103 -0
- package/app/Models/commerce/Order.ts +44 -3
- package/app/password-policy.ts +46 -0
- package/bootstrap.ts +67 -19
- package/ide/vscode/package.json +1 -1
- package/package.json +1 -1
- package/routes/auth.ts +83 -0
- package/routes/dashboard.ts +0 -61
- package/routes/socials.ts +32 -0
- package/vcs/github/CONTRIBUTING.md +1 -1
- package/vcs/github/workflows/README.md +8 -9
- package/vcs/github/workflows/release.yml +4 -181
- package/vcs/github/workflows/export-size.yml +0 -25
package/app/Middleware/Can.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
53
|
-
|
|
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
|
+
})
|
package/app/Models/User.ts
CHANGED
|
@@ -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(
|
|
102
|
+
rule: schema.string().required().min(PASSWORD_MIN_LENGTH).max(PASSWORD_MAX_LENGTH),
|
|
102
103
|
message: {
|
|
103
104
|
required: 'Password is required',
|
|
104
|
-
min:
|
|
105
|
-
max:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(),
|