@weirdfingers/baseboards 0.2.0

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 (139) hide show
  1. package/README.md +191 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +887 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +64 -0
  6. package/templates/README.md +120 -0
  7. package/templates/api/.env.example +62 -0
  8. package/templates/api/Dockerfile +32 -0
  9. package/templates/api/README.md +132 -0
  10. package/templates/api/alembic/env.py +106 -0
  11. package/templates/api/alembic/script.py.mako +28 -0
  12. package/templates/api/alembic/versions/20250101_000000_initial_schema.py +448 -0
  13. package/templates/api/alembic/versions/20251022_174729_remove_provider_name_from_generations.py +71 -0
  14. package/templates/api/alembic/versions/20251023_165852_switch_to_declarative_base_and_mapping.py +411 -0
  15. package/templates/api/alembic/versions/2025925_62735_add_seed_data_for_default_tenant.py +85 -0
  16. package/templates/api/alembic.ini +36 -0
  17. package/templates/api/config/generators.yaml +25 -0
  18. package/templates/api/config/storage_config.yaml +26 -0
  19. package/templates/api/docs/ADDING_GENERATORS.md +409 -0
  20. package/templates/api/docs/GENERATORS_API.md +502 -0
  21. package/templates/api/docs/MIGRATIONS.md +472 -0
  22. package/templates/api/docs/storage_providers.md +337 -0
  23. package/templates/api/pyproject.toml +165 -0
  24. package/templates/api/src/boards/__init__.py +10 -0
  25. package/templates/api/src/boards/api/app.py +171 -0
  26. package/templates/api/src/boards/api/auth.py +75 -0
  27. package/templates/api/src/boards/api/endpoints/__init__.py +3 -0
  28. package/templates/api/src/boards/api/endpoints/jobs.py +76 -0
  29. package/templates/api/src/boards/api/endpoints/setup.py +505 -0
  30. package/templates/api/src/boards/api/endpoints/sse.py +129 -0
  31. package/templates/api/src/boards/api/endpoints/storage.py +74 -0
  32. package/templates/api/src/boards/api/endpoints/tenant_registration.py +296 -0
  33. package/templates/api/src/boards/api/endpoints/webhooks.py +13 -0
  34. package/templates/api/src/boards/auth/__init__.py +15 -0
  35. package/templates/api/src/boards/auth/adapters/__init__.py +20 -0
  36. package/templates/api/src/boards/auth/adapters/auth0.py +220 -0
  37. package/templates/api/src/boards/auth/adapters/base.py +73 -0
  38. package/templates/api/src/boards/auth/adapters/clerk.py +172 -0
  39. package/templates/api/src/boards/auth/adapters/jwt.py +122 -0
  40. package/templates/api/src/boards/auth/adapters/none.py +102 -0
  41. package/templates/api/src/boards/auth/adapters/oidc.py +284 -0
  42. package/templates/api/src/boards/auth/adapters/supabase.py +110 -0
  43. package/templates/api/src/boards/auth/context.py +35 -0
  44. package/templates/api/src/boards/auth/factory.py +115 -0
  45. package/templates/api/src/boards/auth/middleware.py +221 -0
  46. package/templates/api/src/boards/auth/provisioning.py +129 -0
  47. package/templates/api/src/boards/auth/tenant_extraction.py +278 -0
  48. package/templates/api/src/boards/cli.py +354 -0
  49. package/templates/api/src/boards/config.py +116 -0
  50. package/templates/api/src/boards/database/__init__.py +7 -0
  51. package/templates/api/src/boards/database/cli.py +110 -0
  52. package/templates/api/src/boards/database/connection.py +252 -0
  53. package/templates/api/src/boards/database/models.py +19 -0
  54. package/templates/api/src/boards/database/seed_data.py +182 -0
  55. package/templates/api/src/boards/dbmodels/__init__.py +455 -0
  56. package/templates/api/src/boards/generators/__init__.py +57 -0
  57. package/templates/api/src/boards/generators/artifacts.py +53 -0
  58. package/templates/api/src/boards/generators/base.py +140 -0
  59. package/templates/api/src/boards/generators/implementations/__init__.py +12 -0
  60. package/templates/api/src/boards/generators/implementations/audio/__init__.py +3 -0
  61. package/templates/api/src/boards/generators/implementations/audio/whisper.py +66 -0
  62. package/templates/api/src/boards/generators/implementations/image/__init__.py +3 -0
  63. package/templates/api/src/boards/generators/implementations/image/dalle3.py +93 -0
  64. package/templates/api/src/boards/generators/implementations/image/flux_pro.py +85 -0
  65. package/templates/api/src/boards/generators/implementations/video/__init__.py +3 -0
  66. package/templates/api/src/boards/generators/implementations/video/lipsync.py +70 -0
  67. package/templates/api/src/boards/generators/loader.py +253 -0
  68. package/templates/api/src/boards/generators/registry.py +114 -0
  69. package/templates/api/src/boards/generators/resolution.py +515 -0
  70. package/templates/api/src/boards/generators/testmods/class_gen.py +34 -0
  71. package/templates/api/src/boards/generators/testmods/import_side_effect.py +35 -0
  72. package/templates/api/src/boards/graphql/__init__.py +7 -0
  73. package/templates/api/src/boards/graphql/access_control.py +136 -0
  74. package/templates/api/src/boards/graphql/mutations/root.py +136 -0
  75. package/templates/api/src/boards/graphql/queries/root.py +116 -0
  76. package/templates/api/src/boards/graphql/resolvers/__init__.py +8 -0
  77. package/templates/api/src/boards/graphql/resolvers/auth.py +12 -0
  78. package/templates/api/src/boards/graphql/resolvers/board.py +1055 -0
  79. package/templates/api/src/boards/graphql/resolvers/generation.py +889 -0
  80. package/templates/api/src/boards/graphql/resolvers/generator.py +50 -0
  81. package/templates/api/src/boards/graphql/resolvers/user.py +25 -0
  82. package/templates/api/src/boards/graphql/schema.py +81 -0
  83. package/templates/api/src/boards/graphql/types/board.py +102 -0
  84. package/templates/api/src/boards/graphql/types/generation.py +130 -0
  85. package/templates/api/src/boards/graphql/types/generator.py +17 -0
  86. package/templates/api/src/boards/graphql/types/user.py +47 -0
  87. package/templates/api/src/boards/jobs/repository.py +104 -0
  88. package/templates/api/src/boards/logging.py +195 -0
  89. package/templates/api/src/boards/middleware.py +339 -0
  90. package/templates/api/src/boards/progress/__init__.py +4 -0
  91. package/templates/api/src/boards/progress/models.py +25 -0
  92. package/templates/api/src/boards/progress/publisher.py +64 -0
  93. package/templates/api/src/boards/py.typed +0 -0
  94. package/templates/api/src/boards/redis_pool.py +118 -0
  95. package/templates/api/src/boards/storage/__init__.py +52 -0
  96. package/templates/api/src/boards/storage/base.py +363 -0
  97. package/templates/api/src/boards/storage/config.py +187 -0
  98. package/templates/api/src/boards/storage/factory.py +278 -0
  99. package/templates/api/src/boards/storage/implementations/__init__.py +27 -0
  100. package/templates/api/src/boards/storage/implementations/gcs.py +340 -0
  101. package/templates/api/src/boards/storage/implementations/local.py +201 -0
  102. package/templates/api/src/boards/storage/implementations/s3.py +294 -0
  103. package/templates/api/src/boards/storage/implementations/supabase.py +218 -0
  104. package/templates/api/src/boards/tenant_isolation.py +446 -0
  105. package/templates/api/src/boards/validation.py +262 -0
  106. package/templates/api/src/boards/workers/__init__.py +1 -0
  107. package/templates/api/src/boards/workers/actors.py +201 -0
  108. package/templates/api/src/boards/workers/cli.py +125 -0
  109. package/templates/api/src/boards/workers/context.py +188 -0
  110. package/templates/api/src/boards/workers/middleware.py +58 -0
  111. package/templates/api/src/py.typed +0 -0
  112. package/templates/compose.dev.yaml +39 -0
  113. package/templates/compose.yaml +109 -0
  114. package/templates/docker/env.example +23 -0
  115. package/templates/web/.env.example +28 -0
  116. package/templates/web/Dockerfile +51 -0
  117. package/templates/web/components.json +22 -0
  118. package/templates/web/imageLoader.js +18 -0
  119. package/templates/web/next-env.d.ts +5 -0
  120. package/templates/web/next.config.js +36 -0
  121. package/templates/web/package.json +37 -0
  122. package/templates/web/postcss.config.mjs +7 -0
  123. package/templates/web/public/favicon.ico +0 -0
  124. package/templates/web/src/app/boards/[boardId]/page.tsx +232 -0
  125. package/templates/web/src/app/globals.css +120 -0
  126. package/templates/web/src/app/layout.tsx +21 -0
  127. package/templates/web/src/app/page.tsx +35 -0
  128. package/templates/web/src/app/providers.tsx +18 -0
  129. package/templates/web/src/components/boards/ArtifactInputSlots.tsx +142 -0
  130. package/templates/web/src/components/boards/ArtifactPreview.tsx +125 -0
  131. package/templates/web/src/components/boards/GenerationGrid.tsx +45 -0
  132. package/templates/web/src/components/boards/GenerationInput.tsx +251 -0
  133. package/templates/web/src/components/boards/GeneratorSelector.tsx +89 -0
  134. package/templates/web/src/components/header.tsx +30 -0
  135. package/templates/web/src/components/ui/button.tsx +58 -0
  136. package/templates/web/src/components/ui/card.tsx +92 -0
  137. package/templates/web/src/components/ui/navigation-menu.tsx +168 -0
  138. package/templates/web/src/lib/utils.ts +6 -0
  139. package/templates/web/tsconfig.json +47 -0
@@ -0,0 +1,1055 @@
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
+ parent_generation_id=gen.parent_generation_id,
446
+ input_generation_ids=gen.input_generation_ids or [],
447
+ external_job_id=gen.external_job_id,
448
+ status=GenerationStatus(gen.status),
449
+ progress=float(gen.progress or 0.0),
450
+ error_message=gen.error_message,
451
+ started_at=gen.started_at,
452
+ completed_at=gen.completed_at,
453
+ created_at=gen.created_at,
454
+ updated_at=gen.updated_at,
455
+ )
456
+ for gen in generations
457
+ ]
458
+
459
+
460
+ async def resolve_board_generation_count(board: Board, info: strawberry.Info) -> int:
461
+ """
462
+ Get the total count of generations for a board.
463
+
464
+ More efficient than fetching all generations when only count is needed.
465
+ """
466
+ auth_context = await get_auth_context_from_info(info)
467
+
468
+ async with get_async_session() as session:
469
+ # First check board access
470
+ stmt = (
471
+ select(Boards)
472
+ .where(Boards.id == board.id)
473
+ .options(
474
+ selectinload(Boards.board_members),
475
+ )
476
+ )
477
+ result = await session.execute(stmt)
478
+ db_board = result.scalar_one_or_none()
479
+
480
+ if not db_board or not can_access_board(db_board, auth_context):
481
+ logger.info("Access denied to board generation count", board_id=str(board.id))
482
+ return 0
483
+
484
+ # Count generations for this board
485
+ from sqlalchemy import func
486
+
487
+ count_stmt = select(func.count(Generations.id)).where(Generations.board_id == board.id)
488
+
489
+ count_result = await session.execute(count_stmt)
490
+ return count_result.scalar() or 0
491
+
492
+
493
+ # BoardMember field resolvers
494
+ async def resolve_board_member_user(member: BoardMember, info: strawberry.Info) -> User:
495
+ """
496
+ Resolve the user for a board member. Requires access to board details.
497
+ """
498
+ auth_context = await get_auth_context_from_info(info)
499
+
500
+ async with get_async_session() as session:
501
+ # First verify access to the board that this member belongs to
502
+ board_stmt = (
503
+ select(Boards)
504
+ .where(Boards.id == member.board_id)
505
+ .options(
506
+ selectinload(Boards.board_members),
507
+ )
508
+ )
509
+ board_result = await session.execute(board_stmt)
510
+ board = board_result.scalar_one_or_none()
511
+
512
+ if not board or not can_access_board_details(board, auth_context):
513
+ raise RuntimeError("Access denied to board member information")
514
+
515
+ # Query the user
516
+ user_stmt = select(Users).where(Users.id == member.user_id)
517
+ user_result = await session.execute(user_stmt)
518
+ user = user_result.scalar_one_or_none()
519
+
520
+ if not user:
521
+ raise RuntimeError("Board member user not found")
522
+
523
+ from ..types.user import User as UserType
524
+
525
+ return UserType(
526
+ id=user.id,
527
+ tenant_id=user.tenant_id,
528
+ auth_provider=user.auth_provider,
529
+ auth_subject=user.auth_subject,
530
+ email=user.email,
531
+ display_name=user.display_name,
532
+ avatar_url=user.avatar_url,
533
+ created_at=user.created_at,
534
+ updated_at=user.updated_at,
535
+ )
536
+
537
+
538
+ async def resolve_board_member_inviter(member: BoardMember, info: strawberry.Info) -> User | None:
539
+ """
540
+ Resolve the user who invited this board member.
541
+
542
+ Returns None if the member is the original owner or if no inviter is recorded.
543
+ """
544
+ if not member.invited_by:
545
+ return None
546
+
547
+ auth_context = await get_auth_context_from_info(info)
548
+
549
+ async with get_async_session() as session:
550
+ # First verify access to the board that this member belongs to
551
+ board_stmt = (
552
+ select(Boards)
553
+ .where(Boards.id == member.board_id)
554
+ .options(
555
+ selectinload(Boards.board_members),
556
+ )
557
+ )
558
+ board_result = await session.execute(board_stmt)
559
+ board = board_result.scalar_one_or_none()
560
+
561
+ if not board or not can_access_board_details(board, auth_context):
562
+ raise RuntimeError("Access denied to board member inviter information")
563
+
564
+ # Query the inviter
565
+ inviter_stmt = select(Users).where(Users.id == member.invited_by)
566
+ inviter_result = await session.execute(inviter_stmt)
567
+ inviter = inviter_result.scalar_one_or_none()
568
+
569
+ if not inviter:
570
+ return None
571
+
572
+ from ..types.user import User as UserType
573
+
574
+ return UserType(
575
+ id=inviter.id,
576
+ tenant_id=inviter.tenant_id,
577
+ auth_provider=inviter.auth_provider,
578
+ auth_subject=inviter.auth_subject,
579
+ email=inviter.email,
580
+ display_name=inviter.display_name,
581
+ avatar_url=inviter.avatar_url,
582
+ created_at=inviter.created_at,
583
+ updated_at=inviter.updated_at,
584
+ )
585
+
586
+
587
+ # Mutation resolvers
588
+ async def create_board(info: strawberry.Info, input: CreateBoardInput) -> Board:
589
+ """
590
+ Create a new board.
591
+
592
+ The authenticated user becomes the owner of the board.
593
+ """
594
+ auth_context = await get_auth_context_from_info(info)
595
+ if not auth_context or not auth_context.is_authenticated:
596
+ raise RuntimeError("Authentication required to create a board")
597
+
598
+ async with get_async_session() as session:
599
+ # Get the tenant UUID from the database
600
+ tenant_uuid = auth_context.tenant_id
601
+
602
+ # Create the new board
603
+ new_board = Boards(
604
+ tenant_id=tenant_uuid,
605
+ owner_id=auth_context.user_id,
606
+ title=input.title,
607
+ description=input.description,
608
+ is_public=input.is_public,
609
+ settings=input.settings or {},
610
+ )
611
+
612
+ session.add(new_board)
613
+ await session.commit()
614
+ await session.refresh(new_board)
615
+
616
+ # Load relationships for the response
617
+ stmt = (
618
+ select(Boards)
619
+ .where(Boards.id == new_board.id)
620
+ .options(
621
+ selectinload(Boards.owner),
622
+ selectinload(Boards.board_members).selectinload(BoardMembers.user),
623
+ )
624
+ )
625
+ result = await session.execute(stmt)
626
+ board = result.scalar_one()
627
+
628
+ logger.info(
629
+ "Board created",
630
+ board_id=str(board.id),
631
+ user_id=str(auth_context.user_id),
632
+ title=board.title,
633
+ )
634
+
635
+ from ..types.board import Board as BoardType
636
+
637
+ return BoardType(
638
+ id=board.id,
639
+ tenant_id=tenant_uuid,
640
+ owner_id=board.owner_id,
641
+ title=board.title,
642
+ description=board.description,
643
+ is_public=board.is_public,
644
+ settings=board.settings or {},
645
+ metadata=board.metadata_ or {},
646
+ created_at=board.created_at,
647
+ updated_at=board.updated_at,
648
+ )
649
+
650
+
651
+ async def update_board(info: strawberry.Info, input: UpdateBoardInput) -> Board:
652
+ """
653
+ Update an existing board.
654
+
655
+ Only the board owner or an admin member can update the board.
656
+ """
657
+ auth_context = await get_auth_context_from_info(info)
658
+ if not auth_context or not auth_context.is_authenticated:
659
+ raise RuntimeError("Authentication required to update a board")
660
+
661
+ async with get_async_session() as session:
662
+ # Get the board with members
663
+ stmt = (
664
+ select(Boards)
665
+ .where(Boards.id == input.id)
666
+ .options(
667
+ selectinload(Boards.owner),
668
+ selectinload(Boards.board_members).selectinload(BoardMembers.user),
669
+ )
670
+ )
671
+ result = await session.execute(stmt)
672
+ board = result.scalar_one_or_none()
673
+
674
+ if not board:
675
+ raise RuntimeError("Board not found")
676
+
677
+ # Check permissions: must be owner or admin
678
+ is_owner = board.owner_id == auth_context.user_id
679
+ is_admin = any(
680
+ member.user_id == auth_context.user_id and member.role == "admin"
681
+ for member in board.board_members
682
+ )
683
+
684
+ if not is_owner and not is_admin:
685
+ raise RuntimeError("Permission denied: only board owner or admin can update")
686
+
687
+ # Update fields if provided
688
+ if input.title is not None:
689
+ board.title = input.title
690
+ if input.description is not None:
691
+ board.description = input.description
692
+ if input.is_public is not None:
693
+ board.is_public = input.is_public
694
+ if input.settings is not None:
695
+ board.settings = input.settings
696
+
697
+ await session.commit()
698
+ await session.refresh(board)
699
+
700
+ logger.info(
701
+ "Board updated",
702
+ board_id=str(board.id),
703
+ user_id=str(auth_context.user_id),
704
+ updated_fields=[
705
+ k
706
+ for k, v in {
707
+ "title": input.title,
708
+ "description": input.description,
709
+ "is_public": input.is_public,
710
+ "settings": input.settings,
711
+ }.items()
712
+ if v is not None
713
+ ],
714
+ )
715
+
716
+ from ..types.board import Board as BoardType
717
+
718
+ return BoardType(
719
+ id=board.id,
720
+ tenant_id=board.tenant_id,
721
+ owner_id=board.owner_id,
722
+ title=board.title,
723
+ description=board.description,
724
+ is_public=board.is_public,
725
+ settings=board.settings or {},
726
+ metadata=board.metadata_ or {},
727
+ created_at=board.created_at,
728
+ updated_at=board.updated_at,
729
+ )
730
+
731
+
732
+ async def delete_board(info: strawberry.Info, id: UUID) -> bool:
733
+ """
734
+ Delete a board and all associated data.
735
+
736
+ Only the board owner can delete a board.
737
+ """
738
+ auth_context = await get_auth_context_from_info(info)
739
+ if not auth_context or not auth_context.is_authenticated:
740
+ raise RuntimeError("Authentication required to delete a board")
741
+
742
+ async with get_async_session() as session:
743
+ # Get the board
744
+ stmt = select(Boards).where(Boards.id == id)
745
+ result = await session.execute(stmt)
746
+ board = result.scalar_one_or_none()
747
+
748
+ if not board:
749
+ raise RuntimeError("Board not found")
750
+
751
+ # Check if user is the owner
752
+ if board.owner_id != auth_context.user_id:
753
+ raise RuntimeError("Permission denied: only board owner can delete")
754
+
755
+ # Delete the board (cascade will handle related records)
756
+ await session.delete(board)
757
+ await session.commit()
758
+
759
+ logger.info("Board deleted", board_id=str(id), user_id=str(auth_context.user_id))
760
+
761
+ return True
762
+
763
+
764
+ async def add_board_member(info: strawberry.Info, input: AddBoardMemberInput) -> Board:
765
+ """
766
+ Add a new member to a board.
767
+
768
+ Only the board owner or an admin member can add new members.
769
+ """
770
+ auth_context = await get_auth_context_from_info(info)
771
+ if not auth_context or not auth_context.is_authenticated:
772
+ raise RuntimeError("Authentication required to add board members")
773
+
774
+ async with get_async_session() as session:
775
+ # Get the board with members
776
+ stmt = (
777
+ select(Boards)
778
+ .where(Boards.id == input.board_id)
779
+ .options(
780
+ selectinload(Boards.owner),
781
+ selectinload(Boards.board_members).selectinload(BoardMembers.user),
782
+ )
783
+ )
784
+ result = await session.execute(stmt)
785
+ board = result.scalar_one_or_none()
786
+
787
+ if not board:
788
+ raise RuntimeError("Board not found")
789
+
790
+ # Check permissions: must be owner or admin
791
+ is_owner = board.owner_id == auth_context.user_id
792
+ is_admin = any(
793
+ member.user_id == auth_context.user_id and member.role == "admin"
794
+ for member in board.board_members
795
+ )
796
+
797
+ if not is_owner and not is_admin:
798
+ raise RuntimeError("Permission denied: only board owner or admin can add members")
799
+
800
+ # Check if user to be added exists
801
+ user_stmt = select(Users).where(Users.id == input.user_id)
802
+ user_result = await session.execute(user_stmt)
803
+ user = user_result.scalar_one_or_none()
804
+
805
+ if not user:
806
+ raise RuntimeError("User not found")
807
+
808
+ # Check if user is already the owner
809
+ if board.owner_id == input.user_id:
810
+ raise RuntimeError("User is already the board owner")
811
+
812
+ # Check if user is already a member
813
+ existing_member = any(member.user_id == input.user_id for member in board.board_members)
814
+
815
+ if existing_member:
816
+ raise RuntimeError("User is already a board member")
817
+
818
+ # Add the new member
819
+ new_member = BoardMembers(
820
+ board_id=input.board_id,
821
+ user_id=input.user_id,
822
+ role=input.role.value,
823
+ invited_by=auth_context.user_id,
824
+ )
825
+
826
+ session.add(new_member)
827
+ await session.commit()
828
+
829
+ # Refresh the board to get updated members
830
+ await session.refresh(board)
831
+
832
+ # Re-query with all relationships loaded
833
+ stmt = (
834
+ select(Boards)
835
+ .where(Boards.id == input.board_id)
836
+ .options(
837
+ selectinload(Boards.owner),
838
+ selectinload(Boards.board_members).selectinload(BoardMembers.user),
839
+ )
840
+ )
841
+ result = await session.execute(stmt)
842
+ board = result.scalar_one()
843
+
844
+ logger.info(
845
+ "Board member added",
846
+ board_id=str(board.id),
847
+ user_id=str(input.user_id),
848
+ role=input.role.value,
849
+ invited_by=str(auth_context.user_id),
850
+ )
851
+
852
+ from ..types.board import Board as BoardType
853
+
854
+ return BoardType(
855
+ id=board.id,
856
+ tenant_id=board.tenant_id,
857
+ owner_id=board.owner_id,
858
+ title=board.title,
859
+ description=board.description,
860
+ is_public=board.is_public,
861
+ settings=board.settings or {},
862
+ metadata=board.metadata_ or {},
863
+ created_at=board.created_at,
864
+ updated_at=board.updated_at,
865
+ )
866
+
867
+
868
+ async def remove_board_member(info: strawberry.Info, board_id: UUID, user_id: UUID) -> Board:
869
+ """
870
+ Remove a member from a board.
871
+
872
+ Only the board owner, an admin member, or the member themselves can remove a member.
873
+ The board owner cannot be removed.
874
+ """
875
+ auth_context = await get_auth_context_from_info(info)
876
+ if not auth_context or not auth_context.is_authenticated:
877
+ raise RuntimeError("Authentication required to remove board members")
878
+
879
+ async with get_async_session() as session:
880
+ # Get the board with members
881
+ stmt = (
882
+ select(Boards)
883
+ .where(Boards.id == board_id)
884
+ .options(
885
+ selectinload(Boards.owner),
886
+ selectinload(Boards.board_members).selectinload(BoardMembers.user),
887
+ )
888
+ )
889
+ result = await session.execute(stmt)
890
+ board = result.scalar_one_or_none()
891
+
892
+ if not board:
893
+ raise RuntimeError("Board not found")
894
+
895
+ # Cannot remove the board owner
896
+ if board.owner_id == user_id:
897
+ raise RuntimeError("Cannot remove the board owner")
898
+
899
+ # Find the member to remove
900
+ member_to_remove = None
901
+ for member in board.board_members:
902
+ if member.user_id == user_id:
903
+ member_to_remove = member
904
+ break
905
+
906
+ if not member_to_remove:
907
+ raise RuntimeError("User is not a board member")
908
+
909
+ # Check permissions
910
+ is_owner = board.owner_id == auth_context.user_id
911
+ is_admin = any(
912
+ member.user_id == auth_context.user_id and member.role == "admin"
913
+ for member in board.board_members
914
+ )
915
+ is_self = user_id == auth_context.user_id
916
+
917
+ if not is_owner and not is_admin and not is_self:
918
+ raise RuntimeError("Permission denied: insufficient permissions to remove member")
919
+
920
+ # Remove the member
921
+ await session.delete(member_to_remove)
922
+ await session.commit()
923
+
924
+ # Re-query the board with updated members
925
+ stmt = (
926
+ select(Boards)
927
+ .where(Boards.id == board_id)
928
+ .options(
929
+ selectinload(Boards.owner),
930
+ selectinload(Boards.board_members).selectinload(BoardMembers.user),
931
+ )
932
+ )
933
+ result = await session.execute(stmt)
934
+ board = result.scalar_one()
935
+
936
+ logger.info(
937
+ "Board member removed",
938
+ board_id=str(board_id),
939
+ removed_user_id=str(user_id),
940
+ removed_by=str(auth_context.user_id),
941
+ )
942
+
943
+ from ..types.board import Board as BoardType
944
+
945
+ return BoardType(
946
+ id=board.id,
947
+ tenant_id=board.tenant_id,
948
+ owner_id=board.owner_id,
949
+ title=board.title,
950
+ description=board.description,
951
+ is_public=board.is_public,
952
+ settings=board.settings or {},
953
+ metadata=board.metadata_ or {},
954
+ created_at=board.created_at,
955
+ updated_at=board.updated_at,
956
+ )
957
+
958
+
959
+ async def update_board_member_role(
960
+ info: strawberry.Info, board_id: UUID, user_id: UUID, role: BoardRole
961
+ ) -> Board:
962
+ """
963
+ Update a board member's role.
964
+
965
+ Only the board owner or an admin member can change member roles.
966
+ The board owner's role cannot be changed (they are always the owner).
967
+ """
968
+ auth_context = await get_auth_context_from_info(info)
969
+ if not auth_context or not auth_context.is_authenticated:
970
+ raise RuntimeError("Authentication required to update member roles")
971
+
972
+ async with get_async_session() as session:
973
+ # Get the board with members
974
+ stmt = (
975
+ select(Boards)
976
+ .where(Boards.id == board_id)
977
+ .options(
978
+ selectinload(Boards.owner),
979
+ selectinload(Boards.board_members).selectinload(BoardMembers.user),
980
+ )
981
+ )
982
+ result = await session.execute(stmt)
983
+ board = result.scalar_one_or_none()
984
+
985
+ if not board:
986
+ raise RuntimeError("Board not found")
987
+
988
+ # Cannot change the owner's role
989
+ if board.owner_id == user_id:
990
+ raise RuntimeError("Cannot change the board owner's role")
991
+
992
+ # Check permissions: must be owner or admin
993
+ is_owner = board.owner_id == auth_context.user_id
994
+ is_admin = any(
995
+ member.user_id == auth_context.user_id and member.role == "admin"
996
+ for member in board.board_members
997
+ )
998
+
999
+ if not is_owner and not is_admin:
1000
+ raise RuntimeError(
1001
+ "Permission denied: only board owner or admin can change member roles"
1002
+ )
1003
+
1004
+ # Find the member to update
1005
+ member_to_update = None
1006
+ for member in board.board_members:
1007
+ if member.user_id == user_id:
1008
+ member_to_update = member
1009
+ break
1010
+
1011
+ if not member_to_update:
1012
+ raise RuntimeError("User is not a board member")
1013
+
1014
+ # Update the role
1015
+ old_role = member_to_update.role
1016
+ member_to_update.role = role.value
1017
+
1018
+ await session.commit()
1019
+ await session.refresh(board)
1020
+
1021
+ # Re-query with all relationships loaded
1022
+ stmt = (
1023
+ select(Boards)
1024
+ .where(Boards.id == board_id)
1025
+ .options(
1026
+ selectinload(Boards.owner),
1027
+ selectinload(Boards.board_members).selectinload(BoardMembers.user),
1028
+ )
1029
+ )
1030
+ result = await session.execute(stmt)
1031
+ board = result.scalar_one()
1032
+
1033
+ logger.info(
1034
+ "Board member role updated",
1035
+ board_id=str(board_id),
1036
+ user_id=str(user_id),
1037
+ old_role=old_role,
1038
+ new_role=role.value,
1039
+ updated_by=str(auth_context.user_id),
1040
+ )
1041
+
1042
+ from ..types.board import Board as BoardType
1043
+
1044
+ return BoardType(
1045
+ id=board.id,
1046
+ tenant_id=board.tenant_id,
1047
+ owner_id=board.owner_id,
1048
+ title=board.title,
1049
+ description=board.description,
1050
+ is_public=board.is_public,
1051
+ settings=board.settings or {},
1052
+ metadata=board.metadata_ or {},
1053
+ created_at=board.created_at,
1054
+ updated_at=board.updated_at,
1055
+ )