@stacksjs/defaults 0.70.380 → 0.71.2
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/app/Actions/Auth/MagicLinkConsumeAction.ts +53 -0
- package/app/Actions/Auth/MagicLinkSendAction.ts +35 -0
- package/app/Actions/Cms/SitemapAction.ts +23 -2
- package/app/Jobs/PublishScheduledPagesJob.ts +26 -0
- package/app/Middleware/Site.ts +14 -0
- package/app/Middleware.ts +1 -0
- package/app/Models/Automation.ts +23 -0
- package/app/Models/AutomationRun.ts +26 -0
- package/app/Models/Campaign.ts +67 -3
- package/app/Models/CampaignSend.ts +90 -6
- package/app/Models/CampaignVariant.ts +25 -0
- package/app/Models/CommunicationSuppression.ts +22 -0
- package/app/Models/ConsentEvent.ts +26 -0
- package/app/Models/Content/Menu.ts +56 -0
- package/app/Models/Content/MenuItem.ts +91 -0
- package/app/Models/Content/Page.ts +111 -14
- package/app/Models/Content/PageRevision.ts +86 -0
- package/app/Models/Content/Post.ts +24 -9
- package/app/Models/Content/Redirect.ts +80 -0
- package/app/Models/Forms/Form.ts +91 -0
- package/app/Models/Forms/FormField.ts +127 -0
- package/app/Models/Forms/FormSubmission.ts +114 -0
- package/app/Models/MagicLinkToken.ts +97 -0
- package/app/Models/SenderDomain.ts +22 -0
- package/app/Models/Site.ts +112 -0
- package/app/Models/SiteDomain.ts +74 -0
- package/app/Models/SmsOptOut.ts +65 -0
- package/app/Models/UsageEvent.ts +24 -0
- package/app/Models/commerce/Auction.ts +173 -0
- package/app/Models/commerce/AuctionItem.ts +204 -0
- package/app/Models/commerce/Bid.ts +129 -0
- package/app/Models/commerce/Pledge.ts +112 -0
- package/bootstrap.ts +7 -0
- package/functions/public-application-url.ts +1 -1
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/resources/functions/dashboard/sidebar.ts +109 -3
- package/resources/functions/dashboard/toggles.ts +90 -2
- package/resources/views/cms/blocks/columns.stx +10 -0
- package/resources/views/cms/blocks/cta.stx +8 -0
- package/resources/views/cms/blocks/embed.stx +14 -0
- package/resources/views/cms/blocks/form.stx +132 -0
- package/resources/views/cms/blocks/hero.stx +16 -0
- package/resources/views/cms/blocks/image.stx +7 -0
- package/resources/views/cms/blocks/rich-text.stx +4 -0
- package/resources/views/cms/page.stx +28 -0
- package/routes/auth.ts +7 -0
- package/routes/forms.ts +110 -0
- package/views/auth/magic/[token].stx +88 -0
- package/views/dashboard/.discovered-models.json +46 -1
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { defineModel } from '@stacksjs/orm'
|
|
2
|
+
import { schema } from '@stacksjs/validation'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One field of a Form. `name` is the machine key submissions store values
|
|
6
|
+
* under; `conditions` is a show/hide rule set evaluated client-side for UX
|
|
7
|
+
* and re-evaluated server-side for correctness (a hidden required field is
|
|
8
|
+
* not required, and values for hidden fields are discarded).
|
|
9
|
+
*/
|
|
10
|
+
export default defineModel({
|
|
11
|
+
name: 'FormField',
|
|
12
|
+
table: 'form_fields',
|
|
13
|
+
primaryKey: 'id',
|
|
14
|
+
autoIncrement: true,
|
|
15
|
+
|
|
16
|
+
indexes: [
|
|
17
|
+
{
|
|
18
|
+
name: 'form_fields_form_name_unique',
|
|
19
|
+
columns: ['form_id', 'name'],
|
|
20
|
+
unique: true,
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
|
|
24
|
+
traits: {
|
|
25
|
+
useTimestamps: true,
|
|
26
|
+
useApi: {
|
|
27
|
+
middleware: ['auth'],
|
|
28
|
+
uri: 'form-fields',
|
|
29
|
+
routes: ['index', 'store', 'show', 'update', 'destroy'],
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
belongsTo: ['Form'],
|
|
34
|
+
|
|
35
|
+
attributes: {
|
|
36
|
+
name: {
|
|
37
|
+
required: true,
|
|
38
|
+
order: 1,
|
|
39
|
+
fillable: true,
|
|
40
|
+
validation: {
|
|
41
|
+
rule: schema.string().min(1).max(64).matches(/^[a-z0-9_]+$/),
|
|
42
|
+
},
|
|
43
|
+
factory: faker => faker.lorem.word(),
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
label: {
|
|
47
|
+
required: true,
|
|
48
|
+
order: 2,
|
|
49
|
+
fillable: true,
|
|
50
|
+
validation: {
|
|
51
|
+
rule: schema.string().min(1).max(255),
|
|
52
|
+
},
|
|
53
|
+
factory: faker => faker.lorem.words(2),
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
type: {
|
|
57
|
+
required: true,
|
|
58
|
+
order: 3,
|
|
59
|
+
fillable: true,
|
|
60
|
+
validation: {
|
|
61
|
+
rule: schema.enum(['text', 'textarea', 'email', 'phone', 'select', 'checkbox', 'radio', 'date', 'file', 'currency', 'section_break'] as const),
|
|
62
|
+
},
|
|
63
|
+
factory: faker => faker.helpers.arrayElement(['text', 'email', 'select']),
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
required: {
|
|
67
|
+
required: false,
|
|
68
|
+
order: 4,
|
|
69
|
+
fillable: true,
|
|
70
|
+
default: false,
|
|
71
|
+
validation: {
|
|
72
|
+
rule: schema.boolean(),
|
|
73
|
+
},
|
|
74
|
+
factory: faker => faker.datatype.boolean(),
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
position: {
|
|
78
|
+
required: false,
|
|
79
|
+
order: 5,
|
|
80
|
+
fillable: true,
|
|
81
|
+
default: 0,
|
|
82
|
+
validation: {
|
|
83
|
+
rule: schema.number().min(0),
|
|
84
|
+
},
|
|
85
|
+
factory: faker => faker.number.int({ min: 0, max: 20 }),
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
width: {
|
|
89
|
+
required: false,
|
|
90
|
+
order: 6,
|
|
91
|
+
fillable: true,
|
|
92
|
+
default: 'full',
|
|
93
|
+
validation: {
|
|
94
|
+
rule: schema.enum(['full', 'half'] as const),
|
|
95
|
+
},
|
|
96
|
+
factory: () => 'full',
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Per-type options JSON: { placeholder?, choices?: {label,value}[],
|
|
101
|
+
* min?, max?, accept?: string[], maxSizeMb?, amountCents? }
|
|
102
|
+
*/
|
|
103
|
+
options: {
|
|
104
|
+
required: false,
|
|
105
|
+
order: 7,
|
|
106
|
+
fillable: true,
|
|
107
|
+
validation: {
|
|
108
|
+
rule: schema.json(),
|
|
109
|
+
},
|
|
110
|
+
factory: () => JSON.stringify({}),
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Visibility rules JSON: { action: 'show'|'hide', logic: 'all'|'any',
|
|
115
|
+
* rules: [{ field, op, value? }] }
|
|
116
|
+
*/
|
|
117
|
+
conditions: {
|
|
118
|
+
required: false,
|
|
119
|
+
order: 8,
|
|
120
|
+
fillable: true,
|
|
121
|
+
validation: {
|
|
122
|
+
rule: schema.json(),
|
|
123
|
+
},
|
|
124
|
+
factory: () => null,
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
} as const)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { defineModel } from '@stacksjs/orm'
|
|
2
|
+
import { schema } from '@stacksjs/validation'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One submission of a Form. `values` is the validated field map; `email` and
|
|
6
|
+
* `name` are extracted into typed columns at submit time (from the fields the
|
|
7
|
+
* form's settings name, defaulting to the first email field) so lookups,
|
|
8
|
+
* dedupe and confirmation addressing never need JSON extraction SQL.
|
|
9
|
+
*
|
|
10
|
+
* No `useApi`: public writes and admin reads both go through the
|
|
11
|
+
* `@stacksjs/forms` routes, which own spam guards, payment states and
|
|
12
|
+
* site scoping.
|
|
13
|
+
*/
|
|
14
|
+
export default defineModel({
|
|
15
|
+
name: 'FormSubmission',
|
|
16
|
+
table: 'form_submissions',
|
|
17
|
+
primaryKey: 'id',
|
|
18
|
+
autoIncrement: true,
|
|
19
|
+
|
|
20
|
+
traits: {
|
|
21
|
+
useUuid: true,
|
|
22
|
+
useTimestamps: true,
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
belongsTo: ['Form', 'Site'],
|
|
26
|
+
|
|
27
|
+
attributes: {
|
|
28
|
+
/** Validated { [fieldName]: value } JSON. Named data, not values - values is a SQL keyword. */
|
|
29
|
+
data: {
|
|
30
|
+
required: true,
|
|
31
|
+
order: 1,
|
|
32
|
+
fillable: true,
|
|
33
|
+
validation: {
|
|
34
|
+
rule: schema.json(),
|
|
35
|
+
},
|
|
36
|
+
factory: () => JSON.stringify({}),
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
email: {
|
|
40
|
+
required: false,
|
|
41
|
+
order: 2,
|
|
42
|
+
fillable: true,
|
|
43
|
+
validation: {
|
|
44
|
+
rule: schema.string().email().max(255),
|
|
45
|
+
},
|
|
46
|
+
factory: faker => faker.internet.email(),
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
name: {
|
|
50
|
+
required: false,
|
|
51
|
+
order: 3,
|
|
52
|
+
fillable: true,
|
|
53
|
+
validation: {
|
|
54
|
+
rule: schema.string().max(255),
|
|
55
|
+
},
|
|
56
|
+
factory: faker => faker.person.fullName(),
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
status: {
|
|
60
|
+
required: false,
|
|
61
|
+
order: 4,
|
|
62
|
+
fillable: true,
|
|
63
|
+
default: 'complete',
|
|
64
|
+
validation: {
|
|
65
|
+
rule: schema.enum(['pending_payment', 'complete', 'spam'] as const),
|
|
66
|
+
},
|
|
67
|
+
factory: () => 'complete',
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
/** Server-computed. Never trusted from the client. */
|
|
71
|
+
amountCents: {
|
|
72
|
+
required: false,
|
|
73
|
+
order: 5,
|
|
74
|
+
fillable: true,
|
|
75
|
+
validation: {
|
|
76
|
+
rule: schema.number().min(0),
|
|
77
|
+
},
|
|
78
|
+
factory: () => null,
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
paymentIntentId: {
|
|
82
|
+
required: false,
|
|
83
|
+
order: 6,
|
|
84
|
+
fillable: true,
|
|
85
|
+
validation: {
|
|
86
|
+
rule: schema.string().max(255),
|
|
87
|
+
},
|
|
88
|
+
factory: () => null,
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
ip: {
|
|
92
|
+
required: false,
|
|
93
|
+
order: 7,
|
|
94
|
+
fillable: true,
|
|
95
|
+
validation: {
|
|
96
|
+
rule: schema.string().max(64),
|
|
97
|
+
},
|
|
98
|
+
factory: () => null,
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
submittedAt: {
|
|
102
|
+
required: false,
|
|
103
|
+
order: 8,
|
|
104
|
+
fillable: true,
|
|
105
|
+
validation: {
|
|
106
|
+
rule: schema.timestamp(),
|
|
107
|
+
},
|
|
108
|
+
factory: (faker) => {
|
|
109
|
+
const date = faker.date.recent()
|
|
110
|
+
return date.toISOString().slice(0, 19).replace('T', ' ')
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
} as const)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { defineModel } from '@stacksjs/orm'
|
|
2
|
+
import { schema } from '@stacksjs/validation'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A single-use passwordless sign-in token. `token` stores the SHA-256 of the
|
|
6
|
+
* raw value - deterministic, so consume is one indexed lookup, and with 256
|
|
7
|
+
* bits of entropy in the raw token an offline attack on a fast hash is
|
|
8
|
+
* irrelevant (deliberately NOT the bcrypt-and-scan of password resets).
|
|
9
|
+
*
|
|
10
|
+
* No `useApi`: these rows are written and consumed by the auth flow only.
|
|
11
|
+
*/
|
|
12
|
+
export default defineModel({
|
|
13
|
+
name: 'MagicLinkToken',
|
|
14
|
+
table: 'magic_link_tokens',
|
|
15
|
+
primaryKey: 'id',
|
|
16
|
+
autoIncrement: true,
|
|
17
|
+
|
|
18
|
+
indexes: [
|
|
19
|
+
{
|
|
20
|
+
name: 'magic_link_tokens_token_unique',
|
|
21
|
+
columns: ['token'],
|
|
22
|
+
unique: true,
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
|
|
26
|
+
traits: {
|
|
27
|
+
useTimestamps: true,
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
belongsTo: ['User'],
|
|
31
|
+
|
|
32
|
+
attributes: {
|
|
33
|
+
email: {
|
|
34
|
+
required: true,
|
|
35
|
+
order: 1,
|
|
36
|
+
fillable: true,
|
|
37
|
+
validation: {
|
|
38
|
+
rule: schema.string().email().max(255),
|
|
39
|
+
},
|
|
40
|
+
factory: faker => faker.internet.email(),
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
/** SHA-256 hex of the raw token. Never the raw value. */
|
|
44
|
+
token: {
|
|
45
|
+
required: true,
|
|
46
|
+
order: 2,
|
|
47
|
+
fillable: true,
|
|
48
|
+
unique: true,
|
|
49
|
+
validation: {
|
|
50
|
+
rule: schema.string().min(64).max(64),
|
|
51
|
+
},
|
|
52
|
+
factory: faker => faker.string.hexadecimal({ length: 64, prefix: '' }).toLowerCase(),
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
expiresAt: {
|
|
56
|
+
required: true,
|
|
57
|
+
order: 3,
|
|
58
|
+
fillable: true,
|
|
59
|
+
validation: {
|
|
60
|
+
rule: schema.timestamp(),
|
|
61
|
+
},
|
|
62
|
+
factory: () => new Date(Date.now() + 15 * 60_000).toISOString().slice(0, 19).replace('T', ' '),
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
consumedAt: {
|
|
66
|
+
required: false,
|
|
67
|
+
order: 4,
|
|
68
|
+
fillable: true,
|
|
69
|
+
validation: {
|
|
70
|
+
rule: schema.timestamp(),
|
|
71
|
+
},
|
|
72
|
+
factory: () => null,
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
/** Relative path the consumed link lands on. Validated relative-only. */
|
|
76
|
+
redirectTo: {
|
|
77
|
+
required: false,
|
|
78
|
+
order: 5,
|
|
79
|
+
fillable: true,
|
|
80
|
+
validation: {
|
|
81
|
+
rule: schema.string().max(2048),
|
|
82
|
+
},
|
|
83
|
+
factory: () => null,
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
/** The site whose primary host the link targets, on multi-site apps. */
|
|
87
|
+
siteId: {
|
|
88
|
+
required: false,
|
|
89
|
+
order: 6,
|
|
90
|
+
fillable: true,
|
|
91
|
+
validation: {
|
|
92
|
+
rule: schema.number().min(1),
|
|
93
|
+
},
|
|
94
|
+
factory: () => null,
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
} as const)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { defineModel } from '@stacksjs/orm'
|
|
2
|
+
import { schema } from '@stacksjs/validation'
|
|
3
|
+
|
|
4
|
+
export default defineModel({
|
|
5
|
+
name: 'SenderDomain',
|
|
6
|
+
table: 'sender_domains',
|
|
7
|
+
belongsTo: ['Team'],
|
|
8
|
+
traits: {
|
|
9
|
+
useUuid: true,
|
|
10
|
+
useTimestamps: true,
|
|
11
|
+
useApi: { uri: 'sender-domains', routes: ['index', 'store', 'show', 'update', 'destroy'], middleware: ['auth'] },
|
|
12
|
+
},
|
|
13
|
+
indexes: [{ name: 'sender_domains_domain_unique', columns: ['domain'], unique: true }],
|
|
14
|
+
attributes: {
|
|
15
|
+
domain: { required: true, fillable: true, validation: { rule: schema.string().max(253) }, factory: faker => faker.internet.domainName() },
|
|
16
|
+
status: { required: true, fillable: true, default: 'pending', validation: { rule: schema.enum(['pending', 'verified', 'failed', 'disabled']) }, factory: () => 'pending' },
|
|
17
|
+
selector: { required: true, fillable: true, default: 'mail', validation: { rule: schema.string().max(63) }, factory: () => 'mail' },
|
|
18
|
+
dnsRecords: { required: false, fillable: true, validation: { rule: schema.json() }, factory: () => JSON.stringify([]) },
|
|
19
|
+
verifiedAt: { required: false, fillable: true, validation: { rule: schema.timestamp() }, factory: () => null },
|
|
20
|
+
lastCheckedAt: { required: false, fillable: true, validation: { rule: schema.timestamp() }, factory: () => null },
|
|
21
|
+
},
|
|
22
|
+
} as const)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { defineModel } from '@stacksjs/orm'
|
|
2
|
+
import { schema } from '@stacksjs/validation'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A public web property served by this app: one host (subdomain of
|
|
6
|
+
* `config.sites.baseDomain`, plus any verified custom domains via
|
|
7
|
+
* SiteDomain), one content tree, one theme. A team may own several - a main
|
|
8
|
+
* site, a microsite, a campaign landing page - each with its own domain.
|
|
9
|
+
*
|
|
10
|
+
* The `useApi` surface is the ADMIN surface (auth'd, team-scoped). Public
|
|
11
|
+
* visitors never read sites through it; the serving layer resolves the site
|
|
12
|
+
* from the Host header (`@stacksjs/sites`).
|
|
13
|
+
*/
|
|
14
|
+
export default defineModel({
|
|
15
|
+
name: 'Site',
|
|
16
|
+
table: 'sites',
|
|
17
|
+
primaryKey: 'id',
|
|
18
|
+
autoIncrement: true,
|
|
19
|
+
|
|
20
|
+
traits: {
|
|
21
|
+
useUuid: true,
|
|
22
|
+
useTimestamps: true,
|
|
23
|
+
observe: true, // site:updated clears the host-resolution cache
|
|
24
|
+
|
|
25
|
+
useSeeder: {
|
|
26
|
+
count: 2,
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
useApi: {
|
|
30
|
+
uri: 'sites',
|
|
31
|
+
routes: ['index', 'store', 'show', 'update', 'destroy'],
|
|
32
|
+
middleware: ['auth'],
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
useSearch: {
|
|
36
|
+
displayable: ['id', 'name', 'subdomain', 'status'],
|
|
37
|
+
searchable: ['name', 'subdomain'],
|
|
38
|
+
sortable: ['name', 'createdAt'],
|
|
39
|
+
filterable: ['status'],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
belongsTo: ['Team'],
|
|
44
|
+
hasMany: ['SiteDomain'],
|
|
45
|
+
|
|
46
|
+
attributes: {
|
|
47
|
+
name: {
|
|
48
|
+
required: true,
|
|
49
|
+
order: 1,
|
|
50
|
+
fillable: true,
|
|
51
|
+
validation: {
|
|
52
|
+
rule: schema.string().min(2).max(255),
|
|
53
|
+
message: {
|
|
54
|
+
min: 'Site name must have a minimum of 2 characters',
|
|
55
|
+
max: 'Site name must have a maximum of 255 characters',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
factory: faker => `${faker.company.name()} School`,
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
subdomain: {
|
|
62
|
+
required: true,
|
|
63
|
+
order: 2,
|
|
64
|
+
fillable: true,
|
|
65
|
+
unique: true,
|
|
66
|
+
validation: {
|
|
67
|
+
rule: schema.string().min(2).max(63).matches(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/),
|
|
68
|
+
message: {
|
|
69
|
+
matches: 'Subdomain may only contain lowercase letters, numbers and hyphens',
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
factory: faker => faker.internet.domainWord(),
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
status: {
|
|
76
|
+
required: true,
|
|
77
|
+
order: 3,
|
|
78
|
+
fillable: true,
|
|
79
|
+
default: 'active',
|
|
80
|
+
validation: {
|
|
81
|
+
rule: schema.enum(['active', 'suspended', 'archived'] as const),
|
|
82
|
+
},
|
|
83
|
+
factory: () => 'active',
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
/** Theme tokens, locale, analytics id - render-time knobs, JSON. */
|
|
87
|
+
settings: {
|
|
88
|
+
required: false,
|
|
89
|
+
order: 4,
|
|
90
|
+
fillable: true,
|
|
91
|
+
validation: {
|
|
92
|
+
rule: schema.json(),
|
|
93
|
+
},
|
|
94
|
+
factory: () => JSON.stringify({}),
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
timezone: {
|
|
98
|
+
required: false,
|
|
99
|
+
order: 5,
|
|
100
|
+
fillable: true,
|
|
101
|
+
default: 'America/New_York',
|
|
102
|
+
validation: {
|
|
103
|
+
rule: schema.string().max(64),
|
|
104
|
+
},
|
|
105
|
+
factory: () => 'America/New_York',
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
dashboard: {
|
|
110
|
+
highlight: true,
|
|
111
|
+
},
|
|
112
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { defineModel } from '@stacksjs/orm'
|
|
2
|
+
import { schema } from '@stacksjs/validation'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A custom domain routed to a Site. Only rows with `verifiedAt` set resolve
|
|
6
|
+
* traffic - verification proves the school controls the DNS before we serve
|
|
7
|
+
* their content on it. `isPrimary` names the canonical host: sessions are
|
|
8
|
+
* host-scoped cookies, so every other host 301s there and portal logins
|
|
9
|
+
* always land on one origin.
|
|
10
|
+
*/
|
|
11
|
+
export default defineModel({
|
|
12
|
+
name: 'SiteDomain',
|
|
13
|
+
table: 'site_domains',
|
|
14
|
+
primaryKey: 'id',
|
|
15
|
+
autoIncrement: true,
|
|
16
|
+
|
|
17
|
+
traits: {
|
|
18
|
+
useTimestamps: true,
|
|
19
|
+
observe: true, // domain changes clear the host-resolution cache
|
|
20
|
+
|
|
21
|
+
useApi: {
|
|
22
|
+
uri: 'site-domains',
|
|
23
|
+
routes: ['index', 'store', 'show', 'update', 'destroy'],
|
|
24
|
+
middleware: ['auth'],
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
belongsTo: ['Site'],
|
|
29
|
+
|
|
30
|
+
attributes: {
|
|
31
|
+
domain: {
|
|
32
|
+
required: true,
|
|
33
|
+
order: 1,
|
|
34
|
+
fillable: true,
|
|
35
|
+
unique: true,
|
|
36
|
+
validation: {
|
|
37
|
+
rule: schema.string().min(4).max(253),
|
|
38
|
+
},
|
|
39
|
+
factory: faker => faker.internet.domainName(),
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
isPrimary: {
|
|
43
|
+
required: false,
|
|
44
|
+
order: 2,
|
|
45
|
+
fillable: true,
|
|
46
|
+
default: false,
|
|
47
|
+
validation: {
|
|
48
|
+
rule: schema.boolean(),
|
|
49
|
+
},
|
|
50
|
+
factory: () => false,
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
verifiedAt: {
|
|
54
|
+
required: false,
|
|
55
|
+
order: 3,
|
|
56
|
+
fillable: true,
|
|
57
|
+
validation: {
|
|
58
|
+
rule: schema.timestamp(),
|
|
59
|
+
},
|
|
60
|
+
factory: () => null,
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
sslStatus: {
|
|
64
|
+
required: false,
|
|
65
|
+
order: 4,
|
|
66
|
+
fillable: true,
|
|
67
|
+
default: 'pending',
|
|
68
|
+
validation: {
|
|
69
|
+
rule: schema.enum(['pending', 'issued', 'failed'] as const),
|
|
70
|
+
},
|
|
71
|
+
factory: () => 'pending',
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { defineModel } from '@stacksjs/orm'
|
|
2
|
+
import { schema } from '@stacksjs/validation'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A phone number that texted STOP. Keyed by NUMBER, not user - the reply
|
|
6
|
+
* comes from a handset, and whoever holds it has spoken for it. Checked by
|
|
7
|
+
* `notify()`'s sms channel on every non-emergency send; written and cleared
|
|
8
|
+
* by `handleInboundSms()` (`@stacksjs/sms`).
|
|
9
|
+
*/
|
|
10
|
+
export default defineModel({
|
|
11
|
+
name: 'SmsOptOut',
|
|
12
|
+
table: 'sms_opt_outs',
|
|
13
|
+
primaryKey: 'id',
|
|
14
|
+
autoIncrement: true,
|
|
15
|
+
|
|
16
|
+
indexes: [
|
|
17
|
+
{
|
|
18
|
+
name: 'sms_opt_outs_phone_unique',
|
|
19
|
+
columns: ['phone'],
|
|
20
|
+
unique: true,
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
|
|
24
|
+
traits: {
|
|
25
|
+
useTimestamps: true,
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
attributes: {
|
|
29
|
+
/** E.164 where derivable (`+13105550199`). */
|
|
30
|
+
phone: {
|
|
31
|
+
required: true,
|
|
32
|
+
order: 1,
|
|
33
|
+
fillable: true,
|
|
34
|
+
unique: true,
|
|
35
|
+
validation: {
|
|
36
|
+
rule: schema.string().min(7).max(20),
|
|
37
|
+
},
|
|
38
|
+
factory: faker => faker.phone.number(),
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
reason: {
|
|
42
|
+
required: false,
|
|
43
|
+
order: 2,
|
|
44
|
+
fillable: true,
|
|
45
|
+
default: 'stop-keyword',
|
|
46
|
+
validation: {
|
|
47
|
+
rule: schema.string().max(64),
|
|
48
|
+
},
|
|
49
|
+
factory: () => 'stop-keyword',
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
optedOutAt: {
|
|
53
|
+
required: true,
|
|
54
|
+
order: 3,
|
|
55
|
+
fillable: true,
|
|
56
|
+
validation: {
|
|
57
|
+
rule: schema.timestamp(),
|
|
58
|
+
},
|
|
59
|
+
factory: (faker) => {
|
|
60
|
+
const date = faker.date.recent()
|
|
61
|
+
return date.toISOString().slice(0, 19).replace('T', ' ')
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
} as const)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { defineModel } from '@stacksjs/orm'
|
|
2
|
+
import { schema } from '@stacksjs/validation'
|
|
3
|
+
|
|
4
|
+
export default defineModel({
|
|
5
|
+
name: 'UsageEvent',
|
|
6
|
+
table: 'usage_events',
|
|
7
|
+
belongsTo: ['Team'],
|
|
8
|
+
traits: {
|
|
9
|
+
useUuid: true,
|
|
10
|
+
useTimestamps: true,
|
|
11
|
+
useApi: { uri: 'usage-events', routes: ['index', 'show'], middleware: ['auth'] },
|
|
12
|
+
},
|
|
13
|
+
indexes: [
|
|
14
|
+
{ name: 'usage_events_idempotency_unique', columns: ['idempotency_key'], unique: true },
|
|
15
|
+
{ name: 'usage_events_meter_period', columns: ['team_id', 'meter', 'occurred_at'] },
|
|
16
|
+
],
|
|
17
|
+
attributes: {
|
|
18
|
+
meter: { required: true, fillable: true, validation: { rule: schema.enum(['contacts', 'email_sends', 'sms_segments', 'ai_generations', 'storage_bytes']) }, factory: () => 'email_sends' },
|
|
19
|
+
quantity: { required: true, fillable: true, default: 1, validation: { rule: schema.number().min(0) }, factory: () => 1 },
|
|
20
|
+
idempotencyKey: { required: true, fillable: true, validation: { rule: schema.string().max(255) }, factory: faker => faker.string.uuid() },
|
|
21
|
+
metadata: { required: false, fillable: true, validation: { rule: schema.json() }, factory: () => JSON.stringify({}) },
|
|
22
|
+
occurredAt: { required: true, fillable: true, validation: { rule: schema.timestamp() }, factory: () => new Date().toISOString() },
|
|
23
|
+
},
|
|
24
|
+
} as const)
|