jebin2 commited on
Commit
34f76dc
·
1 Parent(s): 2a0f64d

table change

Browse files
core/models.py CHANGED
@@ -1,49 +1,36 @@
1
  """
2
  SQLAlchemy models for the APIGateway application.
 
 
 
 
 
 
 
 
 
 
3
  """
4
- from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, Boolean
 
5
  from sqlalchemy.sql import func
6
  from core.database import Base
7
 
8
 
9
- class BlinkData(Base):
10
- """
11
- Model for storing decrypted blink data.
12
-
13
- Attributes:
14
- id: Primary key
15
- user_id: User identifier (first 20 chars from URL param)
16
- refer_url: Referer URL from request header
17
- json_data: Decrypted JSON data
18
- created_at: Timestamp of record creation
19
- """
20
- __tablename__ = "blink_data"
21
-
22
- id = Column(Integer, primary_key=True, autoincrement=True, index=True)
23
- user_id = Column(String(20), index=True, nullable=False)
24
- refer_url = Column(Text, nullable=True)
25
- ip_address = Column(String(45), nullable=True) # IPv6 can be up to 45 chars
26
- ipv4_address = Column(String(15), nullable=True)
27
- ipv6_address = Column(String(45), nullable=True)
28
- country = Column(String(100), nullable=True) # Country from IP geolocation
29
- region = Column(String(100), nullable=True) # Region/State from IP geolocation
30
- json_data = Column(JSON, nullable=True)
31
- created_at = Column(DateTime(timezone=True), server_default=func.now())
32
-
33
- def __repr__(self):
34
- return f"<BlinkData(id={self.id}, user_id={self.user_id})>"
35
-
36
 
37
  class User(Base):
38
  """
39
  User model for credit system.
40
- Supports both legacy secret key and Google OAuth authentication.
 
41
  """
42
  __tablename__ = "users"
43
 
44
  id = Column(Integer, primary_key=True, autoincrement=True, index=True)
45
  user_id = Column(String(50), unique=True, index=True, nullable=False) # Backend generated UUID
46
- temp_user_id = Column(String(50), index=True, nullable=True) # From frontend
47
  email = Column(String(255), unique=True, index=True, nullable=False)
48
 
49
  # Google OAuth fields
@@ -51,11 +38,7 @@ class User(Base):
51
  name = Column(String(255), nullable=True) # Display name from Google
52
  profile_picture = Column(Text, nullable=True) # Google profile picture URL
53
 
54
- # Legacy field (kept for migration, nullable now)
55
- secret_key_hash = Column(String(255), nullable=True)
56
-
57
  # Token versioning for JWT invalidation
58
- # Incrementing this invalidates all existing tokens for this user
59
  token_version = Column(Integer, default=1, nullable=False)
60
 
61
  # Credits and status
@@ -65,40 +48,101 @@ class User(Base):
65
  last_used_at = Column(DateTime(timezone=True), nullable=True)
66
  is_active = Column(Boolean, default=True)
67
 
 
 
 
 
 
 
 
68
  def __repr__(self):
69
  return f"<User(id={self.id}, email={self.email})>"
70
 
71
 
72
-
73
- class RateLimit(Base):
74
  """
75
- Rate limit tracking table.
 
 
 
 
 
 
 
76
  """
77
- __tablename__ = "rate_limits"
78
 
79
  id = Column(Integer, primary_key=True, autoincrement=True, index=True)
