Over time, our communication stack has grown organically across multiple tools, delivery channels, and implementation paradigms. As a result, we now face the following challenges:
<aside> 💡 Fragmentation: Communication to users happens via banners, push notifications, OneSignal journeys, in-app cards, emails, and surveys—all with different data sources, logic, storage, and rendering mechanisms.
Duplication and Redundancy: Some messages are sent through multiple channels simultaneously often with inconsistent content or tone.
Poor Maintainability: Some systems rely on brittle implementations or schema-less migrations for push messages. Others require manual PRs to deliver updates.
Inefficient Targeting: There’s no shared system of targeting or segmentation across all communication channels, which limits personalization and leads to user fatigue.
Limited Observability: There is no unified place where users can see what messages they've received or dismissed, nor any clear audit trail for internal teams. (we have logs but not sure folks know how to check them or filter them?)
Inconsistent UX and Tone: Each channel looks and feels different, with no unified design system or voice. Learn and Manage handle UI logic differently for the similar message types (e.g., in-app notifications).
Hard to Scale: Adding new campaigns or flows requires one-off logic and duplication of work across product, design, and engineering.
</aside>
here you can find the diagram
We propose building a centralized Messaging Infrastructure Layer, consisting of:
messages Tableid, created_at, updated_at, cta, created_by, source (learn, manage, teach).| id | created_at | updated_at | cta | created_by | source |
|---|---|---|---|---|---|
| 1 | … | … | <null> | 763212 | learn |
| 2 | … | … | <null> | 718955 | learn |
With the following supporting schema:
message_contents Tabletype_idcontent can be a JSON that houses either the default information (title, body, etc.) but also other extra, like images, or even a componentmessage_contents can belong to a same message_id, this way we have the user dismiss all message_contents from the same messages source (prevents upsetting user with spamming)| id | timestamps | type_id | content | message_id |
|---|---|---|---|---|
| 1 | … | 3 | {…} | 1 |
| 2 | … | 2 | {…} | 1 |
| 3 | … | 2 | {…} | 2 |
message_types Tabledelivery_channel specifies the channel in which the message type should be sent, e.g. we can have multiple notification types but there are different channels (OneSignal, in-app) for each type;snooze field represents whether 1. the message type can be dismissed (and show up later); 2. how much time the message will show up again after being dismissed (if not past the actionable_until date)| id | timestamps | name | delivery_channel | snooze (hours) |
|---|---|---|---|---|
| 1 | … | notification | OneSignal | <null> |
| 2 | … | notification | in-app | <null> |
| 3 | … | nps survey | in-app | 48 |
| 4 | … | notify-lessons | targeted-banner | <null> |
message_targets Tablecreating flag.target_type represents which class type target_id belongs to (organization, segment …) → no users, since this would trigger a bunch of messages queueing up. Specifically for the *all* type, we'll implement safeguards to permit only specific message types to send this kind of message (ui-only banners, for example)actionable_until is the field that specifies the message_targets’ expiration date, it's important, for example, in cases where we always reuse a same old message but on different periods of time (e.g. "Your Graduation Test is ready!” but on different graduations, so we need different expiration dates). This field will have a default date (something like 14 days from now);starts_at data (to support this, we'll also implement backoff handling + missed-job recovery logic).| id | created_at | updated_at | starts_at | actionable_until | message_id | target_type | target_id |
|---|---|---|---|---|---|---|---|
| 1 | … | … | <null> | <null> | 1 | segment | 1 |
| 2 | … | … | 20th June | 30th June | 1 | organization | 250 |
| 3 | … | … | <null> | <null> | 2 | segment | 2 |
| 4 | … | … | 20th June | 30th June | all | <null> |
message_segments tabledefault segments (will show up as options);default);| id | timestamps | default | slug | user_ids_query | type |
|---|---|---|---|---|---|
| 1 | … | true | premium_users |
“SELECT DISTINCT users.id FROM users INNER JOIN classrooms_licenses ON classrooms_licenses.user_id = users.id INNER JOIN classrooms_offerings ON classrooms_offerings.id = classrooms_licenses.offering_id AND classrooms_offerings.active WHERE classrooms_offerings.name in ('Premium') AND (classrooms_licenses.ends_at > current_timestamp)” | sql |
| 2 | … | true | premium_and_closed_premium_users |
“SELECT DISTINCT users.id FROM users INNER JOIN classrooms_licenses ON classrooms_licenses.user_id = users.id INNER JOIN classrooms_offerings ON classrooms_offerings.id = classrooms_licenses.offering_id AND classrooms_offerings.active WHERE classrooms_offerings.name in ('Premium', 'Closed Premium') AND (classrooms_licenses.ends_at > current_timestamp)” | sql |
| 3 | true | pending_graduation_test_users |
“`SELECT users.id FROM users INNER JOIN tests ON tests.user_id = users.id INNER JOIN test_collections ON test_collections.id = tests.collection_id WHERE (tests.expires_at > current_timestamp) AND tests.started_at IS NULL AND tests.ended_at IS NULL AND tests.recreated_at IS NULL AND tests.cancelled_at IS NULL AND tests.score IS NULL AND (test_collections.name = 'Class assessment %' ** | ||
| 4 | false | slang_premium |
{”organization_id”: 1, | ||
| ”fields”: { | |||||
| ”table”: …, “join_users_on": “id”, “join_table_on”: “user_id”, “field_1”: {…}, “field_2”: {…} | |||||
| }} | json |
user_message_logs TableTracks user-based action e.g. reads, dismissals… (like it was done in user_event_logs for some dismissals prior).
This information will be stored in Clickhouse instead of Postgres;
With indexed message_target_id and user_id for faster querying.
Data source for in-app reports and analytics.
Some event options include: dismissed (will be sent again according to the corresponding message_types’ snooze), read, pinned, filled, unpinned.
| id | timestamps | user_id | message_target_id | event |
|---|---|---|---|---|
| 1 | … | 763212 | 1 | dismissed |
| 2 | … | 763212 | 2 | read |
| 3 | … | 763212 | 1 | pinned |
| 4 | … | 763212 | 1 | filled |
we'll keep user submissions in their respective current tables, but the 'sending’ logic will be held by the process and schemas we described above.