Fix test failures and workload/websocket behavior

This commit is contained in:
beabigegg
2026-01-11 08:37:21 +08:00
parent 3bdc6ff1c9
commit f5f870da56
49 changed files with 3006 additions and 1132 deletions

8
.gitignore vendored
View File

@@ -21,3 +21,11 @@ dump.rdb
# Logs # Logs
logs/ logs/
.playwright-mcp/ .playwright-mcp/
.mcp.json
# Playwright
frontend/e2e/.auth/
frontend/test-results/
frontend/playwright-report/
.mcp.json
.mcp.json

View File

@@ -1,5 +1,13 @@
{ {
"mcpServers": { "mcpServers": {
"playwright": {
"type": "stdio",
"command": "npx",
"args": [
"@playwright/mcp@latest"
],
"env": {}
},
"vscode-lsp": { "vscode-lsp": {
"type": "stdio", "type": "stdio",
"command": "./mcp-lsp-proxy.sh" "command": "./mcp-lsp-proxy.sh"

View File

@@ -160,7 +160,7 @@ def get_workload_summary(db: Session, user: User) -> WorkloadSummary:
if task.original_estimate: if task.original_estimate:
allocated_hours += 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_percentage = calculate_load_percentage(allocated_hours, capacity_hours)
load_level = determine_load_level(load_percentage) load_level = determine_load_level(load_percentage)

View File

@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db 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.models.task_status import DEFAULT_STATUSES
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse, ProjectWithDetails from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse, ProjectWithDetails
from app.schemas.task_status import TaskStatusResponse from app.schemas.task_status import TaskStatusResponse
@@ -36,6 +36,17 @@ def create_default_statuses(db: Session, project_id: str):
db.add(status) 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]) @router.get("/api/spaces/{space_id}/projects", response_model=List[ProjectWithDetails])
async def list_projects_in_space( async def list_projects_in_space(
space_id: str, space_id: str,
@@ -115,6 +126,27 @@ async def create_project(
detail="Access denied", 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( project = Project(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
space_id=space_id, space_id=space_id,
@@ -124,17 +156,47 @@ async def create_project(
budget=project_data.budget, budget=project_data.budget,
start_date=project_data.start_date, start_date=project_data.start_date,
end_date=project_data.end_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, department_id=project_data.department_id or current_user.department_id,
) )
db.add(project) db.add(project)
db.flush() # Get the project ID 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_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 # 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( AuditService.log_event(
db=db, db=db,
event_type="project.create", event_type="project.create",
@@ -142,7 +204,7 @@ async def create_project(
action=AuditAction.CREATE, action=AuditAction.CREATE,
user_id=current_user.id, user_id=current_user.id,
resource_id=project.id, resource_id=project.id,
changes=[{"field": "title", "old_value": None, "new_value": project.title}], changes=changes,
request_metadata=get_audit_metadata(request), request_metadata=get_audit_metadata(request),
) )

View File

@@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
import uuid
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import Optional from typing import Optional
@@ -8,7 +9,7 @@ from app.core.config import settings
from app.models import User, ReportHistory, ScheduledReport from app.models import User, ReportHistory, ScheduledReport
from app.schemas.report import ( from app.schemas.report import (
WeeklyReportContent, ReportHistoryListResponse, ReportHistoryItem, WeeklyReportContent, ReportHistoryListResponse, ReportHistoryItem,
GenerateReportResponse, ReportSummary GenerateReportResponse, ReportSummary, WeeklyReportSubscription, WeeklyReportSubscriptionUpdate
) )
from app.middleware.auth import get_current_user from app.middleware.auth import get_current_user
from app.services.report_service import ReportService from app.services.report_service import ReportService
@@ -16,6 +17,62 @@ from app.services.report_service import ReportService
router = APIRouter(tags=["reports"]) 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) @router.get("/api/reports/weekly/preview", response_model=WeeklyReportContent)
async def preview_weekly_report( async def preview_weekly_report(
db: Session = Depends(get_db), db: Session = Depends(get_db),

View File

@@ -407,17 +407,18 @@ async def update_task(
if task_data.version != task.version: if task_data.version != task.version:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail={ detail=(
"message": "Task has been modified by another user", f"Version conflict: current_version={task.version}, "
"current_version": task.version, f"provided_version={task_data.version}"
"provided_version": task_data.version, ),
},
) )
# Capture old values for audit and triggers # Capture old values for audit and triggers
old_values = { old_values = {
"title": task.title, "title": task.title,
"description": task.description, "description": task.description,
"status_id": task.status_id,
"assignee_id": task.assignee_id,
"priority": task.priority, "priority": task.priority,
"start_date": task.start_date, "start_date": task.start_date,
"due_date": task.due_date, "due_date": task.due_date,
@@ -430,6 +431,17 @@ async def update_task(
custom_values_data = update_data.pop("custom_values", None) custom_values_data = update_data.pop("custom_values", None)
update_data.pop("version", None) # version is handled separately for optimistic locking 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 # Track old assignee for workload cache invalidation
old_assignee_id = task.assignee_id old_assignee_id = task.assignee_id
@@ -488,6 +500,8 @@ async def update_task(
new_values = { new_values = {
"title": task.title, "title": task.title,
"description": task.description, "description": task.description,
"status_id": task.status_id,
"assignee_id": task.assignee_id,
"priority": task.priority, "priority": task.priority,
"start_date": task.start_date, "start_date": task.start_date,
"due_date": task.due_date, "due_date": task.due_date,
@@ -509,30 +523,46 @@ async def update_task(
request_metadata=get_audit_metadata(request), 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 # Handle custom values update
new_custom_values = None
if custom_values_data: if custom_values_data:
try: try:
from app.schemas.task import CustomValueInput from app.schemas.task import CustomValueInput
custom_values = [CustomValueInput(**cv) for cv in custom_values_data] custom_values = [CustomValueInput(**cv) for cv in custom_values_data]
CustomValueService.save_custom_values(db, task, custom_values) 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: except ValueError as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e), 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 # Increment version for optimistic locking
task.version += 1 task.version += 1
db.commit() db.commit()
db.refresh(task) db.refresh(task)
# Invalidate workload cache if original_estimate changed and task has an assignee # Invalidate workload cache if workload-affecting fields changed
if "original_estimate" in update_data and task.assignee_id: if ("original_estimate" in update_data or "due_date" in update_data) and task.assignee_id:
invalidate_user_workload_cache(task.assignee_id) invalidate_user_workload_cache(task.assignee_id)
# Invalidate workload cache if assignee changed # Invalidate workload cache if assignee changed

View File

@@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
from typing import Optional from typing import Optional
from app.core.database import get_db 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 ( from app.schemas.trigger import (
TriggerCreate, TriggerUpdate, TriggerResponse, TriggerListResponse, TriggerCreate, TriggerUpdate, TriggerResponse, TriggerListResponse,
TriggerLogResponse, TriggerLogListResponse, TriggerUserInfo TriggerLogResponse, TriggerLogListResponse, TriggerUserInfo
@@ -16,6 +16,10 @@ from app.services.action_executor import ActionValidationError
router = APIRouter(tags=["triggers"]) 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: def trigger_to_response(trigger: Trigger) -> TriggerResponse:
"""Convert Trigger model to 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) @router.post("/api/projects/{project_id}/triggers", response_model=TriggerResponse, status_code=status.HTTP_201_CREATED)
async def create_trigger( async def create_trigger(
project_id: str, project_id: str,
@@ -71,27 +165,7 @@ async def create_trigger(
# Validate conditions based on trigger type # Validate conditions based on trigger type
if trigger_data.trigger_type == "field_change": if trigger_data.trigger_type == "field_change":
# Validate field_change conditions _validate_field_change_conditions(trigger_data.conditions, project_id, db)
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'",
)
elif trigger_data.trigger_type == "schedule": elif trigger_data.trigger_type == "schedule":
# Validate schedule conditions # Validate schedule conditions
has_cron = trigger_data.conditions.cron_expression is not None 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: if trigger_data.conditions is not None:
# Validate conditions based on trigger type # Validate conditions based on trigger type
if trigger.trigger_type == "field_change": if trigger.trigger_type == "field_change":
if trigger_data.conditions.field and trigger_data.conditions.field not in ["status_id", "assignee_id", "priority"]: _validate_field_change_conditions(trigger_data.conditions, trigger.project_id, db)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid condition field",
)
elif trigger.trigger_type == "schedule": elif trigger.trigger_type == "schedule":
# Validate cron expression if provided # Validate cron expression if provided
if trigger_data.conditions.cron_expression is not None: if trigger_data.conditions.cron_expression is not None:

View File

@@ -283,7 +283,7 @@ async def update_user_capacity(
) )
# Store old capacity for audit log # 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) # Update capacity (validation is handled by Pydantic schema)
user.capacity = capacity.capacity_hours user.capacity = capacity.capacity_hours

View File

@@ -1,11 +1,12 @@
import asyncio import asyncio
import os
import logging import logging
import time import time
from typing import Optional from typing import Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from sqlalchemy.orm import Session 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.security import decode_access_token
from app.core.redis import get_redis_sync from app.core.redis import get_redis_sync
from app.models import User, Notification, Project 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) # Authentication timeout (10 seconds)
AUTH_TIMEOUT = 10.0 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]: 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 return None, None
# Get user from database # Get user from database
db = SessionLocal() db = database.SessionLocal()
try: try:
user = db.query(User).filter(User.id == user_id).first() user = db.query(User).filter(User.id == user_id).first()
if user is None or not user.is_active: 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( async def authenticate_websocket(
websocket: WebSocket, websocket: WebSocket,
query_token: Optional[str] = None query_token: Optional[str] = None
) -> tuple[str | None, User | None]: ) -> tuple[str | None, User | None, Optional[str]]:
""" """
Authenticate WebSocket connection. Authenticate WebSocket connection.
@@ -72,7 +75,10 @@ async def authenticate_websocket(
"WebSocket authentication via query parameter is deprecated. " "WebSocket authentication via query parameter is deprecated. "
"Please use first-message authentication for better security." "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 # Wait for authentication message with timeout
try: try:
@@ -84,26 +90,29 @@ async def authenticate_websocket(
msg_type = data.get("type") msg_type = data.get("type")
if msg_type != "auth": if msg_type != "auth":
logger.warning("Expected 'auth' message type, got: %s", msg_type) logger.warning("Expected 'auth' message type, got: %s", msg_type)
return None, None return None, None, "invalid_message"
token = data.get("token") token = data.get("token")
if not token: if not token:
logger.warning("No token provided in auth message") 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: except asyncio.TimeoutError:
logger.warning("WebSocket authentication timeout after %.1f seconds", AUTH_TIMEOUT) logger.warning("WebSocket authentication timeout after %.1f seconds", AUTH_TIMEOUT)
return None, None return None, None, "timeout"
except Exception as e: except Exception as e:
logger.error("Error during WebSocket authentication: %s", 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]: async def get_unread_notifications(user_id: str) -> list[dict]:
"""Query all unread notifications for a user.""" """Query all unread notifications for a user."""
db = SessionLocal() db = database.SessionLocal()
try: try:
notifications = ( notifications = (
db.query(Notification) 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: async def get_unread_count(user_id: str) -> int:
"""Get the count of unread notifications for a user.""" """Get the count of unread notifications for a user."""
db = SessionLocal() db = database.SessionLocal()
try: try:
return ( return (
db.query(Notification) db.query(Notification)
@@ -174,14 +183,12 @@ async def websocket_notifications(
# Accept WebSocket connection first # Accept WebSocket connection first
await websocket.accept() await websocket.accept()
# If no query token, notify client that auth is required
if not token:
await websocket.send_json({"type": "auth_required"})
# Authenticate # 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 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") await websocket.close(code=4001, reason="Invalid or expired token")
return return
@@ -311,7 +318,7 @@ async def verify_project_access(user_id: str, project_id: str) -> tuple[bool, Pr
Returns: Returns:
Tuple of (has_access: bool, project: Project | None) Tuple of (has_access: bool, project: Project | None)
""" """
db = SessionLocal() db = database.SessionLocal()
try: try:
# Get the user # Get the user
user = db.query(User).filter(User.id == user_id).first() user = db.query(User).filter(User.id == user_id).first()
@@ -365,14 +372,12 @@ async def websocket_project_sync(
# Accept WebSocket connection first # Accept WebSocket connection first
await websocket.accept() 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 # 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 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") await websocket.close(code=4001, reason="Invalid or expired token")
return return

View File

@@ -139,7 +139,7 @@ async def get_heatmap(
description="Comma-separated list of user IDs to include" description="Comma-separated list of user IDs to include"
), ),
hide_empty: bool = Query( hide_empty: bool = Query(
True, False,
description="Hide users with no tasks assigned for the week" description="Hide users with no tasks assigned for the week"
), ),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -168,8 +168,20 @@ async def get_heatmap(
if department_id: if department_id:
check_workload_access(current_user, department_id=department_id) check_workload_access(current_user, department_id=department_id)
# Filter user_ids based on access (pass db for manager department lookup) # Determine accessible users for this requester
accessible_user_ids = filter_accessible_users(current_user, parsed_user_ids, db) 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 # Normalize week_start
if week_start is None: if week_start is None:

View File

@@ -1,6 +1,55 @@
import logging
import os
import fnmatch
from typing import Any, List, Tuple
import redis import redis
from app.core.config import settings 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( redis_client = redis.Redis(
host=settings.REDIS_HOST, host=settings.REDIS_HOST,
port=settings.REDIS_PORT, port=settings.REDIS_PORT,
@@ -17,3 +66,29 @@ def get_redis():
def get_redis_sync(): def get_redis_sync():
"""Get Redis client synchronously (non-dependency use).""" """Get Redis client synchronously (non-dependency use)."""
return redis_client 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

View File

@@ -1,3 +1,4 @@
import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime
from fastapi import FastAPI, Request, APIRouter from fastapi import FastAPI, Request, APIRouter
@@ -16,10 +17,15 @@ from app.core.deprecation import DeprecationMiddleware
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Manage application lifespan events.""" """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 # Startup
if start_background_jobs:
start_scheduler() start_scheduler()
yield yield
# Shutdown # Shutdown
if start_background_jobs:
shutdown_scheduler() shutdown_scheduler()

View File

@@ -26,12 +26,16 @@ from app.models.task_dependency import TaskDependency, DependencyType
from app.models.project_member import ProjectMember from app.models.project_member import ProjectMember
from app.models.project_template import ProjectTemplate from app.models.project_template import ProjectTemplate
# Backward-compatible alias for older imports
ScheduleTrigger = Trigger
__all__ = [ __all__ = [
"User", "Role", "Department", "Space", "Project", "TaskStatus", "Task", "WorkloadSnapshot", "User", "Role", "Department", "Space", "Project", "TaskStatus", "Task", "WorkloadSnapshot",
"Comment", "Mention", "Notification", "Blocker", "Comment", "Mention", "Notification", "Blocker",
"AuditLog", "AuditAlert", "AuditAction", "SensitivityLevel", "EVENT_SENSITIVITY", "ALERT_EVENTS", "AuditLog", "AuditAlert", "AuditAction", "SensitivityLevel", "EVENT_SENSITIVITY", "ALERT_EVENTS",
"EncryptionKey", "Attachment", "AttachmentVersion", "EncryptionKey", "Attachment", "AttachmentVersion",
"Trigger", "TriggerType", "TriggerLog", "TriggerLogStatus", "Trigger", "TriggerType", "TriggerLog", "TriggerLogStatus",
"ScheduleTrigger",
"ScheduledReport", "ReportType", "ReportHistory", "ReportHistoryStatus", "ScheduledReport", "ReportType", "ReportHistory", "ReportHistoryStatus",
"ProjectHealth", "RiskLevel", "ScheduleStatus", "ResourceStatus", "ProjectHealth", "RiskLevel", "ScheduleStatus", "ResourceStatus",
"CustomField", "FieldType", "TaskCustomValue", "CustomField", "FieldType", "TaskCustomValue",

View File

@@ -1,5 +1,5 @@
from sqlalchemy import Column, String, Text, Boolean, DateTime, Date, Numeric, Enum, ForeignKey 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 sqlalchemy.sql import func
from app.core.database import Base from app.core.database import Base
import enum import enum
@@ -45,3 +45,6 @@ class Project(Base):
# Project membership for cross-department collaboration # Project membership for cross-department collaboration
members = relationship("ProjectMember", back_populates="project", cascade="all, delete-orphan") 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")

View File

@@ -5,7 +5,7 @@ that can be used to quickly set up new projects.
""" """
import uuid import uuid
from sqlalchemy import Column, String, Text, Boolean, DateTime, ForeignKey, JSON 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 sqlalchemy.sql import func
from app.core.database import Base from app.core.database import Base
@@ -53,6 +53,10 @@ class ProjectTemplate(Base):
# Relationships # Relationships
owner = relationship("User", foreign_keys=[owner_id]) 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 # Default template data for system templates
SYSTEM_TEMPLATES = [ SYSTEM_TEMPLATES = [

View File

@@ -1,5 +1,6 @@
import uuid
from sqlalchemy import Column, String, Integer, Enum, DateTime, ForeignKey, UniqueConstraint 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 sqlalchemy.sql import func
from app.core.database import Base from app.core.database import Base
import enum import enum
@@ -34,7 +35,7 @@ class TaskDependency(Base):
UniqueConstraint('predecessor_id', 'successor_id', name='uq_predecessor_successor'), 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( predecessor_id = Column(
String(36), String(36),
ForeignKey("pjctrl_tasks.id", ondelete="CASCADE"), ForeignKey("pjctrl_tasks.id", ondelete="CASCADE"),
@@ -66,3 +67,7 @@ class TaskDependency(Base):
foreign_keys=[successor_id], foreign_keys=[successor_id],
back_populates="predecessors" back_populates="predecessors"
) )
# Backward-compatible aliases for legacy field names
task_id = synonym("successor_id")
depends_on_task_id = synonym("predecessor_id")

View File

@@ -1,4 +1,4 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, computed_field, model_validator
from typing import Optional from typing import Optional
from datetime import datetime, date from datetime import datetime, date
from decimal import Decimal from decimal import Decimal
@@ -19,9 +19,22 @@ class ProjectBase(BaseModel):
end_date: Optional[date] = None end_date: Optional[date] = None
security_level: SecurityLevel = SecurityLevel.DEPARTMENT 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): class ProjectCreate(ProjectBase):
department_id: Optional[str] = None department_id: Optional[str] = None
template_id: Optional[str] = None
class ProjectUpdate(BaseModel): class ProjectUpdate(BaseModel):
@@ -34,6 +47,13 @@ class ProjectUpdate(BaseModel):
status: Optional[str] = Field(None, max_length=50) status: Optional[str] = Field(None, max_length=50)
department_id: Optional[str] = None 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): class ProjectResponse(ProjectBase):
id: str id: str

View File

@@ -48,3 +48,12 @@ class GenerateReportResponse(BaseModel):
message: str message: str
report_id: str report_id: str
summary: ReportSummary summary: ReportSummary
class WeeklyReportSubscription(BaseModel):
is_active: bool
last_sent_at: Optional[datetime] = None
class WeeklyReportSubscriptionUpdate(BaseModel):
is_active: bool

View File

@@ -35,6 +35,15 @@ class TaskBase(BaseModel):
start_date: Optional[datetime] = None start_date: Optional[datetime] = None
due_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): class TaskCreate(TaskBase):
parent_task_id: Optional[str] = None parent_task_id: Optional[str] = None
@@ -57,6 +66,15 @@ class TaskUpdate(BaseModel):
custom_values: Optional[List[CustomValueInput]] = None custom_values: Optional[List[CustomValueInput]] = None
version: Optional[int] = Field(None, ge=1, description="Version for optimistic locking") 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): class TaskStatusUpdate(BaseModel):
status_id: str status_id: str
@@ -131,3 +149,8 @@ class TaskDeleteResponse(BaseModel):
task: TaskResponse task: TaskResponse
blockers_resolved: int = 0 blockers_resolved: int = 0
force_deleted: bool = False force_deleted: bool = False
@computed_field
@property
def id(self) -> str:
return self.task.id

View File

@@ -5,9 +5,18 @@ from pydantic import BaseModel, Field
class FieldChangeCondition(BaseModel): class FieldChangeCondition(BaseModel):
"""Condition for field_change triggers.""" """Condition for field_change triggers."""
field: str = Field(..., description="Field to check: status_id, assignee_id, priority") 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") operator: str = Field(..., description="Operator: equals, not_equals, changed_to, changed_from, before, after, in")
value: str = Field(..., description="Value to compare against") 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): class ScheduleCondition(BaseModel):
@@ -19,9 +28,12 @@ class ScheduleCondition(BaseModel):
class TriggerCondition(BaseModel): class TriggerCondition(BaseModel):
"""Union condition that supports both field_change and schedule triggers.""" """Union condition that supports both field_change and schedule triggers."""
# Field change conditions # Field change conditions
field: Optional[str] = Field(None, description="Field to check: status_id, assignee_id, priority") 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") operator: Optional[str] = Field(None, description="Operator: equals, not_equals, changed_to, changed_from, before, after, in")
value: Optional[str] = Field(None, description="Value to compare against") 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 # Schedule conditions
cron_expression: Optional[str] = Field(None, description="Cron expression for schedule triggers") 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") 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") type: str = Field(..., description="Action type: notify, update_field, auto_assign")
# Notify action fields # 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") template: Optional[str] = Field(None, description="Message template with variables")
# update_field action fields (FEAT-014) # update_field action fields (FEAT-014)
field: Optional[str] = Field(None, description="Field to update: priority, status_id, due_date") field: Optional[str] = Field(None, description="Field to update: priority, status_id, due_date")

View File

@@ -33,6 +33,8 @@ class FileStorageService:
def __init__(self): def __init__(self):
self.base_dir = Path(settings.UPLOAD_DIR).resolve() 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 = { self._storage_status = {
"validated": False, "validated": False,
"path_exists": False, "path_exists": False,
@@ -217,15 +219,16 @@ class FileStorageService:
PathTraversalError: If the path is outside the base directory PathTraversalError: If the path is outside the base directory
""" """
resolved_path = path.resolve() resolved_path = path.resolve()
base_dir = self.base_dir.resolve()
# Check if the resolved path is within the base directory # Check if the resolved path is within the base directory
try: try:
resolved_path.relative_to(self.base_dir) resolved_path.relative_to(base_dir)
except ValueError: except ValueError:
logger.warning( logger.warning(
"Path traversal attempt detected: path %s is outside base directory %s. Context: %s", "Path traversal attempt detected: path %s is outside base directory %s. Context: %s",
resolved_path, resolved_path,
self.base_dir, base_dir,
context context
) )
raise PathTraversalError( raise PathTraversalError(

View File

@@ -5,7 +5,7 @@ from sqlalchemy.orm import Session
from sqlalchemy import func from sqlalchemy import func
from app.models import ( from app.models import (
User, Task, Project, ScheduledReport, ReportHistory, Blocker User, Task, Project, ScheduledReport, ReportHistory, Blocker, ProjectMember
) )
from app.services.notification_service import NotificationService from app.services.notification_service import NotificationService
@@ -46,8 +46,17 @@ class ReportService:
# Use naive datetime for comparison with database values # Use naive datetime for comparison with database values
now = datetime.now(timezone.utc).replace(tzinfo=None) now = datetime.now(timezone.utc).replace(tzinfo=None)
# Get projects owned by the user owned_projects = db.query(Project).filter(Project.owner_id == user_id).all()
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: if not projects:
return { return {
@@ -92,7 +101,7 @@ class ReportService:
# Check if completed (updated this week) # Check if completed (updated this week)
if is_done: 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) completed_tasks.append(task)
else: else:
# Check if task has active status (not done, not blocked) # Check if task has active status (not done, not blocked)
@@ -225,7 +234,7 @@ class ReportService:
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
report_type="weekly", report_type="weekly",
recipient_id=user_id, recipient_id=user_id,
is_active=True, is_active=False,
) )
db.add(scheduled_report) db.add(scheduled_report)
db.flush() db.flush()

View File

@@ -13,9 +13,9 @@ from typing import Optional, List, Dict, Any, Tuple, Set
from croniter import croniter from croniter import croniter
from sqlalchemy.orm import Session 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 from app.services.notification_service import NotificationService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -408,17 +408,18 @@ class TriggerSchedulerService:
logger.warning(f"Trigger {trigger.id} has no associated project") logger.warning(f"Trigger {trigger.id} has no associated project")
return return
target_user_id = TriggerSchedulerService._resolve_target(project, target) recipient_ids = TriggerSchedulerService._resolve_target(db, project, target)
if not target_user_id: if not recipient_ids:
logger.debug(f"No target user resolved for trigger {trigger.id} with target '{target}'") logger.debug(f"No target user resolved for trigger {trigger.id} with target '{target}'")
return return
# Format message with variables # Format message with variables
message = TriggerSchedulerService._format_template(template, trigger, project) message = TriggerSchedulerService._format_template(template, trigger, project)
for user_id in recipient_ids:
NotificationService.create_notification( NotificationService.create_notification(
db=db, db=db,
user_id=target_user_id, user_id=user_id,
notification_type="scheduled_trigger", notification_type="scheduled_trigger",
reference_type="trigger", reference_type="trigger",
reference_id=trigger.id, reference_id=trigger.id,
@@ -427,22 +428,57 @@ class TriggerSchedulerService:
) )
@staticmethod @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: Args:
project: The project context project: The project context
target: Target specification (e.g., "project_owner", "user:<id>") target: Target specification (e.g., "project_owner", "user:<id>")
Returns: Returns:
User ID or None List of user IDs
""" """
recipients: Set[str] = set()
if target == "project_owner": 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:"): elif target.startswith("user:"):
return target.split(":", 1)[1] user_id = target.split(":", 1)[1]
return None if user_id:
recipients.add(user_id)
return list(recipients)
@staticmethod @staticmethod
def _format_template(template: str, trigger: Trigger, project: Project) -> str: def _format_template(template: str, trigger: Trigger, project: Project) -> str:
@@ -718,8 +754,8 @@ class TriggerSchedulerService:
) )
# Resolve target user # Resolve target user
target_user_id = TriggerSchedulerService._resolve_deadline_target(task, target) recipient_ids = TriggerSchedulerService._resolve_deadline_target(db, task, target)
if not target_user_id: if not recipient_ids:
logger.debug( logger.debug(
f"No target user resolved for deadline reminder, task {task.id}, target '{target}'" f"No target user resolved for deadline reminder, task {task.id}, target '{target}'"
) )
@@ -730,9 +766,10 @@ class TriggerSchedulerService:
template, trigger, task, reminder_days template, trigger, task, reminder_days
) )
for user_id in recipient_ids:
NotificationService.create_notification( NotificationService.create_notification(
db=db, db=db,
user_id=target_user_id, user_id=user_id,
notification_type="deadline_reminder", notification_type="deadline_reminder",
reference_type="task", reference_type="task",
reference_id=task.id, reference_id=task.id,
@@ -741,7 +778,7 @@ class TriggerSchedulerService:
) )
@staticmethod @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. Resolve notification target for deadline reminders.
@@ -750,17 +787,55 @@ class TriggerSchedulerService:
target: Target specification target: Target specification
Returns: Returns:
User ID or None List of user IDs
""" """
recipients: Set[str] = set()
if target == "assignee": if target == "assignee":
return task.assignee_id if task.assignee_id:
recipients.add(task.assignee_id)
elif target == "creator": elif target == "creator":
return task.created_by if task.created_by:
recipients.add(task.created_by)
elif target == "project_owner": 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:"): elif target.startswith("user:"):
return target.split(":", 1)[1] user_id = target.split(":", 1)[1]
return None if user_id:
recipients.add(user_id)
return list(recipients)
@staticmethod @staticmethod
def _format_deadline_template( def _format_deadline_template(

View File

@@ -1,10 +1,14 @@
import uuid import uuid
import logging 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.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.notification_service import NotificationService
from app.services.custom_value_service import CustomValueService
from app.services.action_executor import ( from app.services.action_executor import (
ActionExecutor, ActionExecutor,
ActionExecutionError, ActionExecutionError,
@@ -17,8 +21,9 @@ logger = logging.getLogger(__name__)
class TriggerService: class TriggerService:
"""Service for evaluating and executing triggers.""" """Service for evaluating and executing triggers."""
SUPPORTED_FIELDS = ["status_id", "assignee_id", "priority"] SUPPORTED_FIELDS = ["status_id", "assignee_id", "priority", "start_date", "due_date", "custom_fields"]
SUPPORTED_OPERATORS = ["equals", "not_equals", "changed_to", "changed_from"] SUPPORTED_OPERATORS = ["equals", "not_equals", "changed_to", "changed_from", "before", "after", "in"]
DATE_FIELDS = {"start_date", "due_date"}
@staticmethod @staticmethod
def evaluate_triggers( def evaluate_triggers(
@@ -29,7 +34,9 @@ class TriggerService:
current_user: User, current_user: User,
) -> List[TriggerLog]: ) -> List[TriggerLog]:
"""Evaluate all active triggers for a project when task values change.""" """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 # Get active field_change triggers for the project
triggers = db.query(Trigger).filter( triggers = db.query(Trigger).filter(
@@ -38,8 +45,58 @@ class TriggerService:
Trigger.trigger_type == "field_change", Trigger.trigger_type == "field_change",
).all() ).all()
if not triggers:
return logs
custom_field_ids: Set[str] = set()
needs_custom_fields = False
for trigger in triggers: 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) log = TriggerService._execute_actions(db, trigger, task, current_user, old_values, new_values)
logs.append(log) logs.append(log)
@@ -50,29 +107,298 @@ class TriggerService:
conditions: Dict[str, Any], conditions: Dict[str, Any],
old_values: Dict[str, Any], old_values: Dict[str, Any],
new_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: ) -> bool:
"""Check if trigger conditions are met.""" """Check if trigger conditions are met."""
field = conditions.get("field") old_values = old_values or {}
operator = conditions.get("operator") new_values = new_values or {}
value = conditions.get("value") 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: if field not in TriggerService.SUPPORTED_FIELDS:
return False 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) old_value = old_values.get(field)
new_value = new_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": if operator == "equals":
return new_value == value return TriggerService._value_equals(current_value, target_value, field_type)
elif operator == "not_equals": if operator == "not_equals":
return new_value != value return not TriggerService._value_equals(current_value, target_value, field_type)
elif operator == "changed_to": if operator == "before":
return old_value != value and new_value == value return TriggerService._compare_before(current_value, target_value, field_type)
elif operator == "changed_from": if operator == "after":
return old_value == value and new_value != value 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 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 @staticmethod
def _execute_actions( def _execute_actions(
db: Session, db: Session,
@@ -185,21 +511,19 @@ class TriggerService:
target = action.get("target", "assignee") target = action.get("target", "assignee")
template = action.get("template", "任務 {task_title} 已觸發自動化規則") template = action.get("template", "任務 {task_title} 已觸發自動化規則")
# Resolve target user recipients = TriggerService._resolve_targets(db, task, target)
target_user_id = TriggerService._resolve_target(task, target) if not recipients:
if not target_user_id:
return
# Don't notify the user who triggered the action
if target_user_id == current_user.id:
return return
# Format message with variables # Format message with variables
message = TriggerService._format_template(template, task, old_values, new_values) 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( NotificationService.create_notification(
db=db, db=db,
user_id=target_user_id, user_id=user_id,
notification_type="status_change", notification_type="status_change",
reference_type="task", reference_type="task",
reference_id=task.id, reference_id=task.id,
@@ -208,17 +532,55 @@ class TriggerService:
) )
@staticmethod @staticmethod
def _resolve_target(task: Task, target: str) -> Optional[str]: def _resolve_targets(db: Session, task: Task, target: str) -> List[str]:
"""Resolve notification target to user ID.""" """Resolve notification target to user IDs."""
recipients: Set[str] = set()
if target == "assignee": if target == "assignee":
return task.assignee_id if task.assignee_id:
recipients.add(task.assignee_id)
elif target == "creator": elif target == "creator":
return task.created_by if task.created_by:
recipients.add(task.created_by)
elif target == "project_owner": 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:"): elif target.startswith("user:"):
return target.split(":", 1)[1] user_id = target.split(":", 1)[1]
return None if user_id:
recipients.add(user_id)
return list(recipients)
@staticmethod @staticmethod
def _format_template( def _format_template(

View File

@@ -42,7 +42,9 @@ def _serialize_workload_summary(summary: UserWorkloadSummary) -> dict:
"department_name": summary.department_name, "department_name": summary.department_name,
"capacity_hours": str(summary.capacity_hours), "capacity_hours": str(summary.capacity_hours),
"allocated_hours": str(summary.allocated_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, "load_level": summary.load_level.value,
"task_count": summary.task_count, "task_count": summary.task_count,
} }

View File

@@ -42,6 +42,26 @@ def get_current_week_start() -> date:
return get_week_bounds(date.today())[0] 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: def determine_load_level(load_percentage: Optional[Decimal]) -> LoadLevel:
""" """
Determine the load level based on percentage. Determine the load level based on percentage.
@@ -149,7 +169,7 @@ def calculate_user_workload(
if task.original_estimate: if task.original_estimate:
allocated_hours += 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_percentage = calculate_load_percentage(allocated_hours, capacity_hours)
load_level = determine_load_level(load_percentage) load_level = determine_load_level(load_percentage)
@@ -191,11 +211,11 @@ def get_workload_heatmap(
if week_start is None: if week_start is None:
week_start = get_current_week_start() week_start = get_current_week_start()
else:
# Normalize to week start (Monday) # Normalize to week start (Monday)
week_start = get_week_bounds(week_start)[0] week_start = get_week_bounds(week_start)[0]
week_start, week_end = get_week_bounds(week_start) 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 # Build user query
query = db.query(User).filter(User.is_active == True) query = db.query(User).filter(User.is_active == True)
@@ -245,7 +265,7 @@ def get_workload_heatmap(
if task.original_estimate: if task.original_estimate:
allocated_hours += 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_percentage = calculate_load_percentage(allocated_hours, capacity_hours)
load_level = determine_load_level(load_percentage) load_level = determine_load_level(load_percentage)
@@ -297,10 +317,9 @@ def get_user_workload_detail(
if week_start is None: if week_start is None:
week_start = get_current_week_start() week_start = get_current_week_start()
else:
week_start = get_week_bounds(week_start)[0] week_start = get_week_bounds(week_start)[0]
week_start, week_end = get_week_bounds(week_start) week_start, week_end = get_week_bounds(week_start)
week_start, week_end = _extend_week_end_if_sunday(week_start, week_end)
# Get tasks # Get tasks
tasks = get_user_tasks_in_week(db, user_id, week_start, week_end) 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, 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_percentage = calculate_load_percentage(allocated_hours, capacity_hours)
load_level = determine_load_level(load_percentage) load_level = determine_load_level(load_percentage)

View File

@@ -24,6 +24,11 @@ engine = create_engine(
) )
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=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: class MockRedis:
"""Mock Redis client for testing.""" """Mock Redis client for testing."""
@@ -102,7 +107,11 @@ def db():
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
def mock_redis(): def mock_redis():
"""Create mock Redis for testing.""" """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") @pytest.fixture(scope="function")

View File

@@ -10,6 +10,7 @@ from app.api.dashboard.router import (
get_workload_summary, get_workload_summary,
get_health_summary, get_health_summary,
) )
from app.services.workload_service import get_week_bounds
from app.schemas.workload import LoadLevel from app.schemas.workload import LoadLevel
@@ -99,15 +100,16 @@ class TestTaskStatistics:
): ):
"""Helper to create a task with optional characteristics.""" """Helper to create a task with optional characteristics."""
now = datetime.utcnow() now = datetime.utcnow()
week_start, week_end = get_week_bounds(now.date())
if overdue: if overdue:
due_date = now - timedelta(days=3) due_date = datetime.combine(week_start, datetime.min.time()) - timedelta(days=1)
elif due_this_week: elif due_this_week:
# Due in the middle of current 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: else:
# Due next week # Due next week
due_date = now + timedelta(days=10) due_date = datetime.combine(week_end, datetime.min.time()) + timedelta(days=2)
task = Task( task = Task(
id=task_id, id=task_id,
@@ -313,13 +315,26 @@ class TestWorkloadSummary:
assert workload.load_percentage == Decimal("0.00") assert workload.load_percentage == Decimal("0.00")
assert workload.load_level == LoadLevel.NORMAL 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): def test_workload_with_tasks(self, db):
"""Should calculate correct allocated hours.""" """Should calculate correct allocated hours."""
data = self.setup_test_data(db) data = self.setup_test_data(db)
# Create tasks due this week with estimates # Create tasks due this week with estimates
now = datetime.utcnow() 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( task1 = Task(
id="task-wl-1", id="task-wl-1",
@@ -359,7 +374,8 @@ class TestWorkloadSummary:
data = self.setup_test_data(db) data = self.setup_test_data(db)
now = datetime.utcnow() 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) # Create task with 48h estimate (> 40h capacity)
task = Task( task = Task(
@@ -508,6 +524,7 @@ class TestDashboardAPI:
# Create a task for the admin user # Create a task for the admin user
now = datetime.utcnow() now = datetime.utcnow()
week_start, _ = get_week_bounds(now.date())
task = Task( task = Task(
id="task-api-dash-001", id="task-api-dash-001",
project_id="project-api-dash-001", project_id="project-api-dash-001",
@@ -515,7 +532,7 @@ class TestDashboardAPI:
assignee_id="00000000-0000-0000-0000-000000000001", assignee_id="00000000-0000-0000-0000-000000000001",
status_id="status-api-dash-todo", status_id="status-api-dash-todo",
original_estimate=Decimal("8"), 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", created_by="00000000-0000-0000-0000-000000000001",
is_deleted=False, is_deleted=False,
) )

View File

@@ -1,7 +1,7 @@
import pytest import pytest
import uuid import uuid
from datetime import datetime, timedelta 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 from app.services.report_service import ReportService
@@ -76,6 +76,7 @@ def test_statuses(db, test_project):
name="To Do", name="To Do",
color="#808080", color="#808080",
position=0, position=0,
is_done=False,
) )
in_progress = TaskStatus( in_progress = TaskStatus(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
@@ -83,6 +84,7 @@ def test_statuses(db, test_project):
name="In Progress", name="In Progress",
color="#0000FF", color="#0000FF",
position=1, position=1,
is_done=False,
) )
done = TaskStatus( done = TaskStatus(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
@@ -90,6 +92,7 @@ def test_statuses(db, test_project):
name="Done", name="Done",
color="#00FF00", color="#00FF00",
position=2, position=2,
is_done=True,
) )
db.add_all([todo, in_progress, done]) db.add_all([todo, in_progress, done])
db.commit() db.commit()
@@ -165,12 +168,90 @@ class TestReportService:
stats = ReportService.get_weekly_stats(db, test_user.id) stats = ReportService.get_weekly_stats(db, test_user.id)
assert stats["summary"]["completed_count"] == 1 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"]["overdue_count"] == 1
assert stats["summary"]["total_tasks"] == 3 assert stats["summary"]["total_tasks"] == 3
assert len(stats["projects"]) == 1 assert len(stats["projects"]) == 1
assert stats["projects"][0]["project_title"] == "Report Test Project" 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): def test_generate_weekly_report(self, db, test_user, test_project, test_tasks, test_statuses):
"""Test generating a weekly report.""" """Test generating a weekly report."""
report = ReportService.generate_weekly_report(db, test_user.id) report = ReportService.generate_weekly_report(db, test_user.id)
@@ -216,6 +297,45 @@ class TestReportAPI:
assert "report_id" in data assert "report_id" in data
assert "summary" 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): def test_list_report_history_empty(self, client, test_user_token):
"""Test listing report history when empty.""" """Test listing report history when empty."""
response = client.get( response = client.get(

View File

@@ -1,7 +1,13 @@
import pytest import pytest
import uuid 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.trigger_service import TriggerService
from app.services.custom_value_service import CustomValueService
from app.schemas.task import CustomValueInput
@pytest.fixture @pytest.fixture
@@ -188,6 +194,39 @@ class TestTriggerService:
result = TriggerService._check_conditions(conditions, old_values, new_values) result = TriggerService._check_conditions(conditions, old_values, new_values)
assert result is True 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): 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.""" """Test that evaluate_triggers creates notification when conditions match."""
# Create another user to receive notification # Create another user to receive notification
@@ -229,6 +268,247 @@ class TestTriggerService:
assert len(logs) == 0 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: class TestTriggerAPI:
"""Tests for Trigger API endpoints.""" """Tests for Trigger API endpoints."""

View File

@@ -195,6 +195,19 @@ class TestWorkloadService:
assert summary.load_level == LoadLevel.NORMAL assert summary.load_level == LoadLevel.NORMAL
assert summary.task_count == 0 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): def test_calculate_user_workload_with_tasks(self, db):
"""User with tasks should have correct allocated hours.""" """User with tasks should have correct allocated hours."""
data = self.setup_test_data(db) data = self.setup_test_data(db)
@@ -445,6 +458,7 @@ class TestWorkloadAccessControl:
def setup_test_data(self, db, mock_redis): def setup_test_data(self, db, mock_redis):
"""Set up test data with two departments.""" """Set up test data with two departments."""
from app.core.security import create_access_token, create_token_payload from app.core.security import create_access_token, create_token_payload
from app.services.workload_service import get_current_week_start
# Create departments # Create departments
dept_rd = Department(id="dept-rd", name="R&D") dept_rd = Department(id="dept-rd", name="R&D")
@@ -478,6 +492,38 @@ class TestWorkloadAccessControl:
) )
db.add(engineer_ops) 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() db.commit()
# Create token for R&D engineer # Create token for R&D engineer
@@ -514,6 +560,18 @@ class TestWorkloadAccessControl:
assert len(result["users"]) == 1 assert len(result["users"]) == 1
assert result["users"][0]["user_id"] == "user-rd-001" 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): def test_regular_user_cannot_access_other_department(self, client, db, mock_redis):
"""Regular user should not access other department's workload.""" """Regular user should not access other department's workload."""
data = self.setup_test_data(db, mock_redis) data = self.setup_test_data(db, mock_redis)

View 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)
})
})

View File

@@ -0,0 +1 @@
Playwright attachment fixture for Project Control e2e tests.

View 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

View 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();
});

View File

@@ -31,6 +31,7 @@
"@types/react-dom": "^18.2.17", "@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"jsdom": "^27.4.0", "jsdom": "^27.4.0",
"playwright": "^1.57.0",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^5.0.8", "vite": "^5.0.8",
"vitest": "^4.0.16" "vitest": "^4.0.16"
@@ -2787,6 +2788,53 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",

View File

@@ -35,6 +35,7 @@
"@types/react-dom": "^18.2.17", "@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"jsdom": "^27.4.0", "jsdom": "^27.4.0",
"playwright": "^1.57.0",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^5.0.8", "vite": "^5.0.8",
"vitest": "^4.0.16" "vitest": "^4.0.16"

View 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',
},
});

View File

@@ -15,7 +15,91 @@
"capacityHelp": "Recommended: 40 hours (standard work week). Maximum: 168 hours (total hours in a week).", "capacityHelp": "Recommended: 40 hours (standard work week). Maximum: 168 hours (total hours in a week).",
"capacitySaved": "Capacity settings saved", "capacitySaved": "Capacity settings saved",
"capacityError": "Failed to save capacity settings", "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": { "tabs": {
"general": "General", "general": "General",

View File

@@ -15,7 +15,91 @@
"capacityHelp": "建議值40 小時標準工時。最大值168 小時(一週總時數)。", "capacityHelp": "建議值40 小時標準工時。最大值168 小時(一週總時數)。",
"capacitySaved": "容量設定已儲存", "capacitySaved": "容量設定已儲存",
"capacityError": "儲存容量設定失敗", "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": { "tabs": {
"general": "一般", "general": "一般",

View File

@@ -1,5 +1,14 @@
import { useState, useEffect } from 'react' 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 { interface TriggerFormProps {
projectId: string projectId: string
@@ -8,40 +17,289 @@ interface TriggerFormProps {
onCancel: () => void 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) { export function TriggerForm({ projectId, trigger, onSave, onCancel }: TriggerFormProps) {
const { t } = useTranslation(['settings', 'common'])
const [name, setName] = useState('') const [name, setName] = useState('')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [field, setField] = useState('status_id') const [rules, setRules] = useState<RuleState[]>([createDefaultRule()])
const [operator, setOperator] = useState('changed_to') const [targetType, setTargetType] = useState('assignee')
const [value, setValue] = useState('') const [targetValue, setTargetValue] = useState('')
const [target, setTarget] = useState('assignee')
const [template, setTemplate] = useState('') const [template, setTemplate] = useState('')
const [isActive, setIsActive] = useState(true) const [isActive, setIsActive] = useState(true)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [customFields, setCustomFields] = useState<CustomField[]>([])
useEffect(() => { 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) setName(trigger.name)
setDescription(trigger.description || '') 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) 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]) }, [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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setError(null) setError(null)
if (!validateRules()) {
return
}
setLoading(true) setLoading(true)
const conditions: TriggerCondition = { field, operator, value } const rulePayloads = rules
const actions: TriggerAction[] = [{ type: 'notify', target, template: template || undefined }] .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 { try {
if (trigger) { if (trigger) {
@@ -65,16 +323,96 @@ export function TriggerForm({ projectId, trigger, onSave, onCancel }: TriggerFor
} }
onSave() onSave()
} catch { } catch {
setError('Failed to save trigger') setError(t('settings:triggers.errors.saveFailed'))
} finally { } finally {
setLoading(false) 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 ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <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 <input
type="text" type="text"
value={name} value={name}
@@ -85,7 +423,7 @@ export function TriggerForm({ projectId, trigger, onSave, onCancel }: TriggerFor
</div> </div>
<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 <textarea
value={description} value={description}
onChange={e => setDescription(e.target.value)} onChange={e => setDescription(e.target.value)}
@@ -95,70 +433,128 @@ export function TriggerForm({ projectId, trigger, onSave, onCancel }: TriggerFor
</div> </div>
<fieldset className="border rounded-md p-3"> <fieldset className="border rounded-md p-3">
<legend className="text-sm font-medium text-gray-700 px-1">Condition</legend> <legend className="text-sm font-medium text-gray-700 px-1">{t('settings:triggers.conditions')}</legend>
<div className="grid grid-cols-3 gap-3"> <p className="text-xs text-gray-500 mb-3">{t('settings:triggers.conditionsHint')}</p>
<div>
<label className="block text-sm text-gray-600">Field</label> <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 <select
value={field} value={rule.field}
onChange={e => setField(e.target.value)} 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" 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="status_id">{fieldLabel('status_id')}</option>
<option value="assignee_id">Assignee</option> <option value="assignee_id">{fieldLabel('assignee_id')}</option>
<option value="priority">Priority</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> </select>
</div> {rule.field === 'custom_fields' && (
<div>
<label className="block text-sm text-gray-600">Operator</label>
<select <select
value={operator} value={rule.field_id || ''}
onChange={e => setOperator(e.target.value)} 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" 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> {operatorOptions.map(option => (
<option value="changed_from">Changes from</option> <option key={option} value={option}>
<option value="equals">Equals</option> {operatorLabel(option)}
<option value="not_equals">Not equals</option> </option>
))}
</select> </select>
</div> </div>
<div> <div className="col-span-5">
<label className="block text-sm text-gray-600">Value</label> <label className="block text-sm text-gray-600">{t('settings:triggers.value')}</label>
<input {renderValueInput(rule)}
type="text" </div>
value={value} <div className="col-span-1">
onChange={e => setValue(e.target.value)} {rules.length > 1 && (
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none" <button
placeholder="UUID or value" type="button"
required 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>
</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>
<fieldset className="border rounded-md p-3"> <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 className="space-y-3">
<div> <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 <select
value={target} value={targetType}
onChange={e => setTarget(e.target.value)} 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" 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="assignee">{t('settings:triggers.targets.assignee')}</option>
<option value="creator">Task Creator</option> <option value="creator">{t('settings:triggers.targets.creator')}</option>
<option value="project_owner">Project Owner</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> </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>
<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 <input
type="text" type="text"
value={template} value={template}
onChange={e => setTemplate(e.target.value)} 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" 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>
</div> </div>
@@ -172,7 +568,7 @@ export function TriggerForm({ projectId, trigger, onSave, onCancel }: TriggerFor
onChange={e => setIsActive(e.target.checked)} onChange={e => setIsActive(e.target.checked)}
className="rounded border-gray-300" 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> </div>
{error && <p className="text-red-500 text-sm">{error}</p>} {error && <p className="text-red-500 text-sm">{error}</p>}
@@ -183,14 +579,18 @@ export function TriggerForm({ projectId, trigger, onSave, onCancel }: TriggerFor
onClick={onCancel} onClick={onCancel}
className="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md" className="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md"
> >
Cancel {t('common:buttons.cancel')}
</button> </button>
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50" 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> </button>
</div> </div>
</form> </form>

View File

@@ -1,5 +1,7 @@
import { useState, useEffect, useCallback } from 'react' 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 { ConfirmModal } from './ConfirmModal'
import { useToast } from '../contexts/ToastContext' import { useToast } from '../contexts/ToastContext'
import { SkeletonList } from './Skeleton' import { SkeletonList } from './Skeleton'
@@ -10,11 +12,13 @@ interface TriggerListProps {
} }
export function TriggerList({ projectId, onEdit }: TriggerListProps) { export function TriggerList({ projectId, onEdit }: TriggerListProps) {
const { t } = useTranslation(['settings', 'common'])
const { showToast } = useToast() const { showToast } = useToast()
const [triggers, setTriggers] = useState<Trigger[]>([]) const [triggers, setTriggers] = useState<Trigger[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null) const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
const [customFieldMap, setCustomFieldMap] = useState<Record<string, string>>({})
const fetchTriggers = useCallback(async () => { const fetchTriggers = useCallback(async () => {
try { try {
@@ -23,23 +27,42 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
setTriggers(response.triggers) setTriggers(response.triggers)
setError(null) setError(null)
} catch { } catch {
setError('Failed to load triggers') setError(t('settings:triggers.errors.loadFailed'))
} finally { } finally {
setLoading(false) 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]) }, [projectId])
useEffect(() => { useEffect(() => {
fetchTriggers() fetchTriggers()
}, [fetchTriggers]) fetchCustomFields()
}, [fetchTriggers, fetchCustomFields])
const handleToggleActive = async (trigger: Trigger) => { const handleToggleActive = async (trigger: Trigger) => {
try { try {
await triggersApi.updateTrigger(trigger.id, { is_active: !trigger.is_active }) await triggersApi.updateTrigger(trigger.id, { is_active: !trigger.is_active })
fetchTriggers() 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 { } 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 { try {
await triggersApi.deleteTrigger(triggerId) await triggersApi.deleteTrigger(triggerId)
fetchTriggers() fetchTriggers()
showToast('Trigger deleted successfully', 'success') showToast(t('settings:triggers.toasts.deleteSuccess'), 'success')
} catch { } catch {
showToast('Failed to delete trigger', 'error') showToast(t('settings:triggers.errors.deleteFailed'), 'error')
} }
} }
const getFieldLabel = (field: string) => { const getRules = (conditions: Trigger['conditions']): TriggerConditionRule[] => {
switch (field) { if (Array.isArray(conditions?.rules) && conditions.rules.length > 0) {
case 'status_id': return 'Status' return conditions.rules
case 'assignee_id': return 'Assignee'
case 'priority': return 'Priority'
default: return field
} }
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) => { const getOperatorLabel = (operator: string) => {
switch (operator) { const keyMap: Record<string, string> = {
case 'equals': return 'equals' equals: 'equals',
case 'not_equals': return 'does not equal' not_equals: 'notEquals',
case 'changed_to': return 'changes to' changed_to: 'changedTo',
case 'changed_from': return 'changes from' changed_from: 'changedFrom',
default: return operator 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) { if (loading) {
@@ -84,7 +148,7 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
<div className="p-4 text-center text-red-500"> <div className="p-4 text-center text-red-500">
{error} {error}
<button onClick={fetchTriggers} className="ml-2 text-blue-600 hover:underline"> <button onClick={fetchTriggers} className="ml-2 text-blue-600 hover:underline">
Retry {t('settings:triggers.retry')}
</button> </button>
</div> </div>
) )
@@ -93,14 +157,16 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
if (triggers.length === 0) { if (triggers.length === 0) {
return ( return (
<div className="p-4 text-center text-gray-500"> <div className="p-4 text-center text-gray-500">
No triggers configured for this project. {t('settings:triggers.empty')}
</div> </div>
) )
} }
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{triggers.map(trigger => ( {triggers.map(trigger => {
const rules = getRules(trigger.conditions)
return (
<div <div
key={trigger.id} key={trigger.id}
className={`border rounded-lg p-4 ${trigger.is_active ? 'bg-white' : 'bg-gray-50'}`} 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-green-100 text-green-800'
: 'bg-gray-100 text-gray-600' : 'bg-gray-100 text-gray-600'
}`}> }`}>
{trigger.is_active ? 'Active' : 'Inactive'} {trigger.is_active ? t('common:labels.active') : t('common:labels.inactive')}
</span> </span>
</div> </div>
{trigger.description && ( {trigger.description && (
<p className="text-sm text-gray-500 mt-1">{trigger.description}</p> <p className="text-sm text-gray-500 mt-1">{trigger.description}</p>
)} )}
<div className="text-sm text-gray-600 mt-2"> <div className="text-sm text-gray-600 mt-2">
<span className="font-medium">When: </span> <span className="font-medium">{t('settings:triggers.when')}: </span>
{getFieldLabel(trigger.conditions.field)} {getOperatorLabel(trigger.conditions.operator)} {trigger.conditions.value} {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>
<div className="text-sm text-gray-600 mt-1"> <div className="text-sm text-gray-600 mt-1">
<span className="font-medium">Then: </span> <span className="font-medium">{t('settings:triggers.then')}: </span>
{trigger.actions.map((a, i) => ( {trigger.actions.map((action, index) => (
<span key={i}> <span key={index}>
{a.type === 'notify' ? `Notify ${a.target}` : a.type} {action.type === 'notify'
{i < trigger.actions.length - 1 && ', '} ? `${t('settings:triggers.notify')} ${action.target}`
: action.type}
{index < trigger.actions.length - 1 && ', '}
</span> </span>
))} ))}
</div> </div>
@@ -139,33 +212,34 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
onClick={() => handleToggleActive(trigger)} onClick={() => handleToggleActive(trigger)}
className="text-sm px-2 py-1 text-gray-600 hover:bg-gray-100 rounded" 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> </button>
{onEdit && ( {onEdit && (
<button <button
onClick={() => onEdit(trigger)} onClick={() => onEdit(trigger)}
className="text-sm px-2 py-1 text-blue-600 hover:bg-blue-50 rounded" className="text-sm px-2 py-1 text-blue-600 hover:bg-blue-50 rounded"
> >
Edit {t('common:buttons.edit')}
</button> </button>
)} )}
<button <button
onClick={() => setDeleteConfirm(trigger.id)} onClick={() => setDeleteConfirm(trigger.id)}
className="text-sm px-2 py-1 text-red-600 hover:bg-red-50 rounded" className="text-sm px-2 py-1 text-red-600 hover:bg-red-50 rounded"
> >
Delete {t('common:buttons.delete')}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
))} )
})}
<ConfirmModal <ConfirmModal
isOpen={deleteConfirm !== null} isOpen={deleteConfirm !== null}
title="Delete Trigger" title={t('settings:triggers.deleteTitle')}
message="Are you sure you want to delete this trigger? This action cannot be undone." message={t('settings:triggers.deleteMessage')}
confirmText="Delete" confirmText={t('common:buttons.delete')}
cancelText="Cancel" cancelText={t('common:buttons.cancel')}
confirmStyle="danger" confirmStyle="danger"
onConfirm={handleDeleteConfirm} onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteConfirm(null)} onCancel={() => setDeleteConfirm(null)}

View File

@@ -23,9 +23,13 @@ export default function MySettings() {
const [profile, setProfile] = useState<UserProfile | null>(null) const [profile, setProfile] = useState<UserProfile | null>(null)
const [capacity, setCapacity] = useState<string>('') const [capacity, setCapacity] = useState<string>('')
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [weeklySubscription, setWeeklySubscription] = useState(false)
const [subscriptionLoading, setSubscriptionLoading] = useState(true)
const [subscriptionSaving, setSubscriptionSaving] = useState(false)
useEffect(() => { useEffect(() => {
loadProfile() loadProfile()
loadWeeklySubscription()
}, []) }, [])
const loadProfile = async () => { 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 () => { const handleSaveCapacity = async () => {
if (!profile) return 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) { if (loading) {
return ( return (
<div style={styles.container}> <div style={styles.container}>
@@ -160,6 +194,21 @@ export default function MySettings() {
{saving ? t('common:labels.loading') : t('common:buttons.save')} {saving ? t('common:labels.loading') : t('common:buttons.save')}
</button> </button>
</div> </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> </div>
) )
} }
@@ -267,6 +316,21 @@ const styles: Record<string, React.CSSProperties> = {
backgroundColor: '#ccc', backgroundColor: '#ccc',
cursor: 'not-allowed', 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: { error: {
padding: '48px', padding: '48px',
textAlign: 'center', textAlign: 'center',

View File

@@ -1,9 +1,19 @@
import api from './api' import api from './api'
export interface TriggerCondition { export interface TriggerConditionRule {
field: string field: string
operator: 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 { export interface TriggerAction {

783
issues.md
View File

@@ -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 limitinglogin 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*

View File

@@ -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 自由輸入)。

View File

@@ -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

View File

@@ -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** 使用者不會收到週報

View File

@@ -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