Skip to content

Feature: Notifications for Reel / Video Itinerary Scans

ID: 0011
Status: Draft
Owner: @satya
Created: 2026-05-14
Updated: 2026-05-14
Related ADRs: 0001 (Flutter mobile), 0002 (Supabase + Nest), 0003 (Python workers)
Depends on: 0008 (reel video imports), 0010 (mobile share-sheet)
Supersedes: —

Reel imports are async. The killer flow is: user shares an IG reel via the OS share-sheet (spec 0010) → picks a trip → returns to Instagram. The Python worker may take 10–30s (Gemini upload + generate) before the draft is ready. Today there is nothing that pulls the user back to Treeper when the scan completes — they have to remember and reopen the app. The Notifications tab is a placeholder (apps/mobile/lib/features/notifications/view/notifications_tab.dart) with _fakeItems; we replace it with real events and add OS push so backgrounded users get pulled back in.

Persona P1 — Solo planner: shares reels while commuting, expects to be told when the draft is ready rather than polling the app.

  • F0011.1 In-app notifications surface — Notifications tab + foreground banner — driven by Supabase Realtime on public.imports.
    • F0011.1.a Replace placeholder cards in notifications_tab.dart with a real NotificationsCubit-backed list.
    • F0011.1.b Foreground banner (MaterialBanner) when the user is in-app but not on the Notifications tab, with a “Review” action that deep-links to the import review page.
    • F0011.1.c Tab badge / dot in the root shell when unread > 0.
  • F0011.2 Unread state — derived from user_prefs.notifications_last_seen_at.
  • F0011.3 OS push notifications via FCM (handles APNs under the hood).
    • F0011.3.a Device-token registry table + register/unregister endpoints; auto-register on login and on token refresh.
    • F0011.3.b Tap-through deep-link to the import review screen (/trips/:tripId/imports/:importId/review).
    • F0011.3.c Quiet rules: push on ready/failed, never on committed / discarded (user-initiated transitions).
  • F0011.4 Postgres outbox for durable, retryable push delivery (no Redis — see §10).
    • F0011.4.a notification_outbox table written in the same transaction as the imports.status flip via a DB trigger.
    • F0011.4.b Nest worker drains the outbox via LISTEN/NOTIFY with a 30s safety poll fallback.
    • F0011.4.c Exponential backoff retries; dead-letter after N attempts with status='failed' + error populated.
    • F0011.4.d Idempotency: unique index on (user_id, dedupe_key) so a worker retry / status re-flip doesn’t double-send.
  • Email digests, SMS, in-app realtime broadcast to other devices.
  • Trip-mate / invite / share-link notifications (separate slice when those features ship; the outbox shape is reusable).
  • User notification preferences screen (Settings) — toggle types, quiet hours, channel routing. Defaults only for now.
  • Scheduled / delayed notifications (reminders, daily digests).
  • Web / desktop push.
  • As P1, after I share a reel via the OS share-sheet, I get a push notification when the scan completes — tapping it drops me on the import review screen.
  • As P1, when I’m already in the app, a banner appears at the top (“Your Tokyo reel is ready”) with a “Review” action.
  • As P1, opening the Notifications tab shows my recent import results (ready / failed) with a clear unread indicator until I see them.
  • As P1, when a scan fails (“video too long”, “private reel”) I get a push that explains why, not a silent failure.
  • Notifications tab keeps the existing visual language (AppCard, lucide icons, cream surface). New per-item shape:
    • Icon: LucideIcons.checkCircle for ready, LucideIcons.alertTriangle for failed.
    • Title: "<Trip name> — reel ready to review" / "<Trip name> — reel scan failed".
    • Body: source filename or host (e.g. instagram.com/reel/...), plus the imports.error string on failure (trimmed to ≤120 chars).
    • Trailing: relative time (updatedAt).
    • Unread items: lime left accent stripe; read items: muted.
  • Foreground banner: dismissible after 6s, Review button → same deep-link as the push.
  • Push body mirrors the in-app title; payload carries import_id, trip_id, type for routing.
