From 33a62fe2db01357738f0bbb7298d88034ee5cd43 Mon Sep 17 00:00:00 2001 From: Ahmed Ikram <2571642@dundee.ac.uk> Date: Fri, 8 May 2026 11:58:11 +0100 Subject: [PATCH 01/12] feat: Complete Admin Specific Dashboard and Settings Screens --- .../src/api/controllers/admin_controller.py | 12 + .../src/api/controllers/audit_controller.py | 12 + .../api/controllers/dashboard_controller.py | 9 +- .../api/controllers/projects_controller.py | 29 +- .../src/api/controllers/report_controller.py | 28 + .../src/api/controllers/tasks_controller.py | 64 ++- .../src/api/controllers/users_controller.py | 25 + backend/src/api/routes/admin_routes.py | 34 +- backend/src/api/validators/admin_validator.py | 32 +- backend/src/auth/auth.py | 7 +- backend/src/services/audit_service.py | 10 + backend/src/services/notification_service.py | 24 + backend/src/services/settings_service.py | 154 +++++- backend/src/services/task_rules.py | 133 +++++ backend/src/socketio_server.py | 15 + frontend/src/context/NotificationContext.jsx | 32 ++ frontend/src/pages/AdminDashboard.jsx | 515 ++++++++++++------ frontend/src/pages/AdminSystemSettings.jsx | 136 ++++- frontend/src/pages/TaskDetailsUser.jsx | 12 + frontend/src/pages/TaskList.jsx | 4 + frontend/src/services/utils/api.js | 6 + .../src/tests/pages/AdminDashboard.test.jsx | 21 +- 22 files changed, 1054 insertions(+), 260 deletions(-) create mode 100644 backend/src/services/task_rules.py diff --git a/backend/src/api/controllers/admin_controller.py b/backend/src/api/controllers/admin_controller.py index aa58c4b..05aac37 100644 --- a/backend/src/api/controllers/admin_controller.py +++ b/backend/src/api/controllers/admin_controller.py @@ -6,6 +6,7 @@ from ...auth.rbac import Role from flask_jwt_extended import get_jwt_identity from ...services import audit_service, settings_service +from src.socketio_server import emit_dashboard_refresh def _safe_query_all(model): @@ -77,6 +78,11 @@ def update_system_settings(): resource_type='settings', metadata={'settings_updated': list(data.keys())} ) + emit_dashboard_refresh( + 'settings_updated', + resource_type='settings', + payload={'settings_updated': list(data.keys())} + ) return jsonify({ 'message': 'System settings updated successfully', @@ -107,6 +113,12 @@ def update_user_role(user_id): resource_id=user.id, metadata={'old_role': old_role, 'new_role': user.role} ) + emit_dashboard_refresh( + 'user_role_changed', + resource_type='user', + resource_id=user.id, + payload={'old_role': old_role, 'new_role': user.role} + ) return jsonify({ 'message': 'User role updated successfully', diff --git a/backend/src/api/controllers/audit_controller.py b/backend/src/api/controllers/audit_controller.py index 3a98064..6ad99fd 100644 --- a/backend/src/api/controllers/audit_controller.py +++ b/backend/src/api/controllers/audit_controller.py @@ -2,6 +2,7 @@ from flask import request, jsonify from ...db.models import AuditLog, User +from ...services import settings_service def _build_actor_name_map(logs): @@ -33,6 +34,8 @@ def _serialize_audit_log(log, actor_name_map=None): def get_audit_logs(): """Get paginated and filtered audit logs""" + settings_service.cleanup_old_audit_logs() + action = request.args.get('action') actor_id = request.args.get('actor') from_date = request.args.get('from') @@ -66,6 +69,15 @@ def get_audit_logs(): 'current_page': page }) + +def cleanup_audit_logs(): + """Delete expired audit logs using the configured retention window.""" + deleted_count = settings_service.cleanup_old_audit_logs() + return jsonify({ + 'message': 'Audit log cleanup completed', + 'deleted': deleted_count, + }), 200 + def get_audit_log_by_id(log_id): """Get a specific audit log""" log = AuditLog.query.get_or_404(log_id) diff --git a/backend/src/api/controllers/dashboard_controller.py b/backend/src/api/controllers/dashboard_controller.py index cb3ce98..4de5d19 100644 --- a/backend/src/api/controllers/dashboard_controller.py +++ b/backend/src/api/controllers/dashboard_controller.py @@ -3,6 +3,8 @@ from flask_jwt_extended import get_jwt_identity, get_jwt from ...db.models import db, User, Task, Project, TaskGitHubLink, GitHubRepository # Changed to relative import from ...auth.rbac import Role # Changed to relative import +from ...services import settings_service +from ...services.task_rules import count_overdue_tasks, get_project_scope_ids from datetime import datetime, timedelta import traceback import logging @@ -345,6 +347,8 @@ def get_admin_dashboard(): # Log the request details for debugging logger.info(f"Getting admin dashboard for user ID: {user_id}") + + settings_service.cleanup_completed_projects() # Get basic user info user = User.query.get(user_id) @@ -358,6 +362,8 @@ def get_admin_dashboard(): except Exception as e: logger.error(f"Error querying tasks for admin dashboard (fallback to empty): {str(e)}") all_tasks = [] + + admin_project_ids = get_project_scope_ids(user_id, user_role) # Get user counts by role try: @@ -379,7 +385,8 @@ def get_admin_dashboard(): 'todo': len([t for t in all_tasks if t.status == 'todo']), 'in_progress': len([t for t in all_tasks if t.status == 'in_progress']), 'review': len([t for t in all_tasks if t.status == 'review']), - 'done': len([t for t in all_tasks if _is_completed_task(t)]) + 'done': len([t for t in all_tasks if _is_completed_task(t)]), + 'overdue': count_overdue_tasks(all_tasks, project_ids=admin_project_ids), } # Format response data diff --git a/backend/src/api/controllers/projects_controller.py b/backend/src/api/controllers/projects_controller.py index b510acc..137191c 100644 --- a/backend/src/api/controllers/projects_controller.py +++ b/backend/src/api/controllers/projects_controller.py @@ -5,7 +5,8 @@ from ...db.models import db, Project, Task, User # Changed to relative import from ...auth.rbac import Role # Changed to relative import from ..validators.project_validator import validate_project_data # Changed to relative import -from ...services import audit_service +from ...services import audit_service, settings_service +from ...socketio_server import emit_dashboard_refresh import json import time import uuid @@ -42,6 +43,8 @@ def get_all_projects(): user_id = get_jwt_identity()['user_id'] claims = get_jwt() user_role = claims.get('role') + + settings_service.cleanup_completed_projects() # Apply role-based access control for projects if user_role in [Role.ADMIN.value, Role.TEAM_LEAD.value]: @@ -205,6 +208,12 @@ def create_project(): resource_type='project', resource_id=new_project.id ) + emit_dashboard_refresh( + 'project_created', + resource_type='project', + resource_id=new_project.id, + payload={'status': new_project.status} + ) # region agent log _debug_log( 'H1-H2', @@ -260,6 +269,19 @@ def update_project(project_id): project.team_members.append(member) db.session.commit() + + audit_service.record( + action='project_updated', + resource_type='project', + resource_id=project.id, + metadata={'status': project.status, 'team_members': [member.id for member in _relationship_items(project.team_members)]} + ) + emit_dashboard_refresh( + 'project_updated', + resource_type='project', + resource_id=project.id, + payload={'status': project.status} + ) return jsonify({ 'message': 'Project updated successfully', @@ -282,6 +304,11 @@ def delete_project(project_id): resource_type='project', resource_id=project_id ) + emit_dashboard_refresh( + 'project_deleted', + resource_type='project', + resource_id=project_id + ) # Updated to return 204 return '', 204 diff --git a/backend/src/api/controllers/report_controller.py b/backend/src/api/controllers/report_controller.py index f3c89bd..a7baa8d 100644 --- a/backend/src/api/controllers/report_controller.py +++ b/backend/src/api/controllers/report_controller.py @@ -5,6 +5,8 @@ from ...db.models import db, Report, User from ...auth.rbac import Role from ..validators.report_validator import validate_report_data +from ...services import audit_service +from src.socketio_server import emit_dashboard_refresh def save_report(): """Controller function to save a generated report""" @@ -28,6 +30,19 @@ def save_report(): db.session.add(report) db.session.commit() + + audit_service.record( + action='report_created', + resource_type='report', + resource_id=report.id, + metadata={'report_type': report.report_type, 'date_range': report.date_range} + ) + emit_dashboard_refresh( + 'report_created', + resource_type='report', + resource_id=report.id, + payload={'report_type': report.report_type, 'date_range': report.date_range} + ) return jsonify({ 'message': 'Report saved successfully', @@ -131,6 +146,19 @@ def delete_report(report_id): db.session.delete(report) db.session.commit() + + audit_service.record( + action='report_deleted', + resource_type='report', + resource_id=report_id, + metadata={'report_type': report.report_type, 'date_range': report.date_range} + ) + emit_dashboard_refresh( + 'report_deleted', + resource_type='report', + resource_id=report_id, + payload={'report_type': report.report_type, 'date_range': report.date_range} + ) return jsonify({ 'message': 'Report deleted successfully' diff --git a/backend/src/api/controllers/tasks_controller.py b/backend/src/api/controllers/tasks_controller.py index f02cb65..6286250 100644 --- a/backend/src/api/controllers/tasks_controller.py +++ b/backend/src/api/controllers/tasks_controller.py @@ -1,13 +1,16 @@ # Task controller - business logic import logging +from datetime import datetime from flask import request, jsonify from flask_jwt_extended import get_jwt_identity, get_jwt from ...db.models import db, Task, User # Changed to relative import from ...auth.rbac import Role # Changed to relative import from ..validators.task_validator import validate_task_data # Changed to relative import -from ...services import audit_service +from ...services import audit_service, settings_service from ...services.notification_service import NotificationService +from ...services.task_rules import get_project_scope_ids, is_task_overdue +from src.socketio_server import emit_dashboard_refresh from unittest.mock import Mock logger = logging.getLogger(__name__) @@ -104,6 +107,25 @@ def get_all_tasks(): # Convert tasks to JSON response tasks_data = [_serialize_task(task) for task in tasks] + + if settings_service.get_bool_setting('notify_on_overdue_tasks', True): + scoped_project_ids = get_project_scope_ids(user_id, user_role) + for task in tasks: + overdue_scope = {'project_ids': scoped_project_ids} if user_role in TASK_MANAGER_ROLES else {'assigned_to': user_id} + if not is_task_overdue(task, **overdue_scope): + continue + + assigned_to = _task_value(task, 'assigned_to') + if assigned_to is None and user_role not in TASK_MANAGER_ROLES: + continue + + NotificationService.task_overdue_notification( + task_id=_task_value(task, 'id'), + task_name=_task_value(task, 'title'), + project_id=_task_value(task, 'project_id'), + recipient_user_id=assigned_to or user_id, + due_date=None, + ) return jsonify({'tasks': tasks_data}) @@ -192,6 +214,20 @@ def create_new_task(): db.session.add(new_task) db.session.commit() + audit_service.record( + action='task_created', + resource_type='task', + resource_id=new_task.id, + metadata={'project_id': new_task.project_id, 'assigned_to': new_task.assigned_to} + ) + + emit_dashboard_refresh( + 'task_created', + resource_type='task', + resource_id=new_task.id, + payload={'project_id': new_task.project_id, 'assigned_to': new_task.assigned_to} + ) + _run_notification( NotificationService.task_created_notification, new_task.id, @@ -249,6 +285,20 @@ def update_task_by_id(task_id): db.session.commit() + audit_service.record( + action='task_updated', + resource_type='task', + resource_id=task.id, + metadata={'project_id': task.project_id, 'assigned_to': task.assigned_to} + ) + + emit_dashboard_refresh( + 'task_updated', + resource_type='task', + resource_id=task.id, + payload={'project_id': task.project_id, 'assigned_to': task.assigned_to} + ) + _run_notification( NotificationService.task_updated_notification, task.id, @@ -284,11 +334,19 @@ def delete_task_by_id(task_id): db.session.delete(task) db.session.commit() - + audit_service.record( action='task_deleted', resource_type='task', - resource_id=task_id + resource_id=task_id, + metadata={'project_id': task.project_id, 'assigned_to': task.assigned_to} + ) + + emit_dashboard_refresh( + 'task_deleted', + resource_type='task', + resource_id=task_id, + payload={'project_id': task.project_id, 'assigned_to': task.assigned_to} ) return jsonify({'message': 'Task deleted successfully'}) diff --git a/backend/src/api/controllers/users_controller.py b/backend/src/api/controllers/users_controller.py index 815b5d9..8829202 100644 --- a/backend/src/api/controllers/users_controller.py +++ b/backend/src/api/controllers/users_controller.py @@ -6,6 +6,7 @@ from ...auth.helpers import hash_password, verify_password # Changed to relative import from ..validators.user_validator import validate_user_data, validate_profile_update # Changed to relative import from ...services import audit_service +from src.socketio_server import emit_dashboard_refresh def get_all_users(): """Controller function to get all users""" @@ -53,6 +54,12 @@ def create_user(): resource_id=new_user.id, metadata={'role': new_user.role} ) + emit_dashboard_refresh( + 'user_created', + resource_type='user', + resource_id=new_user.id, + payload={'role': new_user.role} + ) return jsonify({ 'message': 'User created successfully', @@ -110,6 +117,19 @@ def update_user(user_id): user.avatar = data['avatar'] db.session.commit() + + audit_service.record( + action='user_updated', + resource_type='user', + resource_id=user.id, + metadata={'role': user.role} + ) + emit_dashboard_refresh( + 'user_updated', + resource_type='user', + resource_id=user.id, + payload={'role': user.role} + ) return jsonify({ 'message': 'User updated successfully', @@ -133,6 +153,11 @@ def delete_user(user_id): resource_type='user', resource_id=user_id ) + emit_dashboard_refresh( + 'user_deleted', + resource_type='user', + resource_id=user_id + ) return jsonify({'message': 'User deleted successfully'}) diff --git a/backend/src/api/routes/admin_routes.py b/backend/src/api/routes/admin_routes.py index a3621b8..042b429 100644 --- a/backend/src/api/routes/admin_routes.py +++ b/backend/src/api/routes/admin_routes.py @@ -1,6 +1,6 @@ """Admin API routes""" -from flask import request +from flask import request, jsonify from flask_jwt_extended import jwt_required from ..controllers.admin_controller import ( get_system_stats, @@ -8,6 +8,8 @@ update_system_settings, update_user_role ) +from ..controllers.audit_controller import cleanup_audit_logs +from ...services import settings_service from ..middlewares import admin_required from ..middlewares.validation_middleware import validate_json from ..middlewares.rate_limiter import rate_limit @@ -40,6 +42,36 @@ def system_stats(): def system_settings(): """Route to get system settings""" return get_system_settings() + + @bp.route('/admin/audit-logs/cleanup', methods=['POST']) + @jwt_required() + @admin_required() + @rate_limit(requests_per_window=5, window_seconds=60) + def audit_logs_cleanup(): + """Route to purge expired audit logs""" + return cleanup_audit_logs() + + @bp.route('/admin/settings/retention/run', methods=['POST']) + @jwt_required() + @admin_required() + @rate_limit(requests_per_window=5, window_seconds=60) + def run_retention_cleanup(): + """Route to run all retention cleanups immediately""" + try: + result = settings_service.run_retention_cleanup() + return jsonify({ + 'message': 'Retention cleanup completed', + 'result': result, + }), 200 + except Exception as exc: + return jsonify({ + 'message': 'Retention cleanup failed', + 'error': str(exc), + 'result': { + 'audit_logs_deleted': 0, + 'projects_deleted': 0, + }, + }), 200 @bp.route('/admin/settings', methods=['PUT']) @jwt_required() diff --git a/backend/src/api/validators/admin_validator.py b/backend/src/api/validators/admin_validator.py index f350d5d..c5883b4 100644 --- a/backend/src/api/validators/admin_validator.py +++ b/backend/src/api/validators/admin_validator.py @@ -8,33 +8,41 @@ def validate_system_settings(data): # Check for required fields if not data: return jsonify({'message': 'No settings provided'}), 400 - - # Validate app_name if provided + + # Backward-compatible legacy settings fields. The new UI no longer uses these, + # but existing tests and older clients still send them. if 'app_name' in data: if not isinstance(data['app_name'], str) or len(data['app_name']) < 3: return jsonify({'message': 'App name must be between 3 and 100 characters'}), 400 - # Validate boolean fields for bool_field in ['allow_registration', 'github_integration_enabled']: if bool_field in data and not isinstance(data[bool_field], bool): return jsonify({'message': f'{bool_field} must be a boolean value'}), 400 - # Validate default_user_role if provided - if 'default_user_role' in data: - valid_roles = [role.value for role in Role] - if data['default_user_role'] not in valid_roles: - return jsonify({'message': f'Default user role must be one of: {", ".join(valid_roles)}'}), 400 - - # Validate notification_settings if provided if 'notification_settings' in data: if not isinstance(data['notification_settings'], dict): return jsonify({'message': 'notification_settings must be an object'}), 400 - - # Validate each notification setting for key, value in data['notification_settings'].items(): if not isinstance(value, bool): return jsonify({'message': f'Notification setting "{key}" must be a boolean value'}), 400 + # Validate default_user_role if provided + if 'default_user_role' in data: + valid_roles = [role.value for role in Role] + if data['default_user_role'] not in valid_roles: + return jsonify({'message': f'Default user role must be one of: {", ".join(valid_roles)}'}), 400 + + for bool_field in ['allow_self_registration', 'auto_archive_completed_projects', 'notify_on_overdue_tasks']: + if bool_field in data and not isinstance(data[bool_field], bool): + return jsonify({'message': f'{bool_field} must be a boolean value'}), 400 + + for int_field in ['audit_log_retention_days', 'project_retention_days']: + if int_field in data: + if not isinstance(data[int_field], int): + return jsonify({'message': f'{int_field} must be an integer value'}), 400 + if data[int_field] < 0: + return jsonify({'message': f'{int_field} must be a non-negative integer'}), 400 + # If validation passes, return None return None diff --git a/backend/src/auth/auth.py b/backend/src/auth/auth.py index a93c968..cde47b3 100644 --- a/backend/src/auth/auth.py +++ b/backend/src/auth/auth.py @@ -32,8 +32,13 @@ def register_user(): if existing_user: return jsonify({'message': 'Email already registered'}), 409 - # If this is the very first user, automatically make them an admin + # Respect admin-controlled registration policy after the first admin bootstrap user user_count = User.query.count() + allow_self_registration = settings_service.get_bool_setting('allow_self_registration', True) + if user_count > 0 and not allow_self_registration: + return jsonify({'message': 'User registration is currently disabled by an administrator'}), 403 + + # If this is the very first user, automatically make them an admin if user_count == 0: forced_role = Role.ADMIN.value print(f"First user registration detected! Automatically granting admin role to {data['email']}") diff --git a/backend/src/services/audit_service.py b/backend/src/services/audit_service.py index ca56355..31ebf1b 100644 --- a/backend/src/services/audit_service.py +++ b/backend/src/services/audit_service.py @@ -3,6 +3,7 @@ from flask import request from flask_jwt_extended import get_jwt_identity, get_jwt from ..db.models import db, AuditLog +from ..socketio_server import emit_dashboard_refresh logger = logging.getLogger(__name__) @@ -58,6 +59,15 @@ def record(action, *, actor=None, resource_type=None, resource_id=None, metadata db.session.add(audit_entry) db.session.commit() + emit_dashboard_refresh( + 'audit_log_recorded', + resource_type=resource_type, + resource_id=resource_id, + payload={ + 'action': action, + 'metadata': metadata or {}, + }, + ) except Exception as e: logger.error(f"Failed to record audit log '{action}': {str(e)}") try: diff --git a/backend/src/services/notification_service.py b/backend/src/services/notification_service.py index 3e643e8..4fdd30d 100644 --- a/backend/src/services/notification_service.py +++ b/backend/src/services/notification_service.py @@ -284,3 +284,27 @@ def comment_added_notification(task_id, task_name, project_id, comment_id, exclude_user_ids=excluded_project_user_ids, task_id=task_id ) + + @staticmethod + def task_overdue_notification(task_id, task_name, project_id, recipient_user_id, due_date=None): + """Send a one-time overdue task notification to a specific user.""" + if recipient_user_id in (None, ''): + return None + + existing = Notification.query.filter_by( + user_id=recipient_user_id, + task_id=task_id, + notification_type='task_overdue', + ).first() + if existing: + return existing + + due_label = due_date.strftime('%b %d, %Y') if hasattr(due_date, 'strftime') else 'past due' + return NotificationService.send_to_user( + user_id=recipient_user_id, + notification_type='task_overdue', + title='Task is overdue', + message=f'Task "{task_name}" was due on {due_label}.', + reference_id=task_id, + task_id=task_id, + ) diff --git a/backend/src/services/settings_service.py b/backend/src/services/settings_service.py index 1f7ccf6..3bceb6c 100644 --- a/backend/src/services/settings_service.py +++ b/backend/src/services/settings_service.py @@ -1,35 +1,84 @@ """System Settings Service""" -from ..db.models import db, SystemSetting +from datetime import datetime, timedelta, timezone + +from ..db.models import db, SystemSetting, AuditLog, Project, Task, Comment, Notification, TaskGitHubLink + + +DEFAULT_SETTINGS = { + 'default_user_role': 'developer', + 'allow_self_registration': True, + 'audit_log_retention_days': 30, + 'auto_archive_completed_projects': True, + 'project_retention_days': 30, + 'notify_on_overdue_tasks': True, +} + +SUPPORTED_SETTINGS = set(DEFAULT_SETTINGS.keys()) + + +def _to_bool(value, default): + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {'true', '1', 'yes', 'on'}: + return True + if normalized in {'false', '0', 'no', 'off'}: + return False + return default + + +def _to_int(value, default): + try: + parsed = int(value) + return parsed if parsed >= 0 else default + except (TypeError, ValueError): + return default + + +def _normalize_setting(key, value): + default = DEFAULT_SETTINGS[key] + + if key in {'allow_self_registration', 'auto_archive_completed_projects', 'notify_on_overdue_tasks'}: + return _to_bool(value, default) + + if key in {'audit_log_retention_days', 'project_retention_days'}: + return _to_int(value, default) + + if key == 'default_user_role': + return value if isinstance(value, str) and value else default + + return value def get_settings(): """Retrieve all system settings as a dictionary.""" - settings = SystemSetting.query.all() - if not settings: - # Fallback if DB is empty - return { - 'default_user_role': 'developer', - 'notification_settings': { - 'email_notifications': True, - 'task_assignments': True, - 'project_updates': True - } - } - return {setting.key: setting.value for setting in settings} + settings = {key: value for key, value in DEFAULT_SETTINGS.items()} + + try: + for setting in SystemSetting.query.filter(SystemSetting.key.in_(SUPPORTED_SETTINGS)).all(): + settings[setting.key] = _normalize_setting(setting.key, setting.value) + except Exception: + # Fall back to defaults when the table is missing or the SQLAlchemy app + # context is not fully initialized, such as in lightweight unit tests. + return settings + + return settings def update_settings(data, actor_id): """Update multiple system settings.""" - disallowed_keys = {'app_name', 'allow_registration', 'github_integration_enabled'} for key, value in data.items(): - if key in disallowed_keys: + if key not in SUPPORTED_SETTINGS: continue + + normalized_value = _normalize_setting(key, value) setting = SystemSetting.query.get(key) if setting: - setting.value = value + setting.value = normalized_value setting.updated_by = actor_id else: new_setting = SystemSetting( key=key, - value=value, + value=normalized_value, updated_by=actor_id ) db.session.add(new_setting) @@ -38,7 +87,70 @@ def update_settings(data, actor_id): def get_default_role(): """Get the default role for new users.""" - setting = SystemSetting.query.get('default_user_role') - if setting and setting.value: - return setting.value - return 'developer' + return get_settings().get('default_user_role', 'developer') + + +def get_bool_setting(key, default=False): + return _to_bool(get_settings().get(key, default), default) + + +def get_int_setting(key, default=0): + return _to_int(get_settings().get(key, default), default) + + +def cleanup_old_audit_logs(retention_days=None): + """Delete audit logs older than the configured retention window.""" + try: + days = get_int_setting('audit_log_retention_days', DEFAULT_SETTINGS['audit_log_retention_days']) if retention_days is None else _to_int(retention_days, DEFAULT_SETTINGS['audit_log_retention_days']) + if days <= 0: + return 0 + + cutoff = datetime.now(timezone.utc) - timedelta(days=days) + deleted_count = AuditLog.query.filter(AuditLog.created_at < cutoff).delete(synchronize_session=False) + db.session.commit() + return deleted_count + except Exception: + db.session.rollback() + return 0 + + +def cleanup_completed_projects(retention_days=None): + """Delete completed projects and their dependent records after the retention window.""" + try: + days = get_int_setting('project_retention_days', DEFAULT_SETTINGS['project_retention_days']) if retention_days is None else _to_int(retention_days, DEFAULT_SETTINGS['project_retention_days']) + if days <= 0: + return 0 + + cutoff = datetime.now(timezone.utc) - timedelta(days=days) + completed_projects = Project.query.filter(Project.status == 'completed', Project.updated_at < cutoff).all() + + deleted_projects = 0 + for project in completed_projects: + task_ids = [task.id for task in (project.tasks or [])] + + if task_ids: + TaskGitHubLink.query.filter(TaskGitHubLink.task_id.in_(task_ids)).delete(synchronize_session=False) + Comment.query.filter(Comment.task_id.in_(task_ids)).delete(synchronize_session=False) + Notification.query.filter(Notification.task_id.in_(task_ids)).delete(synchronize_session=False) + Task.query.filter(Task.id.in_(task_ids)).delete(synchronize_session=False) + + db.session.delete(project) + deleted_projects += 1 + + if deleted_projects: + db.session.commit() + + return deleted_projects + except Exception: + db.session.rollback() + return 0 + + +def run_retention_cleanup(audit_retention_days=None, project_retention_days=None): + """Run all retention cleanups and return a summary.""" + audit_deleted = cleanup_old_audit_logs(audit_retention_days) + project_deleted = cleanup_completed_projects(project_retention_days) + return { + 'audit_logs_deleted': audit_deleted, + 'projects_deleted': project_deleted, + } diff --git a/backend/src/services/task_rules.py b/backend/src/services/task_rules.py new file mode 100644 index 0000000..82b55a3 --- /dev/null +++ b/backend/src/services/task_rules.py @@ -0,0 +1,133 @@ +"""Shared task rule helpers used by dashboard and notification logic.""" + +from datetime import datetime + +from ..db.models import Project + +COMPLETED_TASK_STATUSES = {'done', 'completed'} +OVERDUE_EXCLUDED_STATUSES = {'done', 'completed', 'review', 'in_review'} +PROJECT_SCOPE_ROLES = {'admin', 'team_lead'} + + +def _to_int(value): + try: + return int(value) + except (TypeError, ValueError): + return None + + +def normalize_task_status(status): + return (status or '').lower().replace('-', '_').replace(' ', '_') + + +def parse_task_deadline(deadline): + if deadline in (None, ''): + return None + + if hasattr(deadline, 'toordinal'): + return deadline + + if isinstance(deadline, str): + try: + return datetime.fromisoformat(deadline) + except ValueError: + pass + + try: + numeric = float(deadline) + except (TypeError, ValueError): + return None + + if numeric > 1e12: + numeric /= 1000.0 + + return datetime.fromtimestamp(numeric) + + +def get_project_scope_ids(user_id, user_role): + """Return the project IDs visible to an admin/team lead for overdue logic.""" + if user_role not in PROJECT_SCOPE_ROLES: + return set() + + user_id = _to_int(user_id) + if user_id is None: + return set() + + project_ids = set() + + try: + projects = Project.query.all() + except Exception: + return set() + + for project in projects: + members = getattr(project, 'team_members', []) or [] + if hasattr(members, 'all'): + members = members.all() + + is_assigned = False + for member in members: + if member is None: + continue + if isinstance(member, (int, str)): + if _to_int(member) == user_id: + is_assigned = True + break + continue + + member_id = ( + getattr(member, 'id', None) + or getattr(member, 'user_id', None) + or getattr(member, 'userId', None) + or getattr(member, 'member_id', None) + ) + if _to_int(member_id) == user_id: + is_assigned = True + break + + if is_assigned or _to_int(getattr(project, 'created_by', None)) == user_id: + project_ids.add(_to_int(getattr(project, 'id', None))) + + project_ids.discard(None) + return project_ids + + +def is_task_overdue(task, *, project_ids=None, assigned_to=None, now=None): + status = normalize_task_status(getattr(task, 'status', None)) + if status in OVERDUE_EXCLUDED_STATUSES: + return False + + if project_ids is not None: + task_project_id = getattr(task, 'project_id', None) or getattr(task, 'projectId', None) + task_project = getattr(task, 'project', None) + if task_project_id is None and task_project is not None: + task_project_id = getattr(task_project, 'id', None) or getattr(task_project, 'project_id', None) + + if _to_int(task_project_id) not in project_ids: + return False + + if assigned_to is not None: + task_assignee = getattr(task, 'assigned_to', None) or getattr(task, 'assignedTo', None) or getattr(task, 'assignee', None) + if isinstance(task_assignee, dict): + task_assignee = task_assignee.get('id') or task_assignee.get('user_id') or task_assignee.get('userId') + + if _to_int(task_assignee) != _to_int(assigned_to): + return False + + deadline = parse_task_deadline( + getattr(task, 'deadline', None) + or getattr(task, 'due_date', None) + or getattr(task, 'dueDate', None) + or getattr(task, 'due_at', None) + or getattr(task, 'dueAt', None) + or getattr(task, 'due', None) + ) + if deadline is None: + return False + + current_time = now or (datetime.now(deadline.tzinfo) if getattr(deadline, 'tzinfo', None) else datetime.now()) + return deadline < current_time + + +def count_overdue_tasks(tasks, **scope): + return sum(1 for task in tasks if is_task_overdue(task, **scope)) \ No newline at end of file diff --git a/backend/src/socketio_server.py b/backend/src/socketio_server.py index ef7a8c6..a0c83c3 100644 --- a/backend/src/socketio_server.py +++ b/backend/src/socketio_server.py @@ -1,4 +1,5 @@ import functools +import logging from flask import request from flask_socketio import SocketIO, emit, join_room, leave_room, disconnect from flask_jwt_extended import decode_token @@ -6,6 +7,7 @@ # Initialize SocketIO socketio = SocketIO(cors_allowed_origins="*") +logger = logging.getLogger(__name__) # Store for connected users and project rooms connected_users = {} # user_id -> session_id @@ -13,6 +15,19 @@ sid_users = {} # session_id -> user_id +def emit_dashboard_refresh(event_type, *, resource_type=None, resource_id=None, payload=None): + """Broadcast a dashboard refresh event to all connected clients.""" + try: + socketio.emit('dashboard_updated', { + 'event_type': event_type, + 'resource_type': resource_type, + 'resource_id': resource_id, + 'payload': payload or {}, + }) + except Exception: + logger.exception('Failed to emit dashboard refresh event') + + def _normalize_user_id(user_id): """Keep JWT numeric identities consistent with database integer IDs.""" if isinstance(user_id, str) and user_id.isdigit(): diff --git a/frontend/src/context/NotificationContext.jsx b/frontend/src/context/NotificationContext.jsx index fd35f99..2ded413 100644 --- a/frontend/src/context/NotificationContext.jsx +++ b/frontend/src/context/NotificationContext.jsx @@ -301,6 +301,38 @@ export const NotificationProvider = ({ children }) => { } } }); + + // Relay task-related socket events into a global browser event so other parts + // of the UI (e.g., dashboards) can react without coupling to the socket context. + const relayTaskEvent = (taskPayload) => { + try { + window.dispatchEvent(new CustomEvent('devsync:task-updated', { detail: taskPayload })); + } catch (e) { + console.warn('Failed to dispatch devsync:task-updated event', e); + } + }; + + const relayDashboardEvent = (payload) => { + try { + window.dispatchEvent(new CustomEvent('devsync:dashboard-updated', { detail: payload })); + } catch (e) { + console.warn('Failed to dispatch devsync:dashboard-updated event', e); + } + }; + + // Common task event names the backend may emit; listen to several variants. + ['task', 'task.updated', 'task_update', 'task:updated', 'task_updated'].forEach((evtName) => { + socketConnection.on(evtName, (payload) => { + console.log(`Received socket event ${evtName}`, payload); + relayTaskEvent(payload || {}); + relayDashboardEvent(payload || {}); + }); + }); + + socketConnection.on('dashboard_updated', (payload) => { + console.log('Received socket event dashboard_updated', payload); + relayDashboardEvent(payload || {}); + }); } catch (error) { console.error('Error setting up socket connection:', error); } diff --git a/frontend/src/pages/AdminDashboard.jsx b/frontend/src/pages/AdminDashboard.jsx index ff7ae15..8edae5b 100644 --- a/frontend/src/pages/AdminDashboard.jsx +++ b/frontend/src/pages/AdminDashboard.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback } from 'react'; import { Link } from 'react-router-dom'; -import { dashboardService, projectService, userService, auditLogService } from '../services/utils/api'; +import { dashboardService, projectService, userService, auditLogService, taskService, reportService } from '../services/utils/api'; import LoadingSpinner from '../components/LoadingSpinner'; import { useAuth } from '../context/AuthContext'; @@ -91,6 +91,11 @@ const PanelHeader = ({ title, linkTo, linkLabel }) => ( ); +// Shared panel styles to keep Basic/Admin dashboards consistent +const panelClass = "bg-slate-900/70 border border-slate-800/70 rounded-2xl overflow-hidden shadow-md backdrop-blur-sm"; +const panelHeaderClass = "px-5 py-4 border-b border-slate-800 flex items-center justify-between"; +const sectionTitleClass = "text-lg font-semibold text-slate-100"; + // ─── Admin Dashboard ─────────────────────────────────────────────────────────── const AdminDashboard = () => { const { currentUser } = useAuth(); @@ -99,34 +104,124 @@ const AdminDashboard = () => { const [recentAuditLogs, setRecentAuditLogs] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [timeRange, setTimeRange] = useState('week'); - const fetchDashboardData = useCallback(async () => { + const fetchDashboardData = useCallback(async (range = 'week') => { try { setLoading(true); - const data = await dashboardService.getAdminDashboardStats(timeRange); + + // Base dashboard stats + const data = await dashboardService.getAdminDashboardStats(range); setDashboardData(data); - - // Fetch team users for the overview + + // Team users try { const users = await userService.getAllUsers(); setTeamUsers(users || []); } catch (userErr) { console.error('Failed to fetch team users:', userErr); + setTeamUsers([]); } - - // Fetch recent audit logs (admin-only) + + // Recent audit logs (admin-only) try { if (currentUser?.role === 'admin') { - const auditResponse = await auditLogService.getLogs({ per_page: 3, page: 1 }); + const auditResponse = await auditLogService.getLogs({ per_page: 5, page: 1 }); setRecentAuditLogs(auditResponse?.logs || []); } else { setRecentAuditLogs([]); } } catch (auditErr) { console.error('Failed to fetch audit logs:', auditErr); + setRecentAuditLogs([]); } - + + // Fetch projects and tasks for KPI calculations and scoping + try { + const [allProjects, allTasks, reportsResp] = await Promise.all([ + projectService.getAllProjects(), + taskService.getAllTasks(), + reportService.getSavedReports({ per_page: 5 }) + ]); + + const projects = Array.isArray(allProjects) ? allProjects : []; + const tasks = Array.isArray(allTasks) ? allTasks : []; + + // Compute admin-scoped projects (projects the admin is assigned to) + const adminProjectIds = new Set(); + projects.forEach((p) => { + const members = Array.isArray(p.team_members) ? p.team_members : []; + const isAssigned = members.some((m) => { + if (m == null) return false; + if (typeof m === 'number' || typeof m === 'string') return Number(m) === Number(currentUser?.id); + const mid = m.id ?? m.user_id ?? m.userId ?? m.member_id ?? null; + if (mid != null) return Number(mid) === Number(currentUser?.id); + return false; + }) || Number(p?.created_by) === Number(currentUser?.id); + if (isAssigned) adminProjectIds.add(Number(p.id)); + }); + + // My projects: projects admin is assigned to + const myProjects = projects.filter(p => adminProjectIds.has(Number(p.id))); + + // KPI: total incomplete projects (all projects with status active or on-hold) + const incompleteProjectsCount = projects.filter(p => ['active', 'on-hold', 'on_hold'].includes(String(p.status))).length; + + // KPI: overdue tasks scoped to admin's projects only + const now = new Date(); + const overdueTasksCount = tasks.filter((t) => { + const pid = t.project_id ?? t.projectId ?? (t.project && (t.project.id ?? t.project.project_id)) ?? null; + if (pid === null) return false; + if (!adminProjectIds.has(Number(pid))) return false; + const status = (t.status || '').toLowerCase().replace(/[^a-z0-9]+/g, '_'); + if (['review', 'in_review', 'in-review', 'done', 'completed'].includes(status)) return false; + const deadlineVal = t.deadline ?? t.due_date ?? t.dueDate ?? t.due_at ?? t.dueAt ?? t.due ?? null; + if (!deadlineVal) return false; + try { + const d = new Date(deadlineVal); + if (Number.isNaN(d.getTime())) { + // try parsing as seconds since epoch + const maybeNum = Number(deadlineVal); + if (!Number.isNaN(maybeNum)) return new Date(maybeNum * (maybeNum > 1e12 ? 1 : 1000)) < now; + return false; + } + return d < now; + } catch (e) { + return false; + } + }).length; + + // KPI: tasks in review scoped to admin's projects + const tasksInReviewCount = tasks.filter((t) => { + const pid = t.project_id ?? t.projectId ?? (t.project && t.project.id) ?? null; + if (pid === null) return false; + if (!adminProjectIds.has(Number(pid))) return false; + const statusNorm = (t.status || '').toLowerCase().replace(/[^a-z0-9]+/g, '_'); + return ['review', 'in_review', 'inreview', 'in_review'].includes(statusNorm); + }).length; + + // My assigned tasks for "My Tasks" + const myAssignedTasks = tasks + .filter((t) => { + let aid = t.assigned_to ?? t.assignedTo ?? t.assignee ?? null; + if (aid && typeof aid === 'object') aid = aid.id ?? aid.user_id ?? aid.userId ?? null; + return aid !== null && Number(aid) === Number(currentUser?.id); + }) + .sort((a, b) => new Date(b.updated_at || b.created_at || 0) - new Date(a.updated_at || a.created_at || 0)); + + setDashboardData(prev => ({ + ...prev, + _allProjects: projects, + myProjects, + _incompleteProjectsCount: incompleteProjectsCount, + _overdueTasksCount: overdueTasksCount, + _tasksInReviewCount: tasksInReviewCount || data?.tasks?.review || data?.tasks?.inReview || data?.tasks?.in_review || 0, + _myAssignedTasks: myAssignedTasks, + recentReports: (reportsResp?.reports) ? reportsResp.reports : (reportsResp?.data ?? []) + })); + } catch (e) { + console.error('Failed to fetch projects/tasks/reports for dashboard:', e); + } + setError(null); } catch (err) { console.error('Dashboard fetch error:', err); @@ -134,10 +229,28 @@ const AdminDashboard = () => { } finally { setLoading(false); } - }, [currentUser?.role, timeRange]); + }, [currentUser]); useEffect(() => { fetchDashboardData(); }, [fetchDashboardData]); + // Listen for global task-updated events and refresh dashboard KPIs live + useEffect(() => { + const handler = () => { + try { + fetchDashboardData(); + } catch (e) { + console.warn('Failed to refresh dashboard on task update', e); + } + }; + + window.addEventListener('devsync:task-updated', handler); + window.addEventListener('devsync:dashboard-updated', handler); + return () => { + window.removeEventListener('devsync:task-updated', handler); + window.removeEventListener('devsync:dashboard-updated', handler); + }; + }, [fetchDashboardData]); + // Fallback: derive recentProjects from projectService if API omits them useEffect(() => { if (!dashboardData) return; @@ -180,18 +293,17 @@ const AdminDashboard = () => {
- - + + + + Create Task + + + +
+
+

Task Alerts

+

Sends one overdue reminder per task when users load tasks and dashboards.

+
+
+
+ Notify on overdue tasks +

Only sends once per task.

+
+ handleChange('notify_on_overdue_tasks', !isEnabled('notify_on_overdue_tasks', true))} /> +
+
- { (dashboardData?._myAssignedTasks && dashboardData._myAssignedTasks.length > 0) ? ( + {(() => { + const activeTasks = (dashboardData?.my_assigned_tasks || []).filter( + (t) => !['done', 'completed'].includes((t.status || '').toLowerCase()) + ); + return activeTasks.length > 0 ? ( - ) : ( + ) : (

No tasks found

-

You don't have any tasks assigned yet.

+

You don't have any active tasks assigned yet.

- )} + ); + })()}
diff --git a/frontend/src/pages/BasicDashboard.jsx b/frontend/src/pages/BasicDashboard.jsx index e57ff7c..86b2e49 100644 --- a/frontend/src/pages/BasicDashboard.jsx +++ b/frontend/src/pages/BasicDashboard.jsx @@ -96,8 +96,9 @@ const BasicDashboard = () => { }; // Use full tasks list if provided by API, otherwise fall back to recentTasks - // Limit to last 10 most recent tasks, sorted by most recent first + // Filter out completed tasks, limit to last 10 most recent tasks, sorted by most recent first const orderedTasksToShow = ((dashboardData?.tasks && dashboardData.tasks.length) ? dashboardData.tasks : (dashboardData?.recentTasks || [])) + .filter((task) => !['done', 'completed'].includes((task.status || '').toLowerCase())) .sort((a, b) => { const dateA = new Date(a.updated_at || a.created_at || 0).getTime(); const dateB = new Date(b.updated_at || b.created_at || 0).getTime(); diff --git a/frontend/src/pages/TaskList.jsx b/frontend/src/pages/TaskList.jsx index e483b84..0737963 100644 --- a/frontend/src/pages/TaskList.jsx +++ b/frontend/src/pages/TaskList.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { taskService } from '../services/utils/api'; +import { taskService, userService } from '../services/utils/api'; import { useAuth } from '../context/AuthContext'; import LoadingSpinner from '../components/LoadingSpinner'; @@ -11,6 +11,7 @@ const TaskList = () => { const [loading, setLoading] = useState(true); const [updating, setUpdating] = useState(false); const [error, setError] = useState(null); + const [userMap, setUserMap] = useState({}); const [filters, setFilters] = useState({ status: 'all', priority: 'all', @@ -23,11 +24,26 @@ const TaskList = () => { const urlParams = new URLSearchParams(window.location.search); const requestedAssignee = urlParams.get('assigned_to') || urlParams.get('assignee'); - // Fetch tasks once; honor deep-link assignee query if provided + // Fetch users and tasks once; honor deep-link assignee query if provided useEffect(() => { + const fetchUsersAndTasks = async () => { + try { + const usersData = await userService.getAllUsers(); + const users = Array.isArray(usersData?.users) ? usersData.users : Array.isArray(usersData) ? usersData : []; + const userById = {}; + users.forEach(user => { + userById[user.id] = user.name; + }); + setUserMap(userById); + } catch (err) { + console.error('Failed to fetch users:', err); + } + }; + const initialUrlParams = new URLSearchParams(window.location.search); const initialAssignee = initialUrlParams.get('assigned_to') || initialUrlParams.get('assignee'); + fetchUsersAndTasks(); if (initialAssignee) { setFilters((prev) => ({ ...prev, scope: 'my' })); fetchTasks({ assigned_to: initialAssignee }); @@ -326,6 +342,9 @@ const TaskList = () => { Priority + + Assignee + Progress @@ -368,6 +387,11 @@ const TaskList = () => { {priorityInfo.icon} {priorityInfo.text} + +
+ {task.assignee?.name || task.assigned_to_name || (task.assigned_to ? userMap[task.assigned_to] || `User #${task.assigned_to}` : '—')} +
+
From ddafd4af4a91edd9cc59567fa83145de7717ba77 Mon Sep 17 00:00:00 2001 From: Ahmed Ikram <2571642@dundee.ac.uk> Date: Fri, 8 May 2026 12:38:45 +0100 Subject: [PATCH 03/12] feat: Enhance task and user notifications with detailed context and role-based recipients --- .../src/api/controllers/tasks_controller.py | 52 +++- .../src/api/controllers/users_controller.py | 61 +++- .../src/services/notification_recipients.py | 277 +++++++++++++++++ backend/src/services/notification_service.py | 279 ++++++++++++++++++ frontend/src/pages/TaskList.jsx | 25 +- 5 files changed, 676 insertions(+), 18 deletions(-) create mode 100644 backend/src/services/notification_recipients.py diff --git a/backend/src/api/controllers/tasks_controller.py b/backend/src/api/controllers/tasks_controller.py index 0adf150..398f362 100644 --- a/backend/src/api/controllers/tasks_controller.py +++ b/backend/src/api/controllers/tasks_controller.py @@ -4,7 +4,7 @@ from datetime import datetime from flask import request, jsonify from flask_jwt_extended import get_jwt_identity, get_jwt -from ...db.models import db, Task, User # Changed to relative import +from ...db.models import db, Task, User, Project # Changed to relative import from ...auth.rbac import Role # Changed to relative import from ..validators.task_validator import validate_task_data # Changed to relative import from ...services import audit_service, settings_service @@ -214,6 +214,19 @@ def create_new_task(): db.session.add(new_task) db.session.commit() + # Fetch project and assignee names for notification context + project_name = None + assignee_name = None + try: + if new_task.project_id: + project = Project.query.get(new_task.project_id) + project_name = project.name if project else None + if new_task.assigned_to: + assignee = User.query.get(new_task.assigned_to) + assignee_name = assignee.name if assignee else None + except Exception: + pass + audit_service.record( action='task_created', resource_type='task', @@ -229,12 +242,14 @@ def create_new_task(): ) _run_notification( - NotificationService.task_created_notification, + NotificationService.task_created_notification_v2, new_task.id, new_task.title, new_task.project_id, user_id, - new_task.assigned_to + assignee_id=new_task.assigned_to, + project_name=project_name, + assignee_name=assignee_name ) return jsonify({ @@ -264,26 +279,47 @@ def update_task_by_id(task_id): if not can_update_task: return jsonify({'message': 'You can only update tasks assigned to you'}), 403 + # Track which fields changed for notification + changed_fields = {} + # Update allowed fields if 'title' in data: + if task.title != data['title']: + changed_fields['title'] = (task.title, data['title']) task.title = data['title'] if 'description' in data: + if task.description != data['description']: + changed_fields['description'] = (task.description, data['description']) task.description = data['description'] if 'status' in data: + if task.status != data['status']: + changed_fields['status'] = (task.status, data['status']) task.status = data['status'] if 'progress' in data: + if task.progress != data['progress']: + changed_fields['progress'] = (task.progress, data['progress']) task.progress = data['progress'] if 'priority' in data: + if task.priority != data['priority']: + changed_fields['priority'] = (task.priority, data['priority']) task.priority = data['priority'] if 'deadline' in data: + if task.deadline != data['deadline']: + changed_fields['deadline'] = (task.deadline, data['deadline']) task.deadline = data['deadline'] if 'project_id' in data: - task.project_id = _coerce_int(data['project_id']) + new_project_id = _coerce_int(data['project_id']) + if task.project_id != new_project_id: + changed_fields['project_id'] = (task.project_id, new_project_id) + task.project_id = new_project_id if 'assigned_to' in data: # Only TL or Admins can change the assignee if can_assign_task: - task.assigned_to = _coerce_int(data['assigned_to']) + new_assignee = _coerce_int(data['assigned_to']) + if task.assigned_to != new_assignee: + changed_fields['assigned_to'] = (task.assigned_to, new_assignee) + task.assigned_to = new_assignee elif _coerce_int(data['assigned_to']) != task.assigned_to: return jsonify({'message': 'You do not have permission to reassign tasks'}), 403 @@ -304,13 +340,13 @@ def update_task_by_id(task_id): ) _run_notification( - NotificationService.task_updated_notification, + NotificationService.task_updated_notification_v2, task.id, task.title, task.project_id, user_id, - old_assignee_id, - task.assigned_to + assignee_id=task.assigned_to, + changed_fields=changed_fields if changed_fields else None ) return jsonify({ diff --git a/backend/src/api/controllers/users_controller.py b/backend/src/api/controllers/users_controller.py index 8829202..4035aa3 100644 --- a/backend/src/api/controllers/users_controller.py +++ b/backend/src/api/controllers/users_controller.py @@ -36,6 +36,8 @@ def create_user(): # Check if email is already taken if User.query.filter_by(email=data['email']).first(): return jsonify({'message': 'Email already in use'}), 409 + + admin_user_id = get_jwt_identity()['user_id'] # Create new user new_user = User( @@ -61,6 +63,15 @@ def create_user(): payload={'role': new_user.role} ) + # Notify admins about new user + from ...services.notification_service import NotificationService + NotificationService.user_crud_notification( + action_type='user_created', + affected_user_name=new_user.name, + affected_user_role=new_user.role, + admin_user_id=admin_user_id + ) + return jsonify({ 'message': 'User created successfully', 'user': { @@ -97,23 +108,33 @@ def update_user(user_id): return validation_result user = User.query.get_or_404(user_id) + admin_user_id = get_jwt_identity()['user_id'] - # Update allowed fields - if 'name' in data: + # Track what fields are being changed + changed_fields = {} + + # Update allowed fields and track changes + if 'name' in data and user.name != data['name']: + changed_fields['name'] = (user.name, data['name']) user.name = data['name'] - if 'email' in data: + if 'email' in data and user.email != data['email']: # Check if email is already taken by another user existing_user = User.query.filter_by(email=data['email']).first() if existing_user and existing_user.id != user_id: return jsonify({'message': 'Email already in use'}), 409 + changed_fields['email'] = (user.email, data['email']) user.email = data['email'] - if 'role' in data: + if 'role' in data and user.role != data['role']: + changed_fields['role'] = (user.role, data['role']) user.role = data['role'] if 'password' in data and data['password']: + changed_fields['password'] = ('***', '***') user.password = hash_password(data['password']) - if 'github_username' in data: + if 'github_username' in data and user.github_username != data['github_username']: + changed_fields['github_username'] = (user.github_username, data['github_username']) user.github_username = data['github_username'] - if 'avatar' in data and hasattr(user, 'avatar'): + if 'avatar' in data and hasattr(user, 'avatar') and user.avatar != data['avatar']: + changed_fields['avatar'] = ('...', '...') user.avatar = data['avatar'] db.session.commit() @@ -131,6 +152,24 @@ def update_user(user_id): payload={'role': user.role} ) + # Notify admins about user update/role change + from ...services.notification_service import NotificationService + if 'role' in changed_fields: + NotificationService.user_crud_notification( + action_type='user_role_changed', + affected_user_name=user.name, + affected_user_role=user.role, + changed_fields=changed_fields if changed_fields else None, + admin_user_id=admin_user_id + ) + else: + NotificationService.user_crud_notification( + action_type='user_updated', + affected_user_name=user.name, + changed_fields=changed_fields if changed_fields else None, + admin_user_id=admin_user_id + ) + return jsonify({ 'message': 'User updated successfully', 'user': { @@ -144,6 +183,8 @@ def update_user(user_id): def delete_user(user_id): """Controller function to delete a user (admin only)""" user = User.query.get_or_404(user_id) + admin_user_id = get_jwt_identity()['user_id'] + user_name = user.name db.session.delete(user) db.session.commit() @@ -159,6 +200,14 @@ def delete_user(user_id): resource_id=user_id ) + # Notify admins about user deletion + from ...services.notification_service import NotificationService + NotificationService.user_crud_notification( + action_type='user_deleted', + affected_user_name=user_name, + admin_user_id=admin_user_id + ) + return jsonify({'message': 'User deleted successfully'}) def get_current_user_profile(): diff --git a/backend/src/services/notification_recipients.py b/backend/src/services/notification_recipients.py new file mode 100644 index 0000000..3473166 --- /dev/null +++ b/backend/src/services/notification_recipients.py @@ -0,0 +1,277 @@ +""" +Notification recipient calculator for role-based notification filtering. +Determines who should receive notifications based on user roles, project scope, and action type. +""" + +from ..db.models import Task, Project, User +from .task_rules import get_project_scope_ids + + +def _to_int(value): + """Safe int conversion""" + try: + return int(value) if value is not None else None + except (TypeError, ValueError): + return None + + +def get_tls_for_project(project_id): + """Get all Team Leads assigned to a project""" + project_id = _to_int(project_id) + if project_id is None: + return [] + + try: + project = Project.query.get(project_id) + if not project: + return [] + + team_leads = [] + members = getattr(project, 'team_members', []) or [] + if hasattr(members, 'all'): + members = members.all() + + for member in members: + if member is None: + continue + role = getattr(member, 'role', None) + if role == 'team_lead': + team_leads.append(member.id) + + return team_leads + except Exception: + return [] + + +def get_admins_for_project(project_id): + """Get the admin (creator) of a project""" + project_id = _to_int(project_id) + if project_id is None: + return [] + + try: + project = Project.query.get(project_id) + if not project: + return [] + + creator_id = getattr(project, 'created_by', None) + if creator_id: + return [creator_id] + return [] + except Exception: + return [] + + +def get_all_admins(): + """Get all admin users in the system""" + try: + admins = User.query.filter_by(role='admin').all() + return [admin.id for admin in admins] + except Exception: + return [] + + +def get_recipients_for_task_assign(task_id, assignee_id, project_id, assigner_id): + """ + Get recipients for task assignment notification. + + Recipients: + - The person being assigned (if not the assigner) + """ + assignee_id = _to_int(assignee_id) + assigner_id = _to_int(assigner_id) + + if assignee_id is None or assignee_id == assigner_id: + return [] + + return [assignee_id] + + +def get_recipients_for_task_create(task_id, project_id, creator_id, assignee_id=None): + """ + Get recipients for task creation notification. + + Recipients: + - The assignee (if assigned) + - All Team Leads of the project + - The project creator (admin) + """ + project_id = _to_int(project_id) + creator_id = _to_int(creator_id) + assignee_id = _to_int(assignee_id) + + recipients = set() + + # Notify the assignee + if assignee_id is not None and assignee_id != creator_id: + recipients.add(assignee_id) + + # Notify project TLs + if project_id: + tls = get_tls_for_project(project_id) + for tl_id in tls: + if tl_id != creator_id and tl_id != assignee_id: + recipients.add(tl_id) + + # Notify project creator (admin) + admins = get_admins_for_project(project_id) + for admin_id in admins: + if admin_id != creator_id and admin_id != assignee_id: + recipients.add(admin_id) + + return list(recipients) + + +def get_recipients_for_task_update(task_id, project_id, updater_id, assignee_id=None): + """ + Get recipients for task update notification. + + Recipients: + - The assigned person (if task is assigned to them and they're not the updater) + - All Team Leads of the project (for visibility of project-wide task updates) + - Project admin + """ + project_id = _to_int(project_id) + updater_id = _to_int(updater_id) + assignee_id = _to_int(assignee_id) + + recipients = set() + + # Notify the assignee + if assignee_id is not None and assignee_id != updater_id: + recipients.add(assignee_id) + + # Notify project TLs and project admin + if project_id: + tls = get_tls_for_project(project_id) + for tl_id in tls: + if tl_id != updater_id: + recipients.add(tl_id) + + admins = get_admins_for_project(project_id) + for admin_id in admins: + if admin_id != updater_id: + recipients.add(admin_id) + + return list(recipients) + + +def get_recipients_for_overdue_task(task_id, project_id, assignee_id): + """ + Get recipients for overdue task notification. + + Recipients: + - The assigned person (if assigned) + - All Team Leads of the project + - All Admins + """ + project_id = _to_int(project_id) + assignee_id = _to_int(assignee_id) + + recipients = set() + + # Notify the assignee + if assignee_id is not None: + recipients.add(assignee_id) + + # Notify project TLs and project admin + if project_id: + tls = get_tls_for_project(project_id) + for tl_id in tls: + recipients.add(tl_id) + + admins = get_admins_for_project(project_id) + for admin_id in admins: + recipients.add(admin_id) + + # Also notify all app-wide admins + all_admins = get_all_admins() + for admin_id in all_admins: + recipients.add(admin_id) + + return list(recipients) + + +def get_recipients_for_project_member_add(project_id, new_member_id, adder_id): + """ + Get recipients for project member add notification. + + Recipients: + - The new member + - All existing Team Leads in the project + - Project admin + """ + project_id = _to_int(project_id) + new_member_id = _to_int(new_member_id) + adder_id = _to_int(adder_id) + + recipients = set() + + # Notify the new member + if new_member_id is not None: + recipients.add(new_member_id) + + # Notify project TLs and admin + if project_id: + tls = get_tls_for_project(project_id) + for tl_id in tls: + if tl_id != new_member_id and tl_id != adder_id: + recipients.add(tl_id) + + admins = get_admins_for_project(project_id) + for admin_id in admins: + if admin_id != new_member_id and admin_id != adder_id: + recipients.add(admin_id) + + return list(recipients) + + +def get_recipients_for_report_available(project_id, creator_id): + """ + Get recipients for 'report available for download' notification. + + Recipients: + - The creator (if they're a TL/Admin) + - All Team Leads of the project + - All Admins + """ + project_id = _to_int(project_id) + creator_id = _to_int(creator_id) + + recipients = set() + + # Notify the creator + if creator_id is not None: + recipients.add(creator_id) + + # Notify project TLs + if project_id: + tls = get_tls_for_project(project_id) + for tl_id in tls: + recipients.add(tl_id) + + admins = get_admins_for_project(project_id) + for admin_id in admins: + recipients.add(admin_id) + + # Also notify all app-wide admins + all_admins = get_all_admins() + for admin_id in all_admins: + recipients.add(admin_id) + + return list(recipients) + + +def get_recipients_for_user_crud(action_type, affected_user_id): + """ + Get recipients for user CRUD operations (create, update, delete, role change). + + Recipients: + - All Admins only + + Args: + action_type: 'user_created', 'user_updated', 'user_deleted', 'user_role_changed' + affected_user_id: The ID of the user being created/updated/deleted + """ + # Only notify admins about user CRUD + return get_all_admins() diff --git a/backend/src/services/notification_service.py b/backend/src/services/notification_service.py index 4fdd30d..595d649 100644 --- a/backend/src/services/notification_service.py +++ b/backend/src/services/notification_service.py @@ -308,3 +308,282 @@ def task_overdue_notification(task_id, task_name, project_id, recipient_user_id, reference_id=task_id, task_id=task_id, ) + + @staticmethod + def send_to_recipients(recipient_user_ids, notification_type, title, message, reference_id=None, task_id=None): + """ + Send notification to a specific list of recipients. + + Args: + recipient_user_ids: List of user IDs to send notification to + notification_type: Type of notification + title: Notification title + message: Notification content + reference_id: ID of the related object + task_id: Optional task ID related to the notification + """ + if not recipient_user_ids: + return [] + + notifications = [] + for user_id in recipient_user_ids: + notification = NotificationService.send_to_user( + user_id=user_id, + notification_type=notification_type, + title=title, + message=message, + reference_id=reference_id, + task_id=task_id + ) + if notification: + notifications.append(notification) + + return notifications + + @staticmethod + def task_created_notification_v2(task_id, task_name, project_id, created_by_user_id, assignee_id=None, + project_name=None, assignee_name=None, recipient_user_ids=None): + """ + Send notification for task creation using role-based recipients with detailed context. + + Args: + task_id: Task ID + task_name: Task name + project_id: Project ID + created_by_user_id: User who created the task + assignee_id: User assigned to the task (if any) + project_name: Name of the project (fetched if None) + assignee_name: Name of the assignee (fetched if None) + recipient_user_ids: Explicit list of user IDs to notify (if None, calculates from recipients module) + """ + # Fetch project name if not provided + if project_name is None and project_id: + try: + from ..db.models import Project + project = Project.query.get(project_id) + project_name = project.name if project else None + except Exception: + project_name = None + + # Fetch assignee name if not provided + if assignee_name is None and assignee_id: + try: + from ..db.models import User + assignee = User.query.get(assignee_id) + assignee_name = assignee.name if assignee else None + except Exception: + assignee_name = None + + if recipient_user_ids is None: + from .notification_recipients import get_recipients_for_task_create + recipient_user_ids = get_recipients_for_task_create( + task_id=task_id, + project_id=project_id, + creator_id=created_by_user_id, + assignee_id=assignee_id + ) + + # Build context-rich message + project_context = f" in {project_name}" if project_name else "" + assignee_context = f" assigned to {assignee_name}" if assignee_name else "" + message = f'New task "{task_name}"{project_context}{assignee_context}' + + return NotificationService.send_to_recipients( + recipient_user_ids=recipient_user_ids, + notification_type='task_created', + title='New Task Created', + message=message, + reference_id=task_id, + task_id=task_id + ) + + @staticmethod + def task_updated_notification_v2(task_id, task_name, project_id, updated_by_user_id, assignee_id=None, + changed_fields=None, project_name=None, recipient_user_ids=None): + """ + Send notification for task updates using role-based recipients with specific change details. + + Args: + task_id: Task ID + task_name: Task name + project_id: Project ID + updated_by_user_id: User who updated the task + assignee_id: Current assignee of the task (if any) + changed_fields: Dict with field names as keys and (old_value, new_value) tuples as values + e.g. {'status': ('todo', 'in_progress'), 'priority': ('low', 'high')} + project_name: Name of the project (fetched if None) + recipient_user_ids: Explicit list of user IDs to notify (if None, calculates from recipients module) + """ + # Fetch project name if not provided + if project_name is None and project_id: + try: + from ..db.models import Project + project = Project.query.get(project_id) + project_name = project.name if project else None + except Exception: + project_name = None + + if recipient_user_ids is None: + from .notification_recipients import get_recipients_for_task_update + recipient_user_ids = get_recipients_for_task_update( + task_id=task_id, + project_id=project_id, + updater_id=updated_by_user_id, + assignee_id=assignee_id + ) + + # Build specific message about what changed + project_context = f" in {project_name}" if project_name else "" + + if changed_fields: + # Get the most important change to highlight + important_fields = ['status', 'assigned_to', 'deadline', 'priority'] + main_change = None + + for field in important_fields: + if field in changed_fields: + old_val, new_val = changed_fields[field] + if field == 'status': + main_change = f"status changed to {new_val}" + elif field == 'assigned_to': + # Try to get assignee name + try: + from ..db.models import User + assignee = User.query.get(new_val) if new_val else None + assignee_name = assignee.name if assignee else "someone" + except Exception: + assignee_name = "someone" + main_change = f"assigned to {assignee_name}" + elif field == 'deadline': + main_change = f"deadline updated to {new_val}" + elif field == 'priority': + main_change = f"priority set to {new_val}" + break + + if main_change: + message = f'Task "{task_name}"{project_context} - {main_change}' + else: + # If no important field changed, list what did + changed_list = ', '.join(changed_fields.keys()) + message = f'Task "{task_name}"{project_context} updated ({changed_list})' + else: + message = f'Task "{task_name}"{project_context} was updated' + + return NotificationService.send_to_recipients( + recipient_user_ids=recipient_user_ids, + notification_type='task_updated', + title='Task Updated', + message=message, + reference_id=task_id, + task_id=task_id + ) + + @staticmethod + def user_crud_notification(action_type, affected_user_name, affected_user_role=None, + changed_fields=None, admin_user_id=None, recipient_user_ids=None): + """ + Send notification for user CRUD operations with specific details. + + Args: + action_type: 'user_created', 'user_updated', 'user_deleted', 'user_role_changed' + affected_user_name: Name of the user being affected + affected_user_role: Role of the user (for create/role_change) + changed_fields: Dict with what was changed (for updates) + admin_user_id: User ID performing the action (excluded from recipients) + recipient_user_ids: Explicit list of user IDs to notify (if None, gets all admins) + """ + if recipient_user_ids is None: + from .notification_recipients import get_recipients_for_user_crud + recipient_user_ids = get_recipients_for_user_crud(action_type, None) + + # Exclude the admin who performed the action + if admin_user_id: + recipient_user_ids = [uid for uid in recipient_user_ids if uid != admin_user_id] + + action_titles = { + 'user_created': 'New User Created', + 'user_updated': 'User Updated', + 'user_deleted': 'User Deleted', + 'user_role_changed': 'User Role Changed' + } + + # Build specific messages + if action_type == 'user_created': + role_info = f" as {affected_user_role}" if affected_user_role else "" + action_messages = f'New user "{affected_user_name}"{role_info} created' + elif action_type == 'user_role_changed': + role_info = f" to {affected_user_role}" if affected_user_role else "" + action_messages = f'User "{affected_user_name}" role changed{role_info}' + elif action_type == 'user_updated': + if changed_fields: + changed_list = ', '.join(changed_fields.keys()) + action_messages = f'User "{affected_user_name}" updated ({changed_list})' + else: + action_messages = f'User "{affected_user_name}" was updated' + elif action_type == 'user_deleted': + action_messages = f'User "{affected_user_name}" was deleted' + else: + action_messages = f'User operation on "{affected_user_name}"' + + return NotificationService.send_to_recipients( + recipient_user_ids=recipient_user_ids, + notification_type=action_type, + title=action_titles.get(action_type, 'User Operation'), + message=action_messages, + reference_id=None + ) + + @staticmethod + def report_available_notification(report_id, report_type, project_id, creator_id, recipient_user_ids=None): + """ + Send notification for report availability. + + Args: + report_id: Report ID + report_type: Type of report ('tasks', 'developers', 'github', etc.) + project_id: Project ID the report is for + creator_id: User who created the report + recipient_user_ids: Explicit list of user IDs to notify (if None, calculates from recipients module) + """ + if recipient_user_ids is None: + from .notification_recipients import get_recipients_for_report_available + recipient_user_ids = get_recipients_for_report_available( + project_id=project_id, + creator_id=creator_id + ) + + return NotificationService.send_to_recipients( + recipient_user_ids=recipient_user_ids, + notification_type='report_available', + title='Report Available for Download', + message=f'Your {report_type} report is ready to download', + reference_id=report_id + ) + + @staticmethod + def project_member_added_notification(project_id, new_member_name, new_member_id, adder_id, recipient_user_ids=None): + """ + Send notification when a new member is added to a project. + + Args: + project_id: Project ID + new_member_name: Name of the new member + new_member_id: User ID of the new member + adder_id: User ID of the person adding the member + recipient_user_ids: Explicit list of user IDs to notify + """ + if recipient_user_ids is None: + from .notification_recipients import get_recipients_for_project_member_add + recipient_user_ids = get_recipients_for_project_member_add( + project_id=project_id, + new_member_id=new_member_id, + adder_id=adder_id + ) + + return NotificationService.send_to_recipients( + recipient_user_ids=recipient_user_ids, + notification_type='project_member_added', + title='New Member Added to Project', + message=f'{new_member_name} was added to your project', + reference_id=project_id + ) diff --git a/frontend/src/pages/TaskList.jsx b/frontend/src/pages/TaskList.jsx index 0737963..fba89dc 100644 --- a/frontend/src/pages/TaskList.jsx +++ b/frontend/src/pages/TaskList.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { taskService, userService } from '../services/utils/api'; +import { taskService, userService, projectService } from '../services/utils/api'; import { useAuth } from '../context/AuthContext'; import LoadingSpinner from '../components/LoadingSpinner'; @@ -12,6 +12,7 @@ const TaskList = () => { const [updating, setUpdating] = useState(false); const [error, setError] = useState(null); const [userMap, setUserMap] = useState({}); + const [projectMap, setProjectMap] = useState({}); const [filters, setFilters] = useState({ status: 'all', priority: 'all', @@ -26,7 +27,7 @@ const TaskList = () => { // Fetch users and tasks once; honor deep-link assignee query if provided useEffect(() => { - const fetchUsersAndTasks = async () => { + const fetchUsersAndProjects = async () => { try { const usersData = await userService.getAllUsers(); const users = Array.isArray(usersData?.users) ? usersData.users : Array.isArray(usersData) ? usersData : []; @@ -35,15 +36,23 @@ const TaskList = () => { userById[user.id] = user.name; }); setUserMap(userById); + + const projectsData = await projectService.getAllProjects(); + const projects = Array.isArray(projectsData?.projects) ? projectsData.projects : Array.isArray(projectsData) ? projectsData : []; + const projectById = {}; + projects.forEach((project) => { + projectById[project.id] = project.name; + }); + setProjectMap(projectById); } catch (err) { - console.error('Failed to fetch users:', err); + console.error('Failed to fetch users or projects:', err); } }; const initialUrlParams = new URLSearchParams(window.location.search); const initialAssignee = initialUrlParams.get('assigned_to') || initialUrlParams.get('assignee'); - fetchUsersAndTasks(); + fetchUsersAndProjects(); if (initialAssignee) { setFilters((prev) => ({ ...prev, scope: 'my' })); fetchTasks({ assigned_to: initialAssignee }); @@ -345,6 +354,9 @@ const TaskList = () => { Assignee + + Project + Progress @@ -392,6 +404,11 @@ const TaskList = () => { {task.assignee?.name || task.assigned_to_name || (task.assigned_to ? userMap[task.assigned_to] || `User #${task.assigned_to}` : '—')}
+ +
+ {task.project_name || task.project?.name || (task.project_id ? projectMap[task.project_id] || `Project #${task.project_id}` : '—')} +
+
From 1de76bf13f380bc07b67b3c01dd7ae14cdf0b015 Mon Sep 17 00:00:00 2001 From: Ahmed Ikram <2571642@dundee.ac.uk> Date: Fri, 8 May 2026 12:49:35 +0100 Subject: [PATCH 04/12] feat: Enhance task filtering and sorting options in TaskList component --- .../api/controllers/dashboard_controller.py | 2 - .../src/api/controllers/tasks_controller.py | 4 +- .../src/services/notification_recipients.py | 5 +- backend/src/services/notification_service.py | 10 +- .../unit/controllers/test_users_controller.py | 6 +- frontend/src/pages/BasicDashboard.jsx | 23 ++-- frontend/src/pages/TaskList.jsx | 101 +++++++++++++++++- frontend/src/tests/pages/TaskList.test.jsx | 5 +- 8 files changed, 123 insertions(+), 33 deletions(-) diff --git a/backend/src/api/controllers/dashboard_controller.py b/backend/src/api/controllers/dashboard_controller.py index e88291a..ceebcbf 100644 --- a/backend/src/api/controllers/dashboard_controller.py +++ b/backend/src/api/controllers/dashboard_controller.py @@ -278,8 +278,6 @@ def get_client_dashboard(): key=lambda task: getattr(task, 'updated_at', None) or getattr(task, 'created_at', None) or datetime.min, reverse=True, ) - if not is_team_lead: - recent_tasks = recent_tasks[:10] task_stats = { 'total': len(scoped_tasks), diff --git a/backend/src/api/controllers/tasks_controller.py b/backend/src/api/controllers/tasks_controller.py index 398f362..2172f15 100644 --- a/backend/src/api/controllers/tasks_controller.py +++ b/backend/src/api/controllers/tasks_controller.py @@ -219,10 +219,10 @@ def create_new_task(): assignee_name = None try: if new_task.project_id: - project = Project.query.get(new_task.project_id) + project = db.session.get(Project, new_task.project_id) project_name = project.name if project else None if new_task.assigned_to: - assignee = User.query.get(new_task.assigned_to) + assignee = db.session.get(User, new_task.assigned_to) assignee_name = assignee.name if assignee else None except Exception: pass diff --git a/backend/src/services/notification_recipients.py b/backend/src/services/notification_recipients.py index 3473166..b3793ec 100644 --- a/backend/src/services/notification_recipients.py +++ b/backend/src/services/notification_recipients.py @@ -4,6 +4,7 @@ """ from ..db.models import Task, Project, User +from ..db.db_connection import db from .task_rules import get_project_scope_ids @@ -22,7 +23,7 @@ def get_tls_for_project(project_id): return [] try: - project = Project.query.get(project_id) + project = db.session.get(Project, project_id) if not project: return [] @@ -50,7 +51,7 @@ def get_admins_for_project(project_id): return [] try: - project = Project.query.get(project_id) + project = db.session.get(Project, project_id) if not project: return [] diff --git a/backend/src/services/notification_service.py b/backend/src/services/notification_service.py index 595d649..87bd6d7 100644 --- a/backend/src/services/notification_service.py +++ b/backend/src/services/notification_service.py @@ -359,8 +359,7 @@ def task_created_notification_v2(task_id, task_name, project_id, created_by_user # Fetch project name if not provided if project_name is None and project_id: try: - from ..db.models import Project - project = Project.query.get(project_id) + project = db.session.get(Project, project_id) project_name = project.name if project else None except Exception: project_name = None @@ -369,7 +368,7 @@ def task_created_notification_v2(task_id, task_name, project_id, created_by_user if assignee_name is None and assignee_id: try: from ..db.models import User - assignee = User.query.get(assignee_id) + assignee = db.session.get(User, assignee_id) assignee_name = assignee.name if assignee else None except Exception: assignee_name = None @@ -417,8 +416,7 @@ def task_updated_notification_v2(task_id, task_name, project_id, updated_by_user # Fetch project name if not provided if project_name is None and project_id: try: - from ..db.models import Project - project = Project.query.get(project_id) + project = db.session.get(Project, project_id) project_name = project.name if project else None except Exception: project_name = None @@ -449,7 +447,7 @@ def task_updated_notification_v2(task_id, task_name, project_id, updated_by_user # Try to get assignee name try: from ..db.models import User - assignee = User.query.get(new_val) if new_val else None + assignee = db.session.get(User, new_val) if new_val else None assignee_name = assignee.name if assignee else "someone" except Exception: assignee_name = "someone" diff --git a/backend/tests/unit/controllers/test_users_controller.py b/backend/tests/unit/controllers/test_users_controller.py index 2ad2f39..3267564 100644 --- a/backend/tests/unit/controllers/test_users_controller.py +++ b/backend/tests/unit/controllers/test_users_controller.py @@ -83,7 +83,7 @@ def test_get_user_by_id(app, mock_db, mock_user): assert data['user']['name'] == "Test User" assert data['user']['email'] == "test@example.com" -def test_update_user_success(app, mock_db, mock_user): +def test_update_user_success(app, mock_db, mock_user, mock_jwt_identity): with app.test_request_context(json={'name': 'Updated Name', 'email': 'new@example.com'}): with patch('backend.src.api.controllers.users_controller.User.query') as mock_query: # Configure the mock query @@ -104,7 +104,7 @@ def test_update_user_success(app, mock_db, mock_user): assert mock_user.email == 'new@example.com' assert mock_db.session.commit.called -def test_update_user_email_exists(app, mock_db, mock_user): +def test_update_user_email_exists(app, mock_db, mock_user, mock_jwt_identity): with app.test_request_context(json={'email': 'existing@example.com'}): with patch('backend.src.api.controllers.users_controller.User.query') as mock_query: # Configure the mock query - email already taken @@ -125,7 +125,7 @@ def test_update_user_email_exists(app, mock_db, mock_user): assert 'Email already in use' in data['message'] assert status == 409 -def test_delete_user(app, mock_db, mock_user): +def test_delete_user(app, mock_db, mock_user, mock_jwt_identity): with app.test_request_context(): with patch('backend.src.api.controllers.users_controller.User.query') as mock_query: # Configure the mock query diff --git a/frontend/src/pages/BasicDashboard.jsx b/frontend/src/pages/BasicDashboard.jsx index 86b2e49..54e810f 100644 --- a/frontend/src/pages/BasicDashboard.jsx +++ b/frontend/src/pages/BasicDashboard.jsx @@ -5,7 +5,6 @@ import LoadingSpinner from '../components/LoadingSpinner'; import { useAuth } from '../context/AuthContext'; const panelClass = "bg-slate-900/70 border border-slate-800/70 rounded-2xl overflow-hidden shadow-md backdrop-blur-sm"; -const panelHeaderClass = "px-6 py-5 border-b border-slate-800/70 flex justify-between items-center"; const sectionTitleClass = "text-lg font-semibold text-slate-100"; const getCount = (counts, keys, fallback = 0) => { @@ -95,16 +94,14 @@ const BasicDashboard = () => { fetchDashboardData(); }; - // Use full tasks list if provided by API, otherwise fall back to recentTasks - // Filter out completed tasks, limit to last 10 most recent tasks, sorted by most recent first - const orderedTasksToShow = ((dashboardData?.tasks && dashboardData.tasks.length) ? dashboardData.tasks : (dashboardData?.recentTasks || [])) + // Get all tasks, filter out completed, sorted by most recent + const tasksToShow = ((dashboardData?.tasks && dashboardData.tasks.length) ? dashboardData.tasks : (dashboardData?.recentTasks || [])) .filter((task) => !['done', 'completed'].includes((task.status || '').toLowerCase())) .sort((a, b) => { const dateA = new Date(a.updated_at || a.created_at || 0).getTime(); const dateB = new Date(b.updated_at || b.created_at || 0).getTime(); return dateB - dateA; }); - const tasksToShow = isTeamLead ? orderedTasksToShow : orderedTasksToShow.slice(0, 10); return (
@@ -233,14 +230,16 @@ const BasicDashboard = () => {
-
-
-

My Tasks

-

Your latest assigned work, with the same fields you see on the tasks page.

+
+
+
+

My Tasks

+

Your latest assigned work, with the same fields you see on the tasks page.

+
+ + View all tasks +
- - View all tasks -
{tasksToShow.length > 0 ? ( diff --git a/frontend/src/pages/TaskList.jsx b/frontend/src/pages/TaskList.jsx index fba89dc..903fde2 100644 --- a/frontend/src/pages/TaskList.jsx +++ b/frontend/src/pages/TaskList.jsx @@ -17,7 +17,10 @@ const TaskList = () => { status: 'all', priority: 'all', search: '', - scope: 'all' + scope: 'all', + project: 'all', + sortBy: 'recent', + assignee: 'all' }); const canCreateTasks = Boolean(currentUser); @@ -161,6 +164,11 @@ const TaskList = () => { return false; } + // Filter by project + if (filters.project !== 'all' && Number(task.project_id) !== Number(filters.project)) { + return false; + } + // Filter by search text if (filters.search && !task.title.toLowerCase().includes(filters.search.toLowerCase())) { return false; @@ -177,8 +185,30 @@ const TaskList = () => { return false; } } + + // Filter by assignee + if (filters.assignee !== 'all' && Number(task.assigned_to) !== Number(filters.assignee)) { + return false; + } return true; + }).sort((a, b) => { + switch (filters.sortBy) { + case 'deadline': + const deadlineA = a.deadline ? new Date(a.deadline).getTime() : Infinity; + const deadlineB = b.deadline ? new Date(b.deadline).getTime() : Infinity; + return deadlineA - deadlineB; + case 'priority': + const priorityOrder = { high: 1, medium: 2, low: 3 }; + const priorityA = priorityOrder[(a.priority || 'medium').toLowerCase()] || 2; + const priorityB = priorityOrder[(b.priority || 'medium').toLowerCase()] || 2; + return priorityA - priorityB; + case 'progress': + return (b.progress || 0) - (a.progress || 0); + case 'recent': + default: + return (new Date(b.updated_at || b.created_at || 0).getTime()) - (new Date(a.updated_at || a.created_at || 0).getTime()); + } }); if (loading) { @@ -238,7 +268,7 @@ const TaskList = () => { )} {/* Filters */} -
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + {(filters.status !== 'all' || filters.priority !== 'all' || filters.project !== 'all' || filters.search || filters.assignee !== 'all') && ( + + )} +
{/* Scope Toggle for Developers */} @@ -323,9 +416,9 @@ const TaskList = () => {

No tasks found matching your filters

- {filters.status !== 'all' || filters.priority !== 'all' || filters.search ? ( + {filters.status !== 'all' || filters.priority !== 'all' || filters.search || filters.project !== 'all' || filters.assignee !== 'all' ? ( + +
+ ); + }; +}); + +jest.mock('../../context/AuthContext', () => ({ + useAuth: jest.fn(), +})); + +const { useAuth } = require('../../context/AuthContext'); + +describe('TaskDetailsUser page branch coverage', () => { + const mockAdminUser = { + id: 1, + name: 'Admin User', + role: 'admin', + email: 'admin@example.com' + }; + + const mockTeamLeadUser = { + id: 2, + name: 'Team Lead', + role: 'team_lead', + email: 'tl@example.com' + }; + + const mockDeveloperUser = { + id: 3, + name: 'Developer', + role: 'developer', + email: 'dev@example.com' + }; + + const mockTask = { + id: 1, + title: 'Test Task', + description: 'Task description', + status: 'todo', + priority: 'medium', + assigned_to: 3, + project_id: 1, + created_at: '2026-05-01T10:00:00Z', + deadline: '2026-05-15T10:00:00Z', + progress: 50, + github_links: [] + }; + + const mockTaskCompleted = { + ...mockTask, + status: 'done', + progress: 100 + }; + + const mockTaskInProgress = { + ...mockTask, + status: 'in_progress' + }; + + beforeEach(() => { + jest.clearAllMocks(); + global.confirm = jest.fn(() => false); + global.alert = jest.fn(); + + api.taskService = { + getTaskById: jest.fn().mockResolvedValue(mockTask), + getTaskComments: jest.fn().mockResolvedValue([]), + addTaskComment: jest.fn(), + updateTask: jest.fn(), + deleteTask: jest.fn(), + getUsers: jest.fn().mockResolvedValue([]), + getProjects: jest.fn().mockResolvedValue([]) + }; + + api.githubService = { + getTaskGithubLinks: jest.fn().mockResolvedValue([]), + getUserRepos: jest.fn().mockResolvedValue([]), + getIssues: jest.fn().mockResolvedValue([]), + linkTaskToGithub: jest.fn(), + unlinkTaskFromGithub: jest.fn() + }; + }); + + describe('Loading and error states', () => { + test('shows loading spinner while fetching', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + api.taskService.getTaskById.mockImplementation( + () => new Promise(resolve => setTimeout(() => resolve(mockTask), 100)) + ); + + render( + + + } /> + + + ); + + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument(); + }); + }); + + test('shows error message on fetch failure', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + api.taskService.getTaskById.mockRejectedValue(new Error('API Error')); + + render( + + + } /> + + + ); + + await waitFor(() => { + // Check that error handling is invoked - component should show error message + expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument(); + }, { timeout: 2000 }); + }); + + test('shows not found message when task is null', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + api.taskService.getTaskById.mockResolvedValue(null); + + render( + + + } /> + + + ); + + await waitFor(() => { + expect(screen.getByText(/Task not found/i)).toBeInTheDocument(); + }); + }); + + test('back to tasks button on error', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + api.taskService.getTaskById.mockRejectedValue(new Error('API Error')); + + render( + + + } /> + + + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Back to Tasks/i })).toBeInTheDocument(); + }); + }); + }); + + describe('Task display - title and dates', () => { + test('displays task title', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + + render( + + + } /> + + + ); + + await waitFor(() => { + expect(screen.getByText(/Test Task/i)).toBeInTheDocument(); + }); + }); + + test('displays created date', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + + render( + + + } /> + + + ); + + await waitFor(() => { + expect(screen.getByText(/Created:/i)).toBeInTheDocument(); + }); + }); + + test('displays deadline when set', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + + render( + + + } /> + + + ); + + await waitFor(() => { + expect(screen.getByText(/Due:/i)).toBeInTheDocument(); + }); + }); + + test('does not display deadline when not set', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + api.taskService.getTaskById.mockResolvedValue({ + ...mockTask, + deadline: null + }); + + render( + + + } /> + + + ); + + await waitFor(() => { + expect(screen.queryByText(/Due:/i)).not.toBeInTheDocument(); + }); + }); + }); + + describe('Status badges - different states', () => { + test('displays todo status badge', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + + render( + + + } /> + + + ); + + await waitFor(() => { + expect(screen.getByText(/To Do/i)).toBeInTheDocument(); + }); + }); + + test('displays in_progress status badge', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + api.taskService.getTaskById.mockResolvedValue(mockTaskInProgress); + + render( + + + } /> + + + ); + + await waitFor(() => { + expect(screen.getByText(/In Progress/i)).toBeInTheDocument(); + }); + }); + + test('displays completed status badge', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + api.taskService.getTaskById.mockResolvedValue(mockTaskCompleted); + + render( + + + } /> + + + ); + + await waitFor(() => { + expect(screen.getByText(/Completed/i)).toBeInTheDocument(); + }); + }); + }); + + describe('Permissions - delete button visibility', () => { + test('admin can delete any task', async () => { + useAuth.mockReturnValue({ currentUser: mockAdminUser }); + + render( + + + } /> + + + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Delete Task/i })).toBeInTheDocument(); + }); + }); + + test('team lead can delete any task', async () => { + useAuth.mockReturnValue({ currentUser: mockTeamLeadUser }); + + render( + + + } /> + + + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Delete Task/i })).toBeInTheDocument(); + }); + }); + + test('assigned developer can delete their task', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + + render( + + + } /> + + + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Delete Task/i })).toBeInTheDocument(); + }); + }); + + test('unassigned developer cannot delete task', async () => { + useAuth.mockReturnValue({ currentUser: { ...mockDeveloperUser, id: 99 } }); + + render( + + + } /> + + + ); + + await waitFor(() => { + expect(screen.queryByRole('button', { name: /Delete Task/i })).not.toBeInTheDocument(); + }); + }); + }); + + describe('Delete functionality', () => { + test('delete task shows confirmation dialog', async () => { + useAuth.mockReturnValue({ currentUser: mockAdminUser }); + + render( + + + } /> + + + ); + + await waitFor(() => { + const deleteButton = screen.getByRole('button', { name: /Delete Task/i }); + fireEvent.click(deleteButton); + }); + + expect(global.confirm).toHaveBeenCalledWith(expect.stringContaining('Delete this task')); + }); + + test('cancels delete on confirmation dismiss', async () => { + useAuth.mockReturnValue({ currentUser: mockAdminUser }); + global.confirm.mockReturnValue(false); + + render( + + + } /> + + + ); + + await waitFor(() => { + const deleteButton = screen.getByRole('button', { name: /Delete Task/i }); + fireEvent.click(deleteButton); + }); + + expect(api.taskService.deleteTask).not.toHaveBeenCalled(); + }); + + test('deletes task on confirmation', async () => { + useAuth.mockReturnValue({ currentUser: mockAdminUser }); + global.confirm.mockReturnValue(true); + + render( + + + } /> + + + ); + + await waitFor(() => { + const deleteButton = screen.getByRole('button', { name: /Delete Task/i }); + fireEvent.click(deleteButton); + }); + + await waitFor(() => { + expect(api.taskService.deleteTask).toHaveBeenCalledWith('1'); + }); + }); + }); + + describe('Progress tracking', () => { + test('progress bar renders', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + + render( + + + } /> + + + ); + + await waitFor(() => { + expect(screen.getByTestId('progress-bar')).toBeInTheDocument(); + }); + }); + + test('updating progress calls update API', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + + render( + + + } /> + + + ); + + await waitFor(() => { + const progressBar = screen.getByTestId('progress-bar'); + fireEvent.change(progressBar, { target: { value: 75 } }); + }); + + await waitFor(() => { + expect(api.taskService.updateTask).toHaveBeenCalledWith('1', { progress: 75 }); + }); + }); + + test('progress 100% prompts task completion', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + global.confirm.mockReturnValue(false); + + render( + + + } /> + + + ); + + await waitFor(() => { + const progressBar = screen.getByTestId('progress-bar'); + fireEvent.change(progressBar, { target: { value: 100 } }); + }); + + await waitFor(() => { + expect(global.confirm).toHaveBeenCalledWith(expect.stringContaining('completed')); + }); + }); + + test('confirms task completion when progress is 100%', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + global.confirm.mockReturnValue(true); + + render( + + + } /> + + + ); + + await waitFor(() => { + const progressBar = screen.getByTestId('progress-bar'); + fireEvent.change(progressBar, { target: { value: 100 } }); + }); + + await waitFor(() => { + expect(api.taskService.updateTask).toHaveBeenCalledWith('1', expect.objectContaining({ status: 'done' })); + }); + }); + }); + + describe('Task editing - permissions and UI', () => { + test('shows edit capability for assigned user', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + + render( + + + } /> + + + ); + + await waitFor(() => { + expect(api.taskService.getTaskById).toHaveBeenCalledWith('1'); + }); + }); + + test('shows edit capability for admin', async () => { + useAuth.mockReturnValue({ currentUser: mockAdminUser }); + + render( + + + } /> + + + ); + + await waitFor(() => { + expect(api.taskService.getTaskById).toHaveBeenCalled(); + }); + }); + + test('shows edit capability for team lead', async () => { + useAuth.mockReturnValue({ currentUser: mockTeamLeadUser }); + + render( + + + } /> + + + ); + + await waitFor(() => { + expect(api.taskService.getTaskById).toHaveBeenCalled(); + }); + }); + }); + + describe('Comments functionality', () => { + test('fetches comments on load', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + + render( + + + } /> + + + ); + + await waitFor(() => { + expect(api.taskService.getTaskComments).toHaveBeenCalledWith('1'); + }); + }); + + test('displays comments list', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + api.taskService.getTaskComments.mockResolvedValue([ + { id: 1, content: 'Comment 1', author_name: 'User 1' }, + { id: 2, content: 'Comment 2', author_name: 'User 2' } + ]); + + render( + + + } /> + + + ); + + await waitFor(() => { + expect(api.taskService.getTaskComments).toHaveBeenCalled(); + }); + }); + + test('comment submission state is initialized', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + + render( + + + } /> + + + ); + + await waitFor(() => { + // Component renders without error - comment UI is initialized + expect(api.taskService.getTaskComments).toHaveBeenCalled(); + }); + }); + }); + + describe('GitHub integration - repositories', () => { + test('fetches repositories on load', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + + render( + + + } /> + + + ); + + await waitFor(() => { + expect(api.githubService.getUserRepos).toHaveBeenCalled(); + }); + }); + + test('handles repository fetch error gracefully', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + api.githubService.getUserRepos.mockRejectedValue(new Error('API Error')); + + render( + + + } /> + + + ); + + await waitFor(() => { + // Component continues to render despite error + expect(api.taskService.getTaskById).toHaveBeenCalled(); + }); + }); + + test('fetches repositories when linking button is clicked', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + api.githubService.getUserRepos.mockResolvedValueOnce([]); + + render( + + + } /> + + + ); + + await waitFor(() => { + expect(api.githubService.getUserRepos).toHaveBeenCalled(); + }); + }); + }); + + describe('GitHub integration - issues and linking', () => { + test('repository selection state management', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + const mockIssues = [ + { id: 1, number: 1, title: 'Issue 1' } + ]; + api.githubService.getIssues.mockResolvedValue(mockIssues); + + render( + + + } /> + + + ); + + await waitFor(() => { + // Component initializes GitHub state + expect(api.githubService.getUserRepos).toHaveBeenCalled(); + }); + }); + + test('fetches GitHub links for task', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + const mockLinks = [ + { id: 1, repo_name: 'my-repo', issue_number: 123 } + ]; + api.githubService.getTaskGithubLinks.mockResolvedValue(mockLinks); + + render( + + + } /> + + + ); + + await waitFor(() => { + expect(api.githubService.getTaskGithubLinks).toHaveBeenCalledWith('1'); + }); + }); + + test('links task to GitHub issue', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + const mockRepos = [ + { id: 1, full_name: 'user/repo' } + ]; + const mockIssues = [ + { id: 1, number: 123, title: 'Issue 123' } + ]; + api.githubService.getUserRepos.mockResolvedValue(mockRepos); + api.githubService.getIssues.mockResolvedValue(mockIssues); + + render( + + + } /> + + + ); + + await waitFor(() => { + expect(api.githubService.getUserRepos).toHaveBeenCalled(); + }); + }); + + test('GitHub link error handling', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + api.githubService.linkTaskToGithub.mockRejectedValue(new Error('API Error')); + + render( + + + } /> + + + ); + + await waitFor(() => { + // Component initializes and handles GitHub state + expect(api.taskService.getTaskById).toHaveBeenCalled(); + }); + }); + }); + + describe('GitHub link management', () => { + test('displays linked GitHub issues', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + api.githubService.getTaskGithubLinks.mockResolvedValue([ + { id: 1, repo_name: 'my-repo', issue_number: 123 } + ]); + + render( + + + } /> + + + ); + + await waitFor(() => { + expect(api.githubService.getTaskGithubLinks).toHaveBeenCalled(); + }); + }); + + test('removes GitHub link from task', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + api.githubService.getTaskGithubLinks + .mockResolvedValueOnce([{ id: 1, repo_name: 'my-repo', issue_number: 123 }]) + .mockResolvedValueOnce([]); + + render( + + + } /> + + + ); + + await waitFor(() => { + expect(api.githubService.getTaskGithubLinks).toHaveBeenCalled(); + }); + }); + + test('unlink GitHub issue error handling', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + api.githubService.unlinkTaskFromGithub.mockRejectedValue(new Error('API Error')); + + render( + + + } /> + + + ); + + await waitFor(() => { + // Component initializes successfully + expect(api.taskService.getTaskById).toHaveBeenCalled(); + }); + }); + }); + + describe('Global event dispatching', () => { + test('dispatches task-updated event on progress change', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + window.dispatchEvent = jest.fn(); + + render( + + + } /> + + + ); + + await waitFor(() => { + const progressBar = screen.getByTestId('progress-bar'); + fireEvent.change(progressBar, { target: { value: 75 } }); + }); + + await waitFor(() => { + expect(window.dispatchEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'devsync:task-updated' }) + ); + }); + }); + + test('dispatches dashboard-updated event on progress change', async () => { + useAuth.mockReturnValue({ currentUser: mockDeveloperUser }); + window.dispatchEvent = jest.fn(); + + render( + + + } /> + + + ); + + await waitFor(() => { + const progressBar = screen.getByTestId('progress-bar'); + fireEvent.change(progressBar, { target: { value: 75 } }); + }); + + await waitFor(() => { + expect(window.dispatchEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'devsync:dashboard-updated' }) + ); + }); + }); + }); +}); diff --git a/frontend/src/tests/services/utils/api.test.jsx b/frontend/src/tests/services/utils/api.test.jsx index d09a0be..3d38c05 100644 --- a/frontend/src/tests/services/utils/api.test.jsx +++ b/frontend/src/tests/services/utils/api.test.jsx @@ -1,7 +1,34 @@ import { + dashboardService, + fetchWithAuth, + githubService, + notificationService, + projectService, + reportService, + taskService, normalizeTaskReportDetails, } from '../../../services/utils/api'; +const makeResponse = ({ + status = 200, + ok = true, + body = {}, + headers = {}, +} = {}) => ({ + status, + ok, + headers: { + get: (name) => { + if (name === 'content-type' || name === 'Content-Type') { + return headers['content-type'] || headers['Content-Type'] || 'application/json'; + } + + return headers[name] ?? headers[name.toLowerCase()] ?? null; + }, + }, + json: jest.fn().mockResolvedValue(body), +}); + describe('normalizeTaskReportDetails', () => { test('hydrates assignee_name from the user list when task rows only expose assigned_to ids', () => { const tasks = [ @@ -83,3 +110,245 @@ describe('normalizeTaskReportDetails', () => { expect(normalized[0].customField).toBe('preserved'); }); }); + +describe('api service branches', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + localStorage.clear(); + jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + global.fetch = jest.fn(); + dashboardService.clearReportDataCache(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + global.fetch = originalFetch; + }); + + test('fetchWithAuth removes corrupted user data and still performs the request', async () => { + const removeItemSpy = jest.spyOn(Storage.prototype, 'removeItem'); + const getItemSpy = jest.spyOn(Storage.prototype, 'getItem').mockReturnValue('not-json'); + global.fetch.mockResolvedValue(makeResponse({ body: { ok: true } })); + + const result = await fetchWithAuth('tasks'); + + expect(result).toEqual({ ok: true }); + expect(getItemSpy).toHaveBeenCalledWith('user'); + expect(removeItemSpy).toHaveBeenCalledWith('user'); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + test('fetchWithAuth returns an empty object for no-content responses', async () => { + global.fetch.mockResolvedValue(makeResponse({ status: 204, ok: true })); + + await expect(fetchWithAuth('tasks/1')).resolves.toEqual({}); + }); + + test('fetchWithAuth surfaces rate limit errors with retryAfter', async () => { + global.fetch.mockResolvedValue(makeResponse({ + status: 429, + ok: false, + headers: { 'Retry-After': '15' }, + })); + + await expect(fetchWithAuth('tasks')).rejects.toMatchObject({ + status: 429, + retryAfter: 15, + }); + }); + + test('notificationService returns an empty list on non-critical auth errors', async () => { + global.fetch.mockResolvedValue(makeResponse({ status: 401, ok: false })); + + await expect(notificationService.getNotifications()).resolves.toEqual([]); + }); + + test('github OAuth initiation surfaces API error details', async () => { + global.fetch.mockResolvedValue(makeResponse({ + status: 400, + ok: false, + body: { message: 'GitHub said no' }, + })); + + await expect(githubService.initiateOAuthFlow()).rejects.toThrow('GitHub said no'); + }); + + test('taskService builds query strings and normalizes response shapes', async () => { + global.fetch.mockResolvedValue(makeResponse({ body: { tasks: [{ id: 1 }] } })); + + const tasks = await taskService.getAllTasks({ assigned_to: 5, status: 'todo' }); + + expect(tasks).toEqual([{ id: 1 }]); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/tasks?assigned_to=5&status=todo'), + expect.objectContaining({ credentials: 'include' }) + ); + }); + + test('taskService falls back to an empty array on fetch failure', async () => { + global.fetch.mockRejectedValue(new TypeError('Failed to fetch')); + + await expect(taskService.getAllTasks()).resolves.toEqual([]); + }); + + test('projectService falls back to null and empty arrays on errors', async () => { + global.fetch.mockRejectedValue(new Error('boom')); + + await expect(projectService.getProjectById(9)).resolves.toBeNull(); + await expect(projectService.getAllProjects()).resolves.toEqual([]); + }); + + test('dashboardService normalizes admin task metrics and uses fallback data on failure', async () => { + global.fetch.mockResolvedValue(makeResponse({ + body: { + tasks: { + backlog: '1', + todo: '2', + in_progress: '3', + review: '4', + done: '5', + }, + projects: { total: 9 }, + users: { total: 6 }, + recentProjects: [{ id: 1 }], + }, + })); + + const stats = await dashboardService.getAdminDashboardStats('week'); + + expect(stats.tasks.total).toBe(15); + expect(stats.tasks.active).toBe(9); + expect(stats.tasks.completed).toBe(5); + + global.fetch.mockRejectedValueOnce(new Error('boom')); + await expect(dashboardService.getAdminDashboardStats('week')).resolves.toEqual({ + projects: { total: 0 }, + tasks: { active: 0, completed: 0 }, + users: { total: 0 }, + recentProjects: [], + }); + }); + + test('dashboardService returns developer progress for the full trackable role set', async () => { + const usersResponse = { users: [ + { id: 1, name: 'Admin', role: 'admin' }, + { id: 2, name: 'Dev', role: 'developer' }, + { id: 3, name: 'TL', role: 'team_lead' }, + { id: 4, name: 'Guest', role: 'viewer' }, + ] }; + + jest.spyOn(taskService, 'getAllTasks').mockResolvedValue([ + { id: 10, assigned_to: 1, status: 'done', updated_at: '2026-01-01T00:00:00.000Z' }, + { id: 11, assigned_to: 2, status: 'in_progress', updated_at: '2026-01-02T00:00:00.000Z' }, + { id: 12, assigned_to: 3, status: 'completed', updated_at: '2026-01-03T00:00:00.000Z' }, + { id: 13, assigned_to: 4, status: 'todo', updated_at: '2026-01-04T00:00:00.000Z' }, + ]); + jest.spyOn(projectService, 'getAllProjects').mockResolvedValue([]); + global.fetch.mockResolvedValue(makeResponse({ body: usersResponse })); + + const progress = await dashboardService.getDeveloperProgressStats({ currentUser: { id: 2, role: 'developer' } }); + + expect(progress).toEqual([ + expect.objectContaining({ id: 1, role: 'admin', total_tasks: 1, completed_tasks: 1 }), + expect.objectContaining({ id: 2, role: 'developer', total_tasks: 1, completed_tasks: 0 }), + expect.objectContaining({ id: 3, role: 'team_lead', total_tasks: 1, completed_tasks: 1 }), + ]); + }); + + test('githubService normalizes repository payloads and handles rate limit helpers', async () => { + global.fetch.mockResolvedValue(makeResponse({ + body: { + repositories: [ + { + name: 'devsync', + open_issues_count: '3', + total_prs: '4', + recent_commits: '5', + pushed_at: '2026-01-01T00:00:00.000Z', + }, + ], + }, + })); + + const repos = await githubService.getUserRepos({ fetchAll: true, activityWindowDays: 30, perPage: 100 }); + + expect(repos[0]).toMatchObject({ + name: 'devsync', + open_issues: 3, + open_issues_count: 3, + total_prs: 4, + recent_commits: 5, + last_updated: '2026-01-01T00:00:00.000Z', + }); + + expect(githubService.handleRateLimitError({ status: 403, data: { message: 'rate limit exceeded', documentation_url: 'https://docs.github.com' } })).toMatchObject({ + title: 'GitHub API Rate Limit Exceeded', + documentationUrl: 'https://docs.github.com', + }); + expect(githubService.handleRateLimitError({ status: 429, retryAfter: 90 })).toMatchObject({ + retryAfter: 90, + }); + }); + + test('reportService saves, fetches, and deletes reports', async () => { + global.fetch.mockResolvedValue(makeResponse({ body: { report: { id: 'r1' }, reports: [{ id: 'r1' }] } })); + + await expect(reportService.saveReport('tasks', 'week', { total: 1 }, [])).resolves.toHaveProperty('report.id', 'r1'); + await expect(reportService.getSavedReports({ type: 'tasks' })).resolves.toHaveProperty('reports'); + await expect(reportService.deleteReport('r1')).resolves.toHaveProperty('report.id', 'r1'); + }); + + test('dashboard report data caches GitHub responses and can be refreshed', async () => { + jest.spyOn(githubService, 'getUserRepos').mockResolvedValue([ + { name: 'repo', open_issues: 1, total_prs: 2, recent_commits: 3 }, + ]); + global.fetch.mockResolvedValue(makeResponse({ body: { connected: true } })); + + const first = await dashboardService.getReportData('github', 'week'); + const second = await dashboardService.getReportData('github', 'week'); + const refreshed = await dashboardService.refreshReportData('github', 'week'); + + expect(first.meta.cache_hit).toBe(false); + expect(second.meta.cache_hit).toBe(true); + expect(refreshed.meta.live).toBe(true); + expect(githubService.getUserRepos).toHaveBeenCalledWith(expect.objectContaining({ + perPage: 100, + fetchAll: true, + activityWindowDays: 7, + })); + }); + + test('dashboard report data builds task and developer summaries', async () => { + jest.spyOn(taskService, 'getAllTasks').mockResolvedValue([ + { id: 1, title: 'A', status: 'done', assigned_to: 1, created_at: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString() }, + { id: 2, title: 'B', status: 'in_progress', assigned_to: 2, created_at: new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(), deadline: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() }, + { id: 3, title: 'C', status: 'completed', assigned_to: 2, created_at: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString() }, + ]); + global.fetch.mockResolvedValue(makeResponse({ body: { users: [ + { id: 1, name: 'Admin', role: 'admin' }, + { id: 2, name: 'Dev', role: 'developer' }, + ] } })); + + const taskReport = await dashboardService.getReportData('tasks', 'week'); + const developerReport = await dashboardService.getReportData('developers', 'week'); + + expect(taskReport.summary.total).toBe(3); + expect(taskReport.summary.completed).toBe(2); + expect(taskReport.summary.in_progress).toBe(1); + expect(developerReport.summary.developers).toBe(2); + expect(developerReport.details).toHaveLength(2); + }); + + test('reportService uses distinct fetch responses for save, list, and delete', async () => { + global.fetch + .mockResolvedValueOnce(makeResponse({ body: { report: { id: 'r1', type: 'tasks' } } })) + .mockResolvedValueOnce(makeResponse({ body: { reports: [{ id: 'r1' }] } })) + .mockResolvedValueOnce(makeResponse({ body: { success: true } })); + + await expect(reportService.saveReport('tasks', 'week', { total: 1 }, [])).resolves.toEqual({ report: { id: 'r1', type: 'tasks' } }); + await expect(reportService.getSavedReports({ type: 'tasks' })).resolves.toEqual({ reports: [{ id: 'r1' }] }); + await expect(reportService.deleteReport('r1')).resolves.toEqual({ success: true }); + }); +}); diff --git a/frontend/src/tests/services/utils/api_extra.test.js b/frontend/src/tests/services/utils/api_extra.test.js new file mode 100644 index 0000000..1d4dd12 --- /dev/null +++ b/frontend/src/tests/services/utils/api_extra.test.js @@ -0,0 +1,49 @@ +import * as api from '../../../services/utils/api'; + +describe('date helpers', () => { + test('getDateRangeStart returns recent dates for week and month', () => { + const week = api.getDateRangeStart('week'); + const month = api.getDateRangeStart('month'); + expect(week instanceof Date).toBe(true); + expect(month instanceof Date).toBe(true); + expect(month.getTime()).toBeLessThan(new Date().getTime()); + }); + + test('getActivityWindowDays maps ranges', () => { + expect(api.getActivityWindowDays('week')).toBe(7); + expect(api.getActivityWindowDays('month')).toBe(30); + expect(api.getActivityWindowDays('year')).toBe(365); + }); + + test('isWithinDateRange handles invalid dates', () => { + expect(api.isWithinDateRange(null, new Date())).toBe(true); + expect(api.isWithinDateRange('invalid-date', new Date())).toBe(true); + }); +}); + +describe('fetchWithAuth branches', () => { + const originalFetch = global.fetch; + afterEach(() => { + global.fetch = originalFetch; + localStorage.clear(); + jest.clearAllMocks(); + }); + + test('returns empty object on 204', async () => { + global.fetch = jest.fn(() => Promise.resolve({ status: 204, headers: new Map(), ok: true })); + const res = await api.fetchWithAuth('/test'); + expect(res).toEqual({}); + }); + + test('non-critical 401 returns graceful object', async () => { + global.fetch = jest.fn(() => Promise.resolve({ status: 401, headers: { get: () => 'application/json' }, ok: false })); + const res = await api.fetchWithAuth('/notifications'); + expect(res).toHaveProperty('isAuthError', true); + }); + + test('429 throws rate limit error with retryAfter', async () => { + const headers = { get: () => '10' }; + global.fetch = jest.fn(() => Promise.resolve({ status: 429, headers, ok: false })); + await expect(api.fetchWithAuth('/test')).rejects.toMatchObject({ status: 429 }); + }); +}); diff --git a/frontend/src/tests/services/utils/api_more.test.js b/frontend/src/tests/services/utils/api_more.test.js new file mode 100644 index 0000000..cb12e0a --- /dev/null +++ b/frontend/src/tests/services/utils/api_more.test.js @@ -0,0 +1,41 @@ +import { fetchWithAuth } from '../../../services/utils/api'; + +describe('fetchWithAuth additional branches', () => { + const originalFetch = global.fetch; + const originalLocation = window.location; + + beforeEach(() => { + delete window.location; + window.location = { href: '' }; + }); + + afterEach(() => { + global.fetch = originalFetch; + window.location = originalLocation; + jest.clearAllMocks(); + localStorage.clear(); + }); + + test('handles 400 GitHub endpoint with JSON error', async () => { + const body = { message: 'bad request' }; + global.fetch = jest.fn(() => Promise.resolve({ + status: 400, + headers: { get: () => 'application/json' }, + ok: false, + json: () => Promise.resolve(body) + })); + + await expect(fetchWithAuth('/github/callback')).rejects.toMatchObject({ isGitHubError: true }); + }); + + test('403 redirects to forbidden page', async () => { + global.fetch = jest.fn(() => Promise.resolve({ status: 403, headers: { get: () => 'application/json' }, ok: false })); + await expect(fetchWithAuth('/some-endpoint')).rejects.toMatchObject({ status: 403 }); + expect(window.location.href).toBe('/forbidden'); + }); + + test('non-json error response returns graceful non-critical object', async () => { + global.fetch = jest.fn(() => Promise.resolve({ status: 500, headers: { get: () => null }, ok: false, text: () => Promise.resolve('err') })); + await expect(fetchWithAuth('/notifications')).resolves.toMatchObject({ error: expect.any(String) }); + }); +}); diff --git a/frontend/src/tests/services/utils/auth.branches.test.js b/frontend/src/tests/services/utils/auth.branches.test.js new file mode 100644 index 0000000..ec38412 --- /dev/null +++ b/frontend/src/tests/services/utils/auth.branches.test.js @@ -0,0 +1,498 @@ +import * as authApi from '../../../services/utils/auth'; + +describe('auth.js utility functions', () => { + beforeEach(() => { + localStorage.clear(); + jest.clearAllMocks(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + describe('fetchWrapper branch coverage', () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + test('fetchWrapper handles successful JSON response', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ success: true, data: 'test' }) + }) + ); + + // Test via register which uses fetchWrapper + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ user: { id: 1, email: 'test@test.com', token: 'abc' } }) + }) + ); + + const result = await authApi.authApi.register({ email: 'test@test.com', password: 'pass' }); + expect(result.user).toBeDefined(); + expect(localStorage.getItem('user')).toBeTruthy(); + }); + + test('fetchWrapper handles JSON parse failure by returning empty object', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.reject(new Error('JSON parse failed')) + }) + ); + + const result = await authApi.authApi.register({ email: 'test@test.com', password: 'pass' }); + // When JSON parse fails but response is ok, it returns empty object + expect(result).toEqual({}); + expect(localStorage.getItem('user')).toBeNull(); + }); + + test('fetchWrapper throws error on non-ok response with message', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + json: () => Promise.resolve({ message: 'Invalid credentials' }) + }) + ); + + await expect( + authApi.authApi.login({ email: 'test@test.com', password: 'wrong' }) + ).rejects.toThrow('Invalid credentials'); + }); + + test('fetchWrapper throws error on non-ok response without message', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + json: () => Promise.resolve({}) + }) + ); + + await expect( + authApi.authApi.login({ email: 'test@test.com', password: 'wrong' }) + ).rejects.toThrow('API request failed'); + }); + + test('fetchWrapper attaches error data and status', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 400, + json: () => Promise.resolve({ message: 'Bad request', field: 'email' }) + }) + ); + + try { + await authApi.authApi.login({ email: 'invalid', password: 'pass' }); + } catch (error) { + expect(error.status).toBe(400); + expect(error.data.field).toBe('email'); + } + }); + }); + + describe('register branch coverage', () => { + test('register stores user on success', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ user: { id: 1, email: 'new@test.com', token: 'abc' } }) + }) + ); + + const result = await authApi.authApi.register({ email: 'new@test.com', password: 'pass123' }); + expect(result.user.id).toBe(1); + expect(JSON.parse(localStorage.getItem('user')).email).toBe('new@test.com'); + }); + + test('register throws on error and logs', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + json: () => Promise.resolve({ message: 'Email already exists' }) + }) + ); + + await expect( + authApi.authApi.register({ email: 'existing@test.com', password: 'pass' }) + ).rejects.toThrow('Email already exists'); + }); + + test('register handles response without user object', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ success: true }) + }) + ); + + const result = await authApi.authApi.register({ email: 'test@test.com', password: 'pass' }); + expect(result.success).toBe(true); + expect(localStorage.getItem('user')).toBeNull(); + }); + }); + + describe('login branch coverage', () => { + test('login stores user with token from data.token', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + user: { id: 1, email: 'user@test.com' }, + token: 'token-abc' + }) + }) + ); + + const result = await authApi.authApi.login({ email: 'user@test.com', password: 'pass' }); + const stored = JSON.parse(localStorage.getItem('user')); + expect(stored.token).toBe('token-abc'); + expect(stored.github_connected).toBe(false); + }); + + test('login stores user with token from data.user.token', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + user: { id: 1, email: 'user@test.com', token: 'nested-token' } + }) + }) + ); + + const result = await authApi.authApi.login({ email: 'user@test.com', password: 'pass' }); + const stored = JSON.parse(localStorage.getItem('user')); + expect(stored.token).toBe('nested-token'); + }); + + test('login includes github_connected and github_username in stored user', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + user: { + id: 1, + email: 'user@test.com', + token: 'abc', + github_connected: true, + github_username: 'johndoe' + } + }) + }) + ); + + await authApi.authApi.login({ email: 'user@test.com', password: 'pass' }); + const stored = JSON.parse(localStorage.getItem('user')); + expect(stored.github_connected).toBe(true); + expect(stored.github_username).toBe('johndoe'); + }); + + test('login handles missing user object in response', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ success: true }) + }) + ); + + const result = await authApi.authApi.login({ email: 'user@test.com', password: 'pass' }); + expect(result.success).toBe(true); + expect(localStorage.getItem('user')).toBeNull(); + }); + + test('login throws on fetch error', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + json: () => Promise.resolve({ message: 'Invalid credentials' }) + }) + ); + + await expect( + authApi.authApi.login({ email: 'user@test.com', password: 'wrong' }) + ).rejects.toThrow(); + }); + }); + + describe('logout branch coverage', () => { + test('logout clears localStorage on success', async () => { + localStorage.setItem('user', JSON.stringify({ id: 1, email: 'test@test.com' })); + + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ success: true }) + }) + ); + + const result = await authApi.authApi.logout(); + expect(result.success).toBe(true); + expect(localStorage.getItem('user')).toBeNull(); + }); + + test('logout clears localStorage even on fetch error', async () => { + localStorage.setItem('user', JSON.stringify({ id: 1, email: 'test@test.com' })); + + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + json: () => Promise.resolve({ message: 'Logout failed' }) + }) + ); + + await expect(authApi.authApi.logout()).rejects.toThrow(); + expect(localStorage.getItem('user')).toBeNull(); + }); + }); + + describe('getCurrentUser branch coverage', () => { + test('getCurrentUser returns null when no user in localStorage', () => { + const user = authApi.authApi.getCurrentUser(); + expect(user).toBeNull(); + }); + + test('getCurrentUser returns parsed user object', () => { + const userData = { id: 1, email: 'test@test.com', name: 'Test User' }; + localStorage.setItem('user', JSON.stringify(userData)); + + const user = authApi.authApi.getCurrentUser(); + expect(user.id).toBe(1); + expect(user.email).toBe('test@test.com'); + }); + + test('getCurrentUser returns null for incomplete user (missing id)', () => { + localStorage.setItem('user', JSON.stringify({ email: 'test@test.com' })); + const user = authApi.authApi.getCurrentUser(); + expect(user).toBeNull(); + }); + + test('getCurrentUser returns null for incomplete user (missing email)', () => { + localStorage.setItem('user', JSON.stringify({ id: 1 })); + const user = authApi.authApi.getCurrentUser(); + expect(user).toBeNull(); + }); + + test('getCurrentUser handles corrupted JSON', () => { + localStorage.setItem('user', 'not valid json'); + const user = authApi.authApi.getCurrentUser(); + expect(user).toBeNull(); + expect(localStorage.getItem('user')).toBeNull(); + }); + + test('getCurrentUser returns null for null user object', () => { + localStorage.setItem('user', JSON.stringify(null)); + const user = authApi.authApi.getCurrentUser(); + expect(user).toBeNull(); + }); + }); + + describe('refreshToken branch coverage', () => { + test('refreshToken updates user token on success', async () => { + const currentUser = { id: 1, email: 'test@test.com', token: 'old-token' }; + localStorage.setItem('user', JSON.stringify(currentUser)); + + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ token: 'new-token' }) + }) + ); + + const result = await authApi.authApi.refreshToken(); + expect(result.token).toBe('new-token'); + expect(JSON.parse(localStorage.getItem('user')).token).toBe('new-token'); + }); + + test('refreshToken uses access_token if token not present', async () => { + const currentUser = { id: 1, email: 'test@test.com', token: 'old-token' }; + localStorage.setItem('user', JSON.stringify(currentUser)); + + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ access_token: 'new-access-token' }) + }) + ); + + const result = await authApi.authApi.refreshToken(); + expect(result.token).toBe('new-access-token'); + }); + + test('refreshToken throws when no current user', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ token: 'new-token' }) + }) + ); + + await expect(authApi.authApi.refreshToken()).rejects.toThrow( + 'Failed to refresh token - no authenticated user' + ); + }); + + test('refreshToken throws when no token in response', async () => { + const currentUser = { id: 1, email: 'test@test.com', token: 'old' }; + localStorage.setItem('user', JSON.stringify(currentUser)); + + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({}) + }) + ); + + await expect(authApi.authApi.refreshToken()).rejects.toThrow( + 'Failed to refresh token - no token in response' + ); + expect(localStorage.getItem('user')).toBeNull(); + }); + + test('refreshToken clears user on 401 error', async () => { + const currentUser = { id: 1, email: 'test@test.com', token: 'old' }; + localStorage.setItem('user', JSON.stringify(currentUser)); + + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 401, + json: () => Promise.resolve({ message: 'Unauthorized' }) + }) + ); + + await expect(authApi.authApi.refreshToken()).rejects.toThrow(); + expect(localStorage.getItem('user')).toBeNull(); + }); + + test('refreshToken does not clear user on non-401 error', async () => { + const currentUser = { id: 1, email: 'test@test.com', token: 'old' }; + localStorage.setItem('user', JSON.stringify(currentUser)); + + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 500, + json: () => Promise.resolve({ message: 'Server error' }) + }) + ); + + await expect(authApi.authApi.refreshToken()).rejects.toThrow(); + expect(localStorage.getItem('user')).toBeTruthy(); + }); + }); + + describe('isTokenExpired branch coverage', () => { + test('isTokenExpired returns true when no user', () => { + const isExpired = authApi.authApi.isTokenExpired(); + expect(isExpired).toBe(true); + }); + + test('isTokenExpired returns true when no token', () => { + localStorage.setItem('user', JSON.stringify({ id: 1, email: 'test@test.com' })); + const isExpired = authApi.authApi.isTokenExpired(); + expect(isExpired).toBe(true); + }); + + test('isTokenExpired returns false when token not expired', () => { + const futureTime = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now + localStorage.setItem('user', JSON.stringify({ + id: 1, + email: 'test@test.com', + token: 'abc', + exp: futureTime + })); + + const isExpired = authApi.authApi.isTokenExpired(); + expect(isExpired).toBe(false); + }); + + test('isTokenExpired returns true when token expired', () => { + const pastTime = Math.floor(Date.now() / 1000) - 600; // 10 min ago + localStorage.setItem('user', JSON.stringify({ + id: 1, + email: 'test@test.com', + token: 'abc', + exp: pastTime + })); + + const isExpired = authApi.authApi.isTokenExpired(); + expect(isExpired).toBe(true); + }); + + test('isTokenExpired returns true when token expires in < 5 min', () => { + const soonExpireTime = Math.floor(Date.now() / 1000) + 200; // 3.3 min + localStorage.setItem('user', JSON.stringify({ + id: 1, + email: 'test@test.com', + token: 'abc', + exp: soonExpireTime + })); + + const isExpired = authApi.authApi.isTokenExpired(); + expect(isExpired).toBe(true); + }); + + test('isTokenExpired returns false when no exp field', () => { + localStorage.setItem('user', JSON.stringify({ + id: 1, + email: 'test@test.com', + token: 'abc' + })); + + const isExpired = authApi.authApi.isTokenExpired(); + expect(isExpired).toBe(false); + }); + + test('isTokenExpired handles parse error gracefully', () => { + localStorage.setItem('user', 'invalid json'); + const isExpired = authApi.authApi.isTokenExpired(); + expect(isExpired).toBe(true); + }); + }); + + describe('updateGitHubStatus branch coverage', () => { + test('updateGitHubStatus updates existing user', () => { + const user = { id: 1, email: 'test@test.com', token: 'abc' }; + localStorage.setItem('user', JSON.stringify(user)); + + const updated = authApi.authApi.updateGitHubStatus(true, 'johndoe'); + expect(updated.github_connected).toBe(true); + expect(updated.github_username).toBe('johndoe'); + expect(JSON.parse(localStorage.getItem('user')).github_connected).toBe(true); + }); + + test('updateGitHubStatus disconnects GitHub', () => { + const user = { id: 1, email: 'test@test.com', github_connected: true, github_username: 'old' }; + localStorage.setItem('user', JSON.stringify(user)); + + const updated = authApi.authApi.updateGitHubStatus(false); + expect(updated.github_connected).toBe(false); + }); + + test('updateGitHubStatus preserves existing username when not provided', () => { + const user = { id: 1, email: 'test@test.com', github_username: 'existing' }; + localStorage.setItem('user', JSON.stringify(user)); + + const updated = authApi.authApi.updateGitHubStatus(true); + expect(updated.github_username).toBe('existing'); + }); + + test('updateGitHubStatus returns null when no user', () => { + const result = authApi.authApi.updateGitHubStatus(true, 'user'); + expect(result).toBeNull(); + }); + + test('updateGitHubStatus sets empty string for username default', () => { + const user = { id: 1, email: 'test@test.com' }; + localStorage.setItem('user', JSON.stringify(user)); + + const updated = authApi.authApi.updateGitHubStatus(true, ''); + expect(updated.github_username).toBe(''); + }); + }); +}); diff --git a/frontend/src/tests/services/utils/fetchWithAuth.branches.test.js b/frontend/src/tests/services/utils/fetchWithAuth.branches.test.js new file mode 100644 index 0000000..fe965f2 --- /dev/null +++ b/frontend/src/tests/services/utils/fetchWithAuth.branches.test.js @@ -0,0 +1,182 @@ +import { fetchWithAuth } from '../../../services/utils/api'; + +describe('fetchWithAuth additional branches', () => { + const originalFetch = global.fetch; + const originalLocation = global.window.location; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + afterEach(() => { + global.fetch = originalFetch; + // restore location without throwing + Object.defineProperty(global.window, 'location', { + value: originalLocation, + writable: true + }); + localStorage.clear(); + }); + + test('returns connection error object when fetch fails with Failed to fetch', async () => { + const err = new Error('Failed to fetch'); + err.name = 'TypeError'; + global.fetch = jest.fn(() => Promise.reject(err)); + + const res = await fetchWithAuth('/test'); + expect(res).toHaveProperty('isConnectionError', true); + expect(res.error).toMatch(/Server connection failed/); + }); + + test('rejects with timeout error when fetch takes too long', async () => { + // fetch that never resolves + global.fetch = jest.fn(() => new Promise(() => {})); + + await expect(fetchWithAuth('/test', { timeout: 1 })).rejects.toThrow('Request timeout'); + }); + + test('returns empty object for non-JSON successful responses', async () => { + const resp = { + status: 200, + ok: true, + headers: { get: () => 'text/html; charset=utf-8' }, + }; + global.fetch = jest.fn(() => Promise.resolve(resp)); + + const res = await fetchWithAuth('/test'); + expect(res).toEqual({}); + }); + + test('non-critical endpoint returns graceful object on server error', async () => { + const headers = { get: () => 'application/json' }; + const resp = { + status: 500, + ok: false, + headers, + json: async () => ({ message: 'server error' }) + }; + + global.fetch = jest.fn(() => Promise.resolve(resp)); + + const res = await fetchWithAuth('notifications'); + expect(res).toHaveProperty('status', 500); + expect(res).toHaveProperty('error'); + }); + + test('returns 204 No Content as empty object', async () => { + const resp = { + status: 204, + ok: true, + headers: { get: () => null } + }; + global.fetch = jest.fn(() => Promise.resolve(resp)); + + const res = await fetchWithAuth('/test'); + expect(res).toEqual({}); + }); + + test('401 auth error throws with isAuthError flag', async () => { + const resp = { + status: 401, + ok: false, + headers: { get: () => 'application/json' } + }; + global.fetch = jest.fn(() => Promise.resolve(resp)); + + await expect(fetchWithAuth('/test')).rejects.toThrow('Authentication failed'); + try { + await fetchWithAuth('/test'); + } catch (error) { + expect(error.isAuthError).toBe(true); + } + }); + + test('401 on non-critical endpoint returns graceful error object', async () => { + const resp = { + status: 401, + ok: false, + headers: { get: () => 'application/json' } + }; + global.fetch = jest.fn(() => Promise.resolve(resp)); + + const res = await fetchWithAuth('github/status'); + expect(res).toHaveProperty('isAuthError', true); + expect(res).toHaveProperty('error'); + }); + + test('403 forbidden redirects to /forbidden page', async () => { + const resp = { + status: 403, + ok: false, + headers: { get: () => 'application/json' } + }; + global.fetch = jest.fn(() => Promise.resolve(resp)); + + // Mock window.location.href setter + delete window.location; + window.location = { href: '' }; + const setHref = jest.fn(); + Object.defineProperty(window.location, 'href', { + set: setHref, + get: () => '' + }); + + try { + await fetchWithAuth('/test'); + } catch (error) { + expect(error.isAuthError).toBe(true); + // In test env the href setter may not work, just verify the error is thrown + expect(error.message).toMatch(/Forbidden/); + } + }); + + test('429 rate limit error includes retryAfter', async () => { + const resp = { + status: 429, + ok: false, + headers: { + get: (name) => name === 'Retry-After' ? '60' : 'application/json' + } + }; + global.fetch = jest.fn(() => Promise.resolve(resp)); + + await expect(fetchWithAuth('/test')).rejects.toThrow('Rate limit exceeded'); + try { + await fetchWithAuth('/test'); + } catch (error) { + expect(error.status).toBe(429); + expect(error.retryAfter).toBe(60); + } + }); + + test('400 on GitHub endpoint parses error and includes data', async () => { + const resp = { + status: 400, + ok: false, + headers: { get: () => 'application/json' }, + json: async () => ({ message: 'Invalid GitHub repository' }) + }; + global.fetch = jest.fn(() => Promise.resolve(resp)); + + await expect(fetchWithAuth('github/connect')).rejects.toThrow('Invalid GitHub repository'); + try { + await fetchWithAuth('github/connect'); + } catch (error) { + expect(error.isGitHubError).toBe(true); + expect(error.data.message).toBe('Invalid GitHub repository'); + } + }); + + test('returns parsed JSON response on success', async () => { + const resp = { + status: 200, + ok: true, + headers: { get: () => 'application/json' }, + json: async () => ({ id: 1, name: 'test' }) + }; + global.fetch = jest.fn(() => Promise.resolve(resp)); + + const res = await fetchWithAuth('/test'); + expect(res).toEqual({ id: 1, name: 'test' }); + }); +});