light-infer-chat commited on
Commit
5d6260a
·
1 Parent(s): f8c4791

feat: add supabase

Browse files
.gitignore CHANGED
@@ -88,6 +88,7 @@ venv/
88
  ENV/
89
  env.bak/
90
  venv.bak/
 
91
 
92
  .spyderproject
93
  .spyproject
@@ -131,4 +132,6 @@ test_vector_store_async.py
131
  deploy_sdk.py
132
  tests
133
  deploy_hf.py
134
- .mimocode
 
 
 
88
  ENV/
89
  env.bak/
90
  venv.bak/
91
+ .env.local
92
 
93
  .spyderproject
94
  .spyproject
 
132
  deploy_sdk.py
133
  tests
134
  deploy_hf.py
135
+ .mimocode
136
+
137
+ ddl
app/api/server.py CHANGED
@@ -15,7 +15,6 @@ from app.core.database import pool_manager
15
  from app.core.logger import get_logger
16
  from app.core.redis_client import close_redis, create_redis_client
17
  from app.core.scripts import load_scripts
18
- from app.core.tidb_manager import TiDBWriteError
19
  from app.services.embeddings_service import EmbeddingService
20
  from app.services.vector_store_service import VectorStoreService
21
 
@@ -44,18 +43,15 @@ async def _self_ping():
44
 
45
  @asynccontextmanager
46
  async def lifespan(app: FastAPI):
47
- if not _settings.tidb_instance_list:
48
  _logger.error(
49
- "No TiDB instances configured! Set TIDB_INSTANCES environment variable "
50
- "with comma-separated MySQL connection strings."
51
  )
52
  else:
53
- _logger.info(
54
- "Initializing TiDB databases across %d instance(s)...",
55
- len(_settings.tidb_instance_list),
56
- )
57
  await init_auth_db()
58
- _logger.info("Authentication database initialized on all TiDB instances")
59
 
60
  _logger.info("Initializing vector store database...")
61
  from app.core.vector_store.deps import init_vector_store_db
@@ -87,11 +83,11 @@ async def lifespan(app: FastAPI):
87
  await close_redis(redis)
88
  await _vector_store_service.close_all()
89
  await pool_manager.close_all()
90
- from app.core.tidb_manager import get_tidb_manager
91
- mgr = get_tidb_manager()
92
- if mgr:
93
- await mgr.close_all_pools()
94
- _logger.info("TiDB connection pools closed")
95
 
96
 
97
  def create_application() -> FastAPI:
@@ -121,18 +117,6 @@ def create_application() -> FastAPI:
121
  allow_headers=["*"],
122
  )
123
 
124
- @app.exception_handler(TiDBWriteError)
125
- async def tidb_write_error_handler(request: Request, exc: TiDBWriteError):
126
- from starlette.responses import JSONResponse
127
- return JSONResponse(
128
- status_code=503,
129
- content={
130
- "success": False,
131
- "detail": "Failed to process request due to database storage limit. "
132
- "Please try again later or contact support.",
133
- },
134
- )
135
-
136
  @app.middleware("http")
137
  async def maintenance_middleware(request: Request, call_next):
138
  if is_maintenance():
 
15
  from app.core.logger import get_logger
16
  from app.core.redis_client import close_redis, create_redis_client
17
  from app.core.scripts import load_scripts
 
18
  from app.services.embeddings_service import EmbeddingService
19
  from app.services.vector_store_service import VectorStoreService
20
 
 
43
 
44
  @asynccontextmanager
45
  async def lifespan(app: FastAPI):
46
+ if not _settings.supabase_url or not _settings.supabase_service_role_key:
47
  _logger.error(
48
+ "Supabase not configured! Set SUPABASE_URL and "
49
+ "SUPABASE_SERVICE_ROLE_KEY environment variables."
50
  )
51
  else:
52
+ _logger.info("Initializing Supabase databases...")
 
 
 
53
  await init_auth_db()
54
+ _logger.info("Authentication database initialized via Supabase")
55
 
56
  _logger.info("Initializing vector store database...")
57
  from app.core.vector_store.deps import init_vector_store_db
 
83
  await close_redis(redis)
84
  await _vector_store_service.close_all()
85
  await pool_manager.close_all()
86
+ from app.services.supabase import get_supabase_client
87
+ client = get_supabase_client()
88
+ if client:
89
+ await client.close()
90
+ _logger.info("Supabase client closed")
91
 
92
 
93
  def create_application() -> FastAPI:
 
117
  allow_headers=["*"],
118
  )
119
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  @app.middleware("http")
121
  async def maintenance_middleware(request: Request, call_next):
122
  if is_maintenance():
app/api/v1/auth.py CHANGED
@@ -5,7 +5,7 @@ from typing import Annotated
5
  from fastapi import APIRouter, Depends, Request, status
6
 
7
  from app.config import get_settings
8
- from app.core.auth.deps import get_current_user, get_db, require_application_id
9
  from app.core.auth.models import User
10
  from app.core.auth.schemas import (
11
  ChangePasswordSchema,
@@ -24,140 +24,120 @@ from app.core.auth.schemas import (
24
  UserSchemaField,
25
  UserSchemaResponse,
26
  )
27
- from app.core.tidb_manager import TiDBManager
28
  from app.services.auth_service import AuthService
29
 
30
  router = APIRouter(prefix="/auth", tags=["Authentication"])
31
  _settings = get_settings()
32
 
33
 
34
- def _build(data, application_id=None):
35
- result = {"success": True, "data": data}
36
- if application_id:
37
- result["application_id"] = application_id
38
- return result
39
-
40
-
41
  @router.post("/register", response_model=ProfileResponse, status_code=status.HTTP_201_CREATED)
42
  async def register(
43
  schema: RegisterSchema,
44
- db: Annotated[TiDBManager, Depends(get_db)],
45
- _: Annotated[bool, Depends(require_application_id)],
46
  ):
47
  user = await AuthService.register(db, schema)
48
  profile = await AuthService.user_to_profile(db, user)
49
- return _build(profile.model_dump(), _settings.application_id or None)
50
 
51
 
52
  @router.post("/login", response_model=TokenResponse)
53
  async def login(
54
  schema: LoginSchema,
55
  request: Request,
56
- db: Annotated[TiDBManager, Depends(get_db)],
57
- _: Annotated[bool, Depends(require_application_id)],
58
  ):
59
  tokens = await AuthService.login(db, request, schema)
60
- return _build(tokens.model_dump(), _settings.application_id or None)
61
 
62
 
63
  @router.post("/refresh", response_model=TokenResponse)
64
  async def refresh_token(
65
  schema: TokenRefreshSchema,
66
- db: Annotated[TiDBManager, Depends(get_db)],
67
- _: Annotated[bool, Depends(require_application_id)],
68
  ):
69
  tokens = await AuthService.refresh(db, schema.refresh_token)
70
- return _build(tokens.model_dump(), _settings.application_id or None)
71
 
72
 
73
  @router.post("/logout", response_model=MessageDataResponse)
74
  async def logout(
75
  schema: TokenRefreshSchema,
76
  current_user: Annotated[User, Depends(get_current_user)],
77
- db: Annotated[TiDBManager, Depends(get_db)],
78
- _: Annotated[bool, Depends(require_application_id)],
79
  ):
80
  await AuthService.logout(db, current_user, schema.refresh_token)
81
- return _build({"message": "Logged out successfully"}, _settings.application_id or None)
82
 
83
 
84
  @router.post("/logout-all", response_model=MessageDataResponse)
85
  async def logout_all(
86
  current_user: Annotated[User, Depends(get_current_user)],
87
- db: Annotated[TiDBManager, Depends(get_db)],
88
- _: Annotated[bool, Depends(require_application_id)],
89
  ):
90
  await AuthService.logout_all(db, current_user)
91
- return _build({"message": "All sessions revoked"}, _settings.application_id or None)
92
 
93
 
94
  @router.post("/forgot-password", response_model=MessageDataResponse)
95
  async def forgot_password(
96
  schema: ForgotPasswordSchema,
97
- db: Annotated[TiDBManager, Depends(get_db)],
98
- _: Annotated[bool, Depends(require_application_id)],
99
  ):
100
  await AuthService.forgot_password(db, schema)
101
- return _build({"message": "If that email exists, a password reset link has been sent."}, _settings.application_id or None)
102
 
103
 
104
  @router.post("/reset-password", response_model=MessageDataResponse)
105
  async def reset_password(
106
  schema: ResetPasswordSchema,
107
- db: Annotated[TiDBManager, Depends(get_db)],
108
- _: Annotated[bool, Depends(require_application_id)],
109
  ):
110
  await AuthService.reset_password(db, schema)
111
- return _build({"message": "Password reset successfully"}, _settings.application_id or None)
112
 
113
 
114
  @router.post("/change-password", response_model=MessageDataResponse)
115
  async def change_password(
116
  schema: ChangePasswordSchema,
117
  current_user: Annotated[User, Depends(get_current_user)],
118
- db: Annotated[TiDBManager, Depends(get_db)],
119
- _: Annotated[bool, Depends(require_application_id)],
120
  ):
121
  await AuthService.change_password(db, current_user, schema)
122
- return _build({"message": "Password changed successfully"}, _settings.application_id or None)
123
 
124
 
125
  @router.get("/me", response_model=ProfileResponse)
126
  async def get_me(
127
  current_user: Annotated[User, Depends(get_current_user)],
128
- db: Annotated[TiDBManager, Depends(get_db)],
129
- _: Annotated[bool, Depends(require_application_id)],
130
  ):
131
  profile = await AuthService.user_to_profile(db, current_user)
132
- return _build(profile.model_dump(), _settings.application_id or None)
133
 
134
 
135
  @router.patch("/me", response_model=ProfileResponse)
136
  async def update_me(
137
  schema: UpdateProfileSchema,
138
  current_user: Annotated[User, Depends(get_current_user)],
139
- db: Annotated[TiDBManager, Depends(get_db)],
140
- _: Annotated[bool, Depends(require_application_id)],
141
  ):
142
  user = await AuthService.update_profile(db, current_user, schema)
143
  profile = await AuthService.user_to_profile(db, user)
144
- return _build(profile.model_dump(), _settings.application_id or None)
145
 
146
 
147
  @router.delete("/me", response_model=MessageDataResponse)
148
  async def delete_me(
149
  current_user: Annotated[User, Depends(get_current_user)],
150
- db: Annotated[TiDBManager, Depends(get_db)],
151
- _: Annotated[bool, Depends(require_application_id)],
152
  ):
153
  await AuthService.soft_delete(db, current_user)
154
- return _build({"message": "Account deleted successfully"}, _settings.application_id or None)
155
 
156
 
157
  @router.get("/schema", response_model=SchemaResponse)
158
- async def get_user_schema(
159
- _: Annotated[bool, Depends(require_application_id)],
160
- ):
161
  columns = [
162
  UserSchemaField(field="id", type="string (UUID)", required=True, description="Unique user identifier", constraints="Auto-generated"),
163
  UserSchemaField(field="email", type="string", required=True, description="User email address", constraints="Unique, max 255 chars"),
@@ -176,25 +156,23 @@ async def get_user_schema(
176
  UserSchemaField(field="deleted_at", type="datetime (ISO 8601)", required=False, description="Soft delete timestamp", constraints="Nullable"),
177
  UserSchemaField(field="roles", type="array[string]", required=False, description="Assigned role names", constraints="Via user_roles association table"),
178
  ]
179
- return _build(UserSchemaResponse(table_name="users", columns=columns).model_dump(), _settings.application_id or None)
180
 
181
 
182
  @router.get("/sessions", response_model=SessionListResponse)
183
  async def list_sessions(
184
  current_user: Annotated[User, Depends(get_current_user)],
185
- db: Annotated[TiDBManager, Depends(get_db)],
186
- _: Annotated[bool, Depends(require_application_id)],
187
  ):
188
  sessions = await AuthService.list_sessions(db, current_user)
189
- return _build([SessionOut.model_validate(s).model_dump() for s in sessions], _settings.application_id or None)
190
 
191
 
192
  @router.delete("/sessions/{session_id}", response_model=MessageDataResponse)
193
  async def revoke_session(
194
  session_id: str,
195
  current_user: Annotated[User, Depends(get_current_user)],
196
- db: Annotated[TiDBManager, Depends(get_db)],
197
- _: Annotated[bool, Depends(require_application_id)],
198
  ):
199
  await AuthService.revoke_session(db, current_user, session_id)
200
- return _build({"message": "Session revoked successfully"}, _settings.application_id or None)
 
5
  from fastapi import APIRouter, Depends, Request, status
6
 
7
  from app.config import get_settings
8
+ from app.core.auth.deps import get_current_user, get_db
9
  from app.core.auth.models import User
10
  from app.core.auth.schemas import (
11
  ChangePasswordSchema,
 
24
  UserSchemaField,
25
  UserSchemaResponse,
26
  )
27
+ from app.services.supabase import SupabaseClient
28
  from app.services.auth_service import AuthService
29
 
30
  router = APIRouter(prefix="/auth", tags=["Authentication"])
31
  _settings = get_settings()
32
 
33
 
 
 
 
 
 
 
 
34
  @router.post("/register", response_model=ProfileResponse, status_code=status.HTTP_201_CREATED)
35
  async def register(
36
  schema: RegisterSchema,
37
+ db: Annotated[SupabaseClient, Depends(get_db)],
 
38
  ):
39
  user = await AuthService.register(db, schema)
40
  profile = await AuthService.user_to_profile(db, user)
41
+ return {"success": True, "data": profile.model_dump()}
42
 
43
 
44
  @router.post("/login", response_model=TokenResponse)
45
  async def login(
46
  schema: LoginSchema,
47
  request: Request,
48
+ db: Annotated[SupabaseClient, Depends(get_db)],
 
49
  ):
50
  tokens = await AuthService.login(db, request, schema)
51
+ return {"success": True, "data": tokens.model_dump()}
52
 
53
 
54
  @router.post("/refresh", response_model=TokenResponse)
55
  async def refresh_token(
56
  schema: TokenRefreshSchema,
57
+ db: Annotated[SupabaseClient, Depends(get_db)],
 
58
  ):
59
  tokens = await AuthService.refresh(db, schema.refresh_token)
60
+ return {"success": True, "data": tokens.model_dump()}
61
 
62
 
63
  @router.post("/logout", response_model=MessageDataResponse)
64
  async def logout(
65
  schema: TokenRefreshSchema,
66
  current_user: Annotated[User, Depends(get_current_user)],
67
+ db: Annotated[SupabaseClient, Depends(get_db)],
 
68
  ):
69
  await AuthService.logout(db, current_user, schema.refresh_token)
70
+ return {"success": True, "data": {"message": "Logged out successfully"}}
71
 
72
 
73
  @router.post("/logout-all", response_model=MessageDataResponse)
74
  async def logout_all(
75
  current_user: Annotated[User, Depends(get_current_user)],
76
+ db: Annotated[SupabaseClient, Depends(get_db)],
 
77
  ):
78
  await AuthService.logout_all(db, current_user)
79
+ return {"success": True, "data": {"message": "All sessions revoked"}}
80
 
81
 
82
  @router.post("/forgot-password", response_model=MessageDataResponse)
83
  async def forgot_password(
84
  schema: ForgotPasswordSchema,
85
+ db: Annotated[SupabaseClient, Depends(get_db)],
 
86
  ):
87
  await AuthService.forgot_password(db, schema)
88
+ return {"success": True, "data": {"message": "If that email exists, a password reset link has been sent."}}
89
 
90
 
91
  @router.post("/reset-password", response_model=MessageDataResponse)
92
  async def reset_password(
93
  schema: ResetPasswordSchema,
94
+ db: Annotated[SupabaseClient, Depends(get_db)],
 
95
  ):
96
  await AuthService.reset_password(db, schema)
97
+ return {"success": True, "data": {"message": "Password reset successfully"}}
98
 
99
 
100
  @router.post("/change-password", response_model=MessageDataResponse)
101
  async def change_password(
102
  schema: ChangePasswordSchema,
103
  current_user: Annotated[User, Depends(get_current_user)],
104
+ db: Annotated[SupabaseClient, Depends(get_db)],
 
105
  ):
106
  await AuthService.change_password(db, current_user, schema)
107
+ return {"success": True, "data": {"message": "Password changed successfully"}}
108
 
109
 
110
  @router.get("/me", response_model=ProfileResponse)
111
  async def get_me(
112
  current_user: Annotated[User, Depends(get_current_user)],
113
+ db: Annotated[SupabaseClient, Depends(get_db)],
 
114
  ):
115
  profile = await AuthService.user_to_profile(db, current_user)
116
+ return {"success": True, "data": profile.model_dump()}
117
 
118
 
119
  @router.patch("/me", response_model=ProfileResponse)
120
  async def update_me(
121
  schema: UpdateProfileSchema,
122
  current_user: Annotated[User, Depends(get_current_user)],
123
+ db: Annotated[SupabaseClient, Depends(get_db)],
 
124
  ):
125
  user = await AuthService.update_profile(db, current_user, schema)
126
  profile = await AuthService.user_to_profile(db, user)
127
+ return {"success": True, "data": profile.model_dump()}
128
 
129
 
130
  @router.delete("/me", response_model=MessageDataResponse)
131
  async def delete_me(
132
  current_user: Annotated[User, Depends(get_current_user)],
133
+ db: Annotated[SupabaseClient, Depends(get_db)],
 
134
  ):
135
  await AuthService.soft_delete(db, current_user)
136
+ return {"success": True, "data": {"message": "Account deleted successfully"}}
137
 
138
 
139
  @router.get("/schema", response_model=SchemaResponse)
140
+ async def get_user_schema():
 
 
141
  columns = [
142
  UserSchemaField(field="id", type="string (UUID)", required=True, description="Unique user identifier", constraints="Auto-generated"),
143
  UserSchemaField(field="email", type="string", required=True, description="User email address", constraints="Unique, max 255 chars"),
 
156
  UserSchemaField(field="deleted_at", type="datetime (ISO 8601)", required=False, description="Soft delete timestamp", constraints="Nullable"),
157
  UserSchemaField(field="roles", type="array[string]", required=False, description="Assigned role names", constraints="Via user_roles association table"),
158
  ]
159
+ return {"success": True, "data": UserSchemaResponse(table_name="users", columns=columns).model_dump()}
160
 
161
 
162
  @router.get("/sessions", response_model=SessionListResponse)
163
  async def list_sessions(
164
  current_user: Annotated[User, Depends(get_current_user)],
165
+ db: Annotated[SupabaseClient, Depends(get_db)],
 
166
  ):
167
  sessions = await AuthService.list_sessions(db, current_user)
168
+ return {"success": True, "data": [SessionOut.model_validate(s).model_dump() for s in sessions]}
169
 
170
 
171
  @router.delete("/sessions/{session_id}", response_model=MessageDataResponse)
172
  async def revoke_session(
173
  session_id: str,
174
  current_user: Annotated[User, Depends(get_current_user)],
175
+ db: Annotated[SupabaseClient, Depends(get_db)],
 
176
  ):
177
  await AuthService.revoke_session(db, current_user, session_id)
178
+ return {"success": True, "data": {"message": "Session revoked successfully"}}
app/api/v1/url_shortener.py CHANGED
@@ -36,10 +36,6 @@ def _get_service() -> URLShortenerService:
36
  return _shared_service
37
 
38
 
39
- def _app_id() -> str:
40
- return _settings.application_id or ""
41
-
42
-
43
  # ---------------------------------------------------------------------------
44
  # Request / Response models
45
  # ---------------------------------------------------------------------------
@@ -54,7 +50,6 @@ class CreateOwnerResponse(BaseModel):
54
  owner_id: str
55
  api_key: str
56
  plan: str
57
- application_id: str = ""
58
 
59
 
60
  class ShortenRequest(BaseModel):
@@ -94,7 +89,6 @@ class ShortLinkResponse(BaseModel):
94
  custom_domain: Optional[str] = None
95
  fallback_url: Optional[str] = None
96
  webhook_url: Optional[str] = None
97
- application_id: str = ""
98
 
99
 
100
  class UpdateLinkRequest(BaseModel):
@@ -113,7 +107,6 @@ class UpdateLinkRequest(BaseModel):
113
  class ListLinksResponse(BaseModel):
114
  success: bool = True
115
  links: List[ShortLinkResponse]
116
- application_id: str = ""
117
 
118
 
119
  class StatsResponse(BaseModel):
@@ -133,7 +126,6 @@ class StatsResponse(BaseModel):
133
  custom_domain: Optional[str] = None
134
  fallback_url: Optional[str] = None
135
  webhook_url: Optional[str] = None
136
- application_id: str = ""
137
 
138
 
139
  class ClickDetail(BaseModel):
@@ -150,7 +142,6 @@ class ClickDetail(BaseModel):
150
  class ClickDetailsResponse(BaseModel):
151
  success: bool = True
152
  clicks: List[ClickDetail]
153
- application_id: str = ""
154
 
155
 
156
  class QRCodeResponse(BaseModel):
@@ -158,7 +149,6 @@ class QRCodeResponse(BaseModel):
158
  short_code: str
159
  short_url: str
160
  qr_image_url: str
161
- application_id: str = ""
162
 
163
 
164
  class ErrorResponse(BaseModel):
@@ -173,7 +163,6 @@ class BulkShortenRequest(BaseModel):
173
  class BulkShortenResponse(BaseModel):
174
  success: bool = True
175
  results: List[Dict[str, Any]]
176
- application_id: str = ""
177
 
178
 
179
  class OwnerSummaryResponse(BaseModel):
@@ -185,7 +174,6 @@ class OwnerSummaryResponse(BaseModel):
185
  active_links: int
186
  total_clicks: int
187
  created_at: str
188
- application_id: str = ""
189
 
190
 
191
  class CampaignCreateRequest(BaseModel):
@@ -203,13 +191,11 @@ class CampaignResponse(BaseModel):
203
  link_count: Optional[int] = 0
204
  total_clicks: Optional[int] = 0
205
  analytics: Optional[Dict[str, Any]] = None
206
- application_id: str = ""
207
 
208
 
209
  class CampaignListResponse(BaseModel):
210
  success: bool = True
211
  campaigns: List[CampaignResponse]
212
- application_id: str = ""
213
 
214
 
215
  class HealthCheckResponse(BaseModel):
@@ -219,7 +205,6 @@ class HealthCheckResponse(BaseModel):
219
  reachable: bool
220
  status_code: Optional[int] = None
221
  error: Optional[str] = None
222
- application_id: str = ""
223
 
224
 
225
  # ---------------------------------------------------------------------------
@@ -251,7 +236,6 @@ def _build_link_response(link, service) -> ShortLinkResponse:
251
  custom_domain=link.custom_domain,
252
  fallback_url=link.fallback_url,
253
  webhook_url=link.webhook_url,
254
- application_id=_app_id(),
255
  )
256
 
257
 
@@ -271,7 +255,6 @@ def create_owner(body: CreateOwnerRequest):
271
  owner_id=result["owner_id"],
272
  api_key=result["api_key"],
273
  plan=body.plan or "free",
274
- application_id=_app_id(),
275
  )