AC-1 F0011.1.a Notifications tab lists imports for the current user
with status in ('ready','failed','committed') for the
last 30 days, newest first.
AC-2 F0011.1.b When app is foreground & current route is NOT the
Notifications tab, a status flip to 'ready' triggers
a banner that deep-links to import review on tap.
AC-3 F0011.1.c Shell nav badge reflects count of items with
updated_at > user_prefs.notifications_last_seen_at.
Tapping the tab updates last_seen_at to now().
AC-4 F0011.3.a POST /v1/push/devices on login persists a row in
push_devices keyed by (user_id, token). Logout
DELETEs the row.
AC-5 F0011.3.b Tapping the OS push opens the app on
/trips/<trip_id>/imports/<import_id>/review.
AC-6 F0011.3.c Status flips to 'committed' / 'discarded' produce
zero outbox rows.
AC-7 F0011.4.a An imports row UPDATE that changes status from a
non-terminal to 'ready' or 'failed' atomically
inserts one notification_outbox row in the same txn.
AC-8 F0011.4.c A simulated FCM 5xx on the first attempt re-enqueues
with next_attempt_at > now() + backoff. Success on
a later attempt marks status='sent'.
AC-9 F0011.4.d Two consecutive UPDATEs flipping the same import row
to 'ready' produce exactly one delivered push.

Migration infra/supabase/migrations/0017_notifications.sql:

