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
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)