diff --git a/Components/Account/Pages/Register.razor b/Components/Account/Pages/Register.razor
index 2c4ab33..6881bb0 100644
--- a/Components/Account/Pages/Register.razor
+++ b/Components/Account/Pages/Register.razor
@@ -82,6 +82,18 @@
Logger.LogInformation("User created a new account with password.");
+ var int userCount = UserManager.User.Count();
+ var string? defaultRole = userCount <= 1 ? AppRoles.Admin : AppRoles.Driver;
+ var IdentityResult? roleResult = await USerManager.AddToRoleAsync(user, defalutRole)
+
+ if (!roleResult.Succeeded)
+ {
+ identityErrors = roleResult.Errors?
+ return;
+ }
+
+ Logger.LogInforamtion(message: "Assigned {Role} role to the new account.", defaultRole);
+
var userId = await UserManager.GetUserIdAsync(user);
var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
diff --git a/Components/Layout/NavMenu.razor b/Components/Layout/NavMenu.razor
index a55c14c..257f104 100644
--- a/Components/Layout/NavMenu.razor
+++ b/Components/Layout/NavMenu.razor
@@ -17,14 +17,38 @@
Home
+
+
+
+
+
+ Products
+
+
+
+
-
-
-
- Auth Required
-
-
+
+
+
+
+ Sales Input
+
+
+
+
+<<<<<<< HEAD
+
+
+
+
+ Delivery Checks
+
+
+
+
+=======
Catalog
@@ -56,6 +80,7 @@
Outgoing/Incoming Boxes
+>>>>>>> main
@@ -89,10 +114,10 @@
@* ── Admin-only navigation links ──
- checks the current user's roles.
+ checks the current user's roles.
The inner content is only rendered if the user has the Admin role.
Non-admin users won't even see these links in the DOM. *@
-
+
diff --git a/Components/Pages/Admin/StorageAdmin.razor b/Components/Pages/Admin/StorageAdmin.razor
index 1bc2b44..21665e2 100644
--- a/Components/Pages/Admin/StorageAdmin.razor
+++ b/Components/Pages/Admin/StorageAdmin.razor
@@ -17,7 +17,7 @@
@page "/admin/storage"
@using Microsoft.AspNetCore.Authorization
@using PuppetFestAPP.Web.Services
-@attribute [Authorize(Roles = "Admin")]
+@attribute [Authorize(Policy = AppPolicies.CanManageUsers)]
@rendermode InteractiveServer
@* ── Inject the services we need ── *@
diff --git a/Components/Pages/Admin/UserManagement.razor b/Components/Pages/Admin/UserManagement.razor
index e60c4c0..23ad642 100644
--- a/Components/Pages/Admin/UserManagement.razor
+++ b/Components/Pages/Admin/UserManagement.razor
@@ -5,7 +5,7 @@
and deleting users. Only users with the "Admin" role can access it.
KEY CONCEPTS:
- • @attribute [Authorize(Roles = "Admin")] → Role-based access control.
+ • @attribute [Authorize(Policiy = AppPolicies.CanManageUsers)] → Role-based access control.
Non-admin users get an "access denied" response, not the page content.
• @rendermode InteractiveServer → Enables interactive features
(button clicks, form bindings) via a real-time SignalR connection.
@@ -38,7 +38,7 @@
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Identity
@using PuppetFestAPP.Web.Data
-@attribute [Authorize(Roles = "Admin")]
+@attribute [Authorize(Policy = AppPolicies.CanManageUsers)]
@rendermode InteractiveServer
@* ── Inject IServiceProvider instead of UserManager/RoleManager ──
@@ -187,7 +187,7 @@
private List users = new();
private string newUserEmail = "";
private string newUserPassword = "";
- private string newUserRole = AppRoles.SalesStaff; // default selection for the "Role" dropdown in the "Create New User"
+ private string newUserRole = AppRoles.Driver; // default selection for the "Role" dropdown in the "Create New User"
private string statusMessage = "";
private string statusCssClass = "";
@@ -362,6 +362,6 @@
public string Email { get; set; } = "";
public List Roles { get; set; } = new();
public bool EmailConfirmed { get; set; }
- public string SelectedNewRole { get; set; } = AppRoles.SalesStaff;
+ public string SelectedNewRole { get; set; } = AppRoles.Driver;
}
}
\ No newline at end of file
diff --git a/Components/Pages/DeliveryChecks.razor b/Components/Pages/DeliveryChecks.razor
new file mode 100644
index 0000000..ef5cc56
--- /dev/null
+++ b/Components/Pages/DeliveryChecks.razor
@@ -0,0 +1,195 @@
+@page "/delivery-checks"
+@using Microsoft.EntityFrameworkCore
+@using PuppetFestAPP.Web.Data
+@attribute [Authorize(Policy = AppPolicies.CanManageDeliveryChecks)]
+@rendermode InteractiveServer
+@inject IDbContextFactory DbFactory
+
+Delivery Checks
+
+Delivery Checks
+
+
+ Confirm box and delivery checks for each product/location stock row.
+
+
+@if (!string.IsNullOrWhiteSpace(statusMessage))
+{
+
+ @statusMessage
+ statusMessage = string.Empty">
+
+}
+
+@if (checkRows.Count == 0)
+{
+ No delivery check records are available.
+}
+else
+{
+
+
+
+
+ Product
+ Location
+ Stock
+ Box Check
+ Delivery Check
+ Actions
+
+
+
+ @foreach (var row in checkRows)
+ {
+
+ @row.ProductName
+ @row.LocationName
+ @row.Quantity
+
+ @if (row.IsBoxChecked)
+ {
+ Checked
+ @FormatDate(row.BoxCheckedAt)
+ }
+ else
+ {
+ Pending
+ }
+
+
+ @if (row.IsDeliveryChecked)
+ {
+ Checked
+ @FormatDate(row.DeliveryCheckedAt)
+ }
+ else
+ {
+ Pending
+ }
+
+
+
+ MarkBoxCheckedAsync(row)">
+ Box Checked
+
+ MarkDeliveryCheckedAsync(row)">
+ Delivery Checked
+
+
+
+
+ }
+
+
+
+}
+
+@code {
+ private List checkRows = new();
+ private string statusMessage = string.Empty;
+ private string statusCssClass = "alert-info";
+
+ protected override async Task OnInitializedAsync()
+ {
+ await LoadCheckRowsAsync();
+ }
+
+ private async Task LoadCheckRowsAsync()
+ {
+ using var context = await DbFactory.CreateDbContextAsync();
+
+ var productLocations = await context.ProductLocations
+ .Include(pl => pl.Product)
+ .Include(pl => pl.Location)
+ .OrderBy(pl => pl.Product.Name)
+ .ThenBy(pl => pl.Location.Name)
+ .ToListAsync();
+
+ checkRows = productLocations.Select(pl => new DeliveryCheckRow
+ {
+ ProductLocationId = pl.Id,
+ ProductName = pl.Product.Name,
+ LocationName = pl.Location.Name,
+ Quantity = pl.Quantity,
+ IsBoxChecked = pl.IsBoxChecked,
+ BoxCheckedAt = pl.BoxCheckedAt,
+ IsDeliveryChecked = pl.IsDeliveryChecked,
+ DeliveryCheckedAt = pl.DeliveryCheckedAt
+ }).ToList();
+ }
+
+ private async Task MarkBoxCheckedAsync(DeliveryCheckRow row)
+ {
+ await UpdateCheckAsync(
+ row,
+ updateBoxCheck: true,
+ successMessage: $"Box check recorded for {row.ProductName} at {row.LocationName}.");
+ }
+
+ private async Task MarkDeliveryCheckedAsync(DeliveryCheckRow row)
+ {
+ await UpdateCheckAsync(
+ row,
+ updateBoxCheck: false,
+ successMessage: $"Delivery check recorded for {row.ProductName} at {row.LocationName}.");
+ }
+
+ private async Task UpdateCheckAsync(
+ DeliveryCheckRow row,
+ bool updateBoxCheck,
+ string successMessage)
+ {
+ using var context = await DbFactory.CreateDbContextAsync();
+ var productLocation = await context.ProductLocations
+ .FirstOrDefaultAsync(pl => pl.Id == row.ProductLocationId);
+
+ if (productLocation is null)
+ {
+ statusMessage = "The selected stock record no longer exists.";
+ statusCssClass = "alert-danger";
+ await LoadCheckRowsAsync();
+ return;
+ }
+
+ var checkedAt = DateTime.UtcNow;
+
+ if (updateBoxCheck)
+ {
+ productLocation.IsBoxChecked = true;
+ productLocation.BoxCheckedAt = checkedAt;
+ }
+ else
+ {
+ productLocation.IsDeliveryChecked = true;
+ productLocation.DeliveryCheckedAt = checkedAt;
+ }
+
+ await context.SaveChangesAsync();
+
+ statusMessage = successMessage;
+ statusCssClass = "alert-success";
+ await LoadCheckRowsAsync();
+ }
+
+ private static string FormatDate(DateTime? value)
+ {
+ return value.HasValue ? value.Value.ToString("g") : string.Empty;
+ }
+
+ private sealed class DeliveryCheckRow
+ {
+ public int ProductLocationId { get; set; }
+ public string ProductName { get; set; } = string.Empty;
+ public string LocationName { get; set; } = string.Empty;
+ public int Quantity { get; set; }
+ public bool IsBoxChecked { get; set; }
+ public DateTime? BoxCheckedAt { get; set; }
+ public bool IsDeliveryChecked { get; set; }
+ public DateTime? DeliveryCheckedAt { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/Components/Pages/ProductPages/Create.razor b/Components/Pages/ProductPages/Create.razor
index 1398e22..00abd75 100644
--- a/Components/Pages/ProductPages/Create.razor
+++ b/Components/Pages/ProductPages/Create.razor
@@ -2,6 +2,7 @@
@using Microsoft.EntityFrameworkCore
@using PuppetFestAPP.Web.Data
@using PuppetFestAPP.Web.Models
+@attribute [Authorize(Policy = AppPolicies.CanEditProducts)]
@inject IDbContextFactory DbFactory
@inject NavigationManager NavigationManager
@inject IWebHostEnvironment Environment
@@ -152,4 +153,4 @@
? $"/products/details/{newProduct.ParentProductId}"
: "/products");
}
-}
\ No newline at end of file
+}
diff --git a/Components/Pages/ProductPages/Delete.razor b/Components/Pages/ProductPages/Delete.razor
index 6a501ac..d654925 100644
--- a/Components/Pages/ProductPages/Delete.razor
+++ b/Components/Pages/ProductPages/Delete.razor
@@ -1,6 +1,7 @@
@page "/products/delete/{Id:int}"
@using Microsoft.EntityFrameworkCore
@using PuppetFestAPP.Web.Data
+@attribute [Authorize(Policy = AppPolicies.CanEditProducts)]
@rendermode InteractiveServer
@inject IDbContextFactory DbFactory
@inject NavigationManager NavigationManager
diff --git a/Components/Pages/ProductPages/Details.razor b/Components/Pages/ProductPages/Details.razor
index 4d1f1ea..f87febf 100644
--- a/Components/Pages/ProductPages/Details.razor
+++ b/Components/Pages/ProductPages/Details.razor
@@ -1,6 +1,7 @@
@page "/products/details/{Id:int}"
@using Microsoft.EntityFrameworkCore
@using PuppetFestAPP.Web.Data
+@attribute [Authorize(Policy = AppPolicies.CanViewProducts)]
@inject IDbContextFactory DbFactory
@inject NavigationManager NavigationManager
@@ -43,7 +44,7 @@
@if (!string.IsNullOrEmpty(Product.Image?.FileName))
{
+ style="max-height: 250px;" />
}
else
{
@@ -54,11 +55,15 @@
-
Inventory Variants (Sizes/Colors)
-
+ Add New Variant
+
Inventory Variants / Stock
+
+
+ + Add New Variant
+
+
- @if (Variants != null && Variants.Any())
+ @if (InventoryItems.Any())
{
@@ -66,27 +71,42 @@
Size
Color
Price
- Actions @* Only one Header *@
+ Stock
+ Location
+ Box Check
+ Delivery Check
+
+
+ Actions
+
+
- @foreach (var v in Variants)
+ @foreach (var v in InventoryItems)
{
@(v.Size == ProductSize.NA || v.Size == null ? "—" : v.Size.ToString())
@(v.Color == ProductColor.NA || v.Color == null ? "—" : v.Color.ToString())
@v.Price.ToString("C")
-
- @* Both buttons live inside this single *@
-
-
+ @GetStockTotal(v)
+ @GetLocations(v)
+ @GetBoxStatus(v)
+ @GetDeliveryStatus(v)
+
+
+
+
+
+
+
}
@@ -94,11 +114,16 @@
}
else
{
- Standalone product. No variations added yet.
+ No inventory records are available for this product yet.
}
}
@@ -108,23 +133,71 @@
[Parameter] public int Id { get; set; }
private Product? Product { get; set; }
private List Variants { get; set; } = new();
+ private List InventoryItems { get; set; } = new();
protected override async Task OnInitializedAsync()
{
using var context = await DbFactory.CreateDbContextAsync();
- // Added .Include(p => p.Image)
Product = await context.Products
- .Include(p => p.Category)
- .Include(p => p.Image)
- .FirstOrDefaultAsync(m => m.Id == Id);
+ .Include(p => p.Category)
+ .Include(p => p.Image)
+ .Include(p => p.ProductLocations)
+ .ThenInclude(pl => pl.Location)
+ .FirstOrDefaultAsync(m => m.Id == Id);
if (Product is not null)
{
Variants = await context.Products
- .Where(p => p.ParentProductId == Id)
- .OrderBy(p => p.Size)
- .ToListAsync();
+ .Include(p => p.ProductLocations)
+ .ThenInclude(pl => pl.Location)
+ .Where(p => p.ParentProductId == Id)
+ .OrderBy(p => p.Size)
+ .ToListAsync();
+
+ InventoryItems = Variants.Any()
+ ? Variants
+ : new List { Product };
+ }
+<<<<<<< HEAD
+ }
+
+ private static int GetStockTotal(Product product)
+ {
+ return product.ProductLocations.Sum(pl => pl.Quantity);
+ }
+
+ private static string GetLocations(Product product)
+ {
+ if (!product.ProductLocations.Any())
+ {
+ return "—";
}
+
+ return string.Join(", ", product.ProductLocations.Select(pl => pl.Location.Name));
+ }
+
+ private static string GetBoxStatus(Product product)
+ {
+ if (!product.ProductLocations.Any())
+ {
+ return "—";
+ }
+
+ return product.ProductLocations.All(pl => pl.IsBoxChecked) ? "Checked" : "Pending";
+ }
+
+ private static string GetDeliveryStatus(Product product)
+ {
+ if (!product.ProductLocations.Any())
+ {
+ return "—";
+ }
+
+ return product.ProductLocations.All(pl => pl.IsDeliveryChecked) ? "Checked" : "Pending";
+ }
+}
+=======
}
-}
\ No newline at end of file
+}
+>>>>>>> main
diff --git a/Components/Pages/ProductPages/Edit.razor b/Components/Pages/ProductPages/Edit.razor
index 00f876d..f9cf8b4 100644
--- a/Components/Pages/ProductPages/Edit.razor
+++ b/Components/Pages/ProductPages/Edit.razor
@@ -2,6 +2,7 @@
@using Microsoft.EntityFrameworkCore
@using PuppetFestAPP.Web.Data
@using PuppetFestAPP.Web.Models
+@attribute [Authorize(Policy = AppPolicies.CanEditProducts)]
@inject IDbContextFactory DbFactory
@inject NavigationManager NavigationManager
@@ -151,4 +152,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/Components/Pages/ProductPages/Index.razor b/Components/Pages/ProductPages/Index.razor
index e631bb7..1389d1a 100644
--- a/Components/Pages/ProductPages/Index.razor
+++ b/Components/Pages/ProductPages/Index.razor
@@ -4,6 +4,7 @@
@using PuppetFestAPP.Web.Data
@using Image = PuppetFestAPP.Web.Models.Image
@using PuppetFestAPP.Web.Models
+@attribute [Authorize(Policy = AppPolicies.CanViewProducts)]
@rendermode InteractiveServer
@inject IDbContextFactory DbFactory
@@ -11,9 +12,13 @@
Product Management
-
- Create New Master Product
-
+
+
+
+ Create New Master Product
+
+
+
@* Search Bar *@
@@ -23,19 +28,19 @@
@* Image Column *@
-
- @* We use 'prod' (lowercase) because the grid defined it as the 'Context' *@
- @if (prod.Image != null && !string.IsNullOrEmpty(prod.Image.FileName))
- {
-
- }
- else
- {
- No Image
- }
-
+
+ @* We use 'prod' (lowercase) because the grid defined it as the 'Context' *@
+ @if (prod.Image != null && !string.IsNullOrEmpty(prod.Image.FileName))
+ {
+
+ }
+ else
+ {
+ No Image
+ }
+
@@ -47,9 +52,17 @@
@@ -80,4 +93,4 @@
products = data.AsQueryable();
}
-}
\ No newline at end of file
+}
diff --git a/Components/Pages/SalesInput.razor b/Components/Pages/SalesInput.razor
new file mode 100644
index 0000000..d82e112
--- /dev/null
+++ b/Components/Pages/SalesInput.razor
@@ -0,0 +1,161 @@
+@page "/sales"
+@using Microsoft.EntityFrameworkCore
+@using PuppetFestAPP.Web.Data
+@attribute [Authorize(Policy = AppPolicies.CanEnterSales)]
+@rendermode InteractiveServer
+@inject IDbContextFactory
DbFactory
+
+Sales Input
+
+Sales Input
+
+
+ Enter sold quantities here. Submitting a sale reduces the stock count for the selected product/location row.
+
+
+@if (!string.IsNullOrWhiteSpace(statusMessage))
+{
+
+ @statusMessage
+ statusMessage = string.Empty">
+
+}
+
+@if (stockRows.Count == 0)
+{
+ No stock records are available.
+}
+else
+{
+
+}
+
+@code {
+ private List stockRows = new();
+ private string statusMessage = string.Empty;
+ private string statusCssClass = "alert-info";
+
+ protected override async Task OnInitializedAsync()
+ {
+ await LoadStockRowsAsync();
+ }
+
+ private async Task LoadStockRowsAsync()
+ {
+ using var context = await DbFactory.CreateDbContextAsync();
+
+ var productLocations = await context.ProductLocations
+ .Include(pl => pl.Product)
+ .ThenInclude(p => p.Category)
+ .Include(pl => pl.Location)
+ .OrderBy(pl => pl.Product.Name)
+ .ThenBy(pl => pl.Location.Name)
+ .ToListAsync();
+
+ stockRows = productLocations.Select(pl => new SalesStockRow
+ {
+ ProductLocationId = pl.Id,
+ ProductName = pl.Product.Name,
+ CategoryName = pl.Product.Category.Name,
+ LocationName = pl.Location.Name,
+ Quantity = pl.Quantity
+ }).ToList();
+ }
+
+ private static bool CanSubmit(SalesStockRow row)
+ {
+ return row.Quantity > 0 &&
+ row.SaleQuantity > 0 &&
+ row.SaleQuantity <= row.Quantity;
+ }
+
+ private async Task SubmitSaleAsync(SalesStockRow row)
+ {
+ if (!CanSubmit(row))
+ {
+ statusMessage = "Enter a valid quantity that does not exceed current stock.";
+ statusCssClass = "alert-warning";
+ return;
+ }
+
+ using var context = await DbFactory.CreateDbContextAsync();
+ var productLocation = await context.ProductLocations
+ .Include(pl => pl.Product)
+ .Include(pl => pl.Location)
+ .FirstOrDefaultAsync(pl => pl.Id == row.ProductLocationId);
+
+ if (productLocation is null)
+ {
+ statusMessage = "The selected stock record no longer exists.";
+ statusCssClass = "alert-danger";
+ await LoadStockRowsAsync();
+ return;
+ }
+
+ if (row.SaleQuantity > productLocation.Quantity)
+ {
+ statusMessage = "The sale quantity exceeds the latest stock count. The list has been refreshed.";
+ statusCssClass = "alert-warning";
+ await LoadStockRowsAsync();
+ return;
+ }
+
+ productLocation.Quantity -= row.SaleQuantity;
+ productLocation.LastUpdated = DateTime.UtcNow;
+
+ await context.SaveChangesAsync();
+
+ statusMessage = $"Sale submitted for {row.ProductName} at {row.LocationName}.";
+ statusCssClass = "alert-success";
+ await LoadStockRowsAsync();
+ }
+
+ private sealed class SalesStockRow
+ {
+ public int ProductLocationId { get; set; }
+ public string ProductName { get; set; } = string.Empty;
+ public string CategoryName { get; set; } = string.Empty;
+ public string LocationName { get; set; } = string.Empty;
+ public int Quantity { get; set; }
+ public int SaleQuantity { get; set; } = 1;
+ }
+}
diff --git a/Components/Routes.razor b/Components/Routes.razor
index 3541f33..d6ad619 100644
--- a/Components/Routes.razor
+++ b/Components/Routes.razor
@@ -1,9 +1,20 @@
@using PuppetFestAPP.Web.Components.Account.Shared
+
-
-
+
+ @if (authContext.User.Identity?.IsAuthenticated == true)
+ {
+
+
Access denied
+
You do not have access to this resource.
+
+ }
+ else
+ {
+
+ }
diff --git a/Components/_Imports.razor b/Components/_Imports.razor
index 4142cff..00a217c 100644
--- a/Components/_Imports.razor
+++ b/Components/_Imports.razor
@@ -10,3 +10,6 @@
@using PuppetFestAPP.Web
@using PuppetFestAPP.Web.Components
@using Microsoft.AspNetCore.Components.QuickGrid
+
+@using Microsoft.AspNetCore.Authorization
+@using PuppetFestAPP.Web.Data
\ No newline at end of file
diff --git a/Data/AppPolicies.cs b/Data/AppPolicies.cs
new file mode 100644
index 0000000..c365cb1
--- /dev/null
+++ b/Data/AppPolicies.cs
@@ -0,0 +1,15 @@
+// File: Data/AppPolicies.cs
+//
+// PURPOSE: Centralizes authorization policy names so pages, navigation,
+// and Program.cs can all reference the same policy constants.
+
+namespace PuppetFestAPP.Web.Data;
+
+public static class AppPolicies
+{
+ public const string CanViewProducts = "CanViewProducts";
+ public const string CanEditProducts = "CanEditProducts";
+ public const string CanEnterSales = "CanEnterSales";
+ public const string CanManageDeliveryChecks = "CanManageDeliveryChecks";
+ public const string CanManageUsers = "CanManageUsers";
+}
diff --git a/Data/AppRoles.cs b/Data/AppRoles.cs
index c7877f0..f6112c3 100644
--- a/Data/AppRoles.cs
+++ b/Data/AppRoles.cs
@@ -1,36 +1,79 @@
// File: Data/AppRoles.cs
//
// PURPOSE: Defines all application roles as compile-time constants.
-// Using constants (instead of raw strings) prevents typos and enables
-// IntelliSense. Every role check in the app references these constants.
+// Using constants instead of raw strings prevents typos and keeps all
+// role-based access rules consistent across pages, navigation, and seeding.
namespace PuppetFestAPP.Web.Data;
///
/// Central registry of all role names used in the application.
-/// Roles control what pages a user can access (via the [Authorize]
-/// attribute on Razor components) and what navigation links they see
-/// (via <AuthorizeView> in NavMenu.razor).
+///
+/// Permission hierarchy:
+/// Admin > SM > FOH > Driver
+///
+/// Driver has the smallest permission set. Any Driver-level capability is
+/// also available to FOH, SM, and Admin.
///
public static class AppRoles
{
- /// Full system access: user management, storage admin, all features.
+ /// Full system access, including user management.
public const string Admin = "Admin";
- /// Can view and edit inventory counts, add/remove products.
- public const string InventoryManager = "InventoryManager";
+ /// Stage manager / operations manager. Can edit operational data but cannot manage users.
+ public const string SM = "SM";
- /// Can process sales at a merch stand during the festival.
- public const string SalesStaff = "SalesStaff";
+ /// Front of house. Can view inventory/status data and enter sales.
+ public const string FOH = "FOH";
- ///
- /// Array of all role names. Used by the database seeder to create
- /// roles on first run, and by UI components to populate dropdowns.
- ///
- public static readonly string[] AllRoles = new[]
- {
+ /// Driver / delivery role. Can view stock/location/status data and complete box/delivery checks.
+ public const string Driver = "Driver";
+
+ /// All current application roles in hierarchy order.
+ public static readonly string[] AllRoles =
+ [
+ Admin,
+ SM,
+ FOH,
+ Driver
+ ];
+
+ /// Roles that can view product, stock, location, and delivery status information.
+ public static readonly string[] ViewProductRoles =
+ [
+ Admin,
+ SM,
+ FOH,
+ Driver
+ ];
+
+ /// Roles that can create, edit, or delete product/inventory records.
+ public static readonly string[] EditProductRoles =
+ [
Admin,
- InventoryManager,
- SalesStaff
- };
-}
\ No newline at end of file
+ SM
+ ];
+
+ /// Roles that can enter sales.
+ public static readonly string[] SalesInputRoles =
+ [
+ Admin,
+ SM,
+ FOH
+ ];
+
+ /// Roles that can perform box and delivery checks.
+ public static readonly string[] DeliveryCheckRoles =
+ [
+ Admin,
+ SM,
+ FOH,
+ Driver
+ ];
+
+ /// Roles that can manage application users and roles.
+ public static readonly string[] UserManagementRoles =
+ [
+ Admin
+ ];
+}
diff --git a/Data/ApplicationDbContext.cs b/Data/ApplicationDbContext.cs
index 886e1e7..9f38a9f 100644
--- a/Data/ApplicationDbContext.cs
+++ b/Data/ApplicationDbContext.cs
@@ -9,12 +9,20 @@ public class ApplicationDbContext(DbContextOptions options
public DbSet Products { get; set; }
public DbSet Categories { get; set; }
+<<<<<<< HEAD
+ // Use the full path here to be 100% sure
+ public DbSet Images { get; set; }
+ public DbSet Locations { get; set; }
+ public DbSet ProductLocations { get; set; }
+
+=======
public DbSet Images { get; set; }
public DbSet Locations { get; set; }
public DbSet ProductLocations { get; set; }
public DbSet StockTransferBoxes { get; set; }
public DbSet StockTransferBoxItems { get; set; }
+>>>>>>> main
public DbSet Inventories { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -36,8 +44,19 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
.Property(p => p.IsActive)
.HasDefaultValue(true);
+<<<<<<< HEAD
+ // Default check flags for existing and new product/location rows.
+ modelBuilder.Entity()
+ .Property(pl => pl.IsBoxChecked)
+ .HasDefaultValue(false);
+
+ modelBuilder.Entity()
+ .Property(pl => pl.IsDeliveryChecked)
+ .HasDefaultValue(false);
+=======
modelBuilder.Entity()
.HasIndex(pl => new { pl.ProductId, pl.LocationId })
.IsUnique();
+>>>>>>> main
}
}
diff --git a/Data/ProductLocation.cs b/Data/ProductLocation.cs
index 652be29..f08c47b 100644
--- a/Data/ProductLocation.cs
+++ b/Data/ProductLocation.cs
@@ -1,6 +1,5 @@
using System.ComponentModel.DataAnnotations;
-using System.ComponentModel.DataAnnotations.Schema;
-using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; // Add this using!
+using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
namespace PuppetFestAPP.Web.Data;
@@ -11,9 +10,9 @@ public class ProductLocation
[Required]
public int ProductId { get; set; }
- [ValidateNever] // 1. Add this to stop the loop back to Product
+ [ValidateNever]
public Product Product { get; set; } = null!;
-
+
[Required]
[Range(0, int.MaxValue, ErrorMessage = "Stock cannot be negative.")]
public int Quantity { get; set; } = 0;
@@ -21,8 +20,15 @@ public class ProductLocation
[Required]
public int LocationId { get; set; }
- [ValidateNever] // 2. Add this to stop the loop into Location
+ [ValidateNever]
public Location Location { get; set; } = null!;
public DateTime LastUpdated { get; set; } = DateTime.UtcNow;
-}
\ No newline at end of file
+
+ // Box/delivery check status is stored on the existing ProductLocation row
+ // so the app can track checks without introducing a separate workflow table.
+ public bool IsBoxChecked { get; set; }
+ public DateTime? BoxCheckedAt { get; set; }
+ public bool IsDeliveryChecked { get; set; }
+ public DateTime? DeliveryCheckedAt { get; set; }
+}
diff --git a/Data/SeedData.cs b/Data/SeedData.cs
index efcf852..7cd33e6 100644
--- a/Data/SeedData.cs
+++ b/Data/SeedData.cs
@@ -9,6 +9,7 @@
// before creating it, so it's safe to run on every startup.
using PuppetFestAPP.Web.Models; // This finds your new Image class
using Image = PuppetFestAPP.Web.Models.Image;
+using System.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
@@ -113,11 +114,22 @@ public static async Task InitializeAsync(IServiceProvider serviceProvider)
{
Console.WriteLine(
$" - Admin user already exists: {adminEmail}");
+
+ if (!await userManager.IsInRoleAsync(adminUser, AppRoles.Admin))
+ {
+ await userManager.AddToRoleAsync(adminUser, AppRoles.Admin);
+ Console.WriteLine($" ✓ Added Admin role to {adminEmail}");
+ }
}
+ await EnsureEveryUserHasApplicationRoleAsync(userManager);
+
using var scope = serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService();
+<<<<<<< HEAD
+ EnsureDeliveryCheckColumns(context);
+=======
@@ -150,10 +162,135 @@ public static async Task InitializeAsync(IServiceProvider serviceProvider)
+>>>>>>> main
SeedProducts(context);
}
-#endregion
+
+ ///
+ /// Gives existing accounts a valid application role after role changes.
+ /// Users without Admin/SM/FOH/Driver are assigned Driver by default.
+ /// If no Admin exists, the first available user is promoted to Admin.
+ ///
+ private static async Task EnsureEveryUserHasApplicationRoleAsync(
+ UserManager userManager)
+ {
+ var users = userManager.Users.ToList();
+
+ foreach (var user in users)
+ {
+ var roles = await userManager.GetRolesAsync(user);
+ var hasCurrentApplicationRole = roles.Any(role => AppRoles.AllRoles.Contains(role));
+
+ if (!hasCurrentApplicationRole)
+ {
+ await userManager.AddToRoleAsync(user, AppRoles.Driver);
+ Console.WriteLine($" ✓ Added default Driver role to {user.Email}");
+ }
+ }
+
+ var hasAdmin = false;
+ foreach (var user in users)
+ {
+ if (await userManager.IsInRoleAsync(user, AppRoles.Admin))
+ {
+ hasAdmin = true;
+ break;
+ }
+ }
+
+ if (!hasAdmin && users.Count > 0)
+ {
+ await userManager.AddToRoleAsync(users[0], AppRoles.Admin);
+ Console.WriteLine($" ✓ Promoted {users[0].Email} to Admin because no Admin existed.");
+ }
+ }
+
+ ///
+ /// Adds delivery-check columns to the existing ProductLocations table.
+ /// This keeps the change narrowly scoped to the existing stock/location
+ /// model and avoids introducing a separate workflow table.
+ ///
+ private static void EnsureDeliveryCheckColumns(ApplicationDbContext context)
+ {
+ AddColumnIfMissing(
+ context,
+ "ProductLocations",
+ "IsBoxChecked",
+ "INTEGER NOT NULL DEFAULT 0");
+
+ AddColumnIfMissing(
+ context,
+ "ProductLocations",
+ "BoxCheckedAt",
+ "TEXT NULL");
+
+ AddColumnIfMissing(
+ context,
+ "ProductLocations",
+ "IsDeliveryChecked",
+ "INTEGER NOT NULL DEFAULT 0");
+
+ AddColumnIfMissing(
+ context,
+ "ProductLocations",
+ "DeliveryCheckedAt",
+ "TEXT NULL");
+ }
+
+ private static void AddColumnIfMissing(
+ ApplicationDbContext context,
+ string tableName,
+ string columnName,
+ string columnDefinition)
+ {
+ var connection = context.Database.GetDbConnection();
+ var shouldClose = connection.State == ConnectionState.Closed;
+
+ if (shouldClose)
+ {
+ connection.Open();
+ }
+
+ try
+ {
+ var columnExists = false;
+
+ using (var command = connection.CreateCommand())
+ {
+ command.CommandText = $"PRAGMA table_info({tableName});";
+
+ using var reader = command.ExecuteReader();
+ while (reader.Read())
+ {
+ if (string.Equals(
+ reader.GetString(1),
+ columnName,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ columnExists = true;
+ break;
+ }
+ }
+ }
+
+ if (!columnExists)
+ {
+ using var alterCommand = connection.CreateCommand();
+ alterCommand.CommandText =
+ $"ALTER TABLE {tableName} ADD COLUMN {columnName} {columnDefinition};";
+ alterCommand.ExecuteNonQuery();
+ }
+ }
+ finally
+ {
+ if (shouldClose)
+ {
+ connection.Close();
+ }
+ }
+ }
+ #endregion
@@ -577,4 +714,4 @@ private static void SeedProducts(ApplicationDbContext context)
}
- #endregion
\ No newline at end of file
+#endregion
\ No newline at end of file
diff --git a/Migrations/20260420000100_AddProductLocationCheckFields.cs b/Migrations/20260420000100_AddProductLocationCheckFields.cs
new file mode 100644
index 0000000..eee4cca
--- /dev/null
+++ b/Migrations/20260420000100_AddProductLocationCheckFields.cs
@@ -0,0 +1,63 @@
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using PuppetFestAPP.Web.Data;
+
+#nullable disable
+
+namespace PuppetFestAPP.Web.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260420000100_AddProductLocationCheckFields")]
+ public partial class AddProductLocationCheckFields : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "IsBoxChecked",
+ table: "ProductLocations",
+ type: "INTEGER",
+ nullable: false,
+ defaultValue: false);
+
+ migrationBuilder.AddColumn(
+ name: "BoxCheckedAt",
+ table: "ProductLocations",
+ type: "TEXT",
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "IsDeliveryChecked",
+ table: "ProductLocations",
+ type: "INTEGER",
+ nullable: false,
+ defaultValue: false);
+
+ migrationBuilder.AddColumn(
+ name: "DeliveryCheckedAt",
+ table: "ProductLocations",
+ type: "TEXT",
+ nullable: true);
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "IsBoxChecked",
+ table: "ProductLocations");
+
+ migrationBuilder.DropColumn(
+ name: "BoxCheckedAt",
+ table: "ProductLocations");
+
+ migrationBuilder.DropColumn(
+ name: "IsDeliveryChecked",
+ table: "ProductLocations");
+
+ migrationBuilder.DropColumn(
+ name: "DeliveryCheckedAt",
+ table: "ProductLocations");
+ }
+ }
+}
diff --git a/Migrations/ApplicationDbContextModelSnapshot.cs b/Migrations/ApplicationDbContextModelSnapshot.cs
index ba55d50..050671e 100644
--- a/Migrations/ApplicationDbContextModelSnapshot.cs
+++ b/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -18,245 +18,258 @@ protected override void BuildModel(ModelBuilder modelBuilder)
modelBuilder.HasAnnotation("ProductVersion", "9.0.9");
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
- {
- b.Property("Id")
- .HasColumnType("TEXT");
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
- b.Property("ConcurrencyStamp")
- .IsConcurrencyToken()
- .HasColumnType("TEXT");
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
- b.Property("Name")
- .HasMaxLength(256)
- .HasColumnType("TEXT");
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
- b.Property("NormalizedName")
- .HasMaxLength(256)
- .HasColumnType("TEXT");
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
- b.HasKey("Id");
+ b.HasKey("Id");
- b.HasIndex("NormalizedName")
- .IsUnique()
- .HasDatabaseName("RoleNameIndex");
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex");
- b.ToTable("AspNetRoles", (string)null);
- });
+ b.ToTable("AspNetRoles", (string)null);
+ });
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
- {
- b.Property("Id")
- .ValueGeneratedOnAdd()
- .HasColumnType("INTEGER");
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
- b.Property("ClaimType")
- .HasColumnType("TEXT");
+ b.Property("ClaimType")
+ .HasColumnType("TEXT");
- b.Property("ClaimValue")
- .HasColumnType("TEXT");
+ b.Property("ClaimValue")
+ .HasColumnType("TEXT");
- b.Property("RoleId")
- .IsRequired()
- .HasColumnType("TEXT");
+ b.Property("RoleId")
+ .IsRequired()
+ .HasColumnType("TEXT");
- b.HasKey("Id");
+ b.HasKey("Id");
- b.HasIndex("RoleId");
+ b.HasIndex("RoleId");
- b.ToTable("AspNetRoleClaims", (string)null);
- });
+ b.ToTable("AspNetRoleClaims", (string)null);
+ });
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
- {
- b.Property("Id")
- .ValueGeneratedOnAdd()
- .HasColumnType("INTEGER");
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
- b.Property("ClaimType")
- .HasColumnType("TEXT");
+ b.Property("ClaimType")
+ .HasColumnType("TEXT");
- b.Property("ClaimValue")
- .HasColumnType("TEXT");
+ b.Property("ClaimValue")
+ .HasColumnType("TEXT");
- b.Property("UserId")
- .IsRequired()
- .HasColumnType("TEXT");
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("TEXT");
- b.HasKey("Id");
+ b.HasKey("Id");
- b.HasIndex("UserId");
+ b.HasIndex("UserId");
- b.ToTable("AspNetUserClaims", (string)null);
- });
+ b.ToTable("AspNetUserClaims", (string)null);
+ });
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
- {
- b.Property("LoginProvider")
- .HasColumnType("TEXT");
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("TEXT");
- b.Property("ProviderKey")
- .HasColumnType("TEXT");
+ b.Property("ProviderKey")
+ .HasColumnType("TEXT");
- b.Property("ProviderDisplayName")
- .HasColumnType("TEXT");
+ b.Property("ProviderDisplayName")
+ .HasColumnType("TEXT");
- b.Property("UserId")
- .IsRequired()
- .HasColumnType("TEXT");
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("TEXT");
- b.HasKey("LoginProvider", "ProviderKey");
+ b.HasKey("LoginProvider", "ProviderKey");
- b.HasIndex("UserId");
+ b.HasIndex("UserId");
- b.ToTable("AspNetUserLogins", (string)null);
- });
+ b.ToTable("AspNetUserLogins", (string)null);
+ });
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
- {
- b.Property("UserId")
- .HasColumnType("TEXT");
+ {
+ b.Property("UserId")
+ .HasColumnType("TEXT");
- b.Property("RoleId")
- .HasColumnType("TEXT");
+ b.Property("RoleId")
+ .HasColumnType("TEXT");
- b.HasKey("UserId", "RoleId");
+ b.HasKey("UserId", "RoleId");
- b.HasIndex("RoleId");
+ b.HasIndex("RoleId");
- b.ToTable("AspNetUserRoles", (string)null);
- });
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
- {
- b.Property("UserId")
- .HasColumnType("TEXT");
+ {
+ b.Property("UserId")
+ .HasColumnType("TEXT");
- b.Property("LoginProvider")
- .HasColumnType("TEXT");
+ b.Property("LoginProvider")
+ .HasColumnType("TEXT");
- b.Property("Name")
- .HasColumnType("TEXT");
+ b.Property("Name")
+ .HasColumnType("TEXT");
- b.Property("Value")
- .HasColumnType("TEXT");
+ b.Property("Value")
+ .HasColumnType("TEXT");
- b.HasKey("UserId", "LoginProvider", "Name");
+ b.HasKey("UserId", "LoginProvider", "Name");
- b.ToTable("AspNetUserTokens", (string)null);
- });
+ b.ToTable("AspNetUserTokens", (string)null);
+ });
modelBuilder.Entity("PuppetFestAPP.Web.Data.ApplicationUser", b =>
- {
- b.Property("Id")
- .HasColumnType("TEXT");
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
- b.Property("AccessFailedCount")
- .HasColumnType("INTEGER");
+ b.Property("AccessFailedCount")
+ .HasColumnType("INTEGER");
- b.Property("ConcurrencyStamp")
- .IsConcurrencyToken()
- .HasColumnType("TEXT");
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
- b.Property("Email")
- .HasMaxLength(256)
- .HasColumnType("TEXT");
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
- b.Property("EmailConfirmed")
- .HasColumnType("INTEGER");
+ b.Property("EmailConfirmed")
+ .HasColumnType("INTEGER");
- b.Property("LockoutEnabled")
- .HasColumnType("INTEGER");
+ b.Property("LockoutEnabled")
+ .HasColumnType("INTEGER");
- b.Property("LockoutEnd")
- .HasColumnType("TEXT");
+ b.Property("LockoutEnd")
+ .HasColumnType("TEXT");
- b.Property("NormalizedEmail")
- .HasMaxLength(256)
- .HasColumnType("TEXT");
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
- b.Property("NormalizedUserName")
- .HasMaxLength(256)
- .HasColumnType("TEXT");
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
- b.Property("PasswordHash")
- .HasColumnType("TEXT");
+ b.Property("PasswordHash")
+ .HasColumnType("TEXT");
- b.Property("PhoneNumber")
- .HasColumnType("TEXT");
+ b.Property("PhoneNumber")
+ .HasColumnType("TEXT");
- b.Property("PhoneNumberConfirmed")
- .HasColumnType("INTEGER");
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("INTEGER");
- b.Property("SecurityStamp")
- .HasColumnType("TEXT");
+ b.Property("SecurityStamp")
+ .HasColumnType("TEXT");
- b.Property("TwoFactorEnabled")
- .HasColumnType("INTEGER");
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("INTEGER");
- b.Property("UserName")
- .HasMaxLength(256)
- .HasColumnType("TEXT");
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
- b.HasKey("Id");
+ b.HasKey("Id");
- b.HasIndex("NormalizedEmail")
- .HasDatabaseName("EmailIndex");
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
- b.HasIndex("NormalizedUserName")
- .IsUnique()
- .HasDatabaseName("UserNameIndex");
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex");
- b.ToTable("AspNetUsers", (string)null);
- });
+ b.ToTable("AspNetUsers", (string)null);
+ });
modelBuilder.Entity("PuppetFestAPP.Web.Data.Category", b =>
- {
- b.Property("Id")
- .ValueGeneratedOnAdd()
- .HasColumnType("INTEGER");
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
- b.Property("Name")
- .IsRequired()
- .HasMaxLength(50)
- .HasColumnType("TEXT");
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
- b.HasKey("Id");
+ b.HasKey("Id");
- b.ToTable("Categories");
- });
+ b.ToTable("Categories");
+ });
modelBuilder.Entity("PuppetFestAPP.Web.Data.Inventory", b =>
- {
- b.Property("Id")
- .ValueGeneratedOnAdd()
- .HasColumnType("INTEGER");
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
- b.Property("LastUpdated")
- .HasColumnType("TEXT");
+ b.Property("LastUpdated")
+ .HasColumnType("TEXT");
- b.Property("Location")
- .HasMaxLength(50)
- .HasColumnType("TEXT");
+ b.Property("Location")
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
- b.Property("ProductId")
- .HasColumnType("INTEGER");
+ b.Property("ProductId")
+ .HasColumnType("INTEGER");
- b.Property("Quantity")
- .HasColumnType("INTEGER");
+ b.Property("Quantity")
+ .HasColumnType("INTEGER");
- b.HasKey("Id");
+ b.HasKey("Id");
- b.HasIndex("ProductId");
+ b.HasIndex("ProductId");
- b.ToTable("Inventories");
- });
+ b.ToTable("Inventories");
+ });
modelBuilder.Entity("PuppetFestAPP.Web.Data.Location", b =>
- {
- b.Property("Id")
- .ValueGeneratedOnAdd()
- .HasColumnType("INTEGER");
-
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+<<<<<<< HEAD
+ b.Property("Address")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+=======
b.Property("Address")
.IsRequired()
.HasMaxLength(200)
@@ -274,94 +287,115 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasColumnType("INTEGER");
b.HasKey("Id");
+>>>>>>> main
- b.ToTable("Locations");
- });
+ b.ToTable("Locations");
+ });
modelBuilder.Entity("PuppetFestAPP.Web.Data.Product", b =>
- {
- b.Property("Id")
- .ValueGeneratedOnAdd()
- .HasColumnType("INTEGER");
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
- b.Property("CategoryId")
- .HasColumnType("INTEGER");
+ b.Property("CategoryId")
+ .HasColumnType("INTEGER");
- b.Property("Color")
- .HasColumnType("INTEGER");
+ b.Property("Color")
+ .HasColumnType("INTEGER");
- b.Property("DateAdded")
- .HasColumnType("TEXT");
+ b.Property("DateAdded")
+ .HasColumnType("TEXT");
- b.Property("Description")
- .HasMaxLength(500)
- .HasColumnType("TEXT");
+ b.Property("Description")
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
- b.Property("ImageId")
- .HasColumnType("INTEGER");
+ b.Property("ImageId")
+ .HasColumnType("INTEGER");
- b.Property("IsActive")
- .ValueGeneratedOnAdd()
- .HasColumnType("INTEGER")
- .HasDefaultValue(true);
+ b.Property("IsActive")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(true);
- b.Property("Material")
- .HasMaxLength(100)
- .HasColumnType("TEXT");
+ b.Property("Material")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
- b.Property("Name")
- .IsRequired()
- .HasMaxLength(100)
- .HasColumnType("TEXT");
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
- b.Property("ParentProductId")
- .HasColumnType("INTEGER");
+ b.Property("ParentProductId")
+ .HasColumnType("INTEGER");
- b.Property("Price")
- .HasPrecision(10, 2)
- .HasColumnType("decimal(10,2)");
+ b.Property("Price")
+ .HasPrecision(10, 2)
+ .HasColumnType("decimal(10,2)");
- b.Property("Size")
- .HasColumnType("INTEGER");
+ b.Property("Size")
+ .HasColumnType("INTEGER");
- b.HasKey("Id");
+ b.HasKey("Id");
- b.HasIndex("CategoryId");
+ b.HasIndex("CategoryId");
- b.HasIndex("ImageId");
+ b.HasIndex("ImageId");
- b.HasIndex("ParentProductId");
+ b.HasIndex("ParentProductId");
- b.ToTable("Products");
- });
+ b.ToTable("Products");
+ });
modelBuilder.Entity("PuppetFestAPP.Web.Data.ProductLocation", b =>
- {
- b.Property("Id")
- .ValueGeneratedOnAdd()
- .HasColumnType("INTEGER");
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
- b.Property("LastUpdated")
- .HasColumnType("TEXT");
+ b.Property("BoxCheckedAt")
+ .HasColumnType("TEXT");
- b.Property("LocationId")
- .HasColumnType("INTEGER");
+ b.Property("DeliveryCheckedAt")
+ .HasColumnType("TEXT");
- b.Property("ProductId")
- .HasColumnType("INTEGER");
+ b.Property("IsBoxChecked")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(false);
- b.Property("Quantity")
- .HasColumnType("INTEGER");
+ b.Property("IsDeliveryChecked")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(false);
- b.HasKey("Id");
+ b.Property("LastUpdated")
+ .HasColumnType("TEXT");
- b.HasIndex("LocationId");
+ b.Property("LocationId")
+ .HasColumnType("INTEGER");
+<<<<<<< HEAD
+ b.Property("ProductId")
+ .HasColumnType("INTEGER");
+=======
b.HasIndex("ProductId", "LocationId")
.IsUnique();
+>>>>>>> main
- b.ToTable("ProductLocations");
- });
+ b.Property("Quantity")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("LocationId");
+
+ b.HasIndex("ProductId");
+
+ b.ToTable("ProductLocations");
+ });
modelBuilder.Entity("PuppetFestAPP.Web.Data.StockTransferBox", b =>
{
@@ -419,129 +453,129 @@ protected override void BuildModel(ModelBuilder modelBuilder)
});
modelBuilder.Entity("PuppetFestAPP.Web.Models.Image", b =>
- {
- b.Property("Id")
- .ValueGeneratedOnAdd()
- .HasColumnType("INTEGER");
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
- b.Property("AltText")
- .HasColumnType("TEXT");
+ b.Property("AltText")
+ .HasColumnType("TEXT");
- b.Property("FileName")
- .IsRequired()
- .HasColumnType("TEXT");
+ b.Property("FileName")
+ .IsRequired()
+ .HasColumnType("TEXT");
- b.Property("UploadDate")
- .HasColumnType("TEXT");
+ b.Property("UploadDate")
+ .HasColumnType("TEXT");
- b.HasKey("Id");
+ b.HasKey("Id");
- b.ToTable("Images");
- });
+ b.ToTable("Images");
+ });
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
- {
- b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
- .WithMany()
- .HasForeignKey("RoleId")
- .OnDelete(DeleteBehavior.Cascade)
- .IsRequired();
- });
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
- {
- b.HasOne("PuppetFestAPP.Web.Data.ApplicationUser", null)
- .WithMany()
- .HasForeignKey("UserId")
- .OnDelete(DeleteBehavior.Cascade)
- .IsRequired();
- });
+ {
+ b.HasOne("PuppetFestAPP.Web.Data.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
- {
- b.HasOne("PuppetFestAPP.Web.Data.ApplicationUser", null)
- .WithMany()
- .HasForeignKey("UserId")
- .OnDelete(DeleteBehavior.Cascade)
- .IsRequired();
- });
+ {
+ b.HasOne("PuppetFestAPP.Web.Data.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
- {
- b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
- .WithMany()
- .HasForeignKey("RoleId")
- .OnDelete(DeleteBehavior.Cascade)
- .IsRequired();
-
- b.HasOne("PuppetFestAPP.Web.Data.ApplicationUser", null)
- .WithMany()
- .HasForeignKey("UserId")
- .OnDelete(DeleteBehavior.Cascade)
- .IsRequired();
- });
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("PuppetFestAPP.Web.Data.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
- {
- b.HasOne("PuppetFestAPP.Web.Data.ApplicationUser", null)
- .WithMany()
- .HasForeignKey("UserId")
- .OnDelete(DeleteBehavior.Cascade)
- .IsRequired();
- });
+ {
+ b.HasOne("PuppetFestAPP.Web.Data.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
modelBuilder.Entity("PuppetFestAPP.Web.Data.Inventory", b =>
- {
- b.HasOne("PuppetFestAPP.Web.Data.Product", "Product")
- .WithMany("Inventories")
- .HasForeignKey("ProductId")
- .OnDelete(DeleteBehavior.Cascade)
- .IsRequired();
+ {
+ b.HasOne("PuppetFestAPP.Web.Data.Product", "Product")
+ .WithMany("Inventories")
+ .HasForeignKey("ProductId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
- b.Navigation("Product");
- });
+ b.Navigation("Product");
+ });
modelBuilder.Entity("PuppetFestAPP.Web.Data.Product", b =>
- {
- b.HasOne("PuppetFestAPP.Web.Data.Category", "Category")
- .WithMany("Products")
- .HasForeignKey("CategoryId")
- .OnDelete(DeleteBehavior.Cascade)
- .IsRequired();
+ {
+ b.HasOne("PuppetFestAPP.Web.Data.Category", "Category")
+ .WithMany("Products")
+ .HasForeignKey("CategoryId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
- b.HasOne("PuppetFestAPP.Web.Models.Image", "Image")
- .WithMany("Products")
- .HasForeignKey("ImageId");
+ b.HasOne("PuppetFestAPP.Web.Models.Image", "Image")
+ .WithMany("Products")
+ .HasForeignKey("ImageId");
- b.HasOne("PuppetFestAPP.Web.Data.Product", "ParentProduct")
- .WithMany("Variants")
- .HasForeignKey("ParentProductId");
+ b.HasOne("PuppetFestAPP.Web.Data.Product", "ParentProduct")
+ .WithMany("Variants")
+ .HasForeignKey("ParentProductId");
- b.Navigation("Category");
+ b.Navigation("Category");
- b.Navigation("Image");
+ b.Navigation("Image");
- b.Navigation("ParentProduct");
- });
+ b.Navigation("ParentProduct");
+ });
modelBuilder.Entity("PuppetFestAPP.Web.Data.ProductLocation", b =>
- {
- b.HasOne("PuppetFestAPP.Web.Data.Location", "Location")
- .WithMany("ProductLocations")
- .HasForeignKey("LocationId")
- .OnDelete(DeleteBehavior.Cascade)
- .IsRequired();
+ {
+ b.HasOne("PuppetFestAPP.Web.Data.Location", "Location")
+ .WithMany("ProductLocations")
+ .HasForeignKey("LocationId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
- b.HasOne("PuppetFestAPP.Web.Data.Product", "Product")
- .WithMany("ProductLocations")
- .HasForeignKey("ProductId")
- .OnDelete(DeleteBehavior.Cascade)
- .IsRequired();
+ b.HasOne("PuppetFestAPP.Web.Data.Product", "Product")
+ .WithMany("ProductLocations")
+ .HasForeignKey("ProductId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
- b.Navigation("Location");
+ b.Navigation("Location");
- b.Navigation("Product");
- });
+ b.Navigation("Product");
+ });
modelBuilder.Entity("PuppetFestAPP.Web.Data.StockTransferBox", b =>
{
@@ -582,23 +616,23 @@ protected override void BuildModel(ModelBuilder modelBuilder)
});
modelBuilder.Entity("PuppetFestAPP.Web.Data.Category", b =>
- {
- b.Navigation("Products");
- });
+ {
+ b.Navigation("Products");
+ });
modelBuilder.Entity("PuppetFestAPP.Web.Data.Location", b =>
- {
- b.Navigation("ProductLocations");
- });
+ {
+ b.Navigation("ProductLocations");
+ });
modelBuilder.Entity("PuppetFestAPP.Web.Data.Product", b =>
- {
- b.Navigation("Inventories");
+ {
+ b.Navigation("Inventories");
- b.Navigation("ProductLocations");
+ b.Navigation("ProductLocations");
- b.Navigation("Variants");
- });
+ b.Navigation("Variants");
+ });
modelBuilder.Entity("PuppetFestAPP.Web.Data.StockTransferBox", b =>
{
@@ -606,9 +640,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
});
modelBuilder.Entity("PuppetFestAPP.Web.Models.Image", b =>
- {
- b.Navigation("Products");
- });
+ {
+ b.Navigation("Products");
+ });
#pragma warning restore 612, 618
}
}
diff --git a/Program.cs b/Program.cs
index e8a2416..0033ed3 100644
--- a/Program.cs
+++ b/Program.cs
@@ -18,12 +18,32 @@
builder.Services.AddScoped();
builder.Services.AddAuthentication(options =>
- {
- options.DefaultScheme = IdentityConstants.ApplicationScheme;
- options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
- })
+{
+ options.DefaultScheme = IdentityConstants.ApplicationScheme;
+ options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
+})
.AddIdentityCookies();
+// Role-based authorization policies. These keep page access rules and
+// navigation visibility aligned with the Admin > SM > FOH > Driver hierarchy.
+builder.Services.AddAuthorization(options =>
+{
+ options.AddPolicy(AppPolicies.CanViewProducts, policy =>
+ policy.RequireRole(AppRoles.ViewProductRoles));
+
+ options.AddPolicy(AppPolicies.CanEditProducts, policy =>
+ policy.RequireRole(AppRoles.EditProductRoles));
+
+ options.AddPolicy(AppPolicies.CanEnterSales, policy =>
+ policy.RequireRole(AppRoles.SalesInputRoles));
+
+ options.AddPolicy(AppPolicies.CanManageDeliveryChecks, policy =>
+ policy.RequireRole(AppRoles.DeliveryCheckRoles));
+
+ options.AddPolicy(AppPolicies.CanManageUsers, policy =>
+ policy.RequireRole(AppRoles.UserManagementRoles));
+});
+
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
builder.Services.AddDbContextFactory(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")
diff --git a/README.md b/README.md
index 9c5e3e1..00de0d8 100644
--- a/README.md
+++ b/README.md
@@ -35,6 +35,25 @@ You can see the live branch here: [[https://backstagechicagopuppetfest-cff3bgbuh
* Managed Azure deployment
* Oversaw branch management
* Logan (she/her)
+<<<<<<< HEAD
+* * Display Seed Data
+ * Sales page
+
+## Role-based access rules
+
+This project uses the following application roles:
+
+| Feature | Admin | SM | FOH | Driver |
+| --- | --- | --- | --- | --- |
+| View products, stock, locations, and check status | Yes | Yes | Yes | Yes |
+| Box / delivery checks | Yes | Yes | Yes | Yes |
+| Sales input | Yes | Yes | Yes | No |
+| Product create/edit/delete buttons | Yes | Yes | No | No |
+| Direct product create/edit/delete page access | Yes | Yes | No | No |
+| User management / storage admin | Yes | No | No | No |
+
+The permission hierarchy is `Admin > SM > FOH > Driver`. Driver has the smallest permission set; there are no Driver-only features.
+=======
* Displayed the data
* Developed Sales Page (Version 1)
* Built Product, Location, and Catalog pages
@@ -44,3 +63,4 @@ You can see the live branch here: [[https://backstagechicagopuppetfest-cff3bgbuh
* Resolved merge conflicts across branches
* Mia (She/Her)
* Implemented email authentication system
+>>>>>>> main
diff --git a/Security/AppPolicies.cs b/Security/AppPolicies.cs
new file mode 100644
index 0000000..bae10ca
--- /dev/null
+++ b/Security/AppPolicies.cs
@@ -0,0 +1,10 @@
+namespace PuppetFestApp.web.Security;
+
+public static class AppPolicies
+{
+ public const string CanViewOperations = "CanViewOperations";
+ public const string CanEnterSales = "CanEnterSales";
+ public const string CanManageDeliveryChecks = "CanManageDeliveryChecks";
+ public const string CanEditProducts = "CanEditProducts";
+ public const string CanManageUsers = "CanManageUsers";
+}
\ No newline at end of file
diff --git a/Security/AppRoles.cs b/Security/AppRoles.cs
new file mode 100644
index 0000000..abd4fe0
--- /dev/null
+++ b/Security/AppRoles.cs
@@ -0,0 +1,18 @@
+namespace PuppetFestApp.web.Security;
+
+public static class AppRoles
+{
+ public const string Admin = "Admin";
+ public const string SM = "SM";
+ public const string FOH = "FOH";
+ public const string Driver = "Driver"
+
+ public static readonly string[] All = [Admin, SM, FOH, Driver];
+ public static readonly string[] EditRoles = [Admin, SM];
+ public static readonly string[] Sales Roles = [Admin, SM, FOH];
+ public static readonly string[] DeliveryCheckRoles= [Admin, SM, FOH, Driver];
+ public static readonly string[] UserManagementRoles = [Admin];
+
+ public static bool IsKnownRole(string? role) => All.Contains(role);
+
+}
\ No newline at end of file