From 246288a3ded1118165250233a1c48d9cacfb2533 Mon Sep 17 00:00:00 2001 From: Qaisyousuf Date: Sat, 7 Mar 2026 02:37:33 +0100 Subject: [PATCH] redesign the frontend and the backend --- Data/SurveyContext.cs | 30 + Model/ActionPlan.cs | 60 + Model/CaseNote.cs | 36 + Model/CaseStatusEntry.cs | 46 + Model/QuestionnaireAnalysisSnapshot.cs | 45 + Model/Response.cs | 5 + Model/ResponseAnalysis.cs | 58 + Model/ResponseAnswer.cs | 3 + Model/UserTrajectoryCache.cs | 53 + Services/AIViewModel/AIViewModels.cs | 211 +- Services/Implemnetation/AiAnalysisService.cs | 1754 +++++-- .../Implemnetation/UserTrajectoryService.cs | 585 +++ Services/Interaces/IAiAnalysisService.cs | 76 +- Services/Interaces/IUserTrajectoryService.cs | 30 + .../Controllers/AccessDeniedController.cs | 15 + .../Admin/Controllers/AddressController.cs | 2 +- .../Admin/Controllers/AdminController.cs | 18 +- .../Admin/Controllers/BannerController.cs | 2 +- .../Admin/Controllers/FooterController.cs | 2 +- .../Controllers/NewslettersController.cs | 2 +- Web/Areas/Admin/Controllers/PageController.cs | 2 +- .../Controllers/QuestionnaireController.cs | 99 +- .../Admin/Controllers/RegisterController.cs | 2 +- .../Admin/Controllers/RolesController.cs | 178 +- .../Controllers/SocialMediaController.cs | 2 +- .../Controllers/SurveyAnalysisController.cs | 983 +++- .../Controllers/UserResponseController.cs | 2 +- .../UserResponseStatusController.cs | 348 +- .../Admin/Controllers/UsersController.cs | 117 +- .../Admin/Views/AccessDenied/Index.cshtml | 68 + Web/Areas/Admin/Views/Admin/Index.cshtml | 2161 +++------ .../Admin/Views/Questionnaire/Create.cshtml | 1660 +++---- .../Admin/Views/Questionnaire/Delete.cshtml | 930 +--- .../Admin/Views/Questionnaire/Details.cshtml | 1049 ++--- .../Admin/Views/Questionnaire/Edit.cshtml | 2215 +++------ .../Admin/Views/Questionnaire/Index.cshtml | 2043 +++----- .../Questionnaire/SendQuestionnaire.cshtml | 1287 ++---- Web/Areas/Admin/Views/Roles/Index.cshtml | 768 +++- .../Views/Shared/_AccessDeniedModal.cshtml | 77 + .../Admin/Views/Shared/_AdminLayout.cshtml | 783 +--- .../AnalyzeQuestionnaire.cshtml | 2365 +++------- .../Views/SurveyAnalysis/AnalyzeTrends.cshtml | 2846 ++---------- .../BatchAnalysisProgress.cshtml | 2126 ++------- .../Views/SurveyAnalysis/Dashboard.cshtml | 2468 ++-------- .../SurveyAnalysis/GenerateReport.cshtml | 1267 ++--- .../SurveyAnalysis/HighRiskResponses.cshtml | 1919 ++------ .../Admin/Views/SurveyAnalysis/Index.cshtml | 4087 +++-------------- .../ViewHighRiskResponse.cshtml | 3125 ++++--------- .../Admin/Views/UserResponse/Index.cshtml | 1788 +------ .../Views/UserResponse/ViewResponse.cshtml | 1564 +++---- .../Views/UserResponseStatus/Index.cshtml | 1742 ++----- .../UserResponsesStatus.cshtml | 1756 +++---- Web/Areas/Admin/Views/Users/Index.cshtml | 855 +++- Web/Authorization/HasPermissionAttribute.cs | 13 + .../PermissionAuthorizationHandler.cs | 87 + Web/Authorization/PermissionExtensions.cs | 16 + Web/Authorization/Permissions.cs | 124 + Web/Controllers/DemoRequestController.cs | 2 +- .../QuestionnaireResponseController.cs | 6 +- ...7020749_AddAnalysisCacheTables.Designer.cs | 1047 +++++ .../20260227020749_AddAnalysisCacheTables.cs | 114 + ...260303005443_AddCaseManagement.Designer.cs | 1236 +++++ .../20260303005443_AddCaseManagement.cs | 146 + ...6033732_AddUserTrajectoryCache.Designer.cs | 1273 +++++ .../20260306033732_AddUserTrajectoryCache.cs | 41 + Web/Migrations/SurveyContextModelSnapshot.cs | 408 ++ Web/Program.cs | 36 +- Web/ViewModel/AccountVM/RoleViewModel.cs | 7 +- .../ResponseAnswerViewModel.cs | 9 +- Web/Views/Account/Login.cshtml | 898 ++-- Web/Views/Home/Index.cshtml | 719 ++- .../DisplayQuestionnaire.cshtml | 2267 +++------ .../Shared/Components/Banner/Default.cshtml | 1148 +++-- .../Shared/Components/Footer/Default.cshtml | 1090 +++-- Web/Views/Shared/_AccessDeniedModal.cshtml | 77 + Web/Views/Shared/_Layout.cshtml | 1187 ++--- Web/Views/Shared/_LoginLayout.cshtml | 1076 +++-- Web/Web.csproj | 1 + Web/wwwroot/css/site.css | 2 +- .../04066ff0-8f7b-4cfb-8349-4abac02f4735.PNG | Bin 0 -> 45181 bytes .../2afe716d-dfba-4375-9c2b-540d61cc03d8.jpeg | Bin 0 -> 5440 bytes .../2c704a94-1173-42f3-a5ca-a28d89658836.jpeg | Bin 0 -> 174234 bytes .../f111e297-7a90-4e6f-8b7c-78e30491b69d.PNG | Bin 0 -> 75081 bytes 83 files changed, 24467 insertions(+), 34278 deletions(-) create mode 100644 Model/ActionPlan.cs create mode 100644 Model/CaseNote.cs create mode 100644 Model/CaseStatusEntry.cs create mode 100644 Model/QuestionnaireAnalysisSnapshot.cs create mode 100644 Model/ResponseAnalysis.cs create mode 100644 Model/UserTrajectoryCache.cs create mode 100644 Services/Implemnetation/UserTrajectoryService.cs create mode 100644 Services/Interaces/IUserTrajectoryService.cs create mode 100644 Web/Areas/Admin/Controllers/AccessDeniedController.cs create mode 100644 Web/Areas/Admin/Views/AccessDenied/Index.cshtml create mode 100644 Web/Areas/Admin/Views/Shared/_AccessDeniedModal.cshtml create mode 100644 Web/Authorization/HasPermissionAttribute.cs create mode 100644 Web/Authorization/PermissionAuthorizationHandler.cs create mode 100644 Web/Authorization/PermissionExtensions.cs create mode 100644 Web/Authorization/Permissions.cs create mode 100644 Web/Migrations/20260227020749_AddAnalysisCacheTables.Designer.cs create mode 100644 Web/Migrations/20260227020749_AddAnalysisCacheTables.cs create mode 100644 Web/Migrations/20260303005443_AddCaseManagement.Designer.cs create mode 100644 Web/Migrations/20260303005443_AddCaseManagement.cs create mode 100644 Web/Migrations/20260306033732_AddUserTrajectoryCache.Designer.cs create mode 100644 Web/Migrations/20260306033732_AddUserTrajectoryCache.cs create mode 100644 Web/Views/Shared/_AccessDeniedModal.cshtml create mode 100644 Web/wwwroot/uploads/questionimages/04066ff0-8f7b-4cfb-8349-4abac02f4735.PNG create mode 100644 Web/wwwroot/uploads/questionimages/2afe716d-dfba-4375-9c2b-540d61cc03d8.jpeg create mode 100644 Web/wwwroot/uploads/questionimages/2c704a94-1173-42f3-a5ca-a28d89658836.jpeg create mode 100644 Web/wwwroot/uploads/questionimages/f111e297-7a90-4e6f-8b7c-78e30491b69d.PNG diff --git a/Data/SurveyContext.cs b/Data/SurveyContext.cs index 3a89707..f53fac2 100644 --- a/Data/SurveyContext.cs +++ b/Data/SurveyContext.cs @@ -36,8 +36,14 @@ namespace Data public DbSet ResponseAnswers { get; set; } public DbSet SentNewsletterEamils { get; set; } + public DbSet ResponseAnalyses { get; set; } + public DbSet QuestionnaireAnalysisSnapshots { get; set; } + public DbSet CaseNotes { get; set; } + public DbSet CaseStatusEntries { get; set; } + public DbSet ActionPlans { get; set; } + public DbSet UserTrajectoryCaches { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity() @@ -87,6 +93,30 @@ namespace Data .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(ra => ra.Response) + .WithMany() + .HasForeignKey(ra => ra.ResponseId) + .OnDelete(DeleteBehavior.NoAction); + + modelBuilder.Entity() + .HasOne(ra => ra.Question) + .WithMany() + .HasForeignKey(ra => ra.QuestionId) + .OnDelete(DeleteBehavior.NoAction); + + modelBuilder.Entity() + .HasOne(s => s.Questionnaire) + .WithMany() + .HasForeignKey(s => s.QuestionnaireId) + .OnDelete(DeleteBehavior.NoAction); + + + modelBuilder.Entity() + .HasOne(ra => ra.Answer) + .WithMany() + .HasForeignKey(ra => ra.AnswerId) + .OnDelete(DeleteBehavior.NoAction); base.OnModelCreating(modelBuilder); } diff --git a/Model/ActionPlan.cs b/Model/ActionPlan.cs new file mode 100644 index 0000000..d5c813a --- /dev/null +++ b/Model/ActionPlan.cs @@ -0,0 +1,60 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Model +{ + /// + /// Concrete action plans for contacting/intervening with the respondent. + /// + public class ActionPlan + { + public int Id { get; set; } + + [Required] + public int ResponseId { get; set; } + + [ForeignKey("ResponseId")] + public Response? Response { get; set; } + + [Required] + public string Title { get; set; } = string.Empty; + + public string? Description { get; set; } + + /// + /// Type: ImmediateContact, CounselingReferral, WorkplaceAccommodation, FollowUpAssessment, ManagementAlert + /// + [Required] + public string ActionType { get; set; } = "ImmediateContact"; + + /// + /// Priority: Urgent, High, Normal, Low + /// + [Required] + public string Priority { get; set; } = "Normal"; + + /// + /// Status: Pending, InProgress, Completed, Cancelled + /// + public string Status { get; set; } = "Pending"; + + public string? AssignedTo { get; set; } + + public string? AssignedToEmail { get; set; } + + public DateTime? ScheduledDate { get; set; } + + public DateTime? CompletedDate { get; set; } + + public string? CompletionNotes { get; set; } + + public string CreatedByName { get; set; } = string.Empty; + + public string? CreatedByEmail { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime? UpdatedAt { get; set; } + } +} \ No newline at end of file diff --git a/Model/CaseNote.cs b/Model/CaseNote.cs new file mode 100644 index 0000000..58bfcbc --- /dev/null +++ b/Model/CaseNote.cs @@ -0,0 +1,36 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Model +{ + public class CaseNote + { + public int Id { get; set; } + + [Required] + public int ResponseId { get; set; } + + [ForeignKey("ResponseId")] + public Response? Response { get; set; } + + [Required] + public string AuthorName { get; set; } = string.Empty; + + public string? AuthorEmail { get; set; } + + [Required] + public string NoteText { get; set; } = string.Empty; + + /// + /// Category: General, Risk, Intervention, FollowUp, Resolution + /// + public string Category { get; set; } = "General"; + + public bool IsConfidential { get; set; } = true; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime? UpdatedAt { get; set; } + } +} \ No newline at end of file diff --git a/Model/CaseStatusEntry.cs b/Model/CaseStatusEntry.cs new file mode 100644 index 0000000..cb81969 --- /dev/null +++ b/Model/CaseStatusEntry.cs @@ -0,0 +1,46 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Model +{ + /// + /// Tracks the status workflow: New → UnderReview → InterventionScheduled → Resolved + /// Each entry is a log of a status change (full audit trail) + /// + public class CaseStatusEntry + { + public int Id { get; set; } + + [Required] + public int ResponseId { get; set; } + + [ForeignKey("ResponseId")] + public Response? Response { get; set; } + + [Required] + public CaseStatusType Status { get; set; } = CaseStatusType.New; + + public string? ChangedByName { get; set; } + + public string? ChangedByEmail { get; set; } + + /// + /// Optional reason/note for the status change + /// + public string? Reason { get; set; } + + public DateTime ChangedAt { get; set; } = DateTime.UtcNow; + } + + public enum CaseStatusType + { + New = 0, + UnderReview = 1, + InterventionScheduled = 2, + InProgress = 3, + FollowUp = 4, + Resolved = 5, + Closed = 6 + } +} diff --git a/Model/QuestionnaireAnalysisSnapshot.cs b/Model/QuestionnaireAnalysisSnapshot.cs new file mode 100644 index 0000000..65b9a28 --- /dev/null +++ b/Model/QuestionnaireAnalysisSnapshot.cs @@ -0,0 +1,45 @@ +// Model/QuestionnaireAnalysisSnapshot.cs +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Model +{ + public class QuestionnaireAnalysisSnapshot + { + [Key] + public int Id { get; set; } + + [Required] + public int QuestionnaireId { get; set; } + + // Counts + public int TotalResponses { get; set; } + public int AnalyzedResponses { get; set; } + + // Aggregated sentiment averages + public double OverallPositiveSentiment { get; set; } + public double OverallNegativeSentiment { get; set; } + public double OverallNeutralSentiment { get; set; } + + // Risk distribution counts + public int LowRiskCount { get; set; } + public int ModerateRiskCount { get; set; } + public int HighRiskCount { get; set; } + public int CriticalRiskCount { get; set; } + + // Executive summary (generated by Claude once) + public string ExecutiveSummary { get; set; } = string.Empty; + + // Complex data as JSON + public string TopWorkplaceIssuesJson { get; set; } = "[]"; + public string MostCommonKeyPhrasesJson { get; set; } = "[]"; + + // Metadata + public DateTime GeneratedAt { get; set; } = DateTime.UtcNow; + + // Navigation + [ForeignKey("QuestionnaireId")] + public virtual Questionnaire? Questionnaire { get; set; } + } +} \ No newline at end of file diff --git a/Model/Response.cs b/Model/Response.cs index d9c2720..0eef98c 100644 --- a/Model/Response.cs +++ b/Model/Response.cs @@ -20,5 +20,10 @@ namespace Model public List ResponseDetails { get; set; } = new List(); + + + public List CaseNotes { get; set; } = new List(); + public List StatusHistory { get; set; } = new List(); + public List ActionPlans { get; set; } = new List(); } } diff --git a/Model/ResponseAnalysis.cs b/Model/ResponseAnalysis.cs new file mode 100644 index 0000000..ff70982 --- /dev/null +++ b/Model/ResponseAnalysis.cs @@ -0,0 +1,58 @@ +// Model/ResponseAnalysis.cs +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Model +{ + public class ResponseAnalysis + { + [Key] + public int Id { get; set; } + + // Links to existing tables + [Required] + public int ResponseId { get; set; } + + [Required] + public int QuestionId { get; set; } + + // Question context (not PII) + public string QuestionText { get; set; } = string.Empty; + + // Anonymized only — never store raw user text here + public string AnonymizedText { get; set; } = string.Empty; + + // Sentiment (from Claude) + public string SentimentLabel { get; set; } = "Neutral"; // Positive, Negative, Neutral, Mixed + public double SentimentConfidence { get; set; } + public double PositiveScore { get; set; } + public double NegativeScore { get; set; } + public double NeutralScore { get; set; } + + // Risk Assessment (from Claude) + public string RiskLevel { get; set; } = "Low"; // Low, Moderate, High, Critical + public double RiskScore { get; set; } + public bool RequiresImmediateAttention { get; set; } + public string RecommendedAction { get; set; } = string.Empty; + + // Complex data stored as JSON strings + public string RiskIndicatorsJson { get; set; } = "[]"; + public string ProtectiveFactorsJson { get; set; } = "[]"; + public string KeyPhrasesJson { get; set; } = "[]"; + public string WorkplaceFactorsJson { get; set; } = "[]"; + public string EmotionalIndicatorsJson { get; set; } = "[]"; + public string InsightsJson { get; set; } = "[]"; // WorkplaceInsight list + public string CategoriesJson { get; set; } = "[]"; + + // Metadata + public DateTime AnalyzedAt { get; set; } = DateTime.UtcNow; + + // Navigation properties + [ForeignKey("ResponseId")] + public virtual Response? Response { get; set; } + + [ForeignKey("QuestionId")] + public virtual Question? Question { get; set; } + } +} \ No newline at end of file diff --git a/Model/ResponseAnswer.cs b/Model/ResponseAnswer.cs index 7480cb9..362a7a2 100644 --- a/Model/ResponseAnswer.cs +++ b/Model/ResponseAnswer.cs @@ -15,5 +15,8 @@ namespace Model [ForeignKey("ResponseDetailId")] public ResponseDetail? ResponseDetail { get; set; } public int AnswerId { get; set; } + + [ForeignKey("AnswerId")] + public Answer? Answer { get; set; } } } diff --git a/Model/UserTrajectoryCache.cs b/Model/UserTrajectoryCache.cs new file mode 100644 index 0000000..143a7fa --- /dev/null +++ b/Model/UserTrajectoryCache.cs @@ -0,0 +1,53 @@ + + +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Model +{ + /// + /// Caches AI trajectory analysis results per user. + /// Re-analyzed only when new responses are submitted. + /// + public class UserTrajectoryCache + { + public int Id { get; set; } + + /// + /// The user's email — used as the lookup key + /// + [Required] + [MaxLength(256)] + public string UserEmail { get; set; } = string.Empty; + + /// + /// How many responses were included in this analysis + /// + public int AnalyzedResponseCount { get; set; } + + /// + /// Date of the most recent response that was analyzed + /// Used to detect new responses + /// + public DateTime LastResponseDate { get; set; } + + /// + /// The full trajectory analysis result stored as JSON + /// + [Required] + public string TrajectoryJson { get; set; } = string.Empty; + + /// + /// A shorter summary that Claude can use as context + /// for incremental updates (when only new responses are sent) + /// + public string? PreviousSummary { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + } +} + + diff --git a/Services/AIViewModel/AIViewModels.cs b/Services/AIViewModel/AIViewModels.cs index c2cbfcd..449af7b 100644 --- a/Services/AIViewModel/AIViewModels.cs +++ b/Services/AIViewModel/AIViewModels.cs @@ -1,6 +1,9 @@ // Services/AIViewModel/AIAnalysisViewModels.cs namespace Services.AIViewModel { + /// + /// Risk severity levels for mental health assessment. + /// public enum RiskLevel { Low = 1, @@ -9,9 +12,12 @@ namespace Services.AIViewModel Critical = 4 } + /// + /// Sentiment analysis output with confidence breakdown. + /// public class SentimentAnalysisResult { - public string Sentiment { get; set; } = string.Empty; // Positive, Negative, Neutral + public string Sentiment { get; set; } = string.Empty; public double ConfidenceScore { get; set; } public double PositiveScore { get; set; } public double NegativeScore { get; set; } @@ -19,55 +25,67 @@ namespace Services.AIViewModel public DateTime AnalyzedAt { get; set; } = DateTime.UtcNow; } + /// + /// Key phrases extracted with workplace and emotional categorization. + /// public class KeyPhrasesResult { - public List KeyPhrases { get; set; } = new List(); - public List WorkplaceFactors { get; set; } = new List(); - public List EmotionalIndicators { get; set; } = new List(); + public List KeyPhrases { get; set; } = new(); + public List WorkplaceFactors { get; set; } = new(); + public List EmotionalIndicators { get; set; } = new(); public DateTime ExtractedAt { get; set; } = DateTime.UtcNow; } + /// + /// Mental health risk assessment with indicators and recommendations. + /// public class MentalHealthRiskAssessment { public RiskLevel RiskLevel { get; set; } - public double RiskScore { get; set; } // 0-1 scale - public List RiskIndicators { get; set; } = new List(); - public List ProtectiveFactors { get; set; } = new List(); + public double RiskScore { get; set; } + public List RiskIndicators { get; set; } = new(); + public List ProtectiveFactors { get; set; } = new(); public bool RequiresImmediateAttention { get; set; } public string RecommendedAction { get; set; } = string.Empty; public DateTime AssessedAt { get; set; } = DateTime.UtcNow; } + /// + /// Workplace insight with categorized issue and intervention recommendation. + /// public class WorkplaceInsight { - public string Category { get; set; } = string.Empty; // e.g., "Work-Life Balance", "Management", "Workload" + public string Category { get; set; } = string.Empty; public string Issue { get; set; } = string.Empty; public string RecommendedIntervention { get; set; } = string.Empty; - public int Priority { get; set; } // 1-5 scale - public List AffectedAreas { get; set; } = new List(); + public int Priority { get; set; } + public List AffectedAreas { get; set; } = new(); public DateTime IdentifiedAt { get; set; } = DateTime.UtcNow; } + /// + /// Complete analysis result for a single response — aggregates all AI outputs. + /// public class ResponseAnalysisResult { public int ResponseId { get; set; } public int QuestionId { get; set; } public string QuestionText { get; set; } = string.Empty; public string ResponseText { get; set; } = string.Empty; - public string AnonymizedResponseText { get; set; } = string.Empty; // PII removed + public string AnonymizedResponseText { get; set; } = string.Empty; - // Azure Language Service Results public SentimentAnalysisResult? SentimentAnalysis { get; set; } public KeyPhrasesResult? KeyPhrases { get; set; } - - // Azure OpenAI Results public MentalHealthRiskAssessment? RiskAssessment { get; set; } - public List Insights { get; set; } = new List(); + public List Insights { get; set; } = new(); public DateTime AnalyzedAt { get; set; } = DateTime.UtcNow; - public bool IsAnalysisComplete { get; set; } = false; + public bool IsAnalysisComplete { get; set; } } + /// + /// Aggregated analysis overview for an entire questionnaire. + /// public class QuestionnaireAnalysisOverview { public int QuestionnaireId { get; set; } @@ -75,25 +93,28 @@ namespace Services.AIViewModel public int TotalResponses { get; set; } public int AnalyzedResponses { get; set; } - // Overall Statistics + // Sentiment distribution (0.0–1.0) public double OverallPositiveSentiment { get; set; } public double OverallNegativeSentiment { get; set; } public double OverallNeutralSentiment { get; set; } - // Risk Distribution + // Risk distribution counts public int LowRiskResponses { get; set; } public int ModerateRiskResponses { get; set; } public int HighRiskResponses { get; set; } public int CriticalRiskResponses { get; set; } - // Top Issues - public List TopWorkplaceIssues { get; set; } = new List(); - public List MostCommonKeyPhrases { get; set; } = new List(); + // Insights + public List TopWorkplaceIssues { get; set; } = new(); + public List MostCommonKeyPhrases { get; set; } = new(); public DateTime LastAnalyzedAt { get; set; } public string ExecutiveSummary { get; set; } = string.Empty; } + /// + /// Input request for analysis pipeline. + /// public class AnalysisRequest { public int ResponseId { get; set; } @@ -106,4 +127,152 @@ namespace Services.AIViewModel public bool IncludeRiskAssessment { get; set; } = true; public bool IncludeWorkplaceInsights { get; set; } = true; } + + public class UserTrajectoryAnalysis + { + // ── Overall Trajectory ── + /// + /// "Improving", "Stable", "Declining", "Fluctuating", "Initial" (single response) + /// + public string TrajectoryDirection { get; set; } = "Initial"; + + /// + /// Overall wellness score 0-100 + /// + public int TrajectoryScore { get; set; } + + /// + /// Change from first to latest response (e.g., +15 or -20). 0 if single response. + /// + public int ScoreChange { get; set; } + + /// + /// "Low", "Moderate", "High", "Critical" + /// + public string OverallRiskLevel { get; set; } = "Low"; + + /// + /// 2-3 sentence overview + /// + public string ExecutiveSummary { get; set; } = string.Empty; + + /// + /// Longer detailed analysis paragraph + /// + public string DetailedAnalysis { get; set; } = string.Empty; + + // ── Per-Response Snapshots ── + public List ResponseSnapshots { get; set; } = new(); + + // ── Cross-Response Patterns ── + public List PatternInsights { get; set; } = new(); + + // ── Strengths & Concerns ── + public List StrengthFactors { get; set; } = new(); + public List ConcernFactors { get; set; } = new(); + + // ── Recommendations ── + public List Recommendations { get; set; } = new(); + + // ── Narrative ── + /// + /// A story-like professional summary suitable for reports + /// + public string TimelineNarrative { get; set; } = string.Empty; + + // ── Metadata ── + public int TotalResponsesAnalyzed { get; set; } + public DateTime AnalyzedAt { get; set; } = DateTime.UtcNow; + public bool IsIncremental { get; set; } = false; + } + + public class ResponseSnapshot + { + /// + /// The database Response.Id + /// + public int ResponseId { get; set; } + + public string ResponseDate { get; set; } = string.Empty; + + public string QuestionnaireName { get; set; } = string.Empty; + + /// + /// Wellness score 0-100 for this specific response + /// + public int WellnessScore { get; set; } + + /// + /// "Low", "Moderate", "High", "Critical" + /// + public string RiskLevel { get; set; } = "Low"; + + /// + /// "Positive", "Negative", "Mixed", "Neutral" + /// + public string SentimentLabel { get; set; } = "Neutral"; + + /// + /// Key themes detected (e.g., "workload", "management", "isolation") + /// + public List KeyThemes { get; set; } = new(); + + /// + /// One-sentence summary of this response + /// + public string BriefSummary { get; set; } = string.Empty; + } + + public class PatternInsight + { + /// + /// Description of the pattern (e.g., "Recurring workload concerns across all responses") + /// + public string Pattern { get; set; } = string.Empty; + + /// + /// "High", "Medium", "Low" + /// + public string Severity { get; set; } = "Medium"; + + /// + /// When this pattern was first observed + /// + public string FirstSeen { get; set; } = string.Empty; + + /// + /// Whether this pattern is still present in the latest response + /// + public bool StillPresent { get; set; } = true; + } + + public class StrengthFactor + { + public string Factor { get; set; } = string.Empty; + } + + public class ConcernFactor + { + public string Concern { get; set; } = string.Empty; + + /// + /// "Immediate", "Monitor", "Low" + /// + public string Urgency { get; set; } = "Monitor"; + } + + public class TrajectoryRecommendation + { + public string Action { get; set; } = string.Empty; + + /// + /// "Urgent", "High", "Normal" + /// + public string Priority { get; set; } = "Normal"; + + /// + /// "Workplace", "Personal", "Professional Support" + /// + public string Category { get; set; } = "Workplace"; + } } \ No newline at end of file diff --git a/Services/Implemnetation/AiAnalysisService.cs b/Services/Implemnetation/AiAnalysisService.cs index 5f88058..578fe99 100644 --- a/Services/Implemnetation/AiAnalysisService.cs +++ b/Services/Implemnetation/AiAnalysisService.cs @@ -1,16 +1,12 @@ // Services/Implementation/AiAnalysisService.cs -using Azure; -using Azure.AI.OpenAI; -using Azure.AI.TextAnalytics; using Data; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Model; -using OpenAI.Chat; using Services.AIViewModel; using Services.Interaces; -using System.ClientModel; +using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -20,14 +16,19 @@ namespace Services.Implemnetation { public class AiAnalysisService : IAiAnalysisService, IDisposable { - private readonly TextAnalyticsClient _textAnalyticsClient; - private readonly AzureOpenAIClient _azureOpenAIClient; - private readonly ChatClient _chatClient; + private readonly HttpClient _httpClient; private readonly IConfiguration _configuration; private readonly ILogger _logger; private readonly SurveyContext _context; - private readonly string _openAIDeploymentName; - private bool _disposed = false; + private readonly string _apiKey; + private readonly string _model; + private readonly SemaphoreSlim _rateLimiter; + private bool _disposed; + + private const string ClaudeApiUrl = "https://api.anthropic.com/v1/messages"; + private const string AnthropicVersion = "2023-06-01"; + private const int MaxRetries = 3; + private const int BaseDelayMs = 1000; public AiAnalysisService( IConfiguration configuration, @@ -38,52 +39,191 @@ namespace Services.Implemnetation _logger = logger; _context = context; - // Initialize Azure Language Service - var languageEndpoint = _configuration["AzureLanguageService:Endpoint"]; - var languageKey = _configuration["AzureLanguageService:Key"]; - _textAnalyticsClient = new TextAnalyticsClient(new Uri(languageEndpoint), new AzureKeyCredential(languageKey)); + _apiKey = _configuration["Claude:ApiKey"] + ?? throw new InvalidOperationException("Claude:ApiKey is not configured in appsettings.json"); + _model = _configuration["Claude:Model"] ?? "claude-sonnet-4-20250514"; - // Initialize Azure OpenAI - var openAIEndpoint = _configuration["AzureOpenAI:Endpoint"]; - var openAIKey = _configuration["AzureOpenAI:Key"]; - _openAIDeploymentName = _configuration["AzureOpenAI:DeploymentName"]; + // Configure HttpClient + _httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(120) + }; + _httpClient.DefaultRequestHeaders.Add("x-api-key", _apiKey); + _httpClient.DefaultRequestHeaders.Add("anthropic-version", AnthropicVersion); + _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - _azureOpenAIClient = new AzureOpenAIClient(new Uri(openAIEndpoint), new AzureKeyCredential(openAIKey)); - _chatClient = _azureOpenAIClient.GetChatClient(_openAIDeploymentName); + // Rate limiter: max 5 concurrent requests to Claude + _rateLimiter = new SemaphoreSlim(5, 5); - _logger.LogInformation("AiAnalysisService initialized successfully"); + _logger.LogInformation("AiAnalysisService initialized with Claude API (model: {Model})", _model); } - #region Azure Language Service Methods + #region Core Claude API Communication + + /// + /// Sends a message to Claude API with retry logic and rate limiting. + /// + private async Task SendClaudeRequestAsync(string systemPrompt, string userPrompt, float temperature = 0.3f, int maxTokens = 2048) + { + await _rateLimiter.WaitAsync(); + try + { + for (int attempt = 1; attempt <= MaxRetries; attempt++) + { + try + { + var requestBody = new + { + model = _model, + max_tokens = maxTokens, + temperature = temperature, + system = systemPrompt, + messages = new[] + { + new { role = "user", content = userPrompt } + } + }; + + var json = JsonSerializer.Serialize(requestBody, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + var content = new StringContent(json, Encoding.UTF8, "application/json"); + var response = await _httpClient.PostAsync(ClaudeApiUrl, content); + + if (response.IsSuccessStatusCode) + { + var responseJson = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(responseJson); + + // Extract text from Claude's response content array + if (doc.RootElement.TryGetProperty("content", out var contentArray) && + contentArray.ValueKind == JsonValueKind.Array) + { + foreach (var block in contentArray.EnumerateArray()) + { + if (block.TryGetProperty("type", out var type) && + type.GetString() == "text" && + block.TryGetProperty("text", out var text)) + { + return text.GetString() ?? string.Empty; + } + } + } + + _logger.LogWarning("Claude response had no text content block. Raw: {Raw}", + responseJson.Length > 500 ? responseJson[..500] : responseJson); + return string.Empty; + } + + // Handle rate limiting (429) + if ((int)response.StatusCode == 429) + { + var delay = BaseDelayMs * (int)Math.Pow(2, attempt - 1); + _logger.LogWarning("Claude API rate limited (attempt {Attempt}/{Max}). Retrying in {Delay}ms", + attempt, MaxRetries, delay); + await Task.Delay(delay); + continue; + } + + // Handle overloaded (529) + if ((int)response.StatusCode == 529) + { + var delay = BaseDelayMs * (int)Math.Pow(2, attempt); + _logger.LogWarning("Claude API overloaded (attempt {Attempt}/{Max}). Retrying in {Delay}ms", + attempt, MaxRetries, delay); + await Task.Delay(delay); + continue; + } + + // Other errors + var errorBody = await response.Content.ReadAsStringAsync(); + _logger.LogError("Claude API error {StatusCode} (attempt {Attempt}): {Error}", + (int)response.StatusCode, attempt, errorBody.Length > 500 ? errorBody[..500] : errorBody); + + if (attempt == MaxRetries) + throw new HttpRequestException($"Claude API returned {(int)response.StatusCode} after {MaxRetries} attempts"); + + await Task.Delay(BaseDelayMs * attempt); + } + catch (TaskCanceledException) when (attempt < MaxRetries) + { + _logger.LogWarning("Claude API timeout (attempt {Attempt}/{Max})", attempt, MaxRetries); + await Task.Delay(BaseDelayMs * attempt); + } + catch (HttpRequestException) when (attempt < MaxRetries) + { + _logger.LogWarning("Claude API connection error (attempt {Attempt}/{Max})", attempt, MaxRetries); + await Task.Delay(BaseDelayMs * attempt); + } + } + + throw new HttpRequestException("Claude API request failed after all retry attempts"); + } + finally + { + _rateLimiter.Release(); + } + } + + #endregion + + #region Core Analysis Methods public async Task AnalyzeSentimentAsync(string text) { try { if (string.IsNullOrWhiteSpace(text)) - { return new SentimentAnalysisResult { Sentiment = "Neutral", ConfidenceScore = 0.0 }; + + var systemPrompt = @"You are a sentiment analysis engine for workplace mental health surveys. +Respond ONLY with a valid JSON object. No markdown, no explanation, no code fences."; + + var userPrompt = $@"Analyze the sentiment of this workplace survey response: + +""{text}"" + +Return this exact JSON structure: +{{ + ""sentiment"": ""Positive"", + ""confidenceScore"": 0.85, + ""positiveScore"": 0.85, + ""negativeScore"": 0.10, + ""neutralScore"": 0.05 +}} + +Rules: +- sentiment must be exactly ""Positive"", ""Negative"", or ""Neutral"" +- All scores must be between 0.0 and 1.0 +- Scores should approximately sum to 1.0 +- confidenceScore = the highest of the three scores"; + + var response = await SendClaudeRequestAsync(systemPrompt, userPrompt, 0.1f); + var doc = ParseLenientJson(response); + + if (doc == null) + { + _logger.LogWarning("Failed to parse sentiment response. Raw: {Raw}", Truncate(response)); + return new SentimentAnalysisResult { Sentiment = "Neutral", ConfidenceScore = 0.5 }; } - var response = await _textAnalyticsClient.AnalyzeSentimentAsync(text); - var sentiment = response.Value; - + var root = doc.RootElement; return new SentimentAnalysisResult { - Sentiment = sentiment.Sentiment.ToString(), - ConfidenceScore = sentiment.ConfidenceScores.Positive > sentiment.ConfidenceScores.Negative - ? sentiment.ConfidenceScores.Positive - : sentiment.ConfidenceScores.Negative, - PositiveScore = sentiment.ConfidenceScores.Positive, - NegativeScore = sentiment.ConfidenceScores.Negative, - NeutralScore = sentiment.ConfidenceScores.Neutral, + Sentiment = GetStringProp(root, "sentiment", "Neutral"), + ConfidenceScore = GetDoubleProp(root, "confidenceScore", 0.5), + PositiveScore = GetDoubleProp(root, "positiveScore", 0.0), + NegativeScore = GetDoubleProp(root, "negativeScore", 0.0), + NeutralScore = GetDoubleProp(root, "neutralScore", 0.0), AnalyzedAt = DateTime.UtcNow }; } catch (Exception ex) { - _logger.LogError(ex, "Error analyzing sentiment for text: {Text}", text); - throw; + _logger.LogError(ex, "Error analyzing sentiment"); + return new SentimentAnalysisResult { Sentiment = "Neutral", ConfidenceScore = 0.0 }; } } @@ -92,36 +232,49 @@ namespace Services.Implemnetation try { if (string.IsNullOrWhiteSpace(text)) + return new KeyPhrasesResult(); + + var systemPrompt = @"You are a key phrase extraction engine for workplace mental health surveys. +Respond ONLY with a valid JSON object. No markdown, no explanation, no code fences."; + + var userPrompt = $@"Extract key phrases from this workplace survey response: + +""{text}"" + +Return this exact JSON structure: +{{ + ""keyPhrases"": [""phrase1"", ""phrase2""], + ""workplaceFactors"": [""factor1"", ""factor2""], + ""emotionalIndicators"": [""indicator1"", ""indicator2""] +}} + +Rules: +- keyPhrases: all significant noun phrases and concepts (5-15 phrases) +- workplaceFactors: only phrases related to work environment, management, teams, deadlines, meetings, projects, colleagues, workload +- emotionalIndicators: only phrases indicating emotional state (stress, anxiety, satisfaction, motivation, frustration, burnout, happiness, exhaustion)"; + + var response = await SendClaudeRequestAsync(systemPrompt, userPrompt, 0.2f); + var doc = ParseLenientJson(response); + + if (doc == null) { + _logger.LogWarning("Failed to parse key phrases response. Raw: {Raw}", Truncate(response)); return new KeyPhrasesResult(); } - var response = await _textAnalyticsClient.ExtractKeyPhrasesAsync(text); - var keyPhrases = response.Value.ToList(); - - // Mental health specific categorization - var workplaceFactors = keyPhrases.Where(phrase => - phrase.Contains("work") || phrase.Contains("manager") || phrase.Contains("team") || - phrase.Contains("deadline") || phrase.Contains("pressure") || phrase.Contains("colleague") || - phrase.Contains("office") || phrase.Contains("meeting") || phrase.Contains("project")).ToList(); - - var emotionalIndicators = keyPhrases.Where(phrase => - phrase.Contains("stress") || phrase.Contains("anxious") || phrase.Contains("tired") || - phrase.Contains("overwhelmed") || phrase.Contains("frustrated") || phrase.Contains("happy") || - phrase.Contains("satisfied") || phrase.Contains("motivated") || phrase.Contains("burned")).ToList(); - + var root = doc.RootElement; return new KeyPhrasesResult { - KeyPhrases = keyPhrases, - WorkplaceFactors = workplaceFactors, - EmotionalIndicators = emotionalIndicators, + KeyPhrases = GetStringArray(root, "keyPhrases"), + WorkplaceFactors = GetStringArray(root, "workplaceFactors"), + EmotionalIndicators = GetStringArray(root, "emotionalIndicators"), ExtractedAt = DateTime.UtcNow }; } catch (Exception ex) { - _logger.LogError(ex, "Error extracting key phrases for text: {Text}", text); - throw; + _logger.LogError(ex, "Error extracting key phrases"); + return new KeyPhrasesResult(); } } @@ -130,35 +283,32 @@ namespace Services.Implemnetation try { if (string.IsNullOrWhiteSpace(text)) - { return string.Empty; - } - var response = await _textAnalyticsClient.RecognizePiiEntitiesAsync(text); - var piiEntities = response.Value; + var systemPrompt = @"You are a PII anonymization engine. Your job is to replace personally identifiable information in text with placeholder tags. Return ONLY the anonymized text — nothing else."; - string anonymizedText = text; - foreach (var entity in piiEntities.OrderByDescending(e => e.Offset)) - { - var replacement = entity.Category.ToString() switch - { - "Person" => "[NAME]", - "Email" => "[EMAIL]", - "PhoneNumber" => "[PHONE]", - "Address" => "[ADDRESS]", - _ => "[REDACTED]" - }; + var userPrompt = $@"Anonymize this text by replacing PII with these tags: +- Person names → [NAME] +- Email addresses → [EMAIL] +- Phone numbers → [PHONE] +- Physical addresses → [ADDRESS] +- Company names (if identifying specific small company) → [ORG] +- Any other identifying info → [REDACTED] - anonymizedText = anonymizedText.Remove(entity.Offset, entity.Length) - .Insert(entity.Offset, replacement); - } +Keep all non-PII text exactly as-is. Do NOT add explanations or formatting. - return anonymizedText; +Text: ""{text}"""; + + var response = await SendClaudeRequestAsync(systemPrompt, userPrompt, 0.0f); + + // Clean up: remove any quotes Claude might wrap the response in + var cleaned = response.Trim().Trim('"'); + return string.IsNullOrWhiteSpace(cleaned) ? text : cleaned; } catch (Exception ex) { - _logger.LogError(ex, "Error anonymizing text: {Text}", text); - return text; // Return original text if anonymization fails + _logger.LogError(ex, "Error anonymizing text"); + return text; // Return original if anonymization fails } } @@ -167,125 +317,99 @@ namespace Services.Implemnetation try { if (string.IsNullOrWhiteSpace(text)) - { return new List(); - } - var response = await _textAnalyticsClient.RecognizeEntitiesAsync(text); - var entities = response.Value; + var systemPrompt = @"You are a named entity recognition engine. +Respond ONLY with a valid JSON array. No markdown, no explanation."; - return entities.Select(entity => $"{entity.Category}: {entity.Text}").ToList(); + var userPrompt = $@"Detect named entities in this text and categorize them: + +""{text}"" + +Return a JSON array of strings in ""Category: Entity"" format: +[""Person: John Smith"", ""Organization: Acme Corp"", ""Location: New York"", ""Role: Manager""] + +Categories: Person, Organization, Location, Role, Department, Date, Event"; + + var response = await SendClaudeRequestAsync(systemPrompt, userPrompt, 0.1f); + return DeserializeLenient>(response) ?? new List(); } catch (Exception ex) { - _logger.LogError(ex, "Error detecting entities for text: {Text}", text); - throw; + _logger.LogError(ex, "Error detecting entities"); + return new List(); } } #endregion - #region Azure OpenAI Methods + #region Risk Assessment Methods public async Task AssessMentalHealthRiskAsync(string anonymizedText, string questionContext) { try { - var prompt = $@" -As a mental health professional, analyze this workplace survey response and assess the mental health risk level. + var systemPrompt = @"You are a clinical workplace psychologist specializing in occupational mental health risk assessment. You analyze employee survey responses to identify mental health risk factors. -Question Context: {questionContext} -Response: {anonymizedText} +CRITICAL: Respond ONLY with a single valid JSON object. No markdown, no code fences, no explanation text."; -Please provide: -1. Risk Level (Low, Moderate, High, Critical) -2. Risk Score (0.0 to 1.0) -3. Risk Indicators (specific concerns found) -4. Protective Factors (positive elements found) -5. Requires Immediate Attention (true/false) -6. Recommended Action + var userPrompt = $@"Assess the mental health risk level of this anonymized workplace survey response. -Respond in this JSON format: +Survey Question: {questionContext} +Employee Response: {anonymizedText} + +Evaluate for: +- Signs of burnout, exhaustion, or chronic stress +- Anxiety or depression indicators +- Work-life balance deterioration +- Social withdrawal or conflict +- Self-harm or crisis indicators (escalate to Critical) +- Protective factors (coping mechanisms, social support, positive outlook) + +Return this exact JSON structure: {{ - ""riskLevel"": ""Low"", - ""riskScore"": 0.0, - ""riskIndicators"": [""indicator1"", ""indicator2""], - ""protectiveFactors"": [""factor1"", ""factor2""], - ""requiresImmediateAttention"": false, - ""recommendedAction"": ""specific action recommendation"" -}}"; + ""riskLevel"": ""Low"", + ""riskScore"": 0.25, + ""riskIndicators"": [""specific concern 1"", ""specific concern 2""], + ""protectiveFactors"": [""positive factor 1"", ""positive factor 2""], + ""requiresImmediateAttention"": false, + ""recommendedAction"": ""specific actionable recommendation"" +}} - var messages = new List +Rules: +- riskLevel: exactly ""Low"", ""Moderate"", ""High"", or ""Critical"" +- riskScore: 0.0 (no risk) to 1.0 (maximum risk) + - Low: 0.0–0.25, Moderate: 0.26–0.50, High: 0.51–0.75, Critical: 0.76–1.0 +- requiresImmediateAttention: true ONLY for Critical or if self-harm/crisis indicators present +- recommendedAction: professional, specific, actionable (not generic)"; + + var response = await SendClaudeRequestAsync(systemPrompt, userPrompt, 0.2f); + var doc = ParseLenientJson(response); + + if (doc == null) { - new SystemChatMessage("You are a mental health professional specialized in workplace wellness. Always respond with a single valid JSON object only. No markdown, no explanations."), - new UserChatMessage(prompt) - }; - - var chatOptions = new ChatCompletionOptions() - { - Temperature = 0.3f - }; - - var response = await _chatClient.CompleteChatAsync(messages, chatOptions); - var content = response.Value.Content[0].Text; - - _logger.LogInformation("OpenAI Response: {Content}", content); - - // Parse more defensively - var jsonDoc = ParseLenient(content, out var parseErr); - if (jsonDoc == null) - { - _logger.LogWarning("Failed to parse JSON response from OpenAI: {Err}. Raw (first 800): {Raw}", - parseErr, content?.Length > 800 ? content[..800] : content); - - return new MentalHealthRiskAssessment - { - RiskLevel = RiskLevel.Moderate, - RiskScore = 0.5, - RiskIndicators = new List { "Unable to parse AI response" }, - ProtectiveFactors = new List(), - RequiresImmediateAttention = false, - RecommendedAction = "Manual review recommended due to analysis error", - AssessedAt = DateTime.UtcNow - }; + _logger.LogWarning("Failed to parse risk assessment. Raw: {Raw}", Truncate(response)); + return DefaultRiskAssessment("Unable to parse AI response"); } - var root = jsonDoc.RootElement; + var root = doc.RootElement; + var riskLevelStr = GetStringProp(root, "riskLevel", "Moderate"); return new MentalHealthRiskAssessment { - RiskLevel = Enum.TryParse(root.TryGetProperty("riskLevel", out var rl) ? (rl.GetString() ?? "Low") : "Low", out var riskLevel) - ? riskLevel : RiskLevel.Low, - RiskScore = root.TryGetProperty("riskScore", out var scoreProperty) - ? TryGetDoubleFlexible(scoreProperty, out var d) ? d : 0.0 - : 0.0, - RiskIndicators = root.TryGetProperty("riskIndicators", out var indicatorsProperty) - ? indicatorsProperty.EnumerateArray().Select(x => x.GetString() ?? "").Where(s => !string.IsNullOrEmpty(s)).ToList() - : new List(), - ProtectiveFactors = root.TryGetProperty("protectiveFactors", out var factorsProperty) - ? factorsProperty.EnumerateArray().Select(x => x.GetString() ?? "").Where(s => !string.IsNullOrEmpty(s)).ToList() - : new List(), - RequiresImmediateAttention = root.TryGetProperty("requiresImmediateAttention", out var attentionProperty) && - TryGetBoolFlexible(attentionProperty, out var b) && b, - RecommendedAction = root.TryGetProperty("recommendedAction", out var actionProperty) ? (actionProperty.GetString() ?? "") : "", + RiskLevel = Enum.TryParse(riskLevelStr, true, out var rl) ? rl : RiskLevel.Moderate, + RiskScore = Math.Clamp(GetDoubleProp(root, "riskScore", 0.5), 0.0, 1.0), + RiskIndicators = GetStringArray(root, "riskIndicators"), + ProtectiveFactors = GetStringArray(root, "protectiveFactors"), + RequiresImmediateAttention = GetBoolProp(root, "requiresImmediateAttention", false), + RecommendedAction = GetStringProp(root, "recommendedAction", "Manual review recommended"), AssessedAt = DateTime.UtcNow }; } catch (Exception ex) { - _logger.LogError(ex, "Error assessing mental health risk for text: {Text}", anonymizedText); - - // Return a safe default assessment instead of throwing - return new MentalHealthRiskAssessment - { - RiskLevel = RiskLevel.Moderate, - RiskScore = 0.5, - RiskIndicators = new List { $"Analysis error: {ex.Message}" }, - ProtectiveFactors = new List(), - RequiresImmediateAttention = false, - RecommendedAction = "Manual review recommended due to system error", - AssessedAt = DateTime.UtcNow - }; + _logger.LogError(ex, "Error assessing mental health risk"); + return DefaultRiskAssessment($"Analysis error: {ex.Message}"); } } @@ -293,102 +417,72 @@ Respond in this JSON format: { try { - var prompt = $@" -Analyze this workplace survey response and identify specific workplace insights and intervention recommendations. + var systemPrompt = @"You are a workplace mental health consultant specializing in organizational wellness intervention design. -Question Context: {questionContext} -Response: {anonymizedText} +CRITICAL: Respond ONLY with a single valid JSON object. No markdown, no code fences, no explanation."; -Identify workplace issues and provide specific, actionable intervention recommendations. Focus on: -- Work-life balance issues -- Management/leadership concerns -- Team dynamics problems -- Workload and stress factors -- Communication issues -- Organizational culture problems + var userPrompt = $@"Analyze this workplace survey response and identify actionable workplace insights. -Respond in this JSON format: +Survey Question: {questionContext} +Employee Response: {anonymizedText} + +Identify issues across these domains: +- Work-Life Balance, Workload Management, Team Dynamics +- Leadership/Management, Communication, Job Satisfaction +- Stress Management, Career Development, Work Environment +- Organizational Culture, Mental Health Support, Burnout Prevention + +Return this exact JSON: {{ ""insights"": [ {{ - ""category"": ""category name"", - ""issue"": ""specific issue identified"", - ""recommendedIntervention"": ""specific action to take"", - ""priority"": 1, - ""affectedAreas"": [""area1"", ""area2""] + ""category"": ""Work-Life Balance"", + ""issue"": ""specific issue identified from response"", + ""recommendedIntervention"": ""specific, actionable organizational intervention"", + ""priority"": 3, + ""affectedAreas"": [""Employee Wellbeing"", ""Productivity""] }} ] -}}"; +}} - var messages = new List +Rules: +- 1–5 insights maximum (only genuinely identified issues) +- priority: 1 (critical) to 5 (minor) +- recommendedIntervention: professional, specific, implementable by HR/management +- Do NOT invent issues not supported by the response text"; + + var response = await SendClaudeRequestAsync(systemPrompt, userPrompt, 0.3f); + var doc = ParseLenientJson(response); + + if (doc == null) { - new SystemChatMessage("You are a workplace mental health consultant. Always respond with a SINGLE valid JSON object ONLY. No markdown, no code fences, no explanations."), - new UserChatMessage(prompt) - }; - - var chatOptions = new ChatCompletionOptions() - { - Temperature = 0.4f - }; - - var response = await _chatClient.CompleteChatAsync(messages, chatOptions); - var content = response.Value.Content[0].Text; - - var jsonDoc = ParseLenient(content, out var parseErr); - if (jsonDoc == null) - { - _logger.LogWarning("Insights JSON parse failed: {Err}. Raw (first 800): {Raw}", - parseErr, content?.Length > 800 ? content[..800] : content); - - // Fail soft (avoid crashing controller) + _logger.LogWarning("Failed to parse insights. Raw: {Raw}", Truncate(response)); return new List(); } - var root = jsonDoc.RootElement; - - if (!root.TryGetProperty("insights", out var insightsElement) || insightsElement.ValueKind != JsonValueKind.Array) - { - // No insights array — return empty rather than throw + var root = doc.RootElement; + if (!root.TryGetProperty("insights", out var insightsEl) || insightsEl.ValueKind != JsonValueKind.Array) return new List(); - } - var workplaceInsights = new List(); - - foreach (var insight in insightsElement.EnumerateArray()) + var insights = new List(); + foreach (var item in insightsEl.EnumerateArray()) { - // Be defensive with each field - string category = insight.TryGetProperty("category", out var cat) ? (cat.GetString() ?? "") : ""; - string issue = insight.TryGetProperty("issue", out var iss) ? (iss.GetString() ?? "") : ""; - string recommended = insight.TryGetProperty("recommendedIntervention", out var rec) ? (rec.GetString() ?? "") : ""; - int priority = 3; - if (insight.TryGetProperty("priority", out var pr)) + insights.Add(new WorkplaceInsight { - if (pr.ValueKind == JsonValueKind.Number && pr.TryGetInt32(out var pInt)) priority = pInt; - else if (pr.ValueKind == JsonValueKind.String && int.TryParse(pr.GetString(), out var pStr)) priority = pStr; - } - var affectedAreas = new List(); - if (insight.TryGetProperty("affectedAreas", out var aa) && aa.ValueKind == JsonValueKind.Array) - { - affectedAreas = aa.EnumerateArray().Select(x => x.GetString() ?? "").Where(s => !string.IsNullOrWhiteSpace(s)).ToList(); - } - - workplaceInsights.Add(new WorkplaceInsight - { - Category = category, - Issue = issue, - RecommendedIntervention = recommended, - Priority = priority, - AffectedAreas = affectedAreas, + Category = GetStringProp(item, "category", "General"), + Issue = GetStringProp(item, "issue", ""), + RecommendedIntervention = GetStringProp(item, "recommendedIntervention", ""), + Priority = Math.Clamp(GetIntProp(item, "priority", 3), 1, 5), + AffectedAreas = GetStringArray(item, "affectedAreas"), IdentifiedAt = DateTime.UtcNow }); } - return workplaceInsights; + return insights; } catch (Exception ex) { - _logger.LogError(ex, "Error generating workplace insights for text: {Text}", anonymizedText); - // Fail soft to prevent controller crash + _logger.LogError(ex, "Error generating workplace insights"); return new List(); } } @@ -399,9 +493,11 @@ Respond in this JSON format: { var totalResponses = analysisResults.Count; var highRiskCount = analysisResults.Count(r => r.RiskAssessment?.RiskLevel >= RiskLevel.High); - var positiveResponses = analysisResults.Count(r => r.SentimentAnalysis?.Sentiment == "Positive"); + var criticalCount = analysisResults.Count(r => r.RiskAssessment?.RiskLevel == RiskLevel.Critical); + var positiveCount = analysisResults.Count(r => r.SentimentAnalysis?.Sentiment == "Positive"); + var negativeCount = analysisResults.Count(r => r.SentimentAnalysis?.Sentiment == "Negative"); - var commonIssues = analysisResults + var topCategories = analysisResults .SelectMany(r => r.Insights) .GroupBy(i => i.Category) .OrderByDescending(g => g.Count()) @@ -409,43 +505,44 @@ Respond in this JSON format: .Select(g => $"{g.Key}: {g.Count()} instances") .ToList(); - var prompt = $@" -Create an executive summary for a workplace mental health survey analysis. + var topKeyPhrases = analysisResults + .Where(r => r.KeyPhrases != null) + .SelectMany(r => r.KeyPhrases!.KeyPhrases) + .GroupBy(k => k.ToLower()) + .OrderByDescending(g => g.Count()) + .Take(8) + .Select(g => g.Key) + .ToList(); -Survey Statistics: -- Total Responses: {totalResponses} -- High Risk Responses: {highRiskCount} -- Positive Responses: {positiveResponses} -- Most Common Issues: {string.Join(", ", commonIssues)} + var systemPrompt = @"You are a senior organizational psychologist writing executive briefings for C-level leadership. Your writing is precise, data-driven, professional, and actionable. No fluff."; -Create a comprehensive executive summary that includes: -1. Overall mental health status -2. Key findings and trends -3. Areas of concern -4. Positive indicators -5. Immediate action items -6. Long-term recommendations + var userPrompt = $@"Create a professional executive summary for this workplace mental health survey analysis. -Keep it professional, actionable, and suitable for senior management."; +DATA: +- Total analyzed responses: {totalResponses} +- Positive sentiment: {positiveCount} ({(totalResponses > 0 ? (positiveCount * 100.0 / totalResponses).ToString("F1") : "0")}%) +- Negative sentiment: {negativeCount} ({(totalResponses > 0 ? (negativeCount * 100.0 / totalResponses).ToString("F1") : "0")}%) +- High risk responses: {highRiskCount} +- Critical risk responses: {criticalCount} +- Top workplace issues: {string.Join(", ", topCategories)} +- Recurring themes: {string.Join(", ", topKeyPhrases)} - var messages = new List - { - new SystemChatMessage("You are an executive consultant specializing in workplace mental health reporting."), - new UserChatMessage(prompt) - }; +Write the summary with these sections: +1. OVERALL ASSESSMENT (2-3 sentences, overall mental health posture) +2. KEY FINDINGS (3-5 bullet points, data-backed) +3. AREAS OF CONCERN (prioritized list with risk implications) +4. POSITIVE INDICATORS (strengths to preserve) +5. IMMEDIATE ACTIONS (3-5 specific, implementable recommendations) +6. STRATEGIC RECOMMENDATIONS (long-term organizational improvements) - var chatOptions = new ChatCompletionOptions() - { - Temperature = 0.3f - }; +Keep it under 600 words. Professional tone suitable for board presentation."; - var response = await _chatClient.CompleteChatAsync(messages, chatOptions); - return response.Value.Content[0].Text; + return await SendClaudeRequestAsync(systemPrompt, userPrompt, 0.3f, 3000); } catch (Exception ex) { _logger.LogError(ex, "Error generating executive summary"); - throw; + return "Executive summary generation failed. Please retry the analysis."; } } @@ -453,12 +550,14 @@ Keep it professional, actionable, and suitable for senior management."; { try { - var prompt = $@" -Categorize this workplace mental health survey response into relevant themes. + var systemPrompt = @"You are a response categorization engine for workplace surveys. +Respond ONLY with a JSON array of strings. No markdown, no explanation."; -Response: {anonymizedText} + var userPrompt = $@"Categorize this workplace mental health response into applicable themes: -Choose from these categories (select all that apply): +""{anonymizedText}"" + +Choose ALL that apply from: - Work-Life Balance - Workload Management - Team Dynamics @@ -472,47 +571,37 @@ Choose from these categories (select all that apply): - Mental Health Support - Burnout Prevention -Respond with a JSON array of applicable categories: [""category1"", ""category2""]"; +Return as JSON array: [""Category1"", ""Category2""]"; - var messages = new List - { - new SystemChatMessage("You are a mental health professional categorizing workplace responses. Always respond with a JSON array only."), - new UserChatMessage(prompt) - }; - - var chatOptions = new ChatCompletionOptions() - { - Temperature = 0.2f - }; - - var response = await _chatClient.CompleteChatAsync(messages, chatOptions); - var content = response.Value.Content[0].Text; - - // lenient array parse - var categories = DeserializeLenient>(content, out var err) ?? new List(); - if (!string.IsNullOrEmpty(err)) - { - _logger.LogWarning("Category JSON parse failed: {Err}. Raw (first 800): {Raw}", - err, content?.Length > 800 ? content[..800] : content); - } - - return categories; + var response = await SendClaudeRequestAsync(systemPrompt, userPrompt, 0.1f); + return DeserializeLenient>(response) ?? new List(); } catch (Exception ex) { - _logger.LogError(ex, "Error categorizing response: {Text}", anonymizedText); - throw; + _logger.LogError(ex, "Error categorizing response"); + return new List(); } } #endregion - #region Combined Analysis Methods + #region Composite Analysis Methods public async Task AnalyzeCompleteResponseAsync(AnalysisRequest request) { try { + // Step 1: Check if already analyzed in DB + var cached = await LoadResponseAnalysisFromDbAsync(request.ResponseId, request.QuestionId); + if (cached != null) + { + _logger.LogInformation("Loaded cached analysis for ResponseId: {ResponseId}, QuestionId: {QuestionId}", request.ResponseId, request.QuestionId); + return cached; + } + + // Step 2: Not in DB — run full Claude analysis + _logger.LogInformation("No cached analysis found. Calling Claude API for ResponseId: {ResponseId}, QuestionId: {QuestionId}", request.ResponseId, request.QuestionId); + var result = new ResponseAnalysisResult { ResponseId = request.ResponseId, @@ -522,10 +611,10 @@ Respond with a JSON array of applicable categories: [""category1"", ""category2" AnalyzedAt = DateTime.UtcNow }; - // Step 1: Anonymize the text + // Anonymize the text result.AnonymizedResponseText = await AnonymizeTextAsync(request.ResponseText); - // Step 2: Azure Language Service Analysis + // Claude API calls if (request.IncludeSentimentAnalysis) { result.SentimentAnalysis = await AnalyzeSentimentAsync(result.AnonymizedResponseText); @@ -536,7 +625,6 @@ Respond with a JSON array of applicable categories: [""category1"", ""category2" result.KeyPhrases = await ExtractKeyPhrasesAsync(result.AnonymizedResponseText); } - // Step 3: Azure OpenAI Analysis if (request.IncludeRiskAssessment) { result.RiskAssessment = await AssessMentalHealthRiskAsync(result.AnonymizedResponseText, request.QuestionText); @@ -548,6 +636,10 @@ Respond with a JSON array of applicable categories: [""category1"", ""category2" } result.IsAnalysisComplete = true; + + // Step 3: Save to DB for future use + await SaveResponseAnalysisToDbAsync(result); + return result; } catch (Exception ex) @@ -560,33 +652,61 @@ Respond with a JSON array of applicable categories: [""category1"", ""category2" public async Task> AnalyzeQuestionResponsesAsync(int questionId, List requests) { var results = new List(); - foreach (var request in requests) { try { - var result = await AnalyzeCompleteResponseAsync(request); - results.Add(result); + results.Add(await AnalyzeCompleteResponseAsync(request)); } catch (Exception ex) { - _logger.LogError(ex, "Error analyzing response {ResponseId} for question {QuestionId}", request.ResponseId, questionId); - // Continue with other responses even if one fails + _logger.LogError(ex, "Error analyzing response {ResponseId} for question {QuestionId}", + request.ResponseId, questionId); + } + } + return results; + } + /// + /// Builds a combined analysis text from a ResponseDetail, including both text responses + /// and selected answer texts for checkbox/radio/multiple choice questions. + /// + private string BuildAnalysisText(ResponseDetail detail) + { + var parts = new List(); + + // Include text response if present + if (!string.IsNullOrWhiteSpace(detail.TextResponse)) + { + parts.Add(detail.TextResponse); + } + + // Include selected answer texts for non-text questions + if (detail.ResponseAnswers != null && detail.ResponseAnswers.Any()) + { + foreach (var ra in detail.ResponseAnswers) + { + // Navigate through the Answer entity to get the text + if (ra.Answer != null && !string.IsNullOrWhiteSpace(ra.Answer.Text)) + { + parts.Add(ra.Answer.Text); + } } } - return results; + return string.Join(". ", parts); } - public async Task GenerateQuestionnaireOverviewAsync(int questionnaireId) { try { - // Get all responses for this questionnaire + // Get all responses — NOW including ResponseAnswers -> Answer for selected answer text var responses = await _context.Responses .Include(r => r.Questionnaire) .Include(r => r.ResponseDetails) - .ThenInclude(rd => rd.Question) + .ThenInclude(rd => rd.Question) + .Include(r => r.ResponseDetails) + .ThenInclude(rd => rd.ResponseAnswers) + .ThenInclude(ra => ra.Answer) // <-- KEY FIX: load Answer.Text .Where(r => r.QuestionnaireId == questionnaireId) .ToListAsync(); @@ -600,38 +720,21 @@ Respond with a JSON array of applicable categories: [""category1"", ""category2" }; } - // Analyze text responses + // Analyze ALL response types (text + checkbox + radio + etc.) var analysisResults = new List(); foreach (var response in responses) { foreach (var detail in response.ResponseDetails) { - string responseText = ""; + var analysisText = BuildAnalysisText(detail); - // Handle text-based questions (existing) - if (!string.IsNullOrWhiteSpace(detail.TextResponse)) - { - responseText = detail.TextResponse; - } - // Handle CheckBox questions (NEW) - else if (detail.QuestionType == QuestionType.CheckBox && detail.ResponseAnswers.Any()) - { - var selectedAnswers = detail.ResponseAnswers - .Select(ra => detail.Question.Answers.FirstOrDefault(a => a.Id == ra.AnswerId)?.Text) - .Where(text => !string.IsNullOrEmpty(text)) - .ToList(); - - responseText = $"Multiple Selection Question: {detail.Question.Text}\nSelected Options: {string.Join(", ", selectedAnswers)}\nAnalyze these selected workplace factors for mental health implications and patterns."; - } - - // Create analysis request for ALL supported responses - if (!string.IsNullOrEmpty(responseText)) + if (!string.IsNullOrWhiteSpace(analysisText)) { var request = new AnalysisRequest { ResponseId = response.Id, QuestionId = detail.QuestionId, - ResponseText = responseText, + ResponseText = analysisText, QuestionText = detail.Question?.Text ?? "" }; @@ -704,28 +807,24 @@ Respond with a JSON array of applicable categories: [""category1"", ""category2" } } + public async Task> BatchAnalyzeResponsesAsync(List requests) { var results = new List(); - var tasks = new List>(); - // Process in batches to avoid overwhelming the API - const int batchSize = 5; - for (int i = 0; i < requests.Count; i += batchSize) + foreach (var request in requests) { - var batch = requests.Skip(i).Take(batchSize); - foreach (var request in batch) + try { - tasks.Add(AnalyzeCompleteResponseAsync(request)); + // AnalyzeCompleteResponseAsync already checks DB first + // So cached ones return instantly, only new ones hit Claude + var result = await AnalyzeCompleteResponseAsync(request); + results.Add(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error analyzing response {ResponseId} for question {QuestionId}", request.ResponseId, request.QuestionId); } - - // Wait for current batch to complete before starting next - var batchResults = await Task.WhenAll(tasks); - results.AddRange(batchResults); - tasks.Clear(); - - // Small delay between batches to respect API limits - await Task.Delay(1000); } return results; @@ -733,20 +832,34 @@ Respond with a JSON array of applicable categories: [""category1"", ""category2" #endregion - #region Mental Health Specific Methods + #region Mental Health Intelligence public async Task> IdentifyHighRiskResponsesAsync(int questionnaireId) { try { - var overview = await GenerateQuestionnaireOverviewAsync(questionnaireId); - var allResults = await ExportAnonymizedAnalysisAsync(questionnaireId); + // Read from DB — no API calls + var highRiskEntities = await _context.ResponseAnalyses + .Where(ra => _context.Responses + .Where(r => r.QuestionnaireId == questionnaireId) + .Select(r => r.Id) + .Contains(ra.ResponseId)) + .Where(ra => ra.RiskLevel == "High" || ra.RiskLevel == "Critical" || ra.RequiresImmediateAttention) + .OrderByDescending(ra => ra.RiskScore) + .ToListAsync(); - return allResults - .Where(r => r.RiskAssessment != null && - (r.RiskAssessment.RiskLevel >= RiskLevel.High || r.RiskAssessment.RequiresImmediateAttention)) - .OrderByDescending(r => r.RiskAssessment!.RiskScore) - .ToList(); + // Convert entities to view models + var results = new List(); + foreach (var entity in highRiskEntities) + { + var loaded = await LoadResponseAnalysisFromDbAsync(entity.ResponseId, entity.QuestionId); + if (loaded != null) + { + results.Add(loaded); + } + } + + return results; } catch (Exception ex) { @@ -761,16 +874,14 @@ Respond with a JSON array of applicable categories: [""category1"", ""category2" { var responses = await _context.Responses .Include(r => r.ResponseDetails) - .ThenInclude(rd => rd.Question) + .ThenInclude(rd => rd.Question) .Where(r => r.QuestionnaireId == questionnaireId && r.SubmissionDate >= fromDate && r.SubmissionDate <= toDate) .OrderBy(r => r.SubmissionDate) .ToListAsync(); - // This would typically involve more complex trend analysis - // For now, return general insights - var insights = new List + return new List { new WorkplaceInsight { @@ -781,27 +892,20 @@ Respond with a JSON array of applicable categories: [""category1"", ""category2" IdentifiedAt = DateTime.UtcNow } }; - - return insights; } catch (Exception ex) { - _logger.LogError(ex, "Error analyzing mental health trends for QuestionnaireId: {QuestionnaireId}", questionnaireId); + _logger.LogError(ex, "Error analyzing trends for {QuestionnaireId}", questionnaireId); throw; } } - public async Task> CompareTeamMentalHealthAsync(int questionnaireId, List teamIdentifiers) + public async Task> CompareTeamMentalHealthAsync( + int questionnaireId, List teamIdentifiers) { - // This would require team identification in responses - // For now, return a basic implementation var result = new Dictionary(); - foreach (var team in teamIdentifiers) - { result[team] = await GenerateQuestionnaireOverviewAsync(questionnaireId); - } - return result; } @@ -821,12 +925,13 @@ Respond with a JSON array of applicable categories: [""category1"", ""category2" #endregion - #region Reporting Methods + #region Reporting public async Task GenerateDetailedAnalysisReportAsync(int questionnaireId) { try { + // Both of these now read from DB var overview = await GenerateQuestionnaireOverviewAsync(questionnaireId); var highRiskResponses = await IdentifyHighRiskResponsesAsync(questionnaireId); @@ -878,7 +983,10 @@ Respond with a JSON array of applicable categories: [""category1"", ""category2" { var responses = await _context.Responses .Include(r => r.ResponseDetails) - .ThenInclude(rd => rd.Question) + .ThenInclude(rd => rd.Question) + .Include(r => r.ResponseDetails) + .ThenInclude(rd => rd.ResponseAnswers) + .ThenInclude(ra => ra.Answer) .Where(r => r.QuestionnaireId == questionnaireId) .ToListAsync(); @@ -888,32 +996,15 @@ Respond with a JSON array of applicable categories: [""category1"", ""category2" { foreach (var detail in response.ResponseDetails) { - string responseText = ""; + var analysisText = BuildAnalysisText(detail); - // Handle text-based questions - if (!string.IsNullOrWhiteSpace(detail.TextResponse)) - { - responseText = detail.TextResponse; - } - // Handle CheckBox questions - else if (detail.QuestionType == QuestionType.CheckBox && detail.ResponseAnswers.Any()) - { - var selectedAnswers = detail.ResponseAnswers - .Select(ra => detail.Question.Answers.FirstOrDefault(a => a.Id == ra.AnswerId)?.Text) - .Where(text => !string.IsNullOrEmpty(text)) - .ToList(); - - responseText = $"Multiple Selection Question: {detail.Question.Text}\nSelected Options: {string.Join(", ", selectedAnswers)}\nAnalyze these selected workplace factors for mental health implications and patterns."; - } - - // Create analysis request if we have text to analyze - if (!string.IsNullOrEmpty(responseText)) + if (!string.IsNullOrWhiteSpace(analysisText)) { var request = new AnalysisRequest { ResponseId = response.Id, QuestionId = detail.QuestionId, - ResponseText = responseText, + ResponseText = analysisText, QuestionText = detail.Question?.Text ?? "" }; @@ -932,6 +1023,7 @@ Respond with a JSON array of applicable categories: [""category1"", ""category2" } } + public async Task GenerateManagementDashboardAsync(int questionnaireId) { return await GenerateQuestionnaireOverviewAsync(questionnaireId); @@ -939,58 +1031,217 @@ Respond with a JSON array of applicable categories: [""category1"", ""category2" #endregion - #region Utility Methods + #region Service Health - public async Task TestAzureLanguageServiceConnectionAsync() + public async Task TestClaudeConnectionAsync() { try { - await _textAnalyticsClient.AnalyzeSentimentAsync("Test connection"); - return true; + var response = await SendClaudeRequestAsync( + "You are a health check endpoint. Respond with exactly: OK", + "Health check. Respond with exactly: OK", + 0.0f, 10); + + return !string.IsNullOrWhiteSpace(response); } catch (Exception ex) { - _logger.LogError(ex, "Azure Language Service connection test failed"); - return false; - } - } - - public async Task TestAzureOpenAIConnectionAsync() - { - try - { - var messages = new List - { - new UserChatMessage("Test connection") - }; - - var chatOptions = new ChatCompletionOptions(); - - await _chatClient.CompleteChatAsync(messages, chatOptions); - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Azure OpenAI connection test failed"); + _logger.LogError(ex, "Claude connection test failed"); return false; } } public Task ValidateAnalysisRequestAsync(AnalysisRequest request) { - return Task.FromResult(!string.IsNullOrWhiteSpace(request.ResponseText) && - request.ResponseId > 0 && - request.QuestionId > 0); + return Task.FromResult( + !string.IsNullOrWhiteSpace(request.ResponseText) && + request.ResponseId >= 0 && + request.QuestionId >= 0); } public async Task> GetServiceHealthStatusAsync() { - var status = new Dictionary(); + return new Dictionary + { + ["Claude"] = await TestClaudeConnectionAsync() + }; + } - status["AzureLanguageService"] = await TestAzureLanguageServiceConnectionAsync(); - status["AzureOpenAI"] = await TestAzureOpenAIConnectionAsync(); + #endregion - return status; + #region Private Helpers + + /// + /// Extracts analyzable text from a ResponseDetail (text responses + checkbox selections). + /// + private static string ExtractAnalyzableText(ResponseDetail detail) + { + if (!string.IsNullOrWhiteSpace(detail.TextResponse)) + return detail.TextResponse; + + if (detail.QuestionType == QuestionType.CheckBox && detail.ResponseAnswers.Any()) + { + var selectedAnswers = detail.ResponseAnswers + .Select(ra => detail.Question?.Answers?.FirstOrDefault(a => a.Id == ra.AnswerId)?.Text) + .Where(text => !string.IsNullOrEmpty(text)) + .ToList(); + + if (selectedAnswers.Any()) + { + return $"Multiple Selection Question: {detail.Question?.Text}\n" + + $"Selected Options: {string.Join(", ", selectedAnswers)}\n" + + "Analyze these selected workplace factors for mental health implications and patterns."; + } + } + + return string.Empty; + } + + private static MentalHealthRiskAssessment DefaultRiskAssessment(string reason) + { + return new MentalHealthRiskAssessment + { + RiskLevel = RiskLevel.Moderate, + RiskScore = 0.5, + RiskIndicators = new List { reason }, + ProtectiveFactors = new List(), + RequiresImmediateAttention = false, + RecommendedAction = "Manual review recommended due to analysis error", + AssessedAt = DateTime.UtcNow + }; + } + + private static string Truncate(string? s, int max = 800) => + s == null ? "" : s.Length > max ? s[..max] : s; + + #endregion + + #region JSON Parsing Helpers + + private static readonly Regex JsonObjectRegex = new( + @"(\{(?:[^{}]|(?\{)|(?<-o>\}))*(?(o)(?!))\})", + RegexOptions.Singleline | RegexOptions.Compiled); + + private static JsonDocument? ParseLenientJson(string? content) + { + if (string.IsNullOrWhiteSpace(content)) return null; + + var json = content.Trim(); + + // Strip markdown code fences + if (json.StartsWith("```")) + json = Regex.Replace(json, @"^```(?:json)?\s*|\s*```$", "", + RegexOptions.IgnoreCase | RegexOptions.Singleline).Trim(); + + // Try direct parse first + if (json.StartsWith("{")) + { + try + { + return JsonDocument.Parse(json, new JsonDocumentOptions + { + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip + }); + } + catch { } + } + + // Try regex extraction + var match = JsonObjectRegex.Match(json); + if (!match.Success) return null; + + try + { + return JsonDocument.Parse(match.Value, new JsonDocumentOptions + { + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip + }); + } + catch + { + return null; + } + } + + private static T? DeserializeLenient(string? content) + { + if (string.IsNullOrWhiteSpace(content)) return default; + + var json = content.Trim(); + if (json.StartsWith("```")) + json = Regex.Replace(json, @"^```(?:json)?\s*|\s*```$", "", + RegexOptions.IgnoreCase | RegexOptions.Singleline).Trim(); + + try + { + return JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + AllowTrailingCommas = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString + }); + } + catch + { + // Try extracting JSON object/array + if (typeof(T) == typeof(List) && json.Contains("[")) + { + var start = json.IndexOf('['); + var end = json.LastIndexOf(']'); + if (start >= 0 && end > start) + { + try + { + return JsonSerializer.Deserialize(json[start..(end + 1)]); + } + catch { } + } + } + return default; + } + } + + private static string GetStringProp(JsonElement el, string name, string fallback) => + el.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String + ? prop.GetString() ?? fallback : fallback; + + private static double GetDoubleProp(JsonElement el, string name, double fallback) + { + if (!el.TryGetProperty(name, out var prop)) return fallback; + if (prop.ValueKind == JsonValueKind.Number && prop.TryGetDouble(out var d)) return d; + if (prop.ValueKind == JsonValueKind.String && double.TryParse(prop.GetString(), out var d2)) return d2; + return fallback; + } + + private static int GetIntProp(JsonElement el, string name, int fallback) + { + if (!el.TryGetProperty(name, out var prop)) return fallback; + if (prop.ValueKind == JsonValueKind.Number && prop.TryGetInt32(out var i)) return i; + if (prop.ValueKind == JsonValueKind.String && int.TryParse(prop.GetString(), out var i2)) return i2; + return fallback; + } + + private static bool GetBoolProp(JsonElement el, string name, bool fallback) + { + if (!el.TryGetProperty(name, out var prop)) return fallback; + if (prop.ValueKind == JsonValueKind.True) return true; + if (prop.ValueKind == JsonValueKind.False) return false; + if (prop.ValueKind == JsonValueKind.String && bool.TryParse(prop.GetString(), out var b)) return b; + return fallback; + } + + private static List GetStringArray(JsonElement el, string name) + { + if (!el.TryGetProperty(name, out var prop) || prop.ValueKind != JsonValueKind.Array) + return new List(); + + return prop.EnumerateArray() + .Select(x => x.GetString()) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(s => s!) + .ToList(); } #endregion @@ -1009,125 +1260,608 @@ Respond with a JSON array of applicable categories: [""category1"", ""category2" { if (disposing) { - // Dispose managed resources + _httpClient?.Dispose(); + _rateLimiter?.Dispose(); _context?.Dispose(); } _disposed = true; } } - - #endregion - - #region Private JSON Helpers (no new models) - - private static readonly Regex JsonObjectRegex = - new(@"(\{(?:[^{}]|(?\{)|(?<-o>\}))*(?(o)(?!))\})", - RegexOptions.Singleline | RegexOptions.Compiled); - - private static bool TryExtractJsonObject(string text, out string json) + private async Task SaveResponseAnalysisToDbAsync(ResponseAnalysisResult result) { - json = text?.Trim() ?? ""; - - // strip markdown code fences if present - if (json.StartsWith("```")) - { - json = Regex.Replace(json, "^```(?:json)?\\s*|\\s*```$", "", - RegexOptions.IgnoreCase | RegexOptions.Singleline).Trim(); - } - - if (json.StartsWith("{") && json.EndsWith("}")) - return true; - - var m = JsonObjectRegex.Match(json); - if (!m.Success) return false; - - json = m.Value; - return true; - } - - private static JsonDocument? ParseLenient(string content, out string? error) - { - error = null; - - if (!TryExtractJsonObject(content, out var jsonOnly)) - { - error = "No JSON object found in model response."; - return null; - } - - var docOptions = new JsonDocumentOptions - { - AllowTrailingCommas = true, - CommentHandling = JsonCommentHandling.Skip - }; - try { - return JsonDocument.Parse(jsonOnly, docOptions); - } - catch (Exception ex) - { - error = ex.Message; - return null; - } - } + // Detach any tracked entities to avoid conflicts + var trackedEntities = _context.ChangeTracker.Entries() + .Where(e => e.Entity.ResponseId == result.ResponseId && e.Entity.QuestionId == result.QuestionId) + .ToList(); - private static T? DeserializeLenient(string content, out string? error) - { - error = null; - - if (!TryExtractJsonObject(content, out var jsonOnly)) - { - // also try plain array (for CategorizeResponseAsync) - var trimmed = (content ?? "").Trim(); - if (typeof(T) == typeof(List) && trimmed.StartsWith("[") && trimmed.EndsWith("]")) + foreach (var tracked in trackedEntities) { - jsonOnly = trimmed; + tracked.State = EntityState.Detached; + } + + // Check if already exists + var existing = await _context.ResponseAnalyses + .AsNoTracking() + .FirstOrDefaultAsync(ra => ra.ResponseId == result.ResponseId && ra.QuestionId == result.QuestionId); + + if (existing != null) + { + // Update existing + existing.AnonymizedText = result.AnonymizedResponseText; + existing.SentimentLabel = result.SentimentAnalysis?.Sentiment ?? "Neutral"; + existing.SentimentConfidence = result.SentimentAnalysis?.ConfidenceScore ?? 0; + existing.PositiveScore = result.SentimentAnalysis?.PositiveScore ?? 0; + existing.NegativeScore = result.SentimentAnalysis?.NegativeScore ?? 0; + existing.NeutralScore = result.SentimentAnalysis?.NeutralScore ?? 0; + existing.RiskLevel = result.RiskAssessment?.RiskLevel.ToString() ?? "Low"; + existing.RiskScore = result.RiskAssessment?.RiskScore ?? 0; + existing.RequiresImmediateAttention = result.RiskAssessment?.RequiresImmediateAttention ?? false; + existing.RecommendedAction = result.RiskAssessment?.RecommendedAction ?? ""; + existing.RiskIndicatorsJson = JsonSerializer.Serialize(result.RiskAssessment?.RiskIndicators ?? new List()); + existing.ProtectiveFactorsJson = JsonSerializer.Serialize(result.RiskAssessment?.ProtectiveFactors ?? new List()); + existing.KeyPhrasesJson = JsonSerializer.Serialize(result.KeyPhrases?.KeyPhrases ?? new List()); + existing.WorkplaceFactorsJson = JsonSerializer.Serialize(result.KeyPhrases?.WorkplaceFactors ?? new List()); + existing.EmotionalIndicatorsJson = JsonSerializer.Serialize(result.KeyPhrases?.EmotionalIndicators ?? new List()); + existing.InsightsJson = JsonSerializer.Serialize(result.Insights); + existing.AnalyzedAt = DateTime.UtcNow; + + _context.ResponseAnalyses.Attach(existing); + _context.Entry(existing).State = EntityState.Modified; } else { - error = "No JSON object/array found in model response."; - return default; + // Create new + var entity = new Model.ResponseAnalysis + { + ResponseId = result.ResponseId, + QuestionId = result.QuestionId, + QuestionText = result.QuestionText, + AnonymizedText = result.AnonymizedResponseText, + SentimentLabel = result.SentimentAnalysis?.Sentiment ?? "Neutral", + SentimentConfidence = result.SentimentAnalysis?.ConfidenceScore ?? 0, + PositiveScore = result.SentimentAnalysis?.PositiveScore ?? 0, + NegativeScore = result.SentimentAnalysis?.NegativeScore ?? 0, + NeutralScore = result.SentimentAnalysis?.NeutralScore ?? 0, + RiskLevel = result.RiskAssessment?.RiskLevel.ToString() ?? "Low", + RiskScore = result.RiskAssessment?.RiskScore ?? 0, + RequiresImmediateAttention = result.RiskAssessment?.RequiresImmediateAttention ?? false, + RecommendedAction = result.RiskAssessment?.RecommendedAction ?? "", + RiskIndicatorsJson = JsonSerializer.Serialize(result.RiskAssessment?.RiskIndicators ?? new List()), + ProtectiveFactorsJson = JsonSerializer.Serialize(result.RiskAssessment?.ProtectiveFactors ?? new List()), + KeyPhrasesJson = JsonSerializer.Serialize(result.KeyPhrases?.KeyPhrases ?? new List()), + WorkplaceFactorsJson = JsonSerializer.Serialize(result.KeyPhrases?.WorkplaceFactors ?? new List()), + EmotionalIndicatorsJson = JsonSerializer.Serialize(result.KeyPhrases?.EmotionalIndicators ?? new List()), + InsightsJson = JsonSerializer.Serialize(result.Insights), + AnalyzedAt = DateTime.UtcNow + }; + + _context.ResponseAnalyses.Add(entity); } - } - var options = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true, - AllowTrailingCommas = true, - NumberHandling = JsonNumberHandling.AllowReadingFromString - }; - - try - { - return JsonSerializer.Deserialize(jsonOnly, options); + await _context.SaveChangesAsync(); } catch (Exception ex) { - error = ex.Message; - return default; + _logger.LogError(ex, "Error saving analysis to DB for ResponseId: {ResponseId}, QuestionId: {QuestionId}", result.ResponseId, result.QuestionId); } } - private static bool TryGetDoubleFlexible(JsonElement el, out double value) + private async Task LoadResponseAnalysisFromDbAsync(int responseId, int questionId) { - value = 0; - if (el.ValueKind == JsonValueKind.Number) return el.TryGetDouble(out value); - if (el.ValueKind == JsonValueKind.String) return double.TryParse(el.GetString(), out value); - return false; + try + { + var entity = await _context.ResponseAnalyses + .AsNoTracking() + .FirstOrDefaultAsync(ra => ra.ResponseId == responseId && ra.QuestionId == questionId); + + if (entity == null) return null; + + return new ResponseAnalysisResult + { + ResponseId = entity.ResponseId, + QuestionId = entity.QuestionId, + QuestionText = entity.QuestionText, + AnonymizedResponseText = entity.AnonymizedText, + SentimentAnalysis = new SentimentAnalysisResult + { + Sentiment = entity.SentimentLabel, + ConfidenceScore = entity.SentimentConfidence, + PositiveScore = entity.PositiveScore, + NegativeScore = entity.NegativeScore, + NeutralScore = entity.NeutralScore, + AnalyzedAt = entity.AnalyzedAt + }, + KeyPhrases = new KeyPhrasesResult + { + KeyPhrases = JsonSerializer.Deserialize>(entity.KeyPhrasesJson) ?? new List(), + WorkplaceFactors = JsonSerializer.Deserialize>(entity.WorkplaceFactorsJson) ?? new List(), + EmotionalIndicators = JsonSerializer.Deserialize>(entity.EmotionalIndicatorsJson) ?? new List(), + ExtractedAt = entity.AnalyzedAt + }, + RiskAssessment = new MentalHealthRiskAssessment + { + RiskLevel = Enum.TryParse(entity.RiskLevel, out var rl) ? rl : RiskLevel.Low, + RiskScore = entity.RiskScore, + RequiresImmediateAttention = entity.RequiresImmediateAttention, + RecommendedAction = entity.RecommendedAction, + RiskIndicators = JsonSerializer.Deserialize>(entity.RiskIndicatorsJson) ?? new List(), + ProtectiveFactors = JsonSerializer.Deserialize>(entity.ProtectiveFactorsJson) ?? new List(), + AssessedAt = entity.AnalyzedAt + }, + Insights = JsonSerializer.Deserialize>(entity.InsightsJson) ?? new List(), + AnalyzedAt = entity.AnalyzedAt, + IsAnalysisComplete = true + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading analysis from DB for ResponseId: {ResponseId}, QuestionId: {QuestionId}", responseId, questionId); + return null; + } + } + private async Task SaveSnapshotToDbAsync(QuestionnaireAnalysisOverview overview) + { + try + { + var existing = await _context.QuestionnaireAnalysisSnapshots + .FirstOrDefaultAsync(s => s.QuestionnaireId == overview.QuestionnaireId); + + if (existing != null) + { + existing.TotalResponses = overview.TotalResponses; + existing.AnalyzedResponses = overview.AnalyzedResponses; + existing.OverallPositiveSentiment = overview.OverallPositiveSentiment; + existing.OverallNegativeSentiment = overview.OverallNegativeSentiment; + existing.OverallNeutralSentiment = overview.OverallNeutralSentiment; + existing.LowRiskCount = overview.LowRiskResponses; + existing.ModerateRiskCount = overview.ModerateRiskResponses; + existing.HighRiskCount = overview.HighRiskResponses; + existing.CriticalRiskCount = overview.CriticalRiskResponses; + existing.ExecutiveSummary = overview.ExecutiveSummary; + existing.TopWorkplaceIssuesJson = JsonSerializer.Serialize(overview.TopWorkplaceIssues); + existing.MostCommonKeyPhrasesJson = JsonSerializer.Serialize(overview.MostCommonKeyPhrases); + existing.GeneratedAt = DateTime.UtcNow; + + _context.QuestionnaireAnalysisSnapshots.Update(existing); + } + else + { + var entity = new Model.QuestionnaireAnalysisSnapshot + { + QuestionnaireId = overview.QuestionnaireId, + TotalResponses = overview.TotalResponses, + AnalyzedResponses = overview.AnalyzedResponses, + OverallPositiveSentiment = overview.OverallPositiveSentiment, + OverallNegativeSentiment = overview.OverallNegativeSentiment, + OverallNeutralSentiment = overview.OverallNeutralSentiment, + LowRiskCount = overview.LowRiskResponses, + ModerateRiskCount = overview.ModerateRiskResponses, + HighRiskCount = overview.HighRiskResponses, + CriticalRiskCount = overview.CriticalRiskResponses, + ExecutiveSummary = overview.ExecutiveSummary, + TopWorkplaceIssuesJson = JsonSerializer.Serialize(overview.TopWorkplaceIssues), + MostCommonKeyPhrasesJson = JsonSerializer.Serialize(overview.MostCommonKeyPhrases), + GeneratedAt = DateTime.UtcNow + }; + + _context.QuestionnaireAnalysisSnapshots.Add(entity); + } + + await _context.SaveChangesAsync(); + _logger.LogInformation("Saved snapshot for QuestionnaireId: {QuestionnaireId}", overview.QuestionnaireId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving snapshot for QuestionnaireId: {QuestionnaireId}", overview.QuestionnaireId); + } + } + private async Task BuildAnalysisTextAsync(ResponseDetail detail) + { + try + { + var questionText = detail.Question?.Text ?? "Unknown question"; + var questionType = detail.QuestionType; + + // For text-based questions, use TextResponse directly + if (questionType == QuestionType.Text || questionType == QuestionType.Open_ended) + { + return !string.IsNullOrWhiteSpace(detail.TextResponse) ? detail.TextResponse : null; + } + + // For slider, use the numeric value with context + if (questionType == QuestionType.Slider) + { + if (!string.IsNullOrWhiteSpace(detail.TextResponse)) + { + return $"Question: {questionText}\nAnswer: {detail.TextResponse}"; + } + return null; + } + + // For Rating, use TextResponse if available + if (questionType == QuestionType.Rating) + { + if (!string.IsNullOrWhiteSpace(detail.TextResponse)) + { + return $"Rating Question: {questionText}\nRating given: {detail.TextResponse}"; + } + return null; + } + + // For TrueFalse, get the selected answer + if (questionType == QuestionType.TrueFalse) + { + var selectedAnswers = await GetSelectedAnswerTextsAsync(detail); + if (selectedAnswers.Any()) + { + return $"True/False Question: {questionText}\nAnswer: {selectedAnswers.First()}"; + } + return null; + } + + // For Multiple Choice (single selection) + if (questionType == QuestionType.Multiple_choice) + { + var selectedAnswers = await GetSelectedAnswerTextsAsync(detail); + var otherText = !string.IsNullOrWhiteSpace(detail.TextResponse) ? $"\nAdditional comment: {detail.TextResponse}" : ""; + if (selectedAnswers.Any()) + { + return $"Multiple Choice Question: {questionText}\nSelected: {string.Join(", ", selectedAnswers)}{otherText}"; + } + return null; + } + + // For CheckBox (multiple selection) + if (questionType == QuestionType.CheckBox) + { + var selectedAnswers = await GetSelectedAnswerTextsAsync(detail); + var otherText = !string.IsNullOrWhiteSpace(detail.TextResponse) ? $"\nAdditional comment: {detail.TextResponse}" : ""; + if (selectedAnswers.Any()) + { + return $"Multiple Selection Question: {questionText}\nSelected Options: {string.Join(", ", selectedAnswers)}{otherText}\nAnalyze these selected workplace factors for mental health implications and patterns."; + } + return null; + } + + // For Likert scale + if (questionType == QuestionType.Likert) + { + var selectedAnswers = await GetSelectedAnswerTextsAsync(detail); + if (selectedAnswers.Any()) + { + return $"Likert Scale Question: {questionText}\nResponse: {string.Join(", ", selectedAnswers)}"; + } + return null; + } + + // For Matrix + if (questionType == QuestionType.Matrix) + { + var selectedAnswers = await GetSelectedAnswerTextsAsync(detail); + if (selectedAnswers.Any()) + { + return $"Matrix Question: {questionText}\nResponses: {string.Join("; ", selectedAnswers)}"; + } + return null; + } + + // For Ranking + if (questionType == QuestionType.Ranking) + { + var selectedAnswers = await GetSelectedAnswerTextsAsync(detail); + if (selectedAnswers.Any()) + { + return $"Ranking Question: {questionText}\nRanked order: {string.Join(" > ", selectedAnswers)}"; + } + return null; + } + + // For Demographic + if (questionType == QuestionType.Demographic) + { + if (!string.IsNullOrWhiteSpace(detail.TextResponse)) + { + return $"Demographic Question: {questionText}\nAnswer: {detail.TextResponse}"; + } + var selectedAnswers = await GetSelectedAnswerTextsAsync(detail); + if (selectedAnswers.Any()) + { + return $"Demographic Question: {questionText}\nAnswer: {string.Join(", ", selectedAnswers)}"; + } + return null; + } + + // For Image-based questions + if (questionType == QuestionType.Image) + { + var selectedAnswers = await GetSelectedAnswerTextsAsync(detail); + if (selectedAnswers.Any()) + { + return $"Image Selection Question: {questionText}\nSelected: {string.Join(", ", selectedAnswers)}"; + } + return null; + } + + // Fallback — use TextResponse if available + if (!string.IsNullOrWhiteSpace(detail.TextResponse)) + { + return detail.TextResponse; + } + + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error building analysis text for ResponseDetail {DetailId}", detail.Id); + return null; + } } - private static bool TryGetBoolFlexible(JsonElement el, out bool value) + private async Task> GetSelectedAnswerTextsAsync(ResponseDetail detail) { - value = false; - if (el.ValueKind == JsonValueKind.True) { value = true; return true; } - if (el.ValueKind == JsonValueKind.False) { value = false; return true; } - if (el.ValueKind == JsonValueKind.String) return bool.TryParse(el.GetString(), out value); - return false; + try + { + if (detail.ResponseAnswers == null || !detail.ResponseAnswers.Any()) + { + // Load from DB if not included + var answerIds = await _context.Set() + .Where(ra => ra.ResponseDetailId == detail.Id) + .Select(ra => ra.AnswerId) + .ToListAsync(); + + if (!answerIds.Any()) return new List(); + + var answerTexts = await _context.Set() + .Where(a => answerIds.Contains(a.Id)) + .Select(a => a.Text ?? "") + .Where(t => !string.IsNullOrWhiteSpace(t)) + .ToListAsync(); + + return answerTexts; + } + + // Use already-loaded ResponseAnswers + var ids = detail.ResponseAnswers.Select(ra => ra.AnswerId).ToList(); + var texts = await _context.Set() + .Where(a => ids.Contains(a.Id)) + .Select(a => a.Text ?? "") + .Where(t => !string.IsNullOrWhiteSpace(t)) + .ToListAsync(); + + return texts; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting selected answers for ResponseDetail {DetailId}", detail.Id); + return new List(); + } } + private async Task> AnalyzeAllResponsesInOneCallAsync( + List<(int ResponseId, int QuestionId, string QuestionText, string AnswerText)> qaItems) + { + var results = new List(); + if (!qaItems.Any()) return results; + try + { + var qaBlock = new StringBuilder(); + for (int i = 0; i < qaItems.Count; i++) + { + qaBlock.AppendLine($"[Response {i + 1}]"); + qaBlock.AppendLine($"Question: {qaItems[i].QuestionText}"); + qaBlock.AppendLine($"Answer: {qaItems[i].AnswerText}"); + qaBlock.AppendLine(); + } + var systemPrompt = @"You are a workplace mental health professional. Always respond with a single valid JSON array only. No markdown, no code fences, no explanations."; + + var userPrompt = $@"Analyze each of the following survey responses individually. + +For EACH response, provide: +1. Sentiment (Positive, Negative, Neutral, Mixed) +2. Sentiment confidence scores (positive, negative, neutral — each 0.0 to 1.0, must sum to ~1.0) +3. Risk Level (Low, Moderate, High, Critical) +4. Risk Score (0.0 to 1.0) +5. Requires Immediate Attention (true/false) +6. Recommended Action +7. Risk Indicators (list of concerns) +8. Protective Factors (list of positives) +9. Key Phrases +10. Workplace Factors +11. Emotional Indicators +12. Workplace Insights — each with category, issue, recommended intervention, priority (1-5), affected areas + +Survey Responses: +{qaBlock} + +Return a JSON array with one object per response in the same order: +[ + {{ + ""responseIndex"": 0, + ""sentiment"": ""Neutral"", + ""positiveScore"": 0.05, + ""negativeScore"": 0.05, + ""neutralScore"": 0.90, + ""riskLevel"": ""Low"", + ""riskScore"": 0.1, + ""requiresImmediateAttention"": false, + ""recommendedAction"": ""specific action"", + ""riskIndicators"": [""indicator1""], + ""protectiveFactors"": [""factor1""], + ""keyPhrases"": [""phrase1""], + ""workplaceFactors"": [""factor1""], + ""emotionalIndicators"": [""indicator1""], + ""insights"": [ + {{ + ""category"": ""Category Name"", + ""issue"": ""specific issue"", + ""recommendedIntervention"": ""specific intervention"", + ""priority"": 3, + ""affectedAreas"": [""area1""] + }} + ] + }} +] + +Important: +- Analyze EACH response individually based on its question context +- Return exactly one object per response in the same order +- Respond with ONLY the JSON array"; + + _logger.LogInformation("Sending combined analysis request for {Count} responses to Claude API", qaItems.Count); + + var content = await SendClaudeRequestAsync(systemPrompt, userPrompt, 0.3f, 8000); + + _logger.LogInformation("Received combined analysis response from Claude API"); + + var parsedResults = ParseCombinedAnalysisResponse(content, qaItems); + results.AddRange(parsedResults); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in combined analysis call. Falling back to individual analysis."); + + foreach (var item in qaItems) + { + try + { + var request = new AnalysisRequest + { + ResponseId = item.ResponseId, + QuestionId = item.QuestionId, + ResponseText = item.AnswerText, + QuestionText = item.QuestionText + }; + results.Add(await AnalyzeCompleteResponseAsync(request)); + } + catch (Exception innerEx) + { + _logger.LogError(innerEx, "Fallback analysis failed for QuestionId: {QuestionId}", item.QuestionId); + } + } + } + + return results; + } + + private List ParseCombinedAnalysisResponse( + string content, + List<(int ResponseId, int QuestionId, string QuestionText, string AnswerText)> qaItems) + { + var results = new List(); + + try + { + var json = content?.Trim() ?? ""; + if (json.StartsWith("```")) + json = Regex.Replace(json, @"^```(?:json)?\s*|\s*```$", "", + RegexOptions.IgnoreCase | RegexOptions.Singleline).Trim(); + + using var doc = JsonDocument.Parse(json, new JsonDocumentOptions + { + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip + }); + + var array = doc.RootElement; + if (array.ValueKind != JsonValueKind.Array) + { + _logger.LogWarning("Combined analysis response is not a JSON array"); + return results; + } + + int index = 0; + foreach (var item in array.EnumerateArray()) + { + if (index >= qaItems.Count) break; + var qa = qaItems[index]; + + var result = new ResponseAnalysisResult + { + ResponseId = qa.ResponseId, + QuestionId = qa.QuestionId, + QuestionText = qa.QuestionText, + ResponseText = qa.AnswerText, + AnonymizedResponseText = qa.AnswerText, + AnalyzedAt = DateTime.UtcNow, + IsAnalysisComplete = true + }; + + // Sentiment + result.SentimentAnalysis = new SentimentAnalysisResult + { + Sentiment = GetStringProp(item, "sentiment", "Neutral"), + PositiveScore = GetDoubleProp(item, "positiveScore", 0), + NegativeScore = GetDoubleProp(item, "negativeScore", 0), + NeutralScore = GetDoubleProp(item, "neutralScore", 0), + ConfidenceScore = Math.Max(GetDoubleProp(item, "positiveScore", 0), + Math.Max(GetDoubleProp(item, "negativeScore", 0), GetDoubleProp(item, "neutralScore", 0))), + AnalyzedAt = DateTime.UtcNow + }; + + // Risk + var riskLevelStr = GetStringProp(item, "riskLevel", "Low"); + result.RiskAssessment = new MentalHealthRiskAssessment + { + RiskLevel = Enum.TryParse(riskLevelStr, true, out var rle) ? rle : RiskLevel.Low, + RiskScore = Math.Clamp(GetDoubleProp(item, "riskScore", 0), 0.0, 1.0), + RequiresImmediateAttention = GetBoolProp(item, "requiresImmediateAttention", false), + RecommendedAction = GetStringProp(item, "recommendedAction", ""), + RiskIndicators = GetStringArray(item, "riskIndicators"), + ProtectiveFactors = GetStringArray(item, "protectiveFactors"), + AssessedAt = DateTime.UtcNow + }; + + // Key Phrases + result.KeyPhrases = new KeyPhrasesResult + { + KeyPhrases = GetStringArray(item, "keyPhrases"), + WorkplaceFactors = GetStringArray(item, "workplaceFactors"), + EmotionalIndicators = GetStringArray(item, "emotionalIndicators"), + ExtractedAt = DateTime.UtcNow + }; + + // Insights + var insights = new List(); + if (item.TryGetProperty("insights", out var insArr) && insArr.ValueKind == JsonValueKind.Array) + { + foreach (var ins in insArr.EnumerateArray()) + { + insights.Add(new WorkplaceInsight + { + Category = GetStringProp(ins, "category", "General"), + Issue = GetStringProp(ins, "issue", ""), + RecommendedIntervention = GetStringProp(ins, "recommendedIntervention", ""), + Priority = Math.Clamp(GetIntProp(ins, "priority", 3), 1, 5), + AffectedAreas = GetStringArray(ins, "affectedAreas"), + IdentifiedAt = DateTime.UtcNow + }); + } + } + result.Insights = insights; + + results.Add(result); + index++; + } + + _logger.LogInformation("Successfully parsed {Count} results from combined analysis", results.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error parsing combined analysis response. Raw (first 1000): {Raw}", + content?.Length > 1000 ? content[..1000] : content); + } + + return results; + } #endregion + + + } -} + + +} \ No newline at end of file diff --git a/Services/Implemnetation/UserTrajectoryService.cs b/Services/Implemnetation/UserTrajectoryService.cs new file mode 100644 index 0000000..f4732a0 --- /dev/null +++ b/Services/Implemnetation/UserTrajectoryService.cs @@ -0,0 +1,585 @@ + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.EntityFrameworkCore; +using Services.AIViewModel; +using Services.Interaces; +using Data; +using Model; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace Services.Implemnetation +{ + public class UserTrajectoryService : IUserTrajectoryService + { + private readonly SurveyContext _context; + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + private readonly string _claudeApiKey; + private readonly string _claudeModel; + + public UserTrajectoryService( + IConfiguration configuration, + ILogger logger, + SurveyContext context) + { + _logger = logger; + _context = context; + + // Claude API configuration + _claudeApiKey = configuration["Claude:ApiKey"] + ?? throw new ArgumentNullException("Claude:ApiKey is missing from configuration"); + _claudeModel = configuration["Claude:Model"] ?? "claude-sonnet-4-20250514"; + + _httpClient = new HttpClient(); + _httpClient.DefaultRequestHeaders.Add("x-api-key", _claudeApiKey); + _httpClient.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01"); + + _logger.LogInformation("UserTrajectoryService initialized with Claude API (Model: {Model})", _claudeModel); + } + + // ═══════════════════════════════════════════ + // PUBLIC METHODS + // ═══════════════════════════════════════════ + + public async Task GetOrAnalyzeTrajectoryAsync(string userEmail) + { + try + { + // 1. Count current responses + var currentResponses = await GetUserResponses(userEmail); + var currentCount = currentResponses.Count; + + if (currentCount == 0) + return CreateEmptyResult(); + + // 2. Check cache + var cache = await _context.UserTrajectoryCaches + .FirstOrDefaultAsync(c => c.UserEmail == userEmail); + + if (cache != null && cache.AnalyzedResponseCount == currentCount) + { + // Cache is fresh — return it + _logger.LogInformation("Returning cached trajectory for {Email} ({Count} responses)", userEmail, currentCount); + return DeserializeResult(cache.TrajectoryJson); + } + + // 3. Need to analyze + UserTrajectoryAnalysis result; + + if (cache == null) + { + // First-time analysis — send ALL responses + _logger.LogInformation("First trajectory analysis for {Email} ({Count} responses)", userEmail, currentCount); + var responseText = BuildFullResponseText(currentResponses); + result = await CallClaudeFullAnalysis(responseText, currentCount); + } + else + { + // Incremental — send only NEW responses + previous summary + var newResponses = currentResponses + .Where(r => r.SubmissionDate > cache.LastResponseDate) + .OrderBy(r => r.SubmissionDate) + .ToList(); + + _logger.LogInformation("Incremental trajectory for {Email} ({New} new of {Total} total)", + userEmail, newResponses.Count, currentCount); + + var newResponseText = BuildFullResponseText(newResponses); + result = await CallClaudeIncrementalAnalysis( + newResponseText, newResponses.Count, + cache.PreviousSummary ?? "", currentCount); + + result.IsIncremental = true; + } + + result.TotalResponsesAnalyzed = currentCount; + result.AnalyzedAt = DateTime.UtcNow; + + // 4. Save to cache + var latestDate = currentResponses.Max(r => r.SubmissionDate); + var json = SerializeResult(result); + var summary = BuildSummaryForCache(result); + + if (cache == null) + { + _context.UserTrajectoryCaches.Add(new UserTrajectoryCache + { + UserEmail = userEmail, + AnalyzedResponseCount = currentCount, + LastResponseDate = latestDate, + TrajectoryJson = json, + PreviousSummary = summary, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }); + } + else + { + cache.AnalyzedResponseCount = currentCount; + cache.LastResponseDate = latestDate; + cache.TrajectoryJson = json; + cache.PreviousSummary = summary; + cache.UpdatedAt = DateTime.UtcNow; + } + + await _context.SaveChangesAsync(); + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error analyzing trajectory for {Email}", userEmail); + throw; + } + } + + public async Task ForceReanalyzeTrajectoryAsync(string userEmail) + { + var cache = await _context.UserTrajectoryCaches + .FirstOrDefaultAsync(c => c.UserEmail == userEmail); + + if (cache != null) + { + _context.UserTrajectoryCaches.Remove(cache); + await _context.SaveChangesAsync(); + } + + return await GetOrAnalyzeTrajectoryAsync(userEmail); + } + + public async Task<(bool HasCache, bool IsStale, int CachedCount, int CurrentCount)> CheckCacheStatusAsync(string userEmail) + { + var currentCount = await _context.Responses.CountAsync(r => r.UserEmail == userEmail); + var cache = await _context.UserTrajectoryCaches.FirstOrDefaultAsync(c => c.UserEmail == userEmail); + + if (cache == null) + return (false, true, 0, currentCount); + + var isStale = cache.AnalyzedResponseCount < currentCount; + return (true, isStale, cache.AnalyzedResponseCount, currentCount); + } + + // ═══════════════════════════════════════════ + // DATA BUILDING — Complete response data + // ═══════════════════════════════════════════ + + private async Task> GetUserResponses(string userEmail) + { + return await _context.Responses + .Include(r => r.Questionnaire) + .ThenInclude(q => q.Questions) + .ThenInclude(q => q.Answers) + .Include(r => r.ResponseDetails) + .ThenInclude(rd => rd.Question) + .ThenInclude(q => q.Answers) + .Include(r => r.ResponseDetails) + .ThenInclude(rd => rd.ResponseAnswers) + .ThenInclude(ra => ra.Answer) + .Where(r => r.UserEmail == userEmail) + .OrderBy(r => r.SubmissionDate) + .ToListAsync(); + } + + /// + /// Builds a complete text representation of all responses. + /// Includes ALL questions with types, available options, and user answers. + /// NO personal data (name, email) is included. + /// + private string BuildFullResponseText(List responses) + { + var sb = new StringBuilder(); + int responseNum = 0; + + foreach (var response in responses) + { + responseNum++; + sb.AppendLine($"--- Response {responseNum}: \"{response.Questionnaire?.Title ?? "Unknown Survey"}\" (Submitted: {response.SubmissionDate:MMMM dd, yyyy}) ---"); + sb.AppendLine(); + + var questions = response.Questionnaire?.Questions?.OrderBy(q => q.Id).ToList() + ?? new List(); + + int qNum = 0; + foreach (var question in questions) + { + qNum++; + sb.AppendLine($" Q{qNum}: {question.Text} [{question.Type}]"); + + // Show available options for non-text questions + if (question.Answers != null && question.Answers.Any()) + { + var optionTexts = question.Answers.Select(a => a.Text).ToList(); + sb.AppendLine($" Options: {string.Join(", ", optionTexts)}"); + } + + // Find the user's response detail for this question + var detail = response.ResponseDetails?.FirstOrDefault(d => d.QuestionId == question.Id); + + if (detail == null) + { + sb.AppendLine($" → (No response recorded)"); + } + else + { + // Text response + if (!string.IsNullOrWhiteSpace(detail.TextResponse)) + { + sb.AppendLine($" → Text Answer: \"{detail.TextResponse}\""); + } + + // Selected answers (checkbox, radio, multiple choice) + if (detail.ResponseAnswers != null && detail.ResponseAnswers.Any()) + { + var selectedTexts = detail.ResponseAnswers + .Where(ra => ra.Answer != null) + .Select(ra => ra.Answer!.Text) + .ToList(); + + if (selectedTexts.Any()) + { + sb.AppendLine($" → Selected: {string.Join(", ", selectedTexts)}"); + } + } + + // Other/custom text + if (!string.IsNullOrWhiteSpace(detail.OtherText)) + { + sb.AppendLine($" → Custom Response: \"{detail.OtherText}\""); + } + + // Status + if (detail.Status == ResponseStatus.Skipped) + { + sb.AppendLine($" → (Skipped{(string.IsNullOrEmpty(detail.SkipReason) ? "" : $": {detail.SkipReason}")})"); + } + else if (detail.Status == ResponseStatus.Shown) + { + sb.AppendLine($" → (Shown but not answered)"); + } + } + + sb.AppendLine(); + } + + sb.AppendLine(); + } + + return sb.ToString(); + } + + // ═══════════════════════════════════════════ + // CLAUDE API CALLS + // ═══════════════════════════════════════════ + + private async Task CallClaudeFullAnalysis(string responseText, int responseCount) + { + var isMultiple = responseCount > 1; + var trajectoryInstruction = isMultiple + ? "Analyze the TRAJECTORY of this employee's mental health over time. Compare responses chronologically. Determine if their wellbeing is improving, stable, declining, or fluctuating." + : "This is a SINGLE initial assessment. Analyze the employee's current mental health state as a baseline evaluation. Set trajectoryDirection to 'Initial'."; + + var systemPrompt = "You are a senior workplace mental health consultant. You analyze anonymized employee survey responses to assess mental health trajectories over time. You NEVER receive personal data — only survey questions, answer options, and the employee's responses. Always respond with a SINGLE valid JSON object. No markdown, no code fences, no explanations outside the JSON."; + + var userPrompt = $@"{trajectoryInstruction} + +Employee Survey Data ({responseCount} response{(responseCount > 1 ? "s" : "")}): + +{responseText} + +Respond with this exact JSON structure: +{{ + ""trajectoryDirection"": ""Improving|Stable|Declining|Fluctuating|Initial"", + ""trajectoryScore"": 0, + ""scoreChange"": 0, + ""overallRiskLevel"": ""Low|Moderate|High|Critical"", + ""executiveSummary"": ""2-3 sentence overview"", + ""detailedAnalysis"": ""Detailed paragraph with specific observations"", + ""responseSnapshots"": [ + {{ + ""responseDate"": ""MMMM dd, yyyy"", + ""questionnaireName"": ""name"", + ""wellnessScore"": 0, + ""riskLevel"": ""Low|Moderate|High|Critical"", + ""sentimentLabel"": ""Positive|Negative|Mixed|Neutral"", + ""keyThemes"": [""theme1"", ""theme2""], + ""briefSummary"": ""One sentence"" + }} + ], + ""patternInsights"": [ + {{ + ""pattern"": ""Description of cross-response pattern"", + ""severity"": ""High|Medium|Low"", + ""firstSeen"": ""date"", + ""stillPresent"": true + }} + ], + ""strengthFactors"": [ + {{ ""factor"": ""Positive observation"" }} + ], + ""concernFactors"": [ + {{ ""concern"": ""Concern description"", ""urgency"": ""Immediate|Monitor|Low"" }} + ], + ""recommendations"": [ + {{ ""action"": ""Specific action"", ""priority"": ""Urgent|High|Normal"", ""category"": ""Workplace|Personal|Professional Support"" }} + ], + ""timelineNarrative"": ""A professional narrative describing the employee's mental health journey suitable for case reports"" +}} + +IMPORTANT: trajectoryScore and wellnessScore must be integers 0-100 where 100 is excellent mental health. scoreChange is the difference between first and last response scores (positive = improvement). Provide at least 2 patternInsights, 2 strengthFactors, 2 concernFactors, and 3 recommendations."; + + return await CallClaudeApi(systemPrompt, userPrompt); + } + + private async Task CallClaudeIncrementalAnalysis( + string newResponseText, int newCount, string previousSummary, int totalCount) + { + var systemPrompt = "You are a senior workplace mental health consultant. You are updating an existing employee trajectory analysis with new survey data. You NEVER receive personal data — only survey questions, answer options, and responses. Always respond with a SINGLE valid JSON object. No markdown, no code fences."; + + var userPrompt = $@"PREVIOUS ANALYSIS SUMMARY (based on {totalCount - newCount} earlier responses): +{previousSummary} + +NEW SURVEY DATA ({newCount} new response{(newCount > 1 ? "s" : "")} — total is now {totalCount}): + +{newResponseText} + +Update the trajectory analysis incorporating this new data with the existing history. The trajectoryScore and scoreChange should reflect the FULL trajectory (all {totalCount} responses), not just the new ones. + +Respond with this exact JSON structure: +{{ + ""trajectoryDirection"": ""Improving|Stable|Declining|Fluctuating"", + ""trajectoryScore"": 0, + ""scoreChange"": 0, + ""overallRiskLevel"": ""Low|Moderate|High|Critical"", + ""executiveSummary"": ""2-3 sentence overview of full trajectory"", + ""detailedAnalysis"": ""Detailed paragraph incorporating both old and new data"", + ""responseSnapshots"": [ + {{ + ""responseDate"": ""date"", + ""questionnaireName"": ""name"", + ""wellnessScore"": 0, + ""riskLevel"": ""Low|Moderate|High|Critical"", + ""sentimentLabel"": ""Positive|Negative|Mixed|Neutral"", + ""keyThemes"": [""theme1""], + ""briefSummary"": ""One sentence"" + }} + ], + ""patternInsights"": [ + {{ ""pattern"": ""description"", ""severity"": ""High|Medium|Low"", ""firstSeen"": ""date"", ""stillPresent"": true }} + ], + ""strengthFactors"": [{{ ""factor"": ""observation"" }}], + ""concernFactors"": [{{ ""concern"": ""description"", ""urgency"": ""Immediate|Monitor|Low"" }}], + ""recommendations"": [{{ ""action"": ""action"", ""priority"": ""Urgent|High|Normal"", ""category"": ""Workplace|Personal|Professional Support"" }}], + ""timelineNarrative"": ""Updated professional narrative for full trajectory"" +}} + +IMPORTANT: responseSnapshots should include entries for ALL {totalCount} responses (use the previous summary to reconstruct earlier snapshots). Scores are 0-100 integers."; + + return await CallClaudeApi(systemPrompt, userPrompt); + } + + // ═══════════════════════════════════════════ + // CLAUDE HTTP API CALL + // ═══════════════════════════════════════════ + + private async Task CallClaudeApi(string systemPrompt, string userPrompt) + { + var requestBody = new + { + model = _claudeModel, + max_tokens = 4096, + system = systemPrompt, + messages = new[] + { + new { role = "user", content = userPrompt } + } + }; + + var jsonRequest = JsonSerializer.Serialize(requestBody); + var httpContent = new StringContent(jsonRequest, Encoding.UTF8, "application/json"); + + var httpResponse = await _httpClient.PostAsync("https://api.anthropic.com/v1/messages", httpContent); + + if (!httpResponse.IsSuccessStatusCode) + { + var errorBody = await httpResponse.Content.ReadAsStringAsync(); + _logger.LogError("Claude API error {StatusCode}: {Error}", httpResponse.StatusCode, errorBody); + throw new Exception($"Claude API returned {httpResponse.StatusCode}: {errorBody}"); + } + + var responseJson = await httpResponse.Content.ReadAsStringAsync(); + _logger.LogInformation("Claude API response length: {Length} chars", responseJson.Length); + + // Extract the text content from Claude's response + var claudeResponse = JsonSerializer.Deserialize(responseJson); + var contentArray = claudeResponse.GetProperty("content"); + var textContent = ""; + + foreach (var block in contentArray.EnumerateArray()) + { + if (block.GetProperty("type").GetString() == "text") + { + textContent = block.GetProperty("text").GetString() ?? ""; + break; + } + } + + if (string.IsNullOrEmpty(textContent)) + { + _logger.LogWarning("Claude returned empty text content"); + return CreateFallbackResult(); + } + + // Parse the JSON from Claude's text response + var result = DeserializeLenient(textContent, out var error); + + if (result == null) + { + _logger.LogWarning("Failed to parse trajectory JSON from Claude: {Error}. Raw (first 1000): {Raw}", + error, textContent.Length > 1000 ? textContent[..1000] : textContent); + return CreateFallbackResult(); + } + + return result; + } + + // ═══════════════════════════════════════════ + // CACHE HELPERS + // ═══════════════════════════════════════════ + + private string BuildSummaryForCache(UserTrajectoryAnalysis result) + { + var sb = new StringBuilder(); + sb.AppendLine($"Trajectory: {result.TrajectoryDirection} | Score: {result.TrajectoryScore}/100 | Risk: {result.OverallRiskLevel}"); + sb.AppendLine($"Summary: {result.ExecutiveSummary}"); + + if (result.ResponseSnapshots.Any()) + { + sb.AppendLine("Response History:"); + foreach (var snap in result.ResponseSnapshots) + { + sb.AppendLine($" - {snap.ResponseDate} ({snap.QuestionnaireName}): Wellness {snap.WellnessScore}/100, {snap.RiskLevel} risk, {snap.SentimentLabel} sentiment. {snap.BriefSummary}"); + } + } + + if (result.PatternInsights.Any()) + { + sb.AppendLine("Key Patterns: " + string.Join("; ", result.PatternInsights.Select(p => $"{p.Pattern} ({p.Severity})"))); + } + + if (result.ConcernFactors.Any()) + { + sb.AppendLine("Concerns: " + string.Join("; ", result.ConcernFactors.Select(c => $"{c.Concern} ({c.Urgency})"))); + } + + if (result.StrengthFactors.Any()) + { + sb.AppendLine("Strengths: " + string.Join("; ", result.StrengthFactors.Select(s => s.Factor))); + } + + return sb.ToString(); + } + + private UserTrajectoryAnalysis CreateEmptyResult() + { + return new UserTrajectoryAnalysis + { + TrajectoryDirection = "Initial", + TrajectoryScore = 0, + OverallRiskLevel = "Low", + ExecutiveSummary = "No survey responses found for this user.", + AnalyzedAt = DateTime.UtcNow + }; + } + + private UserTrajectoryAnalysis CreateFallbackResult() + { + return new UserTrajectoryAnalysis + { + TrajectoryDirection = "Initial", + TrajectoryScore = 50, + OverallRiskLevel = "Moderate", + ExecutiveSummary = "Analysis could not be fully parsed. Please try re-analyzing.", + DetailedAnalysis = "The AI response could not be parsed into the expected format. Please use the 'Re-analyze' option to try again.", + AnalyzedAt = DateTime.UtcNow + }; + } + + // ═══════════════════════════════════════════ + // JSON SERIALIZATION + // ═══════════════════════════════════════════ + + private static readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + AllowTrailingCommas = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private string SerializeResult(UserTrajectoryAnalysis result) + { + return JsonSerializer.Serialize(result, _jsonOptions); + } + + private UserTrajectoryAnalysis DeserializeResult(string json) + { + try + { + return JsonSerializer.Deserialize(json, _jsonOptions) + ?? new UserTrajectoryAnalysis(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to deserialize cached trajectory JSON"); + return new UserTrajectoryAnalysis + { + ExecutiveSummary = "Cached data could not be loaded. Please re-analyze." + }; + } + } + + // ═══════════════════════════════════════════ + // JSON PARSE HELPERS + // ═══════════════════════════════════════════ + + private static readonly Regex JsonObjectRegex = + new(@"(\{(?:[^{}]|(?\{)|(?<-o>\}))*(?(o)(?!))\})", + RegexOptions.Singleline | RegexOptions.Compiled); + + private static T? DeserializeLenient(string content, out string? error) + { + error = null; + var json = content?.Trim() ?? ""; + + // Strip markdown fences + if (json.StartsWith("```")) + { + json = Regex.Replace(json, "^```(?:json)?\\s*|\\s*```$", "", + RegexOptions.IgnoreCase | RegexOptions.Singleline).Trim(); + } + + // Try direct parse first + if (json.StartsWith("{") && json.EndsWith("}")) + { + try { return JsonSerializer.Deserialize(json, _jsonOptions); } + catch (Exception ex) { error = ex.Message; } + } + + // Try regex extraction + var m = JsonObjectRegex.Match(json); + if (m.Success) + { + try { return JsonSerializer.Deserialize(m.Value, _jsonOptions); } + catch (Exception ex) { error = ex.Message; } + } + + error ??= "No valid JSON object found in response."; + return default; + } + } +} \ No newline at end of file diff --git a/Services/Interaces/IAiAnalysisService.cs b/Services/Interaces/IAiAnalysisService.cs index b399cfe..627cd43 100644 --- a/Services/Interaces/IAiAnalysisService.cs +++ b/Services/Interaces/IAiAnalysisService.cs @@ -3,142 +3,152 @@ using Services.AIViewModel; namespace Services.Interaces { + /// + /// Unified AI analysis service powered by Claude API (Anthropic). + /// Provides sentiment analysis, risk assessment, key phrase extraction, + /// PII anonymization, workplace insights, and executive reporting. + /// public interface IAiAnalysisService { - #region Azure Language Service Methods + #region Core Analysis Methods /// - /// Analyzes sentiment of response text using Azure Language Service + /// Analyzes sentiment of response text (Positive, Negative, Neutral) + /// with confidence scores using Claude AI. /// Task AnalyzeSentimentAsync(string text); /// - /// Extracts key phrases and workplace factors from response text + /// Extracts key phrases, workplace factors, and emotional indicators + /// from response text using Claude AI. /// Task ExtractKeyPhrasesAsync(string text); /// - /// Removes PII (Personally Identifiable Information) from response text + /// Removes PII (names, emails, phone numbers, addresses) from text + /// using Claude AI entity recognition. /// Task AnonymizeTextAsync(string text); /// - /// Detects entities in text (workplace factors, departments, roles, etc.) + /// Detects named entities in text (people, organizations, locations, roles). /// Task> DetectEntitiesAsync(string text); #endregion - #region Azure OpenAI Methods + #region Risk Assessment Methods /// - /// Assesses mental health risk level using GPT-3.5 Turbo + /// Assesses mental health risk level (Low → Critical) with indicators, + /// protective factors, and recommended actions using Claude AI. /// Task AssessMentalHealthRiskAsync(string anonymizedText, string questionContext); /// /// Generates workplace insights and intervention recommendations + /// categorized by priority and affected areas. /// Task> GenerateWorkplaceInsightsAsync(string anonymizedText, string questionContext); /// - /// Creates executive summary for questionnaire analysis + /// Creates a professional executive summary from aggregated analysis results + /// suitable for C-level reporting. /// Task GenerateExecutiveSummaryAsync(List analysisResults); /// - /// Categorizes responses into mental health themes + /// Categorizes response into workplace mental health themes + /// (Work-Life Balance, Burnout, Leadership, etc.). /// Task> CategorizeResponseAsync(string anonymizedText); #endregion - #region Combined Analysis Methods + #region Composite Analysis Methods /// - /// Performs complete AI analysis on a single response (both Azure services) + /// Performs full analysis pipeline on a single response: + /// Anonymize → Sentiment → Key Phrases → Risk → Insights. /// Task AnalyzeCompleteResponseAsync(AnalysisRequest request); /// - /// Analyzes multiple responses for a specific question + /// Analyzes multiple responses for a specific question. /// Task> AnalyzeQuestionResponsesAsync(int questionId, List requests); /// - /// Generates comprehensive analysis overview for entire questionnaire + /// Generates comprehensive analysis overview for an entire questionnaire + /// including sentiment distribution, risk breakdown, and executive summary. /// Task GenerateQuestionnaireOverviewAsync(int questionnaireId); /// - /// Batch processes multiple responses efficiently + /// Batch processes multiple responses with rate-limit-aware concurrency. /// Task> BatchAnalyzeResponsesAsync(List requests); #endregion - #region Mental Health Specific Methods + #region Mental Health Intelligence /// - /// Identifies responses requiring immediate attention (high risk) + /// Identifies responses flagged as High or Critical risk + /// requiring immediate organizational attention. /// Task> IdentifyHighRiskResponsesAsync(int questionnaireId); /// - /// Generates mental health trends across time periods + /// Analyzes mental health trends across a date range. /// Task> AnalyzeMentalHealthTrendsAsync(int questionnaireId, DateTime fromDate, DateTime toDate); /// - /// Compares mental health metrics between departments/teams + /// Compares mental health metrics across team identifiers. /// Task> CompareTeamMentalHealthAsync(int questionnaireId, List teamIdentifiers); /// - /// Generates intervention recommendations based on overall analysis + /// Generates prioritized intervention recommendations based on analysis. /// Task> GenerateInterventionRecommendationsAsync(int questionnaireId); #endregion - #region Reporting Methods + #region Reporting /// - /// Creates detailed analysis report for specific questionnaire + /// Creates a detailed markdown analysis report for management review. /// Task GenerateDetailedAnalysisReportAsync(int questionnaireId); /// - /// Generates anonymized data export for further analysis + /// Exports fully anonymized analysis data for external processing. /// Task> ExportAnonymizedAnalysisAsync(int questionnaireId); /// - /// Creates management dashboard summary + /// Generates management dashboard data with KPIs and summaries. /// Task GenerateManagementDashboardAsync(int questionnaireId); #endregion - #region Utility Methods + #region Service Health /// - /// Tests connection to Azure Language Service + /// Tests the Claude API connection with a minimal request. /// - Task TestAzureLanguageServiceConnectionAsync(); + Task TestClaudeConnectionAsync(); /// - /// Tests connection to Azure OpenAI Service - /// - Task TestAzureOpenAIConnectionAsync(); - - /// - /// Validates analysis request before processing + /// Validates an analysis request before processing. /// Task ValidateAnalysisRequestAsync(AnalysisRequest request); /// - /// Gets analysis service health status + /// Returns service health status. Key: "Claude", Value: online/offline. /// Task> GetServiceHealthStatusAsync(); diff --git a/Services/Interaces/IUserTrajectoryService.cs b/Services/Interaces/IUserTrajectoryService.cs new file mode 100644 index 0000000..2fbd752 --- /dev/null +++ b/Services/Interaces/IUserTrajectoryService.cs @@ -0,0 +1,30 @@ + + +using Services.AIViewModel; + +namespace Services.Interaces +{ + public interface IUserTrajectoryService + { + /// + /// Analyzes the wellness trajectory for a user. + /// Uses cached results when available; calls Claude API only when + /// new responses exist since the last analysis. + /// + /// The user's email to look up responses + /// Complete trajectory analysis + Task GetOrAnalyzeTrajectoryAsync(string userEmail); + + /// + /// Forces a fresh analysis regardless of cache state. + /// Useful when admin wants to re-analyze. + /// + Task ForceReanalyzeTrajectoryAsync(string userEmail); + + /// + /// Checks if a cached analysis exists and whether it's stale + /// (i.e., new responses exist since last analysis). + /// + Task<(bool HasCache, bool IsStale, int CachedCount, int CurrentCount)> CheckCacheStatusAsync(string userEmail); + } +} \ No newline at end of file diff --git a/Web/Areas/Admin/Controllers/AccessDeniedController.cs b/Web/Areas/Admin/Controllers/AccessDeniedController.cs new file mode 100644 index 0000000..e7a1247 --- /dev/null +++ b/Web/Areas/Admin/Controllers/AccessDeniedController.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Web.Areas.Admin.Controllers +{ + [Area("Admin")] + [AllowAnonymous] + public class AccessDeniedController : Controller + { + public IActionResult Index() + { + return View(); + } + } +} diff --git a/Web/Areas/Admin/Controllers/AddressController.cs b/Web/Areas/Admin/Controllers/AddressController.cs index 7de345a..194ec49 100644 --- a/Web/Areas/Admin/Controllers/AddressController.cs +++ b/Web/Areas/Admin/Controllers/AddressController.cs @@ -6,7 +6,7 @@ using Web.ViewModel.AddressVM; namespace Web.Areas.Admin.Controllers { - + [Area("Admin")] public class AddressController : Controller { private readonly IAddressRepository _addresContext; diff --git a/Web/Areas/Admin/Controllers/AdminController.cs b/Web/Areas/Admin/Controllers/AdminController.cs index ff6415e..c5ea499 100644 --- a/Web/Areas/Admin/Controllers/AdminController.cs +++ b/Web/Areas/Admin/Controllers/AdminController.cs @@ -1,16 +1,18 @@ -using Microsoft.AspNetCore.Authorization; +using Data; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Model; -using Data; using Services.Interaces; using System.Security.Claims; +using Web.Authorization; using Web.ViewModel.DashboardVM; namespace Web.Areas.Admin.Controllers { - [Authorize(Roles = "Admin,Demo")] + [Area("Admin")] + [HasPermission(Permissions.Dashboard.View)] public class AdminController : Controller { private readonly SignInManager _signInManager; @@ -533,16 +535,20 @@ namespace Web.Areas.Admin.Controllers { var thirtyDaysAgo = DateTime.Now.AddDays(-30); - var trendData = await _context.Responses + var responses = await _context.Responses .Where(r => r.SubmissionDate >= thirtyDaysAgo) - .GroupBy(r => r.SubmissionDate.Date) + .Select(r => new { r.SubmissionDate }) + .ToListAsync(); // Pull into memory first + + var trendData = responses + .GroupBy(r => r.SubmissionDate.Date) // Now safe — in-memory grouping .Select(g => new { Date = g.Key.ToString("yyyy-MM-dd"), Responses = g.Count() }) .OrderBy(d => d.Date) - .ToListAsync(); + .ToList(); return Json(trendData); } diff --git a/Web/Areas/Admin/Controllers/BannerController.cs b/Web/Areas/Admin/Controllers/BannerController.cs index b6fe21a..90570da 100644 --- a/Web/Areas/Admin/Controllers/BannerController.cs +++ b/Web/Areas/Admin/Controllers/BannerController.cs @@ -8,7 +8,7 @@ using Web.ViewModel.BannerVM; namespace Web.Areas.Admin.Controllers { - + [Area("Admin")] public class BannerController : Controller { private readonly IBannerRepository _banner; diff --git a/Web/Areas/Admin/Controllers/FooterController.cs b/Web/Areas/Admin/Controllers/FooterController.cs index 1f1e6ba..09a2531 100644 --- a/Web/Areas/Admin/Controllers/FooterController.cs +++ b/Web/Areas/Admin/Controllers/FooterController.cs @@ -16,7 +16,7 @@ namespace Web.Areas.Admin.Controllers { - + [Area("Admin")] public class FooterController : Controller { private readonly IFooterRepository _footer; diff --git a/Web/Areas/Admin/Controllers/NewslettersController.cs b/Web/Areas/Admin/Controllers/NewslettersController.cs index f09c2af..d2e8cd9 100644 --- a/Web/Areas/Admin/Controllers/NewslettersController.cs +++ b/Web/Areas/Admin/Controllers/NewslettersController.cs @@ -21,7 +21,7 @@ using System.Text.RegularExpressions; namespace Web.Areas.Admin.Controllers { - + [Area("Admin")] public class NewslettersController : Controller { private readonly INewsLetterRepository _repository; diff --git a/Web/Areas/Admin/Controllers/PageController.cs b/Web/Areas/Admin/Controllers/PageController.cs index c9edd26..beb456a 100644 --- a/Web/Areas/Admin/Controllers/PageController.cs +++ b/Web/Areas/Admin/Controllers/PageController.cs @@ -9,7 +9,7 @@ using Web.ViewModel.PageVM; namespace Web.Areas.Admin.Controllers { - + [Area("Admin")] public class PageController : Controller { private readonly IPageRepository _pageRepository; diff --git a/Web/Areas/Admin/Controllers/QuestionnaireController.cs b/Web/Areas/Admin/Controllers/QuestionnaireController.cs index 3803768..95b0e32 100644 --- a/Web/Areas/Admin/Controllers/QuestionnaireController.cs +++ b/Web/Areas/Admin/Controllers/QuestionnaireController.cs @@ -15,6 +15,7 @@ using System.Globalization; using System.Security.Cryptography; using System.Text; using System.Web; +using Web.Authorization; using Web.ViewModel.QuestionnaireVM; @@ -22,7 +23,8 @@ namespace Web.Areas.Admin.Controllers { - + [Area("Admin")] + [HasPermission(Permissions.Questionnaires.View)] public class QuestionnaireController : Controller { private readonly IQuestionnaireRepository _questionnaire; @@ -97,7 +99,7 @@ namespace Web.Areas.Admin.Controllers }); } [HttpGet] - [Authorize(Roles = "Admin")] + [HasPermission(Permissions.Questionnaires.Create)] public IActionResult Create() @@ -121,7 +123,7 @@ namespace Web.Areas.Admin.Controllers return View(questionnaire); } [HttpPost] - [Authorize(Roles = "Admin")] + [HasPermission(Permissions.Questionnaires.Create)] public async Task Create(QuestionnaireViewModel viewmodel) { if (ModelState.IsValid) @@ -133,10 +135,10 @@ namespace Web.Areas.Admin.Controllers Description = viewmodel.Description, }; - var questions = viewmodel.Questions; - - foreach (var questionViewModel in viewmodel.Questions) + for (int qIndex = 0; qIndex < viewmodel.Questions.Count; qIndex++) { + var questionViewModel = viewmodel.Questions[qIndex]; + var question = new Question { QuestionnaireId = questionViewModel.QuestionnaireId, @@ -145,20 +147,62 @@ namespace Web.Areas.Admin.Controllers Answers = new List() }; - foreach (var answerViewModel in questionViewModel.Answers) + // Handle Image type questions — save uploaded files + if (questionViewModel.Type == QuestionType.Image) { - // Skip empty answers - if (string.IsNullOrWhiteSpace(answerViewModel.Text)) - continue; + var imageFiles = HttpContext.Request.Form.Files + .Where(f => f.Name.StartsWith($"ImageFiles_{qIndex}_")) + .OrderBy(f => f.Name) + .ToList(); - var answer = new Answer + foreach (var imageFile in imageFiles) { - Text = answerViewModel.Text, - QuestionId = answerViewModel.QuestionId, - IsOtherOption = answerViewModel.IsOtherOption // NEW: Handle IsOtherOption property - }; + if (imageFile != null && imageFile.Length > 0) + { + var fileExtension = Path.GetExtension(imageFile.FileName); + var uniqueFileName = $"{Guid.NewGuid()}{fileExtension}"; + var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads", "questionimages"); - question.Answers.Add(answer); + if (!Directory.Exists(uploadsFolder)) + Directory.CreateDirectory(uploadsFolder); + + var filePath = Path.Combine(uploadsFolder, uniqueFileName); + + using (var stream = new FileStream(filePath, FileMode.Create)) + { + await imageFile.CopyToAsync(stream); + } + + var answer = new Answer + { + Text = $"/uploads/questionimages/{uniqueFileName}", + QuestionId = question.Id, + IsOtherOption = false, + ConditionJson = null + }; + + question.Answers.Add(answer); + } + } + } + else + { + // Handle all other question types + if (questionViewModel.Answers != null) + { + foreach (var answerModel in questionViewModel.Answers) + { + var answer = new Answer + { + Text = answerModel.Text, + QuestionId = answerModel.QuestionId, + IsOtherOption = answerModel.IsOtherOption, + ConditionJson = answerModel.ConditionJson // Save the condition JSON + }; + + question.Answers.Add(answer); + } + } } questionnaire.Questions.Add(question); @@ -174,7 +218,7 @@ namespace Web.Areas.Admin.Controllers } [HttpGet] - [Authorize(Roles = "Admin")] + [HasPermission(Permissions.Questionnaires.Edit)] public IActionResult Edit(int id) { var questionTypes = Enum.GetValues(typeof(QuestionType)) @@ -217,7 +261,7 @@ namespace Web.Areas.Admin.Controllers return View(viewModel); } - [Authorize(Roles = "Admin")] + [HasPermission(Permissions.Questionnaires.Edit)] [HttpPost] public async Task Edit(EditQuestionnaireViewModel viewModel) { @@ -513,7 +557,8 @@ namespace Web.Areas.Admin.Controllers } } [HttpGet] - [Authorize(Roles = "Admin")] + + [HasPermission(Permissions.Questionnaires.Delete)] public IActionResult Delete(int id) { var questionTypes = Enum.GetValues(typeof(QuestionType)).Cast(); @@ -551,6 +596,7 @@ namespace Web.Areas.Admin.Controllers [HttpPost] [ActionName("Delete")] + [HasPermission(Permissions.Questionnaires.Delete)] public async Task DeleteConfirm(int id) { try @@ -579,6 +625,7 @@ namespace Web.Areas.Admin.Controllers } [HttpGet] + public IActionResult Details(int id) { var questionTypes = Enum.GetValues(typeof(QuestionType)).Cast(); @@ -615,21 +662,25 @@ namespace Web.Areas.Admin.Controllers } [HttpGet] + [HasPermission(Permissions.Questionnaires.Send)] public IActionResult SendQuestionnaire(int id) { var quesstionnaireFromDb = _questionnaire.GetQuestionnaireWithQuestionAndAnswer(id); var sendquestionviewmodel = new SendQuestionnaireViewModel(); - sendquestionviewmodel.QuestionnaireId = id; ViewBag.questionnaireName = quesstionnaireFromDb.Title; - return View(sendquestionviewmodel); + // Users who have submitted ANY questionnaire response + ViewBag.Users = _context.Responses + .Select(r => new { r.UserName, r.UserEmail }) + .Distinct() + .ToList(); + return View(sendquestionviewmodel); } - [HttpPost] - + [HasPermission(Permissions.Questionnaires.Send)] public async Task SendQuestionnaire(SendQuestionnaireViewModel viewModel) { if (!ModelState.IsValid) @@ -834,7 +885,7 @@ namespace Web.Areas.Admin.Controllers // Add these methods to your existing QuestionnaireController class [HttpGet] - [Authorize(Roles = "Admin")] + public IActionResult SetLogic(int id) { var questionnaire = _questionnaire.GetQuestionnaireWithQuestionAndAnswer(id); diff --git a/Web/Areas/Admin/Controllers/RegisterController.cs b/Web/Areas/Admin/Controllers/RegisterController.cs index 79df8eb..34069f3 100644 --- a/Web/Areas/Admin/Controllers/RegisterController.cs +++ b/Web/Areas/Admin/Controllers/RegisterController.cs @@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Mvc; namespace Web.Areas.Admin.Controllers { - + [Area("Admin")] public class RegisterController : Controller { public IActionResult Index() diff --git a/Web/Areas/Admin/Controllers/RolesController.cs b/Web/Areas/Admin/Controllers/RolesController.cs index 40498db..f16c7e9 100644 --- a/Web/Areas/Admin/Controllers/RolesController.cs +++ b/Web/Areas/Admin/Controllers/RolesController.cs @@ -1,12 +1,15 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; +using Web.Authorization; using Web.ViewModel.AccountVM; namespace Web.Areas.Admin.Controllers { - + [HasPermission(Permissions.Roles.View)] + [Area("Admin")] public class RolesController : Controller { private readonly RoleManager _roleManager; @@ -15,99 +18,146 @@ namespace Web.Areas.Admin.Controllers { _roleManager = roleManager; } - public IActionResult Index() + + public async Task Index() { - var roles = _roleManager.Roles.Select(r => new RoleViewModel + var roles = _roleManager.Roles.ToList(); + var models = new List(); + + foreach (var role in roles) { - Id = r.Id, - Name = r.Name, - - }).ToList(); + var claims = await _roleManager.GetClaimsAsync(role); + var permissions = claims + .Where(c => c.Type == Permissions.ClaimType) + .Select(c => c.Value) + .ToList(); - return View(roles); + models.Add(new RoleViewModel + { + Id = role.Id, + Name = role.Name, + SelectedPermissions = permissions + }); + } + + // Pass grouped permissions for the UI + ViewBag.PermissionGroups = Permissions.GetAllGrouped(); + + return View(models); } - public IActionResult Create() - { - return View(new RoleViewModel()); - } - + [HasPermission(Permissions.Roles.Create)] [HttpPost] [ValidateAntiForgeryToken] - public async Task Create(RoleViewModel model) + public async Task CreateAjax(RoleViewModel model) { - if (ModelState.IsValid) + if (string.IsNullOrWhiteSpace(model.Name)) { - var role = new IdentityRole - { - Name = model.Name - }; - // Optionally handle the description if your IdentityRole class supports it - var result = await _roleManager.CreateAsync(role); - if (result.Succeeded) - { - TempData["Success"] = "role created successfully"; - return RedirectToAction("Index"); - - } - foreach (var error in result.Errors) - { - ModelState.AddModelError("", error.Description); - } + return Json(new { success = false, errors = new List { "Role name is required." } }); } - return View(model); + + // Check if role already exists + var existingRole = await _roleManager.FindByNameAsync(model.Name); + if (existingRole != null) + { + return Json(new { success = false, errors = new List { $"Role '{model.Name}' already exists." } }); + } + + var role = new IdentityRole { Name = model.Name }; + var result = await _roleManager.CreateAsync(role); + + if (result.Succeeded) + { + // Save permissions as claims + if (model.SelectedPermissions != null && model.SelectedPermissions.Any()) + { + foreach (var permission in model.SelectedPermissions) + { + await _roleManager.AddClaimAsync(role, new Claim(Permissions.ClaimType, permission)); + } + } + + return Json(new { success = true, message = $"Role '{model.Name}' created successfully." }); + } + + var errors = result.Errors.Select(e => e.Description).ToList(); + return Json(new { success = false, errors }); } - public async Task Edit(string id) + [HttpGet] + + [HasPermission(Permissions.Roles.View)] + public async Task GetRolePermissions(string id) { var role = await _roleManager.FindByIdAsync(id); if (role == null) { - return NotFound(); + return Json(new { success = false, errors = new List { "Role not found." } }); } - var model = new RoleViewModel - { - Id = role.Id, - Name = role.Name, - - }; + var claims = await _roleManager.GetClaimsAsync(role); + var permissions = claims + .Where(c => c.Type == Permissions.ClaimType) + .Select(c => c.Value) + .ToList(); - return View(model); + return Json(new + { + success = true, + id = role.Id, + name = role.Name, + permissions + }); } [HttpPost] [ValidateAntiForgeryToken] - public async Task Edit(RoleViewModel model) + [HasPermission(Permissions.Roles.Edit)] + public async Task EditAjax(RoleViewModel model) { - if (ModelState.IsValid) + if (string.IsNullOrWhiteSpace(model.Name)) { - var role = await _roleManager.FindByIdAsync(model.Id); - if (role == null) - { - return NotFound(); - } + return Json(new { success = false, errors = new List { "Role name is required." } }); + } - role.Name = model.Name; - + var role = await _roleManager.FindByIdAsync(model.Id); + if (role == null) + { + return Json(new { success = false, errors = new List { "Role not found." } }); + } - var result = await _roleManager.UpdateAsync(role); - if (result.Succeeded) + // Update name + role.Name = model.Name; + var result = await _roleManager.UpdateAsync(role); + + if (!result.Succeeded) + { + var errors = result.Errors.Select(e => e.Description).ToList(); + return Json(new { success = false, errors }); + } + + // Remove old permission claims + var existingClaims = await _roleManager.GetClaimsAsync(role); + foreach (var claim in existingClaims.Where(c => c.Type == Permissions.ClaimType)) + { + await _roleManager.RemoveClaimAsync(role, claim); + } + + // Add new permission claims + if (model.SelectedPermissions != null && model.SelectedPermissions.Any()) + { + foreach (var permission in model.SelectedPermissions) { - TempData["Success"] = "Role updated successfully"; - return RedirectToAction(nameof(Index)); - } - foreach (var error in result.Errors) - { - ModelState.AddModelError("", error.Description); + await _roleManager.AddClaimAsync(role, new Claim(Permissions.ClaimType, permission)); } } - return View(model); + return Json(new { success = true, message = $"Role '{model.Name}' updated successfully." }); } + [HasPermission(Permissions.Roles.Delete)] [HttpPost] [ValidateAntiForgeryToken] public async Task DeleteMultiple(List selectedRoles) @@ -123,6 +173,13 @@ namespace Web.Areas.Admin.Controllers var role = await _roleManager.FindByIdAsync(roleId); if (role != null) { + // Remove all claims first + var claims = await _roleManager.GetClaimsAsync(role); + foreach (var claim in claims) + { + await _roleManager.RemoveClaimAsync(role, claim); + } + await _roleManager.DeleteAsync(role); } } @@ -130,6 +187,5 @@ namespace Web.Areas.Admin.Controllers TempData["Success"] = "Selected roles deleted successfully."; return RedirectToAction(nameof(Index)); } - } -} +} \ No newline at end of file diff --git a/Web/Areas/Admin/Controllers/SocialMediaController.cs b/Web/Areas/Admin/Controllers/SocialMediaController.cs index d1305f3..58d1a3b 100644 --- a/Web/Areas/Admin/Controllers/SocialMediaController.cs +++ b/Web/Areas/Admin/Controllers/SocialMediaController.cs @@ -7,7 +7,7 @@ using Web.ViewModel.SocialMediaVM; namespace Web.Areas.Admin.Controllers { - + [Area("Admin")] public class SocialMediaController : Controller { private readonly ISocialMediaRepository _context; diff --git a/Web/Areas/Admin/Controllers/SurveyAnalysisController.cs b/Web/Areas/Admin/Controllers/SurveyAnalysisController.cs index ed59ca4..b003e30 100644 --- a/Web/Areas/Admin/Controllers/SurveyAnalysisController.cs +++ b/Web/Areas/Admin/Controllers/SurveyAnalysisController.cs @@ -9,10 +9,11 @@ using Services.AIViewModel; using Services.Interaces; using System.Text; using System.Text.Json; +using Web.Authorization; namespace Web.Areas.Admin.Controllers { - + [Area("Admin")] public class SurveyAnalysisController : Controller { private readonly IAiAnalysisService _aiAnalysisService; @@ -38,40 +39,28 @@ namespace Web.Areas.Admin.Controllers { try { - var questionnaires = await _context.Questionnaires - .Include(q => q.Questions) + var responses = await _context.Responses + .Include(r => r.Questionnaire) + .ThenInclude(q => q.Questions) + .Include(r => r.ResponseDetails) + .ThenInclude(rd => rd.ResponseAnswers) + .OrderByDescending(r => r.SubmissionDate) .ToListAsync(); - var result = questionnaires.Select(q => new + var result = responses.Select(r => new { - q.Id, - q.Title, - q.Description, - QuestionCount = q.Questions.Count, - ResponseCount = _context.Responses.Count(r => r.QuestionnaireId == q.Id), - AnalyzableResponseCount = _context.Responses - .Include(r => r.ResponseDetails) - .ThenInclude(rd => rd.ResponseAnswers) - .Where(r => r.QuestionnaireId == q.Id) - .SelectMany(r => r.ResponseDetails) - .Count(rd => !string.IsNullOrEmpty(rd.TextResponse) || - (rd.QuestionType == QuestionType.CheckBox && rd.ResponseAnswers.Any())), - LastResponse = _context.Responses - .Where(r => r.QuestionnaireId == q.Id) - .OrderByDescending(r => r.SubmissionDate) - .Select(r => r.SubmissionDate) - .FirstOrDefault(), - Users = _context.Responses - .Where(r => r.QuestionnaireId == q.Id && !string.IsNullOrEmpty(r.UserName)) - .OrderByDescending(r => r.SubmissionDate) - .Select(r => new - { - UserName = r.UserName, - Email = r.UserEmail - }) - .Distinct() - .Take(5) - .ToList() + ResponseId = r.Id, + QuestionnaireId = r.QuestionnaireId, + Title = r.Questionnaire?.Title ?? "Unknown", + Description = r.Questionnaire?.Description ?? "", + QuestionCount = r.Questionnaire?.Questions?.Count ?? 0, + AnalyzableCount = r.ResponseDetails.Count(rd => + !string.IsNullOrEmpty(rd.TextResponse) || + rd.ResponseAnswers.Any()), + TotalAnswered = r.ResponseDetails.Count, + SubmissionDate = r.SubmissionDate, + UserName = r.UserName ?? "Anonymous", + UserEmail = r.UserEmail ?? "No email available" }).ToList(); ViewBag.ServiceHealth = await _aiAnalysisService.GetServiceHealthStatusAsync(); @@ -88,6 +77,7 @@ namespace Web.Areas.Admin.Controllers /// /// Generate comprehensive analysis overview for a questionnaire /// + [HasPermission(Permissions.SurveyAnalysis.Analyze)] public async Task AnalyzeQuestionnaire(int id) { try @@ -102,60 +92,33 @@ namespace Web.Areas.Admin.Controllers return RedirectToAction(nameof(Index)); } - // Check if there are responses to analyze - var totalResponses = await _context.Responses - .CountAsync(r => r.QuestionnaireId == id); + // Check if there are ANY responses (not just text) + var hasResponses = await _context.Responses + .Include(r => r.ResponseDetails) + .ThenInclude(rd => rd.ResponseAnswers) + .AnyAsync(r => r.QuestionnaireId == id && + r.ResponseDetails.Any(rd => + !string.IsNullOrEmpty(rd.TextResponse) || + rd.ResponseAnswers.Any())); - if (totalResponses == 0) + if (!hasResponses) { - TempData["WarningMessage"] = "No responses found for this questionnaire."; + TempData["WarningMessage"] = "No analyzable responses found for this questionnaire."; return RedirectToAction(nameof(Index)); } - // Calculate analyzable responses (same logic as dashboard) - var analyzableCount = await _context.Responses - .Where(r => r.QuestionnaireId == id) - .SelectMany(r => r.ResponseDetails) - .CountAsync(rd => !string.IsNullOrEmpty(rd.TextResponse) || - (rd.QuestionType == QuestionType.CheckBox && - _context.ResponseAnswers.Any(ra => ra.ResponseDetailId == rd.Id))); + _logger.LogInformation("Starting analysis for questionnaire {QuestionnaireId}", id); - if (analyzableCount == 0) - { - TempData["WarningMessage"] = "No analyzable responses found. Responses must contain text or checkbox selections."; - return RedirectToAction(nameof(Index)); - } - - _logger.LogInformation("Starting analysis for questionnaire {QuestionnaireId}. Total responses: {TotalResponses}, Analyzable: {AnalyzableCount}", - id, totalResponses, analyzableCount); - - // Generate comprehensive analysis var analysisOverview = await _aiAnalysisService.GenerateQuestionnaireOverviewAsync(id); - var actuallyAnalyzed = analysisOverview.AnalyzedResponses; - - _logger.LogInformation("Analysis completed for questionnaire {QuestionnaireId}. " + - "Analyzable: {AnalyzableCount}, Successfully Analyzed: {ActuallyAnalyzed}", - id, analyzableCount, actuallyAnalyzed); - - // Provide user feedback about the difference if significant - if (analyzableCount > actuallyAnalyzed && (analyzableCount - actuallyAnalyzed) > 0) - { - var difference = analyzableCount - actuallyAnalyzed; - TempData["InfoMessage"] = $"Analysis completed successfully. {actuallyAnalyzed} of {analyzableCount} analyzable responses were processed. " + - $"{difference} response(s) could not be analyzed due to processing limitations or API constraints."; - } - else - { - TempData["SuccessMessage"] = $"Analysis completed successfully. All {actuallyAnalyzed} analyzable responses were processed."; - } + _logger.LogInformation("Analysis completed successfully for questionnaire {QuestionnaireId}", id); return View(analysisOverview); } catch (Exception ex) { _logger.LogError(ex, "Error analyzing questionnaire {QuestionnaireId}: {ErrorMessage}", id, ex.Message); - TempData["ErrorMessage"] = $"Error analyzing questionnaire: {ex.Message}. Please check the logs for more details."; + TempData["ErrorMessage"] = $"Error analyzing questionnaire: {ex.Message}"; return RedirectToAction(nameof(Index)); } } @@ -167,6 +130,7 @@ namespace Web.Areas.Admin.Controllers /// /// Identify and display high-risk responses requiring immediate attention /// + [HasPermission(Permissions.SurveyAnalysis.HighRisk)] public async Task HighRiskResponses(int id) { try @@ -198,6 +162,7 @@ namespace Web.Areas.Admin.Controllers /// /// View detailed analysis of a specific high-risk response /// + [HasPermission(Permissions.SurveyAnalysis.HighRisk)] public async Task ViewHighRiskResponse(int questionnaireId, int responseId) { try @@ -206,6 +171,9 @@ namespace Web.Areas.Admin.Controllers .Include(r => r.Questionnaire) .Include(r => r.ResponseDetails) .ThenInclude(rd => rd.Question) + .Include(r => r.ResponseDetails) + .ThenInclude(rd => rd.ResponseAnswers) + .ThenInclude(ra => ra.Answer) .FirstOrDefaultAsync(r => r.Id == responseId && r.QuestionnaireId == questionnaireId); if (response == null) @@ -214,21 +182,25 @@ namespace Web.Areas.Admin.Controllers return RedirectToAction(nameof(HighRiskResponses), new { id = questionnaireId }); } - // Get AI analysis for each text response var analysisResults = new List(); - foreach (var detail in response.ResponseDetails.Where(rd => !string.IsNullOrWhiteSpace(rd.TextResponse))) + foreach (var detail in response.ResponseDetails) { - var analysisRequest = new AnalysisRequest - { - ResponseId = response.Id, - QuestionId = detail.QuestionId, - ResponseText = detail.TextResponse, - QuestionText = detail.Question?.Text ?? "" - }; + var analysisText = BuildResponseText(detail); - var analysis = await _aiAnalysisService.AnalyzeCompleteResponseAsync(analysisRequest); - analysisResults.Add(analysis); + if (!string.IsNullOrWhiteSpace(analysisText)) + { + var analysisRequest = new AnalysisRequest + { + ResponseId = response.Id, + QuestionId = detail.QuestionId, + ResponseText = analysisText, + QuestionText = detail.Question?.Text ?? "" + }; + + var analysis = await _aiAnalysisService.AnalyzeCompleteResponseAsync(analysisRequest); + analysisResults.Add(analysis); + } } ViewBag.Response = response; @@ -242,6 +214,7 @@ namespace Web.Areas.Admin.Controllers } } + #endregion #region Individual Response Analysis @@ -264,9 +237,7 @@ namespace Web.Areas.Admin.Controllers var isValid = await _aiAnalysisService.ValidateAnalysisRequestAsync(analysisRequest); if (!isValid) - { return Json(new { success = false, message = "Invalid analysis request." }); - } var analysis = await _aiAnalysisService.AnalyzeCompleteResponseAsync(analysisRequest); @@ -281,7 +252,8 @@ namespace Web.Areas.Admin.Controllers riskScore = analysis.RiskAssessment?.RiskScore ?? 0, requiresAttention = analysis.RiskAssessment?.RequiresImmediateAttention ?? false, recommendedAction = analysis.RiskAssessment?.RecommendedAction ?? "", - insights = analysis.Insights.Select(i => new { + insights = analysis.Insights.Select(i => new + { category = i.Category, issue = i.Issue, intervention = i.RecommendedIntervention, @@ -292,7 +264,7 @@ namespace Web.Areas.Admin.Controllers } catch (Exception ex) { - _logger.LogError(ex, "Error analyzing individual response {ResponseId}", responseId); + _logger.LogError(ex, "Error analyzing response {ResponseId}", responseId); return Json(new { success = false, message = "Error analyzing response. Please try again." }); } } @@ -304,6 +276,7 @@ namespace Web.Areas.Admin.Controllers /// /// Process batch analysis for all responses in a questionnaire /// + [HasPermission(Permissions.SurveyAnalysis.Analyze)] public async Task BatchAnalyze(int id) { try @@ -317,10 +290,13 @@ namespace Web.Areas.Admin.Controllers return RedirectToAction(nameof(Index)); } - // Get all text responses for the questionnaire + // Get all responses with ALL answer types var responses = await _context.Responses .Include(r => r.ResponseDetails) .ThenInclude(rd => rd.Question) + .Include(r => r.ResponseDetails) + .ThenInclude(rd => rd.ResponseAnswers) + .ThenInclude(ra => ra.Answer) .Where(r => r.QuestionnaireId == id) .ToListAsync(); @@ -330,32 +306,16 @@ namespace Web.Areas.Admin.Controllers { foreach (var detail in response.ResponseDetails) { - string responseText = ""; + // Build combined text from text response + selected answer texts + var analysisText = BuildResponseText(detail); - // Handle text-based questions - if (!string.IsNullOrWhiteSpace(detail.TextResponse)) - { - responseText = detail.TextResponse; - } - // Handle CheckBox questions - else if (detail.QuestionType == QuestionType.CheckBox && detail.ResponseAnswers.Any()) - { - var selectedAnswers = detail.ResponseAnswers - .Select(ra => detail.Question.Answers.FirstOrDefault(a => a.Id == ra.AnswerId)?.Text) - .Where(text => !string.IsNullOrEmpty(text)) - .ToList(); - - responseText = $"Multiple Selection Question: {detail.Question.Text}\nSelected Options: {string.Join(", ", selectedAnswers)}\nAnalyze these selected workplace factors for mental health implications and patterns."; - } - - // Add to analysis requests if we have text to analyze - if (!string.IsNullOrEmpty(responseText)) + if (!string.IsNullOrWhiteSpace(analysisText)) { analysisRequests.Add(new AnalysisRequest { ResponseId = response.Id, QuestionId = detail.QuestionId, - ResponseText = responseText, + ResponseText = analysisText, QuestionText = detail.Question?.Text ?? "" }); } @@ -364,11 +324,10 @@ namespace Web.Areas.Admin.Controllers if (!analysisRequests.Any()) { - TempData["WarningMessage"] = "No text responses found to analyze."; + TempData["WarningMessage"] = "No analyzable responses found. Responses must contain text or checkbox selections."; return RedirectToAction(nameof(AnalyzeQuestionnaire), new { id }); } - // Process batch analysis (this might take a while) ViewBag.QuestionnaireName = questionnaire.Title; ViewBag.QuestionnaireId = id; ViewBag.TotalRequests = analysisRequests.Count; @@ -386,6 +345,7 @@ namespace Web.Areas.Admin.Controllers /// /// AJAX endpoint for batch analysis progress /// + [HasPermission(Permissions.SurveyAnalysis.Analyze)] [HttpPost] public async Task ProcessBatchAnalysis(int questionnaireId) { @@ -394,6 +354,9 @@ namespace Web.Areas.Admin.Controllers var responses = await _context.Responses .Include(r => r.ResponseDetails) .ThenInclude(rd => rd.Question) + .Include(r => r.ResponseDetails) + .ThenInclude(rd => rd.ResponseAnswers) + .ThenInclude(ra => ra.Answer) .Where(r => r.QuestionnaireId == questionnaireId) .ToListAsync(); @@ -403,32 +366,15 @@ namespace Web.Areas.Admin.Controllers { foreach (var detail in response.ResponseDetails) { - string responseText = ""; + var analysisText = BuildResponseText(detail); - // Handle text-based questions - if (!string.IsNullOrWhiteSpace(detail.TextResponse)) - { - responseText = detail.TextResponse; - } - // Handle CheckBox questions - else if (detail.QuestionType == QuestionType.CheckBox && detail.ResponseAnswers.Any()) - { - var selectedAnswers = detail.ResponseAnswers - .Select(ra => detail.Question.Answers.FirstOrDefault(a => a.Id == ra.AnswerId)?.Text) - .Where(text => !string.IsNullOrEmpty(text)) - .ToList(); - - responseText = $"Multiple Selection Question: {detail.Question.Text}\nSelected Options: {string.Join(", ", selectedAnswers)}\nAnalyze these selected workplace factors for mental health implications and patterns."; - } - - // Add to analysis requests if we have text to analyze - if (!string.IsNullOrEmpty(responseText)) + if (!string.IsNullOrWhiteSpace(analysisText)) { analysisRequests.Add(new AnalysisRequest { ResponseId = response.Id, QuestionId = detail.QuestionId, - ResponseText = responseText, + ResponseText = analysisText, QuestionText = detail.Question?.Text ?? "" }); } @@ -448,11 +394,7 @@ namespace Web.Areas.Admin.Controllers catch (Exception ex) { _logger.LogError(ex, "Error processing batch analysis for questionnaire {QuestionnaireId}", questionnaireId); - return Json(new - { - success = false, - message = "Error processing batch analysis. Please try again." - }); + return Json(new { success = false, message = "Error processing batch analysis. Please try again." }); } } @@ -463,6 +405,7 @@ namespace Web.Areas.Admin.Controllers /// /// Generate detailed analysis report for management /// + [HasPermission(Permissions.SurveyAnalysis.Reports)] public async Task GenerateReport(int id) { try @@ -496,6 +439,7 @@ namespace Web.Areas.Admin.Controllers /// /// Download report as text file /// + [HasPermission(Permissions.SurveyAnalysis.Reports)] public async Task DownloadReport(int id) { try @@ -511,7 +455,6 @@ namespace Web.Areas.Admin.Controllers var report = await _aiAnalysisService.GenerateDetailedAnalysisReportAsync(id); var bytes = Encoding.UTF8.GetBytes(report); - var fileName = $"Mental_Health_Analysis_{questionnaire.Title}_{DateTime.Now:yyyy-MM-dd}.txt"; return File(bytes, "text/plain", fileName); @@ -542,11 +485,7 @@ namespace Web.Areas.Admin.Controllers var analysisData = await _aiAnalysisService.ExportAnonymizedAnalysisAsync(id); - var json = System.Text.Json.JsonSerializer.Serialize(analysisData, new JsonSerializerOptions - { - WriteIndented = true - }); - + var json = JsonSerializer.Serialize(analysisData, new JsonSerializerOptions { WriteIndented = true }); var bytes = Encoding.UTF8.GetBytes(json); var fileName = $"Anonymized_Analysis_{questionnaire.Title}_{DateTime.Now:yyyy-MM-dd}.json"; @@ -567,6 +506,7 @@ namespace Web.Areas.Admin.Controllers /// /// Analyze mental health trends over time periods /// + [HasPermission(Permissions.SurveyAnalysis.Analyze)] public async Task AnalyzeTrends(int id, DateTime? fromDate = null, DateTime? toDate = null) { try @@ -580,7 +520,6 @@ namespace Web.Areas.Admin.Controllers return RedirectToAction(nameof(Index)); } - // Default to last 6 months if no dates provided var from = fromDate ?? DateTime.Now.AddMonths(-6); var to = toDate ?? DateTime.Now; @@ -606,7 +545,7 @@ namespace Web.Areas.Admin.Controllers #region Service Health and Testing /// - /// Check AI service health status + /// Check Claude AI service health status (AJAX) /// public async Task ServiceHealth() { @@ -633,7 +572,7 @@ namespace Web.Areas.Admin.Controllers } /// - /// Test AI analysis with sample text + /// Test Claude AI analysis with sample text (AJAX) /// [HttpPost] public async Task TestAnalysis(string sampleText) @@ -641,14 +580,12 @@ namespace Web.Areas.Admin.Controllers try { if (string.IsNullOrWhiteSpace(sampleText)) - { return Json(new { success = false, message = "Please provide sample text." }); - } var analysisRequest = new AnalysisRequest { - ResponseId = 0, // Test request - QuestionId = 0, // Test request + ResponseId = 0, + QuestionId = 0, ResponseText = sampleText, QuestionText = "Test question: How are you feeling about your work environment?" }; @@ -667,11 +604,7 @@ namespace Web.Areas.Admin.Controllers catch (Exception ex) { _logger.LogError(ex, "Error in test analysis"); - return Json(new - { - success = false, - message = "Error performing test analysis. Please try again." - }); + return Json(new { success = false, message = "Error performing test analysis. Please try again." }); } } @@ -696,7 +629,6 @@ namespace Web.Areas.Admin.Controllers } var dashboard = await _aiAnalysisService.GenerateManagementDashboardAsync(id); - return View(dashboard); } catch (Exception ex) @@ -706,6 +638,749 @@ namespace Web.Areas.Admin.Controllers return RedirectToAction(nameof(Index)); } } + + /// + /// Builds analysis text from a ResponseDetail, combining text responses and selected answer texts. + /// + private string BuildResponseText(ResponseDetail detail) + { + var parts = new List(); + + if (!string.IsNullOrWhiteSpace(detail.TextResponse)) + { + parts.Add(detail.TextResponse); + } + + if (detail.ResponseAnswers != null && detail.ResponseAnswers.Any()) + { + foreach (var ra in detail.ResponseAnswers) + { + if (ra.Answer != null && !string.IsNullOrWhiteSpace(ra.Answer.Text)) + { + parts.Add(ra.Answer.Text); + } + } + } + + return string.Join(". ", parts); + } + + #region Case Management — Notes, Status, Action Plans, PDF Export, History + + // ───────────────────────────────────────────── + // CASE NOTES CRUD + // ───────────────────────────────────────────── + + /// + /// Get all notes for a response (AJAX) + /// + [HttpGet] + public async Task GetCaseNotes(int responseId) + { + var notes = await _context.CaseNotes + .Where(n => n.ResponseId == responseId) + .OrderByDescending(n => n.CreatedAt) + .Select(n => new + { + n.Id, + n.AuthorName, + n.AuthorEmail, + n.NoteText, + n.Category, + n.IsConfidential, + CreatedAt = n.CreatedAt.ToString("MMM dd, yyyy HH:mm"), + UpdatedAt = n.UpdatedAt.HasValue ? n.UpdatedAt.Value.ToString("MMM dd, yyyy HH:mm") : null + }) + .ToListAsync(); + + return Json(new { success = true, notes }); + } + + /// + /// Add a case note (AJAX) + /// + [HttpPost] + public async Task AddCaseNote(int responseId, string noteText, string category) + { + try + { + if (string.IsNullOrWhiteSpace(noteText)) + return Json(new { success = false, message = "Note text is required." }); + + var currentUser = User.Identity?.Name ?? "Unknown"; + var currentEmail = User.Claims.FirstOrDefault(c => c.Type == System.Security.Claims.ClaimTypes.Email)?.Value; + + var note = new CaseNote + { + ResponseId = responseId, + AuthorName = currentUser, + AuthorEmail = currentEmail, + NoteText = noteText.Trim(), + Category = string.IsNullOrWhiteSpace(category) ? "General" : category, + CreatedAt = DateTime.UtcNow + }; + + _context.CaseNotes.Add(note); + await _context.SaveChangesAsync(); + + return Json(new + { + success = true, + note = new + { + note.Id, + note.AuthorName, + note.AuthorEmail, + note.NoteText, + note.Category, + note.IsConfidential, + CreatedAt = note.CreatedAt.ToString("MMM dd, yyyy HH:mm") + } + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error adding case note for ResponseId {ResponseId}", responseId); + return Json(new { success = false, message = "Failed to save note." }); + } + } + + /// + /// Delete a case note (AJAX) + /// + [HttpPost] + public async Task DeleteCaseNote(int noteId) + { + try + { + var note = await _context.CaseNotes.FindAsync(noteId); + if (note == null) + return Json(new { success = false, message = "Note not found." }); + + _context.CaseNotes.Remove(note); + await _context.SaveChangesAsync(); + + return Json(new { success = true }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting case note {NoteId}", noteId); + return Json(new { success = false, message = "Failed to delete note." }); + } + } + + // ───────────────────────────────────────────── + // CASE STATUS TRACKING + // ───────────────────────────────────────────── + + /// + /// Get status history for a response (AJAX) + /// + [HttpGet] + public async Task GetCaseStatus(int responseId) + { + var history = await _context.CaseStatusEntries + .Where(s => s.ResponseId == responseId) + .OrderByDescending(s => s.ChangedAt) + .Select(s => new + { + s.Id, + Status = s.Status.ToString(), + StatusInt = (int)s.Status, + s.ChangedByName, + s.Reason, + ChangedAt = s.ChangedAt.ToString("MMM dd, yyyy HH:mm") + }) + .ToListAsync(); + + // Current status = most recent entry, or "New" if no entries + var currentStatus = history.FirstOrDefault()?.Status ?? "New"; + var currentStatusInt = history.FirstOrDefault()?.StatusInt ?? 0; + + return Json(new { success = true, currentStatus, currentStatusInt, history }); + } + + /// + /// Update case status (AJAX) + /// + [HttpPost] + public async Task UpdateCaseStatus(int responseId, int newStatus, string reason) + { + try + { + if (!Enum.IsDefined(typeof(CaseStatusType), newStatus)) + return Json(new { success = false, message = "Invalid status." }); + + var currentUser = User.Identity?.Name ?? "Unknown"; + var currentEmail = User.Claims.FirstOrDefault(c => c.Type == System.Security.Claims.ClaimTypes.Email)?.Value; + + var entry = new CaseStatusEntry + { + ResponseId = responseId, + Status = (CaseStatusType)newStatus, + ChangedByName = currentUser, + ChangedByEmail = currentEmail, + Reason = string.IsNullOrWhiteSpace(reason) ? null : reason.Trim(), + ChangedAt = DateTime.UtcNow + }; + + _context.CaseStatusEntries.Add(entry); + await _context.SaveChangesAsync(); + + return Json(new + { + success = true, + entry = new + { + entry.Id, + Status = entry.Status.ToString(), + StatusInt = (int)entry.Status, + entry.ChangedByName, + entry.Reason, + ChangedAt = entry.ChangedAt.ToString("MMM dd, yyyy HH:mm") + } + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating case status for ResponseId {ResponseId}", responseId); + return Json(new { success = false, message = "Failed to update status." }); + } + } + + // ───────────────────────────────────────────── + // ACTION PLANS CRUD + // ───────────────────────────────────────────── + + /// + /// Get all action plans for a response (AJAX) + /// + [HttpGet] + public async Task GetActionPlans(int responseId) + { + var plans = await _context.ActionPlans + .Where(a => a.ResponseId == responseId) + .OrderByDescending(a => a.CreatedAt) + .Select(a => new + { + a.Id, + a.Title, + a.Description, + a.ActionType, + a.Priority, + a.Status, + a.AssignedTo, + a.AssignedToEmail, + ScheduledDate = a.ScheduledDate.HasValue ? a.ScheduledDate.Value.ToString("MMM dd, yyyy HH:mm") : null, + CompletedDate = a.CompletedDate.HasValue ? a.CompletedDate.Value.ToString("MMM dd, yyyy HH:mm") : null, + a.CompletionNotes, + a.CreatedByName, + CreatedAt = a.CreatedAt.ToString("MMM dd, yyyy HH:mm") + }) + .ToListAsync(); + + return Json(new { success = true, plans }); + } + + /// + /// Create a new action plan (AJAX) + /// + [HttpPost] + public async Task CreateActionPlan( + int responseId, string title, string description, + string actionType, string priority, string assignedTo, + string assignedToEmail, string scheduledDate) + { + try + { + if (string.IsNullOrWhiteSpace(title)) + return Json(new { success = false, message = "Title is required." }); + + var currentUser = User.Identity?.Name ?? "Unknown"; + var currentEmail = User.Claims.FirstOrDefault(c => c.Type == System.Security.Claims.ClaimTypes.Email)?.Value; + + var plan = new ActionPlan + { + ResponseId = responseId, + Title = title.Trim(), + Description = description?.Trim(), + ActionType = string.IsNullOrWhiteSpace(actionType) ? "ImmediateContact" : actionType, + Priority = string.IsNullOrWhiteSpace(priority) ? "Normal" : priority, + Status = "Pending", + AssignedTo = assignedTo?.Trim(), + AssignedToEmail = assignedToEmail?.Trim(), + ScheduledDate = DateTime.TryParse(scheduledDate, out var sd) ? sd : null, + CreatedByName = currentUser, + CreatedByEmail = currentEmail, + CreatedAt = DateTime.UtcNow + }; + + _context.ActionPlans.Add(plan); + await _context.SaveChangesAsync(); + + // Also auto-add a status entry for "InterventionScheduled" if not already + var hasIntervention = await _context.CaseStatusEntries + .AnyAsync(s => s.ResponseId == responseId && s.Status == CaseStatusType.InterventionScheduled); + if (!hasIntervention) + { + _context.CaseStatusEntries.Add(new CaseStatusEntry + { + ResponseId = responseId, + Status = CaseStatusType.InterventionScheduled, + ChangedByName = currentUser, + ChangedByEmail = currentEmail, + Reason = $"Action plan created: {title}", + ChangedAt = DateTime.UtcNow + }); + await _context.SaveChangesAsync(); + } + + return Json(new + { + success = true, + plan = new + { + plan.Id, + plan.Title, + plan.Description, + plan.ActionType, + plan.Priority, + plan.Status, + plan.AssignedTo, + ScheduledDate = plan.ScheduledDate?.ToString("MMM dd, yyyy HH:mm"), + plan.CreatedByName, + CreatedAt = plan.CreatedAt.ToString("MMM dd, yyyy HH:mm") + } + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating action plan for ResponseId {ResponseId}", responseId); + return Json(new { success = false, message = "Failed to create action plan." }); + } + } + + /// + /// Update action plan status (AJAX) — mark as InProgress, Completed, Cancelled + /// + [HttpPost] + public async Task UpdateActionPlanStatus(int planId, string status, string completionNotes) + { + try + { + var plan = await _context.ActionPlans.FindAsync(planId); + if (plan == null) + return Json(new { success = false, message = "Action plan not found." }); + + plan.Status = status; + plan.UpdatedAt = DateTime.UtcNow; + + if (status == "Completed") + { + plan.CompletedDate = DateTime.UtcNow; + plan.CompletionNotes = completionNotes?.Trim(); + } + + await _context.SaveChangesAsync(); + + return Json(new { success = true, newStatus = plan.Status }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating action plan {PlanId}", planId); + return Json(new { success = false, message = "Failed to update action plan." }); + } + } + + // ───────────────────────────────────────────── + // RESPONDENT HISTORY TIMELINE + // ───────────────────────────────────────────── + + /// + /// Get all responses from the same user across all questionnaires (AJAX) + /// + [HttpGet] + public async Task GetRespondentHistory(int responseId) + { + try + { + // Get the current response to find the user + var currentResponse = await _context.Responses + .FirstOrDefaultAsync(r => r.Id == responseId); + + if (currentResponse == null) + return Json(new { success = false, message = "Response not found." }); + + var userEmail = currentResponse.UserEmail; + var userName = currentResponse.UserName; + + // Find all responses from this user + var history = await _context.Responses + .Include(r => r.Questionnaire) + .Include(r => r.ResponseDetails) + .ThenInclude(rd => rd.ResponseAnswers) + .Where(r => + (!string.IsNullOrEmpty(userEmail) && r.UserEmail == userEmail) || + (!string.IsNullOrEmpty(userName) && r.UserName == userName)) + .OrderByDescending(r => r.SubmissionDate) + .Select(r => new + { + r.Id, + QuestionnaireTitle = r.Questionnaire != null ? r.Questionnaire.Title : "Unknown", + r.QuestionnaireId, + SubmissionDate = r.SubmissionDate.ToString("MMM dd, yyyy HH:mm"), + SubmissionDateRaw = r.SubmissionDate, + TotalAnswered = r.ResponseDetails.Count, + TextResponses = r.ResponseDetails.Count(rd => !string.IsNullOrEmpty(rd.TextResponse)), + CheckboxResponses = r.ResponseDetails.Count(rd => rd.ResponseAnswers.Any()), + IsCurrent = r.Id == responseId, + // Get case status if any + LatestStatus = _context.CaseStatusEntries + .Where(s => s.ResponseId == r.Id) + .OrderByDescending(s => s.ChangedAt) + .Select(s => s.Status.ToString()) + .FirstOrDefault() + }) + .ToListAsync(); + + return Json(new + { + success = true, + userName = userName ?? "Anonymous", + userEmail = userEmail ?? "No email", + totalResponses = history.Count, + history + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting respondent history for ResponseId {ResponseId}", responseId); + return Json(new { success = false, message = "Failed to load history." }); + } + } + + // ───────────────────────────────────────────── + // PDF EXPORT — Case Report + // ───────────────────────────────────────────── + + /// + /// Export case as PDF report (download) + /// + [HttpGet] + public async Task ExportCasePdf(int questionnaireId, int responseId) + { + try + { + var response = await _context.Responses + .Include(r => r.Questionnaire) + .Include(r => r.ResponseDetails) + .ThenInclude(rd => rd.Question) + .Include(r => r.ResponseDetails) + .ThenInclude(rd => rd.ResponseAnswers) + .ThenInclude(ra => ra.Answer) + .FirstOrDefaultAsync(r => r.Id == responseId && r.QuestionnaireId == questionnaireId); + + if (response == null) + return NotFound(); + + // Get analysis results + var analysisResults = new List(); + foreach (var detail in response.ResponseDetails) + { + var analysisText = BuildResponseText(detail); + if (!string.IsNullOrWhiteSpace(analysisText)) + { + var analysisRequest = new AnalysisRequest + { + ResponseId = response.Id, + QuestionId = detail.QuestionId, + ResponseText = analysisText, + QuestionText = detail.Question?.Text ?? "" + }; + var analysis = await _aiAnalysisService.AnalyzeCompleteResponseAsync(analysisRequest); + analysisResults.Add(analysis); + } + } + + // Get case notes + var notes = await _context.CaseNotes + .Where(n => n.ResponseId == responseId) + .OrderByDescending(n => n.CreatedAt) + .ToListAsync(); + + // Get status history + var statusHistory = await _context.CaseStatusEntries + .Where(s => s.ResponseId == responseId) + .OrderByDescending(s => s.ChangedAt) + .ToListAsync(); + + // Get action plans + var actionPlans = await _context.ActionPlans + .Where(a => a.ResponseId == responseId) + .OrderByDescending(a => a.CreatedAt) + .ToListAsync(); + + // Build PDF content as structured text (to be rendered by view) + var reportData = new + { + Response = response, + Analysis = analysisResults, + Notes = notes, + StatusHistory = statusHistory, + ActionPlans = actionPlans, + GeneratedAt = DateTime.UtcNow, + GeneratedBy = User.Identity?.Name ?? "System" + }; + + // Build plain text report for download + var report = new System.Text.StringBuilder(); + report.AppendLine("═══════════════════════════════════════════════════"); + report.AppendLine(" NVKN — MENTAL HEALTH CASE REPORT (CONFIDENTIAL)"); + report.AppendLine("═══════════════════════════════════════════════════"); + report.AppendLine(); + report.AppendLine($"Generated: {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC"); + report.AppendLine($"Generated By: {User.Identity?.Name ?? "System"}"); + report.AppendLine($"Response ID: #{response.Id}"); + report.AppendLine($"Questionnaire: {response.Questionnaire?.Title}"); + report.AppendLine($"Respondent: {response.UserName ?? "Anonymous"}"); + report.AppendLine($"Submission Date: {response.SubmissionDate:yyyy-MM-dd HH:mm}"); + report.AppendLine(); + + // Status + report.AppendLine("── CASE STATUS ──────────────────────────────────"); + if (statusHistory.Any()) + { + var current = statusHistory.First(); + report.AppendLine($"Current Status: {current.Status}"); + report.AppendLine($"Last Updated: {current.ChangedAt:yyyy-MM-dd HH:mm} by {current.ChangedByName}"); + if (!string.IsNullOrEmpty(current.Reason)) + report.AppendLine($"Reason: {current.Reason}"); + } + else + { + report.AppendLine("Current Status: New"); + } + report.AppendLine(); + + // Analysis Results + report.AppendLine("── AI ANALYSIS RESULTS ─────────────────────────"); + foreach (var analysis in analysisResults) + { + report.AppendLine(); + report.AppendLine($"Question: {analysis.QuestionText}"); + report.AppendLine($"Response (Anonymized): {analysis.AnonymizedResponseText}"); + + if (analysis.SentimentAnalysis != null) + { + report.AppendLine($"Sentiment: {analysis.SentimentAnalysis.Sentiment}"); + report.AppendLine($" Positive: {Math.Round(analysis.SentimentAnalysis.PositiveScore * 100, 1)}%"); + report.AppendLine($" Neutral: {Math.Round(analysis.SentimentAnalysis.NeutralScore * 100, 1)}%"); + report.AppendLine($" Negative: {Math.Round(analysis.SentimentAnalysis.NegativeScore * 100, 1)}%"); + } + + if (analysis.RiskAssessment != null) + { + report.AppendLine($"Risk Level: {analysis.RiskAssessment.RiskLevel}"); + report.AppendLine($"Risk Score: {Math.Round(analysis.RiskAssessment.RiskScore * 100, 0)}%"); + if (analysis.RiskAssessment.RequiresImmediateAttention) + report.AppendLine("⚠ REQUIRES IMMEDIATE ATTENTION"); + if (!string.IsNullOrEmpty(analysis.RiskAssessment.RecommendedAction)) + report.AppendLine($"Recommended Action: {analysis.RiskAssessment.RecommendedAction}"); + if (analysis.RiskAssessment.RiskIndicators?.Any() == true) + report.AppendLine($"Risk Indicators: {string.Join(", ", analysis.RiskAssessment.RiskIndicators)}"); + if (analysis.RiskAssessment.ProtectiveFactors?.Any() == true) + report.AppendLine($"Protective Factors: {string.Join(", ", analysis.RiskAssessment.ProtectiveFactors)}"); + } + + if (analysis.KeyPhrases?.KeyPhrases?.Any() == true) + report.AppendLine($"Key Phrases: {string.Join(", ", analysis.KeyPhrases.KeyPhrases)}"); + + if (analysis.Insights?.Any() == true) + { + report.AppendLine("Workplace Insights:"); + foreach (var insight in analysis.Insights) + { + report.AppendLine($" [{insight.Category}] {insight.Issue}"); + report.AppendLine($" → {insight.RecommendedIntervention} (Priority: {insight.Priority})"); + } + } + report.AppendLine(" ─ ─ ─ ─ ─ ─ ─ ─ ─ ─"); + } + report.AppendLine(); + + // Action Plans + report.AppendLine("── ACTION PLANS ────────────────────────────────"); + if (actionPlans.Any()) + { + foreach (var plan in actionPlans) + { + report.AppendLine($" [{plan.Priority}] {plan.Title} — {plan.Status}"); + report.AppendLine($" Type: {plan.ActionType}"); + if (!string.IsNullOrEmpty(plan.AssignedTo)) + report.AppendLine($" Assigned To: {plan.AssignedTo}"); + if (plan.ScheduledDate.HasValue) + report.AppendLine($" Scheduled: {plan.ScheduledDate.Value:yyyy-MM-dd HH:mm}"); + if (!string.IsNullOrEmpty(plan.Description)) + report.AppendLine($" Description: {plan.Description}"); + if (plan.CompletedDate.HasValue) + report.AppendLine($" Completed: {plan.CompletedDate.Value:yyyy-MM-dd HH:mm}"); + report.AppendLine(); + } + } + else + { + report.AppendLine(" No action plans created yet."); + } + report.AppendLine(); + + // Case Notes + report.AppendLine("── CASE NOTES ──────────────────────────────────"); + if (notes.Any()) + { + foreach (var note in notes) + { + report.AppendLine($" [{note.Category}] {note.CreatedAt:yyyy-MM-dd HH:mm} — {note.AuthorName}"); + report.AppendLine($" {note.NoteText}"); + report.AppendLine(); + } + } + else + { + report.AppendLine(" No case notes recorded yet."); + } + report.AppendLine(); + + // Status History + report.AppendLine("── STATUS CHANGE LOG ───────────────────────────"); + if (statusHistory.Any()) + { + foreach (var entry in statusHistory) + { + report.AppendLine($" {entry.ChangedAt:yyyy-MM-dd HH:mm} — {entry.Status} by {entry.ChangedByName}"); + if (!string.IsNullOrEmpty(entry.Reason)) + report.AppendLine($" Reason: {entry.Reason}"); + } + } + else + { + report.AppendLine(" No status changes recorded."); + } + report.AppendLine(); + report.AppendLine("═══════════════════════════════════════════════════"); + report.AppendLine(" END OF REPORT — CONFIDENTIAL"); + report.AppendLine("═══════════════════════════════════════════════════"); + + var bytes = System.Text.Encoding.UTF8.GetBytes(report.ToString()); + var fileName = $"CaseReport_Response{responseId}_{DateTime.UtcNow:yyyyMMdd_HHmm}.txt"; + + return File(bytes, "text/plain", fileName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error exporting case PDF for ResponseId {ResponseId}", responseId); + TempData["ErrorMessage"] = "Error exporting case report."; + return RedirectToAction(nameof(ViewHighRiskResponse), new { questionnaireId, responseId }); + } + } + + #endregion + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #endregion } diff --git a/Web/Areas/Admin/Controllers/UserResponseController.cs b/Web/Areas/Admin/Controllers/UserResponseController.cs index 7748096..2d312eb 100644 --- a/Web/Areas/Admin/Controllers/UserResponseController.cs +++ b/Web/Areas/Admin/Controllers/UserResponseController.cs @@ -9,7 +9,7 @@ using Web.ViewModel.QuestionnaireVM; namespace Web.Areas.Admin.Controllers { - + [Area("Admin")] public class UserResponseController : Controller { private readonly SurveyContext _context; diff --git a/Web/Areas/Admin/Controllers/UserResponseStatusController.cs b/Web/Areas/Admin/Controllers/UserResponseStatusController.cs index d5221bd..fdf6c9f 100644 --- a/Web/Areas/Admin/Controllers/UserResponseStatusController.cs +++ b/Web/Areas/Admin/Controllers/UserResponseStatusController.cs @@ -7,23 +7,28 @@ using Model; using OfficeOpenXml; using Services.Interaces; using Web.ViewModel.QuestionnaireVM; +using Services.Interaces; +using Services.AIViewModel; namespace Web.Areas.Admin.Controllers { + [Area("Admin")] public class UserResponseStatusController : Controller { private readonly SurveyContext _context; private readonly IUserResponseRepository _userResponse; private readonly ILogger _logger; + private readonly IUserTrajectoryService _trajectoryService; public UserResponseStatusController( SurveyContext context, IUserResponseRepository userResponse, - ILogger logger) + ILogger logger, IUserTrajectoryService trajectoryService) { _context = context; _userResponse = userResponse; _logger = logger; + _trajectoryService = trajectoryService; } public async Task Index() @@ -688,5 +693,346 @@ namespace Web.Areas.Admin.Controllers var count = await _context.Responses.GroupBy(r => r.UserEmail).CountAsync(); return Json(new { count }); } + + /// + /// Analyze wellness trajectory for a user (AJAX) + /// Returns cached result or calls Claude API if new responses exist + /// + [HttpGet] + public async Task AnalyzeTrajectory(string userEmail) + { + try + { + if (string.IsNullOrWhiteSpace(userEmail)) + return Json(new { success = false, message = "User email is required." }); + + // Check if user has any responses + var hasResponses = await _context.Responses.AnyAsync(r => r.UserEmail == userEmail); + if (!hasResponses) + return Json(new { success = false, message = "No responses found for this user." }); + + var result = await _trajectoryService.GetOrAnalyzeTrajectoryAsync(userEmail); + + return Json(new + { + success = true, + data = result + }); + } + catch (Exception ex) + { + return Json(new { success = false, message = "Error analyzing trajectory: " + ex.Message }); + } + } + + /// + /// Force re-analyze trajectory (ignores cache) + /// + [HttpPost] + public async Task ReanalyzeTrajectory(string userEmail) + { + try + { + if (string.IsNullOrWhiteSpace(userEmail)) + return Json(new { success = false, message = "User email is required." }); + + var result = await _trajectoryService.ForceReanalyzeTrajectoryAsync(userEmail); + + return Json(new + { + success = true, + data = result + }); + } + catch (Exception ex) + { + return Json(new { success = false, message = "Error re-analyzing: " + ex.Message }); + } + } + + /// + /// Check cache status (AJAX) — useful for showing "new data available" badge + /// + [HttpGet] + public async Task CheckTrajectoryStatus(string userEmail) + { + try + { + var (hasCache, isStale, cachedCount, currentCount) = + await _trajectoryService.CheckCacheStatusAsync(userEmail); + + return Json(new + { + success = true, + hasCache, + isStale, + cachedCount, + currentCount, + newResponses = currentCount - cachedCount + }); + } + catch (Exception ex) + { + return Json(new { success = false, message = ex.Message }); + } + } + + /// + /// Export trajectory analysis as a professional PDF report + /// + [HttpGet] + public async Task ExportTrajectoryPdf(string userEmail) + { + try + { + if (string.IsNullOrWhiteSpace(userEmail)) + return BadRequest("User email is required."); + + var analysis = await _trajectoryService.GetOrAnalyzeTrajectoryAsync(userEmail); + + var userResponse = await _context.Responses + .FirstOrDefaultAsync(r => r.UserEmail == userEmail); + var userName = userResponse?.UserName ?? "Unknown"; + + // ── Build PDF ── + var stream = new MemoryStream(); + var document = new Document(PageSize.A4, 40, 40, 40, 40); + var writer = PdfWriter.GetInstance(document, stream); + writer.CloseStream = false; + document.Open(); + + // Fonts + var titleFont = FontFactory.GetFont(FontFactory.HELVETICA_BOLD, 20, new BaseColor(30, 41, 59)); + var h1Font = FontFactory.GetFont(FontFactory.HELVETICA_BOLD, 14, new BaseColor(30, 41, 59)); + var h2Font = FontFactory.GetFont(FontFactory.HELVETICA_BOLD, 11, new BaseColor(71, 85, 105)); + var bodyFont = FontFactory.GetFont(FontFactory.HELVETICA, 10, BaseColor.BLACK); + var smallFont = FontFactory.GetFont(FontFactory.HELVETICA, 9, new BaseColor(100, 116, 139)); + var boldFont = FontFactory.GetFont(FontFactory.HELVETICA_BOLD, 10, BaseColor.BLACK); + var accentFont = FontFactory.GetFont(FontFactory.HELVETICA_BOLD, 10, new BaseColor(79, 70, 229)); + + // Colors + var primaryBg = new BaseColor(238, 242, 255); // indigo-50 + var greenBg = new BaseColor(236, 253, 245); // green-50 + var redBg = new BaseColor(254, 242, 242); // red-50 + var yellowBg = new BaseColor(254, 252, 232); // yellow-50 + var grayBg = new BaseColor(248, 250, 252); // slate-50 + var darkText = new BaseColor(30, 41, 59); + var greenText = new BaseColor(22, 163, 74); + var redText = new BaseColor(220, 38, 38); + var yellowText = new BaseColor(161, 98, 7); + + // ── Logo ── + var logoPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "images", "logo.png"); + if (System.IO.File.Exists(logoPath)) + { + var logo = Image.GetInstance(logoPath); + logo.ScaleToFit(120f, 60f); + logo.Alignment = Image.ALIGN_LEFT; + document.Add(logo); + document.Add(new Paragraph(" ") { SpacingAfter = 5 }); + } + + // ── Title ── + document.Add(new Paragraph("Wellness Trajectory Analysis Report", titleFont) { SpacingAfter = 5 }); + document.Add(new Paragraph("CONFIDENTIAL — For Professional Use Only", + FontFactory.GetFont(FontFactory.HELVETICA, 8, new BaseColor(220, 38, 38))) + { SpacingAfter = 15 }); + + // ── User Info Table ── + var infoTable = new PdfPTable(2) { WidthPercentage = 100, SpacingAfter = 15 }; + infoTable.SetWidths(new float[] { 1, 2 }); + AddInfoRow(infoTable, "Respondent:", userName, boldFont, bodyFont, grayBg); + AddInfoRow(infoTable, "Email:", userEmail, boldFont, bodyFont, grayBg); + AddInfoRow(infoTable, "Responses Analyzed:", analysis.TotalResponsesAnalyzed.ToString(), boldFont, bodyFont, grayBg); + AddInfoRow(infoTable, "Report Generated:", DateTime.UtcNow.ToString("MMMM dd, yyyy HH:mm UTC"), boldFont, bodyFont, grayBg); + document.Add(infoTable); + + // ── Trajectory Overview ── + AddSectionHeader(document, "TRAJECTORY OVERVIEW", h1Font); + + var overviewTable = new PdfPTable(4) { WidthPercentage = 100, SpacingAfter = 10 }; + overviewTable.SetWidths(new float[] { 1, 1, 1, 1 }); + + AddMetricCell(overviewTable, "Direction", analysis.TrajectoryDirection, accentFont, smallFont, primaryBg); + AddMetricCell(overviewTable, "Wellness Score", $"{analysis.TrajectoryScore}/100", accentFont, smallFont, primaryBg); + AddMetricCell(overviewTable, "Score Change", $"{(analysis.ScoreChange >= 0 ? "+" : "")}{analysis.ScoreChange}", accentFont, smallFont, + analysis.ScoreChange >= 0 ? greenBg : redBg); + AddMetricCell(overviewTable, "Risk Level", analysis.OverallRiskLevel, + FontFactory.GetFont(FontFactory.HELVETICA_BOLD, 10, + analysis.OverallRiskLevel == "Critical" || analysis.OverallRiskLevel == "High" ? redText : + analysis.OverallRiskLevel == "Moderate" ? yellowText : greenText), + smallFont, + analysis.OverallRiskLevel == "Critical" || analysis.OverallRiskLevel == "High" ? redBg : + analysis.OverallRiskLevel == "Moderate" ? yellowBg : greenBg); + document.Add(overviewTable); + + // Executive Summary + document.Add(new Paragraph("Executive Summary", h2Font) { SpacingBefore = 8, SpacingAfter = 5 }); + document.Add(new Paragraph(analysis.ExecutiveSummary, bodyFont) { SpacingAfter = 15 }); + + // ── Response History ── + AddSectionHeader(document, "RESPONSE HISTORY", h1Font); + + foreach (var snap in analysis.ResponseSnapshots) + { + var snapTable = new PdfPTable(4) { WidthPercentage = 100, SpacingAfter = 8 }; + snapTable.SetWidths(new float[] { 1.5f, 0.8f, 0.8f, 0.8f }); + + var nameCell = new PdfPCell(new Phrase($"{snap.ResponseDate} — {snap.QuestionnaireName}", boldFont)) + { Colspan = 4, BackgroundColor = grayBg, Padding = 8, BorderColor = new BaseColor(226, 232, 240) }; + snapTable.AddCell(nameCell); + + AddSnapCell(snapTable, $"Score: {snap.WellnessScore}/100", bodyFont, BaseColor.WHITE); + AddSnapCell(snapTable, $"Risk: {snap.RiskLevel}", bodyFont, + snap.RiskLevel == "Critical" || snap.RiskLevel == "High" ? redBg : + snap.RiskLevel == "Moderate" ? yellowBg : greenBg); + AddSnapCell(snapTable, $"Sentiment: {snap.SentimentLabel}", bodyFont, BaseColor.WHITE); + AddSnapCell(snapTable, snap.KeyThemes.Any() ? string.Join(", ", snap.KeyThemes) : "—", smallFont, BaseColor.WHITE); + + var summCell = new PdfPCell(new Phrase(snap.BriefSummary, smallFont)) + { Colspan = 4, Padding = 6, BorderColor = new BaseColor(226, 232, 240), PaddingBottom = 8 }; + snapTable.AddCell(summCell); + + document.Add(snapTable); + } + + // ── Pattern Insights ── + if (analysis.PatternInsights.Any()) + { + AddSectionHeader(document, "PATTERN INSIGHTS", h1Font); + foreach (var p in analysis.PatternInsights) + { + var status = p.StillPresent ? "Still Present" : "Resolved"; + var statusColor = p.StillPresent ? redText : greenText; + document.Add(new Paragraph($"[{p.Severity.ToUpper()}] {p.Pattern}", boldFont) { SpacingAfter = 2 }); + document.Add(new Paragraph($"First seen: {p.FirstSeen} — {status}", + FontFactory.GetFont(FontFactory.HELVETICA, 9, statusColor)) + { SpacingAfter = 8 }); + } + } + + // ── Strengths ── + if (analysis.StrengthFactors.Any()) + { + AddSectionHeader(document, "PROTECTIVE STRENGTHS", h1Font); + foreach (var s in analysis.StrengthFactors) + { + document.Add(new Paragraph($"✓ {s.Factor}", + FontFactory.GetFont(FontFactory.HELVETICA, 10, greenText)) + { SpacingAfter = 5 }); + } + } + + // ── Concerns ── + if (analysis.ConcernFactors.Any()) + { + document.Add(new Paragraph(" ") { SpacingAfter = 5 }); + AddSectionHeader(document, "AREAS OF CONCERN", h1Font); + foreach (var c in analysis.ConcernFactors) + { + document.Add(new Paragraph($"⚠ [{c.Urgency.ToUpper()}] {c.Concern}", + FontFactory.GetFont(FontFactory.HELVETICA, 10, + c.Urgency == "Immediate" ? redText : darkText)) + { SpacingAfter = 5 }); + } + } + + // ── Recommendations ── + if (analysis.Recommendations.Any()) + { + document.Add(new Paragraph(" ") { SpacingAfter = 5 }); + AddSectionHeader(document, "AI RECOMMENDATIONS", h1Font); + int recNum = 0; + foreach (var r in analysis.Recommendations) + { + recNum++; + document.Add(new Paragraph($"{recNum}. [{r.Priority.ToUpper()}] {r.Action}", boldFont) { SpacingAfter = 2 }); + document.Add(new Paragraph($" Category: {r.Category}", smallFont) { SpacingAfter = 8 }); + } + } + + // ── Narrative ── + document.Add(new Paragraph(" ") { SpacingAfter = 5 }); + AddSectionHeader(document, "PROFESSIONAL NARRATIVE", h1Font); + document.Add(new Paragraph(analysis.TimelineNarrative, + FontFactory.GetFont(FontFactory.HELVETICA_OBLIQUE, 10, darkText)) + { SpacingAfter = 15 }); + + // ── Detailed Analysis ── + AddSectionHeader(document, "DETAILED CLINICAL ANALYSIS", h1Font); + document.Add(new Paragraph(analysis.DetailedAnalysis, bodyFont) { SpacingAfter = 15 }); + + // ── Footer ── + var footerTable = new PdfPTable(1) { WidthPercentage = 100, SpacingBefore = 20 }; + var footerCell = new PdfPCell(new Phrase( + "This report was generated by AI-powered wellness analysis. It is intended for professional mental health consultants and should be treated as CONFIDENTIAL.", + FontFactory.GetFont(FontFactory.HELVETICA, 8, new BaseColor(148, 163, 184)))) + { + BackgroundColor = grayBg, + Padding = 10, + HorizontalAlignment = Element.ALIGN_CENTER, + BorderColor = new BaseColor(226, 232, 240) + }; + footerTable.AddCell(footerCell); + document.Add(footerTable); + + document.Close(); + writer.Close(); + + stream.Position = 0; + var fileName = $"WellnessTrajectory_{userName.Replace(" ", "_")}_{DateTime.UtcNow:yyyyMMdd}.pdf"; + return File(stream, "application/pdf", fileName); + } + catch (Exception ex) + { + TempData["ErrorMessage"] = "Error exporting trajectory report: " + ex.Message; + return RedirectToAction(nameof(UserResponsesStatus), new { userEmail }); + } + } + + // ── PDF Helper Methods (add these as private methods in the controller) ── + + private void AddSectionHeader(Document doc, string text, Font font) + { + var p = new Paragraph(text, font) { SpacingBefore = 10, SpacingAfter = 8 }; + doc.Add(p); + // Add a thin line under the header + var line = new PdfPTable(1) { WidthPercentage = 100, SpacingAfter = 8 }; + var lineCell = new PdfPCell() { FixedHeight = 2, BackgroundColor = new BaseColor(79, 70, 229), Border = 0 }; + line.AddCell(lineCell); + doc.Add(line); + } + + private void AddInfoRow(PdfPTable table, string label, string value, Font labelFont, Font valueFont, BaseColor bg) + { + var lCell = new PdfPCell(new Phrase(label, labelFont)) + { BackgroundColor = bg, Padding = 6, BorderColor = new BaseColor(226, 232, 240) }; + var vCell = new PdfPCell(new Phrase(value, valueFont)) + { Padding = 6, BorderColor = new BaseColor(226, 232, 240) }; + table.AddCell(lCell); + table.AddCell(vCell); + } + + private void AddMetricCell(PdfPTable table, string label, string value, Font valueFont, Font labelFont, BaseColor bg) + { + var cell = new PdfPCell() + { + BackgroundColor = bg, + Padding = 10, + HorizontalAlignment = Element.ALIGN_CENTER, + BorderColor = new BaseColor(226, 232, 240) + }; + cell.AddElement(new Paragraph(value, valueFont) { Alignment = Element.ALIGN_CENTER }); + cell.AddElement(new Paragraph(label, labelFont) { Alignment = Element.ALIGN_CENTER, SpacingBefore = 2 }); + table.AddCell(cell); + } + + private void AddSnapCell(PdfPTable table, string text, Font font, BaseColor bg) + { + var cell = new PdfPCell(new Phrase(text, font)) + { BackgroundColor = bg, Padding = 6, BorderColor = new BaseColor(226, 232, 240) }; + table.AddCell(cell); + } + } } \ No newline at end of file diff --git a/Web/Areas/Admin/Controllers/UsersController.cs b/Web/Areas/Admin/Controllers/UsersController.cs index 3b52469..3757ee3 100644 --- a/Web/Areas/Admin/Controllers/UsersController.cs +++ b/Web/Areas/Admin/Controllers/UsersController.cs @@ -4,12 +4,14 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; using Model; using System.Security.Claims; +using Web.Authorization; using Web.ViewModel.AccountVM; namespace Web.Areas.Admin.Controllers { - - [Authorize(Roles = "Admin")] + [HasPermission(Permissions.Users.View)] + [Area("Admin")] + public class UsersController : Controller { private readonly UserManager _userManager; @@ -22,27 +24,32 @@ namespace Web.Areas.Admin.Controllers } public async Task Index() { - var users = _userManager.Users.ToList(); // Consider pagination or asynchronous list retrieval if the user list is very large. + var users = _userManager.Users.ToList(); var models = new List(); foreach (var user in users) { - var roles = await _userManager.GetRolesAsync(user); // Await the asynchronous call to get roles. + var roles = await _userManager.GetRolesAsync(user); var model = new RegisterViewModel { - Id=user.Id, + Id = user.Id, Email = user.Email, - FirstName = user.FirstName, // Assuming these fields are in ApplicationUser + FirstName = user.FirstName, LastName = user.LastName, - SelectedRoles = roles.ToList() // Now roles is properly awaited and converted to List. + SelectedRoles = roles.ToList() }; models.Add(model); } + // Pass roles for the modals + ViewBag.Roles = _roleManager.Roles + .Select(r => new SelectListItem { Value = r.Name, Text = r.Name }) + .ToList(); + return View(models); } - + [HasPermission(Permissions.Users.Create)] public IActionResult Register() { var model = new RegisterViewModel @@ -52,6 +59,8 @@ namespace Web.Areas.Admin.Controllers return View(model); } + + [HasPermission(Permissions.Users.Create)] [HttpPost] [ValidateAntiForgeryToken] public async Task Register(RegisterViewModel model) @@ -100,6 +109,8 @@ namespace Web.Areas.Admin.Controllers } + + [HasPermission(Permissions.Users.Delete)] [HttpPost] [ValidateAntiForgeryToken] public async Task DeleteSelected(List selectedUserIds) @@ -120,7 +131,7 @@ namespace Web.Areas.Admin.Controllers return RedirectToAction(nameof(Index)); } - + [HasPermission(Permissions.Users.Edit)] [HttpGet] public async Task Edit(string id) { @@ -150,8 +161,11 @@ namespace Web.Areas.Admin.Controllers return View(viewModel); } + + [HasPermission(Permissions.Users.Edit)] [HttpPost] [ValidateAntiForgeryToken] + public async Task Edit(EditUserViewModel model) { var user = await _userManager.FindByIdAsync(model.Id); @@ -196,8 +210,93 @@ namespace Web.Areas.Admin.Controllers return View(model); } + [HasPermission(Permissions.Users.Create)] + [HttpPost] + [ValidateAntiForgeryToken] + public async Task RegisterAjax(RegisterViewModel model) + { + if (!ModelState.IsValid) + { + var errors = ModelState.Values + .SelectMany(v => v.Errors) + .Select(e => e.ErrorMessage) + .ToList(); + return Json(new { success = false, errors }); + } + + var existingUser = await _userManager.FindByEmailAsync(model.Email); + if (existingUser != null) + { + return Json(new { success = false, errors = new List { "A user with this email already exists." } }); + } + + var user = new ApplicationUser + { + UserName = model.Email, + Email = model.Email, + FirstName = model.FirstName, + LastName = model.LastName + }; + + var result = await _userManager.CreateAsync(user, model.Password); + if (result.Succeeded) + { + if (model.SelectedRoles != null && model.SelectedRoles.Any()) + { + foreach (var role in model.SelectedRoles) + { + await _userManager.AddToRoleAsync(user, role); + } + } + + return Json(new { success = true, message = "User created successfully." }); + } + + var createErrors = result.Errors.Select(e => e.Description).ToList(); + return Json(new { success = false, errors = createErrors }); + } + [HasPermission(Permissions.Users.Edit)] + [HttpPost] + [ValidateAntiForgeryToken] + public async Task EditAjax(EditUserViewModel model) + { + if (!ModelState.IsValid) + { + var errors = ModelState.Values + .SelectMany(v => v.Errors) + .Select(e => e.ErrorMessage) + .ToList(); + return Json(new { success = false, errors }); + } + + var user = await _userManager.FindByIdAsync(model.Id); + if (user == null) + { + return Json(new { success = false, errors = new List { $"User with Id = {model.Id} cannot be found." } }); + } + + user.FirstName = model.FirstName; + user.LastName = model.LastName; + + var result = await _userManager.UpdateAsync(user); + if (result.Succeeded) + { + var currentRoles = await _userManager.GetRolesAsync(user); + await _userManager.RemoveFromRolesAsync(user, currentRoles); + + if (model.SelectedRoles != null && model.SelectedRoles.Any()) + { + await _userManager.AddToRolesAsync(user, model.SelectedRoles); + } + + return Json(new { success = true, message = "User updated successfully." }); + } + + var updateErrors = result.Errors.Select(e => e.Description).ToList(); + return Json(new { success = false, errors = updateErrors }); + } } } diff --git a/Web/Areas/Admin/Views/AccessDenied/Index.cshtml b/Web/Areas/Admin/Views/AccessDenied/Index.cshtml new file mode 100644 index 0000000..e63000e --- /dev/null +++ b/Web/Areas/Admin/Views/AccessDenied/Index.cshtml @@ -0,0 +1,68 @@ +@{ + ViewData["Title"] = "Access Denied"; +} + +@section Styles { + +} + +
+
+ +
+
+ +
+
403
+

Access Denied

+

+ You do not have permission to access this resource. + Your current role does not include the required privileges for this action. +

+ +
+ + If you believe this is an error, please contact your system administrator to request the appropriate permissions for your role. +
+ + + +
+ Logged in as: @User.Identity?.Name +
+
+
\ No newline at end of file diff --git a/Web/Areas/Admin/Views/Admin/Index.cshtml b/Web/Areas/Admin/Views/Admin/Index.cshtml index 718e6ec..a66bcc5 100644 --- a/Web/Areas/Admin/Views/Admin/Index.cshtml +++ b/Web/Areas/Admin/Views/Admin/Index.cshtml @@ -4,1617 +4,740 @@ } @section Styles { - } -
- -
-
-
-

