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

1import enum 

2from datetime import datetime 

3from typing import TYPE_CHECKING 

4 

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 

22 

23from couchers.constants import DATETIME_INFINITY 

24from couchers.models import ModerationState 

25from couchers.models.base import Base 

26from couchers.proto import notification_data_pb2 

27 

28if TYPE_CHECKING: 

29 from couchers.models import User 

30 

31 

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() 

39 

40 

41dt = NotificationDeliveryType 

42nd = notification_data_pb2 

43dt_sec = [dt.email, dt.push] 

44dt_all = [dt.email, dt.push, dt.digest] 

45dt_empty: list[NotificationDeliveryType] = [] 

46 

47 

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 

56 

57 self.data_type = data_type 

58 

59 def unpack(self) -> tuple[str, str]: 

60 return self.topic, self.action 

61 

62 @property 

63 def display(self) -> str: 

64 return f"{self.topic}:{self.action}" 

65 

66 def __str__(self) -> str: 

67 return self.display 

68 

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) 

72 

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) 

82 

83 activeness__probe = ("activeness:probe", dt_sec, False, nd.ActivenessProbe) 

84 

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) 

91 

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) 

96 

97 badge__add = ("badge:add", [dt.push, dt.digest], True, nd.BadgeAdd) 

98 badge__remove = ("badge:remove", [dt.push, dt.digest], True, nd.BadgeRemove) 

99 

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) 

103 

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) 

116 

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) 

121 

122 # someone responds to any of your top-level comment across the platform 

123 thread__reply = ("thread:reply", dt_all, True, nd.ThreadReply) 

124 

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) 

134 

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) 

141 

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) 

146 

147 donation__received = ("donation:received", dt_sec, True, nd.DonationReceived) 

148 

149 onboarding__reminder = ("onboarding:reminder", dt_sec, True, empty_pb2.Empty) 

150 

151 modnote__create = ("modnote:create", dt_sec, False, empty_pb2.Empty) 

152 

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) 

155 

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) 

165 

166 # general announcements 

167 general__new_blog_post = ("general:new_blog_post", [dt.push, dt.digest], True, nd.GeneralNewBlogPost) 

168 

169 

170class NotificationPreference(Base, kw_only=True): 

171 __tablename__ = "notification_preferences" 

172 

173 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

174 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

175 

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) 

179 

180 user: Mapped[User] = relationship(init=False, foreign_keys="NotificationPreference.user_id") 

181 

182 __table_args__ = (UniqueConstraint("user_id", "topic_action", "delivery_type"),) 

183 

184 

185class Notification(Base, kw_only=True): 

186 """ 

187 Table for accumulating notifications until it is time to send email digest 

188 """ 

189 

190 __tablename__ = "notifications" 

191 

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) 

194 

195 # recipient user id 

196 user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) 

197 

198 topic_action: Mapped[NotificationTopicAction] = mapped_column(Enum(NotificationTopicAction)) 

199 key: Mapped[str] = mapped_column(String) 

200 

201 data: Mapped[bytes] = mapped_column(Binary) 

202 

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) 

205 

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 ) 

211 

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 ) 

216 

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 ) 

238 

239 @property 

240 def topic(self) -> str: 

241 return self.topic_action.topic 

242 

243 @property 

244 def action(self) -> str: 

245 return self.topic_action.action 

246 

247 

248class NotificationDelivery(Base, kw_only=True): 

249 __tablename__ = "notification_deliveries" 

250 

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") 

261 

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 ) 

277 

278 

279class DeviceType(enum.Enum): 

280 ios = enum.auto() 

281 android = enum.auto() 

282 

283 

284class PushNotificationPlatform(enum.Enum): 

285 web_push = enum.auto() 

286 expo = enum.auto() 

287 

288 

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() 

297 

298 

299class PushNotificationSubscription(Base, kw_only=True): 

300 __tablename__ = "push_notification_subscriptions" 

301 

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) 

304 

305 # which user this is connected to 

306 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

307 

308 platform: Mapped[PushNotificationPlatform] = mapped_column(Enum(PushNotificationPlatform)) 

309 

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) 

318 

319 full_subscription_info: Mapped[str | None] = mapped_column(String, default=None) 

320 

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) 

323 

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) 

328 

329 # when it was disabled 

330 disabled_at: Mapped[datetime] = mapped_column( 

331 DateTime(timezone=True), server_default=DATETIME_INFINITY.isoformat(), init=False 

332 ) 

333 

334 user: Mapped[User] = relationship(init=False) 

335 

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 ) 

348 

349 

350class PushNotificationDeliveryAttempt(Base, kw_only=True): 

351 __tablename__ = "push_notification_delivery_attempt" 

352 

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) 

355 

356 push_notification_subscription_id: Mapped[int] = mapped_column( 

357 ForeignKey("push_notification_subscriptions.id"), index=True 

358 ) 

359 

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) 

363 

364 # can be null if it was a success 

365 response: Mapped[str | None] = mapped_column(String, default=None) 

366 

367 # Expo-specific: ticket ID for receipt checking 

368 expo_ticket_id: Mapped[str | None] = mapped_column(String, default=None) 

369 

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" 

374 

375 push_notification_subscription: Mapped[PushNotificationSubscription] = relationship(init=False)