@weirdfingers/baseboards 0.9.6 → 0.9.7

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 (237) hide show
  1. package/dist/index.js +560 -469
  2. package/dist/index.js.map +1 -1
  3. package/package.json +2 -5
  4. package/templates/README.md +0 -122
  5. package/templates/api/.env.example +0 -65
  6. package/templates/api/ARTIFACT_RESOLUTION_GUIDE.md +0 -148
  7. package/templates/api/Dockerfile +0 -32
  8. package/templates/api/README.md +0 -264
  9. package/templates/api/alembic/env.py +0 -114
  10. package/templates/api/alembic/script.py.mako +0 -28
  11. package/templates/api/alembic/versions/20250101_000000_initial_schema.py +0 -506
  12. package/templates/api/alembic/versions/20251022_174729_remove_provider_name_from_generations.py +0 -75
  13. package/templates/api/alembic/versions/20251023_165852_switch_to_declarative_base_and_mapping.py +0 -467
  14. package/templates/api/alembic/versions/20251202_000000_add_artifact_lineage.py +0 -134
  15. package/templates/api/alembic/versions/2025925_62735_add_seed_data_for_default_tenant.py +0 -88
  16. package/templates/api/alembic.ini +0 -36
  17. package/templates/api/config/generators.yaml +0 -237
  18. package/templates/api/config/storage_config.yaml +0 -26
  19. package/templates/api/docs/ADDING_GENERATORS.md +0 -409
  20. package/templates/api/docs/GENERATORS_API.md +0 -502
  21. package/templates/api/docs/MIGRATIONS.md +0 -472
  22. package/templates/api/docs/TESTING_LIVE_APIS.md +0 -417
  23. package/templates/api/docs/storage_providers.md +0 -337
  24. package/templates/api/pyproject.toml +0 -205
  25. package/templates/api/src/boards/__init__.py +0 -10
  26. package/templates/api/src/boards/api/app.py +0 -172
  27. package/templates/api/src/boards/api/auth.py +0 -75
  28. package/templates/api/src/boards/api/endpoints/__init__.py +0 -3
  29. package/templates/api/src/boards/api/endpoints/jobs.py +0 -76
  30. package/templates/api/src/boards/api/endpoints/setup.py +0 -505
  31. package/templates/api/src/boards/api/endpoints/sse.py +0 -129
  32. package/templates/api/src/boards/api/endpoints/storage.py +0 -155
  33. package/templates/api/src/boards/api/endpoints/tenant_registration.py +0 -296
  34. package/templates/api/src/boards/api/endpoints/uploads.py +0 -149
  35. package/templates/api/src/boards/api/endpoints/webhooks.py +0 -13
  36. package/templates/api/src/boards/auth/__init__.py +0 -15
  37. package/templates/api/src/boards/auth/adapters/__init__.py +0 -27
  38. package/templates/api/src/boards/auth/adapters/auth0.py +0 -220
  39. package/templates/api/src/boards/auth/adapters/base.py +0 -73
  40. package/templates/api/src/boards/auth/adapters/clerk.py +0 -172
  41. package/templates/api/src/boards/auth/adapters/jwt.py +0 -122
  42. package/templates/api/src/boards/auth/adapters/none.py +0 -102
  43. package/templates/api/src/boards/auth/adapters/oidc.py +0 -284
  44. package/templates/api/src/boards/auth/adapters/supabase.py +0 -110
  45. package/templates/api/src/boards/auth/context.py +0 -35
  46. package/templates/api/src/boards/auth/factory.py +0 -129
  47. package/templates/api/src/boards/auth/middleware.py +0 -221
  48. package/templates/api/src/boards/auth/provisioning.py +0 -129
  49. package/templates/api/src/boards/auth/tenant_extraction.py +0 -278
  50. package/templates/api/src/boards/cli.py +0 -354
  51. package/templates/api/src/boards/config.py +0 -131
  52. package/templates/api/src/boards/database/__init__.py +0 -7
  53. package/templates/api/src/boards/database/cli.py +0 -110
  54. package/templates/api/src/boards/database/connection.py +0 -292
  55. package/templates/api/src/boards/database/models.py +0 -19
  56. package/templates/api/src/boards/database/seed_data.py +0 -182
  57. package/templates/api/src/boards/dbmodels/__init__.py +0 -441
  58. package/templates/api/src/boards/generators/__init__.py +0 -57
  59. package/templates/api/src/boards/generators/artifact_resolution.py +0 -405
  60. package/templates/api/src/boards/generators/artifacts.py +0 -53
  61. package/templates/api/src/boards/generators/base.py +0 -144
  62. package/templates/api/src/boards/generators/implementations/__init__.py +0 -14
  63. package/templates/api/src/boards/generators/implementations/fal/__init__.py +0 -25
  64. package/templates/api/src/boards/generators/implementations/fal/audio/__init__.py +0 -23
  65. package/templates/api/src/boards/generators/implementations/fal/audio/beatoven_music_generation.py +0 -171
  66. package/templates/api/src/boards/generators/implementations/fal/audio/beatoven_sound_effect_generation.py +0 -167
  67. package/templates/api/src/boards/generators/implementations/fal/audio/chatterbox_text_to_speech.py +0 -176
  68. package/templates/api/src/boards/generators/implementations/fal/audio/chatterbox_tts_turbo.py +0 -195
  69. package/templates/api/src/boards/generators/implementations/fal/audio/elevenlabs_sound_effects_v2.py +0 -194
  70. package/templates/api/src/boards/generators/implementations/fal/audio/elevenlabs_tts_eleven_v3.py +0 -209
  71. package/templates/api/src/boards/generators/implementations/fal/audio/fal_elevenlabs_tts_turbo_v2_5.py +0 -206
  72. package/templates/api/src/boards/generators/implementations/fal/audio/fal_minimax_speech_26_hd.py +0 -237
  73. package/templates/api/src/boards/generators/implementations/fal/audio/minimax_music_v2.py +0 -173
  74. package/templates/api/src/boards/generators/implementations/fal/audio/minimax_speech_2_6_turbo.py +0 -221
  75. package/templates/api/src/boards/generators/implementations/fal/image/__init__.py +0 -63
  76. package/templates/api/src/boards/generators/implementations/fal/image/bytedance_seedream_v45_edit.py +0 -219
  77. package/templates/api/src/boards/generators/implementations/fal/image/clarity_upscaler.py +0 -220
  78. package/templates/api/src/boards/generators/implementations/fal/image/crystal_upscaler.py +0 -173
  79. package/templates/api/src/boards/generators/implementations/fal/image/fal_ideogram_character.py +0 -227
  80. package/templates/api/src/boards/generators/implementations/fal/image/flux_2.py +0 -203
  81. package/templates/api/src/boards/generators/implementations/fal/image/flux_2_edit.py +0 -230
  82. package/templates/api/src/boards/generators/implementations/fal/image/flux_2_pro.py +0 -204
  83. package/templates/api/src/boards/generators/implementations/fal/image/flux_2_pro_edit.py +0 -221
  84. package/templates/api/src/boards/generators/implementations/fal/image/flux_pro_kontext.py +0 -216
  85. package/templates/api/src/boards/generators/implementations/fal/image/flux_pro_ultra.py +0 -197
  86. package/templates/api/src/boards/generators/implementations/fal/image/gemini_25_flash_image.py +0 -177
  87. package/templates/api/src/boards/generators/implementations/fal/image/gemini_25_flash_image_edit.py +0 -208
  88. package/templates/api/src/boards/generators/implementations/fal/image/gpt_image_15_edit.py +0 -216
  89. package/templates/api/src/boards/generators/implementations/fal/image/gpt_image_1_5.py +0 -177
  90. package/templates/api/src/boards/generators/implementations/fal/image/gpt_image_1_edit_image.py +0 -182
  91. package/templates/api/src/boards/generators/implementations/fal/image/gpt_image_1_mini.py +0 -167
  92. package/templates/api/src/boards/generators/implementations/fal/image/ideogram_character_edit.py +0 -299
  93. package/templates/api/src/boards/generators/implementations/fal/image/ideogram_v2.py +0 -190
  94. package/templates/api/src/boards/generators/implementations/fal/image/imagen4_preview.py +0 -191
  95. package/templates/api/src/boards/generators/implementations/fal/image/imagen4_preview_fast.py +0 -179
  96. package/templates/api/src/boards/generators/implementations/fal/image/nano_banana.py +0 -183
  97. package/templates/api/src/boards/generators/implementations/fal/image/nano_banana_edit.py +0 -212
  98. package/templates/api/src/boards/generators/implementations/fal/image/nano_banana_pro.py +0 -179
  99. package/templates/api/src/boards/generators/implementations/fal/image/nano_banana_pro_edit.py +0 -226
  100. package/templates/api/src/boards/generators/implementations/fal/image/qwen_image.py +0 -249
  101. package/templates/api/src/boards/generators/implementations/fal/image/qwen_image_edit.py +0 -244
  102. package/templates/api/src/boards/generators/implementations/fal/image/reve_edit.py +0 -178
  103. package/templates/api/src/boards/generators/implementations/fal/image/reve_text_to_image.py +0 -155
  104. package/templates/api/src/boards/generators/implementations/fal/image/seedream_v45_text_to_image.py +0 -180
  105. package/templates/api/src/boards/generators/implementations/fal/utils.py +0 -61
  106. package/templates/api/src/boards/generators/implementations/fal/video/__init__.py +0 -77
  107. package/templates/api/src/boards/generators/implementations/fal/video/bytedance_seedance_v1_pro_text_to_video.py +0 -209
  108. package/templates/api/src/boards/generators/implementations/fal/video/creatify_lipsync.py +0 -161
  109. package/templates/api/src/boards/generators/implementations/fal/video/fal_bytedance_seedance_v1_pro_image_to_video.py +0 -222
  110. package/templates/api/src/boards/generators/implementations/fal/video/fal_minimax_hailuo_02_standard_text_to_video.py +0 -152
  111. package/templates/api/src/boards/generators/implementations/fal/video/fal_pixverse_lipsync.py +0 -197
  112. package/templates/api/src/boards/generators/implementations/fal/video/fal_sora_2_text_to_video.py +0 -173
  113. package/templates/api/src/boards/generators/implementations/fal/video/infinitalk.py +0 -221
  114. package/templates/api/src/boards/generators/implementations/fal/video/kling_video_ai_avatar_v2_pro.py +0 -168
  115. package/templates/api/src/boards/generators/implementations/fal/video/kling_video_ai_avatar_v2_standard.py +0 -159
  116. package/templates/api/src/boards/generators/implementations/fal/video/kling_video_v2_5_turbo_pro_image_to_video.py +0 -175
  117. package/templates/api/src/boards/generators/implementations/fal/video/kling_video_v2_5_turbo_pro_text_to_video.py +0 -168
  118. package/templates/api/src/boards/generators/implementations/fal/video/minimax_hailuo_2_3_pro_image_to_video.py +0 -153
  119. package/templates/api/src/boards/generators/implementations/fal/video/sora2_image_to_video.py +0 -172
  120. package/templates/api/src/boards/generators/implementations/fal/video/sora_2_image_to_video_pro.py +0 -175
  121. package/templates/api/src/boards/generators/implementations/fal/video/sora_2_text_to_video_pro.py +0 -163
  122. package/templates/api/src/boards/generators/implementations/fal/video/sync_lipsync_v2.py +0 -167
  123. package/templates/api/src/boards/generators/implementations/fal/video/sync_lipsync_v2_pro.py +0 -155
  124. package/templates/api/src/boards/generators/implementations/fal/video/veed_fabric_1_0.py +0 -180
  125. package/templates/api/src/boards/generators/implementations/fal/video/veed_lipsync.py +0 -174
  126. package/templates/api/src/boards/generators/implementations/fal/video/veo3.py +0 -194
  127. package/templates/api/src/boards/generators/implementations/fal/video/veo31.py +0 -190
  128. package/templates/api/src/boards/generators/implementations/fal/video/veo31_fast.py +0 -190
  129. package/templates/api/src/boards/generators/implementations/fal/video/veo31_fast_image_to_video.py +0 -191
  130. package/templates/api/src/boards/generators/implementations/fal/video/veo31_first_last_frame_to_video.py +0 -187
  131. package/templates/api/src/boards/generators/implementations/fal/video/veo31_image_to_video.py +0 -183
  132. package/templates/api/src/boards/generators/implementations/fal/video/veo31_reference_to_video.py +0 -172
  133. package/templates/api/src/boards/generators/implementations/fal/video/wan_25_preview_image_to_video.py +0 -212
  134. package/templates/api/src/boards/generators/implementations/fal/video/wan_25_preview_text_to_video.py +0 -208
  135. package/templates/api/src/boards/generators/implementations/fal/video/wan_pro_image_to_video.py +0 -158
  136. package/templates/api/src/boards/generators/implementations/kie/__init__.py +0 -11
  137. package/templates/api/src/boards/generators/implementations/kie/base.py +0 -316
  138. package/templates/api/src/boards/generators/implementations/kie/image/__init__.py +0 -3
  139. package/templates/api/src/boards/generators/implementations/kie/image/nano_banana_edit.py +0 -190
  140. package/templates/api/src/boards/generators/implementations/kie/utils.py +0 -98
  141. package/templates/api/src/boards/generators/implementations/kie/video/__init__.py +0 -8
  142. package/templates/api/src/boards/generators/implementations/kie/video/veo3.py +0 -161
  143. package/templates/api/src/boards/generators/implementations/openai/__init__.py +0 -1
  144. package/templates/api/src/boards/generators/implementations/openai/audio/__init__.py +0 -1
  145. package/templates/api/src/boards/generators/implementations/openai/audio/whisper.py +0 -69
  146. package/templates/api/src/boards/generators/implementations/openai/image/__init__.py +0 -1
  147. package/templates/api/src/boards/generators/implementations/openai/image/dalle3.py +0 -96
  148. package/templates/api/src/boards/generators/implementations/replicate/__init__.py +0 -1
  149. package/templates/api/src/boards/generators/implementations/replicate/image/__init__.py +0 -1
  150. package/templates/api/src/boards/generators/implementations/replicate/image/flux_pro.py +0 -88
  151. package/templates/api/src/boards/generators/implementations/replicate/video/__init__.py +0 -1
  152. package/templates/api/src/boards/generators/implementations/replicate/video/lipsync.py +0 -73
  153. package/templates/api/src/boards/generators/loader.py +0 -253
  154. package/templates/api/src/boards/generators/registry.py +0 -114
  155. package/templates/api/src/boards/generators/resolution.py +0 -632
  156. package/templates/api/src/boards/generators/testmods/class_gen.py +0 -34
  157. package/templates/api/src/boards/generators/testmods/import_side_effect.py +0 -35
  158. package/templates/api/src/boards/graphql/__init__.py +0 -7
  159. package/templates/api/src/boards/graphql/access_control.py +0 -136
  160. package/templates/api/src/boards/graphql/mutations/root.py +0 -148
  161. package/templates/api/src/boards/graphql/queries/root.py +0 -116
  162. package/templates/api/src/boards/graphql/resolvers/__init__.py +0 -8
  163. package/templates/api/src/boards/graphql/resolvers/auth.py +0 -12
  164. package/templates/api/src/boards/graphql/resolvers/board.py +0 -1053
  165. package/templates/api/src/boards/graphql/resolvers/generation.py +0 -666
  166. package/templates/api/src/boards/graphql/resolvers/generator.py +0 -50
  167. package/templates/api/src/boards/graphql/resolvers/lineage.py +0 -381
  168. package/templates/api/src/boards/graphql/resolvers/upload.py +0 -463
  169. package/templates/api/src/boards/graphql/resolvers/user.py +0 -25
  170. package/templates/api/src/boards/graphql/schema.py +0 -81
  171. package/templates/api/src/boards/graphql/types/board.py +0 -102
  172. package/templates/api/src/boards/graphql/types/generation.py +0 -166
  173. package/templates/api/src/boards/graphql/types/generator.py +0 -17
  174. package/templates/api/src/boards/graphql/types/user.py +0 -47
  175. package/templates/api/src/boards/jobs/repository.py +0 -153
  176. package/templates/api/src/boards/logging.py +0 -195
  177. package/templates/api/src/boards/middleware.py +0 -339
  178. package/templates/api/src/boards/progress/__init__.py +0 -4
  179. package/templates/api/src/boards/progress/models.py +0 -25
  180. package/templates/api/src/boards/progress/publisher.py +0 -64
  181. package/templates/api/src/boards/py.typed +0 -0
  182. package/templates/api/src/boards/redis_pool.py +0 -118
  183. package/templates/api/src/boards/storage/__init__.py +0 -52
  184. package/templates/api/src/boards/storage/base.py +0 -363
  185. package/templates/api/src/boards/storage/config.py +0 -187
  186. package/templates/api/src/boards/storage/factory.py +0 -288
  187. package/templates/api/src/boards/storage/implementations/__init__.py +0 -27
  188. package/templates/api/src/boards/storage/implementations/gcs.py +0 -340
  189. package/templates/api/src/boards/storage/implementations/local.py +0 -201
  190. package/templates/api/src/boards/storage/implementations/s3.py +0 -294
  191. package/templates/api/src/boards/storage/implementations/supabase.py +0 -218
  192. package/templates/api/src/boards/tenant_isolation.py +0 -446
  193. package/templates/api/src/boards/validation.py +0 -262
  194. package/templates/api/src/boards/workers/__init__.py +0 -1
  195. package/templates/api/src/boards/workers/actors.py +0 -274
  196. package/templates/api/src/boards/workers/cli.py +0 -125
  197. package/templates/api/src/boards/workers/context.py +0 -348
  198. package/templates/api/src/boards/workers/middleware.py +0 -58
  199. package/templates/api/src/py.typed +0 -0
  200. package/templates/compose.web.yaml +0 -35
  201. package/templates/compose.yaml +0 -116
  202. package/templates/docker/env.example +0 -23
  203. package/templates/web/.env.example +0 -28
  204. package/templates/web/Dockerfile +0 -51
  205. package/templates/web/components.json +0 -22
  206. package/templates/web/imageLoader.js +0 -18
  207. package/templates/web/next-env.d.ts +0 -5
  208. package/templates/web/next.config.js +0 -36
  209. package/templates/web/package.json +0 -41
  210. package/templates/web/postcss.config.mjs +0 -7
  211. package/templates/web/public/favicon.ico +0 -0
  212. package/templates/web/src/app/boards/[boardId]/page.tsx +0 -353
  213. package/templates/web/src/app/globals.css +0 -123
  214. package/templates/web/src/app/layout.tsx +0 -31
  215. package/templates/web/src/app/lineage/[generationId]/page.tsx +0 -235
  216. package/templates/web/src/app/page.tsx +0 -35
  217. package/templates/web/src/app/providers.tsx +0 -18
  218. package/templates/web/src/components/boards/ArtifactInputSlots.tsx +0 -206
  219. package/templates/web/src/components/boards/ArtifactPreview.tsx +0 -466
  220. package/templates/web/src/components/boards/GenerationGrid.tsx +0 -282
  221. package/templates/web/src/components/boards/GenerationInput.tsx +0 -370
  222. package/templates/web/src/components/boards/GeneratorSelector.tsx +0 -272
  223. package/templates/web/src/components/boards/UploadArtifact.tsx +0 -563
  224. package/templates/web/src/components/header.tsx +0 -32
  225. package/templates/web/src/components/theme-provider.tsx +0 -10
  226. package/templates/web/src/components/theme-toggle.tsx +0 -75
  227. package/templates/web/src/components/ui/alert-dialog.tsx +0 -157
  228. package/templates/web/src/components/ui/button.tsx +0 -58
  229. package/templates/web/src/components/ui/card.tsx +0 -92
  230. package/templates/web/src/components/ui/dropdown-menu.tsx +0 -200
  231. package/templates/web/src/components/ui/navigation-menu.tsx +0 -168
  232. package/templates/web/src/components/ui/toast.tsx +0 -128
  233. package/templates/web/src/components/ui/toaster.tsx +0 -35
  234. package/templates/web/src/components/ui/use-toast.ts +0 -187
  235. package/templates/web/src/hooks/useGeneratorMRU.ts +0 -57
  236. package/templates/web/src/lib/utils.ts +0 -6
  237. package/templates/web/tsconfig.json +0 -41