Survey Analytics Dashboard

-

Real-time insights into your questionnaire performance

+
+
+ + - -
-
-
-
- +
+ + +
+
+
+
+
+

Survey Analytics Dashboard

+

Real-time insights into your questionnaire performance

+
+
+
-
@Model.SurveyAnalytics.TotalQuestionnaires
-
Total Questionnaires
-
- - @Model.SurveyAnalytics.TrendData.QuestionnairesTrendText -
-
-
-
- + +
+
+
+
@Model.SurveyAnalytics.TotalQuestionnaires
+
Total Questionnaires
+
+ + @Model.SurveyAnalytics.TrendData.QuestionnairesTrendText
-
@Model.SurveyAnalytics.TotalResponses
-
Total Responses
-
- - @Model.SurveyAnalytics.TrendData.ResponsesTrendText -
-
- -
-
-
- +
+
+
@Model.SurveyAnalytics.TotalResponses
+
Total Responses
+
+ + @Model.SurveyAnalytics.TrendData.ResponsesTrendText
-
@Model.SurveyAnalytics.CompletionRate%
-
Avg. Completion Rate
-
- - @Model.SurveyAnalytics.TrendData.CompletionTrendText -
-
- -
-
-
- +
+
+
@Model.SurveyAnalytics.CompletionRate%
+
Avg. Completion Rate
+
+ + @Model.SurveyAnalytics.TrendData.CompletionTrendText
-
@Model.SurveyAnalytics.AvgResponseTime
-
Avg. Minutes per Survey
-
- - @Model.SurveyAnalytics.TrendData.ResponseTimeTrendText -
-
-
- - -
-
-
-

