core-framework 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. core_framework/__init__.py +0 -0
  2. core_framework/alembic/comment/alembic/README +1 -0
  3. core_framework/alembic/comment/alembic/env.py +72 -0
  4. core_framework/alembic/comment/alembic/script.py.mako +28 -0
  5. core_framework/alembic/comment/alembic/versions/30334fd1347b_init.py +59 -0
  6. core_framework/alembic/comment/alembic/versions/a2b3c4d5e6f7_improve_comment_indexes.py +54 -0
  7. core_framework/alembic/comment/alembic/versions/bcc8e00cfc8b_add_extra_tables.py +64 -0
  8. core_framework/alembic/comment/alembic/versions/d1e2f3a4b5c6_add_comment_stats_dirty_table.py +29 -0
  9. core_framework/alembic/comment/alembic/versions/e3f4a5b6c7d8_cascade_delete_comment_descendants.py +49 -0
  10. core_framework/alembic/comment/alembic/versions/f7e6d5c4b3a2_comments_path_to_ltree.py +60 -0
  11. core_framework/alembic/comment/alembic.ini +52 -0
  12. core_framework/alembic/extension/alembic/README +1 -0
  13. core_framework/alembic/extension/alembic/env.py +98 -0
  14. core_framework/alembic/extension/alembic/script.py.mako +28 -0
  15. core_framework/alembic/extension/alembic/versions/0389226049cb_add_pg_trgm_extension.py +25 -0
  16. core_framework/alembic/extension/alembic/versions/5dc58b016cf5_add_citext_extension.py +25 -0
  17. core_framework/alembic/extension/alembic/versions/b0ba0d8a284e_add_pg_stat_statements_extension.py +25 -0
  18. core_framework/alembic/extension/alembic/versions/c9d0e1f2a3b4_add_ltree_extension.py +25 -0
  19. core_framework/alembic/extension/alembic.ini +147 -0
  20. core_framework/alembic/moderation/alembic/README +1 -0
  21. core_framework/alembic/moderation/alembic/env.py +98 -0
  22. core_framework/alembic/moderation/alembic/script.py.mako +28 -0
  23. core_framework/alembic/moderation/alembic/versions/085ba9021850_add_category_to_user_restrictions.py +93 -0
  24. core_framework/alembic/moderation/alembic/versions/5f9e4fc14a41_create_moderation_appeals_table.py +69 -0
  25. core_framework/alembic/moderation/alembic/versions/63e37381e73b_add_user_reports_table.py +33 -0
  26. core_framework/alembic/moderation/alembic/versions/6a2ae31b7ac6_add_moderation_actions_table.py +34 -0
  27. core_framework/alembic/moderation/alembic/versions/716aa1735c03_improve_indexes.py +36 -0
  28. core_framework/alembic/moderation/alembic/versions/7d243ddbfde1_add_post_reports_table.py +35 -0
  29. core_framework/alembic/moderation/alembic/versions/8fba1f72dd46_add_indexes.py +64 -0
  30. core_framework/alembic/moderation/alembic/versions/95cc35a51984_update_restriction_history.py +91 -0
  31. core_framework/alembic/moderation/alembic/versions/9ad79d0af730_add_unique_constraint_user_reports_.py +28 -0
  32. core_framework/alembic/moderation/alembic/versions/a5e569f5df1a_create_user_restrictions_table.py +38 -0
  33. core_framework/alembic/moderation/alembic/versions/b2c3d4e5f6a7_add_indexes.py +42 -0
  34. core_framework/alembic/moderation/alembic/versions/c3d4e5f6a7b8_improve_report_indexes.py +48 -0
  35. core_framework/alembic/moderation/alembic/versions/d4af74643ff5_add_internal_notes_table.py +38 -0
  36. core_framework/alembic/moderation/alembic/versions/db20f2fb7390_add_comment_reports_table.py +35 -0
  37. core_framework/alembic/moderation/alembic/versions/e66226952ea6_add_report_category_to_user_reports_.py +54 -0
  38. core_framework/alembic/moderation/alembic/versions/f5e8cb275c30_enforce_1_pending_appeal.py +29 -0
  39. core_framework/alembic/moderation/alembic/versions/fe1faad2832d_create_restriction_history_table.py +69 -0
  40. core_framework/alembic/moderation/alembic.ini +147 -0
  41. core_framework/alembic/post/alembic/README +1 -0
  42. core_framework/alembic/post/alembic/env.py +97 -0
  43. core_framework/alembic/post/alembic/script.py.mako +28 -0
  44. core_framework/alembic/post/alembic/versions/51542673f5c8_add_tables_for_muted_banned_users.py +41 -0
  45. core_framework/alembic/post/alembic/versions/5beeeae40a4a_add_post_views_table.py +45 -0
  46. core_framework/alembic/post/alembic/versions/620971509a8b_init.py +55 -0
  47. core_framework/alembic/post/alembic/versions/a1b2c3d4e5f6_add_indexes.py +44 -0
  48. core_framework/alembic/post/alembic/versions/c1d2e3f4a5b6_add_post_hashtags_table.py +36 -0
  49. core_framework/alembic/post/alembic/versions/e56723f2afff_add_post_stats_table.py +39 -0
  50. core_framework/alembic/post/alembic/versions/fbc723ac58cc_add_post_likes_table.py +32 -0
  51. core_framework/alembic/post/alembic.ini +149 -0
  52. core_framework/alembic/user/alembic/README +1 -0
  53. core_framework/alembic/user/alembic/env.py +98 -0
  54. core_framework/alembic/user/alembic/script.py.mako +28 -0
  55. core_framework/alembic/user/alembic/versions/1a8bb99726ed_remove_avatar_id_from_users.py +81 -0
  56. core_framework/alembic/user/alembic/versions/2ccacf455941_improve_indexes.py +34 -0
  57. core_framework/alembic/user/alembic/versions/47f47ce2110e_create_user_deletions_table.py +31 -0
  58. core_framework/alembic/user/alembic/versions/5976db3f0175_drop_user_states.py +26 -0
  59. core_framework/alembic/user/alembic/versions/62417002cf32_add_indexes.py +46 -0
  60. core_framework/alembic/user/alembic/versions/6f7ccf3c226b_refactor_user_login_events.py +66 -0
  61. core_framework/alembic/user/alembic/versions/73432817015b_add_user_preferences_table.py +33 -0
  62. core_framework/alembic/user/alembic/versions/765bc01a7a59_create_user_blocks_table.py +33 -0
  63. core_framework/alembic/user/alembic/versions/7a56631f9927_create_user_login_events_table.py +49 -0
  64. core_framework/alembic/user/alembic/versions/831611e589bc_create_user_state.py +31 -0
  65. core_framework/alembic/user/alembic/versions/83c98ab2a779_add_user_profiles_table.py +88 -0
  66. core_framework/alembic/user/alembic/versions/8a94362cad6d_create_user_role.py +31 -0
  67. core_framework/alembic/user/alembic/versions/94b973923895_add_user_change_history_table.py +97 -0
  68. core_framework/alembic/user/alembic/versions/cbc0f4efe84f_add_avatar_id_column_to_users_table.py +31 -0
  69. core_framework/alembic/user/alembic/versions/d8b98ac6b073_add_index_for_get_admin_user_ids_query.py +29 -0
  70. core_framework/alembic/user/alembic/versions/ddb70cc09d16_create_user_states_table.py +34 -0
  71. core_framework/alembic/user/alembic/versions/f9ba10815ecd_add_users_table.py +33 -0
  72. core_framework/alembic/user/alembic.ini +147 -0
  73. core_framework/api/__init__.py +0 -0
  74. core_framework/api/admin/__init__.py +0 -0
  75. core_framework/api/admin/comments/router.py +69 -0
  76. core_framework/api/admin/comments/schemas.py +53 -0
  77. core_framework/api/admin/moderation/__init__.py +0 -0
  78. core_framework/api/admin/moderation/router.py +205 -0
  79. core_framework/api/admin/moderation/schemas.py +110 -0
  80. core_framework/api/admin/posts/router.py +62 -0
  81. core_framework/api/admin/posts/schemas.py +29 -0
  82. core_framework/api/admin/router.py +17 -0
  83. core_framework/api/admin/users/__init__.py +0 -0
  84. core_framework/api/admin/users/router.py +181 -0
  85. core_framework/api/admin/users/schemas.py +137 -0
  86. core_framework/api/auth/__init__.py +0 -0
  87. core_framework/api/auth/router.py +21 -0
  88. core_framework/api/auth/schemas.py +28 -0
  89. core_framework/api/comments/authenticated/router.py +126 -0
  90. core_framework/api/comments/authenticated/schemas.py +27 -0
  91. core_framework/api/comments/public/router.py +103 -0
  92. core_framework/api/comments/public/schemas.py +36 -0
  93. core_framework/api/comments/router.py +9 -0
  94. core_framework/api/comments/schemas.py +17 -0
  95. core_framework/api/dependencies.py +168 -0
  96. core_framework/api/events/router.py +39 -0
  97. core_framework/api/events/schemas.py +20 -0
  98. core_framework/api/posts/authenticated/router.py +83 -0
  99. core_framework/api/posts/authenticated/schemas.py +37 -0
  100. core_framework/api/posts/public/router.py +100 -0
  101. core_framework/api/posts/public/schemas.py +39 -0
  102. core_framework/api/posts/router.py +9 -0
  103. core_framework/api/posts/schemas.py +39 -0
  104. core_framework/api/router.py +19 -0
  105. core_framework/api/schemas.py +9 -0
  106. core_framework/api/system/__init__.py +0 -0
  107. core_framework/api/system/router.py +108 -0
  108. core_framework/api/users/__init__.py +0 -0
  109. core_framework/api/users/authenticated/__init__.py +0 -0
  110. core_framework/api/users/authenticated/router.py +244 -0
  111. core_framework/api/users/authenticated/schemas.py +81 -0
  112. core_framework/api/users/public/__init__.py +0 -0
  113. core_framework/api/users/public/router.py +25 -0
  114. core_framework/api/users/public/schemas.py +7 -0
  115. core_framework/api/users/router.py +9 -0
  116. core_framework/api/users/shared/schemas.py +174 -0
  117. core_framework/application/__init__.py +0 -0
  118. core_framework/application/auth/__init__.py +0 -0
  119. core_framework/application/auth/access_service.py +26 -0
  120. core_framework/application/auth/auth_service.py +10 -0
  121. core_framework/application/auth/models.py +10 -0
  122. core_framework/application/bootstrap.py +19 -0
  123. core_framework/application/comments/admin_service.py +236 -0
  124. core_framework/application/comments/aggregation_service.py +28 -0
  125. core_framework/application/comments/authenticated_service.py +89 -0
  126. core_framework/application/comments/public_service.py +218 -0
  127. core_framework/application/events/README.md +26 -0
  128. core_framework/application/events/event_service.py +51 -0
  129. core_framework/application/events/event_token.py +46 -0
  130. core_framework/application/events/models.py +9 -0
  131. core_framework/application/moderation/__init__.py +0 -0
  132. core_framework/application/moderation/appeal_service.py +98 -0
  133. core_framework/application/moderation/moderator_service.py +46 -0
  134. core_framework/application/moderation/report_service.py +180 -0
  135. core_framework/application/moderation/scheduled_service.py +5 -0
  136. core_framework/application/moderation/user_service.py +180 -0
  137. core_framework/application/posts/admin_service.py +104 -0
  138. core_framework/application/posts/aggregation_service.py +28 -0
  139. core_framework/application/posts/authenticated_service.py +72 -0
  140. core_framework/application/posts/public_service.py +197 -0
  141. core_framework/application/shared/__init__.py +0 -0
  142. core_framework/application/shared/enums.py +16 -0
  143. core_framework/application/shared/exceptions.py +16 -0
  144. core_framework/application/shared/user_agent.py +24 -0
  145. core_framework/application/users/__init__.py +0 -0
  146. core_framework/application/users/admin_service.py +298 -0
  147. core_framework/application/users/authenticated_service.py +179 -0
  148. core_framework/application/users/public_service.py +7 -0
  149. core_framework/application/users/scheduled_service.py +5 -0
  150. core_framework/bundled_alembic.py +57 -0
  151. core_framework/core/__init__.py +37 -0
  152. core_framework/core/cache.py +234 -0
  153. core_framework/core/context.py +14 -0
  154. core_framework/core/database.py +111 -0
  155. core_framework/core/exception_handlers/__init__.py +3 -0
  156. core_framework/core/exception_handlers/comment.py +99 -0
  157. core_framework/core/exception_handlers/common.py +5 -0
  158. core_framework/core/exception_handlers/moderation.py +104 -0
  159. core_framework/core/exception_handlers/post.py +54 -0
  160. core_framework/core/exception_handlers/setup.py +80 -0
  161. core_framework/core/exception_handlers/user.py +72 -0
  162. core_framework/core/http_client.py +64 -0
  163. core_framework/core/logging.py +99 -0
  164. core_framework/core/middleware.py +64 -0
  165. core_framework/core/observability.py +36 -0
  166. core_framework/core/pagination.py +203 -0
  167. core_framework/core/redis.py +135 -0
  168. core_framework/core/runtime.py +66 -0
  169. core_framework/core/settings.py +189 -0
  170. core_framework/domains/__init__.py +0 -0
  171. core_framework/domains/comment/README.md +243 -0
  172. core_framework/domains/comment/__init__.py +25 -0
  173. core_framework/domains/comment/constants.py +3 -0
  174. core_framework/domains/comment/dependencies.py +29 -0
  175. core_framework/domains/comment/enums.py +11 -0
  176. core_framework/domains/comment/exceptions.py +31 -0
  177. core_framework/domains/comment/models.py +54 -0
  178. core_framework/domains/comment/repository.py +947 -0
  179. core_framework/domains/comment/service.py +259 -0
  180. core_framework/domains/moderation/README.md +138 -0
  181. core_framework/domains/moderation/__init__.py +47 -0
  182. core_framework/domains/moderation/dependencies.py +29 -0
  183. core_framework/domains/moderation/enums.py +62 -0
  184. core_framework/domains/moderation/exceptions.py +31 -0
  185. core_framework/domains/moderation/models.py +94 -0
  186. core_framework/domains/moderation/repository.py +828 -0
  187. core_framework/domains/moderation/service.py +334 -0
  188. core_framework/domains/post/README.md +182 -0
  189. core_framework/domains/post/__init__.py +22 -0
  190. core_framework/domains/post/constants.py +3 -0
  191. core_framework/domains/post/dependencies.py +29 -0
  192. core_framework/domains/post/enums.py +18 -0
  193. core_framework/domains/post/exceptions.py +21 -0
  194. core_framework/domains/post/models.py +53 -0
  195. core_framework/domains/post/repository.py +791 -0
  196. core_framework/domains/post/service.py +204 -0
  197. core_framework/domains/user/README.md +74 -0
  198. core_framework/domains/user/__init__.py +39 -0
  199. core_framework/domains/user/constants.py +8 -0
  200. core_framework/domains/user/dependencies.py +29 -0
  201. core_framework/domains/user/enums.py +19 -0
  202. core_framework/domains/user/exceptions.py +31 -0
  203. core_framework/domains/user/models.py +124 -0
  204. core_framework/domains/user/repository.py +612 -0
  205. core_framework/domains/user/service.py +257 -0
  206. core_framework/domains/user/utils.py +182 -0
  207. core_framework/main.py +104 -0
  208. core_framework/worker/__init__.py +0 -0
  209. core_framework/worker/main.py +56 -0
  210. core_framework/worker/schedules/__init__.py +35 -0
  211. core_framework/worker/schedules/schedule_aggregate_comment_stats.py +32 -0
  212. core_framework/worker/schedules/schedule_aggregate_post_view_counts.py +28 -0
  213. core_framework/worker/schedules/schedule_expired_account_deletions.py +24 -0
  214. core_framework/worker/schedules/schedule_expired_mute_lifts.py +24 -0
  215. core_framework/worker/tasks/__init__.py +11 -0
  216. core_framework/worker/tasks/process_account_deletion.py +13 -0
  217. core_framework/worker/tasks/process_aggregate_comment_stats.py +19 -0
  218. core_framework/worker/tasks/process_aggregate_post_stats.py +12 -0
  219. core_framework/worker/tasks/process_mute_lift.py +13 -0
  220. core_framework-0.3.0.dist-info/METADATA +22 -0
  221. core_framework-0.3.0.dist-info/RECORD +222 -0
  222. core_framework-0.3.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,791 @@
