Introduction

There are cases where you would want to implement push notifications in your app without using services such as OneSignal. This article will share one way to achieve this. This article will focus on the building blocks used, not the configurations of Apple and Android - there are many articles on that subject.

✍🏾 Note

There are many benefits to using external services to implement push notifications, this article by no means makes an argument against using such services.

Core requirements

The basics

The goal of this article is to outline the design thinking behind this implementation, not necessarily what technology to use. With that said, here are some technologies used in this implementation:

  1. A database such as PostgreSQL with triggers or anything equivalent e.g. Firestore. This should be the primary data storage for your application - where your notification objects are stored.
  2. An API endpoint where this trigger will send information. This can be a Firebase function, Supabase edge function, or even a lambda function on AWS.
  3. A service or tool to run cron-jobs to create those scheduled notifications. If you are using PostgreSQL, you can add pg_cron extension to do this for you.

Types of notifications

Before we get to the “how”, it is important to talk about the “what”.

What are the types of notifications we are interested in sending to our users? For Zaka Manager, those are scheduled and event base notifications.

  1. Scheduled - These are created using a cron job. For example, the “Create Budget” notification is triggered if a user does not have an active budget, and we have not notified them of this in the past 7 days.
  2. Event base - These are notifications sent based on a specific event that can happen at any time. For example, we allow users to add recurring transactions in the app. On the next recurrence of such a transaction, we send the user a notification that a recurring transaction was added.

The final implementation

Enough hong-hong

Below is a diagram showing all the components involved in delivering notifications to users.

Illustration of components used in Zaka Manager push notifications implementation.

As can be seen in the above diagram, different services can insert notification rows intopublic.notifications table. These services can range from our machine learning service, our backend service and even the cron service mentioned previously.

Using triggers, we have set up webhook mechanisms that listen to insert operations onpublic.notifications table. At this point, everything should be clear from the diagram.

Some code implementation

Let us decorate this article with some code shall we.

Inserting notifications using a cron job

Here is a simple function to insert notifications for all users with no active budget.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
create or replace function insert_budget_push_notifications() 
returns void 
language plpgsql 
security definer as $$
declare
    row record;
begin
  for row in filtered_users_joined_with_notification_configuration loop
    insert into public.notifications (title, body, owner_id, configuration_id, metadata, updated_at) 
    values (
      'Unlock budget insights! 🗓️',
      'Let''s get budgeting! Don''t guess, control. Manage your money like a boss with a budget. 💼',
      row.user_id,
      row.id,
      jsonb_build_object(
        'notification_configuration',
        jsonb_build_object('id', row.id, 'disabled', row.disabled, 'notification_type', row.notification_type)
      ),
      current_timestamp
    );
  end loop;
end;
$$;

I excluded the code that sets filtered_users_joined_with_notification_configuration as it is a bit cumbersome and adds no value to this post. The TL;DR is that this variable is set using a join of auth.users and public.notification_configurations with the following where conditions:

  • The user is active - email confirmed. This is for obvious reasons.
  • The user has enabled budget notifications in the app. As previously mentioned, we give users the freedom to pick which notifications they want to receive.
  • They haven’t received a notification in the past 7 days. We give a user a week before we remind them again.

To run this function, we use the pg_cron extension to set up a cron job that runs every 6 hours. This is arbitrary, you can choose any reasonable frequency.

1
2
3
4
5
select cron.schedule(
  'invoke-insert-budget-push-notifications-fn',
  '* */6 * * *',
  'select public.insert_budget_push_notifications()'
);

Again, the inserting of notifications into the DB can be triggered by any service. The above is just one of them.

Using triggers to call the Firebase function

Before we look at the Firebase function, we first need to set up a trigger that makes a call to the Firebase function. This trigger needs to listen to any insert into the table. That is:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
create or replace function firebase_send_notification()
returns trigger
language plpgsql
as $$
begin
  perform net.http_post(
    url := 'https://sendnotifications.run.app',
    body := jsonb_build_object('type', TG_OP, 'table', TG_TABLE_NAME, 'record', row_to_json(new), 'schema', TG_TABLE_SCHEMA),
    headers := '{"Content-Type": "application/json"}'::jsonb,
    timeout_milliseconds := 5000
  );
  return new;
end;
$$;

create trigger "firebase-send-notification-webhook"
  after insert on public.notifications
for each row
  execute function firebase_send_notification();

The above should be obvious. What should also be obvious is what you send to the firebase function as the body depends on your requirements. But you must send the information about the row that was inserted. From the above snippet, this is done using row_to_json(new) where new is the row that was just inserted into the table.

The firebase function to trigger FCM

With the trigger setup, now it is time to look at the logic inside the Firebase function.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import * as express from "express";
import * as logger from "firebase-functions/logger";
import * as admin from "firebase-admin";
import {onRequest, Request} from "firebase-functions/v2/https";
import {setGlobalOptions} from "firebase-functions/v2/options";
import {Database} from "./types.generated";
import {createClient} from "@supabase/supabase-js";

setGlobalOptions({ maxInstances: 10, region: "europe-west1", concurrency: 100, memory: "128MiB" });
admin.initializeApp();

export const sendNotifications = onRequest(async (
  request: Request,
  response: express.Response
) => {
  logger.log("Request body: ", request.body);

  const supabase = createClient<Database>({...});

  const {data, error} = await supabase.from("devices")
    .select("*")
    .eq("deleted", false)
    .eq("owner_id", request.body?.record?.owner_id);

  if (error) {
    response.send(JSON.stringify(error, null, 2));
    return;
  }

  logger.log("Devices: ", JSON.stringify(data, null, 2));

  const notificationConfiguration = request.body
    ?.record
    ?.metadata
    ?.notification_configuration;

  const disabled: boolean = notificationConfiguration?.disabled??false;


  if ((data?.length??0) > 0 && !disabled) {
    const tokens = new Set(data.map((device) => device.fcm_token));
    const results = await admin.messaging().sendEachForMulticast({
      tokens: Array.from(tokens),
      notification: {
        title: request.body.record.title,
        body: request.body.record.body,
      },
      fcmOptions: {
        analyticsLabel: notificationConfiguration?.notification_type,
      },
    });
    logger.log("Notifications have been sent.");
    logger.log("Message send response: ", JSON.stringify(results, null, 2));
    response.send(JSON.stringify(results, null, 2));
    return;
  }
  response.send(JSON.stringify({
    message: "No FMC Tokens provided or notification is disabled!",
  }, null, 2));
});

From the function above, we basically use the information we got from the trigger as shown previously to fetch devices FCM tokens that are saved in our db every time the user opens our app. Using that information and the notification configuration data, we can use Firebase admin sdk to send multicast notifications to the given user.

Conclusion

I really had fun building this notification feature in the Zaka Manager app hence why I thought I would share what I learned. What I really like about this setup is that is decoupled and modular. Basically:

  1. Insert the notification in your data storage(DB, Firestore etc)
  2. Have a hook that is triggered on insert. This hook can tell any service to send this push notification. In our case, Firebase.

To show the benefit of this, initially, we were using the Supabase edge function to trigger the push notification using firebase-sdk. We had scale issues where edge functions would time out and not scale with the increase in demand. We then switched to Firebase functions. This change just meant we needed to update the trigger to call a different endpoint.

Additionally, if I want to add more notification types in the app, all I need to touch is the logic that adds that notification to public.notifications. Everything after that does not need any updating. This is why I don’t even need to show you how event-based notifications we discussed above are sent, that is because all you need to do is insert that notification in the db when the event happens and call it a day.

There might be corner cases and other performance requirements that this solution does not handle well. But for a small app with few users, this should do the job.