Coverage for app / backend / src / couchers / models / notifications.py: 99%
166 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-03 06:18 +0000
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-03 06:18 +0000
1import enum
2from datetime import datetime
3from typing import TYPE_CHECKING
5from google.protobuf import empty_pb2
6from sqlalchemy import (
7 BigInteger,
8 Boolean,
9 CheckConstraint,
10 DateTime,
11 Enum,
12 ForeignKey,
13 Index,
14 Integer,
15 String,
16 UniqueConstraint,
17 func,
18)
19from sqlalchemy import LargeBinary as Binary
20from sqlalchemy.orm import Mapped, mapped_column, relationship
21from sqlalchemy.sql import expression
23from couchers.constants import DATETIME_INFINITY
24from couchers.models import ModerationState
25from couchers.models.base import Base
26from couchers.proto import notification_data_pb2
28if TYPE_CHECKING:
29 from couchers.models import User
32class NotificationDeliveryType(enum.Enum):
33 # send push notification to mobile/web
34 push = enum.auto()
35 # send individual email immediately
36 email = enum.auto()
37 # send in digest
38 digest = enum.auto()
41dt = NotificationDeliveryType
42nd = notification_data_pb2
43dt_sec = [dt.email, dt.push]
44dt_all = [dt.email, dt.push, dt.digest]
45dt_empty: list[NotificationDeliveryType] = []
48class NotificationTopicAction(enum.Enum):
49 def __init__(
50 self, topic_action: str, defaults: list[NotificationDeliveryType], user_editable: bool, data_type: type
51 ) -> None:
52 self.topic, self.action = topic_action.split(":")
53 self.defaults = defaults
54 # for now user editable == not a security notification
55 self.user_editable = user_editable
57 self.data_type = data_type
59 def unpack(self) -> tuple[str, str]:
60 return self.topic, self.action
62 @property
63 def display(self) -> str:
64 return f"{self.topic}:{self.action}"
66 def __str__(self) -> str:
67 return self.display
69 # topic, action, default delivery types
70 friend_request__create = ("friend_request:create", dt_all, True, nd.FriendRequestCreate)
71 friend_request__accept = ("friend_request:accept", dt_all, True, nd.FriendRequestAccept)
73 # host requests
74 host_request__create = ("host_request:create", dt_all, True, nd.HostRequestCreate)
75 host_request__accept = ("host_request:accept", dt_all, True, nd.HostRequestAccept)
76 host_request__reject = ("host_request:reject", dt_all, True, nd.HostRequestReject)
77 host_request__confirm = ("host_request:confirm", dt_all, True, nd.HostRequestConfirm)
78 host_request__cancel = ("host_request:cancel", dt_all, True, nd.HostRequestCancel)
79 host_request__message = ("host_request:message", [dt.push, dt.digest], True, nd.HostRequestMessage)
80 host_request__missed_messages = ("host_request:missed_messages", [dt.email], True, nd.HostRequestMissedMessages)
81 host_request__reminder = ("host_request:reminder", dt_all, True, nd.HostRequestReminder)
83 activeness__probe = ("activeness:probe", dt_sec, False, nd.ActivenessProbe)
85 # you receive a friend ref
86 reference__receive_friend = ("reference:receive_friend", dt_all, True, nd.ReferenceReceiveFriend)
87 # you receive a reference from ... the host
88 reference__receive_hosted = ("reference:receive_hosted", dt_all, True, nd.ReferenceReceiveHostRequest)
89 # ... the surfer
90 reference__receive_surfed = ("reference:receive_surfed", dt_all, True, nd.ReferenceReceiveHostRequest)
92 # you hosted
93 reference__reminder_hosted = ("reference:reminder_hosted", dt_all, True, nd.ReferenceReminder)
94 # you surfed
95 reference__reminder_surfed = ("reference:reminder_surfed", dt_all, True, nd.ReferenceReminder)
97 badge__add = ("badge:add", [dt.push, dt.digest], True, nd.BadgeAdd)
98 badge__remove = ("badge:remove", [dt.push, dt.digest], True, nd.BadgeRemove)
100 # group chats
101 chat__message = ("chat:message", [dt.push, dt.digest], True, nd.ChatMessage)
102 chat__missed_messages = ("chat:missed_messages", [dt.email], True, nd.ChatMissedMessages)
104 # events
105 # approved by mods
106 event__create_approved = ("event:create_approved", dt_all, True, nd.EventCreate)
107 # any user creates any event, default to no notifications
108 event__create_any = ("event:create_any", dt_empty, True, nd.EventCreate)
109 event__update = ("event:update", dt_all, True, nd.EventUpdate)
110 event__cancel = ("event:cancel", dt_all, True, nd.EventCancel)
111 event__delete = ("event:delete", dt_all, True, nd.EventDelete)
112 event__invite_organizer = ("event:invite_organizer", dt_all, True, nd.EventInviteOrganizer)
113 event__reminder = ("event:reminder", dt_all, True, nd.EventReminder)
114 # toplevel comment on an event
115 event__comment = ("event:comment", dt_all, True, nd.EventComment)
117 # discussion created
118 discussion__create = ("discussion:create", [dt.digest], True, nd.DiscussionCreate)
119 # someone comments on your discussion
120 discussion__comment = ("discussion:comment", dt_all, True, nd.DiscussionComment)
122 # someone responds to any of your top-level comment across the platform
123 thread__reply = ("thread:reply", dt_all, True, nd.ThreadReply)
125 # account settings
126 password__change = ("password:change", dt_sec, False, empty_pb2.Empty)
127 email_address__change = ("email_address:change", dt_sec, False, nd.EmailAddressChange)
128 email_address__verify = ("email_address:verify", dt_sec, False, empty_pb2.Empty)
129 phone_number__change = ("phone_number:change", dt_sec, False, nd.PhoneNumberChange)
130 phone_number__verify = ("phone_number:verify", dt_sec, False, nd.PhoneNumberVerify)
131 # reset password
132 password_reset__start = ("password_reset:start", dt_sec, False, nd.PasswordResetStart)
133 password_reset__complete = ("password_reset:complete", dt_sec, False, empty_pb2.Empty)
135 # account deletion
136 account_deletion__start = ("account_deletion:start", dt_sec, False, nd.AccountDeletionStart)
137 # no more pushing to do
138 account_deletion__complete = ("account_deletion:complete", dt_sec, False, nd.AccountDeletionComplete)
139 # undeleted
140 account_deletion__recovered = ("account_deletion:recovered", dt_sec, False, empty_pb2.Empty)
142 # admin actions
143 gender__change = ("gender:change", dt_sec, False, nd.GenderChange)
144 birthdate__change = ("birthdate:change", dt_sec, False, nd.BirthdateChange)
145 api_key__create = ("api_key:create", dt_sec, False, nd.ApiKeyCreate)
147 donation__received = ("donation:received", dt_sec, True, nd.DonationReceived)
149 onboarding__reminder = ("onboarding:reminder", dt_sec, True, empty_pb2.Empty)
151 modnote__create = ("modnote:create", dt_sec, False, empty_pb2.Empty)
153 verification__sv_fail = ("verification:sv_fail", dt_sec, False, nd.VerificationSVFail)
154 verification__sv_success = ("verification:sv_success", dt_sec, False, empty_pb2.Empty)
156 # postal verification
157 postal_verification__postcard_sent = (
158 "postal_verification:postcard_sent",
159 dt_sec,
160 False,
161 nd.PostalVerificationPostcardSent,
162 )
163 postal_verification__success = ("postal_verification:success", dt_sec, False, empty_pb2.Empty)
164 postal_verification__failed = ("postal_verification:failed", dt_sec, False, nd.PostalVerificationFailed)
166 # general announcements
167 general__new_blog_post = ("general:new_blog_post", [dt.push, dt.digest], True, nd.GeneralNewBlogPost)
170class NotificationPreference(Base, kw_only=True):
171 __tablename__ = "notification_preferences"
173 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
174 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
176 topic_action: Mapped[NotificationTopicAction] = mapped_column(Enum(NotificationTopicAction))
177 delivery_type: Mapped[NotificationDeliveryType] = mapped_column(Enum(NotificationDeliveryType))
178 deliver: Mapped[bool] = mapped_column(Boolean)
180 user: Mapped[User] = relationship(init=False, foreign_keys="NotificationPreference.user_id")
182 __table_args__ = (UniqueConstraint("user_id", "topic_action", "delivery_type"),)
185class Notification(Base, kw_only=True):
186 """
187 Table for accumulating notifications until it is time to send email digest
188 """
190 __tablename__ = "notifications"
192 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
193 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
195 # recipient user id
196 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
198 topic_action: Mapped[NotificationTopicAction] = mapped_column(Enum(NotificationTopicAction))
199 key: Mapped[str] = mapped_column(String)
201 data: Mapped[bytes] = mapped_column(Binary)
203 # whether the user has marked this notification as seen or not
204 is_seen: Mapped[bool] = mapped_column(Boolean, server_default=expression.false(), init=False)
206 # optional link to moderation state for content that requires moderation approval
207 # if set, notification delivery is deferred until content becomes VISIBLE/UNLISTED
208 moderation_state_id: Mapped[int | None] = mapped_column(
209 ForeignKey("moderation_states.id"), default=None, index=True
210 )
212 user: Mapped[User] = relationship(init=False, foreign_keys="Notification.user_id")
213 moderation_state: Mapped[ModerationState] = relationship(
214 init=False, foreign_keys="Notification.moderation_state_id"
215 )
217 __table_args__ = (
218 # used in looking up which notifications need delivery
219 Index(
220 "ix_notifications_created",
221 created,
222 ),
223 # Fast lookup for unseen notification count
224 Index(
225 "ix_notifications_unseen",
226 user_id,
227 topic_action,
228 postgresql_where=(is_seen == False),
229 ),
230 # Fast lookup for latest notifications
231 Index(
232 "ix_notifications_latest",
233 user_id,
234 id.desc(),
235 topic_action,
236 ),
237 )
239 @property
240 def topic(self) -> str:
241 return self.topic_action.topic
243 @property
244 def action(self) -> str:
245 return self.topic_action.action
248class NotificationDelivery(Base, kw_only=True):
249 __tablename__ = "notification_deliveries"
251 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
252 notification_id: Mapped[int] = mapped_column(ForeignKey("notifications.id"), index=True)
253 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
254 delivered: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
255 read: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
256 # todo: enum of "phone, web, digest"
257 delivery_type: Mapped[NotificationDeliveryType] = mapped_column(Enum(NotificationDeliveryType))
258 # todo: device id
259 # todo: receipt id, etc
260 notification: Mapped[Notification] = relationship(init=False, foreign_keys="NotificationDelivery.notification_id")
262 __table_args__ = (
263 UniqueConstraint("notification_id", "delivery_type"),
264 # used in looking up which notifications need delivery
265 Index(
266 "ix_notification_deliveries_delivery_type",
267 delivery_type,
268 postgresql_where=(delivered != None),
269 ),
270 Index(
271 "ix_notification_deliveries_dt_ni_dnull",
272 delivery_type,
273 notification_id,
274 delivered == None,
275 ),
276 )
279class DeviceType(enum.Enum):
280 ios = enum.auto()
281 android = enum.auto()
284class PushNotificationPlatform(enum.Enum):
285 web_push = enum.auto()
286 expo = enum.auto()
289class PushNotificationDeliveryOutcome(enum.Enum):
290 success = enum.auto()
291 # transient failure, will retry
292 transient_failure = enum.auto()
293 # message can't be delivered (e.g. too long), but subscription is fine
294 permanent_message_failure = enum.auto()
295 # subscription is broken (e.g. device unregistered), was disabled
296 permanent_subscription_failure = enum.auto()
299class PushNotificationSubscription(Base, kw_only=True):
300 __tablename__ = "push_notification_subscriptions"
302 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
303 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
305 # which user this is connected to
306 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
308 platform: Mapped[PushNotificationPlatform] = mapped_column(Enum(PushNotificationPlatform))
310 ## platform specific: web_push
311 # these come from https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription
312 # the endpoint
313 endpoint: Mapped[str | None] = mapped_column(String, default=None)
314 # the "auth" key
315 auth_key: Mapped[bytes | None] = mapped_column(Binary, default=None)
316 # the "p256dh" key
317 p256dh_key: Mapped[bytes | None] = mapped_column(Binary, default=None)
319 full_subscription_info: Mapped[str | None] = mapped_column(String, default=None)
321 # the browser user-agent, so we can tell the user what browser notifications are going to
322 user_agent: Mapped[str | None] = mapped_column(String, default=None)
324 ## platform specific: expo
325 token: Mapped[str | None] = mapped_column(String, unique=True, index=True, default=None)
326 device_name: Mapped[str | None] = mapped_column(String, default=None)
327 device_type: Mapped[DeviceType | None] = mapped_column(Enum(DeviceType), default=None)
329 # when it was disabled
330 disabled_at: Mapped[datetime] = mapped_column(
331 DateTime(timezone=True), server_default=DATETIME_INFINITY.isoformat(), init=False
332 )
334 user: Mapped[User] = relationship(init=False)
336 __table_args__ = (
337 # web_push platform requires: endpoint, auth_key, p256dh_key, full_subscription_info
338 # expo platform requires: token
339 CheckConstraint(
340 """
341 (platform = 'web_push' AND endpoint IS NOT NULL AND auth_key IS NOT NULL AND p256dh_key IS NOT NULL AND full_subscription_info IS NOT NULL AND token IS NULL)
342 OR
343 (platform = 'expo' AND token IS NOT NULL AND endpoint IS NULL AND auth_key IS NULL AND p256dh_key IS NULL AND full_subscription_info IS NULL)
344 """,
345 name="platform_columns",
346 ),
347 )
350class PushNotificationDeliveryAttempt(Base, kw_only=True):
351 __tablename__ = "push_notification_delivery_attempt"
353 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
354 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
356 push_notification_subscription_id: Mapped[int] = mapped_column(
357 ForeignKey("push_notification_subscriptions.id"), index=True
358 )
360 outcome: Mapped[PushNotificationDeliveryOutcome] = mapped_column(Enum(PushNotificationDeliveryOutcome))
361 # the HTTP status code, 201 is success, or similar for other platforms
362 status_code: Mapped[int | None] = mapped_column(Integer, default=None)
364 # can be null if it was a success
365 response: Mapped[str | None] = mapped_column(String, default=None)
367 # Expo-specific: ticket ID for receipt checking
368 expo_ticket_id: Mapped[str | None] = mapped_column(String, default=None)
370 # Receipt check results (populated by delayed job)
371 receipt_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
372 receipt_status: Mapped[str | None] = mapped_column(String, default=None) # "ok" or "error"
373 receipt_error_code: Mapped[str | None] = mapped_column(String, default=None) # e.g., "DeviceNotRegistered"
375 push_notification_subscription: Mapped[PushNotificationSubscription] = relationship(init=False)