80
- identifier = Column(String(255), index=True, nullable=False) # IP or email
81
- endpoint = Column(String(255), index=True, nullable=False)
82
- attempts = Column(Integer, default=0)
83
- window_start = Column(DateTime(timezone=True), nullable=False)
84
- expires_at = Column(DateTime(timezone=True), nullable=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
 
 
 
 
86
 
87
  class AuditLog(Base):
88
  """
89
- Audit log for security events.
 
 
 
 
 
90
  """
91
  __tablename__ = "audit_logs"
92
 
93
  id = Column(Integer, primary_key=True, autoincrement=True, index=True)
94
- user_id = Column(String(50), nullable=True)
95
- action = Column(String(50), nullable=False)
96
- ip_address = Column(String(45), nullable=False)
97
- user_agent = Column(String(255), nullable=True)
98
- status = Column(String(20), nullable=False)
 
 
 
 
 
 
 
 
 
 
 
 
99
  error_message = Column(Text, nullable=True)
100
- timestamp = Column(DateTime(timezone=True), server_default=func.now())
 
 
 
 
 
 
 
 
 
101
 
 
 
 
102
 
103
  class GeminiJob(Base):
104
  """
@@ -109,7 +153,7 @@ class GeminiJob(Base):
109
 
110
  id = Column(Integer, primary_key=True, autoincrement=True, index=True)
111
  job_id = Column(String(100), unique=True, index=True, nullable=False) # Our ID for client
112
- user_id = Column(String(50), index=True, nullable=False) # User who requested
113
  job_type = Column(String(20), index=True, nullable=False) # video, image, text, analyze
114
  third_party_id = Column(String(255), nullable=True) # Gemini operation name (for video)
115
  status = Column(String(20), default="queued", index=True) # queued, processing, completed, failed, cancelled
@@ -130,29 +174,16 @@ class GeminiJob(Base):
130
  credits_reserved = Column(Integer, default=0) # Credits reserved for this job
131
  credits_refunded = Column(Boolean, default=False) # Whether credits were refunded
132
 
 
 
 
133
  def __repr__(self):
134
  return f"<GeminiJob(job_id={self.job_id}, type={self.job_type}, status={self.status}, priority={self.priority})>"
135
 
136
 
137
- class ApiKeyUsage(Base):
138
- """
139
- Track API key usage for round-robin load balancing.
140
- Only stores the key index, not the actual key for security.
141
- """
142
- __tablename__ = "api_key_usage"
143
-
144
- id = Column(Integer, primary_key=True, autoincrement=True)
145
- key_index = Column(Integer, unique=True, index=True, nullable=False) # Index of key in GEMINI_API_KEYS
146
- total_requests = Column(Integer, default=0)
147
- success_count = Column(Integer, default=0)
148
- failure_count = Column(Integer, default=0)
149
- last_error = Column(Text, nullable=True) # Stores the last error message for review
150
- last_used_at = Column(DateTime(timezone=True), nullable=True)
151
- created_at = Column(DateTime(timezone=True), server_default=func.now())
152
-
153
- def __repr__(self):
154
- return f"<ApiKeyUsage(index={self.key_index}, total={self.total_requests}, success={self.success_count}, failed={self.failure_count})>"
155
-
156
 
157
  class PaymentTransaction(Base):
158
  """
@@ -163,7 +194,7 @@ class PaymentTransaction(Base):
163
 
164
  id = Column(Integer, primary_key=True, autoincrement=True, index=True)
165
  transaction_id = Column(String(50), unique=True, index=True, nullable=False) # Our internal ID
166
- user_id = Column(String(50), index=True, nullable=False) # Links to User.user_id
167
 
168
  # Order details
169
  gateway = Column(String(20), nullable=False) # razorpay, stripe
@@ -187,12 +218,19 @@ class PaymentTransaction(Base):
187
  # Metadata
188
  razorpay_signature = Column(String(255), nullable=True) # For verification audit
189
  error_message = Column(Text, nullable=True)
190
- extra_data = Column(JSON, nullable=True) # Additional gateway-specific data (renamed from 'metadata' - reserved)
 
 
 
191
 
192
  def __repr__(self):
193
  return f"<PaymentTransaction(id={self.transaction_id}, status={self.status}, amount={self.amount_paise})>"
194
 
195
 
 
 
 
 
196
  class Contact(Base):
197
  """
198
  Contact form submissions from authenticated users.
@@ -201,12 +239,49 @@ class Contact(Base):
201
  __tablename__ = "contacts"
202
 
203
  id = Column(Integer, primary_key=True, autoincrement=True, index=True)
204
- user_id = Column(String(50), index=True, nullable=False) # Links to User.user_id
205
  email = Column(String(255), nullable=False, index=True) # User's email
206
  subject = Column(String(500), nullable=True)
207
  message = Column(Text, nullable=False)
208
  ip_address = Column(String(45), nullable=True) # IPv6 can be up to 45 chars
209
  created_at = Column(DateTime(timezone=True), server_default=func.now())
210
 
 
 
 
211
  def __repr__(self):
212
  return f"<Contact(id={self.id}, user_id={self.user_id}, email={self.email})>"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
  SQLAlchemy models for the APIGateway application.
3
+
4
+ Tables:
5
+ - User: Server-side users with credits
6
+ - ClientUser: Client identifiers mapping to server users
7
+ - AuditLog: Unified client/server audit logging
8
+ - GeminiJob: AI job queue
9
+ - PaymentTransaction: Credit purchases
10
+ - Contact: Support tickets
11
+ - ApiKeyUsage: API key load balancing
12
+ - RateLimit: Rate limiting
13
  """
14
+ from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, Boolean, ForeignKey
15
+ from sqlalchemy.orm import relationship
16
  from sqlalchemy.sql import func
17
  from core.database import Base
18
 
19
 
20
+ # =============================================================================
21
+ # User & Client Tracking
22
+ # =============================================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
  class User(Base):
25
  """
26
  User model for credit system.
27
+ Supports Google OAuth authentication.
28
+ One User can have many ClientUser mappings.
29
  """
30
  __tablename__ = "users"
31
 
32
  id = Column(Integer, primary_key=True, autoincrement=True, index=True)
33
  user_id = Column(String(50), unique=True, index=True, nullable=False) # Backend generated UUID
 
34
  email = Column(String(255), unique=True, index=True, nullable=False)
35
 
36
  # Google OAuth fields
 
38
  name = Column(String(255), nullable=True) # Display name from Google
39
  profile_picture = Column(Text, nullable=True) # Google profile picture URL
40
 
 
 
 
41
  # Token versioning for JWT invalidation
 
42
  token_version = Column(Integer, default=1, nullable=False)
43
 
44
  # Credits and status
 
48
  last_used_at = Column(DateTime(timezone=True), nullable=True)
49
  is_active = Column(Boolean, default=True)
50
 
51
+ # Relationships
52
+ client_users = relationship("ClientUser", back_populates="user", lazy="dynamic")
53
+ jobs = relationship("GeminiJob", back_populates="user", lazy="dynamic")
54
+ payments = relationship("PaymentTransaction", back_populates="user", lazy="dynamic")
55
+ contacts = relationship("Contact", back_populates="user", lazy="dynamic")
56
+ audit_logs = relationship("AuditLog", back_populates="user", lazy="dynamic")
57
+
58
  def __repr__(self):
59
  return f"<User(id={self.id}, email={self.email})>"
60
 
61
 
62
+ class ClientUser(Base):
 
63
  """
64
+ Map server users to multiple client identifiers.
65
+ Enables tracking users across devices, IPs, and login states.
66
+
67
+ Use cases:
68
+ - Track same user across multiple devices
69
+ - Link anonymous activity to logged-in user
70
+ - Detect same device before/after login
71
+ - Track users by network/location
72
  """
73
+ __tablename__ = "client_users"
74
 
75
  id = Column(Integer, primary_key=True, autoincrement=True, index=True)
76
+ user_id = Column(String(50), ForeignKey("users.user_id"), index=True, nullable=False)
77
+ client_user_id = Column(String(100), index=True, nullable=True) # Client-side temp identifier
78
+
79
+ # IP tracking for network/location correlation
80
+ ipv4_address = Column(String(15), nullable=True, index=True)
81
+ ipv6_address = Column(String(45), nullable=True, index=True)
82
+
83
+ # Device identification
84
+ device_fingerprint = Column(String(255), nullable=True, index=True) # Browser fingerprint hash
85
+ device_info = Column(JSON, nullable=True) # Browser, OS, screen size, language, etc.
86
+
87
+ # Timestamps
88
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
89
+ last_seen_at = Column(DateTime(timezone=True), nullable=True)
90
+
91
+ # Relationship
92
+ user = relationship("User", back_populates="client_users")
93
+
94
+ def __repr__(self):
95
+ return f"<ClientUser(user_id={self.user_id}, client_user_id={self.client_user_id})>"
96
+
97
 
98
+ # =============================================================================
99
+ # Audit Logging
100
+ # =============================================================================
101
 
102
  class AuditLog(Base):
103
  """
104
+ Unified audit log for client and server events.
105
+ Replaces BlinkData - captures all trackable events.
106
+
107
+ log_type:
108
+ - "client": Login attempts, page views, API calls from client
109
+ - "server": Job processing, credit changes, internal operations
110
  """
111
  __tablename__ = "audit_logs"
112
 
113
  id = Column(Integer, primary_key=True, autoincrement=True, index=True)
114
+ log_type = Column(String(20), index=True, nullable=False) # "client" or "server"
115
+
116
+ # User tracking
117
+ user_id = Column(String(50), ForeignKey("users.user_id"), nullable=True, index=True)
118
+ client_user_id = Column(String(100), nullable=True, index=True) # For anonymous client logs
119
+
120
+ # Event details
121
+ action = Column(String(50), index=True, nullable=False) # login, page_view, job_created, etc.
122
+ details = Column(JSON, nullable=True) # Flexible data storage
123
+
124
+ # Request context
125
+ ip_address = Column(String(45), nullable=True)
126
+ user_agent = Column(String(500), nullable=True)
127
+ refer_url = Column(Text, nullable=True)
128
+
129
+ # Outcome
130
+ status = Column(String(20), nullable=False) # success, failure, error
131
  error_message = Column(Text, nullable=True)
132
+
133
+ # Timestamp
134
+ timestamp = Column(DateTime(timezone=True), server_default=func.now(), index=True)
135
+
136
+ # Relationship
137
+ user = relationship("User", back_populates="audit_logs")
138
+
139
+ def __repr__(self):
140
+ return f"<AuditLog(type={self.log_type}, action={self.action}, status={self.status})>"
141
+
142
 
143
+ # =============================================================================
144
+ # Job Queue
145
+ # =============================================================================
146
 
147
  class GeminiJob(Base):
148
  """
 
153
 
154
  id = Column(Integer, primary_key=True, autoincrement=True, index=True)
155
  job_id = Column(String(100), unique=True, index=True, nullable=False) # Our ID for client
156
+ user_id = Column(String(50), ForeignKey("users.user_id"), index=True, nullable=False)
157
  job_type = Column(String(20), index=True, nullable=False) # video, image, text, analyze
158
  third_party_id = Column(String(255), nullable=True) # Gemini operation name (for video)
159
  status = Column(String(20), default="queued", index=True) # queued, processing, completed, failed, cancelled
 
174
  credits_reserved = Column(Integer, default=0) # Credits reserved for this job
175
  credits_refunded = Column(Boolean, default=False) # Whether credits were refunded
176
 
177
+ # Relationship
178
+ user = relationship("User", back_populates="jobs")
179
+
180
  def __repr__(self):
181
  return f"<GeminiJob(job_id={self.job_id}, type={self.job_type}, status={self.status}, priority={self.priority})>"
182
 
183
 
184
+ # =============================================================================
185
+ # Payments
186
+ # =============================================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
 
188
  class PaymentTransaction(Base):
189
  """
 
194
 
195
  id = Column(Integer, primary_key=True, autoincrement=True, index=True)
196
  transaction_id = Column(String(50), unique=True, index=True, nullable=False) # Our internal ID
197
+ user_id = Column(String(50), ForeignKey("users.user_id"), index=True, nullable=False)
198
 
199
  # Order details
200
  gateway = Column(String(20), nullable=False) # razorpay, stripe
 
218
  # Metadata
219
  razorpay_signature = Column(String(255), nullable=True) # For verification audit
220
  error_message = Column(Text, nullable=True)
221
+ extra_data = Column(JSON, nullable=True) # Additional gateway-specific data
222
+
223
+ # Relationship
224
+ user = relationship("User", back_populates="payments")
225
 
226
  def __repr__(self):
227
  return f"<PaymentTransaction(id={self.transaction_id}, status={self.status}, amount={self.amount_paise})>"
228
 
229
 
230
+ # =============================================================================
231
+ # Support & Utilities
232
+ # =============================================================================
233
+
234
  class Contact(Base):
235
  """
236
  Contact form submissions from authenticated users.
 
239
  __tablename__ = "contacts"
240
 
241
  id = Column(Integer, primary_key=True, autoincrement=True, index=True)
242
+ user_id = Column(String(50), ForeignKey("users.user_id"), index=True, nullable=False)
243
  email = Column(String(255), nullable=False, index=True) # User's email
244
  subject = Column(String(500), nullable=True)
245
  message = Column(Text, nullable=False)
246
  ip_address = Column(String(45), nullable=True) # IPv6 can be up to 45 chars
247
  created_at = Column(DateTime(timezone=True), server_default=func.now())
248
 
249
+ # Relationship
250
+ user = relationship("User", back_populates="contacts")
251
+
252
  def __repr__(self):
253
  return f"<Contact(id={self.id}, user_id={self.user_id}, email={self.email})>"
254
+
255
+
256
+ class RateLimit(Base):
257
+ """
258
+ Rate limit tracking table.
259
+ """
260
+ __tablename__ = "rate_limits"
261
+
262
+ id = Column(Integer, primary_key=True, autoincrement=True, index=True)
263
+ identifier = Column(String(255), index=True, nullable=False) # IP or email
264
+ endpoint = Column(String(255), index=True, nullable=False)
265
+ attempts = Column(Integer, default=0)
266
+ window_start = Column(DateTime(timezone=True), nullable=False)
267
+ expires_at = Column(DateTime(timezone=True), nullable=False)
268
+
269
+
270
+ class ApiKeyUsage(Base):
271
+ """
272
+ Track API key usage for round-robin load balancing.
273
+ Only stores the key index, not the actual key for security.
274
+ """
275
+ __tablename__ = "api_key_usage"
276
+
277
+ id = Column(Integer, primary_key=True, autoincrement=True)
278
+ key_index = Column(Integer, unique=True, index=True, nullable=False) # Index of key in GEMINI_API_KEYS
279
+ total_requests = Column(Integer, default=0)
280
+ success_count = Column(Integer, default=0)
281
+ failure_count = Column(Integer, default=0)
282
+ last_error = Column(Text, nullable=True) # Stores the last error message for review
283
+ last_used_at = Column(DateTime(timezone=True), nullable=True)
284
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
285
+
286
+ def __repr__(self):
287
+ return f"<ApiKeyUsage(index={self.key_index}, total={self.total_requests}, success={self.success_count}, failed={self.failure_count})>"
routers/auth.py CHANGED
@@ -13,7 +13,7 @@ import uuid
13
  import logging
14
 
15
  from core.database import get_db
16
- from core.models import User, AuditLog
17
  from core.schemas import (
18
  CheckRegistrationRequest,
19
  GoogleAuthRequest,
@@ -58,11 +58,12 @@ async def check_registration(
58
  if not await check_rate_limit(db, ip, "/auth/check-registration", 10, 1):
59
  raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many requests")
60
 
61
- query = select(User).where(User.temp_user_id == request.user_id)
 
62
  result = await db.execute(query)
63
- user = result.scalar_one_or_none()
64
 
65
- return {"is_registered": user is not None}
66
 
67
 
68
  @router.post("/google", response_model=AuthResponse)
@@ -108,6 +109,7 @@ async def google_auth(
108
 
109
  # Log failed attempt
110
  audit_log = AuditLog(
 
111
  user_id=None,
112
  action="google_auth",
113
  ip_address=ip,
@@ -139,15 +141,34 @@ async def google_auth(
139
  user.profile_picture = google_info.picture
140
  user.last_used_at = datetime.utcnow()
141
 
142
- # Link temp_user_id if provided and not already set
143
- if request.temp_user_id and not user.temp_user_id:
144
- user.temp_user_id = request.temp_user_id
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  else:
146
  # New user - create account
147
  is_new_user = True
148
  user = User(
149
  user_id="usr_" + str(uuid.uuid4()),
150
- temp_user_id=request.temp_user_id,
151
  email=google_info.email,
152
  google_id=google_info.google_id,
153
  name=google_info.name,
@@ -156,10 +177,23 @@ async def google_auth(
156
  )
157
  db.add(user)
158
  logger.info(f"New user created via Google: {google_info.email}")
 
 
 
 
 
 
 
 
 
 
 
159
 
160
  # Log successful auth
161
  audit_log = AuditLog(
 
162
  user_id=user.user_id,
 
163
  action="google_auth",
164
  ip_address=ip,
165
  status="success"
@@ -297,6 +331,7 @@ async def logout(
297
 
298
  # Log logout
299
  audit_log = AuditLog(
 
300
  user_id=user.user_id,
301
  action="logout",
302
  ip_address=ip,
 
13
  import logging
14
 
15
  from core.database import get_db
16
+ from core.models import User, AuditLog, ClientUser
17
  from core.schemas import (
18
  CheckRegistrationRequest,
19
  GoogleAuthRequest,
 
58
  if not await check_rate_limit(db, ip, "/auth/check-registration", 10, 1):
59
  raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many requests")
60
 
61
+ # Check if this client_user_id has been linked to a server user
62
+ query = select(ClientUser).where(ClientUser.client_user_id == request.user_id)
63
  result = await db.execute(query)
64
+ client_user = result.scalar_one_or_none()
65
 
66
+ return {"is_registered": client_user is not None}
67
 
68
 
69
  @router.post("/google", response_model=AuthResponse)
 
109
 
110
  # Log failed attempt
111
  audit_log = AuditLog(
112
+ log_type="server",
113
  user_id=None,
114
  action="google_auth",
115
  ip_address=ip,
 
141
  user.profile_picture = google_info.picture
142
  user.last_used_at = datetime.utcnow()
143
 
144
+ # Link client_user_id if provided
145
+ if request.temp_user_id:
146
+ # Check if this client mapping exists
147
+ client_query = select(ClientUser).where(
148
+ ClientUser.user_id == user.user_id,
149
+ ClientUser.client_user_id == request.temp_user_id
150
+ )
151
+ client_result = await db.execute(client_query)
152
+ existing_client = client_result.scalar_one_or_none()
153
+
154
+ if not existing_client:
155
+ # Create new client user mapping
156
+ client_user = ClientUser(
157
+ user_id=user.user_id,
158
+ client_user_id=request.temp_user_id,
159
+ ipv4_address=ip if ":" not in ip else None,
160
+ ipv6_address=ip if ":" in ip else None,
161
+ last_seen_at=datetime.utcnow()
162
+ )
163
+ db.add(client_user)
164
+ else:
165
+ # Update last seen
166
+ existing_client.last_seen_at = datetime.utcnow()
167
  else:
168
  # New user - create account
169
  is_new_user = True
170
  user = User(
171
  user_id="usr_" + str(uuid.uuid4()),
 
172
  email=google_info.email,
173
  google_id=google_info.google_id,
174
  name=google_info.name,
 
177
  )
178
  db.add(user)
179
  logger.info(f"New user created via Google: {google_info.email}")
180
+
181
+ # Create client user mapping if temp_user_id provided
182
+ if request.temp_user_id:
183
+ client_user = ClientUser(
184
+ user_id=user.user_id,
185
+ client_user_id=request.temp_user_id,
186
+ ipv4_address=ip if ":" not in ip else None,
187
+ ipv6_address=ip if ":" in ip else None,
188
+ last_seen_at=datetime.utcnow()
189
+ )
190
+ db.add(client_user)
191
 
192
  # Log successful auth
193
  audit_log = AuditLog(
194
+ log_type="server",
195
  user_id=user.user_id,
196
+ client_user_id=request.temp_user_id,
197
  action="google_auth",
198
  ip_address=ip,
199
  status="success"
 
331
 
332
  # Log logout
333
  audit_log = AuditLog(
334
+ log_type="server",
335
  user_id=user.user_id,
336
  action="logout",
337
  ip_address=ip,
routers/blink.py CHANGED
@@ -1,3 +1,7 @@
 
 
 
 
1
  from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
2
  from fastapi.responses import JSONResponse
3
  from sqlalchemy.ext.asyncio import AsyncSession
@@ -6,7 +10,7 @@ import ipaddress
6
  import logging
7
 
8
  from core.database import get_db
9
- from core.models import BlinkData, User, AuditLog, GeminiJob, Contact
10
  from services.encryption_service import decrypt_multiple_blocks
11
  from dependencies import get_geolocation
12
 
@@ -17,28 +21,45 @@ router = APIRouter()
17
  # User ID length constant
18
  USER_ID_LENGTH = 20
19
 
 
 
 
 
 
20
  @router.get("/api/data")
21
  async def get_data(
22
  page: int = Query(1, ge=1, description="Page number"),
23
  limit: int = Query(100, ge=1, le=500, description="Items per page"),
 
24
  db: AsyncSession = Depends(get_db)
25
  ):
26
  """
27
- Get paginated blink data.
 
28
  """
29
  try:
30
  offset = (page - 1) * limit
31
 
 
 
 
 
 
 
 
 
32
  # Get total count
33
- total_result = await db.execute(select(func.count(BlinkData.id)))
34
  total = total_result.scalar() or 0
35
 
36
  # Get unique users count
37
- unique_result = await db.execute(select(func.count(func.distinct(BlinkData.user_id))))
 
 
38
  unique_users = unique_result.scalar() or 0
39
 
40
  # Get paginated items
41
- query = select(BlinkData).order_by(BlinkData.id.desc()).offset(offset).limit(limit)
42
  result = await db.execute(query)
43
  items = result.scalars().all()
44
 
@@ -46,15 +67,16 @@ async def get_data(
46
  "items": [
47
  {
48
  "id": item.id,
 
49
  "user_id": item.user_id,
50
- "refer_url": item.refer_url,
 
 
51
  "ip_address": item.ip_address,
52
- "ipv4_address": item.ipv4_address,
53
- "ipv6_address": item.ipv6_address,
54
- "country": item.country,
55
- "region": item.region,
56
- "json_data": item.json_data,
57
- "created_at": item.created_at.isoformat() if item.created_at else None
58
  }
59
  for item in items
60
  ],
@@ -119,24 +141,96 @@ async def get_users(
119
  )
120
 
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  @router.get("/api/audit-logs")
123
  async def get_audit_logs(
124
  page: int = Query(1, ge=1, description="Page number"),
125
  limit: int = Query(50, ge=1, le=500, description="Items per page"),
 
 
126
  db: AsyncSession = Depends(get_db)
127
  ):
128
  """
129
- Get paginated audit logs.
130
  """
131
  try:
132
  offset = (page - 1) * limit
133
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  # Get total count
135
- total_result = await db.execute(select(func.count(AuditLog.id)))
136
  total = total_result.scalar() or 0
137
 
138
  # Get paginated items
139
- query = select(AuditLog).order_by(AuditLog.id.desc()).offset(offset).limit(limit)
140
  result = await db.execute(query)
141
  items = result.scalars().all()
142
 
@@ -144,8 +238,11 @@ async def get_audit_logs(
144
  "items": [
145
  {
146
  "id": item.id,
 
147
  "user_id": item.user_id,
 
148
  "action": item.action,
 
149
  "ip_address": item.ip_address,
150
  "status": item.status,
151
  "error_message": item.error_message,
@@ -198,7 +295,7 @@ async def get_gemini_jobs(
198
  "created_at": item.created_at.isoformat() if item.created_at else None,
199
  "completed_at": item.completed_at.isoformat() if item.completed_at else None
200
  }
201
- for item in items
202
  ],
203
  "total": total,
204
  "page": page,
@@ -324,6 +421,11 @@ async def get_contacts(
324
  detail="Error fetching contacts"
325
  )
326
 
 
 
 
 
 
327
  @router.get("/blink")
328
  async def blink(
329
  request: Request,
@@ -332,6 +434,7 @@ async def blink(
332
  ):
333
  """
334
  Process blink request with encrypted user data.
 
335
  """
336
  try:
337
  # Validate minimum length
@@ -341,96 +444,85 @@ async def blink(
341
  detail=f"Parameter 'userid' must be at least {USER_ID_LENGTH} characters"
342
  )
343
 
344
- # Extract user_id (first 20 characters)
345
- user_id = userid[:USER_ID_LENGTH]
346
 
347
  # Extract encrypted data (remaining characters)
348
  encrypted_data = userid[USER_ID_LENGTH:]
349
 
350
  if not encrypted_data:
351
- logger.warning(f"No encrypted data received for user: {user_id}")
352
- # Still store the record with empty json_data
353
  decrypted_results = []
354
  else:
355
- # Try to decrypt - might be single or multiple blocks
356
  try:
357
  decrypted_results = decrypt_multiple_blocks(encrypted_data)
358
  except Exception as e:
359
- logger.error(f"Decryption failed for user {user_id}: {e}")
360
- # Store with error information
361
  decrypted_results = [{"error": str(e), "raw_encrypted": encrypted_data[:100]}]
362
 
363
- # Get referer URL from headers (full URL, not just origin)
364
  refer_url = request.headers.get("referer")
 
365
 
366
  # Get client IP address
367
- # Check X-Forwarded-For header first (for proxies/load balancers)
368
  forwarded_for = request.headers.get("x-forwarded-for")
369
  if forwarded_for:
370
- # X-Forwarded-For can contain multiple IPs, take the first one
371
  ip_address = forwarded_for.split(",")[0].strip()
372
  else:
373
- # Fall back to direct client IP
374
  ip_address = request.client.host if request.client else None
375
 
376
  # Get geolocation from IP address
377
  country, region = await get_geolocation(ip_address)
378
 
379
- # Determine IPv4 vs IPv6
380
- ipv4_address = None
381
- ipv6_address = None
382
-
383
- if ip_address:
384
- try:
385
- ip_obj = ipaddress.ip_address(ip_address)
386
- if isinstance(ip_obj, ipaddress.IPv4Address):
387
- ipv4_address = ip_address
388
- elif isinstance(ip_obj, ipaddress.IPv6Address):
389
- ipv6_address = ip_address
390
- except ValueError:
391
- # Invalid IP address format, just keep it in ip_address
392
- pass
393
-
394
- # Store each decrypted result as separate records
395
  records_created = 0
396
  for json_data in decrypted_results:
397
- blink_record = BlinkData(
398
- user_id=user_id,
399
- refer_url=refer_url,
 
 
 
 
 
 
 
 
 
 
400
  ip_address=ip_address,
401
- ipv4_address=ipv4_address,
402
- ipv6_address=ipv6_address,
403
- country=country,
404
- region=region,
405
- json_data=json_data
406
  )
407
- db.add(blink_record)
408
  records_created += 1
409
 
410
- # If no results but we have encrypted data, store a record with the raw data reference
411
  if not decrypted_results and encrypted_data:
412
- blink_record = BlinkData(
413
- user_id=user_id,
414
- refer_url=refer_url,
 
 
 
415
  ip_address=ip_address,
416
- ipv4_address=ipv4_address,
417
- ipv6_address=ipv6_address,
418
- country=country,
419
- region=region,
420
- json_data={"encrypted_length": len(encrypted_data)}
421
  )
422
- db.add(blink_record)
423
  records_created = 1
424
 
425
  await db.commit()
426
 
427
- logger.info(f"Successfully processed blink for user: {user_id}, records: {records_created}")
428
 
429
  return JSONResponse(
430
  status_code=status.HTTP_200_OK,
431
  content={
432
  "status": "success",
433
- "user_id": user_id,
434
  "records_created": records_created
435
  }
436
  )
 
1
+ """
2
+ Blink Router - Admin endpoints for viewing and tracking data.
3
+ Uses AuditLog for unified client/server event tracking.
4
+ """
5
  from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
6
  from fastapi.responses import JSONResponse
7
  from sqlalchemy.ext.asyncio import AsyncSession
 
10
  import logging
11
 
12
  from core.database import get_db
13
+ from core.models import User, AuditLog, GeminiJob, Contact, ClientUser
14
  from services.encryption_service import decrypt_multiple_blocks
15
  from dependencies import get_geolocation
16
 
 
21
  # User ID length constant
22
  USER_ID_LENGTH = 20
23
 
24
+
25
+ # =============================================================================
26
+ # Admin Data Endpoints
27
+ # =============================================================================
28
+
29
  @router.get("/api/data")
30
  async def get_data(
31
  page: int = Query(1, ge=1, description="Page number"),
32
  limit: int = Query(100, ge=1, le=500, description="Items per page"),
33
+ log_type: str = Query(None, description="Filter by log type: client, server"),
34
  db: AsyncSession = Depends(get_db)
35
  ):
36
  """
37
+ Get paginated audit log data.
38
+ Replaces old BlinkData endpoint - now uses unified AuditLog.
39
  """
40
  try:
41
  offset = (page - 1) * limit
42
 
43
+ # Build query with optional filter
44
+ base_query = select(AuditLog)
45
+ count_query = select(func.count(AuditLog.id))
46
+
47
+ if log_type:
48
+ base_query = base_query.where(AuditLog.log_type == log_type)
49
+ count_query = count_query.where(AuditLog.log_type == log_type)
50
+
51
  # Get total count
52
+ total_result = await db.execute(count_query)
53
  total = total_result.scalar() or 0
54
 
55
  # Get unique users count
56
+ unique_result = await db.execute(
57
+ select(func.count(func.distinct(AuditLog.user_id)))
58
+ )
59
  unique_users = unique_result.scalar() or 0
60
 
61
  # Get paginated items
62
+ query = base_query.order_by(AuditLog.timestamp.desc()).offset(offset).limit(limit)
63
  result = await db.execute(query)
64
  items = result.scalars().all()
65
 
 
67
  "items": [
68
  {
69
  "id": item.id,
70
+ "log_type": item.log_type,
71
  "user_id": item.user_id,
72
+ "client_user_id": item.client_user_id,
73
+ "action": item.action,
74
+ "details": item.details,
75
  "ip_address": item.ip_address,
76
+ "refer_url": item.refer_url,
77
+ "status": item.status,
78
+ "error_message": item.error_message,
79
+ "timestamp": item.timestamp.isoformat() if item.timestamp else None
 
 
80
  }
81
  for item in items
82
  ],
 
141
  )
142
 
143
 
144
+ @router.get("/api/client-users")
145
+ async def get_client_users(
146
+ page: int = Query(1, ge=1, description="Page number"),
147
+ limit: int = Query(50, ge=1, le=500, description="Items per page"),
148
+ user_id: str = Query(None, description="Filter by server user_id"),
149
+ db: AsyncSession = Depends(get_db)
150
+ ):
151
+ """
152
+ Get paginated client user mappings.
153
+ Shows how server users map to multiple client identifiers.
154
+ """
155
+ try:
156
+ offset = (page - 1) * limit
157
+
158
+ # Build query with optional filter
159
+ base_query = select(ClientUser)
160
+ count_query = select(func.count(ClientUser.id))
161
+
162
+ if user_id:
163
+ base_query = base_query.where(ClientUser.user_id == user_id)
164
+ count_query = count_query.where(ClientUser.user_id == user_id)
165
+
166
+ # Get total count
167
+ total_result = await db.execute(count_query)
168
+ total = total_result.scalar() or 0
169
+
170
+ # Get paginated items
171
+ query = base_query.order_by(ClientUser.id.desc()).offset(offset).limit(limit)
172
+ result = await db.execute(query)
173
+ items = result.scalars().all()
174
+
175
+ return {
176
+ "items": [
177
+ {
178
+ "id": item.id,
179
+ "user_id": item.user_id,
180
+ "client_user_id": item.client_user_id,
181
+ "ipv4_address": item.ipv4_address,
182
+ "ipv6_address": item.ipv6_address,
183
+ "device_fingerprint": item.device_fingerprint,
184
+ "device_info": item.device_info,
185
+ "created_at": item.created_at.isoformat() if item.created_at else None,
186
+ "last_seen_at": item.last_seen_at.isoformat() if item.last_seen_at else None
187
+ }
188
+ for item in items
189
+ ],
190
+ "total": total,
191
+ "page": page,
192
+ "limit": limit
193
+ }
194
+ except Exception as e:
195
+ logger.error(f"Error fetching client users: {e}")
196
+ raise HTTPException(
197
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
198
+ detail="Error fetching client users"
199
+ )
200
+
201
+
202
  @router.get("/api/audit-logs")
203
  async def get_audit_logs(
204
  page: int = Query(1, ge=1, description="Page number"),
205
  limit: int = Query(50, ge=1, le=500, description="Items per page"),
206
+ log_type: str = Query(None, description="Filter by log type: client, server"),
207
+ action: str = Query(None, description="Filter by action"),
208
  db: AsyncSession = Depends(get_db)
209
  ):
210
  """
211
+ Get paginated audit logs with optional filters.
212
  """
213
  try:
214
  offset = (page - 1) * limit
215
 
216
+ # Build query with filters
217
+ base_query = select(AuditLog)
218
+ count_query = select(func.count(AuditLog.id))
219
+
220
+ if log_type:
221
+ base_query = base_query.where(AuditLog.log_type == log_type)
222
+ count_query = count_query.where(AuditLog.log_type == log_type)
223
+
224
+ if action:
225
+ base_query = base_query.where(AuditLog.action == action)
226
+ count_query = count_query.where(AuditLog.action == action)
227
+
228
  # Get total count
229
+ total_result = await db.execute(count_query)
230
  total = total_result.scalar() or 0
231
 
232
  # Get paginated items
233
+ query = base_query.order_by(AuditLog.timestamp.desc()).offset(offset).limit(limit)
234
  result = await db.execute(query)
235
  items = result.scalars().all()
236
 
 
238
  "items": [
239
  {
240
  "id": item.id,
241
+ "log_type": item.log_type,
242
  "user_id": item.user_id,
243
+ "client_user_id": item.client_user_id,
244
  "action": item.action,
245
+ "details": item.details,
246
  "ip_address": item.ip_address,
247
  "status": item.status,
248
  "error_message": item.error_message,
 
295
  "created_at": item.created_at.isoformat() if item.created_at else None,
296
  "completed_at": item.completed_at.isoformat() if item.completed_at else None
297
  }
298
+ for item in items
299
  ],
300
  "total": total,
301
  "page": page,
 
421
  detail="Error fetching contacts"
422
  )
423
 
424
+
425
+ # =============================================================================
426
+ # Client Tracking Endpoint
427
+ # =============================================================================
428
+
429
  @router.get("/blink")
430
  async def blink(
431
  request: Request,
 
434
  ):
435
  """
436
  Process blink request with encrypted user data.
437
+ Logs to AuditLog with log_type='client'.
438
  """
439
  try:
440
  # Validate minimum length
 
444
  detail=f"Parameter 'userid' must be at least {USER_ID_LENGTH} characters"
445
  )
446
 
447
+ # Extract client_user_id (first 20 characters)
448
+ client_user_id = userid[:USER_ID_LENGTH]
449
 
450
  # Extract encrypted data (remaining characters)
451
  encrypted_data = userid[USER_ID_LENGTH:]
452
 
453
  if not encrypted_data:
454
+ logger.warning(f"No encrypted data received for client: {client_user_id}")
 
455
  decrypted_results = []
456
  else:
 
457
  try:
458
  decrypted_results = decrypt_multiple_blocks(encrypted_data)
459
  except Exception as e:
460
+ logger.error(f"Decryption failed for client {client_user_id}: {e}")
 
461
  decrypted_results = [{"error": str(e), "raw_encrypted": encrypted_data[:100]}]
462
 
463
+ # Get referer URL from headers
464
  refer_url = request.headers.get("referer")
465
+ user_agent = request.headers.get("user-agent")
466
 
467
  # Get client IP address
 
468
  forwarded_for = request.headers.get("x-forwarded-for")
469
  if forwarded_for:
 
470
  ip_address = forwarded_for.split(",")[0].strip()
471
  else:
 
472
  ip_address = request.client.host if request.client else None
473
 
474
  # Get geolocation from IP address
475
  country, region = await get_geolocation(ip_address)
476
 
477
+ # Store each decrypted result as separate audit log entries
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
478
  records_created = 0
479
  for json_data in decrypted_results:
480
+ # Add geolocation to details
481
+ details = {
482
+ "data": json_data,
483
+ "country": country,
484
+ "region": region
485
+ }
486
+
487
+ audit_log = AuditLog(
488
+ log_type="client",
489
+ user_id=None, # Anonymous until linked
490
+ client_user_id=client_user_id,
491
+ action="blink",
492
+ details=details,
493
  ip_address=ip_address,
494
+ user_agent=user_agent,
495
+ refer_url=refer_url,
496
+ status="success"
 
 
497
  )
498
+ db.add(audit_log)
499
  records_created += 1
500
 
501
+ # If no results but we have encrypted data, store a record
502
  if not decrypted_results and encrypted_data:
503
+ audit_log = AuditLog(
504
+ log_type="client",
505
+ user_id=None,
506
+ client_user_id=client_user_id,
507
+ action="blink",
508
+ details={"encrypted_length": len(encrypted_data), "country": country, "region": region},
509
  ip_address=ip_address,
510
+ user_agent=user_agent,
511
+ refer_url=refer_url,
512
+ status="success"
 
 
513
  )
514
+ db.add(audit_log)
515
  records_created = 1
516
 
517
  await db.commit()
518
 
519
+ logger.info(f"Successfully processed blink for client: {client_user_id}, records: {records_created}")
520
 
521
  return JSONResponse(
522
  status_code=status.HTTP_200_OK,
523
  content={
524
  "status": "success",
525
+ "client_user_id": client_user_id,
526
  "records_created": records_created
527
  }
528
  )
templates/index.html CHANGED
@@ -295,9 +295,9 @@
295
  </header>
296
 
297
  <div class="tabs">
298
- <button class="tab-btn active" onclick="switchTab('blink')">📊 Blink Data</button>
 
299
  <button class="tab-btn" onclick="switchTab('users')">👥 Users</button>
300
- <button class="tab-btn" onclick="switchTab('audit')">📝 Audit Logs</button>
301
  <button class="tab-btn" onclick="switchTab('jobs')">⚡ Gemini Jobs</button>
302
  <button class="tab-btn" onclick="switchTab('payments')">💳 Payments</button>
303
  <button class="tab-btn" onclick="switchTab('contacts')">📧 Contacts</button>
@@ -315,25 +315,29 @@
315
  </div>
316
  </div>
317
 
318
- <!-- Blink Data Table -->
319
- <div class="table-container" id="blinkTable">
320
  <div class="table-wrapper">
321
  <table>
322
  <thead>
323
  <tr>
324
  <th>ID</th>
 
325
  <th>User ID</th>
 
 
 
 
 
326
  <th>Refer URL</th>
327
- <th>IPv4</th>
328
- <th>Country</th>
329
- <th>Region</th>
330
- <th>JSON Data</th>
331
- <th>Created At</th>
332
  </tr>
333
  </thead>
334
- <tbody id="blinkBody">
335
  <tr>
336
- <td colspan="8">
337
  <div class="loading">
338
  <div class="spinner"></div>Loading...
339
  </div>
@@ -343,10 +347,10 @@
343
  </table>
344
  </div>
345
  <div class="pagination">
346
- <button onclick="prevPage('blink')" id="blinkPrev" disabled>← Previous</button>
347
- <span class="page-info">Page <span id="blinkCurrentPage">1</span> of <span
348
- id="blinkTotalPages">1</span></span>
349
- <button onclick="nextPage('blink')" id="blinkNext">Next →</button>
350
  </div>
351
  </div>
352
 
@@ -359,16 +363,20 @@
359
  <th>ID</th>
360
  <th>User ID</th>
361
  <th>Email</th>
 
362
  <th>Name</th>
 
 
363
  <th>Credits</th>
364
  <th>Active</th>
365
  <th>Created At</th>
 
366
  <th>Last Used</th>
367
  </tr>
368
  </thead>
369
  <tbody id="usersBody">
370
  <tr>
371
- <td colspan="8">
372
  <div class="loading">
373
  <div class="spinner"></div>Loading...
374
  </div>
@@ -385,24 +393,26 @@
385
  </div>
386
  </div>
387
 
388
- <!-- Audit Logs Table -->
389
- <div class="table-container hidden" id="auditTable">
390
  <div class="table-wrapper">
391
  <table>
392
  <thead>
393
  <tr>
394
  <th>ID</th>
395
- <th>User ID</th>
396
- <th>Action</th>
397
- <th>IP Address</th>
398
- <th>Status</th>
399
- <th>Error</th>
400
- <th>Timestamp</th>
 
 
401
  </tr>
402
  </thead>
403
- <tbody id="auditBody">
404
  <tr>
405
- <td colspan="7">
406
  <div class="loading">
407
  <div class="spinner"></div>Loading...
408
  </div>
@@ -412,13 +422,12 @@
412
  </table>
413
  </div>
414
  <div class="pagination">
415
- <button onclick="prevPage('audit')" id="auditPrev" disabled>← Previous</button>
416
- <span class="page-info">Page <span id="auditCurrentPage">1</span> of <span
417
- id="auditTotalPages">1</span></span>
418
- <button onclick="nextPage('audit')" id="auditNext">Next →</button>
419
  </div>
420
  </div>
421
-
422
  <!-- Gemini Jobs Table -->
423
  <div class="table-container hidden" id="jobsTable">
424
  <div class="table-wrapper">
@@ -564,19 +573,19 @@
564
  <script>
565
  const PAGE_SIZE = 50;
566
  const state = {
567
- blink: { page: 1, total: 0 },
568
- users: { page: 1, total: 0 },
569
  audit: { page: 1, total: 0 },
 
 
570
  jobs: { page: 1, total: 0 },
571
  payments: { page: 1, total: 0, revenue: 0 },
572
  contacts: { page: 1, total: 0 }
573
  };
574
- let currentTab = 'blink';
575
 
576
  const endpoints = {
577
- blink: '/api/data',
578
- users: '/api/users',
579
  audit: '/api/audit-logs',
 
 
580
  jobs: '/api/gemini-jobs',
581
  payments: '/api/payment-transactions',
582
  contacts: '/api/contacts'
@@ -590,16 +599,16 @@
590
  event.target.classList.add('active');
591
 
592
  // Show/hide tables
593
- document.getElementById('blinkTable').classList.toggle('hidden', tab !== 'blink');
 
594
  document.getElementById('usersTable').classList.toggle('hidden', tab !== 'users');
595
- document.getElementById('auditTable').classList.toggle('hidden', tab !== 'audit');
596
  document.getElementById('jobsTable').classList.toggle('hidden', tab !== 'jobs');
597
  document.getElementById('paymentsTable').classList.toggle('hidden', tab !== 'payments');
598
  document.getElementById('contactsTable').classList.toggle('hidden', tab !== 'contacts');
599
  document.getElementById('keysTable').classList.toggle('hidden', tab !== 'keys');
600
 
601
- // Show/hide unique users stat (for blink and payments)
602
- document.getElementById('uniqueUsersCard').classList.toggle('hidden', tab !== 'blink' && tab !== 'payments');
603
 
604
  // Load data
605
  if (tab === 'keys') {
@@ -619,61 +628,71 @@
619
  }
620
  }
621
 
622
- function renderBlinkTable(items) {
623
- const tbody = document.getElementById('blinkBody');
624
  if (items.length === 0) {
625
- tbody.innerHTML = '<tr><td colspan="8"><div class="empty-state">No data available</div></td></tr>';
626
  return;
627
  }
628
  tbody.innerHTML = items.map(item => `
629
  <tr>
630
  <td>${item.id}</td>
631
- <td class="user-id">${item.user_id}</td>
 
 
 
 
 
 
632
  <td class="truncate" title="${item.refer_url || ''}">${item.refer_url || '-'}</td>
633
- <td>${item.ipv4_address || item.ip_address || '-'}</td>
634
- <td>${item.country || '-'}</td>
635
- <td>${item.region || '-'}</td>
636
- <td><pre class="json-data">${JSON.stringify(item.json_data, null, 2)}</pre></td>
637
- <td class="timestamp">${item.created_at ? new Date(item.created_at).toLocaleString() : '-'}</td>
638
  </tr>
639
  `).join('');
640
  }
641
 
642
- function renderUsersTable(items) {
643
- const tbody = document.getElementById('usersBody');
644
  if (items.length === 0) {
645
- tbody.innerHTML = '<tr><td colspan="8"><div class="empty-state">No users found</div></td></tr>';
646
  return;
647
  }
648
  tbody.innerHTML = items.map(item => `
649
  <tr>
650
  <td>${item.id}</td>
651
  <td class="user-id">${item.user_id}</td>
652
- <td>${item.email}</td>
653
- <td>${item.name || '-'}</td>
654
- <td><span class="credits-badge">${item.credits}</span></td>
655
- <td><span class="status-badge ${item.is_active ? 'status-success' : 'status-failed'}">${item.is_active ? 'Active' : 'Inactive'}</span></td>
 
656
  <td class="timestamp">${item.created_at ? new Date(item.created_at).toLocaleString() : '-'}</td>
657
- <td class="timestamp">${item.last_used_at ? new Date(item.last_used_at).toLocaleString() : '-'}</td>
658
  </tr>
659
  `).join('');
660
  }
661
 
662
- function renderAuditTable(items) {
663
- const tbody = document.getElementById('auditBody');
664
  if (items.length === 0) {
665
- tbody.innerHTML = '<tr><td colspan="7"><div class="empty-state">No audit logs found</div></td></tr>';
666
  return;
667
  }
668
  tbody.innerHTML = items.map(item => `
669
  <tr>
670
  <td>${item.id}</td>
671
- <td class="user-id">${item.user_id || '-'}</td>
672
- <td>${item.action}</td>
673
- <td>${item.ip_address}</td>
674
- <td><span class="status-badge status-${item.status}">${item.status}</span></td>
675
- <td class="truncate" title="${item.error_message || ''}">${item.error_message || '-'}</td>
676
- <td class="timestamp">${item.timestamp ? new Date(item.timestamp).toLocaleString() : '-'}</td>
 
 
 
 
 
677
  </tr>
678
  `).join('');
679
  }
@@ -736,9 +755,9 @@
736
  }
737
 
738
  const renderers = {
739
- blink: renderBlinkTable,
740
- users: renderUsersTable,
741
  audit: renderAuditTable,
 
 
742
  jobs: renderJobsTable,
743
  payments: renderPaymentsTable,
744
  contacts: renderContactsTable
@@ -767,7 +786,7 @@
767
  state.payments.revenue = data.total_revenue_rupees;
768
  document.getElementById('uniqueUsers').textContent = '₹' + data.total_revenue_rupees.toLocaleString();
769
  document.getElementById('uniqueUsersCard').querySelector('.stat-label').textContent = 'Total Revenue';
770
- } else if (tab === 'blink') {
771
  document.getElementById('uniqueUsersCard').querySelector('.stat-label').textContent = 'Unique Users';
772
  }
773
 
@@ -851,7 +870,7 @@
851
  }, 10000);
852
 
853
  // Initial load
854
- loadPage('blink', 1);
855
  </script>
856
  </body>
857
 
 
295
  </header>
296
 
297
  <div class="tabs">
298
+ <button class="tab-btn active" onclick="switchTab('audit')">📝 Audit Logs</button>
299
+ <button class="tab-btn" onclick="switchTab('clients')">🔗 Client Users</button>
300
  <button class="tab-btn" onclick="switchTab('users')">👥 Users</button>
 
301
  <button class="tab-btn" onclick="switchTab('jobs')">⚡ Gemini Jobs</button>
302
  <button class="tab-btn" onclick="switchTab('payments')">💳 Payments</button>
303
  <button class="tab-btn" onclick="switchTab('contacts')">📧 Contacts</button>
 
315
  </div>
316
  </div>
317
 
318
+ <!-- Audit Logs Table -->
319
+ <div class="table-container" id="auditMainTable">
320
  <div class="table-wrapper">
321
  <table>
322
  <thead>
323
  <tr>
324
  <th>ID</th>
325
+ <th>Log Type</th>
326
  <th>User ID</th>
327
+ <th>Client User ID</th>
328
+ <th>Action</th>
329
+ <th>Details (JSON)</th>
330
+ <th>IP Address</th>
331
+ <th>User Agent</th>
332
  <th>Refer URL</th>
333
+ <th>Status</th>
334
+ <th>Error</th>
335
+ <th>Timestamp</th>
 
 
336
  </tr>
337
  </thead>
338
+ <tbody id="auditMainBody">
339
  <tr>
340
+ <td colspan="12">
341
  <div class="loading">
342
  <div class="spinner"></div>Loading...
343
  </div>
 
347
  </table>
348
  </div>
349
  <div class="pagination">
350
+ <button onclick="prevPage('audit')" id="auditPrev" disabled>← Previous</button>
351
+ <span class="page-info">Page <span id="auditCurrentPage">1</span> of <span
352
+ id="auditTotalPages">1</span></span>
353
+ <button onclick="nextPage('audit')" id="auditNext">Next →</button>
354
  </div>
355
  </div>
356
 
 
363
  <th>ID</th>
364
  <th>User ID</th>
365
  <th>Email</th>
366
+ <th>Google ID</th>
367
  <th>Name</th>
368
+ <th>Profile Picture</th>
369
+ <th>Token Version</th>
370
  <th>Credits</th>
371
  <th>Active</th>
372
  <th>Created At</th>
373
+ <th>Updated At</th>
374
  <th>Last Used</th>
375
  </tr>
376
  </thead>
377
  <tbody id="usersBody">
378
  <tr>
379
+ <td colspan="12">
380
  <div class="loading">
381
  <div class="spinner"></div>Loading...
382
  </div>
 
393
  </div>
394
  </div>
395
 
396
+ <!-- Client Users Table -->
397
+ <div class="table-container hidden" id="clientsTable">
398
  <div class="table-wrapper">
399
  <table>
400
  <thead>
401
  <tr>
402
  <th>ID</th>
403
+ <th>User ID (Server)</th>
404
+ <th>Client User ID</th>
405
+ <th>IPv4 Address</th>
406
+ <th>IPv6 Address</th>
407
+ <th>Device Fingerprint</th>
408
+ <th>Device Info (JSON)</th>
409
+ <th>Created At</th>
410
+ <th>Last Seen At</th>
411
  </tr>
412
  </thead>
413
+ <tbody id="clientsBody">
414
  <tr>
415
+ <td colspan="9">
416
  <div class="loading">
417
  <div class="spinner"></div>Loading...
418
  </div>
 
422
  </table>
423
  </div>
424
  <div class="pagination">
425
+ <button onclick="prevPage('clients')" id="clientsPrev" disabled>← Previous</button>
426
+ <span class="page-info">Page <span id="clientsCurrentPage">1</span> of <span
427
+ id="clientsTotalPages">1</span></span>
428
+ <button onclick="nextPage('clients')" id="clientsNext">Next →</button>
429
  </div>
430
  </div>
 
431
  <!-- Gemini Jobs Table -->
432
  <div class="table-container hidden" id="jobsTable">
433
  <div class="table-wrapper">
 
573
  <script>
574
  const PAGE_SIZE = 50;
575
  const state = {
 
 
576
  audit: { page: 1, total: 0 },
577
+ clients: { page: 1, total: 0 },
578
+ users: { page: 1, total: 0 },
579
  jobs: { page: 1, total: 0 },
580
  payments: { page: 1, total: 0, revenue: 0 },
581
  contacts: { page: 1, total: 0 }
582
  };
583
+ let currentTab = 'audit';
584
 
585
  const endpoints = {
 
 
586
  audit: '/api/audit-logs',
587
+ clients: '/api/client-users',
588
+ users: '/api/users',
589
  jobs: '/api/gemini-jobs',
590
  payments: '/api/payment-transactions',
591
  contacts: '/api/contacts'
 
599
  event.target.classList.add('active');
600
 
601
  // Show/hide tables
602
+ document.getElementById('auditMainTable').classList.toggle('hidden', tab !== 'audit');
603
+ document.getElementById('clientsTable').classList.toggle('hidden', tab !== 'clients');
604
  document.getElementById('usersTable').classList.toggle('hidden', tab !== 'users');
 
605
  document.getElementById('jobsTable').classList.toggle('hidden', tab !== 'jobs');
606
  document.getElementById('paymentsTable').classList.toggle('hidden', tab !== 'payments');
607
  document.getElementById('contactsTable').classList.toggle('hidden', tab !== 'contacts');
608
  document.getElementById('keysTable').classList.toggle('hidden', tab !== 'keys');
609
 
610
+ // Show/hide unique users stat (only for audit and payments)
611
+ document.getElementById('uniqueUsersCard').classList.toggle('hidden', tab !== 'audit' && tab !== 'payments');
612
 
613
  // Load data
614
  if (tab === 'keys') {
 
628
  }
629
  }
630
 
631
+ function renderAuditTable(items) {
632
+ const tbody = document.getElementById('auditMainBody');
633
  if (items.length === 0) {
634
+ tbody.innerHTML = '<tr><td colspan="12"><div class="empty-state">No audit logs found</div></td></tr>';
635
  return;
636
  }
637
  tbody.innerHTML = items.map(item => `
638
  <tr>
639
  <td>${item.id}</td>
640
+ <td><span class="status-badge" style="background: rgba(0, 212, 255, 0.2); color: #00d4ff;">${item.log_type}</span></td>
641
+ <td class="user-id">${item.user_id || '-'}</td>
642
+ <td class="user-id">${item.client_user_id || '-'}</td>
643
+ <td>${item.action}</td>
644
+ <td><pre class="json-data">${JSON.stringify(item.details, null, 2)}</pre></td>
645
+ <td>${item.ip_address || '-'}</td>
646
+ <td class="truncate" title="${item.user_agent || ''}">${item.user_agent || '-'}</td>
647
  <td class="truncate" title="${item.refer_url || ''}">${item.refer_url || '-'}</td>
648
+ <td><span class="status-badge status-${item.status}">${item.status}</span></td>
649
+ <td class="truncate" title="${item.error_message || ''}">${item.error_message || '-'}</td>
650
+ <td class="timestamp">${item.timestamp ? new Date(item.timestamp).toLocaleString() : '-'}</td>
 
 
651
  </tr>
652
  `).join('');
653
  }
654
 
655
+ function renderClientsTable(items) {
656
+ const tbody = document.getElementById('clientsBody');
657
  if (items.length === 0) {
658
+ tbody.innerHTML = '<tr><td colspan="9"><div class="empty-state">No client users found</div></td></tr>';
659
  return;
660
  }
661
  tbody.innerHTML = items.map(item => `
662
  <tr>
663
  <td>${item.id}</td>
664
  <td class="user-id">${item.user_id}</td>
665
+ <td class="user-id">${item.client_user_id || '-'}</td>
666
+ <td>${item.ipv4_address || '-'}</td>
667
+ <td>${item.ipv6_address || '-'}</td>
668
+ <td class="truncate" title="${item.device_fingerprint || ''}">${item.device_fingerprint || '-'}</td>
669
+ <td><pre class="json-data">${JSON.stringify(item.device_info, null, 2)}</pre></td>
670
  <td class="timestamp">${item.created_at ? new Date(item.created_at).toLocaleString() : '-'}</td>
671
+ <td class="timestamp">${item.last_seen_at ? new Date(item.last_seen_at).toLocaleString() : '-'}</td>
672
  </tr>
673
  `).join('');
674
  }
675
 
676
+ function renderUsersTable(items) {
677
+ const tbody = document.getElementById('usersBody');
678
  if (items.length === 0) {
679
+ tbody.innerHTML = '<tr><td colspan="12"><div class="empty-state">No users found</div></td></tr>';
680
  return;
681
  }
682
  tbody.innerHTML = items.map(item => `
683
  <tr>
684
  <td>${item.id}</td>
685
+ <td class="user-id">${item.user_id}</td>
686
+ <td>${item.email}</td>
687
+ <td class="truncate" title="${item.google_id || ''}">${item.google_id || '-'}</td>
688
+ <td>${item.name || '-'}</td>
689
+ <td class="truncate" title="${item.profile_picture || ''}"><img src="${item.profile_picture || ''}" width="40" height="40" style="border-radius: 50%;" onerror="this.style.display='none'" /></td>
690
+ <td>${item.token_version || 1}</td>
691
+ <td><span class="credits-badge">${item.credits}</span></td>
692
+ <td><span class="status-badge ${item.is_active ? 'status-success' : 'status-failed'}">${item.is_active ? 'Active' : 'Inactive'}</span></td>
693
+ <td class="timestamp">${item.created_at ? new Date(item.created_at).toLocaleString() : '-'}</td>
694
+ <td class="timestamp">${item.updated_at ? new Date(item.updated_at).toLocaleString() : '-'}</td>
695
+ <td class="timestamp">${item.last_used_at ? new Date(item.last_used_at).toLocaleString() : '-'}</td>
696
  </tr>
697
  `).join('');
698
  }
 
755
  }
756
 
757
  const renderers = {
 
 
758
  audit: renderAuditTable,
759
+ clients: renderClientsTable,
760
+ users: renderUsersTable,
761
  jobs: renderJobsTable,
762
  payments: renderPaymentsTable,
763
  contacts: renderContactsTable
 
786
  state.payments.revenue = data.total_revenue_rupees;
787
  document.getElementById('uniqueUsers').textContent = '₹' + data.total_revenue_rupees.toLocaleString();
788
  document.getElementById('uniqueUsersCard').querySelector('.stat-label').textContent = 'Total Revenue';
789
+ } else if (tab === 'audit') {
790
  document.getElementById('uniqueUsersCard').querySelector('.stat-label').textContent = 'Unique Users';
791
  }
792
 
 
870
  }, 10000);
871
 
872
  // Initial load
873
+ loadPage('audit', 1);
874
  </script>
875
  </body>
876
 
tests/test_integration.py CHANGED
@@ -25,9 +25,9 @@ async def clear_tables(db_session):
25
  """Truncate all tables between tests."""
26
  async with db_session.begin():
27
  await db_session.execute(text("DELETE FROM users"))
 
28
  await db_session.execute(text("DELETE FROM rate_limits"))
29
  await db_session.execute(text("DELETE FROM audit_logs"))
30
- await db_session.execute(text("DELETE FROM blink_data"))
31
  await db_session.commit()
32
 
33
 
@@ -183,14 +183,15 @@ class TestBlinkFlow:
183
  assert response.status_code == 200
184
  data = response.json()
185
  assert data["status"] == "success"
186
- assert data["user_id"] == user_id
187
 
188
- # Verify data stored
189
  response = client.get("/api/data")
190
  assert response.status_code == 200
191
  items = response.json()["items"]
192
  assert len(items) > 0
193
- assert items[0]["user_id"] == user_id
 
194
 
195
 
196
  class TestRateLimiting:
 
25
  """Truncate all tables between tests."""
26
  async with db_session.begin():
27
  await db_session.execute(text("DELETE FROM users"))
28
+ await db_session.execute(text("DELETE FROM client_users"))
29
  await db_session.execute(text("DELETE FROM rate_limits"))
30
  await db_session.execute(text("DELETE FROM audit_logs"))
 
31
  await db_session.commit()
32
 
33
 
 
183
  assert response.status_code == 200
184
  data = response.json()
185
  assert data["status"] == "success"
186
+ assert data["client_user_id"] == user_id # Changed from user_id
187
 
188
+ # Verify data stored in audit_logs
189
  response = client.get("/api/data")
190
  assert response.status_code == 200
191
  items = response.json()["items"]
192
  assert len(items) > 0
193
+ assert items[0]["client_user_id"] == user_id # Changed from user_id
194
+ assert items[0]["log_type"] == "client" # New field
195
 
196
 
197
  class TestRateLimiting: