Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,157 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class AnnouncementsController : Controller
{
private readonly ApplicationDbContext _db;
private readonly IInAppNotificationService _inApp;
public AnnouncementsController(ApplicationDbContext db, IInAppNotificationService inApp)
{
_db = db;
_inApp = inApp;
}
/// <summary>
/// Lists all platform announcements in reverse-chronological order. SuperAdmin-only (enforced at controller level by the SuperAdminOnly policy).
/// </summary>
public async Task<IActionResult> Index()
{
var announcements = await _db.Announcements
.OrderByDescending(a => a.CreatedAt)
.ToListAsync();
return View(announcements);
}
/// <summary>
/// Shows the announcement creation form with sensible defaults: starts now, dismissible, and active.
/// </summary>
public IActionResult Create()
{
PopulateDropdowns();
return View(new Announcement { StartsAt = DateTime.Now, IsDismissible = true, IsActive = true });
}
/// <summary>
/// Persists a new announcement and immediately dispatches it as in-app notifications to all targeted companies. Dates are converted to UTC before storage; DispatchNotificationsAsync handles the fan-out.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Create(Announcement model)
{
if (!ModelState.IsValid) { PopulateDropdowns(); return View(model); }
model.CreatedByUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
model.CreatedByUserName = User.Identity?.Name ?? "SuperAdmin";
model.CreatedAt = DateTime.UtcNow;
model.StartsAt = model.StartsAt.ToUniversalTime();
if (model.ExpiresAt.HasValue) model.ExpiresAt = model.ExpiresAt.Value.ToUniversalTime();
_db.Announcements.Add(model);
await _db.SaveChangesAsync();
// Dispatch as in-app notifications to targeted companies
await DispatchNotificationsAsync(model);
TempData["Success"] = "Announcement created and sent as notifications.";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Shows the edit form for an existing announcement. Note: editing does NOT re-dispatch notifications; it only updates the stored announcement record.
/// </summary>
public async Task<IActionResult> Edit(int id)
{
var announcement = await _db.Announcements.FindAsync(id);
if (announcement == null) return NotFound();
PopulateDropdowns();
return View(announcement);
}
/// <summary>
/// Saves changes to an existing announcement. TargetPlan and TargetCompanyId are cleared when the Target field changes, preventing stale filter values from persisting on the record.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, Announcement model)
{
if (!ModelState.IsValid) { PopulateDropdowns(); return View(model); }
var existing = await _db.Announcements.FindAsync(id);
if (existing == null) return NotFound();
existing.Title = model.Title;
existing.Message = model.Message;
existing.Type = model.Type;
existing.Target = model.Target;
existing.TargetPlan = model.Target == "Plan" ? model.TargetPlan : null;
existing.TargetCompanyId = model.Target == "Company" ? model.TargetCompanyId : null;
existing.StartsAt = model.StartsAt.ToUniversalTime();
existing.ExpiresAt = model.ExpiresAt.HasValue ? model.ExpiresAt.Value.ToUniversalTime() : null;
existing.IsDismissible = model.IsDismissible;
existing.IsActive = model.IsActive;
existing.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
TempData["Success"] = "Announcement updated.";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Permanently deletes an announcement record. Hard delete is intentional here — announcements are platform content, not business data, and do not require audit-trail soft deletion.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id)
{
var announcement = await _db.Announcements.FindAsync(id);
if (announcement == null) return NotFound();
_db.Announcements.Remove(announcement);
await _db.SaveChangesAsync();
TempData["Success"] = "Announcement deleted.";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Fans out the announcement as in-app notifications to each matching company. IgnoreQueryFilters is required to reach all active companies regardless of tenant context (this runs as SuperAdmin). Filtering by Target/Plan/Company happens before the foreach so only relevant tenants receive the notification.
/// </summary>
private async Task DispatchNotificationsAsync(Announcement model)
{
IQueryable<PowderCoating.Core.Entities.Company> companyQuery = _db.Companies
.IgnoreQueryFilters()
.Where(c => !c.IsDeleted && c.IsActive);
if (model.Target == "Plan" && model.TargetPlan.HasValue)
companyQuery = companyQuery.Where(c => c.SubscriptionPlan == model.TargetPlan.Value);
else if (model.Target == "Company" && model.TargetCompanyId.HasValue)
companyQuery = companyQuery.Where(c => c.Id == model.TargetCompanyId.Value);
var companyIds = await companyQuery.Select(c => c.Id).ToListAsync();
foreach (var companyId in companyIds)
{
await _inApp.CreateAsync(
companyId,
model.Title,
model.Message,
"Announcement");
}
}
/// <summary>
/// Loads company and plan lists into ViewBag for the Create/Edit form dropdowns. Uses AsNoTracking and IgnoreQueryFilters to bypass soft-delete and tenant filters so all companies are available for targeting.
/// </summary>
private void PopulateDropdowns()
{
ViewBag.Companies = _db.Companies.AsNoTracking().IgnoreQueryFilters()
.Where(c => !c.IsDeleted).OrderBy(c => c.CompanyName)
.Select(c => new { c.Id, c.CompanyName }).ToList();
ViewBag.PlanConfigs = _db.SubscriptionPlanConfigs.AsNoTracking().IgnoreQueryFilters()
.Where(p => p.IsActive).OrderBy(p => p.SortOrder).ToList();
}
}