feat: complete LOW priority code quality improvements

Backend:
- LOW-002: Add Query validation with max page size limits (100)
- LOW-003: Replace magic strings with TaskStatus.is_done flag
- LOW-004: Add 'creation' trigger type validation
- Add action_executor.py with UpdateFieldAction and AutoAssignAction

Frontend:
- LOW-005: Replace TypeScript 'any' with 'unknown' + type guards
- LOW-006: Add ConfirmModal component with A11Y support
- LOW-007: Add ToastContext for user feedback notifications
- LOW-009: Add Skeleton components (17 loading states replaced)
- LOW-010: Setup Vitest with 21 tests for ConfirmModal and Skeleton

Components updated:
- App.tsx, ProtectedRoute.tsx, Spaces.tsx, Projects.tsx, Tasks.tsx
- ProjectSettings.tsx, AuditPage.tsx, WorkloadPage.tsx, ProjectHealthPage.tsx
- Comments.tsx, AttachmentList.tsx, TriggerList.tsx, TaskDetailModal.tsx
- NotificationBell.tsx, BlockerDialog.tsx, CalendarView.tsx, WorkloadUserDetail.tsx

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2026-01-07 21:24:36 +08:00
parent 2d80a8384e
commit 4b5a9c1d0a
66 changed files with 7809 additions and 171 deletions

View File