Response Rate Trends

-
-
- Live Data +
+
+
@Model.SurveyAnalytics.AvgResponseTime
+
Avg. Minutes per Survey
+
+ + @Model.SurveyAnalytics.TrendData.ResponseTimeTrendText
-
@Model.SurveyAnalytics.ResponseRateTrend%
-
Current Month Response Rate
-
-
+
+ + +
+
+

Response Rate Trends

+
@Model.SurveyAnalytics.ResponseRateTrend%
+
Current Month Response Rate
+
+
+
+

User Engagement

Last 30 days
+
@Model.SurveyAnalytics.MonthlyActiveUsers
+
Active Participants
+
+
+
+

Survey Quality

Response quality
+
@Model.SurveyAnalytics.QualityScore/10
+
Quality Score
+
-
-
-

User Engagement

- Last 30 days + +
+ +
+
+

Responses Over Time

+ 30 Days +
+
+ + +
-
@Model.SurveyAnalytics.MonthlyActiveUsers
-
Active Participants
-
-
+ + +
+
+

Questions vs Responses

+ Per Survey +
+
+ + +
+
+ + +
+
+

Completion Rate

+ Per Survey +
+
+ + +
+
+ + +
+
+

Weekly Activity

+ This Week +
+
+ + +
-
-
-

