ReviewOS

also looking at this

loghqorg/loghq

Promote: give the subscribe action a model it can call

#18
Merged glennmichael123 wants to merge main into production
2 files +144 -0

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

app/Models/Subscriber.tsadded+89-0
Changes to app/Models/Subscriber.ts
@@ -0,0 +1,89 @@
1import { defineModel } from '@stacksjs/orm'
2import { schema } from '@stacksjs/validation'
3
4export default defineModel({
5 name: 'Subscriber',
6 table: 'subscribers',
7 primaryKey: 'id',
8 autoIncrement: true,
9 belongsTo: ['User'],
10 hasMany: ['SubscriberEmail', 'EmailListSubscriber', 'CampaignSend'],
11
12 traits: {
13 useUuid: true,
14 useTimestamps: true,
15 useSeeder: {
16 count: 10,
17 },
18 useApi: {
19 uri: 'subscribers',
20 routes: ['index', 'store', 'show', 'update', 'destroy'],
21 // Subscriber rows are PII (email + status + source). Auth-gate
22 // every CRUD route. The public signup path is the dedicated
23 // `/api/email/subscribe` action — that has its own rate-limited,
24 // CSRF-skipped handler that wraps `Subscriber.create()`. Apps
25 // that want public destroy (one-click unsubscribe) should use
26 // the existing `/api/email/unsubscribe` UUID-token route, not
27 // expose `DELETE /api/subscribers/{id}`.
28 middleware: ['auth'],
29 },
30 },
31
32 attributes: {
33 email: {
34 unique: true,
35 required: true,
36 fillable: true,
37 validation: {
38 rule: schema.string().email().max(255),
39 message: {
40 string: 'email must be a string',
41 required: 'email is required',
42 email: 'email must be a valid email address',
43 max: 'email must have a maximum of 255 characters',
44 },
45 },
46 factory: faker => faker.internet.email(),
47 },
48
49 status: {
50 required: true,
51 fillable: true,
52 default: 'subscribed',
53 validation: {
54 rule: schema.enum(['subscribed', 'unsubscribed', 'pending', 'bounced']),
55 message: {
56 enum: 'status must be one of: subscribed, unsubscribed, pending, bounced',
57 },
58 },
59 factory: faker => faker.helpers.arrayElement(['subscribed', 'subscribed', 'subscribed', 'unsubscribed', 'pending']),
60 },
61
62 source: {
63 required: false,
64 fillable: true,
65 default: 'homepage',
66 validation: {
67 rule: schema.string().max(100),
68 message: {
69 string: 'source must be a string',
70 max: 'source must have a maximum of 100 characters',
71 },
72 },
73 factory: faker => faker.helpers.arrayElement(['homepage', 'blog', 'landing-page', 'api', 'import']),
74 },
75
76 // Set when the subscriber globally unsubscribes via the legacy
77 // /api/email/unsubscribe?token=<uuid> route. Per-list unsubscribe
78 // state (the modern flow) lives on `email_list_subscribers` so a
79 // user can drop "Weekly Digest" without losing transactional mail.
80 unsubscribedAt: {
81 required: false,
82 fillable: true,
83 validation: {
84 rule: schema.timestamp(),
85 },
86 factory: () => null,
87 },
88 },
89} as const)
app/Models/SubscriberEmail.tsadded+55-0
Changes to app/Models/SubscriberEmail.ts
@@ -0,0 +1,55 @@
1import { defineModel } from '@stacksjs/orm'
2import { schema } from '@stacksjs/validation'
3
4export default defineModel({
5 name: 'SubscriberEmail',
6 table: 'subscriber_emails',
7 primaryKey: 'id',
8 autoIncrement: true,
9 belongsTo: ['Subscriber'],
10
11 traits: {
12 useUuid: true,
13 useTimestamps: true,
14 useApi: {
15 uri: 'subscriber-emails',
16 routes: ['index', 'show'],
17 // SubscriberEmail rows are PII (email + signup source). Without
18 // this gate, anyone can scrape the list at GET /api/subscriber-emails.
19 // Apps that genuinely want a public list (e.g. a transparency
20 // page) can override the model in `app/Models/`.
21 middleware: ['auth'],
22 },
23 },
24
25 attributes: {
26 email: {
27 required: true,
28 fillable: true,
29 validation: {
30 rule: schema.string().email().max(255),
31 message: {
32 string: 'email must be a string',
33 required: 'email is required',
34 email: 'email must be a valid email address',
35 max: 'email must have a maximum of 255 characters',
36 },
37 },
38 factory: faker => faker.internet.email(),
39 },
40
41 source: {
42 required: false,
43 fillable: true,
44 default: 'homepage',
45 validation: {
46 rule: schema.string().max(100),
47 message: {
48 string: 'source must be a string',
49 max: 'source must have a maximum of 100 characters',
50 },
51 },
52 factory: faker => faker.helpers.arrayElement(['homepage', 'blog', 'landing-page', 'api', 'import']),
53 },
54 },
55} as const)