@@ -118,6 +118,9 @@ def should_encrypt_file(project: Project, db: Session) -> tuple[bool, Optional[E
Returns:
Tuple of (should_encrypt, encryption_key)
Raises:
HTTPException: If project is confidential but encryption is not available
"""
# Only encrypt for confidential projects
if project.security_level != "confidential":
@@ -125,11 +128,14 @@ def should_encrypt_file(project: Project, db: Session) -> tuple[bool, Optional[E
# Check if encryption is available
if not encryption_service.is_encryption_available():
logger.warning(
logger.error(
f"Project {project.id} is confidential but encryption is not configured. "
"Files will be stored unencrypted."
"Rejecting file upload to maintain security."
)
raise HTTPException(
status_code=400,
detail="Confidential project requires encryption. Please configure ENCRYPTION_MASTER_KEY environment variable."
)
return False, None
# Get active encryption key
active_key = db.query(EncryptionKey).filter(
@@ -137,11 +143,14 @@ def should_encrypt_file(project: Project, db: Session) -> tuple[bool, Optional[E
).first()
if not active_key:
logger.warning(
logger.error(
f"Project {project.id} is confidential but no active encryption key exists. "
"Files will be stored unencrypted. Create a key using /api/admin/encryption-keys/rotate"
"Rejecting file upload to maintain security."
)
raise HTTPException(
status_code=400,
detail="Confidential project requires encryption. Please create an active encryption key first."
)
return False, None
return True, active_key

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import List
@@ -13,8 +13,8 @@ router = APIRouter()
@router.get("", response_model=List[DepartmentResponse])
async def list_departments(
skip: int = 0,
limit: int = 100,
skip: int = Query(0, ge=0, description="Number of departments to skip"),
limit: int = Query(100, ge=1, le=200, description="Max departments to return"),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("users.read")),
):

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import Optional
@@ -71,8 +71,8 @@ async def generate_weekly_report(
@router.get("/api/reports/history", response_model=ReportHistoryListResponse)
async def list_report_history(
limit: int = 10,
offset: int = 0,
limit: int = Query(10, ge=1, le=100, description="Number of reports to return"),
offset: int = Query(0, ge=0, description="Number of reports to skip"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):

View File

@@ -1,5 +1,5 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import Optional
@@ -11,6 +11,8 @@ from app.schemas.trigger import (
)
from app.middleware.auth import get_current_user, check_project_access, check_project_edit_access
from app.services.trigger_scheduler import TriggerSchedulerService
from app.services.trigger_service import TriggerService
from app.services.action_executor import ActionValidationError
router = APIRouter(tags=["triggers"])
@@ -60,10 +62,11 @@ async def create_trigger(
)
# Validate trigger type
if trigger_data.trigger_type not in ["field_change", "schedule"]:
valid_trigger_types = ["field_change", "schedule", "creation"]
if trigger_data.trigger_type not in valid_trigger_types:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid trigger type. Must be 'field_change' or 'schedule'",
detail=f"Invalid trigger type. Must be one of: {', '.join(valid_trigger_types)}",
)
# Validate conditions based on trigger type
@@ -111,6 +114,16 @@ async def create_trigger(
detail=error_msg or "Invalid cron expression",
)
# Validate actions configuration (FEAT-014, FEAT-015)
try:
actions_dicts = [a.model_dump(exclude_none=True) for a in trigger_data.actions]
TriggerService.validate_actions(actions_dicts, db)
except ActionValidationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
# Create trigger
trigger = Trigger(
id=str(uuid.uuid4()),
@@ -119,7 +132,7 @@ async def create_trigger(
description=trigger_data.description,
trigger_type=trigger_data.trigger_type,
conditions=trigger_data.conditions.model_dump(),
actions=[a.model_dump() for a in trigger_data.actions],
actions=[a.model_dump(exclude_none=True) for a in trigger_data.actions],
is_active=trigger_data.is_active,
created_by=current_user.id,
)
@@ -239,7 +252,16 @@ async def update_trigger(
)
trigger.conditions = trigger_data.conditions.model_dump(exclude_none=True)
if trigger_data.actions is not None:
trigger.actions = [a.model_dump() for a in trigger_data.actions]
# Validate actions configuration (FEAT-014, FEAT-015)
try:
actions_dicts = [a.model_dump(exclude_none=True) for a in trigger_data.actions]
TriggerService.validate_actions(actions_dicts, db)
except ActionValidationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
trigger.actions = [a.model_dump(exclude_none=True) for a in trigger_data.actions]
if trigger_data.is_active is not None:
trigger.is_active = trigger_data.is_active
@@ -278,8 +300,8 @@ async def delete_trigger(
@router.get("/api/triggers/{trigger_id}/logs", response_model=TriggerLogListResponse)
async def list_trigger_logs(
trigger_id: str,
limit: int = 50,
offset: int = 0,
limit: int = Query(50, ge=1, le=200, description="Number of logs to return"),
offset: int = Query(0, ge=0, description="Number of logs to skip"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):

View File

@@ -50,8 +50,8 @@ async def search_users(
@router.get("", response_model=List[UserResponse])
async def list_users(
skip: int = 0,
limit: int = 100,
skip: int = Query(0, ge=0, description="Number of users to skip"),
limit: int = Query(100, ge=1, le=500, description="Max users to return"),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("users.read")),
):

View File

@@ -1,4 +1,4 @@
from pydantic import BaseModel
from pydantic import BaseModel, computed_field
from typing import Optional, List, Any, Dict
from datetime import datetime
from decimal import Decimal
@@ -79,6 +79,12 @@ class TaskResponse(TaskBase):
class Config:
from_attributes = True
# Alias for original_estimate for frontend compatibility
@computed_field
@property
def time_estimate(self) -> Optional[Decimal]:
return self.original_estimate
class TaskWithDetails(TaskResponse):
assignee_name: Optional[str] = None

View File

@@ -28,9 +28,23 @@ class TriggerCondition(BaseModel):
class TriggerAction(BaseModel):
type: str = Field(default="notify", description="Action type: notify")
target: str = Field(default="assignee", description="Target: assignee, creator, project_owner, user:<id>")
"""Action configuration for triggers.
Supported action types:
- notify: Send notification (requires target, optional template)
- update_field: Update task field (requires field, value)
- auto_assign: Auto-assign task (requires strategy, optional user_id for specific_user)
"""
type: str = Field(..., description="Action type: notify, update_field, auto_assign")
# Notify action fields
target: Optional[str] = Field(None, description="Target: assignee, creator, project_owner, user:<id>")
template: Optional[str] = Field(None, description="Message template with variables")
# update_field action fields (FEAT-014)
field: Optional[str] = Field(None, description="Field to update: priority, status_id, due_date")
value: Optional[Any] = Field(None, description="New value for the field")
# auto_assign action fields (FEAT-015)
strategy: Optional[str] = Field(None, description="Strategy: round_robin, least_loaded, specific_user")
user_id: Optional[str] = Field(None, description="User ID for specific_user strategy")
class TriggerCreate(BaseModel):

View File

@@ -0,0 +1,484 @@
"""Action executor service for automation triggers.
This module provides the action execution framework for the automation system.
It supports extensible action types through a registry pattern.
FEAT-014: update_field - Update task field values
FEAT-015: auto_assign - Automatic task assignment with multiple strategies
"""
import logging
import uuid
from abc import ABC, abstractmethod
from datetime import datetime, date
from decimal import Decimal
from typing import Any, Dict, List, Optional, Type
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.models import Task, User, TaskStatus
logger = logging.getLogger(__name__)
class ActionExecutionError(Exception):
"""Exception raised when action execution fails."""
pass
class ActionValidationError(Exception):
"""Exception raised when action config validation fails."""
pass
class BaseAction(ABC):
"""Base class for all action types."""
action_type: str = ""
@abstractmethod
def validate_config(self, config: Dict[str, Any], db: Session) -> None:
"""Validate action configuration.
Args:
config: Action configuration dict
db: Database session for validation queries
Raises:
ActionValidationError: If config is invalid
"""
pass
@abstractmethod
def execute(
self,
db: Session,
task: Task,
config: Dict[str, Any],
context: Dict[str, Any],
) -> Dict[str, Any]:
"""Execute the action.
Args:
db: Database session
task: Target task
config: Action configuration
context: Execution context (old_values, new_values, current_user, etc.)
Returns:
Dict with execution result details
Raises:
ActionExecutionError: If execution fails
"""
pass
class UpdateFieldAction(BaseAction):
"""Action to update task field values (FEAT-014).
Supported fields:
- priority: low, medium, high, urgent
- status_id: Valid status ID for the task's project
- due_date: ISO format date string
Config format:
{
"field": "priority",
"value": "high"
}
"""
action_type = "update_field"
# Standard fields that can be updated
UPDATABLE_FIELDS = {
"priority": ["low", "medium", "high", "urgent"],
"status_id": None, # Validated dynamically
"due_date": None, # Date string validation
}
def validate_config(self, config: Dict[str, Any], db: Session) -> None:
"""Validate update_field configuration."""
field = config.get("field")
value = config.get("value")
if not field:
raise ActionValidationError("Missing required 'field' in update_field config")
if value is None:
raise ActionValidationError("Missing required 'value' in update_field config")
if field not in self.UPDATABLE_FIELDS:
raise ActionValidationError(
f"Invalid field '{field}'. Supported fields: {list(self.UPDATABLE_FIELDS.keys())}"
)
# Validate priority values
if field == "priority":
valid_values = self.UPDATABLE_FIELDS["priority"]
if value not in valid_values:
raise ActionValidationError(
f"Invalid priority value '{value}'. Valid values: {valid_values}"
)
# Validate due_date format
if field == "due_date" and value:
try:
if isinstance(value, str):
datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
raise ActionValidationError(
f"Invalid due_date format '{value}'. Use ISO format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)"
)
def execute(
self,
db: Session,
task: Task,
config: Dict[str, Any],
context: Dict[str, Any],
) -> Dict[str, Any]:
"""Execute field update action."""
field = config["field"]
value = config["value"]
old_value = getattr(task, field, None)
logger.info(
f"Executing update_field action: task={task.id}, field={field}, "
f"old_value={old_value}, new_value={value}"
)
# Validate status_id exists for this project
if field == "status_id" and value:
status = db.query(TaskStatus).filter(
TaskStatus.id == value,
TaskStatus.project_id == task.project_id,
).first()
if not status:
raise ActionExecutionError(
f"Status ID '{value}' not found in project {task.project_id}"
)
# Convert due_date string to datetime
if field == "due_date" and value:
if isinstance(value, str):
value = datetime.fromisoformat(value.replace("Z", "+00:00"))
# Update the field
setattr(task, field, value)
task.updated_at = datetime.utcnow()
return {
"action_type": self.action_type,
"status": "success",
"field": field,
"old_value": str(old_value) if old_value else None,
"new_value": str(value) if value else None,
}
class AutoAssignAction(BaseAction):
"""Action for automatic task assignment (FEAT-015).
Strategies:
- round_robin: Assign to project members in rotation
- least_loaded: Assign to member with lowest workload
- specific_user: Assign to a specific user
Config format:
{
"strategy": "round_robin" | "least_loaded" | "specific_user",
"user_id": "xxx" // Required only for specific_user strategy
}
"""
action_type = "auto_assign"
VALID_STRATEGIES = ["round_robin", "least_loaded", "specific_user"]
# Class-level state for round-robin tracking per project
_round_robin_index: Dict[str, int] = {}
def validate_config(self, config: Dict[str, Any], db: Session) -> None:
"""Validate auto_assign configuration."""
strategy = config.get("strategy")
if not strategy:
raise ActionValidationError("Missing required 'strategy' in auto_assign config")
if strategy not in self.VALID_STRATEGIES:
raise ActionValidationError(
f"Invalid strategy '{strategy}'. Valid strategies: {self.VALID_STRATEGIES}"
)
if strategy == "specific_user":
user_id = config.get("user_id")
if not user_id:
raise ActionValidationError(
"Missing required 'user_id' for specific_user strategy"
)
# Validate user exists
user = db.query(User).filter(
User.id == user_id,
User.is_active == True,
).first()
if not user:
raise ActionValidationError(f"User '{user_id}' not found or inactive")
def execute(
self,
db: Session,
task: Task,
config: Dict[str, Any],
context: Dict[str, Any],
) -> Dict[str, Any]:
"""Execute auto-assign action."""
strategy = config["strategy"]
old_assignee_id = task.assignee_id
logger.info(
f"Executing auto_assign action: task={task.id}, strategy={strategy}, "
f"old_assignee={old_assignee_id}"
)
if strategy == "specific_user":
new_assignee_id = self._assign_specific_user(db, config)
elif strategy == "round_robin":
new_assignee_id = self._assign_round_robin(db, task)
elif strategy == "least_loaded":
new_assignee_id = self._assign_least_loaded(db, task)
else:
raise ActionExecutionError(f"Unknown strategy: {strategy}")
if new_assignee_id:
task.assignee_id = new_assignee_id
task.updated_at = datetime.utcnow()
# Get assignee name for logging
assignee = db.query(User).filter(User.id == new_assignee_id).first()
assignee_name = assignee.name if assignee else "Unknown"
logger.info(
f"Task {task.id} assigned to user {new_assignee_id} ({assignee_name}) "
f"using {strategy} strategy"
)
return {
"action_type": self.action_type,
"status": "success",
"strategy": strategy,
"old_assignee_id": old_assignee_id,
"new_assignee_id": new_assignee_id,
"assignee_name": assignee_name,
}
else:
logger.warning(
f"No eligible assignee found for task {task.id} using {strategy} strategy"
)
return {
"action_type": self.action_type,
"status": "skipped",
"strategy": strategy,
"reason": "No eligible assignee found",
}
def _assign_specific_user(self, db: Session, config: Dict[str, Any]) -> Optional[str]:
"""Assign to a specific user."""
user_id = config["user_id"]
user = db.query(User).filter(
User.id == user_id,
User.is_active == True,
).first()
return user.id if user else None
def _assign_round_robin(self, db: Session, task: Task) -> Optional[str]:
"""Assign using round-robin strategy among project members.
Gets all active users in the same department as the project owner,
then rotates through them.
"""
project = task.project
if not project:
return None
# Get project members: owner + users in same department
members = self._get_project_members(db, project)
if not members:
return None
# Get or initialize round-robin index for this project
project_id = project.id
if project_id not in self._round_robin_index:
self._round_robin_index[project_id] = 0
# Get next member in rotation
index = self._round_robin_index[project_id] % len(members)
selected_user = members[index]
# Update index for next assignment
self._round_robin_index[project_id] = (index + 1) % len(members)
return selected_user.id
def _assign_least_loaded(self, db: Session, task: Task) -> Optional[str]:
"""Assign to the project member with lowest workload.
Workload is calculated based on number of incomplete tasks assigned.
"""
project = task.project
if not project:
return None
members = self._get_project_members(db, project)
if not members:
return None
# Calculate workload for each member (count of incomplete tasks)
member_workloads = []
for member in members:
# Count incomplete tasks (status.is_done = False or no status)
incomplete_count = (
db.query(func.count(Task.id))
.outerjoin(TaskStatus, Task.status_id == TaskStatus.id)
.filter(
Task.assignee_id == member.id,
Task.is_deleted == False,
(TaskStatus.is_done == False) | (Task.status_id == None),
)
.scalar()
)
member_workloads.append((member, incomplete_count))
# Sort by workload (ascending) and return the least loaded member
member_workloads.sort(key=lambda x: x[1])
if member_workloads:
selected_user, workload = member_workloads[0]
logger.debug(
f"Least loaded assignment: user={selected_user.id}, "
f"current_workload={workload}"
)
return selected_user.id
return None
def _get_project_members(self, db: Session, project) -> List[User]:
"""Get all potential assignees for a project.
Returns active users who are either:
- The project owner
- In the same department as the project
"""
owner_id = project.owner_id
department_id = project.department_id
query = db.query(User).filter(User.is_active == True)
if department_id:
# Get users in same department OR project owner
query = query.filter(
(User.department_id == department_id) | (User.id == owner_id)
)
else:
# No department, just return owner
query = query.filter(User.id == owner_id)
return query.all()
class ActionExecutor:
"""Registry and executor for action types.
Usage:
executor = ActionExecutor()
result = executor.execute_action(db, task, action_config, context)
"""
_actions: Dict[str, Type[BaseAction]] = {}
@classmethod
def register(cls, action_class: Type[BaseAction]) -> None:
"""Register an action type."""
cls._actions[action_class.action_type] = action_class
logger.debug(f"Registered action type: {action_class.action_type}")
@classmethod
def get_supported_actions(cls) -> List[str]:
"""Get list of supported action types."""
return list(cls._actions.keys())
@classmethod
def validate_action(cls, action: Dict[str, Any], db: Session) -> None:
"""Validate an action configuration.
Args:
action: Action dict with 'type' and other config
db: Database session
Raises:
ActionValidationError: If action is invalid
"""
action_type = action.get("type")
if not action_type:
raise ActionValidationError("Missing action 'type'")
if action_type not in cls._actions:
# Allow unknown actions (like 'notify') to pass through
return
action_class = cls._actions[action_type]
action_instance = action_class()
config = action.get("config", action) # Support both nested and flat config
action_instance.validate_config(config, db)
@classmethod
def execute_action(
cls,
db: Session,
task: Task,
action: Dict[str, Any],
context: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
"""Execute a single action.
Args:
db: Database session
task: Target task
action: Action dict with 'type' and other config
context: Execution context
Returns:
Execution result dict or None if action type not registered
Raises:
ActionExecutionError: If execution fails
"""
action_type = action.get("type")
if action_type not in cls._actions:
# Not a registered action (might be 'notify' handled elsewhere)
return None
action_class = cls._actions[action_type]
action_instance = action_class()
# Extract config - support both nested and flat config
config = action.get("config", action)
try:
result = action_instance.execute(db, task, config, context)
return result
except ActionExecutionError:
raise
except Exception as e:
logger.exception(f"Unexpected error executing {action_type} action")
raise ActionExecutionError(f"Failed to execute {action_type}: {str(e)}")
# Register built-in actions
ActionExecutor.register(UpdateFieldAction)
ActionExecutor.register(AutoAssignAction)

View File

@@ -87,16 +87,17 @@ class ReportService:
next_week_tasks = []
for task in all_tasks:
status_name = task.status.name.lower() if task.status else ""
is_done = status_name in ["done", "completed", "完成"]
# Use TaskStatus.is_done flag instead of magic strings
is_done = task.status.is_done if task.status else False
# Check if completed (updated this week)
if is_done:
if task.updated_at and task.updated_at >= week_start:
completed_tasks.append(task)
else:
# Check if in progress
if status_name in ["in progress", "進行中", "doing"]:
# Check if task has active status (not done, not blocked)
# Tasks without a done status are considered in progress
if task.status and not task.status.is_done:
in_progress_tasks.append(task)
# Check if overdue

View File

@@ -1,9 +1,17 @@
import uuid
import logging
from typing import List, Dict, Any, Optional
from sqlalchemy.orm import Session
from app.models import Trigger, TriggerLog, Task, User, Project
from app.services.notification_service import NotificationService
from app.services.action_executor import (
ActionExecutor,
ActionExecutionError,
ActionValidationError,
)
logger = logging.getLogger(__name__)
class TriggerService:
@@ -74,23 +82,78 @@ class TriggerService:
old_values: Dict[str, Any],
new_values: Dict[str, Any],
) -> TriggerLog:
"""Execute trigger actions and log the result."""
"""Execute trigger actions and log the result.
Uses a database savepoint to ensure atomicity - if any action fails,
all previously executed actions within this trigger are rolled back.
"""
actions = trigger.actions if isinstance(trigger.actions, list) else [trigger.actions]
executed_actions = []
error_message = None
# Build execution context
context = {
"old_values": old_values,
"new_values": new_values,
"current_user": current_user,
"trigger": trigger,
}
# Use savepoint for transaction atomicity - if any action fails,
# all changes made by previous actions will be rolled back
savepoint = db.begin_nested()
try:
for action in actions:
action_type = action.get("type")
# Handle built-in notify action
if action_type == "notify":
TriggerService._execute_notify_action(db, action, task, current_user, old_values, new_values)
executed_actions.append({"type": action_type, "status": "success"})
# Handle update_field action (FEAT-014)
elif action_type == "update_field":
result = ActionExecutor.execute_action(db, task, action, context)
if result:
executed_actions.append(result)
logger.info(
f"Trigger '{trigger.name}' executed update_field: "
f"field={result.get('field')}, new_value={result.get('new_value')}"
)
# Handle auto_assign action (FEAT-015)
elif action_type == "auto_assign":
result = ActionExecutor.execute_action(db, task, action, context)
if result:
executed_actions.append(result)
logger.info(
f"Trigger '{trigger.name}' executed auto_assign: "
f"strategy={result.get('strategy')}, assignee={result.get('new_assignee_id')}"
)
# Try to execute via ActionExecutor for extensibility
else:
result = ActionExecutor.execute_action(db, task, action, context)
if result:
executed_actions.append(result)
# All actions succeeded, commit the savepoint
savepoint.commit()
status = "success"
except Exception as e:
except ActionExecutionError as e:
# Rollback all changes made by previously executed actions
savepoint.rollback()
status = "failed"
error_message = str(e)
executed_actions.append({"type": "error", "message": str(e)})
logger.error(f"Trigger '{trigger.name}' action execution failed, rolling back: {e}")
except Exception as e:
# Rollback all changes made by previously executed actions
savepoint.rollback()
status = "failed"
error_message = str(e)
executed_actions.append({"type": "error", "message": str(e)})
logger.exception(f"Trigger '{trigger.name}' unexpected error, rolling back: {e}")
log = TriggerLog(
id=str(uuid.uuid4()),
@@ -198,3 +261,36 @@ class TriggerService:
)
db.add(log)
return log
@staticmethod
def validate_actions(actions: List[Dict[str, Any]], db: Session) -> None:
"""Validate trigger actions configuration.
Args:
actions: List of action configurations
db: Database session
Raises:
ActionValidationError: If any action is invalid
"""
valid_action_types = ["notify", "update_field", "auto_assign"]
for action in actions:
action_type = action.get("type")
if not action_type:
raise ActionValidationError("Missing action 'type'")
if action_type not in valid_action_types:
raise ActionValidationError(
f"Invalid action type '{action_type}'. "
f"Valid types: {valid_action_types}"
)
# Validate via ActionExecutor for extensible actions
if action_type in ["update_field", "auto_assign"]:
ActionExecutor.validate_action(action, db)
@staticmethod
def get_supported_action_types() -> List[str]:
"""Get list of all supported action types."""
return ["notify"] + ActionExecutor.get_supported_actions()

View File

@@ -0,0 +1,700 @@
"""Tests for action_executor.py - FEAT-014 and FEAT-015.
This module tests the action executor framework and the two new action types:
- update_field: Update task field values
- auto_assign: Automatic task assignment with multiple strategies
"""
import pytest
import uuid
from datetime import datetime, timedelta
from decimal import Decimal
from app.models import User, Space, Project, Task, TaskStatus, Trigger, TriggerLog, Department
from app.services.action_executor import (
ActionExecutor,
UpdateFieldAction,
AutoAssignAction,
ActionExecutionError,
ActionValidationError,
)
from app.services.trigger_service import TriggerService
@pytest.fixture
def test_department(db):
"""Create a test department."""
dept = Department(
id=str(uuid.uuid4()),
name="Engineering",
)
db.add(dept)
db.commit()
return dept
@pytest.fixture
def test_user(db, test_department):
"""Create a test user."""
user = User(
id=str(uuid.uuid4()),
email="testuser@example.com",
name="Test User",
department_id=test_department.id,
role_id="00000000-0000-0000-0000-000000000003",
is_active=True,
is_system_admin=False,
capacity=Decimal("40.00"),
)
db.add(user)
db.commit()
return user
@pytest.fixture
def additional_users(db, test_department):
"""Create additional test users for auto-assign tests."""
users = []
for i in range(3):
user = User(
id=str(uuid.uuid4()),
email=f"user{i}@example.com",
name=f"User {i}",
department_id=test_department.id,
role_id="00000000-0000-0000-0000-000000000003",
is_active=True,
is_system_admin=False,
capacity=Decimal("40.00"),
)
db.add(user)
users.append(user)
db.commit()
return users
@pytest.fixture
def test_space(db, test_user):
"""Create a test space."""
space = Space(
id=str(uuid.uuid4()),
name="Test Space",
description="Test space for action executor",
owner_id=test_user.id,
)
db.add(space)
db.commit()
return space
@pytest.fixture
def test_project(db, test_space, test_user, test_department):
"""Create a test project."""
project = Project(
id=str(uuid.uuid4()),
space_id=test_space.id,
title="Test Project",
description="Test project for action executor",
owner_id=test_user.id,
department_id=test_department.id,
)
db.add(project)
db.commit()
return project
@pytest.fixture
def test_statuses(db, test_project):
"""Create test task statuses."""
status_todo = TaskStatus(
id=str(uuid.uuid4()),
project_id=test_project.id,
name="To Do",
color="#808080",
position=0,
is_done=False,
)
status_in_progress = TaskStatus(
id=str(uuid.uuid4()),
project_id=test_project.id,
name="In Progress",
color="#0000FF",
position=1,
is_done=False,
)
status_done = TaskStatus(
id=str(uuid.uuid4()),
project_id=test_project.id,
name="Done",
color="#00FF00",
position=2,
is_done=True,
)
db.add_all([status_todo, status_in_progress, status_done])
db.commit()
return status_todo, status_in_progress, status_done
@pytest.fixture
def test_task(db, test_project, test_user, test_statuses):
"""Create a test task."""
task = Task(
id=str(uuid.uuid4()),
project_id=test_project.id,
title="Test Task",
description="Test task for action executor",
status_id=test_statuses[0].id,
created_by=test_user.id,
assignee_id=test_user.id,
priority="medium",
)
db.add(task)
db.commit()
db.refresh(task)
return task
class TestUpdateFieldAction:
"""Tests for FEAT-014: Update Field Action."""
def test_validate_config_valid_priority(self, db):
"""Test validation of valid priority update config."""
action = UpdateFieldAction()
config = {"field": "priority", "value": "high"}
# Should not raise
action.validate_config(config, db)
def test_validate_config_valid_status_id(self, db):
"""Test validation of valid status_id update config."""
action = UpdateFieldAction()
config = {"field": "status_id", "value": "some-uuid"}
# Should not raise (status_id validation is deferred to execution)
action.validate_config(config, db)
def test_validate_config_valid_due_date(self, db):
"""Test validation of valid due_date update config."""
action = UpdateFieldAction()
config = {"field": "due_date", "value": "2025-12-31T23:59:59"}
# Should not raise
action.validate_config(config, db)
def test_validate_config_missing_field(self, db):
"""Test validation fails when field is missing."""
action = UpdateFieldAction()
config = {"value": "high"}
with pytest.raises(ActionValidationError) as exc:
action.validate_config(config, db)
assert "Missing required 'field'" in str(exc.value)
def test_validate_config_missing_value(self, db):
"""Test validation fails when value is missing."""
action = UpdateFieldAction()
config = {"field": "priority"}
with pytest.raises(ActionValidationError) as exc:
action.validate_config(config, db)
assert "Missing required 'value'" in str(exc.value)
def test_validate_config_invalid_field(self, db):
"""Test validation fails for unsupported field."""
action = UpdateFieldAction()
config = {"field": "invalid_field", "value": "test"}
with pytest.raises(ActionValidationError) as exc:
action.validate_config(config, db)
assert "Invalid field" in str(exc.value)
def test_validate_config_invalid_priority(self, db):
"""Test validation fails for invalid priority value."""
action = UpdateFieldAction()
config = {"field": "priority", "value": "critical"}
with pytest.raises(ActionValidationError) as exc:
action.validate_config(config, db)
assert "Invalid priority value" in str(exc.value)
def test_validate_config_invalid_due_date_format(self, db):
"""Test validation fails for invalid due_date format."""
action = UpdateFieldAction()
config = {"field": "due_date", "value": "not-a-date"}
with pytest.raises(ActionValidationError) as exc:
action.validate_config(config, db)
assert "Invalid due_date format" in str(exc.value)
def test_execute_update_priority(self, db, test_task, test_user):
"""Test executing priority update."""
action = UpdateFieldAction()
config = {"field": "priority", "value": "urgent"}
context = {"current_user": test_user}
assert test_task.priority == "medium"
result = action.execute(db, test_task, config, context)
db.commit()
assert result["status"] == "success"
assert result["field"] == "priority"
assert result["old_value"] == "medium"
assert result["new_value"] == "urgent"
assert test_task.priority == "urgent"
def test_execute_update_status_id(self, db, test_task, test_statuses, test_user):
"""Test executing status_id update."""
action = UpdateFieldAction()
new_status = test_statuses[1] # In Progress
config = {"field": "status_id", "value": new_status.id}
context = {"current_user": test_user}
result = action.execute(db, test_task, config, context)
db.commit()
assert result["status"] == "success"
assert result["field"] == "status_id"
assert test_task.status_id == new_status.id
def test_execute_update_status_id_invalid(self, db, test_task, test_user):
"""Test executing status_id update with invalid status."""
action = UpdateFieldAction()
config = {"field": "status_id", "value": "non-existent-status"}
context = {"current_user": test_user}
with pytest.raises(ActionExecutionError) as exc:
action.execute(db, test_task, config, context)
assert "not found in project" in str(exc.value)
def test_execute_update_due_date(self, db, test_task, test_user):
"""Test executing due_date update."""
action = UpdateFieldAction()
due_date_str = "2025-12-31T23:59:59"
config = {"field": "due_date", "value": due_date_str}
context = {"current_user": test_user}
result = action.execute(db, test_task, config, context)
db.commit()
assert result["status"] == "success"
assert result["field"] == "due_date"
assert test_task.due_date is not None
class TestAutoAssignAction:
"""Tests for FEAT-015: Auto Assign Action."""
def test_validate_config_valid_round_robin(self, db):
"""Test validation of valid round_robin config."""
action = AutoAssignAction()
config = {"strategy": "round_robin"}
# Should not raise
action.validate_config(config, db)
def test_validate_config_valid_least_loaded(self, db):
"""Test validation of valid least_loaded config."""
action = AutoAssignAction()
config = {"strategy": "least_loaded"}
# Should not raise
action.validate_config(config, db)
def test_validate_config_valid_specific_user(self, db, test_user):
"""Test validation of valid specific_user config."""
action = AutoAssignAction()
config = {"strategy": "specific_user", "user_id": test_user.id}
# Should not raise
action.validate_config(config, db)
def test_validate_config_missing_strategy(self, db):
"""Test validation fails when strategy is missing."""
action = AutoAssignAction()
config = {}
with pytest.raises(ActionValidationError) as exc:
action.validate_config(config, db)
assert "Missing required 'strategy'" in str(exc.value)
def test_validate_config_invalid_strategy(self, db):
"""Test validation fails for invalid strategy."""
action = AutoAssignAction()
config = {"strategy": "invalid_strategy"}
with pytest.raises(ActionValidationError) as exc:
action.validate_config(config, db)
assert "Invalid strategy" in str(exc.value)
def test_validate_config_specific_user_missing_user_id(self, db):
"""Test validation fails when user_id is missing for specific_user."""
action = AutoAssignAction()
config = {"strategy": "specific_user"}
with pytest.raises(ActionValidationError) as exc:
action.validate_config(config, db)
assert "Missing required 'user_id'" in str(exc.value)
def test_validate_config_specific_user_invalid_user_id(self, db):
"""Test validation fails for non-existent user."""
action = AutoAssignAction()
config = {"strategy": "specific_user", "user_id": "non-existent-user"}
with pytest.raises(ActionValidationError) as exc:
action.validate_config(config, db)
assert "not found or inactive" in str(exc.value)
def test_execute_specific_user(self, db, test_task, test_user, additional_users):
"""Test executing specific_user assignment."""
action = AutoAssignAction()
target_user = additional_users[0]
config = {"strategy": "specific_user", "user_id": target_user.id}
context = {"current_user": test_user}
result = action.execute(db, test_task, config, context)
db.commit()
assert result["status"] == "success"
assert result["strategy"] == "specific_user"
assert result["new_assignee_id"] == target_user.id
assert test_task.assignee_id == target_user.id
def test_execute_round_robin(self, db, test_task, test_user, additional_users, test_project):
"""Test executing round_robin assignment."""
action = AutoAssignAction()
config = {"strategy": "round_robin"}
context = {"current_user": test_user}
# Clear round robin state for this project
AutoAssignAction._round_robin_index.pop(test_project.id, None)
# First assignment
result1 = action.execute(db, test_task, config, context)
db.commit()
first_assignee = result1["new_assignee_id"]
assert result1["status"] == "success"
assert result1["strategy"] == "round_robin"
assert first_assignee is not None
# Create a new task for second assignment
task2 = Task(
id=str(uuid.uuid4()),
project_id=test_project.id,
title="Test Task 2",
status_id=test_task.status_id,
created_by=test_user.id,
priority="medium",
)
db.add(task2)
db.commit()
db.refresh(task2)
# Second assignment should get next member in rotation
result2 = action.execute(db, task2, config, context)
db.commit()
second_assignee = result2["new_assignee_id"]
assert result2["status"] == "success"
# Round robin should cycle through members
assert second_assignee is not None
def test_execute_least_loaded(self, db, test_task, test_user, additional_users, test_project, test_statuses):
"""Test executing least_loaded assignment."""
# Create tasks assigned to different users to create varied workloads
for i, user in enumerate(additional_users):
for j in range(i + 1): # User 0 gets 1 task, User 1 gets 2, etc.
task = Task(
id=str(uuid.uuid4()),
project_id=test_project.id,
title=f"Task for user {i} - {j}",
status_id=test_statuses[0].id, # To Do (not done)
created_by=test_user.id,
assignee_id=user.id,
priority="medium",
)
db.add(task)
db.commit()
# Create a new unassigned task
new_task = Task(
id=str(uuid.uuid4()),
project_id=test_project.id,
title="New Task for Assignment",
status_id=test_statuses[0].id,
created_by=test_user.id,
priority="medium",
)
db.add(new_task)
db.commit()
db.refresh(new_task)
action = AutoAssignAction()
config = {"strategy": "least_loaded"}
context = {"current_user": test_user}
result = action.execute(db, new_task, config, context)
db.commit()
assert result["status"] == "success"
assert result["strategy"] == "least_loaded"
# Should assign to user with fewest tasks
assert result["new_assignee_id"] is not None
def test_execute_no_eligible_members(self, db, test_user, test_space):
"""Test assignment when no eligible members are found."""
# Create project without department (only owner as member)
project = Project(
id=str(uuid.uuid4()),
space_id=test_space.id,
title="Solo Project",
owner_id=test_user.id,
department_id=None, # No department
)
db.add(project)
status = TaskStatus(
id=str(uuid.uuid4()),
project_id=project.id,
name="To Do",
color="#808080",
position=0,
)
db.add(status)
db.commit()
task = Task(
id=str(uuid.uuid4()),
project_id=project.id,
title="Solo Task",
status_id=status.id,
created_by=test_user.id,
priority="medium",
)
db.add(task)
db.commit()
db.refresh(task)
action = AutoAssignAction()
config = {"strategy": "round_robin"}
context = {"current_user": test_user}
result = action.execute(db, task, config, context)
# Should still work - assigns to owner
assert result["status"] == "success"
assert task.assignee_id == test_user.id
class TestActionExecutor:
"""Tests for ActionExecutor registry and execution."""
def test_get_supported_actions(self):
"""Test getting supported action types."""
actions = ActionExecutor.get_supported_actions()
assert "update_field" in actions
assert "auto_assign" in actions
def test_validate_action_update_field(self, db):
"""Test validating update_field action via executor."""
action = {
"type": "update_field",
"config": {"field": "priority", "value": "high"},
}
# Should not raise
ActionExecutor.validate_action(action, db)
def test_validate_action_auto_assign(self, db, test_user):
"""Test validating auto_assign action via executor."""
action = {
"type": "auto_assign",
"config": {"strategy": "specific_user", "user_id": test_user.id},
}
# Should not raise
ActionExecutor.validate_action(action, db)
def test_validate_action_unknown_type(self, db):
"""Test that unknown action types pass through (for extensibility)."""
action = {"type": "unknown_action"}
# Should not raise - allows other handlers to process
ActionExecutor.validate_action(action, db)
def test_execute_action_update_field(self, db, test_task, test_user):
"""Test executing update_field action via executor."""
action = {
"type": "update_field",
"config": {"field": "priority", "value": "urgent"},
}
context = {"current_user": test_user}
result = ActionExecutor.execute_action(db, test_task, action, context)
db.commit()
assert result is not None
assert result["status"] == "success"
assert test_task.priority == "urgent"
def test_execute_action_auto_assign(self, db, test_task, test_user, additional_users):
"""Test executing auto_assign action via executor."""
target_user = additional_users[1]
action = {
"type": "auto_assign",
"config": {"strategy": "specific_user", "user_id": target_user.id},
}
context = {"current_user": test_user}
result = ActionExecutor.execute_action(db, test_task, action, context)
db.commit()
assert result is not None
assert result["status"] == "success"
assert test_task.assignee_id == target_user.id
def test_execute_action_unknown_type_returns_none(self, db, test_task, test_user):
"""Test that unknown action types return None."""
action = {"type": "unknown_action"}
context = {"current_user": test_user}
result = ActionExecutor.execute_action(db, test_task, action, context)
assert result is None
class TestTriggerServiceIntegration:
"""Tests for TriggerService integration with new actions."""
def test_validate_actions_update_field(self, db):
"""Test TriggerService validates update_field actions."""
actions = [{
"type": "update_field",
"config": {"field": "priority", "value": "high"},
}]
# Should not raise
TriggerService.validate_actions(actions, db)
def test_validate_actions_auto_assign(self, db, test_user):
"""Test TriggerService validates auto_assign actions."""
actions = [{
"type": "auto_assign",
"config": {"strategy": "specific_user", "user_id": test_user.id},
}]
# Should not raise
TriggerService.validate_actions(actions, db)
def test_validate_actions_invalid_action_type(self, db):
"""Test TriggerService rejects invalid action types."""
actions = [{"type": "invalid_action"}]
with pytest.raises(ActionValidationError) as exc:
TriggerService.validate_actions(actions, db)
assert "Invalid action type" in str(exc.value)
def test_get_supported_action_types(self):
"""Test getting all supported action types."""
types = TriggerService.get_supported_action_types()
assert "notify" in types
assert "update_field" in types
assert "auto_assign" in types
def test_evaluate_triggers_with_update_field_action(
self, db, test_task, test_user, test_project, test_statuses
):
"""Test trigger evaluation with update_field action."""
# Create trigger that updates priority when status changes
trigger = Trigger(
id=str(uuid.uuid4()),
project_id=test_project.id,
name="Status Change Priority Trigger",
trigger_type="field_change",
conditions={
"field": "status_id",
"operator": "changed_to",
"value": test_statuses[1].id, # In Progress
},
actions=[{
"type": "update_field",
"config": {"field": "priority", "value": "high"},
}],
is_active=True,
created_by=test_user.id,
)
db.add(trigger)
db.commit()
old_values = {"status_id": test_statuses[0].id}
new_values = {"status_id": test_statuses[1].id}
logs = TriggerService.evaluate_triggers(db, test_task, old_values, new_values, test_user)
db.commit()
assert len(logs) == 1
assert logs[0].status == "success"
assert test_task.priority == "high"
def test_evaluate_triggers_with_auto_assign_action(
self, db, test_task, test_user, test_project, test_statuses, additional_users
):
"""Test trigger evaluation with auto_assign action."""
target_user = additional_users[0]
# Create trigger that auto-assigns when priority changes to urgent
trigger = Trigger(
id=str(uuid.uuid4()),
project_id=test_project.id,
name="Priority Auto Assign Trigger",
trigger_type="field_change",
conditions={
"field": "priority",
"operator": "changed_to",
"value": "urgent",
},
actions=[{
"type": "auto_assign",
"config": {"strategy": "specific_user", "user_id": target_user.id},
}],
is_active=True,
created_by=test_user.id,
)
db.add(trigger)
db.commit()
old_values = {"priority": "medium"}
new_values = {"priority": "urgent"}
logs = TriggerService.evaluate_triggers(db, test_task, old_values, new_values, test_user)
db.commit()
assert len(logs) == 1
assert logs[0].status == "success"
assert test_task.assignee_id == target_user.id
def test_evaluate_triggers_with_multiple_actions(
self, db, test_task, test_user, test_project, test_statuses, additional_users
):
"""Test trigger evaluation with multiple actions."""
target_user = additional_users[0]
# Create trigger with both update_field and auto_assign
trigger = Trigger(
id=str(uuid.uuid4()),
project_id=test_project.id,
name="Multi Action Trigger",
trigger_type="field_change",
conditions={
"field": "status_id",
"operator": "changed_to",
"value": test_statuses[1].id,
},
actions=[
{
"type": "update_field",
"config": {"field": "priority", "value": "urgent"},
},
{
"type": "auto_assign",
"config": {"strategy": "specific_user", "user_id": target_user.id},
},
],
is_active=True,
created_by=test_user.id,
)
db.add(trigger)
db.commit()
old_values = {"status_id": test_statuses[0].id}
new_values = {"status_id": test_statuses[1].id}
logs = TriggerService.evaluate_triggers(db, test_task, old_values, new_values, test_user)
db.commit()
assert len(logs) == 1
assert logs[0].status == "success"
assert test_task.priority == "urgent"
assert test_task.assignee_id == target_user.id
# Check that both actions are logged
details = logs[0].details
assert len(details["actions_executed"]) == 2

View File

@@ -273,3 +273,145 @@ class TestEncryptionKeyValidation:
}):
settings = Settings()
assert settings.ENCRYPTION_MASTER_KEY is None
class TestConfidentialProjectUpload:
"""Tests for file upload on confidential projects."""
@pytest.fixture
def test_user(self, db):
"""Create a test user."""
from app.models import User
import uuid as uuid_module
user = User(
id=str(uuid_module.uuid4()),
email="testuser_enc@example.com",
name="Test User Encryption",
role_id="00000000-0000-0000-0000-000000000003",
is_active=True,
is_system_admin=False,
)
db.add(user)
db.commit()
return user
@pytest.fixture
def test_user_token(self, client, mock_redis, test_user):
"""Get a token for test user."""
from app.core.security import create_access_token, create_token_payload
token_data = create_token_payload(
user_id=test_user.id,
email=test_user.email,
role="engineer",
department_id=None,
is_system_admin=False,
)
token = create_access_token(token_data)
mock_redis.setex(f"session:{test_user.id}", 900, token)
return token
@pytest.fixture
def test_space(self, db, test_user):
"""Create a test space."""
from app.models import Space
import uuid as uuid_module
space = Space(
id=str(uuid_module.uuid4()),
name="Test Space Encryption",
description="Test space for encryption tests",
owner_id=test_user.id,
)
db.add(space)
db.commit()
return space
@pytest.fixture
def confidential_project(self, db, test_space, test_user):
"""Create a confidential test project."""
from app.models import Project
import uuid as uuid_module
project = Project(
id=str(uuid_module.uuid4()),
space_id=test_space.id,
title="Confidential Project",
description="Test confidential project",
owner_id=test_user.id,
security_level="confidential",
)
db.add(project)
db.commit()
return project
@pytest.fixture
def test_task(self, db, confidential_project, test_user):
"""Create a test task in confidential project."""
from app.models import Task
import uuid as uuid_module
task = Task(
id=str(uuid_module.uuid4()),
project_id=confidential_project.id,
title="Test Task Encryption",
description="Test task for encryption tests",
created_by=test_user.id,
)
db.add(task)
db.commit()
return task
def test_upload_confidential_project_encryption_unavailable(
self, client, test_user_token, test_task, db
):
"""Test that uploading to confidential project returns 400 when encryption is unavailable."""
from io import BytesIO
# Mock encryption service to return False for is_encryption_available
with patch('app.api.attachments.router.encryption_service') as mock_enc_service:
mock_enc_service.is_encryption_available.return_value = False
content = b"Test file content"
files = {"file": ("test.pdf", BytesIO(content), "application/pdf")}
response = client.post(
f"/api/tasks/{test_task.id}/attachments",
headers={"Authorization": f"Bearer {test_user_token}"},
files=files,
)
assert response.status_code == 400
assert "ENCRYPTION_MASTER_KEY" in response.json()["detail"]
assert "environment variable" in response.json()["detail"]
def test_upload_confidential_project_no_active_key(
self, client, test_user_token, test_task, db
):
"""Test that uploading to confidential project returns 400 when no active encryption key exists."""
from io import BytesIO
from app.models import EncryptionKey
# Make sure no active encryption keys exist
db.query(EncryptionKey).filter(EncryptionKey.is_active == True).update(
{"is_active": False}
)
db.commit()
# Mock encryption service to return True for is_encryption_available
with patch('app.api.attachments.router.encryption_service') as mock_enc_service:
mock_enc_service.is_encryption_available.return_value = True
content = b"Test file content"
files = {"file": ("test.pdf", BytesIO(content), "application/pdf")}
response = client.post(
f"/api/tasks/{test_task.id}/attachments",
headers={"Authorization": f"Bearer {test_user_token}"},
files=files,
)
assert response.status_code == 400
assert "active encryption key" in response.json()["detail"]
assert "create" in response.json()["detail"].lower()

View File

@@ -375,3 +375,119 @@ class TestTriggerAPI:
assert response.status_code == 400
assert "Invalid operator" in response.json()["detail"]
class TestTriggerActionRollback:
"""Tests for trigger action rollback mechanism."""
def test_multi_action_rollback_on_failure(self, db, test_task, test_project, test_user, test_status):
"""Test that when one action fails, all previous actions are rolled back.
Scenario:
1. Create a trigger with 2 actions: update_field (priority) + update_field (invalid status_id)
2. The first action should update priority to 'high'
3. The second action should fail because of invalid status_id
4. The first action's change should be rolled back
"""
# Record original priority
original_priority = test_task.priority
# Create a trigger with multiple actions where the second one will fail
trigger = Trigger(
id=str(uuid.uuid4()),
project_id=test_project.id,
name="Multi-Action Trigger",
description="Test rollback on failure",
trigger_type="field_change",
conditions={
"field": "status_id",
"operator": "changed_to",
"value": test_status[1].id,
},
actions=[
# First action: update priority (should succeed)
{
"type": "update_field",
"field": "priority",
"value": "high",
},
# Second action: update to invalid status (should fail)
{
"type": "update_field",
"field": "status_id",
"value": "non-existent-status-id",
},
],
is_active=True,
created_by=test_user.id,
)
db.add(trigger)
db.commit()
old_values = {"status_id": test_status[0].id}
new_values = {"status_id": test_status[1].id}
# Execute trigger - second action should fail
logs = TriggerService.evaluate_triggers(db, test_task, old_values, new_values, test_user)
db.commit()
# Verify trigger execution failed
assert len(logs) == 1
assert logs[0].status == "failed"
assert logs[0].error_message is not None
assert "not found" in logs[0].error_message.lower()
# Refresh task from database to get the actual state
db.refresh(test_task)
# Verify that the first action's change was rolled back
# Priority should remain unchanged (not 'high')
assert test_task.priority == original_priority, (
f"Expected priority to be rolled back to '{original_priority}', "
f"but got '{test_task.priority}'"
)
def test_all_actions_succeed_committed(self, db, test_task, test_project, test_user, test_status):
"""Test that when all actions succeed, changes are committed."""
# Create a trigger with multiple successful actions
trigger = Trigger(
id=str(uuid.uuid4()),
project_id=test_project.id,
name="Success Trigger",
description="Test successful commit",
trigger_type="field_change",
conditions={
"field": "status_id",
"operator": "changed_to",
"value": test_status[1].id,
},
actions=[
# First action: update priority
{
"type": "update_field",
"field": "priority",
"value": "urgent",
},
],
is_active=True,
created_by=test_user.id,
)
db.add(trigger)
db.commit()
old_values = {"status_id": test_status[0].id}
new_values = {"status_id": test_status[1].id}
# Execute trigger
logs = TriggerService.evaluate_triggers(db, test_task, old_values, new_values, test_user)
db.commit()
# Verify trigger execution succeeded
assert len(logs) == 1
assert logs[0].status == "success"
# Refresh task from database
db.refresh(test_task)
# Verify that the change was committed
assert test_task.priority == "urgent"

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,10 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@fullcalendar/core": "^6.1.20",
@@ -21,10 +24,15 @@
"react-router-dom": "^6.21.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"jsdom": "^27.4.0",
"typescript": "^5.2.2",
"vite": "^5.0.8"
"vite": "^5.0.8",
"vitest": "^4.0.16"
}
}

View File

@@ -1,5 +1,6 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuth } from './contexts/AuthContext'
import { Skeleton } from './components/Skeleton'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import Spaces from './pages/Spaces'
@@ -16,7 +17,12 @@ function App() {
const { isAuthenticated, loading } = useAuth()
if (loading) {
return <div className="container">Loading...</div>
return (
<div className="container" style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
<Skeleton variant="rect" width="100%" height={60} style={{ marginBottom: '24px' }} />
<Skeleton variant="rect" width="100%" height={400} />
</div>
)
}
return (

View File

@@ -1,21 +1,30 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { attachmentService, Attachment } from '../services/attachments'
import { AttachmentVersionHistory } from './AttachmentVersionHistory'
import { ConfirmModal } from './ConfirmModal'
import { useToast } from '../contexts/ToastContext'
import { SkeletonList } from './Skeleton'
interface AttachmentListProps {
taskId: string
onRefresh?: () => void
}
interface VersionHistoryState {
attachmentId: string
currentVersion: number
filename: string
}
export function AttachmentList({ taskId, onRefresh }: AttachmentListProps) {
const { showToast } = useToast()
const [attachments, setAttachments] = useState<Attachment[]>([])
const [loading, setLoading] = useState(true)
const [deleting, setDeleting] = useState<string | null>(null)
const [versionHistory, setVersionHistory] = useState<VersionHistoryState | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<Attachment | null>(null)
useEffect(() => {
loadAttachments()
}, [taskId])
const loadAttachments = async () => {
const loadAttachments = useCallback(async () => {
setLoading(true)
try {
const response = await attachmentService.listAttachments(taskId)
@@ -25,37 +34,59 @@ export function AttachmentList({ taskId, onRefresh }: AttachmentListProps) {
} finally {
setLoading(false)
}
}
}, [taskId])
useEffect(() => {
loadAttachments()
}, [loadAttachments])
const handleDownload = async (attachment: Attachment) => {
try {
await attachmentService.downloadAttachment(attachment.id)
} catch (error) {
console.error('Failed to download attachment:', error)
alert('Failed to download file')
showToast('Failed to download file', 'error')
}
}
const handleDelete = async (attachment: Attachment) => {
if (!confirm(`Are you sure you want to delete "${attachment.filename}"?`)) {
return
}
const handleDeleteConfirm = async () => {
if (!deleteConfirm) return
const attachment = deleteConfirm
setDeleteConfirm(null)
setDeleting(attachment.id)
try {
await attachmentService.deleteAttachment(attachment.id)
setAttachments(prev => prev.filter(a => a.id !== attachment.id))
onRefresh?.()
showToast('File deleted successfully', 'success')
} catch (error) {
console.error('Failed to delete attachment:', error)
alert('Failed to delete file')
showToast('Failed to delete file', 'error')
} finally {
setDeleting(null)
}
}
const handleOpenHistory = (attachment: Attachment) => {
setVersionHistory({
attachmentId: attachment.id,
currentVersion: attachment.current_version,
filename: attachment.filename,
})
}
const handleCloseHistory = () => {
setVersionHistory(null)
}
const handleVersionRestored = () => {
loadAttachments()
onRefresh?.()
}
if (loading) {
return <div style={styles.loading}>Loading attachments...</div>
return <SkeletonList count={2} />
}
if (attachments.length === 0) {
@@ -84,6 +115,15 @@ export function AttachmentList({ taskId, onRefresh }: AttachmentListProps) {
</div>
</div>
<div style={styles.actions}>
{attachment.current_version > 1 && (
<button
style={styles.historyBtn}
onClick={() => handleOpenHistory(attachment)}
title="Version History"
>
History
</button>
)}
<button
style={styles.downloadBtn}
onClick={() => handleDownload(attachment)}
@@ -93,7 +133,7 @@ export function AttachmentList({ taskId, onRefresh }: AttachmentListProps) {
</button>
<button
style={styles.deleteBtn}
onClick={() => handleDelete(attachment)}
onClick={() => setDeleteConfirm(attachment)}
disabled={deleting === attachment.id}
title="Delete"
>
@@ -102,6 +142,28 @@ export function AttachmentList({ taskId, onRefresh }: AttachmentListProps) {
</div>
</div>
))}
{versionHistory && (
<AttachmentVersionHistory
attachmentId={versionHistory.attachmentId}
currentVersion={versionHistory.currentVersion}
filename={versionHistory.filename}
isOpen={true}
onClose={handleCloseHistory}
onRestore={handleVersionRestored}
/>
)}
<ConfirmModal
isOpen={deleteConfirm !== null}
title="Delete File"
message={`Are you sure you want to delete "${deleteConfirm?.filename}"? This action cannot be undone.`}
confirmText="Delete"
cancelText="Cancel"
confirmStyle="danger"
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteConfirm(null)}
/>
</div>
)
}
@@ -163,6 +225,15 @@ const styles: Record<string, React.CSSProperties> = {
display: 'flex',
gap: '8px',
},
historyBtn: {
padding: '6px 12px',
backgroundColor: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '12px',
cursor: 'pointer',
},
downloadBtn: {
padding: '6px 12px',
backgroundColor: '#007bff',

View File

@@ -0,0 +1,437 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { attachmentService, AttachmentVersion, VersionHistoryResponse } from '../services/attachments'
interface AttachmentVersionHistoryProps {
attachmentId: string
currentVersion: number
filename: string
isOpen: boolean
onClose: () => void
onRestore: () => void
}
export function AttachmentVersionHistory({
attachmentId,
currentVersion,
filename,
isOpen,
onClose,
onRestore,
}: AttachmentVersionHistoryProps) {
const [versions, setVersions] = useState<AttachmentVersion[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [restoring, setRestoring] = useState<number | null>(null)
const [confirmRestore, setConfirmRestore] = useState<number | null>(null)
const modalOverlayRef = useRef<HTMLDivElement>(null)
// A11Y: Handle Escape key to close modal
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose()
}
}
if (isOpen) {
document.addEventListener('keydown', handleKeyDown)
modalOverlayRef.current?.focus()
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen, onClose])
const loadVersionHistory = useCallback(async () => {
setLoading(true)
setError(null)
try {
const response: VersionHistoryResponse = await attachmentService.getVersionHistory(attachmentId)
setVersions(response.versions)
} catch (err) {
console.error('Failed to load version history:', err)
setError('Failed to load version history')
} finally {
setLoading(false)
}
}, [attachmentId])
useEffect(() => {
if (isOpen) {
loadVersionHistory()
}
}, [isOpen, loadVersionHistory])
const handleRestore = async (version: number) => {
setRestoring(version)
setError(null)
try {
await attachmentService.restoreVersion(attachmentId, version)
setConfirmRestore(null)
onRestore()
onClose()
} catch (err) {
console.error('Failed to restore version:', err)
setError('Failed to restore version. Please try again.')
} finally {
setRestoring(null)
}
}
const handleDownloadVersion = async (version: number) => {
try {
await attachmentService.downloadAttachment(attachmentId, version)
} catch (err) {
console.error('Failed to download version:', err)
setError('Failed to download version')
}
}
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleString()
}
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose()
}
}
if (!isOpen) return null
return (
<div
ref={modalOverlayRef}
style={styles.overlay}
onClick={handleOverlayClick}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="version-history-title"
>
<div style={styles.modal}>
<div style={styles.header}>
<div style={styles.headerContent}>
<h3 id="version-history-title" style={styles.title}>Version History</h3>
<p style={styles.subtitle}>{filename}</p>
</div>
<button onClick={onClose} style={styles.closeButton} aria-label="Close">
X
</button>
</div>
<div style={styles.content}>
{error && (
<div style={styles.error}>
{error}
</div>
)}
{loading ? (
<div style={styles.loading}>Loading version history...</div>
) : versions.length === 0 ? (
<div style={styles.empty}>No version history available</div>
) : (
<div style={styles.versionList}>
{versions.map((version) => {
const isCurrent = version.version === currentVersion
const isConfirming = confirmRestore === version.version
const isRestoring = restoring === version.version
return (
<div
key={version.id}
style={{
...styles.versionItem,
...(isCurrent ? styles.currentVersionItem : {}),
}}
>
<div style={styles.versionInfo}>
<div style={styles.versionHeader}>
<span style={styles.versionNumber}>
Version {version.version}
</span>
{isCurrent && (
<span style={styles.currentBadge}>Current</span>
)}
</div>
<div style={styles.versionMeta}>
<span>{formatDate(version.created_at)}</span>
<span style={styles.separator}>|</span>
<span>{attachmentService.formatFileSize(version.file_size)}</span>
{version.uploader_name && (
<>
<span style={styles.separator}>|</span>
<span>by {version.uploader_name}</span>
</>
)}
</div>
</div>
<div style={styles.versionActions}>
<button
style={styles.downloadVersionBtn}
onClick={() => handleDownloadVersion(version.version)}
title="Download this version"
>
Download
</button>
{!isCurrent && (
<>
{isConfirming ? (
<div style={styles.confirmGroup}>
<span style={styles.confirmText}>Restore?</span>
<button
style={styles.confirmYesBtn}
onClick={() => handleRestore(version.version)}
disabled={isRestoring}
>
{isRestoring ? '...' : 'Yes'}
</button>
<button
style={styles.confirmNoBtn}
onClick={() => setConfirmRestore(null)}
disabled={isRestoring}
>
No
</button>
</div>
) : (
<button
style={styles.restoreBtn}
onClick={() => setConfirmRestore(version.version)}
title="Restore to this version"
>
Restore
</button>
)}
</>
)}
</div>
</div>
)
})}
</div>
)}
</div>
<div style={styles.footer}>
<button onClick={onClose} style={styles.closeFooterBtn}>
Close
</button>
</div>
</div>
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
overlay: {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1100,
},
modal: {
backgroundColor: 'white',
borderRadius: '12px',
width: '90%',
maxWidth: '600px',
maxHeight: '80vh',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.2)',
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
padding: '20px 24px',
borderBottom: '1px solid #eee',
},
headerContent: {
flex: 1,
},
title: {
margin: 0,
fontSize: '18px',
fontWeight: 600,
color: '#212529',
},
subtitle: {
margin: '4px 0 0 0',
fontSize: '14px',
color: '#6c757d',
},
closeButton: {
width: '32px',
height: '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'transparent',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '16px',
color: '#666',
},
content: {
flex: 1,
overflowY: 'auto',
padding: '16px 24px',
},
error: {
padding: '12px',
backgroundColor: '#fee2e2',
color: '#dc2626',
borderRadius: '6px',
marginBottom: '16px',
fontSize: '14px',
},
loading: {
padding: '32px',
textAlign: 'center',
color: '#6c757d',
fontSize: '14px',
},
empty: {
padding: '32px',
textAlign: 'center',
color: '#6c757d',
fontSize: '14px',
},
versionList: {
display: 'flex',
flexDirection: 'column',
gap: '12px',
},
versionItem: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '14px 16px',
backgroundColor: '#f8f9fa',
borderRadius: '8px',
border: '1px solid #e9ecef',
},
currentVersionItem: {
backgroundColor: '#e8f4fd',
borderColor: '#b8daff',
},
versionInfo: {
flex: 1,
minWidth: 0,
},
versionHeader: {
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '4px',
},
versionNumber: {
fontWeight: 600,
fontSize: '14px',
color: '#212529',
},
currentBadge: {
display: 'inline-block',
padding: '2px 8px',
backgroundColor: '#007bff',
color: 'white',
borderRadius: '4px',
fontSize: '11px',
fontWeight: 500,
},
versionMeta: {
fontSize: '12px',
color: '#6c757d',
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
alignItems: 'center',
},
separator: {
color: '#adb5bd',
},
versionActions: {
display: 'flex',
alignItems: 'center',
gap: '8px',
marginLeft: '16px',
flexShrink: 0,
},
downloadVersionBtn: {
padding: '6px 12px',
backgroundColor: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '12px',
cursor: 'pointer',
},
restoreBtn: {
padding: '6px 12px',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '12px',
cursor: 'pointer',
},
confirmGroup: {
display: 'flex',
alignItems: 'center',
gap: '6px',
},
confirmText: {
fontSize: '12px',
color: '#495057',
fontWeight: 500,
},
confirmYesBtn: {
padding: '4px 10px',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '11px',
cursor: 'pointer',
},
confirmNoBtn: {
padding: '4px 10px',
backgroundColor: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '11px',
cursor: 'pointer',
},
footer: {
padding: '16px 24px',
borderTop: '1px solid #eee',
display: 'flex',
justifyContent: 'flex-end',
},
closeFooterBtn: {
padding: '8px 20px',
backgroundColor: '#f8f9fa',
color: '#495057',
border: '1px solid #dee2e6',
borderRadius: '6px',
fontSize: '14px',
cursor: 'pointer',
},
}
export default AttachmentVersionHistory

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'
import { blockersApi, Blocker } from '../services/collaboration'
import { SkeletonList } from './Skeleton'
interface BlockerDialogProps {
taskId: string
@@ -104,7 +105,9 @@ export function BlockerDialog({
)}
{loading ? (
<div className="text-center text-gray-500 py-4">Loading...</div>
<div className="py-2">
<SkeletonList count={2} showAvatar={false} />
</div>
) : (
<>
{/* Active blocker */}

View File

@@ -5,6 +5,7 @@ import timeGridPlugin from '@fullcalendar/timegrid'
import interactionPlugin from '@fullcalendar/interaction'
import { EventClickArg, EventDropArg, DatesSetArg } from '@fullcalendar/core'
import api from '../services/api'
import { Skeleton } from './Skeleton'
interface Task {
id: string
@@ -354,7 +355,7 @@ export function CalendarView({
</button>
)}
{loading && <span style={styles.loadingIndicator}>Loading...</span>}
{loading && <Skeleton variant="text" width={80} height={20} />}
</div>
{/* Calendar */}

View File

@@ -1,6 +1,8 @@
import { useState, useEffect, useCallback } from 'react'
import { commentsApi, usersApi, Comment, UserSearchResult } from '../services/collaboration'
import { useAuth } from '../contexts/AuthContext'
import { ConfirmModal } from './ConfirmModal'
import { SkeletonList } from './Skeleton'
interface CommentsProps {
taskId: string
@@ -18,6 +20,7 @@ export function Comments({ taskId }: CommentsProps) {
const [editContent, setEditContent] = useState('')
const [mentionSuggestions, setMentionSuggestions] = useState<UserSearchResult[]>([])
const [showMentions, setShowMentions] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
const fetchComments = useCallback(async () => {
try {
@@ -71,10 +74,10 @@ export function Comments({ taskId }: CommentsProps) {
}
const handleDelete = async (commentId: string) => {
if (!confirm('Delete this comment?')) return
try {
await commentsApi.delete(commentId)
await fetchComments()
setDeleteConfirm(null)
} catch (err) {
setError('Failed to delete comment')
}
@@ -112,7 +115,7 @@ export function Comments({ taskId }: CommentsProps) {
setShowMentions(false)
}
if (loading) return <div className="p-4 text-gray-500">Loading comments...</div>
if (loading) return <SkeletonList count={3} showAvatar />
return (
<div className="space-y-4">
@@ -148,7 +151,7 @@ export function Comments({ taskId }: CommentsProps) {
Edit
</button>
<button
onClick={() => handleDelete(comment.id)}
onClick={() => setDeleteConfirm(comment.id)}
className="text-sm text-red-600 hover:underline"
>
Delete
@@ -253,6 +256,17 @@ export function Comments({ taskId }: CommentsProps) {
{submitting ? 'Posting...' : 'Post Comment'}
</button>
</form>
<ConfirmModal
isOpen={deleteConfirm !== null}
title="Delete Comment"
message="Are you sure you want to delete this comment? This action cannot be undone."
confirmText="Delete"
cancelText="Cancel"
confirmStyle="danger"
onConfirm={() => deleteConfirm && handleDelete(deleteConfirm)}
onCancel={() => setDeleteConfirm(null)}
/>
</div>
)
}

View File

@@ -0,0 +1,75 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { ConfirmModal } from './ConfirmModal'
describe('ConfirmModal', () => {
const defaultProps = {
isOpen: true,
title: 'Confirm Action',
message: 'Are you sure you want to proceed?',
onConfirm: vi.fn(),
onCancel: vi.fn(),
}
it('renders nothing when isOpen is false', () => {
render(<ConfirmModal {...defaultProps} isOpen={false} />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('renders modal when isOpen is true', () => {
render(<ConfirmModal {...defaultProps} />)
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByText('Confirm Action')).toBeInTheDocument()
expect(screen.getByText('Are you sure you want to proceed?')).toBeInTheDocument()
})
it('calls onConfirm when confirm button is clicked', () => {
const onConfirm = vi.fn()
render(<ConfirmModal {...defaultProps} onConfirm={onConfirm} />)
fireEvent.click(screen.getByText('Confirm'))
expect(onConfirm).toHaveBeenCalledTimes(1)
})
it('calls onCancel when cancel button is clicked', () => {
const onCancel = vi.fn()
render(<ConfirmModal {...defaultProps} onCancel={onCancel} />)
fireEvent.click(screen.getByText('Cancel'))
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('calls onCancel when Escape key is pressed', () => {
const onCancel = vi.fn()
render(<ConfirmModal {...defaultProps} onCancel={onCancel} />)
fireEvent.keyDown(document, { key: 'Escape' })
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('uses custom button text', () => {
render(
<ConfirmModal
{...defaultProps}
confirmText="Delete"
cancelText="Go Back"
/>
)
expect(screen.getByText('Delete')).toBeInTheDocument()
expect(screen.getByText('Go Back')).toBeInTheDocument()
})
it('renders confirm button with danger style prop', () => {
const { rerender } = render(<ConfirmModal {...defaultProps} confirmStyle="danger" />)
const dangerButton = screen.getByText('Confirm')
// The danger button should be rendered
expect(dangerButton).toBeInTheDocument()
// When rendered with primary style, the button should also be rendered
rerender(<ConfirmModal {...defaultProps} confirmStyle="primary" />)
const primaryButton = screen.getByText('Confirm')
expect(primaryButton).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,155 @@
import { useEffect, useRef } from 'react'
interface ConfirmModalProps {
isOpen: boolean
title: string
message: string
confirmText?: string
cancelText?: string
confirmStyle?: 'danger' | 'primary'
onConfirm: () => void
onCancel: () => void
}
export function ConfirmModal({
isOpen,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
confirmStyle = 'danger',
onConfirm,
onCancel,
}: ConfirmModalProps) {
const modalOverlayRef = useRef<HTMLDivElement>(null)
const confirmButtonRef = useRef<HTMLButtonElement>(null)
// A11Y: Handle Escape key to close modal
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onCancel()
}
}
if (isOpen) {
document.addEventListener('keydown', handleKeyDown)
// Focus confirm button when modal opens
confirmButtonRef.current?.focus()
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen, onCancel])
if (!isOpen) return null
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onCancel()
}
}
return (
<div
ref={modalOverlayRef}
style={styles.overlay}
onClick={handleOverlayClick}
role="dialog"
aria-modal="true"
aria-labelledby="confirm-modal-title"
aria-describedby="confirm-modal-message"
>
<div style={styles.modal}>
<h2 id="confirm-modal-title" style={styles.title}>
{title}
</h2>
<p id="confirm-modal-message" style={styles.message}>
{message}
</p>
<div style={styles.actions}>
<button onClick={onCancel} style={styles.cancelButton}>
{cancelText}
</button>
<button
ref={confirmButtonRef}
onClick={onConfirm}
style={{
...styles.confirmButton,
...(confirmStyle === 'danger' ? styles.dangerButton : styles.primaryButton),
}}
>
{confirmText}
</button>
</div>
</div>
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
overlay: {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1200,
},
modal: {
backgroundColor: 'white',
borderRadius: '8px',
padding: '24px',
width: '400px',
maxWidth: '90%',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.2)',
},
title: {
margin: '0 0 12px 0',
fontSize: '18px',
fontWeight: 600,
color: '#212529',
},
message: {
margin: '0 0 24px 0',
fontSize: '14px',
color: '#495057',
lineHeight: 1.5,
},
actions: {
display: 'flex',
justifyContent: 'flex-end',
gap: '12px',
},
cancelButton: {
padding: '10px 20px',
backgroundColor: '#f8f9fa',
color: '#495057',
border: '1px solid #dee2e6',
borderRadius: '6px',
fontSize: '14px',
cursor: 'pointer',
},
confirmButton: {
padding: '10px 20px',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: 500,
cursor: 'pointer',
},
dangerButton: {
backgroundColor: '#dc3545',
},
primaryButton: {
backgroundColor: '#0066cc',
},
}
export default ConfirmModal

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import {
customFieldsApi,
CustomField,
@@ -38,6 +38,23 @@ export function CustomFieldEditor({
const [formula, setFormula] = useState(field?.formula || '')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const modalOverlayRef = useRef<HTMLDivElement>(null)
// A11Y: Handle Escape key to close modal
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
}
}
document.addEventListener('keydown', handleKeyDown)
modalOverlayRef.current?.focus()
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [onClose])
// Reset form when field changes
useEffect(() => {
@@ -155,10 +172,18 @@ export function CustomFieldEditor({
}
return (
<div style={styles.overlay} onClick={handleOverlayClick}>
<div
ref={modalOverlayRef}
style={styles.overlay}
onClick={handleOverlayClick}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="custom-field-editor-title"
>
<div style={styles.modal}>
<div style={styles.header}>
<h2 style={styles.title}>
<h2 id="custom-field-editor-title" style={styles.title}>
{isEditing ? 'Edit Custom Field' : 'Create Custom Field'}
</h2>
<button onClick={onClose} style={styles.closeButton} aria-label="Close">
@@ -424,7 +449,7 @@ const styles: Record<string, React.CSSProperties> = {
},
typeNote: {
fontSize: '12px',
color: '#999',
color: '#767676', // WCAG AA compliant
fontStyle: 'italic',
},
optionsList: {

View File

@@ -225,7 +225,7 @@ const styles: Record<string, React.CSSProperties> = {
},
formulaHint: {
fontSize: '11px',
color: '#999',
color: '#767676', // WCAG AA compliant
fontStyle: 'italic',
},
unsupported: {

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { customFieldsApi, CustomField, FieldType } from '../services/customFields'
import { CustomFieldEditor } from './CustomFieldEditor'
import { useToast } from '../contexts/ToastContext'
interface CustomFieldListProps {
projectId: string
@@ -16,6 +17,7 @@ const FIELD_TYPE_LABELS: Record<FieldType, string> = {
}
export function CustomFieldList({ projectId }: CustomFieldListProps) {
const { showToast } = useToast()
const [fields, setFields] = useState<CustomField[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -79,13 +81,14 @@ export function CustomFieldList({ projectId }: CustomFieldListProps) {
await customFieldsApi.deleteCustomField(deleteConfirm)
setDeleteConfirm(null)
loadFields()
showToast('Custom field deleted successfully', 'success')
} catch (err: unknown) {
const errorMessage =
err instanceof Error
? err.message
: (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ||
'Failed to delete field'
alert(errorMessage)
showToast(errorMessage, 'error')
} finally {
setDeleting(false)
}
@@ -274,7 +277,7 @@ const styles: Record<string, React.CSSProperties> = {
},
emptyHint: {
fontSize: '13px',
color: '#999',
color: '#767676', // WCAG AA compliant
marginTop: '8px',
},
fieldList: {

View File

@@ -288,7 +288,7 @@ const styles: Record<string, React.CSSProperties> = {
color: '#666',
},
subtaskBadge: {
color: '#999',
color: '#767676', // WCAG AA compliant
},
customValueBadge: {
backgroundColor: '#f3e5f5',
@@ -304,7 +304,7 @@ const styles: Record<string, React.CSSProperties> = {
emptyColumn: {
textAlign: 'center',
padding: '24px',
color: '#999',
color: '#767676', // WCAG AA compliant
fontSize: '13px',
border: '2px dashed #ddd',
borderRadius: '6px',

View File

@@ -1,5 +1,6 @@
import { useState, useRef, useEffect } from 'react'
import { useNotifications } from '../contexts/NotificationContext'
import { SkeletonList } from './Skeleton'
export function NotificationBell() {
const { notifications, unreadCount, loading, fetchNotifications, markAsRead, markAllAsRead } =
@@ -100,7 +101,9 @@ export function NotificationBell() {
<div className="max-h-96 overflow-y-auto">
{loading ? (
<div className="p-4 text-center text-gray-500">Loading...</div>
<div className="p-2">
<SkeletonList count={3} showAvatar={false} />
</div>
) : notifications.length === 0 ? (
<div className="p-4 text-center text-gray-500">No notifications</div>
) : (

View File

@@ -1,6 +1,7 @@
import { Navigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { ReactNode } from 'react'
import { Skeleton } from './Skeleton'
interface ProtectedRouteProps {
children: ReactNode
@@ -10,7 +11,12 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated, loading } = useAuth()
if (loading) {
return <div className="container">Loading...</div>
return (
<div className="container" style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
<Skeleton variant="rect" width="100%" height={60} style={{ marginBottom: '24px' }} />
<Skeleton variant="rect" width="100%" height={400} />
</div>
)
}
if (!isAuthenticated) {

View File

@@ -41,11 +41,26 @@ export function ResourceHistory({ resourceType, resourceId, title = 'Change Hist
return <div style={styles.empty}>No change history available</div>
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setExpanded(!expanded)
}
}
return (
<div style={styles.container}>
<div style={styles.header} onClick={() => setExpanded(!expanded)}>
<div
style={styles.header}
onClick={() => setExpanded(!expanded)}
role="button"
tabIndex={0}
onKeyDown={handleKeyDown}
aria-expanded={expanded}
aria-label={`${title}, ${logs.length} items`}
>
<span style={styles.title}>{title}</span>
<span style={styles.toggleIcon}>{expanded ? '▼' : '▶'}</span>
<span style={styles.toggleIcon} aria-hidden="true">{expanded ? '▼' : '▶'}</span>
</div>
{expanded && (
<div style={styles.content}>

View File

@@ -0,0 +1,106 @@
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { Skeleton, SkeletonCard, SkeletonList, SkeletonTable, SkeletonGrid, SkeletonKanban } from './Skeleton'
describe('Skeleton', () => {
it('renders with default props', () => {
render(<Skeleton />)
const skeleton = document.querySelector('[aria-hidden="true"]')
expect(skeleton).toBeInTheDocument()
})
it('applies text variant styles', () => {
render(<Skeleton variant="text" />)
const skeleton = document.querySelector('[aria-hidden="true"]')
expect(skeleton).toHaveStyle({ borderRadius: '4px' })
})
it('applies circle variant styles', () => {
render(<Skeleton variant="circle" width={40} height={40} />)
const skeleton = document.querySelector('[aria-hidden="true"]')
expect(skeleton).toHaveStyle({ borderRadius: '50%' })
})
it('applies custom dimensions', () => {
render(<Skeleton width={200} height={100} />)
const skeleton = document.querySelector('[aria-hidden="true"]')
expect(skeleton).toHaveStyle({ width: '200px', height: '100px' })
})
it('is hidden from screen readers', () => {
render(<Skeleton />)
const skeleton = document.querySelector('[aria-hidden="true"]')
expect(skeleton).toHaveAttribute('aria-hidden', 'true')
})
})
describe('SkeletonCard', () => {
it('renders with default lines', () => {
render(<SkeletonCard />)
// Card should have 4 skeletons (1 title + 3 content lines by default)
const skeletons = document.querySelectorAll('[aria-hidden="true"]')
expect(skeletons.length).toBe(4)
})
it('renders with custom number of lines', () => {
render(<SkeletonCard lines={5} />)
const skeletons = document.querySelectorAll('[aria-hidden="true"]')
expect(skeletons.length).toBe(6) // title + 5 lines
})
it('renders image placeholder when showImage is true', () => {
render(<SkeletonCard showImage lines={2} />)
const skeletons = document.querySelectorAll('[aria-hidden="true"]')
expect(skeletons.length).toBe(4) // image + title + 2 lines
})
})
describe('SkeletonList', () => {
it('renders default number of items', () => {
const { container } = render(<SkeletonList />)
// Each item has 2 skeletons, default count is 3
const skeletons = container.querySelectorAll('[aria-hidden="true"]')
expect(skeletons.length).toBe(6)
})
it('renders with avatars when showAvatar is true', () => {
const { container } = render(<SkeletonList count={2} showAvatar />)
// Each item has 3 skeletons (avatar + 2 text), count is 2
const skeletons = container.querySelectorAll('[aria-hidden="true"]')
expect(skeletons.length).toBe(6)
})
})
describe('SkeletonTable', () => {
it('renders default rows and columns', () => {
const { container } = render(<SkeletonTable />)
// Header (4 columns) + 5 rows * 4 columns = 24 skeletons
const skeletons = container.querySelectorAll('[aria-hidden="true"]')
expect(skeletons.length).toBe(24)
})
it('renders with custom rows and columns', () => {
const { container } = render(<SkeletonTable rows={3} columns={2} />)
// Header (2 columns) + 3 rows * 2 columns = 8 skeletons
const skeletons = container.querySelectorAll('[aria-hidden="true"]')
expect(skeletons.length).toBe(8)
})
})
describe('SkeletonGrid', () => {
it('renders default number of cards', () => {
const { container } = render(<SkeletonGrid />)
// 6 cards, each with 3 skeletons (title + 2 lines)
const skeletons = container.querySelectorAll('[aria-hidden="true"]')
expect(skeletons.length).toBe(18)
})
})
describe('SkeletonKanban', () => {
it('renders kanban columns', () => {
const { container } = render(<SkeletonKanban columns={3} />)
// 3 columns, each with header + 2-3 cards
const skeletons = container.querySelectorAll('[aria-hidden="true"]')
expect(skeletons.length).toBeGreaterThan(10)
})
})

View File

@@ -0,0 +1,240 @@
import React from 'react'
interface SkeletonProps {
variant?: 'text' | 'rect' | 'circle'
width?: string | number
height?: string | number
className?: string
style?: React.CSSProperties
}
export function Skeleton({
variant = 'rect',
width = '100%',
height,
className = '',
style,
}: SkeletonProps) {
const baseStyle: React.CSSProperties = {
backgroundColor: '#e5e7eb',
borderRadius: variant === 'circle' ? '50%' : variant === 'text' ? '4px' : '8px',
width: typeof width === 'number' ? `${width}px` : width,
height:
height !== undefined
? typeof height === 'number'
? `${height}px`
: height
: variant === 'text'
? '1em'
: '100px',
animation: 'skeleton-pulse 1.5s ease-in-out infinite',
...style,
}
return <div className={className} style={baseStyle} aria-hidden="true" />
}
// Skeleton for card-like content
interface SkeletonCardProps {
lines?: number
showImage?: boolean
className?: string
}
export function SkeletonCard({ lines = 3, showImage = false, className = '' }: SkeletonCardProps) {
return (
<div className={className} style={cardStyles.container}>
{showImage && <Skeleton variant="rect" height={120} style={{ marginBottom: '12px' }} />}
<Skeleton variant="text" width="60%" height={20} style={{ marginBottom: '8px' }} />
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
key={i}
variant="text"
width={i === lines - 1 ? '40%' : '100%'}
height={14}
style={{ marginBottom: '6px' }}
/>
))}
</div>
)
}
// Skeleton for list items
interface SkeletonListProps {
count?: number
showAvatar?: boolean
className?: string
}
export function SkeletonList({ count = 3, showAvatar = false, className = '' }: SkeletonListProps) {
return (
<div className={className} style={listStyles.container}>
{Array.from({ length: count }).map((_, i) => (
<div key={i} style={listStyles.item}>
{showAvatar && (
<Skeleton variant="circle" width={40} height={40} style={{ marginRight: '12px' }} />
)}
<div style={listStyles.content}>
<Skeleton variant="text" width="70%" height={16} style={{ marginBottom: '6px' }} />
<Skeleton variant="text" width="40%" height={12} />
</div>
</div>
))}
</div>
)
}
// Skeleton for table rows
interface SkeletonTableProps {
rows?: number
columns?: number
className?: string
}
export function SkeletonTable({ rows = 5, columns = 4, className = '' }: SkeletonTableProps) {
return (
<div className={className} style={tableStyles.container}>
{/* Header */}
<div style={tableStyles.header}>
{Array.from({ length: columns }).map((_, i) => (
<Skeleton key={i} variant="text" width="80%" height={14} />
))}
</div>
{/* Rows */}
{Array.from({ length: rows }).map((_, rowIndex) => (
<div key={rowIndex} style={tableStyles.row}>
{Array.from({ length: columns }).map((_, colIndex) => (
<Skeleton
key={colIndex}
variant="text"
width={colIndex === 0 ? '90%' : '60%'}
height={14}
/>
))}
</div>
))}
</div>
)
}
// Skeleton for project/task cards in grid
interface SkeletonGridProps {
count?: number
columns?: number
className?: string
}
export function SkeletonGrid({ count = 6, columns = 3, className = '' }: SkeletonGridProps) {
return (
<div
className={className}
style={{
display: 'grid',
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap: '16px',
}}
>
{Array.from({ length: count }).map((_, i) => (
<SkeletonCard key={i} lines={2} />
))}
</div>
)
}
// Skeleton for Kanban board
export function SkeletonKanban({ columns = 4 }: { columns?: number }) {
return (
<div style={kanbanStyles.container}>
{Array.from({ length: columns }).map((_, i) => (
<div key={i} style={kanbanStyles.column}>
<Skeleton variant="text" width="60%" height={20} style={{ marginBottom: '16px' }} />
<SkeletonCard lines={2} />
<SkeletonCard lines={2} />
{i < 2 && <SkeletonCard lines={2} />}
</div>
))}
</div>
)
}
const cardStyles: Record<string, React.CSSProperties> = {
container: {
padding: '16px',
backgroundColor: '#f9fafb',
borderRadius: '8px',
border: '1px solid #e5e7eb',
},
}
const listStyles: Record<string, React.CSSProperties> = {
container: {
display: 'flex',
flexDirection: 'column',
gap: '12px',
},
item: {
display: 'flex',
alignItems: 'center',
padding: '12px',
backgroundColor: '#f9fafb',
borderRadius: '8px',
},
content: {
flex: 1,
},
}
const tableStyles: Record<string, React.CSSProperties> = {
container: {
width: '100%',
},
header: {
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: '16px',
padding: '12px 16px',
backgroundColor: '#f3f4f6',
borderRadius: '8px 8px 0 0',
marginBottom: '2px',
},
row: {
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: '16px',
padding: '16px',
backgroundColor: '#f9fafb',
marginBottom: '2px',
},
}
const kanbanStyles: Record<string, React.CSSProperties> = {
container: {
display: 'flex',
gap: '16px',
overflowX: 'auto',
},
column: {
minWidth: '280px',
backgroundColor: '#f3f4f6',
borderRadius: '8px',
padding: '16px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
},
}
// CSS keyframes for animation (inject once)
const styleId = 'skeleton-styles'
if (typeof document !== 'undefined' && !document.getElementById(styleId)) {
const style = document.createElement('style')
style.id = styleId
style.textContent = `
@keyframes skeleton-pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
`
document.head.appendChild(style)
}

View File

@@ -0,0 +1,383 @@
import { useState, useEffect, useCallback } from 'react'
import api from '../services/api'
interface Subtask {
id: string
title: string
status_id: string | null
status_name: string | null
status_color: string | null
assignee_id: string | null
assignee_name: string | null
priority: string
}
interface SubtaskListProps {
taskId: string
projectId: string
onSubtaskClick?: (subtaskId: string) => void
onSubtaskCreated?: () => void
}
export function SubtaskList({
taskId,
projectId,
onSubtaskClick,
onSubtaskCreated,
}: SubtaskListProps) {
const [subtasks, setSubtasks] = useState<Subtask[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [expanded, setExpanded] = useState(true)
const [showAddForm, setShowAddForm] = useState(false)
const [newSubtaskTitle, setNewSubtaskTitle] = useState('')
const [submitting, setSubmitting] = useState(false)
const fetchSubtasks = useCallback(async () => {
try {
setLoading(true)
const response = await api.get(`/tasks/${taskId}/subtasks`)
setSubtasks(response.data.tasks || [])
setError(null)
} catch (err) {
console.error('Failed to fetch subtasks:', err)
setError('Failed to load subtasks')
} finally {
setLoading(false)
}
}, [taskId])
useEffect(() => {
fetchSubtasks()
}, [fetchSubtasks])
const handleAddSubtask = async (e: React.FormEvent) => {
e.preventDefault()
if (!newSubtaskTitle.trim() || submitting) return
try {
setError(null)
setSubmitting(true)
await api.post(`/projects/${projectId}/tasks`, {
title: newSubtaskTitle.trim(),
parent_task_id: taskId,
})
setNewSubtaskTitle('')
setShowAddForm(false)
fetchSubtasks()
onSubtaskCreated?.()
} catch (err: unknown) {
console.error('Failed to create subtask:', err)
const axiosError = err as { response?: { data?: { detail?: string } } }
const errorMessage = axiosError.response?.data?.detail || 'Failed to create subtask'
setError(errorMessage)
} finally {
setSubmitting(false)
}
}
const handleCancelAdd = () => {
setNewSubtaskTitle('')
setShowAddForm(false)
}
const getPriorityColor = (priority: string): string => {
const colors: Record<string, string> = {
low: '#808080',
medium: '#0066cc',
high: '#ff9800',
urgent: '#f44336',
}
return colors[priority] || colors.medium
}
return (
<div style={styles.container}>
<div
style={styles.header}
onClick={() => setExpanded(!expanded)}
role="button"
tabIndex={0}
aria-expanded={expanded}
aria-label={`Subtasks section, ${subtasks.length} items`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setExpanded(!expanded)
}
}}
>
<span style={styles.title}>Subtasks ({subtasks.length})</span>
<span style={styles.toggleIcon}>{expanded ? '\u25BC' : '\u25B6'}</span>
</div>
{expanded && (
<div style={styles.content}>
{loading ? (
<div style={styles.loadingText}>Loading subtasks...</div>
) : error ? (
<div style={styles.errorText}>{error}</div>
) : (
<>
{/* Subtask List */}
{subtasks.length > 0 ? (
<div style={styles.subtaskList}>
{subtasks.map((subtask) => (
<div
key={subtask.id}
style={styles.subtaskItem}
onClick={() => onSubtaskClick?.(subtask.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onSubtaskClick?.(subtask.id)
}
}}
>
<div style={styles.subtaskLeft}>
<div
style={{
...styles.priorityDot,
backgroundColor: getPriorityColor(subtask.priority),
}}
/>
<span style={styles.subtaskTitle}>{subtask.title}</span>
</div>
<div style={styles.subtaskRight}>
{subtask.status_name && (
<span
style={{
...styles.statusBadge,
backgroundColor: subtask.status_color || '#e0e0e0',
}}
>
{subtask.status_name}
</span>
)}
{subtask.assignee_name && (
<span style={styles.assigneeName}>
{subtask.assignee_name}
</span>
)}
</div>
</div>
))}
</div>
) : (
<div style={styles.emptyText}>No subtasks yet</div>
)}
{/* Add Subtask Form */}
{showAddForm ? (
<form onSubmit={handleAddSubtask} style={styles.addForm}>
<label htmlFor="new-subtask-title" style={styles.visuallyHidden}>
Subtask title
</label>
<input
id="new-subtask-title"
type="text"
value={newSubtaskTitle}
onChange={(e) => setNewSubtaskTitle(e.target.value)}
placeholder="Enter subtask title..."
style={styles.input}
autoFocus
disabled={submitting}
/>
<div style={styles.formActions}>
<button
type="button"
onClick={handleCancelAdd}
style={styles.cancelButton}
disabled={submitting}
>
Cancel
</button>
<button
type="submit"
style={styles.submitButton}
disabled={!newSubtaskTitle.trim() || submitting}
>
{submitting ? 'Adding...' : 'Add'}
</button>
</div>
</form>
) : (
<button
onClick={() => setShowAddForm(true)}
style={styles.addButton}
>
+ Add Subtask
</button>
)}
</>
)}
</div>
)}
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
visuallyHidden: {
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
},
container: {
backgroundColor: '#f8f9fa',
borderRadius: '8px',
border: '1px solid #e9ecef',
marginTop: '16px',
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '12px 16px',
cursor: 'pointer',
userSelect: 'none',
},
title: {
fontWeight: 600,
fontSize: '14px',
color: '#495057',
},
toggleIcon: {
fontSize: '12px',
color: '#6c757d',
},
content: {
borderTop: '1px solid #e9ecef',
padding: '16px',
},
loadingText: {
fontSize: '13px',
color: '#6c757d',
fontStyle: 'italic',
},
errorText: {
fontSize: '13px',
color: '#dc3545',
},
emptyText: {
fontSize: '13px',
color: '#6c757d',
marginBottom: '12px',
},
subtaskList: {
display: 'flex',
flexDirection: 'column',
gap: '8px',
marginBottom: '12px',
},
subtaskItem: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '10px 12px',
backgroundColor: 'white',
borderRadius: '6px',
border: '1px solid #e9ecef',
cursor: 'pointer',
transition: 'background-color 0.15s ease',
},
subtaskLeft: {
display: 'flex',
alignItems: 'center',
gap: '10px',
flex: 1,
minWidth: 0,
},
priorityDot: {
width: '8px',
height: '8px',
borderRadius: '50%',
flexShrink: 0,
},
subtaskTitle: {
fontSize: '14px',
color: '#212529',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
subtaskRight: {
display: 'flex',
alignItems: 'center',
gap: '10px',
marginLeft: '12px',
flexShrink: 0,
},
statusBadge: {
fontSize: '11px',
fontWeight: 500,
color: 'white',
padding: '3px 8px',
borderRadius: '4px',
},
assigneeName: {
fontSize: '12px',
color: '#6c757d',
maxWidth: '100px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
addForm: {
display: 'flex',
flexDirection: 'column',
gap: '10px',
},
input: {
width: '100%',
padding: '10px 12px',
fontSize: '14px',
border: '1px solid #ced4da',
borderRadius: '6px',
boxSizing: 'border-box',
outline: 'none',
},
formActions: {
display: 'flex',
justifyContent: 'flex-end',
gap: '8px',
},
cancelButton: {
padding: '8px 16px',
fontSize: '13px',
backgroundColor: '#f5f5f5',
border: '1px solid #ddd',
borderRadius: '4px',
cursor: 'pointer',
},
submitButton: {
padding: '8px 16px',
fontSize: '13px',
backgroundColor: '#0066cc',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
},
addButton: {
width: '100%',
padding: '10px',
fontSize: '13px',
fontWeight: 500,
color: '#0066cc',
backgroundColor: 'transparent',
border: '1px dashed #ced4da',
borderRadius: '6px',
cursor: 'pointer',
transition: 'background-color 0.15s ease',
},
}
export default SubtaskList

View File

@@ -1,11 +1,13 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import api from '../services/api'
import { Comments } from './Comments'
import { TaskAttachments } from './TaskAttachments'
import { SubtaskList } from './SubtaskList'
import { UserSelect } from './UserSelect'
import { UserSearchResult } from '../services/collaboration'
import { customFieldsApi, CustomField, CustomValueResponse } from '../services/customFields'
import { CustomFieldInput } from './CustomFieldInput'
import { SkeletonList } from './Skeleton'
interface Task {
id: string
@@ -37,6 +39,7 @@ interface TaskDetailModalProps {
isOpen: boolean
onClose: () => void
onUpdate: () => void
onSubtaskClick?: (subtaskId: string) => void
}
export function TaskDetailModal({
@@ -45,6 +48,7 @@ export function TaskDetailModal({
isOpen,
onClose,
onUpdate,
onSubtaskClick,
}: TaskDetailModalProps) {
const [isEditing, setIsEditing] = useState(false)
const [saving, setSaving] = useState(false)
@@ -69,14 +73,7 @@ export function TaskDetailModal({
const [editCustomValues, setEditCustomValues] = useState<Record<string, string | number | null>>({})
const [loadingCustomFields, setLoadingCustomFields] = useState(false)
// Load custom fields for the project
useEffect(() => {
if (task.project_id) {
loadCustomFields()
}
}, [task.project_id])
const loadCustomFields = async () => {
const loadCustomFields = useCallback(async () => {
setLoadingCustomFields(true)
try {
const response = await customFieldsApi.getCustomFields(task.project_id)
@@ -86,7 +83,14 @@ export function TaskDetailModal({
} finally {
setLoadingCustomFields(false)
}
}, [task.project_id])
// Load custom fields for the project
useEffect(() => {
if (task.project_id) {
loadCustomFields()
}
}, [task.project_id, loadCustomFields])
// Initialize custom values from task
useEffect(() => {
@@ -120,6 +124,28 @@ export function TaskDetailModal({
setIsEditing(false)
}, [task])
// Reference to the modal overlay for focus management
const overlayRef = useRef<HTMLDivElement>(null)
// Handle Escape key to close modal
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose()
}
}
if (isOpen) {
document.addEventListener('keydown', handleKeyDown)
// Focus the overlay for keyboard accessibility
overlayRef.current?.focus()
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen, onClose])
if (!isOpen) return null
const handleSave = async () => {
@@ -204,7 +230,15 @@ export function TaskDetailModal({
}
return (
<div style={styles.overlay} onClick={handleOverlayClick}>
<div
ref={overlayRef}
style={styles.overlay}
onClick={handleOverlayClick}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="task-detail-title"
>
<div style={styles.modal}>
<div style={styles.header}>
<div style={styles.headerLeft}>
@@ -223,7 +257,7 @@ export function TaskDetailModal({
autoFocus
/>
) : (
<h2 style={styles.title}>{task.title}</h2>
<h2 id="task-detail-title" style={styles.title}>{task.title}</h2>
)}
</div>
<div style={styles.headerActions}>
@@ -285,6 +319,16 @@ export function TaskDetailModal({
<div style={styles.section}>
<TaskAttachments taskId={task.id} />
</div>
{/* Subtasks Section */}
<div style={styles.section}>
<SubtaskList
taskId={task.id}
projectId={task.project_id}
onSubtaskClick={onSubtaskClick}
onSubtaskCreated={onUpdate}
/>
</div>
</div>
<div style={styles.sidebar}>
@@ -420,7 +464,7 @@ export function TaskDetailModal({
<div style={styles.customFieldsDivider} />
<div style={styles.customFieldsHeader}>Custom Fields</div>
{loadingCustomFields ? (
<div style={styles.loadingText}>Loading...</div>
<SkeletonList count={3} showAvatar={false} />
) : (
customFields.map((field) => {
// Get the value for this field

View File

@@ -1,5 +1,8 @@
import { useState, useEffect, useCallback } from 'react'
import { triggersApi, Trigger } from '../services/triggers'
import { ConfirmModal } from './ConfirmModal'
import { useToast } from '../contexts/ToastContext'
import { SkeletonList } from './Skeleton'
interface TriggerListProps {
projectId: string
@@ -7,9 +10,11 @@ interface TriggerListProps {
}
export function TriggerList({ projectId, onEdit }: TriggerListProps) {
const { showToast } = useToast()
const [triggers, setTriggers] = useState<Trigger[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
const fetchTriggers = useCallback(async () => {
try {
@@ -32,18 +37,22 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
try {
await triggersApi.updateTrigger(trigger.id, { is_active: !trigger.is_active })
fetchTriggers()
showToast(`Trigger ${trigger.is_active ? 'disabled' : 'enabled'}`, 'success')
} catch {
setError('Failed to update trigger')
showToast('Failed to update trigger', 'error')
}
}
const handleDelete = async (triggerId: string) => {
if (!confirm('Are you sure you want to delete this trigger?')) return
const handleDeleteConfirm = async () => {
if (!deleteConfirm) return
const triggerId = deleteConfirm
setDeleteConfirm(null)
try {
await triggersApi.deleteTrigger(triggerId)
fetchTriggers()
showToast('Trigger deleted successfully', 'success')
} catch {
setError('Failed to delete trigger')
showToast('Failed to delete trigger', 'error')
}
}
@@ -67,7 +76,7 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
}
if (loading) {
return <div className="p-4 text-center text-gray-500">Loading triggers...</div>
return <SkeletonList count={3} />
}
if (error) {
@@ -141,7 +150,7 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
</button>
)}
<button
onClick={() => handleDelete(trigger.id)}
onClick={() => setDeleteConfirm(trigger.id)}
className="text-sm px-2 py-1 text-red-600 hover:bg-red-50 rounded"
>
Delete
@@ -150,6 +159,17 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
</div>
</div>
))}
<ConfirmModal
isOpen={deleteConfirm !== null}
title="Delete Trigger"
message="Are you sure you want to delete this trigger? This action cannot be undone."
confirmText="Delete"
cancelText="Cancel"
confirmStyle="danger"
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteConfirm(null)}
/>
</div>
)
}

View File

@@ -189,7 +189,7 @@ const styles: Record<string, React.CSSProperties> = {
flex: 1,
},
placeholder: {
color: '#999',
color: '#767676', // WCAG AA compliant
fontSize: '14px',
},
clearButton: {
@@ -257,7 +257,7 @@ const styles: Record<string, React.CSSProperties> = {
emptyItem: {
padding: '12px',
textAlign: 'center',
color: '#999',
color: '#767676', // WCAG AA compliant
},
}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'
import { reportsApi, WeeklyReportContent, ProjectSummary } from '../services/reports'
import { useToast } from '../contexts/ToastContext'
interface CollapsibleSectionProps {
title: string
@@ -142,6 +143,7 @@ function ProjectCard({ project }: { project: ProjectSummary }) {
}
export function WeeklyReportPreview() {
const { showToast } = useToast()
const [report, setReport] = useState<WeeklyReportContent | null>(null)
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
@@ -168,10 +170,10 @@ export function WeeklyReportPreview() {
try {
setGenerating(true)
await reportsApi.generateWeeklyReport()
alert('Report generated and notification sent!')
showToast('Report generated and notification sent!', 'success')
fetchPreview()
} catch {
setError('Failed to generate report')
showToast('Failed to generate report', 'error')
} finally {
setGenerating(false)
}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef, useCallback } from 'react'
import { UserWorkloadDetail, LoadLevel, workloadApi } from '../services/workload'
import { SkeletonList } from './Skeleton'
interface WorkloadUserDetailProps {
userId: string
@@ -34,14 +35,27 @@ export function WorkloadUserDetail({
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [detail, setDetail] = useState<UserWorkloadDetail | null>(null)
const modalOverlayRef = useRef<HTMLDivElement>(null)
// A11Y: Handle Escape key to close modal
useEffect(() => {
if (isOpen && userId) {
loadUserDetail()
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose()
}
}
}, [isOpen, userId, weekStart])
const loadUserDetail = async () => {
if (isOpen) {
document.addEventListener('keydown', handleKeyDown)
modalOverlayRef.current?.focus()
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen, onClose])
const loadUserDetail = useCallback(async () => {
setLoading(true)
setError(null)
try {
@@ -53,7 +67,13 @@ export function WorkloadUserDetail({
} finally {
setLoading(false)
}
}, [userId, weekStart])
useEffect(() => {
if (isOpen && userId) {
loadUserDetail()
}
}, [isOpen, userId, weekStart, loadUserDetail])
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-'
@@ -64,11 +84,19 @@ export function WorkloadUserDetail({
if (!isOpen) return null
return (
<div style={styles.overlay} onClick={onClose}>
<div
ref={modalOverlayRef}
style={styles.overlay}
onClick={onClose}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="workload-detail-title"
>
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
<div style={styles.header}>
<div>
<h2 style={styles.title}>{userName}</h2>
<h2 id="workload-detail-title" style={styles.title}>{userName}</h2>
<span style={styles.subtitle}>Workload Details</span>
</div>
<button style={styles.closeButton} onClick={onClose} aria-label="Close">
@@ -77,7 +105,9 @@ export function WorkloadUserDetail({
</div>
{loading ? (
<div style={styles.loading}>Loading...</div>
<div style={{ padding: '16px' }}>
<SkeletonList count={5} showAvatar={false} />
</div>
) : error ? (
<div style={styles.error}>{error}</div>
) : detail ? (
@@ -203,7 +233,7 @@ const styles: { [key: string]: React.CSSProperties } = {
border: 'none',
fontSize: '28px',
cursor: 'pointer',
color: '#999',
color: '#767676', // WCAG AA compliant
padding: '0',
lineHeight: 1,
},
@@ -265,7 +295,7 @@ const styles: { [key: string]: React.CSSProperties } = {
emptyTasks: {
textAlign: 'center',
padding: '24px',
color: '#999',
color: '#767676', // WCAG AA compliant
fontSize: '14px',
},
taskList: {

View File

@@ -0,0 +1,162 @@
import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
type ToastType = 'success' | 'error' | 'warning' | 'info'
interface Toast {
id: string
type: ToastType
message: string
}
interface ToastContextValue {
toasts: Toast[]
showToast: (message: string, type?: ToastType) => void
removeToast: (id: string) => void
}
const ToastContext = createContext<ToastContextValue | null>(null)
export function useToast() {
const context = useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within a ToastProvider')
}
return context
}
interface ToastProviderProps {
children: ReactNode
}
export function ToastProvider({ children }: ToastProviderProps) {
const [toasts, setToasts] = useState<Toast[]>([])
const showToast = useCallback((message: string, type: ToastType = 'info') => {
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
const toast: Toast = { id, type, message }
setToasts((prev) => [...prev, toast])
// Auto-remove after 5 seconds
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, 5000)
}, [])
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
return (
<ToastContext.Provider value={{ toasts, showToast, removeToast }}>
{children}
<ToastContainer toasts={toasts} removeToast={removeToast} />
</ToastContext.Provider>
)
}
// Toast Container Component
interface ToastContainerProps {
toasts: Toast[]
removeToast: (id: string) => void
}
function ToastContainer({ toasts, removeToast }: ToastContainerProps) {
if (toasts.length === 0) return null
return (
<div style={styles.container} role="region" aria-label="Notifications">
{toasts.map((toast) => (
<div
key={toast.id}
style={{ ...styles.toast, ...getToastStyle(toast.type) }}
role="alert"
aria-live="polite"
>
<span style={styles.icon}>{getToastIcon(toast.type)}</span>
<span style={styles.message}>{toast.message}</span>
<button
onClick={() => removeToast(toast.id)}
style={styles.closeButton}
aria-label="Dismiss notification"
>
x
</button>
</div>
))}
</div>
)
}
function getToastIcon(type: ToastType): string {
switch (type) {
case 'success':
return '✓'
case 'error':
return '✕'
case 'warning':
return '!'
case 'info':
return 'i'
}
}
function getToastStyle(type: ToastType): React.CSSProperties {
switch (type) {
case 'success':
return { backgroundColor: '#d4edda', borderColor: '#c3e6cb', color: '#155724' }
case 'error':
return { backgroundColor: '#f8d7da', borderColor: '#f5c6cb', color: '#721c24' }
case 'warning':
return { backgroundColor: '#fff3cd', borderColor: '#ffeeba', color: '#856404' }
case 'info':
return { backgroundColor: '#cce5ff', borderColor: '#b8daff', color: '#004085' }
}
}
const styles: Record<string, React.CSSProperties> = {
container: {
position: 'fixed',
bottom: '20px',
right: '20px',
zIndex: 1300,
display: 'flex',
flexDirection: 'column',
gap: '10px',
maxWidth: '400px',
},
toast: {
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px 16px',
borderRadius: '6px',
border: '1px solid',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
animation: 'slideIn 0.3s ease-out',
},
icon: {
width: '20px',
height: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 'bold',
fontSize: '12px',
},
message: {
flex: 1,
fontSize: '14px',
lineHeight: 1.4,
},
closeButton: {
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: '16px',
opacity: 0.6,
padding: '0 4px',
},
}
export default ToastProvider

View File

@@ -4,6 +4,19 @@
box-sizing: border-box;
}
/* A11Y: Global focus-visible styles for keyboard navigation */
*:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
/* A11Y: Custom focus styles for inputs that use outline: none */
.login-input:focus,
.login-input:focus-visible {
border-color: #0066cc;
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.2);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;

View File

@@ -5,17 +5,20 @@ import App from './App'
import { AuthProvider } from './contexts/AuthContext'
import { NotificationProvider } from './contexts/NotificationContext'
import { ProjectSyncProvider } from './contexts/ProjectSyncContext'
import { ToastProvider } from './contexts/ToastContext'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<ToastProvider>
<NotificationProvider>
<ProjectSyncProvider>
<App />
</ProjectSyncProvider>
</NotificationProvider>
</ToastProvider>
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useAuth } from '../contexts/AuthContext'
import { auditService, AuditLog, AuditLogFilters } from '../services/audit'
import { SkeletonTable } from '../components/Skeleton'
import { auditService, AuditLog, AuditLogFilters, IntegrityCheckResponse } from '../services/audit'
interface AuditLogDetailProps {
log: AuditLog
@@ -8,12 +9,38 @@ interface AuditLogDetailProps {
}
function AuditLogDetail({ log, onClose }: AuditLogDetailProps) {
const modalOverlayRef = useRef<HTMLDivElement>(null)
// Handle Escape key to close modal - document-level listener
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
}
}
document.addEventListener('keydown', handleKeyDown)
// Focus the overlay for accessibility
modalOverlayRef.current?.focus()
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [onClose])
return (
<div style={styles.modal}>
<div
ref={modalOverlayRef}
style={styles.modal}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="audit-log-detail-title"
>
<div style={styles.modalContent}>
<div style={styles.modalHeader}>
<h3>Audit Log Details</h3>
<button onClick={onClose} style={styles.closeButton}>×</button>
<h3 id="audit-log-detail-title">Audit Log Details</h3>
<button onClick={onClose} style={styles.closeButton} aria-label="Close">×</button>
</div>
<div style={styles.modalBody}>
<div style={styles.detailRow}>
@@ -96,6 +123,162 @@ function getSensitivityStyle(level: string): React.CSSProperties {
}
}
interface IntegrityVerificationModalProps {
result: IntegrityCheckResponse | null
isLoading: boolean
error: string | null
onClose: () => void
}
function IntegrityVerificationModal({ result, isLoading, error, onClose }: IntegrityVerificationModalProps) {
const isSuccess = result && result.invalid_count === 0
const modalOverlayRef = useRef<HTMLDivElement>(null)
// Handle Escape key to close modal - document-level listener
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
}
}
document.addEventListener('keydown', handleKeyDown)
// Focus the overlay for accessibility
modalOverlayRef.current?.focus()
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [onClose])
// Add spinner animation via style tag
useEffect(() => {
const styleId = 'integrity-spinner-style'
if (!document.getElementById(styleId)) {
const style = document.createElement('style')
style.id = styleId
style.textContent = `
@keyframes integrity-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.integrity-spinner {
animation: integrity-spin 1s linear infinite;
}
`
document.head.appendChild(style)
}
return () => {
// Clean up on unmount if no other modals use it
}
}, [])
return (
<div
ref={modalOverlayRef}
style={styles.modal}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="integrity-verification-title"
>
<div style={styles.modalContent}>
<div style={styles.modalHeader}>
<h3 id="integrity-verification-title">Integrity Verification</h3>
<button onClick={onClose} style={styles.closeButton} aria-label="Close">x</button>
</div>
<div style={styles.modalBody}>
{isLoading && (
<div style={styles.loadingContainer}>
<div className="integrity-spinner" style={styles.spinner}></div>
<p style={styles.loadingText}>Verifying audit log integrity...</p>
<p style={styles.loadingSubtext}>This may take a moment depending on the number of records.</p>
</div>
)}
{error && (
<div style={styles.errorContainer}>
<div style={styles.errorIcon}>!</div>
<h4 style={styles.errorTitle}>Verification Failed</h4>
<p style={styles.errorMessage}>{error}</p>
</div>
)}
{result && !isLoading && (
<div>
{/* Overall Status */}
<div style={{
...styles.statusBanner,
backgroundColor: isSuccess ? '#d4edda' : '#f8d7da',
borderColor: isSuccess ? '#c3e6cb' : '#f5c6cb',
}}>
<div style={{
...styles.statusIcon,
backgroundColor: isSuccess ? '#28a745' : '#dc3545',
}}>
{isSuccess ? '✓' : '✗'}
</div>
<div>
<h4 style={{
...styles.statusTitle,
color: isSuccess ? '#155724' : '#721c24',
}}>
{isSuccess ? 'Integrity Verified' : 'Integrity Issues Detected'}
</h4>
<p style={{
...styles.statusDescription,
color: isSuccess ? '#155724' : '#721c24',
}}>
{isSuccess
? 'All audit records have valid checksums and have not been tampered with.'
: 'Some audit records have invalid checksums, indicating potential tampering or corruption.'}
</p>
</div>
</div>
{/* Statistics */}
<div style={styles.statsContainer}>
<div style={styles.statBox}>
<span style={styles.statValue}>{result.total_checked}</span>
<span style={styles.statLabel}>Total Checked</span>
</div>
<div style={{ ...styles.statBox, backgroundColor: '#e8f5e9' }}>
<span style={{ ...styles.statValue, color: '#28a745' }}>{result.valid_count}</span>
<span style={styles.statLabel}>Valid</span>
</div>
<div style={{ ...styles.statBox, backgroundColor: result.invalid_count > 0 ? '#ffebee' : '#f5f5f5' }}>
<span style={{ ...styles.statValue, color: result.invalid_count > 0 ? '#dc3545' : '#666' }}>
{result.invalid_count}
</span>
<span style={styles.statLabel}>Invalid</span>
</div>
</div>
{/* Invalid Records List */}
{result.invalid_records && result.invalid_records.length > 0 && (
<div style={styles.invalidRecordsSection}>
<h4 style={styles.invalidRecordsTitle}>Invalid Records</h4>
<p style={styles.invalidRecordsDescription}>
The following record IDs failed integrity verification:
</p>
<div style={styles.invalidRecordsList}>
{result.invalid_records.map((recordId, index) => (
<div key={index} style={styles.invalidRecordItem}>
<span style={styles.invalidRecordIcon}>!</span>
<span style={styles.invalidRecordId}>{recordId}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
)
}
export default function AuditPage() {
const { user } = useAuth()
const [logs, setLogs] = useState<AuditLog[]>([])
@@ -113,13 +296,13 @@ export default function AuditPage() {
sensitivity_level: '',
})
useEffect(() => {
if (user?.is_system_admin) {
loadLogs()
}
}, [filters, user])
// Integrity verification state
const [showVerificationModal, setShowVerificationModal] = useState(false)
const [verificationResult, setVerificationResult] = useState<IntegrityCheckResponse | null>(null)
const [verificationLoading, setVerificationLoading] = useState(false)
const [verificationError, setVerificationError] = useState<string | null>(null)
const loadLogs = async () => {
const loadLogs = useCallback(async () => {
setLoading(true)
try {
const response = await auditService.getAuditLogs(filters)
@@ -130,7 +313,13 @@ export default function AuditPage() {
} finally {
setLoading(false)
}
}, [filters])
useEffect(() => {
if (user?.is_system_admin) {
loadLogs()
}
}, [loadLogs, user?.is_system_admin])
const handleApplyFilters = () => {
setFilters({
@@ -242,6 +431,38 @@ export default function AuditPage() {
setFilters({ ...filters, offset: newOffset })
}
const handleVerifyIntegrity = async () => {
setShowVerificationModal(true)
setVerificationLoading(true)
setVerificationResult(null)
setVerificationError(null)
try {
// Use filter dates if available, otherwise use default range (last 30 days)
const endDate = tempFilters.end_date || new Date().toISOString()
const startDate = tempFilters.start_date || new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
const result = await auditService.verifyIntegrity(startDate, endDate)
setVerificationResult(result)
} catch (error: unknown) {
console.error('Failed to verify integrity:', error)
const err = error as { response?: { data?: { detail?: string } }; message?: string }
setVerificationError(
err.response?.data?.detail ||
err.message ||
'An error occurred while verifying integrity. Please try again.'
)
} finally {
setVerificationLoading(false)
}
}
const handleCloseVerificationModal = () => {
setShowVerificationModal(false)
setVerificationResult(null)
setVerificationError(null)
}
if (!user?.is_system_admin) {
return (
<div style={styles.container}>
@@ -312,12 +533,15 @@ export default function AuditPage() {
<button onClick={handleExportPDF} style={styles.exportPdfButton}>
Export PDF
</button>
<button onClick={handleVerifyIntegrity} style={styles.verifyButton}>
Verify Integrity
</button>
</div>
</div>
{/* Results */}
{loading ? (
<div>Loading...</div>
<SkeletonTable rows={10} columns={6} />
) : (
<>
<div style={styles.summary}>
@@ -387,6 +611,16 @@ export default function AuditPage() {
{selectedLog && (
<AuditLogDetail log={selectedLog} onClose={() => setSelectedLog(null)} />
)}
{/* Integrity Verification Modal */}
{showVerificationModal && (
<IntegrityVerificationModal
result={verificationResult}
isLoading={verificationLoading}
error={verificationError}
onClose={handleCloseVerificationModal}
/>
)}
</div>
)
}
@@ -559,4 +793,182 @@ const styles: Record<string, React.CSSProperties> = {
color: '#666',
wordBreak: 'break-all',
},
// Verify Integrity Button
verifyButton: {
padding: '8px 16px',
backgroundColor: '#6f42c1',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: 'bold',
},
// Loading styles
loadingContainer: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '40px 20px',
},
spinner: {
width: '48px',
height: '48px',
border: '4px solid #f3f3f3',
borderTop: '4px solid #6f42c1',
borderRadius: '50%',
},
loadingText: {
marginTop: '16px',
fontSize: '16px',
fontWeight: 'bold',
color: '#333',
},
loadingSubtext: {
marginTop: '8px',
fontSize: '14px',
color: '#666',
},
// Error styles
errorContainer: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '40px 20px',
},
errorIcon: {
width: '48px',
height: '48px',
backgroundColor: '#dc3545',
color: 'white',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '24px',
fontWeight: 'bold',
},
errorTitle: {
marginTop: '16px',
fontSize: '18px',
fontWeight: 'bold',
color: '#dc3545',
},
errorMessage: {
marginTop: '8px',
fontSize: '14px',
color: '#666',
textAlign: 'center',
},
// Status banner styles
statusBanner: {
display: 'flex',
alignItems: 'flex-start',
gap: '16px',
padding: '16px',
borderRadius: '8px',
border: '1px solid',
marginBottom: '20px',
},
statusIcon: {
width: '40px',
height: '40px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: '20px',
fontWeight: 'bold',
flexShrink: 0,
},
statusTitle: {
margin: 0,
fontSize: '16px',
fontWeight: 'bold',
},
statusDescription: {
margin: '4px 0 0 0',
fontSize: '14px',
},
// Statistics styles
statsContainer: {
display: 'flex',
gap: '16px',
marginBottom: '20px',
},
statBox: {
flex: 1,
padding: '16px',
backgroundColor: '#f8f9fa',
borderRadius: '8px',
textAlign: 'center',
display: 'flex',
flexDirection: 'column',
gap: '4px',
},
statValue: {
fontSize: '28px',
fontWeight: 'bold',
color: '#333',
},
statLabel: {
fontSize: '12px',
color: '#666',
textTransform: 'uppercase',
letterSpacing: '0.5px',
},
// Invalid records styles
invalidRecordsSection: {
marginTop: '20px',
padding: '16px',
backgroundColor: '#fff5f5',
borderRadius: '8px',
border: '1px solid #ffcdd2',
},
invalidRecordsTitle: {
margin: '0 0 8px 0',
fontSize: '16px',
fontWeight: 'bold',
color: '#c62828',
},
invalidRecordsDescription: {
margin: '0 0 12px 0',
fontSize: '14px',
color: '#666',
},
invalidRecordsList: {
maxHeight: '200px',
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
gap: '8px',
},
invalidRecordItem: {
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
backgroundColor: 'white',
borderRadius: '4px',
border: '1px solid #ffcdd2',
},
invalidRecordIcon: {
width: '20px',
height: '20px',
backgroundColor: '#dc3545',
color: 'white',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontWeight: 'bold',
flexShrink: 0,
},
invalidRecordId: {
fontFamily: 'monospace',
fontSize: '13px',
color: '#333',
wordBreak: 'break-all',
},
}

View File

@@ -18,10 +18,11 @@ export default function Login() {
try {
await login({ email, password })
navigate('/')
} catch (err: any) {
if (err.response?.status === 401) {
} catch (err: unknown) {
const error = err as { response?: { status?: number } }
if (error.response?.status === 401) {
setError('Invalid email or password')
} else if (err.response?.status === 503) {
} else if (error.response?.status === 503) {
setError('Authentication service temporarily unavailable')
} else {
setError('An error occurred. Please try again.')
@@ -50,6 +51,7 @@ export default function Login() {
value={email}
onChange={(e) => setEmail(e.target.value)}
style={styles.input}
className="login-input"
placeholder="your.email@company.com"
required
/>
@@ -65,6 +67,7 @@ export default function Login() {
value={password}
onChange={(e) => setPassword(e.target.value)}
style={styles.input}
className="login-input"
placeholder="Enter your password"
required
/>

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { ProjectHealthCard } from '../components/ProjectHealthCard'
import { SkeletonGrid } from '../components/Skeleton'
import {
projectHealthApi,
ProjectHealthDashboardResponse,
@@ -184,9 +185,7 @@ export default function ProjectHealthPage() {
{/* Content */}
{loading ? (
<div style={styles.loadingContainer}>
<div style={styles.loading}>Loading project health data...</div>
</div>
<SkeletonGrid count={6} columns={3} />
) : error ? (
<div style={styles.errorContainer}>
<p style={styles.error}>{error}</p>

View File

@@ -2,6 +2,8 @@ import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import api from '../services/api'
import { CustomFieldList } from '../components/CustomFieldList'
import { useToast } from '../contexts/ToastContext'
import { Skeleton } from '../components/Skeleton'
interface Project {
id: string
@@ -15,6 +17,7 @@ interface Project {
export default function ProjectSettings() {
const { projectId } = useParams()
const navigate = useNavigate()
const { showToast } = useToast()
const [project, setProject] = useState<Project | null>(null)
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<'general' | 'custom-fields'>('custom-fields')
@@ -29,13 +32,30 @@ export default function ProjectSettings() {
setProject(response.data)
} catch (err) {
console.error('Failed to load project:', err)
showToast('Failed to load project settings', 'error')
} finally {
setLoading(false)
}
}
if (loading) {
return <div style={styles.loading}>Loading...</div>
return (
<div style={styles.container}>
<div style={styles.header}>
<Skeleton variant="text" width={200} height={32} />
<Skeleton variant="rect" width={120} height={40} />
</div>
<div style={styles.layout}>
<div style={styles.sidebar}>
<Skeleton variant="rect" width="100%" height={44} style={{ marginBottom: '8px' }} />
<Skeleton variant="rect" width="100%" height={44} />
</div>
<div style={styles.content}>
<Skeleton variant="rect" width="100%" height={300} />
</div>
</div>
</div>
)
}
if (!project) {

View File

@@ -1,6 +1,8 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import api from '../services/api'
import { SkeletonGrid } from '../components/Skeleton'
import { useToast } from '../contexts/ToastContext'
interface Project {
id: string
@@ -23,6 +25,7 @@ interface Space {
export default function Projects() {
const { spaceId } = useParams()
const navigate = useNavigate()
const { showToast } = useToast()
const [space, setSpace] = useState<Space | null>(null)
const [projects, setProjects] = useState<Project[]>([])
const [loading, setLoading] = useState(true)
@@ -33,11 +36,31 @@ export default function Projects() {
security_level: 'department',
})
const [creating, setCreating] = useState(false)
const modalOverlayRef = useRef<HTMLDivElement>(null)
useEffect(() => {
loadData()
}, [spaceId])
// Handle Escape key to close modal - document-level listener
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && showCreateModal) {
setShowCreateModal(false)
}
}
if (showCreateModal) {
document.addEventListener('keydown', handleKeyDown)
// Focus the overlay for accessibility
modalOverlayRef.current?.focus()
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [showCreateModal])
const loadData = async () => {
try {
const [spaceRes, projectsRes] = await Promise.all([
@@ -48,6 +71,7 @@ export default function Projects() {
setProjects(projectsRes.data)
} catch (err) {
console.error('Failed to load data:', err)
showToast('Failed to load projects', 'error')
} finally {
setLoading(false)
}
@@ -62,8 +86,10 @@ export default function Projects() {
setShowCreateModal(false)
setNewProject({ title: '', description: '', security_level: 'department' })
loadData()
showToast('Project created successfully', 'success')
} catch (err) {
console.error('Failed to create project:', err)
showToast('Failed to create project', 'error')
} finally {
setCreating(false)
}
@@ -84,7 +110,15 @@ export default function Projects() {
}
if (loading) {
return <div style={styles.loading}>Loading...</div>
return (
<div style={styles.container}>
<div style={styles.header}>
<div style={{ width: '200px', height: '32px', backgroundColor: '#e5e7eb', borderRadius: '4px' }} />
<div style={{ width: '120px', height: '40px', backgroundColor: '#e5e7eb', borderRadius: '8px' }} />
</div>
<SkeletonGrid count={6} columns={3} />
</div>
)
}
return (
@@ -110,6 +144,15 @@ export default function Projects() {
key={project.id}
style={styles.card}
onClick={() => navigate(`/projects/${project.id}`)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
navigate(`/projects/${project.id}`)
}
}}
role="button"
tabIndex={0}
aria-label={`Open project: ${project.title}`}
>
<div style={styles.cardHeader}>
<h3 style={styles.cardTitle}>{project.title}</h3>
@@ -135,17 +178,33 @@ export default function Projects() {
</div>
{showCreateModal && (
<div style={styles.modalOverlay}>
<div
ref={modalOverlayRef}
style={styles.modalOverlay}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="create-project-title"
>
<div style={styles.modal}>
<h2 style={styles.modalTitle}>Create New Project</h2>
<h2 id="create-project-title" style={styles.modalTitle}>Create New Project</h2>
<label htmlFor="project-title" style={styles.visuallyHidden}>
Project title
</label>
<input
id="project-title"
type="text"
placeholder="Project title"
value={newProject.title}
onChange={(e) => setNewProject({ ...newProject, title: e.target.value })}
style={styles.input}
autoFocus
/>
<label htmlFor="project-description" style={styles.visuallyHidden}>
Description
</label>
<textarea
id="project-description"
placeholder="Description (optional)"
value={newProject.description}
onChange={(e) => setNewProject({ ...newProject, description: e.target.value })}
@@ -258,7 +317,7 @@ const styles: { [key: string]: React.CSSProperties } = {
display: 'flex',
justifyContent: 'space-between',
fontSize: '12px',
color: '#999',
color: '#767676', // WCAG AA compliant
},
empty: {
gridColumn: '1 / -1',
@@ -348,4 +407,15 @@ const styles: { [key: string]: React.CSSProperties } = {
borderRadius: '4px',
cursor: 'pointer',
},
visuallyHidden: {
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
},
}

View File

@@ -1,6 +1,8 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import api from '../services/api'
import { useToast } from '../contexts/ToastContext'
import { SkeletonGrid } from '../components/Skeleton'
interface Space {
id: string
@@ -14,22 +16,44 @@ interface Space {
export default function Spaces() {
const navigate = useNavigate()
const { showToast } = useToast()
const [spaces, setSpaces] = useState<Space[]>([])
const [loading, setLoading] = useState(true)
const [showCreateModal, setShowCreateModal] = useState(false)
const [newSpace, setNewSpace] = useState({ name: '', description: '' })
const [creating, setCreating] = useState(false)
const modalOverlayRef = useRef<HTMLDivElement>(null)
useEffect(() => {
loadSpaces()
}, [])
// Handle Escape key to close modal - document-level listener
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && showCreateModal) {
setShowCreateModal(false)
}
}
if (showCreateModal) {
document.addEventListener('keydown', handleKeyDown)
// Focus the overlay for accessibility
modalOverlayRef.current?.focus()
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [showCreateModal])
const loadSpaces = async () => {
try {
const response = await api.get('/spaces')
setSpaces(response.data)
} catch (err) {
console.error('Failed to load spaces:', err)
showToast('Failed to load spaces', 'error')
} finally {
setLoading(false)
}
@@ -44,15 +68,25 @@ export default function Spaces() {
setShowCreateModal(false)
setNewSpace({ name: '', description: '' })
loadSpaces()
showToast('Space created successfully', 'success')
} catch (err) {
console.error('Failed to create space:', err)
showToast('Failed to create space', 'error')
} finally {
setCreating(false)
}
}
if (loading) {
return <div style={styles.loading}>Loading...</div>
return (
<div style={styles.container}>
<div style={styles.header}>
<div style={{ width: '100px', height: '32px', backgroundColor: '#e5e7eb', borderRadius: '4px' }} />
<div style={{ width: '120px', height: '40px', backgroundColor: '#e5e7eb', borderRadius: '6px' }} />
</div>
<SkeletonGrid count={6} columns={3} />
</div>
)
}
return (
@@ -70,6 +104,15 @@ export default function Spaces() {
key={space.id}
style={styles.card}
onClick={() => navigate(`/spaces/${space.id}`)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
navigate(`/spaces/${space.id}`)
}
}}
role="button"
tabIndex={0}
aria-label={`Open space: ${space.name}`}
>
<h3 style={styles.cardTitle}>{space.name}</h3>
<p style={styles.cardDescription}>
@@ -89,17 +132,33 @@ export default function Spaces() {
</div>
{showCreateModal && (
<div style={styles.modalOverlay}>
<div
ref={modalOverlayRef}
style={styles.modalOverlay}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="create-space-title"
>
<div style={styles.modal}>
<h2 style={styles.modalTitle}>Create New Space</h2>
<h2 id="create-space-title" style={styles.modalTitle}>Create New Space</h2>
<label htmlFor="space-name" style={styles.visuallyHidden}>
Space name
</label>
<input
id="space-name"
type="text"
placeholder="Space name"
value={newSpace.name}
onChange={(e) => setNewSpace({ ...newSpace, name: e.target.value })}
style={styles.input}
autoFocus
/>
<label htmlFor="space-description" style={styles.visuallyHidden}>
Description
</label>
<textarea
id="space-description"
placeholder="Description (optional)"
value={newSpace.description}
onChange={(e) => setNewSpace({ ...newSpace, description: e.target.value })}
@@ -179,7 +238,7 @@ const styles: { [key: string]: React.CSSProperties } = {
},
cardMeta: {
fontSize: '12px',
color: '#999',
color: '#767676', // WCAG AA compliant
},
empty: {
gridColumn: '1 / -1',
@@ -254,4 +313,15 @@ const styles: { [key: string]: React.CSSProperties } = {
borderRadius: '4px',
cursor: 'pointer',
},
visuallyHidden: {
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
},
}

View File

@@ -10,6 +10,7 @@ import { UserSearchResult } from '../services/collaboration'
import { useProjectSync, TaskEvent } from '../contexts/ProjectSyncContext'
import { customFieldsApi, CustomField, CustomValueResponse } from '../services/customFields'
import { CustomFieldInput } from '../components/CustomFieldInput'
import { SkeletonTable, SkeletonKanban, Skeleton } from '../components/Skeleton'
interface Task {
id: string
@@ -98,6 +99,7 @@ export default function Tasks() {
})
const [showColumnMenu, setShowColumnMenu] = useState(false)
const columnMenuRef = useRef<HTMLDivElement>(null)
const createModalOverlayRef = useRef<HTMLDivElement>(null)
useEffect(() => {
loadData()
@@ -250,6 +252,25 @@ export default function Tasks() {
}
}, [showColumnMenu])
// Handle Escape key to close create modal - document-level listener
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && showCreateModal) {
setShowCreateModal(false)
}
}
if (showCreateModal) {
document.addEventListener('keydown', handleKeyDown)
// Focus the overlay for accessibility
createModalOverlayRef.current?.focus()
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [showCreateModal])
const loadData = async () => {
try {
const [projectRes, tasksRes, statusesRes] = await Promise.all([
@@ -386,6 +407,24 @@ export default function Tasks() {
loadData()
}
const handleSubtaskClick = async (subtaskId: string) => {
try {
const response = await api.get(`/tasks/${subtaskId}`)
const subtask = response.data
// Ensure subtask has project_id for custom fields loading
const subtaskWithProject = {
...subtask,
project_id: projectId!,
// Map API response fields to frontend Task interface
time_estimate: subtask.original_estimate,
}
setSelectedTask(subtaskWithProject)
// Modal is already open, just update the task
} catch (err) {
console.error('Failed to load subtask:', err)
}
}
const getPriorityStyle = (priority: string): React.CSSProperties => {
const colors: { [key: string]: string } = {
low: '#808080',
@@ -401,7 +440,18 @@ export default function Tasks() {
}
if (loading) {
return <div style={styles.loading}>Loading...</div>
return (
<div style={styles.container}>
<div style={styles.header}>
<Skeleton variant="text" width={200} height={32} />
<div style={{ display: 'flex', gap: '8px' }}>
<Skeleton variant="rect" width={100} height={36} />
<Skeleton variant="rect" width={100} height={36} />
</div>
</div>
{viewMode === 'kanban' ? <SkeletonKanban columns={4} /> : <SkeletonTable rows={8} columns={5} />}
</div>
)
}
return (
@@ -623,17 +673,33 @@ export default function Tasks() {
{/* Create Task Modal */}
{showCreateModal && (
<div style={styles.modalOverlay}>
<div
ref={createModalOverlayRef}
style={styles.modalOverlay}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="create-task-title"
>
<div style={styles.modal}>
<h2 style={styles.modalTitle}>Create New Task</h2>
<h2 id="create-task-title" style={styles.modalTitle}>Create New Task</h2>
<label htmlFor="task-title" style={styles.visuallyHidden}>
Task title
</label>
<input
id="task-title"
type="text"
placeholder="Task title"
value={newTask.title}
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
style={styles.input}
autoFocus
/>
<label htmlFor="task-description" style={styles.visuallyHidden}>
Description
</label>
<textarea
id="task-description"
placeholder="Description (optional)"
value={newTask.description}
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
@@ -734,6 +800,7 @@ export default function Tasks() {
isOpen={showDetailModal}
onClose={handleCloseDetailModal}
onUpdate={handleTaskUpdate}
onSubtaskClick={handleSubtaskClick}
/>
)}
</div>
@@ -879,7 +946,7 @@ const styles: { [key: string]: React.CSSProperties } = {
color: '#0066cc',
},
subtaskCount: {
color: '#999',
color: '#767676', // WCAG AA compliant
},
statusSelect: {
padding: '6px 12px',
@@ -1069,4 +1136,15 @@ const styles: { [key: string]: React.CSSProperties } = {
color: '#888',
fontSize: '13px',
},
visuallyHidden: {
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
},
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react'
import { WorkloadHeatmap } from '../components/WorkloadHeatmap'
import { WorkloadUserDetail } from '../components/WorkloadUserDetail'
import { SkeletonTable } from '../components/Skeleton'
import { workloadApi, WorkloadHeatmapResponse } from '../services/workload'
// Helper to get Monday of a given week
@@ -120,9 +121,7 @@ export default function WorkloadPage() {
{/* Content */}
{loading ? (
<div style={styles.loadingContainer}>
<div style={styles.loading}>Loading workload data...</div>
</div>
<SkeletonTable rows={5} columns={6} />
) : error ? (
<div style={styles.errorContainer}>
<p style={styles.error}>{error}</p>

View File

@@ -0,0 +1,32 @@
import '@testing-library/jest-dom'
import { afterEach } from 'vitest'
import { cleanup } from '@testing-library/react'
// Cleanup after each test
afterEach(() => {
cleanup()
})
// Mock window.matchMedia (used by some components)
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
})
// Mock localStorage
const localStorageMock = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
clear: () => {},
}
Object.defineProperty(window, 'localStorage', { value: localStorageMock })

16
frontend/vitest.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
coverage: {
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'src/test/'],
},
},
})

View File

@@ -1,9 +1,9 @@
# PROJECT CONTROL - Issue Tracking
> 審核日期: 2026-01-04
> 更新日期: 2026-01-05
> 整體完成度: 約 99%
> 已修復問題: 25 (CRIT-001~003, HIGH-001~008, MED-001~012, NEW-001~002)
> 更新日期: 2026-01-06
> 整體完成度: 100% (核心功能及 A11Y 全部完成)
> 已修復問題: 32 (CRIT-001~003, HIGH-001~008, MED-001~012, NEW-001~002, A11Y-001~006)
---
@@ -403,7 +403,7 @@
- **模組**: Backend - API
- **檔案**: 多個 list endpoints
- **問題描述**: 雖有分頁實作,但部分 endpoints 無最大 page size 限制。
- **狀態**: [ ] 修復
- **狀態**: [x] 修復 (Query validation with max limits)
---
@@ -414,7 +414,7 @@
- **檔案**: `backend/app/services/report_service.py:84-92`
- **問題描述**: 使用硬編碼字串比對狀態名稱 `["done", "completed", "完成"]`。
- **建議修復**: 統一使用 TaskStatus model 的 is_done flag
- **狀態**: [ ] 待改善
- **狀態**: [x] 已修復 (使用 TaskStatus.is_done flag)
---
@@ -424,7 +424,7 @@
- **模組**: Backend - Automation
- **檔案**: `backend/app/api/triggers/router.py:62-66`
- **問題描述**: 觸發器類型僅驗證 "field_change" 和 "schedule",但 spec 提及 "creation" 類型。
- **狀態**: [ ] 修復
- **狀態**: [x] 修復 (新增 "creation" 類型驗證)
---
@@ -435,7 +435,7 @@
- **檔案**: `frontend/src/pages/Login.tsx:21`
- **問題描述**: `catch (err: any)` 失去類型安全。
- **建議修復**: 使用 `catch (err: unknown)` 並進行類型守衛
- **狀態**: [ ] 待改善
- **狀態**: [x] 已修復 (使用 unknown + type guard)
---
@@ -450,7 +450,7 @@
- `frontend/src/components/WeeklyReportPreview.tsx:171`
- **問題描述**: 使用原生對話框,非無障礙且 UX 不一致。
- **建議修復**: 建立可重用的確認 Modal 元件
- **狀態**: [ ] 待改善
- **狀態**: [x] 已修復 (ConfirmModal 元件 + A11Y 支援)
---
@@ -461,7 +461,7 @@
- **檔案**: `frontend/src/pages/Spaces.tsx:31-32`
- **問題描述**: 錯誤僅記錄至 console無向使用者顯示。
- **建議修復**: 新增 toast 通知系統
- **狀態**: [ ] 待改善
- **狀態**: [x] 已修復 (ToastContext + 整合至 Spaces/Projects/ProjectSettings 等頁面)
---
@@ -482,7 +482,9 @@
- **模組**: Frontend - Multiple
- **問題描述**: 所有載入狀態顯示純文字 "Loading...",造成版面跳動。
- **建議修復**: 新增骨架元件 (skeleton components)
- **狀態**: [ ] 待改善
- **狀態**: [x] 已修復 (Skeleton 元件系列 + 全站整合)
- **已整合頁面**: App.tsx, ProtectedRoute.tsx, Spaces.tsx, Projects.tsx, Tasks.tsx, ProjectSettings.tsx, AuditPage.tsx, WorkloadPage.tsx, ProjectHealthPage.tsx
- **已整合元件**: Comments.tsx, AttachmentList.tsx, TriggerList.tsx, TaskDetailModal.tsx, NotificationBell.tsx, BlockerDialog.tsx, CalendarView.tsx, WorkloadUserDetail.tsx
---
@@ -492,7 +494,7 @@
- **模組**: Frontend
- **問題描述**: 未發現任何測試檔案。
- **建議修復**: 新增 Vitest/Jest 單元測試
- **狀態**: [ ] 待開發
- **狀態**: [x] 已修復 (Vitest + 21 測試)
---
@@ -504,18 +506,18 @@
| FEAT-002 | Task Management | 看板視角 (Kanban View) | 有 | 有 | 高 | ✅ 已完成 (KanbanBoard.tsx) |
| FEAT-003 | Task Management | 甘特圖視角 (Gantt View) | 有 | 有 | 中 | ✅ 已完成 (2026-01-05) |
| FEAT-004 | Task Management | 行事曆視角 (Calendar View) | 有 | 有 | 中 | ✅ 已完成 (2026-01-05) |
| FEAT-005 | Task Management | 子任務建立 UI | 有 | | 中 | 待開發 |
| FEAT-005 | Task Management | 子任務建立 UI | 有 | | 中 | ✅ 已完成 (2026-01-06) |
| FEAT-006 | Task Management | 拖拉變更狀態 | 有 | 有 | 中 | ✅ 已完成 (KanbanBoard drag-drop) |
| FEAT-007 | Resource Management | 負載熱圖 UI | 有 | 有 | 高 | ✅ 已完成 (WorkloadPage.tsx) |
| FEAT-008 | Resource Management | 專案健康看板 | 有 | 有 | 中 | ✅ 已完成 (ProjectHealthPage.tsx) |
| FEAT-009 | Resource Management | 容量更新 API | 有 | N/A | 低 | ✅ 已完成 (PUT /api/users/{id}/capacity) |
| FEAT-010 | Document Management | AES-256 加密存儲 | 有 | N/A | 高 | ✅ 已完成 (2026-01-05) |
| FEAT-011 | Document Management | 動態浮水印 | 有 | N/A | 中 | ✅ 已完成 (watermark_service.py) |
| FEAT-012 | Document Management | 版本還原 UI | 有 | | 低 | 待開發 |
| FEAT-012 | Document Management | 版本還原 UI | 有 | | 低 | ✅ 已完成 (2026-01-06) |
| FEAT-013 | Automation | 排程觸發器執行 | 有 | N/A | 中 | ✅ 已完成 (trigger_scheduler.py) |
| FEAT-014 | Automation | 更新欄位動作 | | | 低 | 待開發 |
| FEAT-015 | Automation | 自動指派動作 | | | 低 | 待開發 |
| FEAT-016 | Audit Trail | 稽核完整性驗證 UI | 有 | | 低 | 待開發 |
| FEAT-014 | Automation | 更新欄位動作 | | N/A | 低 | ✅ 已完成 (2026-01-06) |
| FEAT-015 | Automation | 自動指派動作 | | N/A | 低 | ✅ 已完成 (2026-01-06) |
| FEAT-016 | Audit Trail | 稽核完整性驗證 UI | 有 | | 低 | ✅ 已完成 (2026-01-06) |
---
@@ -526,7 +528,8 @@
- **檔案**: `frontend/src/pages/Spaces.tsx:95-101`
- **問題**: Modal 中的 input 欄位缺少關聯的 `<label>` 元素。
- **WCAG**: 1.3.1 Info and Relationships
- **狀態**: [ ] 修復
- **狀態**: [x] 修復 (2026-01-06)
- **修復方式**: 新增 visuallyHidden label 元素關聯 input 欄位,同時修復 Tasks.tsx, Projects.tsx
---
@@ -535,7 +538,8 @@
- **檔案**: `frontend/src/components/ResourceHistory.tsx:46`
- **問題**: 可點擊的 div 缺少 button role 或鍵盤處理。
- **WCAG**: 4.1.2 Name, Role, Value
- **狀態**: [ ] 修復
- **狀態**: [x] 修復 (2026-01-06)
- **修復方式**: 新增 `role="button"`, `tabIndex={0}`, `onKeyDown` 處理 Enter/Space 鍵, `aria-expanded`, `aria-label`
---
@@ -544,16 +548,18 @@
- **檔案**: `frontend/src/pages/AuditPage.tsx:16`
- **問題**: 關閉按鈕 (x) 缺少 aria-label。
- **WCAG**: 4.1.2 Name, Role, Value
- **狀態**: [ ] 修復
- **狀態**: [x] 修復 (2026-01-06)
- **修復方式**: 新增 `aria-label="Close"` 至所有 Modal 關閉按鈕
---
### A11Y-004: 顏色對比不足
- **檔案**: 多個檔案
- **問題**: 使用淺灰色文字 (#999, #666) 可能未達 WCAG AA 對比度標準。
- **問題**: 使用淺灰色文字 (#999) 未達 WCAG AA 對比度標準 (2.85:1 < 4.5:1)
- **WCAG**: 1.4.3 Contrast (Minimum)
- **狀態**: [ ] 待檢查
- **狀態**: [x] 已修復 (2026-01-06)
- **修復方式**: 將所有 `#999` 改為 `#767676` (對比度 4.54:1符合 WCAG AA)
---
@@ -562,7 +568,8 @@
- **檔案**: `frontend/src/pages/Login.tsx`
- **問題**: Input 樣式設定 `outline: none` 但無自訂焦點樣式。
- **WCAG**: 2.4.7 Focus Visible
- **狀態**: [ ] 修復
- **狀態**: [x] 修復 (2026-01-06)
- **修復方式**: 在 index.css 新增全域 `*:focus-visible` 樣式和 `.login-input:focus` 自訂樣式
---
@@ -571,7 +578,8 @@
- **檔案**: 多個 Modal 元件
- **問題**: Modal 未捕獲焦點,不支援 Escape 鍵關閉。
- **WCAG**: 2.1.2 No Keyboard Trap
- **狀態**: [ ] 修復
- **狀態**: [x] 修復 (2026-01-06)
- **修復方式**: 所有 Modal 新增 `useEffect` 監聽 document keydown 事件處理 Escape 鍵,新增 `role="dialog"`, `aria-modal="true"`, `aria-labelledby`, `tabIndex={-1}`
---
@@ -614,8 +622,8 @@
- [x] 高優先問題全部修復 (8/8 已完成)
- [x] 中優先問題全部修復 (12/12 已完成)
- [x] 核心功能實作完成
- [ ] 可訪問性問題修復
- [ ] 程式碼品質改善
- [x] 可訪問性問題修復 (6/6 已完成)
- [x] 程式碼品質改善 (LOW-002~010 已完成, LOW-001/LOW-008 待改善)
### 本次修復摘要 (2026-01-04)
@@ -645,12 +653,42 @@
| MED-011 | DOM 操作在元件外執行 | ✅ 已修復 |
| MED-012 | PDF 匯出未實作 | ✅ 已修復 |
### 本次修復摘要 (2026-01-06)
| Issue ID | 問題 | 狀態 |
|----------|------|------|
| FEAT-005 | 子任務建立 UI | ✅ 已完成 (SubtaskList.tsx) |
| FEAT-012 | 版本還原 UI | ✅ 已完成 (AttachmentVersionHistory.tsx) |
| FEAT-014 | 更新欄位動作 | ✅ 已完成 (action_executor.py UpdateFieldAction) |
| FEAT-015 | 自動指派動作 | ✅ 已完成 (action_executor.py AutoAssignAction) |
| FEAT-016 | 稽核完整性驗證 UI | ✅ 已完成 (IntegrityVerificationModal) |
| A11Y-001 | 表單缺少 Label | ✅ 已修復 (visuallyHidden labels) |
| A11Y-002 | 非語義化按鈕 | ✅ 已修復 (role, tabIndex, onKeyDown) |
| A11Y-003 | 圖示按鈕缺少 aria-label | ✅ 已修復 |
| A11Y-005 | 缺少焦點指示器 | ✅ 已修復 (global focus-visible styles) |
| A11Y-004 | 顏色對比不足 | ✅ 已修復 (#999 → #767676) |
| A11Y-006 | Modal 無焦點捕獲 | ✅ 已修復 (Escape key, ARIA attrs) |
| 機密專案 | 強制加密上傳 | ✅ 已完成 (attachments/router.py) |
### 本次修復摘要 (2026-01-07)
| Issue ID | 問題 | 狀態 |
|----------|------|------|
| LOW-002 | 分頁無最大限制 | ✅ 已修復 (Query validation with max limits) |
| LOW-003 | 狀態名稱使用魔術字串 | ✅ 已修復 (使用 TaskStatus.is_done flag) |
| LOW-004 | 觸發器類型驗證不完整 | ✅ 已修復 (新增 "creation" 類型驗證) |
| LOW-005 | 使用 any 類型 | ✅ 已修復 (使用 unknown + type guard) |
| LOW-006 | 使用原生 confirm()/alert() | ✅ 已修復 (ConfirmModal 元件 + A11Y 支援) |
| LOW-007 | 錯誤處理無使用者回饋 | ✅ 已修復 (ToastContext + 整合多頁面) |
| LOW-009 | 缺少載入骨架 | ✅ 已修復 (Skeleton 元件系列 + 全站 17 處整合) |
| LOW-010 | 缺少前端測試 | ✅ 已修復 (Vitest + 21 測試) |
### 後續待處理
| 類別 | 問題 | 備註 |
|------|------|------|
| LOW-001~010 | 低優先問題 | 程式碼品質改善 |
| A11Y-001~006 | 可訪問性問題 | WCAG 合規 |
| LOW-001 | 缺少完整類型提示 | Backend services 待改善 |
| LOW-008 | 樣式方法不一致 | 非關鍵,建議後續統一 |
### QA 驗證結果
@@ -742,4 +780,4 @@
---
*此文件由 Claude Code 自動生成於 2026-01-04*
*更新於 2026-01-05*
*更新於 2026-01-07*

1
logs/latest Symbolic link
View File

@@ -0,0 +1 @@
/Users/egg/Projects/PROJECT CONTORL/logs/run-20260107-212333

View File

@@ -0,0 +1,904 @@
INFO: Will watch for changes in these directories: ['/Users/egg/Projects/PROJECT CONTORL/backend']
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO: Started reloader process [41994] using WatchFiles
INFO: Started server process [42024]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: 127.0.0.1:61723 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:61733 - "POST /api/auth/login HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
+ Exception Group Traceback (most recent call last):
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 85, in collapse_excgroups
| yield
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 190, in __call__
| async with anyio.create_task_group() as task_group:
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/anyio/_backends/_asyncio.py", line 783, in __aexit__
| raise BaseExceptionGroup(
| ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
+-+---------------- 1 ----------------
| Traceback (most recent call last):
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
| result = await app( # type: ignore[func-returns-value]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
| return await self.app(scope, receive, send)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/applications.py", line 1054, in __call__
| await super().__call__(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/applications.py", line 123, in __call__
| await self.middleware_stack(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 186, in __call__
| raise exc
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 164, in __call__
| await self.app(scope, receive, _send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 189, in __call__
| with collapse_excgroups():
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/contextlib.py", line 158, in __exit__
| self.gen.throw(typ, value, traceback)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 91, in collapse_excgroups
| raise exc
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 191, in __call__
| response = await self.dispatch_func(request, call_next)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/Projects/PROJECT CONTORL/backend/app/middleware/audit.py", line 18, in dispatch
| response = await call_next(request)
| ^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 165, in call_next
| raise app_exc
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 151, in coro
| await self.app(scope, receive_or_disconnect, send_no_error)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 91, in __call__
| await self.simple_response(scope, receive, send, request_headers=headers)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 146, in simple_response
| await self.app(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
| await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
| raise exc
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
| await app(scope, receive, sender)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 762, in __call__
| await self.middleware_stack(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 782, in app
| await route.handle(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 297, in handle
| await self.app(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 77, in app
| await wrap_app_handling_exceptions(app, request)(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
| raise exc
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
| await app(scope, receive, sender)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 72, in app
| response = await func(request)
| ^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 299, in app
| raise e
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 294, in app
| raw_response = await run_endpoint_function(
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
| return await dependant.call(**values)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 732, in async_wrapper
| self._check_request_limit(request, func, False)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 630, in _check_request_limit
| self.__evaluate_limits(request, _endpoint_key, all_limits)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 509, in __evaluate_limits
| if not self.limiter.hit(lim.limit, *args, cost=cost):
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/strategies.py", line 157, in hit
| self.storage.incr(
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/base.py", line 25, in inner
| return fn(*args, **kwargs)
| ^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/redis.py", line 224, in incr
| return int(self.lua_incr_expire([key], [expiry, amount]))
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 5948, in __call__
| return client.evalsha(self.sha, len(keys), *args)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6165, in evalsha
| return self._evalsha("EVALSHA", sha, numkeys, *keys_and_args)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6149, in _evalsha
| return self.execute_command(command, sha, numkeys, *keys_and_args)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 657, in execute_command
| return self._execute_command(*args, **options)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 663, in _execute_command
| conn = self.connection or pool.get_connection()
| ^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/utils.py", line 196, in wrapper
| return func(*args, **kwargs)
| ^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 2603, in get_connection
| connection.connect()
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 846, in connect
| self.connect_check_health(check_health=True)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 863, in connect_check_health
| raise ConnectionError(self._error_message(e))
| redis.exceptions.ConnectionError: Error 61 connecting to localhost:6379. Connection refused.
+------------------------------------
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/applications.py", line 123, in __call__
await self.middleware_stack(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 186, in __call__
raise exc
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 189, in __call__
with collapse_excgroups():
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/contextlib.py", line 158, in __exit__
self.gen.throw(typ, value, traceback)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 91, in collapse_excgroups
raise exc
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 191, in __call__
response = await self.dispatch_func(request, call_next)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/Projects/PROJECT CONTORL/backend/app/middleware/audit.py", line 18, in dispatch
response = await call_next(request)
^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 165, in call_next
raise app_exc
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 151, in coro
await self.app(scope, receive_or_disconnect, send_no_error)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 91, in __call__
await self.simple_response(scope, receive, send, request_headers=headers)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 146, in simple_response
await self.app(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 762, in __call__
await self.middleware_stack(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 782, in app
await route.handle(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 297, in handle
await self.app(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 77, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 72, in app
response = await func(request)
^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 299, in app
raise e
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 294, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
return await dependant.call(**values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 732, in async_wrapper
self._check_request_limit(request, func, False)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 630, in _check_request_limit
self.__evaluate_limits(request, _endpoint_key, all_limits)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 509, in __evaluate_limits
if not self.limiter.hit(lim.limit, *args, cost=cost):
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/strategies.py", line 157, in hit
self.storage.incr(
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/base.py", line 25, in inner
return fn(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/redis.py", line 224, in incr
return int(self.lua_incr_expire([key], [expiry, amount]))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 5948, in __call__
return client.evalsha(self.sha, len(keys), *args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6165, in evalsha
return self._evalsha("EVALSHA", sha, numkeys, *keys_and_args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6149, in _evalsha
return self.execute_command(command, sha, numkeys, *keys_and_args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 657, in execute_command
return self._execute_command(*args, **options)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 663, in _execute_command
conn = self.connection or pool.get_connection()
^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/utils.py", line 196, in wrapper
return func(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 2603, in get_connection
connection.connect()
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 846, in connect
self.connect_check_health(check_health=True)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 863, in connect_check_health
raise ConnectionError(self._error_message(e))
redis.exceptions.ConnectionError: Error 61 connecting to localhost:6379. Connection refused.
INFO: 127.0.0.1:61737 - "POST /api/auth/login HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
+ Exception Group Traceback (most recent call last):
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 85, in collapse_excgroups
| yield
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 190, in __call__
| async with anyio.create_task_group() as task_group:
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/anyio/_backends/_asyncio.py", line 783, in __aexit__
| raise BaseExceptionGroup(
| ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
+-+---------------- 1 ----------------
| Traceback (most recent call last):
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
| result = await app( # type: ignore[func-returns-value]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
| return await self.app(scope, receive, send)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/applications.py", line 1054, in __call__
| await super().__call__(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/applications.py", line 123, in __call__
| await self.middleware_stack(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 186, in __call__
| raise exc
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 164, in __call__
| await self.app(scope, receive, _send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 189, in __call__
| with collapse_excgroups():
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/contextlib.py", line 158, in __exit__
| self.gen.throw(typ, value, traceback)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 91, in collapse_excgroups
| raise exc
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 191, in __call__
| response = await self.dispatch_func(request, call_next)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/Projects/PROJECT CONTORL/backend/app/middleware/audit.py", line 18, in dispatch
| response = await call_next(request)
| ^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 165, in call_next
| raise app_exc
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 151, in coro
| await self.app(scope, receive_or_disconnect, send_no_error)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 91, in __call__
| await self.simple_response(scope, receive, send, request_headers=headers)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 146, in simple_response
| await self.app(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
| await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
| raise exc
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
| await app(scope, receive, sender)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 762, in __call__
| await self.middleware_stack(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 782, in app
| await route.handle(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 297, in handle
| await self.app(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 77, in app
| await wrap_app_handling_exceptions(app, request)(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
| raise exc
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
| await app(scope, receive, sender)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 72, in app
| response = await func(request)
| ^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 299, in app
| raise e
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 294, in app
| raw_response = await run_endpoint_function(
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
| return await dependant.call(**values)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 732, in async_wrapper
| self._check_request_limit(request, func, False)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 630, in _check_request_limit
| self.__evaluate_limits(request, _endpoint_key, all_limits)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 509, in __evaluate_limits
| if not self.limiter.hit(lim.limit, *args, cost=cost):
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/strategies.py", line 157, in hit
| self.storage.incr(
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/base.py", line 25, in inner
| return fn(*args, **kwargs)
| ^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/redis.py", line 224, in incr
| return int(self.lua_incr_expire([key], [expiry, amount]))
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 5948, in __call__
| return client.evalsha(self.sha, len(keys), *args)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6165, in evalsha
| return self._evalsha("EVALSHA", sha, numkeys, *keys_and_args)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6149, in _evalsha
| return self.execute_command(command, sha, numkeys, *keys_and_args)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 657, in execute_command
| return self._execute_command(*args, **options)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 663, in _execute_command
| conn = self.connection or pool.get_connection()
| ^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/utils.py", line 196, in wrapper
| return func(*args, **kwargs)
| ^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 2603, in get_connection
| connection.connect()
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 846, in connect
| self.connect_check_health(check_health=True)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 863, in connect_check_health
| raise ConnectionError(self._error_message(e))
| redis.exceptions.ConnectionError: Error 61 connecting to localhost:6379. Connection refused.
+------------------------------------
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/applications.py", line 123, in __call__
await self.middleware_stack(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 186, in __call__
raise exc
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 189, in __call__
with collapse_excgroups():
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/contextlib.py", line 158, in __exit__
self.gen.throw(typ, value, traceback)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 91, in collapse_excgroups
raise exc
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 191, in __call__
response = await self.dispatch_func(request, call_next)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/Projects/PROJECT CONTORL/backend/app/middleware/audit.py", line 18, in dispatch
response = await call_next(request)
^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 165, in call_next
raise app_exc
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 151, in coro
await self.app(scope, receive_or_disconnect, send_no_error)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 91, in __call__
await self.simple_response(scope, receive, send, request_headers=headers)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 146, in simple_response
await self.app(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 762, in __call__
await self.middleware_stack(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 782, in app
await route.handle(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 297, in handle
await self.app(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 77, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 72, in app
response = await func(request)
^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 299, in app
raise e
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 294, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
return await dependant.call(**values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 732, in async_wrapper
self._check_request_limit(request, func, False)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 630, in _check_request_limit
self.__evaluate_limits(request, _endpoint_key, all_limits)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 509, in __evaluate_limits
if not self.limiter.hit(lim.limit, *args, cost=cost):
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/strategies.py", line 157, in hit
self.storage.incr(
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/base.py", line 25, in inner
return fn(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/redis.py", line 224, in incr
return int(self.lua_incr_expire([key], [expiry, amount]))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 5948, in __call__
return client.evalsha(self.sha, len(keys), *args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6165, in evalsha
return self._evalsha("EVALSHA", sha, numkeys, *keys_and_args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6149, in _evalsha
return self.execute_command(command, sha, numkeys, *keys_and_args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 657, in execute_command
return self._execute_command(*args, **options)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 663, in _execute_command
conn = self.connection or pool.get_connection()
^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/utils.py", line 196, in wrapper
return func(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 2603, in get_connection
connection.connect()
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 846, in connect
self.connect_check_health(check_health=True)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 863, in connect_check_health
raise ConnectionError(self._error_message(e))
redis.exceptions.ConnectionError: Error 61 connecting to localhost:6379. Connection refused.
INFO: 127.0.0.1:61759 - "POST /api/auth/login HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
+ Exception Group Traceback (most recent call last):
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 85, in collapse_excgroups
| yield
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 190, in __call__
| async with anyio.create_task_group() as task_group:
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/anyio/_backends/_asyncio.py", line 783, in __aexit__
| raise BaseExceptionGroup(
| ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
+-+---------------- 1 ----------------
| Traceback (most recent call last):
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
| result = await app( # type: ignore[func-returns-value]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
| return await self.app(scope, receive, send)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/applications.py", line 1054, in __call__
| await super().__call__(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/applications.py", line 123, in __call__
| await self.middleware_stack(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 186, in __call__
| raise exc
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 164, in __call__
| await self.app(scope, receive, _send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 189, in __call__
| with collapse_excgroups():
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/contextlib.py", line 158, in __exit__
| self.gen.throw(typ, value, traceback)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 91, in collapse_excgroups
| raise exc
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 191, in __call__
| response = await self.dispatch_func(request, call_next)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/Projects/PROJECT CONTORL/backend/app/middleware/audit.py", line 18, in dispatch
| response = await call_next(request)
| ^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 165, in call_next
| raise app_exc
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 151, in coro
| await self.app(scope, receive_or_disconnect, send_no_error)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 91, in __call__
| await self.simple_response(scope, receive, send, request_headers=headers)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 146, in simple_response
| await self.app(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
| await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
| raise exc
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
| await app(scope, receive, sender)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 762, in __call__
| await self.middleware_stack(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 782, in app
| await route.handle(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 297, in handle
| await self.app(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 77, in app
| await wrap_app_handling_exceptions(app, request)(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
| raise exc
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
| await app(scope, receive, sender)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 72, in app
| response = await func(request)
| ^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 299, in app
| raise e
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 294, in app
| raw_response = await run_endpoint_function(
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
| return await dependant.call(**values)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 732, in async_wrapper
| self._check_request_limit(request, func, False)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 630, in _check_request_limit
| self.__evaluate_limits(request, _endpoint_key, all_limits)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 509, in __evaluate_limits
| if not self.limiter.hit(lim.limit, *args, cost=cost):
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/strategies.py", line 157, in hit
| self.storage.incr(
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/base.py", line 25, in inner
| return fn(*args, **kwargs)
| ^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/redis.py", line 224, in incr
| return int(self.lua_incr_expire([key], [expiry, amount]))
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 5948, in __call__
| return client.evalsha(self.sha, len(keys), *args)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6165, in evalsha
| return self._evalsha("EVALSHA", sha, numkeys, *keys_and_args)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6149, in _evalsha
| return self.execute_command(command, sha, numkeys, *keys_and_args)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 657, in execute_command
| return self._execute_command(*args, **options)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 663, in _execute_command
| conn = self.connection or pool.get_connection()
| ^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/utils.py", line 196, in wrapper
| return func(*args, **kwargs)
| ^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 2603, in get_connection
| connection.connect()
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 846, in connect
| self.connect_check_health(check_health=True)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 863, in connect_check_health
| raise ConnectionError(self._error_message(e))
| redis.exceptions.ConnectionError: Error 61 connecting to localhost:6379. Connection refused.
+------------------------------------
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/applications.py", line 123, in __call__
await self.middleware_stack(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 186, in __call__
raise exc
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 189, in __call__
with collapse_excgroups():
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/contextlib.py", line 158, in __exit__
self.gen.throw(typ, value, traceback)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 91, in collapse_excgroups
raise exc
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 191, in __call__
response = await self.dispatch_func(request, call_next)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/Projects/PROJECT CONTORL/backend/app/middleware/audit.py", line 18, in dispatch
response = await call_next(request)
^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 165, in call_next
raise app_exc
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 151, in coro
await self.app(scope, receive_or_disconnect, send_no_error)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 91, in __call__
await self.simple_response(scope, receive, send, request_headers=headers)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 146, in simple_response
await self.app(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 762, in __call__
await self.middleware_stack(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 782, in app
await route.handle(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 297, in handle
await self.app(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 77, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 72, in app
response = await func(request)
^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 299, in app
raise e
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 294, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
return await dependant.call(**values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 732, in async_wrapper
self._check_request_limit(request, func, False)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 630, in _check_request_limit
self.__evaluate_limits(request, _endpoint_key, all_limits)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 509, in __evaluate_limits
if not self.limiter.hit(lim.limit, *args, cost=cost):
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/strategies.py", line 157, in hit
self.storage.incr(
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/base.py", line 25, in inner
return fn(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/redis.py", line 224, in incr
return int(self.lua_incr_expire([key], [expiry, amount]))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 5948, in __call__
return client.evalsha(self.sha, len(keys), *args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6165, in evalsha
return self._evalsha("EVALSHA", sha, numkeys, *keys_and_args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6149, in _evalsha
return self.execute_command(command, sha, numkeys, *keys_and_args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 657, in execute_command
return self._execute_command(*args, **options)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 663, in _execute_command
conn = self.connection or pool.get_connection()
^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/utils.py", line 196, in wrapper
return func(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 2603, in get_connection
connection.connect()
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 846, in connect
self.connect_check_health(check_health=True)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 863, in connect_check_health
raise ConnectionError(self._error_message(e))
redis.exceptions.ConnectionError: Error 61 connecting to localhost:6379. Connection refused.
INFO: 127.0.0.1:61763 - "POST /api/auth/login HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
+ Exception Group Traceback (most recent call last):
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 85, in collapse_excgroups
| yield
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 190, in __call__
| async with anyio.create_task_group() as task_group:
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/anyio/_backends/_asyncio.py", line 783, in __aexit__
| raise BaseExceptionGroup(
| ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
+-+---------------- 1 ----------------
| Traceback (most recent call last):
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
| result = await app( # type: ignore[func-returns-value]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
| return await self.app(scope, receive, send)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/applications.py", line 1054, in __call__
| await super().__call__(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/applications.py", line 123, in __call__
| await self.middleware_stack(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 186, in __call__
| raise exc
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 164, in __call__
| await self.app(scope, receive, _send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 189, in __call__
| with collapse_excgroups():
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/contextlib.py", line 158, in __exit__
| self.gen.throw(typ, value, traceback)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 91, in collapse_excgroups
| raise exc
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 191, in __call__
| response = await self.dispatch_func(request, call_next)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/Projects/PROJECT CONTORL/backend/app/middleware/audit.py", line 18, in dispatch
| response = await call_next(request)
| ^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 165, in call_next
| raise app_exc
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 151, in coro
| await self.app(scope, receive_or_disconnect, send_no_error)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 91, in __call__
| await self.simple_response(scope, receive, send, request_headers=headers)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 146, in simple_response
| await self.app(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
| await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
| raise exc
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
| await app(scope, receive, sender)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 762, in __call__
| await self.middleware_stack(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 782, in app
| await route.handle(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 297, in handle
| await self.app(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 77, in app
| await wrap_app_handling_exceptions(app, request)(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
| raise exc
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
| await app(scope, receive, sender)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 72, in app
| response = await func(request)
| ^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 299, in app
| raise e
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 294, in app
| raw_response = await run_endpoint_function(
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
| return await dependant.call(**values)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 732, in async_wrapper
| self._check_request_limit(request, func, False)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 630, in _check_request_limit
| self.__evaluate_limits(request, _endpoint_key, all_limits)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 509, in __evaluate_limits
| if not self.limiter.hit(lim.limit, *args, cost=cost):
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/strategies.py", line 157, in hit
| self.storage.incr(
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/base.py", line 25, in inner
| return fn(*args, **kwargs)
| ^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/redis.py", line 224, in incr
| return int(self.lua_incr_expire([key], [expiry, amount]))
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 5948, in __call__
| return client.evalsha(self.sha, len(keys), *args)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6165, in evalsha
| return self._evalsha("EVALSHA", sha, numkeys, *keys_and_args)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6149, in _evalsha
| return self.execute_command(command, sha, numkeys, *keys_and_args)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 657, in execute_command
| return self._execute_command(*args, **options)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 663, in _execute_command
| conn = self.connection or pool.get_connection()
| ^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/utils.py", line 196, in wrapper
| return func(*args, **kwargs)
| ^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 2603, in get_connection
| connection.connect()
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 846, in connect
| self.connect_check_health(check_health=True)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 863, in connect_check_health
| raise ConnectionError(self._error_message(e))
| redis.exceptions.ConnectionError: Error 61 connecting to localhost:6379. Connection refused.
+------------------------------------
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/applications.py", line 123, in __call__
await self.middleware_stack(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 186, in __call__
raise exc
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 189, in __call__
with collapse_excgroups():
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/contextlib.py", line 158, in __exit__
self.gen.throw(typ, value, traceback)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 91, in collapse_excgroups
raise exc
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 191, in __call__
response = await self.dispatch_func(request, call_next)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/Projects/PROJECT CONTORL/backend/app/middleware/audit.py", line 18, in dispatch
response = await call_next(request)
^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 165, in call_next
raise app_exc
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 151, in coro
await self.app(scope, receive_or_disconnect, send_no_error)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 91, in __call__
await self.simple_response(scope, receive, send, request_headers=headers)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 146, in simple_response
await self.app(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 762, in __call__
await self.middleware_stack(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 782, in app
await route.handle(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 297, in handle
await self.app(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 77, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 72, in app
response = await func(request)
^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 299, in app
raise e
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 294, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
return await dependant.call(**values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 732, in async_wrapper
self._check_request_limit(request, func, False)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 630, in _check_request_limit
self.__evaluate_limits(request, _endpoint_key, all_limits)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 509, in __evaluate_limits
if not self.limiter.hit(lim.limit, *args, cost=cost):
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/strategies.py", line 157, in hit
self.storage.incr(
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/base.py", line 25, in inner
return fn(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/redis.py", line 224, in incr
return int(self.lua_incr_expire([key], [expiry, amount]))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 5948, in __call__
return client.evalsha(self.sha, len(keys), *args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6165, in evalsha
return self._evalsha("EVALSHA", sha, numkeys, *keys_and_args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6149, in _evalsha
return self.execute_command(command, sha, numkeys, *keys_and_args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 657, in execute_command
return self._execute_command(*args, **options)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 663, in _execute_command
conn = self.connection or pool.get_connection()
^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/utils.py", line 196, in wrapper
return func(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 2603, in get_connection
connection.connect()
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 846, in connect
self.connect_check_health(check_health=True)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 863, in connect_check_health
raise ConnectionError(self._error_message(e))
redis.exceptions.ConnectionError: Error 61 connecting to localhost:6379. Connection refused.
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [42024]
INFO: Stopping reloader process [41994]

View File

@@ -0,0 +1,18 @@
Timestamp: 2026-01-06T15:49:37Z
Python: Python 3.11.14
Python Path: /Users/egg/miniconda3/envs/pjctrl/bin/python
Node: v22.19.0
NPM: 11.6.2
Env file: /Users/egg/Projects/PROJECT CONTORL/backend/.env
OK: MYSQL_HOST
OK: MYSQL_PORT
OK: MYSQL_USER
OK: MYSQL_PASSWORD
OK: MYSQL_DATABASE
OK: REDIS_HOST
OK: REDIS_PORT
OK: JWT_SECRET_KEY
OK: AUTH_API_URL
OK: SYSTEM_ADMIN_EMAIL
WARN: ENCRYPTION_MASTER_KEY not set
MySQL: Port reachable (mysql.theaken.com:33306)

View File

@@ -0,0 +1,8 @@
> pjctrl-frontend@0.1.0 dev
> vite --host 127.0.0.1 --port 5173
VITE v5.4.21 ready in 117 ms
➜ Local: http://127.0.0.1:5173/

View File

@@ -0,0 +1,2 @@
2026-01-06 23:49:39 OK backend http://127.0.0.1:8000/health
2026-01-06 23:49:39 OK frontend http://127.0.0.1:5173/

View File

@@ -0,0 +1,10 @@
[23:49:36] Run directory: /Users/egg/Projects/PROJECT CONTORL/logs/run-20260106-234936
[23:49:37] WARN: Optional env key not set: ENCRYPTION_MASTER_KEY
[23:49:38] WARN: Redis not reachable at localhost:6379
[23:49:38] Starting backend on 127.0.0.1:8000 (python: /Users/egg/miniconda3/envs/pjctrl/bin/python)...
[23:49:38] Starting frontend on 127.0.0.1:5173...
[23:49:38] Running health checks...
[23:49:39] Logs: /Users/egg/Projects/PROJECT CONTORL/logs/run-20260106-234936
[23:49:39] Backend log: /Users/egg/Projects/PROJECT CONTORL/logs/run-20260106-234936/backend.log
[23:49:39] Frontend log: /Users/egg/Projects/PROJECT CONTORL/logs/run-20260106-234936/frontend.log
[23:49:39] Health log: /Users/egg/Projects/PROJECT CONTORL/logs/run-20260106-234936/health.log

View File

@@ -0,0 +1,230 @@
INFO: Will watch for changes in these directories: ['/Users/egg/Projects/PROJECT CONTORL/backend']
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO: Started reloader process [46401] using WatchFiles
INFO: Started server process [46413]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: 127.0.0.1:61358 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:61371 - "POST /api/auth/login HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
+ Exception Group Traceback (most recent call last):
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 85, in collapse_excgroups
| yield
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 190, in __call__
| async with anyio.create_task_group() as task_group:
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/anyio/_backends/_asyncio.py", line 783, in __aexit__
| raise BaseExceptionGroup(
| ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
+-+---------------- 1 ----------------
| Traceback (most recent call last):
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
| result = await app( # type: ignore[func-returns-value]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
| return await self.app(scope, receive, send)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/applications.py", line 1054, in __call__
| await super().__call__(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/applications.py", line 123, in __call__
| await self.middleware_stack(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 186, in __call__
| raise exc
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 164, in __call__
| await self.app(scope, receive, _send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 189, in __call__
| with collapse_excgroups():
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/contextlib.py", line 158, in __exit__
| self.gen.throw(typ, value, traceback)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 91, in collapse_excgroups
| raise exc
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 191, in __call__
| response = await self.dispatch_func(request, call_next)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/Projects/PROJECT CONTORL/backend/app/middleware/audit.py", line 18, in dispatch
| response = await call_next(request)
| ^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 165, in call_next
| raise app_exc
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 151, in coro
| await self.app(scope, receive_or_disconnect, send_no_error)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 91, in __call__
| await self.simple_response(scope, receive, send, request_headers=headers)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 146, in simple_response
| await self.app(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
| await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
| raise exc
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
| await app(scope, receive, sender)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 762, in __call__
| await self.middleware_stack(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 782, in app
| await route.handle(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 297, in handle
| await self.app(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 77, in app
| await wrap_app_handling_exceptions(app, request)(scope, receive, send)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
| raise exc
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
| await app(scope, receive, sender)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 72, in app
| response = await func(request)
| ^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 299, in app
| raise e
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 294, in app
| raw_response = await run_endpoint_function(
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
| return await dependant.call(**values)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 732, in async_wrapper
| self._check_request_limit(request, func, False)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 630, in _check_request_limit
| self.__evaluate_limits(request, _endpoint_key, all_limits)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 509, in __evaluate_limits
| if not self.limiter.hit(lim.limit, *args, cost=cost):
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/strategies.py", line 157, in hit
| self.storage.incr(
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/base.py", line 25, in inner
| return fn(*args, **kwargs)
| ^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/redis.py", line 224, in incr
| return int(self.lua_incr_expire([key], [expiry, amount]))
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 5948, in __call__
| return client.evalsha(self.sha, len(keys), *args)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6165, in evalsha
| return self._evalsha("EVALSHA", sha, numkeys, *keys_and_args)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6149, in _evalsha
| return self.execute_command(command, sha, numkeys, *keys_and_args)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 657, in execute_command
| return self._execute_command(*args, **options)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 663, in _execute_command
| conn = self.connection or pool.get_connection()
| ^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/utils.py", line 196, in wrapper
| return func(*args, **kwargs)
| ^^^^^^^^^^^^^^^^^^^^^
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 2603, in get_connection
| connection.connect()
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 846, in connect
| self.connect_check_health(check_health=True)
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 863, in connect_check_health
| raise ConnectionError(self._error_message(e))
| redis.exceptions.ConnectionError: Error 61 connecting to localhost:6379. Connection refused.
+------------------------------------
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/applications.py", line 123, in __call__
await self.middleware_stack(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 186, in __call__
raise exc
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 189, in __call__
with collapse_excgroups():
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/contextlib.py", line 158, in __exit__
self.gen.throw(typ, value, traceback)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 91, in collapse_excgroups
raise exc
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 191, in __call__
response = await self.dispatch_func(request, call_next)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/Projects/PROJECT CONTORL/backend/app/middleware/audit.py", line 18, in dispatch
response = await call_next(request)
^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 165, in call_next
raise app_exc
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 151, in coro
await self.app(scope, receive_or_disconnect, send_no_error)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 91, in __call__
await self.simple_response(scope, receive, send, request_headers=headers)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 146, in simple_response
await self.app(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 762, in __call__
await self.middleware_stack(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 782, in app
await route.handle(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 297, in handle
await self.app(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 77, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
raise exc
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 72, in app
response = await func(request)
^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 299, in app
raise e
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 294, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
return await dependant.call(**values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 732, in async_wrapper
self._check_request_limit(request, func, False)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 630, in _check_request_limit
self.__evaluate_limits(request, _endpoint_key, all_limits)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 509, in __evaluate_limits
if not self.limiter.hit(lim.limit, *args, cost=cost):
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/strategies.py", line 157, in hit
self.storage.incr(
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/base.py", line 25, in inner
return fn(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/redis.py", line 224, in incr
return int(self.lua_incr_expire([key], [expiry, amount]))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 5948, in __call__
return client.evalsha(self.sha, len(keys), *args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6165, in evalsha
return self._evalsha("EVALSHA", sha, numkeys, *keys_and_args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6149, in _evalsha
return self.execute_command(command, sha, numkeys, *keys_and_args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 657, in execute_command
return self._execute_command(*args, **options)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 663, in _execute_command
conn = self.connection or pool.get_connection()
^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/utils.py", line 196, in wrapper
return func(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 2603, in get_connection
connection.connect()
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 846, in connect
self.connect_check_health(check_health=True)
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 863, in connect_check_health
raise ConnectionError(self._error_message(e))
redis.exceptions.ConnectionError: Error 61 connecting to localhost:6379. Connection refused.

View File

@@ -0,0 +1 @@
46401

View File

@@ -0,0 +1,18 @@
Timestamp: 2026-01-07T13:23:35Z
Python: Python 3.11.14
Python Path: /Users/egg/miniconda3/envs/pjctrl/bin/python
Node: v22.19.0
NPM: 11.6.2
Env file: /Users/egg/Projects/PROJECT CONTORL/backend/.env
OK: MYSQL_HOST
OK: MYSQL_PORT
OK: MYSQL_USER
OK: MYSQL_PASSWORD
OK: MYSQL_DATABASE
OK: REDIS_HOST
OK: REDIS_PORT
OK: JWT_SECRET_KEY
OK: AUTH_API_URL
OK: SYSTEM_ADMIN_EMAIL
WARN: ENCRYPTION_MASTER_KEY not set
MySQL: Port reachable (mysql.theaken.com:33306)

View File

@@ -0,0 +1,9 @@
> pjctrl-frontend@0.1.0 dev
> vite --host 127.0.0.1 --port 5173
Re-optimizing dependencies because lockfile has changed
VITE v5.4.21 ready in 100 ms
➜ Local: http://127.0.0.1:5173/

View File

@@ -0,0 +1 @@
46406

View File

@@ -0,0 +1,2 @@
2026-01-07 21:23:37 OK backend http://127.0.0.1:8000/health
2026-01-07 21:23:37 OK frontend http://127.0.0.1:5173/

View File

@@ -0,0 +1,10 @@
[21:23:33] Run directory: /Users/egg/Projects/PROJECT CONTORL/logs/run-20260107-212333
[21:23:35] WARN: Optional env key not set: ENCRYPTION_MASTER_KEY
[21:23:35] WARN: Redis not reachable at localhost:6379
[21:23:36] Starting backend on 127.0.0.1:8000 (python: /Users/egg/miniconda3/envs/pjctrl/bin/python)...
[21:23:36] Starting frontend on 127.0.0.1:5173...
[21:23:36] Running health checks...
[21:23:37] Logs: /Users/egg/Projects/PROJECT CONTORL/logs/run-20260107-212333
[21:23:37] Backend log: /Users/egg/Projects/PROJECT CONTORL/logs/run-20260107-212333/backend.log
[21:23:37] Frontend log: /Users/egg/Projects/PROJECT CONTORL/logs/run-20260107-212333/frontend.log
[21:23:37] Health log: /Users/egg/Projects/PROJECT CONTORL/logs/run-20260107-212333/health.log

461
projectctl.sh Executable file
View File

@@ -0,0 +1,461 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BACKEND_DIR="$ROOT_DIR/backend"
FRONTEND_DIR="$ROOT_DIR/frontend"
LOG_ROOT="$ROOT_DIR/logs"
RUN_ID="$(date +"%Y%m%d-%H%M%S")"
RUN_DIR="$LOG_ROOT/run-$RUN_ID"
LATEST_LINK="$LOG_ROOT/latest"
BACKEND_HOST="${BACKEND_HOST:-127.0.0.1}"
BACKEND_PORT="${BACKEND_PORT:-8000}"
FRONTEND_HOST="${FRONTEND_HOST:-127.0.0.1}"
FRONTEND_PORT="${FRONTEND_PORT:-5173}"
BACKEND_RELOAD="${BACKEND_RELOAD:-1}"
AUTO_INSTALL="${AUTO_INSTALL:-0}"
FOLLOW_LOGS="${FOLLOW_LOGS:-0}"
CONDA_ENV="${CONDA_ENV:-pjctrl}"
# Python path detection (prefer conda env)
PYTHON_BIN=""
detect_python() {
# Check for conda environment
local conda_python
if command -v conda >/dev/null 2>&1; then
conda_python="$(conda run -n "$CONDA_ENV" which python 2>/dev/null || true)"
if [[ -n "$conda_python" && -x "$conda_python" ]]; then
PYTHON_BIN="$conda_python"
return
fi
fi
# Fallback to system python3
if command -v python3 >/dev/null 2>&1; then
PYTHON_BIN="python3"
return
fi
log "ERROR: No Python interpreter found"
exit 1
}
ENV_LOG=""
RUN_LOG=""
HEALTH_LOG=""
usage() {
cat <<'EOF'
Usage: ./projectctl.sh [start|stop|status|health]
Commands:
start Check environment, start backend/frontend, run health checks
stop Stop backend/frontend based on latest pid files
status Show current process status from latest pid files
health Run health checks against running services
Environment variables:
BACKEND_HOST (default: 127.0.0.1)
BACKEND_PORT (default: 8000)
FRONTEND_HOST (default: 127.0.0.1)
FRONTEND_PORT (default: 5173)
BACKEND_RELOAD (default: 1) use 0 to disable --reload
AUTO_INSTALL (default: 0) set 1 to auto npm install if missing
FOLLOW_LOGS (default: 0) set 1 to tail logs after start
CONDA_ENV (default: pjctrl) conda environment name for backend
EOF
}
log() {
local msg="$*"
local ts
ts="$(date +"%H:%M:%S")"
echo "[$ts] $msg" | tee -a "$RUN_LOG"
}
env_log() {
echo "$*" >> "$ENV_LOG"
}
health_log() {
echo "$*" | tee -a "$HEALTH_LOG"
}
ensure_dirs() {
mkdir -p "$RUN_DIR"
mkdir -p "$LOG_ROOT"
ln -sfn "$RUN_DIR" "$LATEST_LINK"
ENV_LOG="$RUN_DIR/env.log"
RUN_LOG="$RUN_DIR/run.log"
HEALTH_LOG="$RUN_DIR/health.log"
}
require_cmds() {
local missing=()
for cmd in "$@"; do
if ! command -v "$cmd" >/dev/null 2>&1; then
missing+=("$cmd")
fi
done
if [[ ${#missing[@]} -gt 0 ]]; then
log "ERROR: Missing required commands: ${missing[*]}"
exit 1
fi
}
get_env_value() {
local key="$1"
local file="$2"
local line
line="$(grep -E "^${key}=" "$file" | tail -n1 || true)"
if [[ -z "$line" ]]; then
echo ""
return
fi
local val="${line#*=}"
val="$(printf '%s' "$val" | sed -e 's/#.*$//' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
echo "$val"
}
check_env_file() {
local env_file="$BACKEND_DIR/.env"
if [[ ! -f "$env_file" ]]; then
log "WARN: backend/.env not found. Copy backend/.env.example and set values."
return 1
fi
local required_keys=(
MYSQL_HOST
MYSQL_PORT
MYSQL_USER
MYSQL_PASSWORD
MYSQL_DATABASE
REDIS_HOST
REDIS_PORT
JWT_SECRET_KEY
AUTH_API_URL
SYSTEM_ADMIN_EMAIL
)
local optional_keys=(
ENCRYPTION_MASTER_KEY
)
env_log "Env file: $env_file"
for key in "${required_keys[@]}"; do
local val
val="$(get_env_value "$key" "$env_file")"
if [[ -z "$val" ]]; then
log "ERROR: Missing or empty env key: $key"
env_log "MISSING: $key"
return 1
fi
env_log "OK: $key"
done
for key in "${optional_keys[@]}"; do
local val
val="$(get_env_value "$key" "$env_file")"
if [[ -z "$val" ]]; then
log "WARN: Optional env key not set: $key"
env_log "WARN: $key not set"
else
env_log "OK: $key"
fi
done
return 0
}
check_python_deps() {
"$PYTHON_BIN" - <<'PY'
import importlib
import sys
required = ["fastapi", "uvicorn", "sqlalchemy", "redis", "pydantic", "alembic"]
missing = []
for mod in required:
try:
importlib.import_module(mod)
except Exception:
missing.append(mod)
if missing:
sys.stderr.write("Missing python packages: " + ", ".join(missing) + "\n")
sys.exit(1)
PY
}
check_redis() {
local env_file="$BACKEND_DIR/.env"
local redis_host redis_port
redis_host="$(get_env_value "REDIS_HOST" "$env_file")"
redis_port="$(get_env_value "REDIS_PORT" "$env_file")"
redis_host="${redis_host:-localhost}"
redis_port="${redis_port:-6379}"
if command -v redis-cli >/dev/null 2>&1; then
if ! redis-cli -h "$redis_host" -p "$redis_port" ping >/dev/null 2>&1; then
log "WARN: Redis not reachable at $redis_host:$redis_port"
return 1
fi
env_log "Redis: OK ($redis_host:$redis_port)"
else
# Try nc as fallback
if command -v nc >/dev/null 2>&1; then
if ! nc -z "$redis_host" "$redis_port" >/dev/null 2>&1; then
log "WARN: Redis port not open at $redis_host:$redis_port"
return 1
fi
env_log "Redis: Port reachable ($redis_host:$redis_port)"
else
log "WARN: Cannot verify Redis (no redis-cli or nc)"
fi
fi
return 0
}
check_mysql() {
local env_file="$BACKEND_DIR/.env"
local mysql_host mysql_port
mysql_host="$(get_env_value "MYSQL_HOST" "$env_file")"
mysql_port="$(get_env_value "MYSQL_PORT" "$env_file")"
mysql_host="${mysql_host:-localhost}"
mysql_port="${mysql_port:-3306}"
# Just check if port is reachable (don't require mysql client)
if command -v nc >/dev/null 2>&1; then
if ! nc -z -w 3 "$mysql_host" "$mysql_port" >/dev/null 2>&1; then
log "WARN: MySQL port not reachable at $mysql_host:$mysql_port"
return 1
fi
env_log "MySQL: Port reachable ($mysql_host:$mysql_port)"
else
log "WARN: Cannot verify MySQL connectivity (no nc command)"
fi
return 0
}
check_node_deps() {
if [[ ! -d "$FRONTEND_DIR/node_modules" ]]; then
if [[ "$AUTO_INSTALL" == "1" ]]; then
log "node_modules missing; running npm install..."
(cd "$FRONTEND_DIR" && npm install)
else
log "ERROR: frontend/node_modules missing. Run npm install or set AUTO_INSTALL=1."
exit 1
fi
fi
}
port_in_use() {
local port="$1"
if command -v lsof >/dev/null 2>&1; then
lsof -iTCP:"$port" -sTCP:LISTEN -n -P >/dev/null 2>&1
return $?
fi
if command -v nc >/dev/null 2>&1; then
nc -z 127.0.0.1 "$port" >/dev/null 2>&1
return $?
fi
return 1
}
check_ports() {
if port_in_use "$BACKEND_PORT"; then
log "ERROR: Port $BACKEND_PORT is already in use."
exit 1
fi
if port_in_use "$FRONTEND_PORT"; then
log "ERROR: Port $FRONTEND_PORT is already in use."
exit 1
fi
}
start_backend() {
local reload_args=()
if [[ "$BACKEND_RELOAD" == "1" ]]; then
reload_args=("--reload")
fi
log "Starting backend on $BACKEND_HOST:$BACKEND_PORT (python: $PYTHON_BIN)..."
(
cd "$BACKEND_DIR"
nohup env PYTHONPATH="$BACKEND_DIR" \
"$PYTHON_BIN" -m uvicorn app.main:app \
--host "$BACKEND_HOST" \
--port "$BACKEND_PORT" \
"${reload_args[@]+"${reload_args[@]}"}" \
> "$RUN_DIR/backend.log" 2>&1 &
echo $! > "$RUN_DIR/backend.pid"
)
}
start_frontend() {
log "Starting frontend on $FRONTEND_HOST:$FRONTEND_PORT..."
(
cd "$FRONTEND_DIR"
nohup npm run dev -- --host "$FRONTEND_HOST" --port "$FRONTEND_PORT" \
> "$RUN_DIR/frontend.log" 2>&1 &
echo $! > "$RUN_DIR/frontend.pid"
)
}
wait_for_url() {
local url="$1"
local name="$2"
local retries="${3:-30}"
local delay="${4:-1}"
local i=0
while (( i < retries )); do
if curl -fsS "$url" >/dev/null 2>&1; then
health_log "$(date +"%Y-%m-%d %H:%M:%S") OK $name $url"
return 0
fi
sleep "$delay"
i=$((i + 1))
done
health_log "$(date +"%Y-%m-%d %H:%M:%S") FAIL $name $url"
return 1
}
run_health_checks() {
log "Running health checks..."
local backend_url="http://$BACKEND_HOST:$BACKEND_PORT/health"
local frontend_url="http://$FRONTEND_HOST:$FRONTEND_PORT/"
wait_for_url "$backend_url" "backend" 40 1 || log "WARN: Backend health check failed."
wait_for_url "$frontend_url" "frontend" 40 1 || log "WARN: Frontend health check failed."
}
latest_run_dir() {
if [[ -L "$LATEST_LINK" ]]; then
readlink "$LATEST_LINK"
return
fi
ls -dt "$LOG_ROOT"/run-* 2>/dev/null | head -n1 || true
}
stop_service() {
local name="$1"
local pidfile="$2"
if [[ ! -f "$pidfile" ]]; then
echo "$name: pid file not found."
return
fi
local pid
pid="$(cat "$pidfile")"
if kill -0 "$pid" >/dev/null 2>&1; then
echo "Stopping $name (pid $pid)..."
# Kill child processes first (for uvicorn workers, vite children, etc.)
if command -v pgrep >/dev/null 2>&1; then
local children
children="$(pgrep -P "$pid" 2>/dev/null || true)"
if [[ -n "$children" ]]; then
echo "$children" | xargs kill 2>/dev/null || true
fi
fi
kill -TERM "$pid" 2>/dev/null || true
# Wait briefly for graceful shutdown
sleep 1
# Force kill if still running
kill -0 "$pid" 2>/dev/null && kill -KILL "$pid" 2>/dev/null || true
else
echo "$name: process not running."
fi
rm -f "$pidfile"
}
status_service() {
local name="$1"
local pidfile="$2"
if [[ ! -f "$pidfile" ]]; then
echo "$name: pid file not found."
return
fi
local pid
pid="$(cat "$pidfile")"
if kill -0 "$pid" >/dev/null 2>&1; then
echo "$name: running (pid $pid)"
else
echo "$name: not running"
fi
}
start() {
ensure_dirs
log "Run directory: $RUN_DIR"
require_cmds node npm curl
detect_python
env_log "Timestamp: $(date -u +"%Y-%m-%dT%H:%M:%SZ")"
env_log "Python: $("$PYTHON_BIN" --version 2>&1)"
env_log "Python Path: $PYTHON_BIN"
env_log "Node: $(node --version 2>&1)"
env_log "NPM: $(npm --version 2>&1)"
check_env_file
check_python_deps
check_node_deps
check_redis || true # Warn but don't fail
check_mysql || true # Warn but don't fail
check_ports
start_backend
start_frontend
run_health_checks
log "Logs: $RUN_DIR"
log "Backend log: $RUN_DIR/backend.log"
log "Frontend log: $RUN_DIR/frontend.log"
log "Health log: $RUN_DIR/health.log"
if [[ "$FOLLOW_LOGS" == "1" ]]; then
log "Tailing logs. Press Ctrl+C to stop tailing."
tail -n 200 -f "$RUN_DIR/backend.log" "$RUN_DIR/frontend.log"
fi
}
stop() {
local dir
dir="$(latest_run_dir)"
if [[ -z "$dir" ]]; then
echo "No run directory found."
return
fi
stop_service "backend" "$dir/backend.pid"
stop_service "frontend" "$dir/frontend.pid"
}
status() {
local dir
dir="$(latest_run_dir)"
if [[ -z "$dir" ]]; then
echo "No run directory found."
return
fi
status_service "backend" "$dir/backend.pid"
status_service "frontend" "$dir/frontend.pid"
}
health() {
local dir
dir="$(latest_run_dir)"
if [[ -z "$dir" ]]; then
echo "No run directory found."
return
fi
RUN_DIR="$dir"
RUN_LOG="$dir/run.log"
HEALTH_LOG="$dir/health.log"
run_health_checks
}
case "${1:-start}" in
start) start ;;
stop) stop ;;
status) status ;;
health) health ;;
-h|--help) usage ;;
*) usage; exit 1 ;;
esac