Update on Saheb Development Plan

Purpose of This Documentation

This presentation covers sections that required further research.

Key Points

This is the list of topics we will cover:

    1. Local-First App Architecture
    2. Handling Local Notifications
    3. Auth Flow (Web And Mobile)
    4. Database Technology

    5. Logging Security and Strategy

    6. Processing Tasks with BullMQ in NestJS

    7. OneSignal or Expo

    8. Versioning Strategy

    9. Crash Reporting (Sentry)
    10. Styling
    11. Saving initial data localy?

1. Local-First App Architecture

Local-first apps keep data locally, only using the cloud to synchronize data between machines (or peer‑to‑peer) and it prioritizes storing and manipulating data in a local database on the user's device

ChoiceWatermelonDB (SQLite-based)
Why
    1. Offline-First by Design
      • a high-performance database built on top of SQLite, specifically designed for offline-first and sync-based mobile applications.
      • stores all application data locally on the user’s device using SQLite.

      • The application remains fully usable without any internet connection.

      • All reads and writes happen locally.

      • Network availability does not block user actions.

    2. Synchronization
      • provides a manual synchronization mechanism based on two explicit phases:

        • Push local changes to the backend

        • Pull remote changes from the backend

    3. WatermelonDB does not impose any backend technology, it means u can use it with any backend technology
Setup Example

database.ts

import { Database } from '@nozbe/watermelondb';
import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite';
import { schema } from './schema';
import { Note } from './note.model';

const adapter = new SQLiteAdapter({
  schema,
});

export const database = new Database({
  adapter,
  modelClasses: [Note],
});

schema.ts

import { appSchema, tableSchema } from '@nozbe/watermelondb';

export const schema = appSchema({
  version: 1,
  tables: [
    tableSchema({
      name: 'notes',
      columns: [
        { name: 'title', type: 'string' },
        { name: 'content', type: 'string' },
        { name: 'updated_at', type: 'number' },
        { name: 'deleted_at', type: 'number', isOptional: true },
      ],
    }),
  ],
});

note.model.ts

import { Model } from '@nozbe/watermelondb';
import { field } from '@nozbe/watermelondb/decorators';

export class Note extends Model {
  static table = 'notes';

  @field('title') title!: string;
  @field('content') content!: string;
  @field('updated_at') updatedAt!: number;
  @field('deleted_at') deletedAt?: number;
}

sync.ts

import { synchronize } from '@nozbe/watermelondb/sync'

async function mySync() {
  await synchronize({
    database,
    pullChanges: async ({ lastPulledAt, schemaVersion, migration }) => {
      const urlParams = `last_pulled_at=${lastPulledAt}&schema_version=${schemaVersion}&migration=${encodeURIComponent(
        JSON.stringify(migration),
      )}`
      const response = await fetch(`https://my.backend/sync?${urlParams}`)
      if (!response.ok) {
        throw new Error(await response.text())
      }

      const { changes, timestamp } = await response.json()
      return { changes, timestamp }
    },
    pushChanges: async ({ changes, lastPulledAt }) => {
      const response = await fetch(`https://my.backend/sync?last_pulled_at=${lastPulledAt}`, {
        method: 'POST',
        body: JSON.stringify(changes),
      })
      if (!response.ok) {
        throw new Error(await response.text())
      }
    },
    migrationsEnabledAtVersion: 1,
  })
}
Usage

Quick (over-simplified) example: an app with posts and comments.

First, you define Models:

class Post extends Model {
  @field('name') name
  @field('body') body
  @children('comments') comments
}

class Comment extends Model {
  @field('body') body
  @field('author') author
}
const Post = ({ postId }) => (
  <View>
    <Text>{post.name}</Text>
    <Text>Comments:</Text>
    {comments.map(comment =>
      <EnhancedComment key={comment.id} comment={comment} />
    )}
  </View>
)

const enhance = withObservables(['post'], ({ postId }) => ({
  post: database.collections.get('posts').findAndObserve(postId),
  comments: post.comments
}))

