Backend for the nightlight app:
- Node.js + Express.js for RESTful API
- MongoDB for database
- SocketIO for group location sharing
- Messaging queue for delay actions and expiring data.
Our docker-compose.yml
spins up two containers:
- a redis service, which uses an image pulled from docker hub.
- our express + worker, which uses an image pulled from our private registry.
CI/CD:
- The
main
branch is configured for CD to automatically re-build our image on Azure Container Registry (ACR), which is then automatically pulled from our Azure Container Apps (ACA) and deployed atApplication Url
.
- It is preferable to use the deployed backend URL (see Application Url under our Azure Container App), rather than pull the image from Azure Container Registry, unless you are making changes to the backend, in which case read below.
When developing on a separate branch, you would ideally:
- Write code ✨
- Build the image locally
docker build . -t nightlight.azurecr.io/nightlight-backend --no-cache
- Run container locally
docker compose up
- See it work!
Tests are not run through Docker, so you do not need to spin up Docker.
- Run redis and worker in the background
npm run start:test
- Then run the tests
npm run test
- Smile while you see the tests pass! (hopefully)
Note: Make sure MONGODB_URI is pointing to our test db when running tests!
{
"_id": mongoose.Types.ObjectId,
"firebaseUid": string,
"imgUrlProfileSmall": string,
"imgUrlProfileLarge": string,
"imgUrlCover": string,
"firstName": string,
"lastName": string,
"email": string,
"phone": string,
"birthday": string,
"currentGroup": mongoose.Types.ObjectId | undefined,
"friends": mongoose.Types.ObjectId[],
"friendRequests": mongoose.Types.ObjectId[],
"lastActive?": {
"location": {
"latitude": Number,
"longitude": Number,
},
"time": string,
},
"savedGroups": [{
"name": String,
"users": mongoose.Types.ObjectId[],
}],
}
{
"_id": mongoose.Types.ObjectId,
"name": String,
"members": mongoose.Types.ObjectId[],
"invitedMembers": mongoose.Types.ObjectId[],
"expectedDestination": {
"latitude": Number,
"longitide": Number,
},
"creationDatetime": String,
"expirationDatetime": String,
}
{
"_id": mongoose.Types.ObjectId,
"address": String,
"location": String,
"reactions": [{
userId: String,
emoji: String,
date: String
}]
}
{
"_id": mongoose.Types.ObjectId,
"userId": mongoose.Types.ObjectId,
"title": String,
"body": String,
"data": Object,
"delay": Number
}
When the REST server is started it also creates the queue using the queue.setup.ts file with a specific name (nightlight-queue). The queue assumes the presence of a redis container running on port 6379. To add to the queue we use jobs.
Jobs are how the REST server adds to the queue. In jobs.ts we have a function called addGroupExpireJob which takes in the groupId to be added to the queue and a specified delay in milliseconds. We add the job to the queue with a job name (string), the job data (a type and the groupId) and the delay. There is an interface for the Job for extra type checking.
The worker is a seperate node process in the same codebase that is run in parallel to the REST server, also assuming the presence of a redis container at port 6379. The worker's function is to intently stare at the redis queue (which was marked by the name setup in the queue.setup.ts) and to emit actions when the queue items pop out of the queue. When setting up the worker in workers.setup.ts, we have to connect to mongo again since this is a seperate process. The worker handler function takes the job and decides what to do with that job based off of the type (string) in the job. Internally, the delayed queue jobs are put on a seperate queue while they wait to expire while the jobs that can be processed at the current instance are handled on a seperate main queue.
The worker functions in worker.ts do the actions based on the jobs popped off the queue. In this case we are deleting the group from mongo. Since the mongo model exists within the same codebase as both the worker and the REST server, we do not have to duplicate the code despite the worker being a seperate node process.
In group.controller.test.ts at the end of the first describe block you can see that we get the group (which should be successful) and delay the tests for 5000 milliseconds. When we get the group again it should not exist since the delay in the queue was set to 3000 milliseconds in the createGroup function in group.controller.ts. We'll have to modify this in the future to do the actual delay.
When sending any request, be sure to include the firebase id in the headers of the request.
Also, make sure the environment variable in the .env is sent to anything but 'development'
.
fetch(url, {
headers: {
Authorization: `Bearer ${firebaseId}`,
},
}).then(response => {
// code
});
Push notifications are sent to the user in the following function, which uses the expo push notifications service that handles the architecture and security relate to apple push notification services (APNS):
export const sendNotificationToExpo = async (notification: ExpoNotification) => {
// code
};
When sending a notification to expo, we use an interface defined by the expo push notification service:
interface ExpoNotification {
to: string;
sound: string;
title: string;
body: string;
data: Object;
}
We are going to have to store notifications in the database for a variety of reasons. We can store them in a separate collection and index them by a user’s mongo id:
interface MongoNotification {
userId: string; // indexed by this
title: string;
body: string;
data: NotificationData;
delay: number;
}
where we store the notification data in a nightlight defined object:
interface NotificationData {
notificationType: String;
}
The notification type in the mongo document will be a string that invokes differences in the notification screen. For example we want notifications for friend requests, group invites, group updates, check ins, pings, etc. We will have to be able to distinguish between them (since the actions will be different for each type of notification)
The backend has an endpoint to handle adding notifications but the user will never use this endpoint. The endpoint can be used by us for sending marketing and other notifications to the users but there is never a case where a user needs to send a notification that isn’t accompanied by an action done in another endpoint.
Sending notifications is a separate function in the backend that can be called by any endpoint or worker function. It's important to note that a notification caused by a user action will never be put onto the queue. For example, if a group is created we don’t want to put a notification job and a group expiration job on the queue. Instead, we want to add just the group expiration job and when the group expires we want to send a notification from the worker that is completing the group expiration job.
The function to send a notification is found in notification.utils.ts
and headered as follows:
export const sendNotifications = async (
userIds: string[],
title: string,
body: string,
data: NotificationData,
delay: number = 0
) => {
// code
});
The function sends both a notification to the mongo and through the expo push notifications service. This is important to know because it means that this function is the only function in notification.utils.ts
that should be called by a user action. The functions sendNotificationToExpo
and sendNotificationToUser
are helper functions that simplify the code of the sendNotifications
functions. The only exception to this is in the admin endpoint described before that will not be used by the users (the endpoint for sending notifications to the mongo only). If the user does not have a push notifications token (the token is undefined in their user document), the function will simply skip over them and not attempt to send the push notification.
The notifications are done automatically by the backend based on the endpoint that is called. The function can be passed a list of user ids that the notifications can be sent to with a common title, body, data, type, and delay.
We need to be sure to remember that some users will not have push notifications enabled. Therefore, the notifications should still be added to mongo even if they are not pushed through expo.
Notifications are secondary to any action that is completed in the database. For example, if the user creates a group and the notification fails to send to either the database or the expo, the database should remain if the group was created correctly. This means all of the notification stuff needs to be no throw or made in some way such that the success of the notifications do not have an effect on the success of the http request as a whole.
In the future, users will have notification preferences that will be checked against when sending push notifications. Users will have the choice to only receive push notifications for certain actions which they can choose in the app settings.
- Delete user ✅
- Update venue ✅
- Save group post ✅
- Save group delete ✅
- Refactor user for group invitations ✅
- Invite member to group post ✅
- Invite member group delete ✅
- Accept invitation to group patch ✅
- Delete user from group (leave group) TODO
- Get venues (with pagination - get 10 at a time) ✅
- Refactor for receivedFriendRequests ✅
- Send friend request (post) ✅
- Accept friend request (post) ✅
- Upload profile image (use queue) ✅
- Replace profile image (use queue) ✅
- Delete profile image (use queue)
- Upload cover image (use queue)
- Replace cover image (use queue)
- Delete cover image (use queue)
- Reaction expire ✅
- Group expire ✅
- Notifications 💀 (use queue) ✅