@@ -1,1053 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from typing import TYPE_CHECKING
4
- from uuid import UUID
5
-
6
- import strawberry
7
- from sqlalchemy import and_, or_, select
8
- from sqlalchemy.orm import selectinload
9
-
10
- from ...database.connection import get_async_session
11
- from ...dbmodels import BoardMembers, Boards, Generations, Users
12
- from ...logging import get_logger
13
- from ..access_control import (
14
- BoardQueryRole,
15
- SortOrder,
16
- can_access_board,
17
- can_access_board_details,
18
- ensure_preloaded,
19
- get_auth_context_from_info,
20
- )
21
-
22
- if TYPE_CHECKING:
23
- from ..mutations.root import AddBoardMemberInput, CreateBoardInput, UpdateBoardInput
24
- from ..types.board import Board, BoardMember, BoardRole
25
- from ..types.generation import Generation
26
- from ..types.user import User
27
-
28
- logger = get_logger(__name__)
29
-
30
-
31
- # Query resolvers
32
- async def resolve_board_by_id(info: strawberry.Info, id: UUID) -> Board | None:
33
- """
34
- Resolve a board by its ID.
35
-
36
- Checks authorization: board must be public or user must be owner/member.
37
- """
38
- auth_context = await get_auth_context_from_info(info)
39
- if auth_context is None:
40
- return None
41
-
42
- async with get_async_session() as session:
43
- # Query board with owner and members eagerly loaded
44
- stmt = (
45
- select(Boards)
46
- .where(Boards.id == id)
47
- .options(
48
- selectinload(Boards.owner),
49
- selectinload(Boards.board_members).selectinload(BoardMembers.user),
50
- )
51
- )
52
-
53
- result = await session.execute(stmt)
54
- board = result.scalar_one_or_none()
55
-
56
- if not board:
57
- logger.info("Board not found", board_id=str(id))
58
- return None
59
-
60
- # Check authorization using shared logic
61
- if not can_access_board(board, auth_context):
62
- logger.info(
63
- "Access denied to board",
64
- board_id=str(id),
65
- user_id=(
66
- str(auth_context.user_id) if auth_context and auth_context.user_id else None
67
- ),
68
- )
69
- return None
70
-
71
- # Convert SQLAlchemy model to GraphQL type
72
- from ..types.board import Board as BoardType
73
-
74
- return BoardType(
75
- id=board.id,
76
- tenant_id=board.tenant_id,
77
- owner_id=board.owner_id,
78
- title=board.title,
79
- description=board.description,
80
- is_public=board.is_public,
81
- settings=board.settings or {},
82
- metadata=board.metadata_ or {},
83
- created_at=board.created_at,
84
- updated_at=board.updated_at,
85
- )
86
-
87
-
88
- async def resolve_my_boards(
89
- info: strawberry.Info,
90
- limit: int,
91
- offset: int,
92
- role: BoardQueryRole = BoardQueryRole.ANY,
93
- sort: SortOrder = SortOrder.UPDATED_DESC,
94
- ) -> list[Board]:
95
- """
96
- Resolve boards where the authenticated user is owner or member.
97
-
98
- Args:
99
- role: Filter by role (ANY, OWNER, MEMBER)
100
- sort: Sort order for results
101
- """
102
- auth_context = await get_auth_context_from_info(info)
103
- if not auth_context or not auth_context.is_authenticated:
104
- logger.info("Unauthenticated access to my_boards")
105
- return []
106
-
107
- async with get_async_session() as session:
108
- # Build the query based on role filter
109
- if role == BoardQueryRole.OWNER:
110
- # Only boards owned by user
111
- boards_condition = Boards.owner_id == auth_context.user_id
112
- elif role == BoardQueryRole.MEMBER:
113
- # Only boards where user is a member (not owner)
114
- member_board_ids = select(BoardMembers.board_id).where(
115
- BoardMembers.user_id == auth_context.user_id
116
- )
117
- boards_condition = and_(
118
- Boards.id.in_(member_board_ids), Boards.owner_id != auth_context.user_id
119
- )
120
- else: # BoardQueryRole.ANY
121
- # Boards where user is owner OR member
122
- member_board_ids = select(BoardMembers.board_id).where(
123
- BoardMembers.user_id == auth_context.user_id
124
- )
125
- boards_condition = or_(
126
- Boards.owner_id == auth_context.user_id, Boards.id.in_(member_board_ids)
127
- )
128
-
129
- # Add sorting
130
- if sort == SortOrder.CREATED_ASC:
131
- order_by = Boards.created_at.asc()
132
- elif sort == SortOrder.CREATED_DESC:
133
- order_by = Boards.created_at.desc()
134
- elif sort == SortOrder.UPDATED_ASC:
135
- order_by = Boards.updated_at.asc()
136
- else: # UPDATED_DESC (default)
137
- order_by = Boards.updated_at.desc()
138
-
139
- stmt = (
140
- select(Boards)
141
- .where(boards_condition)
142
- .options(
143
- selectinload(Boards.owner),
144
- selectinload(Boards.board_members).selectinload(BoardMembers.user),
145
- )
146
- .order_by(order_by)
147
- .limit(limit)
148
- .offset(offset)
149
- )
150
-
151
- result = await session.execute(stmt)
152
- boards = result.scalars().all()
153
-
154
- # Convert to GraphQL types
155
- from ..types.board import Board as BoardType
156
-
157
- return [
158
- BoardType(
159
- id=board.id,
160
- tenant_id=board.tenant_id,
161
- owner_id=board.owner_id,
162
- title=board.title,
163
- description=board.description,
164
- is_public=board.is_public,
165
- settings=board.settings or {},
166
- metadata=board.metadata_ or {},
167
- created_at=board.created_at,
168
- updated_at=board.updated_at,
169
- )
170
- for board in boards
171
- ]
172
-
173
-
174
- async def resolve_public_boards(
175
- info: strawberry.Info,
176
- limit: int,
177
- offset: int,
178
- sort: SortOrder = SortOrder.UPDATED_DESC,
179
- ) -> list[Board]:
180
- """
181
- Resolve public boards (no authentication required).
182
-
183
- Args:
184
- sort: Sort order for results
185
- """
186
- async with get_async_session() as session:
187
- # Add sorting
188
- if sort == SortOrder.CREATED_ASC:
189
- order_by = Boards.created_at.asc()
190
- elif sort == SortOrder.CREATED_DESC:
191
- order_by = Boards.created_at.desc()
192
- elif sort == SortOrder.UPDATED_ASC:
193
- order_by = Boards.updated_at.asc()
194
- else: # UPDATED_DESC (default)
195
- order_by = Boards.updated_at.desc()
196
-
197
- stmt = (
198
- select(Boards)
199
- .where(Boards.is_public)
200
- .options(
201
- selectinload(Boards.owner),
202
- selectinload(Boards.board_members).selectinload(BoardMembers.user),
203
- )
204
- .order_by(order_by)
205
- .limit(limit)
206
- .offset(offset)
207
- )
208
-
209
- result = await session.execute(stmt)
210
- boards = result.scalars().all()
211
-
212
- # Convert to GraphQL types
213
- from ..types.board import Board as BoardType
214
-
215
- return [
216
- BoardType(
217
- id=board.id,
218
- tenant_id=board.tenant_id,
219
- owner_id=board.owner_id,
220
- title=board.title,
221
- description=board.description,
222
- is_public=board.is_public,
223
- settings=board.settings or {},
224
- metadata=board.metadata_ or {},
225
- created_at=board.created_at,
226
- updated_at=board.updated_at,
227
- )
228
- for board in boards
229
- ]
230
-
231
-
232
- async def search_boards(info: strawberry.Info, query: str, limit: int, offset: int) -> list[Board]:
233
- """
234
- Search for boards based on a text query.
235
-
236
- Searches board titles and descriptions for the query string.
237
- Only returns boards the user has access to.
238
- """
239
- auth_context = await get_auth_context_from_info(info)
240
-
241
- async with get_async_session() as session:
242
- # Build base query with case-insensitive search
243
- search_pattern = f"%{query}%"
244
-
245
- # Base condition for text search
246
- search_condition = or_(
247
- Boards.title.ilike(search_pattern), Boards.description.ilike(search_pattern)
248
- )
249
-
250
- # Add access control conditions
251
- if auth_context and auth_context.is_authenticated:
252
- # User can see: public boards OR boards they own OR boards they're a member of
253
- member_board_ids = select(BoardMembers.board_id).where(
254
- BoardMembers.user_id == auth_context.user_id
255
- )
256
- access_condition = or_(
257
- Boards.is_public,
258
- Boards.owner_id == auth_context.user_id,
259
- Boards.id.in_(member_board_ids),
260
- )
261
- else:
262
- # Unauthenticated users can only see public boards
263
- access_condition = Boards.is_public
264
-
265
- stmt = (
266
- select(Boards)
267
- .where(and_(search_condition, access_condition))
268
- .options(
269
- selectinload(Boards.owner),
270
- selectinload(Boards.board_members).selectinload(BoardMembers.user),
271
- )
272
- .order_by(Boards.updated_at.desc())
273
- .limit(limit)
274
- .offset(offset)
275
- )
276
-
277
- result = await session.execute(stmt)
278
- boards = result.scalars().all()
279
-
280
- # Convert to GraphQL types
281
- from ..types.board import Board as BoardType
282
-
283
- return [
284
- BoardType(
285
- id=board.id,
286
- tenant_id=board.tenant_id,
287
- owner_id=board.owner_id,
288
- title=board.title,
289
- description=board.description,
290
- is_public=board.is_public,
291
- settings=board.settings or {},
292
- metadata=board.metadata_ or {},
293
- created_at=board.created_at,
294
- updated_at=board.updated_at,
295
- )
296
- for board in boards
297
- ]
298
-
299
-
300
- # Board field resolvers
301
- async def resolve_board_owner(board: Board, info: strawberry.Info) -> User:
302
- """
303
- Resolve the owner of a board. Requires user to have access to board details.
304
- """
305
- auth_context = await get_auth_context_from_info(info)
306
-
307
- # Check if user can access board details
308
- # We need to get the actual board from database to check access
309
- async with get_async_session() as session:
310
- stmt = (
311
- select(Boards)
312
- .where(Boards.id == board.id)
313
- .options(
314
- selectinload(Boards.owner),
315
- selectinload(Boards.board_members),
316
- )
317
- )
318
- result = await session.execute(stmt)
319
- db_board = result.scalar_one_or_none()
320
-
321
- if not db_board or not can_access_board_details(db_board, auth_context):
322
- raise RuntimeError("Access denied to board owner information")
323
-
324
- # Ensure owner is preloaded
325
- ensure_preloaded(db_board, "owner", "Board owner relationship was not preloaded")
326
-
327
- if not db_board.owner:
328
- raise RuntimeError("Board owner not found")
329
-
330
- from ..types.user import User as UserType
331
-
332
- return UserType(
333
- id=db_board.owner.id,
334
- tenant_id=db_board.owner.tenant_id,
335
- auth_provider=db_board.owner.auth_provider,
336
- auth_subject=db_board.owner.auth_subject,
337
- email=db_board.owner.email,
338
- display_name=db_board.owner.display_name,
339
- avatar_url=db_board.owner.avatar_url,
340
- created_at=db_board.owner.created_at,
341
- updated_at=db_board.owner.updated_at,
342
- )
343
-
344
-
345
- async def resolve_board_members(board: Board, info: strawberry.Info) -> list[BoardMember]:
346
- """
347
- Resolve the members of a board. Requires user to have access to board details.
348
- """
349
- auth_context = await get_auth_context_from_info(info)
350
-
351
- # Check if user can access board details
352
- async with get_async_session() as session:
353
- stmt = (
354
- select(Boards)
355
- .where(Boards.id == board.id)
356
- .options(
357
- selectinload(Boards.board_members).selectinload(BoardMembers.user),
358
- selectinload(Boards.owner),
359
- )
360
- )
361
- result = await session.execute(stmt)
362
- db_board = result.scalar_one_or_none()
363
-
364
- if not db_board or not can_access_board_details(db_board, auth_context):
365
- raise RuntimeError("Access denied to board member information")
366
-
367
- # Ensure members are preloaded
368
- ensure_preloaded(db_board, "board_members", "Board members relationship was not preloaded")
369
-
370
- from ..types.board import BoardMember as BoardMemberType
371
- from ..types.board import BoardRole
372
-
373
- members = []
374
- for member in db_board.board_members:
375
- # Ensure user relationship is preloaded
376
- ensure_preloaded(member, "user", "BoardMember user relationship was not preloaded")
377
-
378
- members.append(
379
- BoardMemberType(
380
- id=member.id,
381
- board_id=member.board_id,
382
- user_id=member.user_id,
383
- role=BoardRole(member.role),
384
- invited_by=member.invited_by,
385
- joined_at=member.joined_at,
386
- )
387
- )
388
-
389
- return members
390
-
391
-
392
- async def resolve_board_generations(
393
- board: Board, info: strawberry.Info, limit: int, offset: int
394
- ) -> list[Generation]:
395
- """
396
- Resolve generations for a board. Requires user to have access to the board.
397
- """
398
- auth_context = await get_auth_context_from_info(info)
399
-
400
- # Check if user can access board
401
- async with get_async_session() as session:
402
- # First check board access
403
- stmt = (
404
- select(Boards)
405
- .where(Boards.id == board.id)
406
- .options(
407
- selectinload(Boards.board_members),
408
- )
409
- )
410
- result = await session.execute(stmt)
411
- db_board = result.scalar_one_or_none()
412
-
413
- if not db_board or not can_access_board(db_board, auth_context):
414
- logger.info("Access denied to board generations", board_id=str(board.id))
415
- return []
416
-
417
- # Query generations for this board
418
- generations_stmt = (
419
- select(Generations)
420
- .where(Generations.board_id == board.id)
421
- .order_by(Generations.created_at.desc())
422
- .limit(limit)
423
- .offset(offset)
424
- )
425
-
426
- generations_result = await session.execute(generations_stmt)
427
- generations = generations_result.scalars().all()
428
-
429
- from ..types.generation import ArtifactType, GenerationStatus
430
- from ..types.generation import Generation as GenerationType
431
-
432
- return [
433
- GenerationType(
434
- id=gen.id,
435
- tenant_id=gen.tenant_id,
436
- board_id=gen.board_id,
437
- user_id=gen.user_id,
438
- generator_name=gen.generator_name,
439
- artifact_type=ArtifactType(gen.artifact_type),
440
- storage_url=gen.storage_url,
441
- thumbnail_url=gen.thumbnail_url,
442
- additional_files=gen.additional_files or [],
443
- input_params=gen.input_params or {},
444
- output_metadata=gen.output_metadata or {},
445
- external_job_id=gen.external_job_id,
446
- status=GenerationStatus(gen.status),
447
- progress=float(gen.progress or 0.0),
448
- error_message=gen.error_message,
449
- started_at=gen.started_at,
450
- completed_at=gen.completed_at,
451
- created_at=gen.created_at,
452
- updated_at=gen.updated_at,
453
- )
454
- for gen in generations
455
- ]
456
-
457
-
458
- async def resolve_board_generation_count(board: Board, info: strawberry.Info) -> int:
459
- """
460
- Get the total count of generations for a board.
461
-
462
- More efficient than fetching all generations when only count is needed.
463
- """
464
- auth_context = await get_auth_context_from_info(info)
465
-
466
- async with get_async_session() as session:
467
- # First check board access
468
- stmt = (
469
- select(Boards)
470
- .where(Boards.id == board.id)
471
- .options(
472
- selectinload(Boards.board_members),
473
- )
474
- )
475
- result = await session.execute(stmt)
476
- db_board = result.scalar_one_or_none()
477
-
478
- if not db_board or not can_access_board(db_board, auth_context):
479
- logger.info("Access denied to board generation count", board_id=str(board.id))
480
- return 0
481
-
482
- # Count generations for this board
483
- from sqlalchemy import func
484
-
485
- count_stmt = select(func.count(Generations.id)).where(Generations.board_id == board.id)
486
-
487
- count_result = await session.execute(count_stmt)
488
- return count_result.scalar() or 0
489
-
490
-
491
- # BoardMember field resolvers
492
- async def resolve_board_member_user(member: BoardMember, info: strawberry.Info) -> User:
493
- """
494
- Resolve the user for a board member. Requires access to board details.
495
- """
496
- auth_context = await get_auth_context_from_info(info)
497
-
498
- async with get_async_session() as session:
499
- # First verify access to the board that this member belongs to
500
- board_stmt = (
501
- select(Boards)
502
- .where(Boards.id == member.board_id)
503
- .options(
504
- selectinload(Boards.board_members),
505
- )
506
- )
507
- board_result = await session.execute(board_stmt)
508
- board = board_result.scalar_one_or_none()
509
-
510
- if not board or not can_access_board_details(board, auth_context):
511
- raise RuntimeError("Access denied to board member information")
512
-
513
- # Query the user
514
- user_stmt = select(Users).where(Users.id == member.user_id)
515
- user_result = await session.execute(user_stmt)
516
- user = user_result.scalar_one_or_none()
517
-
518
- if not user:
519
- raise RuntimeError("Board member user not found")
520
-
521
- from ..types.user import User as UserType
522
-
523
- return UserType(
524
- id=user.id,
525
- tenant_id=user.tenant_id,
526
- auth_provider=user.auth_provider,
527
- auth_subject=user.auth_subject,
528
- email=user.email,
529
- display_name=user.display_name,
530
- avatar_url=user.avatar_url,
531
- created_at=user.created_at,
532
- updated_at=user.updated_at,
533
- )
534
-
535
-
536
- async def resolve_board_member_inviter(member: BoardMember, info: strawberry.Info) -> User | None:
537
- """
538
- Resolve the user who invited this board member.
539
-
540
- Returns None if the member is the original owner or if no inviter is recorded.
541
- """
542
- if not member.invited_by:
543
- return None
544
-
545
- auth_context = await get_auth_context_from_info(info)
546
-
547
- async with get_async_session() as session:
548
- # First verify access to the board that this member belongs to
549
- board_stmt = (
550
- select(Boards)
551
- .where(Boards.id == member.board_id)
552
- .options(
553
- selectinload(Boards.board_members),
554
- )
555
- )
556
- board_result = await session.execute(board_stmt)
557
- board = board_result.scalar_one_or_none()
558
-
559
- if not board or not can_access_board_details(board, auth_context):
560
- raise RuntimeError("Access denied to board member inviter information")
561
-
562
- # Query the inviter
563
- inviter_stmt = select(Users).where(Users.id == member.invited_by)
564
- inviter_result = await session.execute(inviter_stmt)
565
- inviter = inviter_result.scalar_one_or_none()
566
-
567
- if not inviter:
568
- return None
569
-
570
- from ..types.user import User as UserType
571
-
572
- return UserType(
573
- id=inviter.id,
574
- tenant_id=inviter.tenant_id,
575
- auth_provider=inviter.auth_provider,
576
- auth_subject=inviter.auth_subject,
577
- email=inviter.email,
578
- display_name=inviter.display_name,
579
- avatar_url=inviter.avatar_url,
580
- created_at=inviter.created_at,
581
- updated_at=inviter.updated_at,
582
- )
583
-
584
-
585
- # Mutation resolvers
586
- async def create_board(info: strawberry.Info, input: CreateBoardInput) -> Board:
587
- """
588
- Create a new board.
589
-
590
- The authenticated user becomes the owner of the board.
591
- """
592
- auth_context = await get_auth_context_from_info(info)
593
- if not auth_context or not auth_context.is_authenticated:
594
- raise RuntimeError("Authentication required to create a board")
595
-
596
- async with get_async_session() as session:
597
- # Get the tenant UUID from the database
598
- tenant_uuid = auth_context.tenant_id
599
-
600
- # Create the new board
601
- new_board = Boards(
602
- tenant_id=tenant_uuid,
603
- owner_id=auth_context.user_id,
604
- title=input.title,
605
- description=input.description,
606
- is_public=input.is_public,
607
- settings=input.settings or {},
608
- )
609
-
610
- session.add(new_board)
611
- await session.commit()
612
- await session.refresh(new_board)
613
-
614
- # Load relationships for the response
615
- stmt = (
616
- select(Boards)
617
- .where(Boards.id == new_board.id)
618
- .options(
619
- selectinload(Boards.owner),
620
- selectinload(Boards.board_members).selectinload(BoardMembers.user),
621
- )
622
- )
623
- result = await session.execute(stmt)
624
- board = result.scalar_one()
625
-
626
- logger.info(
627
- "Board created",
628
- board_id=str(board.id),
629
- user_id=str(auth_context.user_id),
630
- title=board.title,
631
- )
632
-
633
- from ..types.board import Board as BoardType
634
-
635
- return BoardType(
636
- id=board.id,
637
- tenant_id=tenant_uuid,
638
- owner_id=board.owner_id,
639
- title=board.title,
640
- description=board.description,
641
- is_public=board.is_public,
642
- settings=board.settings or {},
643
- metadata=board.metadata_ or {},
644
- created_at=board.created_at,
645
- updated_at=board.updated_at,
646
- )
647
-
648
-
649
- async def update_board(info: strawberry.Info, input: UpdateBoardInput) -> Board:
650
- """
651
- Update an existing board.
652
-
653
- Only the board owner or an admin member can update the board.
654
- """
655
- auth_context = await get_auth_context_from_info(info)
656
- if not auth_context or not auth_context.is_authenticated:
657
- raise RuntimeError("Authentication required to update a board")
658
-
659
- async with get_async_session() as session:
660
- # Get the board with members
661
- stmt = (
662
- select(Boards)
663
- .where(Boards.id == input.id)
664
- .options(
665
- selectinload(Boards.owner),
666
- selectinload(Boards.board_members).selectinload(BoardMembers.user),
667
- )
668
- )
669
- result = await session.execute(stmt)
670
- board = result.scalar_one_or_none()
671
-
672
- if not board:
673
- raise RuntimeError("Board not found")
674
-
675
- # Check permissions: must be owner or admin
676
- is_owner = board.owner_id == auth_context.user_id
677
- is_admin = any(
678
- member.user_id == auth_context.user_id and member.role == "admin"
679
- for member in board.board_members
680
- )
681
-
682
- if not is_owner and not is_admin:
683
- raise RuntimeError("Permission denied: only board owner or admin can update")
684
-
685
- # Update fields if provided
686
- if input.title is not None:
687
- board.title = input.title
688
- if input.description is not None:
689
- board.description = input.description
690
- if input.is_public is not None:
691
- board.is_public = input.is_public
692
- if input.settings is not None:
693
- board.settings = input.settings
694
-
695
- await session.commit()
696
- await session.refresh(board)
697
-
698
- logger.info(
699
- "Board updated",
700
- board_id=str(board.id),
701
- user_id=str(auth_context.user_id),
702
- updated_fields=[
703
- k
704
- for k, v in {
705
- "title": input.title,
706
- "description": input.description,
707
- "is_public": input.is_public,
708
- "settings": input.settings,
709
- }.items()
710
- if v is not None
711
- ],
712
- )
713
-
714
- from ..types.board import Board as BoardType
715
-
716
- return BoardType(
717
- id=board.id,
718
- tenant_id=board.tenant_id,
719
- owner_id=board.owner_id,
720
- title=board.title,
721
- description=board.description,
722
- is_public=board.is_public,
723
- settings=board.settings or {},
724
- metadata=board.metadata_ or {},
725
- created_at=board.created_at,
726
- updated_at=board.updated_at,
727
- )
728
-
729
-
730
- async def delete_board(info: strawberry.Info, id: UUID) -> bool:
731
- """
732
- Delete a board and all associated data.
733
-
734
- Only the board owner can delete a board.
735
- """
736
- auth_context = await get_auth_context_from_info(info)
737
- if not auth_context or not auth_context.is_authenticated:
738
- raise RuntimeError("Authentication required to delete a board")
739
-
740
- async with get_async_session() as session:
741
- # Get the board
742
- stmt = select(Boards).where(Boards.id == id)
743
- result = await session.execute(stmt)
744
- board = result.scalar_one_or_none()
745
-
746
- if not board:
747
- raise RuntimeError("Board not found")
748
-
749
- # Check if user is the owner
750
- if board.owner_id != auth_context.user_id:
751
- raise RuntimeError("Permission denied: only board owner can delete")
752
-
753
- # Delete the board (cascade will handle related records)
754
- await session.delete(board)
755
- await session.commit()
756
-
757
- logger.info("Board deleted", board_id=str(id), user_id=str(auth_context.user_id))
758
-
759
- return True
760
-
761
-
762
- async def add_board_member(info: strawberry.Info, input: AddBoardMemberInput) -> Board:
763
- """
764
- Add a new member to a board.
765
-
766
- Only the board owner or an admin member can add new members.
767
- """
768
- auth_context = await get_auth_context_from_info(info)
769
- if not auth_context or not auth_context.is_authenticated:
770
- raise RuntimeError("Authentication required to add board members")
771
-
772
- async with get_async_session() as session:
773
- # Get the board with members
774
- stmt = (
775
- select(Boards)
776
- .where(Boards.id == input.board_id)
777
- .options(
778
- selectinload(Boards.owner),
779
- selectinload(Boards.board_members).selectinload(BoardMembers.user),
780
- )
781
- )
782
- result = await session.execute(stmt)
783
- board = result.scalar_one_or_none()
784
-
785
- if not board:
786
- raise RuntimeError("Board not found")
787
-
788
- # Check permissions: must be owner or admin
789
- is_owner = board.owner_id == auth_context.user_id
790
- is_admin = any(
791
- member.user_id == auth_context.user_id and member.role == "admin"
792
- for member in board.board_members
793
- )
794
-
795
- if not is_owner and not is_admin:
796
- raise RuntimeError("Permission denied: only board owner or admin can add members")
797
-
798
- # Check if user to be added exists
799
- user_stmt = select(Users).where(Users.id == input.user_id)
800
- user_result = await session.execute(user_stmt)
801
- user = user_result.scalar_one_or_none()
802
-
803
- if not user:
804
- raise RuntimeError("User not found")
805
-
806
- # Check if user is already the owner
807
- if board.owner_id == input.user_id:
808
- raise RuntimeError("User is already the board owner")
809
-
810
- # Check if user is already a member
811
- existing_member = any(member.user_id == input.user_id for member in board.board_members)
812
-
813
- if existing_member:
814
- raise RuntimeError("User is already a board member")
815
-
816
- # Add the new member
817
- new_member = BoardMembers(
818
- board_id=input.board_id,
819
- user_id=input.user_id,
820
- role=input.role.value,
821
- invited_by=auth_context.user_id,
822
- )
823
-
824
- session.add(new_member)
825
- await session.commit()
826
-
827
- # Refresh the board to get updated members
828
- await session.refresh(board)
829
-
830
- # Re-query with all relationships loaded
831
- stmt = (
832
- select(Boards)
833
- .where(Boards.id == input.board_id)
834
- .options(
835
- selectinload(Boards.owner),
836
- selectinload(Boards.board_members).selectinload(BoardMembers.user),
837
- )
838
- )
839
- result = await session.execute(stmt)
840
- board = result.scalar_one()
841
-
842
- logger.info(
843
- "Board member added",
844
- board_id=str(board.id),
845
- user_id=str(input.user_id),
846
- role=input.role.value,
847
- invited_by=str(auth_context.user_id),
848
- )
849
-
850
- from ..types.board import Board as BoardType
851
-
852
- return BoardType(
853
- id=board.id,
854
- tenant_id=board.tenant_id,
855
- owner_id=board.owner_id,
856
- title=board.title,
857
- description=board.description,
858
- is_public=board.is_public,
859
- settings=board.settings or {},
860
- metadata=board.metadata_ or {},
861
- created_at=board.created_at,
862
- updated_at=board.updated_at,
863
- )
864
-
865
-
866
- async def remove_board_member(info: strawberry.Info, board_id: UUID, user_id: UUID) -> Board:
867
- """
868
- Remove a member from a board.
869
-
870
- Only the board owner, an admin member, or the member themselves can remove a member.
871
- The board owner cannot be removed.
872
- """
873
- auth_context = await get_auth_context_from_info(info)
874
- if not auth_context or not auth_context.is_authenticated:
875
- raise RuntimeError("Authentication required to remove board members")
876
-
877
- async with get_async_session() as session:
878
- # Get the board with members
879
- stmt = (
880
- select(Boards)
881
- .where(Boards.id == board_id)
882
- .options(
883
- selectinload(Boards.owner),
884
- selectinload(Boards.board_members).selectinload(BoardMembers.user),
885
- )
886
- )
887
- result = await session.execute(stmt)
888
- board = result.scalar_one_or_none()
889
-
890
- if not board:
891
- raise RuntimeError("Board not found")
892
-
893
- # Cannot remove the board owner
894
- if board.owner_id == user_id:
895
- raise RuntimeError("Cannot remove the board owner")
896
-
897
- # Find the member to remove
898
- member_to_remove = None
899
- for member in board.board_members:
900
- if member.user_id == user_id:
901
- member_to_remove = member
902
- break
903
-
904
- if not member_to_remove:
905
- raise RuntimeError("User is not a board member")
906
-
907
- # Check permissions
908
- is_owner = board.owner_id == auth_context.user_id
909
- is_admin = any(
910
- member.user_id == auth_context.user_id and member.role == "admin"
911
- for member in board.board_members
912
- )
913
- is_self = user_id == auth_context.user_id
914
-
915
- if not is_owner and not is_admin and not is_self:
916
- raise RuntimeError("Permission denied: insufficient permissions to remove member")
917
-
918
- # Remove the member
919
- await session.delete(member_to_remove)
920
- await session.commit()
921
-
922
- # Re-query the board with updated members
923
- stmt = (
924
- select(Boards)
925
- .where(Boards.id == board_id)
926
- .options(
927
- selectinload(Boards.owner),
928
- selectinload(Boards.board_members).selectinload(BoardMembers.user),
929
- )
930
- )
931
- result = await session.execute(stmt)
932
- board = result.scalar_one()
933
-
934
- logger.info(
935
- "Board member removed",
936
- board_id=str(board_id),
937
- removed_user_id=str(user_id),
938
- removed_by=str(auth_context.user_id),
939
- )
940
-
941
- from ..types.board import Board as BoardType
942
-
943
- return BoardType(
944
- id=board.id,
945
- tenant_id=board.tenant_id,
946
- owner_id=board.owner_id,
947
- title=board.title,
948
- description=board.description,
949
- is_public=board.is_public,
950
- settings=board.settings or {},
951
- metadata=board.metadata_ or {},
952
- created_at=board.created_at,
953
- updated_at=board.updated_at,
954
- )
955
-
956
-
957
- async def update_board_member_role(
958
- info: strawberry.Info, board_id: UUID, user_id: UUID, role: BoardRole
959
- ) -> Board:
960
- """
961
- Update a board member's role.
962
-
963
- Only the board owner or an admin member can change member roles.
964
- The board owner's role cannot be changed (they are always the owner).
965
- """
966
- auth_context = await get_auth_context_from_info(info)
967
- if not auth_context or not auth_context.is_authenticated:
968
- raise RuntimeError("Authentication required to update member roles")
969
-
970
- async with get_async_session() as session:
971
- # Get the board with members
972
- stmt = (
973
- select(Boards)
974
- .where(Boards.id == board_id)
975
- .options(
976
- selectinload(Boards.owner),
977
- selectinload(Boards.board_members).selectinload(BoardMembers.user),
978
- )
979
- )
980
- result = await session.execute(stmt)
981
- board = result.scalar_one_or_none()
982
-
983
- if not board:
984
- raise RuntimeError("Board not found")
985
-
986
- # Cannot change the owner's role
987
- if board.owner_id == user_id:
988
- raise RuntimeError("Cannot change the board owner's role")
989
-
990
- # Check permissions: must be owner or admin
991
- is_owner = board.owner_id == auth_context.user_id
992
- is_admin = any(
993
- member.user_id == auth_context.user_id and member.role == "admin"
994
- for member in board.board_members
995
- )
996
-
997
- if not is_owner and not is_admin:
998
- raise RuntimeError(
999
- "Permission denied: only board owner or admin can change member roles"
1000
- )
1001
-
1002
- # Find the member to update
1003
- member_to_update = None
1004
- for member in board.board_members:
1005
- if member.user_id == user_id:
1006
- member_to_update = member
1007
- break
1008
-
1009
- if not member_to_update:
1010
- raise RuntimeError("User is not a board member")
1011
-
1012
- # Update the role
1013
- old_role = member_to_update.role
1014
- member_to_update.role = role.value
1015
-
1016
- await session.commit()
1017
- await session.refresh(board)
1018
-
1019
- # Re-query with all relationships loaded
1020
- stmt = (
1021
- select(Boards)
1022
- .where(Boards.id == board_id)
1023
- .options(
1024
- selectinload(Boards.owner),
1025
- selectinload(Boards.board_members).selectinload(BoardMembers.user),
1026
- )
1027
- )
1028
- result = await session.execute(stmt)
1029
- board = result.scalar_one()
1030
-
1031
- logger.info(
1032
- "Board member role updated",
1033
- board_id=str(board_id),
1034
- user_id=str(user_id),
1035
- old_role=old_role,
1036
- new_role=role.value,
1037
- updated_by=str(auth_context.user_id),
1038
- )
1039
-
1040
- from ..types.board import Board as BoardType
1041
-
1042
- return BoardType(
1043
- id=board.id,
1044
- tenant_id=board.tenant_id,
1045
- owner_id=board.owner_id,
1046
- title=board.title,
1047
- description=board.description,
1048
- is_public=board.is_public,
1049
- settings=board.settings or {},
1050
- metadata=board.metadata_ or {},
1051
- created_at=board.created_at,
1052
- updated_at=board.updated_at,
1053
- )