@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,53 @@
|
|
|
1
|
+
import { Action } from '@stacksjs/actions'
|
|
2
|
+
import { Auth, authCookie, consumeMagicLink } from '@stacksjs/auth'
|
|
3
|
+
import { config } from '@stacksjs/config'
|
|
4
|
+
import { response } from '@stacksjs/router'
|
|
5
|
+
import { schema } from '@stacksjs/validation'
|
|
6
|
+
|
|
7
|
+
export default new Action({
|
|
8
|
+
name: 'MagicLinkConsumeAction',
|
|
9
|
+
description: 'Consume a magic link and sign the user in',
|
|
10
|
+
method: 'POST',
|
|
11
|
+
|
|
12
|
+
validations: {
|
|
13
|
+
token: {
|
|
14
|
+
rule: schema.string().min(16).max(255),
|
|
15
|
+
message: 'Token is required.',
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
async handle(request: RequestInstance) {
|
|
20
|
+
if (!config.auth.magicLink?.enabled)
|
|
21
|
+
return response.notFound('Magic-link sign-in is not enabled')
|
|
22
|
+
|
|
23
|
+
const consumed = await consumeMagicLink(String(request.get('token')))
|
|
24
|
+
if (!consumed.ok) {
|
|
25
|
+
const messages: Record<string, string> = {
|
|
26
|
+
invalid: 'That sign-in link is not valid.',
|
|
27
|
+
expired: 'That sign-in link has expired. Request a new one.',
|
|
28
|
+
used: 'That sign-in link was already used. Request a new one.',
|
|
29
|
+
'no-user': 'That sign-in link is not valid.',
|
|
30
|
+
}
|
|
31
|
+
return response.unauthorized(messages[consumed.reason] ?? 'That sign-in link is not valid.')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The same token pack + httpOnly cookie a password login issues, so
|
|
35
|
+
// stxPageAuthMiddleware-gated pages treat passwordless users identically.
|
|
36
|
+
const result = await Auth.loginUsingId(consumed.userId)
|
|
37
|
+
if (!result)
|
|
38
|
+
return response.unauthorized('That sign-in link is not valid.')
|
|
39
|
+
|
|
40
|
+
return response.json({
|
|
41
|
+
access_token: result.token,
|
|
42
|
+
refresh_token: result.refreshToken,
|
|
43
|
+
token_type: 'Bearer',
|
|
44
|
+
expires_in: result.expiresIn,
|
|
45
|
+
redirect_to: consumed.redirectTo,
|
|
46
|
+
user: {
|
|
47
|
+
id: result.user?.id,
|
|
48
|
+
email: result.user?.email,
|
|
49
|
+
name: result.user?.name,
|
|
50
|
+
},
|
|
51
|
+
}, { headers: { 'Set-Cookie': authCookie(result.token) } })
|
|
52
|
+
},
|
|
53
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Action } from '@stacksjs/actions'
|
|
2
|
+
import { sendMagicLink } from '@stacksjs/auth'
|
|
3
|
+
import { config } from '@stacksjs/config'
|
|
4
|
+
import { response } from '@stacksjs/router'
|
|
5
|
+
import { schema } from '@stacksjs/validation'
|
|
6
|
+
|
|
7
|
+
export default new Action({
|
|
8
|
+
name: 'MagicLinkSendAction',
|
|
9
|
+
description: 'Email a passwordless sign-in link',
|
|
10
|
+
method: 'POST',
|
|
11
|
+
|
|
12
|
+
validations: {
|
|
13
|
+
email: {
|
|
14
|
+
rule: schema.string().email(),
|
|
15
|
+
message: 'Email must be a valid email address.',
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
async handle(request: RequestInstance) {
|
|
20
|
+
if (!config.auth.magicLink?.enabled)
|
|
21
|
+
return response.notFound('Magic-link sign-in is not enabled')
|
|
22
|
+
|
|
23
|
+
const email = request.get('email')
|
|
24
|
+
const redirectTo = request.get('redirect_to') as string | undefined
|
|
25
|
+
|
|
26
|
+
// Fire-and-return uniform: sendMagicLink is a silent no-op for unknown
|
|
27
|
+
// emails and self-rate-limits per address, so the response never says
|
|
28
|
+
// whether the account exists.
|
|
29
|
+
await sendMagicLink(String(email), { redirectTo })
|
|
30
|
+
|
|
31
|
+
return response.json({
|
|
32
|
+
message: 'If an account exists for that address, a sign-in link is on its way.',
|
|
33
|
+
}, { status: 202 })
|
|
34
|
+
},
|
|
35
|
+
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Action } from '@stacksjs/actions'
|
|
2
2
|
import { config } from '@stacksjs/config'
|
|
3
|
-
import { Category, Post } from '@stacksjs/orm'
|
|
3
|
+
import { Category, Page, Post } from '@stacksjs/orm'
|
|
4
4
|
import { response } from '@stacksjs/router'
|
|
5
5
|
|
|
6
6
|
export default new Action({
|
|
@@ -10,6 +10,7 @@ export default new Action({
|
|
|
10
10
|
async handle() {
|
|
11
11
|
const allPosts = await Post.where('status', '=', 'published').get()
|
|
12
12
|
const allCategories = await Category.all()
|
|
13
|
+
const allPages = await Page.where('status', '=', 'published').get()
|
|
13
14
|
const siteUrl = config.app.url || 'https://example.com'
|
|
14
15
|
|
|
15
16
|
const postUrls = allPosts
|
|
@@ -18,9 +19,10 @@ export default new Action({
|
|
|
18
19
|
? new Date(post.updated_at).toISOString().split('T')[0]
|
|
19
20
|
: new Date().toISOString().split('T')[0]
|
|
20
21
|
|
|
22
|
+
// Slug-first now that posts carry one; id keeps pre-slug rows reachable.
|
|
21
23
|
return `
|
|
22
24
|
<url>
|
|
23
|
-
<loc>${siteUrl}/blog/${post.id}</loc>
|
|
25
|
+
<loc>${siteUrl}/blog/${post.slug || post.id}</loc>
|
|
24
26
|
<lastmod>${lastmod}</lastmod>
|
|
25
27
|
<changefreq>weekly</changefreq>
|
|
26
28
|
<priority>0.8</priority>
|
|
@@ -28,6 +30,24 @@ export default new Action({
|
|
|
28
30
|
})
|
|
29
31
|
.join('\n')
|
|
30
32
|
|
|
33
|
+
// CMS pages: published block documents served at their materialized path.
|
|
34
|
+
const pageUrls = allPages
|
|
35
|
+
.filter(page => page.path && page.path !== '/')
|
|
36
|
+
.map((page) => {
|
|
37
|
+
const lastmod = page.updated_at
|
|
38
|
+
? new Date(page.updated_at).toISOString().split('T')[0]
|
|
39
|
+
: new Date().toISOString().split('T')[0]
|
|
40
|
+
|
|
41
|
+
return `
|
|
42
|
+
<url>
|
|
43
|
+
<loc>${siteUrl}${page.path}</loc>
|
|
44
|
+
<lastmod>${lastmod}</lastmod>
|
|
45
|
+
<changefreq>weekly</changefreq>
|
|
46
|
+
<priority>0.7</priority>
|
|
47
|
+
</url>`
|
|
48
|
+
})
|
|
49
|
+
.join('\n')
|
|
50
|
+
|
|
31
51
|
const categoryUrls = allCategories
|
|
32
52
|
.map((category) => {
|
|
33
53
|
return `
|
|
@@ -53,6 +73,7 @@ export default new Action({
|
|
|
53
73
|
</url>
|
|
54
74
|
${postUrls}
|
|
55
75
|
${categoryUrls}
|
|
76
|
+
${pageUrls}
|
|
56
77
|
</urlset>`
|
|
57
78
|
|
|
58
79
|
return response.xml(sitemap)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { publishDuePages } from '@stacksjs/cms'
|
|
2
|
+
import { log } from '@stacksjs/logging'
|
|
3
|
+
import { Job } from '@stacksjs/queue'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Flip due `scheduled` CMS pages to `published`.
|
|
7
|
+
*
|
|
8
|
+
* `status: 'scheduled'` + `scheduled_at` is a promise the editor made; this
|
|
9
|
+
* job is what keeps it. Every minute, cheap when nothing is due (one indexed
|
|
10
|
+
* SELECT). Runs via the standard scheduler registration - add it to
|
|
11
|
+
* `app/Scheduler.ts` (`schedule.job('PublishScheduledPages').everyMinute()`),
|
|
12
|
+
* or rely on an app's own registration conventions.
|
|
13
|
+
*/
|
|
14
|
+
export default new Job({
|
|
15
|
+
name: 'PublishScheduledPages',
|
|
16
|
+
description: 'Publish CMS pages whose scheduled time has arrived',
|
|
17
|
+
queue: 'default',
|
|
18
|
+
tries: 2,
|
|
19
|
+
backoff: [30],
|
|
20
|
+
|
|
21
|
+
async handle() {
|
|
22
|
+
const published = await publishDuePages()
|
|
23
|
+
if (published > 0)
|
|
24
|
+
log.info(`[cms] published ${published} scheduled page${published === 1 ? '' : 's'}`)
|
|
25
|
+
},
|
|
26
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { siteResolver } from '@stacksjs/sites'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Site Middleware
|
|
5
|
+
*
|
|
6
|
+
* Resolves the request's site from its Host header (`@stacksjs/sites`) and
|
|
7
|
+
* publishes it as `request.site` plus the ambient `currentSite()` context.
|
|
8
|
+
* A no-op while `config/sites.ts` is disabled. With `sites.strict`, an
|
|
9
|
+
* unknown host answers 404 here.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* route.group({ middleware: ['site'] }, () => { ... public tenant routes ... })
|
|
13
|
+
*/
|
|
14
|
+
export default siteResolver
|
package/app/Middleware.ts
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { defineModel } from '@stacksjs/orm'
|
|
2
|
+
import { schema } from '@stacksjs/validation'
|
|
3
|
+
|
|
4
|
+
export default defineModel({
|
|
5
|
+
name: 'Automation',
|
|
6
|
+
table: 'automations',
|
|
7
|
+
belongsTo: ['Team'],
|
|
8
|
+
hasMany: ['AutomationRun'],
|
|
9
|
+
traits: {
|
|
10
|
+
useUuid: true,
|
|
11
|
+
useTimestamps: true,
|
|
12
|
+
useApi: { uri: 'automations', routes: ['index', 'store', 'show', 'update', 'destroy'], middleware: ['auth'] },
|
|
13
|
+
observe: true,
|
|
14
|
+
},
|
|
15
|
+
attributes: {
|
|
16
|
+
name: { required: true, fillable: true, validation: { rule: schema.string().max(255) }, factory: faker => faker.lorem.words(3) },
|
|
17
|
+
status: { required: true, fillable: true, default: 'draft', validation: { rule: schema.enum(['draft', 'active', 'paused', 'archived']) }, factory: () => 'draft' },
|
|
18
|
+
version: { required: true, fillable: true, default: 1, validation: { rule: schema.number().min(1) }, factory: () => 1 },
|
|
19
|
+
trigger: { required: true, fillable: true, validation: { rule: schema.json() }, factory: () => JSON.stringify({ type: 'subscriber_joined' }) },
|
|
20
|
+
graph: { required: true, fillable: true, validation: { rule: schema.json() }, factory: () => JSON.stringify({ nodes: [], edges: [] }) },
|
|
21
|
+
publishedAt: { required: false, fillable: true, validation: { rule: schema.timestamp() }, factory: () => null },
|
|
22
|
+
},
|
|
23
|
+
} as const)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { defineModel } from '@stacksjs/orm'
|
|
2
|
+
import { schema } from '@stacksjs/validation'
|
|
3
|
+
|
|
4
|
+
export default defineModel({
|
|
5
|
+
name: 'AutomationRun',
|
|
6
|
+
table: 'automation_runs',
|
|
7
|
+
belongsTo: ['Team', 'Automation'],
|
|
8
|
+
traits: {
|
|
9
|
+
useUuid: true,
|
|
10
|
+
useTimestamps: true,
|
|
11
|
+
useApi: { uri: 'automation-runs', routes: ['index', 'show'], middleware: ['auth'] },
|
|
12
|
+
},
|
|
13
|
+
indexes: [{ name: 'automation_runs_idempotency_unique', columns: ['idempotency_key'], unique: true }],
|
|
14
|
+
attributes: {
|
|
15
|
+
status: { required: true, fillable: true, default: 'queued', validation: { rule: schema.enum(['queued', 'running', 'waiting', 'completed', 'failed', 'cancelled']) }, factory: () => 'queued' },
|
|
16
|
+
currentNodeId: { required: false, fillable: true, validation: { rule: schema.string().max(100) }, factory: () => null },
|
|
17
|
+
version: { required: true, fillable: true, default: 1, validation: { rule: schema.number().min(1) }, factory: () => 1 },
|
|
18
|
+
subjectType: { required: false, fillable: true, validation: { rule: schema.string().max(80) }, factory: () => 'contact' },
|
|
19
|
+
subjectId: { required: false, fillable: true, validation: { rule: schema.string().max(120) }, factory: () => null },
|
|
20
|
+
context: { required: true, fillable: true, validation: { rule: schema.json() }, factory: () => JSON.stringify({}) },
|
|
21
|
+
idempotencyKey: { required: true, fillable: true, validation: { rule: schema.string().max(255) }, factory: faker => faker.string.uuid() },
|
|
22
|
+
startedAt: { required: false, fillable: true, validation: { rule: schema.timestamp() }, factory: () => null },
|
|
23
|
+
finishedAt: { required: false, fillable: true, validation: { rule: schema.timestamp() }, factory: () => null },
|
|
24
|
+
error: { required: false, fillable: true, validation: { rule: schema.string() }, factory: () => null },
|
|
25
|
+
},
|
|
26
|
+
} as const)
|
package/app/Models/Campaign.ts
CHANGED
|
@@ -6,8 +6,8 @@ export default defineModel({
|
|
|
6
6
|
table: 'campaigns',
|
|
7
7
|
primaryKey: 'id',
|
|
8
8
|
autoIncrement: true,
|
|
9
|
-
belongsTo: ['EmailList'],
|
|
10
|
-
hasMany: ['CampaignSend'],
|
|
9
|
+
belongsTo: ['Team', 'EmailList'],
|
|
10
|
+
hasMany: ['CampaignSend', 'CampaignVariant'],
|
|
11
11
|
|
|
12
12
|
traits: {
|
|
13
13
|
useUuid: true,
|
|
@@ -24,7 +24,7 @@ export default defineModel({
|
|
|
24
24
|
displayable: ['id', 'name', 'type', 'status', 'subject', 'scheduledAt', 'sentAt'],
|
|
25
25
|
searchable: ['name', 'description', 'subject'],
|
|
26
26
|
sortable: ['name', 'type', 'status', 'scheduledAt', 'sentAt', 'createdAt', 'updatedAt'],
|
|
27
|
-
filterable: ['type', 'status', 'emailListId', 'currency'],
|
|
27
|
+
filterable: ['teamId', 'type', 'status', 'emailListId', 'currency'],
|
|
28
28
|
},
|
|
29
29
|
observe: true,
|
|
30
30
|
},
|
|
@@ -109,6 +109,33 @@ export default defineModel({
|
|
|
109
109
|
factory: faker => faker.lorem.paragraphs(2),
|
|
110
110
|
},
|
|
111
111
|
|
|
112
|
+
content: {
|
|
113
|
+
required: false,
|
|
114
|
+
fillable: true,
|
|
115
|
+
validation: {
|
|
116
|
+
rule: schema.json(),
|
|
117
|
+
},
|
|
118
|
+
factory: () => JSON.stringify([]),
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
channelSettings: {
|
|
122
|
+
required: false,
|
|
123
|
+
fillable: true,
|
|
124
|
+
validation: {
|
|
125
|
+
rule: schema.json(),
|
|
126
|
+
},
|
|
127
|
+
factory: () => JSON.stringify({}),
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
segmentDefinition: {
|
|
131
|
+
required: false,
|
|
132
|
+
fillable: true,
|
|
133
|
+
validation: {
|
|
134
|
+
rule: schema.json(),
|
|
135
|
+
},
|
|
136
|
+
factory: () => JSON.stringify({ operator: 'and', rules: [] }),
|
|
137
|
+
},
|
|
138
|
+
|
|
112
139
|
fromName: {
|
|
113
140
|
required: false,
|
|
114
141
|
fillable: true,
|
|
@@ -127,6 +154,43 @@ export default defineModel({
|
|
|
127
154
|
factory: faker => faker.internet.email(),
|
|
128
155
|
},
|
|
129
156
|
|
|
157
|
+
replyTo: {
|
|
158
|
+
required: false,
|
|
159
|
+
fillable: true,
|
|
160
|
+
validation: {
|
|
161
|
+
rule: schema.string().email().max(255),
|
|
162
|
+
},
|
|
163
|
+
factory: faker => faker.internet.email(),
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
timezone: {
|
|
167
|
+
required: true,
|
|
168
|
+
fillable: true,
|
|
169
|
+
default: 'UTC',
|
|
170
|
+
validation: {
|
|
171
|
+
rule: schema.string().max(100),
|
|
172
|
+
},
|
|
173
|
+
factory: () => 'UTC',
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
recurrence: {
|
|
177
|
+
required: false,
|
|
178
|
+
fillable: true,
|
|
179
|
+
validation: {
|
|
180
|
+
rule: schema.string().max(255),
|
|
181
|
+
},
|
|
182
|
+
factory: () => null,
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
experimentMetric: {
|
|
186
|
+
required: false,
|
|
187
|
+
fillable: true,
|
|
188
|
+
validation: {
|
|
189
|
+
rule: schema.enum(['open_rate', 'click_rate', 'conversion_rate']),
|
|
190
|
+
},
|
|
191
|
+
factory: () => null,
|
|
192
|
+
},
|
|
193
|
+
|
|
130
194
|
emailListId: {
|
|
131
195
|
required: false,
|
|
132
196
|
fillable: true,
|
|
@@ -6,7 +6,15 @@ export default defineModel({
|
|
|
6
6
|
table: 'campaign_sends',
|
|
7
7
|
primaryKey: 'id',
|
|
8
8
|
autoIncrement: true,
|
|
9
|
-
belongsTo: ['Campaign', 'Subscriber', 'EmailList'],
|
|
9
|
+
belongsTo: ['Team', 'Campaign', 'Subscriber', 'EmailList', 'CampaignVariant'],
|
|
10
|
+
|
|
11
|
+
indexes: [
|
|
12
|
+
{
|
|
13
|
+
name: 'campaign_sends_idempotency_unique',
|
|
14
|
+
columns: ['idempotency_key'],
|
|
15
|
+
unique: true,
|
|
16
|
+
},
|
|
17
|
+
],
|
|
10
18
|
|
|
11
19
|
traits: {
|
|
12
20
|
useUuid: true,
|
|
@@ -32,21 +40,21 @@ export default defineModel({
|
|
|
32
40
|
},
|
|
33
41
|
|
|
34
42
|
subscriberId: {
|
|
35
|
-
required:
|
|
43
|
+
required: false,
|
|
36
44
|
fillable: true,
|
|
37
45
|
validation: {
|
|
38
46
|
rule: schema.number(),
|
|
39
47
|
},
|
|
40
|
-
factory:
|
|
48
|
+
factory: () => null,
|
|
41
49
|
},
|
|
42
50
|
|
|
43
51
|
emailListId: {
|
|
44
|
-
required:
|
|
52
|
+
required: false,
|
|
45
53
|
fillable: true,
|
|
46
54
|
validation: {
|
|
47
55
|
rule: schema.number(),
|
|
48
56
|
},
|
|
49
|
-
factory:
|
|
57
|
+
factory: () => null,
|
|
50
58
|
},
|
|
51
59
|
|
|
52
60
|
status: {
|
|
@@ -54,11 +62,42 @@ export default defineModel({
|
|
|
54
62
|
fillable: true,
|
|
55
63
|
default: 'queued',
|
|
56
64
|
validation: {
|
|
57
|
-
rule: schema.enum([
|
|
65
|
+
rule: schema.enum([
|
|
66
|
+
'queued', 'deferred', 'sending', 'sent', 'delivered', 'failed',
|
|
67
|
+
'undelivered', 'bounced', 'complained', 'suppressed', 'cancelled',
|
|
68
|
+
]),
|
|
58
69
|
},
|
|
59
70
|
factory: faker => faker.helpers.arrayElement(['sent', 'sent', 'sent', 'queued', 'failed', 'bounced']),
|
|
60
71
|
},
|
|
61
72
|
|
|
73
|
+
channel: {
|
|
74
|
+
required: true,
|
|
75
|
+
fillable: true,
|
|
76
|
+
default: 'email',
|
|
77
|
+
validation: {
|
|
78
|
+
rule: schema.enum(['email', 'sms', 'push']),
|
|
79
|
+
},
|
|
80
|
+
factory: faker => faker.helpers.arrayElement(['email', 'sms']),
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
recipient: {
|
|
84
|
+
required: true,
|
|
85
|
+
fillable: true,
|
|
86
|
+
validation: {
|
|
87
|
+
rule: schema.string().max(255),
|
|
88
|
+
},
|
|
89
|
+
factory: faker => faker.internet.email(),
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
idempotencyKey: {
|
|
93
|
+
required: true,
|
|
94
|
+
fillable: true,
|
|
95
|
+
validation: {
|
|
96
|
+
rule: schema.string().max(255),
|
|
97
|
+
},
|
|
98
|
+
factory: faker => faker.string.uuid(),
|
|
99
|
+
},
|
|
100
|
+
|
|
62
101
|
providerMessageId: {
|
|
63
102
|
required: false,
|
|
64
103
|
fillable: true,
|
|
@@ -103,5 +142,50 @@ export default defineModel({
|
|
|
103
142
|
},
|
|
104
143
|
factory: () => null,
|
|
105
144
|
},
|
|
145
|
+
|
|
146
|
+
deliveredAt: {
|
|
147
|
+
required: false,
|
|
148
|
+
fillable: true,
|
|
149
|
+
validation: {
|
|
150
|
+
rule: schema.timestamp(),
|
|
151
|
+
},
|
|
152
|
+
factory: () => null,
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
failedAt: {
|
|
156
|
+
required: false,
|
|
157
|
+
fillable: true,
|
|
158
|
+
validation: {
|
|
159
|
+
rule: schema.timestamp(),
|
|
160
|
+
},
|
|
161
|
+
factory: () => null,
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
segments: {
|
|
165
|
+
required: false,
|
|
166
|
+
fillable: true,
|
|
167
|
+
validation: {
|
|
168
|
+
rule: schema.number().min(1),
|
|
169
|
+
},
|
|
170
|
+
factory: () => 1,
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
cost: {
|
|
174
|
+
required: false,
|
|
175
|
+
fillable: true,
|
|
176
|
+
validation: {
|
|
177
|
+
rule: schema.number().min(0),
|
|
178
|
+
},
|
|
179
|
+
factory: () => 0,
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
metadata: {
|
|
183
|
+
required: false,
|
|
184
|
+
fillable: true,
|
|
185
|
+
validation: {
|
|
186
|
+
rule: schema.json(),
|
|
187
|
+
},
|
|
188
|
+
factory: () => JSON.stringify({}),
|
|
189
|
+
},
|
|
106
190
|
},
|
|
107
191
|
} as const)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { defineModel } from '@stacksjs/orm'
|
|
2
|
+
import { schema } from '@stacksjs/validation'
|
|
3
|
+
|
|
4
|
+
export default defineModel({
|
|
5
|
+
name: 'CampaignVariant',
|
|
6
|
+
table: 'campaign_variants',
|
|
7
|
+
belongsTo: ['Team', 'Campaign'],
|
|
8
|
+
traits: {
|
|
9
|
+
useUuid: true,
|
|
10
|
+
useTimestamps: true,
|
|
11
|
+
useApi: { uri: 'campaign-variants', routes: ['index', 'store', 'show', 'update', 'destroy'], middleware: ['auth'] },
|
|
12
|
+
},
|
|
13
|
+
indexes: [{ name: 'campaign_variants_name_unique', columns: ['campaign_id', 'name'], unique: true }],
|
|
14
|
+
attributes: {
|
|
15
|
+
name: { required: true, fillable: true, validation: { rule: schema.string().max(80) }, factory: faker => faker.helpers.arrayElement(['Control', 'Variant B']) },
|
|
16
|
+
subject: { required: false, fillable: true, validation: { rule: schema.string().max(255) }, factory: faker => faker.lorem.sentence(6) },
|
|
17
|
+
content: { required: true, fillable: true, validation: { rule: schema.json() }, factory: () => JSON.stringify([]) },
|
|
18
|
+
allocation: { required: true, fillable: true, default: 50, validation: { rule: schema.number().min(0).max(100) }, factory: () => 50 },
|
|
19
|
+
sentCount: { required: true, fillable: true, default: 0, validation: { rule: schema.number().min(0) }, factory: () => 0 },
|
|
20
|
+
openCount: { required: true, fillable: true, default: 0, validation: { rule: schema.number().min(0) }, factory: () => 0 },
|
|
21
|
+
clickCount: { required: true, fillable: true, default: 0, validation: { rule: schema.number().min(0) }, factory: () => 0 },
|
|
22
|
+
conversionCount: { required: true, fillable: true, default: 0, validation: { rule: schema.number().min(0) }, factory: () => 0 },
|
|
23
|
+
isWinner: { required: true, fillable: true, default: false, validation: { rule: schema.boolean() }, factory: () => false },
|
|
24
|
+
},
|
|
25
|
+
} 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: 'CommunicationSuppression',
|
|
6
|
+
table: 'communication_suppressions',
|
|
7
|
+
belongsTo: ['Team'],
|
|
8
|
+
traits: {
|
|
9
|
+
useUuid: true,
|
|
10
|
+
useTimestamps: true,
|
|
11
|
+
useApi: { uri: 'communication-suppressions', routes: ['index', 'store', 'show', 'destroy'], middleware: ['auth'] },
|
|
12
|
+
},
|
|
13
|
+
indexes: [{ name: 'communication_suppressions_unique', columns: ['team_id', 'channel', 'recipient'], unique: true }],
|
|
14
|
+
attributes: {
|
|
15
|
+
recipient: { required: true, fillable: true, validation: { rule: schema.string().max(255) }, factory: faker => faker.internet.email() },
|
|
16
|
+
channel: { required: true, fillable: true, validation: { rule: schema.enum(['email', 'sms', 'push']) }, factory: () => 'email' },
|
|
17
|
+
reason: { required: true, fillable: true, validation: { rule: schema.enum(['unsubscribe', 'bounce', 'complaint', 'carrier', 'manual', 'legal']) }, factory: () => 'unsubscribe' },
|
|
18
|
+
source: { required: true, fillable: true, validation: { rule: schema.string().max(120) }, factory: () => 'preference_center' },
|
|
19
|
+
suppressedAt: { required: true, fillable: true, validation: { rule: schema.timestamp() }, factory: () => new Date().toISOString() },
|
|
20
|
+
liftedAt: { required: false, fillable: false, validation: { rule: schema.timestamp() }, factory: () => null },
|
|
21
|
+
},
|
|
22
|
+
} as const)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { defineModel } from '@stacksjs/orm'
|
|
2
|
+
import { schema } from '@stacksjs/validation'
|
|
3
|
+
|
|
4
|
+
export default defineModel({
|
|
5
|
+
name: 'ConsentEvent',
|
|
6
|
+
table: 'consent_events',
|
|
7
|
+
belongsTo: ['Team'],
|
|
8
|
+
traits: {
|
|
9
|
+
useUuid: true,
|
|
10
|
+
useTimestamps: true,
|
|
11
|
+
useApi: { uri: 'consent-events', routes: ['index', 'show'], middleware: ['auth'] },
|
|
12
|
+
},
|
|
13
|
+
indexes: [{ name: 'consent_events_lookup', columns: ['team_id', 'channel', 'recipient', 'occurred_at'] }],
|
|
14
|
+
attributes: {
|
|
15
|
+
recipient: { required: true, fillable: true, validation: { rule: schema.string().max(255) }, factory: faker => faker.internet.email() },
|
|
16
|
+
channel: { required: true, fillable: true, validation: { rule: schema.enum(['email', 'sms', 'push']) }, factory: () => 'email' },
|
|
17
|
+
action: { required: true, fillable: true, validation: { rule: schema.enum(['requested', 'granted', 'revoked', 'confirmed', 'suppressed']) }, factory: () => 'granted' },
|
|
18
|
+
purpose: { required: true, fillable: true, validation: { rule: schema.string().max(120) }, factory: () => 'marketing' },
|
|
19
|
+
source: { required: true, fillable: true, validation: { rule: schema.string().max(120) }, factory: () => 'signup_form' },
|
|
20
|
+
jurisdiction: { required: false, fillable: true, validation: { rule: schema.string().max(80) }, factory: () => null },
|
|
21
|
+
policyVersion: { required: true, fillable: true, validation: { rule: schema.string().max(40) }, factory: () => '1.0' },
|
|
22
|
+
proof: { required: false, fillable: true, validation: { rule: schema.json() }, factory: () => JSON.stringify({}) },
|
|
23
|
+
ipAddress: { required: false, fillable: true, validation: { rule: schema.string().max(45) }, factory: faker => faker.internet.ip() },
|
|
24
|
+
occurredAt: { required: true, fillable: true, validation: { rule: schema.timestamp() }, factory: () => new Date().toISOString() },
|
|
25
|
+
},
|
|
26
|
+
} as const)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { defineModel } from '@stacksjs/orm'
|
|
2
|
+
import { schema } from '@stacksjs/validation'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A named navigation slot on a site - `main`, `footer`, `portal`. The items
|
|
6
|
+
* are MenuItem rows; templates fetch the tree by handle
|
|
7
|
+
* (`fetchMenuTree(siteId, 'main')`) so navigation is content, not code.
|
|
8
|
+
*/
|
|
9
|
+
export default defineModel({
|
|
10
|
+
name: 'Menu',
|
|
11
|
+
table: 'menus',
|
|
12
|
+
primaryKey: 'id',
|
|
13
|
+
autoIncrement: true,
|
|
14
|
+
|
|
15
|
+
indexes: [
|
|
16
|
+
{
|
|
17
|
+
name: 'menus_site_handle_unique',
|
|
18
|
+
columns: ['site_id', 'handle'],
|
|
19
|
+
unique: true,
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
|
|
23
|
+
traits: {
|
|
24
|
+
useTimestamps: true,
|
|
25
|
+
useApi: {
|
|
26
|
+
middleware: ['auth'],
|
|
27
|
+
uri: 'menus',
|
|
28
|
+
routes: ['index', 'store', 'show', 'update', 'destroy'],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
belongsTo: ['Site'],
|
|
33
|
+
hasMany: ['MenuItem'],
|
|
34
|
+
|
|
35
|
+
attributes: {
|
|
36
|
+
handle: {
|
|
37
|
+
required: true,
|
|
38
|
+
order: 1,
|
|
39
|
+
fillable: true,
|
|
40
|
+
validation: {
|
|
41
|
+
rule: schema.string().min(2).max(64).matches(/^[a-z0-9-]+$/),
|
|
42
|
+
},
|
|
43
|
+
factory: faker => faker.helpers.arrayElement(['main', 'footer', 'portal']),
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
name: {
|
|
47
|
+
required: true,
|
|
48
|
+
order: 2,
|
|
49
|
+
fillable: true,
|
|
50
|
+
validation: {
|
|
51
|
+
rule: schema.string().min(2).max(255),
|
|
52
|
+
},
|
|
53
|
+
factory: faker => faker.lorem.words(2),
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
} as const)
|