Survey Quality

- Response quality + +
+
+

Recent Activity

+ Last 24 hours
-
@Model.SurveyAnalytics.QualityScore/10
-
Quality Score
-
-
-
-
-
- - -
-
-
-

Survey Responses Over Time

- -
-
- -
-
- -
-
-

Question Types Distribution

- All Surveys -
-
- -
-
- -
-
-

Survey Performance

- Top performing -
-
- -
-
- -
-
-

Response Rate Trends

- -
-
- -
-
-
- - - - -
-
-

Recent Activity

- Last 24 hours -
-
- @if (Model.SurveyAnalytics.RecentActivity.Any()) - { - @foreach (var activity in Model.SurveyAnalytics.RecentActivity.Take(5)) +
+ @if (Model.SurveyAnalytics.RecentActivity.Any()) { -
-
- -
-
-
@activity.Description
-
- @activity.Timestamp.ToString("MMM dd, yyyy HH:mm") - @(!string.IsNullOrEmpty(activity.UserName) ? $"by {activity.UserName}" : "") - @if (!string.IsNullOrEmpty(activity.UserEmail)) + @foreach (var activity in Model.SurveyAnalytics.RecentActivity.Take(5)) + { +
+
+ +
+
+
@activity.Description
+
+ @activity.Timestamp.ToString("MMM dd, yyyy HH:mm") + @(!string.IsNullOrEmpty(activity.UserName) ? $"by {activity.UserName}" : "") + @if (!string.IsNullOrEmpty(activity.UserEmail)) + { (@activity.UserEmail) } +
+
+
+ @if (activity.Type == "response") { - (@activity.UserEmail) + @if (activity.ResponseId > 0) + { View } + @if (!string.IsNullOrEmpty(activity.UserEmail)) + { Analytics } } + else if (activity.Type == "creation") + { + @if (activity.QuestionnaireId > 0) + { + View + Edit + } + } + else + { View All }
-
- @if (activity.Type == "response") - { - - @if (activity.ResponseId > 0) - { - - View Response - - } - - @if (!string.IsNullOrEmpty(activity.UserEmail)) - { - - User Analytics - - } - } - else if (activity.Type == "creation") - { - - @if (activity.QuestionnaireId > 0) - { - - View Survey - - - Edit - - } - } - else - { - - - View All - - } + } + } + else + { +
+
+
+
Welcome to your survey dashboard!
+
Start by creating your first questionnaire to see activity here
+
+
} +
+
+ + +
+

Active Questionnaires

+ @if (Model.SurveyAnalytics.TopSurveys.Any()) + { +
+ + + + @foreach (var survey in Model.SurveyAnalytics.TopSurveys) + { + string initial = !string.IsNullOrEmpty(survey.Title) ? survey.Title.Substring(0, 1).ToUpper() : "S"; + + + + + + + + + } + +
SurveyQuestionsResponsesCompletionStatusActions
+
+
@initial
+
+
@survey.Title
+
+ @survey.Id +
+
+
+
@survey.QuestionCount@survey.ResponseCount +
+ = 50 ? "var(--neon-yellow)" : "var(--neon-red)")">@survey.CompletionRate% +
= 50 ? "var(--neon-yellow)" : survey.CompletionRate > 0 ? "var(--neon-red)" : "var(--dark-600)")">
+
+
Active +
+ + + +
+
+
} else { -
-
- -
-
-
Welcome to your survey dashboard!
-
Start by creating your first questionnaire to see activity here
-
- +
+
+

No surveys yet

+

Create your first survey to start collecting responses

+ Create Survey
}
-
- - - -
-
-