-- per-user prefs (created on demand by the backend on first read).
create table if not exists public.user_prefs (
user_id uuid primary key references auth.users(id) on delete cascade,
notifications_last_seen_at timestamptz not null default 'epoch',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- push device registry.
create table if not exists public.push_devices (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
token text not null,
platform text not null check (platform in ('ios','android')),
last_seen_at timestamptz not null default now(),
created_at timestamptz not null default now(),
unique (user_id, token)
);
-- durable outbox for push delivery (see §7a).
create type notification_status as enum ('pending','sent','failed');
create type notification_type as enum ('import_ready','import_failed');
create table if not exists public.notification_outbox (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
type notification_type not null,
payload jsonb not null, -- { import_id, trip_id, trip_name, error? }
dedupe_key text not null, -- e.g. 'import:<id>:ready'
status notification_status not null default 'pending',
attempts int not null default 0,
next_attempt_at timestamptz not null default now(),
sent_at timestamptz,
error text,
created_at timestamptz not null default now(),
unique (user_id, dedupe_key)
);
create index notification_outbox_due_idx
on public.notification_outbox (next_attempt_at)
where status = 'pending';

RLS: user_prefs and push_devices scoped to auth.uid(). notification_outbox is service-role only — no authenticated grants. The mobile client never reads the outbox.

flowchart LR
Flip[imports UPDATE<br/>status → ready / failed]
Trig[AFTER UPDATE trigger<br/>same txn]
Outbox[(notification_outbox<br/>status='pending')]
Notify[pg_notify 'notification_outbox']
Nest[Nest PushWorker<br/>LISTEN + 30s poll]
Claim[UPDATE … RETURNING<br/>FOR UPDATE SKIP LOCKED]
FCM[FCM HTTP v1]
Done[status='sent']
Retry[attempts++<br/>next_attempt_at = now + 2^n s]
Dead[status='failed']
Flip --> Trig --> Outbox --> Notify --> Nest
Nest --> Claim --> FCM
FCM -- 2xx --> Done
FCM -- 5xx / network --> Retry
Retry -- "attempts > 6" --> Dead

Trigger (in 0017_notifications.sql):

create or replace function public.imports_notify_on_terminal()
returns trigger language plpgsql security definer as $$
declare
_key text;
_type notification_type;
_trip_name text;
begin
if new.status not in ('ready','failed') then return new; end if;
if old.status = new.status then return new; end if;
_type := case new.status when 'ready' then 'import_ready'
else 'import_failed' end;
_key := format('import:%s:%s', new.id, new.status);
select name into _trip_name from public.trips where id = new.trip_id;
insert into public.notification_outbox (user_id, type, payload, dedupe_key)
values (
new.user_id, _type,
jsonb_build_object(
'import_id', new.id,
'trip_id', new.trip_id,
'trip_name', _trip_name,
'error', new.error
),
_key
)
on conflict (user_id, dedupe_key) do nothing;
perform pg_notify('notification_outbox', new.user_id::text);
return new;
end $$;
create trigger imports_notify_on_terminal
after update of status on public.imports
for each row execute function public.imports_notify_on_terminal();

Claim query (idempotent, multi-instance safe):

update public.notification_outbox o
set status = 'pending', attempts = o.attempts + 1
from (
select id from public.notification_outbox
where status = 'pending' and next_attempt_at <= now()
order by next_attempt_at
for update skip locked
limit 32
) due
where o.id = due.id
returning o.*;

Backoff: next_attempt_at = now() + (2 ^ attempts) * interval '1 second', capped at 5 minutes; attempts > 6status = 'failed'.

POST /v1/push/devices { token, platform } → 204
DELETE /v1/push/devices/:token → 204
GET /v1/notifications ?since=<iso>&limit=50 → [{ ...item }]
POST /v1/notifications/seen { at?: iso } → 204

GET /v1/notifications reads from imports joined to trips — no new persistence layer for the in-app surface. Shape:

{
id: string; // imports.id
type: 'import_ready' | 'import_failed' | 'import_committed';
trip_id: string;
trip_name: string;
import: { source_type, source_filename, source_uri, error };
updated_at: string;
read: boolean; // computed against last_seen_at
}
  • PushModule — registers PushWorker (OnModuleInit) and exposes PushService.send(deviceToken, payload) over FCM HTTP v1 with a service-account JWT minted at boot.
  • PushWorker:
    • On boot: open a dedicated pg Client, run LISTEN notification_outbox.
    • On notify (or every 30s): run the claim query, send each row, mark sent / bump backoff.
    • Concurrency 4. Graceful shutdown drains in-flight sends.
  • NotificationsRepository — REST against /v1/notifications.
  • NotificationsService (singleton) — owns:
    • Realtime channel on imports filtered by user_id, fans status flips into an in-memory stream.
    • firebase_messaging token refresh → repository.registerDevice.
    • Foreground FCM handler → same stream.
  • NotificationsCubit — list state, unread count, mark-as-read.
  • notifications_tab.dart — wired to the cubit; replaces fakes.
AspectTarget
Delivery latency≤ 5s p50 from imports.status='ready' commit to push tap-ready.
At-least-onceOutbox + dedupe key guarantees ≥1 delivery; dedupe prevents duplicates.
Backend costZero new infra. One extra pg client per Nest pod.
Push costFCM HTTP v1 is free at our volume (≤ low thousands/day).
Outbox volume~1 row per import. ≤ 5k rows/day projected; index keeps drain O(log n).
PrivacyPush body uses trip name only — no source URLs / parsed places.
  • R1 — Outbox grows unbounded. Mitigation: nightly cron (or pg_cron) deletes status='sent' rows older than 7 days and status='failed' older than 30.
  • R2 — Stale device tokens. FCM returns UNREGISTERED / INVALID_ARGUMENT; worker deletes the push_devices row on these permanent errors so we don’t keep retrying.
  • R3 — Trigger runs in the same txn as the imports UPDATE. A slow pg_notify or trips lookup could lengthen worker writes. Mitigation: the trigger only does an INSERT + one indexed SELECT on trips; both are fast. Keep security definer and a tight body.
  • R4 — Why not Redis / BullMQ? Considered and rejected for this milestone:
    • Volume is ≤ low thousands of pushes/day; FCM latency, not queue latency, dominates.
    • Postgres outbox gives us durability, retries, idempotency, and multi-instance fairness with FOR UPDATE SKIP LOCKED — no new service to deploy on Coolify.
    • Migration path is clean: if we add email digests, scheduled reminders, cross-service rate limits, or sustain > 5–10k pushes/day, the outbox rows port to BullMQ jobs 1:1.
  • Q1 — Do we want a separate notifications table for non-import events (invites, trip-mate joins) or extend notification_outbox
    • add an in_app_notifications view? Deferred to the invite slice.
  • Q2 — Per-user quiet hours? Deferred to a Settings slice.

Two slices, each independently shippable:

  • Slice 1 — In-app (this PR cluster). Migration + /v1/notifications
    • NotificationsService + Notifications tab + foreground banner. No FCM, no outbox yet. Unblocks the UX for users who keep the app open.
  • Slice 2 — OS push. Migration adds notification_outbox, push_devices, trigger; Nest PushWorker; mobile firebase_messaging wiring + iOS push capability + Android google-services.json. Behind no flag — landing the worker code is what activates it.

Rollback for Slice 2: drop the trigger; outbox rows stop being written; mobile keeps working off Realtime alone.