unitStatusHandler = async delegate (UnitStatusEvent message)
diff --git a/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs b/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs
index 63b15c49..c728e0ba 100644
--- a/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs
+++ b/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
@@ -1670,7 +1670,7 @@ public static void AddRecordClaims(ClaimsIdentity identity, bool isAdmin, List
+ /// Readiness Pro: Stripe USD 150/month, product prod_VDtkPNAa2qNBx3.
+ /// No base PlanId: the add-on has an independent monthly interval. Paddle EUR 195/month
+ /// is configured in PaymentProviderConfig.PaddleReadinessProAddon (product
+ /// pro_01m20xwmzpnkxzp7mm7nwwxp7p). Test IDs are deliberately unset.
+ ///
+ [Migration(190)]
+ public class M0190_SeedReadinessProAddon : Migration
+ {
+ private const string ReadinessProAddonId = "8a82f517-13db-4950-a514-d990248a67e6";
+
+ public override void Up()
+ {
+ Execute.Sql(
+ "IF NOT EXISTS (SELECT 1 FROM [PlanAddons] WHERE [PlanAddonId] = '" + ReadinessProAddonId + "' OR [AddonType] = 3) " +
+ "INSERT INTO [PlanAddons] ([PlanAddonId], [AddonType], [Cost], [ExternalId], [TestExternalId]) " +
+ "VALUES ('" + ReadinessProAddonId + "', 3, 150, 'price_0UDRwaqJFDZJcnkVnYP8bAcd', '');");
+ }
+
+ public override void Down()
+ {
+ // Do not remove an operator-owned or potentially billed row on rollback. Up preserves
+ // pre-existing Readiness Pro catalogs and safely tolerates reapplication.
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0191_AddChecklistWorkflow.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0191_AddChecklistWorkflow.cs
new file mode 100644
index 00000000..dbf1abe3
--- /dev/null
+++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0191_AddChecklistWorkflow.cs
@@ -0,0 +1,94 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.Migrations.Migrations
+{
+ [Migration(191)]
+ public class M0191_AddChecklistWorkflow : Migration
+ {
+ private static string N(string value) => value;
+ public override void Up()
+ {
+ foreach (var name in new[] { "ChecklistDefinitions", "ChecklistDefinitionVersions", "ChecklistOccurrences", "ChecklistCompletions", "ChecklistCompletionItems", "ChecklistCompletionFiles", "DepartmentChecklistSettings" })
+ {
+ if (Schema.Table(N(name)).Exists()) continue;
+ var table = Create.Table(N(name))
+ .WithColumn(N("Id")).AsString(36).PrimaryKey()
+ .WithColumn(N("DepartmentId")).AsInt32().NotNullable()
+ .WithColumn(N("ParentId")).AsString(36).Nullable()
+ .WithColumn(N("Content")).AsString(int.MaxValue).Nullable()
+ .WithColumn(N("Revision")).AsInt32().NotNullable().WithDefaultValue(1)
+ .WithColumn(N("CreatedOn")).AsDateTime2().NotNullable()
+ .WithColumn(N("UpdatedOn")).AsDateTime2().NotNullable()
+ .WithColumn(N("CreatedBy")).AsString(128).NotNullable()
+ .WithColumn(N("IsProtected")).AsBoolean().NotNullable().WithDefaultValue(false);
+ switch (name)
+ {
+ case "ChecklistDefinitions":
+ table.WithColumn(N("CurrentVersionId")).AsString(36).Nullable()
+ .WithColumn(N("PublishedVersion")).AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn(N("Retired")).AsBoolean().NotNullable().WithDefaultValue(false)
+ .WithColumn(N("DeletedOn")).AsDateTime2().Nullable(); break;
+ case "ChecklistDefinitionVersions": table.WithColumn(N("Version")).AsInt32().NotNullable(); break;
+ case "ChecklistOccurrences":
+ table.WithColumn(N("VersionId")).AsString(36).NotNullable()
+ .WithColumn(N("CompletionId")).AsString(36).NotNullable()
+ .WithColumn(N("TargetType")).AsInt32().NotNullable()
+ .WithColumn(N("TargetId")).AsString(128).NotNullable()
+ .WithColumn(N("State")).AsInt32().NotNullable(); break;
+ case "ChecklistCompletions":
+ table.WithColumn(N("VersionId")).AsString(36).NotNullable()
+ .WithColumn(N("OccurrenceId")).AsString(36).NotNullable()
+ .WithColumn(N("TargetType")).AsInt32().NotNullable()
+ .WithColumn(N("TargetId")).AsString(128).NotNullable()
+ .WithColumn(N("State")).AsInt32().NotNullable()
+ .WithColumn(N("SubmittedOn")).AsDateTime2().Nullable()
+ .WithColumn(N("WitnessUserId")).AsString(128).Nullable()
+ .WithColumn(N("TargetGroupId")).AsInt32().Nullable()
+ .WithColumn(N("WitnessedOn")).AsDateTime2().Nullable()
+ .WithColumn(N("Score")).AsDecimal(7,2).Nullable()
+ .WithColumn(N("Passed")).AsBoolean().NotNullable()
+ .WithColumn(N("SubmissionHash")).AsString(64).Nullable(); break;
+ case "ChecklistCompletionItems": table.WithColumn(N("ItemId")).AsString(36).NotNullable().WithColumn(N("IsFailure")).AsBoolean().NotNullable(); break;
+ case "ChecklistCompletionFiles":
+ table.WithColumn(N("ItemId")).AsString(36).NotNullable()
+ .WithColumn(N("ContentType")).AsString(100).NotNullable()
+ .WithColumn(N("Size")).AsInt32().NotNullable()
+ .WithColumn(N("Sha256")).AsString(64).NotNullable()
+ .WithColumn(N("Data")).AsBinary(int.MaxValue).NotNullable()
+ .WithColumn(N("ScanState")).AsInt32().NotNullable(); break;
+ }
+ Create.Index(N("UX_" + name + "_TenantId")).OnTable(N(name)).OnColumn(N("DepartmentId")).Ascending().OnColumn(N("Id")).Ascending().WithOptions().Unique();
+ Create.Index(N("IX_" + name + "_ParentDate")).OnTable(N(name)).OnColumn(N("DepartmentId")).Ascending().OnColumn(N("ParentId")).Ascending().OnColumn(N("CreatedOn")).Descending();
+ }
+ Unique("ChecklistDefinitionVersions", "Version", "ParentId", "Version");
+ Unique("ChecklistCompletions", "Occurrence", "OccurrenceId");
+ Unique("ChecklistOccurrences", "Completion", "CompletionId");
+ Unique("ChecklistCompletionItems", "Item", "ParentId", "ItemId");
+ Unique("DepartmentChecklistSettings", "Department");
+ foreach (var child in new[] { "ChecklistDefinitionVersions", "ChecklistOccurrences", "ChecklistCompletions" }) Foreign(child, "ParentId", "ChecklistDefinitions");
+ foreach (var child in new[] { "ChecklistOccurrences", "ChecklistCompletions" }) Foreign(child, "VersionId", "ChecklistDefinitionVersions");
+ Foreign("ChecklistCompletions", "OccurrenceId", "ChecklistOccurrences");
+ Foreign("ChecklistCompletionItems", "ParentId", "ChecklistCompletions");
+ Foreign("ChecklistCompletionFiles", "ParentId", "ChecklistCompletions");
+ }
+ private void Unique(string table, string suffix, params string[] columns)
+ {
+ var name = N("UX_" + table + "_" + suffix);
+ if (Schema.Table(N(table)).Index(name).Exists()) return;
+ var index = Create.Index(name).OnTable(N(table)).OnColumn(N("DepartmentId")).Ascending();
+ foreach (var column in columns) index = index.OnColumn(N(column)).Ascending();
+ index.WithOptions().Unique();
+ }
+ private void Foreign(string child, string column, string parent)
+ {
+ var name = N("FK_" + child + "_" + column);
+ if (!Schema.Table(N(child)).Constraint(name).Exists())
+ Create.ForeignKey(name).FromTable(N(child)).ForeignColumns(N("DepartmentId"), N(column)).ToTable(N(parent)).PrimaryColumns(N("DepartmentId"), N("Id"));
+ }
+ public override void Down()
+ {
+ foreach (var name in new[] { "DepartmentChecklistSettings", "ChecklistCompletionFiles", "ChecklistCompletionItems", "ChecklistCompletions", "ChecklistOccurrences", "ChecklistDefinitionVersions", "ChecklistDefinitions" })
+ if (Schema.Table(N(name)).Exists()) Delete.Table(N(name));
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0189_SeedReadinessFeatureFlagsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0189_SeedReadinessFeatureFlagsPg.cs
new file mode 100644
index 00000000..9dfb956b
--- /dev/null
+++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0189_SeedReadinessFeatureFlagsPg.cs
@@ -0,0 +1,19 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.MigrationsPg.Migrations
+{
+ [Migration(189)]
+ public class M0189_SeedReadinessFeatureFlagsPg : Migration
+ {
+ public override void Up()
+ {
+ Execute.Sql("INSERT INTO featureflags (flagkey, name, description, category, isenabledglobally) SELECT 'Checklists.System', 'Checklists', 'Free checklists for all departments. Independent of paid plans and Readiness Pro. Seeded off.', 'Readiness', false WHERE NOT EXISTS (SELECT 1 FROM featureflags WHERE flagkey = 'Checklists.System');");
+ Execute.Sql("INSERT INTO featureflags (flagkey, name, description, category, isenabledglobally) SELECT 'Maintenance.WorkOrders', 'Readiness Pro', 'Maintenance and work orders rollout gate. Requires a separate active monthly Readiness Pro entitlement. Independent of Checklists.System. Seeded off.', 'Readiness', false WHERE NOT EXISTS (SELECT 1 FROM featureflags WHERE flagkey = 'Maintenance.WorkOrders');");
+ }
+
+ public override void Down()
+ {
+ // Preserve operator-owned settings, as in the SQL Server twin.
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0190_SeedReadinessProAddonPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0190_SeedReadinessProAddonPg.cs
new file mode 100644
index 00000000..80acb189
--- /dev/null
+++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0190_SeedReadinessProAddonPg.cs
@@ -0,0 +1,29 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.MigrationsPg.Migrations
+{
+ ///
+ /// Readiness Pro: Stripe USD 150/month, product prod_VDtkPNAa2qNBx3.
+ /// No base PlanId: the add-on has an independent monthly interval. Paddle EUR 195/month
+ /// is configured in PaymentProviderConfig.PaddleReadinessProAddon (product
+ /// pro_01m20xwmzpnkxzp7mm7nwwxp7p). Test IDs are deliberately unset.
+ ///
+ [Migration(190)]
+ public class M0190_SeedReadinessProAddonPg : Migration
+ {
+ private const string ReadinessProAddonId = "8a82f517-13db-4950-a514-d990248a67e6";
+
+ public override void Up()
+ {
+ Execute.Sql(
+ "INSERT INTO planaddons (planaddonid, addontype, cost, externalid, testexternalid) " +
+ "SELECT '" + ReadinessProAddonId + "', 3, 150, 'price_0UDRwaqJFDZJcnkVnYP8bAcd', '' " +
+ "WHERE NOT EXISTS (SELECT 1 FROM planaddons WHERE planaddonid = '" + ReadinessProAddonId + "' OR addontype = 3);");
+ }
+
+ public override void Down()
+ {
+ // Preserve the catalog and any billed references, as in the SQL Server twin.
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0191_AddChecklistWorkflowPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0191_AddChecklistWorkflowPg.cs
new file mode 100644
index 00000000..9ac08bdf
--- /dev/null
+++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0191_AddChecklistWorkflowPg.cs
@@ -0,0 +1,94 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.MigrationsPg.Migrations
+{
+ [Migration(191)]
+ public class M0191_AddChecklistWorkflowPg : Migration
+ {
+ private static string N(string value) => value.ToLowerInvariant();
+ public override void Up()
+ {
+ foreach (var name in new[] { "ChecklistDefinitions", "ChecklistDefinitionVersions", "ChecklistOccurrences", "ChecklistCompletions", "ChecklistCompletionItems", "ChecklistCompletionFiles", "DepartmentChecklistSettings" })
+ {
+ if (Schema.Table(N(name)).Exists()) continue;
+ var table = Create.Table(N(name))
+ .WithColumn(N("Id")).AsString(36).PrimaryKey()
+ .WithColumn(N("DepartmentId")).AsInt32().NotNullable()
+ .WithColumn(N("ParentId")).AsString(36).Nullable()
+ .WithColumn(N("Content")).AsString(int.MaxValue).Nullable()
+ .WithColumn(N("Revision")).AsInt32().NotNullable().WithDefaultValue(1)
+ .WithColumn(N("CreatedOn")).AsDateTime2().NotNullable()
+ .WithColumn(N("UpdatedOn")).AsDateTime2().NotNullable()
+ .WithColumn(N("CreatedBy")).AsString(128).NotNullable()
+ .WithColumn(N("IsProtected")).AsBoolean().NotNullable().WithDefaultValue(false);
+ switch (name)
+ {
+ case "ChecklistDefinitions":
+ table.WithColumn(N("CurrentVersionId")).AsString(36).Nullable()
+ .WithColumn(N("PublishedVersion")).AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn(N("Retired")).AsBoolean().NotNullable().WithDefaultValue(false)
+ .WithColumn(N("DeletedOn")).AsDateTime2().Nullable(); break;
+ case "ChecklistDefinitionVersions": table.WithColumn(N("Version")).AsInt32().NotNullable(); break;
+ case "ChecklistOccurrences":
+ table.WithColumn(N("VersionId")).AsString(36).NotNullable()
+ .WithColumn(N("CompletionId")).AsString(36).NotNullable()
+ .WithColumn(N("TargetType")).AsInt32().NotNullable()
+ .WithColumn(N("TargetId")).AsString(128).NotNullable()
+ .WithColumn(N("State")).AsInt32().NotNullable(); break;
+ case "ChecklistCompletions":
+ table.WithColumn(N("VersionId")).AsString(36).NotNullable()
+ .WithColumn(N("OccurrenceId")).AsString(36).NotNullable()
+ .WithColumn(N("TargetType")).AsInt32().NotNullable()
+ .WithColumn(N("TargetId")).AsString(128).NotNullable()
+ .WithColumn(N("State")).AsInt32().NotNullable()
+ .WithColumn(N("SubmittedOn")).AsDateTime2().Nullable()
+ .WithColumn(N("WitnessUserId")).AsString(128).Nullable()
+ .WithColumn(N("TargetGroupId")).AsInt32().Nullable()
+ .WithColumn(N("WitnessedOn")).AsDateTime2().Nullable()
+ .WithColumn(N("Score")).AsDecimal(7,2).Nullable()
+ .WithColumn(N("Passed")).AsBoolean().NotNullable()
+ .WithColumn(N("SubmissionHash")).AsString(64).Nullable(); break;
+ case "ChecklistCompletionItems": table.WithColumn(N("ItemId")).AsString(36).NotNullable().WithColumn(N("IsFailure")).AsBoolean().NotNullable(); break;
+ case "ChecklistCompletionFiles":
+ table.WithColumn(N("ItemId")).AsString(36).NotNullable()
+ .WithColumn(N("ContentType")).AsString(100).NotNullable()
+ .WithColumn(N("Size")).AsInt32().NotNullable()
+ .WithColumn(N("Sha256")).AsString(64).NotNullable()
+ .WithColumn(N("Data")).AsBinary(int.MaxValue).NotNullable()
+ .WithColumn(N("ScanState")).AsInt32().NotNullable(); break;
+ }
+ Create.Index(N("UX_" + name + "_TenantId")).OnTable(N(name)).OnColumn(N("DepartmentId")).Ascending().OnColumn(N("Id")).Ascending().WithOptions().Unique();
+ Create.Index(N("IX_" + name + "_ParentDate")).OnTable(N(name)).OnColumn(N("DepartmentId")).Ascending().OnColumn(N("ParentId")).Ascending().OnColumn(N("CreatedOn")).Descending();
+ }
+ Unique("ChecklistDefinitionVersions", "Version", "ParentId", "Version");
+ Unique("ChecklistCompletions", "Occurrence", "OccurrenceId");
+ Unique("ChecklistOccurrences", "Completion", "CompletionId");
+ Unique("ChecklistCompletionItems", "Item", "ParentId", "ItemId");
+ Unique("DepartmentChecklistSettings", "Department");
+ foreach (var child in new[] { "ChecklistDefinitionVersions", "ChecklistOccurrences", "ChecklistCompletions" }) Foreign(child, "ParentId", "ChecklistDefinitions");
+ foreach (var child in new[] { "ChecklistOccurrences", "ChecklistCompletions" }) Foreign(child, "VersionId", "ChecklistDefinitionVersions");
+ Foreign("ChecklistCompletions", "OccurrenceId", "ChecklistOccurrences");
+ Foreign("ChecklistCompletionItems", "ParentId", "ChecklistCompletions");
+ Foreign("ChecklistCompletionFiles", "ParentId", "ChecklistCompletions");
+ }
+ private void Unique(string table, string suffix, params string[] columns)
+ {
+ var name = N("UX_" + table + "_" + suffix);
+ if (Schema.Table(N(table)).Index(name).Exists()) return;
+ var index = Create.Index(name).OnTable(N(table)).OnColumn(N("DepartmentId")).Ascending();
+ foreach (var column in columns) index = index.OnColumn(N(column)).Ascending();
+ index.WithOptions().Unique();
+ }
+ private void Foreign(string child, string column, string parent)
+ {
+ var name = N("FK_" + child + "_" + column);
+ if (!Schema.Table(N(child)).Constraint(name).Exists())
+ Create.ForeignKey(name).FromTable(N(child)).ForeignColumns(N("DepartmentId"), N(column)).ToTable(N(parent)).PrimaryColumns(N("DepartmentId"), N("Id"));
+ }
+ public override void Down()
+ {
+ foreach (var name in new[] { "DepartmentChecklistSettings", "ChecklistCompletionFiles", "ChecklistCompletionItems", "ChecklistCompletions", "ChecklistOccurrences", "ChecklistDefinitionVersions", "ChecklistDefinitions" })
+ if (Schema.Table(N(name)).Exists()) Delete.Table(N(name));
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/ChecklistRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/ChecklistRepository.cs
new file mode 100644
index 00000000..6d12c643
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/ChecklistRepository.cs
@@ -0,0 +1,54 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations.Schema;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Model.Checklists;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Repositories.Connection;
+using Resgrid.Model.Repositories.Queries;
+using Resgrid.Repositories.DataRepository.Configs;
+
+namespace Resgrid.Repositories.DataRepository
+{
+ /// Only the closed checklist table map can form identifiers. Every read/write is tenant scoped.
+ public class ChecklistRepository : RmsRepositoryBase, IChecklistRepository
+ {
+ public ChecklistRepository(IConnectionProvider connection, SqlConfiguration config, IUnitOfWork uow, IQueryFactory queries) : base(connection, config, uow, queries) { }
+ public Task LockDepartmentAsync(int departmentId, CancellationToken ct = default) => LockRecordsDepartmentAsync(departmentId, ct);
+ private static string Table() where T : ChecklistRow => ChecklistTables.All[typeof(T)];
+ private static string[] Columns(bool includeData = true) where T : ChecklistRow => typeof(T).GetProperties()
+ .Where(p => p.CanWrite && !Attribute.IsDefined(p, typeof(NotMappedAttribute)) && (includeData || p.Name != "Data")).Select(p => p.Name).ToArray();
+ public Task GetAsync(int departmentId, string id, CancellationToken ct = default) where T : ChecklistRow =>
+ QueryFirstOrDefaultAsync($"SELECT {Cols(Columns())} FROM {Tbl(Table())} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("Id")}={P}Id", new { DepartmentId = departmentId, Id = id }, ct);
+ public async Task> ListAsync(int departmentId, string parentId = null, int skip = 0, int take = 100, CancellationToken ct = default) where T : ChecklistRow
+ {
+ if (skip < 0 || take < 1 || take > 500) throw new ArgumentOutOfRangeException(nameof(take));
+ var parent = parentId == null ? "" : $" AND {Col("ParentId")}={P}ParentId";
+ return (await QueryAsync($"SELECT {Cols(Columns(false))} FROM {Tbl(Table())} WHERE {Col("DepartmentId")}={P}DepartmentId{parent} ORDER BY {Col("CreatedOn")} DESC, {Col("Id")} {Paging()}", new { DepartmentId = departmentId, ParentId = parentId, Skip = skip, Take = take }, ct)).ToList();
+ }
+ public async Task WriteAsync(T row, bool insert, CancellationToken ct = default) where T : ChecklistRow
+ {
+ if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Checklist writes require a transaction.");
+ if (!insert && typeof(T) == typeof(ChecklistDefinitionVersion)) throw new InvalidOperationException("Published versions are immutable.");
+ var columns = Columns();
+ var sql = insert ? $"INSERT INTO {Tbl(Table())} ({Cols(columns)}) VALUES ({string.Join(",", columns.Select(c => P + c))})"
+ : $"UPDATE {Tbl(Table())} SET {string.Join(",", columns.Where(c => c != "Id" && c != "DepartmentId").Select(c => Col(c) + "=" + P + c))} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("Id")}={P}Id";
+ if (await ExecuteAsync(sql, row, ct) != 1) throw new InvalidOperationException("Checklist row could not be saved.");
+ }
+ public async Task ReplaceAnswersAsync(int departmentId, string completionId, IEnumerable items, CancellationToken ct = default)
+ {
+ if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Checklist writes require a transaction.");
+ await ExecuteAsync($"DELETE FROM {Tbl("ChecklistCompletionItems")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("ParentId")}={P}Id", new { DepartmentId = departmentId, Id = completionId }, ct);
+ foreach (var item in items)
+ {
+ if (item.DepartmentId != departmentId || item.ParentId != completionId) throw new InvalidOperationException("Answer ownership mismatch.");
+ await WriteAsync(item, true, ct);
+ }
+ }
+ public Task DeleteFileAsync(int departmentId, string id, CancellationToken ct = default) => ExecuteAsync($"DELETE FROM {Tbl("ChecklistCompletionFiles")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("Id")}={P}Id", new { DepartmentId = departmentId, Id = id }, ct);
+ public Task GetFileMetadataAsync(int departmentId, string id) => QueryFirstOrDefaultAsync(
+ $"SELECT {Cols(Columns(false))} FROM {Tbl("ChecklistCompletionFiles")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("Id")}={P}Id", new { DepartmentId = departmentId, Id = id });
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs
index 901917e0..57c71e2b 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Configuration;
using System.Data;
using Microsoft.Data.SqlClient;
@@ -31,7 +31,17 @@ public async Task DeleteDepartmentAndUsersAsync(int departmentId)
using (var transaction = db.BeginTransaction())
{
var result = await db.ExecuteAsync(@"
- DECLARE @UserId NVARCHAR(128)
+ IF OBJECT_ID('dbo.ChecklistDefinitions', 'U') IS NOT NULL
+ BEGIN
+ DELETE FROM [dbo].[ChecklistCompletionFiles] WHERE DepartmentId = @DepartmentId
+ DELETE FROM [dbo].[ChecklistCompletionItems] WHERE DepartmentId = @DepartmentId
+ DELETE FROM [dbo].[ChecklistCompletions] WHERE DepartmentId = @DepartmentId
+ DELETE FROM [dbo].[ChecklistOccurrences] WHERE DepartmentId = @DepartmentId
+ DELETE FROM [dbo].[ChecklistDefinitionVersions] WHERE DepartmentId = @DepartmentId
+ DELETE FROM [dbo].[ChecklistDefinitions] WHERE DepartmentId = @DepartmentId
+ DELETE FROM [dbo].[DepartmentChecklistSettings] WHERE DepartmentId = @DepartmentId
+ END
+ DECLARE @UserId NVARCHAR(128)
DECLARE @UnitId INT
DECLARE @ManagingUserId NVARCHAR(128)
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs
index 57103be5..99226f54 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs
@@ -1,4 +1,4 @@
-using Autofac;
+using Autofac;
using Resgrid.Model.Repositories;
using Resgrid.Model.Repositories.Connection;
using Resgrid.Model.Repositories.Queries;
@@ -15,6 +15,7 @@ public class ApiDataModule : Module
{
protected override void Load(ContainerBuilder builder)
{
+ builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs
index 96b77629..043fb84c 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs
@@ -1,4 +1,4 @@
-using Autofac;
+using Autofac;
using Resgrid.Model.Repositories;
using Resgrid.Model.Repositories.Connection;
using Resgrid.Model.Repositories.Queries;
@@ -15,6 +15,7 @@ public class DataModule : Module
{
protected override void Load(ContainerBuilder builder)
{
+ builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
if (Config.DataConfig.DatabaseType == Config.DatabaseTypes.Postgres)
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs
index d7dfdbee..b71d900e 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs
@@ -1,4 +1,4 @@
-using Autofac;
+using Autofac;
using Resgrid.Model.Repositories;
using Resgrid.Model.Repositories.Connection;
using Resgrid.Model.Repositories.Queries;
@@ -15,6 +15,7 @@ public class NonWebDataModule : Module
{
protected override void Load(ContainerBuilder builder)
{
+ builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
if (Config.DataConfig.DatabaseType == Config.DatabaseTypes.Postgres)
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs
index 6a09fef1..4fdcabe2 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs
@@ -15,6 +15,7 @@ public class TestingDataModule : Module
{
protected override void Load(ContainerBuilder builder)
{
+ builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
if (Config.DataConfig.DatabaseType == Config.DatabaseTypes.Postgres)
diff --git a/Tests/Resgrid.Tests/Allocations/IdentifierAllocationTests.cs b/Tests/Resgrid.Tests/Allocations/IdentifierAllocationTests.cs
index 44d83a09..3a7d94ae 100644
--- a/Tests/Resgrid.Tests/Allocations/IdentifierAllocationTests.cs
+++ b/Tests/Resgrid.Tests/Allocations/IdentifierAllocationTests.cs
@@ -22,6 +22,8 @@ public class IdentifierAllocationTests
#region Registry test 1 — no duplicate values in append-only enums
[TestCase(typeof(PermissionTypes))]
+ [TestCase(typeof(EventingTypes))]
+ [TestCase(typeof(PlanAddonTypes))]
[TestCase(typeof(WorkflowTriggerEventType))]
[TestCase(typeof(DepartmentSettingTypes))]
[TestCase(typeof(Resgrid.Model.Events.EventTypes))]
diff --git a/Tests/Resgrid.Tests/Rms/RecordsAnalyticsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsAnalyticsServiceTests.cs
index b29fc92c..3c8b399d 100644
--- a/Tests/Resgrid.Tests/Rms/RecordsAnalyticsServiceTests.cs
+++ b/Tests/Resgrid.Tests/Rms/RecordsAnalyticsServiceTests.cs
@@ -254,6 +254,14 @@ public async Task Gate_flag_viewer_window_clamp_and_row_cap_apply_to_every_dashb
(wide.End - wide.Start).TotalDays.Should().Be(RecordsAnalyticsLimits.MaxWindowDays);
wide.Warnings.Should().ContainSingle(w => w.Contains("clamped"));
+ // The due-state read is capped on its own, so the overdue figure says it was cut short in its own words.
+ _dueStates.Setup(d => d.GetLastEmittedInRangeAsync(Dept, Start, End, It.IsAny())).ReturnsAsync((int dept, DateTime s, DateTime e, int take) =>
+ Enumerable.Range(0, Math.Min(take, RecordsAnalyticsLimits.RowCap + 1))
+ .Select(i => new RmsRecordDueState { RecordId = "d" + i, OverdueCount = 1, LastEmittedState = (int)RmsDueState.Overdue, LastEmittedOn = T0 }).ToList());
+ var cappedDue = await _svc.GetExecutiveSummaryAsync(Dept, Admin, Q());
+ cappedDue.WentOverdue.Should().Be(RecordsAnalyticsLimits.RowCap);
+ cappedDue.Warnings.Should().ContainSingle(w => w.Contains("due-state changes"));
+
for (var i = 0; i < RecordsAnalyticsLimits.RowCap + 1; i++) _records.Add(new RmsOperationalRecord { RmsOperationalRecordId = "x" + i, DepartmentId = Dept, DefinitionKey = RmsDefinitionKeys.Run, RecordType = 1, State = (int)RmsRecordState.Finalized, CurrentRevisionId = "rx" + i, StartedOn = T0, CreatedOn = T0, FinalizedOn = T0 });
var capped = await _svc.GetWorkloadAsync(Dept, Member, Q());
capped.Truncated.Should().BeTrue();
diff --git a/Tests/Resgrid.Tests/Services/ChecklistAuthorizationTests.cs b/Tests/Resgrid.Tests/Services/ChecklistAuthorizationTests.cs
new file mode 100644
index 00000000..4800ced4
--- /dev/null
+++ b/Tests/Resgrid.Tests/Services/ChecklistAuthorizationTests.cs
@@ -0,0 +1,95 @@
+using System;
+using System.Collections.Generic;
+using System.Security.Claims;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Moq;
+using NUnit.Framework;
+using Resgrid.Model;
+using Resgrid.Model.Checklists;
+using Resgrid.Model.Services;
+using Resgrid.Providers.Claims;
+using Resgrid.Services;
+
+namespace Resgrid.Tests.Services
+{
+ [TestFixture]
+ public class ChecklistAuthorizationTests
+ {
+ private ChecklistAuthorizationService _service;
+ private Mock _departments;
+ private Mock _groups;
+ private Mock _permissions;
+ private Mock _units;
+ private DepartmentMember _member;
+ private readonly ChecklistActor _actor = new ChecklistActor { DepartmentId = 77, UserId = "member" };
+ [SetUp]
+ public void Setup()
+ {
+ _member = new DepartmentMember { DepartmentId = 77, UserId = "member" };
+ _departments = new Mock(); _groups = new Mock(); _permissions = new Mock(); _units = new Mock();
+ _departments.Setup(d => d.GetDepartmentMemberAsync("member", 77, true)).ReturnsAsync(() => _member);
+ _departments.Setup(d => d.GetDepartmentByIdAsync(77, true)).ReturnsAsync(new Department { DepartmentId = 77, ManagingUserId = "owner", Name = "Department" });
+ _groups.Setup(g => g.GetGroupForUserAsync("member", 77)).ReturnsAsync(new DepartmentGroup { DepartmentId = 77, DepartmentGroupId = 10, Members = new List