Skip to content

Commit eb0c2c9

Browse files
authored
Merge pull request #169 from constructive-io/feat/notifications-skill
docs: add standalone constructive-notifications skill
2 parents 95cac52 + b3af3aa commit eb0c2c9

7 files changed

Lines changed: 388 additions & 3 deletions

File tree

.agents/skills/constructive-features/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ When a feature is gated by a module, installing / omitting the module from a pre
218218
| Deterministic DB migrations | `pgpm deploy / verify / revert` || [`pgpm`](../pgpm/SKILL.md) |
219219
| Module provisioning | `metaschema_generators.provision_database_modules` || [`constructive-platform`](../constructive-blueprints/references/module-presets.md) |
220220
| DB introspection → SDK | `cnc codegen` || [`constructive-codegen`](../constructive-codegen/SKILL.md) |
221-
| Notifications (email/push/webhook) | `notifications_module` | `b2b`, `full` | [`constructive-platform`](../constructive-platform/SKILL.md) |
221+
| Notifications (email/push/webhook) | `notifications_module` | `b2b`, `full` | [`constructive-notifications`](../constructive-notifications/SKILL.md) |
222222
| Internationalization (multilingual search + translations) | `i18n_module` + `DataI18n` + `lang_column` | `full` | [`constructive-i18n`](../constructive-i18n/SKILL.md) |
223223

