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,612 @@
1
+ from datetime import datetime
2
+ from typing import Any
3
+
4
+ import asyncpg
5
+ import orjson
6
+
7
+ from core_framework.core.database import Postgres
8
+ from core_framework.domains.user.enums import UserRole
9
+ from core_framework.domains.user.exceptions import (
10
+ DomainUserNotFoundException,
11
+ SelfBlockException,
12
+ UserCreationException,
13
+ UserIdConflictException,
14
+ UsernameConflictException,
15
+ )
16
+ from core_framework.domains.user.models import (
17
+ BlockedUser,
18
+ Preferences,
19
+ Profile,
20
+ UserChangeHistory,
21
+ UserIdentity,
22
+ UserWithProfile,
23
+ )
24
+
25
+
26
+ class UserRepository:
27
+ def __init__(self, write_database: Postgres, read_database: Postgres, strong_read_database: Postgres):
28
+ self.write_database = write_database
29
+ self.read_database = read_database
30
+ self.strong_read_database = strong_read_database
31
+
32
+ # User Management
33
+ async def select_admin_user_ids(self) -> set[str]:
34
+ query = """
35
+ select
36
+ user_id
37
+ from "user".users
38
+ where true
39
+ and role = 'admin'
40
+ """
41
+ async with self.read_database.get_connection() as connection:
42
+ result = await connection.fetch(query)
43
+ return {record["user_id"] for record in result}
44
+
45
+ async def insert_user(self, *, user_id: str, username: str, role: str) -> None:
46
+ user_query = """
47
+ with attempt as (
48
+ merge into "user".users as target
49
+ using (values ($1, $2, $3::"user".user_role)) as source(user_id, username, role)
50
+ on target.user_id = source.user_id
51
+ or target.username = source.username
52
+ when matched then
53
+ do nothing
54
+ when not matched then
55
+ insert (user_id, username, role)
56
+ values (source.user_id, source.username, source.role)
57
+ returning 1 as inserted
58
+ )
59
+ select
60
+ case
61
+ when exists (select 1 from attempt) then 'INSERTED'
62
+ when exists (select 1 from "user".users u where u.user_id = $1) then 'USER_ID_CONFLICT'
63
+ when exists (select 1 from "user".users u where u.username = $2) then 'USERNAME_CONFLICT'
64
+ else 'UNKNOWN'
65
+ end as result;
66
+ """
67
+ profile_query = """
68
+ insert into "user".user_profiles (user_id) values ($1)
69
+ """
70
+ preferences_query = """
71
+ insert into "user".user_preferences (user_id) values ($1)
72
+ """
73
+ async with self.write_database.get_transaction() as connection:
74
+ result = await connection.fetchrow(user_query, user_id, username, role)
75
+ if result["result"] == "USER_ID_CONFLICT":
76
+ raise UserIdConflictException(user_id)
77
+ if result["result"] == "USERNAME_CONFLICT":
78
+ raise UsernameConflictException(username)
79
+ if result["result"] == "UNKNOWN":
80
+ raise UserCreationException(user_id)
81
+
82
+ await connection.execute(profile_query, user_id)
83
+ await connection.execute(preferences_query, user_id)
84
+
85
+ async def delete_user(self, *, user_id: str) -> None:
86
+ deletion_query = """
87
+ delete from "user".user_deletions
88
+ where user_id = $1
89
+ """
90
+ user_query = """
91
+ delete from "user".users
92
+ where user_id = $1
93
+ """
94
+ async with self.write_database.get_transaction() as connection:
95
+ # Delete from user_deletions first so its AFTER DELETE trigger can insert
96
+ # into user_change_history while the parent users row still exists.
97
+ # If users is deleted first, the trigger insert may violate FK to users.
98
+ await connection.execute(deletion_query, user_id)
99
+ await connection.execute(user_query, user_id)
100
+
101
+ # Login/Authentication
102
+ async def insert_login_event(self, *, user_id: str, request_context: dict[str, str]) -> None:
103
+ query = """
104
+ insert into "user".user_login_events (user_id, request_context)
105
+ values ($1, $2::jsonb)
106
+ """
107
+ json_value = orjson.dumps(request_context).decode()
108
+ async with self.write_database.get_connection() as connection:
109
+ try:
110
+ await connection.execute(query, user_id, json_value)
111
+ except asyncpg.exceptions.ForeignKeyViolationError:
112
+ raise DomainUserNotFoundException()
113
+
114
+ # User Blocks
115
+ async def select_blocked_users(self, *, blocker_id: str, created_at: datetime, limit: int) -> list[BlockedUser]:
116
+ query = """
117
+ select
118
+ blocked_id,
119
+ username,
120
+ "user".user_blocks.created_at
121
+ from "user".user_blocks
122
+ join "user".users on "user".user_blocks.blocked_id = "user".users.user_id
123
+ where true
124
+ and "user".user_blocks.blocker_id = $1
125
+ and "user".user_blocks.created_at <= $2
126
+ order by "user".user_blocks.created_at desc
127
+ limit $3
128
+ """
129
+ async with self.read_database.get_connection() as connection:
130
+ result = await connection.fetch(query, blocker_id, created_at, limit)
131
+ return [
132
+ BlockedUser(user_id=record["blocked_id"], username=record["username"], created_at=record["created_at"])
133
+ for record in result
134
+ ]
135
+
136
+ async def insert_user_block(self, *, blocker_id: str, blocked_id: str) -> None:
137
+ query = """
138
+ merge into "user".user_blocks as target
139
+ using (values ($1, $2)) as source(blocker_id, blocked_id)
140
+ on target.blocker_id = source.blocker_id
141
+ and target.blocked_id = source.blocked_id
142
+ when matched then
143
+ do nothing
144
+ when not matched then
145
+ insert (blocker_id, blocked_id)
146
+ values (source.blocker_id, source.blocked_id)
147
+ """
148
+ async with self.write_database.get_connection() as connection:
149
+ try:
150
+ await connection.execute(query, blocker_id, blocked_id)
151
+ except asyncpg.exceptions.ForeignKeyViolationError:
152
+ pass
153
+ except asyncpg.exceptions.CheckViolationError as exc:
154
+ if getattr(exc, "constraint_name", None) == "no_self_block":
155
+ raise SelfBlockException() from exc
156
+ raise
157
+
158
+ async def delete_user_block(self, *, blocker_id: str, blocked_id: str) -> None:
159
+ query = """
160
+ delete from "user".user_blocks
161
+ where true
162
+ and blocker_id = $1
163
+ and blocked_id = $2
164
+ """
165
+ async with self.write_database.get_connection() as connection:
166
+ await connection.execute(query, blocker_id, blocked_id)
167
+
168
+ # Preferences
169
+ async def _select_preferences_with_database(self, *, user_id: str, database: Postgres) -> Preferences:
170
+ query = """
171
+ select
172
+ theme,
173
+ language
174
+ from "user".user_preferences
175
+ where user_id = $1
176
+ """
177
+ async with database.get_connection() as connection:
178
+ result = await connection.fetchrow(query, user_id)
179
+ if result is None:
180
+ raise DomainUserNotFoundException()
181
+ return Preferences(
182
+ theme=result["theme"],
183
+ language=result["language"],
184
+ )
185
+
186
+ async def select_preferences(self, *, user_id: str) -> Preferences:
187
+ return await self._select_preferences_with_database(
188
+ user_id=user_id,
189
+ database=self.read_database,
190
+ )
191
+
192
+ async def select_preferences_strong(self, *, user_id: str) -> Preferences:
193
+ return await self._select_preferences_with_database(
194
+ user_id=user_id,
195
+ database=self.strong_read_database,
196
+ )
197
+
198
+ async def update_preferences(self, *, user_id: str, validated_update_request: dict[str, Any]) -> None:
199
+ if not validated_update_request:
200
+ return
201
+
202
+ ensure_preferences_row_query = """
203
+ insert into "user".user_preferences (user_id)
204
+ values ($1)
205
+ on conflict (user_id) do nothing
206
+ """
207
+
208
+ allowed_fields = {
209
+ "theme": "theme",
210
+ "language": "language",
211
+ }
212
+
213
+ set_clauses = []
214
+ params: list[Any] = [user_id]
215
+
216
+ for field, value in validated_update_request.items():
217
+ if field not in allowed_fields:
218
+ continue
219
+ column = allowed_fields[field]
220
+ params.append(value)
221
+ set_clauses.append(f"{column} = ${len(params)}")
222
+
223
+ if not set_clauses:
224
+ return
225
+
226
+ set_clauses.append("updated_at = now()")
227
+
228
+ query = f"""
229
+ update "user".user_preferences
230
+ set {", ".join(set_clauses)}
231
+ where user_id = $1
232
+ """
233
+
234
+ async with self.write_database.get_transaction() as connection:
235
+ await connection.execute(ensure_preferences_row_query, user_id)
236
+ await connection.execute(query, *params)
237
+
238
+ # Profile
239
+ async def _select_profile_with_database(self, *, user_id: str, database: Postgres) -> Profile:
240
+ query = """
241
+ select
242
+ display_name,
243
+ avatar_id,
244
+ banner_id,
245
+ bio,
246
+ status,
247
+ social_links,
248
+ profile_visibility::text as profile_visibility
249
+ from "user".user_profiles
250
+ where user_id = $1
251
+ """
252
+ async with database.get_connection() as connection:
253
+ result = await connection.fetchrow(query, user_id)
254
+ if result is None:
255
+ raise DomainUserNotFoundException()
256
+ return Profile(
257
+ display_name=result["display_name"],
258
+ avatar_id=result["avatar_id"],
259
+ banner_id=result["banner_id"],
260
+ bio=result["bio"],
261
+ status=result["status"],
262
+ social_links=result["social_links"],
263
+ profile_visibility=result["profile_visibility"],
264
+ )
265
+
266
+ async def select_profile(self, *, user_id: str) -> Profile:
267
+ return await self._select_profile_with_database(
268
+ user_id=user_id,
269
+ database=self.read_database,
270
+ )
271
+
272
+ async def select_profile_strong(self, *, user_id: str) -> Profile:
273
+ return await self._select_profile_with_database(
274
+ user_id=user_id,
275
+ database=self.strong_read_database,
276
+ )
277
+
278
+ async def update_profile(
279
+ self,
280
+ *,
281
+ user_id: str,
282
+ validated_update_request: dict[str, Any],
283
+ actor_id: str | None = None,
284
+ ) -> None:
285
+ if not validated_update_request:
286
+ return
287
+
288
+ ensure_profile_row_query = """
289
+ insert into "user".user_profiles (user_id)
290
+ values ($1)
291
+ on conflict (user_id) do nothing
292
+ """
293
+
294
+ # Map of allowed fields to column names
295
+ allowed_fields = {
296
+ "display_name": "display_name",
297
+ "avatar_id": "avatar_id",
298
+ "banner_id": "banner_id",
299
+ "bio": "bio",
300
+ "status": "status",
301
+ "social_links": "social_links",
302
+ "profile_visibility": "profile_visibility",
303
+ }
304
+
305
+ set_clauses = []
306
+ params: list[Any] = [user_id]
307
+
308
+ for field, value in validated_update_request.items():
309
+ if field not in allowed_fields:
310
+ continue
311
+ column = allowed_fields[field]
312
+ params.append(value)
313
+ set_clauses.append(f"{column} = ${len(params)}")
314
+
315
+ if not set_clauses:
316
+ return
317
+
318
+ set_clauses.append("updated_at = now()")
319
+
320
+ query = f"""
321
+ update "user".user_profiles
322
+ set {", ".join(set_clauses)}
323
+ where user_id = $1
324
+ """
325
+
326
+ async with self.write_database.get_transaction_with_actor_id(actor_id) as connection:
327
+ await connection.execute(ensure_profile_row_query, user_id)
328
+ await connection.execute(query, *params)
329
+
330
+ async def update_account(
331
+ self,
332
+ *,
333
+ user_id: str,
334
+ validated_update_request: dict[str, Any],
335
+ actor_id: str | None = None,
336
+ ) -> None:
337
+ if not validated_update_request:
338
+ return
339
+
340
+ allowed_fields = {
341
+ "username": "username",
342
+ }
343
+
344
+ set_clauses = []
345
+ params: list[Any] = [user_id]
346
+
347
+ for field, value in validated_update_request.items():
348
+ if field not in allowed_fields:
349
+ continue
350
+ column = allowed_fields[field]
351
+ params.append(value)
352
+ set_clauses.append(f"{column} = ${len(params)}")
353
+
354
+ if not set_clauses:
355
+ return
356
+
357
+ set_clauses.append("updated_at = now()")
358
+
359
+ query = f"""
360
+ update "user".users
361
+ set {", ".join(set_clauses)}
362
+ where user_id = $1
363
+ """
364
+
365
+ async with self.write_database.get_transaction_with_actor_id(actor_id) as connection:
366
+ try:
367
+ await connection.execute(query, *params)
368
+ except asyncpg.exceptions.UniqueViolationError as exc:
369
+ if getattr(exc, "constraint_name", None) == "users_username_key":
370
+ raise UsernameConflictException(validated_update_request.get("username", "")) from exc
371
+ raise
372
+
373
+ async def update_user_role(
374
+ self,
375
+ *,
376
+ user_id: str,
377
+ role: str,
378
+ actor_id: str | None = None,
379
+ ) -> None:
380
+ query = """
381
+ update "user".users
382
+ set role = $2::"user".user_role, updated_at = now()
383
+ where user_id = $1
384
+ """
385
+ async with self.write_database.get_transaction_with_actor_id(actor_id) as connection:
386
+ await connection.execute(query, user_id, role)
387
+
388
+ # Account Deletion
389
+ async def insert_user_deletion(
390
+ self,
391
+ *,
392
+ user_id: str,
393
+ scheduled_for: datetime,
394
+ actor_id: str | None = None,
395
+ ) -> None:
396
+ query = """
397
+ merge into "user".user_deletions as target
398
+ using (values ($1, $2::timestamptz)) as source(user_id, scheduled_for)
399
+ on target.user_id = source.user_id
400
+ when matched then
401
+ do nothing
402
+ when not matched then
403
+ insert (user_id, scheduled_for)
404
+ values (source.user_id, source.scheduled_for)
405
+ """
406
+ async with self.write_database.get_transaction_with_actor_id(actor_id) as connection:
407
+ await connection.execute(query, user_id, scheduled_for)
408
+
409
+ async def delete_user_deletion(self, *, user_id: str, actor_id: str | None = None) -> None:
410
+ query = """
411
+ delete from "user".user_deletions
412
+ where true
413
+ and user_id = $1
414
+ """
415
+ async with self.write_database.get_transaction_with_actor_id(actor_id) as connection:
416
+ await connection.execute(query, user_id)
417
+
418
+ async def select_user_for_detail(self, *, user_id: str) -> UserWithProfile | None:
419
+ query = """
420
+ select
421
+ users.user_id,
422
+ users.username,
423
+ users.role,
424
+ users.created_at,
425
+ user_profiles.display_name,
426
+ user_profiles.avatar_id,
427
+ user_profiles.banner_id,
428
+ user_profiles.bio,
429
+ user_profiles.status,
430
+ user_profiles.social_links,
431
+ user_profiles.profile_visibility::text as profile_visibility,
432
+ user_deletions.scheduled_for
433
+ from "user".users
434
+ inner join "user".user_profiles on users.user_id = user_profiles.user_id
435
+ left join "user".user_deletions on users.user_id = user_deletions.user_id
436
+ where users.user_id = $1
437
+ """
438
+ async with self.read_database.get_connection() as connection:
439
+ result = await connection.fetchrow(query, user_id)
440
+ if result is None:
441
+ return None
442
+ return UserWithProfile(
443
+ identity=UserIdentity(
444
+ user_id=result["user_id"],
445
+ username=result["username"],
446
+ display_name=result["display_name"],
447
+ role=UserRole(result["role"]),
448
+ avatar_id=result["avatar_id"],
449
+ created_at=result["created_at"],
450
+ ),
451
+ profile=Profile(
452
+ display_name=result["display_name"],
453
+ avatar_id=result["avatar_id"],
454
+ banner_id=result["banner_id"],
455
+ bio=result["bio"],
456
+ status=result["status"],
457
+ social_links=result["social_links"],
458
+ profile_visibility=result["profile_visibility"],
459
+ ),
460
+ deletion_scheduled_for=result["scheduled_for"],
461
+ )
462
+
463
+ async def select_user_deletion_scheduled_for(self, *, user_id: str) -> datetime | None:
464
+ query = """
465
+ select
466
+ scheduled_for
467
+ from "user".user_deletions
468
+ where true
469
+ and user_id = $1
470
+ """
471
+ async with self.read_database.get_connection() as connection:
472
+ result = await connection.fetchrow(query, user_id)
473
+ return result["scheduled_for"] if result is not None else None
474
+
475
+ async def select_expired_user_deletions(self) -> list[str]:
476
+ query = """
477
+ select
478
+ user_id
479
+ from "user".user_deletions
480
+ where true
481
+ and scheduled_for <= now()
482
+ order by scheduled_for asc
483
+ """
484
+ async with self.read_database.get_connection() as connection:
485
+ result = await connection.fetch(query)
486
+ return [record["user_id"] for record in result]
487
+
488
+ # User Identity
489
+ async def _select_user_identity_mapping_with_database(
490
+ self,
491
+ *,
492
+ user_ids: set[str],
493
+ database: Postgres,
494
+ ) -> dict[str, UserIdentity]:
495
+ if not user_ids:
496
+ return {}
497
+
498
+ query = """
499
+ select
500
+ users.user_id,
501
+ users.username,
502
+ user_profiles.display_name,
503
+ users.role,
504
+ user_profiles.avatar_id,
505
+ users.created_at
506
+ from "user".users
507
+ left join "user".user_profiles on users.user_id = user_profiles.user_id
508
+ where users.user_id = any($1)
509
+ """
510
+ async with database.get_connection() as connection:
511
+ result = await connection.fetch(query, user_ids)
512
+ return {
513
+ record["user_id"]: UserIdentity(
514
+ user_id=record["user_id"],
515
+ username=record["username"],
516
+ display_name=record["display_name"],
517
+ role=UserRole(record["role"]),
518
+ avatar_id=record["avatar_id"],
519
+ created_at=record["created_at"],
520
+ )
521
+ for record in result
522
+ }
523
+
524
+ async def select_user_identity_mapping(self, *, user_ids: set[str]) -> dict[str, UserIdentity]:
525
+ return await self._select_user_identity_mapping_with_database(
526
+ user_ids=user_ids,
527
+ database=self.read_database,
528
+ )
529
+
530
+ async def select_user_identity_mapping_strong(self, *, user_ids: set[str]) -> dict[str, UserIdentity]:
531
+ return await self._select_user_identity_mapping_with_database(
532
+ user_ids=user_ids,
533
+ database=self.strong_read_database,
534
+ )
535
+
536
+ async def select_user_identities(
537
+ self,
538
+ *,
539
+ username: str | None,
540
+ role: UserRole | None,
541
+ cursor: datetime,
542
+ limit: int,
543
+ ) -> list[UserIdentity]:
544
+ query = """
545
+ select
546
+ users.user_id,
547
+ users.username,
548
+ user_profiles.display_name,
549
+ users.role,
550
+ user_profiles.avatar_id,
551
+ users.created_at
552
+ from "user".users
553
+ left join "user".user_profiles on users.user_id = user_profiles.user_id
554
+ where true
555
+ and users.created_at <= $1
556
+ and ($3::text is null or users.username ilike $3)
557
+ and ($4::"user".user_role is null or users.role = $4::"user".user_role)
558
+ order by users.created_at desc
559
+ limit $2
560
+ """
561
+ async with self.read_database.get_connection() as connection:
562
+ username_filter = f"%{username}%" if username else None
563
+ result = await connection.fetch(query, cursor, limit, username_filter, role)
564
+ return [
565
+ UserIdentity(
566
+ user_id=record["user_id"],
567
+ username=record["username"],
568
+ display_name=record["display_name"],
569
+ role=UserRole(record["role"]),
570
+ avatar_id=record["avatar_id"],
571
+ created_at=record["created_at"],
572
+ )
573
+ for record in result
574
+ ]
575
+
576
+ async def select_user_change_history(
577
+ self,
578
+ *,
579
+ user_id: str,
580
+ cursor: datetime,
581
+ limit: int,
582
+ ) -> list[UserChangeHistory]:
583
+ query = """
584
+ select
585
+ user_id,
586
+ actor_id,
587
+ entity_type,
588
+ field,
589
+ old_value,
590
+ new_value,
591
+ created_at
592
+ from "user".user_change_history
593
+ where true
594
+ and user_id = $1
595
+ and created_at <= $2
596
+ order by created_at desc
597
+ limit $3
598
+ """
599
+ async with self.read_database.get_connection() as connection:
600
+ result = await connection.fetch(query, user_id, cursor, limit)
601
+ return [
602
+ UserChangeHistory(
603
+ user_id=record["user_id"],
604
+ actor_id=record["actor_id"],
605
+ entity_type=record["entity_type"],
606
+ field=record["field"],
607
+ old_value=record["old_value"],
608
+ new_value=record["new_value"],
609
+ created_at=record["created_at"],
610
+ )
611
+ for record in result
612
+ ]