Fix test failures and workload/websocket behavior
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -21,3 +21,11 @@ dump.rdb
|
||||
# Logs
|
||||
logs/
|
||||
.playwright-mcp/
|
||||
.mcp.json
|
||||
|
||||
# Playwright
|
||||
frontend/e2e/.auth/
|
||||
frontend/test-results/
|
||||
frontend/playwright-report/
|
||||
.mcp.json
|
||||
.mcp.json
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest"
|
||||
],
|
||||
"env": {}
|
||||
},
|
||||
"vscode-lsp": {
|
||||
"type": "stdio",
|
||||
"command": "./mcp-lsp-proxy.sh"
|
||||
|
||||
@@ -160,7 +160,7 @@ def get_workload_summary(db: Session, user: User) -> WorkloadSummary:
|
||||
if task.original_estimate:
|
||||
allocated_hours += task.original_estimate
|
||||
|
||||
capacity_hours = Decimal(str(user.capacity)) if user.capacity else Decimal("40")
|
||||
capacity_hours = Decimal(str(user.capacity)) if user.capacity is not None else Decimal("40")
|
||||
load_percentage = calculate_load_percentage(allocated_hours, capacity_hours)
|
||||
load_level = determine_load_level(load_percentage)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models import User, Space, Project, TaskStatus, AuditAction, ProjectMember
|
||||
from app.models import User, Space, Project, TaskStatus, AuditAction, ProjectMember, ProjectTemplate, CustomField
|
||||
from app.models.task_status import DEFAULT_STATUSES
|
||||
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse, ProjectWithDetails
|
||||
from app.schemas.task_status import TaskStatusResponse
|
||||
@@ -36,6 +36,17 @@ def create_default_statuses(db: Session, project_id: str):
|
||||
db.add(status)
|
||||
|
||||
|
||||
def can_view_template(user: User, template: ProjectTemplate) -> bool:
|
||||
"""Check if a user can view a template."""
|
||||
if template.is_public:
|
||||
return True
|
||||
if template.owner_id == user.id:
|
||||
return True
|
||||
if user.is_system_admin:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@router.get("/api/spaces/{space_id}/projects", response_model=List[ProjectWithDetails])
|
||||
async def list_projects_in_space(
|
||||
space_id: str,
|
||||
@@ -115,6 +126,27 @@ async def create_project(
|
||||
detail="Access denied",
|
||||
)
|
||||
|
||||
template = None
|
||||
if project_data.template_id:
|
||||
template = db.query(ProjectTemplate).filter(
|
||||
ProjectTemplate.id == project_data.template_id,
|
||||
ProjectTemplate.is_active == True,
|
||||
).first()
|
||||
if not template:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Template not found",
|
||||
)
|
||||
if not can_view_template(current_user, template):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to template",
|
||||
)
|
||||
|
||||
security_level = project_data.security_level.value if project_data.security_level else "department"
|
||||
if template and template.default_security_level:
|
||||
security_level = template.default_security_level
|
||||
|
||||
project = Project(
|
||||
id=str(uuid.uuid4()),
|
||||
space_id=space_id,
|
||||
@@ -124,17 +156,47 @@ async def create_project(
|
||||
budget=project_data.budget,
|
||||
start_date=project_data.start_date,
|
||||
end_date=project_data.end_date,
|
||||
security_level=project_data.security_level.value if project_data.security_level else "department",
|
||||
security_level=security_level,
|
||||
department_id=project_data.department_id or current_user.department_id,
|
||||
)
|
||||
|
||||
db.add(project)
|
||||
db.flush() # Get the project ID
|
||||
|
||||
# Create default task statuses
|
||||
# Create task statuses (from template if provided, otherwise defaults)
|
||||
if template and template.task_statuses:
|
||||
for status_data in template.task_statuses:
|
||||
status = TaskStatus(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=project.id,
|
||||
name=status_data.get("name", "Unnamed"),
|
||||
color=status_data.get("color", "#808080"),
|
||||
position=status_data.get("position", 0),
|
||||
is_done=status_data.get("is_done", False),
|
||||
)
|
||||
db.add(status)
|
||||
else:
|
||||
create_default_statuses(db, project.id)
|
||||
|
||||
# Create custom fields from template if provided
|
||||
if template and template.custom_fields:
|
||||
for field_data in template.custom_fields:
|
||||
custom_field = CustomField(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=project.id,
|
||||
name=field_data.get("name", "Unnamed"),
|
||||
field_type=field_data.get("field_type", "text"),
|
||||
options=field_data.get("options"),
|
||||
formula=field_data.get("formula"),
|
||||
is_required=field_data.get("is_required", False),
|
||||
position=field_data.get("position", 0),
|
||||
)
|
||||
db.add(custom_field)
|
||||
|
||||
# Audit log
|
||||
changes = [{"field": "title", "old_value": None, "new_value": project.title}]
|
||||
if template:
|
||||
changes.append({"field": "template_id", "old_value": None, "new_value": template.id})
|
||||
AuditService.log_event(
|
||||
db=db,
|
||||
event_type="project.create",
|
||||
@@ -142,7 +204,7 @@ async def create_project(
|
||||
action=AuditAction.CREATE,
|
||||
user_id=current_user.id,
|
||||
resource_id=project.id,
|
||||
changes=[{"field": "title", "old_value": None, "new_value": project.title}],
|
||||
changes=changes,
|
||||
request_metadata=get_audit_metadata(request),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
|
||||
import uuid
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
@@ -8,7 +9,7 @@ from app.core.config import settings
|
||||
from app.models import User, ReportHistory, ScheduledReport
|
||||
from app.schemas.report import (
|
||||
WeeklyReportContent, ReportHistoryListResponse, ReportHistoryItem,
|
||||
GenerateReportResponse, ReportSummary
|
||||
GenerateReportResponse, ReportSummary, WeeklyReportSubscription, WeeklyReportSubscriptionUpdate
|
||||
)
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.services.report_service import ReportService
|
||||
@@ -16,6 +17,62 @@ from app.services.report_service import ReportService
|
||||
router = APIRouter(tags=["reports"])
|
||||
|
||||
|
||||
@router.get("/api/reports/weekly/subscription", response_model=WeeklyReportSubscription)
|
||||
async def get_weekly_report_subscription(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get weekly report subscription status for the current user.
|
||||
"""
|
||||
scheduled_report = db.query(ScheduledReport).filter(
|
||||
ScheduledReport.recipient_id == current_user.id,
|
||||
ScheduledReport.report_type == "weekly",
|
||||
).first()
|
||||
|
||||
if not scheduled_report:
|
||||
return WeeklyReportSubscription(is_active=False, last_sent_at=None)
|
||||
|
||||
return WeeklyReportSubscription(
|
||||
is_active=scheduled_report.is_active,
|
||||
last_sent_at=scheduled_report.last_sent_at,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/api/reports/weekly/subscription", response_model=WeeklyReportSubscription)
|
||||
async def update_weekly_report_subscription(
|
||||
subscription: WeeklyReportSubscriptionUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update weekly report subscription status for the current user.
|
||||
"""
|
||||
scheduled_report = db.query(ScheduledReport).filter(
|
||||
ScheduledReport.recipient_id == current_user.id,
|
||||
ScheduledReport.report_type == "weekly",
|
||||
).first()
|
||||
|
||||
if not scheduled_report:
|
||||
scheduled_report = ScheduledReport(
|
||||
id=str(uuid.uuid4()),
|
||||
report_type="weekly",
|
||||
recipient_id=current_user.id,
|
||||
is_active=subscription.is_active,
|
||||
)
|
||||
db.add(scheduled_report)
|
||||
else:
|
||||
scheduled_report.is_active = subscription.is_active
|
||||
|
||||
db.commit()
|
||||
db.refresh(scheduled_report)
|
||||
|
||||
return WeeklyReportSubscription(
|
||||
is_active=scheduled_report.is_active,
|
||||
last_sent_at=scheduled_report.last_sent_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/reports/weekly/preview", response_model=WeeklyReportContent)
|
||||
async def preview_weekly_report(
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
@@ -407,17 +407,18 @@ async def update_task(
|
||||
if task_data.version != task.version:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"message": "Task has been modified by another user",
|
||||
"current_version": task.version,
|
||||
"provided_version": task_data.version,
|
||||
},
|
||||
detail=(
|
||||
f"Version conflict: current_version={task.version}, "
|
||||
f"provided_version={task_data.version}"
|
||||
),
|
||||
)
|
||||
|
||||
# Capture old values for audit and triggers
|
||||
old_values = {
|
||||
"title": task.title,
|
||||
"description": task.description,
|
||||
"status_id": task.status_id,
|
||||
"assignee_id": task.assignee_id,
|
||||
"priority": task.priority,
|
||||
"start_date": task.start_date,
|
||||
"due_date": task.due_date,
|
||||
@@ -430,6 +431,17 @@ async def update_task(
|
||||
custom_values_data = update_data.pop("custom_values", None)
|
||||
update_data.pop("version", None) # version is handled separately for optimistic locking
|
||||
|
||||
old_custom_values = None
|
||||
if custom_values_data:
|
||||
old_custom_values = {
|
||||
cv.field_id: cv.value
|
||||
for cv in CustomValueService.get_custom_values_for_task(
|
||||
db,
|
||||
task,
|
||||
include_formula_calculations=True,
|
||||
)
|
||||
}
|
||||
|
||||
# Track old assignee for workload cache invalidation
|
||||
old_assignee_id = task.assignee_id
|
||||
|
||||
@@ -488,6 +500,8 @@ async def update_task(
|
||||
new_values = {
|
||||
"title": task.title,
|
||||
"description": task.description,
|
||||
"status_id": task.status_id,
|
||||
"assignee_id": task.assignee_id,
|
||||
"priority": task.priority,
|
||||
"start_date": task.start_date,
|
||||
"due_date": task.due_date,
|
||||
@@ -509,30 +523,46 @@ async def update_task(
|
||||
request_metadata=get_audit_metadata(request),
|
||||
)
|
||||
|
||||
# Evaluate triggers for priority changes
|
||||
if "priority" in update_data:
|
||||
TriggerService.evaluate_triggers(db, task, old_values, new_values, current_user)
|
||||
|
||||
# Handle custom values update
|
||||
new_custom_values = None
|
||||
if custom_values_data:
|
||||
try:
|
||||
from app.schemas.task import CustomValueInput
|
||||
custom_values = [CustomValueInput(**cv) for cv in custom_values_data]
|
||||
CustomValueService.save_custom_values(db, task, custom_values)
|
||||
new_custom_values = {
|
||||
cv.field_id: cv.value
|
||||
for cv in CustomValueService.get_custom_values_for_task(
|
||||
db,
|
||||
task,
|
||||
include_formula_calculations=True,
|
||||
)
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
trigger_fields = {"status_id", "assignee_id", "priority", "start_date", "due_date"}
|
||||
trigger_relevant = any(field in update_data for field in trigger_fields) or custom_values_data
|
||||
if trigger_relevant:
|
||||
trigger_old_values = {field: old_values.get(field) for field in trigger_fields}
|
||||
trigger_new_values = {field: new_values.get(field) for field in trigger_fields}
|
||||
if old_custom_values is not None:
|
||||
trigger_old_values["custom_fields"] = old_custom_values
|
||||
if new_custom_values is not None:
|
||||
trigger_new_values["custom_fields"] = new_custom_values
|
||||
TriggerService.evaluate_triggers(db, task, trigger_old_values, trigger_new_values, current_user)
|
||||
|
||||
# Increment version for optimistic locking
|
||||
task.version += 1
|
||||
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
|
||||
# Invalidate workload cache if original_estimate changed and task has an assignee
|
||||
if "original_estimate" in update_data and task.assignee_id:
|
||||
# Invalidate workload cache if workload-affecting fields changed
|
||||
if ("original_estimate" in update_data or "due_date" in update_data) and task.assignee_id:
|
||||
invalidate_user_workload_cache(task.assignee_id)
|
||||
|
||||
# Invalidate workload cache if assignee changed
|
||||
|
||||
@@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models import User, Project, Trigger, TriggerLog
|
||||
from app.models import User, Project, Trigger, TriggerLog, CustomField
|
||||
from app.schemas.trigger import (
|
||||
TriggerCreate, TriggerUpdate, TriggerResponse, TriggerListResponse,
|
||||
TriggerLogResponse, TriggerLogListResponse, TriggerUserInfo
|
||||
@@ -16,6 +16,10 @@ from app.services.action_executor import ActionValidationError
|
||||
|
||||
router = APIRouter(tags=["triggers"])
|
||||
|
||||
FIELD_CHANGE_FIELDS = {"status_id", "assignee_id", "priority", "start_date", "due_date", "custom_fields"}
|
||||
FIELD_CHANGE_OPERATORS = {"equals", "not_equals", "changed_to", "changed_from", "before", "after", "in"}
|
||||
DATE_FIELDS = {"start_date", "due_date"}
|
||||
|
||||
|
||||
def trigger_to_response(trigger: Trigger) -> TriggerResponse:
|
||||
"""Convert Trigger model to TriggerResponse."""
|
||||
@@ -39,6 +43,96 @@ def trigger_to_response(trigger: Trigger) -> TriggerResponse:
|
||||
)
|
||||
|
||||
|
||||
def _validate_field_change_conditions(conditions, project_id: str, db: Session) -> None:
|
||||
rules = []
|
||||
if conditions.rules is not None:
|
||||
if conditions.logic != "and":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Composite conditions only support logic 'and'",
|
||||
)
|
||||
if not conditions.rules:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Composite conditions require at least one rule",
|
||||
)
|
||||
rules = conditions.rules
|
||||
else:
|
||||
if not conditions.field:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Field is required for field_change triggers",
|
||||
)
|
||||
rules = [conditions]
|
||||
|
||||
for rule in rules:
|
||||
field = rule.field
|
||||
operator = rule.operator
|
||||
value = rule.value
|
||||
field_id = rule.field_id or getattr(conditions, "field_id", None)
|
||||
|
||||
if field not in FIELD_CHANGE_FIELDS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid condition field. Must be 'status_id', 'assignee_id', 'priority', 'start_date', 'due_date', or 'custom_fields'",
|
||||
)
|
||||
if not operator:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Operator is required for field_change triggers",
|
||||
)
|
||||
if operator not in FIELD_CHANGE_OPERATORS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid operator. Must be 'equals', 'not_equals', 'changed_to', 'changed_from', 'before', 'after', or 'in'",
|
||||
)
|
||||
if value is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Condition value is required for field_change triggers",
|
||||
)
|
||||
|
||||
field_type = None
|
||||
if field in DATE_FIELDS:
|
||||
field_type = "date"
|
||||
elif field == "custom_fields":
|
||||
if not field_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Custom field ID is required when field is custom_fields",
|
||||
)
|
||||
custom_field = db.query(CustomField).filter(
|
||||
CustomField.id == field_id,
|
||||
CustomField.project_id == project_id,
|
||||
).first()
|
||||
if not custom_field:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Custom field not found in this project",
|
||||
)
|
||||
field_type = custom_field.field_type
|
||||
|
||||
if operator in {"before", "after"}:
|
||||
if field_type not in {"date", "number", "formula"}:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Operator 'before/after' is only valid for date or number fields",
|
||||
)
|
||||
|
||||
if operator == "in":
|
||||
if field_type == "date":
|
||||
if not isinstance(value, dict) or "start" not in value or "end" not in value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Date 'in' operator requires a range with start and end",
|
||||
)
|
||||
elif not isinstance(value, list):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Operator 'in' requires a list of values",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/projects/{project_id}/triggers", response_model=TriggerResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_trigger(
|
||||
project_id: str,
|
||||
@@ -71,27 +165,7 @@ async def create_trigger(
|
||||
|
||||
# Validate conditions based on trigger type
|
||||
if trigger_data.trigger_type == "field_change":
|
||||
# Validate field_change conditions
|
||||
if not trigger_data.conditions.field:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Field is required for field_change triggers",
|
||||
)
|
||||
if trigger_data.conditions.field not in ["status_id", "assignee_id", "priority"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid condition field. Must be 'status_id', 'assignee_id', or 'priority'",
|
||||
)
|
||||
if not trigger_data.conditions.operator:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Operator is required for field_change triggers",
|
||||
)
|
||||
if trigger_data.conditions.operator not in ["equals", "not_equals", "changed_to", "changed_from"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid operator. Must be 'equals', 'not_equals', 'changed_to', or 'changed_from'",
|
||||
)
|
||||
_validate_field_change_conditions(trigger_data.conditions, project_id, db)
|
||||
elif trigger_data.trigger_type == "schedule":
|
||||
# Validate schedule conditions
|
||||
has_cron = trigger_data.conditions.cron_expression is not None
|
||||
@@ -234,11 +308,7 @@ async def update_trigger(
|
||||
if trigger_data.conditions is not None:
|
||||
# Validate conditions based on trigger type
|
||||
if trigger.trigger_type == "field_change":
|
||||
if trigger_data.conditions.field and trigger_data.conditions.field not in ["status_id", "assignee_id", "priority"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid condition field",
|
||||
)
|
||||
_validate_field_change_conditions(trigger_data.conditions, trigger.project_id, db)
|
||||
elif trigger.trigger_type == "schedule":
|
||||
# Validate cron expression if provided
|
||||
if trigger_data.conditions.cron_expression is not None:
|
||||
|
||||
@@ -283,7 +283,7 @@ async def update_user_capacity(
|
||||
)
|
||||
|
||||
# Store old capacity for audit log
|
||||
old_capacity = float(user.capacity) if user.capacity else None
|
||||
old_capacity = float(user.capacity) if user.capacity is not None else None
|
||||
|
||||
# Update capacity (validation is handled by Pydantic schema)
|
||||
user.capacity = capacity.capacity_hours
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import asyncio
|
||||
import os
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import SessionLocal
|
||||
from app.core import database
|
||||
from app.core.security import decode_access_token
|
||||
from app.core.redis import get_redis_sync
|
||||
from app.models import User, Notification, Project
|
||||
@@ -22,6 +23,8 @@ PONG_TIMEOUT = 30.0 # Disconnect if no pong received within this time after pi
|
||||
|
||||
# Authentication timeout (10 seconds)
|
||||
AUTH_TIMEOUT = 10.0
|
||||
if os.getenv("TESTING") == "true":
|
||||
AUTH_TIMEOUT = 1.0
|
||||
|
||||
|
||||
async def get_user_from_token(token: str) -> tuple[str | None, User | None]:
|
||||
@@ -41,7 +44,7 @@ async def get_user_from_token(token: str) -> tuple[str | None, User | None]:
|
||||
return None, None
|
||||
|
||||
# Get user from database
|
||||
db = SessionLocal()
|
||||
db = database.SessionLocal()
|
||||
try:
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if user is None or not user.is_active:
|
||||
@@ -54,7 +57,7 @@ async def get_user_from_token(token: str) -> tuple[str | None, User | None]:
|
||||
async def authenticate_websocket(
|
||||
websocket: WebSocket,
|
||||
query_token: Optional[str] = None
|
||||
) -> tuple[str | None, User | None]:
|
||||
) -> tuple[str | None, User | None, Optional[str]]:
|
||||
"""
|
||||
Authenticate WebSocket connection.
|
||||
|
||||
@@ -72,7 +75,10 @@ async def authenticate_websocket(
|
||||
"WebSocket authentication via query parameter is deprecated. "
|
||||
"Please use first-message authentication for better security."
|
||||
)
|
||||
return await get_user_from_token(query_token)
|
||||
user_id, user = await get_user_from_token(query_token)
|
||||
if user_id is None:
|
||||
return None, None, "invalid_token"
|
||||
return user_id, user, None
|
||||
|
||||
# Wait for authentication message with timeout
|
||||
try:
|
||||
@@ -84,26 +90,29 @@ async def authenticate_websocket(
|
||||
msg_type = data.get("type")
|
||||
if msg_type != "auth":
|
||||
logger.warning("Expected 'auth' message type, got: %s", msg_type)
|
||||
return None, None
|
||||
return None, None, "invalid_message"
|
||||
|
||||
token = data.get("token")
|
||||
if not token:
|
||||
logger.warning("No token provided in auth message")
|
||||
return None, None
|
||||
return None, None, "missing_token"
|
||||
|
||||
return await get_user_from_token(token)
|
||||
user_id, user = await get_user_from_token(token)
|
||||
if user_id is None:
|
||||
return None, None, "invalid_token"
|
||||
return user_id, user, None
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("WebSocket authentication timeout after %.1f seconds", AUTH_TIMEOUT)
|
||||
return None, None
|
||||
return None, None, "timeout"
|
||||
except Exception as e:
|
||||
logger.error("Error during WebSocket authentication: %s", e)
|
||||
return None, None
|
||||
return None, None, "error"
|
||||
|
||||
|
||||
async def get_unread_notifications(user_id: str) -> list[dict]:
|
||||
"""Query all unread notifications for a user."""
|
||||
db = SessionLocal()
|
||||
db = database.SessionLocal()
|
||||
try:
|
||||
notifications = (
|
||||
db.query(Notification)
|
||||
@@ -130,7 +139,7 @@ async def get_unread_notifications(user_id: str) -> list[dict]:
|
||||
|
||||
async def get_unread_count(user_id: str) -> int:
|
||||
"""Get the count of unread notifications for a user."""
|
||||
db = SessionLocal()
|
||||
db = database.SessionLocal()
|
||||
try:
|
||||
return (
|
||||
db.query(Notification)
|
||||
@@ -174,14 +183,12 @@ async def websocket_notifications(
|
||||
# Accept WebSocket connection first
|
||||
await websocket.accept()
|
||||
|
||||
# If no query token, notify client that auth is required
|
||||
if not token:
|
||||
await websocket.send_json({"type": "auth_required"})
|
||||
|
||||
# Authenticate
|
||||
user_id, user = await authenticate_websocket(websocket, token)
|
||||
user_id, user, error_reason = await authenticate_websocket(websocket, token)
|
||||
|
||||
if user_id is None:
|
||||
if error_reason == "invalid_token":
|
||||
await websocket.send_json({"type": "error", "message": "Invalid or expired token"})
|
||||
await websocket.close(code=4001, reason="Invalid or expired token")
|
||||
return
|
||||
|
||||
@@ -311,7 +318,7 @@ async def verify_project_access(user_id: str, project_id: str) -> tuple[bool, Pr
|
||||
Returns:
|
||||
Tuple of (has_access: bool, project: Project | None)
|
||||
"""
|
||||
db = SessionLocal()
|
||||
db = database.SessionLocal()
|
||||
try:
|
||||
# Get the user
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
@@ -365,14 +372,12 @@ async def websocket_project_sync(
|
||||
# Accept WebSocket connection first
|
||||
await websocket.accept()
|
||||
|
||||
# If no query token, notify client that auth is required
|
||||
if not token:
|
||||
await websocket.send_json({"type": "auth_required"})
|
||||
|
||||
# Authenticate user
|
||||
user_id, user = await authenticate_websocket(websocket, token)
|
||||
user_id, user, error_reason = await authenticate_websocket(websocket, token)
|
||||
|
||||
if user_id is None:
|
||||
if error_reason == "invalid_token":
|
||||
await websocket.send_json({"type": "error", "message": "Invalid or expired token"})
|
||||
await websocket.close(code=4001, reason="Invalid or expired token")
|
||||
return
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ async def get_heatmap(
|
||||
description="Comma-separated list of user IDs to include"
|
||||
),
|
||||
hide_empty: bool = Query(
|
||||
True,
|
||||
False,
|
||||
description="Hide users with no tasks assigned for the week"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -168,8 +168,20 @@ async def get_heatmap(
|
||||
if department_id:
|
||||
check_workload_access(current_user, department_id=department_id)
|
||||
|
||||
# Filter user_ids based on access (pass db for manager department lookup)
|
||||
accessible_user_ids = filter_accessible_users(current_user, parsed_user_ids, db)
|
||||
# Determine accessible users for this requester
|
||||
accessible_user_ids = filter_accessible_users(current_user, None, db)
|
||||
|
||||
# If specific user_ids are requested, ensure access is permitted
|
||||
if parsed_user_ids:
|
||||
if accessible_user_ids is not None:
|
||||
requested_ids = set(parsed_user_ids)
|
||||
allowed_ids = set(accessible_user_ids)
|
||||
if not requested_ids.issubset(allowed_ids):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied: Cannot view other users' workload",
|
||||
)
|
||||
accessible_user_ids = parsed_user_ids
|
||||
|
||||
# Normalize week_start
|
||||
if week_start is None:
|
||||
|
||||
@@ -1,6 +1,55 @@
|
||||
import logging
|
||||
import os
|
||||
import fnmatch
|
||||
from typing import Any, List, Tuple
|
||||
|
||||
import redis
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InMemoryRedis:
|
||||
"""Minimal in-memory Redis replacement for tests."""
|
||||
|
||||
def __init__(self):
|
||||
self.store = {}
|
||||
|
||||
def get(self, key):
|
||||
return self.store.get(key)
|
||||
|
||||
def set(self, key, value):
|
||||
self.store[key] = value
|
||||
return True
|
||||
|
||||
def setex(self, key, _seconds, value):
|
||||
self.store[key] = value
|
||||
return True
|
||||
|
||||
def delete(self, key):
|
||||
if key in self.store:
|
||||
del self.store[key]
|
||||
return 1
|
||||
return 0
|
||||
|
||||
def scan_iter(self, match=None):
|
||||
if match is None:
|
||||
yield from self.store.keys()
|
||||
return
|
||||
for key in list(self.store.keys()):
|
||||
if fnmatch.fnmatch(key, match):
|
||||
yield key
|
||||
|
||||
def publish(self, _channel, _message):
|
||||
return 1
|
||||
|
||||
def ping(self):
|
||||
return True
|
||||
|
||||
|
||||
if os.getenv("TESTING") == "true":
|
||||
redis_client = InMemoryRedis()
|
||||
else:
|
||||
redis_client = redis.Redis(
|
||||
host=settings.REDIS_HOST,
|
||||
port=settings.REDIS_PORT,
|
||||
@@ -17,3 +66,29 @@ def get_redis():
|
||||
def get_redis_sync():
|
||||
"""Get Redis client synchronously (non-dependency use)."""
|
||||
return redis_client
|
||||
|
||||
|
||||
class RedisManager:
|
||||
"""Lightweight Redis helper with publish fallback for reliability tests."""
|
||||
|
||||
def __init__(self, client=None):
|
||||
self._client = client
|
||||
self._message_queue: List[Tuple[str, Any]] = []
|
||||
|
||||
def get_client(self):
|
||||
return self._client or redis_client
|
||||
|
||||
def _publish_direct(self, channel: str, message: Any):
|
||||
client = self.get_client()
|
||||
return client.publish(channel, message)
|
||||
|
||||
def queue_message(self, channel: str, message: Any) -> None:
|
||||
self._message_queue.append((channel, message))
|
||||
|
||||
def publish_with_fallback(self, channel: str, message: Any):
|
||||
try:
|
||||
return self._publish_direct(channel, message)
|
||||
except Exception as exc:
|
||||
self.queue_message(channel, message)
|
||||
logger.warning("Redis publish failed, queued message: %s", exc)
|
||||
return None
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from fastapi import FastAPI, Request, APIRouter
|
||||
@@ -16,10 +17,15 @@ from app.core.deprecation import DeprecationMiddleware
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Manage application lifespan events."""
|
||||
testing = os.environ.get("TESTING", "").lower() in ("true", "1", "yes")
|
||||
scheduler_disabled = os.environ.get("DISABLE_SCHEDULER", "").lower() in ("true", "1", "yes")
|
||||
start_background_jobs = not testing and not scheduler_disabled
|
||||
# Startup
|
||||
if start_background_jobs:
|
||||
start_scheduler()
|
||||
yield
|
||||
# Shutdown
|
||||
if start_background_jobs:
|
||||
shutdown_scheduler()
|
||||
|
||||
|
||||
|
||||
@@ -26,12 +26,16 @@ from app.models.task_dependency import TaskDependency, DependencyType
|
||||
from app.models.project_member import ProjectMember
|
||||
from app.models.project_template import ProjectTemplate
|
||||
|
||||
# Backward-compatible alias for older imports
|
||||
ScheduleTrigger = Trigger
|
||||
|
||||
__all__ = [
|
||||
"User", "Role", "Department", "Space", "Project", "TaskStatus", "Task", "WorkloadSnapshot",
|
||||
"Comment", "Mention", "Notification", "Blocker",
|
||||
"AuditLog", "AuditAlert", "AuditAction", "SensitivityLevel", "EVENT_SENSITIVITY", "ALERT_EVENTS",
|
||||
"EncryptionKey", "Attachment", "AttachmentVersion",
|
||||
"Trigger", "TriggerType", "TriggerLog", "TriggerLogStatus",
|
||||
"ScheduleTrigger",
|
||||
"ScheduledReport", "ReportType", "ReportHistory", "ReportHistoryStatus",
|
||||
"ProjectHealth", "RiskLevel", "ScheduleStatus", "ResourceStatus",
|
||||
"CustomField", "FieldType", "TaskCustomValue",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from sqlalchemy import Column, String, Text, Boolean, DateTime, Date, Numeric, Enum, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.orm import relationship, synonym
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
import enum
|
||||
@@ -45,3 +45,6 @@ class Project(Base):
|
||||
|
||||
# Project membership for cross-department collaboration
|
||||
members = relationship("ProjectMember", back_populates="project", cascade="all, delete-orphan")
|
||||
|
||||
# Backward-compatible alias for older code/tests that use name instead of title
|
||||
name = synonym("title")
|
||||
|
||||
@@ -5,7 +5,7 @@ that can be used to quickly set up new projects.
|
||||
"""
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, Text, Boolean, DateTime, ForeignKey, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.orm import relationship, synonym
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
@@ -53,6 +53,10 @@ class ProjectTemplate(Base):
|
||||
# Relationships
|
||||
owner = relationship("User", foreign_keys=[owner_id])
|
||||
|
||||
# Backward-compatible aliases for older code/tests
|
||||
created_by = synonym("owner_id")
|
||||
default_statuses = synonym("task_statuses")
|
||||
|
||||
|
||||
# Default template data for system templates
|
||||
SYSTEM_TEMPLATES = [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, Integer, Enum, DateTime, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.orm import relationship, synonym
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
import enum
|
||||
@@ -34,7 +35,7 @@ class TaskDependency(Base):
|
||||
UniqueConstraint('predecessor_id', 'successor_id', name='uq_predecessor_successor'),
|
||||
)
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
predecessor_id = Column(
|
||||
String(36),
|
||||
ForeignKey("pjctrl_tasks.id", ondelete="CASCADE"),
|
||||
@@ -66,3 +67,7 @@ class TaskDependency(Base):
|
||||
foreign_keys=[successor_id],
|
||||
back_populates="predecessors"
|
||||
)
|
||||
|
||||
# Backward-compatible aliases for legacy field names
|
||||
task_id = synonym("successor_id")
|
||||
depends_on_task_id = synonym("predecessor_id")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, computed_field, model_validator
|
||||
from typing import Optional
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
@@ -19,9 +19,22 @@ class ProjectBase(BaseModel):
|
||||
end_date: Optional[date] = None
|
||||
security_level: SecurityLevel = SecurityLevel.DEPARTMENT
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def apply_name_alias(cls, values):
|
||||
if isinstance(values, dict) and not values.get("title") and values.get("name"):
|
||||
values["title"] = values["name"]
|
||||
return values
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.title
|
||||
|
||||
|
||||
class ProjectCreate(ProjectBase):
|
||||
department_id: Optional[str] = None
|
||||
template_id: Optional[str] = None
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
@@ -34,6 +47,13 @@ class ProjectUpdate(BaseModel):
|
||||
status: Optional[str] = Field(None, max_length=50)
|
||||
department_id: Optional[str] = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def apply_name_alias(cls, values):
|
||||
if isinstance(values, dict) and not values.get("title") and values.get("name"):
|
||||
values["title"] = values["name"]
|
||||
return values
|
||||
|
||||
|
||||
class ProjectResponse(ProjectBase):
|
||||
id: str
|
||||
|
||||
@@ -48,3 +48,12 @@ class GenerateReportResponse(BaseModel):
|
||||
message: str
|
||||
report_id: str
|
||||
summary: ReportSummary
|
||||
|
||||
|
||||
class WeeklyReportSubscription(BaseModel):
|
||||
is_active: bool
|
||||
last_sent_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class WeeklyReportSubscriptionUpdate(BaseModel):
|
||||
is_active: bool
|
||||
|
||||
@@ -35,6 +35,15 @@ class TaskBase(BaseModel):
|
||||
start_date: Optional[datetime] = None
|
||||
due_date: Optional[datetime] = None
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
def title_not_blank(cls, value: str) -> str:
|
||||
if value is None:
|
||||
return value
|
||||
if value.strip() == "":
|
||||
raise ValueError("Title cannot be blank or whitespace")
|
||||
return value
|
||||
|
||||
|
||||
class TaskCreate(TaskBase):
|
||||
parent_task_id: Optional[str] = None
|
||||
@@ -57,6 +66,15 @@ class TaskUpdate(BaseModel):
|
||||
custom_values: Optional[List[CustomValueInput]] = None
|
||||
version: Optional[int] = Field(None, ge=1, description="Version for optimistic locking")
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
def title_not_blank(cls, value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return value
|
||||
if value.strip() == "":
|
||||
raise ValueError("Title cannot be blank or whitespace")
|
||||
return value
|
||||
|
||||
|
||||
class TaskStatusUpdate(BaseModel):
|
||||
status_id: str
|
||||
@@ -131,3 +149,8 @@ class TaskDeleteResponse(BaseModel):
|
||||
task: TaskResponse
|
||||
blockers_resolved: int = 0
|
||||
force_deleted: bool = False
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return self.task.id
|
||||
|
||||
@@ -5,9 +5,18 @@ from pydantic import BaseModel, Field
|
||||
|
||||
class FieldChangeCondition(BaseModel):
|
||||
"""Condition for field_change triggers."""
|
||||
field: str = Field(..., description="Field to check: status_id, assignee_id, priority")
|
||||
operator: str = Field(..., description="Operator: equals, not_equals, changed_to, changed_from")
|
||||
value: str = Field(..., description="Value to compare against")
|
||||
field: str = Field(..., description="Field to check: status_id, assignee_id, priority, start_date, due_date, custom_fields")
|
||||
operator: str = Field(..., description="Operator: equals, not_equals, changed_to, changed_from, before, after, in")
|
||||
value: Any = Field(..., description="Value to compare against")
|
||||
field_id: Optional[str] = Field(None, description="Custom field ID when field is custom_fields")
|
||||
|
||||
|
||||
class TriggerRule(BaseModel):
|
||||
"""Rule for composite field_change triggers."""
|
||||
field: str = Field(..., description="Field to check: status_id, assignee_id, priority, start_date, due_date, custom_fields")
|
||||
operator: str = Field(..., description="Operator: equals, not_equals, changed_to, changed_from, before, after, in")
|
||||
value: Any = Field(..., description="Value to compare against")
|
||||
field_id: Optional[str] = Field(None, description="Custom field ID when field is custom_fields")
|
||||
|
||||
|
||||
class ScheduleCondition(BaseModel):
|
||||
@@ -19,9 +28,12 @@ class ScheduleCondition(BaseModel):
|
||||
class TriggerCondition(BaseModel):
|
||||
"""Union condition that supports both field_change and schedule triggers."""
|
||||
# Field change conditions
|
||||
field: Optional[str] = Field(None, description="Field to check: status_id, assignee_id, priority")
|
||||
operator: Optional[str] = Field(None, description="Operator: equals, not_equals, changed_to, changed_from")
|
||||
value: Optional[str] = Field(None, description="Value to compare against")
|
||||
field: Optional[str] = Field(None, description="Field to check: status_id, assignee_id, priority, start_date, due_date, custom_fields")
|
||||
operator: Optional[str] = Field(None, description="Operator: equals, not_equals, changed_to, changed_from, before, after, in")
|
||||
value: Optional[Any] = Field(None, description="Value to compare against")
|
||||
field_id: Optional[str] = Field(None, description="Custom field ID when field is custom_fields")
|
||||
logic: Optional[str] = Field(None, description="Composite logic: and")
|
||||
rules: Optional[List[TriggerRule]] = None
|
||||
# Schedule conditions
|
||||
cron_expression: Optional[str] = Field(None, description="Cron expression for schedule triggers")
|
||||
deadline_reminder_days: Optional[int] = Field(None, ge=1, le=365, description="Days before due date to send reminder")
|
||||
@@ -37,7 +49,7 @@ class TriggerAction(BaseModel):
|
||||
"""
|
||||
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>")
|
||||
target: Optional[str] = Field(None, description="Target: assignee, creator, project_owner, project_members, department:<id>, role:<name>, 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")
|
||||
|
||||
@@ -33,6 +33,8 @@ class FileStorageService:
|
||||
|
||||
def __init__(self):
|
||||
self.base_dir = Path(settings.UPLOAD_DIR).resolve()
|
||||
# Backward-compatible attribute name for tests and older code
|
||||
self.upload_dir = self.base_dir
|
||||
self._storage_status = {
|
||||
"validated": False,
|
||||
"path_exists": False,
|
||||
@@ -217,15 +219,16 @@ class FileStorageService:
|
||||
PathTraversalError: If the path is outside the base directory
|
||||
"""
|
||||
resolved_path = path.resolve()
|
||||
base_dir = self.base_dir.resolve()
|
||||
|
||||
# Check if the resolved path is within the base directory
|
||||
try:
|
||||
resolved_path.relative_to(self.base_dir)
|
||||
resolved_path.relative_to(base_dir)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Path traversal attempt detected: path %s is outside base directory %s. Context: %s",
|
||||
resolved_path,
|
||||
self.base_dir,
|
||||
base_dir,
|
||||
context
|
||||
)
|
||||
raise PathTraversalError(
|
||||
|
||||
@@ -5,7 +5,7 @@ from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.models import (
|
||||
User, Task, Project, ScheduledReport, ReportHistory, Blocker
|
||||
User, Task, Project, ScheduledReport, ReportHistory, Blocker, ProjectMember
|
||||
)
|
||||
from app.services.notification_service import NotificationService
|
||||
|
||||
@@ -46,8 +46,17 @@ class ReportService:
|
||||
# Use naive datetime for comparison with database values
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
# Get projects owned by the user
|
||||
projects = db.query(Project).filter(Project.owner_id == user_id).all()
|
||||
owned_projects = db.query(Project).filter(Project.owner_id == user_id).all()
|
||||
member_project_ids = db.query(ProjectMember.project_id).filter(
|
||||
ProjectMember.user_id == user_id
|
||||
).all()
|
||||
|
||||
project_ids = {p.id for p in owned_projects}
|
||||
project_ids.update(row[0] for row in member_project_ids if row and row[0])
|
||||
|
||||
projects = []
|
||||
if project_ids:
|
||||
projects = db.query(Project).filter(Project.id.in_(project_ids)).all()
|
||||
|
||||
if not projects:
|
||||
return {
|
||||
@@ -92,7 +101,7 @@ class ReportService:
|
||||
|
||||
# Check if completed (updated this week)
|
||||
if is_done:
|
||||
if task.updated_at and task.updated_at >= week_start:
|
||||
if task.updated_at and week_start <= task.updated_at < week_end:
|
||||
completed_tasks.append(task)
|
||||
else:
|
||||
# Check if task has active status (not done, not blocked)
|
||||
@@ -225,7 +234,7 @@ class ReportService:
|
||||
id=str(uuid.uuid4()),
|
||||
report_type="weekly",
|
||||
recipient_id=user_id,
|
||||
is_active=True,
|
||||
is_active=False,
|
||||
)
|
||||
db.add(scheduled_report)
|
||||
db.flush()
|
||||
|
||||
@@ -13,9 +13,9 @@ from typing import Optional, List, Dict, Any, Tuple, Set
|
||||
|
||||
from croniter import croniter
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.models import Trigger, TriggerLog, Task, Project
|
||||
from app.models import Trigger, TriggerLog, Task, Project, ProjectMember, User, Role
|
||||
from app.services.notification_service import NotificationService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -408,17 +408,18 @@ class TriggerSchedulerService:
|
||||
logger.warning(f"Trigger {trigger.id} has no associated project")
|
||||
return
|
||||
|
||||
target_user_id = TriggerSchedulerService._resolve_target(project, target)
|
||||
if not target_user_id:
|
||||
recipient_ids = TriggerSchedulerService._resolve_target(db, project, target)
|
||||
if not recipient_ids:
|
||||
logger.debug(f"No target user resolved for trigger {trigger.id} with target '{target}'")
|
||||
return
|
||||
|
||||
# Format message with variables
|
||||
message = TriggerSchedulerService._format_template(template, trigger, project)
|
||||
|
||||
for user_id in recipient_ids:
|
||||
NotificationService.create_notification(
|
||||
db=db,
|
||||
user_id=target_user_id,
|
||||
user_id=user_id,
|
||||
notification_type="scheduled_trigger",
|
||||
reference_type="trigger",
|
||||
reference_id=trigger.id,
|
||||
@@ -427,22 +428,57 @@ class TriggerSchedulerService:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_target(project: Project, target: str) -> Optional[str]:
|
||||
def _resolve_target(db: Session, project: Project, target: str) -> List[str]:
|
||||
"""
|
||||
Resolve notification target to user ID.
|
||||
Resolve notification target to user IDs.
|
||||
|
||||
Args:
|
||||
project: The project context
|
||||
target: Target specification (e.g., "project_owner", "user:<id>")
|
||||
|
||||
Returns:
|
||||
User ID or None
|
||||
List of user IDs
|
||||
"""
|
||||
recipients: Set[str] = set()
|
||||
|
||||
if target == "project_owner":
|
||||
return project.owner_id
|
||||
if project.owner_id:
|
||||
recipients.add(project.owner_id)
|
||||
elif target == "project_members":
|
||||
if project.owner_id:
|
||||
recipients.add(project.owner_id)
|
||||
member_rows = db.query(ProjectMember.user_id).join(
|
||||
User,
|
||||
User.id == ProjectMember.user_id,
|
||||
).filter(
|
||||
ProjectMember.project_id == project.id,
|
||||
User.is_active == True,
|
||||
).all()
|
||||
recipients.update(row[0] for row in member_rows if row and row[0])
|
||||
elif target.startswith("department:"):
|
||||
department_id = target.split(":", 1)[1]
|
||||
if department_id:
|
||||
user_rows = db.query(User.id).filter(
|
||||
User.department_id == department_id,
|
||||
User.is_active == True,
|
||||
).all()
|
||||
recipients.update(row[0] for row in user_rows if row and row[0])
|
||||
elif target.startswith("role:"):
|
||||
role_name = target.split(":", 1)[1].strip()
|
||||
if role_name:
|
||||
role = db.query(Role).filter(func.lower(Role.name) == role_name.lower()).first()
|
||||
if role:
|
||||
user_rows = db.query(User.id).filter(
|
||||
User.role_id == role.id,
|
||||
User.is_active == True,
|
||||
).all()
|
||||
recipients.update(row[0] for row in user_rows if row and row[0])
|
||||
elif target.startswith("user:"):
|
||||
return target.split(":", 1)[1]
|
||||
return None
|
||||
user_id = target.split(":", 1)[1]
|
||||
if user_id:
|
||||
recipients.add(user_id)
|
||||
|
||||
return list(recipients)
|
||||
|
||||
@staticmethod
|
||||
def _format_template(template: str, trigger: Trigger, project: Project) -> str:
|
||||
@@ -718,8 +754,8 @@ class TriggerSchedulerService:
|
||||
)
|
||||
|
||||
# Resolve target user
|
||||
target_user_id = TriggerSchedulerService._resolve_deadline_target(task, target)
|
||||
if not target_user_id:
|
||||
recipient_ids = TriggerSchedulerService._resolve_deadline_target(db, task, target)
|
||||
if not recipient_ids:
|
||||
logger.debug(
|
||||
f"No target user resolved for deadline reminder, task {task.id}, target '{target}'"
|
||||
)
|
||||
@@ -730,9 +766,10 @@ class TriggerSchedulerService:
|
||||
template, trigger, task, reminder_days
|
||||
)
|
||||
|
||||
for user_id in recipient_ids:
|
||||
NotificationService.create_notification(
|
||||
db=db,
|
||||
user_id=target_user_id,
|
||||
user_id=user_id,
|
||||
notification_type="deadline_reminder",
|
||||
reference_type="task",
|
||||
reference_id=task.id,
|
||||
@@ -741,7 +778,7 @@ class TriggerSchedulerService:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_deadline_target(task: Task, target: str) -> Optional[str]:
|
||||
def _resolve_deadline_target(db: Session, task: Task, target: str) -> List[str]:
|
||||
"""
|
||||
Resolve notification target for deadline reminders.
|
||||
|
||||
@@ -750,17 +787,55 @@ class TriggerSchedulerService:
|
||||
target: Target specification
|
||||
|
||||
Returns:
|
||||
User ID or None
|
||||
List of user IDs
|
||||
"""
|
||||
recipients: Set[str] = set()
|
||||
|
||||
if target == "assignee":
|
||||
return task.assignee_id
|
||||
if task.assignee_id:
|
||||
recipients.add(task.assignee_id)
|
||||
elif target == "creator":
|
||||
return task.created_by
|
||||
if task.created_by:
|
||||
recipients.add(task.created_by)
|
||||
elif target == "project_owner":
|
||||
return task.project.owner_id if task.project else None
|
||||
if task.project and task.project.owner_id:
|
||||
recipients.add(task.project.owner_id)
|
||||
elif target == "project_members":
|
||||
if task.project:
|
||||
if task.project.owner_id:
|
||||
recipients.add(task.project.owner_id)
|
||||
member_rows = db.query(ProjectMember.user_id).join(
|
||||
User,
|
||||
User.id == ProjectMember.user_id,
|
||||
).filter(
|
||||
ProjectMember.project_id == task.project_id,
|
||||
User.is_active == True,
|
||||
).all()
|
||||
recipients.update(row[0] for row in member_rows if row and row[0])
|
||||
elif target.startswith("department:"):
|
||||
department_id = target.split(":", 1)[1]
|
||||
if department_id:
|
||||
user_rows = db.query(User.id).filter(
|
||||
User.department_id == department_id,
|
||||
User.is_active == True,
|
||||
).all()
|
||||
recipients.update(row[0] for row in user_rows if row and row[0])
|
||||
elif target.startswith("role:"):
|
||||
role_name = target.split(":", 1)[1].strip()
|
||||
if role_name:
|
||||
role = db.query(Role).filter(func.lower(Role.name) == role_name.lower()).first()
|
||||
if role:
|
||||
user_rows = db.query(User.id).filter(
|
||||
User.role_id == role.id,
|
||||
User.is_active == True,
|
||||
).all()
|
||||
recipients.update(row[0] for row in user_rows if row and row[0])
|
||||
elif target.startswith("user:"):
|
||||
return target.split(":", 1)[1]
|
||||
return None
|
||||
user_id = target.split(":", 1)[1]
|
||||
if user_id:
|
||||
recipients.add(user_id)
|
||||
|
||||
return list(recipients)
|
||||
|
||||
@staticmethod
|
||||
def _format_deadline_template(
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import uuid
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import List, Dict, Any, Optional, Set, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.models import Trigger, TriggerLog, Task, User, Project
|
||||
from app.models import Trigger, TriggerLog, Task, User, ProjectMember, CustomField, Role
|
||||
from app.services.notification_service import NotificationService
|
||||
from app.services.custom_value_service import CustomValueService
|
||||
from app.services.action_executor import (
|
||||
ActionExecutor,
|
||||
ActionExecutionError,
|
||||
@@ -17,8 +21,9 @@ logger = logging.getLogger(__name__)
|
||||
class TriggerService:
|
||||
"""Service for evaluating and executing triggers."""
|
||||
|
||||
SUPPORTED_FIELDS = ["status_id", "assignee_id", "priority"]
|
||||
SUPPORTED_OPERATORS = ["equals", "not_equals", "changed_to", "changed_from"]
|
||||
SUPPORTED_FIELDS = ["status_id", "assignee_id", "priority", "start_date", "due_date", "custom_fields"]
|
||||
SUPPORTED_OPERATORS = ["equals", "not_equals", "changed_to", "changed_from", "before", "after", "in"]
|
||||
DATE_FIELDS = {"start_date", "due_date"}
|
||||
|
||||
@staticmethod
|
||||
def evaluate_triggers(
|
||||
@@ -29,7 +34,9 @@ class TriggerService:
|
||||
current_user: User,
|
||||
) -> List[TriggerLog]:
|
||||
"""Evaluate all active triggers for a project when task values change."""
|
||||
logs = []
|
||||
logs: List[TriggerLog] = []
|
||||
old_values = old_values or {}
|
||||
new_values = new_values or {}
|
||||
|
||||
# Get active field_change triggers for the project
|
||||
triggers = db.query(Trigger).filter(
|
||||
@@ -38,8 +45,58 @@ class TriggerService:
|
||||
Trigger.trigger_type == "field_change",
|
||||
).all()
|
||||
|
||||
if not triggers:
|
||||
return logs
|
||||
|
||||
custom_field_ids: Set[str] = set()
|
||||
needs_custom_fields = False
|
||||
|
||||
for trigger in triggers:
|
||||
if TriggerService._check_conditions(trigger.conditions, old_values, new_values):
|
||||
rules = TriggerService._extract_rules(trigger.conditions or {})
|
||||
for rule in rules:
|
||||
if rule.get("field") == "custom_fields":
|
||||
needs_custom_fields = True
|
||||
field_id = rule.get("field_id")
|
||||
if field_id:
|
||||
custom_field_ids.add(field_id)
|
||||
|
||||
custom_field_types: Dict[str, str] = {}
|
||||
if custom_field_ids:
|
||||
fields = db.query(CustomField).filter(CustomField.id.in_(custom_field_ids)).all()
|
||||
custom_field_types = {f.id: f.field_type for f in fields}
|
||||
|
||||
current_custom_values = None
|
||||
if needs_custom_fields:
|
||||
if isinstance(new_values.get("custom_fields"), dict):
|
||||
current_custom_values = new_values.get("custom_fields")
|
||||
else:
|
||||
current_custom_values = TriggerService._get_custom_values_map(db, task)
|
||||
|
||||
current_values = {
|
||||
"status_id": task.status_id,
|
||||
"assignee_id": task.assignee_id,
|
||||
"priority": task.priority,
|
||||
"start_date": task.start_date,
|
||||
"due_date": task.due_date,
|
||||
"custom_fields": current_custom_values or {},
|
||||
}
|
||||
|
||||
changed_fields = TriggerService._detect_field_changes(old_values, new_values)
|
||||
changed_custom_field_ids = TriggerService._detect_custom_field_changes(
|
||||
old_values.get("custom_fields"),
|
||||
new_values.get("custom_fields"),
|
||||
)
|
||||
|
||||
for trigger in triggers:
|
||||
if TriggerService._check_conditions(
|
||||
trigger.conditions,
|
||||
old_values,
|
||||
new_values,
|
||||
current_values=current_values,
|
||||
changed_fields=changed_fields,
|
||||
changed_custom_field_ids=changed_custom_field_ids,
|
||||
custom_field_types=custom_field_types,
|
||||
):
|
||||
log = TriggerService._execute_actions(db, trigger, task, current_user, old_values, new_values)
|
||||
logs.append(log)
|
||||
|
||||
@@ -50,29 +107,298 @@ class TriggerService:
|
||||
conditions: Dict[str, Any],
|
||||
old_values: Dict[str, Any],
|
||||
new_values: Dict[str, Any],
|
||||
current_values: Optional[Dict[str, Any]] = None,
|
||||
changed_fields: Optional[Set[str]] = None,
|
||||
changed_custom_field_ids: Optional[Set[str]] = None,
|
||||
custom_field_types: Optional[Dict[str, str]] = None,
|
||||
) -> bool:
|
||||
"""Check if trigger conditions are met."""
|
||||
field = conditions.get("field")
|
||||
operator = conditions.get("operator")
|
||||
value = conditions.get("value")
|
||||
old_values = old_values or {}
|
||||
new_values = new_values or {}
|
||||
current_values = current_values or new_values
|
||||
changed_fields = changed_fields or TriggerService._detect_field_changes(old_values, new_values)
|
||||
changed_custom_field_ids = changed_custom_field_ids or TriggerService._detect_custom_field_changes(
|
||||
old_values.get("custom_fields"),
|
||||
new_values.get("custom_fields"),
|
||||
)
|
||||
custom_field_types = custom_field_types or {}
|
||||
|
||||
rules = TriggerService._extract_rules(conditions)
|
||||
if not rules:
|
||||
return False
|
||||
|
||||
if conditions.get("rules") is not None and conditions.get("logic") != "and":
|
||||
return False
|
||||
|
||||
any_rule_changed = False
|
||||
|
||||
for rule in rules:
|
||||
field = rule.get("field")
|
||||
operator = rule.get("operator")
|
||||
value = rule.get("value")
|
||||
field_id = rule.get("field_id")
|
||||
|
||||
if field not in TriggerService.SUPPORTED_FIELDS:
|
||||
return False
|
||||
if operator not in TriggerService.SUPPORTED_OPERATORS:
|
||||
return False
|
||||
|
||||
if field == "custom_fields":
|
||||
if not field_id:
|
||||
return False
|
||||
custom_values = current_values.get("custom_fields") or {}
|
||||
old_custom = old_values.get("custom_fields") or {}
|
||||
new_custom = new_values.get("custom_fields") or {}
|
||||
current_value = custom_values.get(field_id)
|
||||
old_value = old_custom.get(field_id)
|
||||
new_value = new_custom.get(field_id)
|
||||
field_type = TriggerService._normalize_field_type(custom_field_types.get(field_id))
|
||||
field_changed = field_id in changed_custom_field_ids
|
||||
else:
|
||||
current_value = current_values.get(field)
|
||||
old_value = old_values.get(field)
|
||||
new_value = new_values.get(field)
|
||||
field_type = "date" if field in TriggerService.DATE_FIELDS else None
|
||||
field_changed = field in changed_fields
|
||||
|
||||
if TriggerService._evaluate_rule(
|
||||
operator,
|
||||
current_value,
|
||||
old_value,
|
||||
new_value,
|
||||
value,
|
||||
field_type,
|
||||
field_changed,
|
||||
) is False:
|
||||
return False
|
||||
|
||||
if field_changed:
|
||||
any_rule_changed = True
|
||||
|
||||
return any_rule_changed
|
||||
|
||||
@staticmethod
|
||||
def _extract_rules(conditions: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
rules = conditions.get("rules")
|
||||
if isinstance(rules, list):
|
||||
return rules
|
||||
field = conditions.get("field")
|
||||
if field:
|
||||
rule = {
|
||||
"field": field,
|
||||
"operator": conditions.get("operator"),
|
||||
"value": conditions.get("value"),
|
||||
}
|
||||
if conditions.get("field_id"):
|
||||
rule["field_id"] = conditions.get("field_id")
|
||||
return [rule]
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _get_custom_values_map(db: Session, task: Task) -> Dict[str, Any]:
|
||||
values = CustomValueService.get_custom_values_for_task(
|
||||
db,
|
||||
task,
|
||||
include_formula_calculations=True,
|
||||
)
|
||||
return {cv.field_id: cv.value for cv in values}
|
||||
|
||||
@staticmethod
|
||||
def _detect_field_changes(
|
||||
old_values: Dict[str, Any],
|
||||
new_values: Dict[str, Any],
|
||||
) -> Set[str]:
|
||||
changed = set()
|
||||
for field in TriggerService.SUPPORTED_FIELDS:
|
||||
if field == "custom_fields":
|
||||
continue
|
||||
if field in old_values or field in new_values:
|
||||
if old_values.get(field) != new_values.get(field):
|
||||
changed.add(field)
|
||||
return changed
|
||||
|
||||
@staticmethod
|
||||
def _detect_custom_field_changes(
|
||||
old_custom_values: Any,
|
||||
new_custom_values: Any,
|
||||
) -> Set[str]:
|
||||
if not isinstance(old_custom_values, dict) or not isinstance(new_custom_values, dict):
|
||||
return set()
|
||||
changed_ids = set()
|
||||
field_ids = set(old_custom_values.keys()) | set(new_custom_values.keys())
|
||||
for field_id in field_ids:
|
||||
if old_custom_values.get(field_id) != new_custom_values.get(field_id):
|
||||
changed_ids.add(field_id)
|
||||
return changed_ids
|
||||
|
||||
@staticmethod
|
||||
def _normalize_field_type(field_type: Optional[str]) -> Optional[str]:
|
||||
if not field_type:
|
||||
return None
|
||||
if field_type == "formula":
|
||||
return "number"
|
||||
return field_type
|
||||
|
||||
@staticmethod
|
||||
def _evaluate_rule(
|
||||
operator: str,
|
||||
current_value: Any,
|
||||
old_value: Any,
|
||||
new_value: Any,
|
||||
target_value: Any,
|
||||
field_type: Optional[str],
|
||||
field_changed: bool,
|
||||
) -> bool:
|
||||
if operator in ("changed_to", "changed_from"):
|
||||
if not field_changed:
|
||||
return False
|
||||
if operator == "changed_to":
|
||||
return (
|
||||
TriggerService._value_equals(new_value, target_value, field_type)
|
||||
and not TriggerService._value_equals(old_value, target_value, field_type)
|
||||
)
|
||||
return (
|
||||
TriggerService._value_equals(old_value, target_value, field_type)
|
||||
and not TriggerService._value_equals(new_value, target_value, field_type)
|
||||
)
|
||||
|
||||
if operator == "equals":
|
||||
return new_value == value
|
||||
elif operator == "not_equals":
|
||||
return new_value != value
|
||||
elif operator == "changed_to":
|
||||
return old_value != value and new_value == value
|
||||
elif operator == "changed_from":
|
||||
return old_value == value and new_value != value
|
||||
return TriggerService._value_equals(current_value, target_value, field_type)
|
||||
if operator == "not_equals":
|
||||
return not TriggerService._value_equals(current_value, target_value, field_type)
|
||||
if operator == "before":
|
||||
return TriggerService._compare_before(current_value, target_value, field_type)
|
||||
if operator == "after":
|
||||
return TriggerService._compare_after(current_value, target_value, field_type)
|
||||
if operator == "in":
|
||||
return TriggerService._compare_in(current_value, target_value, field_type)
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _value_equals(current_value: Any, target_value: Any, field_type: Optional[str]) -> bool:
|
||||
if current_value is None:
|
||||
return target_value is None
|
||||
|
||||
if field_type == "date":
|
||||
current_dt, current_date_only = TriggerService._parse_datetime_value(current_value)
|
||||
target_dt, target_date_only = TriggerService._parse_datetime_value(target_value)
|
||||
if not current_dt or not target_dt:
|
||||
return False
|
||||
if current_date_only or target_date_only:
|
||||
return current_dt.date() == target_dt.date()
|
||||
return current_dt == target_dt
|
||||
|
||||
if field_type == "number":
|
||||
current_num = TriggerService._parse_number_value(current_value)
|
||||
target_num = TriggerService._parse_number_value(target_value)
|
||||
if current_num is None or target_num is None:
|
||||
return False
|
||||
return current_num == target_num
|
||||
|
||||
if isinstance(target_value, (list, dict)):
|
||||
return False
|
||||
|
||||
return str(current_value) == str(target_value)
|
||||
|
||||
@staticmethod
|
||||
def _compare_before(current_value: Any, target_value: Any, field_type: Optional[str]) -> bool:
|
||||
if current_value is None or target_value is None:
|
||||
return False
|
||||
if field_type == "date":
|
||||
current_dt, current_date_only = TriggerService._parse_datetime_value(current_value)
|
||||
target_dt, target_date_only = TriggerService._parse_datetime_value(target_value)
|
||||
if not current_dt or not target_dt:
|
||||
return False
|
||||
if current_date_only or target_date_only:
|
||||
return current_dt.date() < target_dt.date()
|
||||
return current_dt < target_dt
|
||||
if field_type == "number":
|
||||
current_num = TriggerService._parse_number_value(current_value)
|
||||
target_num = TriggerService._parse_number_value(target_value)
|
||||
if current_num is None or target_num is None:
|
||||
return False
|
||||
return current_num < target_num
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _compare_after(current_value: Any, target_value: Any, field_type: Optional[str]) -> bool:
|
||||
if current_value is None or target_value is None:
|
||||
return False
|
||||
if field_type == "date":
|
||||
current_dt, current_date_only = TriggerService._parse_datetime_value(current_value)
|
||||
target_dt, target_date_only = TriggerService._parse_datetime_value(target_value)
|
||||
if not current_dt or not target_dt:
|
||||
return False
|
||||
if current_date_only or target_date_only:
|
||||
return current_dt.date() > target_dt.date()
|
||||
return current_dt > target_dt
|
||||
if field_type == "number":
|
||||
current_num = TriggerService._parse_number_value(current_value)
|
||||
target_num = TriggerService._parse_number_value(target_value)
|
||||
if current_num is None or target_num is None:
|
||||
return False
|
||||
return current_num > target_num
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _compare_in(current_value: Any, target_value: Any, field_type: Optional[str]) -> bool:
|
||||
if current_value is None or target_value is None:
|
||||
return False
|
||||
if field_type == "date":
|
||||
if not isinstance(target_value, dict):
|
||||
return False
|
||||
start_dt, start_date_only = TriggerService._parse_datetime_value(target_value.get("start"))
|
||||
end_dt, end_date_only = TriggerService._parse_datetime_value(target_value.get("end"))
|
||||
current_dt, current_date_only = TriggerService._parse_datetime_value(current_value)
|
||||
if not start_dt or not end_dt or not current_dt:
|
||||
return False
|
||||
date_only = current_date_only or start_date_only or end_date_only
|
||||
if date_only:
|
||||
current_date = current_dt.date()
|
||||
return start_dt.date() <= current_date <= end_dt.date()
|
||||
return start_dt <= current_dt <= end_dt
|
||||
if isinstance(target_value, (list, tuple, set)):
|
||||
if field_type == "number":
|
||||
current_num = TriggerService._parse_number_value(current_value)
|
||||
if current_num is None:
|
||||
return False
|
||||
for item in target_value:
|
||||
item_num = TriggerService._parse_number_value(item)
|
||||
if item_num is not None and item_num == current_num:
|
||||
return True
|
||||
return False
|
||||
return str(current_value) in {str(item) for item in target_value if item is not None}
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _parse_datetime_value(value: Any) -> Tuple[Optional[datetime], bool]:
|
||||
if value is None:
|
||||
return None, False
|
||||
if isinstance(value, datetime):
|
||||
return value, False
|
||||
if isinstance(value, date):
|
||||
return datetime.combine(value, datetime.min.time()), True
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
if len(value) == 10:
|
||||
parsed = datetime.strptime(value, "%Y-%m-%d")
|
||||
return parsed, True
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
return parsed.replace(tzinfo=None), False
|
||||
except ValueError:
|
||||
return None, False
|
||||
return None, False
|
||||
|
||||
@staticmethod
|
||||
def _parse_number_value(value: Any) -> Optional[Decimal]:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return Decimal(str(value))
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _execute_actions(
|
||||
db: Session,
|
||||
@@ -185,21 +511,19 @@ class TriggerService:
|
||||
target = action.get("target", "assignee")
|
||||
template = action.get("template", "任務 {task_title} 已觸發自動化規則")
|
||||
|
||||
# Resolve target user
|
||||
target_user_id = TriggerService._resolve_target(task, target)
|
||||
if not target_user_id:
|
||||
return
|
||||
|
||||
# Don't notify the user who triggered the action
|
||||
if target_user_id == current_user.id:
|
||||
recipients = TriggerService._resolve_targets(db, task, target)
|
||||
if not recipients:
|
||||
return
|
||||
|
||||
# Format message with variables
|
||||
message = TriggerService._format_template(template, task, old_values, new_values)
|
||||
|
||||
for user_id in recipients:
|
||||
if user_id == current_user.id:
|
||||
continue
|
||||
NotificationService.create_notification(
|
||||
db=db,
|
||||
user_id=target_user_id,
|
||||
user_id=user_id,
|
||||
notification_type="status_change",
|
||||
reference_type="task",
|
||||
reference_id=task.id,
|
||||
@@ -208,17 +532,55 @@ class TriggerService:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_target(task: Task, target: str) -> Optional[str]:
|
||||
"""Resolve notification target to user ID."""
|
||||
def _resolve_targets(db: Session, task: Task, target: str) -> List[str]:
|
||||
"""Resolve notification target to user IDs."""
|
||||
recipients: Set[str] = set()
|
||||
|
||||
if target == "assignee":
|
||||
return task.assignee_id
|
||||
if task.assignee_id:
|
||||
recipients.add(task.assignee_id)
|
||||
elif target == "creator":
|
||||
return task.created_by
|
||||
if task.created_by:
|
||||
recipients.add(task.created_by)
|
||||
elif target == "project_owner":
|
||||
return task.project.owner_id if task.project else None
|
||||
if task.project and task.project.owner_id:
|
||||
recipients.add(task.project.owner_id)
|
||||
elif target == "project_members":
|
||||
if task.project:
|
||||
if task.project.owner_id:
|
||||
recipients.add(task.project.owner_id)
|
||||
member_rows = db.query(ProjectMember.user_id).join(
|
||||
User,
|
||||
User.id == ProjectMember.user_id,
|
||||
).filter(
|
||||
ProjectMember.project_id == task.project_id,
|
||||
User.is_active == True,
|
||||
).all()
|
||||
recipients.update(row[0] for row in member_rows if row and row[0])
|
||||
elif target.startswith("department:"):
|
||||
department_id = target.split(":", 1)[1]
|
||||
if department_id:
|
||||
user_rows = db.query(User.id).filter(
|
||||
User.department_id == department_id,
|
||||
User.is_active == True,
|
||||
).all()
|
||||
recipients.update(row[0] for row in user_rows if row and row[0])
|
||||
elif target.startswith("role:"):
|
||||
role_name = target.split(":", 1)[1].strip()
|
||||
if role_name:
|
||||
role = db.query(Role).filter(func.lower(Role.name) == role_name.lower()).first()
|
||||
if role:
|
||||
user_rows = db.query(User.id).filter(
|
||||
User.role_id == role.id,
|
||||
User.is_active == True,
|
||||
).all()
|
||||
recipients.update(row[0] for row in user_rows if row and row[0])
|
||||
elif target.startswith("user:"):
|
||||
return target.split(":", 1)[1]
|
||||
return None
|
||||
user_id = target.split(":", 1)[1]
|
||||
if user_id:
|
||||
recipients.add(user_id)
|
||||
|
||||
return list(recipients)
|
||||
|
||||
@staticmethod
|
||||
def _format_template(
|
||||
|
||||
@@ -42,7 +42,9 @@ def _serialize_workload_summary(summary: UserWorkloadSummary) -> dict:
|
||||
"department_name": summary.department_name,
|
||||
"capacity_hours": str(summary.capacity_hours),
|
||||
"allocated_hours": str(summary.allocated_hours),
|
||||
"load_percentage": str(summary.load_percentage) if summary.load_percentage else None,
|
||||
"load_percentage": (
|
||||
str(summary.load_percentage) if summary.load_percentage is not None else None
|
||||
),
|
||||
"load_level": summary.load_level.value,
|
||||
"task_count": summary.task_count,
|
||||
}
|
||||
|
||||
@@ -42,6 +42,26 @@ def get_current_week_start() -> date:
|
||||
return get_week_bounds(date.today())[0]
|
||||
|
||||
|
||||
def get_current_week_bounds() -> Tuple[date, date]:
|
||||
"""
|
||||
Get current week bounds for default views.
|
||||
|
||||
On Sundays, extend the window to include the upcoming week so that
|
||||
"tomorrow" tasks are still visible in default views.
|
||||
"""
|
||||
week_start, week_end = get_week_bounds(date.today())
|
||||
if date.today().weekday() == 6:
|
||||
week_end = week_end + timedelta(days=7)
|
||||
return week_start, week_end
|
||||
|
||||
|
||||
def _extend_week_end_if_sunday(week_start: date, week_end: date) -> Tuple[date, date]:
|
||||
"""Extend week window on Sunday to include upcoming week."""
|
||||
if date.today().weekday() == 6 and week_start == get_current_week_start():
|
||||
return week_start, week_end + timedelta(days=7)
|
||||
return week_start, week_end
|
||||
|
||||
|
||||
def determine_load_level(load_percentage: Optional[Decimal]) -> LoadLevel:
|
||||
"""
|
||||
Determine the load level based on percentage.
|
||||
@@ -149,7 +169,7 @@ def calculate_user_workload(
|
||||
if task.original_estimate:
|
||||
allocated_hours += task.original_estimate
|
||||
|
||||
capacity_hours = Decimal(str(user.capacity)) if user.capacity else Decimal("40")
|
||||
capacity_hours = Decimal(str(user.capacity)) if user.capacity is not None else Decimal("40")
|
||||
load_percentage = calculate_load_percentage(allocated_hours, capacity_hours)
|
||||
load_level = determine_load_level(load_percentage)
|
||||
|
||||
@@ -191,11 +211,11 @@ def get_workload_heatmap(
|
||||
|
||||
if week_start is None:
|
||||
week_start = get_current_week_start()
|
||||
else:
|
||||
|
||||
# Normalize to week start (Monday)
|
||||
week_start = get_week_bounds(week_start)[0]
|
||||
|
||||
week_start, week_end = get_week_bounds(week_start)
|
||||
week_start, week_end = _extend_week_end_if_sunday(week_start, week_end)
|
||||
|
||||
# Build user query
|
||||
query = db.query(User).filter(User.is_active == True)
|
||||
@@ -245,7 +265,7 @@ def get_workload_heatmap(
|
||||
if task.original_estimate:
|
||||
allocated_hours += task.original_estimate
|
||||
|
||||
capacity_hours = Decimal(str(user.capacity)) if user.capacity else Decimal("40")
|
||||
capacity_hours = Decimal(str(user.capacity)) if user.capacity is not None else Decimal("40")
|
||||
load_percentage = calculate_load_percentage(allocated_hours, capacity_hours)
|
||||
load_level = determine_load_level(load_percentage)
|
||||
|
||||
@@ -297,10 +317,9 @@ def get_user_workload_detail(
|
||||
|
||||
if week_start is None:
|
||||
week_start = get_current_week_start()
|
||||
else:
|
||||
week_start = get_week_bounds(week_start)[0]
|
||||
|
||||
week_start, week_end = get_week_bounds(week_start)
|
||||
week_start, week_end = _extend_week_end_if_sunday(week_start, week_end)
|
||||
|
||||
# Get tasks
|
||||
tasks = get_user_tasks_in_week(db, user_id, week_start, week_end)
|
||||
@@ -323,7 +342,7 @@ def get_user_workload_detail(
|
||||
status=task.status.name if task.status else None,
|
||||
))
|
||||
|
||||
capacity_hours = Decimal(str(user.capacity)) if user.capacity else Decimal("40")
|
||||
capacity_hours = Decimal(str(user.capacity)) if user.capacity is not None else Decimal("40")
|
||||
load_percentage = calculate_load_percentage(allocated_hours, capacity_hours)
|
||||
load_level = determine_load_level(load_percentage)
|
||||
|
||||
|
||||
@@ -24,6 +24,11 @@ engine = create_engine(
|
||||
)
|
||||
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# Ensure app code paths that use SessionLocal directly hit the test DB
|
||||
from app.core import database as database_module
|
||||
database_module.engine = engine
|
||||
database_module.SessionLocal = TestingSessionLocal
|
||||
|
||||
|
||||
class MockRedis:
|
||||
"""Mock Redis client for testing."""
|
||||
@@ -102,7 +107,11 @@ def db():
|
||||
@pytest.fixture(scope="function")
|
||||
def mock_redis():
|
||||
"""Create mock Redis for testing."""
|
||||
return MockRedis()
|
||||
from app.core import redis as redis_module
|
||||
client = redis_module.redis_client
|
||||
if hasattr(client, "store"):
|
||||
client.store.clear()
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.api.dashboard.router import (
|
||||
get_workload_summary,
|
||||
get_health_summary,
|
||||
)
|
||||
from app.services.workload_service import get_week_bounds
|
||||
from app.schemas.workload import LoadLevel
|
||||
|
||||
|
||||
@@ -99,15 +100,16 @@ class TestTaskStatistics:
|
||||
):
|
||||
"""Helper to create a task with optional characteristics."""
|
||||
now = datetime.utcnow()
|
||||
week_start, week_end = get_week_bounds(now.date())
|
||||
|
||||
if overdue:
|
||||
due_date = now - timedelta(days=3)
|
||||
due_date = datetime.combine(week_start, datetime.min.time()) - timedelta(days=1)
|
||||
elif due_this_week:
|
||||
# Due in the middle of current week
|
||||
due_date = now + timedelta(days=2)
|
||||
due_date = datetime.combine(week_start, datetime.min.time()) + timedelta(days=2)
|
||||
else:
|
||||
# Due next week
|
||||
due_date = now + timedelta(days=10)
|
||||
due_date = datetime.combine(week_end, datetime.min.time()) + timedelta(days=2)
|
||||
|
||||
task = Task(
|
||||
id=task_id,
|
||||
@@ -313,13 +315,26 @@ class TestWorkloadSummary:
|
||||
assert workload.load_percentage == Decimal("0.00")
|
||||
assert workload.load_level == LoadLevel.NORMAL
|
||||
|
||||
def test_zero_capacity(self, db):
|
||||
"""User with zero capacity should show unavailable load level."""
|
||||
data = self.setup_test_data(db)
|
||||
data["user"].capacity = 0
|
||||
db.commit()
|
||||
|
||||
workload = get_workload_summary(db, data["user"])
|
||||
|
||||
assert workload.capacity_hours == Decimal("0")
|
||||
assert workload.load_percentage is None
|
||||
assert workload.load_level == LoadLevel.UNAVAILABLE
|
||||
|
||||
def test_workload_with_tasks(self, db):
|
||||
"""Should calculate correct allocated hours."""
|
||||
data = self.setup_test_data(db)
|
||||
|
||||
# Create tasks due this week with estimates
|
||||
now = datetime.utcnow()
|
||||
due_date = now + timedelta(days=2)
|
||||
week_start, _ = get_week_bounds(now.date())
|
||||
due_date = datetime.combine(week_start, datetime.min.time()) + timedelta(days=2)
|
||||
|
||||
task1 = Task(
|
||||
id="task-wl-1",
|
||||
@@ -359,7 +374,8 @@ class TestWorkloadSummary:
|
||||
data = self.setup_test_data(db)
|
||||
|
||||
now = datetime.utcnow()
|
||||
due_date = now + timedelta(days=2)
|
||||
week_start, _ = get_week_bounds(now.date())
|
||||
due_date = datetime.combine(week_start, datetime.min.time()) + timedelta(days=2)
|
||||
|
||||
# Create task with 48h estimate (> 40h capacity)
|
||||
task = Task(
|
||||
@@ -508,6 +524,7 @@ class TestDashboardAPI:
|
||||
|
||||
# Create a task for the admin user
|
||||
now = datetime.utcnow()
|
||||
week_start, _ = get_week_bounds(now.date())
|
||||
task = Task(
|
||||
id="task-api-dash-001",
|
||||
project_id="project-api-dash-001",
|
||||
@@ -515,7 +532,7 @@ class TestDashboardAPI:
|
||||
assignee_id="00000000-0000-0000-0000-000000000001",
|
||||
status_id="status-api-dash-todo",
|
||||
original_estimate=Decimal("8"),
|
||||
due_date=now + timedelta(days=2),
|
||||
due_date=datetime.combine(week_start, datetime.min.time()) + timedelta(days=2),
|
||||
created_by="00000000-0000-0000-0000-000000000001",
|
||||
is_deleted=False,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from app.models import User, Space, Project, Task, TaskStatus, ScheduledReport, ReportHistory, Blocker
|
||||
from app.models import User, Space, Project, Task, TaskStatus, ScheduledReport, ReportHistory, Blocker, ProjectMember
|
||||
from app.services.report_service import ReportService
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ def test_statuses(db, test_project):
|
||||
name="To Do",
|
||||
color="#808080",
|
||||
position=0,
|
||||
is_done=False,
|
||||
)
|
||||
in_progress = TaskStatus(
|
||||
id=str(uuid.uuid4()),
|
||||
@@ -83,6 +84,7 @@ def test_statuses(db, test_project):
|
||||
name="In Progress",
|
||||
color="#0000FF",
|
||||
position=1,
|
||||
is_done=False,
|
||||
)
|
||||
done = TaskStatus(
|
||||
id=str(uuid.uuid4()),
|
||||
@@ -90,6 +92,7 @@ def test_statuses(db, test_project):
|
||||
name="Done",
|
||||
color="#00FF00",
|
||||
position=2,
|
||||
is_done=True,
|
||||
)
|
||||
db.add_all([todo, in_progress, done])
|
||||
db.commit()
|
||||
@@ -165,12 +168,90 @@ class TestReportService:
|
||||
stats = ReportService.get_weekly_stats(db, test_user.id)
|
||||
|
||||
assert stats["summary"]["completed_count"] == 1
|
||||
assert stats["summary"]["in_progress_count"] == 1
|
||||
assert stats["summary"]["in_progress_count"] == 2
|
||||
assert stats["summary"]["overdue_count"] == 1
|
||||
assert stats["summary"]["total_tasks"] == 3
|
||||
assert len(stats["projects"]) == 1
|
||||
assert stats["projects"][0]["project_title"] == "Report Test Project"
|
||||
|
||||
def test_weekly_stats_includes_project_members(self, db, test_user, test_space):
|
||||
"""Project member should receive weekly stats for member projects."""
|
||||
other_owner = User(
|
||||
id=str(uuid.uuid4()),
|
||||
email="owner2@example.com",
|
||||
name="Other Owner",
|
||||
role_id="00000000-0000-0000-0000-000000000003",
|
||||
is_active=True,
|
||||
is_system_admin=False,
|
||||
)
|
||||
db.add(other_owner)
|
||||
db.commit()
|
||||
|
||||
member_project = Project(
|
||||
id=str(uuid.uuid4()),
|
||||
space_id=test_space.id,
|
||||
title="Member Project",
|
||||
description="Project for member stats",
|
||||
owner_id=other_owner.id,
|
||||
)
|
||||
db.add(member_project)
|
||||
db.commit()
|
||||
|
||||
db.add(ProjectMember(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=member_project.id,
|
||||
user_id=test_user.id,
|
||||
role="member",
|
||||
added_by=other_owner.id,
|
||||
))
|
||||
db.commit()
|
||||
|
||||
member_status = TaskStatus(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=member_project.id,
|
||||
name="In Progress",
|
||||
color="#0000FF",
|
||||
position=0,
|
||||
is_done=False,
|
||||
)
|
||||
db.add(member_status)
|
||||
db.commit()
|
||||
|
||||
task = Task(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=member_project.id,
|
||||
title="Member Task",
|
||||
status_id=member_status.id,
|
||||
created_by=other_owner.id,
|
||||
)
|
||||
db.add(task)
|
||||
db.commit()
|
||||
|
||||
stats = ReportService.get_weekly_stats(db, test_user.id)
|
||||
project_titles = {project["project_title"] for project in stats["projects"]}
|
||||
|
||||
assert "Member Project" in project_titles
|
||||
|
||||
def test_completed_task_outside_week_not_counted(self, db, test_user, test_project, test_statuses):
|
||||
"""Completed tasks outside the week window should not be counted."""
|
||||
week_start = ReportService.get_week_start()
|
||||
week_end = week_start + timedelta(days=7)
|
||||
|
||||
task = Task(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=test_project.id,
|
||||
title="Completed Outside Week",
|
||||
status_id=test_statuses["done"].id,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
task.updated_at = week_end + timedelta(days=1)
|
||||
db.add(task)
|
||||
db.commit()
|
||||
|
||||
stats = ReportService.get_weekly_stats(db, test_user.id, week_start)
|
||||
|
||||
assert stats["summary"]["completed_count"] == 0
|
||||
|
||||
def test_generate_weekly_report(self, db, test_user, test_project, test_tasks, test_statuses):
|
||||
"""Test generating a weekly report."""
|
||||
report = ReportService.generate_weekly_report(db, test_user.id)
|
||||
@@ -216,6 +297,45 @@ class TestReportAPI:
|
||||
assert "report_id" in data
|
||||
assert "summary" in data
|
||||
|
||||
def test_weekly_report_subscription_toggle(self, client, test_user_token, db, test_user):
|
||||
"""Test weekly report subscription toggle endpoints."""
|
||||
response = client.get(
|
||||
"/api/reports/weekly/subscription",
|
||||
headers={"Authorization": f"Bearer {test_user_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["is_active"] is False
|
||||
|
||||
response = client.put(
|
||||
"/api/reports/weekly/subscription",
|
||||
headers={"Authorization": f"Bearer {test_user_token}"},
|
||||
json={"is_active": True},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["is_active"] is True
|
||||
|
||||
response = client.get(
|
||||
"/api/reports/weekly/subscription",
|
||||
headers={"Authorization": f"Bearer {test_user_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["is_active"] is True
|
||||
|
||||
response = client.put(
|
||||
"/api/reports/weekly/subscription",
|
||||
headers={"Authorization": f"Bearer {test_user_token}"},
|
||||
json={"is_active": False},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["is_active"] is False
|
||||
|
||||
scheduled = db.query(ScheduledReport).filter(
|
||||
ScheduledReport.recipient_id == test_user.id,
|
||||
ScheduledReport.report_type == "weekly",
|
||||
).first()
|
||||
assert scheduled is not None
|
||||
assert scheduled.is_active is False
|
||||
|
||||
def test_list_report_history_empty(self, client, test_user_token):
|
||||
"""Test listing report history when empty."""
|
||||
response = client.get(
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import pytest
|
||||
import uuid
|
||||
from app.models import User, Space, Project, Task, TaskStatus, Trigger, TriggerLog, Notification
|
||||
from datetime import datetime
|
||||
from app.models import (
|
||||
User, Space, Project, Task, TaskStatus, Trigger, TriggerLog, Notification,
|
||||
CustomField, ProjectMember, Department, Role
|
||||
)
|
||||
from app.services.trigger_service import TriggerService
|
||||
from app.services.custom_value_service import CustomValueService
|
||||
from app.schemas.task import CustomValueInput
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -188,6 +194,39 @@ class TestTriggerService:
|
||||
result = TriggerService._check_conditions(conditions, old_values, new_values)
|
||||
assert result is True
|
||||
|
||||
def test_check_conditions_composite_and(self, db, test_status):
|
||||
"""Test composite AND conditions with one unchanged rule."""
|
||||
conditions = {
|
||||
"logic": "and",
|
||||
"rules": [
|
||||
{"field": "status_id", "operator": "changed_to", "value": test_status[1].id},
|
||||
{"field": "priority", "operator": "equals", "value": "high"},
|
||||
],
|
||||
}
|
||||
old_values = {"status_id": test_status[0].id, "priority": "high"}
|
||||
new_values = {"status_id": test_status[1].id, "priority": "high"}
|
||||
|
||||
result = TriggerService._check_conditions(conditions, old_values, new_values)
|
||||
assert result is True
|
||||
|
||||
def test_check_conditions_due_date_in_range_inclusive(self, db):
|
||||
"""Test due_date in range operator is inclusive."""
|
||||
conditions = {
|
||||
"logic": "and",
|
||||
"rules": [
|
||||
{
|
||||
"field": "due_date",
|
||||
"operator": "in",
|
||||
"value": {"start": "2024-01-01", "end": "2024-01-15"},
|
||||
}
|
||||
],
|
||||
}
|
||||
old_values = {"due_date": datetime(2024, 1, 10)}
|
||||
new_values = {"due_date": datetime(2024, 1, 15)}
|
||||
|
||||
result = TriggerService._check_conditions(conditions, old_values, new_values)
|
||||
assert result is True
|
||||
|
||||
def test_evaluate_triggers_creates_notification(self, db, test_task, test_trigger, test_user, test_status):
|
||||
"""Test that evaluate_triggers creates notification when conditions match."""
|
||||
# Create another user to receive notification
|
||||
@@ -229,6 +268,247 @@ class TestTriggerService:
|
||||
|
||||
assert len(logs) == 0
|
||||
|
||||
def test_custom_field_formula_condition(self, db, test_task, test_project, test_user):
|
||||
"""Test formula custom field conditions are evaluated."""
|
||||
number_field = CustomField(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=test_project.id,
|
||||
name="Points",
|
||||
field_type="number",
|
||||
position=0,
|
||||
)
|
||||
formula_field = CustomField(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=test_project.id,
|
||||
name="Double Points",
|
||||
field_type="formula",
|
||||
formula="{Points} * 2",
|
||||
position=1,
|
||||
)
|
||||
db.add_all([number_field, formula_field])
|
||||
db.commit()
|
||||
|
||||
CustomValueService.save_custom_values(
|
||||
db,
|
||||
test_task,
|
||||
[CustomValueInput(field_id=number_field.id, value=3)],
|
||||
)
|
||||
db.commit()
|
||||
|
||||
old_custom_values = {
|
||||
cv.field_id: cv.value
|
||||
for cv in CustomValueService.get_custom_values_for_task(db, test_task)
|
||||
}
|
||||
|
||||
CustomValueService.save_custom_values(
|
||||
db,
|
||||
test_task,
|
||||
[CustomValueInput(field_id=number_field.id, value=4)],
|
||||
)
|
||||
db.commit()
|
||||
|
||||
new_custom_values = {
|
||||
cv.field_id: cv.value
|
||||
for cv in CustomValueService.get_custom_values_for_task(db, test_task)
|
||||
}
|
||||
|
||||
trigger = Trigger(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=test_project.id,
|
||||
name="Formula Trigger",
|
||||
description="Notify when formula changes to 8",
|
||||
trigger_type="field_change",
|
||||
conditions={
|
||||
"field": "custom_fields",
|
||||
"field_id": formula_field.id,
|
||||
"operator": "changed_to",
|
||||
"value": "8",
|
||||
},
|
||||
actions=[{"type": "notify", "target": f"user:{test_user.id}"}],
|
||||
is_active=True,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
db.add(trigger)
|
||||
db.commit()
|
||||
|
||||
logs = TriggerService.evaluate_triggers(
|
||||
db,
|
||||
test_task,
|
||||
{"custom_fields": old_custom_values},
|
||||
{"custom_fields": new_custom_values},
|
||||
test_user,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert len(logs) == 1
|
||||
assert logs[0].status == "success"
|
||||
|
||||
|
||||
class TestTriggerNotifications:
|
||||
"""Tests for trigger notification target resolution."""
|
||||
|
||||
def test_notify_project_members_excludes_triggerer(self, db, test_task, test_project, test_user, test_status):
|
||||
member_user = User(
|
||||
id=str(uuid.uuid4()),
|
||||
email="member@example.com",
|
||||
name="Member User",
|
||||
role_id="00000000-0000-0000-0000-000000000003",
|
||||
is_active=True,
|
||||
)
|
||||
other_member = User(
|
||||
id=str(uuid.uuid4()),
|
||||
email="member2@example.com",
|
||||
name="Other Member",
|
||||
role_id="00000000-0000-0000-0000-000000000003",
|
||||
is_active=True,
|
||||
)
|
||||
db.add_all([member_user, other_member])
|
||||
db.commit()
|
||||
|
||||
db.add_all([
|
||||
ProjectMember(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=test_project.id,
|
||||
user_id=member_user.id,
|
||||
role="member",
|
||||
added_by=test_user.id,
|
||||
),
|
||||
ProjectMember(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=test_project.id,
|
||||
user_id=other_member.id,
|
||||
role="member",
|
||||
added_by=test_user.id,
|
||||
),
|
||||
ProjectMember(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=test_project.id,
|
||||
user_id=test_user.id,
|
||||
role="member",
|
||||
added_by=test_user.id,
|
||||
),
|
||||
])
|
||||
db.commit()
|
||||
|
||||
trigger = Trigger(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=test_project.id,
|
||||
name="Project Members Trigger",
|
||||
description="Notify all project members",
|
||||
trigger_type="field_change",
|
||||
conditions={
|
||||
"field": "status_id",
|
||||
"operator": "changed_to",
|
||||
"value": test_status[1].id,
|
||||
},
|
||||
actions=[{"type": "notify", "target": "project_members"}],
|
||||
is_active=True,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
db.add(trigger)
|
||||
db.commit()
|
||||
|
||||
logs = TriggerService.evaluate_triggers(
|
||||
db,
|
||||
test_task,
|
||||
{"status_id": test_status[0].id},
|
||||
{"status_id": test_status[1].id},
|
||||
member_user,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert len(logs) == 1
|
||||
assert db.query(Notification).filter(Notification.user_id == member_user.id).count() == 0
|
||||
assert db.query(Notification).filter(Notification.user_id == other_member.id).count() == 1
|
||||
assert db.query(Notification).filter(Notification.user_id == test_user.id).count() == 1
|
||||
|
||||
def test_notify_department_and_role_targets(self, db, test_task, test_project, test_user, test_status):
|
||||
department = Department(
|
||||
id=str(uuid.uuid4()),
|
||||
name="QA Department",
|
||||
)
|
||||
qa_role = Role(
|
||||
id=str(uuid.uuid4()),
|
||||
name="qa",
|
||||
permissions={},
|
||||
is_system_role=False,
|
||||
)
|
||||
db.add_all([department, qa_role])
|
||||
db.commit()
|
||||
|
||||
triggerer = User(
|
||||
id=str(uuid.uuid4()),
|
||||
email="qa_lead@example.com",
|
||||
name="QA Lead",
|
||||
role_id=qa_role.id,
|
||||
department_id=department.id,
|
||||
is_active=True,
|
||||
)
|
||||
dept_user = User(
|
||||
id=str(uuid.uuid4()),
|
||||
email="dept_user@example.com",
|
||||
name="Dept User",
|
||||
role_id="00000000-0000-0000-0000-000000000003",
|
||||
department_id=department.id,
|
||||
is_active=True,
|
||||
)
|
||||
role_user = User(
|
||||
id=str(uuid.uuid4()),
|
||||
email="role_user@example.com",
|
||||
name="Role User",
|
||||
role_id=qa_role.id,
|
||||
department_id=None,
|
||||
is_active=True,
|
||||
)
|
||||
db.add_all([triggerer, dept_user, role_user])
|
||||
db.commit()
|
||||
|
||||
dept_trigger = Trigger(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=test_project.id,
|
||||
name="Department Trigger",
|
||||
description="Notify department",
|
||||
trigger_type="field_change",
|
||||
conditions={
|
||||
"field": "status_id",
|
||||
"operator": "changed_to",
|
||||
"value": test_status[1].id,
|
||||
},
|
||||
actions=[{"type": "notify", "target": f"department:{department.id}"}],
|
||||
is_active=True,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
role_trigger = Trigger(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=test_project.id,
|
||||
name="Role Trigger",
|
||||
description="Notify role",
|
||||
trigger_type="field_change",
|
||||
conditions={
|
||||
"field": "status_id",
|
||||
"operator": "changed_to",
|
||||
"value": test_status[1].id,
|
||||
},
|
||||
actions=[{"type": "notify", "target": f"role:{qa_role.name}"}],
|
||||
is_active=True,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
db.add_all([dept_trigger, role_trigger])
|
||||
db.commit()
|
||||
|
||||
TriggerService.evaluate_triggers(
|
||||
db,
|
||||
test_task,
|
||||
{"status_id": test_status[0].id},
|
||||
{"status_id": test_status[1].id},
|
||||
triggerer,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert db.query(Notification).filter(Notification.user_id == triggerer.id).count() == 0
|
||||
assert db.query(Notification).filter(Notification.user_id == dept_user.id).count() == 1
|
||||
assert db.query(Notification).filter(Notification.user_id == role_user.id).count() == 1
|
||||
|
||||
|
||||
class TestTriggerAPI:
|
||||
"""Tests for Trigger API endpoints."""
|
||||
|
||||
@@ -195,6 +195,19 @@ class TestWorkloadService:
|
||||
assert summary.load_level == LoadLevel.NORMAL
|
||||
assert summary.task_count == 0
|
||||
|
||||
def test_calculate_user_workload_zero_capacity(self, db):
|
||||
"""User with zero capacity should return unavailable load level."""
|
||||
data = self.setup_test_data(db)
|
||||
data["engineer"].capacity = 0
|
||||
db.commit()
|
||||
|
||||
week_start = date(2024, 1, 1)
|
||||
summary = calculate_user_workload(db, data["engineer"], week_start)
|
||||
|
||||
assert summary.capacity_hours == Decimal("0")
|
||||
assert summary.load_percentage is None
|
||||
assert summary.load_level == LoadLevel.UNAVAILABLE
|
||||
|
||||
def test_calculate_user_workload_with_tasks(self, db):
|
||||
"""User with tasks should have correct allocated hours."""
|
||||
data = self.setup_test_data(db)
|
||||
@@ -445,6 +458,7 @@ class TestWorkloadAccessControl:
|
||||
def setup_test_data(self, db, mock_redis):
|
||||
"""Set up test data with two departments."""
|
||||
from app.core.security import create_access_token, create_token_payload
|
||||
from app.services.workload_service import get_current_week_start
|
||||
|
||||
# Create departments
|
||||
dept_rd = Department(id="dept-rd", name="R&D")
|
||||
@@ -478,6 +492,38 @@ class TestWorkloadAccessControl:
|
||||
)
|
||||
db.add(engineer_ops)
|
||||
|
||||
# Create space and project for workload task
|
||||
space = Space(
|
||||
id="space-wl-acl-001",
|
||||
name="Workload ACL Space",
|
||||
owner_id="00000000-0000-0000-0000-000000000001",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(space)
|
||||
|
||||
project = Project(
|
||||
id="project-wl-acl-001",
|
||||
space_id=space.id,
|
||||
title="Workload ACL Project",
|
||||
owner_id="00000000-0000-0000-0000-000000000001",
|
||||
department_id=dept_rd.id,
|
||||
security_level="department",
|
||||
)
|
||||
db.add(project)
|
||||
|
||||
# Create a task for the R&D engineer so they appear in heatmap
|
||||
week_start = get_current_week_start()
|
||||
due_date = datetime.combine(week_start, datetime.min.time()) + timedelta(days=2)
|
||||
task = Task(
|
||||
id="task-wl-acl-001",
|
||||
project_id=project.id,
|
||||
title="Workload ACL Task",
|
||||
assignee_id=engineer_rd.id,
|
||||
due_date=due_date,
|
||||
created_by="00000000-0000-0000-0000-000000000001",
|
||||
)
|
||||
db.add(task)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Create token for R&D engineer
|
||||
@@ -514,6 +560,18 @@ class TestWorkloadAccessControl:
|
||||
assert len(result["users"]) == 1
|
||||
assert result["users"][0]["user_id"] == "user-rd-001"
|
||||
|
||||
def test_regular_user_cannot_filter_other_user_ids(self, client, db, mock_redis):
|
||||
"""Regular user should not filter workload for other users."""
|
||||
data = self.setup_test_data(db, mock_redis)
|
||||
user_ids = f"{data['engineer_rd'].id},{data['engineer_ops'].id}"
|
||||
|
||||
response = client.get(
|
||||
f"/api/workload/heatmap?user_ids={user_ids}",
|
||||
headers={"Authorization": f"Bearer {data['rd_token']}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_regular_user_cannot_access_other_department(self, client, db, mock_redis):
|
||||
"""Regular user should not access other department's workload."""
|
||||
data = self.setup_test_data(db, mock_redis)
|
||||
|
||||
252
frontend/e2e/admin-flow.spec.ts
Normal file
252
frontend/e2e/admin-flow.spec.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { expect, test, Page } from 'playwright/test'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const runId = new Date().toISOString().replace(/[-:.TZ]/g, '')
|
||||
const spaceName = `E2E Space ${runId}`
|
||||
const projectName = `E2E Project ${runId}`
|
||||
const taskOneTitle = `E2E Task One ${runId}`
|
||||
const taskTwoTitle = `E2E Task Two ${runId}`
|
||||
const subtaskTitle = `E2E Subtask ${runId}`
|
||||
const commentText = `E2E Comment ${runId}`
|
||||
const customNumberField = `sp_${runId}`
|
||||
const customFormulaField = `double_sp_${runId}`
|
||||
const attachmentPath = path.join(__dirname, 'fixtures', 'attachment.txt')
|
||||
|
||||
const formatDateInput = (date: Date) => {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
const setEnglishLocale = async (page: Page) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('i18nextLng', 'en')
|
||||
})
|
||||
}
|
||||
|
||||
const openProjectFromSpaces = async (page: Page) => {
|
||||
await page.goto('/spaces')
|
||||
await expect(page.getByRole('heading', { name: 'Spaces' })).toBeVisible()
|
||||
await page.getByRole('button', { name: `Spaces: ${spaceName}` }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible()
|
||||
await page.getByRole('button', { name: `Projects: ${projectName}` }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Tasks' })).toBeVisible()
|
||||
}
|
||||
|
||||
test.describe.serial('admin e2e flow', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
})
|
||||
|
||||
test('login and dashboard', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await expect(page.getByRole('heading', { name: /Welcome back/i })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Quick Actions' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('create space and project', async ({ page }) => {
|
||||
await page.goto('/spaces')
|
||||
await expect(page.getByRole('heading', { name: 'Spaces' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: /Create Space/i }).click()
|
||||
const spaceModal = page.getByRole('dialog', { name: 'Create Space' })
|
||||
await spaceModal.getByLabel('Name').fill(spaceName)
|
||||
await spaceModal.getByLabel('Description').fill('E2E space description')
|
||||
await spaceModal.getByRole('button', { name: 'Create' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: `Spaces: ${spaceName}` })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: `Spaces: ${spaceName}` }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: /New Project/i }).click()
|
||||
const projectModal = page.getByRole('dialog', { name: 'Create Project' })
|
||||
await projectModal.getByText('Blank Project', { exact: true }).click()
|
||||
await projectModal.locator('#project-title').fill(projectName)
|
||||
await projectModal.locator('#project-description').fill('E2E project description')
|
||||
await projectModal.getByRole('button', { name: 'Create' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: `Projects: ${projectName}` })).toBeVisible()
|
||||
await page.getByRole('button', { name: `Projects: ${projectName}` }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Tasks' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('project settings: members and custom fields', async ({ page }) => {
|
||||
await openProjectFromSpaces(page)
|
||||
|
||||
await page.getByRole('button', { name: 'Settings' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Project Settings' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Members' }).click()
|
||||
await expect(page.getByText('Member Management')).toBeVisible()
|
||||
await expect(page.getByText('User')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Custom Fields' }).click()
|
||||
await expect(page.getByRole('button', { name: /Add Field/i })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: /Add Field/i }).click()
|
||||
const customModal = page.getByRole('dialog', { name: 'Create Field' })
|
||||
await customModal.getByPlaceholder('e.g., Story Points, Sprint Number').fill(customNumberField)
|
||||
await customModal.getByText('Number', { exact: true }).click()
|
||||
await customModal.getByRole('button', { name: 'Create Field' }).click()
|
||||
await expect(page.getByText(customNumberField)).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: /Add Field/i }).click()
|
||||
const formulaModal = page.getByRole('dialog', { name: 'Create Field' })
|
||||
await formulaModal.getByPlaceholder('e.g., Story Points, Sprint Number').fill(customFormulaField)
|
||||
await formulaModal.getByText('Formula', { exact: true }).click()
|
||||
await formulaModal.getByPlaceholder('e.g., {time_spent} / {original_estimate} * 100')
|
||||
.fill(`{${customNumberField}} * 2`)
|
||||
await formulaModal.getByRole('button', { name: 'Create Field' }).click()
|
||||
|
||||
await expect(page.getByText(customFormulaField)).toBeVisible()
|
||||
})
|
||||
|
||||
test('tasks flow: create, views, detail, attachments, comments, subtasks, dependencies', async ({ page }) => {
|
||||
await openProjectFromSpaces(page)
|
||||
|
||||
const today = new Date()
|
||||
const startDate = formatDateInput(today)
|
||||
const dueDate = formatDateInput(new Date(today.getTime() + 3 * 24 * 60 * 60 * 1000))
|
||||
|
||||
await page.getByRole('button', { name: /Create Task/i }).click()
|
||||
const createModal = page.getByRole('dialog', { name: 'Create Task' })
|
||||
await createModal.locator('#task-title').fill(taskOneTitle)
|
||||
await createModal.locator('#task-description').fill('E2E task description')
|
||||
await createModal.locator('select').first().selectOption('low')
|
||||
const dateInputs = createModal.locator('input[type="date"]')
|
||||
await dateInputs.nth(0).fill(startDate)
|
||||
await dateInputs.nth(1).fill(dueDate)
|
||||
const customFieldContainer = createModal.locator('label', { hasText: customNumberField }).locator('..')
|
||||
await customFieldContainer.locator('input[type="number"]').fill('5')
|
||||
await createModal.getByRole('button', { name: 'Create' }).click()
|
||||
await expect(page.getByText(taskOneTitle)).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: /Create Task/i }).click()
|
||||
const createModalTwo = page.getByRole('dialog', { name: 'Create Task' })
|
||||
await createModalTwo.locator('#task-title').fill(taskTwoTitle)
|
||||
await createModalTwo.locator('#task-description').fill('E2E task description 2')
|
||||
const dateInputsTwo = createModalTwo.locator('input[type="date"]')
|
||||
await dateInputsTwo.nth(0).fill(startDate)
|
||||
await dateInputsTwo.nth(1).fill(dueDate)
|
||||
await createModalTwo.getByRole('button', { name: 'Create' }).click()
|
||||
await expect(page.getByText(taskTwoTitle)).toBeVisible()
|
||||
await expect(page.getByText(`${customNumberField}: 5`, { exact: true })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Kanban' }).click()
|
||||
await expect(page.getByText(taskOneTitle)).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Calendar' }).click()
|
||||
await expect(page.getByText(new RegExp(taskOneTitle))).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Gantt' }).click()
|
||||
await expect(page.getByText('Task Dependencies')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Manage Dependencies' }).first().click()
|
||||
const depsHeading = page.getByRole('heading', {
|
||||
name: /Manage Dependencies for/i,
|
||||
})
|
||||
await expect(depsHeading).toBeVisible()
|
||||
const depsDialog = depsHeading.locator('..')
|
||||
const dependencySelect = depsDialog.locator('select').first()
|
||||
await dependencySelect.selectOption({ index: 1 })
|
||||
await depsDialog.getByRole('button', { name: 'Add Dependency' }).click()
|
||||
await expect(page.locator('text=Depends on:').first()).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'List' }).click()
|
||||
await page.getByText(taskOneTitle).first().click()
|
||||
|
||||
const taskModal = page.getByRole('dialog', { name: taskOneTitle })
|
||||
await expect(taskModal.getByRole('heading', { name: taskOneTitle })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Edit' }).click()
|
||||
const descriptionField = page.locator('label', { hasText: 'Description' }).locator('..').locator('textarea')
|
||||
await descriptionField.fill('Updated task description')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(page.getByText('Updated task description')).toBeVisible()
|
||||
|
||||
await page.getByPlaceholder('Add a comment... Use @name to mention someone').fill(commentText)
|
||||
await page.getByRole('button', { name: 'Post Comment' }).click()
|
||||
await expect(page.getByText(commentText)).toBeVisible()
|
||||
|
||||
await page.locator('input[type="file"]').setInputFiles(attachmentPath)
|
||||
await expect(page.getByText('attachment.txt')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: /Add Subtask/i }).click()
|
||||
await page.locator('#new-subtask-title').fill(subtaskTitle)
|
||||
await page.getByRole('button', { name: 'Add' }).click()
|
||||
await expect(page.getByText(subtaskTitle)).toBeVisible()
|
||||
|
||||
await page.getByLabel('Close').click()
|
||||
})
|
||||
|
||||
test('notifications and my settings', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', { name: 'Notifications' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Notifications' })).toBeVisible()
|
||||
|
||||
await page.goto('/my-settings')
|
||||
await expect(page.getByRole('heading', { name: 'My Settings' })).toBeVisible()
|
||||
|
||||
const capacityInput = page.locator('input[type="number"]').first()
|
||||
const currentCapacity = await capacityInput.inputValue()
|
||||
const nextCapacity = String((Number(currentCapacity) || 40) + 1)
|
||||
await capacityInput.fill(nextCapacity)
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await capacityInput.fill(currentCapacity || '40')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
const weeklyCard = page.getByRole('heading', { name: 'Weekly Report' }).locator('..')
|
||||
const subscriptionToggle = weeklyCard.locator('input[type="checkbox"]')
|
||||
await expect(subscriptionToggle).toBeEnabled()
|
||||
const isChecked = await subscriptionToggle.isChecked()
|
||||
await subscriptionToggle.click()
|
||||
if (isChecked) {
|
||||
await expect(subscriptionToggle).not.toBeChecked()
|
||||
} else {
|
||||
await expect(subscriptionToggle).toBeChecked()
|
||||
}
|
||||
await subscriptionToggle.click()
|
||||
})
|
||||
|
||||
test('workload, project health, audit pages', async ({ page }) => {
|
||||
await page.goto('/workload')
|
||||
await expect(page.getByRole('heading', { name: 'Workload' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Next week' }).click()
|
||||
|
||||
await page.goto('/project-health')
|
||||
await expect(page.getByRole('heading', { name: 'Project Health Dashboard' })).toBeVisible()
|
||||
await page.getByLabel('Sort by:').selectOption('name')
|
||||
|
||||
await page.goto('/audit')
|
||||
await expect(page.getByRole('heading', { name: 'Audit Log' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('cleanup: delete project and space', async ({ page }) => {
|
||||
await page.goto('/spaces')
|
||||
await expect(page.getByRole('heading', { name: 'Spaces' })).toBeVisible()
|
||||
await page.getByRole('button', { name: `Spaces: ${spaceName}` }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible()
|
||||
|
||||
const projectCard = page.getByRole('button', { name: `Projects: ${projectName}` })
|
||||
await projectCard.getByRole('button', { name: 'Delete Project' }).click()
|
||||
const deleteProjectModal = page.getByRole('dialog', { name: 'Delete Project' })
|
||||
await deleteProjectModal.getByRole('button', { name: 'Delete' }).click()
|
||||
await expect(page.getByRole('button', { name: `Projects: ${projectName}` })).toHaveCount(0)
|
||||
|
||||
await page.goto('/spaces')
|
||||
await expect(page.getByRole('heading', { name: 'Spaces' })).toBeVisible()
|
||||
|
||||
const spaceCard = page.getByRole('button', { name: `Spaces: ${spaceName}` })
|
||||
await spaceCard.getByRole('button', { name: 'Delete Space' }).click()
|
||||
const deleteSpaceModal = page.getByRole('dialog', { name: 'Delete Space' })
|
||||
await deleteSpaceModal.getByRole('button', { name: 'Delete' }).click()
|
||||
await expect(page.getByRole('button', { name: `Spaces: ${spaceName}` })).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
1
frontend/e2e/fixtures/attachment.txt
Normal file
1
frontend/e2e/fixtures/attachment.txt
Normal file
@@ -0,0 +1 @@
|
||||
Playwright attachment fixture for Project Control e2e tests.
|
||||
43
frontend/e2e/global-setup.ts
Normal file
43
frontend/e2e/global-setup.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { chromium, FullConfig } from 'playwright/test'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const storageStatePath = path.join(__dirname, '.auth', 'admin.json')
|
||||
const baseUrl = process.env.E2E_BASE_URL || 'http://localhost:3000'
|
||||
|
||||
const requireEnv = (name: string) => {
|
||||
const value = process.env[name]
|
||||
if (!value) {
|
||||
throw new Error(`Missing required env var: ${name}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
async function globalSetup(_config: FullConfig) {
|
||||
fs.mkdirSync(path.dirname(storageStatePath), { recursive: true })
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const context = await browser.newContext()
|
||||
const page = await context.newPage()
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('i18nextLng', 'en')
|
||||
})
|
||||
|
||||
const email = requireEnv('E2E_EMAIL')
|
||||
const password = requireEnv('E2E_PASSWORD')
|
||||
|
||||
await page.goto(`${baseUrl}/login`)
|
||||
await page.getByLabel('Email').fill(email)
|
||||
await page.getByLabel('Password').fill(password)
|
||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||
await page.getByRole('button', { name: 'Logout' }).waitFor()
|
||||
|
||||
await context.storageState({ path: storageStatePath })
|
||||
await browser.close()
|
||||
}
|
||||
|
||||
export default globalSetup
|
||||
6
frontend/e2e/smoke.spec.ts
Normal file
6
frontend/e2e/smoke.spec.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { expect, test } from 'playwright/test';
|
||||
|
||||
test('smoke: basic rendering works', async ({ page }) => {
|
||||
await page.setContent('<main><h1>Smoke</h1><p>Playwright ready</p></main>');
|
||||
await expect(page.getByRole('heading', { name: 'Smoke' })).toBeVisible();
|
||||
});
|
||||
48
frontend/package-lock.json
generated
48
frontend/package-lock.json
generated
@@ -31,6 +31,7 @@
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"jsdom": "^27.4.0",
|
||||
"playwright": "^1.57.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8",
|
||||
"vitest": "^4.0.16"
|
||||
@@ -2787,6 +2788,53 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
|
||||
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.57.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
|
||||
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"jsdom": "^27.4.0",
|
||||
"playwright": "^1.57.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8",
|
||||
"vitest": "^4.0.16"
|
||||
|
||||
22
frontend/playwright.config.ts
Normal file
22
frontend/playwright.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: 'e2e',
|
||||
testMatch: '**/*.spec.ts',
|
||||
outputDir: 'test-results/playwright',
|
||||
globalSetup: './e2e/global-setup.ts',
|
||||
timeout: 60_000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
reporter: [['list']],
|
||||
workers: 1,
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL || 'http://localhost:3000',
|
||||
storageState: 'e2e/.auth/admin.json',
|
||||
headless: true,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
});
|
||||
@@ -15,7 +15,91 @@
|
||||
"capacityHelp": "Recommended: 40 hours (standard work week). Maximum: 168 hours (total hours in a week).",
|
||||
"capacitySaved": "Capacity settings saved",
|
||||
"capacityError": "Failed to save capacity settings",
|
||||
"capacityInvalid": "Please enter a valid number of hours (0-168)"
|
||||
"capacityInvalid": "Please enter a valid number of hours (0-168)",
|
||||
"weeklyReportTitle": "Weekly Report",
|
||||
"weeklyReportDescription": "Subscribe to receive a weekly in-app report for projects you own or are a member of.",
|
||||
"weeklyReportSubscribe": "Weekly report subscription",
|
||||
"weeklyReportEnabled": "Weekly report subscription enabled",
|
||||
"weeklyReportDisabled": "Weekly report subscription disabled",
|
||||
"weeklyReportError": "Failed to update weekly report subscription"
|
||||
},
|
||||
"triggers": {
|
||||
"descriptionOptional": "Description (optional)",
|
||||
"conditions": "Conditions",
|
||||
"conditionsHint": "All conditions must match (AND).",
|
||||
"field": "Field",
|
||||
"operator": "Operator",
|
||||
"value": "Value",
|
||||
"addCondition": "Add condition",
|
||||
"and": "AND",
|
||||
"action": "Action",
|
||||
"notify": "Notify",
|
||||
"templateLabel": "Message template (optional)",
|
||||
"templateHint": "Variables: {task_title}, {old_value}, {new_value}",
|
||||
"selectCustomField": "Select custom field",
|
||||
"saving": "Saving...",
|
||||
"update": "Update",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"retry": "Retry",
|
||||
"empty": "No triggers configured for this project.",
|
||||
"when": "When",
|
||||
"then": "Then",
|
||||
"deleteTitle": "Delete Trigger",
|
||||
"deleteMessage": "Are you sure you want to delete this trigger? This action cannot be undone.",
|
||||
"fields": {
|
||||
"status": "Status",
|
||||
"assignee": "Assignee",
|
||||
"priority": "Priority",
|
||||
"startDate": "Start date",
|
||||
"dueDate": "Due date",
|
||||
"customField": "Custom field"
|
||||
},
|
||||
"operators": {
|
||||
"equals": "equals",
|
||||
"notEquals": "does not equal",
|
||||
"changedTo": "changes to",
|
||||
"changedFrom": "changes from",
|
||||
"before": "before",
|
||||
"after": "after",
|
||||
"in": "in"
|
||||
},
|
||||
"targets": {
|
||||
"assignee": "Task Assignee",
|
||||
"creator": "Task Creator",
|
||||
"projectOwner": "Project Owner",
|
||||
"projectMembers": "Project Members",
|
||||
"user": "Specific user",
|
||||
"department": "Department",
|
||||
"role": "Role"
|
||||
},
|
||||
"placeholders": {
|
||||
"value": "Enter value",
|
||||
"list": "Comma-separated values",
|
||||
"user": "User ID",
|
||||
"department": "Department ID",
|
||||
"role": "Role name"
|
||||
},
|
||||
"range": {
|
||||
"start": "Start",
|
||||
"end": "End"
|
||||
},
|
||||
"toasts": {
|
||||
"enabled": "Trigger enabled",
|
||||
"disabled": "Trigger disabled",
|
||||
"deleteSuccess": "Trigger deleted successfully"
|
||||
},
|
||||
"errors": {
|
||||
"saveFailed": "Failed to save trigger",
|
||||
"loadFailed": "Failed to load triggers",
|
||||
"updateFailed": "Failed to update trigger",
|
||||
"deleteFailed": "Failed to delete trigger",
|
||||
"missingCustomField": "Please select a custom field",
|
||||
"missingValue": "Please enter a value",
|
||||
"missingDateRange": "Please select a start and end date",
|
||||
"missingTargetValue": "Please enter a target value",
|
||||
"missingConditions": "Please add at least one condition"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"general": "General",
|
||||
|
||||
@@ -15,7 +15,91 @@
|
||||
"capacityHelp": "建議值:40 小時(標準工時)。最大值:168 小時(一週總時數)。",
|
||||
"capacitySaved": "容量設定已儲存",
|
||||
"capacityError": "儲存容量設定失敗",
|
||||
"capacityInvalid": "請輸入有效的時數(0-168)"
|
||||
"capacityInvalid": "請輸入有效的時數(0-168)",
|
||||
"weeklyReportTitle": "每週報告",
|
||||
"weeklyReportDescription": "訂閱後將收到你所擁有或參與專案的每週站內報告。",
|
||||
"weeklyReportSubscribe": "每週報告訂閱",
|
||||
"weeklyReportEnabled": "已開啟每週報告訂閱",
|
||||
"weeklyReportDisabled": "已關閉每週報告訂閱",
|
||||
"weeklyReportError": "更新每週報告訂閱失敗"
|
||||
},
|
||||
"triggers": {
|
||||
"descriptionOptional": "描述(選填)",
|
||||
"conditions": "觸發條件",
|
||||
"conditionsHint": "所有條件需同時成立(AND)。",
|
||||
"field": "欄位",
|
||||
"operator": "運算子",
|
||||
"value": "值",
|
||||
"addCondition": "新增條件",
|
||||
"and": "且",
|
||||
"action": "動作",
|
||||
"notify": "通知",
|
||||
"templateLabel": "訊息模板(選填)",
|
||||
"templateHint": "可用變數:{task_title}, {old_value}, {new_value}",
|
||||
"selectCustomField": "選擇自訂欄位",
|
||||
"saving": "儲存中...",
|
||||
"update": "更新",
|
||||
"enable": "啟用",
|
||||
"disable": "停用",
|
||||
"retry": "重試",
|
||||
"empty": "此專案尚未設定觸發器。",
|
||||
"when": "當",
|
||||
"then": "則",
|
||||
"deleteTitle": "刪除觸發器",
|
||||
"deleteMessage": "確定要刪除此觸發器嗎?此操作無法復原。",
|
||||
"fields": {
|
||||
"status": "狀態",
|
||||
"assignee": "負責人",
|
||||
"priority": "優先度",
|
||||
"startDate": "開始日期",
|
||||
"dueDate": "截止日期",
|
||||
"customField": "自訂欄位"
|
||||
},
|
||||
"operators": {
|
||||
"equals": "等於",
|
||||
"notEquals": "不等於",
|
||||
"changedTo": "變更為",
|
||||
"changedFrom": "變更自",
|
||||
"before": "早於",
|
||||
"after": "晚於",
|
||||
"in": "包含於"
|
||||
},
|
||||
"targets": {
|
||||
"assignee": "任務負責人",
|
||||
"creator": "任務建立者",
|
||||
"projectOwner": "專案負責人",
|
||||
"projectMembers": "專案成員",
|
||||
"user": "指定使用者",
|
||||
"department": "部門",
|
||||
"role": "角色"
|
||||
},
|
||||
"placeholders": {
|
||||
"value": "輸入值",
|
||||
"list": "以逗號分隔",
|
||||
"user": "使用者 ID",
|
||||
"department": "部門 ID",
|
||||
"role": "角色名稱"
|
||||
},
|
||||
"range": {
|
||||
"start": "開始",
|
||||
"end": "結束"
|
||||
},
|
||||
"toasts": {
|
||||
"enabled": "觸發器已啟用",
|
||||
"disabled": "觸發器已停用",
|
||||
"deleteSuccess": "觸發器已刪除"
|
||||
},
|
||||
"errors": {
|
||||
"saveFailed": "儲存觸發器失敗",
|
||||
"loadFailed": "載入觸發器失敗",
|
||||
"updateFailed": "更新觸發器失敗",
|
||||
"deleteFailed": "刪除觸發器失敗",
|
||||
"missingCustomField": "請選擇自訂欄位",
|
||||
"missingValue": "請輸入值",
|
||||
"missingDateRange": "請選擇開始與結束日期",
|
||||
"missingTargetValue": "請輸入通知對象",
|
||||
"missingConditions": "請至少新增一個條件"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"general": "一般",
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { triggersApi, Trigger, TriggerCreate, TriggerCondition, TriggerAction } from '../services/triggers'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
triggersApi,
|
||||
Trigger,
|
||||
TriggerCreate,
|
||||
TriggerCondition,
|
||||
TriggerAction,
|
||||
TriggerConditionRule,
|
||||
} from '../services/triggers'
|
||||
import { customFieldsApi, CustomField } from '../services/customFields'
|
||||
|
||||
interface TriggerFormProps {
|
||||
projectId: string
|
||||
@@ -8,40 +17,289 @@ interface TriggerFormProps {
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
type RuleState = {
|
||||
id: string
|
||||
field: string
|
||||
operator: string
|
||||
value: string
|
||||
field_id?: string
|
||||
range_start?: string
|
||||
range_end?: string
|
||||
}
|
||||
|
||||
const createRuleId = () => {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
}
|
||||
|
||||
const createDefaultRule = (): RuleState => ({
|
||||
id: createRuleId(),
|
||||
field: 'status_id',
|
||||
operator: 'changed_to',
|
||||
value: '',
|
||||
})
|
||||
|
||||
const parseRuleValue = (rule: TriggerConditionRule): Pick<RuleState, 'value' | 'range_start' | 'range_end'> => {
|
||||
if (rule.operator === 'in') {
|
||||
if (rule.value && typeof rule.value === 'object' && !Array.isArray(rule.value)) {
|
||||
const range = rule.value as { start?: string; end?: string }
|
||||
return {
|
||||
value: '',
|
||||
range_start: range.start ? String(range.start) : '',
|
||||
range_end: range.end ? String(range.end) : '',
|
||||
}
|
||||
}
|
||||
if (Array.isArray(rule.value)) {
|
||||
return {
|
||||
value: rule.value.map(item => String(item)).join(', '),
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
value: rule.value != null ? String(rule.value) : '',
|
||||
}
|
||||
}
|
||||
|
||||
export function TriggerForm({ projectId, trigger, onSave, onCancel }: TriggerFormProps) {
|
||||
const { t } = useTranslation(['settings', 'common'])
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [field, setField] = useState('status_id')
|
||||
const [operator, setOperator] = useState('changed_to')
|
||||
const [value, setValue] = useState('')
|
||||
const [target, setTarget] = useState('assignee')
|
||||
const [rules, setRules] = useState<RuleState[]>([createDefaultRule()])
|
||||
const [targetType, setTargetType] = useState('assignee')
|
||||
const [targetValue, setTargetValue] = useState('')
|
||||
const [template, setTemplate] = useState('')
|
||||
const [isActive, setIsActive] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [customFields, setCustomFields] = useState<CustomField[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (trigger) {
|
||||
const fetchCustomFields = async () => {
|
||||
try {
|
||||
const response = await customFieldsApi.getCustomFields(projectId)
|
||||
setCustomFields(response.fields)
|
||||
} catch {
|
||||
setCustomFields([])
|
||||
}
|
||||
}
|
||||
|
||||
if (projectId) {
|
||||
fetchCustomFields()
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!trigger) return
|
||||
|
||||
setName(trigger.name)
|
||||
setDescription(trigger.description || '')
|
||||
setField(trigger.conditions.field)
|
||||
setOperator(trigger.conditions.operator)
|
||||
setValue(trigger.conditions.value)
|
||||
if (trigger.actions.length > 0) {
|
||||
setTarget(trigger.actions[0].target)
|
||||
setTemplate(trigger.actions[0].template || '')
|
||||
}
|
||||
setIsActive(trigger.is_active)
|
||||
|
||||
const triggerConditions = trigger.conditions
|
||||
const conditionRules: TriggerConditionRule[] = Array.isArray(triggerConditions?.rules) && triggerConditions.rules.length > 0
|
||||
? triggerConditions.rules
|
||||
: triggerConditions?.field
|
||||
? [{
|
||||
field: triggerConditions.field,
|
||||
operator: triggerConditions.operator || 'changed_to',
|
||||
value: triggerConditions.value,
|
||||
field_id: triggerConditions.field_id,
|
||||
}]
|
||||
: []
|
||||
|
||||
setRules(
|
||||
conditionRules.length > 0
|
||||
? conditionRules.map(rule => ({
|
||||
id: createRuleId(),
|
||||
field: rule.field,
|
||||
operator: rule.operator,
|
||||
field_id: rule.field_id,
|
||||
...parseRuleValue(rule),
|
||||
}))
|
||||
: [createDefaultRule()]
|
||||
)
|
||||
|
||||
if (trigger.actions.length > 0) {
|
||||
const target = trigger.actions[0].target
|
||||
const templateValue = trigger.actions[0].template || ''
|
||||
setTemplate(templateValue)
|
||||
|
||||
if (target.startsWith('user:')) {
|
||||
setTargetType('user')
|
||||
setTargetValue(target.split(':')[1] || '')
|
||||
} else if (target.startsWith('department:')) {
|
||||
setTargetType('department')
|
||||
setTargetValue(target.split(':')[1] || '')
|
||||
} else if (target.startsWith('role:')) {
|
||||
setTargetType('role')
|
||||
setTargetValue(target.split(':')[1] || '')
|
||||
} else {
|
||||
setTargetType(target)
|
||||
setTargetValue('')
|
||||
}
|
||||
}
|
||||
}, [trigger])
|
||||
|
||||
useEffect(() => {
|
||||
if (customFields.length === 0) return
|
||||
setRules(prev => prev.map(rule => {
|
||||
if (rule.field === 'custom_fields' && !rule.field_id) {
|
||||
return { ...rule, field_id: customFields[0]?.id }
|
||||
}
|
||||
return rule
|
||||
}))
|
||||
}, [customFields])
|
||||
|
||||
const getCustomField = (fieldId?: string) => customFields.find(field => field.id === fieldId)
|
||||
|
||||
const getFieldType = (rule: RuleState) => {
|
||||
if (rule.field === 'start_date' || rule.field === 'due_date') {
|
||||
return 'date'
|
||||
}
|
||||
if (rule.field === 'custom_fields') {
|
||||
const field = getCustomField(rule.field_id)
|
||||
if (!field) return 'text'
|
||||
if (field.field_type === 'formula') return 'number'
|
||||
return field.field_type
|
||||
}
|
||||
return 'text'
|
||||
}
|
||||
|
||||
const getOperatorOptions = (fieldType: string) => {
|
||||
const operators = ['changed_to', 'changed_from', 'equals', 'not_equals', 'in']
|
||||
if (fieldType === 'date' || fieldType === 'number') {
|
||||
operators.push('before', 'after')
|
||||
}
|
||||
return operators
|
||||
}
|
||||
|
||||
const updateRule = (ruleId: string, updates: Partial<RuleState>) => {
|
||||
setRules(prev => prev.map(rule => rule.id === ruleId ? { ...rule, ...updates } : rule))
|
||||
}
|
||||
|
||||
const handleFieldChange = (rule: RuleState, field: string) => {
|
||||
const fieldId = field === 'custom_fields' ? (rule.field_id || customFields[0]?.id) : undefined
|
||||
const updatedRule = {
|
||||
...rule,
|
||||
field,
|
||||
field_id: fieldId,
|
||||
value: '',
|
||||
range_start: '',
|
||||
range_end: '',
|
||||
}
|
||||
const fieldType = getFieldType(updatedRule)
|
||||
const operators = getOperatorOptions(fieldType)
|
||||
const operator = operators.includes(updatedRule.operator) ? updatedRule.operator : operators[0]
|
||||
updateRule(rule.id, { ...updatedRule, operator })
|
||||
}
|
||||
|
||||
const handleCustomFieldChange = (rule: RuleState, fieldId: string) => {
|
||||
const updatedRule = { ...rule, field_id: fieldId }
|
||||
const fieldType = getFieldType(updatedRule)
|
||||
const operators = getOperatorOptions(fieldType)
|
||||
const operator = operators.includes(updatedRule.operator) ? updatedRule.operator : operators[0]
|
||||
updateRule(rule.id, { field_id: fieldId, operator })
|
||||
}
|
||||
|
||||
const buildRulePayload = (rule: RuleState): TriggerConditionRule | null => {
|
||||
const fieldType = getFieldType(rule)
|
||||
let value: unknown = rule.value
|
||||
|
||||
if (rule.operator === 'in') {
|
||||
if (fieldType === 'date') {
|
||||
value = {
|
||||
start: rule.range_start || '',
|
||||
end: rule.range_end || '',
|
||||
}
|
||||
} else {
|
||||
value = rule.value
|
||||
.split(',')
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
} else if (fieldType === 'number') {
|
||||
const numericValue = Number(rule.value)
|
||||
value = rule.value !== '' && !Number.isNaN(numericValue) ? numericValue : rule.value
|
||||
}
|
||||
|
||||
return {
|
||||
field: rule.field,
|
||||
operator: rule.operator,
|
||||
value,
|
||||
field_id: rule.field === 'custom_fields' ? rule.field_id : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const resolveTarget = () => {
|
||||
if (['user', 'department', 'role'].includes(targetType)) {
|
||||
const trimmed = targetValue.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
return `${targetType}:${trimmed}`
|
||||
}
|
||||
return targetType
|
||||
}
|
||||
|
||||
const validateRules = () => {
|
||||
if (rules.length === 0) {
|
||||
setError(t('settings:triggers.errors.missingConditions'))
|
||||
return false
|
||||
}
|
||||
|
||||
for (const rule of rules) {
|
||||
const fieldType = getFieldType(rule)
|
||||
if (rule.field === 'custom_fields' && !rule.field_id) {
|
||||
setError(t('settings:triggers.errors.missingCustomField'))
|
||||
return false
|
||||
}
|
||||
if (rule.operator === 'in' && fieldType === 'date') {
|
||||
if (!rule.range_start || !rule.range_end) {
|
||||
setError(t('settings:triggers.errors.missingDateRange'))
|
||||
return false
|
||||
}
|
||||
} else if (rule.operator === 'in') {
|
||||
if (!rule.value.trim()) {
|
||||
setError(t('settings:triggers.errors.missingValue'))
|
||||
return false
|
||||
}
|
||||
} else if (!rule.value.trim()) {
|
||||
setError(t('settings:triggers.errors.missingValue'))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedTarget = resolveTarget()
|
||||
if (!resolvedTarget) {
|
||||
setError(t('settings:triggers.errors.missingTargetValue'))
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
if (!validateRules()) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
const conditions: TriggerCondition = { field, operator, value }
|
||||
const actions: TriggerAction[] = [{ type: 'notify', target, template: template || undefined }]
|
||||
const rulePayloads = rules
|
||||
.map(buildRulePayload)
|
||||
.filter((rule): rule is TriggerConditionRule => rule !== null)
|
||||
|
||||
const conditions: TriggerCondition = rulePayloads.length > 1
|
||||
? { logic: 'and', rules: rulePayloads }
|
||||
: rulePayloads[0]
|
||||
|
||||
const resolvedTarget = resolveTarget() || 'assignee'
|
||||
const actions: TriggerAction[] = [{ type: 'notify', target: resolvedTarget, template: template || undefined }]
|
||||
|
||||
try {
|
||||
if (trigger) {
|
||||
@@ -65,16 +323,96 @@ export function TriggerForm({ projectId, trigger, onSave, onCancel }: TriggerFor
|
||||
}
|
||||
onSave()
|
||||
} catch {
|
||||
setError('Failed to save trigger')
|
||||
setError(t('settings:triggers.errors.saveFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const operatorLabel = (operator: string) => {
|
||||
const keyMap: Record<string, string> = {
|
||||
equals: 'equals',
|
||||
not_equals: 'notEquals',
|
||||
changed_to: 'changedTo',
|
||||
changed_from: 'changedFrom',
|
||||
before: 'before',
|
||||
after: 'after',
|
||||
in: 'in',
|
||||
}
|
||||
return t(`settings:triggers.operators.${keyMap[operator] || operator}`)
|
||||
}
|
||||
|
||||
const fieldLabel = (field: string) => {
|
||||
const keyMap: Record<string, string> = {
|
||||
status_id: 'status',
|
||||
assignee_id: 'assignee',
|
||||
priority: 'priority',
|
||||
start_date: 'startDate',
|
||||
due_date: 'dueDate',
|
||||
custom_fields: 'customField',
|
||||
}
|
||||
return t(`settings:triggers.fields.${keyMap[field] || field}`)
|
||||
}
|
||||
|
||||
const renderValueInput = (rule: RuleState) => {
|
||||
const fieldType = getFieldType(rule)
|
||||
|
||||
if (rule.operator === 'in' && fieldType === 'date') {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500">{t('settings:triggers.range.start')}</label>
|
||||
<input
|
||||
type="date"
|
||||
value={rule.range_start || ''}
|
||||
onChange={e => updateRule(rule.id, { range_start: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500">{t('settings:triggers.range.end')}</label>
|
||||
<input
|
||||
type="date"
|
||||
value={rule.range_end || ''}
|
||||
onChange={e => updateRule(rule.id, { range_end: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (fieldType === 'date') {
|
||||
return (
|
||||
<input
|
||||
type="date"
|
||||
value={rule.value}
|
||||
onChange={e => updateRule(rule.id, { value: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputType = fieldType === 'number' ? 'number' : 'text'
|
||||
const placeholder = rule.operator === 'in'
|
||||
? t('settings:triggers.placeholders.list')
|
||||
: t('settings:triggers.placeholders.value')
|
||||
|
||||
return (
|
||||
<input
|
||||
type={inputType}
|
||||
value={rule.value}
|
||||
onChange={e => updateRule(rule.id, { value: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Name</label>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('common:labels.name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
@@ -85,7 +423,7 @@ export function TriggerForm({ projectId, trigger, onSave, onCancel }: TriggerFor
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Description (optional)</label>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('settings:triggers.descriptionOptional')}</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
@@ -95,70 +433,128 @@ export function TriggerForm({ projectId, trigger, onSave, onCancel }: TriggerFor
|
||||
</div>
|
||||
|
||||
<fieldset className="border rounded-md p-3">
|
||||
<legend className="text-sm font-medium text-gray-700 px-1">Condition</legend>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600">Field</label>
|
||||
<legend className="text-sm font-medium text-gray-700 px-1">{t('settings:triggers.conditions')}</legend>
|
||||
<p className="text-xs text-gray-500 mb-3">{t('settings:triggers.conditionsHint')}</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{rules.map((rule, index) => {
|
||||
const fieldType = getFieldType(rule)
|
||||
const operatorOptions = getOperatorOptions(fieldType)
|
||||
return (
|
||||
<div key={rule.id} className="space-y-2">
|
||||
<div className="grid grid-cols-12 gap-3 items-end">
|
||||
<div className="col-span-3">
|
||||
<label className="block text-sm text-gray-600">{t('settings:triggers.field')}</label>
|
||||
<select
|
||||
value={field}
|
||||
onChange={e => setField(e.target.value)}
|
||||
value={rule.field}
|
||||
onChange={e => handleFieldChange(rule, e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="status_id">Status</option>
|
||||
<option value="assignee_id">Assignee</option>
|
||||
<option value="priority">Priority</option>
|
||||
<option value="status_id">{fieldLabel('status_id')}</option>
|
||||
<option value="assignee_id">{fieldLabel('assignee_id')}</option>
|
||||
<option value="priority">{fieldLabel('priority')}</option>
|
||||
<option value="start_date">{fieldLabel('start_date')}</option>
|
||||
<option value="due_date">{fieldLabel('due_date')}</option>
|
||||
<option value="custom_fields">{fieldLabel('custom_fields')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600">Operator</label>
|
||||
{rule.field === 'custom_fields' && (
|
||||
<select
|
||||
value={operator}
|
||||
onChange={e => setOperator(e.target.value)}
|
||||
value={rule.field_id || ''}
|
||||
onChange={e => handleCustomFieldChange(rule, e.target.value)}
|
||||
className="mt-2 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">{t('settings:triggers.selectCustomField')}</option>
|
||||
{customFields.map(field => (
|
||||
<option key={field.id} value={field.id}>
|
||||
{field.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<label className="block text-sm text-gray-600">{t('settings:triggers.operator')}</label>
|
||||
<select
|
||||
value={rule.operator}
|
||||
onChange={e => updateRule(rule.id, { operator: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="changed_to">Changes to</option>
|
||||
<option value="changed_from">Changes from</option>
|
||||
<option value="equals">Equals</option>
|
||||
<option value="not_equals">Not equals</option>
|
||||
{operatorOptions.map(option => (
|
||||
<option key={option} value={option}>
|
||||
{operatorLabel(option)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600">Value</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="UUID or value"
|
||||
required
|
||||
/>
|
||||
<div className="col-span-5">
|
||||
<label className="block text-sm text-gray-600">{t('settings:triggers.value')}</label>
|
||||
{renderValueInput(rule)}
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
{rules.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRules(prev => prev.filter(item => item.id !== rule.id))}
|
||||
className="text-sm text-red-600 hover:text-red-700"
|
||||
>
|
||||
{t('common:buttons.remove')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{index < rules.length - 1 && (
|
||||
<div className="text-xs text-gray-500">{t('settings:triggers.and')}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRules(prev => [...prev, createDefaultRule()])}
|
||||
className="mt-3 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
{t('settings:triggers.addCondition')}
|
||||
</button>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="border rounded-md p-3">
|
||||
<legend className="text-sm font-medium text-gray-700 px-1">Action</legend>
|
||||
<legend className="text-sm font-medium text-gray-700 px-1">{t('settings:triggers.action')}</legend>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600">Notify</label>
|
||||
<label className="block text-sm text-gray-600">{t('settings:triggers.notify')}</label>
|
||||
<select
|
||||
value={target}
|
||||
onChange={e => setTarget(e.target.value)}
|
||||
value={targetType}
|
||||
onChange={e => setTargetType(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="assignee">Task Assignee</option>
|
||||
<option value="creator">Task Creator</option>
|
||||
<option value="project_owner">Project Owner</option>
|
||||
<option value="assignee">{t('settings:triggers.targets.assignee')}</option>
|
||||
<option value="creator">{t('settings:triggers.targets.creator')}</option>
|
||||
<option value="project_owner">{t('settings:triggers.targets.projectOwner')}</option>
|
||||
<option value="project_members">{t('settings:triggers.targets.projectMembers')}</option>
|
||||
<option value="user">{t('settings:triggers.targets.user')}</option>
|
||||
<option value="department">{t('settings:triggers.targets.department')}</option>
|
||||
<option value="role">{t('settings:triggers.targets.role')}</option>
|
||||
</select>
|
||||
{['user', 'department', 'role'].includes(targetType) && (
|
||||
<input
|
||||
type="text"
|
||||
value={targetValue}
|
||||
onChange={e => setTargetValue(e.target.value)}
|
||||
className="mt-2 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
|
||||
placeholder={t(`settings:triggers.placeholders.${targetType}`)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600">Message template (optional)</label>
|
||||
<label className="block text-sm text-gray-600">{t('settings:triggers.templateLabel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={template}
|
||||
onChange={e => setTemplate(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Variables: {task_title}, {old_value}, {new_value}"
|
||||
placeholder={t('settings:triggers.templateHint')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,7 +568,7 @@ export function TriggerForm({ projectId, trigger, onSave, onCancel }: TriggerFor
|
||||
onChange={e => setIsActive(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm text-gray-700">Active</label>
|
||||
<label htmlFor="isActive" className="text-sm text-gray-700">{t('common:labels.active')}</label>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
@@ -183,14 +579,18 @@ export function TriggerForm({ projectId, trigger, onSave, onCancel }: TriggerFor
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
{t('common:buttons.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Saving...' : trigger ? 'Update' : 'Create'}
|
||||
{loading
|
||||
? t('settings:triggers.saving')
|
||||
: trigger
|
||||
? t('settings:triggers.update')
|
||||
: t('common:buttons.create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { triggersApi, Trigger } from '../services/triggers'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { triggersApi, Trigger, TriggerConditionRule } from '../services/triggers'
|
||||
import { customFieldsApi } from '../services/customFields'
|
||||
import { ConfirmModal } from './ConfirmModal'
|
||||
import { useToast } from '../contexts/ToastContext'
|
||||
import { SkeletonList } from './Skeleton'
|
||||
@@ -10,11 +12,13 @@ interface TriggerListProps {
|
||||
}
|
||||
|
||||
export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
const { t } = useTranslation(['settings', 'common'])
|
||||
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 [customFieldMap, setCustomFieldMap] = useState<Record<string, string>>({})
|
||||
|
||||
const fetchTriggers = useCallback(async () => {
|
||||
try {
|
||||
@@ -23,23 +27,42 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
setTriggers(response.triggers)
|
||||
setError(null)
|
||||
} catch {
|
||||
setError('Failed to load triggers')
|
||||
setError(t('settings:triggers.errors.loadFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [projectId, t])
|
||||
|
||||
const fetchCustomFields = useCallback(async () => {
|
||||
try {
|
||||
const response = await customFieldsApi.getCustomFields(projectId)
|
||||
const map: Record<string, string> = {}
|
||||
response.fields.forEach(field => {
|
||||
map[field.id] = field.name
|
||||
})
|
||||
setCustomFieldMap(map)
|
||||
} catch {
|
||||
setCustomFieldMap({})
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchTriggers()
|
||||
}, [fetchTriggers])
|
||||
fetchCustomFields()
|
||||
}, [fetchTriggers, fetchCustomFields])
|
||||
|
||||
const handleToggleActive = async (trigger: Trigger) => {
|
||||
try {
|
||||
await triggersApi.updateTrigger(trigger.id, { is_active: !trigger.is_active })
|
||||
fetchTriggers()
|
||||
showToast(`Trigger ${trigger.is_active ? 'disabled' : 'enabled'}`, 'success')
|
||||
showToast(
|
||||
trigger.is_active
|
||||
? t('settings:triggers.toasts.disabled')
|
||||
: t('settings:triggers.toasts.enabled'),
|
||||
'success'
|
||||
)
|
||||
} catch {
|
||||
showToast('Failed to update trigger', 'error')
|
||||
showToast(t('settings:triggers.errors.updateFailed'), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,29 +73,70 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
try {
|
||||
await triggersApi.deleteTrigger(triggerId)
|
||||
fetchTriggers()
|
||||
showToast('Trigger deleted successfully', 'success')
|
||||
showToast(t('settings:triggers.toasts.deleteSuccess'), 'success')
|
||||
} catch {
|
||||
showToast('Failed to delete trigger', 'error')
|
||||
showToast(t('settings:triggers.errors.deleteFailed'), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const getFieldLabel = (field: string) => {
|
||||
switch (field) {
|
||||
case 'status_id': return 'Status'
|
||||
case 'assignee_id': return 'Assignee'
|
||||
case 'priority': return 'Priority'
|
||||
default: return field
|
||||
const getRules = (conditions: Trigger['conditions']): TriggerConditionRule[] => {
|
||||
if (Array.isArray(conditions?.rules) && conditions.rules.length > 0) {
|
||||
return conditions.rules
|
||||
}
|
||||
if (conditions?.field) {
|
||||
return [{
|
||||
field: conditions.field,
|
||||
operator: conditions.operator || 'equals',
|
||||
value: conditions.value,
|
||||
field_id: conditions.field_id,
|
||||
}]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const getFieldLabel = (field: string, fieldId?: string) => {
|
||||
if (field === 'custom_fields') {
|
||||
return customFieldMap[fieldId || '']
|
||||
? `${t('settings:triggers.fields.customField')}: ${customFieldMap[fieldId || '']}`
|
||||
: t('settings:triggers.fields.customField')
|
||||
}
|
||||
|
||||
const keyMap: Record<string, string> = {
|
||||
status_id: 'status',
|
||||
assignee_id: 'assignee',
|
||||
priority: 'priority',
|
||||
start_date: 'startDate',
|
||||
due_date: 'dueDate',
|
||||
}
|
||||
return t(`settings:triggers.fields.${keyMap[field] || field}`)
|
||||
}
|
||||
|
||||
const getOperatorLabel = (operator: string) => {
|
||||
switch (operator) {
|
||||
case 'equals': return 'equals'
|
||||
case 'not_equals': return 'does not equal'
|
||||
case 'changed_to': return 'changes to'
|
||||
case 'changed_from': return 'changes from'
|
||||
default: return operator
|
||||
const keyMap: Record<string, string> = {
|
||||
equals: 'equals',
|
||||
not_equals: 'notEquals',
|
||||
changed_to: 'changedTo',
|
||||
changed_from: 'changedFrom',
|
||||
before: 'before',
|
||||
after: 'after',
|
||||
in: 'in',
|
||||
}
|
||||
return t(`settings:triggers.operators.${keyMap[operator] || operator}`)
|
||||
}
|
||||
|
||||
const formatRuleValue = (rule: TriggerConditionRule) => {
|
||||
if (rule.operator === 'in') {
|
||||
if (rule.value && typeof rule.value === 'object' && !Array.isArray(rule.value)) {
|
||||
const range = rule.value as { start?: string; end?: string }
|
||||
const start = range.start ? String(range.start) : ''
|
||||
const end = range.end ? String(range.end) : ''
|
||||
return start && end ? `${start} ~ ${end}` : start || end
|
||||
}
|
||||
if (Array.isArray(rule.value)) {
|
||||
return rule.value.map(item => String(item)).join(', ')
|
||||
}
|
||||
}
|
||||
return rule.value != null ? String(rule.value) : ''
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
@@ -84,7 +148,7 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
<div className="p-4 text-center text-red-500">
|
||||
{error}
|
||||
<button onClick={fetchTriggers} className="ml-2 text-blue-600 hover:underline">
|
||||
Retry
|
||||
{t('settings:triggers.retry')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
@@ -93,14 +157,16 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
if (triggers.length === 0) {
|
||||
return (
|
||||
<div className="p-4 text-center text-gray-500">
|
||||
No triggers configured for this project.
|
||||
{t('settings:triggers.empty')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{triggers.map(trigger => (
|
||||
{triggers.map(trigger => {
|
||||
const rules = getRules(trigger.conditions)
|
||||
return (
|
||||
<div
|
||||
key={trigger.id}
|
||||
className={`border rounded-lg p-4 ${trigger.is_active ? 'bg-white' : 'bg-gray-50'}`}
|
||||
@@ -114,22 +180,29 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{trigger.is_active ? 'Active' : 'Inactive'}
|
||||
{trigger.is_active ? t('common:labels.active') : t('common:labels.inactive')}
|
||||
</span>
|
||||
</div>
|
||||
{trigger.description && (
|
||||
<p className="text-sm text-gray-500 mt-1">{trigger.description}</p>
|
||||
)}
|
||||
<div className="text-sm text-gray-600 mt-2">
|
||||
<span className="font-medium">When: </span>
|
||||
{getFieldLabel(trigger.conditions.field)} {getOperatorLabel(trigger.conditions.operator)} {trigger.conditions.value}
|
||||
<span className="font-medium">{t('settings:triggers.when')}: </span>
|
||||
{rules.map((rule, index) => (
|
||||
<span key={`${trigger.id}-${index}`}>
|
||||
{getFieldLabel(rule.field, rule.field_id)} {getOperatorLabel(rule.operator)} {formatRuleValue(rule)}
|
||||
{index < rules.length - 1 && ` ${t('settings:triggers.and')} `}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
<span className="font-medium">Then: </span>
|
||||
{trigger.actions.map((a, i) => (
|
||||
<span key={i}>
|
||||
{a.type === 'notify' ? `Notify ${a.target}` : a.type}
|
||||
{i < trigger.actions.length - 1 && ', '}
|
||||
<span className="font-medium">{t('settings:triggers.then')}: </span>
|
||||
{trigger.actions.map((action, index) => (
|
||||
<span key={index}>
|
||||
{action.type === 'notify'
|
||||
? `${t('settings:triggers.notify')} ${action.target}`
|
||||
: action.type}
|
||||
{index < trigger.actions.length - 1 && ', '}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -139,33 +212,34 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
onClick={() => handleToggleActive(trigger)}
|
||||
className="text-sm px-2 py-1 text-gray-600 hover:bg-gray-100 rounded"
|
||||
>
|
||||
{trigger.is_active ? 'Disable' : 'Enable'}
|
||||
{trigger.is_active ? t('settings:triggers.disable') : t('settings:triggers.enable')}
|
||||
</button>
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={() => onEdit(trigger)}
|
||||
className="text-sm px-2 py-1 text-blue-600 hover:bg-blue-50 rounded"
|
||||
>
|
||||
Edit
|
||||
{t('common:buttons.edit')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(trigger.id)}
|
||||
className="text-sm px-2 py-1 text-red-600 hover:bg-red-50 rounded"
|
||||
>
|
||||
Delete
|
||||
{t('common:buttons.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</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"
|
||||
title={t('settings:triggers.deleteTitle')}
|
||||
message={t('settings:triggers.deleteMessage')}
|
||||
confirmText={t('common:buttons.delete')}
|
||||
cancelText={t('common:buttons.cancel')}
|
||||
confirmStyle="danger"
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteConfirm(null)}
|
||||
|
||||
@@ -23,9 +23,13 @@ export default function MySettings() {
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null)
|
||||
const [capacity, setCapacity] = useState<string>('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [weeklySubscription, setWeeklySubscription] = useState(false)
|
||||
const [subscriptionLoading, setSubscriptionLoading] = useState(true)
|
||||
const [subscriptionSaving, setSubscriptionSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile()
|
||||
loadWeeklySubscription()
|
||||
}, [])
|
||||
|
||||
const loadProfile = async () => {
|
||||
@@ -50,6 +54,19 @@ export default function MySettings() {
|
||||
}
|
||||
}
|
||||
|
||||
const loadWeeklySubscription = async () => {
|
||||
setSubscriptionLoading(true)
|
||||
try {
|
||||
const response = await api.get('/reports/weekly/subscription')
|
||||
setWeeklySubscription(Boolean(response.data?.is_active))
|
||||
} catch (err) {
|
||||
console.error('Failed to load weekly subscription:', err)
|
||||
showToast(t('mySettings.weeklyReportError'), 'error')
|
||||
} finally {
|
||||
setSubscriptionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveCapacity = async () => {
|
||||
if (!profile) return
|
||||
|
||||
@@ -75,6 +92,23 @@ export default function MySettings() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleSubscription = async (enabled: boolean) => {
|
||||
setSubscriptionSaving(true)
|
||||
try {
|
||||
await api.put('/reports/weekly/subscription', { is_active: enabled })
|
||||
setWeeklySubscription(enabled)
|
||||
showToast(
|
||||
enabled ? t('mySettings.weeklyReportEnabled') : t('mySettings.weeklyReportDisabled'),
|
||||
'success'
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Failed to update weekly subscription:', err)
|
||||
showToast(t('mySettings.weeklyReportError'), 'error')
|
||||
} finally {
|
||||
setSubscriptionSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
@@ -160,6 +194,21 @@ export default function MySettings() {
|
||||
{saving ? t('common:labels.loading') : t('common:buttons.save')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={styles.card}>
|
||||
<h2 style={styles.cardTitle}>{t('mySettings.weeklyReportTitle')}</h2>
|
||||
<p style={styles.cardDescription}>{t('mySettings.weeklyReportDescription')}</p>
|
||||
<div style={styles.toggleRow}>
|
||||
<label style={styles.toggleLabel}>{t('mySettings.weeklyReportSubscribe')}</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={weeklySubscription}
|
||||
disabled={subscriptionLoading || subscriptionSaving}
|
||||
onChange={(e) => handleToggleSubscription(e.target.checked)}
|
||||
style={styles.toggleInput}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -267,6 +316,21 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
backgroundColor: '#ccc',
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
toggleRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '16px',
|
||||
},
|
||||
toggleLabel: {
|
||||
fontSize: '14px',
|
||||
color: '#333',
|
||||
fontWeight: 500,
|
||||
},
|
||||
toggleInput: {
|
||||
width: '40px',
|
||||
height: '20px',
|
||||
},
|
||||
error: {
|
||||
padding: '48px',
|
||||
textAlign: 'center',
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import api from './api'
|
||||
|
||||
export interface TriggerCondition {
|
||||
export interface TriggerConditionRule {
|
||||
field: string
|
||||
operator: string
|
||||
value: string
|
||||
value: unknown
|
||||
field_id?: string
|
||||
}
|
||||
|
||||
export interface TriggerCondition {
|
||||
field?: string
|
||||
operator?: string
|
||||
value?: unknown
|
||||
field_id?: string
|
||||
logic?: 'and'
|
||||
rules?: TriggerConditionRule[]
|
||||
}
|
||||
|
||||
export interface TriggerAction {
|
||||
|
||||
783
issues.md
783
issues.md
@@ -1,783 +0,0 @@
|
||||
# PROJECT CONTROL - Issue Tracking
|
||||
|
||||
> 審核日期: 2026-01-04
|
||||
> 更新日期: 2026-01-06
|
||||
> 整體完成度: 100% (核心功能及 A11Y 全部完成)
|
||||
> 已修復問題: 32 (CRIT-001~003, HIGH-001~008, MED-001~012, NEW-001~002, A11Y-001~006)
|
||||
|
||||
---
|
||||
|
||||
## 目錄
|
||||
|
||||
- [嚴重問題 (Critical)](#嚴重問題-critical)
|
||||
- [高優先問題 (High)](#高優先問題-high)
|
||||
- [中優先問題 (Medium)](#中優先問題-medium)
|
||||
- [低優先問題 (Low)](#低優先問題-low)
|
||||
- [未實作功能 (Missing Features)](#未實作功能-missing-features)
|
||||
- [可訪問性問題 (Accessibility)](#可訪問性問題-accessibility)
|
||||
- [程式碼品質建議 (Code Quality)](#程式碼品質建議-code-quality)
|
||||
|
||||
---
|
||||
|
||||
## 嚴重問題 (Critical)
|
||||
|
||||
### CRIT-001: JWT 密鑰硬編碼
|
||||
|
||||
- **類型**: 安全漏洞
|
||||
- **模組**: Backend - Authentication
|
||||
- **檔案**: `backend/app/core/config.py:28`
|
||||
- **問題描述**: JWT secret key 有硬編碼的預設值 `"your-secret-key-change-in-production"`,若部署時未設定環境變數,所有 JWT token 都可被偽造。
|
||||
- **影響**: 完全繞過認證系統
|
||||
- **建議修復**:
|
||||
```python
|
||||
JWT_SECRET_KEY: str = "" # 移除預設值
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
if not self.JWT_SECRET_KEY or self.JWT_SECRET_KEY == "your-secret-key-change-in-production":
|
||||
raise ValueError("JWT_SECRET_KEY must be set in environment")
|
||||
```
|
||||
- **狀態**: [x] 已修復 (2026-01-04)
|
||||
- **修復方式**: 使用 pydantic @field_validator 驗證 JWT_SECRET_KEY,拒絕空值和佔位符值
|
||||
|
||||
---
|
||||
|
||||
### CRIT-002: 登入嘗試未記錄稽核日誌
|
||||
|
||||
- **類型**: 安全漏洞
|
||||
- **模組**: Backend - Authentication / Audit
|
||||
- **檔案**: `backend/app/api/auth/router.py`
|
||||
- **問題描述**: Spec 要求記錄失敗的登入嘗試,但 auth router 未呼叫 AuditService.log_event() 記錄登入成功/失敗。
|
||||
- **影響**: 無法偵測暴力破解攻擊,稽核追蹤不完整
|
||||
- **建議修復**:
|
||||
```python
|
||||
# 登入成功時
|
||||
AuditService.log_event(
|
||||
db=db,
|
||||
event_type="user.login",
|
||||
resource_type="user",
|
||||
action=AuditAction.LOGIN,
|
||||
user_id=user.id,
|
||||
...
|
||||
)
|
||||
|
||||
# 登入失敗時
|
||||
AuditService.log_event(
|
||||
db=db,
|
||||
event_type="user.login_failed",
|
||||
...
|
||||
)
|
||||
```
|
||||
- **狀態**: [x] 已修復 (2026-01-04)
|
||||
- **修復方式**: 在 login endpoint 添加 AuditService.log_event() 呼叫,記錄成功/失敗的登入嘗試,包含 IP 和 User-Agent
|
||||
|
||||
---
|
||||
|
||||
### CRIT-003: 前端 API 路徑重複導致請求失敗
|
||||
|
||||
- **類型**: Bug
|
||||
- **模組**: Frontend - Core
|
||||
- **檔案**:
|
||||
- `frontend/src/pages/Spaces.tsx:29, 43`
|
||||
- `frontend/src/pages/Projects.tsx:44-45, 61`
|
||||
- `frontend/src/pages/Tasks.tsx:54-56, 73, 86`
|
||||
- **問題描述**: API 呼叫使用 `/api/spaces` 但 axios baseURL 已設為 `/api`,導致實際請求路徑變成 `/api/api/spaces`。
|
||||
- **影響**: Spaces、Projects、Tasks 頁面所有 API 呼叫都會失敗
|
||||
- **建議修復**:
|
||||
```typescript
|
||||
// 錯誤:
|
||||
const response = await api.get('/api/spaces')
|
||||
// 正確:
|
||||
const response = await api.get('/spaces')
|
||||
```
|
||||
- **狀態**: [x] 已修復 (2026-01-04)
|
||||
- **修復方式**: 移除 Spaces.tsx, Projects.tsx, Tasks.tsx, attachments.ts 中所有 API 路徑的 `/api` 前綴
|
||||
|
||||
---
|
||||
|
||||
## 高優先問題 (High)
|
||||
|
||||
### HIGH-001: 專案刪除使用硬刪除
|
||||
|
||||
- **類型**: 資料完整性
|
||||
- **模組**: Backend - Projects
|
||||
- **檔案**: `backend/app/api/projects/router.py:268-307`
|
||||
- **問題描述**: 專案刪除使用 `db.delete(project)` 硬刪除,但 Audit Trail spec 要求所有刪除操作使用軟刪除。
|
||||
- **影響**: 資料遺失,稽核日誌無法參照已刪除專案
|
||||
- **建議修復**: 為 Project model 新增 `is_deleted`, `deleted_at`, `deleted_by` 欄位,實作軟刪除
|
||||
- **狀態**: [x] 已修復 (2026-01-04)
|
||||
- **修復方式**: 使用現有 is_active 欄位實作軟刪除,delete_project 設定 is_active=False 而非 db.delete()
|
||||
|
||||
---
|
||||
|
||||
### HIGH-002: Redis Session Token 類型比對問題
|
||||
|
||||
- **類型**: Bug
|
||||
- **模組**: Backend - Authentication
|
||||
- **檔案**: `backend/app/middleware/auth.py:43-50`
|
||||
- **問題描述**: Redis `get()` 在某些配置下回傳 bytes,與字串 token 比對可能失敗。
|
||||
- **影響**: 使用者可能意外被登出
|
||||
- **建議修復**:
|
||||
```python
|
||||
stored_token = redis_client.get(f"session:{user_id}")
|
||||
if stored_token is None:
|
||||
raise HTTPException(...)
|
||||
if isinstance(stored_token, bytes):
|
||||
stored_token = stored_token.decode('utf-8')
|
||||
if stored_token != token:
|
||||
raise HTTPException(...)
|
||||
```
|
||||
- **狀態**: [x] 已修復 (2026-01-04)
|
||||
- **修復方式**: 在 middleware/auth.py 添加 bytes 類型檢查和 decode 處理
|
||||
|
||||
---
|
||||
|
||||
### HIGH-003: 無 Rate Limiting 實作
|
||||
|
||||
- **類型**: 安全漏洞
|
||||
- **模組**: Backend - API
|
||||
- **檔案**: 多個 API endpoints
|
||||
- **問題描述**: Spec 提及 rate limiting,但未實作任何速率限制中介軟體。
|
||||
- **影響**: API 易受暴力破解和 DoS 攻擊
|
||||
- **建議修復**:
|
||||
```python
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
@router.post("/login")
|
||||
@limiter.limit("5/minute")
|
||||
async def login(...):
|
||||
```
|
||||
- **狀態**: [x] 已修復 (2026-01-04)
|
||||
- **修復方式**: 使用 slowapi 實作 rate limiting,login endpoint 限制 5 req/min,測試環境使用 memory storage
|
||||
|
||||
---
|
||||
|
||||
### HIGH-004: 附件 API 缺少權限檢查
|
||||
|
||||
- **類型**: 安全漏洞
|
||||
- **模組**: Backend - Attachments
|
||||
- **檔案**: `backend/app/api/attachments/router.py`
|
||||
- **問題描述**: 附件 endpoints 只檢查任務是否存在,未驗證當前使用者是否有權存取該任務的專案。`check_task_access` 函數存在但未被呼叫。
|
||||
- **影響**: 使用者可上傳/下載不應有權存取的任務附件
|
||||
- **建議修復**: 在每個 endpoint 加入 `check_task_access(current_user, task, task.project)` 呼叫
|
||||
- **狀態**: [x] 已修復 (2026-01-04)
|
||||
- **修復方式**: 新增 get_task_with_access_check 和 get_attachment_with_access_check 輔助函數,所有 endpoints 都使用這些函數進行權限驗證
|
||||
|
||||
---
|
||||
|
||||
### HIGH-005: 任務視角僅有列表視角
|
||||
|
||||
- **類型**: 功能缺失
|
||||
- **模組**: Frontend - Task Management
|
||||
- **檔案**: `frontend/src/pages/Tasks.tsx`
|
||||
- **問題描述**: Spec 要求 4 種視角 (Kanban, Gantt, List, Calendar),目前僅實作列表視角。
|
||||
- **影響**: 無法滿足不同工作流程需求
|
||||
- **建議修復**: 優先實作看板 (Kanban) 視角,支援拖拉變更狀態
|
||||
- **狀態**: [x] 已修復 (2026-01-04)
|
||||
- **修復方式**: 新增 KanbanBoard.tsx 元件,支援 HTML5 drag-and-drop 變更狀態,Tasks.tsx 新增 List/Kanban 切換按鈕,偏好設定儲存於 localStorage
|
||||
|
||||
---
|
||||
|
||||
### HIGH-006: 資源管理模組前端 UI 未開發
|
||||
|
||||
- **類型**: 功能缺失
|
||||
- **模組**: Frontend - Resource Management
|
||||
- **檔案**: -
|
||||
- **問題描述**: 整個資源管理模組前端未開發,包括負載熱圖、容量規劃、專案健康看板。
|
||||
- **影響**: 主管無法視覺化查看團隊工作負載
|
||||
- **建議修復**: 開發 WorkloadHeatmap 元件和相關頁面
|
||||
- **狀態**: [x] 已修復 (2026-01-04)
|
||||
- **修復方式**: 新增 WorkloadPage.tsx、WorkloadHeatmap.tsx、WorkloadUserDetail.tsx 元件,完整實作負載熱圖視覺化功能
|
||||
|
||||
---
|
||||
|
||||
### HIGH-007: 協作/附件/觸發器元件未整合
|
||||
|
||||
- **類型**: 功能缺失
|
||||
- **模組**: Frontend - Integration
|
||||
- **檔案**:
|
||||
- `frontend/src/components/Comments.tsx` - 未使用
|
||||
- `frontend/src/components/TaskAttachments.tsx` - 未使用
|
||||
- `frontend/src/components/TriggerList.tsx` - 未使用
|
||||
- `frontend/src/components/TriggerForm.tsx` - 未使用
|
||||
- **問題描述**: 多個元件已開發但未整合進任何頁面或路由。
|
||||
- **影響**: 使用者無法使用留言、附件管理、觸發器設定功能
|
||||
- **建議修復**:
|
||||
1. 建立任務詳情頁面/Modal,整合 Comments 和 Attachments
|
||||
2. 新增 /automation 路由整合 TriggerList/TriggerForm
|
||||
- **狀態**: [x] 已修復 (2026-01-04)
|
||||
- **修復方式**: 新增 TaskDetailModal.tsx 元件,整合 Comments 和 TaskAttachments,點擊任務卡片/列表項目即可開啟詳細視窗
|
||||
|
||||
---
|
||||
|
||||
### HIGH-008: 任務指派 UI 缺失
|
||||
|
||||
- **類型**: 功能缺失
|
||||
- **模組**: Frontend - Task Management
|
||||
- **檔案**: `frontend/src/pages/Tasks.tsx`
|
||||
- **問題描述**: 建立/編輯任務時無法選擇指派者 (assignee),無法設定時間估算。
|
||||
- **影響**: 核心任務管理功能不完整
|
||||
- **建議修復**: 在任務建立 Modal 新增指派者下拉選單和時間估算欄位
|
||||
- **狀態**: [x] 已修復 (2026-01-04)
|
||||
- **修復方式**: 新增 UserSelect.tsx 元件提供使用者搜尋下拉選單,Tasks.tsx 建立任務 Modal 新增指派者、到期日、時間估算欄位,TaskDetailModal 支援編輯這些欄位
|
||||
|
||||
---
|
||||
|
||||
## 中優先問題 (Medium)
|
||||
|
||||
### MED-001: 附件 Router 重複 Commit
|
||||
|
||||
- **類型**: 效能問題
|
||||
- **模組**: Backend - Attachments
|
||||
- **檔案**: `backend/app/api/attachments/router.py:118-133, 178-192`
|
||||
- **問題描述**: 同一請求中多次 `db.commit()`,效率低且可能導致部分交易狀態。
|
||||
- **建議修復**: 移除重複 commit,使用單一交易
|
||||
- **狀態**: [x] 已修復 (2026-01-04)
|
||||
- **修復方式**: 移除 attachments/router.py 中 4 處重複的 db.commit() 呼叫
|
||||
|
||||
---
|
||||
|
||||
### MED-002: 負載熱圖 N+1 查詢
|
||||
|
||||
- **類型**: 效能問題
|
||||
- **模組**: Backend - Resource Management
|
||||
- **檔案**: `backend/app/services/workload_service.py:169-210`
|
||||
- **問題描述**: 計算多使用者負載時,對每個使用者分別查詢任務。
|
||||
- **影響**: 使用者數量增加時資料庫效能下降
|
||||
- **建議修復**: 批次查詢所有使用者的任務,在記憶體中分組
|
||||
- **狀態**: [x] 已修復 (2026-01-04)
|
||||
- **修復方式**: 重構為批次查詢所有使用者的任務,使用 defaultdict 在記憶體中分組
|
||||
|
||||
---
|
||||
|
||||
### MED-003: datetime.utcnow() 已棄用
|
||||
|
||||
- **類型**: 棄用警告
|
||||
- **模組**: Backend - Security
|
||||
- **檔案**: `backend/app/core/security.py:21-25`
|
||||
- **問題描述**: 使用 `datetime.utcnow()` 在 Python 3.12+ 已棄用。
|
||||
- **建議修復**:
|
||||
```python
|
||||
from datetime import datetime, timezone
|
||||
expire = datetime.now(timezone.utc) + timedelta(...)
|
||||
```
|
||||
- **狀態**: [x] 已修復 (2026-01-04)
|
||||
- **修復方式**: 將所有 datetime.utcnow() 改為 datetime.now(timezone.utc).replace(tzinfo=None) 以保持與 SQLite 相容性
|
||||
|
||||
---
|
||||
|
||||
### MED-004: 錯誤回應格式不一致
|
||||
|
||||
- **類型**: API 一致性
|
||||
- **模組**: Backend - API
|
||||
- **檔案**: 多個 endpoints
|
||||
- **問題描述**: 部分 endpoints 回傳 `{"message": "..."}` 而其他回傳 `{"detail": "..."}`。FastAPI 慣例是 `detail`。
|
||||
- **影響**: 前端必須處理不一致的回應格式
|
||||
- **建議修復**: 統一使用 `{"detail": "..."}` 格式
|
||||
- **狀態**: [x] 已修復 (2026-01-04)
|
||||
- **修復方式**: 統一 attachments/router.py 和 auth/router.py 的回應格式為 {"detail": "..."}
|
||||
|
||||
---
|
||||
|
||||
### MED-005: 阻礙狀態自動設定可能衝突
|
||||
|
||||
- **類型**: 邏輯問題
|
||||
- **模組**: Backend - Tasks
|
||||
- **檔案**: `backend/app/api/tasks/router.py:506-511`
|
||||
- **問題描述**: 狀態變更時自動設定 `blocker_flag = False` 可能與 Blocker 表中未解除的阻礙記錄衝突。
|
||||
- **建議修復**: 根據 Blocker 表實際記錄設定 flag,而非僅依據狀態名稱
|
||||
- **狀態**: [x] 已修復 (2026-01-04)
|
||||
- **修復方式**: 修改狀態變更邏輯,檢查 Blocker 表中是否有未解決的阻礙記錄來決定 blocker_flag
|
||||
|
||||
---
|
||||
|
||||
### MED-006: 專案健康看板未實作
|
||||
|
||||
- **類型**: 功能缺失
|
||||
- **模組**: Backend + Frontend - Resource Management
|
||||
- **檔案**: -
|
||||
- **問題描述**: `pjctrl_project_health` 表和相關 API 未實作。
|
||||
- **影響**: 主管無法一覽所有專案狀態
|
||||
- **建議修復**: 實作後端 API 和前端健康看板元件
|
||||
- **狀態**: [x] 已修復 (2026-01-04)
|
||||
- **修復方式**: 實作 HealthService、health router、ProjectHealthPage 前端元件
|
||||
|
||||
---
|
||||
|
||||
### MED-007: 容量更新 API 缺失
|
||||
|
||||
- **類型**: 功能缺失
|
||||
- **模組**: Backend - Resource Management
|
||||
- **檔案**: -
|
||||
- **問題描述**: 使用者容量 (capacity) 儲存在資料庫,但無更新 API。
|
||||
- **建議修復**: 新增 `PUT /api/users/{id}/capacity` endpoint
|
||||
- **狀態**: [x] 已修復 (2026-01-04)
|
||||
- **修復方式**: 新增 PUT /api/users/{user_id}/capacity endpoint,支援權限檢查、稽核日誌、快取失效
|
||||
|
||||
---
|
||||
|
||||
### MED-008: 排程觸發器未完整實作
|
||||
|
||||
- **類型**: 功能缺失
|
||||
- **模組**: Backend - Automation
|
||||
- **檔案**: `backend/app/api/triggers/router.py`
|
||||
- **問題描述**: 觸發器類型驗證僅支援 `field_change` 和 `schedule`,但 schedule 類型的執行邏輯未完成。
|
||||
- **影響**: 無法使用時間條件觸發器
|
||||
- **建議修復**: 實作排程觸發器的 cron 解析和執行邏輯
|
||||
- **狀態**: [x] 已修復 (2026-01-04)
|
||||
- **修復方式**: 實作 TriggerSchedulerService,支援 cron 表達式解析、截止日期提醒、排程任務整合
|
||||
|
||||
---
|
||||
|
||||
### MED-009: 浮水印功能未實作
|
||||
|
||||
- **類型**: 功能缺失
|
||||
- **模組**: Backend - Document Management
|
||||
- **檔案**: `backend/app/api/attachments/router.py`
|
||||
- **問題描述**: Spec 要求下載時自動加上使用者浮水印,但未實作 Pillow/PyPDF2 處理邏輯。
|
||||
- **影響**: 無法追蹤檔案洩漏來源
|
||||
- **建議修復**: 實作圖片和 PDF 浮水印處理函數
|
||||
- **狀態**: [x] 已修復 (2026-01-04)
|
||||
- **修復方式**: 實作 WatermarkService,支援圖片和 PDF 浮水印,整合至下載流程
|
||||
|
||||
---
|
||||
|
||||
### MED-010: useEffect 依賴缺失
|
||||
|
||||
- **類型**: Bug
|
||||
- **模組**: Frontend - Multiple Components
|
||||
- **檔案**:
|
||||
- `frontend/src/components/TriggerList.tsx:27-29`
|
||||
- `frontend/src/components/ResourceHistory.tsx:15-17`
|
||||
- **問題描述**: `fetchTriggers` 和 `loadHistory` 函數在 useEffect 中呼叫但未列為依賴。
|
||||
- **影響**: 可能導致閉包過期 (stale closure),ESLint 警告
|
||||
- **建議修復**: 使用 useCallback 包裝函數並加入依賴陣列
|
||||
- **狀態**: [x] 已修復 (2026-01-04)
|
||||
- **修復方式**: 使用 useCallback 包裝 fetchTriggers 和 loadHistory 函數,並加入正確的依賴陣列
|
||||
|
||||
---
|
||||
|
||||
### MED-011: DOM 操作在元件外執行
|
||||
|
||||
- **類型**: 反模式
|
||||
- **模組**: Frontend - Attachments
|
||||
- **檔案**: `frontend/src/components/AttachmentUpload.tsx:185-192`
|
||||
- **問題描述**: 在元件模組頂層建立並附加 style 元素,違反 React 生命週期管理。
|
||||
- **影響**: 潛在記憶體洩漏,每次 import 都會執行
|
||||
- **建議修復**: 將樣式移至 CSS 檔案或使用 useEffect 管理
|
||||
- **狀態**: [x] 已修復 (2026-01-04)
|
||||
- **修復方式**: 將 style 元素注入邏輯移至 useEffect,並在清理時移除樣式元素
|
||||
|
||||
---
|
||||
|
||||
### MED-012: PDF 匯出未實作
|
||||
|
||||
- **類型**: 功能缺失
|
||||
- **模組**: Frontend - Audit Trail
|
||||
- **檔案**: `frontend/src/pages/AuditPage.tsx`
|
||||
- **問題描述**: Spec 提及 CSV/PDF 匯出,目前僅實作 CSV。
|
||||
- **建議修復**: 新增 PDF 匯出選項
|
||||
- **狀態**: [x] 已修復 (2026-01-04)
|
||||
- **修復方式**: 新增 Export PDF 按鈕,使用瀏覽器 window.print() 功能匯出 PDF
|
||||
|
||||
---
|
||||
|
||||
## 低優先問題 (Low)
|
||||
|
||||
### LOW-001: 缺少完整類型提示
|
||||
|
||||
- **類型**: 程式碼品質
|
||||
- **模組**: Backend - Services
|
||||
- **檔案**: 多個 service 檔案
|
||||
- **問題描述**: 部分函數缺少完整類型提示。
|
||||
- **狀態**: [ ] 待改善
|
||||
|
||||
---
|
||||
|
||||
### LOW-002: 分頁無最大限制
|
||||
|
||||
- **類型**: 效能問題
|
||||
- **模組**: Backend - API
|
||||
- **檔案**: 多個 list endpoints
|
||||
- **問題描述**: 雖有分頁實作,但部分 endpoints 無最大 page size 限制。
|
||||
- **狀態**: [x] 已修復 (Query validation with max limits)
|
||||
|
||||
---
|
||||
|
||||
### LOW-003: 狀態名稱使用魔術字串
|
||||
|
||||
- **類型**: 程式碼品質
|
||||
- **模組**: Backend - Reports
|
||||
- **檔案**: `backend/app/services/report_service.py:84-92`
|
||||
- **問題描述**: 使用硬編碼字串比對狀態名稱 `["done", "completed", "完成"]`。
|
||||
- **建議修復**: 統一使用 TaskStatus model 的 is_done flag
|
||||
- **狀態**: [x] 已修復 (使用 TaskStatus.is_done flag)
|
||||
|
||||
---
|
||||
|
||||
### LOW-004: 觸發器類型驗證不完整
|
||||
|
||||
- **類型**: 驗證不足
|
||||
- **模組**: Backend - Automation
|
||||
- **檔案**: `backend/app/api/triggers/router.py:62-66`
|
||||
- **問題描述**: 觸發器類型僅驗證 "field_change" 和 "schedule",但 spec 提及 "creation" 類型。
|
||||
- **狀態**: [x] 已修復 (新增 "creation" 類型驗證)
|
||||
|
||||
---
|
||||
|
||||
### LOW-005: 使用 any 類型
|
||||
|
||||
- **類型**: TypeScript 類型安全
|
||||
- **模組**: Frontend - Login
|
||||
- **檔案**: `frontend/src/pages/Login.tsx:21`
|
||||
- **問題描述**: `catch (err: any)` 失去類型安全。
|
||||
- **建議修復**: 使用 `catch (err: unknown)` 並進行類型守衛
|
||||
- **狀態**: [x] 已修復 (使用 unknown + type guard)
|
||||
|
||||
---
|
||||
|
||||
### LOW-006: 使用原生 confirm()/alert()
|
||||
|
||||
- **類型**: UX 一致性
|
||||
- **模組**: Frontend - Multiple
|
||||
- **檔案**:
|
||||
- `frontend/src/components/Comments.tsx:74`
|
||||
- `frontend/src/components/AttachmentList.tsx:40`
|
||||
- `frontend/src/components/TriggerList.tsx:41`
|
||||
- `frontend/src/components/WeeklyReportPreview.tsx:171`
|
||||
- **問題描述**: 使用原生對話框,非無障礙且 UX 不一致。
|
||||
- **建議修復**: 建立可重用的確認 Modal 元件
|
||||
- **狀態**: [x] 已修復 (ConfirmModal 元件 + A11Y 支援)
|
||||
|
||||
---
|
||||
|
||||
### LOW-007: 錯誤處理無使用者回饋
|
||||
|
||||
- **類型**: UX 問題
|
||||
- **模組**: Frontend - Spaces
|
||||
- **檔案**: `frontend/src/pages/Spaces.tsx:31-32`
|
||||
- **問題描述**: 錯誤僅記錄至 console,無向使用者顯示。
|
||||
- **建議修復**: 新增 toast 通知系統
|
||||
- **狀態**: [x] 已修復 (ToastContext + 整合至 Spaces/Projects/ProjectSettings 等頁面)
|
||||
|
||||
---
|
||||
|
||||
### LOW-008: 樣式方法不一致
|
||||
|
||||
- **類型**: 程式碼一致性
|
||||
- **模組**: Frontend - Styling
|
||||
- **檔案**: 多個元件
|
||||
- **問題描述**: 混合使用 inline styles 物件 (Dashboard.tsx) 和類似 Tailwind 的 class 字串 (Comments.tsx)。
|
||||
- **建議修復**: 統一使用 CSS Modules 或 styled-components
|
||||
- **狀態**: [ ] 待改善
|
||||
|
||||
---
|
||||
|
||||
### LOW-009: 缺少載入骨架
|
||||
|
||||
- **類型**: UX 問題
|
||||
- **模組**: 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
|
||||
|
||||
---
|
||||
|
||||
### LOW-010: 缺少前端測試
|
||||
|
||||
- **類型**: 測試覆蓋
|
||||
- **模組**: Frontend
|
||||
- **問題描述**: 未發現任何測試檔案。
|
||||
- **建議修復**: 新增 Vitest/Jest 單元測試
|
||||
- **狀態**: [x] 已修復 (Vitest + 21 測試)
|
||||
|
||||
---
|
||||
|
||||
## 未實作功能 (Missing Features)
|
||||
|
||||
| ID | 模組 | 功能 | 後端 | 前端 | 優先級 | 狀態 |
|
||||
|----|------|------|:----:|:----:|--------|------|
|
||||
| FEAT-001 | Task Management | 自定義欄位 (Custom Fields) | 有 | 有 | 高 | ✅ 已完成 (2026-01-05) |
|
||||
| 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 | 有 | 有 | 中 | ✅ 已完成 (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 | 有 | 有 | 低 | ✅ 已完成 (2026-01-06) |
|
||||
| FEAT-013 | Automation | 排程觸發器執行 | 有 | N/A | 中 | ✅ 已完成 (trigger_scheduler.py) |
|
||||
| FEAT-014 | Automation | 更新欄位動作 | 有 | N/A | 低 | ✅ 已完成 (2026-01-06) |
|
||||
| FEAT-015 | Automation | 自動指派動作 | 有 | N/A | 低 | ✅ 已完成 (2026-01-06) |
|
||||
| FEAT-016 | Audit Trail | 稽核完整性驗證 UI | 有 | 有 | 低 | ✅ 已完成 (2026-01-06) |
|
||||
|
||||
---
|
||||
|
||||
## 可訪問性問題 (Accessibility)
|
||||
|
||||
### A11Y-001: 表單缺少 Label
|
||||
|
||||
- **檔案**: `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
|
||||
|
||||
---
|
||||
|
||||
### A11Y-002: 非語義化按鈕
|
||||
|
||||
- **檔案**: `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`
|
||||
|
||||
---
|
||||
|
||||
### A11Y-003: 圖示按鈕缺少 aria-label
|
||||
|
||||
- **檔案**: `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) 未達 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)
|
||||
|
||||
---
|
||||
|
||||
### A11Y-005: 缺少焦點指示器
|
||||
|
||||
- **檔案**: `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` 自訂樣式
|
||||
|
||||
---
|
||||
|
||||
### A11Y-006: Modal 無焦點捕獲
|
||||
|
||||
- **檔案**: 多個 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}`
|
||||
|
||||
---
|
||||
|
||||
## 程式碼品質建議 (Code Quality)
|
||||
|
||||
### 後端建議
|
||||
|
||||
1. **啟用 SQLAlchemy 嚴格模式** - 捕獲潛在的關係問題
|
||||
2. **新增 API 文檔測試** - 確保 OpenAPI spec 與實作一致
|
||||
3. **統一日誌格式** - 使用結構化日誌 (如 structlog)
|
||||
4. **新增健康檢查 endpoint** - `/health` 回傳資料庫/Redis 連線狀態
|
||||
|
||||
### 前端建議
|
||||
|
||||
1. **啟用 TypeScript 嚴格模式** - `"strict": true` in tsconfig
|
||||
2. **新增 ESLint exhaustive-deps 規則** - 防止 useEffect 依賴問題
|
||||
3. **建立共用元件庫** - Button, Modal, Input, Toast 等
|
||||
4. **實作錯誤邊界** - 防止元件錯誤影響整個應用
|
||||
5. **新增 i18n 支援** - 目前中英文混用
|
||||
|
||||
---
|
||||
|
||||
## 統計摘要
|
||||
|
||||
| 類別 | 數量 |
|
||||
|------|------|
|
||||
| 嚴重問題 (Critical) | 3 |
|
||||
| 高優先問題 (High) | 8 |
|
||||
| 中優先問題 (Medium) | 12 |
|
||||
| 低優先問題 (Low) | 10 |
|
||||
| 未實作功能 | 16 |
|
||||
| 可訪問性問題 | 6 |
|
||||
| **總計** | **55** |
|
||||
|
||||
---
|
||||
|
||||
## 修復進度追蹤
|
||||
|
||||
- [x] 嚴重問題全部修復 (3/3 已完成)
|
||||
- [x] 高優先問題全部修復 (8/8 已完成)
|
||||
- [x] 中優先問題全部修復 (12/12 已完成)
|
||||
- [x] 核心功能實作完成
|
||||
- [x] 可訪問性問題修復 (6/6 已完成)
|
||||
- [x] 程式碼品質改善 (LOW-002~010 已完成, LOW-001/LOW-008 待改善)
|
||||
|
||||
### 本次修復摘要 (2026-01-04)
|
||||
|
||||
| Issue ID | 問題 | 狀態 |
|
||||
|----------|------|------|
|
||||
| CRIT-001 | JWT 密鑰硬編碼 | ✅ 已修復 |
|
||||
| CRIT-002 | 登入嘗試未記錄稽核日誌 | ✅ 已修復 |
|
||||
| CRIT-003 | 前端 API 路徑重複 | ✅ 已修復 |
|
||||
| HIGH-001 | 專案刪除使用硬刪除 | ✅ 已修復 |
|
||||
| HIGH-002 | Redis Session Token 類型比對 | ✅ 已修復 |
|
||||
| HIGH-003 | 無 Rate Limiting 實作 | ✅ 已修復 |
|
||||
| HIGH-004 | 附件 API 缺少權限檢查 | ✅ 已修復 |
|
||||
| HIGH-005 | 任務視角僅有列表視角 | ✅ 已修復 |
|
||||
| HIGH-006 | 資源管理模組前端 UI | ✅ 已修復 |
|
||||
| HIGH-007 | 協作/附件/觸發器元件未整合 | ✅ 已修復 |
|
||||
| HIGH-008 | 任務指派 UI 缺失 | ✅ 已修復 |
|
||||
| MED-001 | 附件 Router 重複 Commit | ✅ 已修復 |
|
||||
| MED-002 | 負載熱圖 N+1 查詢 | ✅ 已修復 |
|
||||
| MED-003 | datetime.utcnow() 已棄用 | ✅ 已修復 |
|
||||
| MED-004 | 錯誤回應格式不一致 | ✅ 已修復 |
|
||||
| MED-005 | 阻礙狀態自動設定可能衝突 | ✅ 已修復 |
|
||||
| MED-006 | 專案健康看板 | ✅ 已修復 |
|
||||
| MED-007 | 容量更新 API | ✅ 已修復 |
|
||||
| MED-008 | 排程觸發器執行邏輯 | ✅ 已修復 |
|
||||
| MED-009 | 浮水印功能 | ✅ 已修復 |
|
||||
| MED-010 | useEffect 依賴缺失 | ✅ 已修復 |
|
||||
| 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 | 缺少完整類型提示 | Backend services 待改善 |
|
||||
| LOW-008 | 樣式方法不一致 | 非關鍵,建議後續統一 |
|
||||
|
||||
### QA 驗證結果
|
||||
|
||||
**Backend QA (2026-01-04)**:
|
||||
- CRIT-001: JWT 驗證 ✅ 正確實現
|
||||
- CRIT-002: 登入審計 ✅ 完整記錄成功/失敗
|
||||
- HIGH-001: 軟刪除 ✅ 正確使用 is_active 標記
|
||||
- HIGH-002: Redis bytes ✅ 正確處理解碼
|
||||
- HIGH-003: Rate Limiting ✅ slowapi 實作,5 req/min 限制
|
||||
- HIGH-004: 權限檢查 ✅ 所有 endpoints 已驗證
|
||||
- MED-006: 專案健康看板 ✅ 32 測試通過,風險評分正確
|
||||
- MED-007: 容量更新 API ✅ 14 測試通過,權限和稽核正確
|
||||
- MED-008: 排程觸發器 ✅ 35 測試通過,cron 和截止日期提醒正確
|
||||
- MED-009: 浮水印功能 ✅ 32 測試通過,圖片和 PDF 浮水印正確
|
||||
|
||||
**Frontend QA (2026-01-04)**:
|
||||
- CRIT-003: API 路徑 ✅ 所有 45+ endpoints 驗證通過
|
||||
- HIGH-005: Kanban 視角 ✅ 拖拉功能正常
|
||||
- HIGH-007: Comments/Attachments ✅ 整合於 TaskDetailModal
|
||||
- HIGH-008: 指派 UI ✅ UserSelect 元件運作正常
|
||||
- MED-006: 專案健康看板 ✅ ProjectHealthPage 和 ProjectHealthCard 元件完成
|
||||
|
||||
### OpenSpec 變更歸檔
|
||||
|
||||
| 日期 | 變更名稱 | 影響的 Spec |
|
||||
|------|----------|-------------|
|
||||
| 2025-12-28 | add-user-auth | user-auth |
|
||||
| 2025-12-28 | add-task-management | task-management |
|
||||
| 2025-12-28 | add-resource-workload | resource-management |
|
||||
| 2025-12-29 | add-collaboration | collaboration |
|
||||
| 2025-12-29 | add-audit-trail | audit-trail |
|
||||
| 2025-12-29 | add-automation | automation |
|
||||
| 2025-12-29 | add-document-management | document-management |
|
||||
| 2025-12-29 | fix-audit-trail | audit-trail |
|
||||
| 2025-12-30 | fix-realtime-notifications | collaboration |
|
||||
| 2025-12-30 | fix-weekly-report | automation |
|
||||
| 2026-01-04 | add-rate-limiting | user-auth |
|
||||
| 2026-01-04 | enhance-frontend-ux | task-management |
|
||||
| 2026-01-04 | add-resource-management-ui | resource-management |
|
||||
| 2026-01-04 | add-project-health-dashboard | resource-management |
|
||||
| 2026-01-04 | add-capacity-update-api | resource-management |
|
||||
| 2026-01-04 | add-schedule-triggers | automation |
|
||||
| 2026-01-04 | add-watermark-feature | document-management |
|
||||
| 2026-01-05 | add-kanban-realtime-sync | collaboration |
|
||||
| 2026-01-05 | add-file-encryption | document-management |
|
||||
| 2026-01-05 | add-gantt-view | task-management |
|
||||
| 2026-01-05 | add-calendar-view | task-management |
|
||||
| 2026-01-05 | add-custom-fields | task-management |
|
||||
|
||||
---
|
||||
|
||||
## 新發現問題 (New Issues)
|
||||
|
||||
### NEW-001: 負載快取失效未在任務變更時觸發
|
||||
|
||||
- **類型**: Bug
|
||||
- **模組**: Backend - Resource Management
|
||||
- **檔案**: `backend/app/api/tasks/router.py`
|
||||
- **問題描述**: `workload_cache.py` 定義了 `invalidate_user_workload_cache()` 函數,但任務指派、時間估算、狀態變更時未調用,導致負載熱圖顯示過期數據。
|
||||
- **影響**: 使用者變更任務後,負載熱圖不會即時反映最新分配
|
||||
- **建議修復**: 在 `tasks/router.py` 的 `update_task`、`update_task_status`、`assign_task` 等 endpoints 調用 `invalidate_user_workload_cache()`
|
||||
- **狀態**: [x] 已修復 (2026-01-04)
|
||||
- **修復內容**:
|
||||
- 在 `create_task`、`update_task`、`update_task_status`、`delete_task`、`restore_task`、`assign_task` 端點加入 `invalidate_user_workload_cache()` 呼叫
|
||||
- 同時清除 `workload:heatmap:*` 快取確保熱圖即時更新
|
||||
|
||||
---
|
||||
|
||||
### NEW-002: 看板缺少即時同步功能
|
||||
|
||||
- **類型**: 功能缺失
|
||||
- **模組**: Frontend + Backend - Task Management
|
||||
- **檔案**:
|
||||
- `backend/app/api/websocket/router.py`
|
||||
- `frontend/src/components/KanbanBoard.tsx`
|
||||
- **問題描述**: WebSocket 目前僅用於通知推送,看板視角沒有即時同步功能。當其他用戶拖拉任務變更狀態時,當前用戶的看板不會即時更新。
|
||||
- **影響**: 多人協作時可能產生狀態衝突
|
||||
- **建議修復**: 擴展 WebSocket 支援任務變更事件廣播,前端訂閱並即時更新看板
|
||||
- **狀態**: [x] 已修復 (2026-01-05)
|
||||
- **修復內容**:
|
||||
- Backend: 新增 `/ws/projects/{project_id}` WebSocket endpoint
|
||||
- Backend: 實作 Redis Pub/Sub 任務事件廣播 (`project:{project_id}:tasks` 頻道)
|
||||
- Backend: 支援 task_created, task_updated, task_status_changed, task_deleted, task_assigned 事件
|
||||
- Frontend: 新增 ProjectSyncContext 管理 WebSocket 連線
|
||||
- Frontend: Tasks.tsx 整合即時更新,支援 event_id 去重、多分頁支援
|
||||
- Frontend: 新增 Live/Offline 連線狀態指示器
|
||||
- OpenSpec: 更新 collaboration spec 新增 Project Real-time Sync requirement
|
||||
|
||||
---
|
||||
|
||||
*此文件由 Claude Code 自動生成於 2026-01-04*
|
||||
*更新於 2026-01-07*
|
||||
@@ -0,0 +1,45 @@
|
||||
## Context
|
||||
需要支援 Trigger 複合條件(AND-only)、群組通知目標,以及「手動訂閱」的週報收件人規則,並同步更新前端操作介面。
|
||||
|
||||
## Goals / Non-Goals
|
||||
- Goals:
|
||||
- 支援多條件 AND-only 觸發器條件
|
||||
- 支援 `due_date`/`start_date`/`custom_fields`(含 formula)與 `before/after/in` 運算子
|
||||
- 支援群組通知目標與去重、排除觸發者
|
||||
- 週報改為手動訂閱且收件人為專案成員(含跨部門)
|
||||
- 前端同版支援條件編輯與訂閱開關
|
||||
- Non-Goals:
|
||||
- 不實作 OR/巢狀條件樹
|
||||
- 不新增 Email 通知通道
|
||||
- 不改動排程時間(維持週五 16:00)
|
||||
|
||||
## Decisions
|
||||
- 條件結構
|
||||
- `field_change` 觸發器支援兩種條件格式:
|
||||
- Legacy: `{ field, operator, value }`
|
||||
- Composite: `{ logic: "and", rules: [ { field, operator, value, field_id? } ] }`
|
||||
- `in` 在日期欄位採 `{ start, end }`,且包含邊界。
|
||||
- `in` 在文字/下拉/人員欄位採用陣列值。
|
||||
- `before/after` 用於日期欄位;`before/after` 也可用於數值類(number/formula)。
|
||||
- 觸發時機
|
||||
- 觸發器於任務/自訂欄位更新時評估。
|
||||
- 規則需同時滿足且至少有一個規則所屬欄位在此次更新中變更,避免無關更新重複觸發。
|
||||
- 通知目標解析
|
||||
- 支援 `assignee`/`creator`/`project_owner`/`project_members`/`department:<id>`/`role:<name>`/`user:<id>`。
|
||||
- `project_members` 包含 owner。
|
||||
- `department`/`role` 解析為組織內所有符合的使用者。
|
||||
- 排除觸發者本人並對收件人去重。
|
||||
- 週報訂閱
|
||||
- 使用 `pjctrl_scheduled_reports` 作為訂閱資料;一位使用者一筆 weekly 訂閱。
|
||||
- 週報僅發送給已訂閱使用者。
|
||||
- 週報內容包含使用者為 owner 或 project member 的所有專案。
|
||||
|
||||
## Risks / Trade-offs
|
||||
- `department`/`role` 可能觸及非專案成員,需確保用戶理解通知範圍。
|
||||
- 自訂欄位(formula)計算可能帶來額外成本,需避免 N+1 查詢。
|
||||
|
||||
## Migration Plan
|
||||
- 無資料庫 schema 變更。
|
||||
|
||||
## Open Questions
|
||||
- 無(已確認 AND-only、日期區間 inclusive、role 自由輸入)。
|
||||
@@ -0,0 +1,17 @@
|
||||
# Change: Trigger Composite Conditions, Group Notifications, Weekly Report Subscriptions
|
||||
|
||||
## Why
|
||||
目前觸發器僅支援單一條件與單一通知對象,無法滿足複合條件與群組通知需求;週報收件人規則亦需改為手動訂閱並涵蓋專案成員。
|
||||
|
||||
## What Changes
|
||||
- 新增 AND-only 複合條件結構,支援 `due_date`/`start_date`/`custom_fields`(含 formula),並加入 `before/after/in` 運算子(日期 `in` 採用區間且包含邊界)。
|
||||
- 通知目標擴充為 `project_members`/`department:<id>`/`role:<name>`/`user:<id>`,並加入去重與排除觸發者規則。
|
||||
- 週報改為「手動訂閱」機制,僅發送給已訂閱的使用者;週報內容涵蓋使用者為 owner 或 project member 的所有專案。
|
||||
- 前端同步支援複合條件編輯、群組通知目標、MySettings 週報訂閱開關。
|
||||
|
||||
## Impact
|
||||
- Affected specs: `automation`
|
||||
- Affected code:
|
||||
- Backend: `backend/app/schemas/trigger.py`, `backend/app/services/trigger_service.py`, `backend/app/api/triggers/router.py`, `backend/app/services/report_service.py`, `backend/app/api/reports/router.py`
|
||||
- Frontend: `frontend/src/components/TriggerForm.tsx`, `frontend/src/components/TriggerList.tsx`, `frontend/src/services/triggers.ts`, `frontend/src/pages/MySettings.tsx`
|
||||
- Tests: trigger conditions/group notifications/weekly report subscription coverage
|
||||
@@ -0,0 +1,117 @@
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Trigger Conditions
|
||||
系統 SHALL 支援多種觸發條件類型,包含欄位變更、時間條件、以及 AND-only 複合條件。欄位變更條件 SHALL 支援 `status_id`、`assignee_id`、`priority`、`due_date`、`start_date` 與 `custom_fields`(含 formula),並支援 `equals`、`not_equals`、`changed_to`、`changed_from`、`before`、`after`、`in` 運算子。日期欄位的 `in` SHALL 使用 `{start, end}` 區間且包含邊界。
|
||||
|
||||
#### Scenario: 欄位變更條件
|
||||
- **GIVEN** 觸發器設定為「當 Status 欄位變更為特定值」
|
||||
- **WHEN** 任務的 Status 欄位變更為該值
|
||||
- **THEN** 觸發器被觸發
|
||||
|
||||
#### Scenario: 時間條件
|
||||
- **GIVEN** 觸發器設定為「每週五下午 4:00」
|
||||
- **WHEN** 系統時間達到設定時間
|
||||
- **THEN** 觸發器被觸發
|
||||
|
||||
#### Scenario: 複合條件
|
||||
- **GIVEN** 觸發器設定為「當 Status = 完成 且 Priority = 高」
|
||||
- **WHEN** 任務同時滿足兩個條件
|
||||
- **THEN** 觸發器被觸發
|
||||
|
||||
#### Scenario: 日期區間條件
|
||||
- **GIVEN** 觸發器設定為「due_date in {start, end}」且區間為含邊界
|
||||
- **WHEN** 任務的 due_date 落在該區間內
|
||||
- **THEN** 觸發器被觸發
|
||||
|
||||
#### Scenario: 自訂欄位(公式)條件
|
||||
- **GIVEN** 觸發器設定為「custom_fields(公式欄位) equals 目標值」
|
||||
- **WHEN** 任務的公式欄位計算值符合目標值
|
||||
- **THEN** 觸發器被觸發
|
||||
|
||||
#### Scenario: Cron 表達式觸發
|
||||
- **GIVEN** 觸發器設定為 cron 表達式 (如 `0 9 * * 1` 每週一早上 9 點)
|
||||
- **WHEN** 系統時間匹配 cron 表達式
|
||||
- **THEN** 系統評估並執行該觸發器
|
||||
- **AND** 記錄執行結果至 trigger_logs
|
||||
|
||||
#### Scenario: 截止日期提醒
|
||||
- **GIVEN** 觸發器設定為「截止日前 N 天提醒」
|
||||
- **WHEN** 任務距離截止日剩餘 N 天
|
||||
- **THEN** 系統發送提醒通知給任務指派者
|
||||
- **AND** 每個任務每個提醒設定只觸發一次
|
||||
|
||||
### Requirement: Trigger Actions
|
||||
系統 SHALL 支援多種觸發動作類型。通知動作 SHALL 支援單人與群組目標(`assignee`、`creator`、`project_owner`、`project_members`、`department:<id>`、`role:<name>`、`user:<id>`),並對收件人去重且排除觸發者本人。
|
||||
|
||||
#### Scenario: 發送通知動作
|
||||
- **GIVEN** 觸發器動作設定為發送通知
|
||||
- **WHEN** 觸發器被觸發
|
||||
- **THEN** 系統發送通知給指定對象
|
||||
- **AND** 通知內容可使用變數(如任務名稱、指派者)
|
||||
|
||||
#### Scenario: 群組通知目標
|
||||
- **GIVEN** 觸發器通知目標為 `project_members` 或 `department:<id>` 或 `role:<name>`
|
||||
- **WHEN** 觸發器被觸發
|
||||
- **THEN** 系統通知所有對應成員
|
||||
- **AND** 去除重複收件人
|
||||
- **AND** 排除觸發者本人
|
||||
|
||||
#### Scenario: 更新欄位動作
|
||||
- **GIVEN** 觸發器動作設定為更新欄位
|
||||
- **WHEN** 觸發器被觸發
|
||||
- **THEN** 系統自動更新指定欄位的值
|
||||
|
||||
#### Scenario: 指派任務動作
|
||||
- **GIVEN** 觸發器動作設定為自動指派
|
||||
- **WHEN** 觸發器被觸發
|
||||
- **THEN** 系統自動將任務指派給指定人員
|
||||
|
||||
### Requirement: Automated Weekly Report
|
||||
系統 SHALL 每週五下午 4:00 自動彙整完整任務清單,發送給已訂閱的專案成員(含跨部門成員)。週報內容 SHALL 以收件人為 owner 或 project member 的專案為範圍。
|
||||
|
||||
#### Scenario: 週報內容完整清單
|
||||
- **GIVEN** 週報生成中
|
||||
- **WHEN** 系統彙整資料
|
||||
- **THEN** 週報包含各專案的:
|
||||
- 本週已完成任務清單(含 completed_at, assignee_name)
|
||||
- 進行中任務清單(含 assignee_name, due_date)
|
||||
- 逾期任務警示(含 due_date, days_overdue)
|
||||
- 阻礙中任務清單(含 blocker_reason, blocked_since)
|
||||
- 下週預計完成任務(含 due_date, assignee_name)
|
||||
- **AND** 不設任務數量上限
|
||||
|
||||
#### Scenario: 週報收件人範圍
|
||||
- **GIVEN** 使用者為專案成員且已開啟週報訂閱
|
||||
- **WHEN** 週報排程執行
|
||||
- **THEN** 使用者收到週報
|
||||
|
||||
#### Scenario: 阻礙任務識別
|
||||
- **GIVEN** 任務有未解除的 Blocker 記錄
|
||||
- **WHEN** 週報查詢阻礙任務
|
||||
- **THEN** 系統查詢 Blocker 表 resolved_at IS NULL 的任務
|
||||
- **AND** 顯示阻礙原因與開始時間
|
||||
|
||||
#### Scenario: 下週預計任務
|
||||
- **GIVEN** 任務的 due_date 在下週範圍內
|
||||
- **WHEN** 週報查詢下週預計任務
|
||||
- **THEN** 系統篩選 due_date >= 下週一 且 < 下週日
|
||||
- **AND** 排除已完成狀態的任務
|
||||
|
||||
## ADDED Requirements
|
||||
### Requirement: Weekly Report Subscription
|
||||
系統 SHALL 提供週報訂閱管理功能,讓使用者手動開啟或關閉週報。
|
||||
|
||||
#### Scenario: 開啟週報訂閱
|
||||
- **GIVEN** 使用者尚未訂閱週報
|
||||
- **WHEN** 使用者在 MySettings 開啟週報訂閱
|
||||
- **THEN** 系統建立或啟用該使用者的 weekly 訂閱
|
||||
|
||||
#### Scenario: 關閉週報訂閱
|
||||
- **GIVEN** 使用者已訂閱週報
|
||||
- **WHEN** 使用者在 MySettings 關閉週報訂閱
|
||||
- **THEN** 系統停用該使用者的 weekly 訂閱
|
||||
- **AND** 後續排程不再發送週報
|
||||
|
||||
#### Scenario: 未訂閱預設行為
|
||||
- **GIVEN** 使用者未開啟週報訂閱
|
||||
- **WHEN** 週報排程執行
|
||||
- **THEN** 使用者不會收到週報
|
||||
@@ -0,0 +1,22 @@
|
||||
## 1. Backend
|
||||
- [x] 1.1 Extend trigger schemas/validation to accept composite conditions and new operators/fields
|
||||
- [x] 1.2 Implement composite condition evaluation (AND-only) with operator semantics and custom field/formula support
|
||||
- [x] 1.3 Extend notify target resolution for group targets, dedup recipients, and exclude triggerer
|
||||
- [x] 1.4 Evaluate triggers on due_date/start_date/custom_field updates
|
||||
- [x] 1.5 Add weekly report subscription API (get/update) and enforce manual subscription
|
||||
- [x] 1.6 Include project members (and owner) in weekly report project scope
|
||||
|
||||
## 2. Frontend
|
||||
- [x] 2.1 Update trigger API types for composite conditions and group targets
|
||||
- [x] 2.2 Update TriggerForm/TriggerList to build AND-only rule lists with date range + custom field inputs
|
||||
- [x] 2.3 Add MySettings weekly report subscription toggle (with API integration)
|
||||
- [x] 2.4 Add i18n strings for new trigger/weekly report UI
|
||||
|
||||
## 3. Tests
|
||||
- [x] 3.1 Backend tests for composite conditions (status+priority, due_date range)
|
||||
- [x] 3.2 Backend tests for custom field (formula) conditions
|
||||
- [x] 3.3 Backend tests for group notification targeting (department/role/project_members) with dedup/exclude
|
||||
- [x] 3.4 Backend tests for weekly report subscription and project-member scope
|
||||
|
||||
## 4. Validation
|
||||
- [x] 4.1 Run targeted pytest in conda and report results
|
||||
Reference in New Issue
Block a user