feat(notifications): P2e member self-view on /me + docs
getMyNotifications (service-client self-view, mirrors getMyBilling) + a read-only Notifications section on /me. Docs updated in the same change: ARCHITECTURE (Phase 2 subsystem + limitation #5), API_REFERENCE (notifications section, fixed the stale no-auth-middleware claim), DATABASE_SCHEMA + DB_SCHEMA_MAP (migration 041 + table), DEPLOYMENT (env vars + dispatcher crontab), HANDOFF pass-45.
This commit is contained in:
+3
-1
@@ -406,10 +406,11 @@ Default channels created by trigger on space INSERT: `general`, `announcements`,
|
|||||||
| 038 | `space_visits` (presence/attendance) + `spaces.host_requires_card` bool default true. One open visit per member (partial unique); SELECT = any space member; service-client-only writes (self-resolved, immutable). No new permission code |
|
| 038 | `space_visits` (presence/attendance) + `spaces.host_requires_card` bool default true. One open visit per member (partial unique); SELECT = any space member; service-client-only writes (self-resolved, immutable). No new permission code |
|
||||||
| 039 | DATA backfill only: link `form_submissions.member_id` (NULL) to earliest matching member by `(space_id, lower email = lower submitter_email)`. Idempotent; not in schema.sql |
|
| 039 | DATA backfill only: link `form_submissions.member_id` (NULL) to earliest matching member by `(space_id, lower email = lower submitter_email)`. Idempotent; not in schema.sql |
|
||||||
| 040 | Stripe dues P1: `payment_platform` += `stripe`; `member_billing` (member↔Stripe customer/sub/status; SELECT admin/board/treasurer, service-client-only writes); `stripe_webhook_events` (idempotency, PK=event id, service-only). Per-space own keys (vault + integrations.config) |
|
| 040 | Stripe dues P1: `payment_platform` += `stripe`; `member_billing` (member↔Stripe customer/sub/status; SELECT admin/board/treasurer, service-client-only writes); `stripe_webhook_events` (idempotency, PK=event id, service-only). Per-space own keys (vault + integrations.config) |
|
||||||
|
| 041 | Notifications P2: `notifications` outbox (type, recipient, subject, body_html/text, status, attempts, dedupe_key). UNIQUE(space_id,dedupe_key) = idempotent webhook enqueue; partial idx (created_at) WHERE status='pending'. SELECT admin/board/treasurer, service-client-only writes (webhook enqueue + dispatcher cron); member self-view via validated action |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tables added by migrations 016-040 (quick map)
|
## Tables added by migrations 016-041 (quick map)
|
||||||
|
|
||||||
| Table | Key columns | Purpose |
|
| Table | Key columns | Purpose |
|
||||||
|-------|-------------|---------|
|
|-------|-------------|---------|
|
||||||
@@ -442,6 +443,7 @@ Default channels created by trigger on space INSERT: `general`, `announcements`,
|
|||||||
| `space_visits` | space_id, member_id, checked_in_at, checked_out_at, is_host, check_in_note, check_out_note | Presence/attendance. Open (checked_out_at NULL) = present; partial UNIQUE one-open-per-member. SELECT = any space member; NO write policy (self-resolved service-client actions only; immutable). Org-wide attendance view = all members |
|
| `space_visits` | space_id, member_id, checked_in_at, checked_out_at, is_host, check_in_note, check_out_note | Presence/attendance. Open (checked_out_at NULL) = present; partial UNIQUE one-open-per-member. SELECT = any space member; NO write policy (self-resolved service-client actions only; immutable). Org-wide attendance view = all members |
|
||||||
| `member_billing` | space_id, member_id (UNIQUE per space), stripe_customer_id, stripe_subscription_id, subscription_status, current_period_end | Stripe dues link. SELECT = admin/board/treasurer; NO client write policy (webhook/service-client only). Member self-view via a validated action |
|
| `member_billing` | space_id, member_id (UNIQUE per space), stripe_customer_id, stripe_subscription_id, subscription_status, current_period_end | Stripe dues link. SELECT = admin/board/treasurer; NO client write policy (webhook/service-client only). Member self-view via a validated action |
|
||||||
| `stripe_webhook_events` | event_id PK, space_id, type, received_at | Webhook idempotency ledger (dedupe on Stripe's stable event id). Service-client only; no RLS policy |
|
| `stripe_webhook_events` | event_id PK, space_id, type, received_at | Webhook idempotency ledger (dedupe on Stripe's stable event id). Service-client only; no RLS policy |
|
||||||
|
| `notifications` | space_id, member_id, type, channel, recipient, subject, body_html, body_text, status, attempts, last_error, dedupe_key (UNIQUE per space), sent_at | Transactional email outbox. Webhook enqueues (idempotent via the unique key); dispatcher cron sends via Resend with row id as Idempotency-Key. SELECT = admin/board/treasurer; NO client write policy (service-client only). Member self-view via a validated action |
|
||||||
|
|
||||||
Column additions: `space_members.tier_id`, `onboarding_completed_at`, `onboarding_progress`; `secrets.encrypted_value`, `encryption_version`; `knowledge_base.render_markdown`, `is_meeting_minutes`, `meeting_date`; `classes.required_form_id`; `spaces.host_requires_card`. New enum `comment_entity_type`.
|
Column additions: `space_members.tier_id`, `onboarding_completed_at`, `onboarding_progress`; `secrets.encrypted_value`, `encryption_version`; `knowledge_base.render_markdown`, `is_meeting_minutes`, `meeting_date`; `classes.required_form_id`; `spaces.host_requires_card`. New enum `comment_entity_type`.
|
||||||
|
|
||||||
|
|||||||
+32
-1
@@ -8,7 +8,7 @@ import {
|
|||||||
type CertStatus,
|
type CertStatus,
|
||||||
} from '@/lib/certifications-logic'
|
} from '@/lib/certifications-logic'
|
||||||
import { PERMISSIONS, PERMISSION_CODES } from '@/lib/permissions-catalog'
|
import { PERMISSIONS, PERMISSION_CODES } from '@/lib/permissions-catalog'
|
||||||
import { getMyClassSignups, getMyReservations, getMyCards, getMyVisits, getMyBilling } from '@/lib/actions'
|
import { getMyClassSignups, getMyReservations, getMyCards, getMyVisits, getMyBilling, getMyNotifications } from '@/lib/actions'
|
||||||
import { SIGNUP_STATUS_LABEL, SESSION_STATUS_LABEL } from '@/lib/classes-logic'
|
import { SIGNUP_STATUS_LABEL, SESSION_STATUS_LABEL } from '@/lib/classes-logic'
|
||||||
import { RESERVATION_STATUS_LABEL } from '@/lib/equipment-logic'
|
import { RESERVATION_STATUS_LABEL } from '@/lib/equipment-logic'
|
||||||
import { presenceStatus } from '@/lib/presence-logic'
|
import { presenceStatus } from '@/lib/presence-logic'
|
||||||
@@ -116,6 +116,9 @@ export default async function MePage() {
|
|||||||
const billing = ('data' in billingRes ? billingRes.data : null) as
|
const billing = ('data' in billingRes ? billingRes.data : null) as
|
||||||
| { status: string | null; currentPeriodEnd: string | null; hasCustomer: boolean }
|
| { status: string | null; currentPeriodEnd: string | null; hasCustomer: boolean }
|
||||||
| null
|
| null
|
||||||
|
const notifsRes = await getMyNotifications()
|
||||||
|
type MyNotif = { id: string; type: string; subject: string; status: string; createdAt: string; sentAt: string | null }
|
||||||
|
const notifs: MyNotif[] = 'data' in notifsRes ? (notifsRes.data as MyNotif[]) : []
|
||||||
const titleOf = (cs: ClassSignup) => {
|
const titleOf = (cs: ClassSignup) => {
|
||||||
const sess = sessionOf(cs)
|
const sess = sessionOf(cs)
|
||||||
const c = sess ? (Array.isArray(sess.classes) ? sess.classes[0] : sess.classes) : null
|
const c = sess ? (Array.isArray(sess.classes) ? sess.classes[0] : sess.classes) : null
|
||||||
@@ -136,6 +139,34 @@ export default async function MePage() {
|
|||||||
<DuesCard billing={billing} />
|
<DuesCard billing={billing} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground mb-3">
|
||||||
|
Notifications
|
||||||
|
</h2>
|
||||||
|
{notifs.length === 0 ? (
|
||||||
|
<p className="font-sans text-sm text-muted-foreground">
|
||||||
|
No notifications yet. Dues receipts and payment alerts will show up here.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y rounded-lg border border-border">
|
||||||
|
{notifs.map(n => (
|
||||||
|
<li key={n.id} className="p-4 flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="font-sans text-sm text-foreground">{n.subject}</span>
|
||||||
|
<p className="font-mono text-[10px] text-muted-foreground mt-1">
|
||||||
|
{new Date(n.createdAt).toLocaleString()}
|
||||||
|
{n.sentAt ? ` · sent ${new Date(n.sentAt).toLocaleDateString()}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={n.status === 'sent' ? 'default' : 'outline'}>
|
||||||
|
{n.status === 'sent' ? 'Sent' : n.status === 'failed' ? 'Failed' : 'Queued'}
|
||||||
|
</Badge>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground mb-3">
|
<h2 className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground mb-3">
|
||||||
Certifications
|
Certifications
|
||||||
|
|||||||
@@ -959,4 +959,10 @@ Per-space OWN Stripe account (NOT Connect). Pure mapping in `lib/stripe-logic.ts
|
|||||||
- `startDuesCheckout()` — any member; resolves the caller + their tier server-side, ensures a Stripe Customer (reuse/create + `member_billing` upsert), returns a hosted Checkout Session URL in `subscription` mode with `client_reference_id` + metadata on the session AND `subscription_data.metadata`.
|
- `startDuesCheckout()` — any member; resolves the caller + their tier server-side, ensures a Stripe Customer (reuse/create + `member_billing` upsert), returns a hosted Checkout Session URL in `subscription` mode with `client_reference_id` + metadata on the session AND `subscription_data.metadata`.
|
||||||
- `startBillingPortal()` — any member with a customer; returns a Stripe Billing Portal URL (self-serve card/plan/cancel; the space must activate the Portal in its Stripe Dashboard once).
|
- `startBillingPortal()` — any member with a customer; returns a Stripe Billing Portal URL (self-serve card/plan/cancel; the space must activate the Portal in its Stripe Dashboard once).
|
||||||
- `getMyBilling()` — the caller's own billing status (service client; `member_billing` is admin-RLS, mirrors `getMyCards`). `listMemberBilling()` — admin/board/treasurer dues view.
|
- `getMyBilling()` — the caller's own billing status (service client; `member_billing` is admin-RLS, mirrors `getMyCards`). `listMemberBilling()` — admin/board/treasurer dues view.
|
||||||
- Webhook `POST /api/stripe/webhook/[space]` (nodejs, force-dynamic, unauthenticated by design — verify is signature-based; no auth middleware in this app): raw `req.text()` body, loads THAT space's secret + signing secret from the vault, `constructEvent`, idempotency via `stripe_webhook_events` PK (replay→200). On `checkout.session.completed` / `customer.subscription.*` / `invoice.paid|payment_succeeded`: upsert `member_billing`, record a `stripe` `payments` row, and map `duesMemberStatus`+`graceExceeded` onto `space_members.status` (only `current`↔`late` — never auto-inactive or auto-approve `unverified`; `last_paid_at` on paid). Member resolved by metadata then customer id, always space-scoped. Bad signature/config → 400; handler error → 500 (Stripe retries; idempotency-safe).
|
- Webhook `POST /api/stripe/webhook/[space]` (nodejs, force-dynamic, unauthenticated by design — verify is signature-based; `proxy.ts` whitelists `/api/stripe/webhook` so the unauthenticated call is not redirected to `/login`): raw `req.text()` body, loads THAT space's secret + signing secret from the vault, `constructEvent`, idempotency via `stripe_webhook_events` PK (replay→200). On `checkout.session.completed` / `customer.subscription.*` / `invoice.paid|payment_succeeded`: upsert `member_billing`, record a `stripe` `payments` row, and map `duesMemberStatus`+`graceExceeded` onto `space_members.status` (only `current`↔`late` — never auto-inactive or auto-approve `unverified`; `last_paid_at` on paid). Member resolved by metadata then customer id, always space-scoped. It also enqueues dues-lifecycle notifications (see below). Bad signature/config → 400; handler error → 500 (Stripe retries; idempotency-safe).
|
||||||
|
|
||||||
|
### Transactional notifications: `notifications.ts` + `lib/email/*` + dispatcher (migration 041)
|
||||||
|
Outbox + dispatcher; the webhook never sends inline. Pure render + dedupe in `lib/notifications-logic.ts` (`renderDuesEmail`, `duesDedupeKey`, `isTerminalAttempt`, `MAX_NOTIFICATION_ATTEMPTS`) is unit-tested. `lib/email/send.ts` (`sendEmail`) is the transport seam over Resend's HTTP API (no SDK; `fetch`), returning `{ ok, id } | { ok:false, error, retryable }`; unset `RESEND_API_KEY`/`EMAIL_FROM` is a non-retryable no-op.
|
||||||
|
- The Stripe webhook enqueues into `notifications` (`ignoreDuplicates` on the `(space_id, dedupe_key)` unique index, so an event replay is a no-op): `invoice.paid` → `dues_renewed`, `invoice.payment_failed` → `dues_payment_failed`, transition to `late` (in `applySubscription`) → `dues_lapsed`. Recipient + space name resolved server-side; never sends inline.
|
||||||
|
- Dispatcher `POST /api/cron/notifications` (nodejs, force-dynamic; constant-time `CRON_SECRET` bearer; 503 if unset, 401 on mismatch; `proxy.ts` whitelists `/api/cron`). Drains ≤20 oldest `pending` email rows with `attempts < MAX`, sends with the row id as Resend `Idempotency-Key` (overlap-safe), ~4.5/s spacing. Success → `sent`+`sent_at`; failure → `attempts++`, `pending` if retryable and budget remains else `failed`. Returns `{ scanned, sent, failed, retried }`. Hit once a minute by the droplet crontab.
|
||||||
|
- `getMyNotifications()` — the caller's own last 15 notifications (service client; `notifications` is admin-RLS, mirrors `getMyBilling`). Surfaced read-only on `/me`.
|
||||||
|
|||||||
+23
-1
@@ -645,6 +645,28 @@ policy** (webhook/service-client only); the member self-view is a validated
|
|||||||
action. No new permission code. Phases 2-3 (transactional notifications,
|
action. No new permission code. Phases 2-3 (transactional notifications,
|
||||||
broader self-serve) build on this.
|
broader self-serve) build on this.
|
||||||
|
|
||||||
|
### Transactional notifications (migration 041; product spine Phase 2)
|
||||||
|
|
||||||
|
Outbox + dispatcher, not inline send. The Stripe webhook only writes a
|
||||||
|
`notifications` row (`invoice.paid` → `dues_renewed`, `invoice.payment_failed`
|
||||||
|
→ `dues_payment_failed`, lapse-to-`late` → `dues_lapsed`); it never calls the
|
||||||
|
mail provider, so the money path stays fast and retry-safe. The
|
||||||
|
`(space_id, dedupe_key)` unique index makes a Stripe event replay a no-op
|
||||||
|
(`ignoreDuplicates`). Pure render + dedupe logic in
|
||||||
|
`lib/notifications-logic.ts` is unit-tested (HTML-escaped, brand-neutral
|
||||||
|
copy). `lib/email/send.ts` is a one-function transport seam over Resend's
|
||||||
|
HTTP API (no SDK; `fetch`) returning a `retryable` flag; a self-hosted
|
||||||
|
deploy can swap it for SMTP without touching callers, and an unset
|
||||||
|
`RESEND_API_KEY` is a clean non-retryable no-op. The dispatcher
|
||||||
|
`POST /api/cron/notifications` (constant-time `CRON_SECRET` bearer, no
|
||||||
|
session — `proxy.ts` whitelists `/api/cron`) drains ≤20 pending rows per
|
||||||
|
run, passing each row id as Resend's `Idempotency-Key` so an overlapping
|
||||||
|
run cannot double-send; transient failures stay `pending` until the attempt
|
||||||
|
budget is spent, permanent ones go `failed`. `notifications` SELECT =
|
||||||
|
admin/board/treasurer, no client write policy; the member self-view
|
||||||
|
(`getMyNotifications`, surfaced on `/me`) is a validated action. The
|
||||||
|
droplet's crontab POSTs once a minute. No new permission code.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. Known Limitations
|
## 13. Known Limitations
|
||||||
@@ -653,6 +675,6 @@ broader self-serve) build on this.
|
|||||||
2. **Payment integrations** - Manual import/CSV reconciliation is the primary path. A PayPal sync endpoint exists (`app/api/paypal/sync/route.ts`); Stripe/other live APIs are not integrated. See `docs/` and the Payments module section.
|
2. **Payment integrations** - Manual import/CSV reconciliation is the primary path. A PayPal sync endpoint exists (`app/api/paypal/sync/route.ts`); Stripe/other live APIs are not integrated. See `docs/` and the Payments module section.
|
||||||
3. **Social auth** - GitHub/Google sign-in is wired, gated by the `NEXT_PUBLIC_OAUTH_GITHUB`/`NEXT_PUBLIC_OAUTH_GOOGLE` env flags.
|
3. **Social auth** - GitHub/Google sign-in is wired, gated by the `NEXT_PUBLIC_OAUTH_GITHUB`/`NEXT_PUBLIC_OAUTH_GOOGLE` env flags.
|
||||||
4. **Webhooks** - The HMAC signing contract and secret rotation exist; per-event delivery is not implemented (see `docs/WEBHOOKS.md`).
|
4. **Webhooks** - The HMAC signing contract and secret rotation exist; per-event delivery is not implemented (see `docs/WEBHOOKS.md`).
|
||||||
5. **Email notifications** - Not implemented.
|
5. **Email notifications** - Dues-lifecycle transactional email is implemented (migration 041: notifications outbox + Resend transport + dispatcher cron). Other domains (bookings, forms) are not wired yet but reuse the same outbox.
|
||||||
6. **Search** - Client-side filtering only, no server-side full-text search.
|
6. **Search** - Client-side filtering only, no server-side full-text search.
|
||||||
7. **Single space per user** - The auth resolver assumes one active membership per user; a user in 2+ spaces is not supported (no space switcher). Fails closed.
|
7. **Single space per user** - The auth resolver assumes one active membership per user; a user in 2+ spaces is not supported (no space switcher). Fails closed.
|
||||||
|
|||||||
@@ -721,6 +721,7 @@ migration is mirrored as a section in it. Tables/columns added:
|
|||||||
| 038 | `space_visits` (presence/attendance: check-in/out, `is_host`, in/out notes; partial UNIQUE `(space_id,member_id) WHERE checked_out_at IS NULL` = one open visit). RLS additive/default-deny: SELECT = any space member (presence is social), NO client write policy (validated self-resolved service-client actions; immutable history). No new permission code; org-wide attendance view is all-members by product decision. Also adds `spaces.host_requires_card` (bool, default true) gating host check-in, enforced in the app |
|
| 038 | `space_visits` (presence/attendance: check-in/out, `is_host`, in/out notes; partial UNIQUE `(space_id,member_id) WHERE checked_out_at IS NULL` = one open visit). RLS additive/default-deny: SELECT = any space member (presence is social), NO client write policy (validated self-resolved service-client actions; immutable history). No new permission code; org-wide attendance view is all-members by product decision. Also adds `spaces.host_requires_card` (bool, default true) gating host check-in, enforced in the app |
|
||||||
| 039 | DATA backfill only (no structural change): links existing `form_submissions.member_id` (where NULL) to the earliest-joined matching `space_members` by `(space_id, lower(email)=lower(submitter_email))`. Idempotent (NULL-only). Not mirrored in schema.sql (fresh DB has no submissions); ongoing linking is app-side (submitForm + linkSubmissionsByEmail) |
|
| 039 | DATA backfill only (no structural change): links existing `form_submissions.member_id` (where NULL) to the earliest-joined matching `space_members` by `(space_id, lower(email)=lower(submitter_email))`. Idempotent (NULL-only). Not mirrored in schema.sql (fresh DB has no submissions); ongoing linking is app-side (submitForm + linkSubmissionsByEmail) |
|
||||||
| 040 | Stripe recurring dues P1. `payment_platform` += `'stripe'` (ALTER TYPE ADD VALUE; mirrored in the CREATE TYPE list). `member_billing` (member↔Stripe customer/subscription/status/period; SELECT = admin/board/treasurer, NO client write policy — webhook/service-client only). `stripe_webhook_events` (idempotency ledger, PK = Stripe event id; service-client only, no policy). Per-space OWN keys: secrets in the vault, config in integrations.config. No new permission code |
|
| 040 | Stripe recurring dues P1. `payment_platform` += `'stripe'` (ALTER TYPE ADD VALUE; mirrored in the CREATE TYPE list). `member_billing` (member↔Stripe customer/subscription/status/period; SELECT = admin/board/treasurer, NO client write policy — webhook/service-client only). `stripe_webhook_events` (idempotency ledger, PK = Stripe event id; service-client only, no policy). Per-space OWN keys: secrets in the vault, config in integrations.config. No new permission code |
|
||||||
|
| 041 | Transactional notifications P2. `notifications` outbox (space_id, member_id, type, recipient, subject, body_html/text, status pending/sent/failed, attempts, dedupe_key). `UNIQUE (space_id, dedupe_key)` makes the Stripe-webhook enqueue idempotent (event replay -> no-op). SELECT = admin/board/treasurer, NO client write policy (webhook enqueue + dispatcher cron, service-client only); member self-view via a validated action. Partial index on `(created_at) WHERE status='pending'` for the dispatcher drain. No new permission code |
|
||||||
|
|
||||||
New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`.
|
New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`.
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ All variables are listed in [.env.example](../.env.example). At minimum:
|
|||||||
Optional:
|
Optional:
|
||||||
|
|
||||||
- `NEXT_PUBLIC_OAUTH_GITHUB` / `NEXT_PUBLIC_OAUTH_GOOGLE` — set to `"true"` only for an OAuth provider you have actually configured in Supabase Auth. The login page hides any provider button that is not enabled, and hides the whole social-sign-in block if neither is set. These are build-time public values, so a change requires a redeploy to take effect.
|
- `NEXT_PUBLIC_OAUTH_GITHUB` / `NEXT_PUBLIC_OAUTH_GOOGLE` — set to `"true"` only for an OAuth provider you have actually configured in Supabase Auth. The login page hides any provider button that is not enabled, and hides the whole social-sign-in block if neither is set. These are build-time public values, so a change requires a redeploy to take effect.
|
||||||
|
- `RESEND_API_KEY` — Resend HTTP API key for transactional email. Unset means the notification outbox still fills but nothing is sent (dispatcher records each row as failed with "transport not configured").
|
||||||
|
- `EMAIL_FROM` — from address for all platform email, e.g. `HeatSync Labs <[email protected]>`. The domain must be verified in Resend (SPF + DKIM) for production delivery.
|
||||||
|
- `CRON_SECRET` — shared secret guarding the notification dispatcher. Generate with `openssl rand -hex 32`. Unset means the dispatcher returns 503 and never sends.
|
||||||
|
|
||||||
## How a deploy runs
|
## How a deploy runs
|
||||||
|
|
||||||
@@ -51,6 +54,16 @@ See [LOCAL_DEV.md](./LOCAL_DEV.md) for the fresh-clone-to-running sequence using
|
|||||||
|
|
||||||
For initial server provisioning (Docker, the Supabase stack, Caddy, firewall, SSH hardening, deploy-key registration, the systemd unit, backup cron), follow [DEPLOY_DO_SELFHOSTED.md](./DEPLOY_DO_SELFHOSTED.md) end to end.
|
For initial server provisioning (Docker, the Supabase stack, Caddy, firewall, SSH hardening, deploy-key registration, the systemd unit, backup cron), follow [DEPLOY_DO_SELFHOSTED.md](./DEPLOY_DO_SELFHOSTED.md) end to end.
|
||||||
|
|
||||||
|
### Notification dispatcher cron
|
||||||
|
|
||||||
|
Transactional email is an outbox drained by `POST /api/cron/notifications`. Add a once-a-minute root crontab entry on the Droplet (after `RESEND_API_KEY`, `EMAIL_FROM`, `CRON_SECRET` are set in the app `.env`):
|
||||||
|
|
||||||
|
```
|
||||||
|
* * * * * curl -fsS -m 30 -X POST http://127.0.0.1:3000/api/cron/notifications -H "Authorization: Bearer $CRON_SECRET" >/dev/null 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
It hits the app locally (bypassing Caddy), self-throttles under Resend's rate limit, and is idempotent, so a missed or overlapping minute is harmless. Migration `041_notifications.sql` is applied automatically by `deploy.sh` like every other `scripts/0*.sql`; no manual database step.
|
||||||
|
|
||||||
## Operational checklist
|
## Operational checklist
|
||||||
|
|
||||||
- Monitor `/var/log/hackerspace-deploy.log` for deploy outcomes.
|
- Monitor `/var/log/hackerspace-deploy.log` for deploy outcomes.
|
||||||
|
|||||||
@@ -4,6 +4,28 @@ Append-only. Newest entries on top. Keep each entry to one screen.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-05-17 (pass 45) — Product spine Phase 2: transactional notifications (LOCAL, awaiting one reviewed deploy)
|
||||||
|
|
||||||
|
Branch `main`. Built Phase 2 dues-lifecycle email end to end, gated + committed per sub-phase. NOT deployed (ASK pending). Suite 498, build clean.
|
||||||
|
|
||||||
|
### What shipped (commits ddb790a, 5295dc2, 2c88278, 75097a6, + this docs/UI)
|
||||||
|
- **P2a** migration 041 + schema.sql section 26: `notifications` outbox. Service-only writer, SELECT = admin/board/treasurer, member self-view via validated action. `(space_id, dedupe_key)` unique = idempotent enqueue. Pure `lib/notifications-logic.ts` (render + dedupe + terminal rule), 13 tests.
|
||||||
|
- **P2b** `lib/email/send.ts`: Resend HTTP transport seam (no SDK, `fetch`), `retryable` flag, unset-key = clean no-op. 6 tests (fetch mocked). `RESEND_API_KEY`/`EMAIL_FROM` in `.env.example`.
|
||||||
|
- **P2c** Stripe webhook enqueues: `invoice.paid`→renewed, `invoice.payment_failed`→failed, lapse→lapsed. Outbox only, never sends inline (keeps the just-hardened money path fast/retry-safe).
|
||||||
|
- **P2d** `POST /api/cron/notifications`: constant-time `CRON_SECRET`, drains ≤20/min, row id as Resend Idempotency-Key (overlap-safe). `proxy.ts` whitelists `/api/cron` (same redirect trap as the Stripe webhook). `CRON_SECRET` in `.env.example`.
|
||||||
|
- **P2e** `getMyNotifications` + read-only `/me` section. Docs updated same change: DATABASE_SCHEMA, DB_SCHEMA_MAP, API_REFERENCE, ARCHITECTURE, DEPLOYMENT (env + crontab line).
|
||||||
|
|
||||||
|
### Decision / context
|
||||||
|
- Transport = Resend (owner indicated an existing Resend token; none was wired in this repo). Behind a one-function seam so SMTP can replace it later without touching callers. Generic-platform constraint preserved.
|
||||||
|
- Scope locked to dues lifecycle; bookings/forms reuse the same outbox in a later phase.
|
||||||
|
|
||||||
|
### Open / needs owner
|
||||||
|
- **Not deployed.** Awaiting one reviewed deploy (ASK). Also still local: pass-44 docs commit dc76a86.
|
||||||
|
- After deploy, prod needs: `RESEND_API_KEY`, `EMAIL_FROM` (verified domain, SPF+DKIM), `CRON_SECRET` set on the Droplet, plus the once-a-minute crontab line (see DEPLOYMENT.md). Without them the outbox fills but nothing sends.
|
||||||
|
- Validation (real test): Stripe test-mode dues run → confirm `dues_renewed` enqueued once, dispatcher sends, `/me` shows it; force a card failure → `dues_payment_failed`; let it lapse → `dues_lapsed`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-05-17 (pass 44) — Stripe Phase 1 DEPLOYED; post-deploy P0 (webhook routing) caught + fixed + redeployed
|
## 2026-05-17 (pass 44) — Stripe Phase 1 DEPLOYED; post-deploy P0 (webhook routing) caught + fixed + redeployed
|
||||||
|
|
||||||
Branch `main`. Phase 1 shipped (run 25988762909, HEAD 8f74307, success). Post-deploy smoke caught a P0: an unauthenticated POST to `/api/stripe/webhook/<space>` returned **307**, not 400.
|
Branch `main`. Phase 1 shipped (run 25988762909, HEAD 8f74307, success). Post-deploy smoke caught a P0: an unauthenticated POST to `/api/stripe/webhook/<space>` returned **307**, not 400.
|
||||||
|
|||||||
@@ -68,3 +68,6 @@ export * from './presence'
|
|||||||
|
|
||||||
// Stripe recurring dues (product spine Phase 1)
|
// Stripe recurring dues (product spine Phase 1)
|
||||||
export * from './stripe'
|
export * from './stripe'
|
||||||
|
|
||||||
|
// Transactional notifications outbox (product spine Phase 2)
|
||||||
|
export * from './notifications'
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { createAdminClient } from '@/lib/supabase/admin'
|
||||||
|
import { requireMember } from '@/lib/auth-helpers'
|
||||||
|
|
||||||
|
// The caller's own notification history. The notifications RLS SELECT is
|
||||||
|
// admin/board/treasurer only (mirrors member_billing), so the member
|
||||||
|
// self-view goes through the validated service client, same as getMyBilling.
|
||||||
|
export async function getMyNotifications() {
|
||||||
|
const supabase = await createClient()
|
||||||
|
const auth = await requireMember(supabase)
|
||||||
|
if (!auth.ok) return { error: auth.error }
|
||||||
|
const { member } = auth
|
||||||
|
|
||||||
|
const { data } = await createAdminClient()
|
||||||
|
.from('notifications')
|
||||||
|
.select('id, type, subject, status, created_at, sent_at')
|
||||||
|
.eq('space_id', member.space_id)
|
||||||
|
.eq('member_id', member.id)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(15)
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: (data ?? []).map(r => ({
|
||||||
|
id: r.id as string,
|
||||||
|
type: r.type as string,
|
||||||
|
subject: r.subject as string,
|
||||||
|
status: r.status as string,
|
||||||
|
createdAt: r.created_at as string,
|
||||||
|
sentAt: (r.sent_at as string | null) ?? null,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user