Coverage for app / backend / src / couchers / models / notifications.py: 99%
166 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +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 """
50 Identifies a type of notification by the topic it relates to and the action triggered.
51 The grouping by topic allows users to unsubscribe from notifications in two ways:
52 - All notifications of a certain type (topic+action), e.g. all friend requests.
53 - All notifications about a certain instance of a topic,
54 e.g. all notifications about an event, where the key is the event id.
55 """
57 def __init__(
58 self, topic_action: str, defaults: list[NotificationDeliveryType], is_critical: bool, data_type: type
59 ) -> None:
60 self.topic, self.action = topic_action.split(":")
61 self.defaults = defaults
62 # Account security, moderation, donation receipts, etc. cannot be unsubscribed from.
63 self.is_critical = is_critical
64 self.data_type = data_type
66 def unpack(self) -> tuple[str, str]:
67 return self.topic, self.action
69 @property
70 def display(self) -> str:
71 return f"{self.topic}:{self.action}"
73 def __str__(self) -> str:
74 return self.display
76 # topic, action, default delivery types
77 friend_request__create = ("friend_request:create", dt_all, False, nd.FriendRequestCreate)
78 friend_request__accept = ("friend_request:accept", dt_all, False, nd.FriendRequestAccept)
80 # host requests
81 host_request__create = ("host_request:create", dt_all, False, nd.HostRequestCreate)
82 host_request__accept = ("host_request:accept", dt_all, False, nd.HostRequestAccept)
83 host_request__reject = ("host_request:reject", dt_all, False, nd.HostRequestReject)
84 host_request__confirm = ("host_request:confirm", dt_all, False, nd.HostRequestConfirm)
85 host_request__cancel = ("host_request:cancel", dt_all, False, nd.HostRequestCancel)
86 host_request__message = ("host_request:message", [dt.push, dt.digest], False, nd.HostRequestMessage)
87 host_request__missed_messages = ("host_request:missed_messages", [dt.email], False, nd.HostRequestMissedMessages)
88 host_request__reminder = ("host_request:reminder", dt_all, False, nd.HostRequestReminder)
90 activeness__probe = ("activeness:probe", dt_sec, True, nd.ActivenessProbe)
92 # you receive a friend ref
93 reference__receive_friend = ("reference:receive_friend", dt_all, False, nd.ReferenceReceiveFriend)
94 # you receive a reference from ... the host
95 reference__receive_hosted = ("reference:receive_hosted", dt_all, False, nd.ReferenceReceiveHostRequest)
96 # ... the surfer
97 reference__receive_surfed = ("reference:receive_surfed", dt_all, False, nd.ReferenceReceiveHostRequest)
99 # you hosted
100 reference__reminder_hosted = ("reference:reminder_hosted", dt_all, False, nd.ReferenceReminder)
101 # you surfed
102 reference__reminder_surfed = ("reference:reminder_surfed", dt_all, False, nd.ReferenceReminder)
104 badge__add = ("badge:add", [dt.push, dt.digest], False, nd.BadgeAdd)
105 badge__remove = ("badge:remove", [dt.push, dt.digest], False, nd.BadgeRemove)
107 # group chats
108 chat__message = ("chat:message", [dt.push, dt.digest], False, nd.ChatMessage)
109 chat__missed_messages = ("chat:missed_messages", [dt.email], False, nd.ChatMissedMessages)
111 # events
112 # approved by mods
113 event__create_approved = ("event:create_approved", dt_all, False, nd.EventCreate)
114 # any user creates any event, default to no notifications
115 event__create_any = ("event:create_any", dt_empty, False, nd.EventCreate)
116 event__update = ("event:update", dt_all, False, nd.EventUpdate)
117 event__cancel = ("event:cancel", dt_all, False, nd.EventCancel)
118 event__delete = ("event:delete", dt_all, False, nd.EventDelete)
119 event__invite_organizer = ("event:invite_organizer", dt_all, False, nd.EventInviteOrganizer)
120 event__reminder = ("event:reminder", dt_all, False, nd.EventReminder)
121 # toplevel comment on an event
122 event__comment = ("event:comment", dt_all, False, nd.EventComment)
124 # discussion created
125 discussion__create = ("discussion:create", [dt.digest], False, nd.DiscussionCreate)
126 # someone comments on your discussion
127 discussion__comment = ("discussion:comment", dt_all, False, nd.DiscussionComment)
129 # someone responds to any of your top-level comment across the platform
130 thread__reply = ("thread:reply", dt_all, False, nd.ThreadReply)
132 # account settings
133 password__change = ("password:change", dt_sec, True, empty_pb2.Empty)
134 email_address__change = ("email_address:change", dt_sec, True, nd.EmailAddressChange)
135 email_address__verify = ("email_address:verify", dt_sec, True, empty_pb2.Empty)
136 phone_number__change = ("phone_number:change", dt_sec, True, nd.PhoneNumberChange)
137 phone_number__verify = ("phone_number:verify", dt_sec, True, nd.PhoneNumberVerify)
138 # reset password
139 password_reset__start = ("password_reset:start", dt_sec, True, nd.PasswordResetStart)
140 password_reset__complete = ("password_reset:complete", dt_sec, True, empty_pb2.Empty)
142 # account deletion
143 account_deletion__start = ("account_deletion:start", dt_sec, True, nd.AccountDeletionStart)
144 # no more pushing to do
145 account_deletion__complete = ("account_deletion:complete", dt_sec, True, nd.AccountDeletionComplete)
146 # undeleted
147 account_deletion__recovered = ("account_deletion:recovered", dt_sec, True, empty_pb2.Empty)
149 # admin actions
150 gender__change = ("gender:change", dt_sec, True, nd.GenderChange)
151 birthdate__change = ("birthdate:change", dt_sec, True, nd.BirthdateChange)
152 api_key__create = ("api_key:create", dt_sec, True, nd.ApiKeyCreate)
154 # Can't unsubscribe because email includes receipt.
155 donation__received = ("donation:received", dt_sec, True, nd.DonationReceived)
157 onboarding__reminder = ("onboarding:reminder", dt_sec, False, empty_pb2.Empty)
159 modnote__create = ("modnote:create", dt_sec, True, empty_pb2.Empty)
161 verification__sv_fail = ("verification:sv_fail", dt_sec, True, nd.VerificationSVFail)
162 verification__sv_success = ("verification:sv_success", dt_sec, True, empty_pb2.Empty)
164 # postal verification
165 postal_verification__postcard_sent = (
166 "postal_verification:postcard_sent",
167 dt_sec,
168 False,
169 nd.PostalVerificationPostcardSent,
170 )
171 postal_verification__success = ("postal_verification:success", dt_sec, True, empty_pb2.Empty)
172 postal_verification__failed = ("postal_verification:failed", dt_sec, True, nd.PostalVerificationFailed)
174 # general announcements
175 general__new_blog_post = ("general:new_blog_post", [dt.push, dt.digest], False, nd.GeneralNewBlogPost)
178class NotificationPreference(Base, kw_only=True):
179 __tablename__ = "notification_preferences"
181 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
182 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
184 topic_action: Mapped[NotificationTopicAction] = mapped_column(Enum(NotificationTopicAction))
185 delivery_type: Mapped[NotificationDeliveryType] = mapped_column(Enum(NotificationDeliveryType))
186 deliver: Mapped[bool] = mapped_column(Boolean)
188 user: Mapped[User] = relationship(init=False, foreign_keys="NotificationPreference.user_id")
190 __table_args__ = (UniqueConstraint("user_id", "topic_action", "delivery_type"),)
193class Notification(Base, kw_only=True):
194 """
195 Table for accumulating notifications until it is time to send email digest
196 """
198 __tablename__ = "notifications"
200 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
201 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
203 # recipient user id
204 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
206 topic_action: Mapped[NotificationTopicAction] = mapped_column(Enum(NotificationTopicAction))
207 key: Mapped[str] = mapped_column(String)
209 data: Mapped[bytes] = mapped_column(Binary)
211 # whether the user has marked this notification as seen or not
212 is_seen: Mapped[bool] = mapped_column(Boolean, server_default=expression.false(), init=False)
214 # optional link to moderation state for content that requires moderation approval
215 # if set, notification delivery is deferred until content becomes VISIBLE/UNLISTED
216 moderation_state_id: Mapped[int | None] = mapped_column(
217 ForeignKey("moderation_states.id"), default=None, index=True
218 )
220 user: Mapped[User] = relationship(init=False, foreign_keys="Notification.user_id")
221 moderation_state: Mapped[ModerationState] = relationship(
222 init=False, foreign_keys="Notification.moderation_state_id"
223 )
225 __table_args__ = (
226 # used in looking up which notifications need delivery
227 Index(
228 "ix_notifications_created",
229 created,
230 ),
231 # Fast lookup for unseen notification count
232 Index(
233 "ix_notifications_unseen",
234 user_id,
235 topic_action,
236 postgresql_where=(is_seen == False),
237 ),
238 # Fast lookup for latest notifications
239 Index(
240 "ix_notifications_latest",
241 user_id,
242 id.desc(),
243 topic_action,
244 ),
245 )
247 @property
248 def topic(self) -> str:
249 return self.topic_action.topic
251 @property
252 def action(self) -> str:
253 return self.topic_action.action
256class NotificationDelivery(Base, kw_only=True):
257 __tablename__ = "notification_deliveries"
259 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
260 notification_id: Mapped[int] = mapped_column(ForeignKey("notifications.id"), index=True)
261 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
262 delivered: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
263 read: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
264 # todo: enum of "phone, web, digest"
265 delivery_type: Mapped[NotificationDeliveryType] = mapped_column(Enum(NotificationDeliveryType))
266 # todo: device id
267 # todo: receipt id, etc
268 notification: Mapped[Notification] = relationship(init=False, foreign_keys="NotificationDelivery.notification_id")
270 __table_args__ = (
271 UniqueConstraint("notification_id", "delivery_type"),
272 # used in looking up which notifications need delivery
273 Index(
274 "ix_notification_deliveries_delivery_type",
275 delivery_type,
276 postgresql_where=(delivered != None),
277 ),
278 Index(
279 "ix_notification_deliveries_dt_ni_dnull",
280 delivery_type,
281 notification_id,
282 delivered == None,
283 ),
284 )
287class DeviceType(enum.Enum):
288 ios = enum.auto()
289 android = enum.auto()
292class PushNotificationPlatform(enum.Enum):
293 web_push = enum.auto()
294 expo = enum.auto()
297class PushNotificationDeliveryOutcome(enum.Enum):
298 success = enum.auto()
299 # transient failure, will retry
300 transient_failure = enum.auto()
301 # message can't be delivered (e.g. too long), but subscription is fine
302 permanent_message_failure = enum.auto()
303 # subscription is broken (e.g. device unregistered), was disabled
304 permanent_subscription_failure = enum.auto()
307class PushNotificationSubscription(Base, kw_only=True):
308 __tablename__ = "push_notification_subscriptions"
310 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
311 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
313 # which user this is connected to
314 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
316 platform: Mapped[PushNotificationPlatform] = mapped_column(Enum(PushNotificationPlatform))
318 ## platform specific: web_push
319 # these come from https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription
320 # the endpoint
321 endpoint: Mapped[str | None] = mapped_column(String, default=None)
322 # the "auth" key
323 auth_key: Mapped[bytes | None] = mapped_column(Binary, default=None)
324 # the "p256dh" key
325 p256dh_key: Mapped[bytes | None] = mapped_column(Binary, default=None)
327 full_subscription_info: Mapped[str | None] = mapped_column(String, default=None)
329 # the browser user-agent, so we can tell the user what browser notifications are going to
330 user_agent: Mapped[str | None] = mapped_column(String, default=None)
332 ## platform specific: expo
333 token: Mapped[str | None] = mapped_column(String, unique=True, index=True, default=None)
334 device_name: Mapped[str | None] = mapped_column(String, default=None)
335 device_type: Mapped[DeviceType | None] = mapped_column(Enum(DeviceType), default=None)
337 # when it was disabled
338 disabled_at: Mapped[datetime] = mapped_column(
339 DateTime(timezone=True), server_default=DATETIME_INFINITY.isoformat(), init=False
340 )
342 user: Mapped[User] = relationship(init=False)
344 __table_args__ = (
345 # web_push platform requires: endpoint, auth_key, p256dh_key, full_subscription_info
346 # expo platform requires: token
347 CheckConstraint(
348 """
349 (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)
350 OR
351 (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)
352 """,
353 name="platform_columns",
354 ),
355 )
358class PushNotificationDeliveryAttempt(Base, kw_only=True):
359 __tablename__ = "push_notification_delivery_attempt"
361 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
362 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
364 push_notification_subscription_id: Mapped[int] = mapped_column(
365 ForeignKey("push_notification_subscriptions.id"), index=True
366 )
368 outcome: Mapped[PushNotificationDeliveryOutcome] = mapped_column(Enum(PushNotificationDeliveryOutcome))
369 # the HTTP status code, 201 is success, or similar for other platforms
370 status_code: Mapped[int | None] = mapped_column(Integer, default=None)
372 # can be null if it was a success
373 response: Mapped[str | None] = mapped_column(String, default=None)
375 # Expo-specific: ticket ID for receipt checking
376 expo_ticket_id: Mapped[str | None] = mapped_column(String, default=None)
378 # Receipt check results (populated by delayed job)
379 receipt_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
380 receipt_status: Mapped[str | None] = mapped_column(String, default=None) # "ok" or "error"
381 receipt_error_code: Mapped[str | None] = mapped_column(String, default=None) # e.g., "DeviceNotRegistered"
383 push_notification_subscription: Mapped[PushNotificationSubscription] = relationship(init=False)