Active Questionnaires

+ + - @if (Model.SurveyAnalytics.TopSurveys.Any()) - { -
- - - - - - - - - - - - - @foreach (var survey in Model.SurveyAnalytics.TopSurveys) - { - - - - - - - - - } - -
TitleQuestionsResponsesCompletion RateStatusActions
-
- @survey.Title - Survey ID: @survey.Id -
-
- @survey.QuestionCount - - @survey.ResponseCount - -
- @survey.CompletionRate% -
-
= 50 ? "#f59e0b" : "#ef4444");">
-
-
-
- Active - - -
-
- } - else - { -
- -

No surveys yet

-

Create your first survey to start collecting responses and see analytics here.

- - - Create Your First Survey - -
- } -
- - -
+ + + @section Scripts { + -} \ No newline at end of file + + + + + +} diff --git a/Web/Areas/Admin/Views/Questionnaire/Create.cshtml b/Web/Areas/Admin/Views/Questionnaire/Create.cshtml index 1055ed4..0db5f4e 100644 --- a/Web/Areas/Admin/Views/Questionnaire/Create.cshtml +++ b/Web/Areas/Admin/Views/Questionnaire/Create.cshtml @@ -1,887 +1,923 @@ -@model QuestionnaireViewModel +@model Web.ViewModel.QuestionnaireVM.QuestionnaireViewModel @{ - ViewData["Title"] = "Create"; + ViewData["Title"] = "Create Questionnaire"; } -
-
-
-
Create questionnaire
+
+
+
-
- -
- -
- - - -
-
- - - -
-
-
-

