also looking at this
Promote: give the subscribe action a model it can call
#18
2 files
+144
-0
| @@ -0,0 +1,89 @@ | ||
| 1 | import { defineModel } from '@stacksjs/orm' | |
| 2 | import { schema } from '@stacksjs/validation' | |
| 3 | ||
| 4 | export 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) | |
| @@ -0,0 +1,55 @@ | ||
| 1 | import { defineModel } from '@stacksjs/orm' | |
| 2 | import { schema } from '@stacksjs/validation' | |
| 3 | ||
| 4 | export 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) | |