diff --git a/backend/api/api_gateway/main.py b/backend/api/api_gateway/main.py index e2ced0f..f95d255 100644 --- a/backend/api/api_gateway/main.py +++ b/backend/api/api_gateway/main.py @@ -15,6 +15,13 @@ # Load environment variables load_dotenv() +MAX_REQUEST_BODY_SIZE = 1 * 1024 * 1024 # 1MB +EXCLUDED_HEADERS = { + "host", "connection", "keep-alive", "proxy-authenticate", + "proxy-authorization", "te", "trailers", "transfer-encoding", "upgrade", + "content-length", +} + # Create FastAPI app app = FastAPI( title="TaskHub API Gateway", @@ -89,24 +96,38 @@ async def forward_request( Returns: JSONResponse: Response from service """ - # Get request body - body = await request.body() - - # Get request headers - headers = dict(request.headers) + # Filter headers + temp_headers = {} + for name, value in request.headers.items(): + if name.lower() not in EXCLUDED_HEADERS: + temp_headers[name] = value - # Add user ID to headers if available if hasattr(request.state, "user_id"): - headers["X-User-ID"] = request.state.user_id + temp_headers["X-User-ID"] = str(request.state.user_id) + + # Prepare arguments for circuit_breaker.call_service + request_body = await request.body() + + if len(request_body) > MAX_REQUEST_BODY_SIZE: + return JSONResponse( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + content={"detail": f"Request body exceeds maximum allowed size of {MAX_REQUEST_BODY_SIZE} bytes."} + ) + + service_kwargs = { + "headers": temp_headers, + "params": dict(request.query_params) + } + + if request.method.upper() not in ("GET", "HEAD", "DELETE"): + service_kwargs["content"] = request_body # Forward request to service using circuit breaker response = await circuit_breaker.call_service( # type: ignore service_name=service_name, url=target_url, method=request.method, - headers=headers, - content=body, - params=dict(request.query_params), + **service_kwargs ) # Return response diff --git a/backend/api/api_gateway/middleware/auth_middleware.py b/backend/api/api_gateway/middleware/auth_middleware.py index 61e5a9b..f1b7f80 100644 --- a/backend/api/api_gateway/middleware/auth_middleware.py +++ b/backend/api/api_gateway/middleware/auth_middleware.py @@ -1,21 +1,25 @@ import os from typing import Awaitable, Callable, Optional -import httpx from dotenv import load_dotenv from fastapi import HTTPException, Request, status from fastapi.responses import JSONResponse +from jose import ExpiredSignatureError, JWTError, jwt # Load environment variables load_dotenv() -# Auth service URL -AUTH_SERVICE_URL = os.getenv("AUTH_SERVICE_URL", "http://localhost:8001") +SUPABASE_JWT_SECRET = os.getenv("SUPABASE_JWT_SECRET") +SUPABASE_AUDIENCE = os.getenv("SUPABASE_AUDIENCE", "authenticated") +# Optional: Add SUPABASE_ISSUER if you want to validate the 'iss' claim, e.g.: +# SUPABASE_ISSUER = os.getenv("SUPABASE_ISSUER") async def auth_middleware( request: Request, call_next: Callable[[Request], Awaitable[JSONResponse]] ) -> JSONResponse: + if request.method == "OPTIONS": + return await call_next(request) """ Middleware for authentication. @@ -102,56 +106,44 @@ def _get_token_from_request(request: Request) -> Optional[str]: async def _validate_token(token: str) -> str: - """ - Validate token with auth service. - - Args: - token (str): JWT token - - Returns: - str: User ID + if not SUPABASE_JWT_SECRET: + print('ERROR: SUPABASE_JWT_SECRET is not configured in the environment.') + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Authentication system configuration error.', + ) - Raises: - HTTPException: If token is invalid - """ try: - # Make request to auth service - async with httpx.AsyncClient() as client: - response = await client.get( - f"{AUTH_SERVICE_URL}/auth/validate", - headers={"Authorization": f"Bearer {token}"}, + payload = jwt.decode( + token, + SUPABASE_JWT_SECRET, + algorithms=['HS256'], + audience=SUPABASE_AUDIENCE + # If validating issuer, add: issuer=SUPABASE_ISSUER + ) + + user_id = payload.get('sub') + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail='Invalid token: User ID (sub) not found in token.', ) + + return user_id - # Check response - if response.status_code != 200: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" - ) - - # Parse response - data = response.json() - - # Extract user ID from token - # In a real application, you would decode the token and extract the user ID - # For simplicity, we'll assume the auth service returns the user ID - user_id = data.get("user_id") - - if not user_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token, user_id not in response", - ) - - return user_id - except httpx.RequestError as e: + except ExpiredSignatureError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail='Token has expired.' + ) + except JWTError as e: + print(f'JWTError during token validation: {str(e)}') # Server log raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=f"Auth service unavailable: {str(e)}", + status_code=status.HTTP_401_UNAUTHORIZED, + detail='Invalid token.', ) except Exception as e: - # It's good practice to log the error here - # logger.error(f"Unexpected error during token validation with auth service: {str(e)}") + print(f'Unexpected error during token validation: {str(e)}') # Server log raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="An unexpected error occurred while validating the token.", + detail='An unexpected error occurred during token validation.', ) diff --git a/backend/api/api_gateway/middleware/circuit_breaker.py b/backend/api/api_gateway/middleware/circuit_breaker.py index 357fcd8..3f9f922 100644 --- a/backend/api/api_gateway/middleware/circuit_breaker.py +++ b/backend/api/api_gateway/middleware/circuit_breaker.py @@ -23,7 +23,7 @@ def __init__( self, failure_threshold: int = 5, recovery_timeout: int = 30, - timeout: float = 5.0, + timeout: float = 10.0, ): """ Initialize CircuitBreaker. @@ -31,7 +31,7 @@ def __init__( Args: failure_threshold (int, optional): Number of failures before opening circuit. Defaults to 5. recovery_timeout (int, optional): Seconds to wait before trying again. Defaults to 30. - timeout (float, optional): Request timeout in seconds. Defaults to 5.0. + timeout (float, optional): Request timeout in seconds. Defaults to 10.0. """ self.failure_threshold = failure_threshold self.recovery_timeout = recovery_timeout diff --git a/backend/api/api_gateway/utils/service_registry.py b/backend/api/api_gateway/utils/service_registry.py index 01df7bc..7ae0e6a 100644 --- a/backend/api/api_gateway/utils/service_registry.py +++ b/backend/api/api_gateway/utils/service_registry.py @@ -151,6 +151,9 @@ def __init__(self): "methods": ["POST"], }, {"path": "/health", "methods": ["GET"]}, + {"path": "/analytics/card/{card_id}", "methods": ["GET"]}, + {"path": "/calendar/events", "methods": ["GET", "POST"]}, + {"path": "/ai/inference/{model}", "methods": ["POST"]}, ], }, } diff --git a/backend/api/auth_service/app/main.py b/backend/api/auth_service/app/main.py index 3b26d8e..274bbde 100644 --- a/backend/api/auth_service/app/main.py +++ b/backend/api/auth_service/app/main.py @@ -7,7 +7,7 @@ from api.auth_service.app.schemas.user import ( TokenDTO, - TokenValidationResponseDTO, + # TokenValidationResponseDTO, # No longer needed UserProfileDTO, UserRegisterDTO, ) @@ -67,33 +67,6 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends()): return auth_service.login(form_data.username, form_data.password) -@app.get( - "/auth/validate", response_model=TokenValidationResponseDTO, tags=["Authentication"] -) -async def validate(token: str = Security(oauth2_scheme)): - """ - Validate a token. Also returns user_id along with new tokens. - - Args: - token (str): JWT token - """ - return auth_service.validate_token(token) - - -@app.post("/auth/refresh", response_model=TokenDTO, tags=["Authentication"]) -async def refresh(refresh_token: str) -> Any: - """ - Refresh a token. - - Args: - refresh_token (str): Refresh token - - Returns: - TokenDTO: Authentication tokens - """ - return auth_service.refresh_token(refresh_token) - - @app.post("/auth/logout", tags=["Authentication"]) async def logout(token: str = Security(oauth2_scheme)): """ @@ -131,3 +104,4 @@ async def health_check() -> Any: Dict[str, str]: Health status """ return {"status": "healthy"} + diff --git a/backend/api/auth_service/app/services/auth_service.py b/backend/api/auth_service/app/services/auth_service.py index dd41e78..a2d6979 100644 --- a/backend/api/auth_service/app/services/auth_service.py +++ b/backend/api/auth_service/app/services/auth_service.py @@ -1,5 +1,5 @@ import os -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone # Removed timedelta from typing import Any, Dict from api.auth_service.app.schemas.user import TokenDTO, UserProfileDTO, UserRegisterDTO @@ -7,15 +7,11 @@ EmailAlreadyExistsException, InvalidCredentialsException, InvalidTokenException, - TokenExpiredException, -) -from api.shared.utils.jwt import ( - create_access_token, - create_refresh_token, - decode_token, - is_token_valid, + # TokenExpiredException, # No longer raised directly by methods in this class ) +# Imports from api.shared.utils.jwt are no longer needed here from api.shared.utils.supabase import SupabaseManager +from fastapi import HTTPException # For raising 500 error for unexpected issues class AuthService: @@ -51,23 +47,27 @@ def register(self, user_data: UserRegisterDTO) -> TokenDTO: user_data.email, user_data.password, user_metadata ) - # Get user data - user = response.user + if not response.session: + # This case might happen if email confirmation is pending + # Depending on desired UX, could raise an error or return a specific DTO + raise InvalidCredentialsException("User registration succeeded, but session not available. Please confirm your email.") - # Create tokens - access_token = create_access_token({"sub": user.id}) - refresh_token = create_refresh_token({"sub": user.id}) + # Get session data + session = response.session - # Calculate expiration time - expires_at = datetime.now(timezone.utc) + timedelta( - minutes=self.token_expire_minutes - ) + # Extract token details from Supabase session + access_token = session.access_token + refresh_token = session.refresh_token + expires_at_timestamp = session.expires_at + expires_at_dt = datetime.fromtimestamp(expires_at_timestamp, tz=timezone.utc) + token_type = session.token_type # Return tokens return TokenDTO( access_token=access_token, refresh_token=refresh_token, - expires_at=expires_at, + expires_at=expires_at_dt, + token_type=token_type, ) except Exception as _e: # Check if email already exists @@ -93,115 +93,30 @@ def login(self, email: str, password: str) -> TokenDTO: # Sign in user in Supabase response = self.supabase_manager.sign_in(email, password) - # Get user data - user = response.user - - # Create tokens - access_token = create_access_token({"sub": user.id}) - refresh_token = create_refresh_token({"sub": user.id}) + if not response.session: + raise InvalidCredentialsException("Login failed, session not available.") - # Calculate expiration time - expires_at = datetime.now(timezone.utc) + timedelta( - minutes=self.token_expire_minutes - ) + # Get session data + session = response.session + # Extract token details from Supabase session + access_token = session.access_token + refresh_token = session.refresh_token + expires_at_timestamp = session.expires_at + expires_at_dt = datetime.fromtimestamp(expires_at_timestamp, tz=timezone.utc) + token_type = session.token_type + # Return tokens return TokenDTO( access_token=access_token, refresh_token=refresh_token, - expires_at=expires_at, + expires_at=expires_at_dt, + token_type=token_type, ) except Exception as _e: # Invalid credentials raise InvalidCredentialsException() - def validate_token(self, token: str) -> Dict[str, Any]: - """ - Validate a token. - - Args: - token (str): JWT token - - Returns: - Dict[str, Any]: User ID and Authentication tokens - - Raises: - InvalidTokenException: If token is invalid - TokenExpiredException: If token has expired - """ - # decode_token from shared.utils.jwt already raises TokenExpiredException or InvalidTokenException - payload = decode_token(token) - - user_id = payload.get("sub") - if not user_id: - raise InvalidTokenException("User ID (sub) not found in token payload") - - # Create new tokens - access_token = create_access_token({"sub": user_id}) - refresh_token = create_refresh_token({"sub": user_id}) - - # Calculate expiration time - expires_at = datetime.now(timezone.utc) + timedelta( - minutes=self.token_expire_minutes - ) - - # Return user_id and tokens - return { - "user_id": user_id, - "access_token": access_token, - "refresh_token": refresh_token, - "token_type": "bearer", - "expires_at": expires_at, - } - - def refresh_token(self, refresh_token: str) -> TokenDTO: - """ - Refresh a token. - - Args: - refresh_token (str): Refresh token - - Returns: - TokenDTO: Authentication tokens - - Raises: - InvalidTokenException: If token is invalid - TokenExpiredException: If token has expired - """ - try: - # Decode token - payload = decode_token(refresh_token) - - # Check if token is valid - if not is_token_valid(refresh_token): - raise InvalidTokenException() - - # Get user ID - user_id = payload.get("sub") - - # Create new tokens - access_token = create_access_token({"sub": user_id}) - new_refresh_token = create_refresh_token({"sub": user_id}) - - # Calculate expiration time - expires_at = datetime.now(timezone.utc) + timedelta( - minutes=self.token_expire_minutes - ) - - # Return tokens - return TokenDTO( - access_token=access_token, - refresh_token=new_refresh_token, - expires_at=expires_at, - ) - except Exception as _e: - # Check if token has expired - if "expired" in str(_e): - raise TokenExpiredException() - - # Invalid token - raise InvalidTokenException() - def logout(self, token: str) -> Dict[str, Any]: """ Logout a user. @@ -237,31 +152,74 @@ def get_user_profile(self, token: str) -> UserProfileDTO: Raises: InvalidTokenException: If token is invalid + HTTPException: If there is an unexpected error processing the profile """ try: - # Get user from Supabase + # print(f"[DEBUG AuthService.get_user_profile] Attempting to get user from Supabase with token: {token[:20]}...") # Optional debug line response = self.supabase_manager.get_user(token) + user = response.user # This is a User object from supabase-py - # Get user data - user = response.user - - # Safely access user metadata user_metadata = getattr(user, "user_metadata", {}) or {} if not isinstance(user_metadata, dict): user_metadata = {} - # Return user profile + # Helper to handle datetime conversion robustly + def _to_datetime(timestamp_val): + if timestamp_val is None: + return None + if isinstance(timestamp_val, datetime): + return timestamp_val # Already a datetime object + if isinstance(timestamp_val, str): + # Handle 'Z' for UTC if present, common in ISO strings + # Also handle potential existing timezone info from fromisoformat compat + try: + if timestamp_val.endswith('Z'): + # Replace Z with +00:00 for full ISO compatibility across Python versions + return datetime.fromisoformat(timestamp_val[:-1] + '+00:00') + dt_obj = datetime.fromisoformat(timestamp_val) + # If it's naive, assume UTC, as Supabase timestamps are UTC + if dt_obj.tzinfo is None: + return dt_obj.replace(tzinfo=timezone.utc) + return dt_obj + except ValueError as ve: + print(f"[WARN AuthService.get_user_profile] Could not parse timestamp string '{timestamp_val}': {ve}") + return None # Or raise a specific error / handle as appropriate + if isinstance(timestamp_val, (int, float)): # Supabase might return epoch timestamp + try: + return datetime.fromtimestamp(timestamp_val, tz=timezone.utc) + except ValueError as ve: + print(f"[WARN AuthService.get_user_profile] Could not parse numeric timestamp '{timestamp_val}': {ve}") + return None + + print(f"[WARN AuthService.get_user_profile] Unexpected type for timestamp '{timestamp_val}': {type(timestamp_val)}") + return None # Or raise error + + created_at_dt = _to_datetime(user.created_at) + updated_at_dt = _to_datetime(user.updated_at) if user.updated_at else None + + if not isinstance(created_at_dt, datetime) and user.created_at is not None: + # This implies parsing failed for a non-None original value or type was unexpected + print(f"[ERROR AuthService.get_user_profile] Failed to convert user.created_at (value: {user.created_at}, type: {type(user.created_at)}) to datetime.") + raise HTTPException(status_code=500, detail="Error processing user profile data (created_at).") + return UserProfileDTO( id=user.id, email=user.email, full_name=user_metadata.get("full_name", ""), company_name=user_metadata.get("company_name", ""), - role="user", # Default role - created_at=datetime.fromisoformat(user.created_at), - updated_at=( - datetime.fromisoformat(user.updated_at) if user.updated_at else None - ), + role="user", # Default role + created_at=created_at_dt, + updated_at=updated_at_dt, ) - except Exception as _e: - # Invalid token - raise InvalidTokenException() + except InvalidTokenException as e: # Re-raise specific known auth exceptions + # This might be raised by supabase_manager.get_user() if token is truly invalid by Supabase + raise e + except HTTPException as e: # If we raised one above + raise e + except Exception as e: + # Log the original error for server-side debugging + print(f"[ERROR AuthService.get_user_profile] Unexpected exception processing user profile: {type(e).__name__} - {str(e)}") + import traceback + print(traceback.format_exc()) + # Raise a generic server error to the client + raise HTTPException(status_code=500, detail="An internal error occurred while processing the user profile.") diff --git a/backend/api/document_service/app/main.py b/backend/api/document_service/app/main.py index ad39653..761a442 100644 --- a/backend/api/document_service/app/main.py +++ b/backend/api/document_service/app/main.py @@ -10,6 +10,8 @@ Security, UploadFile, File, + HTTPException, + Header ) from fastapi.middleware.cors import CORSMiddleware from fastapi.security import OAuth2PasswordBearer @@ -55,29 +57,10 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login") -def get_current_user(token: str = Security(oauth2_scheme)) -> str: - """ - Get current user ID from token. - - Args: - token (str): JWT token - - Returns: - str: User ID - - Raises: - InvalidTokenException: If token is invalid - """ - try: - payload = decode_token(token) - user_id = payload.get("sub") - - if not user_id: - raise InvalidTokenException() - - return user_id - except Exception: - raise InvalidTokenException() +async def get_current_user(x_user_id: Optional[str] = Header(None, alias="X-User-ID")) -> str: + if not x_user_id: + raise HTTPException(status_code=401, detail="Not authenticated (X-User-ID header missing)") + return x_user_id # Document endpoints diff --git a/backend/api/external_tools_service/app/main.py b/backend/api/external_tools_service/app/main.py index 09ef943..36955dc 100644 --- a/backend/api/external_tools_service/app/main.py +++ b/backend/api/external_tools_service/app/main.py @@ -1,7 +1,7 @@ -from typing import Any, List +from typing import Any, List, Optional from dotenv import load_dotenv -from fastapi import Depends, FastAPI, Path, Security, Body +from fastapi import Depends, FastAPI, Path, Security, Body, Header, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.security import OAuth2PasswordBearer from sqlalchemy.orm import Session @@ -47,29 +47,10 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login") -def get_current_user(token: str = Security(oauth2_scheme)) -> str: - """ - Get current user ID from token. - - Args: - token (str): JWT token - - Returns: - str: User ID - - Raises: - InvalidTokenException: If token is invalid - """ - try: - payload = decode_token(token) - user_id = payload.get("sub") - - if not user_id: - raise InvalidTokenException() - - return user_id - except Exception: - raise InvalidTokenException() +async def get_current_user(x_user_id: Optional[str] = Header(None, alias="X-User-ID")) -> Optional[str]: + if not x_user_id: + raise HTTPException(status_code=401, detail="Not authenticated (X-User-ID header missing)") + return x_user_id # OAuth provider endpoints diff --git a/backend/api/notification_service/app/main.py b/backend/api/notification_service/app/main.py index 75a3bb8..f4c53ce 100644 --- a/backend/api/notification_service/app/main.py +++ b/backend/api/notification_service/app/main.py @@ -1,7 +1,7 @@ -from typing import Any, List +from typing import Any, List, Optional from dotenv import load_dotenv -from fastapi import Depends, FastAPI, Path, Query, Security +from fastapi import Depends, FastAPI, Path, Query, Security, HTTPException, Header from fastapi.middleware.cors import CORSMiddleware from fastapi.security import OAuth2PasswordBearer from sqlalchemy.orm import Session @@ -44,29 +44,10 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login") -def get_current_user(token: str = Security(oauth2_scheme)) -> str: - """ - Get current user ID from token. - - Args: - token (str): JWT token - - Returns: - str: User ID - - Raises: - InvalidTokenException: If token is invalid - """ - try: - payload = decode_token(token) - user_id = payload.get("sub") - - if not user_id: - raise InvalidTokenException() - - return user_id - except Exception: - raise InvalidTokenException() +async def get_current_user(x_user_id: Optional[str] = Header(None, alias="X-User-ID")) -> str: + if not x_user_id: + raise HTTPException(status_code=401, detail="Not authenticated (X-User-ID header missing)") + return x_user_id # Notification endpoints diff --git a/backend/api/project_service/app/main.py b/backend/api/project_service/app/main.py index ac7243c..13ee4fd 100644 --- a/backend/api/project_service/app/main.py +++ b/backend/api/project_service/app/main.py @@ -1,7 +1,7 @@ from typing import Any, List, Optional from dotenv import load_dotenv -from fastapi import Depends, FastAPI, HTTPException, Path, Query, Security +from fastapi import Depends, FastAPI, HTTPException, Path, Query, Security, Header from fastapi.middleware.cors import CORSMiddleware from fastapi.security import OAuth2PasswordBearer from sqlalchemy.orm import Session @@ -61,29 +61,11 @@ command_invoker = CommandInvoker() -def get_current_user(token: str = Security(oauth2_scheme)) -> str: - """ - Get current user ID from token. - - Args: - token (str): JWT token - - Returns: - str: User ID - - Raises: - InvalidTokenException: If token is invalid - """ - try: - payload = decode_token(token) - user_id = payload.get("sub") - - if not user_id: - raise InvalidTokenException() - - return user_id - except Exception: - raise InvalidTokenException() +# A dependency to get the user ID, assuming it's always provided by the gateway for protected routes +async def get_current_user(x_user_id: Optional[str] = Header(None, alias="X-User-ID")) -> str: + if not x_user_id: + raise HTTPException(status_code=401, detail="Not authenticated (X-User-ID header missing)") + return x_user_id # Project endpoints @@ -580,7 +562,7 @@ async def assign_task( priority=task.priority, status=task.status, tags=list(task.tags) if task.tags is not None else [], - metadata=(task.metadata or {}), + meta_data=(task.metadata or {}), created_at=task.created_at, updated_at=task.updated_at, ) diff --git a/backend/api/shared/models/__init__.py b/backend/api/shared/models/__init__.py index 40c6d25..8085087 100644 --- a/backend/api/shared/models/__init__.py +++ b/backend/api/shared/models/__init__.py @@ -1 +1,20 @@ """Package initialization.""" + +from .base import Base, BaseModel +from .user import User, user_roles +from .project import Project, ProjectMember +from .document import Document +from .notification import Notification +from .external_tools import ExternalToolConnection + +__all__ = [ + 'Base', + 'BaseModel', + 'User', + 'Project', + 'ProjectMember', + 'Document', + 'Notification', + 'ExternalToolConnection', + 'user_roles', +] diff --git a/backend/api/shared/models/document.py b/backend/api/shared/models/document.py index 37eb4b6..52b9377 100644 --- a/backend/api/shared/models/document.py +++ b/backend/api/shared/models/document.py @@ -1,9 +1,13 @@ from sqlalchemy import JSON, Boolean, ForeignKey, Integer, String, Text from sqlalchemy.orm import relationship, Mapped, mapped_column -from typing import Any, Optional +from typing import TYPE_CHECKING, Any, Optional from .base import BaseModel +if TYPE_CHECKING: + from .project import Project + from .user import User + class Document(BaseModel): """Document model""" diff --git a/backend/api/shared/models/external_tools.py b/backend/api/shared/models/external_tools.py index 43b8523..985c15c 100644 --- a/backend/api/shared/models/external_tools.py +++ b/backend/api/shared/models/external_tools.py @@ -8,9 +8,13 @@ String, ) from sqlalchemy.orm import relationship +from typing import TYPE_CHECKING from .base import BaseModel +if TYPE_CHECKING: + from .user import User + class OAuthProvider(BaseModel): """OAuth provider model""" @@ -36,9 +40,13 @@ class ExternalToolConnection(BaseModel): __tablename__ = "external_tool_connections" - user_id = Column(String, ForeignKey("users.id"), nullable=False) - provider_id = Column(String, ForeignKey("oauth_providers.id"), nullable=False) - access_token = Column(String, nullable=False) + user_id: str = Column( + String, ForeignKey("users.id"), nullable=False + ) + provider_id: str = Column( + String, ForeignKey("oauth_providers.id"), nullable=False + ) + access_token: str = Column(String, nullable=False) refresh_token = Column(String, nullable=True) token_type = Column(String, nullable=True) scope = Column(String, nullable=True) diff --git a/backend/api/shared/models/notification.py b/backend/api/shared/models/notification.py index ce7f25b..afaa7d9 100644 --- a/backend/api/shared/models/notification.py +++ b/backend/api/shared/models/notification.py @@ -1,8 +1,12 @@ from sqlalchemy import JSON, Boolean, Column, DateTime, ForeignKey, String, Text from sqlalchemy.orm import relationship +from typing import TYPE_CHECKING from .base import BaseModel +if TYPE_CHECKING: + from .user import User + class Notification(BaseModel): """Notification model""" diff --git a/backend/api/shared/models/project.py b/backend/api/shared/models/project.py index 6b2f5e7..c2fb069 100644 --- a/backend/api/shared/models/project.py +++ b/backend/api/shared/models/project.py @@ -7,9 +7,15 @@ Text, ) from sqlalchemy.orm import relationship +from typing import TYPE_CHECKING from .base import BaseModel +if TYPE_CHECKING: + from .user import User + from .document import Document + from .activity_log import ActivityLog + class Project(BaseModel): """Project model""" diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml deleted file mode 100644 index f2d07ca..0000000 --- a/backend/docker-compose.yml +++ /dev/null @@ -1,228 +0,0 @@ -version: '3.8' - -services: - # API Gateway - api_gateway: - build: - context: . - dockerfile: Dockerfile - command: python -m uvicorn api.api_gateway.main:app --host 0.0.0.0 --port 8000 --reload --reload-dir /app/api/api_gateway - ports: - - "8000:8000" - env_file: - - .env - environment: - - AUTH_SERVICE_URL=http://auth_service:8001 - - PROJECT_SERVICE_URL=http://project_service:8002 - - DOCUMENT_SERVICE_URL=http://document_service:8003 - - NOTIFICATION_SERVICE_URL=http://notification_service:8004 - - EXTERNAL_TOOLS_SERVICE_URL=http://external_tools_service:8005 - - DATABASE_URL=postgresql://postgres:Adminqwert1234db@db.bhpkrxaqmlnyoxmcxxth.supabase.co:5432/postgres - - JWT_ALGORITHM=HS256 - - ACCESS_TOKEN_EXPIRE_MINUTES=30 - - REFRESH_TOKEN_EXPIRE_DAYS=7 - - PYTHONPATH=/app - depends_on: - - auth_service - - project_service - - document_service - - notification_service - - external_tools_service - networks: - - taskhub-network - restart: unless-stopped - volumes: - - ./api:/app/api - - # Auth Service - auth_service: - build: - context: . - dockerfile: Dockerfile - command: python -m uvicorn api.auth_service.app.main:app --host 0.0.0.0 --port 8001 --reload --reload-dir /app/api/auth_service/app - ports: - - "8001:8001" - env_file: - - .env - environment: - - DATABASE_URL=postgresql://postgres:Adminqwert1234db@db.bhpkrxaqmlnyoxmcxxth.supabase.co:5432/postgres - - JWT_ALGORITHM=HS256 - - ACCESS_TOKEN_EXPIRE_MINUTES=30 - - REFRESH_TOKEN_EXPIRE_DAYS=7 - - PYTHONPATH=/app - depends_on: - - rabbitmq - networks: - - taskhub-network - restart: unless-stopped - volumes: - - ./api:/app/api - - # Project Service - project_service: - build: - context: . - dockerfile: Dockerfile - command: python -m uvicorn api.project_service.app.main:app --host 0.0.0.0 --port 8002 --reload --reload-dir /app/api/project_service/app - ports: - - "8002:8002" - env_file: - - .env - environment: - - DATABASE_URL=postgresql://postgres:Adminqwert1234db@db.bhpkrxaqmlnyoxmcxxth.supabase.co:5432/postgres - - JWT_ALGORITHM=HS256 - - RABBITMQ_HOST=rabbitmq - - RABBITMQ_PORT=5672 - - RABBITMQ_USER=guest - - RABBITMQ_PASSWORD=guest - - PYTHONPATH=/app - depends_on: - - rabbitmq - networks: - - taskhub-network - restart: unless-stopped - volumes: - - ./api:/app/api - - # Document Service - document_service: - build: - context: . - dockerfile: Dockerfile - command: python -m uvicorn api.document_service.app.main:app --host 0.0.0.0 --port 8003 --reload --reload-dir /app/api/document_service/app - ports: - - "8003:8003" - env_file: - - .env - environment: - - DATABASE_URL=postgresql://postgres:Adminqwert1234db@db.bhpkrxaqmlnyoxmcxxth.supabase.co:5432/postgres - - JWT_ALGORITHM=HS256 - - RABBITMQ_HOST=rabbitmq - - RABBITMQ_PORT=5672 - - RABBITMQ_USER=guest - - RABBITMQ_PASSWORD=guest - - PYTHONPATH=/app - depends_on: - - rabbitmq - networks: - - taskhub-network - restart: unless-stopped - volumes: - - ./api:/app/api - - # Notification Service - notification_service: - build: - context: . - dockerfile: Dockerfile - command: python -m uvicorn api.notification_service.app.main:app --host 0.0.0.0 --port 8004 --reload --reload-dir /app/api/notification_service/app - ports: - - "8004:8004" - env_file: - - .env - environment: - - DATABASE_URL=postgresql://postgres:Adminqwert1234db@db.bhpkrxaqmlnyoxmcxxth.supabase.co:5432/postgres - - JWT_ALGORITHM=HS256 - - RABBITMQ_HOST=rabbitmq - - RABBITMQ_PORT=5672 - - RABBITMQ_USER=guest - - RABBITMQ_PASSWORD=guest - - PYTHONPATH=/app - depends_on: - - rabbitmq - networks: - - taskhub-network - restart: unless-stopped - volumes: - - ./api:/app/api - - # External Tools Service - external_tools_service: - build: - context: . - dockerfile: Dockerfile - command: python -m uvicorn api.external_tools_service.app.main:app --host 0.0.0.0 --port 8005 --reload --reload-dir /app/api/external_tools_service/app - ports: - - "8005:8005" - env_file: - - .env - environment: - - DATABASE_URL=postgresql://postgres:Adminqwert1234db@db.bhpkrxaqmlnyoxmcxxth.supabase.co:5432/postgres - - JWT_ALGORITHM=HS256 - - RABBITMQ_HOST=rabbitmq - - RABBITMQ_PORT=5672 - - RABBITMQ_USER=guest - - RABBITMQ_PASSWORD=guest - - PYTHONPATH=/app - depends_on: - - rabbitmq - networks: - - taskhub-network - restart: unless-stopped - volumes: - - ./api:/app/api - - # RabbitMQ - rabbitmq: - image: rabbitmq:3-management - ports: - - "5672:5672" - - "15672:15672" - environment: - - RABBITMQ_DEFAULT_USER=guest - - RABBITMQ_DEFAULT_PASS=guest - volumes: - - rabbitmq_data:/var/lib/rabbitmq - networks: - - taskhub-network - restart: unless-stopped - - libreoffice: - image: collabora/code - ports: - - "9980:9980" - environment: - - domain=.* - - username=admin - - password=admin - command: --o:ssl.enable=false --o:net.listen.allow=0.0.0.0 - restart: unless-stopped - networks: - - taskhub-network - - metabase: - image: metabase/metabase - ports: - - "3000:3000" - restart: unless-stopped - networks: - - taskhub-network - - gotify: - image: gotify/server - ports: - - "8080:80" - restart: unless-stopped - networks: - - taskhub-network - - radicale: - image: tomsquest/docker-radicale:latest - container_name: radicale - ports: - - "5232:5232" - volumes: - - radicale_data:/data - environment: - - RADICALE_CONFIG=/data/config - restart: unless-stopped - networks: - - taskhub-network - -networks: - taskhub-network: - driver: bridge - -volumes: - rabbitmq_data: - radicale_data: \ No newline at end of file diff --git a/backend/docs/API_DOCUMENTATION.md b/backend/docs/API_DOCUMENTATION.md new file mode 100644 index 0000000..c83832b --- /dev/null +++ b/backend/docs/API_DOCUMENTATION.md @@ -0,0 +1,3956 @@ +# Task Hub API Documentation + +This document provides a comprehensive overview of all API endpoints for the Task Hub microservices. + +--- +# Auth Service API Documentation + +This document provides details about the API endpoints for the Auth Service. + +## POST /auth/register + +**Description:** Register a new user. + +**Required Headers:** +- `Content-Type`: application/json + +**Path Parameters:** +- None + +**Query Parameters:** +- None + +**Request Body:** +```json +{ + "email": "", + "password": "", + "full_name": "", + "company_name": "" +} +``` + +**Response Body:** (`200 OK`) +```json +{ + "access_token": "", + "refresh_token": "", + "token_type": "", + "expires_at": "" +} +``` + +**Example Request (curl):** +```bash +curl -X POST "http://localhost:8000/auth/register" \ + -H "Content-Type: application/json" \ + -d '{ + "email": "user@example.com", + "password": "securepassword123", + "full_name": "Test User", + "company_name": "Test Inc." + }' +``` + +**Example Response (JSON):** +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "token_type": "bearer", + "expires_at": "2023-10-27T10:00:00Z" +} +``` + +--- + +## POST /auth/login + +**Description:** Login a user. + +**Required Headers:** +- `Content-Type`: application/x-www-form-urlencoded + +**Path Parameters:** +- None + +**Query Parameters:** +- None + +**Request Body (Form Data):** +- `username`: +- `password`: + +**Response Body:** (`200 OK`) +```json +{ + "access_token": "", + "refresh_token": "", + "token_type": "", + "expires_at": "" +} +``` + +**Example Request (curl):** +```bash +curl -X POST "http://localhost:8000/auth/login" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=user@example.com&password=securepassword123" +``` + +**Example Response (JSON):** +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "token_type": "bearer", + "expires_at": "2023-10-27T10:00:00Z" +} +``` + +--- + +## GET /auth/validate + +**Description:** Validate a token. Also returns user_id along with new tokens. (Note: The service might issue new tokens upon validation, or re-validate existing ones. The DTO suggests new tokens are returned). + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- None + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK`) +```json +{ + "access_token": "", + "refresh_token": "", + "token_type": "", + "expires_at": "", + "user_id": "" +} +``` + +**Example Request (curl):** +```bash +curl -X GET "http://localhost:8000/auth/validate" \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +``` + +**Example Response (JSON):** +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "token_type": "bearer", + "expires_at": "2023-10-27T11:00:00Z", + "user_id": "some-user-id-123" +} +``` + +--- + +## POST /auth/refresh + +**Description:** Refresh a token. + +**Required Headers:** +- `Content-Type`: application/json + +**Path Parameters:** +- None + +**Query Parameters:** +- None + +**Request Body:** +```json +{ + "refresh_token": "" +} +``` +*(Note: The endpoint expects a JSON body with a `refresh_token` field, based on the `refresh_token: str` type hint in `main.py` and common FastAPI practices for such inputs when `Content-Type` is `application/json`.)* + +**Response Body:** (`200 OK`) +```json +{ + "access_token": "", + "refresh_token": "", + "token_type": "", + "expires_at": "" +} +``` + +**Example Request (curl):** +```bash +curl -X POST "http://localhost:8000/auth/refresh" \ + -H "Content-Type: application/json" \ + -d '{ + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.oldrefreshtoken..." + }' +``` + +**Example Response (JSON):** +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.newaccesstoken...", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.newrefreshtoken...", + "token_type": "bearer", + "expires_at": "2023-10-27T12:00:00Z" +} +``` + +--- + +## POST /auth/logout + +**Description:** Logout a user. (Invalidates the token on the server-side, if applicable, or performs cleanup.) + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- None + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK`) +```json +{ + "message": "" +} +``` +*(Note: The actual response from `auth_service.logout(token)` is `Dict[str, Any]`. A common response is `{"message": "Successfully logged out"}` or similar. This is an assumption.)* + +**Example Request (curl):** +```bash +curl -X POST "http://localhost:8000/auth/logout" \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.sometoken..." +``` + +**Example Response (JSON):** +```json +{ + "message": "Successfully logged out" +} +``` + +--- + +## GET /auth/profile + +**Description:** Get user profile. + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- None + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK`) +```json +{ + "id": "", + "email": "", + "full_name": "", + "company_name": "", + "role": "", + "created_at": "", + "updated_at": "" +} +``` + +**Example Request (curl):** +```bash +curl -X GET "http://localhost:8000/auth/profile" \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.sometoken..." +``` + +**Example Response (JSON):** +```json +{ + "id": "some-user-id-123", + "email": "user@example.com", + "full_name": "Test User", + "company_name": "Test Inc.", + "role": "user", + "created_at": "2023-10-26T10:00:00Z", + "updated_at": "2023-10-26T12:00:00Z" +} +``` + +--- + +## GET /health + +**Description:** Health check endpoint. Standard health check to verify service availability. + +**Required Headers:** +- None + +**Path Parameters:** +- None + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK`) +```json +{ + "status": "" +} +``` + +**Example Request (curl):** +```bash +curl -X GET "http://localhost:8000/health" +``` + +**Example Response (JSON):** +```json +{ + "status": "healthy" +} +``` + +--- +# Project Service API Documentation + +This document provides details about the API endpoints for the Project Service. All routes require `Authorization: Bearer ` header for authentication and expect `Content-Type: application/json` for request bodies. + +## Projects Endpoints + +### POST /projects + +**Description:** Create a new project. + +**Required Headers:** +- `Content-Type`: application/json +- `Authorization`: Bearer + +**Path Parameters:** +- None + +**Query Parameters:** +- None + +**Request Body:** (`ProjectCreateDTO`) +```json +{ + "name": "", + "description": "", + "start_date": "", + "end_date": "", + "status": "", + "tags": "", + "meta_data": "" +} +``` + +**Response Body:** (`200 OK` - `ProjectResponseDTO`) +```json +{ + "id": "", + "name": "", + "description": "", + "start_date": "", + "end_date": "", + "status": "", + "owner_id": "", + "tags": "", + "meta_data": "", + "created_at": "", + "updated_at": "" +} +``` + +**Example Request (curl):** +```bash +curl -X POST "http://localhost:8000/projects" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "name": "New Mobile App", + "description": "Development of a new cross-platform mobile application.", + "status": "planning", + "tags": ["mobile", "flutter"] + }' +``` + +**Example Response (JSON):** +```json +{ + "id": "proj_123abc", + "name": "New Mobile App", + "description": "Development of a new cross-platform mobile application.", + "start_date": null, + "end_date": null, + "status": "planning", + "owner_id": "user_xyz789", + "tags": ["mobile", "flutter"], + "meta_data": null, + "created_at": "2023-10-27T10:00:00Z", + "updated_at": null +} +``` + +--- + +### GET /projects + +**Description:** Get projects for current user. + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- None + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK` - `List[ProjectResponseDTO]`) +```json +[ + { + "id": "", + "name": "", + "description": "", + "start_date": "", + "end_date": "", + "status": "", + "owner_id": "", + "tags": "", + "meta_data": "", + "created_at": "", + "updated_at": "" + } + // ... more projects +] +``` + +**Example Request (curl):** +```bash +curl -X GET "http://localhost:8000/projects" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON):** +```json +[ + { + "id": "proj_123abc", + "name": "New Mobile App", + "description": "Development of a new cross-platform mobile application.", + "start_date": null, + "end_date": null, + "status": "planning", + "owner_id": "user_xyz789", + "tags": ["mobile", "flutter"], + "meta_data": null, + "created_at": "2023-10-27T10:00:00Z", + "updated_at": null + }, + { + "id": "proj_456def", + "name": "Website Redesign", + "description": "Complete overhaul of the company website.", + "start_date": "2023-11-01T00:00:00Z", + "end_date": "2024-03-01T00:00:00Z", + "status": "in_progress", + "owner_id": "user_xyz789", + "tags": ["web", "react"], + "meta_data": {"client": "Internal"}, + "created_at": "2023-10-15T09:00:00Z", + "updated_at": "2023-10-20T14:30:00Z" + } +] +``` + +--- + +### GET /projects/{project_id} + +**Description:** Get a project. + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- `project_id`: + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK` - `ProjectResponseDTO`) +```json +{ + "id": "", + "name": "", + "description": "", + "start_date": "", + "end_date": "", + "status": "", + "owner_id": "", + "tags": "", + "meta_data": "", + "created_at": "", + "updated_at": "" +} +``` + +**Example Request (curl):** +```bash +curl -X GET "http://localhost:8000/projects/proj_123abc" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON):** +```json +{ + "id": "proj_123abc", + "name": "New Mobile App", + "description": "Development of a new cross-platform mobile application.", + "start_date": null, + "end_date": null, + "status": "planning", + "owner_id": "user_xyz789", + "tags": ["mobile", "flutter"], + "meta_data": null, + "created_at": "2023-10-27T10:00:00Z", + "updated_at": null +} +``` + +--- + +### PUT /projects/{project_id} + +**Description:** Update a project. + +**Required Headers:** +- `Content-Type`: application/json +- `Authorization`: Bearer + +**Path Parameters:** +- `project_id`: + +**Query Parameters:** +- None + +**Request Body:** (`ProjectUpdateDTO`) +```json +{ + "name": "", + "description": "", + "start_date": "", + "end_date": "", + "status": "", + "tags": "", + "meta_data": "" +} +``` + +**Response Body:** (`200 OK` - `ProjectResponseDTO`) +```json +{ + "id": "", + "name": "", + "description": "", + // ... other fields from ProjectResponseDTO + "updated_at": "" +} +``` + +**Example Request (curl):** +```bash +curl -X PUT "http://localhost:8000/projects/proj_123abc" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "description": "Updated description for the mobile app.", + "status": "in_progress" + }' +``` + +**Example Response (JSON):** +```json +{ + "id": "proj_123abc", + "name": "New Mobile App", + "description": "Updated description for the mobile app.", + "start_date": null, + "end_date": null, + "status": "in_progress", + "owner_id": "user_xyz789", + "tags": ["mobile", "flutter"], + "meta_data": null, + "created_at": "2023-10-27T10:00:00Z", + "updated_at": "2023-10-27T11:00:00Z" +} +``` + +--- + +### DELETE /projects/{project_id} + +**Description:** Delete a project. + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- `project_id`: + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK`) +```json +{ + "message": "" +} +``` +*(Note: The service returns a `Dict[str, Any]`. This is an example structure.)* + +**Example Request (curl):** +```bash +curl -X DELETE "http://localhost:8000/projects/proj_123abc" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON):** +```json +{ + "message": "Project proj_123abc deleted successfully" +} +``` + +## Project Members Endpoints + +### POST /projects/{project_id}/members + +**Description:** Add a member to a project. + +**Required Headers:** +- `Content-Type`: application/json +- `Authorization`: Bearer + +**Path Parameters:** +- `project_id`: + +**Query Parameters:** +- None + +**Request Body:** (`ProjectMemberCreateDTO`) +```json +{ + "user_id": "", + "role": "" +} +``` + +**Response Body:** (`200 OK` - `ProjectMemberResponseDTO`) +```json +{ + "id": "", + "project_id": "", + "user_id": "", + "role": "", + "joined_at": "" +} +``` + +**Example Request (curl):** +```bash +curl -X POST "http://localhost:8000/projects/proj_123abc/members" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "user_id": "user_def456", + "role": "editor" + }' +``` + +**Example Response (JSON):** +```json +{ + "id": "member_ghi789", + "project_id": "proj_123abc", + "user_id": "user_def456", + "role": "editor", + "joined_at": "2023-10-27T12:00:00Z" +} +``` + +--- + +### GET /projects/{project_id}/members + +**Description:** Get project members. + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- `project_id`: + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK` - `List[ProjectMemberResponseDTO]`) +```json +[ + { + "id": "", + "project_id": "", + "user_id": "", + "role": "", + "joined_at": "" + } + // ... more members +] +``` + +**Example Request (curl):** +```bash +curl -X GET "http://localhost:8000/projects/proj_123abc/members" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON):** +```json +[ + { + "id": "member_owner_xyz", + "project_id": "proj_123abc", + "user_id": "user_xyz789", + "role": "owner", + "joined_at": "2023-10-27T10:00:00Z" + }, + { + "id": "member_ghi789", + "project_id": "proj_123abc", + "user_id": "user_def456", + "role": "editor", + "joined_at": "2023-10-27T12:00:00Z" + } +] +``` + +--- + +### PUT /projects/{project_id}/members/{member_id} + +**Description:** Update a project member. (Here, `member_id` is the `user_id` of the member in the context of this project, not the `id` of the `ProjectMember` record itself. The service implementation uses `member_id` to find the `ProjectMember` record associated with this `user_id` and `project_id`.) + +**Required Headers:** +- `Content-Type`: application/json +- `Authorization`: Bearer + +**Path Parameters:** +- `project_id`: +- `member_id`: + +**Query Parameters:** +- None + +**Request Body:** (`ProjectMemberUpdateDTO`) +```json +{ + "role": "" +} +``` + +**Response Body:** (`200 OK` - `ProjectMemberResponseDTO`) +```json +{ + "id": "", + "project_id": "", + "user_id": "", + "role": "", + "joined_at": "" +} +``` + +**Example Request (curl):** +```bash +curl -X PUT "http://localhost:8000/projects/proj_123abc/members/user_def456" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "role": "viewer" + }' +``` + +**Example Response (JSON):** +```json +{ + "id": "member_ghi789", + "project_id": "proj_123abc", + "user_id": "user_def456", + "role": "viewer", + "joined_at": "2023-10-27T12:00:00Z" +} +``` +*(Note: `joined_at` likely remains unchanged on role update)* + +--- + +### DELETE /projects/{project_id}/members/{member_id} + +**Description:** Remove a project member. (Here, `member_id` refers to the `user_id` of the member to be removed from the project.) + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- `project_id`: +- `member_id`: + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK`) +```json +{ + "message": "" +} +``` +*(Note: The service returns a `Dict[str, Any]`. This is an example structure.)* + +**Example Request (curl):** +```bash +curl -X DELETE "http://localhost:8000/projects/proj_123abc/members/user_def456" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON):** +```json +{ + "message": "Member user_def456 removed from project proj_123abc successfully" +} +``` + +## Task Endpoints + +### POST /projects/{project_id}/tasks + +**Description:** Create a new task. + +**Required Headers:** +- `Content-Type`: application/json +- `Authorization`: Bearer + +**Path Parameters:** +- `project_id`: + +**Query Parameters:** +- None + +**Request Body:** (`TaskCreateDTO`) +```json +{ + "title": "", + "description": "", + "assignee_id": "", + "due_date": "", + "priority": "", + "status": "", + "tags": "", + "meta_data": "" +} +``` + +**Response Body:** (`200 OK` - `TaskResponseDTO`) +```json +{ + "id": "", + "title": "", + "description": "", + "project_id": "", + "creator_id": "", + "assignee_id": "", + "due_date": "", + "priority": "", + "status": "", + "tags": "", + "meta_data": "", + "created_at": "", + "updated_at": "" +} +``` + +**Example Request (curl):** +```bash +curl -X POST "http://localhost:8000/projects/proj_123abc/tasks" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "title": "Implement login feature", + "description": "Users should be able to log in using email and password.", + "assignee_id": "user_def456", + "priority": "high", + "tags": ["auth", "frontend"] + }' +``` + +**Example Response (JSON):** +```json +{ + "id": "task_jkl012", + "title": "Implement login feature", + "description": "Users should be able to log in using email and password.", + "project_id": "proj_123abc", + "creator_id": "user_xyz789", + "assignee_id": "user_def456", + "due_date": null, + "priority": "high", + "status": "todo", + "tags": ["auth", "frontend"], + "meta_data": null, + "created_at": "2023-10-27T13:00:00Z", + "updated_at": null +} +``` + +--- + +### GET /projects/{project_id}/tasks + +**Description:** Get tasks for a project. + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- `project_id`: + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK` - `List[TaskResponseDTO]`) +```json +[ + { + "id": "", + "title": "", + // ... other fields from TaskResponseDTO + } + // ... more tasks +] +``` + +**Example Request (curl):** +```bash +curl -X GET "http://localhost:8000/projects/proj_123abc/tasks" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON):** +```json +[ + { + "id": "task_jkl012", + "title": "Implement login feature", + "description": "Users should be able to log in using email and password.", + "project_id": "proj_123abc", + "creator_id": "user_xyz789", + "assignee_id": "user_def456", + "due_date": null, + "priority": "high", + "status": "todo", + "tags": ["auth", "frontend"], + "meta_data": null, + "created_at": "2023-10-27T13:00:00Z", + "updated_at": null + } +] +``` + +--- + +### GET /projects/{project_id}/tasks/{task_id} + +**Description:** Get a task. + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- `project_id`: +- `task_id`: + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK` - `TaskResponseDTO`) +```json +{ + "id": "", + "title": "", + // ... other fields from TaskResponseDTO +} +``` + +**Example Request (curl):** +```bash +curl -X GET "http://localhost:8000/projects/proj_123abc/tasks/task_jkl012" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON):** +```json +{ + "id": "task_jkl012", + "title": "Implement login feature", + "description": "Users should be able to log in using email and password.", + "project_id": "proj_123abc", + "creator_id": "user_xyz789", + "assignee_id": "user_def456", + "due_date": null, + "priority": "high", + "status": "todo", + "tags": ["auth", "frontend"], + "meta_data": null, + "created_at": "2023-10-27T13:00:00Z", + "updated_at": null +} +``` + +--- + +### PUT /projects/{project_id}/tasks/{task_id} + +**Description:** Update a task. + +**Required Headers:** +- `Content-Type`: application/json +- `Authorization`: Bearer + +**Path Parameters:** +- `project_id`: +- `task_id`: + +**Query Parameters:** +- None + +**Request Body:** (`TaskUpdateDTO`) +```json +{ + "title": "", + "description": "", + "assignee_id": "", + "due_date": "", + "priority": "", + "status": "", + "tags": "", + "meta_data": "" +} +``` + +**Response Body:** (`200 OK` - `TaskResponseDTO`) +```json +{ + "id": "", + "title": "", + // ... other fields from TaskResponseDTO + "updated_at": "" +} +``` + +**Example Request (curl):** +```bash +curl -X PUT "http://localhost:8000/projects/proj_123abc/tasks/task_jkl012" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "status": "in_progress", + "description": "Implementation in progress for login feature." + }' +``` + +**Example Response (JSON):** +```json +{ + "id": "task_jkl012", + "title": "Implement login feature", + "description": "Implementation in progress for login feature.", + "project_id": "proj_123abc", + "creator_id": "user_xyz789", + "assignee_id": "user_def456", + "due_date": null, + "priority": "high", + "status": "in_progress", + "tags": ["auth", "frontend"], + "meta_data": null, + "created_at": "2023-10-27T13:00:00Z", + "updated_at": "2023-10-27T14:00:00Z" +} +``` + +--- + +### DELETE /projects/{project_id}/tasks/{task_id} + +**Description:** Delete a task. + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- `project_id`: +- `task_id`: + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK`) +```json +{ + "message": "" +} +``` +*(Note: The service returns a `Dict[str, Any]`. This is an example structure.)* + +**Example Request (curl):** +```bash +curl -X DELETE "http://localhost:8000/projects/proj_123abc/tasks/task_jkl012" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON):** +```json +{ + "message": "Task task_jkl012 deleted successfully" +} +``` + +## Task Comments Endpoints + +### POST /projects/{project_id}/tasks/{task_id}/comments + +**Description:** Add a comment to a task. + +**Required Headers:** +- `Content-Type`: application/json +- `Authorization`: Bearer + +**Path Parameters:** +- `project_id`: +- `task_id`: + +**Query Parameters:** +- None + +**Request Body:** (`TaskCommentCreateDTO`) +```json +{ + "content": "", + "parent_id": "" +} +``` + +**Response Body:** (`200 OK` - `TaskCommentResponseDTO`) +```json +{ + "id": "", + "task_id": "", + "user_id": "", + "content": "", + "parent_id": "", + "created_at": "", + "updated_at": "" +} +``` + +**Example Request (curl):** +```bash +curl -X POST "http://localhost:8000/projects/proj_123abc/tasks/task_jkl012/comments" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "content": "This is a comment regarding the login feature." + }' +``` + +**Example Response (JSON):** +```json +{ + "id": "comment_mno345", + "task_id": "task_jkl012", + "user_id": "user_xyz789", + "content": "This is a comment regarding the login feature.", + "parent_id": null, + "created_at": "2023-10-27T15:00:00Z", + "updated_at": null +} +``` + +--- + +### GET /projects/{project_id}/tasks/{task_id}/comments + +**Description:** Get comments for a task. + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- `project_id`: +- `task_id`: + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK` - `List[TaskCommentResponseDTO]`) +```json +[ + { + "id": "", + "task_id": "", + "user_id": "", + "content": "", + "parent_id": "", + "created_at": "", + "updated_at": "" + } + // ... more comments +] +``` + +**Example Request (curl):** +```bash +curl -X GET "http://localhost:8000/projects/proj_123abc/tasks/task_jkl012/comments" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON):** +```json +[ + { + "id": "comment_mno345", + "task_id": "task_jkl012", + "user_id": "user_xyz789", + "content": "This is a comment regarding the login feature.", + "parent_id": null, + "created_at": "2023-10-27T15:00:00Z", + "updated_at": null + } +] +``` + +## Activity Endpoints + +### GET /projects/{project_id}/activities + +**Description:** Get activities for a project. + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- `project_id`: + +**Query Parameters:** +- `limit`: +- `offset`: + +**Request Body:** +- None + +**Response Body:** (`200 OK` - `List[ActivityLogResponseDTO]`) +```json +[ + { + "id": "", + "project_id": "", + "user_id": "", + "action": "", + "entity_type": "", + "entity_id": "", + "details": "", + "created_at": "" + } + // ... more activities +] +``` + +**Example Request (curl):** +```bash +curl -X GET "http://localhost:8000/projects/proj_123abc/activities?limit=50&offset=0" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON):** +```json +[ + { + "id": "activity_pqr678", + "project_id": "proj_123abc", + "user_id": "user_xyz789", + "action": "create_task", + "entity_type": "task", + "entity_id": "task_jkl012", + "details": {"title": "Implement login feature"}, + "created_at": "2023-10-27T13:00:00Z" + }, + { + "id": "activity_stu901", + "project_id": "proj_123abc", + "user_id": "user_xyz789", + "action": "update_project", + "entity_type": "project", + "entity_id": "proj_123abc", + "details": {"status": "in_progress"}, + "created_at": "2023-10-27T11:00:00Z" + } +] +``` + +## Task Command Endpoints + +### POST /projects/{project_id}/tasks/{task_id}/assign + +**Description:** Assign a task to a user. + +**Required Headers:** +- `Authorization`: Bearer + *(Content-Type not strictly needed as data is via query param, but client might send application/json for empty body)* + +**Path Parameters:** +- `project_id`: +- `task_id`: + +**Query Parameters:** +- `assignee_id`: + +**Request Body:** +- None (or empty JSON object `{}`) + +**Response Body:** (`200 OK` - `TaskResponseDTO`) +```json +{ + "id": "", + "assignee_id": "", + // ... other fields from TaskResponseDTO + "updated_at": "" +} +``` + +**Example Request (curl):** +```bash +curl -X POST "http://localhost:8000/projects/proj_123abc/tasks/task_jkl012/assign?assignee_id=user_def456" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{}' +``` +*(To unassign, omit `assignee_id` or send `assignee_id=`)* +```bash +curl -X POST "http://localhost:8000/projects/proj_123abc/tasks/task_jkl012/assign" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +**Example Response (JSON for assigning):** +```json +{ + "id": "task_jkl012", + "title": "Implement login feature", + "description": "Implementation in progress for login feature.", + "project_id": "proj_123abc", + "creator_id": "user_xyz789", + "assignee_id": "user_def456", + "due_date": null, + "priority": "high", + "status": "in_progress", + "tags": ["auth", "frontend"], + "meta_data": null, + "created_at": "2023-10-27T13:00:00Z", + "updated_at": "2023-10-27T16:00:00Z" +} +``` + +--- + +### POST /projects/{project_id}/tasks/{task_id}/status + +**Description:** Change task status. + +**Required Headers:** +- `Authorization`: Bearer + *(Content-Type not strictly needed as data is via query param, but client might send application/json for empty body)* + +**Path Parameters:** +- `project_id`: +- `task_id`: + +**Query Parameters:** +- `status`: + +**Request Body:** +- None (or empty JSON object `{}`) + +**Response Body:** (`200 OK` - `TaskResponseDTO`) +```json +{ + "id": "", + "status": "", + // ... other fields from TaskResponseDTO + "updated_at": "" +} +``` + +**Example Request (curl):** +```bash +curl -X POST "http://localhost:8000/projects/proj_123abc/tasks/task_jkl012/status?status=review" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +**Example Response (JSON):** +```json +{ + "id": "task_jkl012", + "title": "Implement login feature", + "description": "Implementation in progress for login feature.", + "project_id": "proj_123abc", + "creator_id": "user_xyz789", + "assignee_id": "user_def456", + "due_date": null, + "priority": "high", + "status": "review", + "tags": ["auth", "frontend"], + "meta_data": null, + "created_at": "2023-10-27T13:00:00Z", + "updated_at": "2023-10-27T17:00:00Z" +} +``` + +--- + +### POST /projects/{project_id}/tasks/{task_id}/undo + +**Description:** Undo the last task command (assign or status change) for this specific task. + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- `project_id`: +- `task_id`: + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK` - `TaskResponseDTO`) +```json +{ + "id": "", + // ... other fields from TaskResponseDTO reflecting the state before the undone command + "updated_at": "" +} +``` + +**Example Request (curl):** +```bash +curl -X POST "http://localhost:8000/projects/proj_123abc/tasks/task_jkl012/undo" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON - assuming status 'review' was undone, reverting to 'in_progress'):** +```json +{ + "id": "task_jkl012", + "title": "Implement login feature", + "description": "Implementation in progress for login feature.", + "project_id": "proj_123abc", + "creator_id": "user_xyz789", + "assignee_id": "user_def456", + "due_date": null, + "priority": "high", + "status": "in_progress", // Status reverted + "tags": ["auth", "frontend"], + "meta_data": null, + "created_at": "2023-10-27T13:00:00Z", + "updated_at": "2023-10-27T16:00:00Z" // Timestamp reflects this undo action +} +``` + +--- + +### POST /projects/{project_id}/tasks/{task_id}/redo + +**Description:** Redo the last undone task command for this specific task. + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- `project_id`: +- `task_id`: + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK` - `TaskResponseDTO`) +```json +{ + "id": "", + // ... other fields from TaskResponseDTO reflecting the state after redoing the command + "updated_at": "" +} +``` + +**Example Request (curl):** +```bash +curl -X POST "http://localhost:8000/projects/proj_123abc/tasks/task_jkl012/redo" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON - assuming status 'review' was redone):** +```json +{ + "id": "task_jkl012", + "title": "Implement login feature", + "description": "Implementation in progress for login feature.", + "project_id": "proj_123abc", + "creator_id": "user_xyz789", + "assignee_id": "user_def456", + "due_date": null, + "priority": "high", + "status": "review", // Status redone + "tags": ["auth", "frontend"], + "meta_data": null, + "created_at": "2023-10-27T13:00:00Z", + "updated_at": "2023-10-27T18:00:00Z" // Timestamp reflects this redo action +} +``` + +## Health Check Endpoint + +### GET /health + +**Description:** Health check endpoint. Standard health check to verify service availability. + +**Required Headers:** +- None + +**Path Parameters:** +- None + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK`) +```json +{ + "status": "" +} +``` + +**Example Request (curl):** +```bash +curl -X GET "http://localhost:8000/health" +``` + +**Example Response (JSON):** +```json +{ + "status": "healthy" +} +``` + +--- +# Document Service API Documentation + +This document provides details about the API endpoints for the Document Service. Most routes require an `Authorization: Bearer ` header for authentication. `Content-Type` will vary based on the endpoint (e.g., `application/json` for JSON payloads, `multipart/form-data` for file uploads). + +## Document Endpoints + +### POST /documents + +**Description:** Create a new document (metadata entry). + +**Required Headers:** +- `Content-Type`: application/json +- `Authorization`: Bearer + +**Path Parameters:** +- None + +**Query Parameters:** +- None + +**Request Body:** (`DocumentCreateDTO`) +```json +{ + "name": "", + "project_id": "", + "parent_id": "", + "type": "", + "content_type": "", + "url": "", + "description": "", + "tags": "", + "meta_data": "" +} +``` + +**Response Body:** (`200 OK` - `DocumentResponseDTO`) +```json +{ + "id": "", + "name": "", + "project_id": "", + "parent_id": "", + "type": "", + "content_type": "", + "size": "", + "url": "", + "description": "", + "version": "", + "creator_id": "", + "tags": "", + "meta_data": "", + "created_at": "", + "updated_at": "" +} +``` + +**Example Request (curl):** +```bash +curl -X POST "http://localhost:8000/documents" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "name": "Quarterly Report Q4", + "project_id": "proj_123abc", + "type": "file", + "content_type": "application/pdf", + "tags": ["report", "q4"] + }' +``` + +**Example Response (JSON):** +```json +{ + "id": "doc_xyz789", + "name": "Quarterly Report Q4", + "project_id": "proj_123abc", + "parent_id": null, + "type": "file", + "content_type": "application/pdf", + "size": null, + "url": null, + "description": null, + "version": 1, + "creator_id": "user_def456", + "tags": ["report", "q4"], + "meta_data": null, + "created_at": "2023-10-27T10:00:00Z", + "updated_at": null +} +``` + +--- + +### GET /documents/{document_id} + +**Description:** Get a document. + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- `document_id`: + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK` - `DocumentResponseDTO`) +```json +{ + "id": "", + "name": "", + // ... other fields from DocumentResponseDTO +} +``` + +**Example Request (curl):** +```bash +curl -X GET "http://localhost:8000/documents/doc_xyz789" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON):** +```json +{ + "id": "doc_xyz789", + "name": "Quarterly Report Q4", + "project_id": "proj_123abc", + "parent_id": null, + "type": "file", + "content_type": "application/pdf", + "size": 102400, # Example size + "url": "https://storage.example.com/doc_xyz789/report.pdf", # Example URL + "description": null, + "version": 1, + "creator_id": "user_def456", + "tags": ["report", "q4"], + "meta_data": null, + "created_at": "2023-10-27T10:00:00Z", + "updated_at": "2023-10-27T10:05:00Z" +} +``` + +--- + +### PUT /documents/{document_id} + +**Description:** Update a document (metadata). + +**Required Headers:** +- `Content-Type`: application/json +- `Authorization`: Bearer + +**Path Parameters:** +- `document_id`: + +**Query Parameters:** +- None + +**Request Body:** (`DocumentUpdateDTO`) +```json +{ + "name": "", + "parent_id": "", + "description": "", + "tags": "", + "meta_data": "" +} +``` + +**Response Body:** (`200 OK` - `DocumentResponseDTO`) +```json +{ + "id": "", + "name": "", + // ... other fields from DocumentResponseDTO + "updated_at": "" +} +``` + +**Example Request (curl):** +```bash +curl -X PUT "http://localhost:8000/documents/doc_xyz789" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "description": "Final version of the Q4 report.", + "tags": ["report", "q4", "final"] + }' +``` + +**Example Response (JSON):** +```json +{ + "id": "doc_xyz789", + "name": "Quarterly Report Q4", + "project_id": "proj_123abc", + "parent_id": null, + "type": "file", + "content_type": "application/pdf", + "size": 102400, + "url": "https://storage.example.com/doc_xyz789/report.pdf", + "description": "Final version of the Q4 report.", + "version": 1, + "creator_id": "user_def456", + "tags": ["report", "q4", "final"], + "meta_data": null, + "created_at": "2023-10-27T10:00:00Z", + "updated_at": "2023-10-27T11:00:00Z" +} +``` + +--- + +### DELETE /documents/{document_id} + +**Description:** Delete a document. + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- `document_id`: + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK`) +```json +{ + "message": "" +} +``` +*(Note: The service returns `Dict[str, Any]` which is represented here as a success message.)* + +**Example Request (curl):** +```bash +curl -X DELETE "http://localhost:8000/documents/doc_xyz789" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON):** +```json +{ + "message": "Document doc_xyz789 deleted successfully" +} +``` + +--- + +### GET /projects/{project_id}/documents + +**Description:** Get documents for a project. Can filter by `parent_id` to list contents of a folder. + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- `project_id`: + +**Query Parameters:** +- `parent_id`: + +**Request Body:** +- None + +**Response Body:** (`200 OK` - `List[DocumentResponseDTO]`) +```json +[ + { + "id": "", + "name": "", + // ... other fields from DocumentResponseDTO + } + // ... more documents +] +``` + +**Example Request (curl for root documents):** +```bash +curl -X GET "http://localhost:8000/projects/proj_123abc/documents" \ + -H "Authorization: Bearer " +``` + +**Example Request (curl for a folder's content):** +```bash +curl -X GET "http://localhost:8000/projects/proj_123abc/documents?parent_id=folder_parent_123" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON):** +```json +[ + { + "id": "doc_xyz789", + "name": "Quarterly Report Q4", + "project_id": "proj_123abc", + "parent_id": null, + "type": "file", + // ... + }, + { + "id": "folder_reports_q4", + "name": "Q4 Reports", + "project_id": "proj_123abc", + "parent_id": null, + "type": "folder", + // ... + } +] +``` + +--- + +### POST /documents/upload + +**Description:** Initiates document upload process by creating a document record and returning a pre-signed URL for the client to upload the actual file to storage. + +**Required Headers:** +- `Content-Type`: application/json +- `Authorization`: Bearer + +**Path Parameters:** +- None + +**Query Parameters:** +- None + +**Request Body:** (`DocumentCreateDTO`) +```json +{ + "name": "", + "project_id": "", + "parent_id": "", + "type": "", // Must be 'file' for upload + "content_type": "", + "description": "", + "tags": "", + "meta_data": "" +} +``` + +**Response Body:** (`200 OK` - `DocumentUploadResponseDTO`) +```json +{ + "document": { + "id": "", + "name": "", + // ... other fields from DocumentResponseDTO (version will be 1, size and url might be null initially) + }, + "upload_url": "" +} +``` + +**Example Request (curl):** +```bash +curl -X POST "http://localhost:8000/documents/upload" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "name": "Annual Presentation.pptx", + "project_id": "proj_123abc", + "type": "file", + "content_type": "application/vnd.openxmlformats-officedocument.presentationml.presentation" + }' +``` + +**Example Response (JSON):** +```json +{ + "document": { + "id": "doc_pres123", + "name": "Annual Presentation.pptx", + "project_id": "proj_123abc", + "parent_id": null, + "type": "file", + "content_type": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "size": null, + "url": null, + "description": null, + "version": 1, + "creator_id": "user_def456", + "tags": null, + "meta_data": null, + "created_at": "2023-10-27T12:00:00Z", + "updated_at": null + }, + "upload_url": "https://storage.example.com/presigned-url-for-upload?signature=..." +} +``` +*(Note: The client would then use the `upload_url` to PUT the file content. That PUT request is not part of this endpoint.)* + +## Document Version Endpoints + +### POST /documents/{document_id}/versions + +**Description:** Create a new document version (metadata). This endpoint registers a new version with content type and changes description. The actual file content upload for this version might be handled separately (e.g., via a pre-signed URL mechanism not detailed by this endpoint's direct inputs). + +**Required Headers:** +- `Content-Type`: multipart/form-data +- `Authorization`: Bearer + +**Path Parameters:** +- `document_id`: + +**Query Parameters:** +- None + +**Request Body (Form Data):** +- `content_type`: +- `changes`: + +**Response Body:** (`200 OK` - `DocumentVersionDTO`) +```json +{ + "id": "", + "document_id": "", + "version": "", + "size": "", + "content_type": "", + "url": "", + "creator_id": "", + "changes": "", + "created_at": "" +} +``` + +**Example Request (curl):** +```bash +curl -X POST "http://localhost:8000/documents/doc_xyz789/versions" \ + -H "Authorization: Bearer " \ + -F "content_type=application/pdf" \ + -F "changes=Updated financial figures for Q4." +``` + +**Example Response (JSON):** +```json +{ + "id": "ver_abc123", + "document_id": "doc_xyz789", + "version": 2, + "size": null, + "content_type": "application/pdf", + "url": null, + "creator_id": "user_def456", + "changes": "Updated financial figures for Q4.", + "created_at": "2023-10-27T13:00:00Z" +} +``` + +--- + +### GET /documents/{document_id}/versions + +**Description:** Get versions for a document. + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- `document_id`: + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK` - `List[DocumentVersionDTO]`) +```json +[ + { + "id": "", + "document_id": "", + "version": "", + // ... other fields from DocumentVersionDTO + } + // ... more versions +] +``` + +**Example Request (curl):** +```bash +curl -X GET "http://localhost:8000/documents/doc_xyz789/versions" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON):** +```json +[ + { + "id": "ver_original", + "document_id": "doc_xyz789", + "version": 1, + "size": 102400, + "content_type": "application/pdf", + "url": "https://storage.example.com/doc_xyz789/report_v1.pdf", + "creator_id": "user_def456", + "changes": "Initial version.", + "created_at": "2023-10-27T10:05:00Z" + }, + { + "id": "ver_abc123", + "document_id": "doc_xyz789", + "version": 2, + "size": 115000, + "content_type": "application/pdf", + "url": "https://storage.example.com/doc_xyz789/report_v2.pdf", + "creator_id": "user_def456", + "changes": "Updated financial figures for Q4.", + "created_at": "2023-10-27T13:00:00Z" + } +] +``` + +--- + +### GET /documents/{document_id}/versions/{version} + +**Description:** Get a specific document version. + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- `document_id`: +- `version`: + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK` - `DocumentVersionDTO`) +```json +{ + "id": "", + "document_id": "", + "version": "", + // ... other fields from DocumentVersionDTO +} +``` + +**Example Request (curl):** +```bash +curl -X GET "http://localhost:8000/documents/doc_xyz789/versions/2" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON):** +```json +{ + "id": "ver_abc123", + "document_id": "doc_xyz789", + "version": 2, + "size": 115000, + "content_type": "application/pdf", + "url": "https://storage.example.com/doc_xyz789/report_v2.pdf", + "creator_id": "user_def456", + "changes": "Updated financial figures for Q4.", + "created_at": "2023-10-27T13:00:00Z" +} +``` + +## Document Permission Endpoints + +### POST /documents/{document_id}/permissions + +**Description:** Add a permission to a document for a user or role. + +**Required Headers:** +- `Content-Type`: application/json +- `Authorization`: Bearer + +**Path Parameters:** +- `document_id`: + +**Query Parameters:** +- None + +**Request Body:** (`DocumentPermissionCreateDTO`) +```json +{ + "user_id": "", + "role_id": "", + "can_view": "", + "can_edit": "", + "can_delete": "", + "can_share": "" +} +``` + +**Response Body:** (`200 OK` - `DocumentPermissionDTO`) +```json +{ + "id": "", + "document_id": "", + "user_id": "", + "role_id": "", + "can_view": "", + "can_edit": "", + "can_delete": "", + "can_share": "", + "created_at": "", + "updated_at": "" +} +``` + +**Example Request (curl):** +```bash +curl -X POST "http://localhost:8000/documents/doc_xyz789/permissions" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "user_id": "user_collaborator1", + "can_edit": true + }' +``` + +**Example Response (JSON):** +```json +{ + "id": "perm_123", + "document_id": "doc_xyz789", + "user_id": "user_collaborator1", + "role_id": null, + "can_view": true, + "can_edit": true, + "can_delete": false, + "can_share": false, + "created_at": "2023-10-27T14:00:00Z", + "updated_at": null +} +``` + +--- + +### PUT /documents/{document_id}/permissions/{permission_id} + +**Description:** Update a document permission. + +**Required Headers:** +- `Content-Type`: application/json +- `Authorization`: Bearer + +**Path Parameters:** +- `document_id`: +- `permission_id`: + +**Query Parameters:** +- None + +**Request Body:** (`DocumentPermissionUpdateDTO`) +```json +{ + "can_view": "", + "can_edit": "", + "can_delete": "", + "can_share": "" +} +``` + +**Response Body:** (`200 OK` - `DocumentPermissionDTO`) +```json +{ + "id": "", + // ... other fields from DocumentPermissionDTO + "updated_at": "" +} +``` + +**Example Request (curl):** +```bash +curl -X PUT "http://localhost:8000/documents/doc_xyz789/permissions/perm_123" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "can_edit": false, + "can_share": true + }' +``` + +**Example Response (JSON):** +```json +{ + "id": "perm_123", + "document_id": "doc_xyz789", + "user_id": "user_collaborator1", + "role_id": null, + "can_view": true, + "can_edit": false, + "can_delete": false, + "can_share": true, + "created_at": "2023-10-27T14:00:00Z", + "updated_at": "2023-10-27T15:00:00Z" +} +``` + +--- + +### DELETE /documents/{document_id}/permissions/{permission_id} + +**Description:** Delete a document permission. + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- `document_id`: +- `permission_id`: + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK`) +```json +{ + "message": "" +} +``` +*(Note: The service returns `Dict[str, Any]` which is represented here as a success message.)* + +**Example Request (curl):** +```bash +curl -X DELETE "http://localhost:8000/documents/doc_xyz789/permissions/perm_123" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON):** +```json +{ + "message": "Permission perm_123 deleted successfully" +} +``` + +--- + +### GET /documents/{document_id}/permissions + +**Description:** Get permissions for a document. + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- `document_id`: + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK` - `List[DocumentPermissionDTO]`) +```json +[ + { + "id": "", + // ... other fields from DocumentPermissionDTO + } + // ... more permissions +] +``` + +**Example Request (curl):** +```bash +curl -X GET "http://localhost:8000/documents/doc_xyz789/permissions" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON):** +```json +[ + { + "id": "perm_owner", + "document_id": "doc_xyz789", + "user_id": "user_def456", // Owner + "role_id": null, + "can_view": true, + "can_edit": true, + "can_delete": true, + "can_share": true, + "created_at": "2023-10-27T10:00:00Z", + "updated_at": null + }, + { + "id": "perm_123", + "document_id": "doc_xyz789", + "user_id": "user_collaborator1", + "role_id": null, + "can_view": true, + "can_edit": false, + "can_delete": false, + "can_share": true, + "created_at": "2023-10-27T14:00:00Z", + "updated_at": "2023-10-27T15:00:00Z" + } +] +``` + +## Document Conversion Endpoint + +### POST /documents/convert + +**Description:** Converts a document using LibreOffice Online and uploads the result to Supabase Storage. + +**Required Headers:** +- `Content-Type`: multipart/form-data +- `Authorization`: Bearer + +**Path Parameters:** +- None + +**Query Parameters:** +- None + +**Request Body (Form Data):** +- `file`: +- `output_format`: +- `supabase_bucket`: +- `supabase_path`: + +**Response Body:** (`200 OK`) +```json +{ + "url": "" +} +``` + +**Example Request (curl):** +```bash +curl -X POST "http://localhost:8000/documents/convert" \ + -H "Authorization: Bearer " \ + -F "file=@/path/to/your/document.docx" \ + -F "output_format=pdf" \ + -F "supabase_bucket=converted-docs" \ + -F "supabase_path=reports/my_converted_report.pdf" +``` + +**Example Response (JSON):** +```json +{ + "url": "https://your-supabase-instance.supabase.co/storage/v1/object/public/converted-docs/reports/my_converted_report.pdf" +} +``` + +## Health Check Endpoint + +### GET /health + +**Description:** Health check endpoint. Standard health check to verify service availability. + +**Required Headers:** +- None + +**Path Parameters:** +- None + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK`) +```json +{ + "status": "" +} +``` + +**Example Request (curl):** +```bash +curl -X GET "http://localhost:8000/health" +``` + +**Example Response (JSON):** +```json +{ + "status": "healthy" +} +``` + +--- +# Notification Service API Documentation + +This document provides details about the API endpoints for the Notification Service. All routes, unless otherwise specified, require an `Authorization: Bearer ` header for authentication and expect `Content-Type: application/json` for request bodies. + +## Notification Endpoints + +### POST /notifications + +**Description:** Create a new notification. + +**Required Headers:** +- `Content-Type`: application/json +- `Authorization`: Bearer + +**Path Parameters:** +- None + +**Query Parameters:** +- None + +**Request Body:** (`NotificationCreateDTO`) +```json +{ + "user_id": "", + "type": "", + "title": "", + "message": "", + "priority": "", + "channels": "", + "related_entity_type": "", + "related_entity_id": "", + "action_url": "", + "meta_data": "", + "scheduled_at": "" +} +``` + +**Response Body:** (`200 OK` - `NotificationResponseDTO`) +```json +{ + "id": "", + "user_id": "", + "type": "", + "title": "", + "message": "", + "priority": "", + "channels": "", + "related_entity_type": "", + "related_entity_id": "", + "action_url": "", + "meta_data": "", + "is_read": "", + "read_at": "", + "created_at": "", + "scheduled_at": "", + "sent_at": "" +} +``` + +**Example Request (curl):** +```bash +curl -X POST "http://localhost:8000/notifications" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "user_id": "user_123", + "type": "task", + "title": "New Task Assigned", + "message": "You have been assigned a new task: Finalize Report Q4.", + "related_entity_type": "task", + "related_entity_id": "task_abc789", + "action_url": "/tasks/task_abc789" + }' +``` + +**Example Response (JSON):** +```json +{ + "id": "notif_xyz456", + "user_id": "user_123", + "type": "task", + "title": "New Task Assigned", + "message": "You have been assigned a new task: Finalize Report Q4.", + "priority": "normal", + "channels": ["in_app"], + "related_entity_type": "task", + "related_entity_id": "task_abc789", + "action_url": "/tasks/task_abc789", + "meta_data": null, + "is_read": false, + "read_at": null, + "created_at": "2023-10-27T10:00:00Z", + "scheduled_at": null, + "sent_at": null +} +``` + +--- + +### POST /notifications/batch + +**Description:** Create multiple notifications at once for different users. + +**Required Headers:** +- `Content-Type`: application/json +- `Authorization`: Bearer + +**Path Parameters:** +- None + +**Query Parameters:** +- None + +**Request Body:** (`NotificationBatchCreateDTO`) +```json +{ + "user_ids": "", + "type": "", + "title": "", + "message": "", + "priority": "", + "channels": "", + "related_entity_type": "", + "related_entity_id": "", + "action_url": "", + "meta_data": "", + "scheduled_at": "" +} +``` + +**Response Body:** (`200 OK` - `List[NotificationResponseDTO]`) +```json +[ + { + "id": "", + "user_id": "", + // ... other fields from NotificationResponseDTO + } + // ... more notification responses +] +``` + +**Example Request (curl):** +```bash +curl -X POST "http://localhost:8000/notifications/batch" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "user_ids": ["user_123", "user_456"], + "type": "project", + "title": "Project Update", + "message": "Project Alpha has been updated with new milestones.", + "related_entity_type": "project", + "related_entity_id": "proj_alpha" + }' +``` + +**Example Response (JSON):** +```json +[ + { + "id": "notif_batch_1", + "user_id": "user_123", + "type": "project", + "title": "Project Update", + "message": "Project Alpha has been updated with new milestones.", + "priority": "normal", + "channels": ["in_app"], + "related_entity_type": "project", + "related_entity_id": "proj_alpha", + "action_url": null, + "meta_data": null, + "is_read": false, + "read_at": null, + "created_at": "2023-10-27T11:00:00Z", + "scheduled_at": null, + "sent_at": null + }, + { + "id": "notif_batch_2", + "user_id": "user_456", + "type": "project", + "title": "Project Update", + "message": "Project Alpha has been updated with new milestones.", + "priority": "normal", + "channels": ["in_app"], + "related_entity_type": "project", + "related_entity_id": "proj_alpha", + "action_url": null, + "meta_data": null, + "is_read": false, + "read_at": null, + "created_at": "2023-10-27T11:00:00Z", + "scheduled_at": null, + "sent_at": null + } +] +``` + +--- + +### GET /notifications + +**Description:** Get notifications for current user. + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- None + +**Query Parameters:** +- `limit`: +- `offset`: + +**Request Body:** +- None + +**Response Body:** (`200 OK` - `List[NotificationResponseDTO]`) +```json +[ + { + "id": "", + // ... other fields from NotificationResponseDTO + } +] +``` + +**Example Request (curl):** +```bash +curl -X GET "http://localhost:8000/notifications?limit=10&offset=0" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON):** +```json +[ + { + "id": "notif_xyz456", + "user_id": "user_123", + "type": "task", + "title": "New Task Assigned", + "message": "You have been assigned a new task: Finalize Report Q4.", + "priority": "normal", + "channels": ["in_app"], + "related_entity_type": "task", + "related_entity_id": "task_abc789", + "action_url": "/tasks/task_abc789", + "meta_data": null, + "is_read": false, + "read_at": null, + "created_at": "2023-10-27T10:00:00Z", + "scheduled_at": null, + "sent_at": null + } + // ... more notifications +] +``` + +--- + +### GET /notifications/unread + +**Description:** Get unread notifications for current user. + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- None + +**Query Parameters:** +- `limit`: +- `offset`: + +**Request Body:** +- None + +**Response Body:** (`200 OK` - `List[NotificationResponseDTO]`) +```json +[ + { + "id": "", + "is_read": false, + // ... other fields from NotificationResponseDTO + } +] +``` + +**Example Request (curl):** +```bash +curl -X GET "http://localhost:8000/notifications/unread?limit=5" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON):** +```json +[ + { + "id": "notif_xyz456", + "user_id": "user_123", + "type": "task", + "title": "New Task Assigned", + "message": "You have been assigned a new task: Finalize Report Q4.", + "priority": "normal", + "channels": ["in_app"], + "related_entity_type": "task", + "related_entity_id": "task_abc789", + "action_url": "/tasks/task_abc789", + "meta_data": null, + "is_read": false, + "read_at": null, + "created_at": "2023-10-27T10:00:00Z", + "scheduled_at": null, + "sent_at": null + } + // ... more unread notifications +] +``` + +--- + +### PUT /notifications/{notification_id}/read + +**Description:** Mark a notification as read. + +**Required Headers:** +- `Content-Type`: application/json (though body is not strictly needed, FastAPI might expect it for PUT) +- `Authorization`: Bearer + +**Path Parameters:** +- `notification_id`: + +**Query Parameters:** +- None + +**Request Body:** +- None (or empty JSON object `{}`) + +**Response Body:** (`200 OK` - `NotificationResponseDTO`) +```json +{ + "id": "", + "is_read": true, + "read_at": "", + // ... other fields from NotificationResponseDTO +} +``` + +**Example Request (curl):** +```bash +curl -X PUT "http://localhost:8000/notifications/notif_xyz456/read" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{}' +``` + +**Example Response (JSON):** +```json +{ + "id": "notif_xyz456", + "user_id": "user_123", + "type": "task", + "title": "New Task Assigned", + "message": "You have been assigned a new task: Finalize Report Q4.", + "priority": "normal", + "channels": ["in_app"], + "related_entity_type": "task", + "related_entity_id": "task_abc789", + "action_url": "/tasks/task_abc789", + "meta_data": null, + "is_read": true, + "read_at": "2023-10-27T12:00:00Z", + "created_at": "2023-10-27T10:00:00Z", + "scheduled_at": null, + "sent_at": null +} +``` + +--- + +### PUT /notifications/read-all + +**Description:** Mark all notifications as read for current user. + +**Required Headers:** +- `Content-Type`: application/json (though body is not strictly needed) +- `Authorization`: Bearer + +**Path Parameters:** +- None + +**Query Parameters:** +- None + +**Request Body:** +- None (or empty JSON object `{}`) + +**Response Body:** (`200 OK`) +```json +{ + "message": "", + "count": "" +} +``` +*(Note: The service returns `Dict[str, Any]`. This is an example structure based on common practice.)* + +**Example Request (curl):** +```bash +curl -X PUT "http://localhost:8000/notifications/read-all" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{}' +``` + +**Example Response (JSON):** +```json +{ + "message": "All notifications marked as read for user user_123.", + "count": 5 +} +``` + +--- + +### DELETE /notifications/{notification_id} + +**Description:** Delete a notification. + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- `notification_id`: + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK`) +```json +{ + "message": "" +} +``` +*(Note: The service returns `Dict[str, Any]`. This is an example structure.)* + +**Example Request (curl):** +```bash +curl -X DELETE "http://localhost:8000/notifications/notif_xyz456" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON):** +```json +{ + "message": "Notification notif_xyz456 deleted successfully." +} +``` + +## Notification Preferences Endpoints + +### GET /notification-preferences + +**Description:** Get notification preferences for current user. + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- None + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK` - `NotificationPreferencesDTO`) +```json +{ + "user_id": "", + "email_enabled": "", + "push_enabled": "", + "sms_enabled": "", + "in_app_enabled": "", + "digest_enabled": "", + "digest_frequency": "", + "quiet_hours_enabled": "", + "quiet_hours_start": "", + "quiet_hours_end": "", + "preferences_by_type": "" +} +``` + +**Example Request (curl):** +```bash +curl -X GET "http://localhost:8000/notification-preferences" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON):** +```json +{ + "user_id": "user_123", + "email_enabled": true, + "push_enabled": true, + "sms_enabled": false, + "in_app_enabled": true, + "digest_enabled": false, + "digest_frequency": null, + "quiet_hours_enabled": false, + "quiet_hours_start": null, + "quiet_hours_end": null, + "preferences_by_type": { + "task": {"email": true, "in_app": true}, + "project": {"email": false, "in_app": true} + } +} +``` + +--- + +### PUT /notification-preferences + +**Description:** Update notification preferences for current user. + +**Required Headers:** +- `Content-Type`: application/json +- `Authorization`: Bearer + +**Path Parameters:** +- None + +**Query Parameters:** +- None + +**Request Body:** (`NotificationPreferencesUpdateDTO`) +```json +{ + "email_enabled": "", + "push_enabled": "", + "sms_enabled": "", + "in_app_enabled": "", + "digest_enabled": "", + "digest_frequency": "", + "quiet_hours_enabled": "", + "quiet_hours_start": "", + "quiet_hours_end": "", + "preferences_by_type": "" +} +``` + +**Response Body:** (`200 OK` - `NotificationPreferencesDTO`) +```json +{ + "user_id": "", + "email_enabled": "", + // ... other fields from NotificationPreferencesDTO +} +``` + +**Example Request (curl):** +```bash +curl -X PUT "http://localhost:8000/notification-preferences" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "email_enabled": false, + "quiet_hours_enabled": true, + "quiet_hours_start": "23:00", + "quiet_hours_end": "07:00", + "preferences_by_type": { + "task": {"email": false, "in_app": true}, + "mention": {"push": true, "in_app": true} + } + }' +``` + +**Example Response (JSON):** +```json +{ + "user_id": "user_123", + "email_enabled": false, + "push_enabled": true, + "sms_enabled": false, + "in_app_enabled": true, + "digest_enabled": false, + "digest_frequency": null, + "quiet_hours_enabled": true, + "quiet_hours_start": "23:00", + "quiet_hours_end": "07:00", + "preferences_by_type": { + "task": {"email": false, "in_app": true}, + "project": {"email": false, "in_app": true}, // Assuming project was pre-existing and not changed + "mention": {"push": true, "in_app": true} + } +} +``` + +## Health Check Endpoint + +### GET /health + +**Description:** Health check endpoint. Standard health check to verify service availability. + +**Required Headers:** +- None + +**Path Parameters:** +- None + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK`) +```json +{ + "status": "" +} +``` + +**Example Request (curl):** +```bash +curl -X GET "http://localhost:8000/health" +``` + +**Example Response (JSON):** +```json +{ + "status": "healthy" +} +``` + +--- +# External Tools Service API Documentation + +This document provides details about the API endpoints for the External Tools Service. All routes, unless otherwise specified, require an `Authorization: Bearer ` header for authentication and generally expect `Content-Type: application/json` for request bodies. + +## OAuth Provider Endpoints + +### GET /oauth/providers + +**Description:** Get OAuth providers. Lists available third-party services that can be connected via OAuth. + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- None + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK` - `List[OAuthProviderDTO]`) +```json +[ + { + "id": "", + "name": "", + "type": "", + "auth_url": "", + "token_url": "", + "scope": "", + "client_id": "", + "redirect_uri": "", + "additional_params": "" + } + // ... more providers +] +``` + +**Example Request (curl):** +```bash +curl -X GET "http://localhost:8000/oauth/providers" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON):** +```json +[ + { + "id": "google_drive_test", + "name": "Google Drive (Test)", + "type": "google_drive", + "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", + "token_url": "https://oauth2.googleapis.com/token", + "scope": "https://www.googleapis.com/auth/drive.readonly", + "client_id": "your-google-client-id", + "redirect_uri": "http://localhost:3000/oauth/callback/google_drive", + "additional_params": {"access_type": "offline", "prompt": "consent"} + } +] +``` + +--- + +### GET /oauth/providers/{provider_id} + +**Description:** Get OAuth provider. Retrieves details for a specific OAuth provider. + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- `provider_id`: + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK` - `OAuthProviderDTO`) +```json +{ + "id": "", + "name": "", + // ... other fields from OAuthProviderDTO +} +``` + +**Example Request (curl):** +```bash +curl -X GET "http://localhost:8000/oauth/providers/google_drive_test" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON):** +```json +{ + "id": "google_drive_test", + "name": "Google Drive (Test)", + "type": "google_drive", + "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", + "token_url": "https://oauth2.googleapis.com/token", + "scope": "https://www.googleapis.com/auth/drive.readonly", + "client_id": "your-google-client-id", + "redirect_uri": "http://localhost:3000/oauth/callback/google_drive", + "additional_params": {"access_type": "offline", "prompt": "consent"} +} +``` + +## OAuth Endpoints + +### POST /oauth/authorize + +**Description:** Get OAuth authorization URL. Constructs and returns the URL to redirect the user to for OAuth authorization with the specified provider. + +**Required Headers:** +- `Content-Type`: application/json +- `Authorization`: Bearer + +**Path Parameters:** +- None + +**Query Parameters:** +- None + +**Request Body:** (`OAuthRequestDTO`) +```json +{ + "provider_id": "", + "redirect_uri": "", + "scope": "", + "state": "" +} +``` + +**Response Body:** (`200 OK` - `str`) +Returns the authorization URL as a plain string. +``` +"https://accounts.google.com/o/oauth2/v2/auth?client_id=your-google-client-id&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Foauth%2Fcallback%2Fgoogle_drive&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdrive.readonly&response_type=code&access_type=offline&prompt=consent&state=custom_state_value" +``` + +**Example Request (curl):** +```bash +curl -X POST "http://localhost:8000/oauth/authorize" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "provider_id": "google_drive_test", + "state": "custom_state_value123" + }' +``` + +**Example Response (Plain Text):** +``` +"https://accounts.google.com/o/oauth2/v2/auth?client_id=your-google-client-id&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Foauth%2Fcallback%2Fgoogle_drive&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdrive.readonly&response_type=code&access_type=offline&prompt=consent&state=custom_state_value123" +``` + +--- + +### POST /oauth/callback + +**Description:** Handle OAuth callback. Processes the authorization code received from the OAuth provider after user authorization, exchanges it for tokens, and creates/updates an external tool connection. + +**Required Headers:** +- `Content-Type`: application/json +- `Authorization`: Bearer + +**Path Parameters:** +- None + +**Query Parameters:** +- None + +**Request Body:** (`OAuthCallbackDTO`) +```json +{ + "provider_id": "", + "code": "", + "state": "", + "error": "" +} +``` + +**Response Body:** (`200 OK` - `ExternalToolConnectionDTO`) +```json +{ + "id": "", + "user_id": "", + "provider_id": "", + "provider_type": "", + "account_name": "", + "account_email": "", + "account_id": "", + "is_active": "", + "meta_data": "", + "created_at": "", + "updated_at": "", + "last_used_at": "", + "expires_at": "" +} +``` + +**Example Request (curl):** +```bash +curl -X POST "http://localhost:8000/oauth/callback" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "provider_id": "google_drive_test", + "code": "auth_code_from_google", + "state": "custom_state_value123" + }' +``` + +**Example Response (JSON):** +```json +{ + "id": "conn_123xyz", + "user_id": "user_abc", + "provider_id": "google_drive_test", + "provider_type": "google_drive", + "account_name": "Test User", + "account_email": "test.user@example.com", + "account_id": "google_user_id_123", + "is_active": true, + "meta_data": {"some_provider_info": "details"}, + "created_at": "2023-10-27T10:00:00Z", + "updated_at": "2023-10-27T10:00:00Z", + "last_used_at": null, + "expires_at": "2023-10-27T11:00:00Z" +} +``` + +## External Tool Connection Endpoints + +### POST /connections + +**Description:** Create external tool connection (manually, if not using OAuth flow or for tools that use API keys). + +**Required Headers:** +- `Content-Type`: application/json +- `Authorization`: Bearer + +**Path Parameters:** +- None + +**Query Parameters:** +- None + +**Request Body:** (`ExternalToolConnectionCreateDTO`) +```json +{ + "provider_id": "", + "access_token": "", + "refresh_token": "", + "account_name": "", + "account_email": "", + "account_id": "", + "meta_data": "", + "expires_at": "" +} +``` + +**Response Body:** (`200 OK` - `ExternalToolConnectionDTO`) +```json +{ + "id": "", + // ... other fields from ExternalToolConnectionDTO +} +``` + +**Example Request (curl):** +```bash +curl -X POST "http://localhost:8000/connections" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "provider_id": "custom_api_service", + "access_token": "user_provided_api_key_or_token", + "account_name": "My Custom Service Account" + }' +``` + +**Example Response (JSON):** +```json +{ + "id": "conn_manual_abc", + "user_id": "user_abc", + "provider_id": "custom_api_service", + "provider_type": "custom", + "account_name": "My Custom Service Account", + "account_email": null, + "account_id": null, + "is_active": true, + "meta_data": null, + "created_at": "2023-10-27T11:00:00Z", + "updated_at": "2023-10-27T11:00:00Z", + "last_used_at": null, + "expires_at": null +} +``` + +--- + +### GET /connections + +**Description:** Get connections for current user. + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- None + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK` - `List[ExternalToolConnectionDTO]`) +```json +[ + { + "id": "", + // ... other fields from ExternalToolConnectionDTO + } +] +``` + +**Example Request (curl):** +```bash +curl -X GET "http://localhost:8000/connections" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON):** +```json +[ + { + "id": "conn_123xyz", + "user_id": "user_abc", + "provider_id": "google_drive_test", + "provider_type": "google_drive", + "account_name": "Test User", + // ... + }, + { + "id": "conn_manual_abc", + "user_id": "user_abc", + "provider_id": "custom_api_service", + "provider_type": "custom", + "account_name": "My Custom Service Account", + // ... + } +] +``` + +--- + +### GET /connections/{connection_id} + +**Description:** Get a connection. + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- `connection_id`: + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK` - `ExternalToolConnectionDTO`) +```json +{ + "id": "", + // ... other fields from ExternalToolConnectionDTO +} +``` + +**Example Request (curl):** +```bash +curl -X GET "http://localhost:8000/connections/conn_123xyz" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON):** +```json +{ + "id": "conn_123xyz", + "user_id": "user_abc", + "provider_id": "google_drive_test", + "provider_type": "google_drive", + "account_name": "Test User", + "account_email": "test.user@example.com", + "account_id": "google_user_id_123", + "is_active": true, + "meta_data": {"some_provider_info": "details"}, + "created_at": "2023-10-27T10:00:00Z", + "updated_at": "2023-10-27T10:00:00Z", + "last_used_at": null, + "expires_at": "2023-10-27T11:00:00Z" +} +``` + +--- + +### POST /connections/{connection_id}/refresh + +**Description:** Refresh connection token. Attempts to use a refresh token (if available) to get a new access token for the connection. + +**Required Headers:** +- `Content-Type`: application/json (though body is not strictly needed) +- `Authorization`: Bearer + +**Path Parameters:** +- `connection_id`: + +**Query Parameters:** +- None + +**Request Body:** +- None (or empty JSON object `{}`) + +**Response Body:** (`200 OK` - `ExternalToolConnectionDTO`) +```json +{ + "id": "", + "expires_at": "", + // ... other fields from ExternalToolConnectionDTO +} +``` + +**Example Request (curl):** +```bash +curl -X POST "http://localhost:8000/connections/conn_123xyz/refresh" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{}' +``` + +**Example Response (JSON):** +```json +{ + "id": "conn_123xyz", + "user_id": "user_abc", + "provider_id": "google_drive_test", + "provider_type": "google_drive", + "account_name": "Test User", + "is_active": true, + "updated_at": "2023-10-27T12:00:00Z", + "expires_at": "2023-10-27T13:00:00Z", // New expiry + // ... other fields +} +``` + +--- + +### POST /connections/{connection_id}/revoke + +**Description:** Revoke connection. Invalidates the access token with the provider and marks the connection as inactive. + +**Required Headers:** +- `Content-Type`: application/json (though body is not strictly needed) +- `Authorization`: Bearer + +**Path Parameters:** +- `connection_id`: + +**Query Parameters:** +- None + +**Request Body:** +- None (or empty JSON object `{}`) + +**Response Body:** (`200 OK`) +```json +{ + "message": "", + "connection_id": "" +} +``` +*(Note: The service returns `Dict[str, Any]`. This is an example structure.)* + +**Example Request (curl):** +```bash +curl -X POST "http://localhost:8000/connections/conn_123xyz/revoke" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{}' +``` + +**Example Response (JSON):** +```json +{ + "message": "Connection revoked successfully.", + "connection_id": "conn_123xyz" +} +``` + +--- + +### DELETE /connections/{connection_id} + +**Description:** Delete connection. Removes the connection record from the system. Does not necessarily revoke the token with the provider. + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- `connection_id`: + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK`) +```json +{ + "message": "", + "connection_id": "" +} +``` +*(Note: The service returns `Dict[str, Any]`. This is an example structure.)* + +**Example Request (curl):** +```bash +curl -X DELETE "http://localhost:8000/connections/conn_123xyz" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON):** +```json +{ + "message": "Connection deleted successfully.", + "connection_id": "conn_123xyz" +} +``` + +## Analytics Endpoint + +### GET /analytics/card/{card_id} + +**Description:** Obtiene datos de una tarjeta de Metabase y opcionalmente los guarda en Supabase. (Fetches data from a Metabase card and optionally saves it to Supabase.) + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- `card_id`: + +**Query Parameters:** +- `session_token`: +- `metabase_url`: +- `supabase_bucket`: +- `supabase_path`: + +**Request Body:** +- None + +**Response Body:** (`200 OK`) +```json +{ + "data": "" + // If saved to Supabase, the response might include the Supabase URL or just the data. +} +``` + +**Example Request (curl):** +```bash +curl -X GET "http://localhost:8000/analytics/card/15?session_token=your_metabase_session_token&metabase_url=https%3A%2F%2Fmetabase.example.com&supabase_bucket=analytics_results&supabase_path=card_15_data.json" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON):** +```json +{ + "data": [ + {"category": "A", "count": 100, "sum_value": 1500.50}, + {"category": "B", "count": 75, "sum_value": 1200.75} + ] + // Could also be: {"data": "https://your-supabase-url/analytics_results/card_15_data.json"} if data is just uploaded. + // The current service code returns the data directly. +} +``` + +## AI Endpoint + +### POST /ai/inference/{model} + +**Description:** Realiza inferencia con Hugging Face y opcionalmente guarda el resultado en Supabase. (Performs inference with Hugging Face and optionally saves the result to Supabase.) + +**Required Headers:** +- `Content-Type`: application/json +- `Authorization`: Bearer + +**Path Parameters:** +- `model`: + +**Query Parameters:** +- `supabase_bucket`: +- `supabase_path`: + +**Request Body:** +A flexible JSON object containing the payload specific to the Hugging Face model. +```json +{ + "inputs": "" + // Other model-specific parameters can be included, e.g., "parameters": {"max_length": 50} +} +``` + +**Response Body:** (`200 OK`) +```json +{ + "result": "" + // If saved to Supabase, the response might include the Supabase URL or just the result. +} +``` + +**Example Request (curl for text generation with gpt2):** +```bash +curl -X POST "http://localhost:8000/ai/inference/gpt2?supabase_bucket=ai_results&supabase_path=gpt2_output.json" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "inputs": "Once upon a time in a land far away" + }' +``` + +**Example Response (JSON for text generation):** +```json +{ + "result": [ // Example structure for text generation + { + "generated_text": "Once upon a time in a land far away, there lived a princess..." + } + ] + // The current service code returns the result directly. +} +``` + +## Calendar Endpoints + +### GET /calendar/events + +**Description:** Lista eventos del calendario CalDAV (Radicale). (Lists events from the CalDAV calendar (Radicale).) + +**Required Headers:** +- `Authorization`: Bearer + +**Path Parameters:** +- None + +**Query Parameters:** +- `calendar_path`: + +**Request Body:** +- None + +**Response Body:** (`200 OK`) +A list of calendar events. The structure of each event depends on the `python-caldav` library's representation, often VEVENT components. +```json +[ + { + "uid": "", + "summary": "", + "dtstart": "", + "dtend": "", + "description": "", + "location": "", + // ... other VEVENT properties + } +] +``` + +**Example Request (curl):** +```bash +curl -X GET "http://localhost:8000/calendar/events?calendar_path=myuser/primary.ics" \ + -H "Authorization: Bearer " +``` + +**Example Response (JSON):** +```json +[ + { + "uid": "event-uid-12345", + "summary": "Team Meeting", + "dtstart": "2023-11-15T10:00:00Z", + "dtend": "2023-11-15T11:00:00Z", + "description": "Discuss project milestones.", + "location": "Conference Room 4B" + } +] +``` + +--- + +### POST /calendar/events + +**Description:** Crea un evento en el calendario CalDAV (Radicale). (Creates an event in the CalDAV calendar (Radicale).) + +**Required Headers:** +- `Content-Type`: application/json +- `Authorization`: Bearer + +**Path Parameters:** +- None + +**Query Parameters:** +- None (The parameters `summary`, `dtstart`, `dtend`, `calendar_path` are taken from the request body as JSON fields based on the function signature.) + +**Request Body:** +```json +{ + "summary": "", + "dtstart": "", + "dtend": "", + "calendar_path": "" +} +``` + +**Response Body:** (`200 OK`) +The created calendar event details, or a success message. The current service code returns the event object from `python-caldav`. +```json +{ + "uid": "", + "summary": "", + "dtstart": "", + "dtend": "", + // ... other VEVENT properties of the created event +} +``` + +**Example Request (curl):** +```bash +curl -X POST "http://localhost:8000/calendar/events" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "summary": "Doctor Appointment", + "dtstart": "2023-12-05T10:30:00Z", + "dtend": "2023-12-05T11:00:00Z", + "calendar_path": "myuser/personal.ics" + }' +``` + +**Example Response (JSON):** +```json +{ + "uid": "new-event-uid-67890", + "summary": "Doctor Appointment", + "dtstart": "2023-12-05T10:30:00Z", + "dtend": "2023-12-05T11:00:00Z" + // Potentially more fields depending on caldav library's event object structure +} +``` + +## Health Check Endpoint + +### GET /health + +**Description:** Health check endpoint. Standard health check to verify service availability. + +**Required Headers:** +- None + +**Path Parameters:** +- None + +**Query Parameters:** +- None + +**Request Body:** +- None + +**Response Body:** (`200 OK`) +```json +{ + "status": "" +} +``` + +**Example Request (curl):** +```bash +curl -X GET "http://localhost:8000/health" +``` + +**Example Response (JSON):** +```json +{ + "status": "healthy" +} +``` diff --git a/docker-compose.yml b/docker-compose.yml index bf196a6..78d79b0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ services: - DOCUMENT_SERVICE_URL=http://document_service:8003 - NOTIFICATION_SERVICE_URL=http://notification_service:8004 - EXTERNAL_TOOLS_SERVICE_URL=http://external_tools_service:8005 - - DATABASE_URL=postgresql://postgres:Adminqwert1234db@db.bhpkrxaqmlnyoxmcxxth.supabase.co:5432/postgres + - DATABASE_URL=${DATABASE_URL} - JWT_ALGORITHM=HS256 - ACCESS_TOKEN_EXPIRE_MINUTES=30 - REFRESH_TOKEN_EXPIRE_DAYS=7 @@ -45,7 +45,7 @@ services: env_file: - ./backend/.env environment: - - DATABASE_URL=postgresql://postgres:Adminqwert1234db@db.bhpkrxaqmlnyoxmcxxth.supabase.co:5432/postgres + - DATABASE_URL=${DATABASE_URL} - JWT_ALGORITHM=HS256 - ACCESS_TOKEN_EXPIRE_MINUTES=30 - REFRESH_TOKEN_EXPIRE_DAYS=7 @@ -69,7 +69,7 @@ services: env_file: - ./backend/.env environment: - - DATABASE_URL=postgresql://postgres:Adminqwert1234db@db.bhpkrxaqmlnyoxmcxxth.supabase.co:5432/postgres + - DATABASE_URL=${DATABASE_URL} - JWT_ALGORITHM=HS256 - RABBITMQ_HOST=rabbitmq - RABBITMQ_PORT=5672 @@ -95,7 +95,7 @@ services: env_file: - ./backend/.env environment: - - DATABASE_URL=postgresql://postgres:Adminqwert1234db@db.bhpkrxaqmlnyoxmcxxth.supabase.co:5432/postgres + - DATABASE_URL=${DATABASE_URL} - JWT_ALGORITHM=HS256 - RABBITMQ_HOST=rabbitmq - RABBITMQ_PORT=5672 @@ -121,7 +121,7 @@ services: env_file: - ./backend/.env environment: - - DATABASE_URL=postgresql://postgres:Adminqwert1234db@db.bhpkrxaqmlnyoxmcxxth.supabase.co:5432/postgres + - DATABASE_URL=${DATABASE_URL} - JWT_ALGORITHM=HS256 - RABBITMQ_HOST=rabbitmq - RABBITMQ_PORT=5672 @@ -147,7 +147,7 @@ services: env_file: - ./backend/.env environment: - - DATABASE_URL=postgresql://postgres:Adminqwert1234db@db.bhpkrxaqmlnyoxmcxxth.supabase.co:5432/postgres + - DATABASE_URL=${DATABASE_URL} - JWT_ALGORITHM=HS256 - RABBITMQ_HOST=rabbitmq - RABBITMQ_PORT=5672 diff --git a/frontend/lib/features/auth/data/auth_service.dart b/frontend/lib/features/auth/data/auth_service.dart index 25b6472..a417c02 100644 --- a/frontend/lib/features/auth/data/auth_service.dart +++ b/frontend/lib/features/auth/data/auth_service.dart @@ -2,187 +2,222 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'auth_models.dart'; -import 'package:flutter/foundation.dart'; - -// Simple User model -class User { - final String? uid; - final String? displayName; - final String? email; - final String? photoURL; - - User({this.uid, this.displayName, this.email, this.photoURL}); -} +import 'package:flutter/foundation.dart'; // ChangeNotifier is here // This is a simplified auth service. In a real app, you would integrate // with Firebase Auth, your own backend, or another auth provider. class AuthService extends ChangeNotifier { - static const String baseUrl = 'http://localhost:8000'; // Cambia por tu IP real - final storage = const FlutterSecureStorage(); + static const String baseUrl = 'http://localhost:8000'; + final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); - User? _currentUser; + UserProfileDTO? _currentUser; - User? get currentUser => _currentUser; + UserProfileDTO? get currentUser => _currentUser; // Check if user is logged in bool get isLoggedIn => _currentUser != null; - // Constructor - initialize with a debug user in debug mode + // Constructor AuthService() { - // Simulamos un usuario autenticado para desarrollo - if (kDebugMode) { - _currentUser = User( - uid: 'user123', - displayName: 'Usuario de Prueba', - email: 'usuario@example.com', - photoURL: null, - ); - notifyListeners(); - } + initialize(); } // Initialize the auth service and check for existing session Future initialize() async { - // Here you would check for existing auth tokens in secure storage - // and validate them with your backend try { - // Skip if we already have a debug user - if (_currentUser != null) return; - - // Simulate loading user data - await Future.delayed(const Duration(milliseconds: 500)); - - // For demo purposes, we'll assume no user is logged in initially - _currentUser = null; - notifyListeners(); + final token = await _secureStorage.read(key: 'access_token'); + if (token != null && token.isNotEmpty) { + // Validate token by fetching profile + final userProfile = await getProfile(); // getProfile uses the stored token + _currentUser = userProfile; + } else { + _currentUser = null; + } } catch (e) { - // Handle initialization error + // If getProfile fails (e.g. token expired), clear token and user + await _secureStorage.delete(key: 'access_token'); + await _secureStorage.delete(key: 'refresh_token'); _currentUser = null; - notifyListeners(); } + notifyListeners(); } - // Sign in with email and password - Future signIn(String email, String password) async { - // Here you would make an API call to your auth endpoint - try { - // Simulate API call - await Future.delayed(const Duration(seconds: 1)); + // Login with email and password + Future login(String email, String password) async { + print('[AuthService.login] Attempting to login...'); + print('[AuthService.login] URL: $baseUrl/auth/login'); + print('[AuthService.login] Headers: {Content-Type: application/x-www-form-urlencoded}'); + print('[AuthService.login] Body: {username: $email, password: }'); - // For demo purposes, we'll create a mock user - _currentUser = User( - uid: 'user123', - email: email, - displayName: 'Usuario Autenticado', - photoURL: null, - ); + final response = await http.post( + Uri.parse('$baseUrl/auth/login'), + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, // Backend expects form data for login + body: {'username': email, 'password': password}, // FastAPI's OAuth2PasswordRequestForm takes 'username' + ); + if (response.statusCode == 200) { + print('[AuthService.login] Login API call successful. Status: ${response.statusCode}'); + print('[AuthService.login] Response body: ${response.body}'); + final data = jsonDecode(response.body); + final tokenDto = TokenDTO.fromJson(data); + await _secureStorage.write(key: 'access_token', value: tokenDto.accessToken); + await _secureStorage.write(key: 'refresh_token', value: tokenDto.refreshToken); + + try { + _currentUser = await getProfile(); + notifyListeners(); + return _currentUser!; // Assuming getProfile will throw if it can't return a user + } catch (e) { + print('[AuthService.login] Error fetching profile after login: ${e.toString()}'); + // If getProfile fails after login, something is wrong. Clean up. + await _secureStorage.delete(key: 'access_token'); + await _secureStorage.delete(key: 'refresh_token'); + _currentUser = null; + notifyListeners(); + throw Exception('Login succeeded but failed to fetch profile: ${e.toString()}'); + } + } else { + print('[AuthService.login] Login API call failed. Status: ${response.statusCode}'); + print('[AuthService.login] Response body: ${response.body}'); + _currentUser = null; notifyListeners(); - return _currentUser; - } catch (e) { - rethrow; + throw Exception('Login failed with status ${response.statusCode}: ${response.body}'); } } - // Sign up with name, email and password - Future signUp(String name, String email, String password) async { - try { - // Simulate API call - await Future.delayed(const Duration(seconds: 1)); + // Register with email, password, full name, and company name + Future register(String email, String password, String fullName, String? companyName) async { + print('[AuthService.register] Attempting to register...'); + print('[AuthService.register] URL: $baseUrl/auth/register'); + final requestBodyMap = { + 'email': email, + 'password': password, + 'full_name': fullName, + if (companyName != null && companyName.isNotEmpty) 'company_name': companyName, + }; + print('[AuthService.register] Headers: {Content-Type: application/json}'); + // Mask password for logging + final loggableBody = Map.from(requestBodyMap); + loggableBody['password'] = ''; + print('[AuthService.register] Request body (raw): $loggableBody'); - // For demo purposes, we'll create a mock user - _currentUser = User( - uid: 'newuser456', - email: email, - displayName: name, - photoURL: null, - ); + final response = await http.post( + Uri.parse('$baseUrl/auth/register'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(requestBodyMap), // Send original map with actual password + ); + if (response.statusCode == 200 || response.statusCode == 201) { // Typically 201 for register + print('[AuthService.register] Register API call successful. Status: ${response.statusCode}'); + print('[AuthService.register] Response body: ${response.body}'); + final data = jsonDecode(response.body); + final tokenDto = TokenDTO.fromJson(data); + await _secureStorage.write(key: 'access_token', value: tokenDto.accessToken); + await _secureStorage.write(key: 'refresh_token', value: tokenDto.refreshToken); + + try { + _currentUser = await getProfile(); + notifyListeners(); + return _currentUser!; + } catch (e) { + print('[AuthService.register] Error fetching profile after registration: ${e.toString()}'); + await _secureStorage.delete(key: 'access_token'); + await _secureStorage.delete(key: 'refresh_token'); + _currentUser = null; + notifyListeners(); + throw Exception('Registration succeeded but failed to fetch profile: ${e.toString()}'); + } + } else { + print('[AuthService.register] Register API call failed. Status: ${response.statusCode}'); + print('[AuthService.register] Response body: ${response.body}'); + _currentUser = null; notifyListeners(); - return _currentUser; - } catch (e) { - rethrow; + throw Exception('Register failed with status ${response.statusCode}: ${response.body}'); } } // Sign out Future signOut() async { - // Here you would invalidate tokens on your backend - try { - // Simulate API call - await Future.delayed(const Duration(seconds: 1)); - - _currentUser = null; - notifyListeners(); - } catch (e) { - rethrow; + final token = await _secureStorage.read(key: 'access_token'); + if (token != null) { + try { + await http.post( + Uri.parse('$baseUrl/auth/logout'), + headers: { + 'Authorization': 'Bearer $token', + }, + ); + // Regardless of API call success, clear local data + } catch (e) { + // Log error or handle silently, but still proceed with local cleanup + if (kDebugMode) { + print('Error during API logout: $e'); + } + } } + + await _secureStorage.delete(key: 'access_token'); + await _secureStorage.delete(key: 'refresh_token'); + _currentUser = null; + notifyListeners(); } - // Update user profile - Future updateProfile({String? displayName, String? email}) async { - final token = await storage.read(key: 'access_token'); - final response = await http.put( - Uri.parse('$baseUrl/auth/profile'), - headers: { - 'Authorization': 'Bearer $token', - 'Content-Type': 'application/json', - }, - body: jsonEncode({ - if (displayName != null) 'full_name': displayName, - if (email != null) 'email': email, - }), - ); - if (response.statusCode != 200) { - throw Exception('Error al actualizar perfil'); + // Get user profile + Future getProfile() async { + final token = await _secureStorage.read(key: 'access_token'); + if (token == null) { + throw Exception('Not authenticated: No token found.'); } - } - Future login(String email, String password) async { - final response = await http.post( - Uri.parse('$baseUrl/auth/login'), - headers: {'Content-Type': 'application/json'}, - body: jsonEncode({'email': email, 'password': password}), + final response = await http.get( + Uri.parse('$baseUrl/auth/profile'), + headers: {'Authorization': 'Bearer $token'}, ); + if (response.statusCode == 200) { - final data = jsonDecode(response.body); - await storage.write(key: 'access_token', value: data['access_token']); - return TokenDTO.fromJson(data); - } else { - throw Exception('Login failed'); + return UserProfileDTO.fromJson(jsonDecode(response.body)); + } else if (response.statusCode == 401) { // Unauthorized + await _secureStorage.delete(key: 'access_token'); + await _secureStorage.delete(key: 'refresh_token'); + _currentUser = null; + notifyListeners(); + throw Exception('Session expired or token invalid. Please login again.'); + } + else { + throw Exception('Failed to fetch profile with status ${response.statusCode}: ${response.body}'); } } - Future register(String email, String password, String fullName, String companyName) async { - final response = await http.post( - Uri.parse('$baseUrl/auth/register'), - headers: {'Content-Type': 'application/json'}, - body: jsonEncode({ - 'email': email, - 'password': password, - 'full_name': fullName, - 'company_name': companyName, - }), - ); - if (response.statusCode == 200) { - final data = jsonDecode(response.body); - await storage.write(key: 'access_token', value: data['access_token']); - return TokenDTO.fromJson(data); - } else { - throw Exception('Register failed'); + // Update user profile + Future updateProfile({String? displayName, String? email}) async { + final token = await _secureStorage.read(key: 'access_token'); + if (token == null) { + throw Exception('Not authenticated for updating profile.'); } - } - Future getProfile() async { - final token = await storage.read(key: 'access_token'); - final response = await http.get( + final Map body = {}; + if (displayName != null) body['full_name'] = displayName; + if (email != null) body['email'] = email; + + if (body.isEmpty) { + return; // No changes to update + } + + final response = await http.put( Uri.parse('$baseUrl/auth/profile'), - headers: {'Authorization': 'Bearer $token'}, + headers: { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + }, + body: jsonEncode(body), ); + if (response.statusCode == 200) { - return UserProfileDTO.fromJson(jsonDecode(response.body)); + // Optionally, re-fetch profile to update _currentUser if email or other critical fields changed + _currentUser = await getProfile(); + notifyListeners(); } else { - throw Exception('Profile fetch failed'); + throw Exception('Error al actualizar perfil: ${response.body}'); } } } diff --git a/frontend/lib/features/auth/screens/login_screen.dart b/frontend/lib/features/auth/screens/login_screen.dart index 4ded4b2..e1f37d7 100644 --- a/frontend/lib/features/auth/screens/login_screen.dart +++ b/frontend/lib/features/auth/screens/login_screen.dart @@ -1,5 +1,10 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; +import '../data/auth_service.dart'; +// UserProfileDTO is not directly used in this screen after AuthService.login refactor, +// but good to have if we were to receive UserProfileDTO here. +// import '../data/auth_models.dart'; import '../../../core/widgets/custom_textfield.dart'; import '../../../core/widgets/primary_button.dart'; @@ -17,18 +22,34 @@ class _LoginScreenState extends State { String? _error; void _login() async { - setState(() => _isLoading = true); - // Simulación de login. Aquí va llamada a AuthService - await Future.delayed(const Duration(seconds: 1)); - setState(() => _isLoading = false); + print('[LoginScreen] _login method CALLED'); + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final authService = Provider.of(context, listen: false); + // AuthService.login now handles setting the user state and returns UserProfileDTO + // We don't need to use the returned UserProfileDTO directly here unless for specific UI update before navigation + await authService.login(_emailController.text.trim(), _passwordController.text.trim()); - if (_emailController.text == 'admin@taskhub.com' && - _passwordController.text == '123456') { - // Redirigir a Home usando go_router if (!mounted) return; context.go('/dashboard'); - } else { - setState(() => _error = 'Credenciales incorrectas'); + + } catch (e) { + if (mounted) { + setState(() { + // You can customize error messages based on exception type if needed + _error = 'Login failed. Please check your credentials or network connection.'; + // Example of more specific error: + // _error = e is Exception ? e.toString().replaceFirst("Exception: ", "") : 'An unknown error occurred.'; + }); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } } } diff --git a/frontend/lib/features/auth/screens/register_screen.dart b/frontend/lib/features/auth/screens/register_screen.dart index bacd6f8..51e1234 100644 --- a/frontend/lib/features/auth/screens/register_screen.dart +++ b/frontend/lib/features/auth/screens/register_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; +import '../data/auth_service.dart'; import '../../../core/widgets/custom_textfield.dart'; import '../../../core/widgets/primary_button.dart'; @@ -15,15 +17,61 @@ class _RegisterScreenState extends State { final _emailController = TextEditingController(); final _passwordController = TextEditingController(); final _confirmPasswordController = TextEditingController(); + // final _companyNameController = TextEditingController(); // Add if UI field is added String? _error; + bool _isLoading = false; + + void _register() async { + print('[RegisterScreen] _register method CALLED'); + setState(() { + _error = null; + _isLoading = true; + }); + + if (_nameController.text.isEmpty || _emailController.text.isEmpty || _passwordController.text.isEmpty) { + setState(() { + _error = 'Por favor, completa todos los campos obligatorios.'; + _isLoading = false; + }); + return; + } - void _register() { - setState(() => _error = null); if (_passwordController.text != _confirmPasswordController.text) { - setState(() => _error = 'Las contraseñas no coinciden'); + setState(() { + _error = 'Las contraseñas no coinciden'; + _isLoading = false; + }); return; } - context.go('/login'); + + try { + final authService = Provider.of(context, listen: false); + // Assuming companyName is optional and can be passed as null if not collected. + // If a _companyNameController is added, use its text value. + await authService.register( + _emailController.text.trim(), + _passwordController.text.trim(), + _nameController.text.trim(), + null, // Passing null for companyName + // _companyNameController.text.trim(), // Use if a company name field is added + ); + + if (!mounted) return; + context.go('/dashboard'); + + } catch (e) { + if (mounted) { + setState(() { + _error = 'Registration failed. Please try again.'; + // Example of more specific error: + // _error = e is Exception ? e.toString().replaceFirst("Exception: ", "") : 'An unknown error occurred.'; + }); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } } @override @@ -91,11 +139,8 @@ class _RegisterScreenState extends State { ], const SizedBox(height: 24), PrimaryButton( - text: 'Crear cuenta', - onPressed: () { - Feedback.forTap(context); - _register(); - }, + text: _isLoading ? 'Creando cuenta...' : 'Crear cuenta', + onPressed: _isLoading ? null : _register, ), const SizedBox(height: 16), TextButton( diff --git a/frontend/lib/features/home/data/document_models.dart b/frontend/lib/features/home/data/document_models.dart index 8b52f1b..a2f01a4 100644 --- a/frontend/lib/features/home/data/document_models.dart +++ b/frontend/lib/features/home/data/document_models.dart @@ -1,9 +1,32 @@ +// Defines the DocumentType enum and related helper functions +enum DocumentType { + file, + folder, + link +} + +// Helper function to convert DocumentType enum to a string for serialization +String documentTypeToString(DocumentType type) { + return type.toString().split('.').last; +} + +// Helper function to parse a string into DocumentType enum, with a fallback +DocumentType documentTypeFromString(String? typeString) { + if (typeString == null) { + return DocumentType.file; // Default or handle as an error appropriately + } + return DocumentType.values.firstWhere( + (e) => e.toString().split('.').last.toLowerCase() == typeString.toLowerCase(), + orElse: () => DocumentType.file, // Default for unrecognized strings + ); +} + class DocumentDTO { final String id; final String name; final String projectId; final String? parentId; - final String type; + final DocumentType type; // Changed from String to DocumentType final String? contentType; final int? size; final String? url; @@ -20,7 +43,7 @@ class DocumentDTO { required this.name, required this.projectId, this.parentId, - required this.type, + required this.type, // Type is DocumentType this.contentType, this.size, this.url, @@ -34,20 +57,39 @@ class DocumentDTO { }); factory DocumentDTO.fromJson(Map json) => DocumentDTO( - id: json['id'], - name: json['name'], - projectId: json['project_id'], - parentId: json['parent_id'], - type: json['type'], - contentType: json['content_type'], - size: json['size'], - url: json['url'], - description: json['description'], - version: json['version'], - creatorId: json['creator_id'], - tags: json['tags'] != null ? List.from(json['tags']) : null, - metaData: json['meta_data'] != null ? Map.from(json['meta_data']) : null, - createdAt: DateTime.parse(json['created_at']), - updatedAt: json['updated_at'] != null ? DateTime.parse(json['updated_at']) : null, + id: json['id'] as String, + name: json['name'] as String, + projectId: json['project_id'] as String, + parentId: json['parent_id'] as String?, + type: documentTypeFromString(json['type'] as String?), // Use helper for parsing + contentType: json['content_type'] as String?, + size: json['size'] as int?, + url: json['url'] as String?, + description: json['description'] as String?, + version: json['version'] as int, + creatorId: json['creator_id'] as String, + tags: json['tags'] != null ? List.from(json['tags'] as List) : null, + metaData: json['meta_data'] != null ? Map.from(json['meta_data'] as Map) : null, + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: json['updated_at'] != null ? DateTime.parse(json['updated_at'] as String) : null, ); + + // Add toJson if needed for sending this DTO to backend (ensure type is converted back to string) + Map toJson() => { + 'id': id, + 'name': name, + 'project_id': projectId, + 'parent_id': parentId, + 'type': documentTypeToString(type), // Convert enum to string + 'content_type': contentType, + 'size': size, + 'url': url, + 'description': description, + 'version': version, + 'creator_id': creatorId, + 'tags': tags, + 'meta_data': metaData, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + }; } \ No newline at end of file diff --git a/frontend/lib/features/home/data/external_tools_service.dart b/frontend/lib/features/home/data/external_tools_service.dart index 3aadcc7..77a05bc 100644 --- a/frontend/lib/features/home/data/external_tools_service.dart +++ b/frontend/lib/features/home/data/external_tools_service.dart @@ -21,6 +21,58 @@ class ExternalToolsService { } } + Future getAuthorizationUrl(String providerId, {String? redirectUri}) async { + final token = await storage.read(key: 'access_token'); + final Map body = { + "provider_id": providerId, + }; + if (redirectUri != null) { + body["redirect_uri"] = redirectUri; + } + + final response = await http.post( + Uri.parse('$baseUrl/oauth/authorize'), + headers: { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + }, + body: jsonEncode(body), + ); + + if (response.statusCode == 200) { + // Backend returns the URL string directly in the body + return response.body; + } else { + throw Exception('Failed to get authorization URL. Status: ${response.statusCode}, Body: ${response.body}'); + } + } + + Future handleOAuthCallback(String providerId, String code, {String? state}) async { + final token = await storage.read(key: 'access_token'); + final Map body = { + "provider_id": providerId, + "code": code, + }; + if (state != null) { + body["state"] = state; + } + + final response = await http.post( + Uri.parse('$baseUrl/oauth/callback'), + headers: { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + }, + body: jsonEncode(body), + ); + + if (response.statusCode == 200) { + return ExternalToolConnectionDTO.fromJson(jsonDecode(response.body)); + } else { + throw Exception('Failed to handle OAuth callback. Status: ${response.statusCode}, Body: ${response.body}'); + } + } + // Obtener conexiones de usuario Future> getUserConnections() async { final token = await storage.read(key: 'access_token'); diff --git a/frontend/lib/features/home/data/notification_service.dart b/frontend/lib/features/home/data/notification_service.dart index 503ee9f..c0bda06 100644 --- a/frontend/lib/features/home/data/notification_service.dart +++ b/frontend/lib/features/home/data/notification_service.dart @@ -43,6 +43,19 @@ class NotificationService { } } + Future markAllNotificationsAsRead() async { + final token = await storage.read(key: 'access_token'); + final response = await http.put( + Uri.parse('$baseUrl/notifications/read-all'), + headers: {'Authorization': 'Bearer $token'}, + ); + // Backend returns a dictionary like {"message": "...", "count": ...}, so 200 is expected. + // 204 No Content could also be valid for some PUT operations if nothing is returned. + if (response.statusCode != 200 && response.statusCode != 204) { + throw Exception('Failed to mark all notifications as read. Status: ${response.statusCode}'); + } + } + // Nuevo: obtener notificaciones del usuario Future> getUserNotifications() async { return getNotifications(); diff --git a/frontend/lib/features/home/data/project_service.dart b/frontend/lib/features/home/data/project_service.dart index 116801d..15c9598 100644 --- a/frontend/lib/features/home/data/project_service.dart +++ b/frontend/lib/features/home/data/project_service.dart @@ -107,6 +107,19 @@ class ProjectService { } } + Future getTaskDetails(String projectId, String taskId) async { + final token = await storage.read(key: 'access_token'); + final response = await http.get( + Uri.parse('$baseUrl/projects/$projectId/tasks/$taskId'), + headers: {'Authorization': 'Bearer $token'}, + ); + if (response.statusCode == 200) { + return TaskDTO.fromJson(jsonDecode(response.body)); + } else { + throw Exception('Failed to fetch task details'); + } + } + Future> getProjectActivities(String projectId) async { final token = await storage.read(key: 'access_token'); final response = await http.get( diff --git a/frontend/lib/features/home/screens/account_settings_screen.dart b/frontend/lib/features/home/screens/account_settings_screen.dart index 473eaff..9f0178d 100644 --- a/frontend/lib/features/home/screens/account_settings_screen.dart +++ b/frontend/lib/features/home/screens/account_settings_screen.dart @@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import '../../../core/constants/colors.dart'; import '../../../theme/theme_provider.dart'; +import '../../auth/data/auth_service.dart'; // Added import class AccountSettingsPage extends StatelessWidget { const AccountSettingsPage({super.key}); @@ -54,9 +55,13 @@ class AccountSettingsPage extends StatelessWidget { leading: const Icon(Icons.logout, color: AppColors.error), title: const Text('Cerrar sesión'), trailing: const Icon(Icons.chevron_right), - onTap: () { + onTap: () async { // Made async Feedback.forTap(context); - context.go('/login'); + final authService = Provider.of(context, listen: false); + await authService.signOut(); + if (context.mounted) { + context.go('/login'); + } }, ), Divider(color: Theme.of(context).dividerColor), diff --git a/frontend/lib/features/home/screens/document_detail_screen.dart b/frontend/lib/features/home/screens/document_detail_screen.dart index c6d4deb..4525400 100644 --- a/frontend/lib/features/home/screens/document_detail_screen.dart +++ b/frontend/lib/features/home/screens/document_detail_screen.dart @@ -1,19 +1,27 @@ import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; // For date formatting import '../../../core/constants/colors.dart'; -import 'package:go_router/go_router.dart'; -import '../../../core/widgets/navigation_utils.dart'; -import '../../home/data/document_service.dart'; -import '../../home/data/document_models.dart'; +import 'package:go_router/go_router.dart'; // Already present, ensure it stays +// import '../../../core/widgets/navigation_utils.dart'; // Not used in my generated code for this screen +import '../data/document_service.dart'; // Path adjusted +import '../data/document_models.dart'; // Path adjusted class DocumentDetailScreen extends StatefulWidget { - final String? documentId; - const DocumentDetailScreen({super.key, this.documentId}); + final String documentId; + final String projectId; + + const DocumentDetailScreen({ + super.key, + required this.documentId, + required this.projectId, + }); @override State createState() => _DocumentDetailScreenState(); } class _DocumentDetailScreenState extends State { + final DocumentService _documentService = DocumentService(); // Added instance DocumentDTO? _document; bool _loading = true; String? _error; @@ -21,60 +29,42 @@ class _DocumentDetailScreenState extends State { @override void initState() { super.initState(); - _fetchDocument(); + _fetchDocumentDetails(); // Renamed call } - Future _fetchDocument() async { + Future _fetchDocumentDetails() async { // Renamed method setState(() { _loading = true; _error = null; }); try { - if (widget.documentId == null) throw Exception('ID de documento no proporcionado'); - final doc = await DocumentService().getDocumentById(widget.documentId!); - setState(() { - _document = doc; - }); + // documentId is now required, no null check needed for widget.documentId itself + final doc = await _documentService.getDocumentById(widget.documentId); // _documentService is now a class member + if (mounted) { + setState(() { + _document = doc; + }); + } } catch (e) { - setState(() { - _error = e.toString(); - }); + if (mounted) { + setState(() { + _error = 'Error al cargar el documento: ${e.toString()}'; // Improved error message + }); + } } finally { - setState(() { - _loading = false; - }); + if (mounted) { // mounted check for finally block + setState(() { + _loading = false; + }); // Corrected brace + } } } - Widget _buildDetail(DocumentDTO doc) { - return ListView( - padding: const EdgeInsets.all(16), - children: [ - Text(doc.name, style: Theme.of(context).textTheme.headlineSmall), - const SizedBox(height: 8), - Text('ID: ${doc.id}'), - Text('Proyecto: ${doc.projectId}'), - if (doc.parentId != null) Text('Carpeta padre: ${doc.parentId}'), - Text('Tipo: ${doc.type}'), - if (doc.contentType != null) Text('Content-Type: ${doc.contentType}'), - if (doc.size != null) Text('Tamaño: ${doc.size} bytes'), - if (doc.url != null) Text('URL: ${doc.url}'), - if (doc.description != null) Text('Descripción: ${doc.description}'), - Text('Versión: ${doc.version}'), - Text('Creador: ${doc.creatorId}'), - if (doc.tags != null && doc.tags!.isNotEmpty) Text('Tags: ${doc.tags!.join(", ")}'), - if (doc.metaData != null && doc.metaData!.isNotEmpty) Text('MetaData: ${doc.metaData}'), - Text('Creado: ${doc.createdAt}'), - if (doc.updatedAt != null) Text('Actualizado: ${doc.updatedAt}'), - ], - ); - } - @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text('Documento ${widget.documentId ?? ''}'), + title: const Text('Detalle del Documento'), // Changed title backgroundColor: AppColors.primary, foregroundColor: AppColors.textOnPrimary, elevation: 2, @@ -91,30 +81,97 @@ class _DocumentDetailScreenState extends State { }, ), ), - body: _loading - ? const Center(child: CircularProgressIndicator()) - : _error != null - ? Center(child: Text('Error: $_error')) - : _document == null - ? const Center(child: Text('Documento no encontrado')) - : Stack( - children: [ - _buildDetail(_document!), - Positioned( - bottom: 24, - right: 24, - child: FloatingActionButton( - onPressed: () { - if (_document != null) { - context.go('/edit-document', extra: _document!); - } - }, - child: const Icon(Icons.edit), - tooltip: 'Editar documento', - ), - ), - ], - ), + body: _buildBody(), // Updated to call _buildBody + ); + } + + Widget _buildBody() { // New _buildBody structure + if (_loading) { + return const Center(child: CircularProgressIndicator()); + } + if (_error != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text(_error!, style: const TextStyle(color: Colors.red, fontSize: 16)), + ), + ); + } + if (_document == null) { + return const Center(child: Text('Documento no encontrado.')); + } + + final doc = _document!; + final textTheme = Theme.of(context).textTheme; + final dateFormat = DateFormat('dd/MM/yyyy HH:mm'); + + return ListView( + padding: const EdgeInsets.all(16.0), + children: [ + Text(doc.name, style: textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + _buildDetailItem(Icons.description, 'Descripción:', doc.description ?? 'Sin descripción'), + _buildDetailItem(Icons.folder_special, 'Tipo:', documentTypeToString(doc.type)), // Used helper + _buildDetailItem(Icons.inventory_2, 'Proyecto ID:', doc.projectId), // Displaying projectId from widget + _buildDetailItem(Icons.person, 'Creador ID:', doc.creatorId), + _buildDetailItem(Icons.tag, 'Versión:', doc.version.toString()), + _buildDetailItem(Icons.calendar_today, 'Creado:', dateFormat.format(doc.createdAt.toLocal())), + if (doc.updatedAt != null) + _buildDetailItem(Icons.edit_calendar, 'Actualizado:', dateFormat.format(doc.updatedAt!.toLocal())), + if (doc.type == DocumentType.link && doc.url != null && doc.url!.isNotEmpty) + _buildDetailItem(Icons.link, 'URL:', doc.url!), + if (doc.contentType != null && doc.contentType!.isNotEmpty) + _buildDetailItem(Icons.attachment, 'Tipo de Contenido:', doc.contentType!), + if (doc.size != null) + _buildDetailItem(Icons.sd_storage, 'Tamaño:', '${(doc.size! / 1024).toStringAsFixed(2)} KB'), // Formatted size + if (doc.tags != null && doc.tags!.isNotEmpty) + _buildDetailItem(Icons.label, 'Tags:', doc.tags!.join(', ')), + if (doc.metaData != null && doc.metaData!.isNotEmpty) + _buildDetailItem(Icons.data_object, 'Metadata:', doc.metaData.toString()), + + const SizedBox(height: 24), + Text( + 'Acciones (TODO):', + style: textTheme.titleMedium, + ), + ListTile( + leading: const Icon(Icons.open_in_new), + title: const Text('Abrir/Descargar'), + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Funcionalidad de abrir/descargar no implementada.')), + ); + }, + ), + ListTile( + leading: const Icon(Icons.history), + title: const Text('Ver Versiones'), + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Funcionalidad de ver versiones no implementada.')), + ); + }, + ), + // Note: The FloatingActionButton for edit is removed in this version of _buildBody + // as it was part of the Stack in the original file's build method. + // If it needs to be kept, it should be added back to the Scaffold in the main build method. + // For this refactoring, I'm focusing on replacing _buildDetail with _buildBody + _buildDetailItem. + ], + ); + } + + Widget _buildDetailItem(IconData icon, String label, String value) { // New helper method + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), // Increased padding + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 12), + Text('$label ', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15)), + Expanded(child: Text(value, style: const TextStyle(fontSize: 15))), + ], + ), ); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/frontend/lib/features/home/screens/documents_screen.dart b/frontend/lib/features/home/screens/documents_screen.dart index 19fc207..fa914d4 100644 --- a/frontend/lib/features/home/screens/documents_screen.dart +++ b/frontend/lib/features/home/screens/documents_screen.dart @@ -127,7 +127,7 @@ class _DocumentsPageState extends State { ), onTap: () { Feedback.forTap(context); - context.go('/document/${doc.id}'); + context.go('/project/${doc.projectId}/document/${doc.id}'); }, ), ); diff --git a/frontend/lib/features/home/screens/externaltools_screen.dart b/frontend/lib/features/home/screens/externaltools_screen.dart index 0141ca9..40e7d36 100644 --- a/frontend/lib/features/home/screens/externaltools_screen.dart +++ b/frontend/lib/features/home/screens/externaltools_screen.dart @@ -12,13 +12,47 @@ class ExternalToolsScreen extends StatefulWidget { class _ExternalToolsScreenState extends State { List _connections = []; - bool _loading = true; - String? _error; + bool _loading = true; // For existing connections + String? _error; // For existing connections + + List _availableProviders = []; + bool _providersLoading = true; // For fetching providers + String? _providersError; // For fetching providers + + final ExternalToolsService _externalToolsService = ExternalToolsService(); @override void initState() { super.initState(); _fetchConnections(); + _fetchAvailableProviders(); // Added call + } + + Future _fetchAvailableProviders() async { + setState(() { + _providersLoading = true; + _providersError = null; + }); + try { + final providers = await _externalToolsService.getOAuthProviders(); + if (mounted) { + setState(() { + _availableProviders = providers; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _providersError = 'Error al cargar proveedores: ${e.toString()}'; + }); + } + } finally { + if (mounted) { + setState(() { + _providersLoading = false; + }); + } + } } Future _fetchConnections() async { @@ -127,11 +161,8 @@ class _ExternalToolsScreenState extends State { }, ), floatingActionButton: FloatingActionButton( - onPressed: () { - // Acción para conectar nueva herramienta externa - // Por ejemplo: Navigator.of(context).pushNamed('/externaltools/connect'); - }, - tooltip: 'Conectar herramienta', + onPressed: _showAvailableProvidersDialog, // Updated FAB onPressed + tooltip: 'Conectar nueva herramienta', child: const Icon(Icons.add_link), ), ); @@ -141,20 +172,87 @@ class _ExternalToolsScreenState extends State { switch (providerType) { case 'github': return Icons.code; - case 'google_drive': - return Icons.cloud; - case 'dropbox': - return Icons.cloud_upload; - case 'onedrive': - return Icons.cloud_done; - case 'slack': - return Icons.chat; - case 'jira': - return Icons.bug_report; - case 'trello': - return Icons.view_kanban; + // Add other cases as defined in your ExternalToolType enum or data + case 'google_drive': // Assuming 'google_drive' is a value from your ExternalToolType + return Icons.cloud_outlined; // Corrected icon name default: return Icons.extension; } } + + void _handleProviderTap(OAuthProviderDTO provider) async { + // Called when a provider is tapped in the dialog + Navigator.of(context).pop(); // Close the dialog + try { + // For this subtask, redirectUri is omitted to use backend default + final authUrl = await _externalToolsService.getAuthorizationUrl(provider.id); + + print('Authorization URL: $authUrl'); // Print to console + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Abrir esta URL para autorizar: $authUrl', maxLines: 3, overflow: TextOverflow.ellipsis), + duration: const Duration(seconds: 10), // Longer duration for URL + action: SnackBarAction(label: 'COPIAR', onPressed: () { + // TODO: Implement copy to clipboard if 'clipboard' package is added + }), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error al obtener URL de autorización: ${e.toString()}')), + ); + } + } + } + + void _showAvailableProvidersDialog() { + showDialog( + context: context, + builder: (BuildContext context) { + // Use a StatefulBuilder if the dialog content needs its own state updates + // For now, relying on _providersLoading, _providersError, _availableProviders from the main screen state. + Widget content; + if (_providersLoading) { + content = const Center(child: CircularProgressIndicator()); + } else if (_providersError != null) { + content = Text(_providersError!, style: const TextStyle(color: Colors.red)); + } else if (_availableProviders.isEmpty) { + content = const Text('No hay proveedores de OAuth disponibles.'); + } else { + content = SizedBox( + width: double.maxFinite, + child: ListView.builder( + shrinkWrap: true, + itemCount: _availableProviders.length, + itemBuilder: (BuildContext context, int index) { + final provider = _availableProviders[index]; + return ListTile( + leading: Icon(_iconForProvider(provider.type.toString().split('.').last.toLowerCase())), // Get string value of enum + title: Text(provider.name), + subtitle: Text(provider.type.toString().split('.').last), + onTap: () => _handleProviderTap(provider), + ); + }, + ), + ); + } + + return AlertDialog( + title: const Text('Conectar nueva herramienta'), + content: content, + actions: [ + TextButton( + child: const Text('Cancelar'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + } } diff --git a/frontend/lib/features/home/screens/home_screen.dart b/frontend/lib/features/home/screens/home_screen.dart index fd9c986..e22ad47 100644 --- a/frontend/lib/features/home/screens/home_screen.dart +++ b/frontend/lib/features/home/screens/home_screen.dart @@ -22,8 +22,8 @@ class _HomeScreenState extends State { const DashboardScreen(), const ProjectsPage(), const DocumentsPage(), - const NotificationsPage(), - const ExternalToolsPage(), + const NotificationsScreen(), + const ExternalToolsScreen(), const ProfilePage(), ]; diff --git a/frontend/lib/features/home/screens/notifications_screen.dart b/frontend/lib/features/home/screens/notifications_screen.dart index 0b3f00c..1416b92 100644 --- a/frontend/lib/features/home/screens/notifications_screen.dart +++ b/frontend/lib/features/home/screens/notifications_screen.dart @@ -43,6 +43,26 @@ class _NotificationsScreenState extends State { } } + Future _deleteNotification(String notificationId) async { + try { + await NotificationService().deleteNotification(notificationId); + if (mounted) { + setState(() { + _notifications.removeWhere((n) => n.id == notificationId); + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Notificación eliminada.')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error al eliminar notificación: $e')), + ); + } + } + } + Future _markAsRead(String notificationId) async { try { await NotificationService().markNotificationAsRead(notificationId); @@ -54,8 +74,29 @@ class _NotificationsScreenState extends State { } } + Future _markAllAsRead() async { + try { + await NotificationService().markAllNotificationsAsRead(); + await _fetchNotifications(); // Refresh the list + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Todas las notificaciones marcadas como leídas.')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error al marcar todas como leídas: $e')), + ); + } + } + } + @override Widget build(BuildContext context) { + // Determine if there are any unread notifications to enable/disable the button + // bool hasUnreadNotifications = _notifications.any((n) => !n.isRead); + return Scaffold( appBar: AppBar( title: const Text('Notificaciones'), @@ -74,6 +115,14 @@ class _NotificationsScreenState extends State { context.pop(); }, ), + actions: [ + IconButton( + icon: const Icon(Icons.done_all), + tooltip: 'Marcar todas como leídas', + // onPressed: hasUnreadNotifications ? _markAllAsRead : null, // Optionally disable if all are read + onPressed: _markAllAsRead, + ), + ], ), body: _loading ? const Center(child: CircularProgressIndicator()) @@ -132,13 +181,22 @@ class _NotificationsScreenState extends State { Text('Leída: ${notif.readAt}'), ], ), - trailing: notif.isRead - ? null - : IconButton( + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!notif.isRead) + IconButton( icon: const Icon(Icons.mark_email_read, color: AppColors.primary), tooltip: 'Marcar como leído', onPressed: () => _markAsRead(notif.id), ), + IconButton( + icon: Icon(Icons.delete_outline, color: Colors.grey[600]), + tooltip: 'Eliminar notificación', + onPressed: () => _deleteNotification(notif.id), + ), + ], + ), onTap: () { Feedback.forTap(context); // Acción al tocar la notificación (por ejemplo, navegar a la entidad relacionada) diff --git a/frontend/lib/features/home/screens/profile_screen.dart b/frontend/lib/features/home/screens/profile_screen.dart index 2a8a2b2..2b0e736 100644 --- a/frontend/lib/features/home/screens/profile_screen.dart +++ b/frontend/lib/features/home/screens/profile_screen.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import '../../../core/constants/strings.dart'; +import 'package:provider/provider.dart'; +import '../../auth/data/auth_service.dart'; +import '../../auth/data/auth_models.dart'; // For UserProfileDTO +import '../../../core/constants/strings.dart'; // Assuming AppStrings is here if used import '../../../core/constants/colors.dart'; class ProfilePage extends StatelessWidget { @@ -8,86 +11,123 @@ class ProfilePage extends StatelessWidget { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Perfil'), - backgroundColor: AppColors.primary, - foregroundColor: AppColors.textOnPrimary, - elevation: 2, - toolbarHeight: 48, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(bottom: Radius.circular(18)), - ), - leading: IconButton( - icon: const Icon(Icons.arrow_back), - tooltip: 'Regresar', - onPressed: () { - Feedback.forTap(context); - context.pop(); - }, - ), - ), - body: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - children: [ - CircleAvatar( - radius: 48, - backgroundColor: AppColors.primary.withAlpha(38), - child: const Icon( - Icons.person, - size: 56, - color: AppColors.primary, - ), - ), - const SizedBox(height: 24), - Text( - 'Nombre de usuario', - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Text( - 'usuario@email.com', - style: TextStyle(color: Colors.grey[700]), + // Using Consumer to react to changes in AuthService, particularly currentUser + return Consumer( + builder: (context, authService, child) { + final UserProfileDTO? currentUser = authService.currentUser; + + return Scaffold( + appBar: AppBar( + title: const Text('Perfil'), + backgroundColor: AppColors.primary, + foregroundColor: AppColors.textOnPrimary, + elevation: 2, + toolbarHeight: 48, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(bottom: Radius.circular(18)), ), - const SizedBox(height: 32), - Card( - elevation: 3, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - color: Theme.of(context).cardColor, - child: ListTile( - leading: const Icon(Icons.edit, color: AppColors.primary), - title: const Text('Editar perfil'), - trailing: const Icon(Icons.chevron_right), - onTap: () { - Feedback.forTap(context); - context.go('/edit-user'); - }, - ), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + tooltip: 'Regresar', + onPressed: () { + Feedback.forTap(context); + if (context.canPop()) { + context.pop(); + } else { + context.go('/dashboard'); // Fallback if cannot pop + } + }, ), - Card( - elevation: 3, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - color: Theme.of(context).cardColor, - child: ListTile( - leading: const Icon(Icons.settings, color: AppColors.primary), - title: const Text('Configuración de cuenta'), - trailing: const Icon(Icons.chevron_right), - onTap: () { - Feedback.forTap(context); - context.go('/account-settings'); - }, - ), + ), + body: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + children: [ + CircleAvatar( + radius: 48, + backgroundColor: AppColors.primary.withAlpha(38), + child: const Icon( + Icons.person, + size: 56, + color: AppColors.primary, + ), + ), + const SizedBox(height: 24), + Text( + currentUser?.fullName ?? 'Nombre de Usuario', + style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + currentUser?.email ?? 'usuario@email.com', + style: TextStyle(color: Colors.grey[700]), + ), + const SizedBox(height: 32), + Card( + elevation: 3, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + color: Theme.of(context).cardColor, + child: ListTile( + leading: const Icon(Icons.edit, color: AppColors.primary), + title: const Text('Editar perfil'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Feedback.forTap(context); + context.go('/edit-user'); + }, + ), + ), + Card( + elevation: 3, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + color: Theme.of(context).cardColor, + child: ListTile( + leading: const Icon(Icons.settings, color: AppColors.primary), + title: const Text('Configuración de cuenta'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Feedback.forTap(context); + context.go('/account-settings'); + }, + ), + ), + const SizedBox(height: 16), // Spacer before logout button + Card( + elevation: 3, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + color: Theme.of(context).cardColor, + child: ListTile( + leading: const Icon(Icons.logout, color: AppColors.error), // Corrected color + title: const Text('Cerrar Sesión'), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + Feedback.forTap(context); + // No need to check mounted here if using listen:false and context is from builder + await Provider.of(context, listen: false).signOut(); + // After signOut, the auth state changes, potentially rebuilding widgets. + // The GoRouter redirect/listen logic in main.dart should handle redirecting to /login + // if the user is no longer authenticated. + // However, an explicit navigation here is also fine. + // Ensure context is still valid if operations are long. + // A common pattern is for signOut to trigger state change that router listens to. + // For direct navigation: + if (context.mounted) { // Check mounted before async gap if any or if context might become invalid + context.go('/login'); + } + }, + ), + ), + ], ), - ], - ), - ), + ), + ); + }, ); } } diff --git a/frontend/lib/features/home/screens/project_create_screen.dart b/frontend/lib/features/home/screens/project_create_screen.dart index cb82e76..9aa7270 100644 --- a/frontend/lib/features/home/screens/project_create_screen.dart +++ b/frontend/lib/features/home/screens/project_create_screen.dart @@ -42,14 +42,15 @@ class _CreateProjectPageState extends State { final description = _descriptionController.text.isNotEmpty ? _descriptionController.text : null; final startDate = _startDateController.text.isNotEmpty ? DateTime.parse(_startDateController.text) : null; final endDate = _endDateController.text.isNotEmpty ? DateTime.parse(_endDateController.text) : null; - await ProjectService().createProject( + // Ensure ProjectDTO is available, typically via project_service.dart or project_models.dart import + final createdProject = await ProjectService().createProject( name: name, description: description, startDate: startDate, endDate: endDate, ); if (!mounted) return; - context.pop(); + context.go('/project/${createdProject.id}'); } catch (e) { setState(() { _error = 'Error al crear proyecto: ' diff --git a/frontend/lib/features/home/screens/project_detail_screen.dart b/frontend/lib/features/home/screens/project_detail_screen.dart index 6b5773a..804b091 100644 --- a/frontend/lib/features/home/screens/project_detail_screen.dart +++ b/frontend/lib/features/home/screens/project_detail_screen.dart @@ -2,9 +2,13 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../data/project_service.dart'; import '../data/project_models.dart'; +import '../data/document_service.dart'; +import '../data/document_models.dart'; +import './document_detail_screen.dart'; import 'task_detail_screen.dart'; import '../../../core/widgets/section_card.dart'; import '../../../core/widgets/navigation_utils.dart'; +import '../../../core/constants/colors.dart'; // Added AppColors import class ProjectDetailPage extends StatefulWidget { final String? projectId; @@ -19,11 +23,15 @@ class _ProjectDetailPageState extends State with SingleTickerProviderStateMixin { late TabController _tabController; final ProjectService _service = ProjectService(); + final DocumentService _documentService = DocumentService(); // Added ProjectDTO? _project; List _members = []; List _tasks = []; List _activities = []; + List _projectDocuments = []; // Added + bool _projectDocumentsLoading = true; // Added + String? _projectDocumentsError; // Added bool _isLoading = true; String? _error; @@ -44,21 +52,54 @@ class _ProjectDetailPageState extends State final members = await _service.getProjectMembers(widget.projectId!); final tasks = await _service.getProjectTasks(widget.projectId!); final activities = await _service.getProjectActivities(widget.projectId!); + await _fetchProjectDocuments(); // Call to fetch documents setState(() { _project = project; _members = members; _tasks = tasks; _activities = activities; - _isLoading = false; + _isLoading = false; // Overall loading for project details }); } catch (e) { setState(() { - _error = 'Error al cargar datos: $e'; + _error = 'Error al cargar datos del proyecto: $e'; _isLoading = false; }); } } + Future _fetchProjectDocuments() async { + if (widget.projectId == null) return; + // Document loading state is managed by _projectDocumentsLoading + // No need to set _isLoading here as it's for the main project data. + // If _loadAll sets _isLoading to true, this will run concurrently or sequentially. + // For clarity, let's manage its own loading state and not interfere with _isLoading for the whole page. + setState(() { + _projectDocumentsLoading = true; + _projectDocumentsError = null; + }); + try { + final docs = await _documentService.getProjectDocuments(widget.projectId!); + if (mounted) { + setState(() { + _projectDocuments = docs; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _projectDocumentsError = 'Error al cargar documentos: ${e.toString()}'; + }); + } + } finally { + if (mounted) { + setState(() { + _projectDocumentsLoading = false; + }); + } + } + } + @override void dispose() { _tabController.dispose(); @@ -120,7 +161,7 @@ class _ProjectDetailPageState extends State children: [ _buildSummaryTab(), _buildTasksTab(), - Center(child: Text('Aquí puedes integrar documentos')), // Puedes usar DocumentService aquí + _buildDocumentsTab(), // Updated _buildActivityTab(), ], ), @@ -285,24 +326,37 @@ class _ProjectDetailPageState extends State child: const Text('Cancelar'), ), TextButton( - onPressed: () { - // Cerrar el diálogo - Navigator.of(context).pop(); + onPressed: () async { + Navigator.of(context).pop(); // Close the dialog first - // Simular eliminación - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text( - 'Proyecto eliminado correctamente', - style: TextStyle(color: Colors.white), - ), - backgroundColor: Colors.black.withAlpha(242), - behavior: SnackBarBehavior.floating, - ), - ); + if (widget.projectId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Error: ID de proyecto no disponible.')), + ); + return; + } + + setState(() => _isLoading = true); + try { + await _service.deleteProject(widget.projectId!); - // Volver a la pantalla anterior - Navigator.of(context).pop(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Proyecto eliminado correctamente')), + ); + context.pop(); // Navigate back + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error al eliminar proyecto: $e')), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } }, child: const Text( 'Eliminar', @@ -313,4 +367,57 @@ class _ProjectDetailPageState extends State ), ); } + + Widget _buildDocumentsTab() { + if (_projectDocumentsLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (_projectDocumentsError != null) { + return Center(child: Text(_projectDocumentsError!, style: const TextStyle(color: Colors.red))); + } + if (_projectDocuments.isEmpty) { + return const Center(child: Text('No hay documentos en este proyecto.')); + } + return ListView.builder( + padding: const EdgeInsets.all(16.0), + itemCount: _projectDocuments.length, + itemBuilder: (context, index) { + final doc = _projectDocuments[index]; + IconData docIcon; + switch (doc.type) { + case DocumentType.folder: + docIcon = Icons.folder; + break; + case DocumentType.link: + docIcon = Icons.link; + break; + case DocumentType.file: + default: + docIcon = Icons.insert_drive_file; + break; + } + return Card( + elevation: 2, + margin: const EdgeInsets.symmetric(vertical: 6), + child: ListTile( + leading: Icon(docIcon, color: AppColors.primary, size: 30), + title: Text(doc.name, style: const TextStyle(fontWeight: FontWeight.w500)), + // Use documentTypeToString helper + subtitle: Text(doc.description ?? 'Tipo: ${documentTypeToString(doc.type)} - ${doc.createdAt.toLocal().toString().substring(0,16)}'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + if (widget.projectId != null) { + context.push('/project/${widget.projectId}/document/${doc.id}'); + } else { + // Handle case where projectId is null, though it shouldn't be at this point + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Error: Project ID no disponible.')), + ); + } + }, + ), + ); + }, + ); + } } diff --git a/frontend/lib/features/home/screens/project_edit_screen.dart b/frontend/lib/features/home/screens/project_edit_screen.dart index 4bc3f64..414eafd 100644 --- a/frontend/lib/features/home/screens/project_edit_screen.dart +++ b/frontend/lib/features/home/screens/project_edit_screen.dart @@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; import '../../../core/constants/strings.dart'; import '../../../core/constants/colors.dart'; import '../data/project_service.dart'; +import '../data/project_models.dart'; // Added import for ProjectDTO class ProjectEditScreen extends StatefulWidget { final String? projectId; @@ -19,24 +20,59 @@ class _ProjectEditScreenState extends State { late TextEditingController _startDateController; late TextEditingController _endDateController; late TextEditingController _membersController; - bool _isLoading = false; + bool _isLoading = true; // Set to true initially as we'll be fetching String? _error; + ProjectDTO? _project; // Added state variable for the project @override void initState() { super.initState(); - // Prefill with simulated data - _nameController = TextEditingController( - text: 'Proyecto ${widget.projectId}', - ); - _descriptionController = TextEditingController( - text: 'Descripción detallada del proyecto ${widget.projectId}', - ); - _startDateController = TextEditingController(text: '2023-06-01'); - _endDateController = TextEditingController(text: '2023-12-31'); - _membersController = TextEditingController( - text: 'Ana García, Carlos López, María Rodríguez', - ); + // Initialize controllers without text first + _nameController = TextEditingController(); + _descriptionController = TextEditingController(); + _startDateController = TextEditingController(); + _endDateController = TextEditingController(); + _membersController = TextEditingController(); + + _fetchProjectDetails(); // Call new method + } + + Future _fetchProjectDetails() async { + if (widget.projectId == null) { + setState(() { + _error = 'ID de proyecto no disponible.'; + _isLoading = false; + }); + return; + } + // Initial _isLoading is true, no need to set it again here unless re-fetching + // If called for a refresh, then: + // setState(() { _isLoading = true; _error = null; }); + try { + final projectData = await ProjectService().getProjectById(widget.projectId!); + if (mounted) { + setState(() { + _project = projectData; + _nameController.text = projectData.name; + _descriptionController.text = projectData.description ?? ''; + _startDateController.text = projectData.startDate?.toIso8601String().substring(0, 10) ?? ''; + _endDateController.text = projectData.endDate?.toIso8601String().substring(0, 10) ?? ''; + // _membersController is not directly tied to ProjectDTO fields for now + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = 'Error al cargar datos del proyecto: ${e.toString()}'; + }); + } + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } } @override @@ -98,25 +134,21 @@ class _ProjectEditScreenState extends State { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Editar proyecto'), - backgroundColor: AppColors.primary, - foregroundColor: AppColors.textOnPrimary, - elevation: 2, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(bottom: Radius.circular(18)), + Widget bodyContent; + if (_isLoading) { + bodyContent = const Center(child: CircularProgressIndicator()); + } else if (_error != null) { + bodyContent = Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text(_error!, style: const TextStyle(color: Colors.red, fontSize: 16)), ), - leading: IconButton( - icon: const Icon(Icons.arrow_back), - tooltip: 'Regresar', - onPressed: () { - Feedback.forTap(context); - context.pop(); - }, - ), - ), - body: Padding( + ); + } else if (_project == null) { + bodyContent = const Center(child: Text('Proyecto no encontrado.')); + } else { + // Form content when data is loaded + bodyContent = Padding( padding: const EdgeInsets.all(24.0), child: Form( key: _formKey, @@ -225,14 +257,39 @@ class _ProjectEditScreenState extends State { icon: const Icon(Icons.save), label: const Text('Guardar cambios'), ), - if (_error != null) ...[ - const SizedBox(height: 12), - Text(_error!, style: const TextStyle(color: Colors.red)), + // Error display is now handled by the main bodyContent logic for _error + // We can keep a smaller error display for save errors specifically if needed, + // but the main _error will cover fetch errors. + // For save errors, the existing display after the button is fine. + if (_error != null && !_isLoading) ...[ // Show save error if not loading + const SizedBox(height: 12), + Text(_error!, style: const TextStyle(color: Colors.red)), ], ], ), ), + ); + } + + return Scaffold( + appBar: AppBar( + title: Text(_project?.name ?? 'Editar proyecto'), // Dynamic title + backgroundColor: AppColors.primary, + foregroundColor: AppColors.textOnPrimary, + elevation: 2, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(bottom: Radius.circular(18)), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + tooltip: 'Regresar', + onPressed: () { + Feedback.forTap(context); + context.pop(); + }, + ), ), + body: bodyContent, ); } } diff --git a/frontend/lib/features/home/screens/task_detail_screen.dart b/frontend/lib/features/home/screens/task_detail_screen.dart index 6e894cd..3a8af5c 100644 --- a/frontend/lib/features/home/screens/task_detail_screen.dart +++ b/frontend/lib/features/home/screens/task_detail_screen.dart @@ -14,6 +14,7 @@ class TaskDetailScreen extends StatefulWidget { } class _TaskDetailScreenState extends State { + final ProjectService _service = ProjectService(); // Added service instance TaskDTO? _task; bool _loading = true; String? _error; @@ -35,10 +36,10 @@ class _TaskDetailScreenState extends State { }); try { if (widget.taskId == null || widget.projectId == null) throw Exception('ID de tarea o proyecto no proporcionado'); - final task = await ProjectService().getProjectTasks(widget.projectId!); - final found = task.firstWhere((t) => t.id == widget.taskId, orElse: () => throw Exception('Tarea no encontrada')); + // Use the new getTaskDetails method + final taskDetails = await _service.getTaskDetails(widget.projectId!, widget.taskId!); setState(() { - _task = found; + _task = taskDetails; }); } catch (e) { setState(() { diff --git a/frontend/lib/features/home/screens/task_edit_screen.dart b/frontend/lib/features/home/screens/task_edit_screen.dart index c112435..f09b389 100644 --- a/frontend/lib/features/home/screens/task_edit_screen.dart +++ b/frontend/lib/features/home/screens/task_edit_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; // For date formatting import '../../../core/constants/colors.dart'; import '../../home/data/project_service.dart'; import '../../home/data/project_models.dart'; @@ -19,20 +20,43 @@ class _TaskEditScreenState extends State { late TextEditingController _descriptionController; late TextEditingController _assigneeController; late TextEditingController _dueDateController; - late TextEditingController _priorityController; - late TextEditingController _statusController; + // Removed _priorityController and _statusController + + late String _priority; + late String _status; + bool _isLoading = false; String? _error; + // Maps for dropdown values and display names + final Map _priorityOptions = { + 'low': 'Baja', + 'medium': 'Media', + 'high': 'Alta', + 'urgent': 'Urgente', + }; + + final Map _statusOptions = { + 'todo': 'Por hacer', + 'in_progress': 'En progreso', + 'review': 'En revisión', + 'done': 'Hecho', + }; + @override void initState() { super.initState(); _titleController = TextEditingController(text: widget.task.title); _descriptionController = TextEditingController(text: widget.task.description ?? ''); _assigneeController = TextEditingController(text: widget.task.assigneeId ?? ''); - _dueDateController = TextEditingController(text: widget.task.dueDate?.toString() ?? ''); - _priorityController = TextEditingController(text: widget.task.priority); - _statusController = TextEditingController(text: widget.task.status); + // Initialize DueDateController with formatted date or empty + _dueDateController = TextEditingController( + text: widget.task.dueDate != null + ? DateFormat('yyyy-MM-dd').format(widget.task.dueDate!) + : ''); + // Initialize state variables for priority and status + _priority = widget.task.priority; + _status = widget.task.status; } @override @@ -41,8 +65,7 @@ class _TaskEditScreenState extends State { _descriptionController.dispose(); _assigneeController.dispose(); _dueDateController.dispose(); - _priorityController.dispose(); - _statusController.dispose(); + // Removed _priorityController.dispose() and _statusController.dispose(); super.dispose(); } @@ -53,15 +76,31 @@ class _TaskEditScreenState extends State { _error = null; }); try { + DateTime? dueDate; + if (_dueDateController.text.isNotEmpty) { + try { + dueDate = DateFormat('yyyy-MM-dd').parse(_dueDateController.text); + } catch (e) { + // Handle parsing error if needed, though DatePicker should prevent this + if (mounted) { + setState(() { + _error = 'Formato de fecha inválido.'; + _isLoading = false; + }); + } + return; + } + } + await ProjectService().updateTask( projectId: widget.projectId, taskId: widget.task.id, title: _titleController.text, - description: _descriptionController.text, + description: _descriptionController.text.isNotEmpty ? _descriptionController.text : null, assigneeId: _assigneeController.text.isNotEmpty ? _assigneeController.text : null, - dueDate: _dueDateController.text.isNotEmpty ? DateTime.tryParse(_dueDateController.text) : null, - priority: _priorityController.text, - status: _statusController.text, + dueDate: dueDate, + priority: _priority, // Use state variable + status: _status, // Use state variable ); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -84,6 +123,26 @@ class _TaskEditScreenState extends State { } } + Future _selectDueDate(BuildContext context) async { + DateTime initialDate = DateTime.now(); + if (_dueDateController.text.isNotEmpty) { + try { + initialDate = DateFormat('yyyy-MM-dd').parse(_dueDateController.text); + } catch (e) { /* Use DateTime.now() if parsing fails */ } + } + final DateTime? picked = await showDatePicker( + context: context, + initialDate: initialDate, + firstDate: DateTime(2000), + lastDate: DateTime(2101), + ); + if (picked != null) { + setState(() { + _dueDateController.text = DateFormat('yyyy-MM-dd').format(picked); + }); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -104,34 +163,79 @@ class _TaskEditScreenState extends State { children: [ TextFormField( controller: _titleController, - decoration: const InputDecoration(labelText: 'Título'), + decoration: const InputDecoration(labelText: 'Título', prefixIcon: Icon(Icons.title)), validator: (v) => v == null || v.isEmpty ? 'Requerido' : null, ), + const SizedBox(height: 16), TextFormField( controller: _descriptionController, - decoration: const InputDecoration(labelText: 'Descripción'), + decoration: const InputDecoration(labelText: 'Descripción', prefixIcon: Icon(Icons.description)), + maxLines: 3, ), + const SizedBox(height: 16), TextFormField( controller: _assigneeController, - decoration: const InputDecoration(labelText: 'ID Asignado'), + decoration: const InputDecoration(labelText: 'ID Asignado', prefixIcon: Icon(Icons.person_outline)), ), + const SizedBox(height: 16), TextFormField( controller: _dueDateController, - decoration: const InputDecoration(labelText: 'Fecha de vencimiento (YYYY-MM-DD)'), + decoration: const InputDecoration( + labelText: 'Fecha de vencimiento', + prefixIcon: Icon(Icons.calendar_today), + ), + readOnly: true, + onTap: () => _selectDueDate(context), ), - TextFormField( - controller: _priorityController, - decoration: const InputDecoration(labelText: 'Prioridad'), + const SizedBox(height: 16), + DropdownButtonFormField( + value: _priority, + decoration: const InputDecoration(labelText: 'Prioridad', prefixIcon: Icon(Icons.priority_high)), + items: _priorityOptions.entries.map((entry) { + return DropdownMenuItem( + value: entry.key, + child: Text(entry.value), + ); + }).toList(), + onChanged: (String? newValue) { + if (newValue != null) { + setState(() { + _priority = newValue; + }); + } + }, + validator: (value) => value == null || value.isEmpty ? 'Selecciona una prioridad' : null, ), - TextFormField( - controller: _statusController, - decoration: const InputDecoration(labelText: 'Estado'), + const SizedBox(height: 16), + DropdownButtonFormField( + value: _status, + decoration: const InputDecoration(labelText: 'Estado', prefixIcon: Icon(Icons.task_alt)), + items: _statusOptions.entries.map((entry) { + return DropdownMenuItem( + value: entry.key, + child: Text(entry.value), + ); + }).toList(), + onChanged: (String? newValue) { + if (newValue != null) { + setState(() { + _status = newValue; + }); + } + }, + validator: (value) => value == null || value.isEmpty ? 'Selecciona un estado' : null, ), const SizedBox(height: 24), ElevatedButton.icon( onPressed: _isLoading ? null : _save, icon: const Icon(Icons.save), - label: const Text('Guardar cambios'), + label: Text(_isLoading ? 'Guardando...' : 'Guardar cambios'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: AppColors.textOnPrimary, + padding: const EdgeInsets.symmetric(vertical: 12), + textStyle: const TextStyle(fontSize: 16) + ), ), if (_error != null) ...[ const SizedBox(height: 12), diff --git a/frontend/lib/features/home/screens/tool_calendar_screen.dart b/frontend/lib/features/home/screens/tool_calendar_screen.dart index e9b4eb1..5e3a7ea 100644 --- a/frontend/lib/features/home/screens/tool_calendar_screen.dart +++ b/frontend/lib/features/home/screens/tool_calendar_screen.dart @@ -1,7 +1,28 @@ import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; // Added import import '../../home/data/external_tools_service.dart'; import '../../../core/constants/colors.dart'; +// Define _CalendarEvent model class +class _CalendarEvent { + final String summary; + final String start; + final String end; + + _CalendarEvent({required this.summary, required this.start, required this.end}); + + factory _CalendarEvent.fromJson(Map json) { + return _CalendarEvent( + summary: json['summary']?.toString() ?? 'Sin resumen', + // Assuming 'start' and 'end' from backend are already strings in desired format or simple strings. + // If they are DateTime objects or need specific parsing, adjust here. + // For now, directly using what backend provides or placeholder. + start: json['dtstart']?.toString() ?? json['start']?.toString() ?? 'Fecha inicio desconocida', + end: json['dtend']?.toString() ?? json['end']?.toString() ?? 'Fecha fin desconocida', + ); + } +} + class ToolCalendarScreen extends StatefulWidget { const ToolCalendarScreen({super.key}); @@ -10,7 +31,7 @@ class ToolCalendarScreen extends StatefulWidget { } class _ToolCalendarScreenState extends State { - List _events = []; + List<_CalendarEvent> _events = []; // Updated type bool _loading = true; String? _error; final TextEditingController _summaryController = TextEditingController(); @@ -23,6 +44,26 @@ class _ToolCalendarScreenState extends State { _fetchEvents(); } + Future _pickDateTime(BuildContext context, TextEditingController controller) async { + final DateTime? date = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime(2101), + ); + if (date == null) return; // User canceled DatePicker + + final TimeOfDay? time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(DateTime.now()), + ); + if (time == null) return; // User canceled TimePicker + + final DateTime dateTime = DateTime(date.year, date.month, date.day, time.hour, time.minute); + // Backend expects "YYYY-MM-DDTHH:MM:SS" + controller.text = DateFormat("yyyy-MM-ddTHH:mm:ss").format(dateTime); + } + Future _fetchEvents() async { setState(() { _loading = true; @@ -30,17 +71,25 @@ class _ToolCalendarScreenState extends State { }); try { final data = await ExternalToolsService().listCalendarEvents(); - setState(() { - _events = List.from(data['events'] ?? []); - }); + // Assuming data['events'] is the key holding the list of event maps + final eventList = data['events'] as List? ?? (data as List? ?? []); // Handle if data itself is the list + if (mounted) { + setState(() { + _events = eventList.map((e) => _CalendarEvent.fromJson(e as Map)).toList(); + }); + } } catch (e) { - setState(() { - _error = e.toString(); - }); + if (mounted) { // Add mounted check + setState(() { + _error = e.toString(); + }); + } // Closing brace for if (mounted) } finally { - setState(() { - _loading = false; - }); + if (mounted) { // Add mounted check for finally + setState(() { + _loading = false; + }); + } } } @@ -108,17 +157,24 @@ class _ToolCalendarScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('Crear nuevo evento', style: TextStyle(fontWeight: FontWeight.bold)), - TextField( + TextFormField( // Changed to TextFormField for potential validation controller: _summaryController, decoration: const InputDecoration(labelText: 'Resumen'), + validator: (value) => (value == null || value.isEmpty) ? 'El resumen es obligatorio' : null, ), - TextField( + TextFormField( controller: _startController, - decoration: const InputDecoration(labelText: 'Inicio (YYYY-MM-DD HH:MM)'), + decoration: const InputDecoration(labelText: 'Inicio (YYYY-MM-DDTHH:MM:SS)'), + readOnly: true, + onTap: () => _pickDateTime(context, _startController), + validator: (value) => (value == null || value.isEmpty) ? 'La fecha de inicio es obligatoria' : null, ), - TextField( + TextFormField( controller: _endController, - decoration: const InputDecoration(labelText: 'Fin (YYYY-MM-DD HH:MM)'), + decoration: const InputDecoration(labelText: 'Fin (YYYY-MM-DDTHH:MM:SS)'), + readOnly: true, + onTap: () => _pickDateTime(context, _endController), + validator: (value) => (value == null || value.isEmpty) ? 'La fecha de fin es obligatoria' : null, ), const SizedBox(height: 12), ElevatedButton.icon( @@ -145,9 +201,11 @@ class _ToolCalendarScreenState extends State { itemCount: _events.length, separatorBuilder: (context, index) => const Divider(), itemBuilder: (context, index) { + final event = _events[index]; return ListTile( leading: const Icon(Icons.event, color: AppColors.primary), - title: Text(_events[index]), + title: Text(event.summary), + subtitle: Text('Inicio: ${event.start}\nFin: ${event.end}'), ); }, ), diff --git a/frontend/lib/features/home/screens/user_edit_screen.dart b/frontend/lib/features/home/screens/user_edit_screen.dart index 4c40423..07ec4d4 100644 --- a/frontend/lib/features/home/screens/user_edit_screen.dart +++ b/frontend/lib/features/home/screens/user_edit_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; // Added import import '../../../core/constants/colors.dart'; import '../../auth/data/auth_service.dart'; @@ -20,9 +21,24 @@ class _UserEditScreenState extends State { @override void initState() { super.initState(); - // Load current user data (simulated) - _nameController.text = 'Nombre del Usuario'; - _emailController.text = 'usuario@taskhub.com'; + // Use WidgetsBinding.instance.addPostFrameCallback to safely access context + WidgetsBinding.instance.addPostFrameCallback((_) { + final authService = Provider.of(context, listen: false); + if (authService.currentUser != null) { + _nameController.text = authService.currentUser!.fullName; + _emailController.text = authService.currentUser!.email; + } else { + // Handle case where user data is not available (e.g. user not logged in) + // This screen should ideally not be reachable if currentUser is null. + // For now, fields will be blank or could show an error/pop. + // Consider setting an error or popping if this state is critical. + if (mounted) { + setState(() { + _error = "No se pudieron cargar los datos del usuario."; + }); + } + } + }); } @override @@ -39,7 +55,8 @@ class _UserEditScreenState extends State { _error = null; }); try { - await AuthService().updateProfile( + final authService = Provider.of(context, listen: false); + await authService.updateProfile( displayName: _nameController.text, email: _emailController.text, ); diff --git a/frontend/lib/routes/app_router.dart b/frontend/lib/routes/app_router.dart index a9dfe05..977fa7b 100644 --- a/frontend/lib/routes/app_router.dart +++ b/frontend/lib/routes/app_router.dart @@ -325,9 +325,12 @@ class AppRouter { ), ), GoRoute( - path: '/document/:id', + path: '/project/:projectId/document/:id', pageBuilder: (context, state) => CustomTransitionPage( - child: DocumentDetailScreen(documentId: state.pathParameters['id']), + child: DocumentDetailScreen( + documentId: state.pathParameters['id']!, + projectId: state.pathParameters['projectId']!, + ), transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child), ), @@ -340,17 +343,6 @@ class AppRouter { FadeTransition(opacity: animation, child: child), ), ), - GoRoute( - path: '/dev-bypass', - builder: (context, state) { - // Simula un token válido y navega al dashboard - AuthService().storage.write(key: 'access_token', value: 'TOKEN_VALIDO_AQUI'); - Future.microtask(() => context.go('/dashboard')); - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); - }, - ), ], ), ], diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index d60428d..4fa5b3b 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: async - sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" url: "https://pub.dev" source: hosted - version: "2.12.0" + version: "2.13.0" boolean_selector: dependency: transitive description: @@ -53,10 +53,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.3.3" ffi: dependency: transitive description: @@ -82,10 +82,10 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "6.0.0" flutter_localizations: dependency: "direct main" description: flutter @@ -153,10 +153,10 @@ packages: dependency: "direct main" description: name: go_router - sha256: b465e99ce64ba75e61c8c0ce3d87b66d8ac07f0b35d0a7e0263fcfc10f99e836 + sha256: b453934c36e289cef06525734d1e676c1f91da9e22e2017d9dcab6ce0f999175 url: "https://pub.dev" source: hosted - version: "13.2.5" + version: "15.1.3" http: dependency: "direct main" description: @@ -177,10 +177,10 @@ packages: dependency: transitive description: name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" url: "https://pub.dev" source: hosted - version: "0.19.0" + version: "0.20.2" js: dependency: transitive description: @@ -193,10 +193,10 @@ packages: dependency: transitive description: name: leak_tracker - sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" url: "https://pub.dev" source: hosted - version: "10.0.8" + version: "10.0.9" leak_tracker_flutter_testing: dependency: transitive description: @@ -217,10 +217,10 @@ packages: dependency: transitive description: name: lints - sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 url: "https://pub.dev" source: hosted - version: "5.1.1" + version: "6.0.0" logging: dependency: transitive description: @@ -470,10 +470,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 url: "https://pub.dev" source: hosted - version: "14.3.1" + version: "15.0.0" web: dependency: transitive description: @@ -499,5 +499,5 @@ packages: source: hosted version: "1.1.0" sdks: - dart: ">=3.7.2 <4.0.0" + dart: ">=3.8.0 <4.0.0" flutter: ">=3.27.0" diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index df28fcb..96633e3 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -35,7 +35,7 @@ dependencies: provider: ^6.1.5 http: ^1.2.1 flutter_secure_storage: ^9.0.0 - go_router: ^13.2.0 + go_router: ^15.1.3 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. @@ -51,7 +51,7 @@ dev_dependencies: # activated in the `analysis_options.yaml` file located at the root of your # package. See that file for information about deactivating specific lint # rules and activating additional ones. - flutter_lints: ^5.0.0 + flutter_lints: ^6.0.0 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/tests.py b/tests.py new file mode 100644 index 0000000..e69de29