And now you can render the whole Post:

  • if post changes, it re-renders

  • if comments change, it re-renders too


Then, you connect components to the data:

const Comment = ({ comment }) => (
  <View style={styles.commentBox}>
    <Text>{comment.body} — by {comment.author}</Text>
  </View>
)

// This is how you make your app reactive! ✨
const enhance = withObservables(['comment'], ({ comment }) => ({
  comment,
}))
const EnhancedComment = enhance(Comment)

Whenever comment changes in the database, EnhancedComment automatically re-renders with the new data.

Implementing the Sync Backend
Understanding the changes Object

WatermelonDB synchronizes data using a table-based changes object.

Each table contains three arrays:

Example changes Object
{
  "notes": {
    "created": [
      { "id": "n1", "title": "Todo", "content": "Buy milk", "updated_at": 1700000000 }
    ],
    "updated": [
      { "id": "n2", "title": "Ideas", "content": "Startup idea", "updated_at": 1700000100 }
    ],
    "deleted": ["n3"]
  }
}

2. Handling Local Notifications

Notification Types

There are two main types of notifications in mobile applications:

Local Notifications

Push Notifications

Current Choice: Local Notifications Only

why

Background Notification Handling (Prayer Time Example)

Some notifications, such as prayer time reminders, must be triggered even when the application is not actively running.

To handle this, we use:

Expo Task Manager

Example use case:

3. Auth Flow (Web and Mobile)

Overview

The authentication system uses separate flows for end users and admins. Each flow is isolated and independent.

How the Flows Are Separated

