0 ? 'ml-4 pl-3 border-l-2 border-border/50' : ''}`}>
- {subtasks.map((sub: any) => {
+ {subtasks.map((sub: SubtaskNode) => {
const hasChildren = sub.subtasks && sub.subtasks.length > 0;
const isExpanded = expanded[sub.id];
@@ -996,7 +1001,7 @@ function SubtaskTree({
{hasChildren && isExpanded && (
@@ -1074,7 +1079,7 @@ function TaskDetailPanel({
queryKey: queryKeys.tasks.comments(task.id),
queryFn: () => tasksService.getComments(task.id),
});
- const comments = Array.isArray(commentsData) ? commentsData : (commentsData as any)?.comments ?? [];
+ const comments: ApiComment[] = commentsData ?? [];
const blockerMutation = useMutation({
mutationFn: () =>
@@ -1250,7 +1255,7 @@ function TaskDetailPanel({
Comments
- {comments.map((comment: any) => (
+ {comments.map((comment: ApiComment) => (
@@ -1540,7 +1545,7 @@ export default function TasksPage() {
const [sortBy, setSortBy] = useState('newest');
const [selectedTask, setSelectedTask] = useState(null);
const queryClient = useQueryClient();
- const { toggleTaskCreationSidebar } = useUIStore();
+ const { toggleTaskCreationSidebar: _toggleTaskCreationSidebar } = useUIStore();
const { data, isLoading, error } = useQuery({
queryKey: queryKeys.tasks.list({ search: searchQuery || undefined, root_only: true, limit: 100 }),
diff --git a/Frontend/src/pages/WorkforcePage.tsx b/Frontend/src/pages/WorkforcePage.tsx
index 6f51650..9cc8c13 100644
--- a/Frontend/src/pages/WorkforcePage.tsx
+++ b/Frontend/src/pages/WorkforcePage.tsx
@@ -351,7 +351,7 @@ function WorkforceScoresTab() {
}
}
- function SortIcon({ col }: { col: typeof sortBy }) {
+ function renderSortIcon(col: typeof sortBy) {
if (sortBy !== col) return null;
return sortDir === 'desc' ? (
@@ -386,35 +386,35 @@ function WorkforceScoresTab() {
onClick={() => handleSort('overall_score')}
>
Overall Score
-
+ {renderSortIcon("overall_score")}
handleSort('velocity_score')}
>
Productivity
-
+ {renderSortIcon("velocity_score")}
handleSort('quality_score')}
>
Quality
-
+ {renderSortIcon("quality_score")}
handleSort('collaboration_score')}
>
Collaboration
-
+ {renderSortIcon("collaboration_score")}
handleSort('learning_score')}
>
Growth
-
+ {renderSortIcon("learning_score")}
diff --git a/Frontend/src/services/auth.service.ts b/Frontend/src/services/auth.service.ts
index 2942f34..63f0a5e 100644
--- a/Frontend/src/services/auth.service.ts
+++ b/Frontend/src/services/auth.service.ts
@@ -1,27 +1,14 @@
import apiClient from '@/lib/api-client';
import type {
- ApiLoginResponse,
ApiRegisterResponse,
- ApiTokenResponse,
ApiCurrentUser,
ApiPasswordChange,
ApiUserUpdate,
ApiConsentResponse,
ApiConsentUpdate,
- ApiSession,
} from '@/types/api';
export const authService = {
- async login(email: string, password: string): Promise {
- const { data } = await apiClient.post('/auth/login', { email, password });
- return data;
- },
-
- async googleLogin(credential: string): Promise {
- const { data } = await apiClient.post('/auth/google', { credential });
- return data;
- },
-
async register(
email: string,
password: string,
@@ -41,22 +28,6 @@ export const authService = {
return data;
},
- async refresh(refreshToken: string): Promise {
- const { data } = await apiClient.post('/auth/refresh', {
- refresh_token: refreshToken,
- });
- return data;
- },
-
- async logout(): Promise {
- await apiClient.post('/auth/logout');
- },
-
- async logoutAll(): Promise<{ message: string }> {
- const { data } = await apiClient.post<{ message: string }>('/auth/logout-all');
- return data;
- },
-
async getMe(): Promise {
const { data } = await apiClient.get('/auth/me');
return data;
@@ -75,14 +46,6 @@ export const authService = {
return data;
},
- async resetPassword(token: string, newPassword: string): Promise<{ message: string }> {
- const { data } = await apiClient.post<{ message: string }>('/auth/reset-password', {
- token,
- new_password: newPassword,
- });
- return data;
- },
-
async getConsent(): Promise {
const { data } = await apiClient.get('/auth/consent');
return data;
@@ -93,8 +56,16 @@ export const authService = {
return data;
},
- async getSessions(): Promise {
- const { data } = await apiClient.get('/auth/sessions');
- return data;
+ async getSessions(): Promise> {
+ try {
+ const { data } = await apiClient.get('/auth/sessions');
+ return data;
+ } catch {
+ return [];
+ }
+ },
+
+ async logoutAll(): Promise {
+ await apiClient.post('/auth/logout-all');
},
};
diff --git a/Frontend/src/store/authStore.ts b/Frontend/src/store/authStore.ts
index 92ae02a..4118cd5 100644
--- a/Frontend/src/store/authStore.ts
+++ b/Frontend/src/store/authStore.ts
@@ -1,5 +1,6 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
+import { supabase } from '@/lib/supabase';
import { authService } from '@/services/auth.service';
import { mapCurrentUserToFrontend, splitFullName } from '@/types/mappers';
import { queryClient } from '@/hooks/useApi';
@@ -20,15 +21,16 @@ interface AuthState {
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise;
- googleLogin: (credential: string) => Promise;
+ oauthLogin: () => Promise;
signup: (email: string, password: string, name: string, company?: string, role?: string) => Promise;
logout: () => void;
updateUser: (user: Partial) => void;
+ initAuth: () => () => void;
}
export const useAuthStore = create()(
persist(
- (set, get) => ({
+ (set) => ({
user: null,
accessToken: null,
refreshToken: null,
@@ -38,12 +40,20 @@ export const useAuthStore = create()(
login: async (email: string, password: string) => {
set({ isLoading: true });
try {
- const response = await authService.login(email, password);
- const frontendUser = mapCurrentUserToFrontend(response.user);
+ const { data, error } = await supabase.auth.signInWithPassword({ email, password });
+ if (error) throw error;
+
+ // Store tokens first so the API client interceptor can attach them to getMe()
+ const accessToken = data.session?.access_token ?? null;
+ const refreshToken = data.session?.refresh_token ?? null;
+ set({ accessToken, refreshToken });
+
+ // Fetch full user profile with RBAC data from backend
+ const meResponse = await authService.getMe();
+ const frontendUser = mapCurrentUserToFrontend(meResponse);
+
set({
user: frontendUser,
- accessToken: response.tokens.access_token,
- refreshToken: response.tokens.refresh_token,
isAuthenticated: true,
isLoading: false,
});
@@ -53,18 +63,17 @@ export const useAuthStore = create()(
}
},
- googleLogin: async (credential: string) => {
+ oauthLogin: async () => {
set({ isLoading: true });
try {
- const response = await authService.googleLogin(credential);
- const frontendUser = mapCurrentUserToFrontend(response.user);
- set({
- user: frontendUser,
- accessToken: response.tokens.access_token,
- refreshToken: response.tokens.refresh_token,
- isAuthenticated: true,
- isLoading: false,
+ const { error } = await supabase.auth.signInWithOAuth({
+ provider: 'google',
+ options: {
+ redirectTo: window.location.origin + '/auth/callback',
+ },
});
+ if (error) throw error;
+ // OAuth redirects the browser; state is set via onAuthStateChange after redirect
} catch (error) {
set({ isLoading: false });
throw error;
@@ -75,19 +84,35 @@ export const useAuthStore = create()(
set({ isLoading: true });
try {
const { firstName, lastName } = splitFullName(name);
- const response = await authService.register(
+
+ // Sign up with Supabase Auth
+ const { data, error } = await supabase.auth.signUp({
+ email,
+ password,
+ options: {
+ data: { first_name: firstName, last_name: lastName },
+ },
+ });
+ if (error) throw error;
+
+ // Store tokens first so the API client interceptor can attach them
+ const accessToken = data.session?.access_token ?? null;
+ const refreshToken = data.session?.refresh_token ?? null;
+ set({ accessToken, refreshToken });
+
+ // Create the local user record in backend (org, role, etc.)
+ const registerResponse = await authService.register(
email,
password,
firstName,
lastName,
company || undefined,
- role || undefined
+ role || undefined,
);
- const frontendUser = mapCurrentUserToFrontend(response.user);
+ const frontendUser = mapCurrentUserToFrontend(registerResponse.user);
+
set({
user: frontendUser,
- accessToken: response.tokens.access_token,
- refreshToken: response.tokens.refresh_token,
isAuthenticated: true,
isLoading: false,
});
@@ -98,10 +123,7 @@ export const useAuthStore = create()(
},
logout: () => {
- const { accessToken } = get();
- if (accessToken) {
- authService.logout().catch(() => {});
- }
+ supabase.auth.signOut().catch(() => {});
queryClient.clear();
set({
user: null,
@@ -116,6 +138,46 @@ export const useAuthStore = create()(
user: state.user ? { ...state.user, ...userData } : null,
}));
},
+
+ initAuth: () => {
+ const { data: { subscription } } = supabase.auth.onAuthStateChange(
+ async (event, session) => {
+ if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED') {
+ if (session) {
+ set({
+ accessToken: session.access_token,
+ refreshToken: session.refresh_token,
+ });
+ try {
+ const meResponse = await authService.getMe();
+ const frontendUser = mapCurrentUserToFrontend(meResponse);
+ set({
+ user: frontendUser,
+ isAuthenticated: true,
+ isLoading: false,
+ });
+ } catch {
+ // Backend may not be reachable yet; tokens are stored,
+ // user profile will be fetched on next navigation
+ }
+ }
+ } else if (event === 'SIGNED_OUT') {
+ queryClient.clear();
+ set({
+ user: null,
+ accessToken: null,
+ refreshToken: null,
+ isAuthenticated: false,
+ });
+ }
+ },
+ );
+
+ // Return unsubscribe function for cleanup
+ return () => {
+ subscription.unsubscribe();
+ };
+ },
}),
{
name: 'taskpulse-auth',
diff --git a/Frontend/src/types/api.ts b/Frontend/src/types/api.ts
index e66781d..1238cff 100644
--- a/Frontend/src/types/api.ts
+++ b/Frontend/src/types/api.ts
@@ -52,6 +52,7 @@ export interface ApiCurrentUser {
team_id?: string;
manager_id?: string;
last_login?: string;
+ consent?: ApiConsentResponse;
created_at: string;
updated_at: string;
organization_name: string;
@@ -60,7 +61,7 @@ export interface ApiCurrentUser {
}
export interface ApiPasswordChange {
- current_password: string;
+ current_password?: string;
new_password: string;
}
@@ -68,7 +69,6 @@ export interface ApiRegisterResponse {
message: string;
user: ApiCurrentUser;
organization: { id: string; name: string; slug: string };
- tokens: ApiTokenResponse;
}
export interface ApiLoginResponse {
@@ -275,7 +275,7 @@ export interface ApiDashboardMetrics {
assigned_to?: string;
}>;
generated_at?: string;
- [key: string]: any;
+ [key: string]: unknown;
}
export interface ApiVelocityData {
@@ -499,16 +499,6 @@ export interface ApiConsentUpdate {
marketing?: boolean;
}
-export interface ApiSession {
- id: string;
- device_info?: string;
- ip_address?: string;
- user_agent?: string;
- last_activity?: string;
- created_at: string;
- is_current: boolean;
-}
-
export interface ApiForgotPassword {
email: string;
}
@@ -759,7 +749,7 @@ export interface ApiCheckInConfigCreate {
ai_sentiment_analysis?: boolean;
}
-export interface ApiCheckInConfigUpdate extends Partial { }
+export type ApiCheckInConfigUpdate = Partial;
export interface ApiCheckInStatistics {
total_checkins: number;
diff --git a/api/index.py b/api/index.py
new file mode 100644
index 0000000..016976a
--- /dev/null
+++ b/api/index.py
@@ -0,0 +1,157 @@
+"""
+Vercel serverless function — TaskPulse FastAPI backend.
+
+Uses the handler class pattern (compatible with @vercel/python in builds config)
+to serve the FastAPI app. A persistent event loop in a background thread ensures
+SQLAlchemy async connections work across multiple requests in a warm container.
+"""
+import sys
+import os
+import io
+import json
+import asyncio
+import threading
+from http.server import BaseHTTPRequestHandler
+from urllib.parse import urlparse, unquote
+
+# Add backend to Python path
+_backend_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'backend')
+if _backend_dir not in sys.path:
+ sys.path.insert(0, _backend_dir)
+
+os.environ.setdefault("ENVIRONMENT", "development")
+
+# ── Persistent event loop ─────────────────────────────────────────────────
+# SQLAlchemy's async engine binds connections to the event loop that created
+# them. Using asyncio.run() per request creates a NEW loop each time, causing
+# "Future attached to a different loop" errors on warm containers.
+# Solution: one long-lived loop in a daemon thread, shared across requests.
+_loop = asyncio.new_event_loop()
+_thread = threading.Thread(target=_loop.run_forever, daemon=True)
+_thread.start()
+
+# Import the FastAPI app (runs in the main thread; engine is created here
+# but connections will be lazily opened on the persistent loop above)
+_app = None
+_import_error = None
+try:
+ from app.main import app as _app
+except Exception:
+ import traceback
+ _import_error = traceback.format_exc()
+
+
+def _run_asgi_sync(scope, body=b""):
+ """Run an ASGI app on the persistent event loop and return (status, headers, body)."""
+ response_started = False
+ status_code = 500
+ response_headers = []
+ response_body = io.BytesIO()
+
+ async def receive():
+ return {"type": "http.request", "body": body, "more_body": False}
+
+ async def send(message):
+ nonlocal response_started, status_code, response_headers
+ if message["type"] == "http.response.start":
+ response_started = True
+ status_code = message["status"]
+ response_headers = [
+ (k.decode() if isinstance(k, bytes) else k,
+ v.decode() if isinstance(v, bytes) else v)
+ for k, v in message.get("headers", [])
+ ]
+ elif message["type"] == "http.response.body":
+ response_body.write(message.get("body", b""))
+
+ async def run():
+ await _app(scope, receive, send)
+
+ # Submit the coroutine to the persistent loop and wait for the result.
+ # Timeout slightly under Vercel's 30 s limit so we can return an error.
+ future = asyncio.run_coroutine_threadsafe(run(), _loop)
+ future.result(timeout=28)
+
+ return status_code, response_headers, response_body.getvalue()
+
+
+class handler(BaseHTTPRequestHandler):
+ def _handle(self):
+ if _app is None:
+ self.send_response(500)
+ self.send_header("Content-Type", "application/json")
+ self.end_headers()
+ self.wfile.write(json.dumps({
+ "error": "Backend import failed",
+ "traceback": _import_error,
+ }).encode())
+ return
+
+ # Read request body
+ content_length = int(self.headers.get("Content-Length", 0))
+ body = self.rfile.read(content_length) if content_length > 0 else b""
+
+ # Parse URL
+ parsed = urlparse(self.path)
+ path = unquote(parsed.path)
+ query_string = (parsed.query or "").encode()
+
+ # Build ASGI scope
+ headers = [
+ (k.lower().encode(), v.encode())
+ for k, v in self.headers.items()
+ ]
+
+ scope = {
+ "type": "http",
+ "asgi": {"version": "3.0"},
+ "http_version": "1.1",
+ "method": self.command,
+ "path": path,
+ "root_path": "",
+ "scheme": "https",
+ "query_string": query_string,
+ "headers": headers,
+ "server": ("localhost", 443),
+ }
+
+ try:
+ status_code, resp_headers, resp_body = _run_asgi_sync(scope, body)
+
+ self.send_response(status_code)
+ for key, value in resp_headers:
+ if key.lower() not in ("transfer-encoding", "connection"):
+ self.send_header(key, value)
+ self.end_headers()
+ self.wfile.write(resp_body)
+ except Exception as e:
+ self.send_response(500)
+ self.send_header("Content-Type", "application/json")
+ self.end_headers()
+ import traceback
+ self.wfile.write(json.dumps({
+ "error": "ASGI invocation failed",
+ "detail": str(e),
+ "traceback": traceback.format_exc(),
+ }).encode())
+
+ def do_GET(self):
+ self._handle()
+
+ def do_POST(self):
+ self._handle()
+
+ def do_PUT(self):
+ self._handle()
+
+ def do_PATCH(self):
+ self._handle()
+
+ def do_DELETE(self):
+ self._handle()
+
+ def do_OPTIONS(self):
+ self._handle()
+
+ def log_message(self, format, *args):
+ pass # Suppress default logging
diff --git a/backend/.env.example b/backend/.env.example
index 5d70869..0b1aa8e 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -6,8 +6,20 @@
# Security — REQUIRED: Generate with: python -c "import secrets; print(secrets.token_urlsafe(64))"
SECRET_KEY=
-# Google OAuth (optional)
-GOOGLE_CLIENT_ID=
+# ==================== Supabase (REQUIRED) ====================
+SUPABASE_URL=https://your-project.supabase.co
+SUPABASE_ANON_KEY=your-anon-key
+SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
+SUPABASE_JWT_SECRET=your-jwt-secret
+SUPABASE_STORAGE_BUCKET=documents
+
+# Database — Supabase PostgreSQL via connection pooler (Supavisor)
+# Format: postgresql+asyncpg://postgres.:@aws--.pooler.supabase.com:6543/postgres
+# Find the exact URL in: Supabase Dashboard → Settings → Database → Connection Pooling
+DATABASE_URL=postgresql+asyncpg://postgres.your-project:password@aws-1-region.pooler.supabase.com:6543/postgres
+
+# Google OAuth (configured in Supabase dashboard)
+# GOOGLE_CLIENT_ID=
# AI Provider: mock | ollama | openai | anthropic | mistral | kimi
AI_PROVIDER=mock
@@ -28,9 +40,5 @@ OLLAMA_MODEL=llama3
# KIMI_API_KEY=
# KIMI_MODEL=moonshot-v1-8k
-# Database (defaults to SQLite; use PostgreSQL for production)
-# DATABASE_URL=sqlite+aiosqlite:///./taskpulse.db
-# DATABASE_URL=postgresql+asyncpg://postgres:YOUR_PASSWORD@db.YOUR_PROJECT.supabase.co:5432/postgres
-
# Environment: development | staging | production
ENVIRONMENT=development
diff --git a/backend/app/agents/base.py b/backend/app/agents/base.py
index 64753c1..8ad6e97 100644
--- a/backend/app/agents/base.py
+++ b/backend/app/agents/base.py
@@ -7,7 +7,7 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
-from datetime import datetime
+from datetime import datetime, timezone
from enum import Enum
from typing import Any, Dict, List, Optional, TYPE_CHECKING
from uuid import uuid4
@@ -128,7 +128,7 @@ class AgentResult:
def complete(self, success: bool = True, error: Optional[str] = None):
"""Mark the result as complete"""
- self.completed_at = datetime.utcnow()
+ self.completed_at = datetime.now(timezone.utc)
self.success = success
self.error = error
if self.started_at:
@@ -255,7 +255,7 @@ async def validate(self, context: "AgentContext") -> bool:
async def before_execute(self, context: "AgentContext") -> None:
"""Hook called before execute(). Override for setup logic."""
self.status = AgentStatus.RUNNING
- self._last_execution = datetime.utcnow()
+ self._last_execution = datetime.now(timezone.utc)
async def after_execute(
self,
diff --git a/backend/app/agents/conversation/chat_agent.py b/backend/app/agents/conversation/chat_agent.py
index 2e77f46..f45367e 100644
--- a/backend/app/agents/conversation/chat_agent.py
+++ b/backend/app/agents/conversation/chat_agent.py
@@ -451,7 +451,7 @@ async def _handle_task_creation_followup(
else:
try:
from dateutil import parser as date_parser
- from datetime import datetime
+ from datetime import datetime, timezone
parsed_date = date_parser.parse(message, fuzzy=True, default=datetime.now())
pending["parsed"]["deadline"] = parsed_date.isoformat()
except Exception:
@@ -1092,7 +1092,7 @@ async def _get_user_stats(self, context: AgentContext) -> Dict[str, Any]:
try:
from sqlalchemy import select, func, and_
from app.models.task import Task, TaskStatus
- from datetime import datetime, timedelta
+ from datetime import datetime, timedelta, timezone
user_id = context.user.id
org_id = context.organization.id if context.organization else None
@@ -1116,7 +1116,7 @@ async def _get_user_stats(self, context: AgentContext) -> Dict[str, Any]:
status_counts[status.value] = result.scalar() or 0
# Completed today
- today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
+ today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
result = await context.db.execute(
select(func.count()).select_from(Task).where(
Task.assigned_to == user_id,
diff --git a/backend/app/agents/event_bus.py b/backend/app/agents/event_bus.py
index 8c23b17..3263223 100644
--- a/backend/app/agents/event_bus.py
+++ b/backend/app/agents/event_bus.py
@@ -9,7 +9,7 @@
import logging
from collections import defaultdict
from dataclasses import dataclass, field
-from datetime import datetime
+from datetime import datetime, timezone
from enum import Enum
from typing import Any, Callable, Dict, List, Optional, Set, TYPE_CHECKING
from uuid import uuid4
@@ -316,7 +316,7 @@ async def _process_event(self, queued: QueuedEvent) -> None:
raise
queued.status = EventStatus.COMPLETED
- queued.processed_at = datetime.utcnow()
+ queued.processed_at = datetime.now(timezone.utc)
self._events_processed += 1
except Exception as e:
diff --git a/backend/app/agents/integrations/base_integration.py b/backend/app/agents/integrations/base_integration.py
index 4c98482..d1dd2d0 100644
--- a/backend/app/agents/integrations/base_integration.py
+++ b/backend/app/agents/integrations/base_integration.py
@@ -8,7 +8,7 @@
import logging
from abc import abstractmethod
from dataclasses import dataclass, field
-from datetime import datetime
+from datetime import datetime, timezone
from enum import Enum
from typing import Any, Dict, List, Optional, TYPE_CHECKING
@@ -58,7 +58,7 @@ class SyncResult:
duration_ms: Optional[int] = None
def complete(self):
- self.completed_at = datetime.utcnow()
+ self.completed_at = datetime.now(timezone.utc)
self.duration_ms = int(
(self.completed_at - self.started_at).total_seconds() * 1000
)
@@ -203,7 +203,7 @@ async def sync(self, context: AgentContext) -> SyncResult:
sync_result.items_synced += outbound_result.get("count", 0)
sync_result.items_updated += outbound_result.get("updated", 0)
- self._last_sync = datetime.utcnow()
+ self._last_sync = datetime.now(timezone.utc)
self.connection_status = IntegrationStatus.CONNECTED
except Exception as e:
diff --git a/backend/app/agents/integrations/calendar_agent.py b/backend/app/agents/integrations/calendar_agent.py
index 75bf98a..761be78 100644
--- a/backend/app/agents/integrations/calendar_agent.py
+++ b/backend/app/agents/integrations/calendar_agent.py
@@ -5,7 +5,7 @@
"""
import logging
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from ..base import AgentCapability, EventType
@@ -391,7 +391,7 @@ async def _find_available_slot(
) -> datetime:
"""Find next available time slot"""
# In production, query calendar for free slots
- start = start_from or datetime.utcnow()
+ start = start_from or datetime.now(timezone.utc)
# Round to next hour
start = start.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
diff --git a/backend/app/agents/integrations/email_agent.py b/backend/app/agents/integrations/email_agent.py
index 8fad6b5..dc86b53 100644
--- a/backend/app/agents/integrations/email_agent.py
+++ b/backend/app/agents/integrations/email_agent.py
@@ -5,7 +5,7 @@
"""
import logging
-from datetime import datetime
+from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from ..base import AgentCapability, EventType
@@ -178,7 +178,7 @@ async def send_daily_digest(
"""Send daily digest email"""
data = {
"user_name": user_name,
- "date": datetime.utcnow().strftime("%B %d, %Y"),
+ "date": datetime.now(timezone.utc).strftime("%B %d, %Y"),
"tasks": tasks,
"stats": stats,
}
@@ -204,7 +204,7 @@ async def send_escalation(
"employee_name": employee_name,
"task": task,
"reason": reason,
- "escalated_at": datetime.utcnow().isoformat(),
+ "escalated_at": datetime.now(timezone.utc).isoformat(),
}
return await self.send_notification(
diff --git a/backend/app/agents/orchestrator.py b/backend/app/agents/orchestrator.py
index 8ecabca..fff3be6 100644
--- a/backend/app/agents/orchestrator.py
+++ b/backend/app/agents/orchestrator.py
@@ -8,7 +8,7 @@
import asyncio
import logging
from dataclasses import dataclass, field
-from datetime import datetime
+from datetime import datetime, timezone
from typing import Any, Callable, Dict, List, Optional, Type, TYPE_CHECKING
from uuid import uuid4
@@ -513,7 +513,7 @@ async def _execute_agent(
record.error = str(e)
finally:
- record.completed_at = datetime.utcnow()
+ record.completed_at = datetime.now(timezone.utc)
record.duration_ms = int(
(record.completed_at - record.started_at).total_seconds() * 1000
)
diff --git a/backend/app/agents/predictor_agent.py b/backend/app/agents/predictor_agent.py
index c4411f2..70a3f33 100644
--- a/backend/app/agents/predictor_agent.py
+++ b/backend/app/agents/predictor_agent.py
@@ -7,7 +7,7 @@
import logging
import random
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from .base import (
@@ -150,7 +150,7 @@ async def _predict_task(self, context: AgentContext) -> Dict[str, Any]:
p90_hours = adjusted_remaining * 1.5 # Pessimistic
# Convert to dates
- now = datetime.utcnow()
+ now = datetime.now(timezone.utc)
work_hours_per_day = 6 # Assume 6 productive hours per day
prediction = {
@@ -265,7 +265,7 @@ def _is_on_track(self, task: Any, p50_hours: float) -> bool:
return True
due = task.due_date if isinstance(task.due_date, datetime) else datetime.fromisoformat(str(task.due_date))
- hours_until_due = (due - datetime.utcnow()).total_seconds() / 3600
+ hours_until_due = (due - datetime.now(timezone.utc)).total_seconds() / 3600
work_hours = hours_until_due * (6/24) # 6 productive hours per day
return p50_hours <= work_hours
@@ -294,7 +294,7 @@ async def _analyze_risks(self, context: AgentContext) -> Dict[str, Any]:
if task.due_date:
due = task.due_date if isinstance(task.due_date, datetime) else datetime.fromisoformat(str(task.due_date))
- days_until_due = (due - datetime.utcnow()).days
+ days_until_due = (due - datetime.now(timezone.utc)).days
if days_until_due < 0:
risks.append({
diff --git a/backend/app/api/v1/admin.py b/backend/app/api/v1/admin.py
index 515347c..dda1c7b 100644
--- a/backend/app/api/v1/admin.py
+++ b/backend/app/api/v1/admin.py
@@ -7,7 +7,7 @@
from fastapi import APIRouter, Depends, Query, status, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, text
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
from app.database import get_db
from app.models.user import User, UserRole
@@ -206,7 +206,7 @@ async def get_system_health(
"total_mb": 10240
},
active_alerts=[],
- snapshot_time=datetime.utcnow()
+ snapshot_time=datetime.now(timezone.utc)
)
@@ -253,7 +253,7 @@ async def request_gdpr_export(
return GDPRExportResponse(
request_id=request.id,
status="pending",
- estimated_completion=datetime.utcnow() + timedelta(hours=24),
+ estimated_completion=datetime.now(timezone.utc) + timedelta(hours=24),
message="Data export request submitted. You will be notified when ready."
)
@@ -320,7 +320,7 @@ async def get_ai_governance(
"model_info": {
"provider": "mock",
"version": "v1.0",
- "last_updated": datetime.utcnow().isoformat()
+ "last_updated": datetime.now(timezone.utc).isoformat()
},
"usage_stats": {
"total_requests_30d": 15000,
@@ -335,7 +335,7 @@ async def get_ai_governance(
"bias_monitoring": {
"gender_bias_score": 0.05,
"role_bias_score": 0.08,
- "last_audit": datetime.utcnow().isoformat()
+ "last_audit": datetime.now(timezone.utc).isoformat()
},
"human_overrides": {
"total_30d": 45,
@@ -396,7 +396,7 @@ async def create_api_key(
expires_at = None
if key_data.expires_in_days:
- expires_at = datetime.utcnow() + timedelta(days=key_data.expires_in_days)
+ expires_at = datetime.now(timezone.utc) + timedelta(days=key_data.expires_in_days)
api_key = APIKey(
id=generate_uuid(),
diff --git a/backend/app/api/v1/agents.py b/backend/app/api/v1/agents.py
index 5e8913c..bc31304 100644
--- a/backend/app/api/v1/agents.py
+++ b/backend/app/api/v1/agents.py
@@ -4,7 +4,7 @@
Manage agents, execute them, and view execution history.
"""
-from datetime import datetime
+from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from uuid import uuid4
@@ -233,7 +233,7 @@ async def run_agent_pipeline(
)
# Run pipeline
- start_time = datetime.utcnow()
+ start_time = datetime.now(timezone.utc)
try:
results = await orchestrator.run_pipeline(
@@ -242,7 +242,7 @@ async def run_agent_pipeline(
stop_on_error=request.stop_on_error
)
- end_time = datetime.utcnow()
+ end_time = datetime.now(timezone.utc)
total_duration = int((end_time - start_time).total_seconds() * 1000)
response_results = [
diff --git a/backend/app/api/v1/ai_unblock.py b/backend/app/api/v1/ai_unblock.py
index c90bb64..d96a30b 100644
--- a/backend/app/api/v1/ai_unblock.py
+++ b/backend/app/api/v1/ai_unblock.py
@@ -289,16 +289,23 @@ async def upload_document(
detail="Uploaded file is empty."
)
- # Decode based on file type
+ # Extract text content from file using file extractor
+ from app.utils.file_extractor import extract_text_from_bytes
try:
- text_content = content.decode('utf-8')
- except UnicodeDecodeError:
+ text_content = extract_text_from_bytes(content, filename)
+ except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
- detail="File must be a text file (txt, md, etc.)"
+ detail=f"Failed to extract text from file: {e}"
)
- # Create document
+ if not text_content.strip():
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="No text could be extracted from the uploaded file."
+ )
+
+ # Create document record
doc_data = DocumentCreate(
title=file.filename or "Uploaded Document",
content=text_content,
@@ -312,6 +319,26 @@ async def upload_document(
doc.file_type = file.content_type
doc.file_size = len(content)
+ # Upload raw file to Supabase Storage
+ from app.services.storage_service import StorageService
+ try:
+ storage = StorageService()
+ storage_path, storage_url = await storage.upload_file(
+ org_id=current_user.org_id,
+ doc_id=doc.id,
+ filename=filename,
+ content=content,
+ content_type=file.content_type or "application/octet-stream",
+ )
+ doc.storage_path = storage_path
+ doc.storage_url = storage_url
+ except Exception:
+ import logging
+ logging.getLogger(__name__).warning(
+ "Supabase storage upload failed for doc %s; document saved without file storage",
+ doc.id,
+ )
+
await service.db.flush()
return UploadResponse(
diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py
index 9dd430f..fcabf65 100644
--- a/backend/app/api/v1/auth.py
+++ b/backend/app/api/v1/auth.py
@@ -1,42 +1,37 @@
"""
TaskPulse - AI Assistant - Authentication API
-Endpoints for user authentication and session management
+Endpoints for user authentication via Supabase Auth
"""
-from typing import Optional
from fastapi import APIRouter, Depends, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field
from app.database import get_db
-from app.config import settings
from app.services.auth_service import AuthService
from app.schemas.user import (
UserRegister,
UserLogin,
- TokenResponse,
- TokenRefresh,
PasswordReset,
- PasswordResetConfirm,
PasswordChange,
UserResponse,
CurrentUserResponse,
ConsentUpdate,
ConsentResponse,
- SessionResponse,
- SessionListResponse
)
-from app.api.v1.dependencies import get_current_user, get_current_active_user
+from app.api.v1.dependencies import get_current_active_user
from app.models.user import User
-class GoogleAuthRequest(BaseModel):
- """Google OAuth login request."""
- credential: str = Field(..., description="Google ID token from Sign In with Google")
+router = APIRouter()
-router = APIRouter()
+# ==================== Request/Response Models ====================
+
+class RefreshTokenRequest(BaseModel):
+ """Request body for token refresh."""
+ refresh_token: str = Field(..., description="Supabase refresh token")
# ==================== Registration & Login ====================
@@ -46,12 +41,12 @@ class GoogleAuthRequest(BaseModel):
response_model=dict,
status_code=status.HTTP_201_CREATED,
summary="Register new user",
- description="Register a new user. Creates new organization if org_name provided, or joins existing if org_id provided."
+ description="Register a new user via Supabase Auth. Creates new organization if org_name provided, or joins existing if org_id provided.",
)
async def register(
user_data: UserRegister,
request: Request,
- db: AsyncSession = Depends(get_db)
+ db: AsyncSession = Depends(get_db),
):
"""
Register a new user account.
@@ -65,14 +60,13 @@ async def register(
"""
auth_service = AuthService(db)
- # Get client info
ip_address = request.client.host if request.client else None
user_agent = request.headers.get("User-Agent")
- user, org, tokens = await auth_service.register(
+ user, org = await auth_service.register(
user_data,
ip_address=ip_address,
- user_agent=user_agent
+ user_agent=user_agent,
)
return {
@@ -81,9 +75,8 @@ async def register(
"organization": {
"id": org.id,
"name": org.name,
- "slug": org.slug
+ "slug": org.slug,
},
- "tokens": tokens
}
@@ -91,235 +84,86 @@ async def register(
"/login",
response_model=dict,
summary="User login",
- description="Authenticate user with email and password"
+ description="Authenticate user with email and password via Supabase Auth",
)
async def login(
credentials: UserLogin,
request: Request,
- db: AsyncSession = Depends(get_db)
+ db: AsyncSession = Depends(get_db),
):
"""
- Authenticate user and get access tokens.
+ Authenticate user and get Supabase session tokens.
- **email**: User's email address
- **password**: User's password
- Returns access and refresh tokens for API authentication.
+ Returns user info and Supabase access/refresh tokens.
"""
auth_service = AuthService(db)
- # Get client info
ip_address = request.client.host if request.client else None
user_agent = request.headers.get("User-Agent")
- user, tokens = await auth_service.login(
+ user, supabase_session = await auth_service.login(
email=credentials.email,
password=credentials.password,
ip_address=ip_address,
- user_agent=user_agent
+ user_agent=user_agent,
)
return {
"message": "Login successful",
"user": UserResponse.model_validate(user),
- "tokens": tokens
- }
-
-
-@router.post(
- "/google",
- response_model=dict,
- summary="Google OAuth login",
- description="Authenticate with Google Sign-In ID token"
-)
-async def google_login(
- payload: GoogleAuthRequest,
- request: Request,
- db: AsyncSession = Depends(get_db)
-):
- """
- Authenticate user with a Google ID token.
-
- - **credential**: The ID token from Google Sign-In
-
- Creates a new account if the user doesn't exist.
- Returns access and refresh tokens.
- """
- import logging
- from google.oauth2 import id_token
- from google.auth.transport import requests as google_requests
-
- logger = logging.getLogger(__name__)
-
- client_id = settings.GOOGLE_CLIENT_ID
- if not client_id:
- from fastapi import HTTPException
- raise HTTPException(
- status_code=status.HTTP_501_NOT_IMPLEMENTED,
- detail="Google OAuth is not configured"
- )
-
- # Verify the Google ID token
- try:
- idinfo = id_token.verify_oauth2_token(
- payload.credential,
- google_requests.Request(),
- client_id,
- )
- except ValueError as e:
- logger.warning(f"Google token verification failed: {e}")
- from fastapi import HTTPException
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail="Invalid Google credential"
- )
-
- email = idinfo.get("email")
- if not email or not idinfo.get("email_verified"):
- from fastapi import HTTPException
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail="Google account email not verified"
- )
-
- first_name = idinfo.get("given_name", "")
- last_name = idinfo.get("family_name", "")
- avatar_url = idinfo.get("picture", "")
-
- auth_service = AuthService(db)
- ip_address = request.client.host if request.client else None
- user_agent = request.headers.get("User-Agent")
-
- # Check if user already exists
- from sqlalchemy import select
- result = await db.execute(select(User).where(User.email == email))
- user = result.scalar_one_or_none()
-
- if user:
- # Existing user — update avatar if missing and create session
- if avatar_url and not user.avatar_url:
- user.avatar_url = avatar_url
- user.is_sso_user = True
- tokens = await auth_service._create_session(user, ip_address, user_agent)
- user.last_login = __import__("datetime").datetime.utcnow()
- await db.commit()
- logger.info(f"Google login: existing user {email}")
- else:
- # New user — register with Google info
- from app.models.organization import Organization
- from app.models.user import UserRole
- from app.core.security import generate_uuid
-
- org = Organization(
- id=generate_uuid(),
- name=f"{first_name}'s Workspace",
- slug=generate_uuid()[:8],
- )
- db.add(org)
-
- user = User(
- id=generate_uuid(),
- org_id=org.id,
- email=email,
- password_hash=None,
- is_sso_user=True,
- first_name=first_name or email.split("@")[0],
- last_name=last_name or "",
- role=UserRole.ORG_ADMIN,
- avatar_url=avatar_url,
- is_active=True,
- is_email_verified=True,
- )
- db.add(user)
- await db.flush()
-
- tokens = await auth_service._create_session(user, ip_address, user_agent)
- user.last_login = __import__("datetime").datetime.utcnow()
- await db.commit()
- logger.info(f"Google login: new user created {email}")
-
- return {
- "message": "Login successful",
- "user": UserResponse.model_validate(user),
- "tokens": tokens
+ "tokens": supabase_session,
}
@router.post(
"/refresh",
- response_model=TokenResponse,
+ response_model=dict,
summary="Refresh tokens",
- description="Get new access token using refresh token"
+ description="Get new Supabase session tokens using refresh token",
)
async def refresh_token(
- token_data: TokenRefresh,
- request: Request,
- db: AsyncSession = Depends(get_db)
+ token_data: RefreshTokenRequest,
+ db: AsyncSession = Depends(get_db),
):
"""
- Refresh access token using refresh token.
+ Refresh access token using Supabase refresh token.
- **refresh_token**: Valid refresh token from login
Returns new access and refresh tokens.
"""
auth_service = AuthService(db)
-
- ip_address = request.client.host if request.client else None
-
tokens = await auth_service.refresh_tokens(
refresh_token=token_data.refresh_token,
- ip_address=ip_address
)
- return tokens
+ return {
+ "message": "Tokens refreshed",
+ "tokens": tokens,
+ }
@router.post(
"/logout",
status_code=status.HTTP_204_NO_CONTENT,
summary="Logout",
- description="Logout current session"
+ description="Logout current session (frontend calls supabase.auth.signOut())",
)
async def logout(
- request: Request,
current_user: User = Depends(get_current_active_user),
- db: AsyncSession = Depends(get_db)
+ db: AsyncSession = Depends(get_db),
):
"""
Logout from current session.
- Invalidates the current access token.
- """
- auth_service = AuthService(db)
-
- # Get token from header
- auth_header = request.headers.get("Authorization", "")
- token = auth_header.replace("Bearer ", "") if auth_header.startswith("Bearer ") else ""
-
- await auth_service.logout(current_user.id, token)
-
-
-@router.post(
- "/logout-all",
- summary="Logout all sessions",
- description="Logout from all active sessions"
-)
-async def logout_all(
- current_user: User = Depends(get_current_active_user),
- db: AsyncSession = Depends(get_db)
-):
- """
- Logout from all active sessions.
-
- Invalidates all tokens for the current user.
+ The frontend is responsible for calling supabase.auth.signOut().
+ This endpoint logs the event server-side.
"""
auth_service = AuthService(db)
- count = await auth_service.logout_all(current_user.id)
-
- return {
- "message": f"Logged out from {count} session(s)"
- }
+ await auth_service.logout(current_user.id)
# ==================== Password Management ====================
@@ -327,82 +171,50 @@ async def logout_all(
@router.post(
"/forgot-password",
summary="Request password reset",
- description="Send password reset email"
+ description="Send password reset email via Supabase",
)
async def forgot_password(
data: PasswordReset,
- db: AsyncSession = Depends(get_db)
+ db: AsyncSession = Depends(get_db),
):
"""
- Request password reset token.
+ Request password reset.
- **email**: User's email address
- Note: Always returns success to prevent email enumeration.
- In production, sends email with reset link.
- """
- auth_service = AuthService(db)
- token = await auth_service.request_password_reset(data.email)
-
- response = {"message": "If the email exists, a reset link has been sent"}
-
- # Only include token in development for testing purposes
- # NEVER expose in production - tokens should only be sent via email
- if settings.is_development and settings.DEBUG:
- response["debug_token"] = token
-
- return response
-
-
-@router.post(
- "/reset-password",
- summary="Reset password",
- description="Reset password using reset token"
-)
-async def reset_password(
- data: PasswordResetConfirm,
- db: AsyncSession = Depends(get_db)
-):
- """
- Reset password using reset token.
-
- - **token**: Password reset token from email
- - **new_password**: New strong password
+ Supabase sends a reset link to the user's email.
+ Always returns success to prevent email enumeration.
"""
auth_service = AuthService(db)
- await auth_service.reset_password(data.token, data.new_password)
+ await auth_service.request_password_reset(data.email)
- return {
- "message": "Password reset successful. Please login with your new password."
- }
+ return {"message": "If the email exists, a reset link has been sent"}
@router.post(
"/change-password",
summary="Change password",
- description="Change password for authenticated user"
+ description="Change password for authenticated user via Supabase",
)
async def change_password(
data: PasswordChange,
current_user: User = Depends(get_current_active_user),
- db: AsyncSession = Depends(get_db)
+ db: AsyncSession = Depends(get_db),
):
"""
Change password for current user.
- - **current_password**: Current password
- **new_password**: New strong password
+
+ Uses Supabase admin API to update the password.
"""
auth_service = AuthService(db)
await auth_service.change_password(
user_id=current_user.id,
- current_password=data.current_password,
- new_password=data.new_password
+ new_password=data.new_password,
)
- return {
- "message": "Password changed successfully"
- }
+ return {"message": "Password changed successfully"}
# ==================== Current User ====================
@@ -411,18 +223,17 @@ async def change_password(
"/me",
response_model=CurrentUserResponse,
summary="Get current user",
- description="Get details of authenticated user"
+ description="Get details of authenticated user",
)
async def get_current_user_info(
current_user: User = Depends(get_current_active_user),
- db: AsyncSession = Depends(get_db)
+ db: AsyncSession = Depends(get_db),
):
"""
Get current authenticated user's profile.
Returns user details including organization and permissions.
"""
- # Get organization info
from app.models.organization import Organization
from sqlalchemy import select
@@ -431,8 +242,6 @@ async def get_current_user_info(
)
org = result.scalar_one_or_none()
- # Build permissions list based on role
- # This will be expanded in Phase 3 (RBAC)
permissions = _get_role_permissions(current_user.role.value)
return CurrentUserResponse(
@@ -456,7 +265,7 @@ async def get_current_user_info(
updated_at=current_user.updated_at,
organization_name=org.name if org else "",
organization_plan=org.plan.value if org else "",
- permissions=permissions
+ permissions=permissions,
)
@@ -466,10 +275,10 @@ async def get_current_user_info(
"/consent",
response_model=ConsentResponse,
summary="Get consent status",
- description="Get current user's consent settings"
+ description="Get current user's consent settings",
)
async def get_consent(
- current_user: User = Depends(get_current_active_user)
+ current_user: User = Depends(get_current_active_user),
):
"""Get current user's GDPR consent status."""
return ConsentResponse(**current_user.consent)
@@ -479,12 +288,12 @@ async def get_consent(
"/consent",
response_model=ConsentResponse,
summary="Update consent",
- description="Update user consent settings"
+ description="Update user consent settings",
)
async def update_consent(
consent_data: ConsentUpdate,
current_user: User = Depends(get_current_active_user),
- db: AsyncSession = Depends(get_db)
+ db: AsyncSession = Depends(get_db),
):
"""
Update GDPR consent settings.
@@ -500,86 +309,42 @@ async def update_consent(
return ConsentResponse(**user.consent)
-# ==================== Session Management ====================
-
-@router.get(
- "/sessions",
- response_model=SessionListResponse,
- summary="List sessions",
- description="List all active sessions for current user"
-)
-async def list_sessions(
- request: Request,
- current_user: User = Depends(get_current_active_user),
- db: AsyncSession = Depends(get_db)
-):
- """Get all active sessions for current user."""
- auth_service = AuthService(db)
-
- # Get current token
- auth_header = request.headers.get("Authorization", "")
- current_token = auth_header.replace("Bearer ", "") if auth_header.startswith("Bearer ") else ""
-
- sessions = await auth_service.get_user_sessions(current_user.id, current_token)
-
- # Mark current session
- from app.core.security import hash_token
- current_hash = hash_token(current_token) if current_token else ""
-
- session_responses = []
- for session in sessions:
- session_responses.append(SessionResponse(
- id=session.id,
- device_info=session.device_info,
- ip_address=session.ip_address,
- last_activity=session.last_activity,
- created_at=session.created_at,
- is_current=session.token_hash == current_hash
- ))
-
- return SessionListResponse(
- sessions=session_responses,
- total=len(session_responses)
- )
-
-
# ==================== Helper Functions ====================
def _get_role_permissions(role: str) -> list[str]:
"""Get list of permissions for a role."""
- # Basic permission mapping - will be expanded in Phase 3
permissions_map = {
"super_admin": [
"users:read", "users:write", "users:delete",
"organizations:read", "organizations:write",
"tasks:read", "tasks:write", "tasks:delete",
"reports:read", "reports:write",
- "admin:access"
+ "admin:access",
],
"org_admin": [
"users:read", "users:write",
"organizations:read", "organizations:write",
"tasks:read", "tasks:write", "tasks:delete",
- "reports:read", "reports:write"
+ "reports:read", "reports:write",
],
"manager": [
"users:read",
"tasks:read", "tasks:write",
- "reports:read"
+ "reports:read",
],
"team_lead": [
"users:read",
"tasks:read", "tasks:write",
- "reports:read"
+ "reports:read",
],
"employee": [
"tasks:read", "tasks:write_own",
- "reports:read_own"
+ "reports:read_own",
],
"viewer": [
"tasks:read",
- "reports:read"
- ]
+ "reports:read",
+ ],
}
return permissions_map.get(role, [])
diff --git a/backend/app/api/v1/automation.py b/backend/app/api/v1/automation.py
index 3279288..3e28da1 100644
--- a/backend/app/api/v1/automation.py
+++ b/backend/app/api/v1/automation.py
@@ -7,7 +7,7 @@
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
-from datetime import datetime
+from datetime import datetime, timezone
from app.database import get_db
from app.models.user import User, UserRole
@@ -181,7 +181,7 @@ async def accept_pattern(
pattern.status = PatternStatus.ACCEPTED
pattern.accepted_by = current_user.id
- pattern.accepted_at = datetime.utcnow()
+ pattern.accepted_at = datetime.now(timezone.utc)
await db.flush()
await db.refresh(pattern)
await db.commit()
@@ -348,13 +348,13 @@ async def update_agent_status(
# Track shadow mode start
if status_data.status == AgentStatus.SHADOW and old_status != AgentStatus.SHADOW:
- agent.shadow_started_at = datetime.utcnow()
+ agent.shadow_started_at = datetime.now(timezone.utc)
# Track approval and live start
if status_data.status == AgentStatus.LIVE:
agent.approved_by = current_user.id
- agent.approved_at = datetime.utcnow()
- agent.live_started_at = datetime.utcnow()
+ agent.approved_at = datetime.now(timezone.utc)
+ agent.live_started_at = datetime.now(timezone.utc)
await db.flush()
await db.refresh(agent)
@@ -416,7 +416,7 @@ async def trigger_agent(
**trigger_request.trigger_data,
"trigger_type": "manual",
"triggered_by": current_user.id,
- "triggered_at": datetime.utcnow().isoformat(),
+ "triggered_at": datetime.now(timezone.utc).isoformat(),
}
is_shadow = (agent.status == AgentStatus.SHADOW)
diff --git a/backend/app/api/v1/chat.py b/backend/app/api/v1/chat.py
index 2f312f0..f14029e 100644
--- a/backend/app/api/v1/chat.py
+++ b/backend/app/api/v1/chat.py
@@ -5,7 +5,7 @@
"""
import logging
-from datetime import datetime
+from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from uuid import uuid4
@@ -115,8 +115,8 @@ async def send_message(
"agent_name": "chat_agent",
"messages": [],
"context_data": {},
- "started_at": datetime.utcnow().isoformat(),
- "last_message_at": datetime.utcnow().isoformat(),
+ "started_at": datetime.now(timezone.utc).isoformat(),
+ "last_message_at": datetime.now(timezone.utc).isoformat(),
"is_active": True,
}
@@ -127,7 +127,7 @@ async def send_message(
"id": str(uuid4()),
"role": "user",
"content": request.message,
- "timestamp": datetime.utcnow().isoformat(),
+ "timestamp": datetime.now(timezone.utc).isoformat(),
}
conversation["messages"].append(user_message)
@@ -141,7 +141,7 @@ async def send_message(
)
# Add agent response to conversation
- now = datetime.utcnow().isoformat()
+ now = datetime.now(timezone.utc).isoformat()
response_text = result.message or "I'm here to help! Try asking about your tasks, or describe a task you'd like to create."
agent_message = {
"id": str(uuid4()),
@@ -177,7 +177,7 @@ async def send_message(
except Exception as e:
# Fallback response on error
- now = datetime.utcnow().isoformat()
+ now = datetime.now(timezone.utc).isoformat()
error_msg = {
"id": str(uuid4()),
"role": "assistant",
@@ -432,9 +432,9 @@ async def websocket_chat(websocket: WebSocket):
await websocket.close(code=4001)
return
- # SEC-008: Verify JWT and extract user identity
- from ...core.security import verify_token
- payload = verify_token(token, token_type="access")
+ # SEC-008: Verify Supabase JWT and extract user identity
+ from ...core.security import verify_supabase_token
+ payload = verify_supabase_token(token)
if not payload:
await websocket.send_json({"error": "Invalid or expired token."})
await websocket.close(code=4001)
@@ -465,8 +465,8 @@ async def websocket_chat(websocket: WebSocket):
"agent_name": "chat_agent",
"messages": [],
"context_data": {},
- "started_at": datetime.utcnow().isoformat(),
- "last_message_at": datetime.utcnow().isoformat(),
+ "started_at": datetime.now(timezone.utc).isoformat(),
+ "last_message_at": datetime.now(timezone.utc).isoformat(),
"is_active": True,
}
@@ -487,7 +487,7 @@ async def websocket_chat(websocket: WebSocket):
"id": str(uuid4()),
"role": "user",
"content": message,
- "timestamp": datetime.utcnow().isoformat(),
+ "timestamp": datetime.now(timezone.utc).isoformat(),
}
conversation["messages"].append(user_message)
@@ -507,11 +507,11 @@ async def websocket_chat(websocket: WebSocket):
"role": "assistant",
"content": result.message,
"agent_name": result.agent_name,
- "timestamp": datetime.utcnow().isoformat(),
+ "timestamp": datetime.now(timezone.utc).isoformat(),
"metadata": result.output,
}
conversation["messages"].append(agent_message)
- conversation["last_message_at"] = datetime.utcnow().isoformat()
+ conversation["last_message_at"] = datetime.now(timezone.utc).isoformat()
await websocket.send_json({
"type": "message",
@@ -572,7 +572,7 @@ async def push_system_message_to_user(
"content": content,
"suggestions": suggestions or [],
"metadata": metadata or {},
- "timestamp": datetime.utcnow().isoformat(),
+ "timestamp": datetime.now(timezone.utc).isoformat(),
}
dead_connections = []
diff --git a/backend/app/api/v1/dependencies.py b/backend/app/api/v1/dependencies.py
index 91b1b15..cf2aacb 100644
--- a/backend/app/api/v1/dependencies.py
+++ b/backend/app/api/v1/dependencies.py
@@ -3,6 +3,7 @@
FastAPI dependencies for authentication and authorization
"""
+import logging
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
@@ -10,7 +11,7 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
-from app.core.security import verify_token, TokenPayload
+from app.core.security import verify_supabase_token, TokenPayload
from app.core.exceptions import (
AuthenticationException,
InvalidTokenException,
@@ -19,6 +20,8 @@
)
from app.models.user import User, UserRole
+logger = logging.getLogger(__name__)
+
# OAuth2 scheme for token extraction
oauth2_scheme = OAuth2PasswordBearer(
@@ -32,29 +35,47 @@ async def get_current_user(
db: AsyncSession = Depends(get_db)
) -> Optional[User]:
"""
- Get current user from JWT token.
+ Get current user from a Supabase JWT token.
+
+ Flow:
+ 1. Extract Bearer token
+ 2. Verify with Supabase JWT secret
+ 3. Look up user by supabase_auth_id (primary)
+ 4. Fall back to User.id lookup for backward compatibility during migration
+ 5. Return the User or None
- Returns None if no token or invalid token.
+ Returns None if no token, invalid token, or user not found.
Use get_current_active_user for endpoints that require authentication.
"""
if not token:
return None
- # Verify token
- payload = verify_token(token, token_type="access")
+ # Verify Supabase token
+ payload = verify_supabase_token(token)
if not payload:
return None
- user_id = payload.get("sub")
- if not user_id:
+ sub = payload.get("sub")
+ if not sub:
return None
- # Get user from database
+ # Primary lookup: by supabase_auth_id
result = await db.execute(
- select(User).where(User.id == user_id)
+ select(User).where(User.supabase_auth_id == sub)
)
user = result.scalar_one_or_none()
+ # Fallback: look up by User.id for backward compatibility during migration
+ if user is None:
+ logger.debug(
+ "User not found by supabase_auth_id=%s, falling back to User.id lookup",
+ sub,
+ )
+ result = await db.execute(
+ select(User).where(User.id == sub)
+ )
+ user = result.scalar_one_or_none()
+
return user
diff --git a/backend/app/api/v1/predictions.py b/backend/app/api/v1/predictions.py
index 6f5a360..6da2b43 100644
--- a/backend/app/api/v1/predictions.py
+++ b/backend/app/api/v1/predictions.py
@@ -7,7 +7,7 @@
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
import random
from app.database import get_db
@@ -93,7 +93,7 @@ async def get_task_prediction(
if not prediction:
# Generate new prediction (mock)
- now = datetime.utcnow()
+ now = datetime.now(timezone.utc)
base_days = task.estimated_hours / 8 if task.estimated_hours else 5
prediction = Prediction(
@@ -142,7 +142,7 @@ async def get_team_velocity(
# Mock velocity data
snapshots = []
- now = datetime.utcnow()
+ now = datetime.now(timezone.utc)
for i in range(5):
week_start = now - timedelta(weeks=5-i)
snapshots.append({
@@ -225,5 +225,5 @@ async def get_prediction_accuracy(
"accuracy_p90": 0.92,
"mean_absolute_error_days": 2.3,
"model_version": "v1.0",
- "last_retrained": datetime.utcnow().isoformat()
+ "last_retrained": datetime.now(timezone.utc).isoformat()
}
diff --git a/backend/app/api/v1/reports.py b/backend/app/api/v1/reports.py
index 43d45e0..50834d5 100644
--- a/backend/app/api/v1/reports.py
+++ b/backend/app/api/v1/reports.py
@@ -6,7 +6,7 @@
from typing import Optional, List
from fastapi import APIRouter, Depends, Query, status, Response
from sqlalchemy.ext.asyncio import AsyncSession
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
from app.database import get_db
from app.models.user import User, UserRole
@@ -109,7 +109,7 @@ async def get_my_team_productivity(
):
"""Get productivity report for the current user's team."""
team_id = current_user.team_id or current_user.org_id
- end = datetime.utcnow()
+ end = datetime.now(timezone.utc)
period_days = {"week": 7, "month": 30, "quarter": 90}.get(period or "month", 30)
start = end - timedelta(days=period_days)
@@ -198,7 +198,7 @@ async def generate_report(
raise ForbiddenException("Not authorized to generate reports")
# Set default dates if not provided
- end_date = request.end_date or datetime.utcnow()
+ end_date = request.end_date or datetime.now(timezone.utc)
start_date = request.start_date or (end_date - timedelta(days=30))
if request.report_type == "team_productivity":
@@ -251,7 +251,7 @@ async def get_team_productivity_report(
service: ReportService = Depends(get_report_service)
):
"""Generate team productivity report."""
- end = end_date or datetime.utcnow()
+ end = end_date or datetime.now(timezone.utc)
start = start_date or (end - timedelta(days=30))
report = await service.generate_team_productivity_report(
diff --git a/backend/app/api/v1/tasks.py b/backend/app/api/v1/tasks.py
index e6dcba7..bb50fff 100644
--- a/backend/app/api/v1/tasks.py
+++ b/backend/app/api/v1/tasks.py
@@ -31,6 +31,20 @@
router = APIRouter()
+def _task_to_response(task) -> TaskResponse:
+ """Convert a Task ORM model to TaskResponse, avoiding duplicate kwargs."""
+ # Get model columns as dict, excluding SQLAlchemy internals
+ data = {k: v for k, v in task.__dict__.items() if not k.startswith("_")}
+ # Add computed properties (not in __dict__)
+ data["is_subtask"] = task.is_subtask
+ data["is_blocked"] = task.is_blocked
+ data["is_completed"] = task.is_completed
+ data["is_overdue"] = task.is_overdue
+ data["progress_percentage"] = task.progress_percentage
+ return TaskResponse(**data)
+
+
+
def get_task_service(db: AsyncSession = Depends(get_db)) -> TaskService:
"""Dependency to get task service."""
return TaskService(db)
@@ -87,17 +101,7 @@ async def create_task(
"org_id": current_user.org_id,
})
- return TaskResponse(
- **task.__dict__,
- tools=task.tools,
- tags=task.tags,
- skills_required=task.skills_required,
- is_subtask=task.is_subtask,
- is_blocked=task.is_blocked,
- is_completed=task.is_completed,
- is_overdue=task.is_overdue,
- progress_percentage=task.progress_percentage
- )
+ return _task_to_response(task)
@router.get(
@@ -149,17 +153,7 @@ async def list_tasks(
)
task_responses = [
- TaskResponse(
- **t.__dict__,
- tools=t.tools,
- tags=t.tags,
- skills_required=t.skills_required,
- is_subtask=t.is_subtask,
- is_blocked=t.is_blocked,
- is_completed=t.is_completed,
- is_overdue=t.is_overdue,
- progress_percentage=t.progress_percentage
- ) for t in tasks
+ _task_to_response(t) for t in tasks
]
return TaskListResponse(
@@ -271,17 +265,7 @@ async def update_task(
task = await service.update_task(task_id, current_user.org_id, task_data, current_user.id)
- return TaskResponse(
- **task.__dict__,
- tools=task.tools,
- tags=task.tags,
- skills_required=task.skills_required,
- is_subtask=task.is_subtask,
- is_blocked=task.is_blocked,
- is_completed=task.is_completed,
- is_overdue=task.is_overdue,
- progress_percentage=task.progress_percentage
- )
+ return _task_to_response(task)
@router.patch(
@@ -326,17 +310,7 @@ async def update_task_status(
"blocker_type": status_data.blocker_type.value if status_data.blocker_type else None,
})
- return TaskResponse(
- **task.__dict__,
- tools=task.tools,
- tags=task.tags,
- skills_required=task.skills_required,
- is_subtask=task.is_subtask,
- is_blocked=task.is_blocked,
- is_completed=task.is_completed,
- is_overdue=task.is_overdue,
- progress_percentage=task.progress_percentage
- )
+ return _task_to_response(task)
@router.post(
@@ -359,17 +333,7 @@ async def publish_draft(
"org_id": current_user.org_id,
})
- return TaskResponse(
- **task.__dict__,
- tools=task.tools,
- tags=task.tags,
- skills_required=task.skills_required,
- is_subtask=task.is_subtask,
- is_blocked=task.is_blocked,
- is_completed=task.is_completed,
- is_overdue=task.is_overdue,
- progress_percentage=task.progress_percentage
- )
+ return _task_to_response(task)
@router.delete(
@@ -417,17 +381,7 @@ async def assign_task(
"assigned_to": assignee_id,
})
- return TaskResponse(
- **task.__dict__,
- tools=task.tools,
- tags=task.tags,
- skills_required=task.skills_required,
- is_subtask=task.is_subtask,
- is_blocked=task.is_blocked,
- is_completed=task.is_completed,
- is_overdue=task.is_overdue,
- progress_percentage=task.progress_percentage
- )
+ return _task_to_response(task)
# ==================== Bulk Operations ====================
@@ -481,17 +435,7 @@ async def create_subtask(
task_id, current_user.org_id, subtask_data, current_user.id
)
- return TaskResponse(
- **subtask.__dict__,
- tools=subtask.tools,
- tags=subtask.tags,
- skills_required=subtask.skills_required,
- is_subtask=subtask.is_subtask,
- is_blocked=subtask.is_blocked,
- is_completed=subtask.is_completed,
- is_overdue=subtask.is_overdue,
- progress_percentage=subtask.progress_percentage
- )
+ return _task_to_response(subtask)
@router.get(
@@ -509,17 +453,7 @@ async def get_subtasks(
subtasks = await service.get_subtasks(task_id, current_user.org_id)
return [
- TaskResponse(
- **s.__dict__,
- tools=s.tools,
- tags=s.tags,
- skills_required=s.skills_required,
- is_subtask=s.is_subtask,
- is_blocked=s.is_blocked,
- is_completed=s.is_completed,
- is_overdue=s.is_overdue,
- progress_percentage=s.progress_percentage
- ) for s in subtasks
+ _task_to_response(s) for s in subtasks
]
@@ -541,17 +475,7 @@ async def reorder_subtasks(
)
return [
- TaskResponse(
- **s.__dict__,
- tools=s.tools,
- tags=s.tags,
- skills_required=s.skills_required,
- is_subtask=s.is_subtask,
- is_blocked=s.is_blocked,
- is_completed=s.is_completed,
- is_overdue=s.is_overdue,
- progress_percentage=s.progress_percentage
- ) for s in subtasks
+ _task_to_response(s) for s in subtasks
]
@@ -886,15 +810,5 @@ async def apply_decomposition(
created_subtasks.append(subtask)
return [
- TaskResponse(
- **s.__dict__,
- tools=s.tools,
- tags=s.tags,
- skills_required=s.skills_required,
- is_subtask=s.is_subtask,
- is_blocked=s.is_blocked,
- is_completed=s.is_completed,
- is_overdue=s.is_overdue,
- progress_percentage=s.progress_percentage
- ) for s in created_subtasks
+ _task_to_response(s) for s in created_subtasks
]
diff --git a/backend/app/api/v1/workforce.py b/backend/app/api/v1/workforce.py
index 112aa4b..2cc57e5 100644
--- a/backend/app/api/v1/workforce.py
+++ b/backend/app/api/v1/workforce.py
@@ -7,7 +7,7 @@
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
import random
from app.database import get_db
@@ -168,7 +168,7 @@ async def get_user_workforce_score(
user_id=user_id, overall_score=75.0, velocity_score=78.0, quality_score=82.0,
self_sufficiency_score=70.0, learning_score=75.0, collaboration_score=80.0,
percentile_rank=65.0, attrition_risk_score=0.15, burnout_risk_score=0.20,
- score_trend="stable", snapshot_date=datetime.utcnow()
+ score_trend="stable", snapshot_date=datetime.now(timezone.utc)
)
return WorkforceScoreResponse(
@@ -239,7 +239,7 @@ async def get_org_health(
management_quality_index=82.0, automation_maturity_index=45.0,
delivery_predictability_index=72.0, total_employees=50, active_tasks=120,
blocked_tasks=8, overdue_tasks=5, high_attrition_risk_count=3,
- high_burnout_risk_count=5, snapshot_date=datetime.utcnow()
+ high_burnout_risk_count=5, snapshot_date=datetime.now(timezone.utc)
)
return OrgHealthResponse(
diff --git a/backend/app/config.py b/backend/app/config.py
index 30de452..0c8d930 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -44,9 +44,16 @@ class Settings(BaseSettings):
WORKERS: int = 1
# ==================== Database ====================
- DATABASE_URL: str = "sqlite+aiosqlite:///./taskpulse.db"
+ DATABASE_URL: str = "postgresql+asyncpg://postgres:password@localhost:5432/postgres"
DATABASE_ECHO: bool = False # Set to True to see SQL queries
+ # ==================== Supabase ====================
+ SUPABASE_URL: str = ""
+ SUPABASE_ANON_KEY: str = ""
+ SUPABASE_SERVICE_ROLE_KEY: str = ""
+ SUPABASE_JWT_SECRET: str = ""
+ SUPABASE_STORAGE_BUCKET: str = "documents"
+
# ==================== Security ====================
# SEC-001: No hardcoded secret. Random key generated per startup if env var is missing.
SECRET_KEY: str = _GENERATED_SECRET_KEY
@@ -55,7 +62,7 @@ class Settings(BaseSettings):
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# ==================== CORS ====================
- CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:5173", "http://localhost:8080", "http://127.0.0.1:3000", "http://127.0.0.1:5173"]
+ CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:5173", "http://localhost:8080", "http://127.0.0.1:3000", "http://127.0.0.1:5173", "https://relaxed-gates.vercel.app"]
CORS_ALLOW_CREDENTIALS: bool = True
# SEC-012: Restrict CORS methods and headers to only what's needed
CORS_ALLOW_METHODS: list[str] = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
@@ -103,7 +110,7 @@ class Settings(BaseSettings):
MAX_UPLOAD_SIZE_MB: int = 10
ALLOWED_UPLOAD_EXTENSIONS: list[str] = [".pdf", ".doc", ".docx", ".txt", ".md"]
- # ==================== Google OAuth ====================
+ # ==================== Google OAuth (legacy, now via Supabase) ====================
GOOGLE_CLIENT_ID: str = ""
# ==================== Email (for notifications) ====================
@@ -161,6 +168,13 @@ def validate_production_settings(self) -> None:
)
if self.RELOAD:
raise ValueError("RELOAD must be False in production")
+ # Validate Supabase configuration
+ if not self.SUPABASE_URL:
+ raise ValueError("SUPABASE_URL must be set in production")
+ if not self.SUPABASE_SERVICE_ROLE_KEY:
+ raise ValueError("SUPABASE_SERVICE_ROLE_KEY must be set in production")
+ if not self.SUPABASE_JWT_SECRET:
+ raise ValueError("SUPABASE_JWT_SECRET must be set in production")
@lru_cache()
diff --git a/backend/app/core/middleware.py b/backend/app/core/middleware.py
index c27882f..b13cff1 100644
--- a/backend/app/core/middleware.py
+++ b/backend/app/core/middleware.py
@@ -155,7 +155,6 @@ class CSRFMiddleware(BaseHTTPMiddleware):
"/api/v1/auth/login",
"/api/v1/auth/register",
"/api/v1/auth/refresh",
- "/api/v1/auth/google",
"/health",
"/docs",
"/openapi.json",
diff --git a/backend/app/core/security.py b/backend/app/core/security.py
index 5da6ef6..d738b8f 100644
--- a/backend/app/core/security.py
+++ b/backend/app/core/security.py
@@ -1,178 +1,143 @@
"""
TaskPulse - AI Assistant - Security Utilities
JWT token handling, password hashing, and authentication helpers
+
+Supabase Auth is the primary authentication provider.
+Legacy local JWT functions are kept for migration backward compatibility.
"""
from datetime import datetime, timedelta, timezone
from typing import Optional, Any
import hashlib
+import json
import secrets
+import logging
+import time
import bcrypt
-from jose import JWTError, jwt
+import httpx
+from jose import JWTError, jwt, jwk
from app.config import settings
+logger = logging.getLogger(__name__)
-# ==================== Password Utilities ====================
-def hash_password(password: str) -> str:
- """
- Hash a password using bcrypt.
+# ==================== JWKS Cache ====================
- Args:
- password: Plain text password
-
- Returns:
- Hashed password string
- """
- # bcrypt requires bytes, encode the password
- password_bytes = password.encode('utf-8')
- # Generate salt and hash
- salt = bcrypt.gensalt()
- hashed = bcrypt.hashpw(password_bytes, salt)
- return hashed.decode('utf-8')
+_jwks_cache: Optional[dict] = None
+_jwks_cache_time: float = 0
+_JWKS_CACHE_TTL = 3600 # Cache JWKS for 1 hour
-def verify_password(plain_password: str, hashed_password: str) -> bool:
- """
- Verify a password against its hash.
+def _get_jwks_keys() -> list[dict]:
+ """Fetch and cache JWKS public keys from Supabase."""
+ global _jwks_cache, _jwks_cache_time
- Args:
- plain_password: Plain text password to verify
- hashed_password: Stored hash to verify against
+ now = time.time()
+ if _jwks_cache and (now - _jwks_cache_time) < _JWKS_CACHE_TTL:
+ return _jwks_cache.get("keys", [])
- Returns:
- True if password matches, False otherwise
- """
try:
- password_bytes = plain_password.encode('utf-8')
- hashed_bytes = hashed_password.encode('utf-8')
- return bcrypt.checkpw(password_bytes, hashed_bytes)
- except Exception:
- return False
-
-
-# ==================== Token Utilities ====================
-
-def create_access_token(
- data: dict,
- expires_delta: Optional[timedelta] = None
-) -> str:
- """
- Create a JWT access token.
-
- Args:
- data: Data to encode in the token
- expires_delta: Optional custom expiration time
-
- Returns:
- Encoded JWT token string
- """
- to_encode = data.copy()
-
- if expires_delta:
- expire = datetime.now(timezone.utc) + expires_delta
- else:
- expire = datetime.now(timezone.utc) + timedelta(
- minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
+ resp = httpx.get(
+ f"{settings.SUPABASE_URL}/auth/v1/.well-known/jwks.json",
+ timeout=10.0,
)
+ resp.raise_for_status()
+ _jwks_cache = resp.json()
+ _jwks_cache_time = now
+ return _jwks_cache.get("keys", [])
+ except Exception as exc:
+ logger.warning("Failed to fetch JWKS: %s", exc)
+ # Return cached keys if available, even if expired
+ if _jwks_cache:
+ return _jwks_cache.get("keys", [])
+ return []
- to_encode.update({
- "exp": expire,
- "iat": datetime.now(timezone.utc),
- "type": "access"
- })
-
- encoded_jwt = jwt.encode(
- to_encode,
- settings.SECRET_KEY,
- algorithm=settings.ALGORITHM
- )
-
- return encoded_jwt
+# ==================== Supabase Auth ====================
-def create_refresh_token(
- data: dict,
- expires_delta: Optional[timedelta] = None
-) -> str:
+def verify_supabase_token(token: str) -> Optional[dict]:
"""
- Create a JWT refresh token.
+ Verify a Supabase-issued JWT token.
+
+ Supports both ES256 (ECDSA, newer Supabase projects) and HS256 (HMAC,
+ legacy) signed tokens. For ES256, fetches the public key from the
+ Supabase JWKS endpoint and caches it.
Args:
- data: Data to encode in the token
- expires_delta: Optional custom expiration time
+ token: Supabase JWT access token string
Returns:
- Encoded JWT refresh token string
+ Decoded payload dict on success, None on verification failure.
"""
- to_encode = data.copy()
-
- if expires_delta:
- expire = datetime.now(timezone.utc) + expires_delta
- else:
- expire = datetime.now(timezone.utc) + timedelta(
- days=settings.REFRESH_TOKEN_EXPIRE_DAYS
- )
-
- to_encode.update({
- "exp": expire,
- "iat": datetime.now(timezone.utc),
- "type": "refresh"
- })
+ # Peek at the token header to determine the algorithm
+ try:
+ header = jwt.get_unverified_header(token)
+ except JWTError:
+ return None
- encoded_jwt = jwt.encode(
- to_encode,
- settings.SECRET_KEY,
- algorithm=settings.ALGORITHM
- )
+ alg = header.get("alg", "HS256")
+ kid = header.get("kid")
- return encoded_jwt
+ if alg == "ES256":
+ return _verify_es256_token(token, kid)
+ else:
+ return _verify_hs256_token(token)
-def decode_token(token: str) -> Optional[dict]:
- """
- Decode and validate a JWT token.
+def _verify_es256_token(token: str, kid: Optional[str]) -> Optional[dict]:
+ """Verify an ES256-signed Supabase JWT using JWKS public key."""
+ keys = _get_jwks_keys()
+ if not keys:
+ logger.warning("No JWKS keys available for ES256 verification")
+ return None
- Args:
- token: JWT token string
+ # Find matching key by kid
+ jwk_data = None
+ for k in keys:
+ if kid and k.get("kid") == kid:
+ jwk_data = k
+ break
+ if jwk_data is None:
+ # Fallback to first key if no kid match
+ jwk_data = keys[0]
- Returns:
- Decoded token payload or None if invalid
- """
try:
+ # Construct the public key from JWK
+ public_key = jwk.construct(jwk_data, algorithm="ES256")
+
payload = jwt.decode(
token,
- settings.SECRET_KEY,
- algorithms=[settings.ALGORITHM]
+ public_key,
+ algorithms=["ES256"],
+ audience="authenticated",
)
return payload
- except JWTError:
+ except JWTError as exc:
+ logger.debug("ES256 token verification failed: %s", exc)
return None
-
-
-def verify_token(token: str, token_type: str = "access") -> Optional[dict]:
- """
- Verify a token and check its type.
-
- Args:
- token: JWT token string
- token_type: Expected token type ("access" or "refresh")
-
- Returns:
- Decoded token payload or None if invalid
- """
- payload = decode_token(token)
-
- if payload is None:
+ except Exception as exc:
+ logger.debug("ES256 key construction or verification error: %s", exc)
return None
- if payload.get("type") != token_type:
+
+def _verify_hs256_token(token: str) -> Optional[dict]:
+ """Verify an HS256-signed Supabase JWT using the JWT secret."""
+ try:
+ payload = jwt.decode(
+ token,
+ settings.SUPABASE_JWT_SECRET,
+ algorithms=["HS256"],
+ audience="authenticated",
+ )
+ return payload
+ except JWTError as exc:
+ logger.debug("HS256 token verification failed: %s", exc)
return None
- return payload
+# ==================== Token Hash Utilities ====================
def hash_token(token: str) -> str:
"""
@@ -218,90 +183,141 @@ def verify_api_key(api_key: str, stored_hash: str) -> bool:
return secrets.compare_digest(key_hash, stored_hash)
-# ==================== Password Reset Utilities ====================
+# ==================== Token Payload Helpers ====================
-def generate_password_reset_token(email: str) -> str:
+class TokenPayload:
"""
- Generate a password reset token.
+ Helper class to work with token payloads.
- Args:
- email: User's email address
-
- Returns:
- Password reset JWT token
+ Handles both legacy local JWTs and Supabase JWTs:
+ - Legacy: sub=local user ID, org_id, role, type="access"/"refresh"
+ - Supabase: sub=supabase auth UUID, email, role="authenticated",
+ aud="authenticated", app_metadata, user_metadata
"""
- expires = datetime.now(timezone.utc) + timedelta(hours=1)
-
- return jwt.encode(
- {
- "sub": email,
- "exp": expires,
- "type": "password_reset"
- },
- settings.SECRET_KEY,
- algorithm=settings.ALGORITHM
- )
+ def __init__(self, payload: dict):
+ # sub is either the local user ID (legacy) or the Supabase auth UUID
+ self.user_id: str = payload.get("sub", "")
+ self.org_id: str = payload.get("org_id", "")
+ self.role: str = payload.get("role", "")
+ self.email: str = payload.get("email", "")
+ self.token_type: str = payload.get("type", "access")
+
+ # Supabase-specific fields
+ self.aud: str = payload.get("aud", "")
+ self.app_metadata: dict = payload.get("app_metadata", {})
+ self.user_metadata: dict = payload.get("user_metadata", {})
-def verify_password_reset_token(token: str) -> Optional[str]:
+ self.issued_at: Optional[datetime] = None
+ self.expires_at: Optional[datetime] = None
+
+ # Parse timestamps
+ if "iat" in payload:
+ self.issued_at = datetime.fromtimestamp(
+ payload["iat"],
+ tz=timezone.utc
+ )
+ if "exp" in payload:
+ self.expires_at = datetime.fromtimestamp(
+ payload["exp"],
+ tz=timezone.utc
+ )
+
+ @property
+ def is_supabase_token(self) -> bool:
+ """Check if this payload originated from a Supabase JWT."""
+ return self.aud == "authenticated"
+
+ @property
+ def is_expired(self) -> bool:
+ """Check if token is expired."""
+ if self.expires_at is None:
+ return True
+ return datetime.now(timezone.utc) > self.expires_at
+
+ def to_dict(self) -> dict:
+ """Convert to dictionary."""
+ return {
+ "user_id": self.user_id,
+ "org_id": self.org_id,
+ "role": self.role,
+ "email": self.email,
+ "token_type": self.token_type,
+ }
+
+
+# ============================================================
+# DEPRECATED: Legacy local auth functions
+# Kept only for the migration script and backward compatibility.
+# All new authentication flows should use Supabase Auth.
+# ============================================================
+
+# DEPRECATED: Used only for migration
+def hash_password(password: str) -> str:
"""
- Verify a password reset token and extract email.
+ Hash a password using bcrypt.
Args:
- token: Password reset token
+ password: Plain text password
Returns:
- Email address or None if invalid
+ Hashed password string
"""
- try:
- payload = jwt.decode(
- token,
- settings.SECRET_KEY,
- algorithms=[settings.ALGORITHM]
- )
-
- if payload.get("type") != "password_reset":
- return None
-
- return payload.get("sub")
- except JWTError:
- return None
-
+ password_bytes = password.encode('utf-8')
+ salt = bcrypt.gensalt()
+ hashed = bcrypt.hashpw(password_bytes, salt)
+ return hashed.decode('utf-8')
-# ==================== Email Verification Utilities ====================
-def generate_email_verification_token(email: str) -> str:
+# DEPRECATED: Used only for migration and tests
+def create_access_token(
+ data: dict,
+ expires_delta: Optional[timedelta] = None
+) -> str:
"""
- Generate an email verification token.
+ Create a JWT access token.
Args:
- email: Email address to verify
+ data: Data to encode in the token
+ expires_delta: Optional custom expiration time
Returns:
- Email verification JWT token
+ Encoded JWT token string
"""
- expires = datetime.now(timezone.utc) + timedelta(days=7)
-
- return jwt.encode(
- {
- "sub": email,
- "exp": expires,
- "type": "email_verification"
- },
+ to_encode = data.copy()
+
+ if expires_delta:
+ expire = datetime.now(timezone.utc) + expires_delta
+ else:
+ expire = datetime.now(timezone.utc) + timedelta(
+ minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
+ )
+
+ to_encode.update({
+ "exp": expire,
+ "iat": datetime.now(timezone.utc),
+ "type": "access"
+ })
+
+ encoded_jwt = jwt.encode(
+ to_encode,
settings.SECRET_KEY,
algorithm=settings.ALGORITHM
)
+ return encoded_jwt
+
-def verify_email_verification_token(token: str) -> Optional[str]:
+# DEPRECATED: Used only for migration and tests
+def decode_token(token: str) -> Optional[dict]:
"""
- Verify an email verification token and extract email.
+ Decode and validate a JWT token.
Args:
- token: Email verification token
+ token: JWT token string
Returns:
- Email address or None if invalid
+ Decoded token payload or None if invalid
"""
try:
payload = jwt.decode(
@@ -309,54 +325,29 @@ def verify_email_verification_token(token: str) -> Optional[str]:
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM]
)
-
- if payload.get("type") != "email_verification":
- return None
-
- return payload.get("sub")
+ return payload
except JWTError:
return None
-# ==================== Token Payload Helpers ====================
+# DEPRECATED: Used only for migration
+def verify_token(token: str, token_type: str = "access") -> Optional[dict]:
+ """
+ Verify a token and check its type.
-class TokenPayload:
- """Helper class to work with token payloads."""
+ Args:
+ token: JWT token string
+ token_type: Expected token type ("access" or "refresh")
- def __init__(self, payload: dict):
- self.user_id: str = payload.get("sub", "")
- self.org_id: str = payload.get("org_id", "")
- self.role: str = payload.get("role", "")
- self.email: str = payload.get("email", "")
- self.token_type: str = payload.get("type", "access")
- self.issued_at: Optional[datetime] = None
- self.expires_at: Optional[datetime] = None
+ Returns:
+ Decoded token payload or None if invalid
+ """
+ payload = decode_token(token)
- # Parse timestamps
- if "iat" in payload:
- self.issued_at = datetime.fromtimestamp(
- payload["iat"],
- tz=timezone.utc
- )
- if "exp" in payload:
- self.expires_at = datetime.fromtimestamp(
- payload["exp"],
- tz=timezone.utc
- )
+ if payload is None:
+ return None
- @property
- def is_expired(self) -> bool:
- """Check if token is expired."""
- if self.expires_at is None:
- return True
- return datetime.now(timezone.utc) > self.expires_at
+ if payload.get("type") != token_type:
+ return None
- def to_dict(self) -> dict:
- """Convert to dictionary."""
- return {
- "user_id": self.user_id,
- "org_id": self.org_id,
- "role": self.role,
- "email": self.email,
- "token_type": self.token_type
- }
+ return payload
diff --git a/backend/app/database.py b/backend/app/database.py
index 179a46c..4cf2568 100644
--- a/backend/app/database.py
+++ b/backend/app/database.py
@@ -1,41 +1,69 @@
"""
TaskPulse - AI Assistant - Database Configuration
-Database setup with SQLAlchemy async support (SQLite & PostgreSQL)
+PostgreSQL (Supabase) database setup with SQLAlchemy async support
"""
+import enum as _enum
import logging
-from datetime import datetime
-from typing import AsyncGenerator
+import ssl
import uuid
+from typing import AsyncGenerator
-from sqlalchemy import Column, DateTime, String, event
+from sqlalchemy import Column, DateTime, JSON, String, func
+from sqlalchemy import Enum as _SQLAlchemyEnum
+from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, declared_attr
-from app.config import settings
-logger = logging.getLogger(__name__)
+# Cross-database compatible JSONB: uses JSONB on PostgreSQL, JSON on SQLite
+CompatibleJSONB = JSONB().with_variant(JSON(), "sqlite")
+
+# Cross-database compatible UUID: uses native UUID on PostgreSQL, String on SQLite
+CompatibleUUID = PG_UUID(as_uuid=True).with_variant(String(36), "sqlite")
+
-_is_sqlite = settings.DATABASE_URL.startswith("sqlite")
+class Enum(_SQLAlchemyEnum):
+ """
+ Custom SQLAlchemy Enum that uses Python enum *values* (not names) for DB storage.
+
+ PostgreSQL enum types were created with lowercase values (e.g. 'super_admin'),
+ matching the Python enum values. SQLAlchemy's default Enum uses enum names
+ (e.g. 'SUPER_ADMIN'), causing a mismatch. This custom class fixes that.
+ """
-# Build engine kwargs based on database type
-_engine_kwargs: dict = {
- "echo": settings.DATABASE_ECHO,
- "future": True,
-}
+ def __init__(self, *enums, **kw):
+ if (
+ len(enums) == 1
+ and isinstance(enums[0], type)
+ and issubclass(enums[0], _enum.Enum)
+ ):
+ kw.setdefault("values_callable", lambda x: [e.value for e in x])
+ super().__init__(*enums, **kw)
-if _is_sqlite:
- _engine_kwargs["connect_args"] = {"check_same_thread": False}
-else:
- # PostgreSQL pool settings for production
- _engine_kwargs["pool_size"] = 10
- _engine_kwargs["max_overflow"] = 20
- _engine_kwargs["pool_pre_ping"] = True
- _engine_kwargs["pool_recycle"] = 300
+from app.config import settings
-logger.info(f"Database backend: {'SQLite' if _is_sqlite else 'PostgreSQL'}")
+logger = logging.getLogger(__name__)
-engine = create_async_engine(settings.DATABASE_URL, **_engine_kwargs)
+# SSL context for Supabase connection pooler (uses self-signed certs)
+_ssl_context = ssl.create_default_context()
+_ssl_context.check_hostname = False
+_ssl_context.verify_mode = ssl.CERT_NONE
+
+# Create async engine for PostgreSQL (Supabase)
+engine = create_async_engine(
+ settings.DATABASE_URL,
+ echo=settings.DATABASE_ECHO,
+ future=True,
+ pool_size=5,
+ max_overflow=10,
+ pool_pre_ping=True,
+ connect_args=(
+ {"ssl": _ssl_context, "statement_cache_size": 0}
+ if "asyncpg" in settings.DATABASE_URL
+ else {}
+ ),
+)
# Create async session factory
AsyncSessionLocal = async_sessionmaker(
@@ -62,46 +90,36 @@ def __tablename__(cls) -> str:
['_' + c.lower() if c.isupper() else c for c in name]
).lstrip('_') + 's'
- # Common columns for all models
+ # Common columns for all models — using cross-DB compatible UUID
id = Column(
- String(36),
+ CompatibleUUID,
primary_key=True,
- default=lambda: str(uuid.uuid4()),
+ default=uuid.uuid4,
index=True
)
created_at = Column(
- DateTime,
- default=datetime.utcnow,
+ DateTime(timezone=True),
+ server_default=func.now(),
nullable=False,
index=True
)
updated_at = Column(
- DateTime,
- default=datetime.utcnow,
- onupdate=datetime.utcnow,
+ DateTime(timezone=True),
+ server_default=func.now(),
+ onupdate=func.now(),
nullable=False
)
def to_dict(self) -> dict:
"""Convert model to dictionary."""
- return {
- column.name: getattr(self, column.name)
- for column in self.__table__.columns
- }
-
-
-# Enable foreign key support for SQLite (no-op for PostgreSQL)
-if _is_sqlite:
- @event.listens_for(engine.sync_engine, "connect")
- def set_sqlite_pragma(dbapi_connection, connection_record):
- """Enable foreign keys and other optimizations for SQLite."""
- cursor = dbapi_connection.cursor()
- cursor.execute("PRAGMA foreign_keys=ON")
- cursor.execute("PRAGMA journal_mode=WAL")
- cursor.execute("PRAGMA synchronous=NORMAL")
- cursor.execute("PRAGMA cache_size=10000")
- cursor.execute("PRAGMA temp_store=MEMORY")
- cursor.close()
+ result = {}
+ for column in self.__table__.columns:
+ value = getattr(self, column.name)
+ # Convert UUID to string for JSON serialization
+ if isinstance(value, uuid.UUID):
+ value = str(value)
+ result[column.name] = value
+ return result
async def get_db() -> AsyncGenerator[AsyncSession, None]:
@@ -134,7 +152,7 @@ async def init_db() -> None:
# Import ALL models to ensure they're registered with Base.metadata
from app.models import ( # noqa: F401
# Phase 2 - Auth & Users
- Organization, User, Session,
+ Organization, User,
# Phase 4 - Tasks
Task, TaskDependency, TaskHistory, TaskComment,
# Phase 6 - Check-ins
diff --git a/backend/app/main.py b/backend/app/main.py
index 7408c70..fb0e824 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -5,7 +5,7 @@
import logging
from contextlib import asynccontextmanager
-from datetime import datetime
+from datetime import datetime, timezone
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
@@ -158,7 +158,7 @@ async def root():
"description": settings.APP_DESCRIPTION,
"status": "running",
"docs": "/docs" if _enable_docs else "disabled",
- "timestamp": datetime.utcnow().isoformat()
+ "timestamp": datetime.now(timezone.utc).isoformat()
}
@@ -193,7 +193,7 @@ async def health_check():
status_code=200 if is_healthy else 503,
content={
"status": "healthy" if is_healthy else "unhealthy",
- "timestamp": datetime.utcnow().isoformat(),
+ "timestamp": datetime.now(timezone.utc).isoformat(),
"version": settings.APP_VERSION,
"components": {
"database": {
@@ -220,7 +220,7 @@ async def readiness_check():
Readiness check for Kubernetes/container orchestration.
Returns 200 only if the application is fully ready.
"""
- return {"status": "ready", "timestamp": datetime.utcnow().isoformat()}
+ return {"status": "ready", "timestamp": datetime.now(timezone.utc).isoformat()}
# ==================== API Version Info ====================
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index 63d637a..8f73e8b 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -5,7 +5,7 @@
# Phase 2 - Authentication & User Management
from app.models.organization import Organization, PlanTier
-from app.models.user import User, Session, UserRole, SkillLevel
+from app.models.user import User, UserRole, SkillLevel
# Phase 4 - Tasks
from app.models.task import (
@@ -71,7 +71,7 @@
# Organization
"Organization", "PlanTier",
# User
- "User", "Session", "UserRole", "SkillLevel",
+ "User", "UserRole", "SkillLevel",
# Task
"Task", "TaskDependency", "TaskHistory", "TaskComment",
"TaskStatus", "TaskPriority", "BlockerType",
diff --git a/backend/app/models/agent.py b/backend/app/models/agent.py
index f211946..5347bde 100644
--- a/backend/app/models/agent.py
+++ b/backend/app/models/agent.py
@@ -4,24 +4,24 @@
Models for tracking agent configuration, executions, and conversations.
"""
-from datetime import datetime
+from datetime import datetime, timezone
from enum import Enum
from typing import Optional
+import uuid
from sqlalchemy import (
Boolean,
Column,
DateTime,
- Enum as SQLEnum,
Float,
ForeignKey,
Integer,
String,
Text,
- JSON,
)
from sqlalchemy.orm import relationship
+from sqlalchemy.sql import func
-from ..database import Base
+from ..database import Base, CompatibleJSONB, CompatibleUUID, Enum as SQLEnum
class AgentType(str, Enum):
@@ -56,8 +56,7 @@ class Agent(Base):
"""
__tablename__ = "agents"
- id = Column(String(36), primary_key=True)
- org_id = Column(String(36), ForeignKey("organizations.id"), nullable=False, index=True)
+ org_id = Column(CompatibleUUID, ForeignKey("organizations.id"), nullable=False, index=True)
# Identity
name = Column(String(100), nullable=False, unique=True)
@@ -67,26 +66,22 @@ class Agent(Base):
# Classification
agent_type = Column(SQLEnum(AgentType), nullable=False, default=AgentType.AI)
- capabilities = Column(JSON, default=list) # List of capability strings
+ capabilities = Column(CompatibleJSONB, default=list) # List of capability strings
# Status
status = Column(SQLEnum(AgentStatusDB), default=AgentStatusDB.ACTIVE)
is_enabled = Column(Boolean, default=True)
# Configuration
- config = Column(JSON, default=dict)
- permissions = Column(JSON, default=list)
+ config = Column(CompatibleJSONB, default=dict)
+ permissions = Column(CompatibleJSONB, default=list)
# Metrics
execution_count = Column(Integer, default=0)
success_count = Column(Integer, default=0)
error_count = Column(Integer, default=0)
avg_duration_ms = Column(Float, nullable=True)
- last_execution_at = Column(DateTime, nullable=True)
-
- # Timestamps
- created_at = Column(DateTime, default=datetime.utcnow)
- updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+ last_execution_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
organization = relationship("Organization", backref="agents")
@@ -104,29 +99,28 @@ class AgentExecution(Base):
"""
__tablename__ = "agent_executions"
- id = Column(String(36), primary_key=True)
- agent_id = Column(String(36), ForeignKey("agents.id"), nullable=False, index=True)
- org_id = Column(String(36), ForeignKey("organizations.id"), nullable=False, index=True)
+ agent_id = Column(CompatibleUUID, ForeignKey("agents.id"), nullable=False, index=True)
+ org_id = Column(CompatibleUUID, ForeignKey("organizations.id"), nullable=False, index=True)
# Trigger information
event_type = Column(String(100), nullable=False)
- event_id = Column(String(36), nullable=True)
+ event_id = Column(CompatibleUUID, nullable=True)
trigger_source = Column(String(100), nullable=True) # user, system, scheduled, chain
# Context
- user_id = Column(String(36), ForeignKey("users.id"), nullable=True)
- task_id = Column(String(36), ForeignKey("tasks.id"), nullable=True)
- context_data = Column(JSON, default=dict)
+ user_id = Column(CompatibleUUID, ForeignKey("users.id"), nullable=True)
+ task_id = Column(CompatibleUUID, ForeignKey("tasks.id"), nullable=True)
+ context_data = Column(CompatibleJSONB, default=dict)
# Execution details
status = Column(SQLEnum(ExecutionStatus), default=ExecutionStatus.PENDING)
- started_at = Column(DateTime, default=datetime.utcnow)
- completed_at = Column(DateTime, nullable=True)
+ started_at = Column(DateTime(timezone=True), server_default=func.now())
+ completed_at = Column(DateTime(timezone=True), nullable=True)
duration_ms = Column(Integer, nullable=True)
# Results
success = Column(Boolean, default=False)
- output_data = Column(JSON, default=dict)
+ output_data = Column(CompatibleJSONB, default=dict)
error_message = Column(Text, nullable=True)
error_code = Column(String(50), nullable=True)
@@ -135,18 +129,15 @@ class AgentExecution(Base):
api_calls = Column(Integer, default=0)
# Chain information
- parent_execution_id = Column(String(36), ForeignKey("agent_executions.id"), nullable=True)
+ parent_execution_id = Column(CompatibleUUID, ForeignKey("agent_executions.id"), nullable=True)
chain_depth = Column(Integer, default=0)
- # Timestamps
- created_at = Column(DateTime, default=datetime.utcnow)
-
# Relationships
agent = relationship("Agent", back_populates="executions")
organization = relationship("Organization")
user = relationship("User")
task = relationship("Task")
- parent_execution = relationship("AgentExecution", remote_side=[id])
+ parent_execution = relationship("AgentExecution", remote_side="AgentExecution.id")
def __repr__(self):
return f""
@@ -160,9 +151,8 @@ class AgentConversation(Base):
"""
__tablename__ = "agent_conversations"
- id = Column(String(36), primary_key=True)
- org_id = Column(String(36), ForeignKey("organizations.id"), nullable=False, index=True)
- user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
+ org_id = Column(CompatibleUUID, ForeignKey("organizations.id"), nullable=False, index=True)
+ user_id = Column(CompatibleUUID, ForeignKey("users.id"), nullable=False, index=True)
# Conversation metadata
title = Column(String(200), nullable=True)
@@ -173,13 +163,13 @@ class AgentConversation(Base):
message_count = Column(Integer, default=0)
# Conversation data
- messages = Column(JSON, default=list) # List of ConversationMessage dicts
- context_data = Column(JSON, default=dict) # Persistent context
+ messages = Column(CompatibleJSONB, default=list) # List of ConversationMessage dicts
+ context_data = Column(CompatibleJSONB, default=dict) # Persistent context
# Timestamps
- started_at = Column(DateTime, default=datetime.utcnow)
- last_message_at = Column(DateTime, default=datetime.utcnow)
- ended_at = Column(DateTime, nullable=True)
+ started_at = Column(DateTime(timezone=True), server_default=func.now())
+ last_message_at = Column(DateTime(timezone=True), server_default=func.now())
+ ended_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
organization = relationship("Organization")
@@ -195,7 +185,7 @@ def add_message(self, role: str, content: str, agent_name: Optional[str] = None,
"content": content,
"agent_name": agent_name,
"metadata": metadata or {},
- "timestamp": datetime.utcnow().isoformat(),
+ "timestamp": datetime.now(timezone.utc).isoformat(),
}
if self.messages is None:
@@ -203,7 +193,7 @@ def add_message(self, role: str, content: str, agent_name: Optional[str] = None,
self.messages.append(message)
self.message_count = len(self.messages)
- self.last_message_at = datetime.utcnow()
+ self.last_message_at = datetime.now(timezone.utc)
return message
@@ -225,9 +215,8 @@ class AgentSchedule(Base):
"""
__tablename__ = "agent_schedules"
- id = Column(String(36), primary_key=True)
- agent_id = Column(String(36), ForeignKey("agents.id"), nullable=False, index=True)
- org_id = Column(String(36), ForeignKey("organizations.id"), nullable=False, index=True)
+ agent_id = Column(CompatibleUUID, ForeignKey("agents.id"), nullable=False, index=True)
+ org_id = Column(CompatibleUUID, ForeignKey("organizations.id"), nullable=False, index=True)
# Schedule definition
name = Column(String(100), nullable=False)
@@ -236,18 +225,14 @@ class AgentSchedule(Base):
# Configuration
is_enabled = Column(Boolean, default=True)
- config = Column(JSON, default=dict) # Additional config for scheduled run
+ config = Column(CompatibleJSONB, default=dict) # Additional config for scheduled run
# Tracking
- last_run_at = Column(DateTime, nullable=True)
- next_run_at = Column(DateTime, nullable=True)
+ last_run_at = Column(DateTime(timezone=True), nullable=True)
+ next_run_at = Column(DateTime(timezone=True), nullable=True)
run_count = Column(Integer, default=0)
failure_count = Column(Integer, default=0)
- # Timestamps
- created_at = Column(DateTime, default=datetime.utcnow)
- updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
-
# Relationships
agent = relationship("Agent")
organization = relationship("Organization")
diff --git a/backend/app/models/audit.py b/backend/app/models/audit.py
index 8c6f8c2..913c816 100644
--- a/backend/app/models/audit.py
+++ b/backend/app/models/audit.py
@@ -3,12 +3,13 @@
Security, compliance, and admin features
"""
-from sqlalchemy import Column, String, Enum, Text, Boolean, ForeignKey, Integer, Float, DateTime
+from sqlalchemy import Column, String, Text, Boolean, ForeignKey, Integer, Float, DateTime
from sqlalchemy.orm import relationship
+from sqlalchemy.sql import func
import enum
-from datetime import datetime
+import uuid
-from app.database import Base
+from app.database import Base, CompatibleJSONB, CompatibleUUID, Enum
class ActorType(str, enum.Enum):
@@ -53,23 +54,23 @@ class AuditLog(Base):
"""Immutable audit log entry."""
__tablename__ = "audit_logs"
- org_id = Column(String(36), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True)
+ org_id = Column(CompatibleUUID, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True)
# Actor
actor_type = Column(Enum(ActorType), nullable=False)
- actor_id = Column(String(36), nullable=True) # User/system ID
+ actor_id = Column(CompatibleUUID, nullable=True) # User/system ID
actor_name = Column(String(200), nullable=True)
# Action
action = Column(Enum(AuditAction), nullable=False)
resource_type = Column(String(100), nullable=False) # user, task, org, etc.
- resource_id = Column(String(36), nullable=True)
+ resource_id = Column(CompatibleUUID, nullable=True)
# Details
description = Column(Text, nullable=True)
- old_value_json = Column(Text, nullable=True)
- new_value_json = Column(Text, nullable=True)
- metadata_json = Column(Text, default="{}")
+ old_value = Column(CompatibleJSONB, nullable=True)
+ new_value = Column(CompatibleJSONB, nullable=True)
+ audit_metadata = Column(CompatibleJSONB, default={})
# Context
ip_address = Column(String(45), nullable=True)
@@ -77,40 +78,35 @@ class AuditLog(Base):
request_id = Column(String(36), nullable=True)
# Timestamp (not using updated_at for immutability)
- timestamp = Column(DateTime, default=datetime.utcnow, index=True)
+ timestamp = Column(DateTime(timezone=True), server_default=func.now(), index=True)
organization = relationship("Organization", backref="audit_logs")
- @property
- def extra_data(self):
- import json
- return json.loads(self.metadata_json or "{}")
-
class GDPRRequest(Base):
"""GDPR data request tracking."""
__tablename__ = "gdpr_requests"
- org_id = Column(String(36), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True)
- user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
+ org_id = Column(CompatibleUUID, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True)
+ user_id = Column(CompatibleUUID, ForeignKey("users.id"), nullable=False, index=True)
request_type = Column(String(50), nullable=False) # export, erasure, access, rectification
status = Column(String(50), default="pending") # pending, processing, completed, failed
# Processing
- requested_at = Column(DateTime, default=datetime.utcnow)
- processed_at = Column(DateTime, nullable=True)
- completed_at = Column(DateTime, nullable=True)
- processed_by = Column(String(36), ForeignKey("users.id"), nullable=True)
+ requested_at = Column(DateTime(timezone=True), server_default=func.now())
+ processed_at = Column(DateTime(timezone=True), nullable=True)
+ completed_at = Column(DateTime(timezone=True), nullable=True)
+ processed_by = Column(CompatibleUUID, ForeignKey("users.id"), nullable=True)
# Result
result_url = Column(String(2000), nullable=True) # For export downloads
- result_expiry = Column(DateTime, nullable=True)
+ result_expiry = Column(DateTime(timezone=True), nullable=True)
error_message = Column(Text, nullable=True)
# Verification
verification_token = Column(String(255), nullable=True)
- verified_at = Column(DateTime, nullable=True)
+ verified_at = Column(DateTime(timezone=True), nullable=True)
organization = relationship("Organization", backref="gdpr_requests")
user = relationship("User", foreign_keys=[user_id], backref="gdpr_requests")
@@ -121,53 +117,43 @@ class APIKey(Base):
"""API key for programmatic access."""
__tablename__ = "api_keys"
- org_id = Column(String(36), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True)
- user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
+ org_id = Column(CompatibleUUID, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True)
+ user_id = Column(CompatibleUUID, ForeignKey("users.id"), nullable=False)
name = Column(String(200), nullable=False)
key_hash = Column(String(255), nullable=False) # Only store hash
key_prefix = Column(String(10), nullable=False) # For identification
# Permissions
- scopes_json = Column(Text, default="[]") # Specific permissions
+ scopes = Column(CompatibleJSONB, default=[]) # Specific permissions
is_full_access = Column(Boolean, default=False)
# Status
is_active = Column(Boolean, default=True)
- expires_at = Column(DateTime, nullable=True)
+ expires_at = Column(DateTime(timezone=True), nullable=True)
# Usage
- last_used_at = Column(DateTime, nullable=True)
+ last_used_at = Column(DateTime(timezone=True), nullable=True)
last_used_ip = Column(String(45), nullable=True)
usage_count = Column(Integer, default=0)
# Limits
rate_limit = Column(Integer, default=1000) # Requests per hour
current_usage = Column(Integer, default=0)
- usage_reset_at = Column(DateTime, nullable=True)
+ usage_reset_at = Column(DateTime(timezone=True), nullable=True)
- created_by = Column(String(36), ForeignKey("users.id"), nullable=False)
+ created_by = Column(CompatibleUUID, ForeignKey("users.id"), nullable=False)
organization = relationship("Organization", backref="api_keys")
owner = relationship("User", foreign_keys=[user_id], backref="api_keys")
creator = relationship("User", foreign_keys=[created_by])
- @property
- def scopes(self):
- import json
- return json.loads(self.scopes_json or "[]")
-
- @scopes.setter
- def scopes(self, value):
- import json
- self.scopes_json = json.dumps(value)
-
class SystemHealth(Base):
"""System health monitoring snapshots."""
__tablename__ = "system_health"
- snapshot_time = Column(DateTime, default=datetime.utcnow, index=True)
+ snapshot_time = Column(DateTime(timezone=True), server_default=func.now(), index=True)
# Database
db_connections_active = Column(Integer, nullable=True)
@@ -192,4 +178,4 @@ class SystemHealth(Base):
storage_used_mb = Column(Float, nullable=True)
# Alerts
- active_alerts_json = Column(Text, default="[]")
+ active_alerts = Column(CompatibleJSONB, default=[])
diff --git a/backend/app/models/automation.py b/backend/app/models/automation.py
index bd394d3..8ea552f 100644
--- a/backend/app/models/automation.py
+++ b/backend/app/models/automation.py
@@ -3,12 +3,14 @@
Pattern detection and AI agent management
"""
-from sqlalchemy import Column, String, Enum, Text, Boolean, ForeignKey, Integer, Float, DateTime
+from sqlalchemy import Column, String, Text, Boolean, ForeignKey, Integer, Float, DateTime
from sqlalchemy.orm import relationship
+from sqlalchemy.sql import func
import enum
-from datetime import datetime
+import uuid
-from app.database import Base
+
+from app.database import Base, CompatibleJSONB, CompatibleUUID, Enum
class PatternStatus(str, enum.Enum):
@@ -34,7 +36,7 @@ class AutomationPattern(Base):
"""Detected automation opportunity."""
__tablename__ = "automation_patterns"
- org_id = Column(String(36), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True)
+ org_id = Column(CompatibleUUID, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True)
# Pattern details
name = Column(String(500), nullable=False)
@@ -53,55 +55,25 @@ class AutomationPattern(Base):
implementation_complexity = Column(Integer, default=5) # 1-10
# Automation details
- automation_recipe_json = Column(Text, default="{}")
- triggers_json = Column(Text, default="[]")
- actions_json = Column(Text, default="[]")
+ automation_recipe = Column(CompatibleJSONB, default={})
+ triggers = Column(CompatibleJSONB, default=[])
+ actions = Column(CompatibleJSONB, default=[])
# User feedback
- accepted_by = Column(String(36), ForeignKey("users.id"), nullable=True)
- accepted_at = Column(DateTime, nullable=True)
+ accepted_by = Column(CompatibleUUID, ForeignKey("users.id"), nullable=True)
+ accepted_at = Column(DateTime(timezone=True), nullable=True)
rejection_reason = Column(Text, nullable=True)
organization = relationship("Organization", backref="automation_patterns")
accepted_user = relationship("User", backref="accepted_patterns")
- @property
- def automation_recipe(self):
- import json
- return json.loads(self.automation_recipe_json or "{}")
-
- @automation_recipe.setter
- def automation_recipe(self, value):
- import json
- self.automation_recipe_json = json.dumps(value)
-
- @property
- def triggers(self):
- import json
- return json.loads(self.triggers_json or "[]")
-
- @triggers.setter
- def triggers(self, value):
- import json
- self.triggers_json = json.dumps(value)
-
- @property
- def actions(self):
- import json
- return json.loads(self.actions_json or "[]")
-
- @actions.setter
- def actions(self, value):
- import json
- self.actions_json = json.dumps(value)
-
class AIAgent(Base):
"""AI automation agent."""
__tablename__ = "ai_agents"
- org_id = Column(String(36), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True)
- pattern_id = Column(String(36), ForeignKey("automation_patterns.id"), nullable=True)
+ org_id = Column(CompatibleUUID, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True)
+ pattern_id = Column(CompatibleUUID, ForeignKey("automation_patterns.id"), nullable=True)
# Agent info
name = Column(String(200), nullable=False)
@@ -109,11 +81,11 @@ class AIAgent(Base):
status = Column(Enum(AgentStatus), default=AgentStatus.CREATED)
# Configuration
- config_json = Column(Text, default="{}")
- permissions_json = Column(Text, default="[]")
+ config = Column(CompatibleJSONB, default={})
+ permissions = Column(CompatibleJSONB, default=[])
# Shadow mode tracking
- shadow_started_at = Column(DateTime, nullable=True)
+ shadow_started_at = Column(DateTime(timezone=True), nullable=True)
shadow_match_rate = Column(Float, nullable=True)
shadow_runs = Column(Integer, default=0)
@@ -121,92 +93,42 @@ class AIAgent(Base):
total_runs = Column(Integer, default=0)
successful_runs = Column(Integer, default=0)
hours_saved_total = Column(Float, default=0)
- last_run_at = Column(DateTime, nullable=True)
- live_started_at = Column(DateTime, nullable=True)
+ last_run_at = Column(DateTime(timezone=True), nullable=True)
+ live_started_at = Column(DateTime(timezone=True), nullable=True)
# Ownership
- created_by = Column(String(36), ForeignKey("users.id"), nullable=False)
- approved_by = Column(String(36), ForeignKey("users.id"), nullable=True)
- approved_at = Column(DateTime, nullable=True)
+ created_by = Column(CompatibleUUID, ForeignKey("users.id"), nullable=False)
+ approved_by = Column(CompatibleUUID, ForeignKey("users.id"), nullable=True)
+ approved_at = Column(DateTime(timezone=True), nullable=True)
organization = relationship("Organization", backref="ai_agents")
pattern = relationship("AutomationPattern", backref="agents")
creator = relationship("User", foreign_keys=[created_by], backref="created_agents")
approver = relationship("User", foreign_keys=[approved_by], backref="approved_agents")
- @property
- def config(self):
- import json
- return json.loads(self.config_json or "{}")
-
- @config.setter
- def config(self, value):
- import json
- self.config_json = json.dumps(value)
-
- @property
- def permissions(self):
- import json
- return json.loads(self.permissions_json or "[]")
-
- @permissions.setter
- def permissions(self, value):
- import json
- self.permissions_json = json.dumps(value)
-
class AgentRun(Base):
"""Individual agent execution record."""
__tablename__ = "agent_runs"
- agent_id = Column(String(36), ForeignKey("ai_agents.id", ondelete="CASCADE"), nullable=False, index=True)
- org_id = Column(String(36), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False)
+ agent_id = Column(CompatibleUUID, ForeignKey("ai_agents.id", ondelete="CASCADE"), nullable=False, index=True)
+ org_id = Column(CompatibleUUID, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False)
# Execution
- started_at = Column(DateTime, default=datetime.utcnow)
- completed_at = Column(DateTime, nullable=True)
+ started_at = Column(DateTime(timezone=True), server_default=func.now())
+ completed_at = Column(DateTime(timezone=True), nullable=True)
status = Column(String(50), default="running") # running, success, failed
execution_time_ms = Column(Integer, nullable=True)
# Results
- input_data_json = Column(Text, default="{}")
- output_data_json = Column(Text, default="{}")
+ input_data = Column(CompatibleJSONB, default={})
+ output_data = Column(CompatibleJSONB, default={})
error_message = Column(Text, nullable=True)
# Shadow mode comparison
is_shadow = Column(Boolean, default=False)
- human_action_json = Column(Text, nullable=True)
+ human_action = Column(CompatibleJSONB, nullable=True)
matched_human = Column(Boolean, nullable=True)
agent = relationship("AIAgent", backref="runs")
organization = relationship("Organization", backref="agent_runs")
-
- @property
- def input_data(self):
- import json
- return json.loads(self.input_data_json or "{}")
-
- @input_data.setter
- def input_data(self, value):
- import json
- self.input_data_json = json.dumps(value)
-
- @property
- def output_data(self):
- import json
- return json.loads(self.output_data_json or "{}")
-
- @output_data.setter
- def output_data(self, value):
- import json
- self.output_data_json = json.dumps(value)
-
- @property
- def human_action(self):
- import json
- return json.loads(self.human_action_json or "null")
-
- @human_action.setter
- def human_action(self, value):
- import json
- self.human_action_json = json.dumps(value)
diff --git a/backend/app/models/checkin.py b/backend/app/models/checkin.py
index f56b943..47d88ab 100644
--- a/backend/app/models/checkin.py
+++ b/backend/app/models/checkin.py
@@ -3,13 +3,15 @@
Smart check-in system for proactive task monitoring
"""
-from sqlalchemy import Column, String, Enum, Text, Boolean, ForeignKey, Integer, Float, DateTime
+from sqlalchemy import Column, String, Text, Boolean, ForeignKey, Integer, Float, DateTime
from sqlalchemy.orm import relationship, backref
+from sqlalchemy.sql import func
import enum
-from datetime import datetime
+import uuid
+from datetime import datetime, timezone
from typing import Optional
-from app.database import Base
+from app.database import Base, CompatibleUUID, Enum
class CheckInTrigger(str, enum.Enum):
@@ -51,19 +53,19 @@ class CheckIn(Base):
# Relationships
task_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("tasks.id", ondelete="CASCADE"),
nullable=False,
index=True
)
user_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
index=True
)
org_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("organizations.id", ondelete="CASCADE"),
nullable=False,
index=True
@@ -75,9 +77,9 @@ class CheckIn(Base):
status = Column(Enum(CheckInStatus), default=CheckInStatus.PENDING, index=True)
# Timing
- scheduled_at = Column(DateTime, nullable=False)
- responded_at = Column(DateTime, nullable=True)
- expires_at = Column(DateTime, nullable=True)
+ scheduled_at = Column(DateTime(timezone=True), nullable=False)
+ responded_at = Column(DateTime(timezone=True), nullable=True)
+ expires_at = Column(DateTime(timezone=True), nullable=True)
# User response
progress_indicator = Column(Enum(ProgressIndicator), nullable=True)
@@ -95,8 +97,8 @@ class CheckIn(Base):
# Escalation
escalated = Column(Boolean, default=False)
- escalated_to = Column(String(36), ForeignKey("users.id"), nullable=True)
- escalated_at = Column(DateTime, nullable=True)
+ escalated_to = Column(CompatibleUUID, ForeignKey("users.id"), nullable=True)
+ escalated_at = Column(DateTime(timezone=True), nullable=True)
escalation_reason = Column(Text, nullable=True)
# Relationships
@@ -115,7 +117,7 @@ def is_overdue(self) -> bool:
return False
if not self.expires_at:
return False
- return datetime.utcnow() > self.expires_at
+ return datetime.now(timezone.utc) > self.expires_at
@property
def response_time_minutes(self) -> Optional[int]:
@@ -135,16 +137,16 @@ class CheckInConfig(Base):
__tablename__ = "checkin_configs"
org_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("organizations.id", ondelete="CASCADE"),
nullable=False,
index=True
)
# Scope - one of these should be set (null = org-wide default)
- team_id = Column(String(36), nullable=True, index=True)
- user_id = Column(String(36), ForeignKey("users.id"), nullable=True, index=True)
- task_id = Column(String(36), ForeignKey("tasks.id"), nullable=True, index=True)
+ team_id = Column(CompatibleUUID, nullable=True, index=True)
+ user_id = Column(CompatibleUUID, ForeignKey("users.id"), nullable=True, index=True)
+ task_id = Column(CompatibleUUID, ForeignKey("tasks.id"), nullable=True, index=True)
# Check-in settings
interval_hours = Column(Float, default=3.0) # Hours between check-ins
@@ -191,22 +193,22 @@ class CheckInReminder(Base):
__tablename__ = "checkin_reminders"
checkin_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("checkins.id", ondelete="CASCADE"),
nullable=False,
index=True
)
user_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True
)
reminder_number = Column(Integer, default=1) # 1st, 2nd reminder
channel = Column(String(50), default="in_app") # in_app, email, slack, etc.
- sent_at = Column(DateTime, default=datetime.utcnow)
+ sent_at = Column(DateTime(timezone=True), server_default=func.now())
acknowledged = Column(Boolean, default=False)
- acknowledged_at = Column(DateTime, nullable=True)
+ acknowledged_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
checkin = relationship("CheckIn", backref="reminders")
diff --git a/backend/app/models/knowledge_base.py b/backend/app/models/knowledge_base.py
index ba02565..f0094a7 100644
--- a/backend/app/models/knowledge_base.py
+++ b/backend/app/models/knowledge_base.py
@@ -3,13 +3,12 @@
Document storage for RAG-powered AI assistance
"""
-from sqlalchemy import Column, String, Enum, Text, Boolean, ForeignKey, Integer, Float, DateTime
+from sqlalchemy import Column, String, Text, Boolean, ForeignKey, Integer, Float, DateTime
from sqlalchemy.orm import relationship
+from pgvector.sqlalchemy import Vector
import enum
-from datetime import datetime
-from typing import Optional
-from app.database import Base
+from app.database import Base, CompatibleJSONB, CompatibleUUID, Enum
class DocumentSource(str, enum.Enum):
@@ -58,7 +57,7 @@ class Document(Base):
__tablename__ = "documents"
org_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("organizations.id", ondelete="CASCADE"),
nullable=False,
index=True
@@ -85,15 +84,17 @@ class Document(Base):
file_name = Column(String(500), nullable=True)
file_type = Column(String(50), nullable=True) # pdf, md, txt, etc.
file_size = Column(Integer, nullable=True)
+ storage_path = Column(String(500), nullable=True)
+ storage_url = Column(String(2000), nullable=True)
language = Column(String(50), default="en")
# Access control
is_public = Column(Boolean, default=False) # Visible to all org members
- team_ids_json = Column(Text, default="[]") # Restricted to these teams
+ team_ids = Column(CompatibleJSONB, default=[]) # Restricted to these teams
# Categorization
- tags_json = Column(Text, default="[]")
- categories_json = Column(Text, default="[]")
+ tags = Column(CompatibleJSONB, default=[])
+ categories = Column(CompatibleJSONB, default=[])
# Stats
view_count = Column(Integer, default=0)
@@ -111,45 +112,6 @@ class Document(Base):
def __repr__(self) -> str:
return f""
- @property
- def tags(self) -> list:
- import json
- try:
- return json.loads(self.tags_json or "[]")
- except:
- return []
-
- @tags.setter
- def tags(self, value: list):
- import json
- self.tags_json = json.dumps(value)
-
- @property
- def categories(self) -> list:
- import json
- try:
- return json.loads(self.categories_json or "[]")
- except:
- return []
-
- @categories.setter
- def categories(self, value: list):
- import json
- self.categories_json = json.dumps(value)
-
- @property
- def team_ids(self) -> list:
- import json
- try:
- return json.loads(self.team_ids_json or "[]")
- except:
- return []
-
- @team_ids.setter
- def team_ids(self, value: list):
- import json
- self.team_ids_json = json.dumps(value)
-
class DocumentChunk(Base):
"""
@@ -160,7 +122,7 @@ class DocumentChunk(Base):
__tablename__ = "document_chunks"
document_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("documents.id", ondelete="CASCADE"),
nullable=False,
index=True
@@ -172,14 +134,13 @@ class DocumentChunk(Base):
start_char = Column(Integer, nullable=True) # Start position in original
end_char = Column(Integer, nullable=True) # End position in original
- # Embedding (stored as JSON string for SQLite compatibility)
- # In production, use pgvector or similar
- embedding_json = Column(Text, nullable=True)
+ # Embedding (pgvector native column)
+ embedding = Column(Vector(1536), nullable=True)
embedding_model = Column(String(100), nullable=True)
# Metadata
token_count = Column(Integer, nullable=True)
- metadata_json = Column(Text, default="{}")
+ chunk_metadata = Column(CompatibleJSONB, default={})
# Relationships
document = relationship("Document", back_populates="chunks")
@@ -187,32 +148,6 @@ class DocumentChunk(Base):
def __repr__(self) -> str:
return f""
- @property
- def embedding(self) -> Optional[list]:
- import json
- try:
- return json.loads(self.embedding_json) if self.embedding_json else None
- except:
- return None
-
- @embedding.setter
- def embedding(self, value: list):
- import json
- self.embedding_json = json.dumps(value) if value else None
-
- @property
- def chunk_metadata(self) -> dict:
- import json
- try:
- return json.loads(self.metadata_json or "{}")
- except:
- return {}
-
- @chunk_metadata.setter
- def chunk_metadata(self, value: dict):
- import json
- self.metadata_json = json.dumps(value)
-
class UnblockSession(Base):
"""
@@ -223,25 +158,25 @@ class UnblockSession(Base):
__tablename__ = "unblock_sessions"
org_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("organizations.id", ondelete="CASCADE"),
nullable=False,
index=True
)
user_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
index=True
)
task_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("tasks.id", ondelete="SET NULL"),
nullable=True,
index=True
)
checkin_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("checkins.id", ondelete="SET NULL"),
nullable=True
)
@@ -254,12 +189,12 @@ class UnblockSession(Base):
# Response
response = Column(Text, nullable=True)
confidence = Column(Float, nullable=True)
- sources_json = Column(Text, default="[]") # Document IDs used
+ sources = Column(CompatibleJSONB, default=[]) # Document IDs used
# Escalation
escalation_recommended = Column(Boolean, default=False)
escalated = Column(Boolean, default=False)
- escalated_to = Column(String(36), ForeignKey("users.id"), nullable=True)
+ escalated_to = Column(CompatibleUUID, ForeignKey("users.id"), nullable=True)
# Feedback
was_helpful = Column(Boolean, nullable=True)
@@ -275,16 +210,3 @@ class UnblockSession(Base):
def __repr__(self) -> str:
return f""
-
- @property
- def sources(self) -> list:
- import json
- try:
- return json.loads(self.sources_json or "[]")
- except:
- return []
-
- @sources.setter
- def sources(self, value: list):
- import json
- self.sources_json = json.dumps(value)
diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py
index 7a0e7b6..da444fc 100644
--- a/backend/app/models/notification.py
+++ b/backend/app/models/notification.py
@@ -3,12 +3,13 @@
Communication and external system connections
"""
-from sqlalchemy import Column, String, Enum, Text, Boolean, ForeignKey, Integer, Float, DateTime
+from sqlalchemy import Column, String, Text, Boolean, ForeignKey, Integer, Float, DateTime
from sqlalchemy.orm import relationship, backref
+from sqlalchemy.sql import func
import enum
-from datetime import datetime
+import uuid
-from app.database import Base
+from app.database import Base, CompatibleJSONB, CompatibleUUID, Enum
class NotificationType(str, enum.Enum):
@@ -65,29 +66,29 @@ class Notification(Base):
"""User notification."""
__tablename__ = "notifications"
- user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
- org_id = Column(String(36), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True)
+ user_id = Column(CompatibleUUID, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
+ org_id = Column(CompatibleUUID, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True)
notification_type = Column(Enum(NotificationType), nullable=False)
title = Column(String(500), nullable=False)
message = Column(Text, nullable=True)
# Related entities
- task_id = Column(String(36), ForeignKey("tasks.id"), nullable=True)
- checkin_id = Column(String(36), ForeignKey("checkins.id"), nullable=True)
+ task_id = Column(CompatibleUUID, ForeignKey("tasks.id"), nullable=True)
+ checkin_id = Column(CompatibleUUID, ForeignKey("checkins.id"), nullable=True)
# Status
is_read = Column(Boolean, default=False)
- read_at = Column(DateTime, nullable=True)
+ read_at = Column(DateTime(timezone=True), nullable=True)
# Delivery
channel = Column(Enum(NotificationChannel), default=NotificationChannel.IN_APP)
delivered = Column(Boolean, default=False)
- delivered_at = Column(DateTime, nullable=True)
+ delivered_at = Column(DateTime(timezone=True), nullable=True)
# Action
action_url = Column(String(1000), nullable=True)
- action_data_json = Column(Text, default="{}")
+ action_data = Column(CompatibleJSONB, default={})
user = relationship("User", backref="notifications")
organization = relationship("Organization", backref="notifications")
@@ -99,8 +100,8 @@ class NotificationPreference(Base):
"""User notification preferences."""
__tablename__ = "notification_preferences"
- user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
- org_id = Column(String(36), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False)
+ user_id = Column(CompatibleUUID, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
+ org_id = Column(CompatibleUUID, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False)
notification_type = Column(Enum(NotificationType), nullable=False)
channel = Column(Enum(NotificationChannel), nullable=False)
@@ -118,7 +119,7 @@ class Integration(Base):
"""External integration configuration."""
__tablename__ = "integrations"
- org_id = Column(String(36), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True)
+ org_id = Column(CompatibleUUID, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True)
integration_type = Column(Enum(IntegrationType), nullable=False)
name = Column(String(200), nullable=False)
@@ -126,97 +127,77 @@ class Integration(Base):
# Connection
is_active = Column(Boolean, default=False)
- config_json = Column(Text, default="{}") # Encrypted in production
- credentials_json = Column(Text, default="{}") # Encrypted
+ config = Column(CompatibleJSONB, default={}) # Encrypted in production
+ credentials = Column(CompatibleJSONB, default={}) # Encrypted
# Sync
sync_enabled = Column(Boolean, default=True)
- last_sync_at = Column(DateTime, nullable=True)
+ last_sync_at = Column(DateTime(timezone=True), nullable=True)
last_sync_status = Column(String(50), nullable=True)
sync_error = Column(Text, nullable=True)
# OAuth
oauth_access_token = Column(Text, nullable=True) # Encrypted
oauth_refresh_token = Column(Text, nullable=True) # Encrypted
- oauth_expires_at = Column(DateTime, nullable=True)
+ oauth_expires_at = Column(DateTime(timezone=True), nullable=True)
- connected_by = Column(String(36), ForeignKey("users.id"), nullable=True)
- connected_at = Column(DateTime, nullable=True)
+ connected_by = Column(CompatibleUUID, ForeignKey("users.id"), nullable=True)
+ connected_at = Column(DateTime(timezone=True), nullable=True)
organization = relationship("Organization", backref="integrations")
connector = relationship("User", backref="connected_integrations")
- @property
- def config(self):
- import json
- return json.loads(self.config_json or "{}")
-
- @config.setter
- def config(self, value):
- import json
- self.config_json = json.dumps(value)
-
class Webhook(Base):
"""Outbound webhook configuration."""
__tablename__ = "webhooks"
- org_id = Column(String(36), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True)
+ org_id = Column(CompatibleUUID, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True)
name = Column(String(200), nullable=False)
url = Column(String(2000), nullable=False)
secret = Column(String(255), nullable=True) # For signature verification
# Events
- events_json = Column(Text, default="[]") # List of event types to trigger
+ events = Column(CompatibleJSONB, default=[]) # List of event types to trigger
# Status
is_active = Column(Boolean, default=True)
# Headers
- headers_json = Column(Text, default="{}")
+ headers = Column(CompatibleJSONB, default={})
# Stats
total_deliveries = Column(Integer, default=0)
successful_deliveries = Column(Integer, default=0)
- last_delivery_at = Column(DateTime, nullable=True)
+ last_delivery_at = Column(DateTime(timezone=True), nullable=True)
last_delivery_status = Column(Integer, nullable=True)
- created_by = Column(String(36), ForeignKey("users.id"), nullable=False)
+ created_by = Column(CompatibleUUID, ForeignKey("users.id"), nullable=False)
organization = relationship("Organization", backref="webhooks")
creator = relationship("User", backref="created_webhooks")
- @property
- def events(self):
- import json
- return json.loads(self.events_json or "[]")
-
- @events.setter
- def events(self, value):
- import json
- self.events_json = json.dumps(value)
-
class WebhookDelivery(Base):
"""Webhook delivery log."""
__tablename__ = "webhook_deliveries"
- webhook_id = Column(String(36), ForeignKey("webhooks.id", ondelete="CASCADE"), nullable=False, index=True)
- org_id = Column(String(36), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False)
+ webhook_id = Column(CompatibleUUID, ForeignKey("webhooks.id", ondelete="CASCADE"), nullable=False, index=True)
+ org_id = Column(CompatibleUUID, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False)
event_type = Column(String(100), nullable=False)
- payload_json = Column(Text, default="{}")
+ payload = Column(CompatibleJSONB, default={})
# Delivery
- attempted_at = Column(DateTime, default=datetime.utcnow)
+ attempted_at = Column(DateTime(timezone=True), server_default=func.now())
response_status = Column(Integer, nullable=True)
response_body = Column(Text, nullable=True)
response_time_ms = Column(Integer, nullable=True)
# Retry
retry_count = Column(Integer, default=0)
- next_retry_at = Column(DateTime, nullable=True)
+ next_retry_at = Column(DateTime(timezone=True), nullable=True)
is_successful = Column(Boolean, default=False)
webhook = relationship("Webhook", backref="deliveries")
diff --git a/backend/app/models/organization.py b/backend/app/models/organization.py
index 722e7ba..4e7f340 100644
--- a/backend/app/models/organization.py
+++ b/backend/app/models/organization.py
@@ -3,11 +3,11 @@
Multi-tenant organization support
"""
-from sqlalchemy import Column, String, Enum, Text, Boolean
+from sqlalchemy import Column, String, Text, Boolean
from sqlalchemy.orm import relationship
import enum
-from app.database import Base
+from app.database import Base, CompatibleJSONB, Enum
class PlanTier(str, enum.Enum):
@@ -38,8 +38,8 @@ class Organization(Base):
nullable=False
)
- # Settings stored as JSON string (SQLite doesn't have native JSON)
- settings_json = Column(Text, default="{}")
+ # Settings stored as JSONB
+ settings_data = Column(CompatibleJSONB, default={})
# Status
is_active = Column(Boolean, default=True, nullable=False)
@@ -53,18 +53,11 @@ def __repr__(self) -> str:
@property
def settings(self) -> dict:
- """Parse settings JSON string to dict."""
- import json
- try:
- return json.loads(self.settings_json or "{}")
- except json.JSONDecodeError:
- return {}
+ return self.settings_data or {}
@settings.setter
def settings(self, value: dict) -> None:
- """Serialize settings dict to JSON string."""
- import json
- self.settings_json = json.dumps(value)
+ self.settings_data = value
@property
def is_enterprise(self) -> bool:
diff --git a/backend/app/models/prediction.py b/backend/app/models/prediction.py
index a17e83e..4a7dd96 100644
--- a/backend/app/models/prediction.py
+++ b/backend/app/models/prediction.py
@@ -3,12 +3,14 @@
ML-powered forecasting and risk assessment
"""
-from sqlalchemy import Column, String, Enum, Text, Boolean, ForeignKey, Integer, Float, DateTime
+from sqlalchemy import Column, String, Text, Boolean, ForeignKey, Integer, Float, DateTime
from sqlalchemy.orm import relationship, backref
+from sqlalchemy.sql import func
import enum
+import uuid
from datetime import datetime
-from app.database import Base
+from app.database import Base, CompatibleJSONB, CompatibleUUID, Enum
class PredictionType(str, enum.Enum):
@@ -24,55 +26,45 @@ class Prediction(Base):
"""Prediction record for tasks/projects."""
__tablename__ = "predictions"
- org_id = Column(String(36), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True)
+ org_id = Column(CompatibleUUID, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True)
prediction_type = Column(Enum(PredictionType), nullable=False)
# Target
- task_id = Column(String(36), ForeignKey("tasks.id"), nullable=True)
- project_id = Column(String(36), nullable=True)
- user_id = Column(String(36), ForeignKey("users.id"), nullable=True)
- team_id = Column(String(36), nullable=True)
+ task_id = Column(CompatibleUUID, ForeignKey("tasks.id"), nullable=True)
+ project_id = Column(CompatibleUUID, nullable=True)
+ user_id = Column(CompatibleUUID, ForeignKey("users.id"), nullable=True)
+ team_id = Column(CompatibleUUID, nullable=True)
# Prediction values
- predicted_date_p25 = Column(DateTime, nullable=True) # 25th percentile
- predicted_date_p50 = Column(DateTime, nullable=True) # Median
- predicted_date_p90 = Column(DateTime, nullable=True) # 90th percentile
+ predicted_date_p25 = Column(DateTime(timezone=True), nullable=True) # 25th percentile
+ predicted_date_p50 = Column(DateTime(timezone=True), nullable=True) # Median
+ predicted_date_p90 = Column(DateTime(timezone=True), nullable=True) # 90th percentile
confidence = Column(Float, nullable=True)
risk_score = Column(Float, nullable=True) # 0-1
# Factors
- risk_factors_json = Column(Text, default="[]")
+ risk_factors = Column(CompatibleJSONB, default=[])
model_version = Column(String(50), default="v1")
- features_json = Column(Text, default="{}")
+ features = Column(CompatibleJSONB, default={})
# Accuracy tracking
- actual_date = Column(DateTime, nullable=True)
+ actual_date = Column(DateTime(timezone=True), nullable=True)
accuracy_score = Column(Float, nullable=True)
organization = relationship("Organization", backref="predictions")
task = relationship("Task", backref=backref("predictions", passive_deletes=True))
user = relationship("User", backref="predictions")
- @property
- def risk_factors(self):
- import json
- return json.loads(self.risk_factors_json or "[]")
-
- @risk_factors.setter
- def risk_factors(self, value):
- import json
- self.risk_factors_json = json.dumps(value)
-
class VelocitySnapshot(Base):
"""Team velocity snapshots for trending."""
__tablename__ = "velocity_snapshots"
- org_id = Column(String(36), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False)
- team_id = Column(String(36), nullable=False, index=True)
+ org_id = Column(CompatibleUUID, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False)
+ team_id = Column(CompatibleUUID, nullable=False, index=True)
- period_start = Column(DateTime, nullable=False)
- period_end = Column(DateTime, nullable=False)
+ period_start = Column(DateTime(timezone=True), nullable=False)
+ period_end = Column(DateTime(timezone=True), nullable=False)
tasks_completed = Column(Integer, default=0)
story_points_completed = Column(Float, default=0)
diff --git a/backend/app/models/skill.py b/backend/app/models/skill.py
index 2a27fb6..5b728f6 100644
--- a/backend/app/models/skill.py
+++ b/backend/app/models/skill.py
@@ -3,13 +3,16 @@
Employee skill tracking and analysis
"""
-from sqlalchemy import Column, String, Enum, Text, Boolean, ForeignKey, Integer, Float, DateTime
-from sqlalchemy.orm import relationship
+import uuid
import enum
-from datetime import datetime
+from datetime import datetime, timezone
from typing import Optional, List
-from app.database import Base
+from sqlalchemy import Column, String, Text, Boolean, ForeignKey, Integer, Float, DateTime
+from sqlalchemy.orm import relationship
+from sqlalchemy.sql import func
+
+from app.database import Base, CompatibleJSONB, CompatibleUUID, Enum
class SkillCategory(str, enum.Enum):
@@ -45,7 +48,7 @@ class Skill(Base):
__tablename__ = "skills"
org_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("organizations.id", ondelete="CASCADE"),
nullable=False,
index=True
@@ -56,10 +59,10 @@ class Skill(Base):
description = Column(Text, nullable=True)
category = Column(Enum(SkillCategory), default=SkillCategory.TECHNICAL)
- # Metadata
- aliases_json = Column(Text, default="[]") # Alternative names
- related_skills_json = Column(Text, default="[]") # Related skill IDs
- prerequisites_json = Column(Text, default="[]") # Prerequisite skill IDs
+ # Metadata (native JSONB)
+ aliases = Column(CompatibleJSONB, default=[]) # Alternative names
+ related_skills = Column(CompatibleJSONB, default=[]) # Related skill IDs
+ prerequisites = Column(CompatibleJSONB, default=[]) # Prerequisite skill IDs
# Benchmarks
org_average_level = Column(Float, nullable=True)
@@ -74,32 +77,6 @@ class Skill(Base):
def __repr__(self) -> str:
return f""
- @property
- def aliases(self) -> List[str]:
- import json
- try:
- return json.loads(self.aliases_json or "[]")
- except:
- return []
-
- @aliases.setter
- def aliases(self, value: List[str]):
- import json
- self.aliases_json = json.dumps(value)
-
- @property
- def related_skills(self) -> List[str]:
- import json
- try:
- return json.loads(self.related_skills_json or "[]")
- except:
- return []
-
- @related_skills.setter
- def related_skills(self, value: List[str]):
- import json
- self.related_skills_json = json.dumps(value)
-
class UserSkill(Base):
"""
@@ -110,19 +87,19 @@ class UserSkill(Base):
__tablename__ = "user_skills"
user_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True
)
skill_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("skills.id", ondelete="CASCADE"),
nullable=False,
index=True
)
org_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("organizations.id", ondelete="CASCADE"),
nullable=False,
index=True
@@ -134,18 +111,18 @@ class UserSkill(Base):
trend = Column(Enum(SkillTrend), default=SkillTrend.STABLE)
# Evidence
- last_demonstrated = Column(DateTime, nullable=True)
+ last_demonstrated = Column(DateTime(timezone=True), nullable=True)
demonstration_count = Column(Integer, default=0)
source = Column(String(50), default="inferred") # inferred, self_reported, manager_assessed, certification
- # History
- level_history_json = Column(Text, default="[]") # [{date, level}, ...]
+ # History (native JSONB)
+ level_history = Column(CompatibleJSONB, default=[]) # [{date, level}, ...]
notes = Column(Text, nullable=True)
# Certification
is_certified = Column(Boolean, default=False)
- certification_date = Column(DateTime, nullable=True)
- certification_expiry = Column(DateTime, nullable=True)
+ certification_date = Column(DateTime(timezone=True), nullable=True)
+ certification_expiry = Column(DateTime(timezone=True), nullable=True)
# Relationships
user = relationship("User", backref="user_skills")
@@ -155,24 +132,11 @@ class UserSkill(Base):
def __repr__(self) -> str:
return f""
- @property
- def level_history(self) -> List[dict]:
- import json
- try:
- return json.loads(self.level_history_json or "[]")
- except:
- return []
-
- @level_history.setter
- def level_history(self, value: List[dict]):
- import json
- self.level_history_json = json.dumps(value)
-
def add_level_snapshot(self, new_level: float) -> None:
"""Add a level snapshot to history."""
- history = self.level_history
+ history = list(self.level_history or [])
history.append({
- "date": datetime.utcnow().isoformat(),
+ "date": datetime.now(timezone.utc).isoformat(),
"level": new_level
})
# Keep last 50 entries
@@ -188,19 +152,19 @@ class SkillGap(Base):
__tablename__ = "skill_gaps"
user_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True
)
skill_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("skills.id", ondelete="CASCADE"),
nullable=False,
index=True
)
org_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("organizations.id", ondelete="CASCADE"),
nullable=False,
index=True
@@ -215,14 +179,14 @@ class SkillGap(Base):
# Context
for_role = Column(String(200), nullable=True) # Role requiring this skill
priority = Column(Integer, default=5) # 1-10, higher = more important
- identified_at = Column(DateTime, default=datetime.utcnow)
+ identified_at = Column(DateTime(timezone=True), server_default=func.now())
# Resolution
is_resolved = Column(Boolean, default=False)
- resolved_at = Column(DateTime, nullable=True)
+ resolved_at = Column(DateTime(timezone=True), nullable=True)
- # Recommendations
- learning_resources_json = Column(Text, default="[]")
+ # Recommendations (native JSONB)
+ learning_resources = Column(CompatibleJSONB, default=[])
# Relationships
user = relationship("User", backref="skill_gaps")
@@ -231,19 +195,6 @@ class SkillGap(Base):
def __repr__(self) -> str:
return f""
- @property
- def learning_resources(self) -> List[dict]:
- import json
- try:
- return json.loads(self.learning_resources_json or "[]")
- except:
- return []
-
- @learning_resources.setter
- def learning_resources(self, value: List[dict]):
- import json
- self.learning_resources_json = json.dumps(value)
-
class SkillMetrics(Base):
"""
@@ -254,21 +205,21 @@ class SkillMetrics(Base):
__tablename__ = "skill_metrics"
user_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True
)
org_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("organizations.id", ondelete="CASCADE"),
nullable=False,
index=True
)
# Period
- period_start = Column(DateTime, nullable=False)
- period_end = Column(DateTime, nullable=False)
+ period_start = Column(DateTime(timezone=True), nullable=False)
+ period_end = Column(DateTime(timezone=True), nullable=False)
# Velocity metrics
task_completion_velocity = Column(Float, nullable=True) # Tasks per week
@@ -307,13 +258,13 @@ class LearningPath(Base):
__tablename__ = "learning_paths"
user_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True
)
org_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("organizations.id", ondelete="CASCADE"),
nullable=False,
index=True
@@ -324,15 +275,15 @@ class LearningPath(Base):
description = Column(Text, nullable=True)
target_role = Column(String(200), nullable=True)
- # Skills to develop
- skills_json = Column(Text, default="[]") # [{skill_id, target_level}, ...]
- milestones_json = Column(Text, default="[]") # Progress milestones
+ # Skills to develop (native JSONB)
+ skills_data = Column(CompatibleJSONB, default=[]) # [{skill_id, target_level}, ...]
+ milestones = Column(CompatibleJSONB, default=[]) # Progress milestones
# Progress
progress_percentage = Column(Float, default=0.0)
- started_at = Column(DateTime, nullable=True)
- target_completion = Column(DateTime, nullable=True)
- completed_at = Column(DateTime, nullable=True)
+ started_at = Column(DateTime(timezone=True), nullable=True)
+ target_completion = Column(DateTime(timezone=True), nullable=True)
+ completed_at = Column(DateTime(timezone=True), nullable=True)
# Status
is_active = Column(Boolean, default=True)
@@ -344,29 +295,3 @@ class LearningPath(Base):
def __repr__(self) -> str:
return f""
-
- @property
- def skills(self) -> List[dict]:
- import json
- try:
- return json.loads(self.skills_json or "[]")
- except:
- return []
-
- @skills.setter
- def skills(self, value: List[dict]):
- import json
- self.skills_json = json.dumps(value)
-
- @property
- def milestones(self) -> List[dict]:
- import json
- try:
- return json.loads(self.milestones_json or "[]")
- except:
- return []
-
- @milestones.setter
- def milestones(self, value: List[dict]):
- import json
- self.milestones_json = json.dumps(value)
diff --git a/backend/app/models/task.py b/backend/app/models/task.py
index 7d96ebb..4820049 100644
--- a/backend/app/models/task.py
+++ b/backend/app/models/task.py
@@ -3,13 +3,13 @@
Task management with subtasks, dependencies, and AI scoring
"""
-from sqlalchemy import Column, String, Enum, Text, Boolean, ForeignKey, Integer, Float, DateTime
+from sqlalchemy import Column, String, Text, Boolean, ForeignKey, Integer, Float, DateTime
from sqlalchemy.orm import relationship, backref
import enum
-from datetime import datetime
+from datetime import datetime, timezone
from typing import Optional, List
-from app.database import Base
+from app.database import Base, CompatibleJSONB, CompatibleUUID, Enum
class TaskStatus(str, enum.Enum):
@@ -61,7 +61,7 @@ class Task(Base):
# Organization
org_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("organizations.id", ondelete="CASCADE"),
nullable=False,
index=True
@@ -87,27 +87,27 @@ class Task(Base):
# Assignment
assigned_to = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
index=True
)
created_by = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=False
)
# Team/project grouping
- team_id = Column(String(36), nullable=True, index=True)
- project_id = Column(String(36), nullable=True, index=True)
+ team_id = Column(CompatibleUUID, nullable=True, index=True)
+ project_id = Column(CompatibleUUID, nullable=True, index=True)
# Time tracking
- deadline = Column(DateTime, nullable=True)
+ deadline = Column(DateTime(timezone=True), nullable=True)
estimated_hours = Column(Float, nullable=True)
actual_hours = Column(Float, default=0.0)
- started_at = Column(DateTime, nullable=True)
- completed_at = Column(DateTime, nullable=True)
+ started_at = Column(DateTime(timezone=True), nullable=True)
+ completed_at = Column(DateTime(timezone=True), nullable=True)
# AI-generated scores
risk_score = Column(Float, nullable=True) # 0.00 - 1.00
@@ -118,14 +118,14 @@ class Task(Base):
blocker_type = Column(Enum(BlockerType), nullable=True)
blocker_description = Column(Text, nullable=True)
- # Metadata stored as JSON string
- tools_json = Column(Text, default="[]") # Tools involved
- tags_json = Column(Text, default="[]") # Custom tags
- skills_required_json = Column(Text, default="[]") # Required skills
+ # Metadata stored as native JSONB
+ tools = Column(CompatibleJSONB, default=[]) # Tools involved
+ tags = Column(CompatibleJSONB, default=[]) # Custom tags
+ skills_required = Column(CompatibleJSONB, default=[]) # Required skills
# Parent task (for subtasks)
parent_task_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("tasks.id", ondelete="CASCADE"),
nullable=True,
index=True
@@ -157,46 +157,6 @@ class Task(Base):
def __repr__(self) -> str:
return f""
- # JSON property helpers
- @property
- def tools(self) -> List[str]:
- import json
- try:
- return json.loads(self.tools_json or "[]")
- except json.JSONDecodeError:
- return []
-
- @tools.setter
- def tools(self, value: List[str]) -> None:
- import json
- self.tools_json = json.dumps(value)
-
- @property
- def tags(self) -> List[str]:
- import json
- try:
- return json.loads(self.tags_json or "[]")
- except json.JSONDecodeError:
- return []
-
- @tags.setter
- def tags(self, value: List[str]) -> None:
- import json
- self.tags_json = json.dumps(value)
-
- @property
- def skills_required(self) -> List[str]:
- import json
- try:
- return json.loads(self.skills_required_json or "[]")
- except json.JSONDecodeError:
- return []
-
- @skills_required.setter
- def skills_required(self, value: List[str]) -> None:
- import json
- self.skills_required_json = json.dumps(value)
-
@property
def is_subtask(self) -> bool:
return self.parent_task_id is not None
@@ -213,7 +173,7 @@ def is_completed(self) -> bool:
def is_overdue(self) -> bool:
if not self.deadline:
return False
- return datetime.utcnow() > self.deadline and not self.is_completed
+ return datetime.now(timezone.utc) > self.deadline and not self.is_completed
@property
def progress_percentage(self) -> float:
@@ -246,9 +206,9 @@ def transition_to(self, new_status: TaskStatus) -> bool:
# Update timestamps
if new_status == TaskStatus.IN_PROGRESS and not self.started_at:
- self.started_at = datetime.utcnow()
+ self.started_at = datetime.now(timezone.utc)
elif new_status == TaskStatus.DONE:
- self.completed_at = datetime.utcnow()
+ self.completed_at = datetime.now(timezone.utc)
# Clear blocker info when unblocked
if old_status == TaskStatus.BLOCKED and new_status != TaskStatus.BLOCKED:
@@ -267,13 +227,13 @@ class TaskDependency(Base):
__tablename__ = "task_dependencies"
task_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("tasks.id", ondelete="CASCADE"),
nullable=False,
index=True
)
depends_on_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("tasks.id", ondelete="CASCADE"),
nullable=False,
index=True
@@ -302,13 +262,13 @@ class TaskHistory(Base):
__tablename__ = "task_history"
task_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("tasks.id", ondelete="CASCADE"),
nullable=False,
index=True
)
user_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True
)
@@ -318,25 +278,12 @@ class TaskHistory(Base):
field_name = Column(String(100), nullable=True)
old_value = Column(Text, nullable=True)
new_value = Column(Text, nullable=True)
- details_json = Column(Text, default="{}")
+ details = Column(CompatibleJSONB, default={})
# Relationships
task = relationship("Task", backref=backref("history", cascade="all, delete-orphan", passive_deletes=True))
user = relationship("User", backref="task_changes")
- @property
- def details(self) -> dict:
- import json
- try:
- return json.loads(self.details_json or "{}")
- except json.JSONDecodeError:
- return {}
-
- @details.setter
- def details(self, value: dict) -> None:
- import json
- self.details_json = json.dumps(value)
-
class TaskComment(Base):
"""
@@ -346,13 +293,13 @@ class TaskComment(Base):
__tablename__ = "task_comments"
task_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("tasks.id", ondelete="CASCADE"),
nullable=False,
index=True
)
user_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True
)
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
index 6f00d67..e8f07e4 100644
--- a/backend/app/models/user.py
+++ b/backend/app/models/user.py
@@ -3,12 +3,15 @@
User management with role-based access control
"""
-from sqlalchemy import Column, String, Enum, Text, Boolean, ForeignKey, DateTime
+from sqlalchemy import (
+ Column, String, Text, Boolean, ForeignKey, DateTime,
+ Integer, UniqueConstraint, func
+)
from sqlalchemy.orm import relationship
import enum
-from datetime import datetime
+from datetime import datetime, timezone
-from app.database import Base
+from app.database import Base, CompatibleJSONB, CompatibleUUID, Enum
class UserRole(str, enum.Enum):
@@ -39,7 +42,7 @@ class User(Base):
# Organization relationship
org_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("organizations.id", ondelete="CASCADE"),
nullable=False,
index=True
@@ -49,6 +52,7 @@ class User(Base):
email = Column(String(255), nullable=False, index=True)
password_hash = Column(String(255), nullable=True) # Null for SSO-only users
is_sso_user = Column(Boolean, default=False)
+ supabase_auth_id = Column(CompatibleUUID, unique=True, index=True, nullable=True)
# Profile
first_name = Column(String(100), nullable=False)
@@ -70,22 +74,22 @@ class User(Base):
)
# Team/reporting structure
- team_id = Column(String(36), nullable=True, index=True)
+ team_id = Column(CompatibleUUID, nullable=True, index=True)
manager_id = Column(
- String(36),
+ CompatibleUUID,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True
)
- # GDPR consent tracking (stored as JSON string)
- consent_json = Column(Text, default="{}")
+ # GDPR consent tracking
+ consent_data = Column(CompatibleJSONB, default={})
# Status
is_active = Column(Boolean, default=True, nullable=False)
is_email_verified = Column(Boolean, default=False)
- last_login = Column(DateTime, nullable=True)
- failed_login_attempts = Column(String(10), default="0") # Stored as string for SQLite
- lockout_until = Column(DateTime, nullable=True) # Account lockout after failed attempts
+ last_login = Column(DateTime(timezone=True), nullable=True)
+ failed_login_attempts = Column(Integer, default=0)
+ lockout_until = Column(DateTime(timezone=True), nullable=True) # Account lockout after failed attempts
# Relationships
organization = relationship("Organization", back_populates="users")
@@ -98,8 +102,7 @@ class User(Base):
# Index for unique email per organization
__table_args__ = (
- # Note: SQLite doesn't enforce this well, we'll handle in app logic
- # Index('idx_user_org_email', 'org_id', 'email', unique=True),
+ UniqueConstraint('org_id', 'email', name='uq_user_org_email'),
)
def __repr__(self) -> str:
@@ -112,18 +115,11 @@ def full_name(self) -> str:
@property
def consent(self) -> dict:
- """Parse consent JSON string to dict."""
- import json
- try:
- return json.loads(self.consent_json or "{}")
- except json.JSONDecodeError:
- return {}
+ return self.consent_data or {}
@consent.setter
def consent(self, value: dict) -> None:
- """Serialize consent dict to JSON string."""
- import json
- self.consent_json = json.dumps(value)
+ self.consent_data = value
@property
def has_ai_monitoring_consent(self) -> bool:
@@ -139,7 +135,7 @@ def update_consent(self, consent_type: str, value: bool) -> None:
"""Update a specific consent value."""
current_consent = self.consent
current_consent[consent_type] = value
- current_consent[f"{consent_type}_updated_at"] = datetime.utcnow().isoformat()
+ current_consent[f"{consent_type}_updated_at"] = datetime.now(timezone.utc).isoformat()
self.consent = current_consent
@property
@@ -198,51 +194,3 @@ def can_manage_user(self, other_user: "User") -> bool:
return other_user.manager_id == self.id
return False
-
-
-class Session(Base):
- """
- User session tracking for security and analytics.
- """
-
- __tablename__ = "sessions"
-
- user_id = Column(
- String(36),
- ForeignKey("users.id", ondelete="CASCADE"),
- nullable=False,
- index=True
- )
-
- # Session info
- token_hash = Column(String(255), nullable=False, unique=True, index=True)
- refresh_token_hash = Column(String(255), nullable=True, unique=True)
-
- # Device/client info
- device_info = Column(String(255), nullable=True)
- ip_address = Column(String(45), nullable=True) # IPv6 can be up to 45 chars
- user_agent = Column(String(500), nullable=True)
-
- # Validity
- expires_at = Column(DateTime, nullable=False)
- refresh_expires_at = Column(DateTime, nullable=True)
- is_active = Column(Boolean, default=True, nullable=False)
-
- # Activity tracking
- last_activity = Column(DateTime, default=datetime.utcnow)
-
- # Relationships
- user = relationship("User", backref="sessions")
-
- def __repr__(self) -> str:
- return f""
-
- @property
- def is_expired(self) -> bool:
- """Check if session is expired."""
- return datetime.utcnow() > self.expires_at
-
- @property
- def is_valid(self) -> bool:
- """Check if session is valid (active and not expired)."""
- return self.is_active and not self.is_expired
diff --git a/backend/app/models/workforce.py b/backend/app/models/workforce.py
index 5a6afa2..b1acf62 100644
--- a/backend/app/models/workforce.py
+++ b/backend/app/models/workforce.py
@@ -3,22 +3,23 @@
Executive-level decision intelligence
"""
-from sqlalchemy import Column, String, Enum, Text, Boolean, ForeignKey, Integer, Float, DateTime
+from sqlalchemy import Column, String, Text, Boolean, ForeignKey, Integer, Float, DateTime
from sqlalchemy.orm import relationship
+from sqlalchemy.sql import func
import enum
-from datetime import datetime
+import uuid
-from app.database import Base
+from app.database import Base, CompatibleJSONB, CompatibleUUID, Enum
class WorkforceScore(Base):
"""Employee workforce score snapshot."""
__tablename__ = "workforce_scores"
- user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
- org_id = Column(String(36), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True)
+ user_id = Column(CompatibleUUID, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
+ org_id = Column(CompatibleUUID, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True)
- snapshot_date = Column(DateTime, default=datetime.utcnow)
+ snapshot_date = Column(DateTime(timezone=True), server_default=func.now())
# Component scores (0-100)
velocity_score = Column(Float, nullable=True)
@@ -46,10 +47,10 @@ class ManagerEffectiveness(Base):
"""Manager effectiveness metrics."""
__tablename__ = "manager_effectiveness"
- manager_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
- org_id = Column(String(36), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True)
+ manager_id = Column(CompatibleUUID, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
+ org_id = Column(CompatibleUUID, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True)
- snapshot_date = Column(DateTime, default=datetime.utcnow)
+ snapshot_date = Column(DateTime(timezone=True), server_default=func.now())
# Team metrics
team_size = Column(Integer, default=0)
@@ -79,8 +80,8 @@ class OrgHealthSnapshot(Base):
"""Organization health daily snapshot."""
__tablename__ = "org_health_snapshots"
- org_id = Column(String(36), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True)
- snapshot_date = Column(DateTime, default=datetime.utcnow)
+ org_id = Column(CompatibleUUID, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True)
+ snapshot_date = Column(DateTime(timezone=True), server_default=func.now())
# Health components (0-100)
productivity_index = Column(Float, nullable=True)
@@ -109,15 +110,15 @@ class RestructuringScenario(Base):
"""What-if scenario for restructuring."""
__tablename__ = "restructuring_scenarios"
- org_id = Column(String(36), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True)
- created_by = Column(String(36), ForeignKey("users.id"), nullable=False)
+ org_id = Column(CompatibleUUID, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True)
+ created_by = Column(CompatibleUUID, ForeignKey("users.id"), nullable=False)
name = Column(String(500), nullable=False)
description = Column(Text, nullable=True)
# Scenario config
scenario_type = Column(String(100), nullable=False) # team_merge, role_change, automation_replace, reduction
- config_json = Column(Text, default="{}")
+ config = Column(CompatibleJSONB, default={})
# Impact projections
projected_cost_change = Column(Float, nullable=True)
@@ -126,23 +127,13 @@ class RestructuringScenario(Base):
affected_employees = Column(Integer, default=0)
# Risk assessment
- risk_factors_json = Column(Text, default="[]")
+ risk_factors = Column(CompatibleJSONB, default=[])
overall_risk_score = Column(Float, nullable=True)
# Status
is_draft = Column(Boolean, default=True)
executed = Column(Boolean, default=False)
- executed_at = Column(DateTime, nullable=True)
+ executed_at = Column(DateTime(timezone=True), nullable=True)
organization = relationship("Organization", backref="restructuring_scenarios")
creator = relationship("User", backref="created_scenarios")
-
- @property
- def config(self):
- import json
- return json.loads(self.config_json or "{}")
-
- @config.setter
- def config(self, value):
- import json
- self.config_json = json.dumps(value)
diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py
index 10564ae..8195af9 100644
--- a/backend/app/schemas/__init__.py
+++ b/backend/app/schemas/__init__.py
@@ -8,6 +8,9 @@
from typing import Generic, TypeVar, Optional, List
from datetime import datetime
+# Re-export StrUUID from _types for convenience
+from app.schemas._types import StrUUID # noqa: F401
+
T = TypeVar("T")
@@ -51,7 +54,7 @@ class TimestampMixin(BaseModel):
UserRegister, UserLogin, UserCreate, UserUpdate, UserAdminUpdate,
TokenResponse, TokenRefresh, PasswordReset, PasswordResetConfirm,
UserResponse, UserDetailResponse, UserListResponse, CurrentUserResponse,
- ConsentUpdate, ConsentResponse, SessionResponse, SessionListResponse,
+ ConsentUpdate, ConsentResponse,
)
# Notification schemas
diff --git a/backend/app/schemas/_types.py b/backend/app/schemas/_types.py
new file mode 100644
index 0000000..2fa5968
--- /dev/null
+++ b/backend/app/schemas/_types.py
@@ -0,0 +1,22 @@
+"""
+Shared Pydantic type utilities for TaskPulse schemas.
+
+This module is imported early and must NOT import from app.schemas
+to avoid circular imports.
+"""
+
+from typing import Annotated
+from uuid import UUID as _PyUUID
+
+from pydantic import BeforeValidator
+
+
+def _uuid_to_str(v):
+ """Convert UUID objects to strings for Pydantic schema compatibility."""
+ return str(v) if isinstance(v, _PyUUID) else v
+
+
+# Use this instead of `str` for any field that may receive a UUID object from the ORM.
+# PostgreSQL returns native UUID objects via asyncpg/PG_UUID(as_uuid=True),
+# but API schemas define IDs as strings for JSON serialization.
+StrUUID = Annotated[str, BeforeValidator(_uuid_to_str)]
diff --git a/backend/app/schemas/agent.py b/backend/app/schemas/agent.py
index 8eb9d42..03da183 100644
--- a/backend/app/schemas/agent.py
+++ b/backend/app/schemas/agent.py
@@ -8,6 +8,7 @@
from pydantic import BaseModel, Field
from app.models.agent import AgentType, AgentStatusDB, ExecutionStatus
+from app.schemas._types import StrUUID
# ==================== Agent Schemas ====================
@@ -21,8 +22,8 @@ class AgentConfigUpdate(BaseModel):
class AgentResponse(BaseModel):
"""Schema for agent in API responses."""
- id: str
- org_id: str
+ id: StrUUID
+ org_id: StrUUID
# Identity
name: str
@@ -98,18 +99,18 @@ class ExecuteAgentRequest(BaseModel):
class AgentExecutionResponse(BaseModel):
"""Schema for agent execution in API responses."""
- id: str
- agent_id: str
- org_id: str
+ id: StrUUID
+ agent_id: StrUUID
+ org_id: StrUUID
# Trigger
event_type: str
- event_id: Optional[str] = None
+ event_id: Optional[StrUUID] = None
trigger_source: Optional[str] = None
# Context
- user_id: Optional[str] = None
- task_id: Optional[str] = None
+ user_id: Optional[StrUUID] = None
+ task_id: Optional[StrUUID] = None
context_data: Dict[str, Any] = Field(default_factory=dict)
# Status
@@ -129,7 +130,7 @@ class AgentExecutionResponse(BaseModel):
api_calls: int = 0
# Chain info
- parent_execution_id: Optional[str] = None
+ parent_execution_id: Optional[StrUUID] = None
chain_depth: int = 0
created_at: datetime
@@ -147,7 +148,7 @@ class ExecutionListResponse(BaseModel):
class ExecutionSummaryResponse(BaseModel):
"""Summary of agent executions."""
- agent_id: str
+ agent_id: StrUUID
agent_name: str
# Counts
@@ -174,7 +175,7 @@ class ExecutionSummaryResponse(BaseModel):
class ConversationMessage(BaseModel):
"""Chat message in a conversation."""
- id: str
+ id: StrUUID
role: str = Field(..., description="Role: user, assistant, system")
content: str
agent_name: Optional[str] = None
@@ -184,9 +185,9 @@ class ConversationMessage(BaseModel):
class AgentConversationResponse(BaseModel):
"""Schema for agent conversation in API responses."""
- id: str
- org_id: str
- user_id: str
+ id: StrUUID
+ org_id: StrUUID
+ user_id: StrUUID
# Metadata
title: Optional[str] = None
@@ -224,8 +225,8 @@ class SendMessageRequest(BaseModel):
class ChatMessageResponse(BaseModel):
"""Response from chat agent."""
- conversation_id: str
- message_id: str
+ conversation_id: StrUUID
+ message_id: StrUUID
# Response
response: str
@@ -258,7 +259,7 @@ class OrchestrationRequest(BaseModel):
class OrchestrationStepResult(BaseModel):
"""Result of a single orchestration step."""
agent_name: str
- execution_id: str
+ execution_id: StrUUID
success: bool
output: Dict[str, Any] = Field(default_factory=dict)
error: Optional[str] = None
@@ -267,7 +268,7 @@ class OrchestrationStepResult(BaseModel):
class OrchestrationResponse(BaseModel):
"""Response from orchestration pipeline."""
- pipeline_id: str
+ pipeline_id: StrUUID
# Results
success: bool
@@ -308,9 +309,9 @@ class AgentScheduleUpdate(BaseModel):
class AgentScheduleResponse(BaseModel):
"""Schema for agent schedule in API responses."""
- id: str
- agent_id: str
- org_id: str
+ id: StrUUID
+ agent_id: StrUUID
+ org_id: StrUUID
# Schedule
name: str
diff --git a/backend/app/schemas/audit.py b/backend/app/schemas/audit.py
index 47e3ee2..1acdbb5 100644
--- a/backend/app/schemas/audit.py
+++ b/backend/app/schemas/audit.py
@@ -8,25 +8,26 @@
from pydantic import BaseModel, Field
from app.models.audit import ActorType, AuditAction
+from app.schemas._types import StrUUID
# ==================== Audit Log Schemas ====================
class AuditLogResponse(BaseModel):
"""Schema for audit log in API responses."""
- id: str
- org_id: str
+ id: StrUUID
+ org_id: StrUUID
# Actor
actor_type: ActorType
- actor_id: str
+ actor_id: StrUUID
actor_email: Optional[str] = None
actor_ip: Optional[str] = None
# Action
action: AuditAction
resource_type: str
- resource_id: Optional[str] = None
+ resource_id: Optional[StrUUID] = None
# Details
details: Dict[str, Any] = Field(default_factory=dict)
@@ -87,9 +88,9 @@ class GDPRRequestCreate(BaseModel):
class GDPRRequestResponse(BaseModel):
"""Schema for GDPR request in API responses."""
- id: str
- org_id: str
- user_id: str
+ id: StrUUID
+ org_id: StrUUID
+ user_id: StrUUID
# Request details
request_type: str
@@ -99,7 +100,7 @@ class GDPRRequestResponse(BaseModel):
status: str = Field(..., description="Status: pending, processing, completed, rejected")
# Processing
- processed_by: Optional[str] = None
+ processed_by: Optional[StrUUID] = None
processed_at: Optional[datetime] = None
rejection_reason: Optional[str] = None
@@ -140,9 +141,9 @@ class APIKeyCreate(BaseModel):
class APIKeyResponse(BaseModel):
"""Schema for API key in API responses."""
- id: str
- org_id: str
- user_id: str
+ id: StrUUID
+ org_id: StrUUID
+ user_id: StrUUID
# Key info (key itself only shown on creation)
name: str
@@ -193,8 +194,8 @@ class ServiceHealth(BaseModel):
class SystemHealthResponse(BaseModel):
"""Schema for system health in API responses."""
- id: str
- org_id: Optional[str] = None
+ id: StrUUID
+ org_id: Optional[StrUUID] = None
# Overall status
status: str = Field(..., description="Status: healthy, degraded, unhealthy")
@@ -285,8 +286,8 @@ class ComplianceReportRequest(BaseModel):
class ComplianceReportResponse(BaseModel):
"""Response for compliance report."""
- id: str
- org_id: str
+ id: StrUUID
+ org_id: StrUUID
# Report info
report_type: str
diff --git a/backend/app/schemas/automation.py b/backend/app/schemas/automation.py
index 7365c13..dd3eab2 100644
--- a/backend/app/schemas/automation.py
+++ b/backend/app/schemas/automation.py
@@ -8,14 +8,15 @@
from pydantic import BaseModel, Field
from app.models.automation import PatternStatus, AgentStatus
+from app.schemas._types import StrUUID
# ==================== Automation Pattern Schemas ====================
class AutomationPatternResponse(BaseModel):
"""Schema for automation pattern in API responses."""
- id: str
- org_id: str
+ id: StrUUID
+ org_id: StrUUID
name: str
description: Optional[str] = None
@@ -83,13 +84,13 @@ class AIAgentUpdate(BaseModel):
class AIAgentResponse(BaseModel):
"""Schema for AI agent in API responses."""
- id: str
- org_id: str
+ id: StrUUID
+ org_id: StrUUID
name: str
description: Optional[str] = None
# Configuration
- pattern_id: Optional[str] = None
+ pattern_id: Optional[StrUUID] = None
trigger_conditions: Dict[str, Any] = Field(default_factory=dict)
actions: List[Dict[str, Any]] = Field(default_factory=list)
schedule: Optional[str] = None
@@ -132,8 +133,8 @@ class AIAgentStatusUpdate(BaseModel):
class AgentRunResponse(BaseModel):
"""Schema for agent run in API responses."""
- id: str
- agent_id: str
+ id: StrUUID
+ agent_id: StrUUID
# Execution
trigger_type: str # scheduled, manual, event
@@ -180,7 +181,7 @@ class ShadowReportMismatch(BaseModel):
class ShadowReportResponse(BaseModel):
"""Response for shadow mode validation report."""
- agent_id: str
+ agent_id: StrUUID
agent_name: str
# Summary
diff --git a/backend/app/schemas/checkin.py b/backend/app/schemas/checkin.py
index bef79d3..a967d8b 100644
--- a/backend/app/schemas/checkin.py
+++ b/backend/app/schemas/checkin.py
@@ -10,16 +10,17 @@
from app.models.checkin import (
CheckInTrigger, CheckInStatus, ProgressIndicator
)
+from app.schemas._types import StrUUID
# ==================== Check-In Response Schemas ====================
class CheckInResponse(BaseModel):
"""Check-in response schema."""
- id: str
- task_id: str
- user_id: Optional[str]
- org_id: str
+ id: StrUUID
+ task_id: StrUUID
+ user_id: Optional[StrUUID]
+ org_id: StrUUID
cycle_number: int
trigger: CheckInTrigger
status: CheckInStatus
@@ -138,11 +139,11 @@ class CheckInConfigUpdate(BaseModel):
class CheckInConfigResponse(CheckInConfigBase):
"""Check-in configuration response."""
- id: str
- org_id: str
- team_id: Optional[str]
- user_id: Optional[str]
- task_id: Optional[str]
+ id: StrUUID
+ org_id: StrUUID
+ team_id: Optional[StrUUID]
+ user_id: Optional[StrUUID]
+ task_id: Optional[StrUUID]
created_at: datetime
updated_at: datetime
@@ -160,7 +161,7 @@ class EscalationRequest(BaseModel):
class EscalationResponse(BaseModel):
"""Escalation result."""
- checkin_id: str
+ checkin_id: StrUUID
escalated_to: str
escalated_at: datetime
reason: str
@@ -185,7 +186,7 @@ class CheckInStatistics(BaseModel):
class UserCheckInSummary(BaseModel):
"""User's check-in summary."""
- user_id: str
+ user_id: StrUUID
user_name: Optional[str]
total_checkins: int
response_rate: float
@@ -196,7 +197,7 @@ class UserCheckInSummary(BaseModel):
class TeamCheckInSummary(BaseModel):
"""Team check-in summary."""
- team_id: str
+ team_id: StrUUID
total_members: int
active_checkins: int
response_rate: float
@@ -208,10 +209,10 @@ class TeamCheckInSummary(BaseModel):
class CheckInFeedItem(BaseModel):
"""Item in the check-in feed for managers."""
- checkin_id: str
- task_id: str
+ checkin_id: StrUUID
+ task_id: StrUUID
task_title: str
- user_id: str
+ user_id: StrUUID
user_name: str
status: CheckInStatus
progress_indicator: Optional[ProgressIndicator]
diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py
index e5b7915..58c7afe 100644
--- a/backend/app/schemas/notification.py
+++ b/backend/app/schemas/notification.py
@@ -8,6 +8,7 @@
from pydantic import BaseModel, Field
from app.models.notification import NotificationType, NotificationChannel, IntegrationType
+from app.schemas._types import StrUUID
# ==================== Notification Schemas ====================
@@ -29,8 +30,8 @@ class NotificationUpdate(BaseModel):
class NotificationResponse(BaseModel):
"""Schema for notification in API responses."""
- id: str
- user_id: str
+ id: StrUUID
+ user_id: StrUUID
type: NotificationType
title: str
message: str
@@ -81,8 +82,8 @@ class NotificationPreferenceUpdate(BaseModel):
class NotificationPreferenceResponse(BaseModel):
"""Schema for notification preferences in API responses."""
- id: str
- user_id: str
+ id: StrUUID
+ user_id: StrUUID
channel: NotificationChannel
enabled: bool
quiet_hours_start: Optional[str] = None
@@ -126,8 +127,8 @@ class IntegrationUpdate(BaseModel):
class IntegrationResponse(BaseModel):
"""Schema for integration in API responses."""
- id: str
- org_id: str
+ id: StrUUID
+ org_id: StrUUID
type: IntegrationType
name: str
status: str
@@ -184,8 +185,8 @@ class WebhookUpdate(BaseModel):
class WebhookResponse(BaseModel):
"""Schema for webhook in API responses."""
- id: str
- org_id: str
+ id: StrUUID
+ org_id: StrUUID
name: str
url: str
events: List[str]
@@ -221,8 +222,8 @@ class WebhookTestResponse(BaseModel):
class WebhookDeliveryResponse(BaseModel):
"""Schema for webhook delivery in API responses."""
- id: str
- webhook_id: str
+ id: StrUUID
+ webhook_id: StrUUID
event_type: str
status_code: Optional[int] = None
success: bool
diff --git a/backend/app/schemas/organization.py b/backend/app/schemas/organization.py
index c2b6e62..6999430 100644
--- a/backend/app/schemas/organization.py
+++ b/backend/app/schemas/organization.py
@@ -8,6 +8,7 @@
from pydantic import BaseModel, Field, field_validator
from app.models.organization import PlanTier
+from app.schemas._types import StrUUID
# ==================== Base Schemas ====================
@@ -57,7 +58,7 @@ class OrganizationPlanUpdate(BaseModel):
class OrganizationResponse(OrganizationBase):
"""Schema for organization in API responses."""
- id: str
+ id: StrUUID
slug: str
plan: PlanTier
is_active: bool
diff --git a/backend/app/schemas/prediction.py b/backend/app/schemas/prediction.py
index 13eff71..77aa62f 100644
--- a/backend/app/schemas/prediction.py
+++ b/backend/app/schemas/prediction.py
@@ -8,6 +8,7 @@
from pydantic import BaseModel, Field
from app.models.prediction import PredictionType
+from app.schemas._types import StrUUID
# ==================== Prediction Request Schemas ====================
@@ -42,7 +43,7 @@ class PredictionEstimate(BaseModel):
class TaskPredictionResponse(BaseModel):
"""Response for task completion prediction."""
- task_id: str
+ task_id: StrUUID
task_title: str
current_status: str
@@ -64,10 +65,10 @@ class TaskPredictionResponse(BaseModel):
class PredictionResponse(BaseModel):
"""Schema for prediction in API responses."""
- id: str
- org_id: str
+ id: StrUUID
+ org_id: StrUUID
prediction_type: PredictionType
- target_id: str
+ target_id: StrUUID
target_type: str
# Estimates
@@ -110,10 +111,10 @@ class VelocityDataPoint(BaseModel):
class VelocitySnapshotResponse(BaseModel):
"""Schema for velocity snapshot in API responses."""
- id: str
- org_id: str
- team_id: Optional[str] = None
- user_id: Optional[str] = None
+ id: StrUUID
+ org_id: StrUUID
+ team_id: Optional[StrUUID] = None
+ user_id: Optional[StrUUID] = None
# Metrics
period_start: date
@@ -133,8 +134,8 @@ class VelocitySnapshotResponse(BaseModel):
class VelocityTrendResponse(BaseModel):
"""Response for velocity trend analysis."""
- team_id: Optional[str] = None
- user_id: Optional[str] = None
+ team_id: Optional[StrUUID] = None
+ user_id: Optional[StrUUID] = None
# Time series data
data_points: List[VelocityDataPoint]
@@ -156,7 +157,7 @@ class VelocityTrendResponse(BaseModel):
class VelocityForecastResponse(BaseModel):
"""Response for velocity forecasting."""
- team_id: Optional[str] = None
+ team_id: Optional[StrUUID] = None
# Forecast data
forecast_dates: List[date]
diff --git a/backend/app/schemas/skill.py b/backend/app/schemas/skill.py
index 3087106..58e4eaa 100644
--- a/backend/app/schemas/skill.py
+++ b/backend/app/schemas/skill.py
@@ -8,6 +8,7 @@
from datetime import datetime
from app.models.skill import SkillCategory, SkillTrend, GapType
+from app.schemas._types import StrUUID
# ==================== Skill Catalog ====================
@@ -38,8 +39,8 @@ class SkillUpdate(BaseModel):
class SkillResponse(BaseModel):
"""Skill response schema."""
- id: str
- org_id: str
+ id: StrUUID
+ org_id: StrUUID
name: str
description: Optional[str]
category: SkillCategory
@@ -90,9 +91,9 @@ class UserSkillUpdate(BaseModel):
class UserSkillResponse(BaseModel):
"""User skill response."""
- id: str
- user_id: str
- skill_id: str
+ id: StrUUID
+ user_id: StrUUID
+ skill_id: StrUUID
skill_name: Optional[str] = None
skill_category: Optional[SkillCategory] = None
level: float
@@ -114,7 +115,7 @@ class Config:
class SkillGraphNode(BaseModel):
"""Node in the skill graph."""
- skill_id: str
+ skill_id: StrUUID
skill_name: str
category: SkillCategory
level: float
@@ -131,7 +132,7 @@ class SkillGraphEdge(BaseModel):
class SkillGraphResponse(BaseModel):
"""User's skill graph."""
- user_id: str
+ user_id: StrUUID
nodes: List[SkillGraphNode]
edges: List[SkillGraphEdge]
total_skills: int
@@ -143,7 +144,7 @@ class SkillGraphResponse(BaseModel):
class SkillVelocityResponse(BaseModel):
"""User's skill velocity metrics."""
- user_id: str
+ user_id: StrUUID
period_days: int
task_completion_velocity: Optional[float]
quality_score: Optional[float]
@@ -159,7 +160,7 @@ class SkillVelocityResponse(BaseModel):
class TeamSkillSummary(BaseModel):
"""Skill summary for a team."""
- skill_id: str
+ skill_id: StrUUID
skill_name: str
category: SkillCategory
team_average_level: float
@@ -170,7 +171,7 @@ class TeamSkillSummary(BaseModel):
class TeamSkillComposition(BaseModel):
"""Team's skill composition."""
- team_id: str
+ team_id: StrUUID
total_members: int
skills: List[TeamSkillSummary]
strongest_skills: List[str]
@@ -182,9 +183,9 @@ class TeamSkillComposition(BaseModel):
class SkillGapResponse(BaseModel):
"""Identified skill gap."""
- id: str
- user_id: str
- skill_id: str
+ id: StrUUID
+ user_id: StrUUID
+ skill_id: StrUUID
skill_name: Optional[str] = None
gap_type: GapType
current_level: Optional[float]
@@ -202,7 +203,7 @@ class Config:
class SkillGapSummary(BaseModel):
"""Summary of skill gaps."""
- user_id: str
+ user_id: StrUUID
total_gaps: int
critical_gaps: int
growth_gaps: int
@@ -216,7 +217,7 @@ class LearningMilestone(BaseModel):
"""Milestone in learning path."""
title: str
description: Optional[str]
- skill_id: Optional[str]
+ skill_id: Optional[StrUUID]
target_level: Optional[float]
completed: bool = False
completed_at: Optional[datetime] = None
@@ -233,8 +234,8 @@ class LearningPathCreate(BaseModel):
class LearningPathResponse(BaseModel):
"""Learning path response."""
- id: str
- user_id: str
+ id: StrUUID
+ user_id: StrUUID
title: str
description: Optional[str]
target_role: Optional[str]
@@ -256,7 +257,7 @@ class Config:
class SelfSufficiencyMetrics(BaseModel):
"""Self-sufficiency metrics for a user."""
- user_id: str
+ user_id: StrUUID
overall_score: float # 0-1
by_skill_category: dict # category -> score
blockers_self_resolved: int
@@ -269,7 +270,7 @@ class SelfSufficiencyMetrics(BaseModel):
class PeerBenchmark(BaseModel):
"""Anonymized peer benchmarking."""
- skill_id: str
+ skill_id: StrUUID
skill_name: str
user_level: float
peer_average: float
diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py
index 2c72e11..55a4d9d 100644
--- a/backend/app/schemas/task.py
+++ b/backend/app/schemas/task.py
@@ -9,6 +9,7 @@
from enum import Enum
from app.models.task import TaskStatus, TaskPriority, BlockerType
+from app.schemas._types import StrUUID
# ==================== Base Schemas ====================
@@ -103,17 +104,17 @@ def validate_blocker_description(cls, v, info):
class TaskResponse(BaseModel):
"""Task response schema."""
- id: str
- org_id: str
+ id: StrUUID
+ org_id: StrUUID
title: str
description: Optional[str]
goal: Optional[str]
status: TaskStatus
priority: TaskPriority
- assigned_to: Optional[str]
- created_by: str
- team_id: Optional[str]
- project_id: Optional[str]
+ assigned_to: Optional[StrUUID]
+ created_by: StrUUID
+ team_id: Optional[StrUUID]
+ project_id: Optional[StrUUID]
deadline: Optional[datetime]
estimated_hours: Optional[float]
actual_hours: float
@@ -127,7 +128,7 @@ class TaskResponse(BaseModel):
tools: List[str]
tags: List[str]
skills_required: List[str]
- parent_task_id: Optional[str]
+ parent_task_id: Optional[StrUUID]
sort_order: int
is_draft: bool
is_subtask: bool
@@ -194,9 +195,9 @@ class DependencyCreate(BaseModel):
class DependencyResponse(BaseModel):
"""Task dependency response."""
- id: str
- task_id: str
- depends_on_id: str
+ id: StrUUID
+ task_id: StrUUID
+ depends_on_id: StrUUID
is_blocking: bool
description: Optional[str]
depends_on_title: Optional[str] = None
@@ -211,9 +212,9 @@ class Config:
class TaskHistoryResponse(BaseModel):
"""Task history entry response."""
- id: str
- task_id: str
- user_id: Optional[str]
+ id: StrUUID
+ task_id: StrUUID
+ user_id: Optional[StrUUID]
action: str
field_name: Optional[str]
old_value: Optional[str]
@@ -240,9 +241,9 @@ class CommentUpdate(BaseModel):
class CommentResponse(BaseModel):
"""Task comment response."""
- id: str
- task_id: str
- user_id: Optional[str]
+ id: StrUUID
+ task_id: StrUUID
+ user_id: Optional[StrUUID]
content: str
is_ai_generated: bool
is_edited: bool
@@ -294,7 +295,7 @@ class SubtaskSuggestion(BaseModel):
class TaskDecompositionResponse(BaseModel):
"""Response from AI task decomposition."""
- task_id: str
+ task_id: StrUUID
suggested_subtasks: List[SubtaskSuggestion]
total_estimated_hours: float
complexity_score: float
diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py
index 35396c3..f871ab0 100644
--- a/backend/app/schemas/user.py
+++ b/backend/app/schemas/user.py
@@ -8,6 +8,7 @@
from pydantic import BaseModel, Field, EmailStr, field_validator
from app.models.user import UserRole, SkillLevel
+from app.schemas._types import StrUUID
# ==================== Base Schemas ====================
@@ -178,8 +179,8 @@ class ConsentResponse(BaseModel):
class UserResponse(UserBase):
"""Schema for user in API responses."""
- id: str
- org_id: str
+ id: StrUUID
+ org_id: StrUUID
role: UserRole
skill_level: SkillLevel
timezone: str
@@ -198,8 +199,8 @@ def full_name(self) -> str:
class UserDetailResponse(UserResponse):
"""Detailed user response with additional info."""
phone: Optional[str] = None
- team_id: Optional[str] = None
- manager_id: Optional[str] = None
+ team_id: Optional[StrUUID] = None
+ manager_id: Optional[StrUUID] = None
last_login: Optional[datetime] = None
consent: ConsentResponse = Field(default_factory=ConsentResponse)
updated_at: datetime
@@ -222,21 +223,3 @@ class CurrentUserResponse(UserDetailResponse):
permissions: list[str] = Field(default_factory=list)
-# ==================== Session Schemas ====================
-
-class SessionResponse(BaseModel):
- """Schema for session in API responses."""
- id: str
- device_info: Optional[str] = None
- ip_address: Optional[str] = None
- last_activity: datetime
- created_at: datetime
- is_current: bool = False
-
- model_config = {"from_attributes": True}
-
-
-class SessionListResponse(BaseModel):
- """Response for list of sessions."""
- sessions: list[SessionResponse]
- total: int
diff --git a/backend/app/schemas/workforce.py b/backend/app/schemas/workforce.py
index 8607ea0..0829773 100644
--- a/backend/app/schemas/workforce.py
+++ b/backend/app/schemas/workforce.py
@@ -7,6 +7,8 @@
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field
+from app.schemas._types import StrUUID
+
# ==================== Workforce Score Schemas ====================
@@ -21,9 +23,9 @@ class ScoreComponent(BaseModel):
class WorkforceScoreResponse(BaseModel):
"""Schema for workforce score in API responses."""
- id: str
- user_id: str
- org_id: str
+ id: StrUUID
+ user_id: StrUUID
+ org_id: StrUUID
# Overall score
overall_score: float = Field(ge=0, le=100)
@@ -76,7 +78,7 @@ class AttritionRiskFactor(BaseModel):
class AttritionRiskResponse(BaseModel):
"""Response for attrition risk analysis."""
- user_id: str
+ user_id: StrUUID
user_name: str
# Risk assessment
@@ -113,7 +115,7 @@ class BurnoutIndicator(BaseModel):
class BurnoutRiskResponse(BaseModel):
"""Response for burnout risk analysis."""
- user_id: str
+ user_id: StrUUID
user_name: str
# Risk assessment
@@ -145,10 +147,10 @@ class BurnoutRiskListResponse(BaseModel):
class ManagerEffectivenessResponse(BaseModel):
"""Schema for manager effectiveness in API responses."""
- id: str
- manager_id: str
+ id: StrUUID
+ manager_id: StrUUID
manager_name: str
- org_id: str
+ org_id: StrUUID
# Team metrics
team_size: int
@@ -193,8 +195,8 @@ class OrgHealthMetric(BaseModel):
class OrgHealthSnapshotResponse(BaseModel):
"""Schema for organization health snapshot in API responses."""
- id: str
- org_id: str
+ id: StrUUID
+ org_id: StrUUID
# Overall health
health_score: float = Field(ge=0, le=100)
@@ -221,7 +223,7 @@ class OrgHealthSnapshotResponse(BaseModel):
class OrgHealthTrendResponse(BaseModel):
"""Response for organization health trend."""
- org_id: str
+ org_id: StrUUID
# Time series
snapshots: List[OrgHealthSnapshotResponse]
@@ -253,8 +255,8 @@ class RestructuringImpact(BaseModel):
class RestructuringScenarioResponse(BaseModel):
"""Schema for restructuring scenario in API responses."""
- id: str
- org_id: str
+ id: StrUUID
+ org_id: StrUUID
# Scenario
scenario_type: str
@@ -301,7 +303,7 @@ class HiringRecommendation(BaseModel):
class HiringPlanResponse(BaseModel):
"""Response for hiring plan generation."""
- org_id: str
+ org_id: StrUUID
# Summary
total_positions: int
diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py
index 3cc740b..875a9d2 100644
--- a/backend/app/services/ai_service.py
+++ b/backend/app/services/ai_service.py
@@ -10,7 +10,7 @@
import re
from collections import OrderedDict
from typing import Optional, List, Dict, Any
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
from dataclasses import dataclass
from enum import Enum
@@ -63,7 +63,7 @@ def get(self, prompt: str, context: str = "") -> Optional[AIResponse]:
key = self._make_key(prompt, context)
if key in self._cache:
response, timestamp = self._cache[key]
- if datetime.utcnow() - timestamp < self.ttl:
+ if datetime.now(timezone.utc) - timestamp < self.ttl:
# Move to end (most recently used)
self._cache.move_to_end(key)
response.cached = True
@@ -78,7 +78,7 @@ def set(self, prompt: str, response: AIResponse, context: str = "") -> None:
# If key already exists, remove it first so it goes to end
if key in self._cache:
del self._cache[key]
- self._cache[key] = (response, datetime.utcnow())
+ self._cache[key] = (response, datetime.now(timezone.utc))
# Evict oldest entries if over max size
while len(self._cache) > self.max_size:
self._cache.popitem(last=False)
diff --git a/backend/app/services/analytics_service.py b/backend/app/services/analytics_service.py
index 068423a..d47a9b5 100644
--- a/backend/app/services/analytics_service.py
+++ b/backend/app/services/analytics_service.py
@@ -4,7 +4,7 @@
"""
from typing import Optional, List, Dict, Any
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, case
@@ -59,7 +59,7 @@ async def get_dashboard_metrics(
"completion_metrics": completion_metrics,
"blocker_analysis": blocker_metrics,
"recent_activity": recent_activity,
- "generated_at": datetime.utcnow().isoformat()
+ "generated_at": datetime.now(timezone.utc).isoformat()
}
async def _get_status_counts(self, filters: List) -> Dict[str, int]:
@@ -89,7 +89,7 @@ async def _get_completion_metrics(
user_id: Optional[str]
) -> Dict[str, Any]:
"""Get completion rate metrics."""
- now = datetime.utcnow()
+ now = datetime.now(timezone.utc)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
@@ -291,7 +291,7 @@ async def get_velocity_chart_data(
) -> Dict[str, Any]:
"""Get velocity data for charting."""
data_points = []
- now = datetime.utcnow()
+ now = datetime.now(timezone.utc)
filters = [Task.org_id == org_id, Task.status == TaskStatus.DONE]
if team_id:
@@ -405,7 +405,7 @@ async def get_bottleneck_analysis(
"bottlenecks": bottlenecks,
"bottleneck_count": len(bottlenecks),
"health_score": max(0, 100 - len(bottlenecks) * 15),
- "analysis_time": datetime.utcnow().isoformat()
+ "analysis_time": datetime.now(timezone.utc).isoformat()
}
async def get_check_in_summary(
@@ -415,7 +415,7 @@ async def get_check_in_summary(
days: int = 7
) -> Dict[str, Any]:
"""Get check-in activity summary."""
- cutoff = datetime.utcnow() - timedelta(days=days)
+ cutoff = datetime.now(timezone.utc) - timedelta(days=days)
filters = [CheckIn.org_id == org_id, CheckIn.created_at >= cutoff]
if team_id:
diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py
index 9ea09aa..b62f44a 100644
--- a/backend/app/services/auth_service.py
+++ b/backend/app/services/auth_service.py
@@ -1,63 +1,52 @@
"""
TaskPulse - AI Assistant - Authentication Service
-Business logic for user authentication and session management
+Business logic for user authentication via Supabase Auth
"""
-from datetime import datetime, timedelta, timezone
+import asyncio
+from datetime import datetime, timezone
from typing import Optional, Tuple
import logging
-from sqlalchemy import select, and_, or_
+from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
-from app.config import settings
-from app.core.security import (
- hash_password,
- verify_password,
- create_access_token,
- create_refresh_token,
- verify_token,
- hash_token,
- generate_password_reset_token,
- verify_password_reset_token
-)
from app.core.exceptions import (
- InvalidCredentialsException,
AlreadyExistsException,
NotFoundException,
InactiveUserException,
- InvalidTokenException,
- TokenExpiredException,
- AccountLockedException,
- ValidationException
+ InvalidCredentialsException,
+ ValidationException,
)
-from app.models.user import User, Session, UserRole
+from app.models.user import User, UserRole
from app.models.organization import Organization, PlanTier
from app.schemas.user import (
UserRegister,
- UserCreate,
- TokenResponse,
- ConsentUpdate
+ ConsentUpdate,
)
+from app.supabase_client import get_supabase_client
from app.utils.helpers import generate_uuid, slugify
logger = logging.getLogger(__name__)
class AuthService:
- """Service for authentication operations."""
+ """Service for authentication operations via Supabase Auth."""
def __init__(self, db: AsyncSession):
self.db = db
+ self.supabase = get_supabase_client()
+
+ # ==================== Public Methods ====================
async def register(
self,
user_data: UserRegister,
ip_address: Optional[str] = None,
- user_agent: Optional[str] = None
- ) -> Tuple[User, Organization, TokenResponse]:
+ user_agent: Optional[str] = None,
+ ) -> Tuple[User, Organization]:
"""
- Register a new user and optionally create an organization.
+ Register a new user via Supabase Auth and create local records.
Args:
user_data: Registration data
@@ -65,13 +54,29 @@ async def register(
user_agent: Client user agent
Returns:
- Tuple of (User, Organization, TokenResponse)
+ Tuple of (User, Organization)
"""
- # Check if email already exists
+ # Check if email already exists locally
existing_user = await self._get_user_by_email(user_data.email)
if existing_user:
raise AlreadyExistsException("User", "email", user_data.email)
+ # Create user in Supabase Auth
+ try:
+ supabase_response = await asyncio.to_thread(
+ self.supabase.auth.admin.create_user,
+ {
+ "email": user_data.email,
+ "password": user_data.password,
+ "email_confirm": True,
+ },
+ )
+ except Exception as e:
+ logger.error(f"Supabase user creation failed: {e}")
+ raise ValidationException(f"Failed to create auth account: {e}")
+
+ supabase_auth_id = supabase_response.user.id
+
# Determine organization
if user_data.org_id:
# Join existing organization
@@ -82,53 +87,42 @@ async def register(
elif user_data.org_name:
# Create new organization
org = await self._create_organization(user_data.org_name)
- role = user_data.role or UserRole.ORG_ADMIN # Creator becomes admin if no role specified
+ role = user_data.role or UserRole.ORG_ADMIN
else:
raise ValidationException(
"Either org_name (for new org) or org_id (to join) is required"
)
- # Create user
+ # Create local user record (no password_hash — Supabase handles auth)
user = User(
id=generate_uuid(),
org_id=org.id,
email=user_data.email.lower(),
- password_hash=hash_password(user_data.password),
+ supabase_auth_id=str(supabase_auth_id),
first_name=user_data.first_name,
last_name=user_data.last_name,
role=role,
is_active=True,
- is_email_verified=False # Would send verification email in production
+ is_email_verified=True, # Supabase confirmed the email
)
self.db.add(user)
- await self.db.flush()
-
- # Create session and tokens
- tokens = await self._create_session(
- user,
- ip_address=ip_address,
- user_agent=user_agent
- )
-
- # Explicit commit to ensure user record is persisted before returning tokens.
- # Without this, the get_db() context manager commits AFTER the endpoint returns,
- # causing login failures when clients immediately use the returned tokens.
await self.db.commit()
logger.info(f"User registered: {user.email} in org {org.name}")
- return user, org, tokens
+ return user, org
async def login(
self,
email: str,
password: str,
ip_address: Optional[str] = None,
- user_agent: Optional[str] = None
- ) -> Tuple[User, TokenResponse]:
+ user_agent: Optional[str] = None,
+ ) -> Tuple[User, dict]:
"""
- Authenticate user and create session.
+ Authenticate user via Supabase Auth. Used for API/mobile clients.
+ Frontend authenticates directly with Supabase; this is the backend fallback.
Args:
email: User email
@@ -137,212 +131,109 @@ async def login(
user_agent: Client user agent
Returns:
- Tuple of (User, TokenResponse)
+ Tuple of (User, supabase_session_dict)
"""
- # Get user by email
- user = await self._get_user_by_email(email)
+ # Authenticate with Supabase
+ try:
+ supabase_response = await asyncio.to_thread(
+ self.supabase.auth.sign_in_with_password,
+ {"email": email, "password": password},
+ )
+ except Exception as e:
+ logger.warning(f"Supabase login failed for {email}: {e}")
+ raise InvalidCredentialsException()
+ # Look up local user
+ user = await self._get_user_by_email(email)
if not user:
- logger.warning(f"Login attempt for non-existent user: {email}")
+ logger.warning(f"Login: no local user for {email}")
raise InvalidCredentialsException()
- # Check if user is active
if not user.is_active:
logger.warning(f"Login attempt for inactive user: {email}")
raise InactiveUserException()
- # Check if account is locked
- if user.lockout_until and user.lockout_until > datetime.utcnow():
- logger.warning(f"Login attempt for locked account: {email}")
- raise AccountLockedException()
-
- # Verify password
- if not user.password_hash or not verify_password(password, user.password_hash):
- # Track failed attempt
- await self._record_failed_login(user)
- logger.warning(f"Failed login attempt for user: {email}")
- raise InvalidCredentialsException()
-
- # Reset failed attempts and lockout on successful login
- user.failed_login_attempts = "0"
- user.lockout_until = None
- user.last_login = datetime.utcnow()
-
- # Create session and tokens
- tokens = await self._create_session(
- user,
- ip_address=ip_address,
- user_agent=user_agent
- )
+ # Update last_login
+ user.last_login = datetime.now(timezone.utc)
+ await self.db.commit()
logger.info(f"User logged in: {user.email}")
- return user, tokens
-
- async def refresh_tokens(
- self,
- refresh_token: str,
- ip_address: Optional[str] = None
- ) -> TokenResponse:
- """
- Refresh access token using refresh token.
-
- Args:
- refresh_token: JWT refresh token
- ip_address: Client IP address
-
- Returns:
- New TokenResponse
- """
- # Verify refresh token
- payload = verify_token(refresh_token, token_type="refresh")
- if not payload:
- raise InvalidTokenException("Invalid refresh token")
-
- user_id = payload.get("sub")
- if not user_id:
- raise InvalidTokenException("Invalid token payload")
-
- # Get user
- user = await self._get_user_by_id(user_id)
- if not user:
- raise InvalidTokenException("User not found")
-
- if not user.is_active:
- raise InactiveUserException()
-
- # Verify session exists
- token_hash = hash_token(refresh_token)
- session = await self._get_session_by_refresh_token(token_hash)
- if not session or not session.is_valid:
- raise InvalidTokenException("Session expired or invalid")
-
- # Create new tokens
- tokens = self._generate_tokens(user)
-
- # Update session
- session.token_hash = hash_token(tokens.access_token)
- session.refresh_token_hash = hash_token(tokens.refresh_token)
- session.last_activity = datetime.utcnow()
- session.expires_at = datetime.utcnow() + timedelta(
- minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
- )
- session.refresh_expires_at = datetime.utcnow() + timedelta(
- days=settings.REFRESH_TOKEN_EXPIRE_DAYS
- )
-
- logger.info(f"Tokens refreshed for user: {user.email}")
-
- return tokens
-
- async def logout(self, user_id: str, token: str) -> None:
- """
- Logout user by invalidating session.
-
- Args:
- user_id: User ID
- token: Current access token
- """
- token_hash = hash_token(token)
- session = await self._get_session_by_token(token_hash)
+ # Build session dict from Supabase response
+ session = supabase_response.session
+ supabase_session = {
+ "access_token": session.access_token,
+ "refresh_token": session.refresh_token,
+ "token_type": "bearer",
+ "expires_in": session.expires_in,
+ }
- if session:
- session.is_active = False
- logger.info(f"User logged out: {user_id}")
+ return user, supabase_session
- async def logout_all(self, user_id: str) -> int:
+ async def refresh_tokens(self, refresh_token: str) -> dict:
"""
- Logout user from all sessions.
+ Refresh Supabase session tokens.
Args:
- user_id: User ID
+ refresh_token: Supabase refresh token
Returns:
- Number of sessions invalidated
+ Dict with new access_token, refresh_token, token_type, expires_in
"""
- result = await self.db.execute(
- select(Session).where(
- and_(
- Session.user_id == user_id,
- Session.is_active == True
- )
+ try:
+ supabase_response = await asyncio.to_thread(
+ self.supabase.auth.refresh_session,
+ refresh_token,
)
- )
- sessions = result.scalars().all()
-
- count = 0
- for session in sessions:
- session.is_active = False
- count += 1
-
- logger.info(f"Logged out user {user_id} from {count} sessions")
- return count
+ except Exception as e:
+ logger.warning(f"Token refresh failed: {e}")
+ raise InvalidCredentialsException("Invalid or expired refresh token")
+
+ session = supabase_response.session
+ return {
+ "access_token": session.access_token,
+ "refresh_token": session.refresh_token,
+ "token_type": "bearer",
+ "expires_in": session.expires_in,
+ }
- async def request_password_reset(self, email: str) -> str:
+ async def logout(self, user_id: str) -> None:
"""
- Generate password reset token.
+ Log the logout event. Frontend calls supabase.auth.signOut() directly.
Args:
- email: User email
-
- Returns:
- Password reset token
+ user_id: User ID
"""
- user = await self._get_user_by_email(email)
-
- # Always return success to prevent email enumeration
- if not user:
- logger.info(f"Password reset requested for non-existent email: {email}")
- return generate_password_reset_token(email)
-
- token = generate_password_reset_token(email)
- logger.info(f"Password reset requested for user: {email}")
+ logger.info(f"User logged out: {user_id}")
- # In production, send email here
- return token
-
- async def reset_password(self, token: str, new_password: str) -> User:
+ async def request_password_reset(self, email: str) -> None:
"""
- Reset user password using reset token.
+ Request password reset via Supabase (sends email).
Args:
- token: Password reset token
- new_password: New password
-
- Returns:
- Updated User
+ email: User email
"""
- email = verify_password_reset_token(token)
- if not email:
- raise InvalidTokenException("Invalid or expired reset token")
-
- user = await self._get_user_by_email(email)
- if not user:
- raise NotFoundException("User")
-
- # Update password
- user.password_hash = hash_password(new_password)
- user.failed_login_attempts = "0"
-
- # Invalidate all sessions
- await self.logout_all(user.id)
-
- logger.info(f"Password reset for user: {email}")
+ try:
+ await asyncio.to_thread(
+ self.supabase.auth.reset_password_for_email,
+ email,
+ )
+ except Exception as e:
+ # Log but don't reveal whether email exists
+ logger.info(f"Password reset requested for {email}: {e}")
- return user
+ logger.info(f"Password reset requested for: {email}")
async def change_password(
self,
user_id: str,
- current_password: str,
- new_password: str
+ new_password: str,
) -> User:
"""
- Change user password.
+ Change user password via Supabase admin API.
Args:
- user_id: User ID
- current_password: Current password
+ user_id: Local user ID
new_password: New password
Returns:
@@ -352,12 +243,18 @@ async def change_password(
if not user:
raise NotFoundException("User", user_id)
- # Verify current password
- if not verify_password(current_password, user.password_hash):
- raise InvalidCredentialsException("Current password is incorrect")
+ if not user.supabase_auth_id:
+ raise ValidationException("User does not have a Supabase auth account")
- # Update password
- user.password_hash = hash_password(new_password)
+ try:
+ await asyncio.to_thread(
+ self.supabase.auth.admin.update_user_by_id,
+ str(user.supabase_auth_id),
+ {"password": new_password},
+ )
+ except Exception as e:
+ logger.error(f"Supabase password change failed: {e}")
+ raise ValidationException(f"Failed to change password: {e}")
logger.info(f"Password changed for user: {user.email}")
@@ -366,7 +263,7 @@ async def change_password(
async def update_consent(
self,
user_id: str,
- consent_data: ConsentUpdate
+ consent_data: ConsentUpdate,
) -> User:
"""
Update user consent settings.
@@ -391,32 +288,6 @@ async def update_consent(
return user
- async def get_user_sessions(
- self,
- user_id: str,
- current_token: str
- ) -> list[Session]:
- """
- Get all active sessions for a user.
-
- Args:
- user_id: User ID
- current_token: Current access token (to mark current session)
-
- Returns:
- List of Sessions
- """
- result = await self.db.execute(
- select(Session).where(
- and_(
- Session.user_id == user_id,
- Session.is_active == True,
- Session.expires_at > datetime.utcnow()
- )
- ).order_by(Session.last_activity.desc())
- )
- return list(result.scalars().all())
-
# ==================== Private Helper Methods ====================
async def _get_user_by_email(self, email: str) -> Optional[User]:
@@ -433,6 +304,13 @@ async def _get_user_by_id(self, user_id: str) -> Optional[User]:
)
return result.scalar_one_or_none()
+ async def _get_user_by_supabase_id(self, supabase_id: str) -> Optional[User]:
+ """Get user by Supabase auth ID."""
+ result = await self.db.execute(
+ select(User).where(User.supabase_auth_id == supabase_id)
+ )
+ return result.scalar_one_or_none()
+
async def _get_organization_by_id(self, org_id: str) -> Optional[Organization]:
"""Get organization by ID."""
result = await self.db.execute(
@@ -461,7 +339,7 @@ async def _create_organization(self, name: str) -> Organization:
name=name,
slug=slug,
plan=PlanTier.STARTER,
- is_active=True
+ is_active=True,
)
self.db.add(org)
@@ -470,103 +348,3 @@ async def _create_organization(self, name: str) -> Organization:
logger.info(f"Organization created: {name} ({slug})")
return org
-
- async def _create_session(
- self,
- user: User,
- ip_address: Optional[str] = None,
- user_agent: Optional[str] = None
- ) -> TokenResponse:
- """Create a new session for user."""
- tokens = self._generate_tokens(user)
-
- session = Session(
- id=generate_uuid(),
- user_id=user.id,
- token_hash=hash_token(tokens.access_token),
- refresh_token_hash=hash_token(tokens.refresh_token),
- ip_address=ip_address,
- user_agent=user_agent,
- expires_at=datetime.utcnow() + timedelta(
- minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
- ),
- refresh_expires_at=datetime.utcnow() + timedelta(
- days=settings.REFRESH_TOKEN_EXPIRE_DAYS
- ),
- is_active=True
- )
-
- self.db.add(session)
- await self.db.flush()
-
- return tokens
-
- def _generate_tokens(self, user: User) -> TokenResponse:
- """Generate access and refresh tokens for user."""
- token_data = {
- "sub": user.id,
- "email": user.email,
- "org_id": user.org_id,
- "role": user.role.value
- }
-
- access_token = create_access_token(token_data)
- refresh_token = create_refresh_token(token_data)
-
- return TokenResponse(
- access_token=access_token,
- refresh_token=refresh_token,
- token_type="bearer",
- expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
- )
-
- async def _get_session_by_token(self, token_hash: str) -> Optional[Session]:
- """Get session by access token hash."""
- result = await self.db.execute(
- select(Session).where(Session.token_hash == token_hash)
- )
- return result.scalar_one_or_none()
-
- async def _get_session_by_refresh_token(
- self,
- refresh_token_hash: str
- ) -> Optional[Session]:
- """Get session by refresh token hash."""
- result = await self.db.execute(
- select(Session).where(Session.refresh_token_hash == refresh_token_hash)
- )
- return result.scalar_one_or_none()
-
- async def _record_failed_login(self, user: User) -> None:
- """
- SEC-014: Record failed login attempt and implement progressive account lockout.
-
- Lockout schedule:
- - 5 failed attempts → 15 minute lockout
- - 10 failed attempts → 1 hour lockout
- - 15+ failed attempts → 4 hour lockout
- """
- try:
- attempts = int(user.failed_login_attempts or "0")
- except ValueError:
- attempts = 0
-
- attempts += 1
- user.failed_login_attempts = str(attempts)
-
- # Progressive lockout based on attempt count
- if attempts >= 15:
- lockout_duration = timedelta(hours=4)
- elif attempts >= 10:
- lockout_duration = timedelta(hours=1)
- elif attempts >= 5:
- lockout_duration = timedelta(minutes=15)
- else:
- lockout_duration = None
-
- if lockout_duration:
- user.lockout_until = datetime.utcnow() + lockout_duration
- logger.warning(
- f"Account locked for user {user.email} after {attempts} failed attempts. "
- f"Locked for {lockout_duration}. Unlocks at {user.lockout_until}"
- )
diff --git a/backend/app/services/automation_executor.py b/backend/app/services/automation_executor.py
index 07b4cd1..5b53e68 100644
--- a/backend/app/services/automation_executor.py
+++ b/backend/app/services/automation_executor.py
@@ -8,7 +8,7 @@
import logging
import time
from typing import Dict, Any, List, Optional
-from datetime import datetime
+from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
@@ -79,7 +79,7 @@ async def execute_agent(
id=generate_uuid(),
agent_id=agent.id,
org_id=agent.org_id,
- started_at=datetime.utcnow(),
+ started_at=datetime.now(timezone.utc),
status="running",
is_shadow=is_shadow,
)
@@ -104,7 +104,7 @@ async def execute_agent(
elapsed_ms = int((time.time() - start_time) * 1000)
run.status = "success" if all_success else "failed"
- run.completed_at = datetime.utcnow()
+ run.completed_at = datetime.now(timezone.utc)
run.execution_time_ms = elapsed_ms
run.output_data = {
"ai_driven": is_ai_driven,
@@ -133,7 +133,7 @@ async def execute_agent(
logger.error(f"Automation execution failed for agent {agent.id}: {e}")
elapsed_ms = int((time.time() - start_time) * 1000)
run.status = "failed"
- run.completed_at = datetime.utcnow()
+ run.completed_at = datetime.now(timezone.utc)
run.execution_time_ms = elapsed_ms
run.error_message = str(e)
run.output_data = {"error": str(e)}
@@ -249,7 +249,7 @@ async def _gather_org_context(self, org_id: str, agent: AIAgent) -> Dict[str, An
overdue_result = await self.db.execute(
select(Task).where(
Task.org_id == org_id,
- Task.deadline < datetime.utcnow(),
+ Task.deadline < datetime.now(timezone.utc),
Task.status.notin_([TaskStatus.DONE, TaskStatus.ARCHIVED]),
).limit(10)
)
@@ -751,7 +751,7 @@ async def _evaluate_condition_trigger(
elif condition_type == "overdue_tasks_exist":
query = select(func.count()).select_from(Task).where(
Task.org_id == org_id,
- Task.deadline < datetime.utcnow(),
+ Task.deadline < datetime.now(timezone.utc),
Task.status.notin_([TaskStatus.DONE, TaskStatus.ARCHIVED]),
)
count = (await self.db.execute(query)).scalar() or 0
@@ -1281,7 +1281,7 @@ async def _update_agent_metrics(self, agent: AIAgent, success: bool):
agent.total_runs = (agent.total_runs or 0) + 1
if success:
agent.successful_runs = (agent.successful_runs or 0) + 1
- agent.last_run_at = datetime.utcnow()
+ agent.last_run_at = datetime.now(timezone.utc)
if success:
hours_per_run = agent.config.get("hours_saved_per_run", 0.25)
diff --git a/backend/app/services/automation_scheduler.py b/backend/app/services/automation_scheduler.py
index 6a16bd6..ea9ec20 100644
--- a/backend/app/services/automation_scheduler.py
+++ b/backend/app/services/automation_scheduler.py
@@ -4,7 +4,7 @@
"""
import logging
-from datetime import datetime
+from datetime import datetime, timezone
from typing import Optional
from apscheduler.schedulers.asyncio import AsyncIOScheduler
@@ -95,7 +95,7 @@ async def _execute_cron_agent(agent_id: str):
is_shadow = (agent.status == AgentStatus.SHADOW)
trigger_data = {
"trigger_type": "schedule",
- "scheduled_at": datetime.utcnow().isoformat(),
+ "scheduled_at": datetime.now(timezone.utc).isoformat(),
}
await executor.execute_agent(agent, trigger_data, is_shadow=is_shadow)
@@ -182,7 +182,7 @@ async def _create_hourly_checkins():
from app.models.checkin import CheckIn, CheckInStatus, CheckInTrigger
from sqlalchemy import select, func, and_
- now = datetime.utcnow()
+ now = datetime.now(timezone.utc)
current_hour = now.hour
# Get all in-progress tasks with assignees
diff --git a/backend/app/services/automation_service.py b/backend/app/services/automation_service.py
index 6e0e34c..4d4fb47 100644
--- a/backend/app/services/automation_service.py
+++ b/backend/app/services/automation_service.py
@@ -4,7 +4,7 @@
"""
from typing import Optional, List, Dict, Any
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
import random
@@ -33,7 +33,7 @@ async def detect_patterns(
"""
Detect repetitive task patterns that could be automated.
"""
- cutoff = datetime.utcnow() - timedelta(days=time_window_days)
+ cutoff = datetime.now(timezone.utc) - timedelta(days=time_window_days)
# Get completed tasks in time window
result = await self.db.execute(
@@ -305,9 +305,9 @@ async def update_agent_status(
# Update timestamps
if new_status == AgentStatus.SHADOW:
- agent.shadow_started_at = datetime.utcnow()
+ agent.shadow_started_at = datetime.now(timezone.utc)
elif new_status == AgentStatus.LIVE:
- agent.live_started_at = datetime.utcnow()
+ agent.live_started_at = datetime.now(timezone.utc)
await self.db.flush()
return agent
@@ -343,7 +343,7 @@ async def record_agent_run(
agent.total_runs = (agent.total_runs or 0) + 1
if success:
agent.successful_runs = (agent.successful_runs or 0) + 1
- agent.last_run_at = datetime.utcnow()
+ agent.last_run_at = datetime.now(timezone.utc)
await self.db.flush()
return run
@@ -383,7 +383,7 @@ async def get_shadow_report(
# Calculate shadow period
shadow_days = 0
if agent.shadow_started_at:
- shadow_days = (datetime.utcnow() - agent.shadow_started_at).days
+ shadow_days = (datetime.now(timezone.utc) - agent.shadow_started_at).days
# Determine readiness for live
ready_for_live = match_rate >= 0.95 and shadow_days >= 14 and total_runs >= 20
@@ -417,7 +417,7 @@ async def get_automation_roi(
period_days: int = 30
) -> Dict[str, Any]:
"""Calculate ROI from automation."""
- cutoff = datetime.utcnow() - timedelta(days=period_days)
+ cutoff = datetime.now(timezone.utc) - timedelta(days=period_days)
# Get live agents
result = await self.db.execute(
diff --git a/backend/app/services/checkin_service.py b/backend/app/services/checkin_service.py
index 78f6a0f..9d0557d 100644
--- a/backend/app/services/checkin_service.py
+++ b/backend/app/services/checkin_service.py
@@ -4,7 +4,7 @@
"""
from typing import Optional, List, Tuple
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
from sqlalchemy import select, func, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -66,7 +66,7 @@ async def create_checkin(
)
cycle_number = (cycle_count.scalar() or 0) + 1
- scheduled = scheduled_at or datetime.utcnow()
+ scheduled = scheduled_at or datetime.now(timezone.utc)
expires = scheduled + timedelta(hours=expires_hours)
checkin = CheckIn(
@@ -179,7 +179,7 @@ async def respond_to_checkin(
# Update check-in with response
checkin.status = CheckInStatus.RESPONDED
- checkin.responded_at = datetime.utcnow()
+ checkin.responded_at = datetime.now(timezone.utc)
checkin.progress_indicator = response.progress_indicator
checkin.progress_notes = response.progress_notes
checkin.completed_since_last = response.completed_since_last
@@ -236,7 +236,7 @@ async def skip_checkin(
raise ValidationException(f"Check-in is already {checkin.status.value}")
checkin.status = CheckInStatus.SKIPPED
- checkin.responded_at = datetime.utcnow()
+ checkin.responded_at = datetime.now(timezone.utc)
checkin.progress_notes = f"Skipped: {skip_data.reason}" if skip_data.reason else "Skipped"
await self.db.flush()
@@ -263,7 +263,7 @@ async def escalate_checkin(
checkin.escalated = True
checkin.escalated_to = escalation.escalate_to
- checkin.escalated_at = datetime.utcnow()
+ checkin.escalated_at = datetime.now(timezone.utc)
checkin.escalation_reason = escalation.reason
checkin.status = CheckInStatus.ESCALATED
@@ -298,7 +298,7 @@ async def auto_escalate_expired(
and_(
CheckIn.org_id == org_id,
CheckIn.status == CheckInStatus.PENDING,
- CheckIn.expires_at < datetime.utcnow(),
+ CheckIn.expires_at < datetime.now(timezone.utc),
CheckIn.escalated == False
)
).options(selectinload(CheckIn.task), selectinload(CheckIn.user))
@@ -321,7 +321,7 @@ async def auto_escalate_expired(
if checkin.user and checkin.user.manager_id:
checkin.escalated = True
checkin.escalated_to = checkin.user.manager_id
- checkin.escalated_at = datetime.utcnow()
+ checkin.escalated_at = datetime.now(timezone.utc)
checkin.escalation_reason = f"Auto-escalated after {missed_count} missed check-ins"
checkin.status = CheckInStatus.ESCALATED
escalated_count += 1
@@ -506,7 +506,7 @@ async def get_statistics(
days: int = 30
) -> dict:
"""Get check-in statistics."""
- since = datetime.utcnow() - timedelta(days=days)
+ since = datetime.now(timezone.utc) - timedelta(days=days)
base_query = select(CheckIn).where(
and_(CheckIn.org_id == org_id, CheckIn.scheduled_at >= since)
)
diff --git a/backend/app/services/integration_service.py b/backend/app/services/integration_service.py
index 1cb321a..e765da3 100644
--- a/backend/app/services/integration_service.py
+++ b/backend/app/services/integration_service.py
@@ -4,7 +4,7 @@
"""
from typing import Optional, List, Dict, Any
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
import hashlib
@@ -95,7 +95,7 @@ async def update_integration_status(
if integration:
integration.status = status
if status == IntegrationStatus.ACTIVE:
- integration.connected_at = datetime.utcnow()
+ integration.connected_at = datetime.now(timezone.utc)
if error_message:
integration.error_message = error_message
else:
@@ -138,7 +138,7 @@ async def sync_integration(
sync_result = await self._perform_sync(integration)
# Update last sync time
- integration.last_sync_at = datetime.utcnow()
+ integration.last_sync_at = datetime.now(timezone.utc)
integration.sync_status = "completed" if sync_result["success"] else "failed"
await self.db.flush()
@@ -413,7 +413,7 @@ async def _deliver_webhook(
# Update webhook stats
webhook.delivery_count = (webhook.delivery_count or 0) + 1
- webhook.last_delivery_at = datetime.utcnow()
+ webhook.last_delivery_at = datetime.now(timezone.utc)
except Exception as e:
delivery.success = False
@@ -422,7 +422,7 @@ async def _deliver_webhook(
# Update failure count
webhook.failure_count = (webhook.failure_count or 0) + 1
- webhook.last_failure_at = datetime.utcnow()
+ webhook.last_failure_at = datetime.now(timezone.utc)
self.db.add(delivery)
await self.db.flush()
@@ -500,7 +500,7 @@ async def test_webhook(
test_payload = {
"event": "test",
"message": "This is a test webhook delivery",
- "timestamp": datetime.utcnow().isoformat(),
+ "timestamp": datetime.now(timezone.utc).isoformat(),
"webhook_id": webhook_id
}
diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py
index 671b013..b91c48e 100644
--- a/backend/app/services/notification_service.py
+++ b/backend/app/services/notification_service.py
@@ -4,7 +4,7 @@
"""
from typing import Optional, List, Dict, Any
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, update, and_
@@ -146,7 +146,7 @@ async def mark_as_read(
if notification:
notification.is_read = True
- notification.read_at = datetime.utcnow()
+ notification.read_at = datetime.now(timezone.utc)
await self.db.flush()
return notification
@@ -160,7 +160,7 @@ async def mark_all_as_read(self, user_id: str, org_id: str) -> int:
Notification.org_id == org_id,
Notification.is_read == False
)
- .values(is_read=True, read_at=datetime.utcnow())
+ .values(is_read=True, read_at=datetime.now(timezone.utc))
)
await self.db.flush()
return result.rowcount
@@ -374,7 +374,7 @@ async def cleanup_old_notifications(
days_to_keep: int = 30
) -> int:
"""Clean up old read notifications."""
- cutoff = datetime.utcnow() - timedelta(days=days_to_keep)
+ cutoff = datetime.now(timezone.utc) - timedelta(days=days_to_keep)
result = await self.db.execute(
select(Notification).where(
diff --git a/backend/app/services/prediction_service.py b/backend/app/services/prediction_service.py
index 8cd776f..14dff0a 100644
--- a/backend/app/services/prediction_service.py
+++ b/backend/app/services/prediction_service.py
@@ -4,7 +4,7 @@
"""
from typing import Optional, List, Dict, Any
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
import random
@@ -49,7 +49,7 @@ async def predict_task_delivery(
adjusted_hours = base_hours * historical_factor
# Calculate P25, P50, P90 estimates
- now = datetime.utcnow()
+ now = datetime.now(timezone.utc)
p50_hours = adjusted_hours
p25_hours = adjusted_hours * 0.7 # Optimistic
p90_hours = adjusted_hours * 1.5 # Pessimistic
@@ -201,7 +201,7 @@ async def predict_project_delivery(
risk_factors.extend(prediction.get("risk_factors", []))
avg_risk = total_risk / len(tasks) if tasks else 0
- now = datetime.utcnow()
+ now = datetime.now(timezone.utc)
# Account for parallelization (assume 2 parallel streams)
parallel_factor = 0.6
@@ -382,7 +382,7 @@ async def get_prediction_accuracy(
days: int = 30
) -> Dict[str, Any]:
"""Get prediction accuracy metrics."""
- cutoff = datetime.utcnow() - timedelta(days=days)
+ cutoff = datetime.now(timezone.utc) - timedelta(days=days)
result = await self.db.execute(
select(Prediction).where(
diff --git a/backend/app/services/report_service.py b/backend/app/services/report_service.py
index b712cf0..859a69d 100644
--- a/backend/app/services/report_service.py
+++ b/backend/app/services/report_service.py
@@ -4,7 +4,7 @@
"""
from typing import Optional, List, Dict, Any
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
import io
@@ -82,7 +82,7 @@ async def generate_team_productivity_report(
"start": start_date.isoformat(),
"end": end_date.isoformat()
},
- "generated_at": datetime.utcnow().isoformat(),
+ "generated_at": datetime.now(timezone.utc).isoformat(),
"summary": {
"team_size": len(team_members),
"total_tasks": total_tasks,
@@ -146,7 +146,7 @@ async def generate_task_completion_report(
"start": start_date.isoformat(),
"end": end_date.isoformat()
},
- "generated_at": datetime.utcnow().isoformat(),
+ "generated_at": datetime.now(timezone.utc).isoformat(),
"summary": {
"total_completed": len(completed_tasks),
"on_time": len(on_time),
@@ -221,7 +221,7 @@ async def generate_blocker_analysis_report(
"start": start_date.isoformat(),
"end": end_date.isoformat()
},
- "generated_at": datetime.utcnow().isoformat(),
+ "generated_at": datetime.now(timezone.utc).isoformat(),
"summary": {
"total_blockers_reported": len(blocked_checkins),
"currently_blocked_tasks": len(currently_blocked),
@@ -267,7 +267,7 @@ async def generate_executive_summary(
period_days: int = 30
) -> Dict[str, Any]:
"""Generate executive summary report."""
- end_date = datetime.utcnow()
+ end_date = datetime.now(timezone.utc)
start_date = end_date - timedelta(days=period_days)
# Get dashboard metrics
@@ -289,7 +289,7 @@ async def generate_executive_summary(
"report_type": "executive_summary",
"organization_id": org_id,
"period_days": period_days,
- "generated_at": datetime.utcnow().isoformat(),
+ "generated_at": datetime.now(timezone.utc).isoformat(),
"key_metrics": {
"total_employees": employee_count,
"active_tasks": dashboard["task_summary"]["total_active"],
@@ -428,6 +428,6 @@ async def schedule_report(
"report_type": report_type,
"schedule": schedule,
"recipients": recipients,
- "next_run": (datetime.utcnow() + timedelta(days=1)).isoformat(),
+ "next_run": (datetime.now(timezone.utc) + timedelta(days=1)).isoformat(),
"message": "Report scheduled successfully"
}
diff --git a/backend/app/services/skill_service.py b/backend/app/services/skill_service.py
index 3bd3e66..caaa3f2 100644
--- a/backend/app/services/skill_service.py
+++ b/backend/app/services/skill_service.py
@@ -4,7 +4,7 @@
"""
from typing import Optional, List, Tuple
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
from sqlalchemy import select, func, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -161,7 +161,7 @@ async def add_user_skill(
confidence=skill_data.confidence,
source=skill_data.source,
notes=skill_data.notes,
- last_demonstrated=datetime.utcnow()
+ last_demonstrated=datetime.now(timezone.utc)
)
self.db.add(user_skill)
@@ -291,7 +291,7 @@ async def get_skill_velocity(
days: int = 30
) -> dict:
"""Calculate skill velocity metrics."""
- since = datetime.utcnow() - timedelta(days=days)
+ since = datetime.now(timezone.utc) - timedelta(days=days)
# Get completed tasks
tasks_result = await self.db.execute(
@@ -614,7 +614,7 @@ async def infer_skills_from_task(
if user_skill:
user_skill.demonstration_count += 1
- user_skill.last_demonstrated = datetime.utcnow()
+ user_skill.last_demonstrated = datetime.now(timezone.utc)
demonstrated_skills.append(skill.id)
await self.db.flush()
diff --git a/backend/app/services/smart_task_service.py b/backend/app/services/smart_task_service.py
index 3e6704e..3c0aad0 100644
--- a/backend/app/services/smart_task_service.py
+++ b/backend/app/services/smart_task_service.py
@@ -6,7 +6,7 @@
import json
import logging
from typing import Dict, Any, Optional, List
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
from sqlalchemy.ext.asyncio import AsyncSession
@@ -39,7 +39,7 @@ async def parse_natural_language_task(self, message: str) -> Dict[str, Any]:
Returns:
Dict with title, description, deadline, work hours, priority, etc.
"""
- today = datetime.utcnow().strftime("%Y-%m-%d")
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
system_prompt = f"""You are a task management AI. Parse the user's message and extract task details.
Today's date is {today}.
@@ -135,7 +135,7 @@ async def create_smart_task(
work_start = parsed_task.get("work_start_hour", 9)
work_end = parsed_task.get("work_end_hour", 18)
available = self._calculate_available_hours(
- datetime.utcnow(), deadline, work_start, work_end
+ datetime.now(timezone.utc), deadline, work_start, work_end
)
decomposition = self._fit_subtasks_to_hours(decomposition, available)
@@ -177,7 +177,7 @@ async def create_smart_task(
checkin_config = await self.checkin_service.create_config(org_id, config_data)
# Create first check-in 1 hour from now (if within work hours)
- now = datetime.utcnow()
+ now = datetime.now(timezone.utc)
next_checkin = now + timedelta(hours=1)
if work_start <= next_checkin.hour < work_end:
first_checkin = await self.checkin_service.create_checkin(
diff --git a/backend/app/services/storage_service.py b/backend/app/services/storage_service.py
new file mode 100644
index 0000000..3100d0f
--- /dev/null
+++ b/backend/app/services/storage_service.py
@@ -0,0 +1,110 @@
+"""
+TaskPulse - AI Assistant - Storage Service
+File upload/download via Supabase Storage
+"""
+
+import asyncio
+import logging
+from typing import Optional, Tuple
+
+from app.config import settings
+from app.supabase_client import get_supabase_client
+
+logger = logging.getLogger(__name__)
+
+BUCKET = settings.SUPABASE_STORAGE_BUCKET # "documents"
+
+
+class StorageService:
+ """Service for file operations via Supabase Storage."""
+
+ def __init__(self):
+ self.client = get_supabase_client()
+
+ async def upload_file(
+ self,
+ org_id: str,
+ doc_id: str,
+ filename: str,
+ content: bytes,
+ content_type: str = "application/octet-stream",
+ ) -> Tuple[str, str]:
+ """
+ Upload a file to Supabase Storage.
+
+ Files are stored under ``{org_id}/{doc_id}/{filename}`` so each
+ document gets its own namespace and org data is isolated.
+
+ Args:
+ org_id: Organization ID (path prefix for isolation)
+ doc_id: Document ID (sub-folder)
+ filename: Original file name
+ content: Raw file bytes
+ content_type: MIME type
+
+ Returns:
+ Tuple of (storage_path, public_url)
+ """
+ storage_path = f"{org_id}/{doc_id}/{filename}"
+
+ try:
+ await asyncio.to_thread(
+ self.client.storage.from_(BUCKET).upload,
+ path=storage_path,
+ file=content,
+ file_options={"content-type": content_type},
+ )
+ except Exception as exc:
+ logger.error("Supabase storage upload failed for %s: %s", storage_path, exc)
+ raise
+
+ # Build the public URL (works for public buckets; for private ones
+ # callers should use get_signed_url instead).
+ public_url = (
+ f"{settings.SUPABASE_URL}/storage/v1/object/public/{BUCKET}/{storage_path}"
+ )
+
+ logger.info("Uploaded %s (%d bytes) to Supabase Storage", storage_path, len(content))
+ return storage_path, public_url
+
+ async def delete_file(self, storage_path: str) -> None:
+ """
+ Delete a file from Supabase Storage.
+
+ Args:
+ storage_path: The path returned by upload_file
+ """
+ try:
+ await asyncio.to_thread(
+ self.client.storage.from_(BUCKET).remove,
+ [storage_path],
+ )
+ logger.info("Deleted %s from Supabase Storage", storage_path)
+ except Exception as exc:
+ logger.warning("Failed to delete %s from storage: %s", storage_path, exc)
+
+ async def get_signed_url(
+ self,
+ storage_path: str,
+ expires_in: int = 3600,
+ ) -> str:
+ """
+ Generate a signed (time-limited) URL for a private file.
+
+ Args:
+ storage_path: The path returned by upload_file
+ expires_in: Seconds until the URL expires (default 1 hour)
+
+ Returns:
+ Signed URL string
+ """
+ try:
+ result = await asyncio.to_thread(
+ self.client.storage.from_(BUCKET).create_signed_url,
+ storage_path,
+ expires_in,
+ )
+ return result["signedURL"]
+ except Exception as exc:
+ logger.error("Failed to create signed URL for %s: %s", storage_path, exc)
+ raise
diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py
index d638e34..711a097 100644
--- a/backend/app/services/task_service.py
+++ b/backend/app/services/task_service.py
@@ -4,7 +4,7 @@
"""
from typing import Optional, List, Tuple
-from datetime import datetime
+from datetime import datetime, timezone
from sqlalchemy import select, func, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -750,7 +750,7 @@ async def get_task_statistics(
overdue_query = select(func.count()).select_from(
base_query.where(
and_(
- Task.deadline < datetime.utcnow(),
+ Task.deadline < datetime.now(timezone.utc),
Task.status.notin_([TaskStatus.DONE, TaskStatus.ARCHIVED])
)
).subquery()
diff --git a/backend/app/services/unblock_service.py b/backend/app/services/unblock_service.py
index 022f5de..6f0d60c 100644
--- a/backend/app/services/unblock_service.py
+++ b/backend/app/services/unblock_service.py
@@ -4,7 +4,7 @@
"""
from typing import Optional, List, Tuple
-from datetime import datetime
+from datetime import datetime, timezone
import hashlib
from sqlalchemy import select, func, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
@@ -95,7 +95,7 @@ async def _process_document(self, doc: Document) -> None:
self.db.add(chunk)
doc.status = DocumentStatus.INDEXED
- doc.processed_at = datetime.utcnow()
+ doc.processed_at = datetime.now(timezone.utc)
except Exception as e:
doc.status = DocumentStatus.FAILED
@@ -234,11 +234,17 @@ async def delete_document(
doc_id: str,
org_id: str
) -> bool:
- """Delete a document."""
+ """Delete a document and its storage file."""
doc = await self.get_document(doc_id, org_id)
if not doc:
raise NotFoundException("Document", doc_id)
+ # Delete file from Supabase Storage if it was uploaded
+ if doc.storage_path:
+ from app.services.storage_service import StorageService
+ storage = StorageService()
+ await storage.delete_file(doc.storage_path)
+
await self.db.delete(doc)
await self.db.flush()
return True
@@ -407,7 +413,7 @@ async def submit_feedback(
session.was_helpful = feedback.was_helpful
session.feedback_text = feedback.feedback_text
- session.feedback_at = datetime.utcnow()
+ session.feedback_at = datetime.now(timezone.utc)
# Update document helpfulness counts
if session.sources:
diff --git a/backend/app/services/workforce_service.py b/backend/app/services/workforce_service.py
index c331b38..be22ef9 100644
--- a/backend/app/services/workforce_service.py
+++ b/backend/app/services/workforce_service.py
@@ -4,7 +4,7 @@
"""
from typing import Optional, List, Dict, Any
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
import random
@@ -37,7 +37,7 @@ async def calculate_workforce_score(
select(Task).where(
Task.assigned_to == user_id,
Task.org_id == org_id,
- Task.completed_at >= datetime.utcnow() - timedelta(days=90)
+ Task.completed_at >= datetime.now(timezone.utc) - timedelta(days=90)
)
)
tasks = result.scalars().all()
@@ -245,7 +245,7 @@ async def calculate_manager_effectiveness(
select(Task).where(
Task.org_id == org_id,
Task.assigned_to.in_(team_ids),
- Task.completed_at >= datetime.utcnow() - timedelta(days=90)
+ Task.completed_at >= datetime.now(timezone.utc) - timedelta(days=90)
)
)
team_tasks = result.scalars().all()
@@ -521,7 +521,7 @@ async def get_attrition_risks(
factors.append("Low engagement signals")
# Calculate tenure
- tenure_months = (datetime.utcnow() - user.created_at).days // 30
+ tenure_months = (datetime.now(timezone.utc) - user.created_at).days // 30
risk_level = (
"critical" if score.attrition_risk_score >= 0.7
diff --git a/backend/app/supabase_client.py b/backend/app/supabase_client.py
new file mode 100644
index 0000000..6e14919
--- /dev/null
+++ b/backend/app/supabase_client.py
@@ -0,0 +1,35 @@
+"""
+Supabase client singleton for server-side operations.
+Uses SERVICE_ROLE_KEY to bypass Row-Level Security.
+"""
+
+from functools import lru_cache
+from supabase import create_client, Client
+from app.config import settings
+
+
+@lru_cache()
+def get_supabase_client() -> Client:
+ """Get cached Supabase client instance using service role key."""
+ if not settings.SUPABASE_URL or not settings.SUPABASE_SERVICE_ROLE_KEY:
+ raise RuntimeError(
+ "SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY must be set. "
+ "Check your .env file."
+ )
+ return create_client(
+ settings.SUPABASE_URL,
+ settings.SUPABASE_SERVICE_ROLE_KEY
+ )
+
+
+def get_supabase_anon_client() -> Client:
+ """Get Supabase client with anon key (respects RLS)."""
+ if not settings.SUPABASE_URL or not settings.SUPABASE_ANON_KEY:
+ raise RuntimeError(
+ "SUPABASE_URL and SUPABASE_ANON_KEY must be set. "
+ "Check your .env file."
+ )
+ return create_client(
+ settings.SUPABASE_URL,
+ settings.SUPABASE_ANON_KEY
+ )
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 64755b5..0f6a300 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -7,17 +7,20 @@ python-multipart>=0.0.6
# ==================== Database ====================
sqlalchemy>=2.0.25
-aiosqlite>=0.19.0
asyncpg>=0.29.0
greenlet>=3.0.3
alembic>=1.13.1
+pgvector>=0.2.5
+
+# ==================== Supabase ====================
+supabase>=2.3.0
+gotrue>=2.1.0
+storage3>=0.7.0
# ==================== Authentication ====================
python-jose[cryptography]>=3.3.0
-passlib[bcrypt]>=1.7.4
bcrypt>=4.1.2
email-validator>=2.1.0
-google-auth>=2.27.0
# ==================== AI/ML (Mock for now) ====================
openai>=1.12.0
@@ -48,6 +51,9 @@ pytest-cov>=4.1.0
pypdf>=4.0.0
python-docx>=1.0.0
+# ==================== Migration (SQLite → Supabase) ====================
+aiosqlite>=0.19.0
+
# ==================== Utilities ====================
python-dateutil>=2.8.2
pytz>=2024.1
diff --git a/backend/schema.sql b/backend/schema.sql
new file mode 100644
index 0000000..979c911
--- /dev/null
+++ b/backend/schema.sql
@@ -0,0 +1,1608 @@
+-- ============================================================================
+-- TaskPulse AI - Complete PostgreSQL DDL Schema
+-- Generated from SQLAlchemy models
+-- ============================================================================
+
+-- Enable required extensions
+CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
+CREATE EXTENSION IF NOT EXISTS vector;
+
+-- ============================================================================
+-- ENUM TYPES
+-- ============================================================================
+
+CREATE TYPE plan_tier AS ENUM (
+ 'starter', 'professional', 'enterprise', 'enterprise_plus'
+);
+
+CREATE TYPE user_role AS ENUM (
+ 'super_admin', 'org_admin', 'manager', 'team_lead', 'employee', 'viewer'
+);
+
+CREATE TYPE skill_level AS ENUM (
+ 'junior', 'mid', 'senior', 'lead'
+);
+
+CREATE TYPE task_status AS ENUM (
+ 'todo', 'in_progress', 'blocked', 'review', 'done', 'archived'
+);
+
+CREATE TYPE task_priority AS ENUM (
+ 'critical', 'high', 'medium', 'low'
+);
+
+CREATE TYPE blocker_type AS ENUM (
+ 'logic', 'tool', 'dependency', 'bug', 'resource', 'unknown'
+);
+
+CREATE TYPE checkin_trigger AS ENUM (
+ 'scheduled', 'progress_stall', 'deadline_approaching',
+ 'manual', 'blocker_detected', 'status_change'
+);
+
+CREATE TYPE checkin_status AS ENUM (
+ 'pending', 'responded', 'skipped', 'expired', 'escalated'
+);
+
+CREATE TYPE progress_indicator AS ENUM (
+ 'on_track', 'slightly_behind', 'significantly_behind',
+ 'blocked', 'ahead', 'completed'
+);
+
+CREATE TYPE document_source AS ENUM (
+ 'manual_upload', 'confluence', 'notion', 'github',
+ 'gitlab', 'jira', 'slack', 'internal_wiki', 'external_url'
+);
+
+CREATE TYPE document_status AS ENUM (
+ 'pending', 'processing', 'indexed', 'failed', 'archived'
+);
+
+CREATE TYPE document_type AS ENUM (
+ 'documentation', 'code_snippet', 'tutorial', 'faq', 'runbook',
+ 'policy', 'meeting_notes', 'architecture', 'guide', 'troubleshooting', 'other'
+);
+
+CREATE TYPE skill_category AS ENUM (
+ 'technical', 'process', 'soft', 'domain', 'tool', 'language'
+);
+
+CREATE TYPE skill_trend AS ENUM (
+ 'improving', 'stable', 'declining'
+);
+
+CREATE TYPE gap_type AS ENUM (
+ 'critical', 'growth', 'stretch'
+);
+
+CREATE TYPE prediction_type AS ENUM (
+ 'task_completion', 'project_delivery', 'team_velocity',
+ 'attrition_risk', 'hiring_needs'
+);
+
+CREATE TYPE pattern_status AS ENUM (
+ 'detected', 'suggested', 'accepted', 'rejected', 'implemented'
+);
+
+CREATE TYPE agent_status AS ENUM (
+ 'created', 'shadow', 'supervised', 'live', 'paused', 'retired'
+);
+
+CREATE TYPE notification_type AS ENUM (
+ 'checkin_reminder', 'task_assigned', 'task_completed', 'task_blocked',
+ 'escalation', 'deadline_approaching', 'ai_suggestion', 'mention', 'system'
+);
+
+CREATE TYPE notification_channel AS ENUM (
+ 'in_app', 'email', 'slack', 'teams', 'webhook'
+);
+
+CREATE TYPE notification_priority AS ENUM (
+ 'low', 'medium', 'high', 'urgent'
+);
+
+CREATE TYPE integration_type AS ENUM (
+ 'jira', 'github', 'gitlab', 'slack', 'teams',
+ 'confluence', 'notion', 'custom_webhook'
+);
+
+CREATE TYPE integration_status AS ENUM (
+ 'pending', 'active', 'error', 'disconnected'
+);
+
+CREATE TYPE actor_type AS ENUM (
+ 'user', 'admin', 'system', 'ai', 'api', 'integration'
+);
+
+CREATE TYPE audit_action AS ENUM (
+ 'login', 'logout', 'password_change', 'mfa_enabled',
+ 'create', 'read', 'update', 'delete',
+ 'role_change', 'permission_change', 'config_change',
+ 'export', 'import',
+ 'data_request', 'data_deletion'
+);
+
+CREATE TYPE agent_type_enum AS ENUM (
+ 'ai', 'integration', 'conversation'
+);
+
+CREATE TYPE agent_status_db AS ENUM (
+ 'active', 'paused', 'error', 'disabled'
+);
+
+CREATE TYPE execution_status AS ENUM (
+ 'pending', 'running', 'completed', 'failed', 'cancelled'
+);
+
+-- ============================================================================
+-- TABLE: organizations (no foreign key dependencies)
+-- ============================================================================
+
+CREATE TABLE organizations (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ name VARCHAR(255) NOT NULL,
+ slug VARCHAR(100) NOT NULL,
+ description TEXT,
+ plan plan_tier NOT NULL DEFAULT 'starter',
+ settings_data JSONB DEFAULT '{}',
+ is_active BOOLEAN NOT NULL DEFAULT TRUE
+);
+
+CREATE UNIQUE INDEX ix_organizations_slug ON organizations (slug);
+CREATE INDEX ix_organizations_id ON organizations (id);
+CREATE INDEX ix_organizations_created_at ON organizations (created_at);
+
+-- ============================================================================
+-- TABLE: users (depends on: organizations)
+-- ============================================================================
+
+CREATE TABLE users (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ -- Organization
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ -- Authentication
+ email VARCHAR(255) NOT NULL,
+ password_hash VARCHAR(255),
+ is_sso_user BOOLEAN DEFAULT FALSE,
+ supabase_auth_id UUID,
+
+ -- Profile
+ first_name VARCHAR(100) NOT NULL,
+ last_name VARCHAR(100) NOT NULL,
+ avatar_url VARCHAR(500),
+ phone VARCHAR(50),
+ timezone VARCHAR(50) NOT NULL DEFAULT 'UTC',
+
+ -- Role and permissions
+ role user_role NOT NULL DEFAULT 'employee',
+ skill_level skill_level NOT NULL DEFAULT 'mid',
+
+ -- Team / reporting structure
+ team_id UUID,
+ manager_id UUID REFERENCES users(id) ON DELETE SET NULL,
+
+ -- GDPR consent tracking
+ consent_data JSONB DEFAULT '{}',
+
+ -- Status
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ is_email_verified BOOLEAN DEFAULT FALSE,
+ last_login TIMESTAMP,
+ failed_login_attempts INTEGER DEFAULT 0,
+ lockout_until TIMESTAMP,
+
+ -- Constraints
+ CONSTRAINT uq_user_org_email UNIQUE (org_id, email)
+);
+
+CREATE INDEX ix_users_id ON users (id);
+CREATE INDEX ix_users_created_at ON users (created_at);
+CREATE INDEX ix_users_org_id ON users (org_id);
+CREATE INDEX ix_users_email ON users (email);
+CREATE UNIQUE INDEX ix_users_supabase_auth_id ON users (supabase_auth_id);
+CREATE INDEX ix_users_team_id ON users (team_id);
+
+-- ============================================================================
+-- TABLE: tasks (depends on: organizations, users)
+-- ============================================================================
+
+CREATE TABLE tasks (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ -- Organization
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ -- Basic info
+ title VARCHAR(500) NOT NULL,
+ description TEXT,
+ goal TEXT,
+
+ -- Status and priority
+ status task_status NOT NULL DEFAULT 'todo',
+ priority task_priority NOT NULL DEFAULT 'medium',
+
+ -- Assignment
+ assigned_to UUID REFERENCES users(id) ON DELETE SET NULL,
+ created_by UUID NOT NULL REFERENCES users(id) ON DELETE SET NULL,
+
+ -- Team/project grouping
+ team_id UUID,
+ project_id UUID,
+
+ -- Time tracking
+ deadline TIMESTAMP WITH TIME ZONE,
+ estimated_hours DOUBLE PRECISION,
+ actual_hours DOUBLE PRECISION DEFAULT 0.0,
+ started_at TIMESTAMP WITH TIME ZONE,
+ completed_at TIMESTAMP WITH TIME ZONE,
+
+ -- AI-generated scores
+ risk_score DOUBLE PRECISION,
+ confidence_score DOUBLE PRECISION,
+ complexity_score DOUBLE PRECISION,
+
+ -- Blocker info
+ blocker_type blocker_type,
+ blocker_description TEXT,
+
+ -- Metadata (JSONB)
+ tools JSONB DEFAULT '[]',
+ tags JSONB DEFAULT '[]',
+ skills_required JSONB DEFAULT '[]',
+
+ -- Parent task (for subtasks, self-reference)
+ parent_task_id UUID REFERENCES tasks(id) ON DELETE CASCADE,
+
+ -- Ordering
+ sort_order INTEGER DEFAULT 0,
+
+ -- Draft flag
+ is_draft BOOLEAN NOT NULL DEFAULT FALSE
+);
+
+CREATE INDEX ix_tasks_id ON tasks (id);
+CREATE INDEX ix_tasks_created_at ON tasks (created_at);
+CREATE INDEX ix_tasks_org_id ON tasks (org_id);
+CREATE INDEX ix_tasks_status ON tasks (status);
+CREATE INDEX ix_tasks_assigned_to ON tasks (assigned_to);
+CREATE INDEX ix_tasks_team_id ON tasks (team_id);
+CREATE INDEX ix_tasks_project_id ON tasks (project_id);
+CREATE INDEX ix_tasks_parent_task_id ON tasks (parent_task_id);
+CREATE INDEX ix_tasks_is_draft ON tasks (is_draft);
+
+-- ============================================================================
+-- TABLE: task_dependencies (depends on: tasks)
+-- ============================================================================
+
+CREATE TABLE task_dependencies (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+ depends_on_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+ is_blocking BOOLEAN DEFAULT TRUE,
+ description VARCHAR(500)
+);
+
+CREATE INDEX ix_task_dependencies_id ON task_dependencies (id);
+CREATE INDEX ix_task_dependencies_created_at ON task_dependencies (created_at);
+CREATE INDEX ix_task_dependencies_task_id ON task_dependencies (task_id);
+CREATE INDEX ix_task_dependencies_depends_on_id ON task_dependencies (depends_on_id);
+
+-- ============================================================================
+-- TABLE: task_history (depends on: tasks, users)
+-- ============================================================================
+
+CREATE TABLE task_history (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+ user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+
+ action VARCHAR(50) NOT NULL,
+ field_name VARCHAR(100),
+ old_value TEXT,
+ new_value TEXT,
+ details JSONB DEFAULT '{}'
+);
+
+CREATE INDEX ix_task_history_id ON task_history (id);
+CREATE INDEX ix_task_history_created_at ON task_history (created_at);
+CREATE INDEX ix_task_history_task_id ON task_history (task_id);
+
+-- ============================================================================
+-- TABLE: task_comments (depends on: tasks, users)
+-- ============================================================================
+
+CREATE TABLE task_comments (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+ user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+
+ content TEXT NOT NULL,
+ is_ai_generated BOOLEAN DEFAULT FALSE,
+ is_edited BOOLEAN DEFAULT FALSE
+);
+
+CREATE INDEX ix_task_comments_id ON task_comments (id);
+CREATE INDEX ix_task_comments_created_at ON task_comments (created_at);
+CREATE INDEX ix_task_comments_task_id ON task_comments (task_id);
+
+-- ============================================================================
+-- TABLE: checkins (depends on: tasks, users, organizations)
+-- ============================================================================
+
+CREATE TABLE checkins (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+ user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ -- Check-in metadata
+ cycle_number INTEGER DEFAULT 1,
+ trigger checkin_trigger DEFAULT 'scheduled',
+ status checkin_status DEFAULT 'pending',
+
+ -- Timing
+ scheduled_at TIMESTAMP WITH TIME ZONE NOT NULL,
+ responded_at TIMESTAMP WITH TIME ZONE,
+ expires_at TIMESTAMP WITH TIME ZONE,
+
+ -- User response
+ progress_indicator progress_indicator,
+ progress_notes TEXT,
+ completed_since_last TEXT,
+ blockers_reported TEXT,
+ help_needed BOOLEAN DEFAULT FALSE,
+ estimated_completion_change DOUBLE PRECISION,
+
+ -- AI analysis
+ ai_suggestion TEXT,
+ ai_confidence DOUBLE PRECISION,
+ sentiment_score DOUBLE PRECISION,
+ friction_detected BOOLEAN DEFAULT FALSE,
+
+ -- Escalation
+ escalated BOOLEAN DEFAULT FALSE,
+ escalated_to UUID REFERENCES users(id),
+ escalated_at TIMESTAMP WITH TIME ZONE,
+ escalation_reason TEXT
+);
+
+CREATE INDEX ix_checkins_id ON checkins (id);
+CREATE INDEX ix_checkins_created_at ON checkins (created_at);
+CREATE INDEX ix_checkins_task_id ON checkins (task_id);
+CREATE INDEX ix_checkins_user_id ON checkins (user_id);
+CREATE INDEX ix_checkins_org_id ON checkins (org_id);
+CREATE INDEX ix_checkins_status ON checkins (status);
+
+-- ============================================================================
+-- TABLE: checkin_configs (depends on: organizations, users, tasks)
+-- ============================================================================
+
+CREATE TABLE checkin_configs (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ -- Scope
+ team_id UUID,
+ user_id UUID REFERENCES users(id),
+ task_id UUID REFERENCES tasks(id),
+
+ -- Check-in settings
+ interval_hours DOUBLE PRECISION DEFAULT 3.0,
+ enabled BOOLEAN DEFAULT TRUE,
+ silent_mode_threshold DOUBLE PRECISION DEFAULT 0.8,
+ max_daily_checkins INTEGER DEFAULT 4,
+
+ -- Working hours
+ work_start_hour INTEGER DEFAULT 9,
+ work_end_hour INTEGER DEFAULT 18,
+ respect_timezone BOOLEAN DEFAULT TRUE,
+ excluded_days VARCHAR(50) DEFAULT '0,6',
+
+ -- Escalation settings
+ auto_escalate_after_missed INTEGER DEFAULT 2,
+ escalate_to_manager BOOLEAN DEFAULT TRUE,
+
+ -- AI settings
+ ai_suggestions_enabled BOOLEAN DEFAULT TRUE,
+ ai_sentiment_analysis BOOLEAN DEFAULT TRUE
+);
+
+CREATE INDEX ix_checkin_configs_id ON checkin_configs (id);
+CREATE INDEX ix_checkin_configs_created_at ON checkin_configs (created_at);
+CREATE INDEX ix_checkin_configs_org_id ON checkin_configs (org_id);
+CREATE INDEX ix_checkin_configs_team_id ON checkin_configs (team_id);
+CREATE INDEX ix_checkin_configs_user_id ON checkin_configs (user_id);
+CREATE INDEX ix_checkin_configs_task_id ON checkin_configs (task_id);
+
+-- ============================================================================
+-- TABLE: checkin_reminders (depends on: checkins, users)
+-- ============================================================================
+
+CREATE TABLE checkin_reminders (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ checkin_id UUID NOT NULL REFERENCES checkins(id) ON DELETE CASCADE,
+ user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+
+ reminder_number INTEGER DEFAULT 1,
+ channel VARCHAR(50) DEFAULT 'in_app',
+ sent_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
+ acknowledged BOOLEAN DEFAULT FALSE,
+ acknowledged_at TIMESTAMP WITH TIME ZONE
+);
+
+CREATE INDEX ix_checkin_reminders_id ON checkin_reminders (id);
+CREATE INDEX ix_checkin_reminders_created_at ON checkin_reminders (created_at);
+CREATE INDEX ix_checkin_reminders_checkin_id ON checkin_reminders (checkin_id);
+
+-- ============================================================================
+-- TABLE: documents (depends on: organizations)
+-- ============================================================================
+
+CREATE TABLE documents (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ -- Document metadata
+ title VARCHAR(500) NOT NULL,
+ description TEXT,
+ source document_source DEFAULT 'manual_upload',
+ source_url VARCHAR(2000),
+ source_id VARCHAR(255),
+
+ -- Content
+ content TEXT NOT NULL,
+ content_hash VARCHAR(64),
+ doc_type document_type DEFAULT 'documentation',
+
+ -- Processing status
+ status document_status DEFAULT 'pending',
+ error_message TEXT,
+ processed_at TIMESTAMP,
+
+ -- File metadata
+ file_name VARCHAR(500),
+ file_type VARCHAR(50),
+ file_size INTEGER,
+ storage_path VARCHAR(500),
+ storage_url VARCHAR(2000),
+ language VARCHAR(50) DEFAULT 'en',
+
+ -- Access control
+ is_public BOOLEAN DEFAULT FALSE,
+ team_ids JSONB DEFAULT '[]',
+
+ -- Categorization
+ tags JSONB DEFAULT '[]',
+ categories JSONB DEFAULT '[]',
+
+ -- Stats
+ view_count INTEGER DEFAULT 0,
+ helpful_count INTEGER DEFAULT 0,
+ not_helpful_count INTEGER DEFAULT 0,
+
+ -- Sync
+ last_synced_at TIMESTAMP,
+ sync_enabled BOOLEAN DEFAULT TRUE
+);
+
+CREATE INDEX ix_documents_id ON documents (id);
+CREATE INDEX ix_documents_created_at ON documents (created_at);
+CREATE INDEX ix_documents_org_id ON documents (org_id);
+CREATE INDEX ix_documents_status ON documents (status);
+
+-- ============================================================================
+-- TABLE: document_chunks (depends on: documents) — uses pgvector
+-- ============================================================================
+
+CREATE TABLE document_chunks (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
+
+ -- Chunk content
+ content TEXT NOT NULL,
+ chunk_index INTEGER NOT NULL,
+ start_char INTEGER,
+ end_char INTEGER,
+
+ -- Embedding (pgvector)
+ embedding vector(1536),
+ embedding_model VARCHAR(100),
+
+ -- Metadata
+ token_count INTEGER,
+ chunk_metadata JSONB DEFAULT '{}'
+);
+
+CREATE INDEX ix_document_chunks_id ON document_chunks (id);
+CREATE INDEX ix_document_chunks_created_at ON document_chunks (created_at);
+CREATE INDEX ix_document_chunks_document_id ON document_chunks (document_id);
+
+-- ============================================================================
+-- TABLE: unblock_sessions (depends on: organizations, users, tasks, checkins)
+-- ============================================================================
+
+CREATE TABLE unblock_sessions (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+ task_id UUID REFERENCES tasks(id) ON DELETE SET NULL,
+ checkin_id UUID REFERENCES checkins(id) ON DELETE SET NULL,
+
+ -- Query
+ query TEXT NOT NULL,
+ blocker_type VARCHAR(50),
+ user_skill_level VARCHAR(50) DEFAULT 'intermediate',
+
+ -- Response
+ response TEXT,
+ confidence DOUBLE PRECISION,
+ sources JSONB DEFAULT '[]',
+
+ -- Escalation
+ escalation_recommended BOOLEAN DEFAULT FALSE,
+ escalated BOOLEAN DEFAULT FALSE,
+ escalated_to UUID REFERENCES users(id),
+
+ -- Feedback
+ was_helpful BOOLEAN,
+ feedback_text TEXT,
+ feedback_at TIMESTAMP
+);
+
+CREATE INDEX ix_unblock_sessions_id ON unblock_sessions (id);
+CREATE INDEX ix_unblock_sessions_created_at ON unblock_sessions (created_at);
+CREATE INDEX ix_unblock_sessions_org_id ON unblock_sessions (org_id);
+CREATE INDEX ix_unblock_sessions_user_id ON unblock_sessions (user_id);
+CREATE INDEX ix_unblock_sessions_task_id ON unblock_sessions (task_id);
+
+-- ============================================================================
+-- TABLE: skills (depends on: organizations)
+-- ============================================================================
+
+CREATE TABLE skills (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ name VARCHAR(200) NOT NULL,
+ description TEXT,
+ category skill_category DEFAULT 'technical',
+
+ -- Metadata (JSONB)
+ aliases JSONB DEFAULT '[]',
+ related_skills JSONB DEFAULT '[]',
+ prerequisites JSONB DEFAULT '[]',
+
+ -- Benchmarks
+ org_average_level DOUBLE PRECISION,
+ industry_average_level DOUBLE PRECISION,
+
+ -- Status
+ is_active BOOLEAN DEFAULT TRUE
+);
+
+CREATE INDEX ix_skills_id ON skills (id);
+CREATE INDEX ix_skills_created_at ON skills (created_at);
+CREATE INDEX ix_skills_org_id ON skills (org_id);
+
+-- ============================================================================
+-- TABLE: user_skills (depends on: users, skills, organizations)
+-- ============================================================================
+
+CREATE TABLE user_skills (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ skill_id UUID NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ -- Current assessment
+ level DOUBLE PRECISION DEFAULT 1.0,
+ confidence DOUBLE PRECISION DEFAULT 0.5,
+ trend skill_trend DEFAULT 'stable',
+
+ -- Evidence
+ last_demonstrated TIMESTAMP WITH TIME ZONE,
+ demonstration_count INTEGER DEFAULT 0,
+ source VARCHAR(50) DEFAULT 'inferred',
+
+ -- History (JSONB)
+ level_history JSONB DEFAULT '[]',
+ notes TEXT,
+
+ -- Certification
+ is_certified BOOLEAN DEFAULT FALSE,
+ certification_date TIMESTAMP WITH TIME ZONE,
+ certification_expiry TIMESTAMP WITH TIME ZONE
+);
+
+CREATE INDEX ix_user_skills_id ON user_skills (id);
+CREATE INDEX ix_user_skills_created_at ON user_skills (created_at);
+CREATE INDEX ix_user_skills_user_id ON user_skills (user_id);
+CREATE INDEX ix_user_skills_skill_id ON user_skills (skill_id);
+CREATE INDEX ix_user_skills_org_id ON user_skills (org_id);
+
+-- ============================================================================
+-- TABLE: skill_gaps (depends on: users, skills, organizations)
+-- ============================================================================
+
+CREATE TABLE skill_gaps (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ skill_id UUID NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ -- Gap details
+ gap_type gap_type DEFAULT 'growth',
+ current_level DOUBLE PRECISION,
+ required_level DOUBLE PRECISION NOT NULL,
+ gap_size DOUBLE PRECISION NOT NULL,
+
+ -- Context
+ for_role VARCHAR(200),
+ priority INTEGER DEFAULT 5,
+ identified_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
+
+ -- Resolution
+ is_resolved BOOLEAN DEFAULT FALSE,
+ resolved_at TIMESTAMP WITH TIME ZONE,
+
+ -- Recommendations (JSONB)
+ learning_resources JSONB DEFAULT '[]'
+);
+
+CREATE INDEX ix_skill_gaps_id ON skill_gaps (id);
+CREATE INDEX ix_skill_gaps_created_at ON skill_gaps (created_at);
+CREATE INDEX ix_skill_gaps_user_id ON skill_gaps (user_id);
+CREATE INDEX ix_skill_gaps_skill_id ON skill_gaps (skill_id);
+CREATE INDEX ix_skill_gaps_org_id ON skill_gaps (org_id);
+
+-- ============================================================================
+-- TABLE: skill_metrics (depends on: users, organizations)
+-- ============================================================================
+
+CREATE TABLE skill_metrics (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ -- Period
+ period_start TIMESTAMP WITH TIME ZONE NOT NULL,
+ period_end TIMESTAMP WITH TIME ZONE NOT NULL,
+
+ -- Velocity metrics
+ task_completion_velocity DOUBLE PRECISION,
+ quality_score DOUBLE PRECISION,
+ self_sufficiency_index DOUBLE PRECISION,
+ learning_velocity DOUBLE PRECISION,
+
+ -- Collaboration
+ collaboration_score DOUBLE PRECISION,
+ help_given_count INTEGER DEFAULT 0,
+ help_received_count INTEGER DEFAULT 0,
+
+ -- Blocker analysis
+ blockers_encountered INTEGER DEFAULT 0,
+ blockers_self_resolved INTEGER DEFAULT 0,
+ avg_blocker_resolution_hours DOUBLE PRECISION,
+
+ -- Peer comparison (percentile)
+ velocity_percentile DOUBLE PRECISION,
+ quality_percentile DOUBLE PRECISION,
+ learning_percentile DOUBLE PRECISION
+);
+
+CREATE INDEX ix_skill_metrics_id ON skill_metrics (id);
+CREATE INDEX ix_skill_metrics_created_at ON skill_metrics (created_at);
+CREATE INDEX ix_skill_metrics_user_id ON skill_metrics (user_id);
+CREATE INDEX ix_skill_metrics_org_id ON skill_metrics (org_id);
+
+-- ============================================================================
+-- TABLE: learning_paths (depends on: users, organizations)
+-- ============================================================================
+
+CREATE TABLE learning_paths (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ -- Path details
+ title VARCHAR(500) NOT NULL,
+ description TEXT,
+ target_role VARCHAR(200),
+
+ -- Skills to develop (JSONB)
+ skills_data JSONB DEFAULT '[]',
+ milestones JSONB DEFAULT '[]',
+
+ -- Progress
+ progress_percentage DOUBLE PRECISION DEFAULT 0.0,
+ started_at TIMESTAMP WITH TIME ZONE,
+ target_completion TIMESTAMP WITH TIME ZONE,
+ completed_at TIMESTAMP WITH TIME ZONE,
+
+ -- Status
+ is_active BOOLEAN DEFAULT TRUE,
+ is_ai_generated BOOLEAN DEFAULT TRUE
+);
+
+CREATE INDEX ix_learning_paths_id ON learning_paths (id);
+CREATE INDEX ix_learning_paths_created_at ON learning_paths (created_at);
+CREATE INDEX ix_learning_paths_user_id ON learning_paths (user_id);
+CREATE INDEX ix_learning_paths_org_id ON learning_paths (org_id);
+
+-- ============================================================================
+-- TABLE: predictions (depends on: organizations, tasks, users)
+-- ============================================================================
+
+CREATE TABLE predictions (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ prediction_type prediction_type NOT NULL,
+
+ -- Target
+ task_id UUID REFERENCES tasks(id),
+ project_id UUID,
+ user_id UUID REFERENCES users(id),
+ team_id UUID,
+
+ -- Prediction values
+ predicted_date_p25 TIMESTAMP WITH TIME ZONE,
+ predicted_date_p50 TIMESTAMP WITH TIME ZONE,
+ predicted_date_p90 TIMESTAMP WITH TIME ZONE,
+ confidence DOUBLE PRECISION,
+ risk_score DOUBLE PRECISION,
+
+ -- Factors
+ risk_factors JSONB DEFAULT '[]',
+ model_version VARCHAR(50) DEFAULT 'v1',
+ features JSONB DEFAULT '{}',
+
+ -- Accuracy tracking
+ actual_date TIMESTAMP WITH TIME ZONE,
+ accuracy_score DOUBLE PRECISION
+);
+
+CREATE INDEX ix_predictions_id ON predictions (id);
+CREATE INDEX ix_predictions_created_at ON predictions (created_at);
+CREATE INDEX ix_predictions_org_id ON predictions (org_id);
+
+-- ============================================================================
+-- TABLE: velocity_snapshots (depends on: organizations)
+-- ============================================================================
+
+CREATE TABLE velocity_snapshots (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ team_id UUID NOT NULL,
+
+ period_start TIMESTAMP WITH TIME ZONE NOT NULL,
+ period_end TIMESTAMP WITH TIME ZONE NOT NULL,
+
+ tasks_completed INTEGER DEFAULT 0,
+ story_points_completed DOUBLE PRECISION DEFAULT 0,
+ velocity DOUBLE PRECISION,
+ capacity_utilization DOUBLE PRECISION
+);
+
+CREATE INDEX ix_velocity_snapshots_id ON velocity_snapshots (id);
+CREATE INDEX ix_velocity_snapshots_created_at ON velocity_snapshots (created_at);
+CREATE INDEX ix_velocity_snapshots_team_id ON velocity_snapshots (team_id);
+
+-- ============================================================================
+-- TABLE: automation_patterns (depends on: organizations, users)
+-- ============================================================================
+
+CREATE TABLE automation_patterns (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ -- Pattern details
+ name VARCHAR(500) NOT NULL,
+ description TEXT,
+ pattern_type VARCHAR(100),
+ status pattern_status DEFAULT 'detected',
+
+ -- Detection info
+ frequency_per_week DOUBLE PRECISION,
+ consistency_score DOUBLE PRECISION,
+ users_affected INTEGER DEFAULT 0,
+
+ -- Savings estimate
+ estimated_hours_saved_weekly DOUBLE PRECISION,
+ estimated_cost_savings_monthly DOUBLE PRECISION,
+ implementation_complexity INTEGER DEFAULT 5,
+
+ -- Automation details
+ automation_recipe JSONB DEFAULT '{}',
+ triggers JSONB DEFAULT '[]',
+ actions JSONB DEFAULT '[]',
+
+ -- User feedback
+ accepted_by UUID REFERENCES users(id),
+ accepted_at TIMESTAMP WITH TIME ZONE,
+ rejection_reason TEXT
+);
+
+CREATE INDEX ix_automation_patterns_id ON automation_patterns (id);
+CREATE INDEX ix_automation_patterns_created_at ON automation_patterns (created_at);
+CREATE INDEX ix_automation_patterns_org_id ON automation_patterns (org_id);
+
+-- ============================================================================
+-- TABLE: ai_agents (depends on: organizations, automation_patterns, users)
+-- ============================================================================
+
+CREATE TABLE ai_agents (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ pattern_id UUID REFERENCES automation_patterns(id),
+
+ -- Agent info
+ name VARCHAR(200) NOT NULL,
+ description TEXT,
+ status agent_status DEFAULT 'created',
+
+ -- Configuration
+ config JSONB DEFAULT '{}',
+ permissions JSONB DEFAULT '[]',
+
+ -- Shadow mode tracking
+ shadow_started_at TIMESTAMP WITH TIME ZONE,
+ shadow_match_rate DOUBLE PRECISION,
+ shadow_runs INTEGER DEFAULT 0,
+
+ -- Performance
+ total_runs INTEGER DEFAULT 0,
+ successful_runs INTEGER DEFAULT 0,
+ hours_saved_total DOUBLE PRECISION DEFAULT 0,
+ last_run_at TIMESTAMP WITH TIME ZONE,
+ live_started_at TIMESTAMP WITH TIME ZONE,
+
+ -- Ownership
+ created_by UUID NOT NULL REFERENCES users(id),
+ approved_by UUID REFERENCES users(id),
+ approved_at TIMESTAMP WITH TIME ZONE
+);
+
+CREATE INDEX ix_ai_agents_id ON ai_agents (id);
+CREATE INDEX ix_ai_agents_created_at ON ai_agents (created_at);
+CREATE INDEX ix_ai_agents_org_id ON ai_agents (org_id);
+
+-- ============================================================================
+-- TABLE: agent_runs (depends on: ai_agents, organizations)
+-- ============================================================================
+
+CREATE TABLE agent_runs (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ agent_id UUID NOT NULL REFERENCES ai_agents(id) ON DELETE CASCADE,
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ -- Execution
+ started_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
+ completed_at TIMESTAMP WITH TIME ZONE,
+ status VARCHAR(50) DEFAULT 'running',
+ execution_time_ms INTEGER,
+
+ -- Results
+ input_data JSONB DEFAULT '{}',
+ output_data JSONB DEFAULT '{}',
+ error_message TEXT,
+
+ -- Shadow mode comparison
+ is_shadow BOOLEAN DEFAULT FALSE,
+ human_action JSONB,
+ matched_human BOOLEAN
+);
+
+CREATE INDEX ix_agent_runs_id ON agent_runs (id);
+CREATE INDEX ix_agent_runs_created_at ON agent_runs (created_at);
+CREATE INDEX ix_agent_runs_agent_id ON agent_runs (agent_id);
+
+-- ============================================================================
+-- TABLE: workforce_scores (depends on: users, organizations)
+-- ============================================================================
+
+CREATE TABLE workforce_scores (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ snapshot_date TIMESTAMP WITH TIME ZONE DEFAULT now(),
+
+ -- Component scores (0-100)
+ velocity_score DOUBLE PRECISION,
+ quality_score DOUBLE PRECISION,
+ self_sufficiency_score DOUBLE PRECISION,
+ learning_score DOUBLE PRECISION,
+ collaboration_score DOUBLE PRECISION,
+
+ -- Composite
+ overall_score DOUBLE PRECISION,
+ percentile_rank DOUBLE PRECISION,
+
+ -- Risk indicators
+ attrition_risk_score DOUBLE PRECISION,
+ burnout_risk_score DOUBLE PRECISION,
+
+ -- Trend
+ score_trend VARCHAR(20) DEFAULT 'stable'
+);
+
+CREATE INDEX ix_workforce_scores_id ON workforce_scores (id);
+CREATE INDEX ix_workforce_scores_created_at ON workforce_scores (created_at);
+CREATE INDEX ix_workforce_scores_user_id ON workforce_scores (user_id);
+CREATE INDEX ix_workforce_scores_org_id ON workforce_scores (org_id);
+
+-- ============================================================================
+-- TABLE: manager_effectiveness (depends on: users, organizations)
+-- ============================================================================
+
+CREATE TABLE manager_effectiveness (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ manager_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ snapshot_date TIMESTAMP WITH TIME ZONE DEFAULT now(),
+
+ -- Team metrics
+ team_size INTEGER DEFAULT 0,
+ team_velocity_avg DOUBLE PRECISION,
+ team_quality_avg DOUBLE PRECISION,
+
+ -- Manager-specific
+ escalation_response_time_hours DOUBLE PRECISION,
+ escalation_resolution_rate DOUBLE PRECISION,
+ team_attrition_rate DOUBLE PRECISION,
+ team_satisfaction_score DOUBLE PRECISION,
+
+ -- AI comparison
+ redundancy_score DOUBLE PRECISION,
+ assignment_quality_score DOUBLE PRECISION,
+ workload_distribution_score DOUBLE PRECISION,
+
+ -- Ranking
+ effectiveness_score DOUBLE PRECISION,
+ org_percentile DOUBLE PRECISION
+);
+
+CREATE INDEX ix_manager_effectiveness_id ON manager_effectiveness (id);
+CREATE INDEX ix_manager_effectiveness_created_at ON manager_effectiveness (created_at);
+CREATE INDEX ix_manager_effectiveness_manager_id ON manager_effectiveness (manager_id);
+CREATE INDEX ix_manager_effectiveness_org_id ON manager_effectiveness (org_id);
+
+-- ============================================================================
+-- TABLE: org_health_snapshots (depends on: organizations)
+-- ============================================================================
+
+CREATE TABLE org_health_snapshots (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ snapshot_date TIMESTAMP WITH TIME ZONE DEFAULT now(),
+
+ -- Health components (0-100)
+ productivity_index DOUBLE PRECISION,
+ skill_coverage_index DOUBLE PRECISION,
+ management_quality_index DOUBLE PRECISION,
+ automation_maturity_index DOUBLE PRECISION,
+ delivery_predictability_index DOUBLE PRECISION,
+
+ -- Composite
+ overall_health_score DOUBLE PRECISION,
+
+ -- Key metrics
+ total_employees INTEGER DEFAULT 0,
+ active_tasks INTEGER DEFAULT 0,
+ blocked_tasks INTEGER DEFAULT 0,
+ overdue_tasks INTEGER DEFAULT 0,
+
+ -- Risk counts
+ high_attrition_risk_count INTEGER DEFAULT 0,
+ high_burnout_risk_count INTEGER DEFAULT 0
+);
+
+CREATE INDEX ix_org_health_snapshots_id ON org_health_snapshots (id);
+CREATE INDEX ix_org_health_snapshots_created_at ON org_health_snapshots (created_at);
+CREATE INDEX ix_org_health_snapshots_org_id ON org_health_snapshots (org_id);
+
+-- ============================================================================
+-- TABLE: restructuring_scenarios (depends on: organizations, users)
+-- ============================================================================
+
+CREATE TABLE restructuring_scenarios (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ created_by UUID NOT NULL REFERENCES users(id),
+
+ name VARCHAR(500) NOT NULL,
+ description TEXT,
+
+ -- Scenario config
+ scenario_type VARCHAR(100) NOT NULL,
+ config JSONB DEFAULT '{}',
+
+ -- Impact projections
+ projected_cost_change DOUBLE PRECISION,
+ projected_productivity_change DOUBLE PRECISION,
+ projected_skill_coverage_change DOUBLE PRECISION,
+ affected_employees INTEGER DEFAULT 0,
+
+ -- Risk assessment
+ risk_factors JSONB DEFAULT '[]',
+ overall_risk_score DOUBLE PRECISION,
+
+ -- Status
+ is_draft BOOLEAN DEFAULT TRUE,
+ executed BOOLEAN DEFAULT FALSE,
+ executed_at TIMESTAMP WITH TIME ZONE
+);
+
+CREATE INDEX ix_restructuring_scenarios_id ON restructuring_scenarios (id);
+CREATE INDEX ix_restructuring_scenarios_created_at ON restructuring_scenarios (created_at);
+CREATE INDEX ix_restructuring_scenarios_org_id ON restructuring_scenarios (org_id);
+
+-- ============================================================================
+-- TABLE: notifications (depends on: users, organizations, tasks, checkins)
+-- ============================================================================
+
+CREATE TABLE notifications (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ notification_type notification_type NOT NULL,
+ title VARCHAR(500) NOT NULL,
+ message TEXT,
+
+ -- Related entities
+ task_id UUID REFERENCES tasks(id),
+ checkin_id UUID REFERENCES checkins(id),
+
+ -- Status
+ is_read BOOLEAN DEFAULT FALSE,
+ read_at TIMESTAMP WITH TIME ZONE,
+
+ -- Delivery
+ channel notification_channel DEFAULT 'in_app',
+ delivered BOOLEAN DEFAULT FALSE,
+ delivered_at TIMESTAMP WITH TIME ZONE,
+
+ -- Action
+ action_url VARCHAR(1000),
+ action_data JSONB DEFAULT '{}'
+);
+
+CREATE INDEX ix_notifications_id ON notifications (id);
+CREATE INDEX ix_notifications_created_at ON notifications (created_at);
+CREATE INDEX ix_notifications_user_id ON notifications (user_id);
+CREATE INDEX ix_notifications_org_id ON notifications (org_id);
+
+-- ============================================================================
+-- TABLE: notification_preferences (depends on: users, organizations)
+-- ============================================================================
+
+CREATE TABLE notification_preferences (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ notification_type notification_type NOT NULL,
+ channel notification_channel NOT NULL,
+
+ enabled BOOLEAN DEFAULT TRUE,
+ quiet_hours_start INTEGER,
+ quiet_hours_end INTEGER,
+ batch_frequency_minutes INTEGER DEFAULT 0
+);
+
+CREATE INDEX ix_notification_preferences_id ON notification_preferences (id);
+CREATE INDEX ix_notification_preferences_created_at ON notification_preferences (created_at);
+CREATE INDEX ix_notification_preferences_user_id ON notification_preferences (user_id);
+
+-- ============================================================================
+-- TABLE: integrations (depends on: organizations, users)
+-- ============================================================================
+
+CREATE TABLE integrations (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ integration_type integration_type NOT NULL,
+ name VARCHAR(200) NOT NULL,
+ description TEXT,
+
+ -- Connection
+ is_active BOOLEAN DEFAULT FALSE,
+ config JSONB DEFAULT '{}',
+ credentials JSONB DEFAULT '{}',
+
+ -- Sync
+ sync_enabled BOOLEAN DEFAULT TRUE,
+ last_sync_at TIMESTAMP WITH TIME ZONE,
+ last_sync_status VARCHAR(50),
+ sync_error TEXT,
+
+ -- OAuth
+ oauth_access_token TEXT,
+ oauth_refresh_token TEXT,
+ oauth_expires_at TIMESTAMP WITH TIME ZONE,
+
+ connected_by UUID REFERENCES users(id),
+ connected_at TIMESTAMP WITH TIME ZONE
+);
+
+CREATE INDEX ix_integrations_id ON integrations (id);
+CREATE INDEX ix_integrations_created_at ON integrations (created_at);
+CREATE INDEX ix_integrations_org_id ON integrations (org_id);
+
+-- ============================================================================
+-- TABLE: webhooks (depends on: organizations, users)
+-- ============================================================================
+
+CREATE TABLE webhooks (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ name VARCHAR(200) NOT NULL,
+ url VARCHAR(2000) NOT NULL,
+ secret VARCHAR(255),
+
+ -- Events
+ events JSONB DEFAULT '[]',
+
+ -- Status
+ is_active BOOLEAN DEFAULT TRUE,
+
+ -- Headers
+ headers JSONB DEFAULT '{}',
+
+ -- Stats
+ total_deliveries INTEGER DEFAULT 0,
+ successful_deliveries INTEGER DEFAULT 0,
+ last_delivery_at TIMESTAMP WITH TIME ZONE,
+ last_delivery_status INTEGER,
+
+ created_by UUID NOT NULL REFERENCES users(id)
+);
+
+CREATE INDEX ix_webhooks_id ON webhooks (id);
+CREATE INDEX ix_webhooks_created_at ON webhooks (created_at);
+CREATE INDEX ix_webhooks_org_id ON webhooks (org_id);
+
+-- ============================================================================
+-- TABLE: webhook_deliveries (depends on: webhooks, organizations)
+-- ============================================================================
+
+CREATE TABLE webhook_deliveries (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ webhook_id UUID NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE,
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ event_type VARCHAR(100) NOT NULL,
+ payload JSONB DEFAULT '{}',
+
+ -- Delivery
+ attempted_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
+ response_status INTEGER,
+ response_body TEXT,
+ response_time_ms INTEGER,
+
+ -- Retry
+ retry_count INTEGER DEFAULT 0,
+ next_retry_at TIMESTAMP WITH TIME ZONE,
+ is_successful BOOLEAN DEFAULT FALSE
+);
+
+CREATE INDEX ix_webhook_deliveries_id ON webhook_deliveries (id);
+CREATE INDEX ix_webhook_deliveries_created_at ON webhook_deliveries (created_at);
+CREATE INDEX ix_webhook_deliveries_webhook_id ON webhook_deliveries (webhook_id);
+
+-- ============================================================================
+-- TABLE: audit_logs (depends on: organizations)
+-- ============================================================================
+
+CREATE TABLE audit_logs (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ -- Actor
+ actor_type actor_type NOT NULL,
+ actor_id UUID,
+ actor_name VARCHAR(200),
+
+ -- Action
+ action audit_action NOT NULL,
+ resource_type VARCHAR(100) NOT NULL,
+ resource_id UUID,
+
+ -- Details
+ description TEXT,
+ old_value JSONB,
+ new_value JSONB,
+ audit_metadata JSONB DEFAULT '{}',
+
+ -- Context
+ ip_address VARCHAR(45),
+ user_agent VARCHAR(500),
+ request_id VARCHAR(36),
+
+ -- Timestamp (immutable record)
+ timestamp TIMESTAMP WITH TIME ZONE DEFAULT now()
+);
+
+CREATE INDEX ix_audit_logs_id ON audit_logs (id);
+CREATE INDEX ix_audit_logs_created_at ON audit_logs (created_at);
+CREATE INDEX ix_audit_logs_org_id ON audit_logs (org_id);
+CREATE INDEX ix_audit_logs_timestamp ON audit_logs (timestamp);
+
+-- ============================================================================
+-- TABLE: gdpr_requests (depends on: organizations, users)
+-- ============================================================================
+
+CREATE TABLE gdpr_requests (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ user_id UUID NOT NULL REFERENCES users(id),
+
+ request_type VARCHAR(50) NOT NULL,
+ status VARCHAR(50) DEFAULT 'pending',
+
+ -- Processing
+ requested_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
+ processed_at TIMESTAMP WITH TIME ZONE,
+ completed_at TIMESTAMP WITH TIME ZONE,
+ processed_by UUID REFERENCES users(id),
+
+ -- Result
+ result_url VARCHAR(2000),
+ result_expiry TIMESTAMP WITH TIME ZONE,
+ error_message TEXT,
+
+ -- Verification
+ verification_token VARCHAR(255),
+ verified_at TIMESTAMP WITH TIME ZONE
+);
+
+CREATE INDEX ix_gdpr_requests_id ON gdpr_requests (id);
+CREATE INDEX ix_gdpr_requests_created_at ON gdpr_requests (created_at);
+CREATE INDEX ix_gdpr_requests_org_id ON gdpr_requests (org_id);
+CREATE INDEX ix_gdpr_requests_user_id ON gdpr_requests (user_id);
+
+-- ============================================================================
+-- TABLE: api_keys (depends on: organizations, users)
+-- ============================================================================
+
+CREATE TABLE api_keys (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ user_id UUID NOT NULL REFERENCES users(id),
+
+ name VARCHAR(200) NOT NULL,
+ key_hash VARCHAR(255) NOT NULL,
+ key_prefix VARCHAR(10) NOT NULL,
+
+ -- Permissions
+ scopes JSONB DEFAULT '[]',
+ is_full_access BOOLEAN DEFAULT FALSE,
+
+ -- Status
+ is_active BOOLEAN DEFAULT TRUE,
+ expires_at TIMESTAMP WITH TIME ZONE,
+
+ -- Usage
+ last_used_at TIMESTAMP WITH TIME ZONE,
+ last_used_ip VARCHAR(45),
+ usage_count INTEGER DEFAULT 0,
+
+ -- Limits
+ rate_limit INTEGER DEFAULT 1000,
+ current_usage INTEGER DEFAULT 0,
+ usage_reset_at TIMESTAMP WITH TIME ZONE,
+
+ created_by UUID NOT NULL REFERENCES users(id)
+);
+
+CREATE INDEX ix_api_keys_id ON api_keys (id);
+CREATE INDEX ix_api_keys_created_at ON api_keys (created_at);
+CREATE INDEX ix_api_keys_org_id ON api_keys (org_id);
+
+-- ============================================================================
+-- TABLE: system_health (no foreign key dependencies)
+-- ============================================================================
+
+CREATE TABLE system_health (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ snapshot_time TIMESTAMP WITH TIME ZONE DEFAULT now(),
+
+ -- Database
+ db_connections_active INTEGER,
+ db_query_avg_ms DOUBLE PRECISION,
+
+ -- API
+ api_requests_per_minute INTEGER,
+ api_error_rate DOUBLE PRECISION,
+ api_latency_p50_ms DOUBLE PRECISION,
+ api_latency_p99_ms DOUBLE PRECISION,
+
+ -- AI
+ ai_requests_per_hour INTEGER,
+ ai_avg_latency_ms DOUBLE PRECISION,
+ ai_cache_hit_rate DOUBLE PRECISION,
+
+ -- Background jobs
+ jobs_pending INTEGER,
+ jobs_failed INTEGER,
+
+ -- Storage
+ storage_used_mb DOUBLE PRECISION,
+
+ -- Alerts
+ active_alerts JSONB DEFAULT '[]'
+);
+
+CREATE INDEX ix_system_health_id ON system_health (id);
+CREATE INDEX ix_system_health_created_at ON system_health (created_at);
+CREATE INDEX ix_system_health_snapshot_time ON system_health (snapshot_time);
+
+-- ============================================================================
+-- TABLE: agents (Phase 15 - Agent Orchestration) (depends on: organizations)
+-- ============================================================================
+
+CREATE TABLE agents (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id),
+
+ -- Identity
+ name VARCHAR(100) NOT NULL UNIQUE,
+ display_name VARCHAR(200) NOT NULL,
+ description TEXT,
+ version VARCHAR(20) DEFAULT '1.0.0',
+
+ -- Classification
+ agent_type agent_type_enum NOT NULL DEFAULT 'ai',
+ capabilities JSONB DEFAULT '[]',
+
+ -- Status
+ status agent_status_db DEFAULT 'active',
+ is_enabled BOOLEAN DEFAULT TRUE,
+
+ -- Configuration
+ config JSONB DEFAULT '{}',
+ permissions JSONB DEFAULT '[]',
+
+ -- Metrics
+ execution_count INTEGER DEFAULT 0,
+ success_count INTEGER DEFAULT 0,
+ error_count INTEGER DEFAULT 0,
+ avg_duration_ms DOUBLE PRECISION,
+ last_execution_at TIMESTAMP WITH TIME ZONE
+);
+
+CREATE INDEX ix_agents_id ON agents (id);
+CREATE INDEX ix_agents_created_at ON agents (created_at);
+CREATE INDEX ix_agents_org_id ON agents (org_id);
+
+-- ============================================================================
+-- TABLE: agent_executions (depends on: agents, organizations, users, tasks)
+-- ============================================================================
+
+CREATE TABLE agent_executions (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ agent_id UUID NOT NULL REFERENCES agents(id),
+ org_id UUID NOT NULL REFERENCES organizations(id),
+
+ -- Trigger information
+ event_type VARCHAR(100) NOT NULL,
+ event_id UUID,
+ trigger_source VARCHAR(100),
+
+ -- Context
+ user_id UUID REFERENCES users(id),
+ task_id UUID REFERENCES tasks(id),
+ context_data JSONB DEFAULT '{}',
+
+ -- Execution details
+ status execution_status DEFAULT 'pending',
+ started_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
+ completed_at TIMESTAMP WITH TIME ZONE,
+ duration_ms INTEGER,
+
+ -- Results
+ success BOOLEAN DEFAULT FALSE,
+ output_data JSONB DEFAULT '{}',
+ error_message TEXT,
+ error_code VARCHAR(50),
+
+ -- Metrics
+ tokens_used INTEGER DEFAULT 0,
+ api_calls INTEGER DEFAULT 0,
+
+ -- Chain information
+ parent_execution_id UUID REFERENCES agent_executions(id),
+ chain_depth INTEGER DEFAULT 0
+);
+
+CREATE INDEX ix_agent_executions_id ON agent_executions (id);
+CREATE INDEX ix_agent_executions_created_at ON agent_executions (created_at);
+CREATE INDEX ix_agent_executions_agent_id ON agent_executions (agent_id);
+CREATE INDEX ix_agent_executions_org_id ON agent_executions (org_id);
+
+-- ============================================================================
+-- TABLE: agent_conversations (depends on: organizations, users)
+-- ============================================================================
+
+CREATE TABLE agent_conversations (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id),
+ user_id UUID NOT NULL REFERENCES users(id),
+
+ -- Conversation metadata
+ title VARCHAR(200),
+ agent_name VARCHAR(100) NOT NULL DEFAULT 'chat_agent',
+
+ -- State
+ is_active BOOLEAN DEFAULT TRUE,
+ message_count INTEGER DEFAULT 0,
+
+ -- Conversation data
+ messages JSONB DEFAULT '[]',
+ context_data JSONB DEFAULT '{}',
+
+ -- Timestamps
+ started_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
+ last_message_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
+ ended_at TIMESTAMP WITH TIME ZONE
+);
+
+CREATE INDEX ix_agent_conversations_id ON agent_conversations (id);
+CREATE INDEX ix_agent_conversations_created_at ON agent_conversations (created_at);
+CREATE INDEX ix_agent_conversations_org_id ON agent_conversations (org_id);
+CREATE INDEX ix_agent_conversations_user_id ON agent_conversations (user_id);
+
+-- ============================================================================
+-- TABLE: agent_schedules (depends on: agents, organizations)
+-- ============================================================================
+
+CREATE TABLE agent_schedules (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ agent_id UUID NOT NULL REFERENCES agents(id),
+ org_id UUID NOT NULL REFERENCES organizations(id),
+
+ -- Schedule definition
+ name VARCHAR(100) NOT NULL,
+ cron_expression VARCHAR(100) NOT NULL,
+ timezone VARCHAR(50) DEFAULT 'UTC',
+
+ -- Configuration
+ is_enabled BOOLEAN DEFAULT TRUE,
+ config JSONB DEFAULT '{}',
+
+ -- Tracking
+ last_run_at TIMESTAMP WITH TIME ZONE,
+ next_run_at TIMESTAMP WITH TIME ZONE,
+ run_count INTEGER DEFAULT 0,
+ failure_count INTEGER DEFAULT 0
+);
+
+CREATE INDEX ix_agent_schedules_id ON agent_schedules (id);
+CREATE INDEX ix_agent_schedules_created_at ON agent_schedules (created_at);
+CREATE INDEX ix_agent_schedules_agent_id ON agent_schedules (agent_id);
+CREATE INDEX ix_agent_schedules_org_id ON agent_schedules (org_id);
+
+-- ============================================================================
+-- END OF SCHEMA
+-- ============================================================================
diff --git a/backend/scripts/migrate_to_supabase.py b/backend/scripts/migrate_to_supabase.py
new file mode 100644
index 0000000..753825b
--- /dev/null
+++ b/backend/scripts/migrate_to_supabase.py
@@ -0,0 +1,707 @@
+#!/usr/bin/env python3
+"""
+TaskPulse AI - SQLite to Supabase (PostgreSQL + Auth) Migration Script
+
+Migrates all data from the existing SQLite database to Supabase PostgreSQL,
+creates Supabase Auth users, and preserves all relationships.
+
+Usage:
+ python -m scripts.migrate_to_supabase \
+ --sqlite-url sqlite+aiosqlite:///./taskpulse.db \
+ --pg-url postgresql+asyncpg://postgres:pass@db.xxx.supabase.co:5432/postgres \
+ --supabase-url https://xxx.supabase.co \
+ --service-role-key eyJ... \
+ [--dry-run] [--skip-auth] [--batch-size 500]
+
+Tables are inserted in topological order to satisfy FK constraints.
+Self-referential FKs use a two-pass strategy (insert with NULL, then update).
+"""
+
+import argparse
+import asyncio
+import json
+import logging
+import sys
+import uuid
+from datetime import datetime
+from typing import Any, Optional
+
+import aiosqlite
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s [%(levelname)s] %(message)s",
+ handlers=[logging.StreamHandler(sys.stdout)],
+)
+logger = logging.getLogger("migrate")
+
+# ---------------------------------------------------------------------------
+# Topological table ordering (FK-dependency sorted)
+# ---------------------------------------------------------------------------
+
+# Phase 1: No FK dependencies (root tables)
+PHASE_1_TABLES = [
+ "organizations",
+ "system_health",
+]
+
+# Phase 2: Depends only on organizations
+PHASE_2_TABLES = [
+ "users", # FK: organizations; self-ref: manager_id (deferred)
+ "skills", # FK: organizations
+]
+
+# Phase 3: Depends on users + organizations
+PHASE_3_TABLES = [
+ # "sessions" removed — Supabase Auth manages sessions now; table no longer exists
+ "tasks", # FK: organizations, users x2; self-ref: parent_task_id (deferred)
+ "agents", # FK: organizations
+ "automation_patterns", # FK: organizations, users
+ "integrations", # FK: organizations, users
+ "webhooks", # FK: organizations, users
+ "notification_preferences",# FK: users, organizations
+ "workforce_scores", # FK: users, organizations
+ "manager_effectiveness", # FK: users, organizations
+ "org_health_snapshots", # FK: organizations
+ "restructuring_scenarios", # FK: organizations, users
+ "user_skills", # FK: users, skills, organizations
+ "skill_gaps", # FK: users, skills, organizations
+ "skill_metrics", # FK: users, organizations
+ "learning_paths", # FK: users, organizations
+ "audit_logs", # FK: organizations
+ "gdpr_requests", # FK: organizations, users x2
+ "api_keys", # FK: organizations, users x2
+ "velocity_snapshots", # FK: organizations
+]
+
+# Phase 4: Depends on tasks, agents, etc.
+PHASE_4_TABLES = [
+ "task_dependencies", # FK: tasks x2
+ "task_history", # FK: tasks, users
+ "task_comments", # FK: tasks, users
+ "checkins", # FK: tasks, users, organizations
+ "checkin_configs", # FK: organizations, users, tasks
+ "documents", # FK: organizations
+ "ai_agents", # FK: organizations, automation_patterns, users x2
+ "predictions", # FK: organizations, tasks, users
+ "notifications", # FK: users, organizations, tasks, checkins
+ "webhook_deliveries", # FK: webhooks, organizations
+ "agent_executions", # FK: agents, organizations, users, tasks; self-ref: parent_execution_id
+ "agent_conversations", # FK: organizations, users
+ "agent_schedules", # FK: agents, organizations
+]
+
+# Phase 5: Depends on documents, checkins
+PHASE_5_TABLES = [
+ "document_chunks", # FK: documents
+ "unblock_sessions", # FK: organizations, users, tasks, checkins
+ "checkin_reminders", # FK: checkins, users
+ "agent_runs", # FK: ai_agents, organizations
+]
+
+ALL_PHASES = [
+ ("Phase 1: Root tables", PHASE_1_TABLES),
+ ("Phase 2: Orgs → Users/Skills", PHASE_2_TABLES),
+ ("Phase 3: Users → dependent tables", PHASE_3_TABLES),
+ ("Phase 4: Tasks/Agents → dependent tables", PHASE_4_TABLES),
+ ("Phase 5: Deep dependencies", PHASE_5_TABLES),
+]
+
+# Self-referential FK columns that must be set to NULL on first pass
+# then updated in a second pass
+SELF_REF_COLUMNS = {
+ "users": "manager_id",
+ "tasks": "parent_task_id",
+ "agent_executions": "parent_execution_id",
+}
+
+# Columns that store JSON as TEXT in SQLite and need to be parsed to dicts/lists
+# for JSONB insertion into PostgreSQL. Maps table -> list of column names.
+JSON_TEXT_COLUMNS: dict[str, list[str]] = {
+ "users": ["consent_data"],
+ "organizations": ["settings_data"],
+ "tasks": ["tools", "tags", "skills_required"],
+ "task_history": ["details"],
+ "documents": ["team_ids", "tags", "categories"],
+ "document_chunks": ["chunk_metadata"],
+ "unblock_sessions": ["sources"],
+ "automation_patterns": ["automation_recipe", "triggers", "actions"],
+ "ai_agents": ["config", "permissions"],
+ "agent_runs": ["input_data", "output_data", "human_action"],
+ "notifications": ["action_data"],
+ "integrations": ["config", "credentials"],
+ "webhooks": ["events", "headers"],
+ "webhook_deliveries": ["payload"],
+ "predictions": ["risk_factors", "features"],
+ "audit_logs": ["old_value", "new_value", "audit_metadata"],
+ "api_keys": ["scopes"],
+ "system_health": ["active_alerts"],
+ "restructuring_scenarios": ["config", "risk_factors"],
+ "skills": ["aliases", "related_skills", "prerequisites"],
+ "user_skills": ["level_history"],
+ "skill_gaps": ["learning_resources"],
+ "learning_paths": ["skills_data", "milestones"],
+ "agents": ["capabilities", "config", "permissions"],
+ "agent_executions": ["context_data", "output_data"],
+ "agent_conversations": ["messages", "context_data"],
+ "agent_schedules": ["config"],
+}
+
+# Columns that were stored as TEXT-encoded floats list in SQLite (embeddings)
+EMBEDDING_COLUMNS: dict[str, str] = {
+ "document_chunks": "embedding",
+}
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def parse_json_value(value: Any) -> Any:
+ """Parse a JSON string value into a Python object."""
+ if value is None:
+ return None
+ if isinstance(value, (dict, list)):
+ return value # Already parsed
+ if isinstance(value, str):
+ try:
+ return json.loads(value)
+ except (json.JSONDecodeError, ValueError):
+ return value # Return as-is if not valid JSON
+ return value
+
+
+def parse_embedding(value: Any) -> Optional[list[float]]:
+ """Parse an embedding stored as JSON text into a list of floats."""
+ if value is None:
+ return None
+ if isinstance(value, list):
+ return [float(v) for v in value]
+ if isinstance(value, str):
+ try:
+ parsed = json.loads(value)
+ if isinstance(parsed, list):
+ return [float(v) for v in parsed]
+ except (json.JSONDecodeError, ValueError):
+ pass
+ return None
+
+
+def coerce_uuid(value: Any) -> Optional[str]:
+ """Ensure a value is a valid UUID string or None."""
+ if value is None:
+ return None
+ try:
+ return str(uuid.UUID(str(value)))
+ except (ValueError, AttributeError):
+ return str(value)
+
+
+def transform_row(
+ table_name: str,
+ row: dict[str, Any],
+ self_ref_col: Optional[str] = None,
+) -> dict[str, Any]:
+ """
+ Transform a SQLite row dict for PostgreSQL insertion.
+
+ - Parses JSON TEXT columns to Python objects (for JSONB).
+ - Parses embedding columns to float lists (for pgvector).
+ - Nullifies self-referential FK columns for first-pass insert.
+ - Coerces UUID columns.
+ """
+ result = dict(row)
+
+ # Parse JSON text columns
+ json_cols = JSON_TEXT_COLUMNS.get(table_name, [])
+ for col in json_cols:
+ if col in result:
+ result[col] = parse_json_value(result[col])
+
+ # Parse embedding columns
+ emb_col = EMBEDDING_COLUMNS.get(table_name)
+ if emb_col and emb_col in result:
+ result[emb_col] = parse_embedding(result[emb_col])
+
+ # Nullify self-referential FK for first pass
+ if self_ref_col and self_ref_col in result:
+ # Store original value for second pass
+ result[f"_orig_{self_ref_col}"] = result[self_ref_col]
+ result[self_ref_col] = None
+
+ return result
+
+
+# ---------------------------------------------------------------------------
+# SQLite reader
+# ---------------------------------------------------------------------------
+
+async def read_sqlite_table(
+ sqlite_path: str,
+ table_name: str,
+) -> list[dict[str, Any]]:
+ """Read all rows from a SQLite table as dicts."""
+ async with aiosqlite.connect(sqlite_path) as db:
+ db.row_factory = aiosqlite.Row
+ try:
+ cursor = await db.execute(f"SELECT * FROM {table_name}") # noqa: S608
+ rows = await cursor.fetchall()
+ return [dict(row) for row in rows]
+ except Exception as e:
+ logger.warning("Could not read table '%s': %s", table_name, e)
+ return []
+
+
+async def get_sqlite_tables(sqlite_path: str) -> list[str]:
+ """Get list of all tables in the SQLite database."""
+ async with aiosqlite.connect(sqlite_path) as db:
+ cursor = await db.execute(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
+ )
+ rows = await cursor.fetchall()
+ return [row[0] for row in rows]
+
+
+# ---------------------------------------------------------------------------
+# PostgreSQL writer (via asyncpg)
+# ---------------------------------------------------------------------------
+
+async def insert_rows_pg(
+ pg_pool,
+ table_name: str,
+ rows: list[dict[str, Any]],
+ batch_size: int = 500,
+) -> int:
+ """
+ Insert rows into a PostgreSQL table using asyncpg.
+
+ Returns the number of rows inserted.
+ """
+ if not rows:
+ return 0
+
+ # Get column names from first row (excluding our temp _orig_ columns)
+ columns = [c for c in rows[0].keys() if not c.startswith("_orig_")]
+
+ inserted = 0
+ for i in range(0, len(rows), batch_size):
+ batch = rows[i : i + batch_size]
+
+ # Build INSERT statement with ON CONFLICT DO NOTHING
+ placeholders = ", ".join(
+ f"${j + 1}" for j in range(len(columns))
+ )
+ col_names = ", ".join(f'"{c}"' for c in columns)
+ sql = f'INSERT INTO "{table_name}" ({col_names}) VALUES ({placeholders}) ON CONFLICT DO NOTHING'
+
+ async with pg_pool.acquire() as conn:
+ for row in batch:
+ values = []
+ for col in columns:
+ val = row.get(col)
+ # Convert Python dicts/lists to JSON strings for asyncpg JSONB
+ if isinstance(val, (dict, list)):
+ val = json.dumps(val)
+ # Convert embedding lists to the pgvector string format
+ if col == EMBEDDING_COLUMNS.get(table_name) and isinstance(val, list):
+ val = "[" + ",".join(str(f) for f in val) + "]"
+ values.append(val)
+ try:
+ await conn.execute(sql, *values)
+ inserted += 1
+ except Exception as e:
+ logger.error(
+ "Failed to insert row into %s (id=%s): %s",
+ table_name,
+ row.get("id", "?"),
+ e,
+ )
+ return inserted
+
+
+async def update_self_refs(
+ pg_pool,
+ table_name: str,
+ self_ref_col: str,
+ rows: list[dict[str, Any]],
+) -> int:
+ """Second-pass: update self-referential FK columns that were nulled on insert."""
+ updated = 0
+ sql = f'UPDATE "{table_name}" SET "{self_ref_col}" = $1 WHERE "id" = $2'
+
+ async with pg_pool.acquire() as conn:
+ for row in rows:
+ orig_val = row.get(f"_orig_{self_ref_col}")
+ if orig_val is not None:
+ row_id = row.get("id")
+ try:
+ await conn.execute(sql, orig_val, row_id)
+ updated += 1
+ except Exception as e:
+ logger.error(
+ "Failed to update %s.%s for id=%s: %s",
+ table_name,
+ self_ref_col,
+ row_id,
+ e,
+ )
+ return updated
+
+
+# ---------------------------------------------------------------------------
+# Supabase Auth user creation
+# ---------------------------------------------------------------------------
+
+async def create_supabase_auth_users(
+ supabase_url: str,
+ service_role_key: str,
+ users: list[dict[str, Any]],
+ pg_pool,
+) -> dict[str, str]:
+ """
+ Create Supabase Auth users for each local user.
+
+ Uses the Supabase Admin API to create users with their existing
+ bcrypt password hashes (Supabase supports importing bcrypt hashes).
+
+ Returns a mapping of local user.id -> supabase auth user.id
+ """
+ import httpx
+
+ id_map: dict[str, str] = {}
+ headers = {
+ "Authorization": f"Bearer {service_role_key}",
+ "apikey": service_role_key,
+ "Content-Type": "application/json",
+ }
+ admin_url = f"{supabase_url}/auth/v1/admin/users"
+
+ async with httpx.AsyncClient(timeout=30) as client:
+ for user in users:
+ email = user.get("email")
+ if not email:
+ continue
+
+ local_id = user.get("id")
+ password_hash = user.get("password_hash")
+
+ # Build request body
+ body: dict[str, Any] = {
+ "email": email,
+ "email_confirm": True, # Auto-confirm since they're migrated users
+ "user_metadata": {
+ "first_name": user.get("first_name", ""),
+ "last_name": user.get("last_name", ""),
+ },
+ "app_metadata": {
+ "provider": "email",
+ "local_user_id": str(local_id),
+ },
+ }
+
+ # Import existing bcrypt hash if available
+ if password_hash and password_hash.startswith("$2"):
+ body["password_hash"] = password_hash
+
+ try:
+ resp = await client.post(admin_url, json=body, headers=headers)
+ if resp.status_code in (200, 201):
+ auth_user = resp.json()
+ supabase_auth_id = auth_user.get("id")
+ id_map[str(local_id)] = supabase_auth_id
+ logger.info(
+ "Created auth user for %s (local=%s, auth=%s)",
+ email,
+ local_id,
+ supabase_auth_id,
+ )
+ elif resp.status_code == 422 and "already been registered" in resp.text:
+ # User already exists in Supabase Auth — fetch their ID
+ logger.info("Auth user %s already exists, fetching ID...", email)
+ list_resp = await client.get(
+ f"{admin_url}?filter={email}",
+ headers=headers,
+ )
+ if list_resp.status_code == 200:
+ auth_users = list_resp.json().get("users", [])
+ for au in auth_users:
+ if au.get("email") == email:
+ id_map[str(local_id)] = au["id"]
+ break
+ else:
+ logger.error(
+ "Failed to create auth user %s: %s %s",
+ email,
+ resp.status_code,
+ resp.text[:200],
+ )
+ except Exception as e:
+ logger.error("Error creating auth user %s: %s", email, e)
+
+ # Update local users table with supabase_auth_id
+ if id_map:
+ sql = 'UPDATE "users" SET "supabase_auth_id" = $1 WHERE "id" = $2'
+ async with pg_pool.acquire() as conn:
+ for local_id, auth_id in id_map.items():
+ try:
+ await conn.execute(sql, auth_id, local_id)
+ except Exception as e:
+ logger.error(
+ "Failed to set supabase_auth_id for user %s: %s",
+ local_id,
+ e,
+ )
+
+ return id_map
+
+
+# ---------------------------------------------------------------------------
+# Main migration
+# ---------------------------------------------------------------------------
+
+async def migrate(
+ sqlite_path: str,
+ pg_dsn: str,
+ supabase_url: str,
+ service_role_key: str,
+ dry_run: bool = False,
+ skip_auth: bool = False,
+ batch_size: int = 500,
+) -> None:
+ """Run the full migration."""
+ import asyncpg
+
+ logger.info("=" * 60)
+ logger.info("TaskPulse AI - SQLite → Supabase Migration")
+ logger.info("=" * 60)
+ logger.info("SQLite: %s", sqlite_path)
+ logger.info("PostgreSQL: %s", pg_dsn.split("@")[-1] if "@" in pg_dsn else "(local)")
+ logger.info("Dry run: %s", dry_run)
+ logger.info("")
+
+ # Verify SQLite tables exist
+ sqlite_tables = await get_sqlite_tables(sqlite_path)
+ logger.info("Found %d tables in SQLite: %s", len(sqlite_tables), ", ".join(sorted(sqlite_tables)))
+
+ # Connect to PostgreSQL
+ # Convert SQLAlchemy-style URL to asyncpg-compatible DSN
+ pg_dsn_clean = pg_dsn.replace("postgresql+asyncpg://", "postgresql://")
+ pg_pool = await asyncpg.create_pool(pg_dsn_clean, min_size=2, max_size=10)
+ logger.info("Connected to PostgreSQL")
+
+ stats = {
+ "tables_migrated": 0,
+ "rows_inserted": 0,
+ "rows_failed": 0,
+ "self_refs_updated": 0,
+ "auth_users_created": 0,
+ }
+
+ # Track rows with self-referential FKs for second pass
+ self_ref_rows: dict[str, list[dict[str, Any]]] = {}
+
+ try:
+ for phase_name, tables in ALL_PHASES:
+ logger.info("")
+ logger.info("─" * 40)
+ logger.info(phase_name)
+ logger.info("─" * 40)
+
+ for table_name in tables:
+ if table_name not in sqlite_tables:
+ logger.info(" ⏭ %s — not found in SQLite, skipping", table_name)
+ continue
+
+ # Read from SQLite
+ rows = await read_sqlite_table(sqlite_path, table_name)
+ if not rows:
+ logger.info(" ⏭ %s — empty, skipping", table_name)
+ continue
+
+ # Transform rows
+ self_ref_col = SELF_REF_COLUMNS.get(table_name)
+ transformed = [
+ transform_row(table_name, row, self_ref_col)
+ for row in rows
+ ]
+
+ # Store for second pass if needed
+ if self_ref_col:
+ self_ref_rows[table_name] = transformed
+
+ if dry_run:
+ logger.info(
+ " 📋 %s — %d rows (dry run, not inserted)",
+ table_name,
+ len(transformed),
+ )
+ continue
+
+ # Insert into PostgreSQL
+ inserted = await insert_rows_pg(
+ pg_pool, table_name, transformed, batch_size
+ )
+ stats["rows_inserted"] += inserted
+ stats["rows_failed"] += len(transformed) - inserted
+ stats["tables_migrated"] += 1
+ logger.info(
+ " ✅ %s — %d/%d rows inserted",
+ table_name,
+ inserted,
+ len(transformed),
+ )
+
+ # Second pass: update self-referential FKs
+ if self_ref_rows and not dry_run:
+ logger.info("")
+ logger.info("─" * 40)
+ logger.info("Second pass: self-referential FK updates")
+ logger.info("─" * 40)
+
+ for table_name, rows in self_ref_rows.items():
+ self_ref_col = SELF_REF_COLUMNS[table_name]
+ rows_with_refs = [
+ r for r in rows if r.get(f"_orig_{self_ref_col}") is not None
+ ]
+ if not rows_with_refs:
+ logger.info(" ⏭ %s.%s — no self-refs to update", table_name, self_ref_col)
+ continue
+
+ updated = await update_self_refs(
+ pg_pool, table_name, self_ref_col, rows_with_refs
+ )
+ stats["self_refs_updated"] += updated
+ logger.info(
+ " ✅ %s.%s — %d/%d rows updated",
+ table_name,
+ self_ref_col,
+ updated,
+ len(rows_with_refs),
+ )
+
+ # Create Supabase Auth users
+ if not skip_auth and not dry_run:
+ logger.info("")
+ logger.info("─" * 40)
+ logger.info("Creating Supabase Auth users")
+ logger.info("─" * 40)
+
+ user_rows = await read_sqlite_table(sqlite_path, "users")
+ if user_rows:
+ id_map = await create_supabase_auth_users(
+ supabase_url, service_role_key, user_rows, pg_pool
+ )
+ stats["auth_users_created"] = len(id_map)
+ logger.info(" ✅ Created %d Supabase Auth users", len(id_map))
+ else:
+ logger.info(" ⏭ No users to migrate")
+
+ finally:
+ await pg_pool.close()
+
+ # Print summary
+ logger.info("")
+ logger.info("=" * 60)
+ logger.info("Migration Summary")
+ logger.info("=" * 60)
+ logger.info(" Tables migrated: %d", stats["tables_migrated"])
+ logger.info(" Rows inserted: %d", stats["rows_inserted"])
+ logger.info(" Rows failed: %d", stats["rows_failed"])
+ logger.info(" Self-refs updated: %d", stats["self_refs_updated"])
+ logger.info(" Auth users created: %d", stats["auth_users_created"])
+ logger.info("")
+
+ if stats["rows_failed"] > 0:
+ logger.warning("⚠️ Some rows failed to insert. Check logs above for details.")
+ else:
+ logger.info("🎉 Migration completed successfully!")
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Migrate TaskPulse AI data from SQLite to Supabase PostgreSQL",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog="""
+Examples:
+ # Dry run (preview what would be migrated)
+ python -m scripts.migrate_to_supabase \\
+ --sqlite-url ./taskpulse.db \\
+ --pg-url postgresql+asyncpg://postgres:pass@db.xxx.supabase.co:5432/postgres \\
+ --supabase-url https://xxx.supabase.co \\
+ --service-role-key eyJ... \\
+ --dry-run
+
+ # Full migration
+ python -m scripts.migrate_to_supabase \\
+ --sqlite-url ./taskpulse.db \\
+ --pg-url postgresql+asyncpg://postgres:pass@db.xxx.supabase.co:5432/postgres \\
+ --supabase-url https://xxx.supabase.co \\
+ --service-role-key eyJ...
+
+ # Skip auth user creation (if already done)
+ python -m scripts.migrate_to_supabase \\
+ --sqlite-url ./taskpulse.db \\
+ --pg-url postgresql+asyncpg://postgres:pass@db.xxx.supabase.co:5432/postgres \\
+ --supabase-url https://xxx.supabase.co \\
+ --service-role-key eyJ... \\
+ --skip-auth
+ """,
+ )
+ parser.add_argument(
+ "--sqlite-url",
+ required=True,
+ help="Path to the SQLite database file (e.g., ./taskpulse.db)",
+ )
+ parser.add_argument(
+ "--pg-url",
+ required=True,
+ help="PostgreSQL connection URL (e.g., postgresql+asyncpg://user:pass@host:5432/db)",
+ )
+ parser.add_argument(
+ "--supabase-url",
+ required=True,
+ help="Supabase project URL (e.g., https://xxx.supabase.co)",
+ )
+ parser.add_argument(
+ "--service-role-key",
+ required=True,
+ help="Supabase service role key for admin API access",
+ )
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Preview migration without inserting data",
+ )
+ parser.add_argument(
+ "--skip-auth",
+ action="store_true",
+ help="Skip creating Supabase Auth users (useful if already created)",
+ )
+ parser.add_argument(
+ "--batch-size",
+ type=int,
+ default=500,
+ help="Number of rows to insert per batch (default: 500)",
+ )
+
+ args = parser.parse_args()
+
+ asyncio.run(
+ migrate(
+ sqlite_path=args.sqlite_url,
+ pg_dsn=args.pg_url,
+ supabase_url=args.supabase_url,
+ service_role_key=args.service_role_key,
+ dry_run=args.dry_run,
+ skip_auth=args.skip_auth,
+ batch_size=args.batch_size,
+ )
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/scripts/seed_data.py b/backend/scripts/seed_data.py
index 729e8dd..5114752 100644
--- a/backend/scripts/seed_data.py
+++ b/backend/scripts/seed_data.py
@@ -6,7 +6,7 @@
import asyncio
import json
import random
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
@@ -38,7 +38,7 @@
from app.utils.helpers import generate_uuid
-NOW = datetime.utcnow()
+NOW = datetime.now(timezone.utc)
def days_ago(d: int, h: int = 0) -> datetime:
diff --git a/backend/scripts/seed_test_data.py b/backend/scripts/seed_test_data.py
new file mode 100644
index 0000000..f896180
--- /dev/null
+++ b/backend/scripts/seed_test_data.py
@@ -0,0 +1,673 @@
+"""
+Seed script for TaskPulse AI - Creates admin users, test users, and sample data.
+
+Usage:
+ cd backend
+ python scripts/seed_test_data.py
+
+Creates:
+ - 1 Organization ("TaskPulse Demo")
+ - 6 Users (one per role: super_admin, org_admin, manager, team_lead, employee, viewer)
+ - 12 Tasks with various statuses and priorities
+ - Check-in configs and sample check-ins
+ - Task comments and history entries
+"""
+
+import asyncio
+import os
+import sys
+import uuid
+from datetime import datetime, timedelta, timezone
+
+# Add backend to path
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from dotenv import load_dotenv
+load_dotenv()
+
+from supabase import create_client
+
+
+# ─── Config ──────────────────────────────────────────────────────────────────
+SUPABASE_URL = os.getenv("SUPABASE_URL")
+SUPABASE_SERVICE_ROLE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
+DATABASE_URL = os.getenv("DATABASE_URL")
+
+PASSWORD = "TestPass123" # Password for all test users (meets validation: uppercase, lowercase, digit)
+
+# ─── Test Users ──────────────────────────────────────────────────────────────
+TEST_USERS = [
+ {
+ "email": "admin@taskpulse.demo",
+ "first_name": "Super",
+ "last_name": "Admin",
+ "role": "super_admin",
+ "skill_level": "lead",
+ },
+ {
+ "email": "orgadmin@taskpulse.demo",
+ "first_name": "Org",
+ "last_name": "Admin",
+ "role": "org_admin",
+ "skill_level": "senior",
+ },
+ {
+ "email": "manager@taskpulse.demo",
+ "first_name": "Mike",
+ "last_name": "Manager",
+ "role": "manager",
+ "skill_level": "senior",
+ },
+ {
+ "email": "lead@taskpulse.demo",
+ "first_name": "Lisa",
+ "last_name": "Lead",
+ "role": "team_lead",
+ "skill_level": "senior",
+ },
+ {
+ "email": "dev@taskpulse.demo",
+ "first_name": "Dave",
+ "last_name": "Developer",
+ "role": "employee",
+ "skill_level": "mid",
+ },
+ {
+ "email": "viewer@taskpulse.demo",
+ "first_name": "Vera",
+ "last_name": "Viewer",
+ "role": "viewer",
+ "skill_level": "junior",
+ },
+]
+
+
+async def main():
+ import ssl
+ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
+ from sqlalchemy import text
+
+ print("=" * 60)
+ print("TaskPulse AI - Seed Test Data")
+ print("=" * 60)
+
+ # ─── 1. Supabase client ──────────────────────────────────────────────
+ sb = create_client(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY)
+ print("\n[1/6] Supabase client ready")
+
+ # ─── 2. SQLAlchemy engine ────────────────────────────────────────────
+ ssl_ctx = ssl.create_default_context()
+ ssl_ctx.check_hostname = False
+ ssl_ctx.verify_mode = ssl.CERT_NONE
+
+ engine = create_async_engine(
+ DATABASE_URL,
+ pool_size=5,
+ pool_pre_ping=True,
+ connect_args={"ssl": ssl_ctx, "statement_cache_size": 0},
+ )
+ Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+ print("[2/6] Database engine ready")
+
+ # ─── 3. Create organization ──────────────────────────────────────────
+ org_id = str(uuid.uuid4())
+ now = datetime.now(timezone.utc)
+
+ async with Session() as db:
+ # Check if org already exists
+ result = await db.execute(
+ text("SELECT id FROM organizations WHERE slug = 'taskpulse-demo'")
+ )
+ existing = result.scalar_one_or_none()
+ if existing:
+ org_id = str(existing)
+ print(f"[3/6] Organization already exists: {org_id}")
+ else:
+ await db.execute(
+ text("""
+ INSERT INTO organizations (id, name, slug, description, plan, is_active, created_at, updated_at)
+ VALUES (:id, :name, :slug, :description, :plan, :is_active, :now, :now)
+ """),
+ {
+ "id": org_id,
+ "name": "TaskPulse Demo",
+ "slug": "taskpulse-demo",
+ "description": "Demo organization for testing TaskPulse AI",
+ "plan": "enterprise",
+ "is_active": True,
+ "now": now,
+ },
+ )
+ await db.commit()
+ print(f"[3/6] Organization created: TaskPulse Demo ({org_id})")
+
+ # ─── 4. Create users in Supabase Auth + local DB ─────────────────────
+ user_ids = {} # email -> uuid
+ auth_ids = {} # email -> supabase_auth_id
+
+ print("[4/6] Creating users...")
+ for u in TEST_USERS:
+ # Create in Supabase Auth (admin API auto-confirms email)
+ try:
+ auth_resp = sb.auth.admin.create_user({
+ "email": u["email"],
+ "password": PASSWORD,
+ "email_confirm": True,
+ "user_metadata": {
+ "first_name": u["first_name"],
+ "last_name": u["last_name"],
+ },
+ })
+ auth_id = auth_resp.user.id
+ auth_ids[u["email"]] = auth_id
+ print(f" Auth: {u['email']} -> {auth_id}")
+ except Exception as e:
+ err_str = str(e)
+ if "already been registered" in err_str or "already exists" in err_str:
+ # User exists — fetch their ID
+ users_list = sb.auth.admin.list_users()
+ for existing_user in users_list:
+ if existing_user.email == u["email"]:
+ auth_id = existing_user.id
+ auth_ids[u["email"]] = auth_id
+ print(f" Auth: {u['email']} already exists -> {auth_id}")
+ break
+ else:
+ print(f" ERROR creating auth user {u['email']}: {e}")
+ continue
+
+ # Create local DB user
+ uid = str(uuid.uuid4())
+ user_ids[u["email"]] = uid
+
+ async with Session() as db:
+ # Check if user already exists
+ result = await db.execute(
+ text("SELECT id FROM users WHERE email = :email AND org_id = :org_id"),
+ {"email": u["email"], "org_id": org_id},
+ )
+ existing = result.scalar_one_or_none()
+ if existing:
+ user_ids[u["email"]] = str(existing)
+ print(f" DB: {u['email']} already exists -> {existing}")
+ continue
+
+ await db.execute(
+ text("""
+ INSERT INTO users (
+ id, org_id, email, first_name, last_name,
+ role, skill_level, is_active, is_email_verified,
+ supabase_auth_id, timezone, failed_login_attempts,
+ created_at, updated_at
+ ) VALUES (
+ :id, :org_id, :email, :first_name, :last_name,
+ :role, :skill_level, true, true,
+ :auth_id, 'UTC', 0,
+ :now, :now
+ )
+ """),
+ {
+ "id": uid,
+ "org_id": org_id,
+ "email": u["email"],
+ "first_name": u["first_name"],
+ "last_name": u["last_name"],
+ "role": u["role"],
+ "skill_level": u["skill_level"],
+ "auth_id": auth_ids.get(u["email"]),
+ "now": now,
+ },
+ )
+ await db.commit()
+ print(f" DB: {u['email']} -> {uid} (role={u['role']})")
+
+ # Set manager relationships: dev and viewer report to lead, lead reports to manager
+ async with Session() as db:
+ manager_id = user_ids.get("manager@taskpulse.demo")
+ lead_id = user_ids.get("lead@taskpulse.demo")
+ if lead_id and manager_id:
+ await db.execute(
+ text("UPDATE users SET manager_id = :mgr WHERE id = :uid"),
+ {"mgr": manager_id, "uid": lead_id},
+ )
+ dev_id = user_ids.get("dev@taskpulse.demo")
+ viewer_id = user_ids.get("viewer@taskpulse.demo")
+ if lead_id:
+ for eid in [dev_id, viewer_id]:
+ if eid:
+ await db.execute(
+ text("UPDATE users SET manager_id = :mgr WHERE id = :uid"),
+ {"mgr": lead_id, "uid": eid},
+ )
+ await db.commit()
+ print(" Manager relationships set")
+
+ # ─── 5. Create tasks ─────────────────────────────────────────────────
+ manager_id = user_ids.get("manager@taskpulse.demo")
+ lead_id = user_ids.get("lead@taskpulse.demo")
+ dev_id = user_ids.get("dev@taskpulse.demo")
+
+ tasks = [
+ {
+ "title": "Design system architecture",
+ "description": "Create the overall system architecture document including tech stack decisions, deployment strategy, and component diagram.",
+ "status": "done",
+ "priority": "critical",
+ "assigned_to": lead_id,
+ "created_by": manager_id,
+ "estimated_hours": 16.0,
+ "actual_hours": 14.0,
+ "tags": '["architecture", "design"]',
+ "skills_required": '["system-design", "documentation"]',
+ },
+ {
+ "title": "Set up CI/CD pipeline",
+ "description": "Configure GitHub Actions for automated testing, linting, and deployment to staging.",
+ "status": "done",
+ "priority": "high",
+ "assigned_to": dev_id,
+ "created_by": lead_id,
+ "estimated_hours": 8.0,
+ "actual_hours": 10.0,
+ "tags": '["devops", "ci-cd"]',
+ "skills_required": '["github-actions", "docker"]',
+ },
+ {
+ "title": "Implement user authentication",
+ "description": "Build login, registration, password reset, and OAuth flows using Supabase Auth.",
+ "status": "done",
+ "priority": "critical",
+ "assigned_to": dev_id,
+ "created_by": manager_id,
+ "estimated_hours": 24.0,
+ "actual_hours": 20.0,
+ "tags": '["auth", "security"]',
+ "skills_required": '["supabase", "jwt", "oauth"]',
+ },
+ {
+ "title": "Build task management API",
+ "description": "Create CRUD endpoints for tasks with filtering, pagination, subtasks, and status transitions.",
+ "status": "in_progress",
+ "priority": "high",
+ "assigned_to": dev_id,
+ "created_by": manager_id,
+ "estimated_hours": 20.0,
+ "actual_hours": 8.0,
+ "tags": '["api", "backend"]',
+ "skills_required": '["fastapi", "sqlalchemy"]',
+ },
+ {
+ "title": "Design dashboard UI mockups",
+ "description": "Create Figma mockups for the main dashboard, task board, and analytics views.",
+ "status": "review",
+ "priority": "high",
+ "assigned_to": lead_id,
+ "created_by": manager_id,
+ "estimated_hours": 12.0,
+ "actual_hours": 11.0,
+ "tags": '["design", "ui"]',
+ "skills_required": '["figma", "ui-design"]',
+ },
+ {
+ "title": "Implement AI task decomposition",
+ "description": "Build the AI engine that breaks down complex tasks into subtasks with estimates and dependencies.",
+ "status": "in_progress",
+ "priority": "critical",
+ "assigned_to": dev_id,
+ "created_by": manager_id,
+ "estimated_hours": 32.0,
+ "actual_hours": 5.0,
+ "tags": '["ai", "core-feature"]',
+ "skills_required": '["llm", "prompt-engineering"]',
+ },
+ {
+ "title": "Set up monitoring and alerting",
+ "description": "Configure application monitoring with health checks, error tracking, and performance metrics.",
+ "status": "todo",
+ "priority": "medium",
+ "assigned_to": dev_id,
+ "created_by": lead_id,
+ "estimated_hours": 6.0,
+ "tags": '["devops", "monitoring"]',
+ "skills_required": '["observability"]',
+ },
+ {
+ "title": "Write API documentation",
+ "description": "Document all REST API endpoints with request/response examples, auth requirements, and error codes.",
+ "status": "todo",
+ "priority": "medium",
+ "assigned_to": lead_id,
+ "created_by": manager_id,
+ "estimated_hours": 8.0,
+ "tags": '["docs", "api"]',
+ "skills_required": '["technical-writing"]',
+ },
+ {
+ "title": "Build notification system",
+ "description": "Create real-time notification delivery via WebSocket, email, and in-app channels.",
+ "status": "todo",
+ "priority": "medium",
+ "assigned_to": None,
+ "created_by": manager_id,
+ "estimated_hours": 16.0,
+ "tags": '["notifications", "realtime"]',
+ "skills_required": '["websocket", "email"]',
+ },
+ {
+ "title": "Performance optimization sprint",
+ "description": "Profile and optimize database queries, API response times, and frontend bundle size.",
+ "status": "blocked",
+ "priority": "high",
+ "assigned_to": dev_id,
+ "created_by": lead_id,
+ "estimated_hours": 12.0,
+ "actual_hours": 2.0,
+ "tags": '["performance", "optimization"]',
+ "skills_required": '["profiling", "sql-optimization"]',
+ "blocker_type": "dependency",
+ "blocker_description": "Waiting for task management API to be completed before profiling queries.",
+ },
+ {
+ "title": "Security audit and penetration testing",
+ "description": "Conduct security review of auth flows, API endpoints, file uploads, and data access patterns.",
+ "status": "todo",
+ "priority": "critical",
+ "assigned_to": None,
+ "created_by": manager_id,
+ "estimated_hours": 24.0,
+ "tags": '["security", "audit"]',
+ "skills_required": '["security", "penetration-testing"]',
+ },
+ {
+ "title": "Mobile responsive design",
+ "description": "Ensure all UI components work correctly on mobile and tablet screen sizes.",
+ "status": "todo",
+ "priority": "low",
+ "assigned_to": lead_id,
+ "created_by": manager_id,
+ "estimated_hours": 10.0,
+ "tags": '["ui", "mobile"]',
+ "skills_required": '["css", "responsive-design"]',
+ },
+ ]
+
+ task_ids = []
+ print("\n[5/6] Creating tasks...")
+ async with Session() as db:
+ for i, t in enumerate(tasks):
+ tid = str(uuid.uuid4())
+ task_ids.append(tid)
+
+ deadline = now + timedelta(days=(i + 1) * 3)
+ started = now - timedelta(days=max(0, 10 - i)) if t["status"] in ("in_progress", "done", "review", "blocked") else None
+ completed = now - timedelta(days=max(1, 5 - i)) if t["status"] == "done" else None
+
+ await db.execute(
+ text("""
+ INSERT INTO tasks (
+ id, org_id, title, description, status, priority,
+ assigned_to, created_by, deadline,
+ estimated_hours, actual_hours,
+ started_at, completed_at,
+ risk_score, complexity_score, confidence_score,
+ blocker_type, blocker_description,
+ tags, skills_required, tools,
+ sort_order, is_draft,
+ created_at, updated_at
+ ) VALUES (
+ :id, :org_id, :title, :description, :status, :priority,
+ :assigned_to, :created_by, :deadline,
+ :estimated_hours, :actual_hours,
+ :started_at, :completed_at,
+ :risk_score, :complexity_score, :confidence_score,
+ :blocker_type, :blocker_description,
+ CAST(:tags AS jsonb), CAST(:skills_required AS jsonb), CAST('[]' AS jsonb),
+ :sort_order, false,
+ :now, :now
+ )
+ """),
+ {
+ "id": tid,
+ "org_id": org_id,
+ "title": t["title"],
+ "description": t["description"],
+ "status": t["status"],
+ "priority": t["priority"],
+ "assigned_to": t["assigned_to"],
+ "created_by": t["created_by"],
+ "deadline": deadline,
+ "estimated_hours": t.get("estimated_hours"),
+ "actual_hours": t.get("actual_hours", 0.0),
+ "started_at": started,
+ "completed_at": completed,
+ "risk_score": 0.3 if t["status"] == "blocked" else 0.1,
+ "complexity_score": 0.7 if t.get("estimated_hours", 0) > 16 else 0.4,
+ "confidence_score": 0.9 if t["status"] == "done" else 0.6,
+ "blocker_type": t.get("blocker_type"),
+ "blocker_description": t.get("blocker_description"),
+ "tags": t.get("tags", "[]"),
+ "skills_required": t.get("skills_required", "[]"),
+ "sort_order": i,
+ "now": now,
+ },
+ )
+ print(f" Task: {t['title'][:40]:<40} [{t['status']:<12}] {t['priority']}")
+
+ await db.commit()
+
+ # Create 2 subtasks for "Build task management API"
+ parent_task_id = task_ids[3] # "Build task management API"
+ subtask_titles = [
+ ("Implement task CRUD endpoints", "in_progress"),
+ ("Add task filtering and pagination", "todo"),
+ ]
+
+ async with Session() as db:
+ for j, (st_title, st_status) in enumerate(subtask_titles):
+ st_id = str(uuid.uuid4())
+ await db.execute(
+ text("""
+ INSERT INTO tasks (
+ id, org_id, title, description, status, priority,
+ assigned_to, created_by, parent_task_id,
+ estimated_hours, sort_order, is_draft,
+ tags, skills_required, tools,
+ created_at, updated_at
+ ) VALUES (
+ :id, :org_id, :title, '', :status, 'high',
+ :assigned_to, :created_by, :parent_id,
+ :hours, :sort, false,
+ CAST('[]' AS jsonb), CAST('[]' AS jsonb), CAST('[]' AS jsonb),
+ :now, :now
+ )
+ """),
+ {
+ "id": st_id,
+ "org_id": org_id,
+ "title": st_title,
+ "status": st_status,
+ "assigned_to": dev_id,
+ "created_by": lead_id,
+ "parent_id": parent_task_id,
+ "hours": 6.0 + j * 4,
+ "sort": j,
+ "now": now,
+ },
+ )
+ print(f" Subtask: {st_title}")
+ await db.commit()
+
+ # ─── 6. Create check-in config + sample check-ins ────────────────────
+ print("\n[6/6] Creating check-in configs and sample data...")
+ async with Session() as db:
+ # Org-level check-in config
+ await db.execute(
+ text("""
+ INSERT INTO checkin_configs (
+ id, org_id, interval_hours, enabled,
+ max_daily_checkins, work_start_hour, work_end_hour,
+ ai_suggestions_enabled, ai_sentiment_analysis,
+ created_at, updated_at
+ ) VALUES (
+ :id, :org_id, 3.0, true,
+ 4, 9, 18,
+ true, true,
+ :now, :now
+ )
+ """),
+ {"id": str(uuid.uuid4()), "org_id": org_id, "now": now},
+ )
+
+ # Sample check-ins for the in-progress tasks
+ checkin_data = [
+ {
+ "task_id": task_ids[3], # Build task management API
+ "user_id": dev_id,
+ "progress": "on_track",
+ "notes": "CRUD endpoints for tasks are mostly done. Working on status transitions now.",
+ "completed": "Implemented create, read, update, delete endpoints. Added input validation.",
+ "sentiment": 0.7,
+ },
+ {
+ "task_id": task_ids[5], # AI task decomposition
+ "user_id": dev_id,
+ "progress": "slightly_behind",
+ "notes": "LLM integration is trickier than expected. Prompt engineering needs more iteration.",
+ "completed": "Set up multi-provider AI client. Basic prompt template done.",
+ "sentiment": 0.3,
+ },
+ {
+ "task_id": task_ids[9], # Performance optimization (blocked)
+ "user_id": dev_id,
+ "progress": "blocked",
+ "notes": "Cannot profile database queries until the task API is complete.",
+ "completed": "Set up profiling tools and baseline metrics.",
+ "blockers": "Task management API must be completed first.",
+ "sentiment": -0.2,
+ },
+ ]
+
+ for ci in checkin_data:
+ await db.execute(
+ text("""
+ INSERT INTO checkins (
+ id, task_id, user_id, org_id,
+ cycle_number, trigger, status,
+ scheduled_at, responded_at,
+ progress_indicator, progress_notes,
+ completed_since_last, blockers_reported,
+ sentiment_score, friction_detected,
+ created_at, updated_at
+ ) VALUES (
+ :id, :task_id, :user_id, :org_id,
+ 1, 'scheduled', 'responded',
+ :scheduled, :responded,
+ :progress, :notes,
+ :completed, :blockers,
+ :sentiment, :friction,
+ :now, :now
+ )
+ """),
+ {
+ "id": str(uuid.uuid4()),
+ "task_id": ci["task_id"],
+ "user_id": ci["user_id"],
+ "org_id": org_id,
+ "scheduled": now - timedelta(hours=4),
+ "responded": now - timedelta(hours=3),
+ "progress": ci["progress"],
+ "notes": ci["notes"],
+ "completed": ci["completed"],
+ "blockers": ci.get("blockers"),
+ "sentiment": ci["sentiment"],
+ "friction": ci["progress"] in ("blocked", "significantly_behind"),
+ "now": now,
+ },
+ )
+
+ # Task comments
+ comments = [
+ (task_ids[0], manager_id, "Great architecture doc. Let's review in the next standup."),
+ (task_ids[0], lead_id, "Updated the diagram to include the new AI microservice."),
+ (task_ids[3], lead_id, "Don't forget to add soft-delete support for tasks."),
+ (task_ids[3], dev_id, "Good point. I'll add an is_archived flag and filter it in queries."),
+ (task_ids[5], manager_id, "This is our differentiator. Take the time to get it right."),
+ (task_ids[9], dev_id, "Blocked on task API. Moving to docs in the meantime."),
+ ]
+
+ for task_id, user_id, content in comments:
+ await db.execute(
+ text("""
+ INSERT INTO task_comments (
+ id, task_id, user_id, content, is_ai_generated, is_edited,
+ created_at, updated_at
+ ) VALUES (
+ :id, :task_id, :user_id, :content, false, false,
+ :now, :now
+ )
+ """),
+ {
+ "id": str(uuid.uuid4()),
+ "task_id": task_id,
+ "user_id": user_id,
+ "content": content,
+ "now": now,
+ },
+ )
+
+ # Task history entries
+ history_entries = [
+ (task_ids[0], lead_id, "status_change", "status", "in_progress", "done"),
+ (task_ids[1], dev_id, "status_change", "status", "in_progress", "done"),
+ (task_ids[2], dev_id, "status_change", "status", "in_progress", "done"),
+ (task_ids[3], dev_id, "status_change", "status", "todo", "in_progress"),
+ (task_ids[4], lead_id, "status_change", "status", "in_progress", "review"),
+ (task_ids[9], dev_id, "status_change", "status", "todo", "blocked"),
+ ]
+
+ for task_id, user_id, action, field, old_val, new_val in history_entries:
+ await db.execute(
+ text("""
+ INSERT INTO task_history (
+ id, task_id, user_id, action, field_name, old_value, new_value,
+ created_at, updated_at
+ ) VALUES (
+ :id, :task_id, :user_id, :action, :field, :old_val, :new_val,
+ :now, :now
+ )
+ """),
+ {
+ "id": str(uuid.uuid4()),
+ "task_id": task_id,
+ "user_id": user_id,
+ "action": action,
+ "field": field,
+ "old_val": old_val,
+ "new_val": new_val,
+ "now": now,
+ },
+ )
+
+ await db.commit()
+
+ # ─── Summary ─────────────────────────────────────────────────────────
+ await engine.dispose()
+
+ print("\n" + "=" * 60)
+ print("SEED COMPLETE!")
+ print("=" * 60)
+ print(f"\nOrganization: TaskPulse Demo ({org_id})")
+ print(f"\nTest Users (password for all: {PASSWORD}):")
+ print(f" {'Email':<30} {'Role':<15} {'Name'}")
+ print(f" {'-'*30} {'-'*15} {'-'*20}")
+ for u in TEST_USERS:
+ print(f" {u['email']:<30} {u['role']:<15} {u['first_name']} {u['last_name']}")
+ print(f"\nTasks created: {len(tasks)} + 2 subtasks")
+ print(f"Check-ins: 3 sample responses")
+ print(f"Comments: {len(comments)}")
+ print(f"History entries: {len(history_entries)}")
+ print()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/backend/supabase/.gitignore b/backend/supabase/.gitignore
new file mode 100644
index 0000000..ad9264f
--- /dev/null
+++ b/backend/supabase/.gitignore
@@ -0,0 +1,8 @@
+# Supabase
+.branches
+.temp
+
+# dotenvx
+.env.keys
+.env.local
+.env.*.local
diff --git a/backend/supabase/config.toml b/backend/supabase/config.toml
new file mode 100644
index 0000000..b6b3c97
--- /dev/null
+++ b/backend/supabase/config.toml
@@ -0,0 +1,384 @@
+# For detailed configuration reference documentation, visit:
+# https://supabase.com/docs/guides/local-development/cli/config
+# A string used to distinguish different Supabase projects on the same host. Defaults to the
+# working directory name when running `supabase init`.
+project_id = "backend"
+
+[api]
+enabled = true
+# Port to use for the API URL.
+port = 54321
+# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
+# endpoints. `public` and `graphql_public` schemas are included by default.
+schemas = ["public", "graphql_public"]
+# Extra schemas to add to the search_path of every request.
+extra_search_path = ["public", "extensions"]
+# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
+# for accidental or malicious requests.
+max_rows = 1000
+
+[api.tls]
+# Enable HTTPS endpoints locally using a self-signed certificate.
+enabled = false
+# Paths to self-signed certificate pair.
+# cert_path = "../certs/my-cert.pem"
+# key_path = "../certs/my-key.pem"
+
+[db]
+# Port to use for the local database URL.
+port = 54322
+# Port used by db diff command to initialize the shadow database.
+shadow_port = 54320
+# Maximum amount of time to wait for health check when starting the local database.
+health_timeout = "2m"
+# The database major version to use. This has to be the same as your remote database's. Run `SHOW
+# server_version;` on the remote database to check.
+major_version = 17
+
+[db.pooler]
+enabled = false
+# Port to use for the local connection pooler.
+port = 54329
+# Specifies when a server connection can be reused by other clients.
+# Configure one of the supported pooler modes: `transaction`, `session`.
+pool_mode = "transaction"
+# How many server connections to allow per user/database pair.
+default_pool_size = 20
+# Maximum number of client connections allowed.
+max_client_conn = 100
+
+# [db.vault]
+# secret_key = "env(SECRET_VALUE)"
+
+[db.migrations]
+# If disabled, migrations will be skipped during a db push or reset.
+enabled = true
+# Specifies an ordered list of schema files that describe your database.
+# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
+schema_paths = []
+
+[db.seed]
+# If enabled, seeds the database after migrations during a db reset.
+enabled = true
+# Specifies an ordered list of seed files to load during db reset.
+# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
+sql_paths = ["./seed.sql"]
+
+[db.network_restrictions]
+# Enable management of network restrictions.
+enabled = false
+# List of IPv4 CIDR blocks allowed to connect to the database.
+# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
+allowed_cidrs = ["0.0.0.0/0"]
+# List of IPv6 CIDR blocks allowed to connect to the database.
+# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
+allowed_cidrs_v6 = ["::/0"]
+
+[realtime]
+enabled = true
+# Bind realtime via either IPv4 or IPv6. (default: IPv4)
+# ip_version = "IPv6"
+# The maximum length in bytes of HTTP request headers. (default: 4096)
+# max_header_length = 4096
+
+[studio]
+enabled = true
+# Port to use for Supabase Studio.
+port = 54323
+# External URL of the API server that frontend connects to.
+api_url = "http://127.0.0.1"
+# OpenAI API Key to use for Supabase AI in the Supabase Studio.
+openai_api_key = "env(OPENAI_API_KEY)"
+
+# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
+# are monitored, and you can view the emails that would have been sent from the web interface.
+[inbucket]
+enabled = true
+# Port to use for the email testing server web interface.
+port = 54324
+# Uncomment to expose additional ports for testing user applications that send emails.
+# smtp_port = 54325
+# pop3_port = 54326
+# admin_email = "admin@email.com"
+# sender_name = "Admin"
+
+[storage]
+enabled = true
+# The maximum file size allowed (e.g. "5MB", "500KB").
+file_size_limit = "50MiB"
+
+# Uncomment to configure local storage buckets
+# [storage.buckets.images]
+# public = false
+# file_size_limit = "50MiB"
+# allowed_mime_types = ["image/png", "image/jpeg"]
+# objects_path = "./images"
+
+# Allow connections via S3 compatible clients
+[storage.s3_protocol]
+enabled = true
+
+# Image transformation API is available to Supabase Pro plan.
+# [storage.image_transformation]
+# enabled = true
+
+# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
+# This feature is only available on the hosted platform.
+[storage.analytics]
+enabled = false
+max_namespaces = 5
+max_tables = 10
+max_catalogs = 2
+
+# Analytics Buckets is available to Supabase Pro plan.
+# [storage.analytics.buckets.my-warehouse]
+
+# Store vector embeddings in S3 for large and durable datasets
+# This feature is only available on the hosted platform.
+[storage.vector]
+enabled = false
+max_buckets = 10
+max_indexes = 5
+
+# Vector Buckets is available to Supabase Pro plan.
+# [storage.vector.buckets.documents-openai]
+
+[auth]
+enabled = true
+# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
+# in emails.
+site_url = "http://127.0.0.1:3000"
+# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
+additional_redirect_urls = ["https://127.0.0.1:3000"]
+# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
+jwt_expiry = 3600
+# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:/auth/v1).
+# jwt_issuer = ""
+# Path to JWT signing key. DO NOT commit your signing keys file to git.
+# signing_keys_path = "./signing_keys.json"
+# If disabled, the refresh token will never expire.
+enable_refresh_token_rotation = true
+# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
+# Requires enable_refresh_token_rotation = true.
+refresh_token_reuse_interval = 10
+# Allow/disallow new user signups to your project.
+enable_signup = true
+# Allow/disallow anonymous sign-ins to your project.
+enable_anonymous_sign_ins = false
+# Allow/disallow testing manual linking of accounts
+enable_manual_linking = false
+# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
+minimum_password_length = 6
+# Passwords that do not meet the following requirements will be rejected as weak. Supported values
+# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
+password_requirements = ""
+
+[auth.rate_limit]
+# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
+email_sent = 2
+# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
+sms_sent = 30
+# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
+anonymous_users = 30
+# Number of sessions that can be refreshed in a 5 minute interval per IP address.
+token_refresh = 150
+# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
+sign_in_sign_ups = 30
+# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
+token_verifications = 30
+# Number of Web3 logins that can be made in a 5 minute interval per IP address.
+web3 = 30
+
+# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
+# [auth.captcha]
+# enabled = true
+# provider = "hcaptcha"
+# secret = ""
+
+[auth.email]
+# Allow/disallow new user signups via email to your project.
+enable_signup = true
+# If enabled, a user will be required to confirm any email change on both the old, and new email
+# addresses. If disabled, only the new email is required to confirm.
+double_confirm_changes = true
+# If enabled, users need to confirm their email address before signing in.
+enable_confirmations = false
+# If enabled, users will need to reauthenticate or have logged in recently to change their password.
+secure_password_change = false
+# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
+max_frequency = "1s"
+# Number of characters used in the email OTP.
+otp_length = 6
+# Number of seconds before the email OTP expires (defaults to 1 hour).
+otp_expiry = 3600
+
+# Use a production-ready SMTP server
+# [auth.email.smtp]
+# enabled = true
+# host = "smtp.sendgrid.net"
+# port = 587
+# user = "apikey"
+# pass = "env(SENDGRID_API_KEY)"
+# admin_email = "admin@email.com"
+# sender_name = "Admin"
+
+# Uncomment to customize email template
+# [auth.email.template.invite]
+# subject = "You have been invited"
+# content_path = "./supabase/templates/invite.html"
+
+# Uncomment to customize notification email template
+# [auth.email.notification.password_changed]
+# enabled = true
+# subject = "Your password has been changed"
+# content_path = "./templates/password_changed_notification.html"
+
+[auth.sms]
+# Allow/disallow new user signups via SMS to your project.
+enable_signup = false
+# If enabled, users need to confirm their phone number before signing in.
+enable_confirmations = false
+# Template for sending OTP to users
+template = "Your code is {{ .Code }}"
+# Controls the minimum amount of time that must pass before sending another sms otp.
+max_frequency = "5s"
+
+# Use pre-defined map of phone number to OTP for testing.
+# [auth.sms.test_otp]
+# 4152127777 = "123456"
+
+# Configure logged in session timeouts.
+# [auth.sessions]
+# Force log out after the specified duration.
+# timebox = "24h"
+# Force log out if the user has been inactive longer than the specified duration.
+# inactivity_timeout = "8h"
+
+# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
+# [auth.hook.before_user_created]
+# enabled = true
+# uri = "pg-functions://postgres/auth/before-user-created-hook"
+
+# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
+# [auth.hook.custom_access_token]
+# enabled = true
+# uri = "pg-functions:////"
+
+# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
+[auth.sms.twilio]
+enabled = false
+account_sid = ""
+message_service_sid = ""
+# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
+auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
+
+# Multi-factor-authentication is available to Supabase Pro plan.
+[auth.mfa]
+# Control how many MFA factors can be enrolled at once per user.
+max_enrolled_factors = 10
+
+# Control MFA via App Authenticator (TOTP)
+[auth.mfa.totp]
+enroll_enabled = false
+verify_enabled = false
+
+# Configure MFA via Phone Messaging
+[auth.mfa.phone]
+enroll_enabled = false
+verify_enabled = false
+otp_length = 6
+template = "Your code is {{ .Code }}"
+max_frequency = "5s"
+
+# Configure MFA via WebAuthn
+# [auth.mfa.web_authn]
+# enroll_enabled = true
+# verify_enabled = true
+
+# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
+# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
+# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
+[auth.external.apple]
+enabled = false
+client_id = ""
+# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
+secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
+# Overrides the default auth redirectUrl.
+redirect_uri = ""
+# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
+# or any other third-party OIDC providers.
+url = ""
+# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
+skip_nonce_check = false
+# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
+email_optional = false
+
+# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
+# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
+[auth.web3.solana]
+enabled = false
+
+# Use Firebase Auth as a third-party provider alongside Supabase Auth.
+[auth.third_party.firebase]
+enabled = false
+# project_id = "my-firebase-project"
+
+# Use Auth0 as a third-party provider alongside Supabase Auth.
+[auth.third_party.auth0]
+enabled = false
+# tenant = "my-auth0-tenant"
+# tenant_region = "us"
+
+# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
+[auth.third_party.aws_cognito]
+enabled = false
+# user_pool_id = "my-user-pool-id"
+# user_pool_region = "us-east-1"
+
+# Use Clerk as a third-party provider alongside Supabase Auth.
+[auth.third_party.clerk]
+enabled = false
+# Obtain from https://clerk.com/setup/supabase
+# domain = "example.clerk.accounts.dev"
+
+# OAuth server configuration
+[auth.oauth_server]
+# Enable OAuth server functionality
+enabled = false
+# Path for OAuth consent flow UI
+authorization_url_path = "/oauth/consent"
+# Allow dynamic client registration
+allow_dynamic_registration = false
+
+[edge_runtime]
+enabled = true
+# Supported request policies: `oneshot`, `per_worker`.
+# `per_worker` (default) — enables hot reload during local development.
+# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
+policy = "per_worker"
+# Port to attach the Chrome inspector for debugging edge functions.
+inspector_port = 8083
+# The Deno major version to use.
+deno_version = 2
+
+# [edge_runtime.secrets]
+# secret_key = "env(SECRET_VALUE)"
+
+[analytics]
+enabled = true
+port = 54327
+# Configure one of the supported backends: `postgres`, `bigquery`.
+backend = "postgres"
+
+# Experimental features may be deprecated any time
+[experimental]
+# Configures Postgres storage engine to use OrioleDB (S3)
+orioledb_version = ""
+# Configures S3 bucket URL, eg. .s3-.amazonaws.com
+s3_host = "env(S3_HOST)"
+# Configures S3 bucket region, eg. us-east-1
+s3_region = "env(S3_REGION)"
+# Configures AWS_ACCESS_KEY_ID for S3 bucket
+s3_access_key = "env(S3_ACCESS_KEY)"
+# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
+s3_secret_key = "env(S3_SECRET_KEY)"
diff --git a/backend/supabase/migrations/20260313000000_initial_schema.sql b/backend/supabase/migrations/20260313000000_initial_schema.sql
new file mode 100644
index 0000000..b27b31d
--- /dev/null
+++ b/backend/supabase/migrations/20260313000000_initial_schema.sql
@@ -0,0 +1,1607 @@
+-- ============================================================================
+-- TaskPulse AI - Complete PostgreSQL DDL Schema
+-- Generated from SQLAlchemy models
+-- ============================================================================
+
+-- Enable required extensions
+CREATE EXTENSION IF NOT EXISTS vector;
+
+-- ============================================================================
+-- ENUM TYPES
+-- ============================================================================
+
+CREATE TYPE plan_tier AS ENUM (
+ 'starter', 'professional', 'enterprise', 'enterprise_plus'
+);
+
+CREATE TYPE user_role AS ENUM (
+ 'super_admin', 'org_admin', 'manager', 'team_lead', 'employee', 'viewer'
+);
+
+CREATE TYPE skill_level AS ENUM (
+ 'junior', 'mid', 'senior', 'lead'
+);
+
+CREATE TYPE task_status AS ENUM (
+ 'todo', 'in_progress', 'blocked', 'review', 'done', 'archived'
+);
+
+CREATE TYPE task_priority AS ENUM (
+ 'critical', 'high', 'medium', 'low'
+);
+
+CREATE TYPE blocker_type AS ENUM (
+ 'logic', 'tool', 'dependency', 'bug', 'resource', 'unknown'
+);
+
+CREATE TYPE checkin_trigger AS ENUM (
+ 'scheduled', 'progress_stall', 'deadline_approaching',
+ 'manual', 'blocker_detected', 'status_change'
+);
+
+CREATE TYPE checkin_status AS ENUM (
+ 'pending', 'responded', 'skipped', 'expired', 'escalated'
+);
+
+CREATE TYPE progress_indicator AS ENUM (
+ 'on_track', 'slightly_behind', 'significantly_behind',
+ 'blocked', 'ahead', 'completed'
+);
+
+CREATE TYPE document_source AS ENUM (
+ 'manual_upload', 'confluence', 'notion', 'github',
+ 'gitlab', 'jira', 'slack', 'internal_wiki', 'external_url'
+);
+
+CREATE TYPE document_status AS ENUM (
+ 'pending', 'processing', 'indexed', 'failed', 'archived'
+);
+
+CREATE TYPE document_type AS ENUM (
+ 'documentation', 'code_snippet', 'tutorial', 'faq', 'runbook',
+ 'policy', 'meeting_notes', 'architecture', 'guide', 'troubleshooting', 'other'
+);
+
+CREATE TYPE skill_category AS ENUM (
+ 'technical', 'process', 'soft', 'domain', 'tool', 'language'
+);
+
+CREATE TYPE skill_trend AS ENUM (
+ 'improving', 'stable', 'declining'
+);
+
+CREATE TYPE gap_type AS ENUM (
+ 'critical', 'growth', 'stretch'
+);
+
+CREATE TYPE prediction_type AS ENUM (
+ 'task_completion', 'project_delivery', 'team_velocity',
+ 'attrition_risk', 'hiring_needs'
+);
+
+CREATE TYPE pattern_status AS ENUM (
+ 'detected', 'suggested', 'accepted', 'rejected', 'implemented'
+);
+
+CREATE TYPE agent_status AS ENUM (
+ 'created', 'shadow', 'supervised', 'live', 'paused', 'retired'
+);
+
+CREATE TYPE notification_type AS ENUM (
+ 'checkin_reminder', 'task_assigned', 'task_completed', 'task_blocked',
+ 'escalation', 'deadline_approaching', 'ai_suggestion', 'mention', 'system'
+);
+
+CREATE TYPE notification_channel AS ENUM (
+ 'in_app', 'email', 'slack', 'teams', 'webhook'
+);
+
+CREATE TYPE notification_priority AS ENUM (
+ 'low', 'medium', 'high', 'urgent'
+);
+
+CREATE TYPE integration_type AS ENUM (
+ 'jira', 'github', 'gitlab', 'slack', 'teams',
+ 'confluence', 'notion', 'custom_webhook'
+);
+
+CREATE TYPE integration_status AS ENUM (
+ 'pending', 'active', 'error', 'disconnected'
+);
+
+CREATE TYPE actor_type AS ENUM (
+ 'user', 'admin', 'system', 'ai', 'api', 'integration'
+);
+
+CREATE TYPE audit_action AS ENUM (
+ 'login', 'logout', 'password_change', 'mfa_enabled',
+ 'create', 'read', 'update', 'delete',
+ 'role_change', 'permission_change', 'config_change',
+ 'export', 'import',
+ 'data_request', 'data_deletion'
+);
+
+CREATE TYPE agent_type_enum AS ENUM (
+ 'ai', 'integration', 'conversation'
+);
+
+CREATE TYPE agent_status_db AS ENUM (
+ 'active', 'paused', 'error', 'disabled'
+);
+
+CREATE TYPE execution_status AS ENUM (
+ 'pending', 'running', 'completed', 'failed', 'cancelled'
+);
+
+-- ============================================================================
+-- TABLE: organizations (no foreign key dependencies)
+-- ============================================================================
+
+CREATE TABLE organizations (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ name VARCHAR(255) NOT NULL,
+ slug VARCHAR(100) NOT NULL,
+ description TEXT,
+ plan plan_tier NOT NULL DEFAULT 'starter',
+ settings_data JSONB DEFAULT '{}',
+ is_active BOOLEAN NOT NULL DEFAULT TRUE
+);
+
+CREATE UNIQUE INDEX ix_organizations_slug ON organizations (slug);
+CREATE INDEX ix_organizations_id ON organizations (id);
+CREATE INDEX ix_organizations_created_at ON organizations (created_at);
+
+-- ============================================================================
+-- TABLE: users (depends on: organizations)
+-- ============================================================================
+
+CREATE TABLE users (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ -- Organization
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ -- Authentication
+ email VARCHAR(255) NOT NULL,
+ password_hash VARCHAR(255),
+ is_sso_user BOOLEAN DEFAULT FALSE,
+ supabase_auth_id UUID,
+
+ -- Profile
+ first_name VARCHAR(100) NOT NULL,
+ last_name VARCHAR(100) NOT NULL,
+ avatar_url VARCHAR(500),
+ phone VARCHAR(50),
+ timezone VARCHAR(50) NOT NULL DEFAULT 'UTC',
+
+ -- Role and permissions
+ role user_role NOT NULL DEFAULT 'employee',
+ skill_level skill_level NOT NULL DEFAULT 'mid',
+
+ -- Team / reporting structure
+ team_id UUID,
+ manager_id UUID REFERENCES users(id) ON DELETE SET NULL,
+
+ -- GDPR consent tracking
+ consent_data JSONB DEFAULT '{}',
+
+ -- Status
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ is_email_verified BOOLEAN DEFAULT FALSE,
+ last_login TIMESTAMP,
+ failed_login_attempts INTEGER DEFAULT 0,
+ lockout_until TIMESTAMP,
+
+ -- Constraints
+ CONSTRAINT uq_user_org_email UNIQUE (org_id, email)
+);
+
+CREATE INDEX ix_users_id ON users (id);
+CREATE INDEX ix_users_created_at ON users (created_at);
+CREATE INDEX ix_users_org_id ON users (org_id);
+CREATE INDEX ix_users_email ON users (email);
+CREATE UNIQUE INDEX ix_users_supabase_auth_id ON users (supabase_auth_id);
+CREATE INDEX ix_users_team_id ON users (team_id);
+
+-- ============================================================================
+-- TABLE: tasks (depends on: organizations, users)
+-- ============================================================================
+
+CREATE TABLE tasks (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ -- Organization
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ -- Basic info
+ title VARCHAR(500) NOT NULL,
+ description TEXT,
+ goal TEXT,
+
+ -- Status and priority
+ status task_status NOT NULL DEFAULT 'todo',
+ priority task_priority NOT NULL DEFAULT 'medium',
+
+ -- Assignment
+ assigned_to UUID REFERENCES users(id) ON DELETE SET NULL,
+ created_by UUID NOT NULL REFERENCES users(id) ON DELETE SET NULL,
+
+ -- Team/project grouping
+ team_id UUID,
+ project_id UUID,
+
+ -- Time tracking
+ deadline TIMESTAMP WITH TIME ZONE,
+ estimated_hours DOUBLE PRECISION,
+ actual_hours DOUBLE PRECISION DEFAULT 0.0,
+ started_at TIMESTAMP WITH TIME ZONE,
+ completed_at TIMESTAMP WITH TIME ZONE,
+
+ -- AI-generated scores
+ risk_score DOUBLE PRECISION,
+ confidence_score DOUBLE PRECISION,
+ complexity_score DOUBLE PRECISION,
+
+ -- Blocker info
+ blocker_type blocker_type,
+ blocker_description TEXT,
+
+ -- Metadata (JSONB)
+ tools JSONB DEFAULT '[]',
+ tags JSONB DEFAULT '[]',
+ skills_required JSONB DEFAULT '[]',
+
+ -- Parent task (for subtasks, self-reference)
+ parent_task_id UUID REFERENCES tasks(id) ON DELETE CASCADE,
+
+ -- Ordering
+ sort_order INTEGER DEFAULT 0,
+
+ -- Draft flag
+ is_draft BOOLEAN NOT NULL DEFAULT FALSE
+);
+
+CREATE INDEX ix_tasks_id ON tasks (id);
+CREATE INDEX ix_tasks_created_at ON tasks (created_at);
+CREATE INDEX ix_tasks_org_id ON tasks (org_id);
+CREATE INDEX ix_tasks_status ON tasks (status);
+CREATE INDEX ix_tasks_assigned_to ON tasks (assigned_to);
+CREATE INDEX ix_tasks_team_id ON tasks (team_id);
+CREATE INDEX ix_tasks_project_id ON tasks (project_id);
+CREATE INDEX ix_tasks_parent_task_id ON tasks (parent_task_id);
+CREATE INDEX ix_tasks_is_draft ON tasks (is_draft);
+
+-- ============================================================================
+-- TABLE: task_dependencies (depends on: tasks)
+-- ============================================================================
+
+CREATE TABLE task_dependencies (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+ depends_on_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+ is_blocking BOOLEAN DEFAULT TRUE,
+ description VARCHAR(500)
+);
+
+CREATE INDEX ix_task_dependencies_id ON task_dependencies (id);
+CREATE INDEX ix_task_dependencies_created_at ON task_dependencies (created_at);
+CREATE INDEX ix_task_dependencies_task_id ON task_dependencies (task_id);
+CREATE INDEX ix_task_dependencies_depends_on_id ON task_dependencies (depends_on_id);
+
+-- ============================================================================
+-- TABLE: task_history (depends on: tasks, users)
+-- ============================================================================
+
+CREATE TABLE task_history (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+ user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+
+ action VARCHAR(50) NOT NULL,
+ field_name VARCHAR(100),
+ old_value TEXT,
+ new_value TEXT,
+ details JSONB DEFAULT '{}'
+);
+
+CREATE INDEX ix_task_history_id ON task_history (id);
+CREATE INDEX ix_task_history_created_at ON task_history (created_at);
+CREATE INDEX ix_task_history_task_id ON task_history (task_id);
+
+-- ============================================================================
+-- TABLE: task_comments (depends on: tasks, users)
+-- ============================================================================
+
+CREATE TABLE task_comments (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+ user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+
+ content TEXT NOT NULL,
+ is_ai_generated BOOLEAN DEFAULT FALSE,
+ is_edited BOOLEAN DEFAULT FALSE
+);
+
+CREATE INDEX ix_task_comments_id ON task_comments (id);
+CREATE INDEX ix_task_comments_created_at ON task_comments (created_at);
+CREATE INDEX ix_task_comments_task_id ON task_comments (task_id);
+
+-- ============================================================================
+-- TABLE: checkins (depends on: tasks, users, organizations)
+-- ============================================================================
+
+CREATE TABLE checkins (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+ user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ -- Check-in metadata
+ cycle_number INTEGER DEFAULT 1,
+ trigger checkin_trigger DEFAULT 'scheduled',
+ status checkin_status DEFAULT 'pending',
+
+ -- Timing
+ scheduled_at TIMESTAMP WITH TIME ZONE NOT NULL,
+ responded_at TIMESTAMP WITH TIME ZONE,
+ expires_at TIMESTAMP WITH TIME ZONE,
+
+ -- User response
+ progress_indicator progress_indicator,
+ progress_notes TEXT,
+ completed_since_last TEXT,
+ blockers_reported TEXT,
+ help_needed BOOLEAN DEFAULT FALSE,
+ estimated_completion_change DOUBLE PRECISION,
+
+ -- AI analysis
+ ai_suggestion TEXT,
+ ai_confidence DOUBLE PRECISION,
+ sentiment_score DOUBLE PRECISION,
+ friction_detected BOOLEAN DEFAULT FALSE,
+
+ -- Escalation
+ escalated BOOLEAN DEFAULT FALSE,
+ escalated_to UUID REFERENCES users(id),
+ escalated_at TIMESTAMP WITH TIME ZONE,
+ escalation_reason TEXT
+);
+
+CREATE INDEX ix_checkins_id ON checkins (id);
+CREATE INDEX ix_checkins_created_at ON checkins (created_at);
+CREATE INDEX ix_checkins_task_id ON checkins (task_id);
+CREATE INDEX ix_checkins_user_id ON checkins (user_id);
+CREATE INDEX ix_checkins_org_id ON checkins (org_id);
+CREATE INDEX ix_checkins_status ON checkins (status);
+
+-- ============================================================================
+-- TABLE: checkin_configs (depends on: organizations, users, tasks)
+-- ============================================================================
+
+CREATE TABLE checkin_configs (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ -- Scope
+ team_id UUID,
+ user_id UUID REFERENCES users(id),
+ task_id UUID REFERENCES tasks(id),
+
+ -- Check-in settings
+ interval_hours DOUBLE PRECISION DEFAULT 3.0,
+ enabled BOOLEAN DEFAULT TRUE,
+ silent_mode_threshold DOUBLE PRECISION DEFAULT 0.8,
+ max_daily_checkins INTEGER DEFAULT 4,
+
+ -- Working hours
+ work_start_hour INTEGER DEFAULT 9,
+ work_end_hour INTEGER DEFAULT 18,
+ respect_timezone BOOLEAN DEFAULT TRUE,
+ excluded_days VARCHAR(50) DEFAULT '0,6',
+
+ -- Escalation settings
+ auto_escalate_after_missed INTEGER DEFAULT 2,
+ escalate_to_manager BOOLEAN DEFAULT TRUE,
+
+ -- AI settings
+ ai_suggestions_enabled BOOLEAN DEFAULT TRUE,
+ ai_sentiment_analysis BOOLEAN DEFAULT TRUE
+);
+
+CREATE INDEX ix_checkin_configs_id ON checkin_configs (id);
+CREATE INDEX ix_checkin_configs_created_at ON checkin_configs (created_at);
+CREATE INDEX ix_checkin_configs_org_id ON checkin_configs (org_id);
+CREATE INDEX ix_checkin_configs_team_id ON checkin_configs (team_id);
+CREATE INDEX ix_checkin_configs_user_id ON checkin_configs (user_id);
+CREATE INDEX ix_checkin_configs_task_id ON checkin_configs (task_id);
+
+-- ============================================================================
+-- TABLE: checkin_reminders (depends on: checkins, users)
+-- ============================================================================
+
+CREATE TABLE checkin_reminders (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ checkin_id UUID NOT NULL REFERENCES checkins(id) ON DELETE CASCADE,
+ user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+
+ reminder_number INTEGER DEFAULT 1,
+ channel VARCHAR(50) DEFAULT 'in_app',
+ sent_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
+ acknowledged BOOLEAN DEFAULT FALSE,
+ acknowledged_at TIMESTAMP WITH TIME ZONE
+);
+
+CREATE INDEX ix_checkin_reminders_id ON checkin_reminders (id);
+CREATE INDEX ix_checkin_reminders_created_at ON checkin_reminders (created_at);
+CREATE INDEX ix_checkin_reminders_checkin_id ON checkin_reminders (checkin_id);
+
+-- ============================================================================
+-- TABLE: documents (depends on: organizations)
+-- ============================================================================
+
+CREATE TABLE documents (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ -- Document metadata
+ title VARCHAR(500) NOT NULL,
+ description TEXT,
+ source document_source DEFAULT 'manual_upload',
+ source_url VARCHAR(2000),
+ source_id VARCHAR(255),
+
+ -- Content
+ content TEXT NOT NULL,
+ content_hash VARCHAR(64),
+ doc_type document_type DEFAULT 'documentation',
+
+ -- Processing status
+ status document_status DEFAULT 'pending',
+ error_message TEXT,
+ processed_at TIMESTAMP,
+
+ -- File metadata
+ file_name VARCHAR(500),
+ file_type VARCHAR(50),
+ file_size INTEGER,
+ storage_path VARCHAR(500),
+ storage_url VARCHAR(2000),
+ language VARCHAR(50) DEFAULT 'en',
+
+ -- Access control
+ is_public BOOLEAN DEFAULT FALSE,
+ team_ids JSONB DEFAULT '[]',
+
+ -- Categorization
+ tags JSONB DEFAULT '[]',
+ categories JSONB DEFAULT '[]',
+
+ -- Stats
+ view_count INTEGER DEFAULT 0,
+ helpful_count INTEGER DEFAULT 0,
+ not_helpful_count INTEGER DEFAULT 0,
+
+ -- Sync
+ last_synced_at TIMESTAMP,
+ sync_enabled BOOLEAN DEFAULT TRUE
+);
+
+CREATE INDEX ix_documents_id ON documents (id);
+CREATE INDEX ix_documents_created_at ON documents (created_at);
+CREATE INDEX ix_documents_org_id ON documents (org_id);
+CREATE INDEX ix_documents_status ON documents (status);
+
+-- ============================================================================
+-- TABLE: document_chunks (depends on: documents) — uses pgvector
+-- ============================================================================
+
+CREATE TABLE document_chunks (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
+
+ -- Chunk content
+ content TEXT NOT NULL,
+ chunk_index INTEGER NOT NULL,
+ start_char INTEGER,
+ end_char INTEGER,
+
+ -- Embedding (pgvector)
+ embedding vector(1536),
+ embedding_model VARCHAR(100),
+
+ -- Metadata
+ token_count INTEGER,
+ chunk_metadata JSONB DEFAULT '{}'
+);
+
+CREATE INDEX ix_document_chunks_id ON document_chunks (id);
+CREATE INDEX ix_document_chunks_created_at ON document_chunks (created_at);
+CREATE INDEX ix_document_chunks_document_id ON document_chunks (document_id);
+
+-- ============================================================================
+-- TABLE: unblock_sessions (depends on: organizations, users, tasks, checkins)
+-- ============================================================================
+
+CREATE TABLE unblock_sessions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+ task_id UUID REFERENCES tasks(id) ON DELETE SET NULL,
+ checkin_id UUID REFERENCES checkins(id) ON DELETE SET NULL,
+
+ -- Query
+ query TEXT NOT NULL,
+ blocker_type VARCHAR(50),
+ user_skill_level VARCHAR(50) DEFAULT 'intermediate',
+
+ -- Response
+ response TEXT,
+ confidence DOUBLE PRECISION,
+ sources JSONB DEFAULT '[]',
+
+ -- Escalation
+ escalation_recommended BOOLEAN DEFAULT FALSE,
+ escalated BOOLEAN DEFAULT FALSE,
+ escalated_to UUID REFERENCES users(id),
+
+ -- Feedback
+ was_helpful BOOLEAN,
+ feedback_text TEXT,
+ feedback_at TIMESTAMP
+);
+
+CREATE INDEX ix_unblock_sessions_id ON unblock_sessions (id);
+CREATE INDEX ix_unblock_sessions_created_at ON unblock_sessions (created_at);
+CREATE INDEX ix_unblock_sessions_org_id ON unblock_sessions (org_id);
+CREATE INDEX ix_unblock_sessions_user_id ON unblock_sessions (user_id);
+CREATE INDEX ix_unblock_sessions_task_id ON unblock_sessions (task_id);
+
+-- ============================================================================
+-- TABLE: skills (depends on: organizations)
+-- ============================================================================
+
+CREATE TABLE skills (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ name VARCHAR(200) NOT NULL,
+ description TEXT,
+ category skill_category DEFAULT 'technical',
+
+ -- Metadata (JSONB)
+ aliases JSONB DEFAULT '[]',
+ related_skills JSONB DEFAULT '[]',
+ prerequisites JSONB DEFAULT '[]',
+
+ -- Benchmarks
+ org_average_level DOUBLE PRECISION,
+ industry_average_level DOUBLE PRECISION,
+
+ -- Status
+ is_active BOOLEAN DEFAULT TRUE
+);
+
+CREATE INDEX ix_skills_id ON skills (id);
+CREATE INDEX ix_skills_created_at ON skills (created_at);
+CREATE INDEX ix_skills_org_id ON skills (org_id);
+
+-- ============================================================================
+-- TABLE: user_skills (depends on: users, skills, organizations)
+-- ============================================================================
+
+CREATE TABLE user_skills (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ skill_id UUID NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ -- Current assessment
+ level DOUBLE PRECISION DEFAULT 1.0,
+ confidence DOUBLE PRECISION DEFAULT 0.5,
+ trend skill_trend DEFAULT 'stable',
+
+ -- Evidence
+ last_demonstrated TIMESTAMP WITH TIME ZONE,
+ demonstration_count INTEGER DEFAULT 0,
+ source VARCHAR(50) DEFAULT 'inferred',
+
+ -- History (JSONB)
+ level_history JSONB DEFAULT '[]',
+ notes TEXT,
+
+ -- Certification
+ is_certified BOOLEAN DEFAULT FALSE,
+ certification_date TIMESTAMP WITH TIME ZONE,
+ certification_expiry TIMESTAMP WITH TIME ZONE
+);
+
+CREATE INDEX ix_user_skills_id ON user_skills (id);
+CREATE INDEX ix_user_skills_created_at ON user_skills (created_at);
+CREATE INDEX ix_user_skills_user_id ON user_skills (user_id);
+CREATE INDEX ix_user_skills_skill_id ON user_skills (skill_id);
+CREATE INDEX ix_user_skills_org_id ON user_skills (org_id);
+
+-- ============================================================================
+-- TABLE: skill_gaps (depends on: users, skills, organizations)
+-- ============================================================================
+
+CREATE TABLE skill_gaps (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ skill_id UUID NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ -- Gap details
+ gap_type gap_type DEFAULT 'growth',
+ current_level DOUBLE PRECISION,
+ required_level DOUBLE PRECISION NOT NULL,
+ gap_size DOUBLE PRECISION NOT NULL,
+
+ -- Context
+ for_role VARCHAR(200),
+ priority INTEGER DEFAULT 5,
+ identified_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
+
+ -- Resolution
+ is_resolved BOOLEAN DEFAULT FALSE,
+ resolved_at TIMESTAMP WITH TIME ZONE,
+
+ -- Recommendations (JSONB)
+ learning_resources JSONB DEFAULT '[]'
+);
+
+CREATE INDEX ix_skill_gaps_id ON skill_gaps (id);
+CREATE INDEX ix_skill_gaps_created_at ON skill_gaps (created_at);
+CREATE INDEX ix_skill_gaps_user_id ON skill_gaps (user_id);
+CREATE INDEX ix_skill_gaps_skill_id ON skill_gaps (skill_id);
+CREATE INDEX ix_skill_gaps_org_id ON skill_gaps (org_id);
+
+-- ============================================================================
+-- TABLE: skill_metrics (depends on: users, organizations)
+-- ============================================================================
+
+CREATE TABLE skill_metrics (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ -- Period
+ period_start TIMESTAMP WITH TIME ZONE NOT NULL,
+ period_end TIMESTAMP WITH TIME ZONE NOT NULL,
+
+ -- Velocity metrics
+ task_completion_velocity DOUBLE PRECISION,
+ quality_score DOUBLE PRECISION,
+ self_sufficiency_index DOUBLE PRECISION,
+ learning_velocity DOUBLE PRECISION,
+
+ -- Collaboration
+ collaboration_score DOUBLE PRECISION,
+ help_given_count INTEGER DEFAULT 0,
+ help_received_count INTEGER DEFAULT 0,
+
+ -- Blocker analysis
+ blockers_encountered INTEGER DEFAULT 0,
+ blockers_self_resolved INTEGER DEFAULT 0,
+ avg_blocker_resolution_hours DOUBLE PRECISION,
+
+ -- Peer comparison (percentile)
+ velocity_percentile DOUBLE PRECISION,
+ quality_percentile DOUBLE PRECISION,
+ learning_percentile DOUBLE PRECISION
+);
+
+CREATE INDEX ix_skill_metrics_id ON skill_metrics (id);
+CREATE INDEX ix_skill_metrics_created_at ON skill_metrics (created_at);
+CREATE INDEX ix_skill_metrics_user_id ON skill_metrics (user_id);
+CREATE INDEX ix_skill_metrics_org_id ON skill_metrics (org_id);
+
+-- ============================================================================
+-- TABLE: learning_paths (depends on: users, organizations)
+-- ============================================================================
+
+CREATE TABLE learning_paths (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ -- Path details
+ title VARCHAR(500) NOT NULL,
+ description TEXT,
+ target_role VARCHAR(200),
+
+ -- Skills to develop (JSONB)
+ skills_data JSONB DEFAULT '[]',
+ milestones JSONB DEFAULT '[]',
+
+ -- Progress
+ progress_percentage DOUBLE PRECISION DEFAULT 0.0,
+ started_at TIMESTAMP WITH TIME ZONE,
+ target_completion TIMESTAMP WITH TIME ZONE,
+ completed_at TIMESTAMP WITH TIME ZONE,
+
+ -- Status
+ is_active BOOLEAN DEFAULT TRUE,
+ is_ai_generated BOOLEAN DEFAULT TRUE
+);
+
+CREATE INDEX ix_learning_paths_id ON learning_paths (id);
+CREATE INDEX ix_learning_paths_created_at ON learning_paths (created_at);
+CREATE INDEX ix_learning_paths_user_id ON learning_paths (user_id);
+CREATE INDEX ix_learning_paths_org_id ON learning_paths (org_id);
+
+-- ============================================================================
+-- TABLE: predictions (depends on: organizations, tasks, users)
+-- ============================================================================
+
+CREATE TABLE predictions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ prediction_type prediction_type NOT NULL,
+
+ -- Target
+ task_id UUID REFERENCES tasks(id),
+ project_id UUID,
+ user_id UUID REFERENCES users(id),
+ team_id UUID,
+
+ -- Prediction values
+ predicted_date_p25 TIMESTAMP WITH TIME ZONE,
+ predicted_date_p50 TIMESTAMP WITH TIME ZONE,
+ predicted_date_p90 TIMESTAMP WITH TIME ZONE,
+ confidence DOUBLE PRECISION,
+ risk_score DOUBLE PRECISION,
+
+ -- Factors
+ risk_factors JSONB DEFAULT '[]',
+ model_version VARCHAR(50) DEFAULT 'v1',
+ features JSONB DEFAULT '{}',
+
+ -- Accuracy tracking
+ actual_date TIMESTAMP WITH TIME ZONE,
+ accuracy_score DOUBLE PRECISION
+);
+
+CREATE INDEX ix_predictions_id ON predictions (id);
+CREATE INDEX ix_predictions_created_at ON predictions (created_at);
+CREATE INDEX ix_predictions_org_id ON predictions (org_id);
+
+-- ============================================================================
+-- TABLE: velocity_snapshots (depends on: organizations)
+-- ============================================================================
+
+CREATE TABLE velocity_snapshots (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ team_id UUID NOT NULL,
+
+ period_start TIMESTAMP WITH TIME ZONE NOT NULL,
+ period_end TIMESTAMP WITH TIME ZONE NOT NULL,
+
+ tasks_completed INTEGER DEFAULT 0,
+ story_points_completed DOUBLE PRECISION DEFAULT 0,
+ velocity DOUBLE PRECISION,
+ capacity_utilization DOUBLE PRECISION
+);
+
+CREATE INDEX ix_velocity_snapshots_id ON velocity_snapshots (id);
+CREATE INDEX ix_velocity_snapshots_created_at ON velocity_snapshots (created_at);
+CREATE INDEX ix_velocity_snapshots_team_id ON velocity_snapshots (team_id);
+
+-- ============================================================================
+-- TABLE: automation_patterns (depends on: organizations, users)
+-- ============================================================================
+
+CREATE TABLE automation_patterns (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ -- Pattern details
+ name VARCHAR(500) NOT NULL,
+ description TEXT,
+ pattern_type VARCHAR(100),
+ status pattern_status DEFAULT 'detected',
+
+ -- Detection info
+ frequency_per_week DOUBLE PRECISION,
+ consistency_score DOUBLE PRECISION,
+ users_affected INTEGER DEFAULT 0,
+
+ -- Savings estimate
+ estimated_hours_saved_weekly DOUBLE PRECISION,
+ estimated_cost_savings_monthly DOUBLE PRECISION,
+ implementation_complexity INTEGER DEFAULT 5,
+
+ -- Automation details
+ automation_recipe JSONB DEFAULT '{}',
+ triggers JSONB DEFAULT '[]',
+ actions JSONB DEFAULT '[]',
+
+ -- User feedback
+ accepted_by UUID REFERENCES users(id),
+ accepted_at TIMESTAMP WITH TIME ZONE,
+ rejection_reason TEXT
+);
+
+CREATE INDEX ix_automation_patterns_id ON automation_patterns (id);
+CREATE INDEX ix_automation_patterns_created_at ON automation_patterns (created_at);
+CREATE INDEX ix_automation_patterns_org_id ON automation_patterns (org_id);
+
+-- ============================================================================
+-- TABLE: ai_agents (depends on: organizations, automation_patterns, users)
+-- ============================================================================
+
+CREATE TABLE ai_agents (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ pattern_id UUID REFERENCES automation_patterns(id),
+
+ -- Agent info
+ name VARCHAR(200) NOT NULL,
+ description TEXT,
+ status agent_status DEFAULT 'created',
+
+ -- Configuration
+ config JSONB DEFAULT '{}',
+ permissions JSONB DEFAULT '[]',
+
+ -- Shadow mode tracking
+ shadow_started_at TIMESTAMP WITH TIME ZONE,
+ shadow_match_rate DOUBLE PRECISION,
+ shadow_runs INTEGER DEFAULT 0,
+
+ -- Performance
+ total_runs INTEGER DEFAULT 0,
+ successful_runs INTEGER DEFAULT 0,
+ hours_saved_total DOUBLE PRECISION DEFAULT 0,
+ last_run_at TIMESTAMP WITH TIME ZONE,
+ live_started_at TIMESTAMP WITH TIME ZONE,
+
+ -- Ownership
+ created_by UUID NOT NULL REFERENCES users(id),
+ approved_by UUID REFERENCES users(id),
+ approved_at TIMESTAMP WITH TIME ZONE
+);
+
+CREATE INDEX ix_ai_agents_id ON ai_agents (id);
+CREATE INDEX ix_ai_agents_created_at ON ai_agents (created_at);
+CREATE INDEX ix_ai_agents_org_id ON ai_agents (org_id);
+
+-- ============================================================================
+-- TABLE: agent_runs (depends on: ai_agents, organizations)
+-- ============================================================================
+
+CREATE TABLE agent_runs (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ agent_id UUID NOT NULL REFERENCES ai_agents(id) ON DELETE CASCADE,
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ -- Execution
+ started_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
+ completed_at TIMESTAMP WITH TIME ZONE,
+ status VARCHAR(50) DEFAULT 'running',
+ execution_time_ms INTEGER,
+
+ -- Results
+ input_data JSONB DEFAULT '{}',
+ output_data JSONB DEFAULT '{}',
+ error_message TEXT,
+
+ -- Shadow mode comparison
+ is_shadow BOOLEAN DEFAULT FALSE,
+ human_action JSONB,
+ matched_human BOOLEAN
+);
+
+CREATE INDEX ix_agent_runs_id ON agent_runs (id);
+CREATE INDEX ix_agent_runs_created_at ON agent_runs (created_at);
+CREATE INDEX ix_agent_runs_agent_id ON agent_runs (agent_id);
+
+-- ============================================================================
+-- TABLE: workforce_scores (depends on: users, organizations)
+-- ============================================================================
+
+CREATE TABLE workforce_scores (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ snapshot_date TIMESTAMP WITH TIME ZONE DEFAULT now(),
+
+ -- Component scores (0-100)
+ velocity_score DOUBLE PRECISION,
+ quality_score DOUBLE PRECISION,
+ self_sufficiency_score DOUBLE PRECISION,
+ learning_score DOUBLE PRECISION,
+ collaboration_score DOUBLE PRECISION,
+
+ -- Composite
+ overall_score DOUBLE PRECISION,
+ percentile_rank DOUBLE PRECISION,
+
+ -- Risk indicators
+ attrition_risk_score DOUBLE PRECISION,
+ burnout_risk_score DOUBLE PRECISION,
+
+ -- Trend
+ score_trend VARCHAR(20) DEFAULT 'stable'
+);
+
+CREATE INDEX ix_workforce_scores_id ON workforce_scores (id);
+CREATE INDEX ix_workforce_scores_created_at ON workforce_scores (created_at);
+CREATE INDEX ix_workforce_scores_user_id ON workforce_scores (user_id);
+CREATE INDEX ix_workforce_scores_org_id ON workforce_scores (org_id);
+
+-- ============================================================================
+-- TABLE: manager_effectiveness (depends on: users, organizations)
+-- ============================================================================
+
+CREATE TABLE manager_effectiveness (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ manager_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ snapshot_date TIMESTAMP WITH TIME ZONE DEFAULT now(),
+
+ -- Team metrics
+ team_size INTEGER DEFAULT 0,
+ team_velocity_avg DOUBLE PRECISION,
+ team_quality_avg DOUBLE PRECISION,
+
+ -- Manager-specific
+ escalation_response_time_hours DOUBLE PRECISION,
+ escalation_resolution_rate DOUBLE PRECISION,
+ team_attrition_rate DOUBLE PRECISION,
+ team_satisfaction_score DOUBLE PRECISION,
+
+ -- AI comparison
+ redundancy_score DOUBLE PRECISION,
+ assignment_quality_score DOUBLE PRECISION,
+ workload_distribution_score DOUBLE PRECISION,
+
+ -- Ranking
+ effectiveness_score DOUBLE PRECISION,
+ org_percentile DOUBLE PRECISION
+);
+
+CREATE INDEX ix_manager_effectiveness_id ON manager_effectiveness (id);
+CREATE INDEX ix_manager_effectiveness_created_at ON manager_effectiveness (created_at);
+CREATE INDEX ix_manager_effectiveness_manager_id ON manager_effectiveness (manager_id);
+CREATE INDEX ix_manager_effectiveness_org_id ON manager_effectiveness (org_id);
+
+-- ============================================================================
+-- TABLE: org_health_snapshots (depends on: organizations)
+-- ============================================================================
+
+CREATE TABLE org_health_snapshots (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ snapshot_date TIMESTAMP WITH TIME ZONE DEFAULT now(),
+
+ -- Health components (0-100)
+ productivity_index DOUBLE PRECISION,
+ skill_coverage_index DOUBLE PRECISION,
+ management_quality_index DOUBLE PRECISION,
+ automation_maturity_index DOUBLE PRECISION,
+ delivery_predictability_index DOUBLE PRECISION,
+
+ -- Composite
+ overall_health_score DOUBLE PRECISION,
+
+ -- Key metrics
+ total_employees INTEGER DEFAULT 0,
+ active_tasks INTEGER DEFAULT 0,
+ blocked_tasks INTEGER DEFAULT 0,
+ overdue_tasks INTEGER DEFAULT 0,
+
+ -- Risk counts
+ high_attrition_risk_count INTEGER DEFAULT 0,
+ high_burnout_risk_count INTEGER DEFAULT 0
+);
+
+CREATE INDEX ix_org_health_snapshots_id ON org_health_snapshots (id);
+CREATE INDEX ix_org_health_snapshots_created_at ON org_health_snapshots (created_at);
+CREATE INDEX ix_org_health_snapshots_org_id ON org_health_snapshots (org_id);
+
+-- ============================================================================
+-- TABLE: restructuring_scenarios (depends on: organizations, users)
+-- ============================================================================
+
+CREATE TABLE restructuring_scenarios (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ created_by UUID NOT NULL REFERENCES users(id),
+
+ name VARCHAR(500) NOT NULL,
+ description TEXT,
+
+ -- Scenario config
+ scenario_type VARCHAR(100) NOT NULL,
+ config JSONB DEFAULT '{}',
+
+ -- Impact projections
+ projected_cost_change DOUBLE PRECISION,
+ projected_productivity_change DOUBLE PRECISION,
+ projected_skill_coverage_change DOUBLE PRECISION,
+ affected_employees INTEGER DEFAULT 0,
+
+ -- Risk assessment
+ risk_factors JSONB DEFAULT '[]',
+ overall_risk_score DOUBLE PRECISION,
+
+ -- Status
+ is_draft BOOLEAN DEFAULT TRUE,
+ executed BOOLEAN DEFAULT FALSE,
+ executed_at TIMESTAMP WITH TIME ZONE
+);
+
+CREATE INDEX ix_restructuring_scenarios_id ON restructuring_scenarios (id);
+CREATE INDEX ix_restructuring_scenarios_created_at ON restructuring_scenarios (created_at);
+CREATE INDEX ix_restructuring_scenarios_org_id ON restructuring_scenarios (org_id);
+
+-- ============================================================================
+-- TABLE: notifications (depends on: users, organizations, tasks, checkins)
+-- ============================================================================
+
+CREATE TABLE notifications (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ notification_type notification_type NOT NULL,
+ title VARCHAR(500) NOT NULL,
+ message TEXT,
+
+ -- Related entities
+ task_id UUID REFERENCES tasks(id),
+ checkin_id UUID REFERENCES checkins(id),
+
+ -- Status
+ is_read BOOLEAN DEFAULT FALSE,
+ read_at TIMESTAMP WITH TIME ZONE,
+
+ -- Delivery
+ channel notification_channel DEFAULT 'in_app',
+ delivered BOOLEAN DEFAULT FALSE,
+ delivered_at TIMESTAMP WITH TIME ZONE,
+
+ -- Action
+ action_url VARCHAR(1000),
+ action_data JSONB DEFAULT '{}'
+);
+
+CREATE INDEX ix_notifications_id ON notifications (id);
+CREATE INDEX ix_notifications_created_at ON notifications (created_at);
+CREATE INDEX ix_notifications_user_id ON notifications (user_id);
+CREATE INDEX ix_notifications_org_id ON notifications (org_id);
+
+-- ============================================================================
+-- TABLE: notification_preferences (depends on: users, organizations)
+-- ============================================================================
+
+CREATE TABLE notification_preferences (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ notification_type notification_type NOT NULL,
+ channel notification_channel NOT NULL,
+
+ enabled BOOLEAN DEFAULT TRUE,
+ quiet_hours_start INTEGER,
+ quiet_hours_end INTEGER,
+ batch_frequency_minutes INTEGER DEFAULT 0
+);
+
+CREATE INDEX ix_notification_preferences_id ON notification_preferences (id);
+CREATE INDEX ix_notification_preferences_created_at ON notification_preferences (created_at);
+CREATE INDEX ix_notification_preferences_user_id ON notification_preferences (user_id);
+
+-- ============================================================================
+-- TABLE: integrations (depends on: organizations, users)
+-- ============================================================================
+
+CREATE TABLE integrations (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ integration_type integration_type NOT NULL,
+ name VARCHAR(200) NOT NULL,
+ description TEXT,
+
+ -- Connection
+ is_active BOOLEAN DEFAULT FALSE,
+ config JSONB DEFAULT '{}',
+ credentials JSONB DEFAULT '{}',
+
+ -- Sync
+ sync_enabled BOOLEAN DEFAULT TRUE,
+ last_sync_at TIMESTAMP WITH TIME ZONE,
+ last_sync_status VARCHAR(50),
+ sync_error TEXT,
+
+ -- OAuth
+ oauth_access_token TEXT,
+ oauth_refresh_token TEXT,
+ oauth_expires_at TIMESTAMP WITH TIME ZONE,
+
+ connected_by UUID REFERENCES users(id),
+ connected_at TIMESTAMP WITH TIME ZONE
+);
+
+CREATE INDEX ix_integrations_id ON integrations (id);
+CREATE INDEX ix_integrations_created_at ON integrations (created_at);
+CREATE INDEX ix_integrations_org_id ON integrations (org_id);
+
+-- ============================================================================
+-- TABLE: webhooks (depends on: organizations, users)
+-- ============================================================================
+
+CREATE TABLE webhooks (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ name VARCHAR(200) NOT NULL,
+ url VARCHAR(2000) NOT NULL,
+ secret VARCHAR(255),
+
+ -- Events
+ events JSONB DEFAULT '[]',
+
+ -- Status
+ is_active BOOLEAN DEFAULT TRUE,
+
+ -- Headers
+ headers JSONB DEFAULT '{}',
+
+ -- Stats
+ total_deliveries INTEGER DEFAULT 0,
+ successful_deliveries INTEGER DEFAULT 0,
+ last_delivery_at TIMESTAMP WITH TIME ZONE,
+ last_delivery_status INTEGER,
+
+ created_by UUID NOT NULL REFERENCES users(id)
+);
+
+CREATE INDEX ix_webhooks_id ON webhooks (id);
+CREATE INDEX ix_webhooks_created_at ON webhooks (created_at);
+CREATE INDEX ix_webhooks_org_id ON webhooks (org_id);
+
+-- ============================================================================
+-- TABLE: webhook_deliveries (depends on: webhooks, organizations)
+-- ============================================================================
+
+CREATE TABLE webhook_deliveries (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ webhook_id UUID NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE,
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ event_type VARCHAR(100) NOT NULL,
+ payload JSONB DEFAULT '{}',
+
+ -- Delivery
+ attempted_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
+ response_status INTEGER,
+ response_body TEXT,
+ response_time_ms INTEGER,
+
+ -- Retry
+ retry_count INTEGER DEFAULT 0,
+ next_retry_at TIMESTAMP WITH TIME ZONE,
+ is_successful BOOLEAN DEFAULT FALSE
+);
+
+CREATE INDEX ix_webhook_deliveries_id ON webhook_deliveries (id);
+CREATE INDEX ix_webhook_deliveries_created_at ON webhook_deliveries (created_at);
+CREATE INDEX ix_webhook_deliveries_webhook_id ON webhook_deliveries (webhook_id);
+
+-- ============================================================================
+-- TABLE: audit_logs (depends on: organizations)
+-- ============================================================================
+
+CREATE TABLE audit_logs (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ -- Actor
+ actor_type actor_type NOT NULL,
+ actor_id UUID,
+ actor_name VARCHAR(200),
+
+ -- Action
+ action audit_action NOT NULL,
+ resource_type VARCHAR(100) NOT NULL,
+ resource_id UUID,
+
+ -- Details
+ description TEXT,
+ old_value JSONB,
+ new_value JSONB,
+ audit_metadata JSONB DEFAULT '{}',
+
+ -- Context
+ ip_address VARCHAR(45),
+ user_agent VARCHAR(500),
+ request_id VARCHAR(36),
+
+ -- Timestamp (immutable record)
+ timestamp TIMESTAMP WITH TIME ZONE DEFAULT now()
+);
+
+CREATE INDEX ix_audit_logs_id ON audit_logs (id);
+CREATE INDEX ix_audit_logs_created_at ON audit_logs (created_at);
+CREATE INDEX ix_audit_logs_org_id ON audit_logs (org_id);
+CREATE INDEX ix_audit_logs_timestamp ON audit_logs (timestamp);
+
+-- ============================================================================
+-- TABLE: gdpr_requests (depends on: organizations, users)
+-- ============================================================================
+
+CREATE TABLE gdpr_requests (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ user_id UUID NOT NULL REFERENCES users(id),
+
+ request_type VARCHAR(50) NOT NULL,
+ status VARCHAR(50) DEFAULT 'pending',
+
+ -- Processing
+ requested_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
+ processed_at TIMESTAMP WITH TIME ZONE,
+ completed_at TIMESTAMP WITH TIME ZONE,
+ processed_by UUID REFERENCES users(id),
+
+ -- Result
+ result_url VARCHAR(2000),
+ result_expiry TIMESTAMP WITH TIME ZONE,
+ error_message TEXT,
+
+ -- Verification
+ verification_token VARCHAR(255),
+ verified_at TIMESTAMP WITH TIME ZONE
+);
+
+CREATE INDEX ix_gdpr_requests_id ON gdpr_requests (id);
+CREATE INDEX ix_gdpr_requests_created_at ON gdpr_requests (created_at);
+CREATE INDEX ix_gdpr_requests_org_id ON gdpr_requests (org_id);
+CREATE INDEX ix_gdpr_requests_user_id ON gdpr_requests (user_id);
+
+-- ============================================================================
+-- TABLE: api_keys (depends on: organizations, users)
+-- ============================================================================
+
+CREATE TABLE api_keys (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ user_id UUID NOT NULL REFERENCES users(id),
+
+ name VARCHAR(200) NOT NULL,
+ key_hash VARCHAR(255) NOT NULL,
+ key_prefix VARCHAR(10) NOT NULL,
+
+ -- Permissions
+ scopes JSONB DEFAULT '[]',
+ is_full_access BOOLEAN DEFAULT FALSE,
+
+ -- Status
+ is_active BOOLEAN DEFAULT TRUE,
+ expires_at TIMESTAMP WITH TIME ZONE,
+
+ -- Usage
+ last_used_at TIMESTAMP WITH TIME ZONE,
+ last_used_ip VARCHAR(45),
+ usage_count INTEGER DEFAULT 0,
+
+ -- Limits
+ rate_limit INTEGER DEFAULT 1000,
+ current_usage INTEGER DEFAULT 0,
+ usage_reset_at TIMESTAMP WITH TIME ZONE,
+
+ created_by UUID NOT NULL REFERENCES users(id)
+);
+
+CREATE INDEX ix_api_keys_id ON api_keys (id);
+CREATE INDEX ix_api_keys_created_at ON api_keys (created_at);
+CREATE INDEX ix_api_keys_org_id ON api_keys (org_id);
+
+-- ============================================================================
+-- TABLE: system_health (no foreign key dependencies)
+-- ============================================================================
+
+CREATE TABLE system_health (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ snapshot_time TIMESTAMP WITH TIME ZONE DEFAULT now(),
+
+ -- Database
+ db_connections_active INTEGER,
+ db_query_avg_ms DOUBLE PRECISION,
+
+ -- API
+ api_requests_per_minute INTEGER,
+ api_error_rate DOUBLE PRECISION,
+ api_latency_p50_ms DOUBLE PRECISION,
+ api_latency_p99_ms DOUBLE PRECISION,
+
+ -- AI
+ ai_requests_per_hour INTEGER,
+ ai_avg_latency_ms DOUBLE PRECISION,
+ ai_cache_hit_rate DOUBLE PRECISION,
+
+ -- Background jobs
+ jobs_pending INTEGER,
+ jobs_failed INTEGER,
+
+ -- Storage
+ storage_used_mb DOUBLE PRECISION,
+
+ -- Alerts
+ active_alerts JSONB DEFAULT '[]'
+);
+
+CREATE INDEX ix_system_health_id ON system_health (id);
+CREATE INDEX ix_system_health_created_at ON system_health (created_at);
+CREATE INDEX ix_system_health_snapshot_time ON system_health (snapshot_time);
+
+-- ============================================================================
+-- TABLE: agents (Phase 15 - Agent Orchestration) (depends on: organizations)
+-- ============================================================================
+
+CREATE TABLE agents (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id),
+
+ -- Identity
+ name VARCHAR(100) NOT NULL UNIQUE,
+ display_name VARCHAR(200) NOT NULL,
+ description TEXT,
+ version VARCHAR(20) DEFAULT '1.0.0',
+
+ -- Classification
+ agent_type agent_type_enum NOT NULL DEFAULT 'ai',
+ capabilities JSONB DEFAULT '[]',
+
+ -- Status
+ status agent_status_db DEFAULT 'active',
+ is_enabled BOOLEAN DEFAULT TRUE,
+
+ -- Configuration
+ config JSONB DEFAULT '{}',
+ permissions JSONB DEFAULT '[]',
+
+ -- Metrics
+ execution_count INTEGER DEFAULT 0,
+ success_count INTEGER DEFAULT 0,
+ error_count INTEGER DEFAULT 0,
+ avg_duration_ms DOUBLE PRECISION,
+ last_execution_at TIMESTAMP WITH TIME ZONE
+);
+
+CREATE INDEX ix_agents_id ON agents (id);
+CREATE INDEX ix_agents_created_at ON agents (created_at);
+CREATE INDEX ix_agents_org_id ON agents (org_id);
+
+-- ============================================================================
+-- TABLE: agent_executions (depends on: agents, organizations, users, tasks)
+-- ============================================================================
+
+CREATE TABLE agent_executions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ agent_id UUID NOT NULL REFERENCES agents(id),
+ org_id UUID NOT NULL REFERENCES organizations(id),
+
+ -- Trigger information
+ event_type VARCHAR(100) NOT NULL,
+ event_id UUID,
+ trigger_source VARCHAR(100),
+
+ -- Context
+ user_id UUID REFERENCES users(id),
+ task_id UUID REFERENCES tasks(id),
+ context_data JSONB DEFAULT '{}',
+
+ -- Execution details
+ status execution_status DEFAULT 'pending',
+ started_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
+ completed_at TIMESTAMP WITH TIME ZONE,
+ duration_ms INTEGER,
+
+ -- Results
+ success BOOLEAN DEFAULT FALSE,
+ output_data JSONB DEFAULT '{}',
+ error_message TEXT,
+ error_code VARCHAR(50),
+
+ -- Metrics
+ tokens_used INTEGER DEFAULT 0,
+ api_calls INTEGER DEFAULT 0,
+
+ -- Chain information
+ parent_execution_id UUID REFERENCES agent_executions(id),
+ chain_depth INTEGER DEFAULT 0
+);
+
+CREATE INDEX ix_agent_executions_id ON agent_executions (id);
+CREATE INDEX ix_agent_executions_created_at ON agent_executions (created_at);
+CREATE INDEX ix_agent_executions_agent_id ON agent_executions (agent_id);
+CREATE INDEX ix_agent_executions_org_id ON agent_executions (org_id);
+
+-- ============================================================================
+-- TABLE: agent_conversations (depends on: organizations, users)
+-- ============================================================================
+
+CREATE TABLE agent_conversations (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ org_id UUID NOT NULL REFERENCES organizations(id),
+ user_id UUID NOT NULL REFERENCES users(id),
+
+ -- Conversation metadata
+ title VARCHAR(200),
+ agent_name VARCHAR(100) NOT NULL DEFAULT 'chat_agent',
+
+ -- State
+ is_active BOOLEAN DEFAULT TRUE,
+ message_count INTEGER DEFAULT 0,
+
+ -- Conversation data
+ messages JSONB DEFAULT '[]',
+ context_data JSONB DEFAULT '{}',
+
+ -- Timestamps
+ started_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
+ last_message_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
+ ended_at TIMESTAMP WITH TIME ZONE
+);
+
+CREATE INDEX ix_agent_conversations_id ON agent_conversations (id);
+CREATE INDEX ix_agent_conversations_created_at ON agent_conversations (created_at);
+CREATE INDEX ix_agent_conversations_org_id ON agent_conversations (org_id);
+CREATE INDEX ix_agent_conversations_user_id ON agent_conversations (user_id);
+
+-- ============================================================================
+-- TABLE: agent_schedules (depends on: agents, organizations)
+-- ============================================================================
+
+CREATE TABLE agent_schedules (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ agent_id UUID NOT NULL REFERENCES agents(id),
+ org_id UUID NOT NULL REFERENCES organizations(id),
+
+ -- Schedule definition
+ name VARCHAR(100) NOT NULL,
+ cron_expression VARCHAR(100) NOT NULL,
+ timezone VARCHAR(50) DEFAULT 'UTC',
+
+ -- Configuration
+ is_enabled BOOLEAN DEFAULT TRUE,
+ config JSONB DEFAULT '{}',
+
+ -- Tracking
+ last_run_at TIMESTAMP WITH TIME ZONE,
+ next_run_at TIMESTAMP WITH TIME ZONE,
+ run_count INTEGER DEFAULT 0,
+ failure_count INTEGER DEFAULT 0
+);
+
+CREATE INDEX ix_agent_schedules_id ON agent_schedules (id);
+CREATE INDEX ix_agent_schedules_created_at ON agent_schedules (created_at);
+CREATE INDEX ix_agent_schedules_agent_id ON agent_schedules (agent_id);
+CREATE INDEX ix_agent_schedules_org_id ON agent_schedules (org_id);
+
+-- ============================================================================
+-- END OF SCHEMA
+-- ============================================================================
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index bdac03f..94865e6 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -10,11 +10,16 @@
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
+from fastapi import Depends
+from fastapi.security import OAuth2PasswordBearer
+from sqlalchemy import select
+
from app.main import app
from app.database import Base, get_db
from app.models.user import User, UserRole
from app.models.organization import Organization, PlanTier
-from app.core.security import hash_password, create_access_token
+from app.core.security import hash_password, create_access_token, decode_token
+from app.api.v1.dependencies import get_current_user
from app.utils.helpers import generate_uuid
# Test database URL
@@ -65,12 +70,45 @@ async def test_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
@pytest.fixture(scope="function")
async def client(test_session) -> AsyncGenerator[AsyncClient, None]:
- """Create test HTTP client with database override."""
+ """Create test HTTP client with database and auth overrides."""
async def override_get_db():
yield test_session
+ # OAuth2 scheme for extracting the Bearer token from the request
+ _oauth2_scheme = OAuth2PasswordBearer(
+ tokenUrl="/api/v1/auth/login",
+ auto_error=False,
+ )
+
+ async def override_get_current_user(
+ token: str | None = Depends(_oauth2_scheme),
+ ) -> User | None:
+ """
+ Test override for get_current_user.
+
+ Decodes the legacy HS256 test token (created by create_access_token)
+ and looks up the user by ID in the test database, bypassing Supabase
+ JWT verification entirely.
+ """
+ if not token:
+ return None
+
+ payload = decode_token(token)
+ if not payload:
+ return None
+
+ user_id = payload.get("sub")
+ if not user_id:
+ return None
+
+ result = await test_session.execute(
+ select(User).where(User.id == user_id)
+ )
+ return result.scalar_one_or_none()
+
app.dependency_overrides[get_db] = override_get_db
+ app.dependency_overrides[get_current_user] = override_get_current_user
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
diff --git a/backend/tests/test_checkins.py b/backend/tests/test_checkins.py
index fbe4bf6..f8550d7 100644
--- a/backend/tests/test_checkins.py
+++ b/backend/tests/test_checkins.py
@@ -5,7 +5,7 @@
import pytest
from httpx import AsyncClient
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
from tests.conftest import auth_headers
from app.models.task import Task, TaskStatus, TaskPriority
@@ -42,7 +42,7 @@ async def test_list_checkins(
task_id=task.id,
user_id=test_user.id,
cycle_number=i + 1,
- scheduled_at=datetime.utcnow() - timedelta(hours=3 * i)
+ scheduled_at=datetime.now(timezone.utc) - timedelta(hours=3 * i)
)
test_session.add(checkin)
await test_session.commit()
@@ -79,7 +79,7 @@ async def test_get_checkin(
task_id=task.id,
user_id=test_user.id,
cycle_number=1,
- scheduled_at=datetime.utcnow()
+ scheduled_at=datetime.now(timezone.utc)
)
test_session.add(checkin)
await test_session.commit()
@@ -116,7 +116,7 @@ async def test_respond_to_checkin(
task_id=task.id,
user_id=test_user.id,
cycle_number=1,
- scheduled_at=datetime.utcnow(),
+ scheduled_at=datetime.now(timezone.utc),
response_status="pending"
)
test_session.add(checkin)
@@ -159,7 +159,7 @@ async def test_respond_with_blocker(
task_id=task.id,
user_id=test_user.id,
cycle_number=1,
- scheduled_at=datetime.utcnow(),
+ scheduled_at=datetime.now(timezone.utc),
response_status="pending"
)
test_session.add(checkin)
@@ -245,7 +245,7 @@ async def test_get_pending_checkins(
task_id=task.id,
user_id=test_user.id,
cycle_number=1,
- scheduled_at=datetime.utcnow() - timedelta(hours=1),
+ scheduled_at=datetime.now(timezone.utc) - timedelta(hours=1),
response_status="pending"
)
test_session.add(checkin)
@@ -282,7 +282,7 @@ async def test_escalate_checkin(
task_id=task.id,
user_id=test_user.id,
cycle_number=1,
- scheduled_at=datetime.utcnow(),
+ scheduled_at=datetime.now(timezone.utc),
response_status="responded",
has_blocker=True
)
diff --git a/public/.gitkeep b/public/.gitkeep
new file mode 100644
index 0000000..48cdce8
--- /dev/null
+++ b/public/.gitkeep
@@ -0,0 +1 @@
+placeholder
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..30b1a10
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,30 @@
+[project]
+name = "taskpulse-api"
+version = "1.0.0"
+description = "TaskPulse AI Backend API"
+requires-python = ">=3.12"
+dependencies = [
+ "fastapi>=0.109.0",
+ "uvicorn[standard]>=0.27.0",
+ "python-multipart>=0.0.6",
+ "sqlalchemy>=2.0.25",
+ "asyncpg>=0.29.0",
+ "greenlet>=3.0.3",
+ "pgvector>=0.2.5",
+ "supabase>=2.3.0",
+ "python-jose[cryptography]>=3.3.0",
+ "bcrypt>=4.1.2",
+ "email-validator>=2.1.0",
+ "apscheduler>=3.10.0",
+ "pydantic>=2.6.0",
+ "pydantic-settings>=2.1.0",
+ "openai>=1.12.0",
+ "anthropic>=0.18.0",
+ "tiktoken>=0.6.0",
+ "python-dotenv>=1.0.0",
+ "httpx>=0.27.0",
+ "tenacity>=8.2.3",
+]
+
+[project.scripts]
+app = "backend.app.main:app"
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..d7d79fc
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,37 @@
+# Vercel Python serverless function dependencies
+
+# Core Framework
+fastapi>=0.109.0
+uvicorn[standard]>=0.27.0
+python-multipart>=0.0.6
+
+# Database
+sqlalchemy>=2.0.25
+asyncpg>=0.29.0
+greenlet>=3.0.3
+pgvector>=0.2.5
+
+# Supabase
+supabase>=2.3.0
+
+# Authentication
+python-jose[cryptography]>=3.3.0
+bcrypt>=4.1.2
+email-validator>=2.1.0
+
+# Scheduler
+apscheduler>=3.10.0
+
+# Validation & Config
+pydantic>=2.6.0
+pydantic-settings>=2.1.0
+
+# AI (mock provider — lightweight)
+openai>=1.12.0
+anthropic>=0.18.0
+tiktoken>=0.6.0
+
+# Utilities
+python-dotenv>=1.0.0
+httpx>=0.27.0
+tenacity>=8.2.3
diff --git a/vercel.json b/vercel.json
new file mode 100644
index 0000000..9980854
--- /dev/null
+++ b/vercel.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "https://openapi.vercel.sh/vercel.json",
+ "builds": [
+ {
+ "src": "Frontend/package.json",
+ "use": "@vercel/static-build",
+ "config": { "distDir": "dist" }
+ },
+ {
+ "src": "api/index.py",
+ "use": "@vercel/python"
+ }
+ ],
+ "routes": [
+ { "src": "/api/(.*)", "dest": "/api/index.py" },
+ { "src": "/(.*)", "dest": "/Frontend/$1", "check": true },
+ { "src": "/(.*)", "dest": "/Frontend/index.html" }
+ ]
+}