@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,75 @@
1
+ """Authentication dependencies for API endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from uuid import UUID
6
+
7
+ from fastapi import Depends, HTTPException
8
+ from pydantic import BaseModel
9
+
10
+ from ..auth import AuthContext, get_auth_context, get_auth_context_optional
11
+ from ..logging import get_logger
12
+
13
+ logger = get_logger(__name__)
14
+
15
+
16
+ class AuthenticatedUser(BaseModel):
17
+ """Represents an authenticated user."""
18
+
19
+ user_id: UUID
20
+ tenant_id: UUID
21
+ email: str | None = None
22
+
23
+
24
+ async def get_current_user(
25
+ auth_context: AuthContext = Depends(get_auth_context),
26
+ ) -> AuthenticatedUser:
27
+ """
28
+ Get the current authenticated user from the auth context.
29
+
30
+ Args:
31
+ auth_context: Authentication context from middleware
32
+
33
+ Returns:
34
+ AuthenticatedUser object with user information
35
+
36
+ Raises:
37
+ HTTPException: If user is not authenticated
38
+ """
39
+ if not auth_context.is_authenticated or not auth_context.user_id:
40
+ raise HTTPException(
41
+ status_code=401,
42
+ detail="Authentication required",
43
+ headers={"WWW-Authenticate": "Bearer"},
44
+ )
45
+
46
+ return AuthenticatedUser(
47
+ user_id=auth_context.user_id,
48
+ tenant_id=auth_context.tenant_id,
49
+ email=auth_context.principal.get("email") if auth_context.principal else None,
50
+ )
51
+
52
+
53
+ async def get_current_user_optional(
54
+ auth_context: AuthContext = Depends(get_auth_context_optional),
55
+ ) -> AuthenticatedUser | None:
56
+ """
57
+ Optional authentication - returns None if not authenticated.
58
+
59
+ Use this for endpoints that can work both authenticated and unauthenticated,
60
+ but may provide different functionality based on auth status.
61
+ """
62
+ if not auth_context.is_authenticated or not auth_context.user_id:
63
+ return None
64
+
65
+ return AuthenticatedUser(
66
+ user_id=auth_context.user_id,
67
+ tenant_id=auth_context.tenant_id,
68
+ email=auth_context.principal.get("email") if auth_context.principal else None,
69
+ )
70
+
71
+
72
+ # Legacy support - keep the old function names for backward compatibility
73
+ async def get_auth_context_dependency() -> AuthContext:
74
+ """Get auth context directly (for advanced use cases)."""
75
+ return await get_auth_context()
@@ -0,0 +1,3 @@
1
+ """
2
+ API endpoint modules for Boards backend
3
+ """
@@ -0,0 +1,76 @@
1
+ """Job submission endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from uuid import UUID
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException
8
+ from pydantic import BaseModel
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from ...database.connection import get_db_session
12
+ from ...jobs import repository as jobs_repo
13
+ from ...logging import get_logger
14
+ from ...workers.actors import process_generation
15
+ from ..auth import AuthenticatedUser, get_current_user
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ router = APIRouter()
21
+
22
+
23
+ class SubmitGenerationRequest(BaseModel):
24
+ tenant_id: UUID
25
+ board_id: UUID
26
+ user_id: UUID
27
+ generator_name: str
28
+ artifact_type: str
29
+ input_params: dict
30
+
31
+
32
+ class SubmitGenerationResponse(BaseModel):
33
+ generation_id: UUID
34
+
35
+
36
+ @router.post("/generations", response_model=SubmitGenerationResponse)
37
+ async def submit_generation(
38
+ body: SubmitGenerationRequest,
39
+ db: AsyncSession = Depends(get_db_session),
40
+ current_user: AuthenticatedUser = Depends(get_current_user),
41
+ ) -> SubmitGenerationResponse:
42
+ """Submit a new generation job.
43
+
44
+ Requires authentication. The authenticated user's ID and tenant will be used
45
+ for the generation, overriding any values provided in the request body.
46
+ """
47
+ try:
48
+ # Validate that user has access to the specified board
49
+ # TODO: Add board access validation logic here
50
+
51
+ # Override user_id and tenant_id with authenticated values for security
52
+ # This prevents users from submitting jobs on behalf of others
53
+ gen = await jobs_repo.create_generation(
54
+ db,
55
+ tenant_id=current_user.tenant_id, # Use authenticated tenant
56
+ board_id=body.board_id,
57
+ user_id=current_user.user_id, # Use authenticated user
58
+ generator_name=body.generator_name,
59
+ artifact_type=body.artifact_type,
60
+ input_params=body.input_params,
61
+ )
62
+
63
+ # Commit the transaction to ensure job is persisted before enqueueing
64
+ await db.commit()
65
+ logger.info(f"Created generation job {gen.id} for user {current_user.user_id}")
66
+
67
+ # Enqueue job for processing
68
+ process_generation.send(str(gen.id))
69
+ logger.info(f"Enqueued generation job {gen.id}")
70
+
71
+ return SubmitGenerationResponse(generation_id=gen.id)
72
+
73
+ except Exception as e:
74
+ logger.error(f"Failed to submit generation: {e}")
75
+ await db.rollback()
76
+ raise HTTPException(status_code=500, detail=f"Failed to submit generation: {str(e)}") from e
@@ -0,0 +1,505 @@
1
+ """
2
+ Setup endpoints for initial tenant configuration.
3
+
4
+ These endpoints help with one-time setup for single-tenant deployments
5
+ or initial configuration of multi-tenant environments.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import UTC
11
+ from typing import Any
12
+ from uuid import UUID
13
+
14
+ from fastapi import APIRouter, HTTPException, Path
15
+ from pydantic import BaseModel, Field
16
+ from sqlalchemy import delete, select, update
17
+
18
+ from ...config import settings
19
+ from ...database.connection import get_async_session
20
+ from ...database.seed_data import ensure_tenant, seed_tenant_with_data
21
+ from ...dbmodels import Tenants
22
+ from ...logging import get_logger
23
+
24
+ logger = get_logger(__name__)
25
+
26
+ router = APIRouter()
27
+
28
+
29
+ class TenantSetupRequest(BaseModel):
30
+ """Request model for tenant setup."""
31
+
32
+ name: str = Field(..., min_length=1, max_length=255, description="Display name for the tenant")
33
+ slug: str = Field(
34
+ ...,
35
+ min_length=1,
36
+ max_length=255,
37
+ pattern=r"^[a-z0-9-]+$",
38
+ description="URL-safe slug for the tenant (lowercase, numbers, hyphens only)",
39
+ )
40
+ settings: dict[str, Any] = Field(
41
+ default_factory=dict, description="Optional tenant-specific settings"
42
+ )
43
+ include_sample_data: bool = Field(
44
+ default=False, description="Whether to include sample boards and data"
45
+ )
46
+
47
+
48
+ class TenantUpdateRequest(BaseModel):
49
+ """Request model for tenant updates."""
50
+
51
+ name: str | None = Field(
52
+ default=None,
53
+ min_length=1,
54
+ max_length=255,
55
+ description="Display name for the tenant",
56
+ )
57
+ slug: str | None = Field(
58
+ None,
59
+ min_length=1,
60
+ max_length=255,
61
+ pattern=r"^[a-z0-9-]+$",
62
+ description="URL-safe slug for the tenant (lowercase, numbers, hyphens only)",
63
+ )
64
+ settings: dict[str, Any] | None = Field(None, description="Tenant-specific settings")
65
+
66
+
67
+ class TenantResponse(BaseModel):
68
+ """Response model for tenant operations."""
69
+
70
+ tenant_id: str = Field(..., description="UUID of the tenant")
71
+ name: str = Field(..., description="Display name of the tenant")
72
+ slug: str = Field(..., description="Slug of the tenant")
73
+ settings: dict[str, Any] = Field(..., description="Tenant-specific settings")
74
+ created_at: str = Field(..., description="Creation timestamp")
75
+ updated_at: str = Field(..., description="Last update timestamp")
76
+
77
+
78
+ class TenantSetupResponse(BaseModel):
79
+ """Response model for tenant setup."""
80
+
81
+ tenant_id: str = Field(..., description="UUID of the created tenant")
82
+ name: str = Field(..., description="Display name of the tenant")
83
+ slug: str = Field(..., description="Slug of the tenant")
84
+ message: str = Field(..., description="Success message")
85
+ existing: bool = Field(..., description="Whether tenant already existed")
86
+
87
+
88
+ class TenantListResponse(BaseModel):
89
+ """Response model for tenant list."""
90
+
91
+ tenants: list[TenantResponse] = Field(..., description="List of tenants")
92
+ total_count: int = Field(..., description="Total number of tenants")
93
+
94
+
95
+ @router.post("/tenant", response_model=TenantSetupResponse)
96
+ async def setup_tenant(request: TenantSetupRequest) -> TenantSetupResponse:
97
+ """
98
+ Create or configure a tenant for initial setup.
99
+
100
+ This endpoint is useful for:
101
+ - Single-tenant initial setup
102
+ - Creating new tenants in multi-tenant mode
103
+ - Demo/development tenant creation
104
+
105
+ In single-tenant mode, this is typically called once during deployment.
106
+ In multi-tenant mode, this can be used for admin tenant creation.
107
+ """
108
+ logger.info(
109
+ "Setting up tenant",
110
+ name=request.name,
111
+ slug=request.slug,
112
+ include_sample_data=request.include_sample_data,
113
+ )
114
+
115
+ try:
116
+ async with get_async_session() as db:
117
+ # Check if tenant already exists
118
+ existing_tenant_id = await ensure_tenant(
119
+ db,
120
+ name=request.name,
121
+ slug=request.slug,
122
+ settings_dict=request.settings,
123
+ )
124
+
125
+ # For now, we'll consider it "existing" if the ensure_tenant call
126
+ # found an existing tenant. We could enhance this by checking
127
+ # if the tenant was created or updated.
128
+ try:
129
+ # Try to create with sample data if requested
130
+ if request.include_sample_data:
131
+ tenant_id = await seed_tenant_with_data(
132
+ db,
133
+ tenant_name=request.name,
134
+ tenant_slug=request.slug,
135
+ tenant_settings=request.settings,
136
+ include_sample_data=True,
137
+ )
138
+ existing = False # seed_tenant_with_data creates new tenant
139
+ else:
140
+ tenant_id = existing_tenant_id
141
+ existing = True # assume it existed (could be enhanced)
142
+
143
+ except Exception as e:
144
+ # If seeding fails, we still have the basic tenant
145
+ logger.warning(
146
+ "Sample data creation failed, but tenant was created",
147
+ error=str(e),
148
+ tenant_id=str(existing_tenant_id),
149
+ )
150
+ tenant_id = existing_tenant_id
151
+ existing = True
152
+
153
+ logger.info(
154
+ "Tenant setup completed",
155
+ tenant_id=str(tenant_id),
156
+ name=request.name,
157
+ slug=request.slug,
158
+ existing=existing,
159
+ )
160
+
161
+ return TenantSetupResponse(
162
+ tenant_id=str(tenant_id),
163
+ name=request.name,
164
+ slug=request.slug,
165
+ message=f"Tenant {'configured' if existing else 'created'} successfully",
166
+ existing=existing,
167
+ )
168
+
169
+ except Exception as e:
170
+ logger.error("Tenant setup failed", error=str(e))
171
+ raise HTTPException(status_code=500, detail=f"Failed to setup tenant: {str(e)}") from e
172
+
173
+
174
+ @router.get("/tenant/{tenant_id}", response_model=TenantResponse)
175
+ async def get_tenant(
176
+ tenant_id: UUID = Path(..., description="UUID of the tenant to retrieve"),
177
+ ) -> TenantResponse:
178
+ """
179
+ Get a specific tenant by ID.
180
+
181
+ This endpoint retrieves detailed information about a tenant including
182
+ its settings, creation date, and other metadata.
183
+ """
184
+ logger.info("Retrieving tenant", tenant_id=str(tenant_id))
185
+
186
+ try:
187
+ async with get_async_session() as db:
188
+ # Query for the tenant
189
+ stmt = select(Tenants).where(Tenants.id == tenant_id)
190
+ result = await db.execute(stmt)
191
+ tenant = result.scalar_one_or_none()
192
+
193
+ if not tenant:
194
+ raise HTTPException(status_code=404, detail=f"Tenant with ID {tenant_id} not found")
195
+
196
+ logger.info(
197
+ "Tenant retrieved successfully",
198
+ tenant_id=str(tenant_id),
199
+ slug=tenant.slug,
200
+ )
201
+
202
+ return TenantResponse(
203
+ tenant_id=str(tenant.id),
204
+ name=tenant.name,
205
+ slug=tenant.slug,
206
+ settings=tenant.settings or {},
207
+ created_at=tenant.created_at.isoformat() if tenant.created_at else "",
208
+ updated_at=tenant.updated_at.isoformat() if tenant.updated_at else "",
209
+ )
210
+
211
+ except HTTPException:
212
+ # Re-raise HTTP exceptions as-is
213
+ raise
214
+ except Exception as e:
215
+ logger.error("Failed to retrieve tenant", tenant_id=str(tenant_id), error=str(e))
216
+ raise HTTPException(status_code=500, detail=f"Failed to retrieve tenant: {str(e)}") from e
217
+
218
+
219
+ @router.get("/tenants", response_model=TenantListResponse)
220
+ async def list_tenants() -> TenantListResponse:
221
+ """
222
+ List all tenants in the system.
223
+
224
+ This endpoint is useful for:
225
+ - Multi-tenant administration
226
+ - Tenant discovery and management
227
+ - System overview and monitoring
228
+ """
229
+ logger.info("Listing all tenants")
230
+
231
+ try:
232
+ async with get_async_session() as db:
233
+ # Query for all tenants, ordered by creation date
234
+ stmt = select(Tenants).order_by(Tenants.created_at.desc())
235
+ result = await db.execute(stmt)
236
+ tenants = result.scalars().all()
237
+
238
+ tenant_responses = [
239
+ TenantResponse(
240
+ tenant_id=str(tenant.id),
241
+ name=tenant.name,
242
+ slug=tenant.slug,
243
+ settings=tenant.settings or {},
244
+ created_at=(tenant.created_at.isoformat() if tenant.created_at else ""),
245
+ updated_at=(tenant.updated_at.isoformat() if tenant.updated_at else ""),
246
+ )
247
+ for tenant in tenants
248
+ ]
249
+
250
+ logger.info(
251
+ "Tenants listed successfully",
252
+ total_count=len(tenant_responses),
253
+ )
254
+
255
+ return TenantListResponse(
256
+ tenants=tenant_responses,
257
+ total_count=len(tenant_responses),
258
+ )
259
+
260
+ except Exception as e:
261
+ logger.error("Failed to list tenants", error=str(e))
262
+ raise HTTPException(status_code=500, detail=f"Failed to list tenants: {str(e)}") from e
263
+
264
+
265
+ @router.put("/tenant/{tenant_id}", response_model=TenantResponse)
266
+ async def update_tenant(
267
+ request: TenantUpdateRequest,
268
+ tenant_id: UUID = Path(..., description="UUID of the tenant to update"),
269
+ ) -> TenantResponse:
270
+ """
271
+ Update a specific tenant.
272
+
273
+ This endpoint allows updating tenant information including:
274
+ - Display name
275
+ - Slug (URL identifier)
276
+ - Settings (JSON metadata)
277
+
278
+ Only provided fields will be updated; others remain unchanged.
279
+ """
280
+ logger.info(
281
+ "Updating tenant",
282
+ tenant_id=str(tenant_id),
283
+ name=request.name,
284
+ slug=request.slug,
285
+ )
286
+
287
+ try:
288
+ async with get_async_session() as db:
289
+ # First, check if tenant exists
290
+ stmt = select(Tenants).where(Tenants.id == tenant_id)
291
+ result = await db.execute(stmt)
292
+ existing_tenant = result.scalar_one_or_none()
293
+
294
+ if not existing_tenant:
295
+ raise HTTPException(status_code=404, detail=f"Tenant with ID {tenant_id} not found")
296
+
297
+ # Check for slug conflicts if slug is being updated
298
+ if request.slug and request.slug != existing_tenant.slug:
299
+ slug_check = select(Tenants).where(
300
+ (Tenants.slug == request.slug) & (Tenants.id != tenant_id)
301
+ )
302
+ result = await db.execute(slug_check)
303
+ if result.scalar_one_or_none():
304
+ raise HTTPException(
305
+ status_code=409,
306
+ detail=f"A tenant with slug '{request.slug}' already exists",
307
+ )
308
+
309
+ # Build update data - only include provided fields
310
+ update_data = {}
311
+ if request.name is not None:
312
+ update_data["name"] = request.name
313
+ if request.slug is not None:
314
+ update_data["slug"] = request.slug
315
+ if request.settings is not None:
316
+ update_data["settings"] = request.settings
317
+
318
+ # Add updated_at timestamp
319
+ from datetime import datetime
320
+
321
+ update_data["updated_at"] = datetime.now(UTC)
322
+
323
+ if update_data:
324
+ # Perform the update
325
+ stmt = update(Tenants).where(Tenants.id == tenant_id).values(**update_data)
326
+ await db.execute(stmt)
327
+ await db.commit()
328
+
329
+ # Fetch the updated tenant
330
+ stmt = select(Tenants).where(Tenants.id == tenant_id)
331
+ result = await db.execute(stmt)
332
+ updated_tenant = result.scalar_one()
333
+
334
+ logger.info(
335
+ "Tenant updated successfully",
336
+ tenant_id=str(tenant_id),
337
+ name=updated_tenant.name,
338
+ slug=updated_tenant.slug,
339
+ )
340
+
341
+ return TenantResponse(
342
+ tenant_id=str(updated_tenant.id),
343
+ name=updated_tenant.name,
344
+ slug=updated_tenant.slug,
345
+ settings=updated_tenant.settings or {},
346
+ created_at=(
347
+ updated_tenant.created_at.isoformat() if updated_tenant.created_at else ""
348
+ ),
349
+ updated_at=(
350
+ updated_tenant.updated_at.isoformat() if updated_tenant.updated_at else ""
351
+ ),
352
+ )
353
+
354
+ except HTTPException:
355
+ # Re-raise HTTP exceptions as-is
356
+ raise
357
+ except Exception as e:
358
+ logger.error("Failed to update tenant", tenant_id=str(tenant_id), error=str(e))
359
+ raise HTTPException(status_code=500, detail=f"Failed to update tenant: {str(e)}") from e
360
+
361
+
362
+ @router.delete("/tenant/{tenant_id}")
363
+ async def delete_tenant(
364
+ tenant_id: UUID = Path(..., description="UUID of the tenant to delete"),
365
+ ) -> dict[str, Any]:
366
+ """
367
+ Delete a specific tenant.
368
+
369
+ **WARNING**: This operation is destructive and will cascade delete all related data:
370
+ - All users in this tenant
371
+ - All boards and their content
372
+ - All generations and media
373
+ - All provider configurations
374
+ - All credit transactions
375
+
376
+ This operation cannot be undone. Use with extreme caution.
377
+ """
378
+ logger.warning(
379
+ "Attempting to delete tenant - this is a destructive operation",
380
+ tenant_id=str(tenant_id),
381
+ )
382
+
383
+ try:
384
+ async with get_async_session() as db:
385
+ # First, check if tenant exists and get its info for logging
386
+ stmt = select(Tenants).where(Tenants.id == tenant_id)
387
+ result = await db.execute(stmt)
388
+ existing_tenant = result.scalar_one_or_none()
389
+
390
+ if not existing_tenant:
391
+ raise HTTPException(status_code=404, detail=f"Tenant with ID {tenant_id} not found")
392
+
393
+ # Prevent deletion of default tenant in single-tenant mode
394
+ if (
395
+ not settings.multi_tenant_mode
396
+ and existing_tenant.slug == settings.default_tenant_slug
397
+ ):
398
+ raise HTTPException(
399
+ status_code=400,
400
+ detail="Cannot delete the default tenant in single-tenant mode. "
401
+ "Enable multi-tenant mode or use a different default tenant first.",
402
+ )
403
+
404
+ tenant_name = existing_tenant.name
405
+ tenant_slug = existing_tenant.slug
406
+
407
+ # Perform the deletion (CASCADE will handle related records)
408
+ stmt = delete(Tenants).where(Tenants.id == tenant_id)
409
+ result = await db.execute(stmt)
410
+ await db.commit()
411
+
412
+ if result.rowcount == 0:
413
+ # This shouldn't happen since we checked existence above
414
+ raise HTTPException(status_code=404, detail=f"Tenant with ID {tenant_id} not found")
415
+
416
+ logger.warning(
417
+ "Tenant deleted successfully - all related data has been removed",
418
+ tenant_id=str(tenant_id),
419
+ name=tenant_name,
420
+ slug=tenant_slug,
421
+ deleted_records=result.rowcount,
422
+ )
423
+
424
+ return {
425
+ "message": f"Tenant '{tenant_name}' ({tenant_slug}) deleted successfully",
426
+ "tenant_id": str(tenant_id),
427
+ "warning": "All related data (users, boards, generations, etc.) has been permanently deleted", # noqa: E501
428
+ }
429
+
430
+ except HTTPException:
431
+ # Re-raise HTTP exceptions as-is
432
+ raise
433
+ except Exception as e:
434
+ logger.error("Failed to delete tenant", tenant_id=str(tenant_id), error=str(e))
435
+ raise HTTPException(status_code=500, detail=f"Failed to delete tenant: {str(e)}") from e
436
+
437
+
438
+ @router.get("/status")
439
+ async def setup_status() -> dict[str, Any]:
440
+ """
441
+ Get the current setup status of the application.
442
+
443
+ This endpoint provides information about:
444
+ - Whether a default tenant exists
445
+ - Current configuration mode
446
+ - Setup recommendations
447
+ """
448
+ try:
449
+ async with get_async_session() as db:
450
+ # Check if default tenant exists
451
+ try:
452
+ default_tenant_id = await ensure_tenant(db, slug=settings.default_tenant_slug)
453
+ has_default_tenant = True
454
+ default_tenant_uuid = str(default_tenant_id)
455
+ except Exception:
456
+ has_default_tenant = False
457
+ default_tenant_uuid = None
458
+
459
+ setup_needed = not has_default_tenant and not settings.multi_tenant_mode
460
+
461
+ return {
462
+ "setup_needed": setup_needed,
463
+ "has_default_tenant": has_default_tenant,
464
+ "default_tenant_id": default_tenant_uuid,
465
+ "default_tenant_slug": settings.default_tenant_slug,
466
+ "multi_tenant_mode": settings.multi_tenant_mode,
467
+ "auth_provider": settings.auth_provider,
468
+ "recommendations": _get_setup_recommendations(
469
+ setup_needed, has_default_tenant, settings.multi_tenant_mode
470
+ ),
471
+ }
472
+
473
+ except Exception as e:
474
+ logger.error("Failed to get setup status", error=str(e))
475
+ raise HTTPException(status_code=500, detail=f"Failed to get setup status: {str(e)}") from e
476
+
477
+
478
+ def _get_setup_recommendations(
479
+ setup_needed: bool, has_default_tenant: bool, multi_tenant_mode: bool
480
+ ) -> list[str]:
481
+ """Get setup recommendations based on current state."""
482
+ recommendations = []
483
+
484
+ if setup_needed:
485
+ recommendations.append(
486
+ f"Create a default tenant using POST /api/setup/tenant with slug '{settings.default_tenant_slug}'" # noqa: E501
487
+ )
488
+
489
+ if not has_default_tenant and not multi_tenant_mode:
490
+ recommendations.append(
491
+ "Run database migrations to create the default tenant: 'alembic upgrade head'"
492
+ )
493
+
494
+ if settings.auth_provider == "none" and not multi_tenant_mode:
495
+ recommendations.append(
496
+ "Consider configuring a proper authentication provider for production use"
497
+ )
498
+
499
+ if not recommendations:
500
+ if multi_tenant_mode:
501
+ recommendations.append("System is ready for multi-tenant operation")
502
+ else:
503
+ recommendations.append("Single-tenant setup is complete and ready to use")
504
+
505
+ return recommendations