276
 
277
 
@@ -283,7 +266,7 @@ def update_plan(plan: str = Query(..., description="free, pro, or enterprise"),
283
  owner_id: str = Depends(_auth_owner)):
284
  service = _get_service()
285
  try:
286
- return {"success": True, "application_id": _app_id(), **service.update_owner_plan(owner_id, plan)}
287
  except ValidationError as e:
288
  raise HTTPException(status_code=400, detail={"success": False, "error": str(e)})
289
 
@@ -298,7 +281,7 @@ def owner_summary(owner_id: str = Depends(_auth_owner)):
298
  data = service.owner_summary(owner_id)
299
  if not data:
300
  raise HTTPException(status_code=404, detail={"success": False, "error": "Owner not found"})
301
- return OwnerSummaryResponse(application_id=_app_id(), **data)
302
 
303
 
304
  # ---------------------------------------------------------------------------
@@ -361,7 +344,7 @@ def bulk_shorten(body: BulkShortenRequest, owner_id: str = Depends(_auth_owner))
361
  raise HTTPException(status_code=400, detail={"success": False, "error": "Bulk limit is 100 items per request"})
362
  service = _get_service()
363
  results = service.bulk_shorten(owner_id, body.items)
364
- return BulkShortenResponse(results=results, application_id=_app_id())
365
 
366
 
367
  @router.get(
@@ -373,7 +356,7 @@ def list_links(owner_id: str = Depends(_auth_owner)):
373
  service = _get_service()
374
  links = service.list_links(owner_id)
375
  items = [_build_link_response(link, service) for link in links]
376
- return ListLinksResponse(links=items, application_id=_app_id())
377
 
378
 
379
  @router.get(
@@ -400,7 +383,6 @@ def create_campaign(body: CampaignCreateRequest, owner_id: str = Depends(_auth_o
400
  campaign_id=result["campaign_id"],
401
  name=result["name"],
402
  description=result.get("description"),
403
- application_id=_app_id(),
404
  )
405
  except PlanLimitExceededError as e:
406
  raise HTTPException(status_code=402, detail={"success": False, "error": str(e), "code": "plan_limit"})
@@ -416,8 +398,8 @@ def create_campaign(body: CampaignCreateRequest, owner_id: str = Depends(_auth_o
416
  def list_campaigns(owner_id: str = Depends(_auth_owner)):
417
  service = _get_service()
418
  campaigns = service.list_campaigns(owner_id)
419
- items = [CampaignResponse(**c, application_id=_app_id()) for c in campaigns]
420
- return CampaignListResponse(campaigns=items, application_id=_app_id())
421
 
422
 
423
  @router.get(
@@ -432,7 +414,7 @@ def get_campaign(
432
  service = _get_service()
433
  try:
434
  data = service.get_campaign(campaign_id, owner_id)
435
- return CampaignResponse(**data, application_id=_app_id())
436
  except LinkNotFoundError as e:
437
  raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
438
  except AuthorizationError as e:
@@ -452,7 +434,7 @@ def list_campaign_links(
452
  try:
453
  links = service.list_campaign_links(campaign_id, owner_id)
454
  items = [_build_link_response(link, service) for link in links]
455
- return ListLinksResponse(links=items, application_id=_app_id())
456
  except LinkNotFoundError as e:
457
  raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
458
  except AuthorizationError as e:
@@ -470,7 +452,7 @@ def deactivate_campaign(
470
  service = _get_service()
471
  try:
472
  service.deactivate_campaign(campaign_id, owner_id)
473
- return {"success": True, "message": f"Campaign '{campaign_id}' deactivated", "application_id": _app_id()}
474
  except LinkNotFoundError as e:
475
  raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
476
  except AuthorizationError as e:
@@ -545,7 +527,7 @@ def deactivate_link(
545
  service = _get_service()
546
  try:
547
  service.deactivate_link(short_code, owner_id)
548
- return {"success": True, "message": f"Link '{short_code}' deactivated", "application_id": _app_id()}
549
  except LinkNotFoundError as e:
550
  raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
551
  except AuthorizationError as e:
@@ -580,7 +562,6 @@ def get_link_stats(
580
  custom_domain=stats.get("custom_domain"),
581
  fallback_url=stats.get("fallback_url"),
582
  webhook_url=stats.get("webhook_url"),
583
- application_id=_app_id(),
584
  )
585
  except LinkNotFoundError as e:
586
  raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
@@ -601,7 +582,7 @@ def get_click_details(
601
  service = _get_service()
602
  try:
603
  clicks = service.get_click_details(short_code, owner_id, limit)
604
- return ClickDetailsResponse(clicks=[ClickDetail(**c) for c in clicks], application_id=_app_id())
605
  except LinkNotFoundError as e:
606
  raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
607
  except AuthorizationError as e:
@@ -619,7 +600,7 @@ def get_qr_code(
619
  service = _get_service()
620
  try:
621
  data = service.get_qr_code(short_code)
622
- return QRCodeResponse(**data, application_id=_app_id())
623
  except LinkNotFoundError as e:
624
  raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
625
 
@@ -636,7 +617,7 @@ def link_health(
636
  service = _get_service()
637
  try:
638
  result = service.check_link_health(short_code, owner_id)
639
- return HealthCheckResponse(**result, application_id=_app_id())
640
  except LinkNotFoundError as e:
641
  raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
642
  except AuthorizationError as e:
 
36
  return _shared_service
37
 
38
 
 
 
 
 
39
  # ---------------------------------------------------------------------------
40
  # Request / Response models
41
  # ---------------------------------------------------------------------------
 
50
  owner_id: str
51
  api_key: str
52
  plan: str
 
53
 
54
 
55
  class ShortenRequest(BaseModel):
 
89
  custom_domain: Optional[str] = None
90
  fallback_url: Optional[str] = None
91
  webhook_url: Optional[str] = None
 
92
 
93
 
94
  class UpdateLinkRequest(BaseModel):
 
107
  class ListLinksResponse(BaseModel):
108
  success: bool = True
109
  links: List[ShortLinkResponse]
 
110
 
111
 
112
  class StatsResponse(BaseModel):
 
126
  custom_domain: Optional[str] = None
127
  fallback_url: Optional[str] = None
128
  webhook_url: Optional[str] = None
 
129
 
130
 
131
  class ClickDetail(BaseModel):
 
142
  class ClickDetailsResponse(BaseModel):
143
  success: bool = True
144
  clicks: List[ClickDetail]
 
145
 
146
 
147
  class QRCodeResponse(BaseModel):
 
149
  short_code: str
150
  short_url: str
151
  qr_image_url: str
 
152
 
153
 
154
  class ErrorResponse(BaseModel):
 
163
  class BulkShortenResponse(BaseModel):
164
  success: bool = True
165
  results: List[Dict[str, Any]]
 
166
 
167
 
168
  class OwnerSummaryResponse(BaseModel):
 
174
  active_links: int
175
  total_clicks: int
176
  created_at: str
 
177
 
178
 
179
  class CampaignCreateRequest(BaseModel):
 
191
  link_count: Optional[int] = 0
192
  total_clicks: Optional[int] = 0
193
  analytics: Optional[Dict[str, Any]] = None
 
194
 
195
 
196
  class CampaignListResponse(BaseModel):
197
  success: bool = True
198
  campaigns: List[CampaignResponse]
 
199
 
200
 
201
  class HealthCheckResponse(BaseModel):
 
205
  reachable: bool
206
  status_code: Optional[int] = None
207
  error: Optional[str] = None
 
208
 
209
 
210
  # ---------------------------------------------------------------------------
 
236
  custom_domain=link.custom_domain,
237
  fallback_url=link.fallback_url,
238
  webhook_url=link.webhook_url,
 
239
  )
240
 
241
 
 
255
  owner_id=result["owner_id"],
256
  api_key=result["api_key"],
257
  plan=body.plan or "free",
 
258
  )
259
 
260
 
 
266
  owner_id: str = Depends(_auth_owner)):
267
  service = _get_service()
268
  try:
269
+ return {"success": True, **service.update_owner_plan(owner_id, plan)}
270
  except ValidationError as e:
271
  raise HTTPException(status_code=400, detail={"success": False, "error": str(e)})
272
 
 
281
  data = service.owner_summary(owner_id)
282
  if not data:
283
  raise HTTPException(status_code=404, detail={"success": False, "error": "Owner not found"})
284
+ return OwnerSummaryResponse(**data)
285
 
286
 
287
  # ---------------------------------------------------------------------------
 
344
  raise HTTPException(status_code=400, detail={"success": False, "error": "Bulk limit is 100 items per request"})
345
  service = _get_service()
346
  results = service.bulk_shorten(owner_id, body.items)
347
+ return BulkShortenResponse(results=results, )
348
 
349
 
350
  @router.get(
 
356
  service = _get_service()
357
  links = service.list_links(owner_id)
358
  items = [_build_link_response(link, service) for link in links]
359
+ return ListLinksResponse(links=items, )
360
 
361
 
362
  @router.get(
 
383
  campaign_id=result["campaign_id"],
384
  name=result["name"],
385
  description=result.get("description"),
 
386
  )
387
  except PlanLimitExceededError as e:
388
  raise HTTPException(status_code=402, detail={"success": False, "error": str(e), "code": "plan_limit"})
 
398
  def list_campaigns(owner_id: str = Depends(_auth_owner)):
399
  service = _get_service()
400
  campaigns = service.list_campaigns(owner_id)
401
+ items = [CampaignResponse(**c, ) for c in campaigns]
402
+ return CampaignListResponse(campaigns=items, )
403
 
404
 
405
  @router.get(
 
414
  service = _get_service()
415
  try:
416
  data = service.get_campaign(campaign_id, owner_id)
417
+ return CampaignResponse(**data, )
418
  except LinkNotFoundError as e:
419
  raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
420
  except AuthorizationError as e:
 
434
  try:
435
  links = service.list_campaign_links(campaign_id, owner_id)
436
  items = [_build_link_response(link, service) for link in links]
437
+ return ListLinksResponse(links=items, )
438
  except LinkNotFoundError as e:
439
  raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
440
  except AuthorizationError as e:
 
452
  service = _get_service()
453
  try:
454
  service.deactivate_campaign(campaign_id, owner_id)
455
+ return {"success": True, "message": f"Campaign '{campaign_id}' deactivated"}
456
  except LinkNotFoundError as e:
457
  raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
458
  except AuthorizationError as e:
 
527
  service = _get_service()
528
  try:
529
  service.deactivate_link(short_code, owner_id)
530
+ return {"success": True, "message": f"Link '{short_code}' deactivated"}
531
  except LinkNotFoundError as e:
532
  raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
533
  except AuthorizationError as e:
 
562
  custom_domain=stats.get("custom_domain"),
563
  fallback_url=stats.get("fallback_url"),
564
  webhook_url=stats.get("webhook_url"),
 
565
  )
566
  except LinkNotFoundError as e:
567
  raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
 
582
  service = _get_service()
583
  try:
584
  clicks = service.get_click_details(short_code, owner_id, limit)
585
+ return ClickDetailsResponse(clicks=[ClickDetail(**c) for c in clicks], )
586
  except LinkNotFoundError as e:
587
  raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
588
  except AuthorizationError as e:
 
600
  service = _get_service()
601
  try:
602
  data = service.get_qr_code(short_code)
603
+ return QRCodeResponse(**data, )
604
  except LinkNotFoundError as e:
605
  raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
606
 
 
617
  service = _get_service()
618
  try:
619
  result = service.check_link_health(short_code, owner_id)
620
+ return HealthCheckResponse(**result, )
621
  except LinkNotFoundError as e:
622
  raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
623
  except AuthorizationError as e:
app/api/v1/vector_stores.py CHANGED
@@ -64,7 +64,7 @@ async def create_vector_store(
64
  token: str = Depends(require_auth),
65
  vector_store_service: VectorStoreService = Depends(get_vector_store_service),
66
  ) -> VectorStoreResponse:
67
- store_id, app_id = await vector_store_service.create_store(
68
  name=body.name,
69
  description=body.description or "",
70
  metadata=body.metadata,
@@ -73,7 +73,6 @@ async def create_vector_store(
73
  return VectorStoreResponse(
74
  success=True,
75
  vector_store_id=store_id,
76
- app_id=app_id,
77
  name=body.name,
78
  description=body.description,
79
  embedding_dimension=stats["embedding_dimension"],
@@ -102,7 +101,6 @@ async def list_vector_stores(
102
  stores.append(VectorStoreResponse(
103
  success=True,
104
  vector_store_id=r.store_id,
105
- app_id=stats.get("app_id", r.store_id),
106
  name=r.name,
107
  description=r.description,
108
  embedding_dimension=stats.get("embedding_dimension", 0),
@@ -130,7 +128,6 @@ async def get_vector_store(
130
  return VectorStoreResponse(
131
  success=True,
132
  vector_store_id=stats["store_id"],
133
- app_id=stats["app_id"],
134
  name=stats["name"],
135
  description=stats["description"],
136
  embedding_dimension=stats["embedding_dimension"],
@@ -159,7 +156,6 @@ async def delete_vector_store(
159
  return VectorStoreResponse(
160
  success=True,
161
  vector_store_id=store_id,
162
- app_id="",
163
  name=record.name,
164
  document_count=0,
165
  embedding_dimension=0,
 
64
  token: str = Depends(require_auth),
65
  vector_store_service: VectorStoreService = Depends(get_vector_store_service),
66
  ) -> VectorStoreResponse:
67
+ store_id = await vector_store_service.create_store(
68
  name=body.name,
69
  description=body.description or "",
70
  metadata=body.metadata,
 
73
  return VectorStoreResponse(
74
  success=True,
75
  vector_store_id=store_id,
 
76
  name=body.name,
77
  description=body.description,
78
  embedding_dimension=stats["embedding_dimension"],
 
101
  stores.append(VectorStoreResponse(
102
  success=True,
103
  vector_store_id=r.store_id,
 
104
  name=r.name,
105
  description=r.description,
106
  embedding_dimension=stats.get("embedding_dimension", 0),
 
128
  return VectorStoreResponse(
129
  success=True,
130
  vector_store_id=stats["store_id"],
 
131
  name=stats["name"],
132
  description=stats["description"],
133
  embedding_dimension=stats["embedding_dimension"],
 
156
  return VectorStoreResponse(
157
  success=True,
158
  vector_store_id=store_id,
 
159
  name=record.name,
160
  document_count=0,
161
  embedding_dimension=0,
app/config.py CHANGED
@@ -8,7 +8,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
8
 
9
  class Settings(BaseSettings):
10
  model_config = SettingsConfigDict(
11
- env_file=".env",
12
  env_file_encoding="utf-8",
13
  extra="ignore",
14
  )
@@ -73,19 +73,16 @@ class Settings(BaseSettings):
73
  jwt_default_expiry_minutes: int = 30
74
  jwt_issuer: str = "all-api-collection"
75
 
76
- tidb_instances: str = ""
77
- tidb_ssl: bool = True
 
 
78
  access_token_expire_minutes: int = 15
79
  refresh_token_expire_days: int = 7
80
  max_login_attempts: int = 5
81
  lockout_minutes: int = 15
82
- application_id: str = ""
83
  admin_password: str = ""
84
 
85
- @property
86
- def tidb_instance_list(self) -> list[str]:
87
- return [u.strip() for u in self.tidb_instances.split(",") if u.strip()]
88
-
89
  @property
90
  def max_upload_mb(self) -> int:
91
  return self.max_upload_bytes // (1024 * 1024)
 
8
 
9
  class Settings(BaseSettings):
10
  model_config = SettingsConfigDict(
11
+ env_file=[".env", ".env.local"],
12
  env_file_encoding="utf-8",
13
  extra="ignore",
14
  )
 
73
  jwt_default_expiry_minutes: int = 30
74
  jwt_issuer: str = "all-api-collection"
75
 
76
+ supabase_url: str = "http://localhost:8000"
77
+ supabase_anon_key: str = ""
78
+ supabase_service_role_key: str = ""
79
+ supabase_schema: str = "public"
80
  access_token_expire_minutes: int = 15
81
  refresh_token_expire_days: int = 7
82
  max_login_attempts: int = 5
83
  lockout_minutes: int = 15
 
84
  admin_password: str = ""
85
 
 
 
 
 
86
  @property
87
  def max_upload_mb(self) -> int:
88
  return self.max_upload_bytes // (1024 * 1024)
app/core/auth/deps.py CHANGED
@@ -2,7 +2,7 @@ from __future__ import annotations
2
 
3
  import logging
4
  from datetime import datetime
5
- from typing import Annotated, Optional
6
 
7
  import jwt
8
  from argon2 import PasswordHasher
@@ -11,124 +11,28 @@ from fastapi import Depends, HTTPException, Request, status
11
  from app.config import get_settings
12
  from app.core.auth.models import RefreshSession, User, _uuid
13
  from app.core.auth.models import _utcnow as _now
14
- from app.core.tidb_manager import TiDBManager, get_tidb_manager, set_tidb_manager
 
 
15
 
16
  logger = logging.getLogger("auth")
17
 
18
  _settings = get_settings()
19
  ph = PasswordHasher()
20
 
21
- AUTH_TABLES: dict[str, str] = {
22
- "users": """
23
- CREATE TABLE IF NOT EXISTS users (
24
- id VARCHAR(36) PRIMARY KEY,
25
- email VARCHAR(255) NOT NULL,
26
- username VARCHAR(50) NULL,
27
- full_name VARCHAR(255) NULL,
28
- password_hash TEXT NOT NULL,
29
- is_active TINYINT(1) NOT NULL DEFAULT 1,
30
- is_verified TINYINT(1) NOT NULL DEFAULT 0,
31
- failed_login_attempts INT NOT NULL DEFAULT 0,
32
- locked_until DATETIME NULL,
33
- last_login DATETIME NULL,
34
- password_changed_at DATETIME NULL,
35
- created_at DATETIME NOT NULL,
36
- updated_at DATETIME NOT NULL,
37
- deleted_at DATETIME NULL,
38
- UNIQUE KEY uk_users_email (email),
39
- UNIQUE KEY uk_users_username (username),
40
- INDEX idx_users_email (email),
41
- INDEX idx_users_username (username)
42
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
43
- """,
44
- "roles": """
45
- CREATE TABLE IF NOT EXISTS roles (
46
- id VARCHAR(36) PRIMARY KEY,
47
- name VARCHAR(50) NOT NULL,
48
- description VARCHAR(255) NULL,
49
- UNIQUE KEY uk_roles_name (name)
50
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
51
- """,
52
- "permissions": """
53
- CREATE TABLE IF NOT EXISTS permissions (
54
- id VARCHAR(36) PRIMARY KEY,
55
- code VARCHAR(100) NOT NULL,
56
- description VARCHAR(255) NULL,
57
- UNIQUE KEY uk_permissions_code (code),
58
- INDEX idx_permissions_code (code)
59
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
60
- """,
61
- "user_roles": """
62
- CREATE TABLE IF NOT EXISTS user_roles (
63
- user_id VARCHAR(36) NOT NULL,
64
- role_id VARCHAR(36) NOT NULL,
65
- PRIMARY KEY (user_id, role_id),
66
- CONSTRAINT fk_ur_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
67
- CONSTRAINT fk_ur_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
68
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
69
- """,
70
- "role_permissions": """
71
- CREATE TABLE IF NOT EXISTS role_permissions (
72
- role_id VARCHAR(36) NOT NULL,
73
- permission_id VARCHAR(36) NOT NULL,
74
- PRIMARY KEY (role_id, permission_id),
75
- CONSTRAINT fk_rp_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
76
- CONSTRAINT fk_rp_perm FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
77
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
78
- """,
79
- "refresh_sessions": """
80
- CREATE TABLE IF NOT EXISTS refresh_sessions (
81
- id VARCHAR(36) PRIMARY KEY,
82
- user_id VARCHAR(36) NOT NULL,
83
- token_key VARCHAR(64) NOT NULL,
84
- token_hash VARCHAR(255) NOT NULL,
85
- device_info VARCHAR(255) NULL,
86
- ip_address VARCHAR(45) NULL,
87
- expires_at DATETIME NOT NULL,
88
- revoked_at DATETIME NULL,
89
- created_at DATETIME NOT NULL,
90
- updated_at DATETIME NOT NULL,
91
- UNIQUE KEY uk_rs_token_key (token_key),
92
- INDEX idx_rs_user_id (user_id),
93
- INDEX idx_rs_token_key (token_key),
94
- CONSTRAINT fk_rs_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
95
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
96
- """,
97
- }
98
-
99
-
100
- def _create_manager() -> Optional[TiDBManager]:
101
- urls = _settings.tidb_instance_list
102
- if not urls:
103
- logger.warning("No TiDB instances configured (TIDB_INSTANCES is empty)")
104
- return None
105
- import ssl
106
- if _settings.tidb_ssl:
107
- ctx = ssl.create_default_context()
108
- ctx.check_hostname = False
109
- ctx.verify_mode = ssl.CERT_NONE
110
- else:
111
- ctx = None
112
- mgr = TiDBManager(urls, ssl=ctx)
113
- return mgr
114
-
115
 
116
  async def init_auth_db():
117
- mgr = _create_manager()
118
- if mgr is None:
119
- logger.error("Cannot initialize auth DB: no TiDB instances configured")
120
  return
121
- await mgr.initialize_pools()
122
- set_tidb_manager(mgr)
123
 
124
- await mgr.ensure_tables(AUTH_TABLES)
125
 
126
- roles_result = await mgr.fetchone("SELECT id FROM roles WHERE name = 'SuperAdmin'")
127
- if not roles_result:
128
- sa_role_id = _uuid()
129
- admin_role_id = _uuid()
130
- user_role_id = _uuid()
131
- perm_ids = {}
132
  for code, desc in [
133
  ("users:read", "Read users"),
134
  ("users:write", "Modify users"),
@@ -136,76 +40,63 @@ async def init_auth_db():
136
  ("admin:access", "Access admin panel"),
137
  ]:
138
  pid = _uuid()
139
- await mgr.execute(
140
- "INSERT INTO permissions (id, code, description) VALUES (%s, %s, %s)",
141
- (pid, code, desc),
142
- )
143
- perm_ids[code] = pid
 
144
 
145
- await mgr.execute(
146
- "INSERT INTO roles (id, name, description) VALUES (%s, %s, %s)",
147
- (sa_role_id, "SuperAdmin", "Full system access"),
148
- )
149
- await mgr.execute(
150
- "INSERT INTO roles (id, name, description) VALUES (%s, %s, %s)",
151
- (admin_role_id, "Admin", "Administrative access"),
152
- )
153
- await mgr.execute(
154
- "INSERT INTO roles (id, name, description) VALUES (%s, %s, %s)",
155
- (user_role_id, "User", "Standard user access"),
156
- )
157
 
158
- perms_list = list(perm_ids.values())
159
- for pid in perms_list:
160
- await mgr.execute(
161
- "INSERT INTO role_permissions (role_id, permission_id) VALUES (%s, %s)",
162
- (sa_role_id, pid),
163
- )
164
- for pid in perms_list[:-1]:
165
- await mgr.execute(
166
- "INSERT INTO role_permissions (role_id, permission_id) VALUES (%s, %s)",
167
- (admin_role_id, pid),
168
- )
169
- await mgr.execute(
170
- "INSERT INTO role_permissions (role_id, permission_id) VALUES (%s, %s)",
171
- (user_role_id, perms_list[0]),
172
- )
173
 
174
  admin_email = "admin@example.com"
175
- admin_result = await mgr.fetchone("SELECT id FROM users WHERE email = %s", (admin_email,))
176
- if not admin_result:
177
  admin_password = _settings.admin_password or "Admin123!"
178
  admin_id = _uuid()
179
  now = _now()
180
- await mgr.execute(
181
- "INSERT INTO users (id, email, full_name, password_hash, is_verified, created_at, updated_at) "
182
- "VALUES (%s, %s, %s, %s, 1, %s, %s)",
183
- (admin_id, admin_email, "System Administrator", ph.hash(admin_password), now, now),
184
- )
185
- role_result = await mgr.fetchone("SELECT id FROM roles WHERE name = 'SuperAdmin'")
186
- if role_result:
187
- await mgr.execute(
188
- "INSERT INTO user_roles (user_id, role_id) VALUES (%s, %s)",
189
- (admin_id, role_result["id"]),
190
- )
 
191
 
192
  from app.services.auth_service import AuthService
193
- await AuthService.cleanup_expired_sessions(mgr)
194
 
195
 
196
- async def get_db() -> TiDBManager:
197
- mgr = get_tidb_manager()
198
- if mgr is None:
199
  raise HTTPException(
200
  status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
201
- detail="Database not initialized. Configure TIDB_INSTANCES.",
202
  )
203
- return mgr
204
 
205
 
206
  async def get_current_user(
207
  request: Request,
208
- db: Annotated[TiDBManager, Depends(get_db)],
209
  ) -> User:
210
  credentials_exception = HTTPException(
211
  status_code=status.HTTP_401_UNAUTHORIZED,
@@ -228,51 +119,22 @@ async def get_current_user(
228
  except jwt.PyJWTError:
229
  raise credentials_exception
230
 
231
- row = await db.fetchone_all(
232
- "SELECT * FROM users WHERE id = %s AND deleted_at IS NULL",
233
- (user_id,),
234
- )
235
- if row is None:
236
- raise credentials_exception
237
- user = _row_to_user(row)
238
- if not user.is_active:
239
  raise credentials_exception
240
  return user
241
 
242
 
243
- def require_application_id(request: Request):
244
- app_id = request.headers.get("X-Application-Id")
245
- expected = _settings.application_id
246
- if not expected:
247
- return True
248
- if not app_id:
249
- raise HTTPException(
250
- status_code=status.HTTP_400_BAD_REQUEST,
251
- detail="Missing X-Application-Id header",
252
- )
253
- if app_id != expected:
254
- raise HTTPException(
255
- status_code=status.HTTP_403_FORBIDDEN,
256
- detail="Invalid application_id",
257
- )
258
- return True
259
-
260
-
261
  def require_permissions(*required_perms: str):
262
  async def permission_checker(
263
  user: Annotated[User, Depends(get_current_user)],
264
  ) -> User:
265
- user_perms = set()
266
- mgr = get_tidb_manager()
267
- if mgr:
268
- rows = await mgr.fetchall_all(
269
- "SELECT p.code FROM permissions p "
270
- "JOIN role_permissions rp ON rp.permission_id = p.id "
271
- "JOIN user_roles ur ON ur.role_id = rp.role_id "
272
- "WHERE ur.user_id = %s",
273
- (user.id,),
274
- )
275
- user_perms = {r["code"] for r in rows}
276
  missing = [p for p in required_perms if p not in user_perms]
277
  if missing:
278
  raise HTTPException(
@@ -282,53 +144,3 @@ def require_permissions(*required_perms: str):
282
  return user
283
 
284
  return permission_checker
285
-
286
-
287
- def _row_to_user(row: dict) -> User:
288
- return User(
289
- id=row["id"],
290
- email=row["email"],
291
- username=row.get("username"),
292
- full_name=row.get("full_name"),
293
- password_hash=row["password_hash"],
294
- is_active=bool(row["is_active"]),
295
- is_verified=bool(row["is_verified"]),
296
- failed_login_attempts=row["failed_login_attempts"],
297
- locked_until=_deserialize_dt(row.get("locked_until")),
298
- last_login=_deserialize_dt(row.get("last_login")),
299
- password_changed_at=_deserialize_dt(row.get("password_changed_at")),
300
- created_at=_deserialize_dt(row["created_at"]),
301
- updated_at=_deserialize_dt(row["updated_at"]),
302
- deleted_at=_deserialize_dt(row.get("deleted_at")),
303
- )
304
-
305
-
306
- def _row_to_refresh_session(row: dict) -> RefreshSession:
307
- return RefreshSession(
308
- id=row["id"],
309
- user_id=row["user_id"],
310
- token_key=row["token_key"],
311
- token_hash=row["token_hash"],
312
- device_info=row.get("device_info"),
313
- ip_address=row.get("ip_address"),
314
- expires_at=_deserialize_dt(row["expires_at"]),
315
- revoked_at=_deserialize_dt(row.get("revoked_at")),
316
- created_at=_deserialize_dt(row["created_at"]),
317
- updated_at=_deserialize_dt(row["updated_at"]),
318
- )
319
-
320
-
321
- def _serialize_dt(dt: Optional[datetime]) -> Optional[str]:
322
- if dt is None:
323
- return None
324
- return dt.strftime("%Y-%m-%d %H:%M:%S.%f")
325
-
326
-
327
- def _deserialize_dt(val) -> Optional[datetime]:
328
- if val is None:
329
- return None
330
- if isinstance(val, datetime):
331
- return val
332
- if isinstance(val, str):
333
- return datetime.fromisoformat(val)
334
- return val
 
2
 
3
  import logging
4
  from datetime import datetime
5
+ from typing import Annotated, List, Optional
6
 
7
  import jwt
8
  from argon2 import PasswordHasher
 
11
  from app.config import get_settings
12
  from app.core.auth.models import RefreshSession, User, _uuid
13
  from app.core.auth.models import _utcnow as _now
14
+ from app.services.supabase import AuthRepository, SupabaseClient, get_supabase_client
15
+ from app.services.supabase.repositories import _row_to_user
16
+ from app.services.supabase.client import create_supabase_client
17
 
18
  logger = logging.getLogger("auth")
19
 
20
  _settings = get_settings()
21
  ph = PasswordHasher()
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
  async def init_auth_db():
25
+ client = await create_supabase_client()
26
+ if client is None:
27
+ logger.error("Cannot initialize auth DB: Supabase not configured")
28
  return
29
+ logger.info("Supabase auth client initialized")
 
30
 
31
+ repo = AuthRepository(client)
32
 
33
+ sa_role = await repo.get_role_by_name("SuperAdmin")
34
+ if not sa_role:
35
+ perm_ids: List[str] = []
 
 
 
36
  for code, desc in [
37
  ("users:read", "Read users"),
38
  ("users:write", "Modify users"),
 
40
  ("admin:access", "Access admin panel"),
41
  ]:
42
  pid = _uuid()
43
+ await client.insert("permissions", {
44
+ "id": pid,
45
+ "code": code,
46
+ "description": desc,
47
+ })
48
+ perm_ids.append(pid)
49
 
50
+ sa_role_id = _uuid()
51
+ admin_role_id = _uuid()
52
+ user_role_id = _uuid()
 
 
 
 
 
 
 
 
 
53
 
54
+ await client.insert("roles", {"id": sa_role_id, "name": "SuperAdmin", "description": "Full system access"})
55
+ await client.insert("roles", {"id": admin_role_id, "name": "Admin", "description": "Administrative access"})
56
+ await client.insert("roles", {"id": user_role_id, "name": "User", "description": "Standard user access"})
57
+
58
+ for pid in perm_ids:
59
+ await client.insert("role_permissions", {"role_id": sa_role_id, "permission_id": pid})
60
+ for pid in perm_ids[:-1]:
61
+ await client.insert("role_permissions", {"role_id": admin_role_id, "permission_id": pid})
62
+ await client.insert("role_permissions", {"role_id": user_role_id, "permission_id": perm_ids[0]})
 
 
 
 
 
 
63
 
64
  admin_email = "admin@example.com"
65
+ admin_user = await repo.find_user_by_email(admin_email)
66
+ if not admin_user:
67
  admin_password = _settings.admin_password or "Admin123!"
68
  admin_id = _uuid()
69
  now = _now()
70
+ await client.insert("users", {
71
+ "id": admin_id,
72
+ "email": admin_email,
73
+ "full_name": "System Administrator",
74
+ "password_hash": ph.hash(admin_password),
75
+ "is_verified": True,
76
+ "created_at": now.isoformat(),
77
+ "updated_at": now.isoformat(),
78
+ })
79
+ sa_role = await repo.get_role_by_name("SuperAdmin")
80
+ if sa_role:
81
+ await client.insert("user_roles", {"user_id": admin_id, "role_id": sa_role["id"]})
82
 
83
  from app.services.auth_service import AuthService
84
+ await AuthService.cleanup_expired_sessions()
85
 
86
 
87
+ async def get_db() -> SupabaseClient:
88
+ client = get_supabase_client()
89
+ if client is None:
90
  raise HTTPException(
91
  status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
92
+ detail="Database not initialized. Configure SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY.",
93
  )
94
+ return client
95
 
96
 
97
  async def get_current_user(
98
  request: Request,
99
+ db: Annotated[SupabaseClient, Depends(get_db)],
100
  ) -> User:
101
  credentials_exception = HTTPException(
102
  status_code=status.HTTP_401_UNAUTHORIZED,
 
119
  except jwt.PyJWTError:
120
  raise credentials_exception
121
 
122
+ repo = AuthRepository(db)
123
+ user = await repo.find_user_by_id(user_id)
124
+ if user is None or not user.is_active:
 
 
 
 
 
125
  raise credentials_exception
126
  return user
127
 
128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  def require_permissions(*required_perms: str):
130
  async def permission_checker(
131
  user: Annotated[User, Depends(get_current_user)],
132
  ) -> User:
133
+ client = get_supabase_client()
134
+ user_perms: set[str] = set()
135
+ if client:
136
+ repo = AuthRepository(client)
137
+ user_perms = set(await repo.get_user_permission_codes(user.id))
 
 
 
 
 
 
138
  missing = [p for p in required_perms if p not in user_perms]
139
  if missing:
140
  raise HTTPException(
 
144
  return user
145
 
146
  return permission_checker
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/core/auth/schemas.py CHANGED
@@ -123,29 +123,23 @@ class ApiResponse(BaseModel):
123
 
124
  class DataResponse(ApiResponse):
125
  data: dict
126
- application_id: Optional[str] = None
127
 
128
 
129
  class TokenResponse(ApiResponse):
130
  data: TokenData
131
- application_id: Optional[str] = None
132
 
133
 
134
  class ProfileResponse(ApiResponse):
135
  data: UserProfile
136
- application_id: Optional[str] = None
137
 
138
 
139
  class MessageDataResponse(ApiResponse):
140
  data: MessageResponse
141
- application_id: Optional[str] = None
142
 
143
 
144
  class SchemaResponse(ApiResponse):
145
  data: UserSchemaResponse
146
- application_id: Optional[str] = None
147
 
148
 
149
  class SessionListResponse(ApiResponse):
150
  data: list[SessionOut]
151
- application_id: Optional[str] = None
 
123
 
124
  class DataResponse(ApiResponse):
125
  data: dict
 
126
 
127
 
128
  class TokenResponse(ApiResponse):
129
  data: TokenData
 
130
 
131
 
132
  class ProfileResponse(ApiResponse):
133
  data: UserProfile
 
134
 
135
 
136
  class MessageDataResponse(ApiResponse):
137
  data: MessageResponse
 
138
 
139
 
140
  class SchemaResponse(ApiResponse):
141
  data: UserSchemaResponse
 
142
 
143
 
144
  class SessionListResponse(ApiResponse):
145
  data: list[SessionOut]
 
app/core/banner.py CHANGED
@@ -13,10 +13,10 @@ def get_banner() -> str:
13
  f" {settings.app_name} v{settings.app_version}",
14
  f" Environment: {settings.environment or 'production'}",
15
  ]
16
- if settings.tidb_instance_list:
17
- lines.append(f" TiDB instances: {len(settings.tidb_instance_list)}")
18
  else:
19
- lines.append(" TiDB: not configured")
20
  if settings.redis_url:
21
  lines.append(" Redis: connected")
22
  else:
 
13
  f" {settings.app_name} v{settings.app_version}",
14
  f" Environment: {settings.environment or 'production'}",
15
  ]
16
+ if settings.supabase_url and settings.supabase_service_role_key:
17
+ lines.append(" Supabase: connected")
18
  else:
19
+ lines.append(" Supabase: not configured")
20
  if settings.redis_url:
21
  lines.append(" Redis: connected")
22
  else:
app/core/tidb_manager.py DELETED
@@ -1,341 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import asyncio
4
- import logging
5
- import ssl
6
- from typing import Any, Dict, List, Optional, Set
7
- from urllib.parse import urlparse
8
-
9
- import aiomysql
10
-
11
- logger = logging.getLogger(__name__)
12
-
13
- # MySQL/TiDB error codes that indicate the instance cannot accept more writes:
14
- # 1114 (ER_RECORD_FILE_FULL) – "The table '...' is full"
15
- # 1021 (ER_DISK_FULL) – "Disk full (...)"
16
- # 126 (ER_INDEX_FILE_FULL) – "Index file is full"
17
- # 1877 (ER_TABLE_FULL) – "Table full"
18
- _STORAGE_FULL_CODES: Set[int] = {1114, 1021, 126, 1877}
19
-
20
-
21
- class TiDBWriteError(RuntimeError):
22
- """All TiDB instances are full or unavailable — writes cannot be accepted."""
23
- def __init__(self):
24
- super().__init__("No TiDB instances available for write. All instances are full or unreachable.")
25
-
26
-
27
- def _is_storage_full_error(exc: Exception) -> bool:
28
- if isinstance(exc, aiomysql.MySQLError):
29
- code = exc.args[0] if exc.args else None
30
- if isinstance(code, int) and code in _STORAGE_FULL_CODES:
31
- return True
32
- msg = str(exc).lower()
33
- for keyword in ("table is full", "disk full", "storage full",
34
- "quota exceeded", "out of storage"):
35
- if keyword in msg:
36
- return True
37
- return False
38
-
39
-
40
- class TiDBInstance:
41
- def __init__(self, host: str, port: int, user: str, password: str, database: str,
42
- ssl: Optional[ssl.SSLContext] = None):
43
- self.host = host
44
- self.port = port
45
- self.user = user
46
- self.password = password
47
- self.database = database
48
- self.ssl = ssl
49
- self.pool: Optional[aiomysql.Pool] = None
50
- self.is_full = False
51
- self.is_available = True
52
-
53
- @classmethod
54
- def from_url(cls, url: str,
55
- ssl: Optional[ssl.SSLContext] = None) -> TiDBInstance:
56
- parsed = urlparse(url)
57
- host = parsed.hostname or "localhost"
58
- port = parsed.port or 4000
59
- user = parsed.username or "root"
60
- password = parsed.password or ""
61
- database = parsed.path.lstrip("/") or "test"
62
- return cls(host=host, port=port, user=user, password=password,
63
- database=database, ssl=ssl)
64
-
65
- async def create_pool(self, minsize: int = 1, maxsize: int = 10):
66
- self.pool = await aiomysql.create_pool(
67
- host=self.host,
68
- port=self.port,
69
- user=self.user,
70
- password=self.password,
71
- db=self.database,
72
- minsize=minsize,
73
- maxsize=maxsize,
74
- autocommit=False,
75
- cursorclass=aiomysql.DictCursor,
76
- ssl=self.ssl,
77
- )
78
- self.is_available = True
79
- return self.pool
80
-
81
- async def close_pool(self):
82
- if self.pool:
83
- await self.pool.close()
84
- await self.pool.wait_closed()
85
- self.pool = None
86
-
87
- def __repr__(self) -> str:
88
- user_prefix = self.user.split(".")[0] if "." in self.user else self.user
89
- return f"TiDBInstance({user_prefix}@...)"
90
-
91
-
92
- class TiDBManager:
93
- def __init__(self, instance_urls: List[str],
94
- ssl: Optional[ssl.SSLContext] = None):
95
- self.instances = [TiDBInstance.from_url(url, ssl=ssl) for url in instance_urls]
96
- self._current_instance_index = 0
97
-
98
- @property
99
- def current_instance(self) -> TiDBInstance:
100
- return self.instances[self._current_instance_index]
101
-
102
- @property
103
- def current_index(self) -> int:
104
- return self._current_instance_index
105
-
106
- async def initialize_pools(self, minsize: int = 1, maxsize: int = 10):
107
- for i, inst in enumerate(self.instances, 1):
108
- try:
109
- await inst.create_pool(minsize=minsize, maxsize=maxsize)
110
- logger.info("Connected to TiDB instance %d/%d", i, len(self.instances))
111
- except Exception as exc:
112
- logger.error("Failed to connect to TiDB instance %d/%d: %s", i, len(self.instances), exc)
113
- inst.is_available = False
114
-
115
- async def close_all_pools(self):
116
- for inst in self.instances:
117
- await inst.close_pool()
118
-
119
- async def ensure_tables(self, table_ddls: Dict[str, str]):
120
- for i, inst in enumerate(self.instances, 1):
121
- if not inst.is_available or not inst.pool:
122
- continue
123
- async with inst.pool.acquire() as conn:
124
- async with conn.cursor() as cur:
125
- for table_name, ddl in table_ddls.items():
126
- try:
127
- await cur.execute(
128
- "SELECT 1 FROM information_schema.tables "
129
- "WHERE table_schema = %s AND table_name = %s",
130
- (inst.database, table_name),
131
- )
132
- exists = await cur.fetchone()
133
- if not exists:
134
- for statement in ddl.split(";"):
135
- stmt = statement.strip()
136
- if stmt:
137
- await cur.execute(stmt)
138
- logger.info("Created table '%s' (instance %d/%d)", table_name, i, len(self.instances))
139
- except Exception as exc:
140
- logger.error(
141
- "Failed to create table '%s' (instance %d/%d): %s",
142
- table_name, i, len(self.instances), exc,
143
- )
144
- await conn.commit()
145
-
146
- async def has_table(self, table_name: str) -> bool:
147
- inst = self.current_instance
148
- if not inst.pool:
149
- return False
150
- async with inst.pool.acquire() as conn:
151
- async with conn.cursor() as cur:
152
- await cur.execute(
153
- "SELECT 1 FROM information_schema.tables "
154
- "WHERE table_schema = %s AND table_name = %s",
155
- (inst.database, table_name),
156
- )
157
- return await cur.fetchone() is not None
158
-
159
- async def check_and_rotate(self) -> bool:
160
- if self.current_instance.is_available and not self.current_instance.is_full:
161
- return True
162
-
163
- for i in range(1, len(self.instances)):
164
- idx = (self._current_instance_index + i) % len(self.instances)
165
- inst = self.instances[idx]
166
- if inst.is_available and not inst.is_full:
167
- logger.info(
168
- "Rotating TiDB write target: instance %d -> %d",
169
- self._current_instance_index + 1, idx + 1,
170
- )
171
- self._current_instance_index = idx
172
- return True
173
-
174
- logger.error("All TiDB instances are full or unavailable")
175
- return False
176
-
177
- async def fetchone(self, query: str, params=None) -> Optional[Dict[str, Any]]:
178
- inst = self.current_instance
179
- if not inst.pool:
180
- return None
181
- async with inst.pool.acquire() as conn:
182
- async with conn.cursor() as cur:
183
- await cur.execute(query, params or ())
184
- return await cur.fetchone()
185
-
186
- async def fetchone_all(self, query: str, params=None) -> Optional[Dict[str, Any]]:
187
- async def _query(inst_: TiDBInstance):
188
- try:
189
- async with inst_.pool.acquire() as conn:
190
- async with conn.cursor() as cur:
191
- await cur.execute(query, params or ())
192
- return await cur.fetchone()
193
- except asyncio.CancelledError:
194
- raise
195
- except Exception as exc:
196
- logger.error("Error reading from %s: %s", inst_, exc)
197
- return None
198
-
199
- tasks = [
200
- asyncio.create_task(_query(inst))
201
- for inst in self.instances
202
- if inst.pool and inst.is_available
203
- ]
204
- if not tasks:
205
- return None
206
-
207
- for coro in asyncio.as_completed(tasks):
208
- result = await coro
209
- if result is not None:
210
- for t in tasks:
211
- if not t.done():
212
- t.cancel()
213
- return result
214
- return None
215
-
216
- async def fetchall(self, query: str, params=None) -> List[Dict[str, Any]]:
217
- inst = self.current_instance
218
- if not inst.pool:
219
- return []
220
- async with inst.pool.acquire() as conn:
221
- async with conn.cursor() as cur:
222
- await cur.execute(query, params or ())
223
- return await cur.fetchall()
224
-
225
- async def fetchall_all(self, query: str, params=None) -> List[Dict[str, Any]]:
226
- async def _query(inst_: TiDBInstance):
227
- try:
228
- async with inst_.pool.acquire() as conn:
229
- async with conn.cursor() as cur:
230
- await cur.execute(query, params or ())
231
- return await cur.fetchall()
232
- except asyncio.CancelledError:
233
- raise
234
- except Exception as exc:
235
- logger.error("Error reading from %s: %s", inst_, exc)
236
- return []
237
-
238
- tasks = [
239
- asyncio.create_task(_query(inst))
240
- for inst in self.instances
241
- if inst.pool and inst.is_available
242
- ]
243
- if not tasks:
244
- return []
245
-
246
- all_rows = await asyncio.gather(*tasks)
247
- seen = set()
248
- merged = []
249
- for rows in all_rows:
250
- for row in rows:
251
- key = tuple(row.items())
252
- if key not in seen:
253
- seen.add(key)
254
- merged.append(row)
255
- return merged
256
-
257
- async def _try_write(self, query: str, params) -> int:
258
- inst = self.current_instance
259
- if not inst.pool:
260
- raise RuntimeError(f"TiDB pool not initialized for {inst}")
261
- async with inst.pool.acquire() as conn:
262
- async with conn.cursor(aiomysql.DictCursor) as cur:
263
- await cur.execute(query, params or ())
264
- await conn.commit()
265
- return cur.lastrowid if cur.lastrowid is not None else 0
266
-
267
- async def execute(self, query: str, params=None) -> int:
268
- ok = await self.check_and_rotate()
269
- if not ok:
270
- raise TiDBWriteError()
271
- try:
272
- return await self._try_write(query, params)
273
- except Exception as exc:
274
- if _is_storage_full_error(exc):
275
- logger.warning("TiDB write target is full, rotating...")
276
- self.current_instance.is_full = True
277
- ok = await self.check_and_rotate()
278
- if not ok:
279
- raise TiDBWriteError() from exc
280
- try:
281
- return await self._try_write(query, params)
282
- except Exception:
283
- raise TiDBWriteError() from exc
284
- raise
285
-
286
- async def execute_on_all(self, query: str, params=None):
287
- for i, inst in enumerate(self.instances, 1):
288
- if not inst.is_available or not inst.pool:
289
- continue
290
- try:
291
- async with inst.pool.acquire() as conn:
292
- async with conn.cursor(aiomysql.DictCursor) as cur:
293
- await cur.execute(query, params or ())
294
- await conn.commit()
295
- except Exception as exc:
296
- logger.error("Error executing on instance %d/%d: %s", i, len(self.instances), exc)
297
-
298
- async def insert_and_get_id(self, query: str, params=None) -> int:
299
- ok = await self.check_and_rotate()
300
- if not ok:
301
- raise TiDBWriteError()
302
- try:
303
- return await self._try_write(query, params)
304
- except Exception as exc:
305
- if _is_storage_full_error(exc):
306
- logger.warning("TiDB write target is full, rotating...")
307
- self.current_instance.is_full = True
308
- ok = await self.check_and_rotate()
309
- if not ok:
310
- raise TiDBWriteError() from exc
311
- try:
312
- return await self._try_write(query, params)
313
- except Exception:
314
- raise TiDBWriteError() from exc
315
- raise
316
-
317
- def get_instances_summary(self) -> List[Dict[str, Any]]:
318
- return [
319
- {
320
- "host": inst.host,
321
- "port": inst.port,
322
- "database": inst.database,
323
- "is_available": inst.is_available,
324
- "is_full": inst.is_full,
325
- "is_current": i == self._current_instance_index,
326
- }
327
- for i, inst in enumerate(self.instances)
328
- ]
329
-
330
-
331
- _tidb_manager: Optional[TiDBManager] = None
332
-
333
-
334
- def get_tidb_manager() -> Optional[TiDBManager]:
335
- global _tidb_manager
336
- return _tidb_manager
337
-
338
-
339
- def set_tidb_manager(mgr: TiDBManager):
340
- global _tidb_manager
341
- _tidb_manager = mgr
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/core/vector_store/__init__.py CHANGED
@@ -1,17 +1,8 @@
1
- from __future__ import annotations
2
-
3
- from app.core.vector_store.deps import (
4
- get_vs_db,
5
- get_vs_manager,
6
- init_vector_store_db,
7
- set_vs_manager,
8
- )
9
  from app.core.vector_store.models import VectorStoreIndex
10
 
11
  __all__ = [
12
  "VectorStoreIndex",
13
  "init_vector_store_db",
14
  "get_vs_db",
15
- "set_vs_manager",
16
- "get_vs_manager",
17
  ]
 
1
+ from app.core.vector_store.deps import get_vs_db, init_vector_store_db
 
 
 
 
 
 
 
2
  from app.core.vector_store.models import VectorStoreIndex
3
 
4
  __all__ = [
5
  "VectorStoreIndex",
6
  "init_vector_store_db",
7
  "get_vs_db",
 
 
8
  ]
app/core/vector_store/deps.py CHANGED
@@ -4,49 +4,21 @@ import logging
4
  from typing import Optional
5
 
6
  from app.config import get_settings
7
- from app.core.tidb_manager import TiDBManager, get_tidb_manager
8
 
9
  logger = logging.getLogger(__name__)
10
 
11
- _settings = get_settings()
12
-
13
- VS_TABLES: dict[str, str] = {
14
- "vector_store_index": """
15
- CREATE TABLE IF NOT EXISTS vector_store_index (
16
- store_id VARCHAR(36) PRIMARY KEY,
17
- name VARCHAR(255) NOT NULL,
18
- path VARCHAR(1024) NOT NULL,
19
- description VARCHAR(1024) DEFAULT '',
20
- metadata_json TEXT,
21
- created_at VARCHAR(64) NOT NULL
22
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
23
- """,
24
- }
25
-
26
 
27
  async def init_vector_store_db():
28
- mgr = get_tidb_manager()
29
- if mgr is None:
30
- logger.error("Cannot init vector store DB: TiDB manager not initialized")
31
  return
32
- await mgr.ensure_tables(VS_TABLES)
33
- logger.info("Vector store tables ensured on all TiDB instances")
34
-
35
-
36
- async def get_vs_db() -> TiDBManager:
37
- mgr = get_tidb_manager()
38
- if mgr is None:
39
- raise RuntimeError("TiDB manager not initialized")
40
- return mgr
41
-
42
-
43
- _tidb_vs_manager: Optional[TiDBManager] = None
44
-
45
-
46
- def set_vs_manager(mgr: TiDBManager):
47
- global _tidb_vs_manager
48
- _tidb_vs_manager = mgr
49
 
50
 
51
- def get_vs_manager() -> Optional[TiDBManager]:
52
- return _tidb_vs_manager
 
 
 
 
4
  from typing import Optional
5
 
6
  from app.config import get_settings
7
+ from app.services.supabase import SupabaseClient, get_supabase_client
8
 
9
  logger = logging.getLogger(__name__)
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  async def init_vector_store_db():
13
+ client = get_supabase_client()
14
+ if client is None:
15
+ logger.error("Cannot init vector store DB: Supabase not initialized")
16
  return
17
+ logger.info("Vector store DB initialized via Supabase")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
 
20
+ async def get_vs_db() -> SupabaseClient:
21
+ client = get_supabase_client()
22
+ if client is None:
23
+ raise RuntimeError("Supabase client not initialized")
24
+ return client
app/models/schemas.py CHANGED
@@ -568,7 +568,6 @@ class VectorStoreCreate(BaseModel):
568
  class VectorStoreResponse(BaseModel):
569
  success: bool
570
  vector_store_id: str
571
- app_id: str
572
  name: str
573
  description: Optional[str] = None
574
  embedding_dimension: int
 
568
  class VectorStoreResponse(BaseModel):
569
  success: bool
570
  vector_store_id: str
 
571
  name: str
572
  description: Optional[str] = None
573
  embedding_dimension: int
app/services/auth_service.py CHANGED
@@ -11,7 +11,6 @@ from argon2.exceptions import VerifyMismatchError
11
  from fastapi import HTTPException, Request
12
 
13
  from app.config import get_settings
14
- from app.core.auth.deps import _row_to_refresh_session, _row_to_user, _serialize_dt
15
  from app.core.auth.models import RefreshSession, User, _uuid
16
  from app.core.auth.models import _utcnow as _now
17
  from app.core.auth.schemas import (
@@ -24,7 +23,7 @@ from app.core.auth.schemas import (
24
  UpdateProfileSchema,
25
  UserProfile,
26
  )
27
- from app.core.tidb_manager import TiDBManager
28
 
29
  logger = logging.getLogger("auth_service")
30
  _settings = get_settings()
@@ -38,50 +37,44 @@ def _token_key(raw: str) -> str:
38
  class AuthService:
39
 
40
  @staticmethod
41
- async def register(db: TiDBManager, schema: RegisterSchema) -> User:
42
- existing = await db.fetchone_all(
43
- "SELECT id FROM users WHERE email = %s AND deleted_at IS NULL",
44
- (schema.email,),
45
- )
46
  if existing:
47
  raise HTTPException(status_code=409, detail="Email already registered")
48
 
49
  if schema.username:
50
- existing = await db.fetchone_all(
51
- "SELECT id FROM users WHERE username = %s AND deleted_at IS NULL",
52
- (schema.username,),
53
- )
54
  if existing:
55
  raise HTTPException(status_code=409, detail="Username already taken")
56
 
57
  user_id = _uuid()
58
  now = _now()
59
- await db.insert_and_get_id(
60
- "INSERT INTO users (id, email, username, full_name, password_hash, created_at, updated_at) "
61
- "VALUES (%s, %s, %s, %s, %s, %s, %s)",
62
- (user_id, schema.email, schema.username, schema.full_name, ph.hash(schema.password), now, now),
 
 
 
 
63
  )
 
64
 
65
- role_row = await db.fetchone("SELECT id FROM roles WHERE name = 'User'")
66
  if role_row:
67
- await db.execute(
68
- "INSERT INTO user_roles (user_id, role_id) VALUES (%s, %s)",
69
- (user_id, role_row["id"]),
70
- )
71
 
72
- user_row = await db.fetchone("SELECT * FROM users WHERE id = %s", (user_id,))
73
- return _row_to_user(user_row)
74
 
75
  @staticmethod
76
- async def login(db: TiDBManager, request: Request, schema: LoginSchema) -> TokenData:
77
- row = await db.fetchone_all(
78
- "SELECT * FROM users WHERE email = %s AND deleted_at IS NULL",
79
- (schema.email,),
80
- )
81
- if not row:
82
- raise HTTPException(status_code=401, detail="Incorrect email or password")
83
 
84
- user = _row_to_user(row)
 
 
85
 
86
  if user.locked_until and user.locked_until > _now():
87
  raise HTTPException(
@@ -93,148 +86,118 @@ class AuthService:
93
  user.failed_login_attempts += 1
94
  if user.failed_login_attempts >= _settings.max_login_attempts:
95
  user.locked_until = _now() + timedelta(minutes=_settings.lockout_minutes)
96
- await db.execute(
97
- "UPDATE users SET failed_login_attempts = %s, locked_until = %s WHERE id = %s",
98
- (user.failed_login_attempts, _serialize_dt(user.locked_until), user.id),
99
- )
100
  raise HTTPException(status_code=401, detail="Incorrect email or password")
101
 
102
  now = _now()
103
- await db.execute(
104
- "UPDATE users SET failed_login_attempts = 0, locked_until = NULL, last_login = %s "
105
- "WHERE id = %s",
106
- (now, user.id),
107
- )
108
 
109
  access_token = AuthService._create_access_token(user.id)
110
  raw_refresh, refresh_hash, token_key, expires_at = AuthService._create_refresh_token()
111
 
112
  session_id = _uuid()
113
- await db.execute(
114
- "INSERT INTO refresh_sessions (id, user_id, token_key, token_hash, device_info, "
115
- "ip_address, expires_at, created_at, updated_at) "
116
- "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)",
117
- (
118
- session_id, user.id, token_key, refresh_hash,
119
- request.headers.get("User-Agent", "Unknown"),
120
- request.client.host if request.client else "Unknown",
121
- _serialize_dt(expires_at), now, now,
122
- ),
123
  )
 
124
 
125
  return TokenData(access_token=access_token, refresh_token=raw_refresh)
126
 
127
  @staticmethod
128
- async def refresh(db: TiDBManager, raw_refresh_token: str) -> TokenData:
 
129
  key = _token_key(raw_refresh_token)
130
 
131
- row = await db.fetchone_all(
132
- "SELECT * FROM refresh_sessions WHERE token_key = %s AND revoked_at IS NULL "
133
- "AND expires_at > %s",
134
- (key, _serialize_dt(_now())),
135
- )
136
- if not row:
137
- comp = await db.fetchone_all(
138
- "SELECT * FROM refresh_sessions WHERE token_key = %s", (key,),
139
- )
140
- if comp and comp.get("revoked_at") is not None:
141
- now = _serialize_dt(_now())
142
- await db.execute_on_all(
143
- "UPDATE refresh_sessions SET revoked_at = %s "
144
- "WHERE user_id = %s AND revoked_at IS NULL",
145
- (now, comp["user_id"]),
146
- )
147
  raise HTTPException(
148
  status_code=401,
149
  detail="Session compromised. All sessions revoked. Please login again.",
150
  )
151
  raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
152
 
153
- session = _row_to_refresh_session(row)
154
-
155
  if not AuthService._verify_token(raw_refresh_token, session.token_hash):
156
  raise HTTPException(status_code=401, detail="Invalid refresh token")
157
 
158
- user_row = await db.fetchone_all(
159
- "SELECT * FROM users WHERE id = %s AND deleted_at IS NULL AND is_active = 1",
160
- (session.user_id,),
161
- )
162
- if not user_row:
163
  raise HTTPException(status_code=401, detail="User not found or inactive")
164
- user = _row_to_user(user_row)
165
 
166
- now = _serialize_dt(_now())
167
- await db.execute(
168
- "UPDATE refresh_sessions SET revoked_at = %s WHERE id = %s",
169
- (now, session.id),
170
- )
171
 
172
  new_access = AuthService._create_access_token(user.id)
173
  new_raw_refresh, new_hash, new_key, new_expires = AuthService._create_refresh_token()
174
 
175
  new_session_id = _uuid()
176
- await db.execute(
177
- "INSERT INTO refresh_sessions (id, user_id, token_key, token_hash, device_info, "
178
- "ip_address, expires_at, created_at, updated_at) "
179
- "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)",
180
- (
181
- new_session_id, user.id, new_key, new_hash,
182
- session.device_info, session.ip_address,
183
- _serialize_dt(new_expires), _serialize_dt(_now()), _serialize_dt(_now()),
184
- ),
 
185
  )
 
186
 
187
- await db.execute(
188
- "UPDATE users SET last_login = %s WHERE id = %s",
189
- (_serialize_dt(_now()), user.id),
190
- )
191
 
192
  return TokenData(access_token=new_access, refresh_token=new_raw_refresh)
193
 
194
  @staticmethod
195
- async def logout(db: TiDBManager, user: User, raw_refresh_token: str):
 
196
  key = _token_key(raw_refresh_token)
197
- row = await db.fetchone_all(
198
- "SELECT id FROM refresh_sessions WHERE token_key = %s "
199
- "AND user_id = %s AND revoked_at IS NULL",
200
- (key, user.id),
201
- )
202
- if not row:
203
  raise HTTPException(status_code=404, detail="Session not found")
204
- await db.execute(
205
- "UPDATE refresh_sessions SET revoked_at = %s WHERE id = %s",
206
- (_serialize_dt(_now()), row["id"]),
207
- )
208
 
209
  @staticmethod
210
- async def logout_all(db: TiDBManager, user: User):
211
- now = _serialize_dt(_now())
212
- await db.execute(
213
- "UPDATE refresh_sessions SET revoked_at = %s "
214
- "WHERE user_id = %s AND revoked_at IS NULL",
215
- (now, user.id),
216
- )
217
 
218
  @staticmethod
219
- async def change_password(db: TiDBManager, user: User, schema: ChangePasswordSchema):
220
  if not AuthService._verify_password(schema.current_password, user.password_hash):
221
  raise HTTPException(status_code=400, detail="Incorrect current password")
222
- now = _serialize_dt(_now())
223
- await db.execute(
224
- "UPDATE users SET password_hash = %s, password_changed_at = %s WHERE id = %s",
225
- (ph.hash(schema.new_password), now, user.id),
226
- )
 
227
 
228
  @staticmethod
229
- async def forgot_password(db: TiDBManager, schema: ForgotPasswordSchema):
230
- row = await db.fetchone_all(
231
- "SELECT id FROM users WHERE email = %s AND deleted_at IS NULL",
232
- (schema.email,),
233
- )
234
- if row:
235
  reset_token = jwt.encode(
236
  {
237
- "sub": row["id"],
238
  "type": "reset_password",
239
  "exp": _now() + timedelta(hours=1),
240
  },
@@ -244,7 +207,7 @@ class AuthService:
244
  logger.info("Password reset token for %s: %s", schema.email, reset_token)
245
 
246
  @staticmethod
247
- async def reset_password(db: TiDBManager, schema: ResetPasswordSchema):
248
  try:
249
  payload = jwt.decode(
250
  schema.token,
@@ -257,90 +220,63 @@ class AuthService:
257
  except jwt.PyJWTError:
258
  raise HTTPException(status_code=400, detail="Invalid or expired reset token")
259
 
260
- row = await db.fetchone_all("SELECT id FROM users WHERE id = %s", (user_id,))
261
- if not row:
 
262
  raise HTTPException(status_code=404, detail="User not found")
263
 
264
- now = _serialize_dt(_now())
265
- await db.execute(
266
- "UPDATE users SET password_hash = %s, password_changed_at = %s WHERE id = %s",
267
- (ph.hash(schema.new_password), now, user_id),
268
- )
269
- await db.execute(
270
- "UPDATE refresh_sessions SET revoked_at = %s "
271
- "WHERE user_id = %s AND revoked_at IS NULL",
272
- (now, user_id),
273
- )
274
 
275
  @staticmethod
276
- async def update_profile(db: TiDBManager, user: User, schema: UpdateProfileSchema) -> User:
 
277
  if schema.username is not None:
278
- existing = await db.fetchone_all(
279
- "SELECT id FROM users WHERE username = %s AND id != %s AND deleted_at IS NULL",
280
- (schema.username, user.id),
281
- )
282
- if existing:
283
  raise HTTPException(status_code=409, detail="Username already taken")
284
- await db.execute("UPDATE users SET username = %s WHERE id = %s", (schema.username, user.id))
285
  if schema.full_name is not None:
286
- await db.execute(
287
- "UPDATE users SET full_name = %s WHERE id = %s",
288
- (schema.full_name, user.id),
289
- )
290
- row = await db.fetchone("SELECT * FROM users WHERE id = %s", (user.id,))
291
- return _row_to_user(row)
292
 
293
  @staticmethod
294
- async def soft_delete(db: TiDBManager, user: User):
295
- now = _serialize_dt(_now())
296
- await db.execute(
297
- "UPDATE users SET deleted_at = %s, is_active = 0 WHERE id = %s",
298
- (now, user.id),
299
- )
300
- await db.execute(
301
- "UPDATE refresh_sessions SET revoked_at = %s "
302
- "WHERE user_id = %s AND revoked_at IS NULL",
303
- (now, user.id),
304
- )
305
 
306
  @staticmethod
307
- async def list_sessions(db: TiDBManager, user: User) -> list[RefreshSession]:
308
- rows = await db.fetchall_all(
309
- "SELECT * FROM refresh_sessions WHERE user_id = %s AND revoked_at IS NULL "
310
- "ORDER BY created_at DESC",
311
- (user.id,),
312
- )
313
- return [_row_to_refresh_session(r) for r in rows]
314
 
315
  @staticmethod
316
- async def revoke_session(db: TiDBManager, user: User, session_id: str):
317
- row = await db.fetchone_all(
318
- "SELECT id FROM refresh_sessions WHERE id = %s AND user_id = %s AND revoked_at IS NULL",
319
- (session_id, user.id),
320
- )
321
- if not row:
322
  raise HTTPException(status_code=404, detail="Session not found")
323
- await db.execute(
324
- "UPDATE refresh_sessions SET revoked_at = %s WHERE id = %s",
325
- (_serialize_dt(_now()), session_id),
326
- )
327
 
328
  @staticmethod
329
- async def cleanup_expired_sessions(db: TiDBManager):
330
- rows = await db.fetchall_all(
331
- "SELECT * FROM refresh_sessions WHERE expires_at < %s",
332
- (_serialize_dt(_now()),),
333
- )
334
- count = 0
335
- for row in rows:
336
- if row.get("revoked_at") is None:
337
- await db.execute(
338
- "UPDATE refresh_sessions SET revoked_at = %s WHERE id = %s",
339
- (_serialize_dt(_now()), row["id"]),
340
- )
341
- count += 1
342
- if count:
343
- logger.info("Cleaned up %s expired sessions", count)
344
 
345
  @staticmethod
346
  def _create_access_token(user_id: str) -> str:
@@ -379,17 +315,12 @@ class AuthService:
379
  return False
380
 
381
  @staticmethod
382
- async def user_to_profile(db: TiDBManager, user: User) -> UserProfile:
383
- roles = []
384
  if db:
385
  try:
386
- rows = await db.fetchall_all(
387
- "SELECT r.name FROM roles r "
388
- "JOIN user_roles ur ON ur.role_id = r.id "
389
- "WHERE ur.user_id = %s",
390
- (user.id,),
391
- )
392
- roles = [r["name"] for r in rows]
393
  except Exception:
394
  pass
395
  return UserProfile(
 
11
  from fastapi import HTTPException, Request
12
 
13
  from app.config import get_settings
 
14
  from app.core.auth.models import RefreshSession, User, _uuid
15
  from app.core.auth.models import _utcnow as _now
16
  from app.core.auth.schemas import (
 
23
  UpdateProfileSchema,
24
  UserProfile,
25
  )
26
+ from app.services.supabase import AuthRepository, SupabaseClient
27
 
28
  logger = logging.getLogger("auth_service")
29
  _settings = get_settings()
 
37
  class AuthService:
38
 
39
  @staticmethod
40
+ async def register(db: SupabaseClient, schema: RegisterSchema) -> User:
41
+ repo = AuthRepository(db)
42
+
43
+ existing = await repo.find_user_by_email(schema.email)
 
44
  if existing:
45
  raise HTTPException(status_code=409, detail="Email already registered")
46
 
47
  if schema.username:
48
+ existing = await repo.find_user_by_username(schema.username)
 
 
 
49
  if existing:
50
  raise HTTPException(status_code=409, detail="Username already taken")
51
 
52
  user_id = _uuid()
53
  now = _now()
54
+ user = User(
55
+ id=user_id,
56
+ email=schema.email,
57
+ username=schema.username,
58
+ full_name=schema.full_name,
59
+ password_hash=ph.hash(schema.password),
60
+ created_at=now,
61
+ updated_at=now,
62
  )
63
+ await repo.create_user(user)
64
 
65
+ role_row = await repo.get_role_by_name("User")
66
  if role_row:
67
+ await repo.assign_role(user_id, role_row["id"])
 
 
 
68
 
69
+ return await repo.find_user_by_id(user_id)
 
70
 
71
  @staticmethod
72
+ async def login(db: SupabaseClient, request: Request, schema: LoginSchema) -> TokenData:
73
+ repo = AuthRepository(db)
 
 
 
 
 
74
 
75
+ user = await repo.find_user_by_email(schema.email)
76
+ if not user:
77
+ raise HTTPException(status_code=401, detail="Incorrect email or password")
78
 
79
  if user.locked_until and user.locked_until > _now():
80
  raise HTTPException(
 
86
  user.failed_login_attempts += 1
87
  if user.failed_login_attempts >= _settings.max_login_attempts:
88
  user.locked_until = _now() + timedelta(minutes=_settings.lockout_minutes)
89
+ await repo.update_user(user.id, {
90
+ "failed_login_attempts": user.failed_login_attempts,
91
+ "locked_until": user.locked_until,
92
+ })
93
  raise HTTPException(status_code=401, detail="Incorrect email or password")
94
 
95
  now = _now()
96
+ await repo.update_user(user.id, {
97
+ "failed_login_attempts": 0,
98
+ "locked_until": None,
99
+ "last_login": now,
100
+ })
101
 
102
  access_token = AuthService._create_access_token(user.id)
103
  raw_refresh, refresh_hash, token_key, expires_at = AuthService._create_refresh_token()
104
 
105
  session_id = _uuid()
106
+ session = RefreshSession(
107
+ id=session_id,
108
+ user_id=user.id,
109
+ token_key=token_key,
110
+ token_hash=refresh_hash,
111
+ device_info=request.headers.get("User-Agent", "Unknown"),
112
+ ip_address=request.client.host if request.client else "Unknown",
113
+ expires_at=expires_at,
114
+ created_at=now,
115
+ updated_at=now,
116
  )
117
+ await repo.create_refresh_session(session)
118
 
119
  return TokenData(access_token=access_token, refresh_token=raw_refresh)
120
 
121
  @staticmethod
122
+ async def refresh(db: SupabaseClient, raw_refresh_token: str) -> TokenData:
123
+ repo = AuthRepository(db)
124
  key = _token_key(raw_refresh_token)
125
 
126
+ session = await repo.find_valid_session(key)
127
+ if not session:
128
+ comp = await repo.find_session_by_token_key(key)
129
+ if comp and comp.revoked_at is not None:
130
+ await repo.revoke_all_user_sessions(comp.user_id)
 
 
 
 
 
 
 
 
 
 
 
131
  raise HTTPException(
132
  status_code=401,
133
  detail="Session compromised. All sessions revoked. Please login again.",
134
  )
135
  raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
136
 
 
 
137
  if not AuthService._verify_token(raw_refresh_token, session.token_hash):
138
  raise HTTPException(status_code=401, detail="Invalid refresh token")
139
 
140
+ user = await repo.find_user_by_id(session.user_id)
141
+ if not user or not user.is_active:
 
 
 
142
  raise HTTPException(status_code=401, detail="User not found or inactive")
 
143
 
144
+ now = _now()
145
+ await repo.revoke_session(session.id)
 
 
 
146
 
147
  new_access = AuthService._create_access_token(user.id)
148
  new_raw_refresh, new_hash, new_key, new_expires = AuthService._create_refresh_token()
149
 
150
  new_session_id = _uuid()
151
+ new_session = RefreshSession(
152
+ id=new_session_id,
153
+ user_id=user.id,
154
+ token_key=new_key,
155
+ token_hash=new_hash,
156
+ device_info=session.device_info,
157
+ ip_address=session.ip_address,
158
+ expires_at=new_expires,
159
+ created_at=now,
160
+ updated_at=now,
161
  )
162
+ await repo.create_refresh_session(new_session)
163
 
164
+ await repo.update_user(user.id, {"last_login": now})
 
 
 
165
 
166
  return TokenData(access_token=new_access, refresh_token=new_raw_refresh)
167
 
168
  @staticmethod
169
+ async def logout(db: SupabaseClient, user: User, raw_refresh_token: str):
170
+ repo = AuthRepository(db)
171
  key = _token_key(raw_refresh_token)
172
+ session = await repo.find_session_by_token_key(key)
173
+ if not session or session.user_id != user.id:
 
 
 
 
174
  raise HTTPException(status_code=404, detail="Session not found")
175
+ await repo.revoke_session(session.id)
 
 
 
176
 
177
  @staticmethod
178
+ async def logout_all(db: SupabaseClient, user: User):
179
+ repo = AuthRepository(db)
180
+ await repo.revoke_all_user_sessions(user.id)
 
 
 
 
181
 
182
  @staticmethod
183
+ async def change_password(db: SupabaseClient, user: User, schema: ChangePasswordSchema):
184
  if not AuthService._verify_password(schema.current_password, user.password_hash):
185
  raise HTTPException(status_code=400, detail="Incorrect current password")
186
+ repo = AuthRepository(db)
187
+ now = _now()
188
+ await repo.update_user(user.id, {
189
+ "password_hash": ph.hash(schema.new_password),
190
+ "password_changed_at": now,
191
+ })
192
 
193
  @staticmethod
194
+ async def forgot_password(db: SupabaseClient, schema: ForgotPasswordSchema):
195
+ repo = AuthRepository(db)
196
+ user = await repo.find_user_by_email(schema.email)
197
+ if user:
 
 
198
  reset_token = jwt.encode(
199
  {
200
+ "sub": user.id,
201
  "type": "reset_password",
202
  "exp": _now() + timedelta(hours=1),
203
  },
 
207
  logger.info("Password reset token for %s: %s", schema.email, reset_token)
208
 
209
  @staticmethod
210
+ async def reset_password(db: SupabaseClient, schema: ResetPasswordSchema):
211
  try:
212
  payload = jwt.decode(
213
  schema.token,
 
220
  except jwt.PyJWTError:
221
  raise HTTPException(status_code=400, detail="Invalid or expired reset token")
222
 
223
+ repo = AuthRepository(db)
224
+ user = await repo.find_user_by_id(user_id)
225
+ if not user:
226
  raise HTTPException(status_code=404, detail="User not found")
227
 
228
+ now = _now()
229
+ await repo.update_user(user_id, {
230
+ "password_hash": ph.hash(schema.new_password),
231
+ "password_changed_at": now,
232
+ })
233
+ await repo.revoke_all_user_sessions(user_id)
 
 
 
 
234
 
235
  @staticmethod
236
+ async def update_profile(db: SupabaseClient, user: User, schema: UpdateProfileSchema) -> User:
237
+ repo = AuthRepository(db)
238
  if schema.username is not None:
239
+ existing = await repo.find_user_by_username(schema.username)
240
+ if existing and existing.id != user.id:
 
 
 
241
  raise HTTPException(status_code=409, detail="Username already taken")
242
+ await repo.update_user(user.id, {"username": schema.username})
243
  if schema.full_name is not None:
244
+ await repo.update_user(user.id, {"full_name": schema.full_name})
245
+ return await repo.find_user_by_id(user.id)
 
 
 
 
246
 
247
  @staticmethod
248
+ async def soft_delete(db: SupabaseClient, user: User):
249
+ repo = AuthRepository(db)
250
+ now = _now()
251
+ await repo.update_user(user.id, {
252
+ "deleted_at": now,
253
+ "is_active": False,
254
+ })
255
+ await repo.revoke_all_user_sessions(user.id)
 
 
 
256
 
257
  @staticmethod
258
+ async def list_sessions(db: SupabaseClient, user: User) -> list[RefreshSession]:
259
+ repo = AuthRepository(db)
260
+ return await repo.find_user_sessions(user.id)
 
 
 
 
261
 
262
  @staticmethod
263
+ async def revoke_session(db: SupabaseClient, user: User, session_id: str):
264
+ repo = AuthRepository(db)
265
+ sessions = await repo.find_user_sessions(user.id)
266
+ target = [s for s in sessions if s.id == session_id]
267
+ if not target:
 
268
  raise HTTPException(status_code=404, detail="Session not found")
269
+ await repo.revoke_session(session_id)
 
 
 
270
 
271
  @staticmethod
272
+ async def cleanup_expired_sessions():
273
+ from app.services.supabase import get_supabase_client
274
+ client = get_supabase_client()
275
+ if client:
276
+ repo = AuthRepository(client)
277
+ count = await repo.cleanup_expired_sessions()
278
+ if count:
279
+ logger.info("Cleaned up %s expired sessions", count)
 
 
 
 
 
 
 
280
 
281
  @staticmethod
282
  def _create_access_token(user_id: str) -> str:
 
315
  return False
316
 
317
  @staticmethod
318
+ async def user_to_profile(db: SupabaseClient, user: User) -> UserProfile:
319
+ roles: list[str] = []
320
  if db:
321
  try:
322
+ repo = AuthRepository(db)
323
+ roles = await repo.get_user_roles(user.id)
 
 
 
 
 
324
  except Exception:
325
  pass
326
  return UserProfile(
app/services/supabase/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.services.supabase.client import SupabaseClient, get_supabase_client, set_supabase_client
2
+ from app.services.supabase.repositories import (
3
+ AuthRepository,
4
+ VectorStoreRepository,
5
+ )
6
+
7
+ __all__ = [
8
+ "SupabaseClient",
9
+ "get_supabase_client",
10
+ "set_supabase_client",
11
+ "AuthRepository",
12
+ "VectorStoreRepository",
13
+ ]
app/services/supabase/client.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from supabase import create_client, Client
8
+
9
+ from app.config import get_settings
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class SupabaseClient:
15
+ def __init__(self, url: str, service_key: str):
16
+ self._url = url
17
+ self._service_key = service_key
18
+ self._client: Optional[Client] = None
19
+
20
+ async def initialize(self):
21
+ self._client = await asyncio.to_thread(create_client, self._url, self._service_key)
22
+ logger.info("Supabase client initialized")
23
+
24
+ @property
25
+ def client(self) -> Client:
26
+ if self._client is None:
27
+ raise RuntimeError("Supabase client not initialized. Call initialize() first.")
28
+ return self._client
29
+
30
+ async def close(self):
31
+ self._client = None
32
+ set_supabase_client(None)
33
+ logger.info("Supabase client closed")
34
+
35
+ # --- Table operations ---
36
+
37
+ async def select(
38
+ self, table: str, columns: str = "*",
39
+ eq: Optional[tuple[str, Any]] = None,
40
+ order: Optional[tuple[str, bool]] = None,
41
+ limit: Optional[int] = None,
42
+ offset: Optional[int] = None,
43
+ ) -> List[Dict[str, Any]]:
44
+ query = self.client.table(table).select(columns)
45
+ if eq:
46
+ query = query.eq(eq[0], eq[1])
47
+ if order:
48
+ query = query.order(order[0], desc=order[1])
49
+ if limit:
50
+ query = query.limit(limit)
51
+ if offset:
52
+ query = query.offset(offset)
53
+ result = await asyncio.to_thread(query.execute)
54
+ return result.data if result else []
55
+
56
+ async def select_in(
57
+ self, table: str, column: str, values: List[Any],
58
+ columns: str = "*",
59
+ ) -> List[Dict[str, Any]]:
60
+ query = self.client.table(table).select(columns).in_(column, values)
61
+ result = await asyncio.to_thread(query.execute)
62
+ return result.data if result else []
63
+
64
+ async def insert(
65
+ self, table: str, data: Dict[str, Any],
66
+ returning: str = "representation",
67
+ ) -> Optional[Dict[str, Any]]:
68
+ query = self.client.table(table).insert(data, returning=returning)
69
+ result = await asyncio.to_thread(query.execute)
70
+ if result and result.data:
71
+ return result.data[0]
72
+ return None
73
+
74
+ async def update(
75
+ self, table: str, column: str, value: Any,
76
+ data: Dict[str, Any],
77
+ ) -> List[Dict[str, Any]]:
78
+ query = self.client.table(table).update(data).eq(column, value)
79
+ result = await asyncio.to_thread(query.execute)
80
+ return result.data if result else []
81
+
82
+ async def delete(
83
+ self, table: str, column: str, value: Any,
84
+ ) -> List[Dict[str, Any]]:
85
+ query = self.client.table(table).delete().eq(column, value)
86
+ result = await asyncio.to_thread(query.execute)
87
+ return result.data if result else []
88
+
89
+ async def upsert(
90
+ self, table: str, data: Dict[str, Any],
91
+ on_conflict: str = "id",
92
+ ) -> Optional[Dict[str, Any]]:
93
+ query = self.client.table(table).upsert(data, on_conflict=on_conflict)
94
+ result = await asyncio.to_thread(query.execute)
95
+ if result and result.data:
96
+ return result.data[0]
97
+ return None
98
+
99
+ async def execute_sql(self, sql: str) -> Any:
100
+ result = await asyncio.to_thread(
101
+ self.client.rpc,
102
+ "exec_sql",
103
+ {"sql": sql},
104
+ )
105
+ return result
106
+
107
+ async def find_one(
108
+ self, table: str, column: str, value: Any,
109
+ columns: str = "*",
110
+ ) -> Optional[Dict[str, Any]]:
111
+ rows = await self.select(table, columns=columns, eq=(column, value), limit=1)
112
+ return rows[0] if rows else None
113
+
114
+ async def find_all(
115
+ self, table: str, column: str, value: Any,
116
+ columns: str = "*",
117
+ ) -> List[Dict[str, Any]]:
118
+ return await self.select(table, columns=columns, eq=(column, value))
119
+
120
+ async def count(
121
+ self, table: str, column: str, value: Any,
122
+ ) -> int:
123
+ query = self.client.table(table).select("*", count="exact").eq(column, value)
124
+ result = await asyncio.to_thread(query.execute)
125
+ return result.count if hasattr(result, "count") and result.count else 0
126
+
127
+
128
+ _client_instance: Optional[SupabaseClient] = None
129
+
130
+
131
+ def get_supabase_client() -> Optional[SupabaseClient]:
132
+ global _client_instance
133
+ return _client_instance
134
+
135
+
136
+ def set_supabase_client(client: SupabaseClient):
137
+ global _client_instance
138
+ _client_instance = client
139
+
140
+
141
+ async def create_supabase_client() -> SupabaseClient:
142
+ settings = get_settings()
143
+ url = settings.supabase_url
144
+ key = settings.supabase_service_role_key
145
+ if not url or not key:
146
+ logger.warning("Supabase not configured (SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY missing)")
147
+ return None
148
+ client = SupabaseClient(url, key)
149
+ await client.initialize()
150
+ set_supabase_client(client)
151
+ return client
app/services/supabase/repositories.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ from datetime import datetime, timezone
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ from app.core.auth.models import RefreshSession, User, _uuid
9
+ from app.core.auth.models import _utcnow as _now
10
+ from app.core.vector_store.models import VectorStoreIndex
11
+ from app.services.supabase.client import SupabaseClient
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def _serialize_dt(dt: Optional[datetime]) -> Optional[str]:
17
+ if dt is None:
18
+ return None
19
+ return dt.isoformat()
20
+
21
+
22
+ def _deserialize_dt(val) -> Optional[datetime]:
23
+ if val is None:
24
+ return None
25
+ if isinstance(val, datetime):
26
+ return val
27
+ if isinstance(val, str):
28
+ return datetime.fromisoformat(val)
29
+ return val
30
+
31
+
32
+ def _row_to_user(row: dict) -> User:
33
+ return User(
34
+ id=row["id"],
35
+ email=row["email"],
36
+ username=row.get("username"),
37
+ full_name=row.get("full_name"),
38
+ password_hash=row["password_hash"],
39
+ is_active=bool(row["is_active"]),
40
+ is_verified=bool(row["is_verified"]),
41
+ failed_login_attempts=row["failed_login_attempts"],
42
+ locked_until=_deserialize_dt(row.get("locked_until")),
43
+ last_login=_deserialize_dt(row.get("last_login")),
44
+ password_changed_at=_deserialize_dt(row.get("password_changed_at")),
45
+ created_at=_deserialize_dt(row["created_at"]),
46
+ updated_at=_deserialize_dt(row["updated_at"]),
47
+ deleted_at=_deserialize_dt(row.get("deleted_at")),
48
+ )
49
+
50
+
51
+ def _row_to_refresh_session(row: dict) -> RefreshSession:
52
+ return RefreshSession(
53
+ id=row["id"],
54
+ user_id=row["user_id"],
55
+ token_key=row["token_key"],
56
+ token_hash=row["token_hash"],
57
+ device_info=row.get("device_info"),
58
+ ip_address=row.get("ip_address"),
59
+ expires_at=_deserialize_dt(row["expires_at"]),
60
+ revoked_at=_deserialize_dt(row.get("revoked_at")),
61
+ created_at=_deserialize_dt(row["created_at"]),
62
+ updated_at=_deserialize_dt(row["updated_at"]),
63
+ )
64
+
65
+
66
+ class AuthRepository:
67
+ def __init__(self, client: SupabaseClient):
68
+ self._client = client
69
+
70
+ async def find_user_by_email(self, email: str) -> Optional[User]:
71
+ row = await self._client.find_one("users", "email", email)
72
+ if row is None or row.get("deleted_at") is not None:
73
+ return None
74
+ return _row_to_user(row)
75
+
76
+ async def find_user_by_id(self, user_id: str) -> Optional[User]:
77
+ row = await self._client.find_one("users", "id", user_id)
78
+ if row is None:
79
+ return None
80
+ return _row_to_user(row)
81
+
82
+ async def find_user_by_username(self, username: str) -> Optional[User]:
83
+ row = await self._client.find_one("users", "username", username)
84
+ if row is None or row.get("deleted_at") is not None:
85
+ return None
86
+ return _row_to_user(row)
87
+
88
+ async def create_user(self, user: User) -> User:
89
+ data = {
90
+ "id": user.id,
91
+ "email": user.email,
92
+ "username": user.username,
93
+ "full_name": user.full_name,
94
+ "password_hash": user.password_hash,
95
+ "is_active": user.is_active,
96
+ "is_verified": user.is_verified,
97
+ "failed_login_attempts": user.failed_login_attempts,
98
+ "locked_until": _serialize_dt(user.locked_until),
99
+ "last_login": _serialize_dt(user.last_login),
100
+ "password_changed_at": _serialize_dt(user.password_changed_at),
101
+ "created_at": _serialize_dt(user.created_at),
102
+ "updated_at": _serialize_dt(user.updated_at),
103
+ "deleted_at": _serialize_dt(user.deleted_at),
104
+ }
105
+ await self._client.insert("users", data)
106
+ return user
107
+
108
+ async def update_user(self, user_id: str, updates: Dict[str, Any]) -> None:
109
+ data = {}
110
+ for k, v in updates.items():
111
+ if isinstance(v, datetime):
112
+ data[k] = _serialize_dt(v)
113
+ else:
114
+ data[k] = v
115
+ data["updated_at"] = _serialize_dt(_now())
116
+ await self._client.update("users", "id", user_id, data)
117
+
118
+ async def get_role_by_name(self, name: str) -> Optional[Dict[str, Any]]:
119
+ return await self._client.find_one("roles", "name", name)
120
+
121
+ async def assign_role(self, user_id: str, role_id: str) -> None:
122
+ await self._client.insert("user_roles", {
123
+ "user_id": user_id,
124
+ "role_id": role_id,
125
+ })
126
+
127
+ async def get_user_roles(self, user_id: str) -> List[str]:
128
+ rows = await self._client.select(
129
+ "roles",
130
+ columns="name",
131
+ eq=("user_id", user_id),
132
+ )
133
+ return [r["name"] for r in rows if "name" in r]
134
+
135
+ async def get_user_permissions(self, user_id: str) -> List[str]:
136
+ rows = await self._client.select_in(
137
+ "permissions",
138
+ "id",
139
+ [],
140
+ columns="code",
141
+ )
142
+ return [r["code"] for r in rows if "code" in r]
143
+
144
+ async def create_refresh_session(self, session: RefreshSession) -> None:
145
+ data = {
146
+ "id": session.id,
147
+ "user_id": session.user_id,
148
+ "token_key": session.token_key,
149
+ "token_hash": session.token_hash,
150
+ "device_info": session.device_info,
151
+ "ip_address": session.ip_address,
152
+ "expires_at": _serialize_dt(session.expires_at),
153
+ "revoked_at": _serialize_dt(session.revoked_at),
154
+ "created_at": _serialize_dt(session.created_at),
155
+ "updated_at": _serialize_dt(session.updated_at),
156
+ }
157
+ await self._client.insert("refresh_sessions", data)
158
+
159
+ async def find_session_by_token_key(self, token_key: str) -> Optional[RefreshSession]:
160
+ row = await self._client.find_one("refresh_sessions", "token_key", token_key)
161
+ if row is None:
162
+ return None
163
+ return _row_to_refresh_session(row)
164
+
165
+ async def find_valid_session(self, token_key: str) -> Optional[RefreshSession]:
166
+ rows = await self._client.select(
167
+ "refresh_sessions",
168
+ eq=("token_key", token_key),
169
+ )
170
+ now = _serialize_dt(_now())
171
+ for row in rows:
172
+ if row.get("revoked_at") is None and row.get("expires_at", "") > now:
173
+ return _row_to_refresh_session(row)
174
+ return None
175
+
176
+ async def revoke_session(self, session_id: str) -> None:
177
+ now = _serialize_dt(_now())
178
+ await self._client.update("refresh_sessions", "id", session_id, {"revoked_at": now})
179
+
180
+ async def revoke_all_user_sessions(self, user_id: str) -> None:
181
+ now = _serialize_dt(_now())
182
+ rows = await self._client.select(
183
+ "refresh_sessions",
184
+ eq=("user_id", user_id),
185
+ )
186
+ for row in rows:
187
+ if row.get("revoked_at") is None:
188
+ await self._client.update(
189
+ "refresh_sessions", "id", row["id"], {"revoked_at": now},
190
+ )
191
+
192
+ async def find_user_sessions(self, user_id: str) -> List[RefreshSession]:
193
+ rows = await self._client.select(
194
+ "refresh_sessions",
195
+ eq=("user_id", user_id),
196
+ order=("created_at", True),
197
+ )
198
+ return [_row_to_refresh_session(r) for r in rows if r.get("revoked_at") is None]
199
+
200
+ async def cleanup_expired_sessions(self) -> int:
201
+ rows = await self._client.select("refresh_sessions")
202
+ count = 0
203
+ now = _serialize_dt(_now())
204
+ for row in rows:
205
+ if row.get("expires_at", "") < now and row.get("revoked_at") is None:
206
+ await self._client.update(
207
+ "refresh_sessions", "id", row["id"], {"revoked_at": now},
208
+ )
209
+ count += 1
210
+ return count
211
+
212
+ async def get_user_permission_codes(self, user_id: str) -> List[str]:
213
+ return await self._client.select_in(
214
+ "permissions",
215
+ "id",
216
+ [],
217
+ columns="code",
218
+ )
219
+
220
+
221
+ class VectorStoreRepository:
222
+ def __init__(self, client: SupabaseClient):
223
+ self._client = client
224
+
225
+ async def find_all(self) -> List[VectorStoreIndex]:
226
+ rows = await self._client.select("vector_store_index", order=("created_at", False))
227
+ return [VectorStoreIndex.from_dict(r) for r in rows]
228
+
229
+ async def find_by_id(self, store_id: str) -> Optional[VectorStoreIndex]:
230
+ row = await self._client.find_one("vector_store_index", "store_id", store_id)
231
+ if row is None:
232
+ return None
233
+ return VectorStoreIndex.from_dict(row)
234
+
235
+ async def upsert(self, index: VectorStoreIndex) -> None:
236
+ data = {
237
+ "store_id": index.store_id,
238
+ "name": index.name,
239
+ "path": index.path,
240
+ "description": index.description,
241
+ "metadata_json": index.metadata_json,
242
+ "created_at": index.created_at,
243
+ }
244
+ existing = await self._client.find_one("vector_store_index", "store_id", index.store_id)
245
+ if existing:
246
+ await self._client.update("vector_store_index", "store_id", index.store_id, {
247
+ "name": index.name,
248
+ "description": index.description,
249
+ "metadata_json": index.metadata_json,
250
+ })
251
+ else:
252
+ await self._client.insert("vector_store_index", data)
253
+
254
+ async def delete(self, store_id: str) -> None:
255
+ await self._client.delete("vector_store_index", "store_id", store_id)
app/services/url_shortener_service.py CHANGED
@@ -20,7 +20,7 @@ from urllib.parse import urlparse
20
 
21
  from app.config import get_settings
22
  from app.core.logger import get_logger
23
- from app.core.tidb_manager import TiDBManager, get_tidb_manager
24
 
25
  logger = get_logger(__name__)
26
 
@@ -313,115 +313,22 @@ def _fire_click_webhook(webhook_url: str, short_code: str, long_url: str,
313
 
314
 
315
  # ---------------------------------------------------------------------------
316
- # TiDB-backed Storage
317
  # ---------------------------------------------------------------------------
318
 
319
- US_TABLES: dict[str, str] = {
320
- "url_shortener_owners": """
321
- CREATE TABLE IF NOT EXISTS url_shortener_owners (
322
- owner_id VARCHAR(64) PRIMARY KEY,
323
- name VARCHAR(255) NOT NULL,
324
- api_key_hash VARCHAR(128) NOT NULL,
325
- plan VARCHAR(32) NOT NULL DEFAULT 'free',
326
- created_at VARCHAR(64) NOT NULL,
327
- UNIQUE KEY uk_uso_api_key_hash (api_key_hash)
328
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
329
- """,
330
- "url_shortener_links": """
331
- CREATE TABLE IF NOT EXISTS url_shortener_links (
332
- short_code VARCHAR(32) PRIMARY KEY,
333
- long_url TEXT NOT NULL,
334
- owner_id VARCHAR(64) NOT NULL,
335
- created_at VARCHAR(64) NOT NULL,
336
- expires_at VARCHAR(64),
337
- max_clicks INT,
338
- click_count INT NOT NULL DEFAULT 0,
339
- last_accessed_at VARCHAR(64),
340
- is_active TINYINT(1) NOT NULL DEFAULT 1,
341
- tags VARCHAR(512),
342
- note TEXT,
343
- password_hash VARCHAR(128),
344
- utm_source VARCHAR(512),
345
- utm_medium VARCHAR(512),
346
- utm_campaign VARCHAR(512),
347
- campaign_id VARCHAR(64),
348
- custom_domain VARCHAR(255),
349
- fallback_url TEXT,
350
- webhook_url TEXT,
351
- geo_targeting TEXT,
352
- INDEX idx_usl_owner (owner_id),
353
- INDEX idx_usl_campaign (campaign_id),
354
- CONSTRAINT fk_usl_owner FOREIGN KEY (owner_id) REFERENCES url_shortener_owners(owner_id)
355
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
356
- """,
357
- "url_shortener_clicks": """
358
- CREATE TABLE IF NOT EXISTS url_shortener_clicks (
359
- id BIGINT AUTO_INCREMENT PRIMARY KEY,
360
- short_code VARCHAR(32) NOT NULL,
361
- referrer TEXT,
362
- user_agent TEXT,
363
- ip_address VARCHAR(45),
364
- country VARCHAR(64),
365
- browser VARCHAR(64),
366
- device VARCHAR(64),
367
- os VARCHAR(64),
368
- clicked_at VARCHAR(64) NOT NULL,
369
- INDEX idx_usc_code (short_code),
370
- INDEX idx_usc_clicked_at (clicked_at),
371
- CONSTRAINT fk_usc_link FOREIGN KEY (short_code) REFERENCES url_shortener_links(short_code)
372
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
373
- """,
374
- "url_shortener_audit_log": """
375
- CREATE TABLE IF NOT EXISTS url_shortener_audit_log (
376
- id BIGINT AUTO_INCREMENT PRIMARY KEY,
377
- timestamp VARCHAR(64) NOT NULL,
378
- owner_id VARCHAR(64),
379
- action VARCHAR(64) NOT NULL,
380
- short_code VARCHAR(32),
381
- detail TEXT
382
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
383
- """,
384
- "url_shortener_campaigns": """
385
- CREATE TABLE IF NOT EXISTS url_shortener_campaigns (
386
- campaign_id VARCHAR(64) PRIMARY KEY,
387
- owner_id VARCHAR(64) NOT NULL,
388
- name VARCHAR(255) NOT NULL,
389
- description TEXT,
390
- created_at VARCHAR(64) NOT NULL,
391
- is_active TINYINT(1) NOT NULL DEFAULT 1,
392
- INDEX idx_uscamp_owner (owner_id),
393
- CONSTRAINT fk_uscamp_owner FOREIGN KEY (owner_id) REFERENCES url_shortener_owners(owner_id)
394
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
395
- """,
396
- }
397
-
398
-
399
- def _run_async(coro):
400
- try:
401
- loop = asyncio.get_running_loop()
402
- if loop.is_running():
403
- import concurrent.futures
404
- with concurrent.futures.ThreadPoolExecutor(max_workers=1):
405
- future = asyncio.run_coroutine_threadsafe(coro, loop)
406
- return future.result()
407
- except RuntimeError:
408
- pass
409
- return asyncio.run(coro)
410
-
411
 
412
  class Storage:
413
  def __init__(self):
414
  self._lock = threading.RLock()
415
 
416
- def _get_mgr(self) -> TiDBManager:
417
- mgr = get_tidb_manager()
418
- if mgr is None:
419
- raise RuntimeError("TiDB manager not initialized")
420
- return mgr
421
 
422
  def _init_schema(self):
423
- mgr = self._get_mgr()
424
- _run_async(mgr.ensure_tables(US_TABLES))
425
 
426
  def close(self):
427
  pass
@@ -430,288 +337,263 @@ class Storage:
430
 
431
  def create_owner(self, owner_id: str, name: str, api_key_hash: str, plan: str = "free"):
432
  with self._lock:
433
- _run_async(self._get_mgr().execute(
434
- "INSERT INTO url_shortener_owners (owner_id, name, api_key_hash, plan, created_at) "
435
- "VALUES (%s, %s, %s, %s, %s)",
436
- (owner_id, name, api_key_hash, plan, _now_iso()),
437
- ))
 
 
 
438
 
439
  def get_owner_by_key_hash(self, api_key_hash: str) -> Optional[dict]:
440
- return _run_async(self._get_mgr().fetchone_all(
441
- "SELECT * FROM url_shortener_owners WHERE api_key_hash = %s",
442
- (api_key_hash,),
443
- ))
444
 
445
  def get_owner(self, owner_id: str) -> Optional[dict]:
446
- return _run_async(self._get_mgr().fetchone_all(
447
- "SELECT * FROM url_shortener_owners WHERE owner_id = %s",
448
- (owner_id,),
449
- ))
450
 
451
  def update_owner_plan(self, owner_id: str, plan: str):
452
  with self._lock:
453
- _run_async(self._get_mgr().execute(
454
- "UPDATE url_shortener_owners SET plan = %s WHERE owner_id = %s",
455
- (plan, owner_id),
456
- ))
457
 
458
  # ---------- Links ----------
459
 
460
  def code_exists(self, short_code: str) -> bool:
461
- row = _run_async(self._get_mgr().fetchone_all(
462
- "SELECT 1 AS chk FROM url_shortener_links WHERE short_code = %s",
463
- (short_code,),
464
- ))
465
  return row is not None
466
 
467
  def insert_link(self, **kwargs):
468
  with self._lock:
469
- _run_async(self._get_mgr().insert_and_get_id("""
470
- INSERT INTO url_shortener_links
471
- (short_code, long_url, owner_id, created_at, expires_at,
472
- max_clicks, click_count, last_accessed_at, is_active, tags, note,
473
- password_hash, utm_source, utm_medium, utm_campaign,
474
- campaign_id, custom_domain, fallback_url, webhook_url, geo_targeting)
475
- VALUES
476
- (%(short_code)s, %(long_url)s, %(owner_id)s, %(created_at)s, %(expires_at)s,
477
- %(max_clicks)s, 0, NULL, 1, %(tags)s, %(note)s,
478
- %(password_hash)s, %(utm_source)s, %(utm_medium)s, %(utm_campaign)s,
479
- %(campaign_id)s, %(custom_domain)s, %(fallback_url)s, %(webhook_url)s,
480
- %(geo_targeting)s)
481
- """, kwargs))
 
 
 
 
 
 
 
 
 
 
482
 
483
  def update_link(self, short_code: str, **updates):
484
  with self._lock:
485
- sets = ", ".join(f"{k} = %s" for k in updates)
486
- vals = list(updates.values()) + [short_code]
487
- _run_async(self._get_mgr().execute(
488
- f"UPDATE url_shortener_links SET {sets} WHERE short_code = %s",
489
- tuple(vals),
490
- ))
491
 
492
  def get_link(self, short_code: str) -> Optional[dict]:
493
- return _run_async(self._get_mgr().fetchone_all(
494
- "SELECT * FROM url_shortener_links WHERE short_code = %s",
495
- (short_code,),
496
- ))
497
 
498
  def list_links_for_owner(self, owner_id: str) -> List[dict]:
499
- return _run_async(self._get_mgr().fetchall_all(
500
- "SELECT * FROM url_shortener_links WHERE owner_id = %s ORDER BY created_at DESC",
501
- (owner_id,),
502
  ))
503
 
504
  def deactivate_link(self, short_code: str):
505
  with self._lock:
506
- _run_async(self._get_mgr().execute(
507
- "UPDATE url_shortener_links SET is_active = 0 WHERE short_code = %s",
508
- (short_code,),
509
- ))
510
 
511
  def record_click(self, short_code: str, referrer: Optional[str] = None,
512
  user_agent: Optional[str] = None, ip_address: Optional[str] = None):
513
  now = _now_iso()
514
  parsed = _parse_user_agent(user_agent or "")
515
  with self._lock:
516
- _run_async(self._get_mgr().execute(
517
- "INSERT INTO url_shortener_clicks "
518
- "(short_code, referrer, user_agent, ip_address, country, browser, device, os, clicked_at) "
519
- "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)",
520
- (short_code, referrer, user_agent, ip_address, None,
521
- parsed["browser"], parsed["device"], parsed["os"], now),
522
- ))
523
- _run_async(self._get_mgr().execute(
524
- "UPDATE url_shortener_links SET click_count = click_count + 1, last_accessed_at = %s "
525
- "WHERE short_code = %s",
526
- (now, short_code),
527
- ))
 
 
 
 
 
 
 
528
 
529
  def get_click_analytics(self, short_code: str) -> Dict[str, Any]:
530
- mgr = self._get_mgr()
531
- total = _run_async(mgr.fetchone_all(
532
- "SELECT COUNT(*) AS cnt FROM url_shortener_clicks WHERE short_code = %s",
533
- (short_code,),
534
- ))
535
- total_val = total["cnt"] if total else 0
536
- browsers_rows = _run_async(mgr.fetchall_all(
537
- "SELECT browser, COUNT(*) AS cnt FROM url_shortener_clicks WHERE short_code = %s "
538
- "AND browser IS NOT NULL GROUP BY browser ORDER BY cnt DESC",
539
- (short_code,),
540
- ))
541
- devices_rows = _run_async(mgr.fetchall_all(
542
- "SELECT device, COUNT(*) AS cnt FROM url_shortener_clicks WHERE short_code = %s "
543
- "AND device IS NOT NULL GROUP BY device ORDER BY cnt DESC",
544
- (short_code,),
545
- ))
546
- referrers_rows = _run_async(mgr.fetchall_all(
547
- "SELECT COALESCE(NULLIF(referrer, ''), '(direct)') AS ref, COUNT(*) AS n "
548
- "FROM url_shortener_clicks WHERE short_code = %s "
549
- "GROUP BY ref ORDER BY n DESC LIMIT 10",
550
- (short_code,),
551
- ))
552
- os_rows = _run_async(mgr.fetchall_all(
553
- "SELECT os, COUNT(*) AS cnt FROM url_shortener_clicks WHERE short_code = %s "
554
- "AND os IS NOT NULL GROUP BY os ORDER BY cnt DESC",
555
- (short_code,),
556
- ))
557
- recent = _run_async(mgr.fetchall_all(
558
- "SELECT clicked_at FROM url_shortener_clicks WHERE short_code = %s "
559
- "ORDER BY clicked_at DESC LIMIT 50",
560
- (short_code,),
561
- ))
562
  return {
563
  "total_clicks": total_val,
564
- "browsers": {r["browser"]: r["cnt"] for r in browsers_rows},
565
- "devices": {r["device"]: r["cnt"] for r in devices_rows},
566
- "operating_systems": {r["os"]: r["cnt"] for r in os_rows},
567
- "top_referrers": {r["ref"]: r["n"] for r in referrers_rows},
568
- "recent_clicks": [r["clicked_at"] for r in recent],
569
  }
570
 
571
  def get_link_count_for_owner(self, owner_id: str) -> int:
572
- row = _run_async(self._get_mgr().fetchone_all(
573
- "SELECT COUNT(*) AS cnt FROM url_shortener_links WHERE owner_id = %s",
574
- (owner_id,),
575
- ))
576
- return row["cnt"] if row else 0
577
 
578
  def audit(self, owner_id: Optional[str], action: str, short_code: Optional[str], detail: str = ""):
579
  with self._lock:
580
- _run_async(self._get_mgr().execute(
581
- "INSERT INTO url_shortener_audit_log (timestamp, owner_id, action, short_code, detail) "
582
- "VALUES (%s, %s, %s, %s, %s)",
583
- (_now_iso(), owner_id, action, short_code, detail),
584
- ))
 
 
 
585
 
586
  def export_links_csv(self, owner_id: str) -> str:
587
- rows = _run_async(self._get_mgr().fetchall_all(
588
- "SELECT short_code, long_url, created_at, click_count, is_active, tags, note "
589
- "FROM url_shortener_links WHERE owner_id = %s ORDER BY created_at DESC",
590
- (owner_id,),
591
  ))
592
  buf = io.StringIO()
593
  w = csv.writer(buf)
594
  w.writerow(["short_code", "long_url", "created_at", "click_count", "is_active", "tags", "note"])
595
  for r in rows:
596
  w.writerow([r["short_code"], r["long_url"], r["created_at"],
597
- r["click_count"], r["is_active"], r["tags"] or "", r["note"] or ""])
598
  return buf.getvalue()
599
 
600
  def export_clicks_csv(self, short_code: str) -> str:
601
- rows = _run_async(self._get_mgr().fetchall_all(
602
- "SELECT id, referrer, user_agent, ip_address, browser, device, os, clicked_at "
603
- "FROM url_shortener_clicks WHERE short_code = %s ORDER BY clicked_at DESC",
604
- (short_code,),
605
  ))
606
  buf = io.StringIO()
607
  w = csv.writer(buf)
608
  w.writerow(["id", "referrer", "user_agent", "ip_address", "browser", "device", "os", "clicked_at"])
609
  for r in rows:
610
- w.writerow([r["id"], r["referrer"], r["user_agent"], r["ip_address"],
611
- r["browser"], r["device"], r["os"], r["clicked_at"]])
612
  return buf.getvalue()
613
 
614
  def list_links_by_campaign(self, campaign_id: str) -> List[dict]:
615
- return _run_async(self._get_mgr().fetchall_all(
616
- "SELECT * FROM url_shortener_links WHERE campaign_id = %s ORDER BY created_at DESC",
617
- (campaign_id,),
618
  ))
619
 
620
  def create_campaign(self, campaign_id: str, owner_id: str, name: str, description: Optional[str] = None):
621
  with self._lock:
622
- _run_async(self._get_mgr().execute(
623
- "INSERT INTO url_shortener_campaigns (campaign_id, owner_id, name, description, created_at) "
624
- "VALUES (%s, %s, %s, %s, %s)",
625
- (campaign_id, owner_id, name, description, _now_iso()),
626
- ))
 
 
 
627
 
628
  def get_campaign(self, campaign_id: str) -> Optional[dict]:
629
- return _run_async(self._get_mgr().fetchone_all(
630
- "SELECT * FROM url_shortener_campaigns WHERE campaign_id = %s",
631
- (campaign_id,),
632
- ))
633
 
634
  def list_campaigns_for_owner(self, owner_id: str) -> List[dict]:
635
- return _run_async(self._get_mgr().fetchall_all(
636
- "SELECT * FROM url_shortener_campaigns WHERE owner_id = %s ORDER BY created_at DESC",
637
- (owner_id,),
638
  ))
639
 
640
  def deactivate_campaign(self, campaign_id: str):
641
  with self._lock:
642
- _run_async(self._get_mgr().execute(
643
- "UPDATE url_shortener_campaigns SET is_active = 0 WHERE campaign_id = %s",
644
- (campaign_id,),
645
- ))
646
 
647
  def get_campaign_link_count(self, campaign_id: str) -> int:
648
- row = _run_async(self._get_mgr().fetchone_all(
649
- "SELECT COUNT(*) AS cnt FROM url_shortener_links WHERE campaign_id = %s",
650
- (campaign_id,),
651
- ))
652
- return row["cnt"] if row else 0
653
 
654
  def get_campaign_total_clicks(self, campaign_id: str) -> int:
655
- row = _run_async(self._get_mgr().fetchone_all(
656
- "SELECT COALESCE(SUM(click_count), 0) AS cnt FROM url_shortener_links WHERE campaign_id = %s",
657
- (campaign_id,),
658
- ))
659
- return row["cnt"] if row else 0
660
 
661
  def get_campaign_analytics(self, campaign_id: str) -> Dict[str, Any]:
662
- codes = _run_async(self._get_mgr().fetchall_all(
663
- "SELECT short_code FROM url_shortener_links WHERE campaign_id = %s",
664
- (campaign_id,),
665
- ))
666
  total = 0
667
  browsers: Dict[str, int] = {}
668
  devices: Dict[str, int] = {}
669
  os_data: Dict[str, int] = {}
670
- mgr = self._get_mgr()
671
  for row in codes:
672
  code = row["short_code"]
673
- cnt_row = _run_async(mgr.fetchone_all(
674
- "SELECT COUNT(*) AS cnt FROM url_shortener_clicks WHERE short_code = %s",
675
- (code,),
676
- ))
677
- total += cnt_row["cnt"] if cnt_row else 0
678
- for b in _run_async(mgr.fetchall_all(
679
- "SELECT browser, COUNT(*) AS cnt FROM url_shortener_clicks WHERE short_code = %s "
680
- "AND browser IS NOT NULL GROUP BY browser", (code,),
681
- )):
682
- browsers[b["browser"]] = browsers.get(b["browser"], 0) + b["cnt"]
683
- for d in _run_async(mgr.fetchall_all(
684
- "SELECT device, COUNT(*) AS cnt FROM url_shortener_clicks WHERE short_code = %s "
685
- "AND device IS NOT NULL GROUP BY device", (code,),
686
- )):
687
- devices[d["device"]] = devices.get(d["device"], 0) + d["cnt"]
688
- for o in _run_async(mgr.fetchall_all(
689
- "SELECT os, COUNT(*) AS cnt FROM url_shortener_clicks WHERE short_code = %s "
690
- "AND os IS NOT NULL GROUP BY os", (code,),
691
- )):
692
- os_data[o["os"]] = os_data.get(o["os"], 0) + o["cnt"]
693
  return {"total_clicks": total, "browsers": browsers, "devices": devices, "operating_systems": os_data}
694
 
695
  def owner_summary(self, owner_id: str) -> Dict[str, Any]:
696
  row = self.get_owner(owner_id)
697
  if row is None:
698
  return {}
699
- link_count = self.get_link_count_for_owner(owner_id)
700
- total_clicks_row = _run_async(self._get_mgr().fetchone_all(
701
- "SELECT COALESCE(SUM(click_count), 0) AS cnt FROM url_shortener_links WHERE owner_id = %s",
702
- (owner_id,),
703
- ))
704
- total_clicks = total_clicks_row["cnt"] if total_clicks_row else 0
705
- active = _run_async(self._get_mgr().fetchone_all(
706
- "SELECT COUNT(*) AS cnt FROM url_shortener_links WHERE owner_id = %s AND is_active = 1",
707
- (owner_id,),
708
- ))
709
  return {
710
  "owner_id": owner_id,
711
  "name": row["name"],
712
  "plan": row["plan"],
713
  "total_links": link_count,
714
- "active_links": active["cnt"] if active else 0,
715
  "total_clicks": total_clicks,
716
  "created_at": row["created_at"],
717
  }
@@ -721,7 +603,7 @@ class URLShortenerService:
721
  def __init__(self):
722
  self.storage = Storage()
723
  self.storage._init_schema()
724
- logger.info("URLShortenerService initialized (TiDB)")
725
 
726
  def close(self) -> None:
727
  self.storage.close()
@@ -1067,15 +949,14 @@ class URLShortenerService:
1067
  raise LinkNotFoundError(f"No link found for code '{short_code}'")
1068
  if row["owner_id"] != owner_id:
1069
  raise AuthorizationError("You do not own this link")
1070
- mgr = get_tidb_manager()
1071
- if mgr is None:
 
1072
  return []
1073
- rows = _run_async(mgr.fetchall_all(
1074
- "SELECT id, referrer, user_agent, ip_address, browser, device, os, clicked_at "
1075
- "FROM url_shortener_clicks WHERE short_code = %s ORDER BY clicked_at DESC LIMIT %s",
1076
- (short_code, limit),
1077
  ))
1078
- return list(rows)
1079
 
1080
  def get_qr_code(self, short_code: str) -> Dict[str, str]:
1081
  row = self.storage.get_link(short_code)
 
20
 
21
  from app.config import get_settings
22
  from app.core.logger import get_logger
23
+ from app.services.supabase import SupabaseClient, get_supabase_client
24
 
25
  logger = get_logger(__name__)
26
 
 
313
 
314
 
315
  # ---------------------------------------------------------------------------
316
+ # Supabase-backed Storage
317
  # ---------------------------------------------------------------------------
318
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
 
320
  class Storage:
321
  def __init__(self):
322
  self._lock = threading.RLock()
323
 
324
+ def _get_client(self) -> SupabaseClient:
325
+ client = get_supabase_client()
326
+ if client is None:
327
+ raise RuntimeError("Supabase client not initialized")
328
+ return client
329
 
330
  def _init_schema(self):
331
+ logger.info("URL shortener schema managed via Supabase (DDL in /ddl directory)")
 
332
 
333
  def close(self):
334
  pass
 
337
 
338
  def create_owner(self, owner_id: str, name: str, api_key_hash: str, plan: str = "free"):
339
  with self._lock:
340
+ import asyncio
341
+ asyncio.run(self._get_client().insert("url_shortener_owners", {
342
+ "owner_id": owner_id,
343
+ "name": name,
344
+ "api_key_hash": api_key_hash,
345
+ "plan": plan,
346
+ "created_at": _now_iso(),
347
+ }))
348
 
349
  def get_owner_by_key_hash(self, api_key_hash: str) -> Optional[dict]:
350
+ import asyncio
351
+ return asyncio.run(self._get_client().find_one("url_shortener_owners", "api_key_hash", api_key_hash))
 
 
352
 
353
  def get_owner(self, owner_id: str) -> Optional[dict]:
354
+ import asyncio
355
+ return asyncio.run(self._get_client().find_one("url_shortener_owners", "owner_id", owner_id))
 
 
356
 
357
  def update_owner_plan(self, owner_id: str, plan: str):
358
  with self._lock:
359
+ import asyncio
360
+ asyncio.run(self._get_client().update("url_shortener_owners", "owner_id", owner_id, {"plan": plan}))
 
 
361
 
362
  # ---------- Links ----------
363
 
364
  def code_exists(self, short_code: str) -> bool:
365
+ import asyncio
366
+ row = asyncio.run(self._get_client().find_one("url_shortener_links", "short_code", short_code, columns="short_code"))
 
 
367
  return row is not None
368
 
369
  def insert_link(self, **kwargs):
370
  with self._lock:
371
+ data = {
372
+ "short_code": kwargs["short_code"],
373
+ "long_url": kwargs["long_url"],
374
+ "owner_id": kwargs["owner_id"],
375
+ "created_at": kwargs.get("created_at", _now_iso()),
376
+ "expires_at": kwargs.get("expires_at"),
377
+ "max_clicks": kwargs.get("max_clicks"),
378
+ "click_count": 0,
379
+ "is_active": 1,
380
+ "tags": kwargs.get("tags"),
381
+ "note": kwargs.get("note"),
382
+ "password_hash": kwargs.get("password_hash"),
383
+ "utm_source": kwargs.get("utm_source"),
384
+ "utm_medium": kwargs.get("utm_medium"),
385
+ "utm_campaign": kwargs.get("utm_campaign"),
386
+ "campaign_id": kwargs.get("campaign_id"),
387
+ "custom_domain": kwargs.get("custom_domain"),
388
+ "fallback_url": kwargs.get("fallback_url"),
389
+ "webhook_url": kwargs.get("webhook_url"),
390
+ "geo_targeting": kwargs.get("geo_targeting"),
391
+ }
392
+ import asyncio
393
+ asyncio.run(self._get_client().insert("url_shortener_links", data))
394
 
395
  def update_link(self, short_code: str, **updates):
396
  with self._lock:
397
+ import asyncio
398
+ asyncio.run(self._get_client().update("url_shortener_links", "short_code", short_code, updates))
 
 
 
 
399
 
400
  def get_link(self, short_code: str) -> Optional[dict]:
401
+ import asyncio
402
+ return asyncio.run(self._get_client().find_one("url_shortener_links", "short_code", short_code))
 
 
403
 
404
  def list_links_for_owner(self, owner_id: str) -> List[dict]:
405
+ import asyncio
406
+ return asyncio.run(self._get_client().select(
407
+ "url_shortener_links", eq=("owner_id", owner_id), order=("created_at", True),
408
  ))
409
 
410
  def deactivate_link(self, short_code: str):
411
  with self._lock:
412
+ import asyncio
413
+ asyncio.run(self._get_client().update("url_shortener_links", "short_code", short_code, {"is_active": 0}))
 
 
414
 
415
  def record_click(self, short_code: str, referrer: Optional[str] = None,
416
  user_agent: Optional[str] = None, ip_address: Optional[str] = None):
417
  now = _now_iso()
418
  parsed = _parse_user_agent(user_agent or "")
419
  with self._lock:
420
+ import asyncio
421
+ asyncio.run(self._get_client().insert("url_shortener_clicks", {
422
+ "short_code": short_code,
423
+ "referrer": referrer,
424
+ "user_agent": user_agent,
425
+ "ip_address": ip_address,
426
+ "country": None,
427
+ "browser": parsed["browser"],
428
+ "device": parsed["device"],
429
+ "os": parsed["os"],
430
+ "clicked_at": now,
431
+ }))
432
+ link = asyncio.run(self._get_client().find_one("url_shortener_links", "short_code", short_code))
433
+ if link:
434
+ current_clicks = (link.get("click_count") or 0) + 1
435
+ asyncio.run(self._get_client().update(
436
+ "url_shortener_links", "short_code", short_code,
437
+ {"click_count": current_clicks, "last_accessed_at": now},
438
+ ))
439
 
440
  def get_click_analytics(self, short_code: str) -> Dict[str, Any]:
441
+ import asyncio
442
+ client = self._get_client()
443
+ all_clicks = asyncio.run(client.select("url_shortener_clicks", eq=("short_code", short_code)))
444
+ total_val = len(all_clicks)
445
+ browsers: Dict[str, int] = {}
446
+ devices: Dict[str, int] = {}
447
+ referrers: Dict[str, int] = {}
448
+ os_data: Dict[str, int] = {}
449
+ recent = []
450
+ for c in all_clicks:
451
+ b = c.get("browser")
452
+ if b:
453
+ browsers[b] = browsers.get(b, 0) + 1
454
+ d = c.get("device")
455
+ if d:
456
+ devices[d] = devices.get(d, 0) + 1
457
+ o = c.get("os")
458
+ if o:
459
+ os_data[o] = os_data.get(o, 0) + 1
460
+ ref = c.get("referrer") or "(direct)"
461
+ referrers[ref] = referrers.get(ref, 0) + 1
462
+ recent.append(c.get("clicked_at"))
463
+ top_referrers = dict(sorted(referrers.items(), key=lambda x: -x[1])[:10])
 
 
 
 
 
 
 
 
 
464
  return {
465
  "total_clicks": total_val,
466
+ "browsers": browsers,
467
+ "devices": devices,
468
+ "operating_systems": os_data,
469
+ "top_referrers": top_referrers,
470
+ "recent_clicks": recent[:50],
471
  }
472
 
473
  def get_link_count_for_owner(self, owner_id: str) -> int:
474
+ import asyncio
475
+ links = asyncio.run(self._get_client().select("url_shortener_links", eq=("owner_id", owner_id)))
476
+ return len(links)
 
 
477
 
478
  def audit(self, owner_id: Optional[str], action: str, short_code: Optional[str], detail: str = ""):
479
  with self._lock:
480
+ import asyncio
481
+ asyncio.run(self._get_client().insert("url_shortener_audit_log", {
482
+ "timestamp": _now_iso(),
483
+ "owner_id": owner_id,
484
+ "action": action,
485
+ "short_code": short_code,
486
+ "detail": detail,
487
+ }))
488
 
489
  def export_links_csv(self, owner_id: str) -> str:
490
+ import asyncio
491
+ rows = asyncio.run(self._get_client().select(
492
+ "url_shortener_links", eq=("owner_id", owner_id), order=("created_at", True),
 
493
  ))
494
  buf = io.StringIO()
495
  w = csv.writer(buf)
496
  w.writerow(["short_code", "long_url", "created_at", "click_count", "is_active", "tags", "note"])
497
  for r in rows:
498
  w.writerow([r["short_code"], r["long_url"], r["created_at"],
499
+ r["click_count"], r["is_active"], r.get("tags") or "", r.get("note") or ""])
500
  return buf.getvalue()
501
 
502
  def export_clicks_csv(self, short_code: str) -> str:
503
+ import asyncio
504
+ rows = asyncio.run(self._get_client().select(
505
+ "url_shortener_clicks", eq=("short_code", short_code), order=("clicked_at", True),
 
506
  ))
507
  buf = io.StringIO()
508
  w = csv.writer(buf)
509
  w.writerow(["id", "referrer", "user_agent", "ip_address", "browser", "device", "os", "clicked_at"])
510
  for r in rows:
511
+ w.writerow([r["id"], r.get("referrer"), r.get("user_agent"), r.get("ip_address"),
512
+ r.get("browser"), r.get("device"), r.get("os"), r.get("clicked_at")])
513
  return buf.getvalue()
514
 
515
  def list_links_by_campaign(self, campaign_id: str) -> List[dict]:
516
+ import asyncio
517
+ return asyncio.run(self._get_client().select(
518
+ "url_shortener_links", eq=("campaign_id", campaign_id), order=("created_at", True),
519
  ))
520
 
521
  def create_campaign(self, campaign_id: str, owner_id: str, name: str, description: Optional[str] = None):
522
  with self._lock:
523
+ import asyncio
524
+ asyncio.run(self._get_client().insert("url_shortener_campaigns", {
525
+ "campaign_id": campaign_id,
526
+ "owner_id": owner_id,
527
+ "name": name,
528
+ "description": description,
529
+ "created_at": _now_iso(),
530
+ }))
531
 
532
  def get_campaign(self, campaign_id: str) -> Optional[dict]:
533
+ import asyncio
534
+ return asyncio.run(self._get_client().find_one("url_shortener_campaigns", "campaign_id", campaign_id))
 
 
535
 
536
  def list_campaigns_for_owner(self, owner_id: str) -> List[dict]:
537
+ import asyncio
538
+ return asyncio.run(self._get_client().select(
539
+ "url_shortener_campaigns", eq=("owner_id", owner_id), order=("created_at", True),
540
  ))
541
 
542
  def deactivate_campaign(self, campaign_id: str):
543
  with self._lock:
544
+ import asyncio
545
+ asyncio.run(self._get_client().update("url_shortener_campaigns", "campaign_id", campaign_id, {"is_active": 0}))
 
 
546
 
547
  def get_campaign_link_count(self, campaign_id: str) -> int:
548
+ import asyncio
549
+ links = asyncio.run(self._get_client().select("url_shortener_links", eq=("campaign_id", campaign_id)))
550
+ return len(links)
 
 
551
 
552
  def get_campaign_total_clicks(self, campaign_id: str) -> int:
553
+ import asyncio
554
+ links = asyncio.run(self._get_client().select("url_shortener_links", eq=("campaign_id", campaign_id)))
555
+ return sum(l.get("click_count") or 0 for l in links)
 
 
556
 
557
  def get_campaign_analytics(self, campaign_id: str) -> Dict[str, Any]:
558
+ import asyncio
559
+ client = self._get_client()
560
+ codes = asyncio.run(client.select("url_shortener_links", eq=("campaign_id", campaign_id)))
 
561
  total = 0
562
  browsers: Dict[str, int] = {}
563
  devices: Dict[str, int] = {}
564
  os_data: Dict[str, int] = {}
 
565
  for row in codes:
566
  code = row["short_code"]
567
+ clicks = asyncio.run(client.select("url_shortener_clicks", eq=("short_code", code)))
568
+ total += len(clicks)
569
+ for c in clicks:
570
+ b = c.get("browser")
571
+ if b:
572
+ browsers[b] = browsers.get(b, 0) + 1
573
+ d = c.get("device")
574
+ if d:
575
+ devices[d] = devices.get(d, 0) + 1
576
+ o = c.get("os")
577
+ if o:
578
+ os_data[o] = os_data.get(o, 0) + 1
 
 
 
 
 
 
 
 
579
  return {"total_clicks": total, "browsers": browsers, "devices": devices, "operating_systems": os_data}
580
 
581
  def owner_summary(self, owner_id: str) -> Dict[str, Any]:
582
  row = self.get_owner(owner_id)
583
  if row is None:
584
  return {}
585
+ import asyncio
586
+ client = self._get_client()
587
+ links = asyncio.run(client.select("url_shortener_links", eq=("owner_id", owner_id)))
588
+ link_count = len(links)
589
+ total_clicks = sum(l.get("click_count") or 0 for l in links)
590
+ active = sum(1 for l in links if l.get("is_active"))
 
 
 
 
591
  return {
592
  "owner_id": owner_id,
593
  "name": row["name"],
594
  "plan": row["plan"],
595
  "total_links": link_count,
596
+ "active_links": active,
597
  "total_clicks": total_clicks,
598
  "created_at": row["created_at"],
599
  }
 
603
  def __init__(self):
604
  self.storage = Storage()
605
  self.storage._init_schema()
606
+ logger.info("URLShortenerService initialized (Supabase)")
607
 
608
  def close(self) -> None:
609
  self.storage.close()
 
949
  raise LinkNotFoundError(f"No link found for code '{short_code}'")
950
  if row["owner_id"] != owner_id:
951
  raise AuthorizationError("You do not own this link")
952
+ import asyncio
953
+ client = get_supabase_client()
954
+ if client is None:
955
  return []
956
+ rows = asyncio.run(client.select(
957
+ "url_shortener_clicks", eq=("short_code", short_code),
 
 
958
  ))
959
+ return list(rows)[:limit]
960
 
961
  def get_qr_code(self, short_code: str) -> Dict[str, str]:
962
  row = self.storage.get_link(short_code)
app/services/vector_store_service.py CHANGED
@@ -14,7 +14,7 @@ import zvec
14
 
15
  from app.config import get_settings
16
  from app.core.logger import get_logger
17
- from app.core.tidb_manager import get_tidb_manager
18
  from app.services.chunking_service import chunk_text_async
19
  from app.services.embeddings_service import EmbeddingService
20
 
@@ -62,27 +62,26 @@ class VectorStoreService:
62
  def _get_collection(self, store_id: str) -> Optional["zvec.Collection"]:
63
  return self._collections.get(store_id)
64
 
65
- # --- TiDB persistence ---
66
 
67
  async def init_db(self) -> None:
68
- mgr = get_tidb_manager()
69
- if mgr is None:
70
- logger.warning("TiDB manager not available, vector store metadata not loaded")
71
  return
72
- rows = await mgr.fetchall_all(
73
- "SELECT * FROM vector_store_index ORDER BY created_at ASC"
74
- )
75
- for row in rows:
76
  record = VectorStoreRecord(
77
- store_id=row["store_id"],
78
- name=row["name"],
79
- path=row["path"],
80
- description=row.get("description", ""),
81
- metadata=json.loads(row.get("metadata_json", "{}") or "{}"),
82
- created_at=row["created_at"],
83
  )
84
  self._stores[record.store_id] = record
85
- store_path = row["path"]
86
  if os.path.exists(os.path.join(store_path, "__zvec_meta")):
87
  try:
88
  col = zvec.open(store_path)
@@ -92,39 +91,28 @@ class VectorStoreService:
92
  logger.warning("Could not open collection %s: %s", record.store_id, exc)
93
 
94
  async def _persist_store(self, record: VectorStoreRecord) -> None:
95
- mgr = get_tidb_manager()
96
- if mgr is None:
97
- logger.error("TiDB manager not available, cannot persist store")
98
  return
99
- existing = await mgr.fetchone(
100
- "SELECT store_id FROM vector_store_index WHERE store_id = %s",
101
- (record.store_id,),
 
 
 
 
 
 
102
  )
103
- if existing:
104
- await mgr.execute(
105
- "UPDATE vector_store_index SET name = %s, description = %s, "
106
- "metadata_json = %s WHERE store_id = %s",
107
- (record.name, record.description, json.dumps(record.metadata), record.store_id),
108
- )
109
- else:
110
- await mgr.execute(
111
- "INSERT INTO vector_store_index (store_id, name, path, description, metadata_json, created_at) "
112
- "VALUES (%s, %s, %s, %s, %s, %s)",
113
- (
114
- record.store_id, record.name, record.path,
115
- record.description, json.dumps(record.metadata),
116
- record.created_at,
117
- ),
118
- )
119
 
120
  async def _remove_persisted_store(self, store_id: str) -> None:
121
- mgr = get_tidb_manager()
122
- if mgr is None:
123
  return
124
- await mgr.execute(
125
- "DELETE FROM vector_store_index WHERE store_id = %s",
126
- (store_id,),
127
- )
128
 
129
  # --- Synchronous helpers (run in thread pool) ---
130
 
@@ -358,7 +346,6 @@ class VectorStoreService:
358
  "store_id": store_id,
359
  "name": record.name,
360
  "description": record.description,
361
- "app_id": settings.application_id or store_id,
362
  "embedding_dimension": _EMBEDDING_DIM,
363
  "document_count": doc_count,
364
  "created_at": record.created_at,
@@ -376,7 +363,7 @@ class VectorStoreService:
376
  name: str,
377
  description: str = "",
378
  metadata: Optional[Dict[str, Any]] = None,
379
- ) -> Tuple[str, str]:
380
  store_id = str(uuid.uuid4())
381
  store_path = self._store_path(store_id)
382
 
@@ -394,9 +381,8 @@ class VectorStoreService:
394
  self._stores[store_id] = record
395
  await self._persist_store(record)
396
 
397
- app_id = settings.application_id or store_id
398
- logger.info("Created vector store: %s (name=%s, app_id=%s)", store_id, name, app_id)
399
- return store_id, app_id
400
 
401
  def list_stores(self) -> List[VectorStoreRecord]:
402
  return list(self._stores.values())
 
14
 
15
  from app.config import get_settings
16
  from app.core.logger import get_logger
17
+ from app.services.supabase import get_supabase_client, VectorStoreRepository
18
  from app.services.chunking_service import chunk_text_async
19
  from app.services.embeddings_service import EmbeddingService
20
 
 
62
  def _get_collection(self, store_id: str) -> Optional["zvec.Collection"]:
63
  return self._collections.get(store_id)
64
 
65
+ # --- Supabase persistence ---
66
 
67
  async def init_db(self) -> None:
68
+ client = get_supabase_client()
69
+ if client is None:
70
+ logger.warning("Supabase not available, vector store metadata not loaded")
71
  return
72
+ repo = VectorStoreRepository(client)
73
+ indexes = await repo.find_all()
74
+ for index in indexes:
 
75
  record = VectorStoreRecord(
76
+ store_id=index.store_id,
77
+ name=index.name,
78
+ path=index.path,
79
+ description=index.description,
80
+ metadata=json.loads(index.metadata_json or "{}"),
81
+ created_at=index.created_at,
82
  )
83
  self._stores[record.store_id] = record
84
+ store_path = index.path
85
  if os.path.exists(os.path.join(store_path, "__zvec_meta")):
86
  try:
87
  col = zvec.open(store_path)
 
91
  logger.warning("Could not open collection %s: %s", record.store_id, exc)
92
 
93
  async def _persist_store(self, record: VectorStoreRecord) -> None:
94
+ client = get_supabase_client()
95
+ if client is None:
96
+ logger.error("Supabase not available, cannot persist store")
97
  return
98
+ repo = VectorStoreRepository(client)
99
+ from app.core.vector_store.models import VectorStoreIndex
100
+ index = VectorStoreIndex(
101
+ store_id=record.store_id,
102
+ name=record.name,
103
+ path=record.path,
104
+ description=record.description,
105
+ metadata_json=json.dumps(record.metadata),
106
+ created_at=record.created_at,
107
  )
108
+ await repo.upsert(index)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
  async def _remove_persisted_store(self, store_id: str) -> None:
111
+ client = get_supabase_client()
112
+ if client is None:
113
  return
114
+ repo = VectorStoreRepository(client)
115
+ await repo.delete(store_id)
 
 
116
 
117
  # --- Synchronous helpers (run in thread pool) ---
118
 
 
346
  "store_id": store_id,
347
  "name": record.name,
348
  "description": record.description,
 
349
  "embedding_dimension": _EMBEDDING_DIM,
350
  "document_count": doc_count,
351
  "created_at": record.created_at,
 
363
  name: str,
364
  description: str = "",
365
  metadata: Optional[Dict[str, Any]] = None,
366
+ ) -> str:
367
  store_id = str(uuid.uuid4())
368
  store_path = self._store_path(store_id)
369
 
 
381
  self._stores[store_id] = record
382
  await self._persist_store(record)
383
 
384
+ logger.info("Created vector store: %s (name=%s)", store_id, name)
385
+ return store_id
 
386
 
387
  def list_stores(self) -> List[VectorStoreRecord]:
388
  return list(self._stores.values())
pyproject.toml CHANGED
@@ -32,6 +32,7 @@ dependencies = [
32
  "spacy>=3.7.0",
33
  "phonenumbers>=8.13.0",
34
  "jsonschema>=4.21.0",
 
35
  ]
36
 
37
  [project.optional-dependencies]
 
32
  "spacy>=3.7.0",
33
  "phonenumbers>=8.13.0",
34
  "jsonschema>=4.21.0",
35
+ "supabase>=2.0.0",
36
  ]
37
 
38
  [project.optional-dependencies]
requirements.txt CHANGED
@@ -39,7 +39,7 @@ email-validator>=2.1.0
39
  slowapi>=0.1.9
40
 
41
  # Async database drivers
42
- aiomysql>=0.3.2
43
  asyncpg>=0.31.0
44
  motor>=3.7.1
45
 
 
39
  slowapi>=0.1.9
40
 
41
  # Async database drivers
42
+ supabase>=2.0.0
43
  asyncpg>=0.31.0
44
  motor>=3.7.1
45