Commit Graph

34335 Commits

Author SHA1 Message Date
Jake Barnby ac87c0e2d6 test(notifications): add regression and happy-path coverage for review-fix critical gaps
Locks the bug-fix invariants from PR #12195's last review pass and rounds out
worker channel coverage:

C1 testEmailSendFailureDoesNotPersistAlert — SMTP throw must NOT leave a dedup
   row behind, retry must deliver and persist exactly once.
C2 testNotificationEventResetClearsAllState — reset() drops every state-bearing
   field including preview (regression: missed in original reset body).
C3 testWebhookSendAlertResetsBetweenCalls — Webhooks::sendAlert must reset()
   the DI-shared Notification event so two paused-webhook alerts in one worker
   pass do not bleed recipients/subject/body into each other.
C4 testConsoleAdapterTreatsDuplicateAsDelivered — Duplicate on createDocument
   must surface as a successful idempotent send, not a per-recipient error.
M7 testTrackingPixelRejectsJwtWithoutPurposeClaim — Track endpoint silently
   ignores JWTs missing or with the wrong purpose claim (defends against
   replaying session/reset JWTs to mark alerts read).

Worker happy-path tests: testEmailChannelHappyPath, testConsoleChannelHappyPath,
testWebhookChannelHappyPath cover the full per-channel dispatch contract end
to end, including HMAC signing for webhooks and the tracking pixel injection
+ post-send persistence for email.

Also extracts CapturingWebhook into its own PSR-4 file so reused across tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 18:02:12 +12:00
Jake Barnby f83993d51e merge: major review fixes (M2 drop SDK auth, M4 dedup unique-index test, M9 e2e secret assertion) 2026-05-06 16:15:27 +12:00
Jake Barnby a0ec2827fa merge: critical review fixes (C1 deferred persist, C2 reset preview, C3 webhook reset, C4 dup catch, M3 status, M5 id helper, M7 jwt purpose) 2026-05-06 16:15:23 +12:00
Jake Barnby 1aa6a05ad8 fix(notifications): drop misleading auth metadata, exercise unique-index dedup, fail loudly on missing tracking secret in tests
- Account/Alerts/Track endpoint is scope:public (email clients have no
  session); SDK Method now declares auth: [] so generators do not require
  an auth header for an unauthenticated endpoint.
- NotificationsTest setUp now creates the production `_key_recipient`
  UNIQUE composite index on (messageId, channel, userId, teamId), and
  testPersistAlertReturnsExistingAlertIdOnDuplicate exercises the
  DuplicateException → return-existing-alertId branch end-to-end (both
  primary-key collision and unique-index collision via a sibling $id).
- Tracking-pixel e2e now asserts _APP_OPENSSL_KEY_V1 is set instead of
  silently falling back to the .env.example placeholder, so a missing
  CI secret fails loudly rather than passing against the wrong key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:13:52 +12:00
Jake Barnby 5f8cc27c9f fix(notifications): defer persistence until after send, reset event state, distinguish duplicate writes, scope tracking jwt purpose
- C1: dispatchEmail now writes the alert row only after a successful
  adapter send. The deterministic alertId is pre-computed via the new
  buildAlertId() helper so the tracking pixel URL stays stable, but the
  database write is deferred. Previously an SMTP failure left an orphan
  dedup row that permanently swallowed the email on retry.
- C2: Notification::reset() now clears $preview alongside the other
  per-trigger fields so state cannot leak across triggers.
- C3: Webhooks::sendAlert() resets the DI-shared notification queue at
  the top, preventing recipients/subject/body accumulation across
  multiple webhook failures in one worker invocation.
- C4: Console adapter catches DuplicateException separately and treats
  it as a successful (already-delivered) result, so an idempotent retry
  no longer surfaces as a delivery failure when combined with
  dispatchConsole's zero-delivery throw.
- M3: dispatchEmail throws plain 500 for any SMTP delivery failure;
  401 mis-signalled auth errors for timeouts/DNS/connection refused.
