Skip to content

Commit 509bde0

Browse files
Notification pipeline: events, reminders, APNs relay + Web Push
The foundation for the native iOS app and PWA notifications — one event pipeline, two transports: - Targets: devices (APNs tokens, per-device sandbox flag for Xcode builds) and push_subscriptions (Web Push), registered via /push/*. - Senders: APNs through the self-hosted push-relay (POST /notify, X-API-Key; PUSH_RELAY_URL/PUSH_RELAY_API_KEY/APNS_BUNDLE_ID env), and Web Push signed with a VAPID keypair auto-generated on first use and persisted under MEDIA_ROOT (no configuration). Delivery is fire-and- forget: dead subscriptions get pruned, failures never break a save. - Events: share-created → grantee; edits to shared notes → every other participant, once per editing session (record_revision now reports whether a new session started — the anti-spam signal); guest edits via secret link → owner. - Reminders: notes.remind_at (set/clear via PATCH, re-arms on change) + a 60s scheduler loop pushing "Reminder: <title>" to the owner. Migration 0014; pywebpush dependency; 8 new tests (110 total).
1 parent 5a68fd3 commit 509bde0

17 files changed

Lines changed: 886 additions & 5 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Push targets (APNs devices, Web Push subscriptions) + note reminders.
2+
3+
Revision ID: 0014
4+
Revises: 0013
5+
Create Date: 2026-07-10
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
import sqlalchemy as sa
11+
from alembic import op
12+
13+
from app.models.types import GUID
14+
15+
revision: str = "0014"
16+
down_revision: Union[str, None] = "0013"
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
op.create_table(
23+
"devices",
24+
sa.Column("id", GUID(), primary_key=True),
25+
sa.Column(
26+
"user_id",
27+
GUID(),
28+
sa.ForeignKey("users.id", ondelete="CASCADE"),
29+
nullable=False,
30+
index=True,
31+
),
32+
sa.Column("token", sa.String(length=200), nullable=False, unique=True),
33+
sa.Column("sandbox", sa.Boolean(), nullable=False, server_default=sa.false()),
34+
sa.Column(
35+
"created_at",
36+
sa.DateTime(timezone=True),
37+
server_default=sa.func.now(),
38+
nullable=False,
39+
),
40+
sa.Column(
41+
"updated_at",
42+
sa.DateTime(timezone=True),
43+
server_default=sa.func.now(),
44+
nullable=False,
45+
),
46+
)
47+
op.create_table(
48+
"push_subscriptions",
49+
sa.Column("id", GUID(), primary_key=True),
50+
sa.Column(
51+
"user_id",
52+
GUID(),
53+
sa.ForeignKey("users.id", ondelete="CASCADE"),
54+
nullable=False,
55+
index=True,
56+
),
57+
sa.Column("endpoint", sa.String(length=1024), nullable=False, unique=True),
58+
sa.Column("p256dh", sa.String(length=200), nullable=False),
59+
sa.Column("auth", sa.String(length=100), nullable=False),
60+
sa.Column(
61+
"created_at",
62+
sa.DateTime(timezone=True),
63+
server_default=sa.func.now(),
64+
nullable=False,
65+
),
66+
sa.Column(
67+
"updated_at",
68+
sa.DateTime(timezone=True),
69+
server_default=sa.func.now(),
70+
nullable=False,
71+
),
72+
)
73+
op.add_column(
74+
"notes", sa.Column("remind_at", sa.DateTime(timezone=True), nullable=True)
75+
)
76+
op.add_column(
77+
"notes", sa.Column("reminded_at", sa.DateTime(timezone=True), nullable=True)
78+
)
79+
op.create_index("ix_notes_remind_at", "notes", ["remind_at"])
80+
81+
82+
def downgrade() -> None:
83+
op.drop_index("ix_notes_remind_at", table_name="notes")
84+
op.drop_column("notes", "reminded_at")
85+
op.drop_column("notes", "remind_at")
86+
op.drop_table("push_subscriptions")
87+
op.drop_table("devices")

backend/app/config.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,19 @@ def assemble_database_url(self) -> "Settings":
6969
f"{quote(self.db_password, safe='')}@{host}:{self.db_port}/"
7070
f"{quote(self.db_name, safe='')}"
7171
)
72+
if not self.vapid_subject:
73+
self.vapid_subject = self.app_url
7274
return self
7375

76+
# --- Push notifications ---------------------------------------------
77+
# APNs (native iOS app) goes through the self-hosted push-relay; unset
78+
# means no native push. Web Push (installed PWA) needs no config: a
79+
# VAPID keypair is auto-generated and persisted under MEDIA_ROOT.
80+
push_relay_url: str | None = Field(default=None, alias="PUSH_RELAY_URL")
81+
push_relay_api_key: str | None = Field(default=None, alias="PUSH_RELAY_API_KEY")
82+
apns_bundle_id: str = Field(default="app.jwapps.notabula", alias="APNS_BUNDLE_ID")
83+
vapid_subject: str = Field(default="", alias="VAPID_SUBJECT")
84+
7485
# --- Media / file storage ------------------------------------------
7586
# Where note attachments live (a persisted Docker volume in compose),
7687
# served back at /media. Used from Phase 2 onward.

backend/app/main.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,19 @@
1616
from app.database import engine
1717
from app.routers import api_router
1818
from app.routers import health
19+
from app.services.notifications import reminder_loop
1920
from app.services.purge import purge_loop
2021

2122

2223
@asynccontextmanager
2324
async def lifespan(app: FastAPI):
24-
# Purge expired Recently Deleted notes now and then daily.
25+
# Purge expired Recently Deleted notes now and then daily; check for
26+
# due note reminders every minute.
2527
purge_task = asyncio.create_task(purge_loop())
28+
reminder_task = asyncio.create_task(reminder_loop())
2629
yield
2730
purge_task.cancel()
31+
reminder_task.cancel()
2832
await engine.dispose()
2933

3034

backend/app/models/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,21 @@
55
from app.models.link import NoteLink
66
from app.models.link_preview import LinkPreview
77
from app.models.note import Note
8+
from app.models.push import Device, PushSubscription
89
from app.models.revision import NoteRevision
910
from app.models.share import FolderShare, NoteShare
1011
from app.models.tag import Tag, note_tags
1112
from app.models.user import Session, TotpRecoveryCode, User
1213

1314
__all__ = [
1415
"Attachment",
16+
"Device",
1517
"Folder",
1618
"FolderShare",
1719
"LinkPreview",
1820
"Note",
1921
"NoteLink",
22+
"PushSubscription",
2023
"NoteRevision",
2124
"NoteShare",
2225
"Session",

backend/app/models/note.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ class Note(UUIDPrimaryKeyMixin, TimestampMixin, Base):
7777
deleted_at: Mapped[datetime | None] = mapped_column(
7878
DateTime(timezone=True), nullable=True, index=True
7979
)
80+
# Per-note reminder: push the owner at remind_at; reminded_at records
81+
# the firing (cleared whenever remind_at is changed).
82+
remind_at: Mapped[datetime | None] = mapped_column(
83+
DateTime(timezone=True), nullable=True, index=True
84+
)
85+
reminded_at: Mapped[datetime | None] = mapped_column(
86+
DateTime(timezone=True), nullable=True
87+
)
8088

8189
# Synced from #hashtags in body_text on every save. Query-only: rows in
8290
# note_tags are written directly by services/tags.py (async engines can't

backend/app/models/push.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Push delivery targets.
2+
3+
Two transports, one pipeline: native iOS devices register APNs tokens
4+
(relayed through the self-hosted push-relay), and installed PWAs register
5+
Web Push subscriptions (sent directly with our VAPID keys). A user may
6+
have any number of each; dead targets are pruned on delivery failure.
7+
"""
8+
9+
import uuid
10+
11+
from sqlalchemy import Boolean, ForeignKey, String
12+
from sqlalchemy.orm import Mapped, mapped_column
13+
14+
from app.database import Base
15+
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
16+
from app.models.types import GUID
17+
18+
19+
class Device(UUIDPrimaryKeyMixin, TimestampMixin, Base):
20+
"""A native app install (APNs token)."""
21+
22+
__tablename__ = "devices"
23+
24+
user_id: Mapped[uuid.UUID] = mapped_column(
25+
GUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
26+
)
27+
token: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
28+
# Debug builds run from Xcode get sandbox APNs tokens; TestFlight/App
29+
# Store builds get production ones. The client reports which it is.
30+
sandbox: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
31+
32+
33+
class PushSubscription(UUIDPrimaryKeyMixin, TimestampMixin, Base):
34+
"""A Web Push subscription from an installed PWA."""
35+
36+
__tablename__ = "push_subscriptions"
37+
38+
user_id: Mapped[uuid.UUID] = mapped_column(
39+
GUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
40+
)
41+
endpoint: Mapped[str] = mapped_column(String(1024), unique=True, nullable=False)
42+
p256dh: Mapped[str] = mapped_column(String(200), nullable=False)
43+
auth: Mapped[str] = mapped_column(String(100), nullable=False)

backend/app/routers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
meta,
1212
notes,
1313
public,
14+
push,
1415
search,
1516
shares,
1617
tags,
@@ -25,6 +26,7 @@
2526
api_router.include_router(links.router)
2627
api_router.include_router(notes.router)
2728
api_router.include_router(public.router)
29+
api_router.include_router(push.router)
2830
api_router.include_router(search.router)
2931
api_router.include_router(shares.router)
3032
api_router.include_router(tags.router)

backend/app/routers/notes.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
RevisionListItem,
3737
)
3838
from app.services.access import note_role, resolved_role, share_maps
39+
from app.services.notifications import notify_note_edited
3940
from app.services.revisions import record_revision
4041
from app.services.tags import sweep_orphan_tags, sync_note_tags
4142

@@ -559,6 +560,10 @@ async def update_note(
559560
if payload.pinned is not None:
560561
note.pinned = payload.pinned
561562

563+
if "remind_at" in payload.model_fields_set:
564+
note.remind_at = payload.remind_at
565+
note.reminded_at = None # re-arm: a new time means a new reminder
566+
562567
note.version += 1
563568
await db.flush()
564569
# Content changes go into history — but never while the note is locked
@@ -567,7 +572,13 @@ async def update_note(
567572
if not note.locked and any(
568573
v is not None for v in (payload.body, payload.body_text, payload.title)
569574
):
570-
await record_revision(db, note, user.id)
575+
new_session = await record_revision(db, note, user.id)
576+
# One push per editing session (not per autosave): tell the other
577+
# participants of a shared note that changes landed.
578+
if new_session:
579+
await notify_note_edited(
580+
db, note, editor_id=user.id, editor_name=user.name
581+
)
571582
# updated_at is computed server-side (onupdate=now()); refresh so the
572583
# response carries the real value instead of an expired attribute.
573584
await db.refresh(note)

backend/app/routers/public.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from app.config import settings
1616
from app.core.deps import DB
1717
from app.models import Note, NoteLink
18+
from app.services.notifications import notify_guest_edited
1819
from app.services.revisions import record_revision
1920
from app.services.tags import sync_note_tags
2021

@@ -105,6 +106,8 @@ async def update_public_note(
105106
note.version += 1
106107
await db.flush()
107108
# editor_id=None + the guest's name → "Sue (guest)" in the history.
108-
await record_revision(db, note, None, guest_name=guest)
109+
new_session = await record_revision(db, note, None, guest_name=guest)
110+
if new_session:
111+
await notify_guest_edited(db, note, guest_name=guest)
109112
await db.refresh(note)
110113
return _public(note, link.role)

backend/app/routers/push.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""Push target registration — APNs devices (native app) and Web Push
2+
subscriptions (installed PWA). Both are per-user and idempotent."""
3+
4+
from fastapi import APIRouter
5+
from pydantic import BaseModel, Field
6+
from sqlalchemy import delete, select
7+
8+
from app.core.deps import DB, CurrentUser
9+
from app.models import Device, PushSubscription
10+
from app.services.push import get_vapid
11+
12+
router = APIRouter(prefix="/push", tags=["push"])
13+
14+
15+
@router.get("/vapid-public-key")
16+
async def vapid_public_key(user: CurrentUser) -> dict:
17+
"""The applicationServerKey the PWA uses to subscribe."""
18+
return {"public_key": get_vapid()["public_key"]}
19+
20+
21+
class DeviceIn(BaseModel):
22+
token: str = Field(min_length=1, max_length=200)
23+
# Xcode debug builds get sandbox APNs tokens; TestFlight/App Store don't.
24+
sandbox: bool = False
25+
26+
27+
@router.post("/devices", status_code=204)
28+
async def register_device(payload: DeviceIn, user: CurrentUser, db: DB) -> None:
29+
existing = (
30+
await db.execute(select(Device).where(Device.token == payload.token))
31+
).scalar_one_or_none()
32+
if existing is None:
33+
db.add(Device(user_id=user.id, token=payload.token, sandbox=payload.sandbox))
34+
else:
35+
# Token moved to another account (device signed in as someone else).
36+
existing.user_id = user.id
37+
existing.sandbox = payload.sandbox
38+
39+
40+
@router.delete("/devices/{token}", status_code=204)
41+
async def unregister_device(token: str, user: CurrentUser, db: DB) -> None:
42+
await db.execute(
43+
delete(Device).where(Device.token == token, Device.user_id == user.id)
44+
)
45+
46+
47+
class SubscriptionKeys(BaseModel):
48+
p256dh: str = Field(min_length=1, max_length=200)
49+
auth: str = Field(min_length=1, max_length=100)
50+
51+
52+
class SubscriptionIn(BaseModel):
53+
endpoint: str = Field(min_length=1, max_length=1024)
54+
keys: SubscriptionKeys
55+
56+
57+
@router.post("/subscriptions", status_code=204)
58+
async def register_subscription(
59+
payload: SubscriptionIn, user: CurrentUser, db: DB
60+
) -> None:
61+
existing = (
62+
await db.execute(
63+
select(PushSubscription).where(
64+
PushSubscription.endpoint == payload.endpoint
65+
)
66+
)
67+
).scalar_one_or_none()
68+
if existing is None:
69+
db.add(
70+
PushSubscription(
71+
user_id=user.id,
72+
endpoint=payload.endpoint,
73+
p256dh=payload.keys.p256dh,
74+
auth=payload.keys.auth,
75+
)
76+
)
77+
else:
78+
existing.user_id = user.id
79+
existing.p256dh = payload.keys.p256dh
80+
existing.auth = payload.keys.auth
81+
82+
83+
class SubscriptionDelete(BaseModel):
84+
endpoint: str = Field(min_length=1, max_length=1024)
85+
86+
87+
@router.post("/subscriptions/delete", status_code=204)
88+
async def unregister_subscription(
89+
payload: SubscriptionDelete, user: CurrentUser, db: DB
90+
) -> None:
91+
await db.execute(
92+
delete(PushSubscription).where(
93+
PushSubscription.endpoint == payload.endpoint,
94+
PushSubscription.user_id == user.id,
95+
)
96+
)

0 commit comments

Comments
 (0)