Added a full guarded notification for all finacial transaction notifi… - #91
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Transactional Outbox Implementation — Summary
CLOSES #83
What I Built
I implemented a complete transactional outbox pattern for the Vaulty backend to ensure reliable delivery of financial and notification jobs. Previously, the backend used a direct BullMQ approach where database writes and queue submissions were separate operations, creating race conditions where a database write could succeed while queue submission failed — leading to lost verification emails, duplicate payment processing, stale streak calculations, or notifications referring to non-existent records.
What I Changed
1. Prisma Schema (Backend/prisma/schema.prisma)
I added the OutboxEvent model with all the fields required by the acceptance criteria:
cuid()eventType— enum (OutboxEventType) covering email, payment, vault, notification, streak, and reconciliation eventsaggregateId— reference to the domain entity (user ID, payment ID, vault ID, etc.)aggregateType— string identifying the domain model typepayload— serialized JSON string of the event dataattemptCount— integer tracking retry attempts, defaulting to 0maxAttempts— integer set to 5 by defaultstatus— enum (OutboxEventStatus) with values PENDING, PUBLISHED, FAILED, DEAD_LETTERnextRetryAt— nullable datetime for scheduling retries with exponential backoffpublishedAt— nullable datetime recording when the event was successfully publishedfailedAt— nullable datetime recording when the event faileddeadLetterReason— nullable string explaining why the event was moved to dead-lettercreatedAtand updatedAt — timestampsI also added the OutboxEventStatus and OutboxEventType enums, plus database indexes on (
status,nextRetryAt), (aggregateId,aggregateType), and (eventType) for efficient querying. I also uncommented the Notification model and added the notifications relation to the User model since the notification service needed it.2. Outbox Repository (Backend/src/repositories/outbox.repository.ts)
I created a new repository class with methods for:
create()— persists a new outbox eventfindPending()— queries for events with status=PENDING, nextRetryAt <= now, and attemptCount < 5, ordered by creation timefindByIdempotencyKey()— checks for existing pending or published events with the same aggregateId + eventType to prevent duplicatesmarkPublished()— sets status to PUBLISHED with a timestamp and clears the retry timestampmarkFailed()— increments attemptCount, sets status to FAILED, computes exponential backoff for nextRetryAt, and records the failure reasonmarkDeadLetter()— moves events to DEAD_LETTER terminal state when max retries are exceededresetFailedEvents()— resets failed events past their retry time back to PENDING for recoverydeletePublishedOlderThan()— cleanup method for pruning old published events3. Outbox Processor (Backend/src/jobs/outbox.processor.ts)
I created a dedicated processor that runs as a continuous loop:
4. Queues Index (Backend/src/queues/index.ts)
I added the
OUTBOX_PROCESSORqueue name and thegetOutboxProcessorQueue()getter function to the existing queues module.5. Jobs Index (Backend/src/jobs/index.ts)
I integrated the outbox processor lifecycle into the jobs module by adding
initializeOutboxProcessor()andstopOutboxProcessorSafe()functions, and exporting them for use in the server startup/shutdown sequence.6. Server (Backend/src/server.ts)
I updated the server to call
stopOutboxProcessorSafe()during the shutdown sequence, ensuring the outbox processor stops polling for new events before Prisma and Redis connections are disconnected. This prevents in-flight publishes from being lost during graceful shutdown.7. Auth Service (Backend/src/services/auth.service.ts)
I replaced the direct
queueVerificationEmail()andqueuePasswordResetEmail()calls with Prisma$transactionblocks that write outbox events atomically alongside domain state changes. The three affected methods are:register()— creates an EMAIL_VERIFICATION outbox event when a new user registersforgotPassword()— creates a PASSWORD_RESET outbox event when a password reset is requestedresendVerificationEmail()— creates an EMAIL_RESEND outbox event when a verification email is resent8. Vault Service (Backend/src/services/vault.service.ts)
I wrapped all vault state-changing operations in Prisma
$transactionblocks that write outbox events alongside domain data:createVault()— creates a VAULT_CLOSE outbox event (for downstream reconciliation)deposit()— creates a VAULT_DEPOSIT outbox event inside the same transaction as the vault transaction creationwithdraw()— creates a VAULT_WITHDRAWAL outbox event inside the same transactionlockVault() — creates a VAULT_LOCK outbox event alongside the vault event recordunlockVault()— creates a VAULT_UNLOCK outbox event alongside the vault event recordcloseVault()— creates a VAULT_CLOSE outbox event alongside the vault event record9. Payment Service (Backend/src/services/payment.service.ts)
I wrapped payment initiation operations in Prisma $transaction blocks:
initiateDeposit()— creates a PAYMENT_INITIATED outbox event inside the same transaction as the payment record creationinitiateWithdrawal()— creates a PAYMENT_INITIATED outbox event inside the same transactionrequestInstructions()— creates a PAYMENT_INSTRUCTIONS outbox event after provider instructions are requested10. Notification Service (Backend/src/services/notification.service.ts)
I created a new notification service that creates notifications and outbox events atomically within a
Prisma $transaction. ThesendNotification()method writes both the notification record and a NOTIFICATION outbox event in the same transaction.11. User Repository (Backend/src/repositories/user.repository.ts)
I added a
createOutboxEvent()convenience helper method to the user repository for creating outbox events with a simplified interface.12. Unit Tests (Backend/tests/unit/outbox.repository.test.ts)
I created comprehensive unit tests for the outbox repository that mock the Prisma client and verify:
13. Integration Tests (Backend/tests/integration/outbox.integration.test.ts)
I created integration tests that verify the core transactional outbox guarantees:
14. Backend README (Backend/README.md)
I added a comprehensive "Transactional Outbox Pattern" section covering:
write phase,publish phase,idempotency,retry with backoff,dead-letter)15. DOCUMENTATION Directory (Backend/DOCUMENTATION/outbox-changes.md)
I created a detailed changes documentation file explaining the problem statement, solution architecture, all modified and new files, the
write/publish/shutdownarchitecture diagrams, idempotency guarantees, retry behavior, event types table, monitoring metrics, and recovery procedures.Bonus Implementation
Beyond the core acceptance criteria, I also implemented several bonus features: