@stacksjs/defaults 0.70.379 → 0.71.1

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 (50) hide show
  1. package/app/Actions/Auth/MagicLinkConsumeAction.ts +53 -0
  2. package/app/Actions/Auth/MagicLinkSendAction.ts +35 -0
  3. package/app/Actions/Cms/SitemapAction.ts +23 -2
  4. package/app/Jobs/PublishScheduledPagesJob.ts +26 -0
  5. package/app/Middleware/Site.ts +14 -0
  6. package/app/Middleware.ts +1 -0
  7. package/app/Models/Automation.ts +23 -0
  8. package/app/Models/AutomationRun.ts +26 -0
  9. package/app/Models/Campaign.ts +67 -3
  10. package/app/Models/CampaignSend.ts +90 -6
  11. package/app/Models/CampaignVariant.ts +25 -0
  12. package/app/Models/CommunicationSuppression.ts +22 -0
  13. package/app/Models/ConsentEvent.ts +26 -0
  14. package/app/Models/Content/Menu.ts +56 -0
  15. package/app/Models/Content/MenuItem.ts +91 -0
  16. package/app/Models/Content/Page.ts +111 -14
  17. package/app/Models/Content/PageRevision.ts +86 -0
  18. package/app/Models/Content/Post.ts +24 -9
  19. package/app/Models/Content/Redirect.ts +80 -0
  20. package/app/Models/Forms/Form.ts +91 -0
  21. package/app/Models/Forms/FormField.ts +127 -0
  22. package/app/Models/Forms/FormSubmission.ts +114 -0
  23. package/app/Models/MagicLinkToken.ts +97 -0
  24. package/app/Models/SenderDomain.ts +22 -0
  25. package/app/Models/Site.ts +112 -0
  26. package/app/Models/SiteDomain.ts +74 -0
  27. package/app/Models/SmsOptOut.ts +65 -0
  28. package/app/Models/UsageEvent.ts +24 -0
  29. package/app/Models/commerce/Auction.ts +173 -0
  30. package/app/Models/commerce/AuctionItem.ts +204 -0
  31. package/app/Models/commerce/Bid.ts +129 -0
  32. package/app/Models/commerce/Pledge.ts +112 -0
  33. package/bootstrap.ts +7 -0
  34. package/functions/public-application-url.ts +1 -1
  35. package/ide/vscode/package.json +1 -1
  36. package/package.json +2 -2
  37. package/resources/functions/dashboard/sidebar.ts +109 -3
  38. package/resources/functions/dashboard/toggles.ts +90 -2
  39. package/resources/views/cms/blocks/columns.stx +10 -0
  40. package/resources/views/cms/blocks/cta.stx +8 -0
  41. package/resources/views/cms/blocks/embed.stx +14 -0
  42. package/resources/views/cms/blocks/form.stx +132 -0
  43. package/resources/views/cms/blocks/hero.stx +16 -0
  44. package/resources/views/cms/blocks/image.stx +7 -0
  45. package/resources/views/cms/blocks/rich-text.stx +4 -0
  46. package/resources/views/cms/page.stx +28 -0
  47. package/routes/auth.ts +7 -0
  48. package/routes/forms.ts +110 -0
  49. package/views/auth/magic/[token].stx +88 -0
  50. 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
@@ -14,6 +14,7 @@ export default {
14
14
  'guest': 'Guest',
15
15
  'api': 'Api',
16
16
  'team': 'Team',
17
+ 'site': 'Site',
17
18
  'logger': 'Logger',
18
19
  'abilities': 'Abilities',
19
20
  'can': 'Can',
@@ -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)
@@ -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: true,
43
+ required: false,
36
44
  fillable: true,
37
45
  validation: {
38
46
  rule: schema.number(),
39
47
  },
40
- factory: faker => faker.number.int({ min: 1, max: 100 }),
48
+ factory: () => null,
41
49
  },
42
50
 
43
51
  emailListId: {
44
- required: true,
52
+ required: false,
45
53
  fillable: true,
46
54
  validation: {
47
55
  rule: schema.number(),
48
56
  },
49
- factory: faker => faker.number.int({ min: 1, max: 8 }),
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(['queued', 'sent', 'failed', 'bounced', 'complained']),
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)