Create Questions

-
- @for (int i = 0; i < Model.Questions?.Count; i++) - { -
- - - - - -
- - Select a question type to see available options -
- -
- - @for (int j = 0; j < Model.Questions?[i].Answers?.Count; j++) - { -
- - - -
- } - - - -
- | - - -
- } -
-
- -
- - +
+ +
+

New Questionnaire

+
+ 0 + questions +
-
-
-
+
+
+
+ + + +
+
+ + + +
+
+
+ +
+
+
Question Types
+
+
Text
+
Checkbox
+
True / False
+
Multiple Choice
+
Rating
+
Likert Scale
+
Matrix
+
Open Ended
+
Demographic
+
Ranking
+
Image
+
Slider
+
+
+ +
+
+
+
+

Drag a question type from the left
or click one to add it here

+
+
+
+
+ +
+ +
+ +
+ +
@section Scripts { - - + const typesRequiredAnswers = ['CheckBox','TrueFalse','Multiple_choice','Rating','Likert','Ranking','Demographic']; + const typesOptionalAnswers = ['Text','Open_ended','Matrix','Slider']; + const typeIsImage = 'Image'; + const typesWithOther = ['Multiple_choice','CheckBox','TrueFalse','Demographic','Likert','Matrix']; + const typesWithConditions = ['Multiple_choice','CheckBox','TrueFalse','Demographic','Likert','Matrix','Rating','Ranking','Image']; + const defaultAnswers = { 'TrueFalse':['True','False'], 'Likert':['Strongly Disagree','Disagree','Neutral','Agree','Strongly Agree'], 'Rating':['1','2','3','4','5'] }; + let questionIndex = 0; + const canvas = document.getElementById('questionsCanvas'); + Sortable.create(canvas, { animation: 200, handle: '.q-drag', ghostClass: 'sortable-ghost', chosenClass: 'sortable-chosen', filter: '.canvas-empty', onSort: () => { updateNums(); rebuild(); refreshAllJumps(); } }); + document.querySelectorAll('.palette-item').forEach(b => { + b.addEventListener('dragstart', e => { e.dataTransfer.setData('qType', b.dataset.type); e.dataTransfer.effectAllowed = 'copy'; }); + b.addEventListener('click', () => addQ(b.dataset.type)); + }); + canvas.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; canvas.classList.add('drag-over'); }); + canvas.addEventListener('dragleave', () => canvas.classList.remove('drag-over')); + canvas.addEventListener('drop', e => { e.preventDefault(); canvas.classList.remove('drag-over'); const t = e.dataTransfer.getData('qType'); if (t) addQ(t); }); - @{ - + function addQ(type) { + $('#canvasPlaceholder').hide(); + const qi = questionIndex; + const isImg = type === typeIsImage; + const isOpt = typesOptionalAnswers.includes(type); + const hasOther = typesWithOther.includes(type); + const hasCond = typesWithConditions.includes(type); + const defs = defaultAnswers[type] || []; + + let ansHtml = ''; + if (isImg) { + ansHtml = `
${imgAns(qi,0)}
`; + } else { + let items = ''; + if (defs.length) defs.forEach((v,i) => items += ansItem(qi,i,v,false,hasOther,hasCond)); + else { items += ansItem(qi,0,'',false,hasOther,hasCond); items += ansItem(qi,1,'',false,hasOther,hasCond); } + const hint = isOpt ? `(optional)` : ''; + ansHtml = `
${items}
`; + } + + const label = type.replace('_',' '); + $(canvas).append(` +
+
+ + ${qi+1} + Untitled Question + ${label} + Saved + + +
+
+
+ ${ansHtml} +
+ + +
+
+
`); + + const al = document.querySelector(`.ans-list[data-qi="${qi}"]`); + if (al) Sortable.create(al, { animation: 150, handle: '.ans-drag', ghostClass: 'sortable-ghost', onSort: () => rebuild() }); + const il = document.querySelector(`.img-ans-list[data-qi="${qi}"]`); + if (il) Sortable.create(il, { animation: 150, handle: '.ans-drag', ghostClass: 'sortable-ghost', onSort: () => rebuild() }); + + questionIndex++; + updateNums(); rebuild(); refreshAllJumps(); + $(`.q-text[data-qi="${qi}"]`).focus(); } - + function ansItem(qi, ai, val, isOth, supOther, supCond) { + const othCls = isOth ? 'is-other' : ''; + const othPill = supOther ? `` : ''; + const condPill = supCond ? `` : ''; + const condPanel = supCond ? ` +
+ + +
+
+
+
Continue normally
+
` : ''; + return `
${othPill}${condPill}${condPanel}
`; + } - + if (cv) sel.val(cv); + } + function refreshAllJumps() { $('.ans-item').each(function () { refreshJump($(this)); }); } -} \ No newline at end of file + // ===== OTHER ===== + $(document).on('change', '.oth-chk', function () { + const item = $(this).closest('.ans-item'), pill = $(this).closest('.ans-pill'); + if ($(this).is(':checked')) { item.addClass('is-other'); pill.addClass('active'); const inp = item.find('.ans-input'); if (!inp.val().trim()) inp.val('Other (please specify)'); } + else { item.removeClass('is-other'); pill.removeClass('active'); } + rebuild(); + }); + + // ===== IMAGE ===== + $(document).on('change', '.img-file-input', function () { + const qi=$(this).data('qi'), ai=$(this).data('ai'), p=$(`#imgP_${qi}_${ai}`); + if (this.files&&this.files[0]) { const r=new FileReader(); r.onload=e=>p.attr('src',e.target.result).addClass('has-image'); r.readAsDataURL(this.files[0]); } + else p.attr('src','').removeClass('has-image'); + rebuild(); + }); + + // ===== ADD/REMOVE ===== + $(document).on('click', '.add-txt-btn', function () { + const qi=$(this).data('qi'), o=String($(this).data('other'))==='true', c=String($(this).data('cond'))==='true'; + const list=$(this).siblings('.ans-list'); + list.append(ansItem(qi,list.children().length,'',false,o,c)); + list.find('.ans-item:last .ans-input').focus(); rebuild(); refreshAllJumps(); + }); + $(document).on('click', '.txt-rm', function () { const l=$(this).closest('.ans-list'); if(l.children().length>1) $(this).closest('.ans-item').fadeOut(150,function(){$(this).remove();rebuild();refreshAllJumps();}); }); + $(document).on('click', '.add-img-btn', function () { const qi=$(this).data('qi'),l=$(this).siblings('.img-ans-list'); l.append(imgAns(qi,l.children().length)); rebuild(); }); + $(document).on('click', '.img-rm', function () { const l=$(this).closest('.img-ans-list'); if(l.children().length>1) $(this).closest('.img-ans-item').fadeOut(150,function(){$(this).remove();rebuild();}); }); + + // ===== TOGGLE / REMOVE ===== + $(document).on('click', '.q-header', function (e) { if($(e.target).closest('.q-remove').length)return; const i=$(this).find('.q-chevron').data('ti'); $(`#qBody_${i}`).toggleClass('show'); $(this).find('.q-chevron').toggleClass('open'); }); + $(document).on('click', '.q-remove', function (e) { e.stopPropagation(); $(this).closest('.q-card').fadeOut(200,function(){$(this).remove();updateNums();rebuild();refreshAllJumps();if(!$('.q-card').length)$('#canvasPlaceholder').show();}); }); + + // ===== SAVE / EDIT ===== + $(document).on('click', '.btn-q-save', function () { + const qi=$(this).data('qi'), card=$(this).closest('.q-card'), type=card.data('type'); + if(!card.find('.q-text').val().trim()){card.find('.q-text').css('border-color','var(--red)').focus();return;} + if(typesRequiredAnswers.includes(type)){let bad=false;card.find('.ans-input').each(function(){if(!$(this).val().trim()){$(this).css('border-color','var(--red)');bad=true;}});if(bad)return;} + if(type===typeIsImage){let m=false;card.find('.img-file-input').each(function(){if(!this.files||!this.files[0]){const p=$(this).closest('.img-ans-item').find('.img-preview');if(!p.hasClass('has-image')){$(this).css('border-color','var(--red)');m=true;}}});if(m)return;} + card.find('.q-text,.ans-input,.img-file-input,.oth-chk,.cond-action,.cond-jump,.cond-skip,.cond-end').prop('disabled',true); + card.find('.txt-rm,.img-rm,.logic-pill,.other-pill').css('pointer-events','none').css('opacity','0.4'); + card.find('.add-txt-btn,.add-img-btn').hide(); + card.find('.ans-drag').css('visibility','hidden'); + card.find('.btn-q-save').hide(); card.find('.btn-q-edit').css('display','inline-flex'); + card.addClass('saved'); $(`#qBody_${qi}`).removeClass('show'); card.find('.q-chevron').removeClass('open'); + rebuild(); + }); + + $(document).on('click', '.btn-q-edit', function () { + const qi=$(this).data('qi'), card=$(this).closest('.q-card'); + card.find('.q-text,.ans-input,.img-file-input,.oth-chk,.cond-action,.cond-jump,.cond-skip,.cond-end').prop('disabled',false); + card.find('.txt-rm,.img-rm,.logic-pill,.other-pill').css('pointer-events','').css('opacity',''); + card.find('.add-txt-btn,.add-img-btn').show(); + card.find('.ans-drag').css('visibility','visible'); + card.find('.btn-q-edit').hide(); card.find('.btn-q-save').show(); + card.removeClass('saved'); $(`#qBody_${qi}`).addClass('show'); card.find('.q-chevron').addClass('open'); + card.find('.q-text').focus(); + }); + + $(document).on('input', '.q-text', function () { const qi=$(this).data('qi'); $(`#qTitle_${qi}`).text($(this).val().trim()||'Untitled Question'); $(this).css('border-color',''); rebuild(); refreshAllJumps(); }); + $(document).on('input', '.ans-input', function () { $(this).css('border-color',''); rebuild(); }); + + function updateNums() { + let c = 0; + $('.q-card').each(function (i) { $(this).find('.q-num').text(i+1); c++; }); + $('#questionCounter').text(c); + } + + function buildCond(item) { + const sel = item.find('.cond-action'); if (!sel.length) return null; + const a = sel.val(); if (!a || a==='0') return null; + let c = {ActionType:parseInt(a)}; + if (a==='1'){const t=item.find('.cond-jump').val();if(t)c.TargetQuestionNumber=parseInt(t);} + else if(a==='2'){const s=item.find('.cond-skip').val();if(s)c.SkipCount=parseInt(s);} + else if(a==='3'){const m=item.find('.cond-end').val();if(m)c.EndMessage=m;} + return JSON.stringify(c); + } + + function rebuild() { + const ct = $('#hiddenFormData'); ct.empty(); + $('.q-card').each(function (qi) { + const card=$(this), type=card.data('type'), text=card.find('.q-text').val()||''; + ct.append(``); + ct.append(``); + let ai=0; + card.find('.ans-list .ans-item').each(function(){ + const v=$(this).find('.ans-input').val()||'', isO=$(this).find('.oth-chk').is(':checked'), cj=buildCond($(this)); + if(v.trim()||typesRequiredAnswers.includes(type)){ + ct.append(``); + ct.append(``); + if(cj) ct.append(``); + ai++; + } + }); + if(type===typeIsImage) card.find('.img-file-input').each(function(ii){$(this).attr('name',`ImageFiles_${qi}_${ii}`);}); + }); + } + + function esc(t){const d=document.createElement('div');d.appendChild(document.createTextNode(t));return d.innerHTML;} + + $('#questionnaireForm').on('submit', function (e) { + $('.q-card.saved').find('.img-file-input,.q-text,.ans-input,.oth-chk,.cond-action,.cond-jump,.cond-skip,.cond-end').prop('disabled',false); + rebuild(); + if(!$('.q-card').length){e.preventDefault();alert('Please add at least one question.');return false;} + let ok=true; + $('.q-card').each(function(){if(!$(this).find('.q-text').val()?.trim()){$(this).find('.q-text').css('border-color','var(--red)');ok=false;}}); + if(!ok){e.preventDefault();alert('Please fill in all question texts.');return false;} + }); +}); + +} diff --git a/Web/Areas/Admin/Views/Questionnaire/Delete.cshtml b/Web/Areas/Admin/Views/Questionnaire/Delete.cshtml index 662aa16..fe42060 100644 --- a/Web/Areas/Admin/Views/Questionnaire/Delete.cshtml +++ b/Web/Areas/Admin/Views/Questionnaire/Delete.cshtml @@ -2,748 +2,308 @@ @{ ViewData["Title"] = "Delete Questionnaire"; + var totalAnswers = Model.Questions?.Sum(q => q.Answers?.Count ?? 0) ?? 0; + var otherCount = Model.Questions?.Count(q => q.Answers != null && q.Answers.Any(a => a.IsOtherOption)) ?? 0; + var qCount = Model.Questions?.Count ?? 0; } -
-
- -
-
- -
-