- M5: buildAlertId() is the single source of truth shared by
  persistAlert() and dispatchEmail(); no more drift between the
  tracking-pixel id and the persisted row's id.
- M7: Tracking JWT now carries a purpose='alert_track' claim and the
  /v1/account/alerts/:alertId/track decoder rejects tokens missing or
  mismatching that purpose, isolating the shared signing key from
  cross-endpoint replay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:06:49 +12:00
Jake Barnby e125bf6aec merge: cross-db alerts persistence fix 2026-05-06 15:26:51 +12:00
Jake Barnby aa9b45b5a1 fix(notifications): persist alerts to platform db regardless of dispatching project
The `alerts` collection lives in the platform database, but the Notifications
worker was injecting `dbForProject` and writing alerts there. Webhooks
dispatches with the user's project context, so alerts were being written to
the user's project DB (which has no `alerts` collection), and `/v1/account/alerts`
(which reads from `dbForPlatform`) returned nothing.

Switch the worker to inject `dbForPlatform` instead. All alert reads (dedup
lookup) and writes (persistAlert + ConsoleAdapter) now target the platform DB,
independent of which project the dispatching event belongs to.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:25:45 +12:00
Jake Barnby 5357e7dd35 fix(notifications): quote $id key in PHPDoc array shape
PHPStan rejects unquoted $id as a variable token in array shape syntax.
Quoting the key satisfies the parser without changing the runtime contract.
2026-05-06 15:24:25 +12:00
Jake Barnby a8ce7c3736 merge: ST17 e2e tests 2026-05-06 15:11:57 +12:00
Jake Barnby 540a7823cd merge: ST16 unit tests 2026-05-06 15:11:54 +12:00
Jake Barnby 534123cd87 test(notifications): add unit tests for dedup, fanout, console skip, zero-delivery, recipient struct, and tracking pixel
Cover the new worker behaviors introduced in waves 1-3:

- testDedupQueriesByAttributeNotById: prove alreadyDelivered() queries the
  messageId attribute, not getDocument($messageId), by seeding a row with
  a non-matching $id but matching messageId.
- testConsoleChannelSkipsPersistAlert: confirm the action loop does NOT
  call persistAlert for console recipients (the adapter persists).
- testConsoleZeroDeliveryThrows: surface adapter failures via the worker.
- testMultiRecipientFanoutNoCollision: same messageId across recipients
  must produce distinct $id values.
- testRecipientStructRoundtripsUserIdAndTeamId: persisted alert carries
  recipient userId/teamId.
- testTrackingPixelInjectedIntoEmailHtml: verify the tracking pixel is
  spliced before the last </body> tag with the expected URL shape.
- testPersistAlertReturnsAlertIdAndStoresUserId: dispatchEmail's
  persistAlert returns a resolvable id and stores userId + read=false.