224224
## 14. Module Presets (starting points, not features)
6.39 KB
Binary file not shown.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
---
2+
name: constructive-notifications
3+
description: "Notification system — inbox, delivery, channels, preferences, bounce/complaint handling, suppression list, and digest batching. Use when asked to 'add notifications', 'notification preferences', 'bounce handling', 'complaint handling', 'suppression list', 'delivery log', 'notification channels', 'digest batching', 'quiet hours', 'webhook ingestion', 'SES bounces', 'SendGrid webhooks', 'channel deactivation', or when working with notifications_module in blueprints."
4+
metadata:
5+
author: constructive-io
6+
version: "1.0.0"
7+
---
8+
9+
# Constructive Notifications
10+
11+
Multi-channel notification system with inbox, delivery tracking, bounce/complaint handling, address suppression, and digest batching.
12+
13+
## When to Apply
14+
15+
Use this skill when:
16+
- Adding notifications to a Constructive app
17+
- Configuring notification channels (email, push, SMS, webhook)
18+
- Setting up bounce/complaint webhook handlers (SES, SendGrid, Twilio)
19+
- Working with the suppression list (blocking sends to hard-bounced/complained addresses)
20+
- Implementing notification preferences, quiet hours, or digest batching
21+
- Debugging delivery failures or channel deactivation
22+
- Understanding the notification table graph and delivery lifecycle
23+
24+
## Module Setup
25+
26+
Include `notifications_module` in your module list. The `full` preset includes it by default.
27+
28+
```ts
29+
import { getModulePreset } from '@constructive-io/node-type-registry';
30+
31+
// Option A: use a preset that includes it
32+
const preset = getModulePreset('full');
33+
34+
// Option B: add it explicitly
35+
const modules = [...baseModules, 'notifications_module'];
36+
```
37+
38+
### Feature Flags
39+
40+
Control which sub-features are generated (zero dead code when off):
41+
42+
| Flag | Default | What it gates |
43+
|------|---------|---------------|
44+
| `has_channels` | `true` | Device endpoints, delivery log, suppression list |
45+
| `has_preferences` | `true` | Per-user channel toggles by category/topic |
46+
| `has_settings_extension` | `false` | Quiet hours, digest frequency, master switch on user/org settings |
47+
| `has_digest_metadata` | `false` | Group collapsing, digest buckets, deliver_after scheduling |
48+
| `has_subscriptions` | `false` | *(reserved for future topic subscription table)* |
49+
50+
## Table Graph
51+
52+
```
53+
notifications_module (config)
54+
├── notifications (public, RLS: AuthzComposite)
55+
│ └── notification_read_state (public, RLS: AuthzDirectOwner)
56+
├── notification_preferences (public, RLS: AuthzDirectOwner) [has_preferences]
57+
├── notification_channels (public, RLS: AuthzDirectOwner) [has_channels]
58+
├── notification_delivery_log (private, no RLS) [has_channels]
59+
└── notification_suppressions (private, no RLS) [has_channels]
60+
```
61+
62+
**Public tables** are exposed via GraphQL with RLS. **Private tables** (delivery_log, suppressions) are internal — your delivery worker and webhook handlers interact with them directly via SQL.
63+
64+
## Delivery Status Lifecycle
65+
66+
```
67+
pending → sent → delivered
68+
→ bounced (async webhook, hard/soft bounce)
69+
→ complained (async webhook, recipient marked as spam)
70+
→ failed (immediate failure)
71+
→ throttled (rate-limited by provider)
72+
→ grouped (collapsed into digest)
73+
```
74+
75+
Key distinction: **bounced** (mailbox doesn't exist) and **complained** (recipient clicked "Report Spam") are different signals requiring different responses. Both create suppression entries, but complaint rates > 0.1% trigger ISP penalties.
76+
77+
## References
78+
79+
| File | Content |
80+
|------|---------|
81+
| [deliverability.md](./references/deliverability.md) | Webhook setup (SES/SendGrid/Twilio), suppression management, channel deactivation, monitoring queries |
82+
| [tables.md](./references/tables.md) | All 6 tables with columns, types, defaults, and descriptions |
83+
84+
## Cross-References
85+
86+
- **SQL-level generator internals:** `constructive-db-notifications` skill in constructive-db
87+
- **Realtime subscriptions (separate system):** [`constructive-realtime`](../constructive-realtime/SKILL.md)
88+
- **Background job triggers:** [`constructive-jobs`](../constructive-jobs/SKILL.md)
89+
- **Billing/limits for rate caps:** [`constructive-billing`](../constructive-billing/SKILL.md)
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
# Deliverability Guide
2+
3+
How to handle bounces, complaints, and address suppression in the Constructive notification system.
4+
5+
For SQL-level internals (generator source, index details), see the `constructive-db-notifications` skill in constructive-db.
6+
7+
## Setting Up Provider Webhooks
8+
9+
### Amazon SES
10+
11+
1. **Create an SNS topic** for bounce/complaint notifications:
12+
```bash
13+
aws sns create-topic --name ses-notifications
14+
```
15+
16+
2. **Subscribe your webhook endpoint** to the topic:
17+
```bash
18+
aws sns subscribe \
19+
--topic-arn arn:aws:sns:us-east-1:123456789:ses-notifications \
20+
--protocol https \
21+
--notification-endpoint https://api.yourapp.com/webhooks/ses
22+
```
23+
24+
3. **Configure SES to publish to the topic:**
25+
```bash
26+
aws ses set-identity-notification-topic \
27+
--identity your-domain.com \
28+
--notification-type Bounce \
29+
--sns-topic arn:aws:sns:us-east-1:123456789:ses-notifications
30+
31+
aws ses set-identity-notification-topic \
32+
--identity your-domain.com \
33+
--notification-type Complaint \
34+
--sns-topic arn:aws:sns:us-east-1:123456789:ses-notifications
35+
```
36+
37+
4. **Webhook handler pseudocode:**
38+
```ts
39+
async function handleSESWebhook(payload) {
40+
const message = JSON.parse(payload.Message);
41+
const messageId = message.mail.messageId;
42+
43+
// 1. Correlate via provider_message_id
44+
const logRow = await db.query(
45+
`SELECT * FROM notification_delivery_log WHERE provider_message_id = $1`,
46+
[messageId]
47+
);
48+
49+
// 2. Update delivery log status
50+
if (message.notificationType === 'Bounce') {
51+
await db.query(
52+
`UPDATE notification_delivery_log SET status = 'bounced', response_payload = $1 WHERE provider_message_id = $2`,
53+
[message, messageId]
54+
);
55+
56+
// 3. Suppress hard bounces
57+
if (message.bounce.bounceType === 'Permanent') {
58+
for (const recipient of message.bounce.bouncedRecipients) {
59+
await db.query(
60+
`INSERT INTO notification_suppressions (address, channel_type, reason, source, provider, metadata)
61+
VALUES ($1, 'email', 'hard_bounce', 'ses_webhook', 'ses', $2)
62+
ON CONFLICT (address, channel_type) DO NOTHING`,
63+
[recipient.emailAddress, message]
64+
);
65+
}
66+
}
67+
}
68+
69+
if (message.notificationType === 'Complaint') {
70+
await db.query(
71+
`UPDATE notification_delivery_log SET status = 'complained', response_payload = $1 WHERE provider_message_id = $2`,
72+
[message, messageId]
73+
);
74+
75+
for (const recipient of message.complaint.complainedRecipients) {
76+
await db.query(
77+
`INSERT INTO notification_suppressions (address, channel_type, reason, source, provider, metadata)
78+
VALUES ($1, 'email', 'complained', 'ses_webhook', 'ses', $2)
79+
ON CONFLICT (address, channel_type) DO NOTHING`,
80+
[recipient.emailAddress, message]
81+
);
82+
}
83+
}
84+
}
85+
```
86+
87+
### SendGrid
88+
89+
1. **Configure Event Webhook** in SendGrid dashboard → Settings → Mail Settings → Event Webhook.
90+
2. Set the POST URL to `https://api.yourapp.com/webhooks/sendgrid`.
91+
3. Enable: `bounce`, `dropped`, `spamreport`, `delivered`.
92+
93+
4. **Webhook handler pseudocode:**
94+
```ts
95+
async function handleSendGridWebhook(events) {
96+
for (const event of events) {
97+
const messageId = event.sg_message_id?.split('.')[0]; // strip filter suffix
98+
99+
if (event.event === 'bounce') {
100+
await db.query(
101+
`UPDATE notification_delivery_log SET status = 'bounced', response_payload = $1 WHERE provider_message_id = $2`,
102+
[event, messageId]
103+
);
104+
await db.query(
105+
`INSERT INTO notification_suppressions (address, channel_type, reason, source, provider, metadata)
106+
VALUES ($1, 'email', 'hard_bounce', 'sendgrid_webhook', 'sendgrid', $2)
107+
ON CONFLICT (address, channel_type) DO NOTHING`,
108+
[event.email, event]
109+
);
110+
}
111+
112+
if (event.event === 'spamreport') {
113+
await db.query(
114+
`UPDATE notification_delivery_log SET status = 'complained', response_payload = $1 WHERE provider_message_id = $2`,
115+
[event, messageId]
116+
);
117+
await db.query(
118+
`INSERT INTO notification_suppressions (address, channel_type, reason, source, provider, metadata)
119+
VALUES ($1, 'email', 'complained', 'sendgrid_webhook', 'sendgrid', $2)
120+
ON CONFLICT (address, channel_type) DO NOTHING`,
121+
[event.email, event]
122+
);
123+
}
124+
}
125+
}
126+
```
127+
128+
### Twilio (SMS)
129+
130+
Configure `StatusCallback` URL when sending:
131+
132+
```ts
133+
const message = await client.messages.create({
134+
to: '+15551234567',
135+
from: '+15559876543',
136+
body: 'Your verification code is 123456',
137+
statusCallback: 'https://api.yourapp.com/webhooks/twilio'
138+
});
139+
140+
// Store message.sid as provider_message_id in delivery_log
141+
```
142+
143+
Handle `undelivered` and `failed` statuses to suppress undeliverable phone numbers.
144+
145+
## Suppression List Management
146+
147+
### Checking before send
148+
149+
The delivery worker runs this before every send:
150+
151+
```sql
152+
SELECT EXISTS (
153+
SELECT 1 FROM notification_suppressions
154+
WHERE address = $1 AND channel_type = $2
155+
) AS is_suppressed;
156+
```
157+
158+
### Viewing recent suppressions
159+
160+
```sql
161+
SELECT address, channel_type, reason, source, provider, created_at
162+
FROM notification_suppressions
163+
ORDER BY created_at DESC
164+
LIMIT 50;
165+
```
166+
167+
### Removing a suppression
168+
169+
When a user re-verifies their email or an admin decides to retry:
170+
171+
```sql
172+
DELETE FROM notification_suppressions
173+
WHERE address = 'dan@example.com' AND channel_type = 'email';
174+
```
175+
176+
### Bulk export for audit
177+
178+
```sql
179+
COPY (
180+
SELECT address, channel_type, reason, source, provider, created_at
181+
FROM notification_suppressions
182+
ORDER BY created_at
183+
) TO '/tmp/suppressions.csv' WITH CSV HEADER;
184+
```
185+
186+
## Channel Auto-Deactivation
187+
188+
Each `notification_channels` row tracks consecutive failures:
189+
190+
```
191+
failed_count: 0 → 1 → 2 → 3 → is_active = false
192+
```
193+
194+
The delivery worker should:
195+
1. On failure: increment `failed_count`, set `last_error` and `last_error_at`
196+
2. On success: reset `failed_count = 0`, set `last_used_at = now()`
197+
3. When `failed_count` exceeds threshold (e.g., 3): set `is_active = false`
198+
199+
This is **per-endpoint** deactivation (expired push tokens, revoked webhooks). Address-level suppression (bounced emails) is handled separately by `notification_suppressions`.
200+
201+
## Monitoring & Troubleshooting
202+
203+
### Delivery success rate (last 24h)
204+
205+
```sql
206+
SELECT channel_type, status, COUNT(*) AS cnt,
207+
ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (PARTITION BY channel_type), 1) AS pct
208+
FROM notification_delivery_log
209+
WHERE created_at > now() - interval '24 hours'
210+
GROUP BY channel_type, status
211+
ORDER BY channel_type, cnt DESC;
212+
```
213+
214+
### Complaint rate (should be < 0.1%)
215+
216+
```sql
217+
SELECT
218+
COUNT(*) FILTER (WHERE status = 'complained') AS complaints,
219+
COUNT(*) FILTER (WHERE status IN ('delivered', 'sent')) AS total_sent,
220+
ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'complained')
221+
/ NULLIF(COUNT(*) FILTER (WHERE status IN ('delivered', 'sent')), 0), 3) AS complaint_rate_pct
222+
FROM notification_delivery_log
223+
WHERE channel_type = 'email'
224+
AND created_at > now() - interval '7 days';
225+
```
226+
227+
### Channels approaching deactivation
228+
229+
```sql
230+
SELECT id, owner_id, channel_type, endpoint, failed_count, last_error, last_error_at
231+
FROM notification_channels
232+
WHERE failed_count > 0 AND is_active = true
233+
ORDER BY failed_count DESC;
234+
```
235+
236+
### Debug a specific delivery
237+
238+
```sql
239+
SELECT status, provider, provider_message_id, error, response_payload, attempt_count, sent_at
240+
FROM notification_delivery_log
241+
WHERE notification_id = $1
242+
ORDER BY created_at;
243+
```
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Notification Tables — Quick Reference
2+
3+
Summary of all 6 tables generated by `notifications_module`. For full column listings with types, defaults, indexes, and RLS policies, see the `constructive-db-notifications` skill in constructive-db.
4+
5+
## 1. notifications (public schema)
6+
7+
The notification inbox. One row per notification event.
8+
9+
**Key columns:** `owner_id` (primary recipient), `recipient_ids` (multi-recipient array), `actor_id` (who triggered it), `category` + `kind` (event classification), `priority`, `topic` (subscriptable resource), `title`, `body`, `image_url`, `data` (jsonb payload), `resource_type` + `resource_id` (deep-link target), `action_url`, `actions` (CTA buttons), `scope_membership_type` + `scope_entity_id` (scope: App/Org/Group), `idempotency_key` (dedup), `expires_at`.
10+
11+
**Digest columns** (when `has_digest_metadata = true`): `group_key`, `group_count`, `digest_bucket` (`immediate`/`hourly`/`daily`/`weekly`/`never`), `deliver_after`, `delivered_at`, `delivery_channels`.
12+
13+
**RLS:** `AuthzComposite` — combines `AuthzDirectOwner` (owner_id), `AuthzMemberList` (recipient_ids), and `AuthzEntityMembership` (scope_entity_id).
14+
15+
## 2. notification_read_state (public schema)
16+
17+
Per-user interaction state. Sparse — one row per (notification, user), written lazily on first interaction.
18+
19+
**Key columns:** `notification_id`, `owner_id`, `seen_at`, `read_at`, `dismissed_at`, `snoozed_until`, `delivered_at`, `delivery_channels`.
20+
21+
**RLS:** `AuthzDirectOwner` — users can only see/modify their own read state.
22+
23+
## 3. notification_preferences (public schema) `[has_preferences]`
24+
25+
Per-user channel preferences by category and optional topic.
26+
27+
**Key columns:** `owner_id`, `category`, `topic`, `channel_email`, `channel_push`, `channel_in_app`, `channel_sms`, `channel_webhook`, `is_muted`, `digest_frequency`, `quiet_hours_start`/`end`/`tz`.
28+
29+
**RLS:** `AuthzDirectOwner` — users manage their own preferences.
30+
31+
## 4. notification_channels (public schema) `[has_channels]`
32+
33+
Registered device and push notification endpoints per user.
34+
35+
**Key columns:** `owner_id`, `channel_type` (web_push/apns/fcm/sms/slack_webhook), `device_name`, `platform`, `endpoint`, `metadata`, `is_active`, `failed_count`, `last_error`, `last_error_at`, `last_used_at`, `expires_at`.
36+
37+
**RLS:** `AuthzDirectOwner` — users manage their own channels.
38+
39+
## 5. notification_delivery_log (private schema) `[has_channels]`
40+
41+
Per-attempt delivery audit trail. Not exposed via GraphQL — internal/ops only.
42+
43+
**Key columns:** `notification_id`, `channel_id`, `channel_type`, `provider`, `provider_message_id` (indexed for webhook correlation), `status` (`pending`/`sent`/`delivered`/`failed`/`throttled`/`grouped`/`bounced`/`complained`), `error`, `response_payload`, `attempt_count`, `duration_ms`, `sent_at`.
44+
45+
## 6. notification_suppressions (private schema) `[has_channels]`
46+
47+
Address-level block list. Prevents sending to hard-bounced or complained addresses.
48+
49+
**Key columns:** `address` (email, phone, device token), `channel_type`, `reason` (`hard_bounce`/`complained`/`manual`/`unsubscribed`), `source`, `provider`, `metadata`.
50+
51+
**Constraint:** UNIQUE on `(address, channel_type)`.

0 commit comments

Comments
 (0)