Delete Questionnaire

-

This action will permanently remove this questionnaire and all associated data

+
+ + +
+ - - -
- -
-
-

⚠️ Critical Warning

-
    -
  • This action cannot be undone
  • -
  • All questions and answers will be permanently deleted
  • -
  • Any survey responses will be lost forever
  • -
  • Associated data and analytics will be removed
  • -
-
-
- - -
-
-
- -
-

Questionnaire Details

-
- -
-
-
Title
-
@Model.Title
-
-
-
Questionnaire ID
-
#@Model.Id
-
-
- -
-
Description
-
- @if (!string.IsNullOrEmpty(Model.Description)) - { - @Html.Raw(Model.Description) - } - else - { - No description provided - } -
-
-
- - -
-

- - Content Statistics -

-
-
- @Model.Questions.Count - Questions -
-
- @Model.Questions.Sum(q => q.Answers.Count) - Total Answers -
-
- @Model.Questions.Count(q => q.Answers.Any(a => a.IsOtherOption)) - Other Options -
-
-
+
+

+ + Delete Questionnaire +

+ Back to List
+
- -
-
- - - - Back to List - + +
+
+
+
+

This action is permanent and cannot be undone

+
    +
  • All questions will be permanently deleted
  • +
  • All answers will be removed
  • +
  • Survey responses will be lost forever
  • +
  • Associated analytics will be erased
  • +
+ + +
+
+
+

Questionnaire Details

+
+
+
+
+
ID
+
#@Model.Id
+
+
+
Title
+
@Model.Title
+
+
+
Description
+ @if (!string.IsNullOrEmpty(Model.Description)) + { +
@Html.Raw(Model.Description)
+ } + else + { +
No description provided
+ } +
+
+
+
+ + +
+
+
@qCount
+
Questions
+
+
+
@totalAnswers
+
Total Answers
+
+
+
@otherCount
+
Other Options
+
+
+ + +
+
+
+
+
+

Ready to delete this questionnaire?

+

This will permanently remove all @qCount questions and @totalAnswers answers

+
+
+
+ Cancel + +
+
+
+
- +