This solution creates a notification center for Book of the Month subscribers built with a TypeScript MVC architecture.
- Runtime: Node.js
- Language: TypeScript
- HTTP Layer: Koa
- Database: Aurora MySQL
The first step in approaching this project was breaking it down into into smaller parts based on the requirements. Based on the product spec, we know the following:
- The notification center needs to support three types of notification triggers (user event, filtered, and a CSV upload of users)
- At this time we are only concerned with in-app notifications (notifications visible when a user clicks on their notification center in the app)
- Need to be able to schedule delivery of notifications based on things like delay, monthly notifications, etc.
- Notifications need to have individual headlines, thumbnails, and other metadata. We also need a way to differentiate notifications that have are read vs. unread.
The second thing to think about before jumping into design and implementation are any potential concerns or considerations there might be right off the bat.
- Scalability: How many notifications are being sent out per day to users? How many users does this system need to be able to support?
- Modularity: While, we have a limited set of requirements at this time, what steps should we take to ensure that this framework is modular and can support adding new features? For example, we may want to add SMS and email notifications in the future. Or, we may want to create new types of notification triggers. Ideally, a good solution should be able to adapt to these types of changes easily.
- Storage: What is the best way to store data efficiently so we can retrieve important information? Are there any SQL queries that may get expensive (specifically for filtered notifications)?
The following is a simple sketch highlighting the different components necessary for this system and how they should interact with one another. The main components of this design include an API gateway for creating, retrieving, updating, and deleting notifications and a data model for both user accounts and notifications.
At a high-level, this project follows a Model-View-Controller (MVC) pattern:
- Models (
src/models/) — TypeScript interfaces and MySQL query functions - Controllers (
src/controllers/) — Creates the API gateway for creating, reading, updating, and deleting notifications - Routes (
src/routes/) — Maps API endpoints to controller functions - Services (
src/services/) — Background jobs: notification queue processor and cleanup of expired notifications - Middleware (
src/middleware/) — Global error handling - DB (
src/db/) — Aurora MySQL connection pool and table definitions
For this implementation, there should be two database tables for accounts and notifications with the following fields.
Represents a BOTM subscriber.
| Field | Type | Description |
|---|---|---|
id |
number | Primary key |
country |
enum | US, CA |
policy |
enum | monthly, annual |
relationshipStatus |
enum | new_member, friend, bff |
credits |
number | Available book credits |
Represents a single notification record.
| Field | Type | Description |
|---|---|---|
id |
number | Primary key |
user_id |
number | FK to accounts |
notification_type |
enum | filter, event, csv |
headline |
string | Primary notification text |
subheadline |
string | Secondary notification text |
thumbnail |
string | Image URL |
link |
string | Action URL |
status |
enum | pending, sent, failed |
is_read |
boolean | Whether the user has clicked on the notification |
read_at |
datetime | When the user read it |
is_active |
boolean | Whether the notification is active |
is_remove_from_app |
boolean | Whether to remove this notification from the app |
next_send_date |
datetime | When the notification should be sent next (for notifications that are meant to be sent multiple times). |
sent_at |
datetime | When the notification was sent |
created_at |
datetime | When the notification was created |
After creating the data models, key query functions were added that will be consumed by controller logic in the next section. The main queries are the following:
getFilteredAccountsqueries the accounts table with any combination ofcountry,policy,relationshipStatus,min_credits, andmax_credits. All filters are optional — omitting a filter returns all values for that field, and passing no filters returns all accounts.
getAllSentNotificationsForUserreturns notifications for a given user wherestatus = sentandis_remove_from_app = false, ordered newest first. This is the query used by the GET endpoint to populate the notification center.getPendingNotificationsreturns all notifications wherestatus = pendingandnext_send_dateis in the past, ordered bynext_send_dateascending. This is the query used by the queue processor to find notifications that are due to be sent.updateNotificationupdates any combination of fields on a notification record includingstatus,sent_at, andnext_send_date. Used by the queue processor after dispatching a notification.
Note
Something that would definitely be flagged here in a true code review process would be the complexity of these query functions as many of them could potentially be extremely slow, especially on a notifications table with a large number of entries. Ideally, I would require an EXPLAIN on any expensive query statements to show whether there are any indexes or changes needed.
A large part of the logic for the notification service lies in the controller implementations. The notification center exposes two categories of API endpoints. The first handles per-user notifications — creating, retrieving, updating, and deleting notifications for a specific user. The second handles bulk notification creation, either by querying accounts that match a set of filters or by uploading a CSV of account IDs.
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/users/:userId/notifications |
Creates a notification for a user triggered by an event action. Accepts an optional delay (days) to schedule delivery. |
GET |
/api/users/:userId/notifications |
Gets all sent, active notifications for a user ordered by newest first |
PATCH |
/api/users/:userId/notifications/:notificationId |
Updates a notification. Passing is_read: true marks it as read with a timestamp. |
DELETE |
/api/users/:userId/notifications/:notificationId |
Deletes a notification record from the DB |
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/notifications/filtered |
Queries accounts by country, policy, relationshipStatus, min_credits, max_credits and creates a notification for each match |
POST |
/api/notifications/csv |
Accepts a CSV of account IDs and creates a notification for each one |
Note
An important callout here would be that none of the endpoints currently support pagination and return all entries without any limit. For a real implementation of a notification center, this is worth addressing as a user could accumulate a large number of notifications over time.
In a fully fleshed out implementation, we would add a hook for any of the POST endpoints wherever in the codebase there is a notification trigger. For example, if purchasing a book is a user event that should trigger a notification, we should add an API request to the /api/users/:userId/notifications endpoint wherever we handle logic for purchases in the codebase. This will create a new notification record and kickstart the pipeline for processing and delivering this notification to the user.
Two cron jobs run automatically when the server starts:
- Queue processor — runs every minute, picks up
pendingnotifications wherenext_send_date <= NOW()and dispatches them - Cleanup job — runs daily at midnight, hard deletes notification records older than 2 months
A simple HTML page served at http://localhost:3000 that displays a user's notifications based on mock data we've created. This is just an extremely simple proof of concept in order to show how a frontend implementation would use the API endpoints to show a user's notifications.
We load a user's active and sent notifications by making a GET request to the /api/users/:userId/notifications endpoint. Unread notifications are highlighted with a blue left border and bold headline. Clicking a notification marks it as read via the PATCH endpoint and updates the UI without a page reload.
- AI usage was helpful for scaffolding the file structure for the project, building out a simple frontend implementation, mocking out fake database data, and creating test cases.
- In this implementation, the notifications table doubles as a queue for simplicity — no separate message broker is needed. Notifications with
status = 'pending'andnext_send_date <= NOW()are picked up by a cron job and dispatched. In a true, production-ready implementation, it would be much better to use a robust queueing system such as Kafka. - There is no retry logic for failed notifications. If a notification fails to send, it is marked as failed and never retried. A retry mechanism with a max attempt count would improve reliability.
- The send() function in the queue processor is currently just a stub because the product spec was only concerned with showing notifications in the app notification center. If we want to be able to send different types of notifications (e.g. SMS, email) we would need to expand this solution and build out channels of delivering these notifications.
- There is no logging beyond the nightly cleanup count. Failed notifications, queue processing runs, and delivery attempts should be logged so issues can be diagnosed.
- Proper unit tests are a must for model query functions and integration tests for the API endpoints would catch regressions as the system grows. I've created a
controllers.test.tsfile to create stubs for various controller test cases as a proof of concept, but more robust testing would be needed using Jest as the testing framework.