diff --git a/.gitignore b/.gitignore index 9491a2fda28342ab358eaf234e1afe0c07a53d62..1d5501bbbf93749c6a889449f192f5fa10b5a6c9 100644 --- a/.gitignore +++ b/.gitignore @@ -266,6 +266,9 @@ ServiceFabricBackup/ *.ldf *.ndf +# Firebase service account keys +TaskTrackingSystem.WebApi/Firebase/*.json + # Business Intelligence projects *.rdl.data *.bim.layout @@ -360,4 +363,4 @@ MigrationBackup/ .ionide/ # Fody - auto-generated XML schema -FodyWeavers.xsd \ No newline at end of file +FodyWeavers.xsd diff --git a/TaskTrackingSystem.Database/AppDbContextModels/AppDbContext.cs b/TaskTrackingSystem.Database/AppDbContextModels/AppDbContext.cs index 85bdead95b7a3cb963fd16f42f7907dd159ae210..b69fb125418f8a58f9c6f9a47fc37a870a39e4aa 100644 --- a/TaskTrackingSystem.Database/AppDbContextModels/AppDbContext.cs +++ b/TaskTrackingSystem.Database/AppDbContextModels/AppDbContext.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Microsoft.EntityFrameworkCore; +using TaskTrackingSystem.Shared.Enums; namespace TaskTrackingSystem.Database.AppDbContextModels; @@ -23,6 +24,8 @@ public partial class AppDbContext : DbContext public virtual DbSet Menus { get; set; } + public virtual DbSet Notifications { get; set; } + public virtual DbSet Permissions { get; set; } public virtual DbSet Projects { get; set; } @@ -41,6 +44,8 @@ public partial class AppDbContext : DbContext public virtual DbSet TimeLogs { get; set; } + public virtual DbSet UserDevices { get; set; } + public virtual DbSet Users { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) @@ -131,6 +136,35 @@ public partial class AppDbContext : DbContext .HasConstraintName("FK_Menus_ParentMenu"); }); + modelBuilder.Entity(entity => + { + entity.ToTable("notifications"); + + entity.HasIndex(e => e.CreatedAt, "IX_notifications_CreatedAt"); + entity.HasIndex(e => new { e.RecipientId, e.IsRead }, "IX_notifications_Recipient_IsRead"); + + entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasColumnType("datetime2"); + entity.Property(e => e.IsRead).HasColumnName("is_read").HasDefaultValue(false); + entity.Property(e => e.NotificationType).HasColumnName("notification_type"); + entity.Property(e => e.Title).HasMaxLength(255); + entity.Property(e => e.Body).HasColumnType("nvarchar(max)"); + entity.Property(e => e.ReadAt).HasColumnName("read_at").HasColumnType("datetime2"); + entity.Property(e => e.RecipientId).HasColumnName("recipient_id"); + entity.Property(e => e.SenderId).HasColumnName("sender_id"); + entity.Property(e => e.SourceId).HasColumnName("source_id"); + entity.Property(e => e.SourceType).HasColumnName("source_type").HasMaxLength(20); + + entity.HasOne(d => d.Recipient).WithMany() + .HasForeignKey(d => d.RecipientId) + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("FK_notifications_Users_Recipient"); + + entity.HasOne(d => d.Sender).WithMany() + .HasForeignKey(d => d.SenderId) + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_notifications_Users_Sender"); + }); + modelBuilder.Entity(entity => { entity.HasIndex(e => e.MenuId, "IX_Permissions_MenuId"); @@ -263,8 +297,8 @@ public partial class AppDbContext : DbContext .HasColumnType("datetime"); entity.Property(e => e.DueDate).HasColumnType("datetime"); entity.Property(e => e.EstimatedHours).HasColumnType("decimal(5, 2)"); - entity.Property(e => e.PriorityId).HasDefaultValue(2L); - entity.Property(e => e.StatusId).HasDefaultValue(1L); + entity.Property(e => e.PriorityId).HasDefaultValue(TaskPriority.Medium); + entity.Property(e => e.StatusId).HasDefaultValue(AppTaskStatus.Todo); entity.Property(e => e.Title).HasMaxLength(200); entity.Property(e => e.UpdatedAt).HasColumnType("datetime"); @@ -350,6 +384,25 @@ public partial class AppDbContext : DbContext .HasConstraintName("FK_Users_Roles"); }); + modelBuilder.Entity(entity => + { + entity.ToTable("user_devices"); + + entity.HasIndex(e => e.UserId, "IX_user_devices_UserId"); + + entity.HasIndex(e => e.FcmToken, "UQ_user_devices_FcmToken").IsUnique(); + + entity.Property(e => e.UserId).HasColumnName("user_id"); + entity.Property(e => e.FcmToken).HasColumnName("fcm_token").HasMaxLength(255); + entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasColumnType("datetime2"); + entity.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasColumnType("datetime2"); + + entity.HasOne(d => d.User).WithMany() + .HasForeignKey(d => d.UserId) + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("FK_user_devices_Users"); + }); + OnModelCreatingPartial(modelBuilder); } diff --git a/TaskTrackingSystem.Database/AppDbContextModels/Notification.cs b/TaskTrackingSystem.Database/AppDbContextModels/Notification.cs new file mode 100644 index 0000000000000000000000000000000000000000..301dd2a0c2855a8e7849e9c4e9623b6ca4800ca5 --- /dev/null +++ b/TaskTrackingSystem.Database/AppDbContextModels/Notification.cs @@ -0,0 +1,32 @@ +using System; + +namespace TaskTrackingSystem.Database.AppDbContextModels; + +public partial class Notification +{ + public long Id { get; set; } + + public long RecipientId { get; set; } + + public long? SenderId { get; set; } + + public byte NotificationType { get; set; } + + public string Title { get; set; } = null!; + + public string Body { get; set; } = null!; + + public string SourceType { get; set; } = null!; + + public long SourceId { get; set; } + + public bool IsRead { get; set; } + + public DateTime? ReadAt { get; set; } + + public DateTime? CreatedAt { get; set; } + + public virtual User Recipient { get; set; } = null!; + + public virtual User? Sender { get; set; } +} diff --git a/TaskTrackingSystem.Database/AppDbContextModels/Task.cs b/TaskTrackingSystem.Database/AppDbContextModels/Task.cs index 8df28d59b2ceaf13eafefaf18ae4572f637f43fb..0574e6f303adfdfdd856a681212890d21f103354 100644 --- a/TaskTrackingSystem.Database/AppDbContextModels/Task.cs +++ b/TaskTrackingSystem.Database/AppDbContextModels/Task.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.Collections.Generic; +using TaskTrackingSystem.Shared.Enums; namespace TaskTrackingSystem.Database.AppDbContextModels; @@ -13,9 +14,9 @@ public partial class Task public long ProjectId { get; set; } - public long StatusId { get; set; } + public AppTaskStatus StatusId { get; set; } - public long PriorityId { get; set; } + public TaskPriority PriorityId { get; set; } public long? AssignedTo { get; set; } diff --git a/TaskTrackingSystem.Database/AppDbContextModels/TaskHistory.cs b/TaskTrackingSystem.Database/AppDbContextModels/TaskHistory.cs index 668aeb22d50445efd51411d66836ad94c50318a8..07a12529e193975d5e1521e3e092b766d8a3e3a3 100644 --- a/TaskTrackingSystem.Database/AppDbContextModels/TaskHistory.cs +++ b/TaskTrackingSystem.Database/AppDbContextModels/TaskHistory.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.Collections.Generic; +using TaskTrackingSystem.Shared.Enums; namespace TaskTrackingSystem.Database.AppDbContextModels; @@ -11,13 +12,13 @@ public partial class TaskHistory public long ModifiedById { get; set; } - public long? OldStatusId { get; set; } + public AppTaskStatus? OldStatusId { get; set; } - public long? NewStatusId { get; set; } + public AppTaskStatus? NewStatusId { get; set; } - public long? OldPriorityId { get; set; } + public TaskPriority? OldPriorityId { get; set; } - public long? NewPriorityId { get; set; } + public TaskPriority? NewPriorityId { get; set; } public string? Remarks { get; set; } diff --git a/TaskTrackingSystem.Database/AppDbContextModels/UserDevice.cs b/TaskTrackingSystem.Database/AppDbContextModels/UserDevice.cs new file mode 100644 index 0000000000000000000000000000000000000000..813d2853c6af39e9716567655ae33ad06b8b70a0 --- /dev/null +++ b/TaskTrackingSystem.Database/AppDbContextModels/UserDevice.cs @@ -0,0 +1,18 @@ +using System; + +namespace TaskTrackingSystem.Database.AppDbContextModels; + +public partial class UserDevice +{ + public long Id { get; set; } + + public long UserId { get; set; } + + public string FcmToken { get; set; } = null!; + + public DateTime? CreatedAt { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public virtual User User { get; set; } = null!; +} diff --git a/TaskTrackingSystem.Database/TaskTrackingSystem.Database.csproj b/TaskTrackingSystem.Database/TaskTrackingSystem.Database.csproj index 1d641e3d02d2dfcfb34d698d8dcfae2be8de5a8b..9ab7d5db1b0c1298d72888eb77f2e314f228af98 100644 --- a/TaskTrackingSystem.Database/TaskTrackingSystem.Database.csproj +++ b/TaskTrackingSystem.Database/TaskTrackingSystem.Database.csproj @@ -20,5 +20,8 @@ + + + diff --git a/TaskTrackingSystem.Shared/Enums/EnumAdminMenuCode.cs b/TaskTrackingSystem.Shared/Enums/EnumAdminMenuCode.cs deleted file mode 100644 index e03326aa486507fe783ff6ca8c4ee25e44ddac84..0000000000000000000000000000000000000000 --- a/TaskTrackingSystem.Shared/Enums/EnumAdminMenuCode.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; -using System.ComponentModel; - -namespace TaskTrackingSystem.Shared.Enums -{ - public enum EnumAdminAction - { - Approve, - Reject, - Create, - Edit, - Update, - Delete, - Detail, - } - - public enum EnumAdminMenuCode - { - Dashboard, - [Description("CMC001")] ProjectList, - [Description("CMC002")] TaskList, - [Description("CMC003")] UserList, - [Description("CMC004")] RoleList, - [Description("CMC005")] ReportList, - } - - public enum EnumAdminActionCode - { - Approve, - Reject, - Create, - Edit, - Update, - Delete, - Detail, - } -} \ No newline at end of file diff --git a/TaskTrackingSystem.Shared/Enums/TaskPriority.cs b/TaskTrackingSystem.Shared/Enums/TaskPriority.cs new file mode 100644 index 0000000000000000000000000000000000000000..0a66a55a1fec0f5265647f0b5a91666ab75ec8cc --- /dev/null +++ b/TaskTrackingSystem.Shared/Enums/TaskPriority.cs @@ -0,0 +1,11 @@ +using System; + +namespace TaskTrackingSystem.Shared.Enums +{ + public enum TaskPriority : long + { + Low = 1, + Medium = 2, + High = 3 + } +} diff --git a/TaskTrackingSystem.Shared/Enums/TaskStatus.cs b/TaskTrackingSystem.Shared/Enums/TaskStatus.cs new file mode 100644 index 0000000000000000000000000000000000000000..6a031ea61f37a162f1d6f268ffdd2165ee552933 --- /dev/null +++ b/TaskTrackingSystem.Shared/Enums/TaskStatus.cs @@ -0,0 +1,11 @@ +using System; + +namespace TaskTrackingSystem.Shared.Enums +{ + public enum AppTaskStatus : long + { + Todo = 1, + InProgress = 2, + Done = 3 + } +} diff --git a/TaskTrackingSystem.Shared/Models/Dashboard/TaskStatusOverviewDto.cs b/TaskTrackingSystem.Shared/Models/Dashboard/TaskStatusOverviewDto.cs index e3f0b65f6d71f3b8bad6d8516511d9f6a1d46643..40f606b3a101728b220d72e5d5797333f66be948 100644 --- a/TaskTrackingSystem.Shared/Models/Dashboard/TaskStatusOverviewDto.cs +++ b/TaskTrackingSystem.Shared/Models/Dashboard/TaskStatusOverviewDto.cs @@ -1,9 +1,12 @@ +using TaskTrackingSystem.Shared.Enums; +using AppTaskStatus = TaskTrackingSystem.Shared.Enums.AppTaskStatus; + namespace TaskTrackingSystem.Shared.Models.Dashboard { public class TaskStatusOverviewDto { public string StatusName { get; set; } = string.Empty; - public long StatusId { get; set; } + public AppTaskStatus StatusId { get; set; } public int TaskCount { get; set; } } } diff --git a/TaskTrackingSystem.Shared/Models/Notification/NotificationDto.cs b/TaskTrackingSystem.Shared/Models/Notification/NotificationDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..ac01a4efc320ba2d37341492de746e9992a2ac37 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Notification/NotificationDto.cs @@ -0,0 +1,17 @@ +using System; + +namespace TaskTrackingSystem.Shared.Models.Notification; + +public class NotificationDto +{ + public long Id { get; set; } + public string Title { get; set; } = string.Empty; + public string Body { get; set; } = string.Empty; + public byte NotificationType { get; set; } + public string SourceType { get; set; } = string.Empty; + public long SourceId { get; set; } + public bool IsRead { get; set; } + public DateTime? CreatedAt { get; set; } + public DateTime? ReadAt { get; set; } + public string? SenderName { get; set; } +} diff --git a/TaskTrackingSystem.Shared/Models/Notification/NotificationType.cs b/TaskTrackingSystem.Shared/Models/Notification/NotificationType.cs new file mode 100644 index 0000000000000000000000000000000000000000..ecae3ec1161bf2d849f8c7ade2522cd05eaff3cc --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Notification/NotificationType.cs @@ -0,0 +1,14 @@ +namespace TaskTrackingSystem.Shared.Models.Notification; + +public enum NotificationType : byte +{ + TaskAssigned = 1, + StatusChanged = 2, + DueDateReminder = 3, + OverdueAlert = 4, + CommentAdded = 5, + Mention = 6, + PriorityChanged = 7, + ProjectUpdated = 8, + System = 9 +} diff --git a/TaskTrackingSystem.Shared/Models/Notification/RegisterDeviceTokenDto.cs b/TaskTrackingSystem.Shared/Models/Notification/RegisterDeviceTokenDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..30dc6677c5c385684cd079fd3d2839e82af628a1 --- /dev/null +++ b/TaskTrackingSystem.Shared/Models/Notification/RegisterDeviceTokenDto.cs @@ -0,0 +1,6 @@ +namespace TaskTrackingSystem.Shared.Models.Notification; + +public class RegisterDeviceTokenDto +{ + public string FcmToken { get; set; } = string.Empty; +} diff --git a/TaskTrackingSystem.Shared/Models/Report/TaskReportDto.cs b/TaskTrackingSystem.Shared/Models/Report/TaskReportDto.cs index ba20a9b1c1275b4bea822eeeeba4ddee8fe292a2..d04cc34f0622a27e5a01c85dd3db573b47b03e11 100644 --- a/TaskTrackingSystem.Shared/Models/Report/TaskReportDto.cs +++ b/TaskTrackingSystem.Shared/Models/Report/TaskReportDto.cs @@ -1,4 +1,6 @@ using System; +using TaskTrackingSystem.Shared.Enums; +using AppTaskStatus = TaskTrackingSystem.Shared.Enums.AppTaskStatus; namespace TaskTrackingSystem.Shared.Models.Report { @@ -9,9 +11,9 @@ namespace TaskTrackingSystem.Shared.Models.Report public string? Description { get; set; } public string ProjectName { get; set; } = string.Empty; public long ProjectId { get; set; } - public long StatusId { get; set; } + public AppTaskStatus StatusId { get; set; } public string StatusName { get; set; } = string.Empty; - public long PriorityId { get; set; } + public TaskPriority PriorityId { get; set; } public string PriorityName { get; set; } = string.Empty; public string? AssignedToUser { get; set; } public long? AssignedToUserId { get; set; } diff --git a/TaskTrackingSystem.Shared/Models/Report/TimeTrackingReportDto.cs b/TaskTrackingSystem.Shared/Models/Report/TimeTrackingReportDto.cs index 37381983a314ac8132a6c57906f75989235d9967..4979d4a6e705b69c5f53d80a9ff1fe2b44ddb67c 100644 --- a/TaskTrackingSystem.Shared/Models/Report/TimeTrackingReportDto.cs +++ b/TaskTrackingSystem.Shared/Models/Report/TimeTrackingReportDto.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using TaskTrackingSystem.Shared.Enums; +using AppTaskStatus = TaskTrackingSystem.Shared.Enums.AppTaskStatus; namespace TaskTrackingSystem.Shared.Models.Report { @@ -54,6 +56,6 @@ namespace TaskTrackingSystem.Shared.Models.Report public decimal? EstimatedHours { get; set; } public decimal CompletedHours { get; set; } public DateTime DueDate { get; set; } - public long StatusId { get; set; } + public AppTaskStatus StatusId { get; set; } } } diff --git a/TaskTrackingSystem.Shared/Models/Task/CreateTaskDto.cs b/TaskTrackingSystem.Shared/Models/Task/CreateTaskDto.cs index 824ebc18c93c9da42a555e2a6f92d4aa3890e9e4..8d010360ba1d6a7ad210cf46268b9f59ffbe73f6 100644 --- a/TaskTrackingSystem.Shared/Models/Task/CreateTaskDto.cs +++ b/TaskTrackingSystem.Shared/Models/Task/CreateTaskDto.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using TaskTrackingSystem.Shared.Enums; +using AppTaskStatus = TaskTrackingSystem.Shared.Enums.AppTaskStatus; namespace TaskTrackingSystem.Shared.Models.Task { @@ -16,11 +18,11 @@ namespace TaskTrackingSystem.Shared.Models.Task [Range(1, long.MaxValue)] public long ProjectId { get; set; } - [Range(0, 3)] - public long StatusId { get; set; } + [EnumDataType(typeof(AppTaskStatus))] + public AppTaskStatus StatusId { get; set; } - [Range(0, 3)] - public long PriorityId { get; set; } + [EnumDataType(typeof(TaskPriority))] + public TaskPriority PriorityId { get; set; } [Range(0, long.MaxValue)] public long? AssignedTo { get; set; } diff --git a/TaskTrackingSystem.Shared/Models/Task/TaskDto.cs b/TaskTrackingSystem.Shared/Models/Task/TaskDto.cs index 7ebdf620b71b931cc5fd05abe0e2afb82c004ea5..d19ab2045c98ae9013435862b67e7c221ebd6cf2 100644 --- a/TaskTrackingSystem.Shared/Models/Task/TaskDto.cs +++ b/TaskTrackingSystem.Shared/Models/Task/TaskDto.cs @@ -2,7 +2,8 @@ using System; using System.Collections.Generic; using System.Linq; using System.Text; -using System.Threading.Tasks; +using TaskTrackingSystem.Shared.Enums; +using AppTaskStatus = TaskTrackingSystem.Shared.Enums.AppTaskStatus; namespace TaskTrackingSystem.Shared.Models.Task { @@ -12,8 +13,8 @@ namespace TaskTrackingSystem.Shared.Models.Task public string Title { get; set; } = string.Empty; public string? Description { get; set; } public long ProjectId { get; set; } - public long StatusId { get; set; } - public long PriorityId { get; set; } + public AppTaskStatus StatusId { get; set; } + public TaskPriority PriorityId { get; set; } public long? AssignedTo { get; set; } public long? AssignedBy { get; set; } public decimal? EstimatedHours { get; set; } diff --git a/TaskTrackingSystem.Shared/Models/Task/UpdateTaskDto.cs b/TaskTrackingSystem.Shared/Models/Task/UpdateTaskDto.cs index 4c52cee056104e7b6ed37efe312ee66c7eda92f9..69a0002a388646f79c37d42cef95c7843fe329ff 100644 --- a/TaskTrackingSystem.Shared/Models/Task/UpdateTaskDto.cs +++ b/TaskTrackingSystem.Shared/Models/Task/UpdateTaskDto.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using TaskTrackingSystem.Shared.Enums; +using AppTaskStatus = TaskTrackingSystem.Shared.Enums.AppTaskStatus; namespace TaskTrackingSystem.Shared.Models.Task { @@ -13,12 +15,12 @@ namespace TaskTrackingSystem.Shared.Models.Task public string? Description { get; set; } [Required] - [Range(0, 3)] - public long StatusId { get; set; } + [EnumDataType(typeof(AppTaskStatus))] + public AppTaskStatus StatusId { get; set; } [Required] - [Range(0, 3)] - public long PriorityId { get; set; } + [EnumDataType(typeof(TaskPriority))] + public TaskPriority PriorityId { get; set; } [Range(0, long.MaxValue)] public long? AssignedTo { get; set; } diff --git a/TaskTrackingSystem.WebApi/Features/Dashboard/DashboardService.cs b/TaskTrackingSystem.WebApi/Features/Dashboard/DashboardService.cs index 796bcaaf1c46a6be61759b8f4b06c34382b550ad..684675ea285c2cee9131f420d1f9bfd4bf931054 100644 --- a/TaskTrackingSystem.WebApi/Features/Dashboard/DashboardService.cs +++ b/TaskTrackingSystem.WebApi/Features/Dashboard/DashboardService.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using TaskTrackingSystem.Database.AppDbContextModels; using TaskTrackingSystem.Shared; using TaskTrackingSystem.Shared.Models.Dashboard; +using TaskTrackingSystem.Shared.Enums; namespace TaskTrackingSystem.WebApi.Features.Dashboard { @@ -33,7 +34,7 @@ namespace TaskTrackingSystem.WebApi.Features.Dashboard .CountAsync(); var activeProjectsCount = await projects.CountAsync(); - var pendingTasksCount = await tasks.CountAsync(t => t.StatusId != 3); + var pendingTasksCount = await tasks.CountAsync(t => t.StatusId != AppTaskStatus.Done); var summary = new DashboardSummaryDto { @@ -57,11 +58,11 @@ namespace TaskTrackingSystem.WebApi.Features.Dashboard .ToListAsync(); // Status mappings (StatusId 1 = To Do, 2 = In Progress, 3 = Done etc.) - var statusMap = new Dictionary + var statusMap = new Dictionary { - { 1, "To Do" }, - { 2, "In Progress" }, - { 3, "Done" } + { AppTaskStatus.Todo, "To Do" }, + { AppTaskStatus.InProgress, "In Progress" }, + { AppTaskStatus.Done, "Done" } }; var overview = groupedTasks.Select(gt => new TaskStatusOverviewDto @@ -102,7 +103,7 @@ namespace TaskTrackingSystem.WebApi.Features.Dashboard .ToListAsync(); int totalTasks = tasks.Count; - int completedTasks = tasks.Count(t => t.StatusId == 3); // StatusId 3 = Done + int completedTasks = tasks.Count(t => t.StatusId == AppTaskStatus.Done); // StatusId 3 = Done double percentage = totalTasks > 0 ? Math.Round(((double)completedTasks / totalTasks) * 100, 2) : 0; diff --git a/TaskTrackingSystem.WebApi/Features/Notification/FirebaseNotificationService.cs b/TaskTrackingSystem.WebApi/Features/Notification/FirebaseNotificationService.cs new file mode 100644 index 0000000000000000000000000000000000000000..f447b8d477e06e00285aa672c814ddd9a26e0dff --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Notification/FirebaseNotificationService.cs @@ -0,0 +1,333 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using TaskTrackingSystem.Database.AppDbContextModels; +using TaskTrackingSystem.Shared.Models.Notification; +using TaskTrackingSystem.Shared.Enums; +using DbNotification = TaskTrackingSystem.Database.AppDbContextModels.Notification; + +namespace TaskTrackingSystem.WebApi.Features.Notification; + +public class FirebaseNotificationService +{ + private static readonly SemaphoreSlim AccessTokenLock = new(1, 1); + private static string? CachedAccessToken; + private static DateTimeOffset CachedAccessTokenExpiresAt; + + private readonly AppDbContext _db; + private readonly IHttpClientFactory _httpClientFactory; + private readonly string _serviceAccountPath; + + public FirebaseNotificationService( + AppDbContext db, + IHttpClientFactory httpClientFactory, + IHostEnvironment environment, + IConfiguration configuration) + { + _db = db; + _httpClientFactory = httpClientFactory; + + var configuredPath = configuration["Firebase:ServiceAccountPath"] ?? "Firebase/ttsfirebasekey.json"; + _serviceAccountPath = Path.IsPathRooted(configuredPath) + ? configuredPath + : Path.Combine(environment.ContentRootPath, configuredPath); + } + + public global::System.Threading.Tasks.Task NotifyTaskAssignedAsync(Database.AppDbContextModels.Task task, long senderId) + { + if (!task.AssignedTo.HasValue) + { + return global::System.Threading.Tasks.Task.CompletedTask; + } + + var title = "Task assigned"; + var body = $"You have been assigned a new task: {task.Title}"; + + return SendAsync( + recipientIds: new[] { task.AssignedTo.Value }, + senderId: senderId, + notificationType: NotificationType.TaskAssigned, + sourceType: "task", + sourceId: task.Id, + title: title, + body: body); + } + + public async global::System.Threading.Tasks.Task NotifyTaskStatusChangedAsync(Database.AppDbContextModels.Task task, long senderId, AppTaskStatus oldStatusId, AppTaskStatus newStatusId) + { + var recipientIds = BuildTaskAudience(task, senderId); + if (recipientIds.Count == 0) + { + return; + } + + var title = "Task status changed"; + var body = $"Task '{task.Title}' changed from {GetStatusLabel(oldStatusId)} to {GetStatusLabel(newStatusId)}"; + + await SendAsync( + recipientIds, + senderId, + NotificationType.StatusChanged, + "task", + task.Id, + title, + body); + } + + public async global::System.Threading.Tasks.Task NotifyCommentAddedAsync(Database.AppDbContextModels.Task task, long senderId, string actorName) + { + var recipientIds = BuildTaskAudience(task, senderId); + if (recipientIds.Count == 0) + { + return; + } + + var title = "New comment"; + var body = $"{actorName} commented on task '{task.Title}'"; + + await SendAsync( + recipientIds, + senderId, + NotificationType.CommentAdded, + "task", + task.Id, + title, + body); + } + + private List BuildTaskAudience(Database.AppDbContextModels.Task task, long senderId) + { + var audience = new HashSet(); + + if (task.AssignedTo.HasValue && task.AssignedTo.Value != senderId) + { + audience.Add(task.AssignedTo.Value); + } + + if (task.AssignedBy.HasValue && task.AssignedBy.Value != senderId) + { + audience.Add(task.AssignedBy.Value); + } + + if (task.CreatedBy.HasValue && task.CreatedBy.Value != senderId) + { + audience.Add(task.CreatedBy.Value); + } + + return audience.ToList(); + } + + private async global::System.Threading.Tasks.Task SendAsync( + IEnumerable recipientIds, + long senderId, + NotificationType notificationType, + string sourceType, + long sourceId, + string title, + string body) + { + var uniqueRecipientIds = recipientIds.Distinct().ToList(); + if (uniqueRecipientIds.Count == 0) + { + return; + } + + var notifications = uniqueRecipientIds.Select(recipientId => new DbNotification + { + RecipientId = recipientId, + SenderId = senderId > 0 ? senderId : null, + NotificationType = (byte)notificationType, + Title = title, + Body = body, + SourceType = sourceType, + SourceId = sourceId, + IsRead = false, + CreatedAt = DateTime.UtcNow + }).ToList(); + + _db.Notifications.AddRange(notifications); + await _db.SaveChangesAsync(); + + var tokens = await _db.UserDevices + .Where(d => uniqueRecipientIds.Contains(d.UserId)) + .Select(d => d.FcmToken) + .Distinct() + .ToListAsync(); + + foreach (var token in tokens) + { + await SendPushAsync(token, title, body, sourceType, sourceId, notificationType); + } + } + + private async global::System.Threading.Tasks.Task SendPushAsync( + string token, + string title, + string body, + string sourceType, + long sourceId, + NotificationType notificationType) + { + var serviceAccount = await LoadServiceAccountAsync(); + var accessToken = await GetAccessTokenAsync(serviceAccount); + + var client = _httpClientFactory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, $"https://fcm.googleapis.com/v1/projects/{serviceAccount.ProjectId}/messages:send"); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken); + + var payload = new + { + message = new + { + token, + notification = new + { + title, + body + }, + data = new Dictionary + { + ["sourceType"] = sourceType, + ["sourceId"] = sourceId.ToString(), + ["notificationType"] = ((byte)notificationType).ToString() + } + } + }; + + request.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); + using var response = await client.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + var error = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"[Firebase] Failed to send push: {(int)response.StatusCode} {response.ReasonPhrase}. {error}"); + } + } + + private async global::System.Threading.Tasks.Task GetAccessTokenAsync(FirebaseServiceAccount serviceAccount) + { + if (!string.IsNullOrWhiteSpace(CachedAccessToken) && CachedAccessTokenExpiresAt > DateTimeOffset.UtcNow.AddMinutes(1)) + { + return CachedAccessToken!; + } + + await AccessTokenLock.WaitAsync(); + try + { + if (!string.IsNullOrWhiteSpace(CachedAccessToken) && CachedAccessTokenExpiresAt > DateTimeOffset.UtcNow.AddMinutes(1)) + { + return CachedAccessToken!; + } + + var iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var exp = iat + 3600; + + var headerJson = JsonSerializer.Serialize(new { alg = "RS256", typ = "JWT" }); + var claimJson = JsonSerializer.Serialize(new + { + iss = serviceAccount.ClientEmail, + scope = "https://www.googleapis.com/auth/firebase.messaging", + aud = "https://oauth2.googleapis.com/token", + iat, + exp + }); + + var unsignedJwt = $"{Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson))}.{Base64UrlEncode(Encoding.UTF8.GetBytes(claimJson))}"; + var signature = SignJwt(unsignedJwt, serviceAccount.PrivateKey); + var assertion = $"{unsignedJwt}.{Base64UrlEncode(signature)}"; + + var client = _httpClientFactory.CreateClient(); + var form = new FormUrlEncodedContent(new[] + { + new KeyValuePair("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), + new KeyValuePair("assertion", assertion) + }); + + using var response = await client.PostAsync("https://oauth2.googleapis.com/token", form); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var token = doc.RootElement.GetProperty("access_token").GetString(); + var expiresIn = doc.RootElement.GetProperty("expires_in").GetInt32(); + + if (string.IsNullOrWhiteSpace(token)) + { + throw new InvalidOperationException("Firebase access token response did not contain an access_token."); + } + + CachedAccessToken = token; + CachedAccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(expiresIn); + + return token; + } + finally + { + AccessTokenLock.Release(); + } + } + + private async global::System.Threading.Tasks.Task LoadServiceAccountAsync() + { + var json = await File.ReadAllTextAsync(_serviceAccountPath); + var serviceAccount = JsonSerializer.Deserialize(json); + if (serviceAccount == null || + string.IsNullOrWhiteSpace(serviceAccount.ProjectId) || + string.IsNullOrWhiteSpace(serviceAccount.ClientEmail) || + string.IsNullOrWhiteSpace(serviceAccount.PrivateKey)) + { + throw new InvalidOperationException("Firebase service account file is missing required fields."); + } + + return serviceAccount; + } + + private static byte[] SignJwt(string unsignedJwt, string privateKey) + { + var pem = privateKey + .Replace("\\n", "\n", StringComparison.Ordinal) + .Replace("-----BEGIN PRIVATE KEY-----", string.Empty, StringComparison.Ordinal) + .Replace("-----END PRIVATE KEY-----", string.Empty, StringComparison.Ordinal) + .Replace("\r", string.Empty, StringComparison.Ordinal) + .Replace("\n", string.Empty, StringComparison.Ordinal) + .Trim(); + + var keyBytes = Convert.FromBase64String(pem); + using var rsa = RSA.Create(); + rsa.ImportPkcs8PrivateKey(keyBytes, out _); + + return rsa.SignData(Encoding.ASCII.GetBytes(unsignedJwt), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + } + + private static string Base64UrlEncode(byte[] input) + { + return Convert.ToBase64String(input) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + private static string GetStatusLabel(AppTaskStatus statusId) => statusId switch + { + AppTaskStatus.Todo => "To Do", + AppTaskStatus.InProgress => "In Progress", + AppTaskStatus.Done => "Done", + _ => $"Status {statusId}" + }; + + private sealed class FirebaseServiceAccount + { + [JsonPropertyName("project_id")] + public string ProjectId { get; set; } = string.Empty; + + [JsonPropertyName("client_email")] + public string ClientEmail { get; set; } = string.Empty; + + [JsonPropertyName("private_key")] + public string PrivateKey { get; set; } = string.Empty; + } +} diff --git a/TaskTrackingSystem.WebApi/Features/Notification/NotificationController.cs b/TaskTrackingSystem.WebApi/Features/Notification/NotificationController.cs new file mode 100644 index 0000000000000000000000000000000000000000..97855232abb10f5da935b71e809f8ceaecc0ccd9 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Notification/NotificationController.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TaskTrackingSystem.Shared; +using TaskTrackingSystem.WebApi.Infrastructure; + +namespace TaskTrackingSystem.WebApi.Features.Notification; + +[ApiController] +[Authorize] +[Route("api/[controller]")] +public class NotificationController : ControllerBase +{ + private readonly NotificationService _notificationService; + + public NotificationController(NotificationService notificationService) + { + _notificationService = notificationService; + } + + [HttpGet("mine")] + public async Task>> GetMine([FromQuery] int take = 10) + { + var userId = User.GetUserId(); + if (userId <= 0) + { + return Unauthorized(); + } + + var items = await _notificationService.GetRecentAsync(userId, take); + return Ok(items); + } + + [HttpGet("unread-count")] + public async Task> GetUnreadCount() + { + var userId = User.GetUserId(); + if (userId <= 0) + { + return Unauthorized(); + } + + var count = await _notificationService.GetUnreadCountAsync(userId); + return Ok(count); + } + + [HttpPost("{id}/read")] + public async Task> MarkRead(long id) + { + var userId = User.GetUserId(); + if (userId <= 0) + { + return Unauthorized(Result.Failure("User is not authenticated.", 401)); + } + + var result = await _notificationService.MarkReadAsync(userId, id); + return StatusCode(result.StatusCode, result); + } +} diff --git a/TaskTrackingSystem.WebApi/Features/Notification/NotificationService.cs b/TaskTrackingSystem.WebApi/Features/Notification/NotificationService.cs new file mode 100644 index 0000000000000000000000000000000000000000..207a8d90d1b7e0a1e26ac8e92ca45e50f6046795 --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/Notification/NotificationService.cs @@ -0,0 +1,67 @@ +using Microsoft.EntityFrameworkCore; +using TaskTrackingSystem.Database.AppDbContextModels; +using TaskTrackingSystem.Shared; +using TaskTrackingSystem.Shared.Models.Notification; +using DbNotification = TaskTrackingSystem.Database.AppDbContextModels.Notification; + +namespace TaskTrackingSystem.WebApi.Features.Notification; + +public class NotificationService +{ + private readonly AppDbContext _db; + + public NotificationService(AppDbContext db) + { + _db = db; + } + + public async Task> GetRecentAsync(long userId, int take = 10) + { + return await ( + from n in _db.Notifications + where n.RecipientId == userId + join sender in _db.Users on n.SenderId equals sender.Id into senderGroup + from sender in senderGroup.DefaultIfEmpty() + orderby n.CreatedAt descending + select new NotificationDto + { + Id = n.Id, + Title = n.Title, + Body = n.Body, + NotificationType = n.NotificationType, + SourceType = n.SourceType, + SourceId = n.SourceId, + IsRead = n.IsRead, + CreatedAt = n.CreatedAt, + ReadAt = n.ReadAt, + SenderName = sender == null ? null : $"{sender.FirstName} {sender.LastName}" + }) + .Take(take) + .ToListAsync(); + } + + public async Task GetUnreadCountAsync(long userId) + { + return await _db.Notifications.CountAsync(n => n.RecipientId == userId && !n.IsRead); + } + + public async Task MarkReadAsync(long userId, long notificationId) + { + var notification = await _db.Notifications + .FirstOrDefaultAsync(n => n.Id == notificationId && n.RecipientId == userId); + + if (notification == null) + { + return Result.Failure("Notification not found.", 404); + } + + if (!notification.IsRead) + { + notification.IsRead = true; + notification.ReadAt = DateTime.UtcNow; + await _db.SaveChangesAsync(); + } + + return Result.Success(); + } +} diff --git a/TaskTrackingSystem.WebApi/Features/Project/ProjectService.cs b/TaskTrackingSystem.WebApi/Features/Project/ProjectService.cs index 2b4469653660234202d2cc22949cf4c04e7f3022..3591fdb585b5299a8f4bd7fca9595715b3038318 100644 --- a/TaskTrackingSystem.WebApi/Features/Project/ProjectService.cs +++ b/TaskTrackingSystem.WebApi/Features/Project/ProjectService.cs @@ -8,6 +8,7 @@ using TaskTrackingSystem.Shared; using TaskTrackingSystem.Shared.Models.User; using TaskTrackingSystem.Shared.Models.Task; using TaskTrackingSystem.Shared.Models.Project; +using TaskTrackingSystem.Shared.Enums; namespace TaskTrackingSystem.WebApi.Features.Project { @@ -136,6 +137,22 @@ namespace TaskTrackingSystem.WebApi.Features.Project project.IsDeleted = true; _db.Projects.Update(project); + + // Soft-delete all tasks associated with this project + var tasksToUpdate = await _db.Tasks + .Where(t => t.ProjectId == id && t.IsDeleted != true) + .ToListAsync(); + foreach (var task in tasksToUpdate) + { + task.IsDeleted = true; + task.UpdatedAt = DateTime.UtcNow; + task.UpdatedBy = currentUserId; + } + if (tasksToUpdate.Any()) + { + _db.Tasks.UpdateRange(tasksToUpdate); + } + await _db.SaveChangesAsync(); return Result.Success(200); } @@ -277,7 +294,7 @@ namespace TaskTrackingSystem.WebApi.Features.Project DueDate = t.DueDate, CreatedAt = t.CreatedAt ?? DateTime.UtcNow, CompletedAt = t.TaskHistories - .Where(th => th.NewStatusId == 3) + .Where(th => th.NewStatusId == AppTaskStatus.Done) .OrderBy(th => th.CreatedAt) .Select(th => th.CreatedAt) .FirstOrDefault() diff --git a/TaskTrackingSystem.WebApi/Features/Report/ReportController.cs b/TaskTrackingSystem.WebApi/Features/Report/ReportController.cs index d71b8bc3b9d9b8ed585386b7e9ecfee734b4a033..3f0420f2d3ea92e68a28ae6f20657c9933fad5d4 100644 --- a/TaskTrackingSystem.WebApi/Features/Report/ReportController.cs +++ b/TaskTrackingSystem.WebApi/Features/Report/ReportController.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using TaskTrackingSystem.Shared; using TaskTrackingSystem.Shared.Models.Report; +using TaskTrackingSystem.Shared.Enums; using TaskTrackingSystem.WebApi.Infrastructure; namespace TaskTrackingSystem.WebApi.Features.Report @@ -21,7 +22,7 @@ namespace TaskTrackingSystem.WebApi.Features.Report _reportService = reportService; } - // ─── Legacy ─────────────────────────────────────────────────────────────── + // ─── Legacy ─────────────────────────────────────────────────────────────── [HttpGet("tasks")] public async Task>>> GetTasksReport( @@ -41,12 +42,12 @@ namespace TaskTrackingSystem.WebApi.Features.Report return StatusCode(result.StatusCode, result); } - // ─── Report 1: Task Status Summary ──────────────────────────────────────── + // ─── Report 1: Task Status Summary ──────────────────────────────────────── [HttpGet("task-status-summary")] public async Task>>> GetTaskStatusSummary( [FromQuery] string? search, - [FromQuery] long? statusId, + [FromQuery] AppTaskStatus? statusId, [FromQuery] long? projectId) { var result = await _reportService.GetTaskStatusSummaryAsync(search, statusId, projectId, User.GetRoleName(), User.GetUserId()); @@ -56,7 +57,7 @@ namespace TaskTrackingSystem.WebApi.Features.Report [HttpGet("task-status-summary/excel")] public async Task DownloadTaskStatusSummaryExcel( [FromQuery] string? search, - [FromQuery] long? statusId, + [FromQuery] AppTaskStatus? statusId, [FromQuery] long? projectId) { var result = await _reportService.GetTaskStatusSummaryAsync(search, statusId, projectId, User.GetRoleName(), User.GetUserId()); @@ -67,7 +68,7 @@ namespace TaskTrackingSystem.WebApi.Features.Report $"TaskStatusSummary_{DateTime.Today:yyyyMMdd}.xlsx"); } - // ─── Report 2: Team Productivity ────────────────────────────────────────── + // ─── Report 2: Team Productivity ────────────────────────────────────────── [HttpGet("team-productivity")] public async Task>>> GetTeamProductivity( @@ -88,7 +89,7 @@ namespace TaskTrackingSystem.WebApi.Features.Report $"TeamProductivity_{DateTime.Today:yyyyMMdd}.xlsx"); } - // ─── Report 3: Overdue & Critical Tasks ─────────────────────────────────── + // ─── Report 3: Overdue & Critical Tasks ─────────────────────────────────── [HttpGet("overdue-critical")] public async Task>>> GetOverdueCritical( @@ -112,13 +113,13 @@ namespace TaskTrackingSystem.WebApi.Features.Report $"OverdueCriticalTasks_{DateTime.Today:yyyyMMdd}.xlsx"); } - // ─── Report 4: Time Tracking ────────────────────────────────────────────── + // ─── Report 4: Time Tracking ────────────────────────────────────────────── [HttpGet("time-tracking")] public async Task>> GetTimeTracking( [FromQuery] string? search, [FromQuery] long? projectId, - [FromQuery] long? statusId) + [FromQuery] AppTaskStatus? statusId) { var result = await _reportService.GetTimeTrackingReportAsync(search, projectId, statusId, User.GetRoleName(), User.GetUserId()); return StatusCode(result.StatusCode, result); diff --git a/TaskTrackingSystem.WebApi/Features/Report/ReportService.cs b/TaskTrackingSystem.WebApi/Features/Report/ReportService.cs index 2e254fbac45fd0b078b262f786e20881b976eea9..5d9fcd30ffe511506237332c1ef1f3afdf1a35c7 100644 --- a/TaskTrackingSystem.WebApi/Features/Report/ReportService.cs +++ b/TaskTrackingSystem.WebApi/Features/Report/ReportService.cs @@ -8,14 +8,15 @@ using System.Threading.Tasks; using TaskTrackingSystem.Database.AppDbContextModels; using TaskTrackingSystem.Shared; using TaskTrackingSystem.Shared.Models.Report; +using TaskTrackingSystem.Shared.Enums; namespace TaskTrackingSystem.WebApi.Features.Report { public class ReportService { private readonly AppDbContext _db; - private static readonly Dictionary StatusMap = new() { { 1, "To Do" }, { 2, "In Progress" }, { 3, "Done" } }; - private static readonly Dictionary PriorityMap = new() { { 1, "Low" }, { 2, "Medium" }, { 3, "High" } }; + private static readonly Dictionary StatusMap = new() { { AppTaskStatus.Todo, "To Do" }, { AppTaskStatus.InProgress, "In Progress" }, { AppTaskStatus.Done, "Done" } }; + private static readonly Dictionary PriorityMap = new() { { TaskPriority.Low, "Low" }, { TaskPriority.Medium, "Medium" }, { TaskPriority.High, "High" } }; public ReportService(AppDbContext db) { @@ -78,7 +79,7 @@ namespace TaskTrackingSystem.WebApi.Features.Report return users.Where(u => u.Id == currentUserId || accessibleUserIds.Contains(u.Id)); } - // ─── Legacy endpoints (kept for backward compatibility) ─────────────────── + // ─── Legacy endpoints (kept for backward compatibility) ─────────────────── public async Task>> GetTasksReportAsync( DateTime? startDate, DateTime? endDate, string? status, int? projectId, string roleName, long currentUserId) @@ -97,13 +98,13 @@ namespace TaskTrackingSystem.WebApi.Features.Report var sl = status.Trim().ToLower(); if (sl == "uncompleted") { - query = query.Where(t => t.StatusId != 3); + query = query.Where(t => t.StatusId != AppTaskStatus.Done); } else { - long? sid = sl switch { "to do" => 1, "in progress" => 2, "done" => 3, _ => null }; + AppTaskStatus? sid = sl switch { "to do" => AppTaskStatus.Todo, "in progress" => AppTaskStatus.InProgress, "done" => AppTaskStatus.Done, _ => null }; if (sid.HasValue) query = query.Where(t => t.StatusId == sid.Value); - else if (long.TryParse(status, out var pid)) query = query.Where(t => t.StatusId == pid); + else if (Enum.TryParse(status, true, out var parsedStatus)) query = query.Where(t => t.StatusId == parsedStatus); } } @@ -167,17 +168,17 @@ namespace TaskTrackingSystem.WebApi.Features.Report return Result>.Success(list); } - // ─── Report 1: Task Status Summary ──────────────────────────────────────── + // ─── Report 1: Task Status Summary ──────────────────────────────────────── public async Task>> GetTaskStatusSummaryAsync( - string? search, long? statusId, long? projectId, string roleName, long currentUserId) + string? search, AppTaskStatus? statusId, long? projectId, string roleName, long currentUserId) { var query = BuildAccessibleTaskQuery(roleName, currentUserId) .Include(t => t.Project) .Include(t => t.AssignedToNavigation) .Where(t => t.IsDeleted != true); - if (statusId.HasValue && statusId > 0) + if (statusId.HasValue) query = query.Where(t => t.StatusId == statusId.Value); if (projectId.HasValue && projectId > 0) query = query.Where(t => t.ProjectId == projectId.Value); @@ -243,7 +244,7 @@ namespace TaskTrackingSystem.WebApi.Features.Report return ms.ToArray(); } - // ─── Report 2: Team Productivity ────────────────────────────────────────── + // ─── Report 2: Team Productivity ────────────────────────────────────────── public async Task>> GetTeamProductivityAsync(string? search, string roleName, long currentUserId) { @@ -262,11 +263,11 @@ namespace TaskTrackingSystem.WebApi.Features.Report FullName = $"{u.FirstName} {u.LastName}".Trim(), Username = u.Username, TotalAssigned = uTasks.Count, - Completed = uTasks.Count(t => t.StatusId == 3), - InProgress = uTasks.Count(t => t.StatusId == 2), - ToDo = uTasks.Count(t => t.StatusId == 1), - Overdue = uTasks.Count(t => t.DueDate < now && t.StatusId != 3), - CompletionRate = uTasks.Count > 0 ? Math.Round((double)uTasks.Count(t => t.StatusId == 3) / uTasks.Count * 100, 1) : 0 + Completed = uTasks.Count(t => t.StatusId == AppTaskStatus.Done), + InProgress = uTasks.Count(t => t.StatusId == AppTaskStatus.InProgress), + ToDo = uTasks.Count(t => t.StatusId == AppTaskStatus.Todo), + Overdue = uTasks.Count(t => t.DueDate < now && t.StatusId != AppTaskStatus.Done), + CompletionRate = uTasks.Count > 0 ? Math.Round((double)uTasks.Count(t => t.StatusId == AppTaskStatus.Done) / uTasks.Count * 100, 1) : 0 }; }).ToList(); @@ -312,7 +313,7 @@ namespace TaskTrackingSystem.WebApi.Features.Report return ms.ToArray(); } - // ─── Report 3: Overdue & Critical Tasks ─────────────────────────────────── + // ─── Report 3: Overdue & Critical Tasks ─────────────────────────────────── public async Task>> GetOverdueCriticalTasksAsync(string? search, long? projectId, string roleName, long currentUserId) { @@ -320,8 +321,8 @@ namespace TaskTrackingSystem.WebApi.Features.Report var query = BuildAccessibleTaskQuery(roleName, currentUserId) .Include(t => t.Project) .Include(t => t.AssignedToNavigation) - .Where(t => t.IsDeleted != true && t.StatusId != 3 && - (t.DueDate < now || t.PriorityId == 3)); // overdue OR high priority + .Where(t => t.IsDeleted != true && t.StatusId != AppTaskStatus.Done && + (t.DueDate < now || t.PriorityId == TaskPriority.High)); // overdue OR high priority if (projectId.HasValue && projectId > 0) query = query.Where(t => t.ProjectId == projectId.Value); @@ -388,7 +389,7 @@ namespace TaskTrackingSystem.WebApi.Features.Report } public async Task> GetTimeTrackingReportAsync( - string? search, long? projectId, long? statusId, string roleName, long currentUserId) + string? search, long? projectId, AppTaskStatus? statusId, string roleName, long currentUserId) { var query = BuildAccessibleTaskQuery(roleName, currentUserId) .Include(t => t.Project) @@ -398,7 +399,7 @@ namespace TaskTrackingSystem.WebApi.Features.Report if (projectId.HasValue && projectId > 0) query = query.Where(t => t.ProjectId == projectId.Value); - if (statusId.HasValue && statusId > 0) + if (statusId.HasValue) query = query.Where(t => t.StatusId == statusId.Value); var tasks = await query.ToListAsync(); diff --git a/TaskTrackingSystem.WebApi/Features/Role/RoleController.cs b/TaskTrackingSystem.WebApi/Features/Role/RoleController.cs index 6d8cdc97ff73de06d10fc7518299d7a10627ff26..8d1f79a1e568df7c4dbd27b3ef3554a182b3470e 100644 --- a/TaskTrackingSystem.WebApi/Features/Role/RoleController.cs +++ b/TaskTrackingSystem.WebApi/Features/Role/RoleController.cs @@ -46,7 +46,7 @@ namespace TaskTrackingSystem.WebApi.Features.Role return Forbid(); } - long? currentUserId = null; + long? currentUserId = User.GetUserId(); var result = await _roleService.CreateRoleAsync(createRoleDto, currentUserId); return StatusCode(result.StatusCode, result); } @@ -59,7 +59,7 @@ namespace TaskTrackingSystem.WebApi.Features.Role return Forbid(); } - long? currentUserId = null; + long? currentUserId = User.GetUserId(); var result = await _roleService.UpdateRoleAsync(id, updateRoleDto, currentUserId); return StatusCode(result.StatusCode, result); } @@ -72,7 +72,7 @@ namespace TaskTrackingSystem.WebApi.Features.Role return Forbid(); } - var result = await _roleService.SoftDeleteRoleAsync(id); + var result = await _roleService.SoftDeleteRoleAsync(id, User.GetUserId()); return StatusCode(result.StatusCode, result); } @@ -103,7 +103,7 @@ namespace TaskTrackingSystem.WebApi.Features.Role return Forbid(); } - var result = await _roleService.AssignAccessToRoleAsync(id, dto); + var result = await _roleService.AssignAccessToRoleAsync(id, dto, User.GetUserId()); if (!result.IsSuccess) { return StatusCode(result.StatusCode, new { message = result.ErrorMessage }); diff --git a/TaskTrackingSystem.WebApi/Features/Role/RoleService.cs b/TaskTrackingSystem.WebApi/Features/Role/RoleService.cs index 85bf453a52e1ad9dc3db2207fdaec674617c60da..2182b7062000bbf125b8d1e14013e69abfea49c8 100644 --- a/TaskTrackingSystem.WebApi/Features/Role/RoleService.cs +++ b/TaskTrackingSystem.WebApi/Features/Role/RoleService.cs @@ -87,7 +87,10 @@ namespace TaskTrackingSystem.WebApi.Features.Role await _auditLog.LogAsync("Create", "Role", $"Created new role '{role.Name}'"); - var assignResult = await AssignAccessToRoleAsync(role.Id, new AssignAccessDto { AccessCodes = dto.AccessCodes ?? new List() }); + var assignResult = await AssignAccessToRoleAsync( + role.Id, + new AssignAccessDto { AccessCodes = dto.AccessCodes ?? new List() }, + currentUserId); if (!assignResult.IsSuccess) { return Result.Failure(assignResult.ErrorMessage ?? ResultMessages.FailedToCreateRole, assignResult.StatusCode); @@ -132,7 +135,7 @@ namespace TaskTrackingSystem.WebApi.Features.Role return Result.Success(200); } - public async Task SoftDeleteRoleAsync(long id) + public async Task SoftDeleteRoleAsync(long id, long? currentUserId = null) { var role = await _db.Roles.FirstOrDefaultAsync(r => r.Id == id && r.IsDeleted != true); if (role == null) @@ -141,6 +144,8 @@ namespace TaskTrackingSystem.WebApi.Features.Role } role.IsDeleted = true; + role.UpdatedAt = DateTime.UtcNow; + role.UpdatedBy = currentUserId; _db.Roles.Update(role); await _db.SaveChangesAsync(); await _auditLog.LogAsync("Delete", "Role", $"Deleted role '{role.Name}'"); @@ -173,7 +178,7 @@ namespace TaskTrackingSystem.WebApi.Features.Role return Result>.Success(assignedCodes); } - public async Task AssignAccessToRoleAsync(long roleId, AssignAccessDto dto) + public async Task AssignAccessToRoleAsync(long roleId, AssignAccessDto dto, long? currentUserId = null) { var role = await _db.Roles.FirstOrDefaultAsync(r => r.Id == roleId && r.IsDeleted != true); if (role == null) @@ -231,51 +236,103 @@ namespace TaskTrackingSystem.WebApi.Features.Role .Distinct() .ToList(); - var existingRoleMenus = await _db.RoleMenus.Where(rm => rm.RoleId == role.Id).ToListAsync(); - var existingRolePermissions = await _db.RolePermissions.Where(rp => rp.RoleId == role.Id).ToListAsync(); + var now = DateTime.UtcNow; + var existingRoleMenus = await _db.RoleMenus + .Where(rm => rm.RoleId == role.Id) + .ToListAsync(); + var existingRolePermissions = await _db.RolePermissions + .Where(rp => rp.RoleId == role.Id) + .ToListAsync(); - if (existingRoleMenus.Any()) - { - _db.RoleMenus.RemoveRange(existingRoleMenus); - } + var oldMenuIds = existingRoleMenus + .Where(rm => !rm.IsDeleted) + .Select(rm => rm.MenuId) + .ToHashSet(); + var oldPermissionIds = existingRolePermissions + .Where(rp => !rp.IsDeleted) + .Select(rp => rp.PermissionId) + .ToHashSet(); + + var existingRoleMenusByMenuId = existingRoleMenus.ToDictionary(rm => rm.MenuId); + var existingRolePermissionsByPermissionId = existingRolePermissions.ToDictionary(rp => rp.PermissionId); - if (existingRolePermissions.Any()) + foreach (var existingMenu in existingRoleMenus) { - _db.RolePermissions.RemoveRange(existingRolePermissions); + var shouldBeActive = menuIdsToPersist.Contains(existingMenu.MenuId); + + if (shouldBeActive) + { + if (existingMenu.IsDeleted) + { + existingMenu.IsDeleted = false; + existingMenu.UpdatedAt = now; + existingMenu.UpdatedById = currentUserId; + } + } + else if (!existingMenu.IsDeleted) + { + existingMenu.IsDeleted = true; + existingMenu.UpdatedAt = now; + existingMenu.UpdatedById = currentUserId; + } } foreach (var menuId in menuIdsToPersist) { + if (existingRoleMenusByMenuId.ContainsKey(menuId)) + { + continue; + } + _db.RoleMenus.Add(new RoleMenu { RoleId = role.Id, MenuId = menuId, IsDeleted = false, - CreatedAt = DateTime.UtcNow + CreatedAt = now, + CreatedById = currentUserId }); } + foreach (var existingPermission in existingRolePermissions) + { + var shouldBeActive = permissionIdsToPersist.Contains(existingPermission.PermissionId); + + if (shouldBeActive) + { + if (existingPermission.IsDeleted) + { + existingPermission.IsDeleted = false; + existingPermission.UpdatedAt = now; + existingPermission.UpdatedById = currentUserId; + } + } + else if (!existingPermission.IsDeleted) + { + existingPermission.IsDeleted = true; + existingPermission.UpdatedAt = now; + existingPermission.UpdatedById = currentUserId; + } + } + foreach (var permissionId in permissionIdsToPersist) { + if (existingRolePermissionsByPermissionId.ContainsKey(permissionId)) + { + continue; + } + _db.RolePermissions.Add(new RolePermission { RoleId = role.Id, PermissionId = permissionId, IsDeleted = false, - CreatedAt = DateTime.UtcNow + CreatedAt = now, + CreatedById = currentUserId }); } // Build a human-readable diff of what changed - var oldMenuCodes = existingRoleMenus - .Where(rm => !rm.IsDeleted) - .Select(rm => rm.MenuId) - .ToHashSet(); - var oldPermissionIds = existingRolePermissions - .Where(rp => !rp.IsDeleted) - .Select(rp => rp.PermissionId) - .ToHashSet(); - // Load all menu and permission names for diff display var allMenuNames = await _db.Menus .Where(m => !m.IsDeleted) @@ -292,12 +349,12 @@ namespace TaskTrackingSystem.WebApi.Features.Role p => $"{p.MenuName} — {p.ActionName}"); var addedMenuNames = menuIdsToPersist - .Except(oldMenuCodes) + .Except(oldMenuIds) .Select(id => menuNameLookup.TryGetValue(id, out var n) ? n : id.ToString()) .OrderBy(n => n) .ToList(); - var removedMenuNames = oldMenuCodes + var removedMenuNames = oldMenuIds .Except(menuIdsToPersist) .Select(id => menuNameLookup.TryGetValue(id, out var n) ? n : id.ToString()) .OrderBy(n => n) diff --git a/TaskTrackingSystem.WebApi/Features/Task/TaskController.cs b/TaskTrackingSystem.WebApi/Features/Task/TaskController.cs index 55825c77d678dda55581a6f4723a26551b1bb561..7537dcfa4cbb1e3f2c19c72b4f94adf4b2c7eb95 100644 --- a/TaskTrackingSystem.WebApi/Features/Task/TaskController.cs +++ b/TaskTrackingSystem.WebApi/Features/Task/TaskController.cs @@ -5,6 +5,7 @@ using TaskTrackingSystem.Shared; using TaskTrackingSystem.Shared.Models.Task; using TaskTrackingSystem.WebApi.Features.Task; using TaskTrackingSystem.WebApi.Infrastructure; +using TaskTrackingSystem.Shared.Enums; namespace TaskTrackingSystem.WebApi.Features.Task { @@ -86,7 +87,7 @@ namespace TaskTrackingSystem.WebApi.Features.Task } [HttpPatch("{id}/status")] - public async Task UpdateTaskStatus(long id, [FromQuery] long statusId) + public async Task UpdateTaskStatus(long id, [FromQuery] AppTaskStatus statusId) { var result = await _taskService.UpdateTaskStatusAsync(id, statusId, User.GetRoleName(), User.GetUserId()); if (!result.IsSuccess) diff --git a/TaskTrackingSystem.WebApi/Features/Task/TaskDetailsController.cs b/TaskTrackingSystem.WebApi/Features/Task/TaskDetailsController.cs index cb953432960e2c49aaee4599bd1da4e94a268e4e..50be8f2344b63dfc07e05a02900486d65003ff1d 100644 --- a/TaskTrackingSystem.WebApi/Features/Task/TaskDetailsController.cs +++ b/TaskTrackingSystem.WebApi/Features/Task/TaskDetailsController.cs @@ -21,10 +21,12 @@ namespace TaskTrackingSystem.WebApi.Features.Task public class TaskDetailsController : ControllerBase { private readonly AppDbContext _db; + private readonly TaskTrackingSystem.WebApi.Features.Notification.FirebaseNotificationService _notificationService; - public TaskDetailsController(AppDbContext db) + public TaskDetailsController(AppDbContext db, TaskTrackingSystem.WebApi.Features.Notification.FirebaseNotificationService notificationService) { _db = db; + _notificationService = notificationService; } // ─── COMMENTS ──────────────────────────────────────────────────────── @@ -96,6 +98,8 @@ namespace TaskTrackingSystem.WebApi.Features.Task _db.Comments.Add(comment); await _db.SaveChangesAsync(); + await _notificationService.NotifyCommentAddedAsync(task, userId, $"{user.FirstName} {user.LastName}"); + var resultDto = new CommentDto { Id = comment.Id, diff --git a/TaskTrackingSystem.WebApi/Features/Task/TaskService.cs b/TaskTrackingSystem.WebApi/Features/Task/TaskService.cs index c019d7f1536d0dca3e348d856f3ecb210560c94e..9c7953d713aec28ff23fd40831d74f5a72354276 100644 --- a/TaskTrackingSystem.WebApi/Features/Task/TaskService.cs +++ b/TaskTrackingSystem.WebApi/Features/Task/TaskService.cs @@ -6,18 +6,20 @@ using System.Threading.Tasks; using TaskTrackingSystem.Database; using TaskTrackingSystem.Database.AppDbContextModels; using TaskTrackingSystem.Shared.Models.Task; - using TaskTrackingSystem.Shared; +using TaskTrackingSystem.Shared.Enums; namespace TaskTrackingSystem.WebApi.Features.Task { public class TaskService { private readonly AppDbContext _db; + private readonly TaskTrackingSystem.WebApi.Features.Notification.FirebaseNotificationService _notificationService; - public TaskService(AppDbContext db) + public TaskService(AppDbContext db, TaskTrackingSystem.WebApi.Features.Notification.FirebaseNotificationService notificationService) { _db = db; + _notificationService = notificationService; } public async Task> GetAllTasksAsync(string roleName, long currentUserId) @@ -107,8 +109,8 @@ namespace TaskTrackingSystem.WebApi.Features.Task Title = dto.Title, Description = dto.Description, ProjectId = dto.ProjectId, - StatusId = dto.StatusId == 0 ? 1 : dto.StatusId, - PriorityId = dto.PriorityId == 0 ? 2 : dto.PriorityId, + StatusId = dto.StatusId == 0 ? AppTaskStatus.Todo : dto.StatusId, + PriorityId = dto.PriorityId == 0 ? TaskPriority.Medium : dto.PriorityId, AssignedTo = dto.AssignedTo, AssignedBy = dto.AssignedBy, EstimatedHours = dto.EstimatedHours, @@ -121,6 +123,11 @@ namespace TaskTrackingSystem.WebApi.Features.Task _db.Tasks.Add(task); await _db.SaveChangesAsync(); + if (task.AssignedTo.HasValue) + { + await _notificationService.NotifyTaskAssignedAsync(task, currentUserId); + } + var resultDto = new TaskDto { Id = task.Id, @@ -150,6 +157,8 @@ namespace TaskTrackingSystem.WebApi.Features.Task var task = await _db.Tasks.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true); if (task == null) return Result.Failure(ResultMessages.TaskNotFound(id), 404); + var previousStatusId = task.StatusId; + if (dto.AssignedTo.HasValue && !await IsProjectMemberAsync(task.ProjectId, dto.AssignedTo.Value)) { return Result.Failure("Task assignee must belong to the selected project.", 400); @@ -166,8 +175,26 @@ namespace TaskTrackingSystem.WebApi.Features.Task task.UpdatedAt = DateTime.UtcNow; task.UpdatedBy = currentUserId; + if (previousStatusId != dto.StatusId) + { + _db.TaskHistories.Add(new TaskHistory + { + TaskId = task.Id, + ModifiedById = currentUserId ?? task.UpdatedBy ?? task.CreatedBy ?? 0, + OldStatusId = previousStatusId, + NewStatusId = dto.StatusId, + CreatedBy = currentUserId ?? task.CreatedBy ?? 0, + CreatedAt = DateTime.UtcNow + }); + } + _db.Tasks.Update(task); await _db.SaveChangesAsync(); + + if (previousStatusId != dto.StatusId && currentUserId.HasValue) + { + await _notificationService.NotifyTaskStatusChangedAsync(task, currentUserId.Value, previousStatusId, dto.StatusId); + } return Result.Success(200); } @@ -218,7 +245,7 @@ namespace TaskTrackingSystem.WebApi.Features.Task return Result>.Success(tasks); } - public async Task UpdateTaskStatusAsync(long id, long statusId, string roleName, long currentUserId) + public async Task UpdateTaskStatusAsync(long id, AppTaskStatus statusId, string roleName, long currentUserId) { var task = await _db.Tasks.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true); if (task == null) @@ -226,13 +253,30 @@ namespace TaskTrackingSystem.WebApi.Features.Task return Result.Failure(ResultMessages.TaskNotFound(id), 404); } + var previousStatusId = task.StatusId; + task.StatusId = statusId; task.UpdatedAt = DateTime.UtcNow; task.UpdatedBy = currentUserId; + _db.TaskHistories.Add(new TaskHistory + { + TaskId = task.Id, + ModifiedById = currentUserId, + OldStatusId = previousStatusId, + NewStatusId = statusId, + CreatedBy = currentUserId, + CreatedAt = DateTime.UtcNow + }); + _db.Tasks.Update(task); await _db.SaveChangesAsync(); + if (previousStatusId != statusId) + { + await _notificationService.NotifyTaskStatusChangedAsync(task, currentUserId, previousStatusId, statusId); + } + return Result.Success(200); } diff --git a/TaskTrackingSystem.WebApi/Features/UserDevice/UserDeviceService.cs b/TaskTrackingSystem.WebApi/Features/UserDevice/UserDeviceService.cs new file mode 100644 index 0000000000000000000000000000000000000000..9b3029af56f6ce251241e05726cf7cfef61cd6cb --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/UserDevice/UserDeviceService.cs @@ -0,0 +1,66 @@ +using Microsoft.EntityFrameworkCore; +using TaskTrackingSystem.Database.AppDbContextModels; +using TaskTrackingSystem.Shared; +using DbUserDevice = TaskTrackingSystem.Database.AppDbContextModels.UserDevice; + +namespace TaskTrackingSystem.WebApi.Features.UserDevice; + +public class UserDeviceService +{ + private readonly AppDbContext _db; + + public UserDeviceService(AppDbContext db) + { + _db = db; + } + + public async Task RegisterTokenAsync(long userId, string fcmToken) + { + var token = fcmToken.Trim(); + if (string.IsNullOrWhiteSpace(token)) + { + return Result.Failure("FCM token is required.", 400); + } + + var existing = await _db.UserDevices.FirstOrDefaultAsync(x => x.FcmToken == token); + var now = DateTime.UtcNow; + + if (existing == null) + { + _db.UserDevices.Add(new DbUserDevice + { + UserId = userId, + FcmToken = token, + CreatedAt = now, + UpdatedAt = now + }); + } + else + { + existing.UserId = userId; + existing.UpdatedAt = now; + } + + await _db.SaveChangesAsync(); + return Result.Success(); + } + + public async Task RemoveTokenAsync(long userId, string fcmToken) + { + var token = fcmToken.Trim(); + if (string.IsNullOrWhiteSpace(token)) + { + return Result.Failure("FCM token is required.", 400); + } + + var device = await _db.UserDevices.FirstOrDefaultAsync(x => x.UserId == userId && x.FcmToken == token); + if (device == null) + { + return Result.Success(204); + } + + _db.UserDevices.Remove(device); + await _db.SaveChangesAsync(); + return Result.Success(204); + } +} diff --git a/TaskTrackingSystem.WebApi/Features/UserDevice/UserDevicesController.cs b/TaskTrackingSystem.WebApi/Features/UserDevice/UserDevicesController.cs new file mode 100644 index 0000000000000000000000000000000000000000..25183d1966b7953808ee17f8e4291e98da41ea6b --- /dev/null +++ b/TaskTrackingSystem.WebApi/Features/UserDevice/UserDevicesController.cs @@ -0,0 +1,46 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TaskTrackingSystem.Shared; +using TaskTrackingSystem.Shared.Models.Notification; +using TaskTrackingSystem.WebApi.Infrastructure; + +namespace TaskTrackingSystem.WebApi.Features.UserDevice; + +[ApiController] +[Authorize] +[Route("api/[controller]")] +public class UserDevicesController : ControllerBase +{ + private readonly UserDeviceService _userDeviceService; + + public UserDevicesController(UserDeviceService userDeviceService) + { + _userDeviceService = userDeviceService; + } + + [HttpPost("register")] + public async Task> Register([FromBody] RegisterDeviceTokenDto dto) + { + var userId = User.GetUserId(); + if (userId <= 0) + { + return Unauthorized(Result.Failure("User is not authenticated.", 401)); + } + + var result = await _userDeviceService.RegisterTokenAsync(userId, dto.FcmToken); + return StatusCode(result.StatusCode, result); + } + + [HttpPost("unregister")] + public async Task> Unregister([FromBody] RegisterDeviceTokenDto dto) + { + var userId = User.GetUserId(); + if (userId <= 0) + { + return Unauthorized(Result.Failure("User is not authenticated.", 401)); + } + + var result = await _userDeviceService.RemoveTokenAsync(userId, dto.FcmToken); + return StatusCode(result.StatusCode, result); + } +} diff --git a/TaskTrackingSystem.WebApi/Program.cs b/TaskTrackingSystem.WebApi/Program.cs index 0ed22fd34769d4c5f6dccae0dc547510962fefe8..82ab7fa6df67d05331e6f60e16ea23533d3c4c7a 100644 --- a/TaskTrackingSystem.WebApi/Program.cs +++ b/TaskTrackingSystem.WebApi/Program.cs @@ -11,6 +11,7 @@ var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddControllers(); +builder.Services.AddHttpClient(); // CORS policy for WebApp builder.Services.AddCors(options => @@ -32,6 +33,9 @@ builder.Services.AddScoped( builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped, PasswordHasher>(); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle @@ -120,5 +124,19 @@ static async System.Threading.Tasks.Task EnsureSeedDataAsync(WebApplication app) backlogMenu.UpdatedAt = DateTime.UtcNow; } + // Soft-delete existing tasks that belong to deleted projects + var orphanedTasks = await db.Tasks + .Where(t => t.IsDeleted != true && t.Project.IsDeleted == true) + .ToListAsync(); + foreach (var task in orphanedTasks) + { + task.IsDeleted = true; + task.UpdatedAt = DateTime.UtcNow; + } + if (orphanedTasks.Any()) + { + db.Tasks.UpdateRange(orphanedTasks); + } + await db.SaveChangesAsync(); } diff --git a/TaskTrackingSystem.WebApi/TaskTrackingSystem.WebApi.csproj b/TaskTrackingSystem.WebApi/TaskTrackingSystem.WebApi.csproj index 4039a65639cf1cb2488cb2722773ebfaec629f33..bf4b33e319e767cc15887d2f7a0619b7b0f590a0 100644 --- a/TaskTrackingSystem.WebApi/TaskTrackingSystem.WebApi.csproj +++ b/TaskTrackingSystem.WebApi/TaskTrackingSystem.WebApi.csproj @@ -21,6 +21,12 @@ + + + PreserveNewest + + + diff --git a/TaskTrackingSystem.WebApi/appsettings.Development.json b/TaskTrackingSystem.WebApi/appsettings.Development.json index 0c208ae9181e5e5717e47ec1bd59368aebc6066e..9a7deb199d33aa171390b63c625dc477b7a2242c 100644 --- a/TaskTrackingSystem.WebApi/appsettings.Development.json +++ b/TaskTrackingSystem.WebApi/appsettings.Development.json @@ -4,5 +4,8 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "Firebase": { + "ServiceAccountPath": "Firebase/ttsfirebasekey.json" } } diff --git a/TaskTrackingSystem.WebApi/appsettings.json b/TaskTrackingSystem.WebApi/appsettings.json index 93481005e76a9ee3c09714499c1c5f2b0068da83..86449db83669af8d9e01210e4dfe13c057223925 100644 --- a/TaskTrackingSystem.WebApi/appsettings.json +++ b/TaskTrackingSystem.WebApi/appsettings.json @@ -14,6 +14,9 @@ "PasswordReset": { "RecoveryCode": "TASKIFY-RESET-2026" }, + "Firebase": { + "ServiceAccountPath": "Firebase/ttsfirebasekey.json" + }, "ConnectionStrings": { "DefaultConnection": "Server=.;Database=TTS;Trusted_Connection=True;TrustServerCertificate=True;Connect Timeout=5;" } diff --git a/TaskTrackingSystem.WebApp/Components/App.razor b/TaskTrackingSystem.WebApp/Components/App.razor index bfe00ea1d69cb8765d379975551f2d5794c3e482..6228532fc2da6c4bb8b787afd9bee32e3d3a00f6 100644 --- a/TaskTrackingSystem.WebApp/Components/App.razor +++ b/TaskTrackingSystem.WebApp/Components/App.razor @@ -12,33 +12,212 @@ + + + +