ConsoleTest: add testMultiRecipientWithSameMessageIdGeneratesDistinctIds
and update existing tests to use the post-ST2 compound `$id` form
(messageId + 8-hex md5 suffix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:10:38 +12:00
Jake Barnby 3e0df70f5c test(notifications): e2e tests for list, mark-read, tracking pixel, and webhook-failure fanout
Add 6 new test methods to NotificationsBase that exercise the wave3
account-alerts surface end-to-end:

- testListAccountAlertsEmpty: GET /v1/account/alerts shape check
- testWebhookFailureCreatesConsoleAlert: drives a webhook past
  _APP_WEBHOOK_MAX_FAILED_ATTEMPTS via user-create events and polls
  /account/alerts until the worker fans the paused alert out to the
  project owner on the console channel
- testMarkAlertReadTogglesFlag: PATCH /:alertId/read happy path
- testMarkAlertReadUnauthorized: stranger console user cannot mark
  someone else's alert as read; alert remains unread for the owner
- testTrackingPixelTogglesRead: GET /:alertId/track with a valid
  HS256 JWT signed with _APP_OPENSSL_KEY_V1 returns the canonical 1x1
  PNG and atomically marks the alert as read
- testTrackingPixelInvalidTokenReturnsPng: tampered JWT still gets a
  PNG (no information disclosure) but performs no DB write

Helpers:

- seedWebhookFailureAlert: registers a webhook pointing at an
  unroutable address (http://127.0.0.1:1/), drives max+2 user-create
  events through the project, polls assertEventually with a 60s budget
  for the paused alert keyed by the deterministic md5 of
  'webhook:<id>:paused:<attempts>'
- createConsoleUser: spins up a fresh, unrelated console user with its
  own session for the unauthorized assertion
- getConsoleAlertHeaders: console-session auth bundle reused across
  every alerts call, so the trait works identically under SideServer
  and SideConsole hosts
2026-05-06 15:09:28 +12:00
Jake Barnby 080b6dbeb2 merge: ST14 alerts Track body 2026-05-06 14:48:39 +12:00
Jake Barnby 678a6bd079 merge: ST13 alerts XList body 2026-05-06 14:48:36 +12:00
Jake Barnby 3b0c46c85e merge: ST12 PATCH /v1/account/alerts/:alertId/read 2026-05-06 14:48:33 +12:00
Jake Barnby c419257755 merge: ST11 Webhooks worker switch to Notification 2026-05-06 14:48:29 +12:00
Jake Barnby 067bf582dc feat(webhooks): emit pausing alerts via notifications worker to owners on email and console
When a webhook is permanently paused after exceeding the max failure
threshold, fan out the alert through the new Notifications worker
instead of the legacy Mails worker. Recipients are filtered to project
owners only and receive both an email and a console notification, with
a single deduplication key per webhook + attempts so duplicate triggers
collapse downstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:46:57 +12:00
Jake Barnby dae98bf240 feat(notifications): track email opens by decoding pixel JWT and marking alert read
Replace placeholder body with JWT decode, alertId/userId match validation,
sparse update of read=true via injected Authorization::skip. Always returns
1x1 PNG regardless of decode outcome to avoid leaking JWT validity through
response status.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:46:33 +12:00
Jake Barnby 5dd987711b feat(notifications): implement GET /v1/account/alerts list endpoint body
Replace the placeholder body of the listAlerts action with real
implementation: scope alerts to the current user via userId equality,
parse incoming queries, support cursor pagination over the alerts
collection, run filters through count for total, and return
MODEL_ALERT_LIST.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:46:22 +12:00
Jake Barnby e54fbf3b3f feat(notifications): mark alert read via PATCH /v1/account/alerts/:alertId/read
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:35:53 +12:00
Jake Barnby 0a9b54543d merge: ST10 queueForNotifications DI registration 2026-05-06 14:20:10 +12:00
Jake Barnby 93bdebb7fa merge: ST9 notifications worker fixes 2026-05-06 14:20:07 +12:00
Jake Barnby 6f01c1492f fix(notifications): worker dedup by attribute, throw on console-zero-delivery, thread recipient userId, inject email tracking pixel
Apply the four Greptile P1 fixes to the Notifications worker and
extend it for C3 email read tracking and ST4's stripped SMTP plumbing.

P1 #1: alreadyDelivered() now queries the indexed messageId attribute
instead of getDocument($messageId). The action loop and Console
adapter both write compound `$id`s (messageId + recipient hash), so
the previous direct-id lookup always missed.

P1 #3: action() no longer calls persistAlert after dispatchConsole;
ConsoleAdapter persists internally. Email persists inside
dispatchEmail BEFORE the adapter send so the alertId is available for
the tracking pixel; webhook persists in the action loop after a
successful HTTP send.

P1 #4: dispatchConsole now throws when the adapter reports
`deliveredTo === 0`, surfacing the per-recipient error.

Recipient threading: dispatch() now takes the full recipient map and
returns the alertId (or null when persistence is the caller's
responsibility). persistAlert() reads userId/teamId from the
recipient and grants per-user / per-team-owner CRUD permissions,
falling back to payload permissions only when neither is set. The
returned alertId lets dispatchEmail splice a 1x1 tracking pixel
before the last `</body>` tag, signed with a 30-day HS256 JWT
(_APP_OPENSSL_KEY_V1) carrying {alertId, userId}.

SMTP resolution: ST4 stripped `smtp` and `customMailOptions` from
the Notification event payload, so the worker now resolves SMTP
from the injected project Document (mirroring Mails.php /
Memberships/Create.php), falling back to the env-driven cloud SMTP
adapter when the project has no enabled override.

Tests updated: SpyNotifications.dispatch() matches the new
signature and emulates per-channel persistence so existing routing
assertions keep their semantics. Memory `alerts` collection adds
the `read` boolean attribute to mirror platform.php.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:16:25 +12:00
Jake Barnby 4e7236a366 feat(notifications): register queueForNotifications resource in worker and request containers
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:00:45 +12:00
Jake Barnby 8c7ecd6cdb fix(notifications): add SDK Method group to alerts skeleton actions
PHPStan flagged missing required `group` parameter on the SDK Method
constructors for the alerts XList and Track\Get skeleton actions.
Group set to 'alerts' to align with the resource grouping convention
used elsewhere in the account namespace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:57:07 +12:00
Jake Barnby 0f1a2b5097 merge: ST4 event recipient + SMTP removal
# Conflicts:
#	src/Appwrite/Event/Notification.php
2026-05-06 13:48:47 +12:00
Jake Barnby a02eb3f41c merge: ST1 NOTIFICATION_TYPE_* rename 2026-05-06 13:48:16 +12:00
Jake Barnby ddffc69914 merge: ST8 alerts track skeleton
# Conflicts:
#	src/Appwrite/Platform/Modules/Account/Services/Http.php
2026-05-06 13:48:10 +12:00
Jake Barnby 439b3db2bf merge: ST7 alerts XList skeleton 2026-05-06 13:47:05 +12:00
Jake Barnby 134a8710da merge: ST6 alerts queries validator 2026-05-06 13:46:59 +12:00
Jake Barnby b9a928d3f8 merge: ST5 alert response model 2026-05-06 13:46:53 +12:00
Jake Barnby d07292ef1f merge: ST3 alerts schema 2026-05-06 13:46:49 +12:00
Jake Barnby 8cafc8c542 merge: ST2 console $id collision fix 2026-05-06 13:46:46 +12:00
Jake Barnby a0400d2e93 feat(notifications): scaffold GET /v1/account/alerts/:alertId/track endpoint
Adds a public-scope tracking-pixel endpoint skeleton that always returns a
1x1 transparent PNG. The body is intentionally minimal so the email-tracking
read-flag flip can be wired up cleanly in Wave 3 (ST14) once the JWT decode
and DB write paths are designed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:44:58 +12:00
Jake Barnby d79be3a6e3 feat(notifications): add alert and alertList response models
Adds Alert response model and MODEL_ALERT/MODEL_ALERT_LIST constants
so future GET /v1/account/alerts endpoint can return MODEL_ALERT_LIST.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:44:27 +12:00
Jake Barnby 55275259d9 feat(notifications): scaffold GET /v1/account/alerts endpoint
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:44:09 +12:00
Jake Barnby 2c2e920a1a feat(notifications): add alerts queries validator
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:42:23 +12:00
Jake Barnby 4351dffb88 refactor(notifications): drop SMTP plumbing from event and extend recipient struct with userId/teamId
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:42:22 +12:00
Jake Barnby a5587ff096 feat(notifications): track read state and enable per-recipient alert idempotency
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:42:10 +12:00
Jake Barnby 3df219df66 refactor(notifications): consolidate channel constants under NOTIFICATION_TYPE_* prefix
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:41:59 +12:00
Jake Barnby 36e1615498 fix(notifications): suffix Console adapter $id with recipient hash to avoid multi-recipient collision
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:41:11 +12:00
Jake Barnby 0ce3978f2c feat(notifications): add worker entrypoint and registration alongside Mails
Adds the bin/worker-notifications entrypoint, Dockerfile chmod, and
docker-compose service definitions for the new Notifications worker
without disturbing the existing Mails worker, and re-adds the Mails
queue/class constants on Event so the legacy callers still resolve.
The full mail->notification swap (caller migration, Mails removal,
worker rename) will land as a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:56:43 +12:00
Jake Barnby d6be6e165b test(notifications): split e2e into base + server/console overlays
Mirrors the dominant e2e pattern (FunctionsBase/MigrationsBase): the
shared health-queue assertions live in a NotificationsBase trait,
with thin per-side overlays for ProjectCustom + SideServer and
ProjectCustom + SideConsole.
2026-05-01 15:51:25 +12:00
Jake Barnby 7839b3bb3f refactor(notifications): align webhook signing with per-recipient signatureKey
Drop the global _APP_NOTIFICATIONS_WEBHOOK_SECRET env var. There's no
analogous global webhook secret in Appwrite; the existing Webhooks
worker carries a per-webhook signatureKey on the webhook document.

Move the same pattern into the Notification event: each webhook
recipient may carry an optional signatureKey, which the worker
forwards to the Webhook adapter for HMAC-SHA256 signing. Recipients
without a key are delivered unsigned and a tag is logged for audit.
2026-05-01 15:51:25 +12:00
Jake Barnby 5320c9441b refactor(notifications): rename dedupKey to deduplicationKey
No abbreviations in identifier names.
2026-05-01 15:51:25 +12:00
Jake Barnby 8ad73aa6bb refactor(notifications): use Utopia Fetch client in Webhook adapter
Replace raw curl with Utopia\Fetch\Client to align with the rest of
the codebase (Github OAuth adapter, Install task). The dispatch seam
is preserved so existing tests can still substitute responses.
2026-05-01 15:51:25 +12:00
Jake Barnby 5cbbacaae5 refactor(notifications): move adapters under Appwrite\Utopia\Messaging
Mirror the upstream Utopia\Messaging package namespace under the
Appwrite\Utopia\Messaging prefix, matching the convention used by
Appwrite\Utopia\Database and Appwrite\Utopia\Response.
2026-05-01 15:51:25 +12:00
Jake Barnby 5ee92e1844 fix(notifications): move alerts collection to platform DB
Alerts is a platform-DB-only concern. Move from common.php to
platform.php where it belongs alongside other platform collections.
2026-05-01 15:51:25 +12:00
Jake Barnby a546c41662 docs(notifications): document _APP_NOTIFICATIONS_WEBHOOK_SECRET
Registers the webhook signing secret in app/config/variables.php under a
new Notifications category, threads it through the worker container in
docker-compose.yml and tests/resources/docker/docker-compose.yml, and
adds an empty default to .env so operators see the knob alongside the
existing SMTP block.

When set, outbound webhook deliveries from the notifications worker
include an X-Appwrite-Webhook-Signature header carrying
sha256=<hex(hmac_sha256(timestamp.body, secret))>. When unset, the
worker delivers payloads unsigned — receivers must decide whether to
accept them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:51:25 +12:00
Jake Barnby df16c84b51 test(notifications): e2e coverage for notifications queue health
Adds the Notifications e2e suite under tests/e2e/Services/Notifications.
Asserts that the live notifications queue depth is reported via
GET /v1/health/queue/notifications, that the threshold guard is honoured,
and that the failed-jobs endpoint accepts the v1-notifications queue name.

Dispatch routing, dedup, and webhook signing are covered by the unit
suite — the worker cannot be deterministically driven through the live
queue from a test client without an admin enqueue endpoint, so the e2e
file pins the public health contract that ops dashboards and KEDA scale
on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:51:24 +12:00