Feature: Notifications for Reel / Video Itinerary Scans
ID: 0011Status: DraftOwner: @satyaCreated: 2026-05-14Updated: 2026-05-14Related ADRs: 0001 (Flutter mobile), 0002 (Supabase + Nest), 0003 (Python workers)Depends on: 0008 (reel video imports), 0010 (mobile share-sheet)Supersedes: —1. Why
Section titled “1. Why”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.
2. Who it is for
Section titled “2. Who it is for”Persona P1 — Solo planner: shares reels while commuting, expects to be told when the draft is ready rather than polling the app.
3. Scope
Section titled “3. Scope”In scope
Section titled “In scope”F0011.1In-app notifications surface — Notifications tab + foreground banner — driven by Supabase Realtime onpublic.imports.F0011.1.aReplace placeholder cards innotifications_tab.dartwith a realNotificationsCubit-backed list.F0011.1.bForeground 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.cTab badge / dot in the root shell when unread > 0.
F0011.2Unread state — derived fromuser_prefs.notifications_last_seen_at.F0011.3OS push notifications via FCM (handles APNs under the hood).F0011.3.aDevice-token registry table + register/unregister endpoints; auto-register on login and on token refresh.F0011.3.bTap-through deep-link to the import review screen (/trips/:tripId/imports/:importId/review).F0011.3.cQuiet rules: push onready/failed, never oncommitted/discarded(user-initiated transitions).
F0011.4Postgres outbox for durable, retryable push delivery (no Redis — see §10).F0011.4.anotification_outboxtable written in the same transaction as theimports.statusflip via a DB trigger.F0011.4.bNest worker drains the outbox viaLISTEN/NOTIFYwith a 30s safety poll fallback.F0011.4.cExponential backoff retries; dead-letter after N attempts withstatus='failed'+errorpopulated.F0011.4.dIdempotency: unique index on(user_id, dedupe_key)so a worker retry / status re-flip doesn’t double-send.
Out of scope (this milestone)
Section titled “Out of scope (this milestone)”- 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.
4. User stories
Section titled “4. User stories”- 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.
5. UX notes
Section titled “5. UX notes”- Notifications tab keeps the existing visual language
(
AppCard, lucide icons, cream surface). New per-item shape:- Icon:
LucideIcons.checkCirclefor ready,LucideIcons.alertTrianglefor failed. - Title:
"<Trip name> — reel ready to review"/"<Trip name> — reel scan failed". - Body: source filename or host (e.g.
instagram.com/reel/...), plus theimports.errorstring on failure (trimmed to ≤120 chars). - Trailing: relative time (
updatedAt). - Unread items: lime left accent stripe; read items: muted.
- Icon:
- Foreground banner: dismissible after 6s,
Reviewbutton → same deep-link as the push. - Push body mirrors the in-app title; payload carries
import_id,trip_id,typefor routing.
6. Acceptance criteria
Section titled “6. Acceptance criteria”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.7. Data model
Section titled “7. Data model”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.
7a. Outbox pipeline
Section titled “7a. Outbox pipeline”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" --> DeadTrigger (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 > 6 → status = 'failed'.
8. APIs / contracts
Section titled “8. APIs / contracts”Backend (NestJS)
Section titled “Backend (NestJS)”POST /v1/push/devices { token, platform } → 204DELETE /v1/push/devices/:token → 204
GET /v1/notifications ?since=<iso>&limit=50 → [{ ...item }]POST /v1/notifications/seen { at?: iso } → 204GET /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}Nest internals
Section titled “Nest internals”PushModule— registersPushWorker(OnModuleInit) and exposesPushService.send(deviceToken, payload)over FCM HTTP v1 with a service-account JWT minted at boot.PushWorker:- On boot: open a dedicated
pgClient, runLISTEN 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.
- On boot: open a dedicated
Mobile
Section titled “Mobile”NotificationsRepository— REST against/v1/notifications.NotificationsService(singleton) — owns:- Realtime channel on
importsfiltered byuser_id, fans status flips into an in-memory stream. firebase_messagingtoken refresh → repository.registerDevice.- Foreground FCM handler → same stream.
- Realtime channel on
NotificationsCubit— list state, unread count, mark-as-read.notifications_tab.dart— wired to the cubit; replaces fakes.
9. Non-functional requirements
Section titled “9. Non-functional requirements”| Aspect | Target |
|---|---|
| Delivery latency | ≤ 5s p50 from imports.status='ready' commit to push tap-ready. |
| At-least-once | Outbox + dedupe key guarantees ≥1 delivery; dedupe prevents duplicates. |
| Backend cost | Zero new infra. One extra pg client per Nest pod. |
| Push cost | FCM 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). |
| Privacy | Push body uses trip name only — no source URLs / parsed places. |
10. Risks & open questions
Section titled “10. Risks & open questions”- R1 — Outbox grows unbounded. Mitigation: nightly cron (or
pg_cron) deletesstatus='sent'rows older than 7 days andstatus='failed'older than 30. - R2 — Stale device tokens. FCM returns
UNREGISTERED/INVALID_ARGUMENT; worker deletes thepush_devicesrow on these permanent errors so we don’t keep retrying. - R3 — Trigger runs in the same txn as the imports UPDATE. A
slow
pg_notifyortripslookup could lengthen worker writes. Mitigation: the trigger only does an INSERT + one indexed SELECT ontrips; both are fast. Keepsecurity definerand 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
notificationstable for non-import events (invites, trip-mate joins) or extendnotification_outbox- add an
in_app_notificationsview? Deferred to the invite slice.
- add an
- Q2 — Per-user quiet hours? Deferred to a Settings slice.
11. Rollout plan
Section titled “11. Rollout plan”Two slices, each independently shippable:
- Slice 1 — In-app (this PR cluster). Migration +
/v1/notificationsNotificationsService+ 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; NestPushWorker; mobilefirebase_messagingwiring + iOS push capability + Androidgoogle-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.
12. References
Section titled “12. References”- Spec 0008 Reel video imports.
- Spec 0010 Mobile share-sheet.
- ADR 0002 Supabase + NestJS.
- Migration
infra/supabase/migrations/0017_notifications.sql(this slice). - FCM HTTP v1 — https://firebase.google.com/docs/cloud-messaging/migrate-v1