1. Separate API Endpoints

  • End Users: All endpoints under /auth/user/*
    • /auth/user/signin
    • /auth/user/me
    • /auth/user/signout
    • etc.
  • Admins: All endpoints under /auth/admin/*
    • /auth/admin/signin
    • /auth/admin/me
    • /auth/admin/signout
    • etc.

2. Separate Database Tables

  • End Users: Stored in users table
  • Admins: Stored in admin_users table
No shared data between the two.

3. Separate JWT Secrets

  • End Users: Use USER_JWT_ACCESS_SECRET and USER_JWT_REFRESH_SECRET
  • Admins: Use ADMIN_JWT_ACCESS_SECRET and ADMIN_JWT_REFRESH_SECRET
Tokens from one flow cannot be used in the other.

4. Different Authentication Methods

  • End Users (Mobile): Bearer token authentication.
  • Tokens returned in response body after login
  • Access token sent in Authorization: Bearer <token> header
  • Refresh token sent in request body when refreshing Mobile-friendly (no cookie dependency)

 

  • Admins (Web): HTTP-only cookie authentication
  • Tokens stored in secure HTTP-only cookies
  • Cookies automatically sent with requests
  • Cookies named AdminAuthentication and AdminRefresh
  • Web-optimized security

5. Complete Authentication Architecture Overview

drawing-1-1768318029.png

4. Database Technology

Choice: PostgreSQL
Why
PostgresSQL (SQL) VS MongoDB (NoSQL)

Based on this amazon article this is the key differences

PostgresSQL vs Mysql Benchmarks

A greate video by Anton Putra called MySQL vs PostgreSQL Performance Benchmark.

First Benchmark overview (INSERT, SELECT)

Screenshot 2026-01-07 at 11.17.01.png

Second Benchmark overview (Read Latency, Finding a Record and Joining Tables)

Screenshot 2026-01-07 at 11.23.25.png

to see each graph details please see the Anton Putra video.

5. Logging Security and Strategies

based on this beautiful article these are the Logging Security and Strategies:

  1. Establish Clear Logging Objectives
    • Decide why you are logging: what problems or goals are you trying to track.

    • Decide what to log: don’t try to log everything.

    • Make logs useful: for errors, include the error and the events leading up to it so issues can be fixed quickly.

     

  2. Do use log levels correctly
    • Here's a summary of common levels and how they're typically used:

      • INFO: noteworthy business events.
      • WARN: Abnormal situations that may indicate future problems.
      • ERROR: errors that affect a specific operation.
      • FATAL: errors that affect the entire program.
  3. Do write meaningful log entries


    • Here's an example of a log entry without sufficient context:

       

      {
        "timestamp": "2023-11-06T14:52:43.123Z",
        "level": "INFO",
        "message": "Login attempt failed"
      }
      

      And here's one with just enough details to piece together who performed the action, why the failure occurred, and other meaningful contextual data.

       

      {
        "timestamp": "2023-11-06T14:52:43.123Z",
        "level": "INFO",
        "message": "Login attempt failed due to incorrect password",
        "user_id": "12345",
        "source_ip": "192.168.1.25",
        "attempt_num": 3,
        "request_id": "xyz-request-456",
        "service": "user-authentication",
        "device_info": "iPhone 12; iOS 16.1",
        "location": "New York, NY"
      }
      

       

  4. Protect Logs and Sensitive Information
    • The mishandling of sensitive information in logs can have severe repercussions, as exemplified by the incidents at Twitter and GitHub in 2018.
    • Do not log sensitive data: passwords, API tokens, session tokens, credit card numbers, Social Security numbers, personal emails, etc.

    • Log references or IDs instead: for example, log a user ID instead of the email or password.

    • Example: Instead of logging the full password:

      • { "userId": "user-123", "password": "********" }
    • log only the user ID to identify the record:
      • { "userId": "user-123", "action": "login_failed" }

6. Processing Tasks with BullMQ in NestJS

We need to perform background tasks, like sending prayer time notifications for example. BullMQ lets you offload work to a worker process so your main server stays responsive.


How the Process Works

1. A job is created (produced)

await this.prayerQueue.add('notify', { userId: 'user-123', prayerName: 'Fajr', time: '05:30', });
At this point, the job is stored in Redis (BullMQ’s backend).

2. The job waits in the queue


3. Single Worker Picks Up the Job


4. Job Completion


Single-Worker Approach

Limitation:


Scaling with Multiple Workers

Example with multiple workers:

Job Flow
  1. Main server receives a request to schedule a notification.

  2. It adds a job to prayerQueue in Redis.

  3. BullMQ distributes the job to any available worker:

    • Worker1 (on main server)

    • Worker2 (on separate server/process)

  4. Each job is executed only once, even if multiple workers are running concurrently.


Key Points

7. OneSignal or Expo

Features & SDK Support

Expo

OneSignal

Conclusion here:
Expo is simpler but more limited. OneSignal is more flexible and enterprise-ready.


Pricing

Onesignal

Expo

Pros & Cons

Expo


OneSignal

Conclusion

Based on our requirements, OneSignal is the best choice for our push notification system. In our case, we will use push notifications only for Android and iOS mobile devices, and for this use case, OneSignal provides mobile push notifications for free with no sending limits.

 

Screenshot 2026-01-07 at 15.35.14.png


8. Versioning Strategy

API Versioning Strategies Comparison

Chosen Approach: URL-Based API Versioning

We will version our APIs using the URL path, for example:

/api/v1/prayer-times
/api/v2/prayer-times
Strategy How it Works Pros Cons
URI Versioning The version is part of the URL path (e.g., /api/v1/users). Very easy to use and test; clear and readable; works perfectly with mobile apps; widely used in real-world APIs. URLs become longer
Header Versioning The version is sent in a custom HTTP header (e.g., X-API-Version: 1). Keeps URLs clean; allows one endpoint for multiple versions. Harder to debug; mobile clients must always send the correct header; mistakes can break requests.
Media Type Versioning The version is included in the Accept header (e.g., application/vnd.myapi.v1+json). Follows strict REST principles (URL represents the resource, header describe how the resource is represented); very flexible. Complex to implement and understand; difficult for mobile apps; overkill for most projects.
Query Parameter Versioning The version is passed as a query parameter (e.g., /users?v=1). Simple to implement; easy to test. easy to misuse; looks like optional data instead of a contract; not recommended for long-term APIs.
Custom Versioning Custom logic extracts the version from any part of the request. Maximum flexibility for special cases. High complexity; harder to maintain; easy to introduce bugs; rarely needed.

This strategy was chosen after comparing it with header-based and query-parameter versioning.


Why URL Versioning Is the Best Choice

9. Sentry

Sentry is an error monitoring and crash reporting tool for both web and mobile applications. It captures:

Integration:

Plans:

10. Styling

Theme Architecture

All design tokens are centralized in a single theme object exported from @/src/theme and it is based on Saheb Figma Design System:

import { theme } from '@/src/theme';

// Colors
theme.colors.primary[400]
theme.colors.text.primary

// Typography
theme.typography.heading
theme.typography.bodyB2

// Spacing
theme.spacing.xl
theme.spacing.md

// Border Radius
theme.radius.lg
theme.radius['2xl']
Color System
export const Colors = {
  light: {
    primary: { 100: '#bfd6e5', 200: '#80adcb', 300: '#4085b2', 400: '#005c98', 500: '#004572' },
    secondary: { 100: '#00ffff', 200: '#02d7d7', 300: '#02a9a9', 400: '#1b9bd8', 500: '#0c74bb' },
    neutral: { 100: '#ffffff', 200: '#e8e8e8', 300: '#d2d2d2', ..., 1000: '#333333' },
    text: { primary: '#0a0a0a', secondary: '#6a7282', tertiary: '#9ca3af', inverse: '#ffffff', placeholder: '#d1d5db' },
    background: { primary: '#ffffff', secondary: '#f9fafb' },
    border: { default: '#e5e7eb', light: '#f3f4f6', dark: '#d1d5db', focused: '#005c98' },
    // ... semantic colors (red, yellow, green)
  }
}
Typography
export const Typography = {
  heading: { fontFamily: Fonts.cairo.bold, fontSize: 24, lineHeight: 32 },
  sectionTitle: { fontFamily: Fonts.cairo.semiBold, fontSize: 18, lineHeight: 24 },
  bodyB2: { fontFamily: Fonts.cairo.black, fontSize: 16, lineHeight: 22 },
  bodyB3: { fontFamily: Fonts.cairo.regular, fontSize: 14, lineHeight: 20 },
  bodyB4: { fontFamily: Fonts.cairo.black, fontSize: 12, lineHeight: 20 },
  bodyB5: { fontFamily: Fonts.cairo.medium, fontSize: 14, lineHeight: 20 },
  button: { fontFamily: Fonts.cairo.bold, fontSize: 16, lineHeight: 24 },
  buttonSecondary: { fontFamily: Fonts.cairo.medium, fontSize: 16, lineHeight: 24 },
  caption: { fontFamily: Fonts.inter.regular, fontSize: 12, lineHeight: 16 },
  small: { fontFamily: Fonts.inter.regular, fontSize: 11, lineHeight: 16 },
  tiny: { fontFamily: Fonts.cairo.regular, fontSize: 10, lineHeight: 14 },
}
Spacing System
export const spacing = {
  none: 0,
  xs: 2,      // 2px
  sm: 4,      // 4px
  md: 8,      // 8px
  lg: 12,     // 12px
  xl: 16,     // 16px
  '2xl': 20,  // 20px
  '3xl': 24,  // 24px
  '4xl': 32,  // 32px
  '5xl': 40,  // 40px
  '6xl': 48,  // 48px
  '7xl': 56,  // 56px
} as const;
Border Radius
export const radius = {
  none: 0,
  xs: 2,      // 2px
  sm: 4,      // 4px
  md: 6,      // 6px
  lg: 8,      // 8px
  xl: 10,     // 10px
  '2xl': 12,  // 12px
  '3xl': 16,  // 16px
  full: 9999, // Circular
} as const;


Revision #31
Created 2026-01-07 08:40:54 UTC by EL MAHDI
Updated 2026-01-17 09:25:47 UTC by EL MAHDI