1
+ from datetime import datetime
2
+ from typing import Any, Literal
3
+
4
+ import orjson
5
+ from asyncpg import Record
6
+
7
+ from core_framework.core.database import Postgres
8
+ from core_framework.domains.post.constants import REDACTED_CONTENT_PLACEHOLDER
9
+ from core_framework.domains.post.enums import PostStatus, PostVisibility
10
+ from core_framework.domains.post.exceptions import (
11
+ EditLimitReachedException,
12
+ PostNotFoundException,
13
+ PostUpdateNotFoundException,
14
+ )
15
+ from core_framework.domains.post.models import Post, PostPreview, PostStats, PostWithMetadata
16
+ from core_framework.domains.user import REDACTED_AUTHOR_ID
17
+
18
+
19
+ class PostRepository:
20
+ def __init__(self, write_database: Postgres, read_database: Postgres, strong_read_database: Postgres):
21
+ self.write_database = write_database
22
+ self.read_database = read_database
23
+ self.strong_read_database = strong_read_database
24
+
25
+ async def insert_post(
26
+ self,
27
+ *,
28
+ post_id: str,
29
+ author_id: str,
30
+ content: str,
31
+ visibility: PostVisibility,
32
+ hashtags: list[str] | None = None,
33
+ ) -> None:
34
+ insert_post_query = """
35
+ insert into "post".posts (id, author_id, content, visibility)
36
+ values ($1, $2, $3, $4::"post".post_visibility)
37
+ """
38
+ insert_post_stats_query = """
39
+ insert into "post".post_stats (post_id)
40
+ values ($1)
41
+ """
42
+ insert_post_hashtags_query = """
43
+ insert into "post".post_hashtags (post_id, hashtag)
44
+ select $1, unnest($2::varchar[])
45
+ """
46
+ async with self.write_database.get_transaction() as connection:
47
+ await connection.execute(insert_post_query, post_id, author_id, content, visibility.value)
48
+ await connection.execute(insert_post_stats_query, post_id)
49
+ if hashtags:
50
+ await connection.execute(insert_post_hashtags_query, post_id, hashtags)
51
+
52
+ async def update_post(
53
+ self,
54
+ *,
55
+ post_id: str,
56
+ author_id: str,
57
+ post_updates: dict[str, Any],
58
+ hashtags: list[str] | None,
59
+ max_edit_count: int,
60
+ ) -> None:
61
+ allowed_fields = {
62
+ "content": "content",
63
+ "visibility": "visibility",
64
+ }
65
+ set_clauses = []
66
+ params: list[Any] = [post_id, author_id, max_edit_count]
67
+ content_param_index: int | None = None
68
+
69
+ for field, value in post_updates.items():
70
+ if field not in allowed_fields:
71
+ continue
72
+ if value is None:
73
+ continue
74
+ column = allowed_fields[field]
75
+ params.append(value)
76
+ if field == "visibility":
77
+ set_clauses.append(f'{column} = ${len(params)}::"post".post_visibility')
78
+ else:
79
+ content_param_index = len(params)
80
+ set_clauses.append(f"{column} = ${len(params)}")
81
+
82
+ if not set_clauses and hashtags is None:
83
+ return
84
+
85
+ if content_param_index is not None:
86
+ if hashtags is not None:
87
+ # Content and hashtags both present: bump when content changed or hashtags updated
88
+ params.append(True)
89
+ hashtags_param_index = len(params)
90
+ set_clauses.append(
91
+ f"edited_count = case when p.content is distinct from ${content_param_index} or ${hashtags_param_index} "
92
+ "then p.edited_count + 1 else p.edited_count end"
93
+ )
94
+ set_clauses.append(
95
+ f"edited_at = case when p.content is distinct from ${content_param_index} or ${hashtags_param_index} then now() else p.edited_at end"
96
+ )
97
+ else:
98
+ # Content only: bump when content actually changed
99
+ set_clauses.append(
100
+ f"edited_count = case when p.content is distinct from ${content_param_index} "
101
+ "then p.edited_count + 1 else p.edited_count end"
102
+ )
103
+ set_clauses.append(
104
+ f"edited_at = case when p.content is distinct from ${content_param_index} then now() else p.edited_at end"
105
+ )
106
+ elif hashtags is not None:
107
+ # Hashtags only: always bump
108
+ set_clauses.append("edited_count = p.edited_count + 1")
109
+ set_clauses.append("edited_at = now()")
110
+ set_clauses.append("updated_at = now()")
111
+
112
+ update_posts_query = f"""
113
+ with post_check as (
114
+ select
115
+ id,
116
+ edited_count
117
+ from "post".posts
118
+ where true
119
+ and id = $1
120
+ and author_id = $2
121
+ and status = 'active'::"post".post_status
122
+ ),
123
+ update_result as (
124
+ update "post".posts p
125
+ set {", ".join(set_clauses)}
126
+ from post_check pc
127
+ where true
128
+ and p.id = pc.id
129
+ and pc.edited_count < $3
130
+ returning 1
131
+ )
132
+ select
133
+ (select count(*) from post_check) as found,
134
+ (select count(*) from update_result) as rows_updated
135
+ """
136
+ replace_post_hashtags_query = """
137
+ with target as (
138
+ select 1
139
+ from "post".posts p
140
+ where true
141
+ and p.id = $1
142
+ and p.author_id = $2
143
+ and p.status = 'active'::"post".post_status
144
+ and p.edited_count < $4
145
+ ),
146
+ deleted as (
147
+ delete from "post".post_hashtags ph
148
+ using target
149
+ where true
150
+ and ph.post_id = $1
151
+ and ph.hashtag <> all($3::varchar[])
152
+ returning 1
153
+ ),
154
+ inserted as (
155
+ insert into "post".post_hashtags (post_id, hashtag)
156
+ select $1, t
157
+ from target, unnest($3::varchar[]) as t
158
+ on conflict (post_id, hashtag) do nothing
159
+ returning 1
160
+ )
161
+ select 1
162
+ from target
163
+ """
164
+
165
+ async with self.write_database.get_transaction() as connection:
166
+ row = await connection.fetchrow(update_posts_query, *params)
167
+ if row["found"] == 0:
168
+ raise PostUpdateNotFoundException()
169
+ if row["rows_updated"] == 0:
170
+ raise EditLimitReachedException()
171
+ if hashtags is not None:
172
+ await connection.execute(
173
+ replace_post_hashtags_query,
174
+ post_id,
175
+ author_id,
176
+ hashtags,
177
+ max_edit_count,
178
+ )
179
+
180
+ async def update_post_status_by_id_and_author(
181
+ self,
182
+ *,
183
+ post_id: str,
184
+ author_id: str,
185
+ status: PostStatus,
186
+ ) -> None:
187
+ query = """
188
+ update "post".posts
189
+ set
190
+ status = $3::"post".post_status,
191
+ updated_at = now()
192
+ where true
193
+ and id = $1
194
+ and author_id = $2
195
+ and status != $3::"post".post_status
196
+ """
197
+ async with self.write_database.get_connection() as connection:
198
+ await connection.execute(query, post_id, author_id, status.value)
199
+
200
+ async def update_post_status_by_id(self, *, post_id: str, status: PostStatus) -> bool:
201
+ query = """
202
+ update "post".posts
203
+ set
204
+ status = $2::"post".post_status,
205
+ updated_at = now()
206
+ where id = $1
207
+ returning 1
208
+ """
209
+ async with self.write_database.get_connection() as connection:
210
+ row = await connection.fetchrow(query, post_id, status.value)
211
+ return row is not None
212
+
213
+ async def delete_post_by_id(self, *, post_id: str) -> bool:
214
+ query = """
215
+ delete from "post".posts
216
+ where id = $1
217
+ returning 1
218
+ """
219
+ async with self.write_database.get_connection() as connection:
220
+ row = await connection.fetchrow(query, post_id)
221
+ return row is not None
222
+
223
+ async def insert_user_restriction_lookup(
224
+ self,
225
+ *,
226
+ user_id: str,
227
+ restriction_type: Literal["muted", "banned"],
228
+ ) -> None:
229
+ query = """
230
+ merge into "post".user_restrictions_lookup as target
231
+ using (values ($1, $2)) as source(user_id, restriction_type)
232
+ on target.user_id = source.user_id
233
+ when matched then
234
+ update set restriction_type = source.restriction_type
235
+ when not matched then
236
+ insert (user_id, restriction_type)
237
+ values (source.user_id, source.restriction_type)
238
+ """
239
+ async with self.write_database.get_connection() as connection:
240
+ await connection.execute(query, user_id, restriction_type)
241
+
242
+ async def delete_user_restriction_lookup(self, *, user_id: str) -> None:
243
+ query = """
244
+ delete from "post".user_restrictions_lookup
245
+ where true
246
+ and user_id = $1
247
+ """
248
+ async with self.write_database.get_connection() as connection:
249
+ await connection.execute(query, user_id)
250
+
251
+ async def insert_user_block_lookup(self, *, blocker_id: str, blocked_id: str) -> None:
252
+ query = """
253
+ merge into "post".user_blocks_lookup as target
254
+ using (values ($1, $2)) as source(blocker_id, blocked_id)
255
+ on target.blocker_id = source.blocker_id
256
+ and target.blocked_id = source.blocked_id
257
+ when not matched then
258
+ insert (blocker_id, blocked_id)
259
+ values (source.blocker_id, source.blocked_id)
260
+ """
261
+ async with self.write_database.get_connection() as connection:
262
+ await connection.execute(query, blocker_id, blocked_id)
263
+
264
+ async def delete_user_block_lookup(self, *, blocker_id: str, blocked_id: str) -> None:
265
+ query = """
266
+ delete from "post".user_blocks_lookup
267
+ where true
268
+ and blocker_id = $1
269
+ and blocked_id = $2
270
+ """
271
+ async with self.write_database.get_connection() as connection:
272
+ await connection.execute(query, blocker_id, blocked_id)
273
+
274
+ async def delete_user(self, *, user_id: str) -> None:
275
+ delete_hashtags_for_author_query = """
276
+ delete from "post".post_hashtags ph
277
+ using "post".posts p
278
+ where true
279
+ and ph.post_id = p.id
280
+ and p.author_id = $1
281
+ """
282
+ redact_posts_query = """
283
+ update "post".posts
284
+ set
285
+ content = $2,
286
+ author_id = $3,
287
+ updated_at = now()
288
+ where true
289
+ and author_id = $1
290
+ """
291
+ delete_restrictions_query = """
292
+ delete from "post".user_restrictions_lookup
293
+ where true
294
+ and user_id = $1
295
+ """
296
+ delete_blocks_query = """
297
+ delete from "post".user_blocks_lookup
298
+ where true
299
+ and (
300
+ blocker_id = $1
301
+ or blocked_id = $1
302
+ )
303
+ """
304
+ async with self.write_database.get_transaction() as connection:
305
+ await connection.execute(delete_hashtags_for_author_query, user_id)
306
+ await connection.execute(
307
+ redact_posts_query,
308
+ user_id,
309
+ REDACTED_CONTENT_PLACEHOLDER,
310
+ REDACTED_AUTHOR_ID,
311
+ )
312
+ await connection.execute(delete_restrictions_query, user_id)
313
+ await connection.execute(delete_blocks_query, user_id)
314
+
315
+ async def select_post_with_metadata_by_id(self, *, post_id: str) -> PostWithMetadata:
316
+ query = """
317
+ select
318
+ id,
319
+ author_id,
320
+ content,
321
+ visibility,
322
+ status,
323
+ edited_count,
324
+ edited_at,
325
+ created_at
326
+ from "post".posts
327
+ where id = $1
328
+ """
329
+ async with self.read_database.get_connection() as connection:
330
+ row = await connection.fetchrow(query, post_id)
331
+ if row is None:
332
+ raise PostNotFoundException()
333
+ return PostWithMetadata(
334
+ id=row["id"],
335
+ author_id=row["author_id"],
336
+ content=row["content"],
337
+ visibility=PostVisibility(row["visibility"]),
338
+ status=PostStatus(row["status"]),
339
+ edited_count=row["edited_count"],
340
+ edited_at=row["edited_at"],
341
+ created_at=row["created_at"],
342
+ )
343
+
344
+ async def select_post_by_id(self, *, post_id: str, viewer_id: str | None) -> Post | None:
345
+ query = """
346
+ select
347
+ p.id,
348
+ p.author_id,
349
+ p.content,
350
+ p.visibility,
351
+ p.edited_count,
352
+ p.edited_at,
353
+ p.created_at
354
+ from "post".posts p
355
+ where true
356
+ and p.id = $1
357
+ and p.status = 'active'::"post".post_status
358
+ and (
359
+ $2::varchar is null
360
+ or not exists(
361
+ select 1
362
+ from "post".user_blocks_lookup b
363
+ where true
364
+ and b.blocker_id = $2
365
+ and b.blocked_id = p.author_id
366
+ )
367
+ )
368
+ """
369
+ async with self.read_database.get_connection() as connection:
370
+ result = await connection.fetchrow(query, post_id, viewer_id)
371
+ if result is None:
372
+ return None
373
+ return self._map_post_row(result)
374
+
375
+ async def select_posts(
376
+ self,
377
+ *,
378
+ viewer_id: str | None,
379
+ cursor: datetime,
380
+ limit: int,
381
+ ) -> list[Post]:
382
+ query = """
383
+ with viewer_context as (
384
+ select exists(
385
+ select 1
386
+ from "post".user_restrictions_lookup
387
+ where true
388
+ and user_id = $1
389
+ and restriction_type = 'muted'
390
+ ) as is_viewer_muted
391
+ )
392
+ select
393
+ p.id,
394
+ p.author_id,
395
+ p.content,
396
+ p.visibility,
397
+ p.edited_count,
398
+ p.edited_at,
399
+ p.created_at
400
+ from "post".posts p
401
+ left join "post".user_restrictions_lookup r
402
+ on r.user_id = p.author_id
403
+ cross join viewer_context vc
404
+ where true
405
+ and p.status = 'active'::"post".post_status
406
+ and p.created_at <= $2
407
+ and (
408
+ r.user_id is null
409
+ or (
410
+ r.restriction_type = 'muted'
411
+ and $1 is not null
412
+ and vc.is_viewer_muted
413
+ and p.author_id = $1
414
+ )
415
+ )
416
+ and (
417
+ $1 is null
418
+ or not exists(
419
+ select 1
420
+ from "post".user_blocks_lookup b
421
+ where true
422
+ and b.blocker_id = $1
423
+ and b.blocked_id = p.author_id
424
+ )
425
+ )
426
+ order by p.created_at desc
427
+ limit $3
428
+ """
429
+ async with self.read_database.get_connection() as connection:
430
+ result = await connection.fetch(query, viewer_id, cursor, limit)
431
+ return [self._map_post_row(row) for row in result]
432
+
433
+ async def select_posts_by_user_id(
434
+ self,
435
+ *,
436
+ user_id: str,
437
+ cursor: datetime,
438
+ limit: int,
439
+ viewer_id: str | None,
440
+ ) -> list[Post]:
441
+ query = """
442
+ with viewer_context as (
443
+ select exists(
444
+ select 1
445
+ from "post".user_restrictions_lookup
446
+ where true
447
+ and user_id = $4
448
+ and restriction_type = 'muted'
449
+ ) as is_viewer_muted
450
+ )
451
+ select
452
+ p.id,
453
+ p.author_id,
454
+ p.content,
455
+ p.visibility,
456
+ p.edited_count,
457
+ p.edited_at,
458
+ p.created_at
459
+ from "post".posts p
460
+ left join "post".user_restrictions_lookup r
461
+ on r.user_id = p.author_id
462
+ cross join viewer_context vc
463
+ where true
464
+ and p.author_id = $1
465
+ and p.status = 'active'::"post".post_status
466
+ and p.created_at <= $2
467
+ and (
468
+ r.user_id is null
469
+ or (
470
+ r.restriction_type = 'muted'
471
+ and $4 is not null
472
+ and vc.is_viewer_muted
473
+ and p.author_id = $4
474
+ )
475
+ )
476
+ and (
477
+ $4::varchar is null
478
+ or not exists(
479
+ select 1
480
+ from "post".user_blocks_lookup
481
+ where true
482
+ and blocker_id = $4
483
+ and blocked_id = $1
484
+ )
485
+ )
486
+ order by p.created_at desc
487
+ limit $3
488
+ """
489
+ async with self.read_database.get_connection() as connection:
490
+ result = await connection.fetch(query, user_id, cursor, limit, viewer_id)
491
+ return [self._map_post_row(row) for row in result]
492
+
493
+ async def select_posts_by_hashtag(
494
+ self,
495
+ *,
496
+ hashtag: str,
497
+ cursor: datetime,
498
+ limit: int,
499
+ viewer_id: str | None,
500
+ ) -> list[Post]:
501
+ query = """
502
+ with viewer_context as (
503
+ select exists(
504
+ select 1
505
+ from "post".user_restrictions_lookup
506
+ where true
507
+ and user_id = $4
508
+ and restriction_type = 'muted'
509
+ ) as is_viewer_muted
510
+ )
511
+ select
512
+ p.id,
513
+ p.author_id,
514
+ p.content,
515
+ p.visibility,
516
+ p.edited_count,
517
+ p.edited_at,
518
+ p.created_at
519
+ from "post".posts p
520
+ join "post".post_hashtags ph on ph.post_id = p.id and ph.hashtag = $1
521
+ left join "post".user_restrictions_lookup r on r.user_id = p.author_id
522
+ cross join viewer_context vc
523
+ where true
524
+ and p.status = 'active'::"post".post_status
525
+ and p.created_at <= $2
526
+ and (
527
+ r.user_id is null
528
+ or (
529
+ r.restriction_type = 'muted'
530
+ and $4 is not null
531
+ and vc.is_viewer_muted
532
+ and p.author_id = $4
533
+ )
534
+ )
535
+ and (
536
+ $4::varchar is null
537
+ or not exists(
538
+ select 1
539
+ from "post".user_blocks_lookup b
540
+ where true
541
+ and b.blocker_id = $4
542
+ and b.blocked_id = p.author_id
543
+ )
544
+ )
545
+ order by p.created_at desc
546
+ limit $3
547
+ """
548
+ async with self.read_database.get_connection() as connection:
549
+ result = await connection.fetch(query, hashtag, cursor, limit, viewer_id)
550
+ return [self._map_post_row(row) for row in result]
551
+
552
+ async def select_posts_unfiltered(
553
+ self,
554
+ *,
555
+ cursor: datetime,
556
+ limit: int,
557
+ ) -> list[PostWithMetadata]:
558
+ query = """
559
+ select
560
+ id,
561
+ author_id,
562
+ content,
563
+ visibility,
564
+ status,
565
+ edited_count,
566
+ edited_at,
567
+ created_at
568
+ from "post".posts
569
+ where true
570
+ and created_at <= $1
571
+ order by created_at desc
572
+ limit $2
573
+ """
574
+ async with self.read_database.get_connection() as connection:
575
+ result = await connection.fetch(query, cursor, limit)
576
+ return [
577
+ PostWithMetadata(
578
+ id=row["id"],
579
+ author_id=row["author_id"],
580
+ content=row["content"],
581
+ visibility=PostVisibility(row["visibility"]),
582
+ status=PostStatus(row["status"]),
583
+ edited_count=row["edited_count"],
584
+ edited_at=row["edited_at"],
585
+ created_at=row["created_at"],
586
+ )
587
+ for row in result
588
+ ]
589
+
590
+ async def insert_post_like(self, *, post_id: str, liker_id: str) -> None:
591
+ query = """
592
+ insert into "post".post_likes (post_id, liker_id)
593
+ select
594
+ $1::varchar,
595
+ $2::varchar
596
+ where exists (
597
+ select 1
598
+ from "post".posts
599
+ where true
600
+ and id = $1::varchar
601
+ and status = 'active'::"post".post_status
602
+ )
603
+ on conflict (post_id, liker_id) do nothing
604
+ """
605
+ async with self.write_database.get_connection() as connection:
606
+ await connection.execute(query, post_id, liker_id)
607
+
608
+ async def delete_post_like(self, *, post_id: str, liker_id: str) -> None:
609
+ query = """
610
+ delete from "post".post_likes
611
+ where true
612
+ and post_id = $1
613
+ and liker_id = $2
614
+ """
615
+ async with self.write_database.get_connection() as connection:
616
+ await connection.execute(query, post_id, liker_id)
617
+
618
+ async def select_post_like_count(self, *, post_id: str) -> int:
619
+ query = """
620
+ select count(*)
621
+ from "post".post_likes
622
+ where post_id = $1
623
+ """
624
+ async with self.read_database.get_connection() as connection:
625
+ return await connection.fetchval(query, post_id)
626
+
627
+ async def select_post_ids_liked_by_user(self, *, liker_id: str, post_ids: set[str]) -> list[str]:
628
+ if not post_ids:
629
+ return []
630
+ query = """
631
+ select post_id
632
+ from "post".post_likes
633
+ where true
634
+ and liker_id = $1
635
+ and post_id = any($2)
636
+ """
637
+ async with self.read_database.get_connection() as connection:
638
+ rows = await connection.fetch(query, liker_id, list(post_ids))
639
+ return [row["post_id"] for row in rows]
640
+
641
+ async def insert_post_view(
642
+ self,
643
+ *,
644
+ post_id: str,
645
+ token: str,
646
+ request_context: dict[str, str],
647
+ ) -> None:
648
+ query = """
649
+ insert into "post".post_views (post_id, token, request_context)
650
+ values ($1, $2, $3::jsonb)
651
+ on conflict (post_id, token) do nothing
652
+ """
653
+ json_value = orjson.dumps(request_context).decode()
654
+ async with self.write_database.get_connection() as connection:
655
+ await connection.execute(query, post_id, token, json_value)
656
+
657
+ async def aggregate_post_view_counts(self) -> list[str]:
658
+ query = """
659
+ with new_counts as (
660
+ select
661
+ pv.post_id,
662
+ count(*) as new_views
663
+ from "post".post_views pv
664
+ join "post".post_stats ps on ps.post_id = pv.post_id
665
+ where pv.created_at > coalesce(ps.view_count_aggregated_at, '1970-01-01'::timestamptz)
666
+ group by pv.post_id
667
+ )
668
+ update "post".post_stats ps
669
+ set
670
+ view_count = ps.view_count + nc.new_views,
671
+ view_count_aggregated_at = now()
672
+ from new_counts nc
673
+ where ps.post_id = nc.post_id
674
+ returning ps.post_id
675
+ """
676
+ async with self.write_database.get_connection() as connection:
677
+ rows = await connection.fetch(query)
678
+ return [row["post_id"] for row in rows]
679
+
680
+ async def update_post_stats(
681
+ self,
682
+ *,
683
+ post_id: str,
684
+ like_count: int,
685
+ comment_count: int,
686
+ report_count: int,
687
+ ) -> None:
688
+ query = """
689
+ update "post".post_stats
690
+ set
691
+ like_count = $2,
692
+ comment_count = $3,
693
+ report_count = $4,
694
+ updated_at = now()
695
+ where post_id = $1
696
+ """
697
+ async with self.write_database.get_connection() as connection:
698
+ await connection.execute(
699
+ query,
700
+ post_id,
701
+ like_count,
702
+ comment_count,
703
+ report_count,
704
+ )
705
+
706
+ async def select_post_stats_mapping(self, *, post_ids: set[str]) -> dict[str, PostStats]:
707
+ if not post_ids:
708
+ return {}
709
+
710
+ query = """
711
+ select
712
+ post_id,
713
+ like_count,
714
+ comment_count,
715
+ view_count,
716
+ report_count
717
+ from "post".post_stats
718
+ where post_id = any($1::varchar[])
719
+ """
720
+ async with self.read_database.get_connection() as connection:
721
+ result = await connection.fetch(query, list(post_ids))
722
+ return {
723
+ row["post_id"]: PostStats(
724
+ like_count=row["like_count"],
725
+ comment_count=row["comment_count"],
726
+ view_count=row["view_count"],
727
+ report_count=row["report_count"],
728
+ )
729
+ for row in result
730
+ }
731
+
732
+ async def select_post_content_mapping(self, *, post_ids: set[str]) -> dict[str, str]:
733
+ if not post_ids:
734
+ return {}
735
+ query = """
736
+ select id, content
737
+ from "post".posts
738
+ where id = any($1::varchar[])
739
+ """
740
+ async with self.read_database.get_connection() as connection:
741
+ result = await connection.fetch(query, list(post_ids))
742
+ return {row["id"]: row["content"] or "" for row in result}
743
+
744
+ async def select_post_preview_mapping(self, *, post_ids: set[str]) -> dict[str, PostPreview]:
745
+ if not post_ids:
746
+ return {}
747
+ query = """
748
+ select
749
+ id,
750
+ author_id,
751
+ content
752
+ from "post".posts
753
+ where id = any($1::varchar[])
754
+ """
755
+ async with self.read_database.get_connection() as connection:
756
+ result = await connection.fetch(query, list(post_ids))
757
+ return {
758
+ row["id"]: PostPreview(
759
+ id=row["id"],
760
+ author_id=row["author_id"],
761
+ content=row["content"],
762
+ )
763
+ for row in result
764
+ }
765
+
766
+ async def select_hashtags_mapping(self, *, post_ids: set[str]) -> dict[str, list[str]]:
767
+ if not post_ids:
768
+ return {}
769
+
770
+ query = """
771
+ select
772
+ post_id,
773
+ array_agg(hashtag order by created_at) as hashtags
774
+ from "post".post_hashtags
775
+ where post_id = any($1::varchar[])
776
+ group by post_id
777
+ """
778
+ async with self.read_database.get_connection() as connection:
779
+ result = await connection.fetch(query, list(post_ids))
780
+ return {row["post_id"]: list(row["hashtags"]) for row in result}
781
+
782
+ def _map_post_row(self, row: Record) -> Post:
783
+ return Post(
784
+ id=row["id"],
785
+ author_id=row["author_id"],
786
+ content=row["content"],
787
+ visibility=PostVisibility(row["visibility"]),
788
+ edited_count=row["edited_count"],
789
+ edited_at=row["edited_at"],
790
+ created_at=row["created_at"],
791
+ )