Skip to content

Commit

Permalink
Email notifications (#218)
Browse files Browse the repository at this point in the history
* Reworked notification email into layout for reusability

* Send rejection email to staff when time entry is rejected

* Added WebUrl to server options, send email notification to PM when time is resubmitted.

* Added BackgroundSendTimeEntryReminderEmail recurring job

* Added BackgroundSendTimeSubmissionReminderEmail recurring job
  • Loading branch information
rmaffitsancsoft authored Jul 7, 2024
1 parent 34f19fc commit da5a436
Show file tree
Hide file tree
Showing 15 changed files with 318 additions and 85 deletions.
20 changes: 12 additions & 8 deletions src/dotnet/HQ.Abstractions/Emails/RejectTimeEntryEmail.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,30 @@ namespace HQ.Abstractions.Emails
{
public class RejectTimeEntryEmail : NotificationEmail
{
public string? Date { get; set; }
public string? ChargeCode { get; set; }
public string? Client { get; set; }
public string? Project { get; set; }
public DateOnly Date { get; set; }
public decimal Hours { get; set; }
public string ChargeCode { get; set; } = null!;
public string Client { get; set; } = null!;
public string Project { get; set; } = null!;
public string? ActivityTask { get; set; }
public string? Description { get; set; }
public string? ReasonForRejection { get; set; }
public string? RejectedBy { get; set; }
public static new RejectTimeEntryEmail Sample = new()
{
Heading = "Time Rejected",
Message = "Your time entry has been rejected. Please review and resubmit.",
Hours = 0,
ButtonLabel = "Open HQ",
ButtonUrl = new Uri("http://hq.localhost:4200/dashboard"),
Date = "07/03/2024",
Date = new DateOnly(2024, 7, 3),
ChargeCode = "P1041",
Client = "SANCSOFT",
Project = "HQ",
ActivityTask = "qqq",
Description = "AQAA",
ReasonForRejection = "The hours entered (0) are not valid. Please ensure that the hours reflect actual time worked."
ActivityTask = "#123",
Description = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
ReasonForRejection = "The hours entered (0) are not valid. Please ensure that the hours reflect actual time worked.",
RejectedBy = "Joe Fabitz"
};
}

Expand Down
19 changes: 3 additions & 16 deletions src/dotnet/HQ.Email/Views/Emails/HTML/Notification.cshtml
Original file line number Diff line number Diff line change
@@ -1,17 +1,4 @@
@model HQ.Abstractions.Emails.NotificationEmail
<mj-column>
<mj-text>
@if (!String.IsNullOrEmpty(Model.Heading))
{
<h1 class="heading-1">@Model.Heading</h1>
}
@if (!String.IsNullOrEmpty(Model.Message))
{
<p>@Model.Message</p>
}
</mj-text>
@if (!String.IsNullOrEmpty(Model.ButtonLabel) && Model.ButtonUrl != null)
{
<mj-button mj-class="btn" href="@Model.ButtonUrl" target="_blank">@Model.ButtonLabel</mj-button>
}
</mj-column>
@{
Layout = "_LayoutEmailNotificationHTML";
}
60 changes: 17 additions & 43 deletions src/dotnet/HQ.Email/Views/Emails/HTML/RejectTimeEntry.cshtml
Original file line number Diff line number Diff line change
@@ -1,46 +1,20 @@
@model HQ.Abstractions.Emails.RejectTimeEntryEmail
<mj-column>
<mj-text>
@if (!String.IsNullOrEmpty(Model.Heading))
{
<h1 class="heading-1">@Model.Heading</h1>
}
@if (!String.IsNullOrEmpty(Model.Message))
{
<p>@Model.Message</p>
}
@if (!String.IsNullOrEmpty(Model.Date))
{
<div><strong>Date:</strong> @Model.Date</div>
}
@if (!String.IsNullOrEmpty(Model.ChargeCode))
{
<div><strong>Charge Code:</strong> @Model.ChargeCode</div>
}
@if (!String.IsNullOrEmpty(Model.Client))
{
<div><strong>Client:</strong> @Model.Client</div>
}
@if (!String.IsNullOrEmpty(Model.Project))
{
<div><strong>Project:</strong> @Model.Project</div>
}
@if (!String.IsNullOrEmpty(Model.ActivityTask))
{
<div><strong>Activity/Task:</strong> @Model.ActivityTask</div>
}
@if (!String.IsNullOrEmpty(Model.Description))
{
<div><strong>Description:</strong> @Model.Description</div>
}
<br>
@if (!String.IsNullOrEmpty(Model.ReasonForRejection))
{
<div><strong>Reason for Rejection:</strong> @Model.ReasonForRejection</div>
}
</mj-text>
@if (!String.IsNullOrEmpty(Model.ButtonLabel) && Model.ButtonUrl != null)
@{
Layout = "_LayoutEmailNotificationHTML";
}
<mj-text>
<div><strong>Date:</strong> @Model.Date</div>
<div><strong>Hours:</strong> @Model.Hours.ToString("0.00")</div>
<div><strong>Charge Code:</strong> @Model.ChargeCode</div>
<div><strong>Client:</strong> @Model.Client</div>
<div><strong>Project:</strong> @Model.Project</div>
<div><strong>Activity/Task:</strong> @Model.ActivityTask</div>
<div><strong>Description:</strong> @Model.Description</div>
</mj-text>
<mj-text>
@if (!String.IsNullOrEmpty(Model.ReasonForRejection))
{
<mj-button mj-class="btn" href="@Model.ButtonUrl" target="_blank">@Model.ButtonLabel</mj-button>
<div><strong>Reason for Rejection:</strong> @Model.ReasonForRejection</div>
}
</mj-column>
<div><strong>Rejected By :</strong> @Model.RejectedBy</div>
</mj-text>
2 changes: 2 additions & 0 deletions src/dotnet/HQ.Email/Views/Emails/Text/RejectTimeEntry.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@
Details:

Date: @Model.Date
Hours: @Model.Hours.ToString("0.00")
Charge Code: @Model.ChargeCode
Client: @Model.Client
Project: @Model.Project
Activity/Task: @Model.ActivityTask
Description: @Model.Description
Reason for Rejection: @Model.ReasonForRejection
Rejected By: @Model.RejectedBy

@if (!String.IsNullOrEmpty(Model.ButtonLabel) && Model.ButtonUrl != null)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
@model HQ.Abstractions.Emails.NotificationEmail
@{
Layout = "_LayoutEmailHTML";
}
<mj-column>
<mj-text>
@if (!String.IsNullOrEmpty(Model.Heading))
{
<h1 class="heading-1">@Model.Heading</h1>
}
@if (!String.IsNullOrEmpty(Model.Message))
{
@Model.Message
}
</mj-text>
@RenderBody()
@if (!String.IsNullOrEmpty(Model.ButtonLabel) && Model.ButtonUrl != null)
{
<mj-button mj-class="btn" href="@Model.ButtonUrl" target="_blank">@Model.ButtonLabel</mj-button>
}
</mj-column>
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
@model HQ.Abstractions.Emails.NotificationEmail
@{
Layout = "_LayoutEmailText";
}
@if (!String.IsNullOrEmpty(Model.Heading))
{
<text>@Model.Heading</text>
<text>---</text>
<text></text>
}
@if (!String.IsNullOrEmpty(Model.Message))
{
<text>@Model.Message</text>
<text></text>
}
@RenderBody()
@if (!String.IsNullOrEmpty(Model.ButtonLabel) && Model.ButtonUrl != null)
{
<text>@Model.ButtonLabel: @Model.ButtonUrl</text>
}
4 changes: 4 additions & 0 deletions src/dotnet/HQ.Server/HQServerOptions.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.Reflection;

Expand All @@ -8,4 +9,7 @@ public class HQServerOptions
public const string Server = nameof(Server);
public bool HangfireInMemory { get; set; }
public bool AutoMigrate { get; set; }

[Required]
public Uri WebUrl { get; set; } = null!;
}
4 changes: 4 additions & 0 deletions src/dotnet/HQ.Server/HQServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ public static IServiceCollection AddHQServices(this IServiceCollection services,
.PersistKeysToDbContext<HQDbContext>();

var serverOptions = configuration.GetSection(HQServerOptions.Server).Get<HQServerOptions>() ?? throw new Exception("Error parsing configuration section 'Server'.");
services.AddOptions<HQServerOptions>()
.Bind(configuration.GetSection(HQServerOptions.Server))
.ValidateDataAnnotations()
.ValidateOnStart();

if (serverOptions.HangfireInMemory)
{
Expand Down
13 changes: 13 additions & 0 deletions src/dotnet/HQ.Server/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Hangfire;

using HQ;
using HQ.Abstractions.Enumerations;
using HQ.Server;
using HQ.Server.API;
using HQ.Server.Authorization;
Expand Down Expand Up @@ -258,6 +259,18 @@
Cron.Weekly(DayOfWeek.Monday, 12),
recurringJobOptions);

recurringJobManager.AddOrUpdate<TimeEntryServiceV1>(
nameof(TimeEntryServiceV1.BackgroundSendTimeEntryReminderEmail),
(t) => t.BackgroundSendTimeEntryReminderEmail(Period.Week, CancellationToken.None),
Cron.Weekly(DayOfWeek.Friday, 8),
recurringJobOptions);

recurringJobManager.AddOrUpdate<TimeEntryServiceV1>(
nameof(TimeEntryServiceV1.BackgroundSendTimeSubmissionReminderEmail),
(t) => t.BackgroundSendTimeSubmissionReminderEmail(Period.LastWeek, CancellationToken.None),
Cron.Weekly(DayOfWeek.Monday, 8),
recurringJobOptions);

recurringJobManager.AddOrUpdate<StaffServiceV1>(
nameof(StaffServiceV1.BackgroundBulkSetTimeEntryCutoffV1),
(t) => t.BackgroundBulkSetTimeEntryCutoffV1(CancellationToken.None),
Expand Down
134 changes: 133 additions & 1 deletion src/dotnet/HQ.Server/Services/EmailMessageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,28 @@
using HQ.Abstractions.Enumerations;
using HQ.Abstractions.Services;
using HQ.API;
using HQ.Server.Data;
using HQ.Server.Services;

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;

namespace HQ.Server.Services
{
public class EmailMessageService
{
private readonly EmailTemplateServiceV1 _emailTemplateService;
private readonly IEmailService _emailService;
public EmailMessageService(EmailTemplateServiceV1 emailTemplateService, IEmailService emailService)
private readonly HQDbContext _context;
private readonly IOptionsMonitor<HQServerOptions> _options;

public EmailMessageService(EmailTemplateServiceV1 emailTemplateService, IEmailService emailService, HQDbContext context, IOptionsMonitor<HQServerOptions> options)
{
_emailTemplateService = emailTemplateService;
_emailService = emailService;
_context = context;
_options = options;
}

public async Task<Result> SendEmail<T>(EmailMessage emailMessage, T model, string to, string subject, MailPriority priority = MailPriority.Normal, IEnumerable<Attachment>? attachments = null, CancellationToken ct = default) where T : BaseEmail
Expand All @@ -45,5 +53,129 @@ public async Task<Result> SendEmail<T>(EmailMessage emailMessage, T model, strin

return Result.Ok();
}

public async Task SendRejectTimeEntryEmail(Guid timeId, CancellationToken ct)
{
var time = await _context.Times
.AsNoTracking()
.Include(t => t.Staff)
.Include(t => t.RejectedBy)
.Include(t => t.Activity)
.Include(t => t.ChargeCode)
.ThenInclude(t => t.Project)
.ThenInclude(t => t!.Client)
.SingleAsync(t => t.Id == timeId, ct);

if (String.IsNullOrEmpty(time.Staff.Email))
{
return;
}

var model = new RejectTimeEntryEmail()
{
Date = time.Date,
Hours = time.Hours,
Description = time.Notes,
ActivityTask = time.Activity?.Name ?? time.Task,
ChargeCode = time.ChargeCode.Code,
Client = time.ChargeCode.Project?.Client?.Name ?? String.Empty,
Project = time.ChargeCode.Project?.Name ?? String.Empty,
ReasonForRejection = time.RejectionNotes,
RejectedBy = time.RejectedBy?.Name,
Heading = "Time Rejected",
Message = "Your time entry has been rejected. Please review and resubmit.",
ButtonLabel = "Open HQ",
ButtonUrl = _options.CurrentValue.WebUrl
};

await SendEmail(EmailMessage.RejectTimeEntry, model, time.Staff.Email, "[HQ] Time Rejected", MailPriority.High, null, ct);
}

public async Task SendResubmitTimeEntryEmail(Guid timeId, CancellationToken ct)
{
var time = await _context.Times
.AsNoTracking()
.Include(t => t.Staff)
.Include(t => t.RejectedBy)
.Include(t => t.Activity)
.Include(t => t.ChargeCode)
.ThenInclude(t => t.Project)
.ThenInclude(t => t!.Client)
.SingleAsync(t => t.Id == timeId, ct);

var psr = await _context.ProjectStatusReports
.AsNoTracking()
.Include(t => t.ProjectManager)
.SingleOrDefaultAsync(t => t.ProjectId == time.ChargeCode.ProjectId && time.Date >= t.StartDate && time.Date <= t.EndDate, ct);

if (psr == null || String.IsNullOrEmpty(psr.ProjectManager?.Email))
{
return;
}

var buttonUrl = new Uri(_options.CurrentValue.WebUrl, $"/psr/{psr.Id}/time");
var model = new RejectTimeEntryEmail()
{
Date = time.Date,
Hours = time.Hours,
Description = time.Notes,
ActivityTask = time.Activity?.Name ?? time.Task,
ChargeCode = time.ChargeCode.Code,
Client = time.ChargeCode.Project?.Client?.Name ?? String.Empty,
Project = time.ChargeCode.Project?.Name ?? String.Empty,
ReasonForRejection = time.RejectionNotes,
RejectedBy = time.RejectedBy?.Name,
Heading = "Time Resubmitted",
Message = "A rejected time entry has been resubmitted.",
ButtonLabel = "View PSR",
ButtonUrl = buttonUrl
};

await SendEmail(EmailMessage.RejectTimeEntry, model, psr.ProjectManager.Email, "[HQ] Time Resubmitted", MailPriority.Normal, null, ct);
}

public async Task SendTimeEntryReminderEmail(Guid staffId, DateOnly from, DateOnly to, CancellationToken ct)
{
var staff = await _context.Staff
.AsNoTracking()
.SingleOrDefaultAsync(t => t.Id == staffId, ct);

if (staff == null || String.IsNullOrEmpty(staff.Email))
{
return;
}

var model = new NotificationEmail()
{
Heading = "Time Entry Reminder",
Message = $"You have 0 hours entered in HQ from {from} to {to}. Please remember to enter time into HQ and submit for review by Monday at 12PM EST.",
ButtonLabel = "Open HQ",
ButtonUrl = _options.CurrentValue.WebUrl
};

await SendEmail(EmailMessage.Notification, model, staff.Email, "[HQ] Time Entry Reminder", MailPriority.Normal, null, ct);
}

public async Task SendTimeSubmissionReminderEmail(Guid staffId, DateOnly from, DateOnly to, CancellationToken ct)
{
var staff = await _context.Staff
.AsNoTracking()
.SingleOrDefaultAsync(t => t.Id == staffId, ct);

if (staff == null || String.IsNullOrEmpty(staff.Email))
{
return;
}

var model = new NotificationEmail()
{
Heading = "Time Submission Reminder",
Message = $"You have unsubmitted time in HQ from {from} to {to}. Please remember to submit for review by 12PM EST.",
ButtonLabel = "Open HQ",
ButtonUrl = _options.CurrentValue.WebUrl
};

await SendEmail(EmailMessage.Notification, model, staff.Email, "[HQ] Time Submission Reminder", MailPriority.High, null, ct);
}
}
}
Loading

0 comments on commit da5a436

Please sign in to comment.