From 887c3d787a7a788b86ab9aa0869f8705f5a339903f17b895dd96af6f8beb234e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= <93217193+Daniel-Garmig@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:04:38 +0200 Subject: [PATCH] GuestDocument Versions - Part 1: - Changed GuestDocument PK from `DocumentType`+`DocumentNumber` to to `ID`+`Version` for future versioning. - Added DB field "IsLastVersion" to simplify queries where only last/current version is allowed. - Updated GuestDocument DB fields type for `CreateTime` & `UpdateTime` to add precision. - Updated usages of GuestDocument PK to new PK. - Limit delete of a GuestDocument when it's currently used in a StayEntry. Note: DB changes require recreating db tables, so all data is lost and migration "rollback" is not allowed. --- .../Controls/GuestDocumentSearchDialog.razor | 5 +- .../Components/Forms/StayEntryForm.razor | 9 +- .../Pages/GuestDocuments/GuestDocDetail.razor | 25 +-- .../Pages/GuestDocuments/GuestDocList.razor | 10 +- .../Pages/GuestDocuments/GuestDocNew.razor | 12 +- .../Pages/StayEntries/StayEntryList.razor | 2 +- .../Enums/Services/ResultCode.cs | 4 + src/Somnia.GuestLog/GuestConsts.cs | 4 +- .../GuestDocumentEntityConfig.cs | 17 +- ...ocument_ChangePKToIdAndVersion.Designer.cs | 205 ++++++++++++++++++ ...43_GuestDocument_ChangePKToIdAndVersion.cs | 101 +++++++++ .../GuestLogContextModelSnapshot.cs | 49 +++-- .../Models/DTOs/GuestDocFilterDTO.cs | 2 + .../Models/DTOs/GuestDocumentDTO.cs | 6 +- .../Models/Entities/GuestDocument.cs | 7 +- src/Somnia.GuestLog/Models/EntityMapping.cs | 2 + .../Resources/LanguageLocale.resx | 5 +- .../Entity/GuestDocumentEntityService.cs | 101 +++++++-- .../Entity/IGuestDocumentEntityService.cs | 20 +- .../Entity/IStayEntryEntityService.cs | 5 +- .../Services/Entity/StayEntryEntityService.cs | 27 ++- 21 files changed, 525 insertions(+), 93 deletions(-) create mode 100644 src/Somnia.GuestLog/Infrastructure/Migrations/20260830201743_GuestDocument_ChangePKToIdAndVersion.Designer.cs create mode 100644 src/Somnia.GuestLog/Infrastructure/Migrations/20260830201743_GuestDocument_ChangePKToIdAndVersion.cs diff --git a/src/Somnia.GuestLog/Components/Controls/GuestDocumentSearchDialog.razor b/src/Somnia.GuestLog/Components/Controls/GuestDocumentSearchDialog.razor index b795a99..f6c9f46 100644 --- a/src/Somnia.GuestLog/Components/Controls/GuestDocumentSearchDialog.razor +++ b/src/Somnia.GuestLog/Components/Controls/GuestDocumentSearchDialog.razor @@ -78,7 +78,10 @@ private IMudDialogInstance MudDialog { get; set; } = null!; - private GuestDocFilterDTO filter { get; set; } = new(); + private GuestDocFilterDTO filter { get; set; } = new() + { + OnlyLastVersion = true + }; private IEnumerable results = []; private GuestDocumentDTO? SelectedResult { get; set; } diff --git a/src/Somnia.GuestLog/Components/Forms/StayEntryForm.razor b/src/Somnia.GuestLog/Components/Forms/StayEntryForm.razor index 9be90fe..bf69078 100644 --- a/src/Somnia.GuestLog/Components/Forms/StayEntryForm.razor +++ b/src/Somnia.GuestLog/Components/Forms/StayEntryForm.razor @@ -95,7 +95,7 @@ { + OnClick="@(() => BtnDeleteGuest(context.Id!.Value, context.Version!.Value))" /> } @if (ReadOnly) @@ -229,8 +229,7 @@ //Check if this guest is already added. if (Model.GuestDocuments.Count > 0) { - bool alreadyAdded = Model.GuestDocuments.Any(e => e.DocumentType == resultData.DocumentType - && e.DocumentNum == resultData.DocumentNum); + bool alreadyAdded = Model.GuestDocuments.Any(e => e.Id == resultData.Id); if (alreadyAdded) { userMessage.ShowMessage("GuestDocument already added!", MessageKind.Warning); @@ -241,13 +240,13 @@ Model.GuestDocuments.Add(resultData); } - private void BtnDeleteGuest(string docType, string docNum) + private void BtnDeleteGuest(long guestDocId, int version) { Debug.Assert(Model.GuestDocuments is not null); //Filter out given GuestDocument from model. Model.GuestDocuments = Model.GuestDocuments - .Where(e => !(e.DocumentType == docType && e.DocumentNum == docNum)) + .Where(e => !(e.Id == guestDocId && e.Version == version)) .ToList(); } diff --git a/src/Somnia.GuestLog/Components/Pages/GuestDocuments/GuestDocDetail.razor b/src/Somnia.GuestLog/Components/Pages/GuestDocuments/GuestDocDetail.razor index e09d9eb..1b769d8 100644 --- a/src/Somnia.GuestLog/Components/Pages/GuestDocuments/GuestDocDetail.razor +++ b/src/Somnia.GuestLog/Components/Pages/GuestDocuments/GuestDocDetail.razor @@ -14,7 +14,7 @@ Somnia - Guest Docs - Details - Guest Document - "@docId" + Guest Document: @docId - @documentModel?.FullDocument @@ -77,7 +77,7 @@ @code { [Parameter] - public string? docId { get; set; } + public long docId { get; set; } GuestDocForm _form = null!; @@ -90,23 +90,8 @@ protected override async Task OnInitializedAsync() { - if (docId is null) - { - userMessage.ShowMessage("No GuestDocument provided!!", MessageKind.Error); - Navigation.NotFound(); - return; - } - - GuestDocIdDTO? docIdModel = GuestDocIdDTO.DecodeFromString(docId); - if (docIdModel is null) - { - userMessage.ShowMessage($"Provided GuestDocId can't be decoded: '{docId}'", MessageKind.Error); - Navigation.NotFound(); - return; - } - //Get model from db. - documentModel = await guestDocService.GetDtoByIdAsync(docIdModel.DocumentType, docIdModel.DocumentNum); + documentModel = await guestDocService.GetLastVersionDtoByIdAsync(docId); if (documentModel is null) { userMessage.ShowMessage($"GuestDocument not found with ID: '{docId}'", MessageKind.Error); @@ -123,13 +108,13 @@ return; } - if (documentModel is null || documentModel.DocumentType is null || documentModel.DocumentNum is null) + if (documentModel is null || documentModel.Id is null || documentModel.Version is null) { throw new InvalidOperationException(); } guestStaysLoading = true; - guestStayList = await stayService.GetStaysWithGuest(documentModel.DocumentType, documentModel.DocumentNum); + guestStayList = await stayService.GetStaysWithGuest(documentModel.Id.Value); guestStaysLoading = false; } diff --git a/src/Somnia.GuestLog/Components/Pages/GuestDocuments/GuestDocList.razor b/src/Somnia.GuestLog/Components/Pages/GuestDocuments/GuestDocList.razor index 5ebd570..8af242d 100644 --- a/src/Somnia.GuestLog/Components/Pages/GuestDocuments/GuestDocList.razor +++ b/src/Somnia.GuestLog/Components/Pages/GuestDocuments/GuestDocList.razor @@ -43,6 +43,8 @@ + + @@ -50,6 +52,7 @@ Title="@LanguageLocale.dto_GuestDocument_DocumentNum" /> + @@ -126,10 +129,9 @@ private async Task BtnViewDetails(GuestDocumentDTO item) { - GuestDocIdDTO idDto = GuestDocIdDTO.BuildFromDto(item) ?? throw new InvalidOperationException(); + string strId = item.Id?.ToString() ?? throw new InvalidOperationException(); - string itemId = GuestDocIdDTO.EncodeAsString(idDto); - string uri = GuestConsts.Page_GuestDocs_Detail.Replace(GuestConsts.Page_GuestDocs_Detail_Param_DocId, itemId); + string uri = GuestConsts.Page_GuestDocs_Detail.Replace(GuestConsts.Page_GuestDocs_Detail_Param_DocId, strId); Navigation.NavigateTo(uri); } @@ -151,7 +153,7 @@ } userMessage.ShowMessage($"Document {item.DocumentType}-{item.DocumentNum} deleted!", MessageKind.Success); - await LoadDataAsync(); + await LoadDataAsync(showMsg: false); } private void BtnNewGuest() diff --git a/src/Somnia.GuestLog/Components/Pages/GuestDocuments/GuestDocNew.razor b/src/Somnia.GuestLog/Components/Pages/GuestDocuments/GuestDocNew.razor index 9f6c8f1..1f7bf65 100644 --- a/src/Somnia.GuestLog/Components/Pages/GuestDocuments/GuestDocNew.razor +++ b/src/Somnia.GuestLog/Components/Pages/GuestDocuments/GuestDocNew.razor @@ -82,6 +82,7 @@ ePageMode currentPageMode = ePageMode.selectDocId; GuestDocIdDTO currentDocId = new() { DocumentNum = "", DocumentType = "" }; + long? alreadyExistDocId = null; GuestDocumentDTO newDocumentModel = new(); bool loadingDocId = false; @@ -100,7 +101,8 @@ } loadingDocId = true; - GuestDocumentDTO? dto = await guestDocService.GetDtoByIdAsync(currentDocId.DocumentType, currentDocId.DocumentNum); + GuestDocumentDTO? dto = + await guestDocService.GetLastVersionDtoByDocumentAsync(currentDocId.DocumentType, currentDocId.DocumentNum); loadingDocId = false; if (dto is null) @@ -117,6 +119,7 @@ //GuestDoc already exists! currentPageMode = ePageMode.alreadyExistDoc; + alreadyExistDocId = dto.Id; } private void BtnReturn() @@ -153,14 +156,15 @@ private void BtnGoToDocument() { - if (currentPageMode != ePageMode.alreadyExistDoc) + if (currentPageMode != ePageMode.alreadyExistDoc || alreadyExistDocId is null) { userMessage.ShowMessage("Invalid operation!", MessageKind.Error); return; } - string docId = GuestDocIdDTO.EncodeAsString(currentDocId); - string uri = GuestConsts.Page_GuestDocs_Detail.Replace("{docId}", docId); + + string strId = alreadyExistDocId.Value.ToString(); + string uri = GuestConsts.Page_GuestDocs_Detail.Replace(GuestConsts.Page_GuestDocs_Detail_Param_DocId, strId); Navigation.NavigateTo(uri); } diff --git a/src/Somnia.GuestLog/Components/Pages/StayEntries/StayEntryList.razor b/src/Somnia.GuestLog/Components/Pages/StayEntries/StayEntryList.razor index 2b6e9b0..66b2b6d 100644 --- a/src/Somnia.GuestLog/Components/Pages/StayEntries/StayEntryList.razor +++ b/src/Somnia.GuestLog/Components/Pages/StayEntries/StayEntryList.razor @@ -117,7 +117,7 @@ private async Task BtnViewDetails(StayEntryDTO item) { - string itemId = $"{item.Id ?? -1}"; + string itemId = item.Id?.ToString() ?? throw new InvalidOperationException(); string uri = GuestConsts.Page_StayEntry_Detail.Replace(GuestConsts.Page_StayEntry_Detail_Param_Id, itemId); Navigation.NavigateTo(uri); } diff --git a/src/Somnia.GuestLog/Enums/Services/ResultCode.cs b/src/Somnia.GuestLog/Enums/Services/ResultCode.cs index e886421..2eb51f2 100644 --- a/src/Somnia.GuestLog/Enums/Services/ResultCode.cs +++ b/src/Somnia.GuestLog/Enums/Services/ResultCode.cs @@ -2,9 +2,13 @@ namespace Somnia.GuestLog.Enums.Services; public enum ResultCode { + //Common Codes Ok, UnknownError, FailedValidation, NotFound, + + //Specific Codes + GuestInStay, GuestCurrentlyInStay, } \ No newline at end of file diff --git a/src/Somnia.GuestLog/GuestConsts.cs b/src/Somnia.GuestLog/GuestConsts.cs index 4ea0342..a868187 100644 --- a/src/Somnia.GuestLog/GuestConsts.cs +++ b/src/Somnia.GuestLog/GuestConsts.cs @@ -6,7 +6,7 @@ public static class GuestConsts public const string Page_GuestDocs_List = "/guest-documents"; public const string Page_GuestDocs_New = "/guest-documents/new"; public const string Page_GuestDocs_Detail = $"/guest-documents/d/{Page_GuestDocs_Detail_Param_DocId}"; - public const string Page_GuestDocs_Detail_Param_DocId = "{docId}"; + public const string Page_GuestDocs_Detail_Param_DocId = "{docId:long}"; public const string Page_StayEntry_List = "/stayEntries"; public const string Page_StayEntry_New = "/stayEntries/new"; @@ -28,4 +28,6 @@ public static class GuestConsts public const string DocumentType_DNI = "DNI"; public const string DocumentType_NIE = "NIE"; public const string DocumentType_PASSPORT = "PASSPORT"; + + public const int GuestDocument_FirstVersion = 1; } \ No newline at end of file diff --git a/src/Somnia.GuestLog/Infrastructure/EntityConfigs/GuestDocumentEntityConfig.cs b/src/Somnia.GuestLog/Infrastructure/EntityConfigs/GuestDocumentEntityConfig.cs index 3628ac6..8f9905b 100644 --- a/src/Somnia.GuestLog/Infrastructure/EntityConfigs/GuestDocumentEntityConfig.cs +++ b/src/Somnia.GuestLog/Infrastructure/EntityConfigs/GuestDocumentEntityConfig.cs @@ -11,7 +11,13 @@ public class GuestDocumentEntityConfig : IEntityTypeConfiguration builder.ToTable("GuestDocument"); //PK - builder.HasKey(e => new { e.DocumentType, e.DocumentNum }); + builder.HasKey(e => new { e.Id, e.Version }); + + builder.Property(e => e.Id) + .UseIdentityByDefaultColumn(); + + builder.Property(e => e.Version) + .IsRequired(); builder.Property(e => e.DocumentType) .IsRequired() @@ -48,6 +54,15 @@ public class GuestDocumentEntityConfig : IEntityTypeConfiguration builder.Property(e => e.EmailAddress) .HasMaxLength(256); + builder.Property(e => e.CreateTime) + .IsRequired() + .HasColumnType(DbConsts.DbType_Timestamptz_3); + builder.Property(e => e.UpdateTime) + .IsRequired() + .HasColumnType(DbConsts.DbType_Timestamptz_3); + builder.Property(e => e.IsLastVersion) + .IsRequired(); + var addressBuilder = builder.ComplexProperty(e => e.AddressDetails); diff --git a/src/Somnia.GuestLog/Infrastructure/Migrations/20260830201743_GuestDocument_ChangePKToIdAndVersion.Designer.cs b/src/Somnia.GuestLog/Infrastructure/Migrations/20260830201743_GuestDocument_ChangePKToIdAndVersion.Designer.cs new file mode 100644 index 0000000..2f0a191 --- /dev/null +++ b/src/Somnia.GuestLog/Infrastructure/Migrations/20260830201743_GuestDocument_ChangePKToIdAndVersion.Designer.cs @@ -0,0 +1,205 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Somnia.GuestLog.Infrastructure; + +#nullable disable + +namespace Somnia.GuestLog.Infrastructure.Migrations +{ + [DbContext(typeof(GuestLogContext))] + [Migration("20260830201743_GuestDocument_ChangePKToIdAndVersion")] + partial class GuestDocument_ChangePKToIdAndVersion + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true) + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("GuestDocumentStayEntry", b => + { + b.Property("StayEntryId") + .HasColumnType("bigint"); + + b.Property("GuestDocumentsId") + .HasColumnType("bigint"); + + b.Property("GuestDocumentsVersion") + .HasColumnType("integer"); + + b.HasKey("StayEntryId", "GuestDocumentsId", "GuestDocumentsVersion"); + + b.HasIndex("GuestDocumentsId", "GuestDocumentsVersion"); + + b.ToTable("GuestDocumentStayEntry"); + }); + + modelBuilder.Entity("Somnia.GuestLog.Models.Entities.GuestDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Version") + .HasColumnType("integer"); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("CreateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DocumentNum") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("DocumentType") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("EmailAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Gender") + .HasMaxLength(1) + .HasColumnType("character varying(1)"); + + b.Property("IssueDate") + .HasColumnType("date"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Nationality") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("PhoneNumber") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SupportNum") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Surname") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Surname2") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdateTime") + .HasColumnType("timestamp with time zone"); + + b.ComplexProperty(typeof(Dictionary), "AddressDetails", "Somnia.GuestLog.Models.Entities.GuestDocument.AddressDetails#GuestAddress", b1 => + { + b1.Property("Address") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b1.Property("AddressCity") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b1.Property("AddressCountry") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b1.Property("AddressProvince") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b1.Property("AddressState") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b1.Property("ZipCode") + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + }); + + b.HasKey("Id", "Version"); + + b.ToTable("GuestDocument", (string)null); + }); + + modelBuilder.Entity("Somnia.GuestLog.Models.Entities.StayEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DateIn") + .HasColumnType("timestamp(0) with time zone"); + + b.Property("DateOut") + .HasColumnType("timestamp(0) with time zone"); + + b.Property("PaymentMethod") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("RoomCount") + .HasColumnType("integer"); + + b.Property("RoomNumber") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdateTime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("StayEntry", (string)null); + }); + + modelBuilder.Entity("GuestDocumentStayEntry", b => + { + b.HasOne("Somnia.GuestLog.Models.Entities.StayEntry", null) + .WithMany() + .HasForeignKey("StayEntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Somnia.GuestLog.Models.Entities.GuestDocument", null) + .WithMany() + .HasForeignKey("GuestDocumentsId", "GuestDocumentsVersion") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Somnia.GuestLog/Infrastructure/Migrations/20260830201743_GuestDocument_ChangePKToIdAndVersion.cs b/src/Somnia.GuestLog/Infrastructure/Migrations/20260830201743_GuestDocument_ChangePKToIdAndVersion.cs new file mode 100644 index 0000000..968b85d --- /dev/null +++ b/src/Somnia.GuestLog/Infrastructure/Migrations/20260830201743_GuestDocument_ChangePKToIdAndVersion.cs @@ -0,0 +1,101 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Somnia.GuestLog.Infrastructure.Migrations +{ + /// + public partial class GuestDocument_ChangePKToIdAndVersion : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + //Data don't really matter right now. Just get it working good. + //This migration will destry all data, but it's fine. + + migrationBuilder.DropTable( + name: "GuestDocumentStayEntry" + ); + + migrationBuilder.Sql("""DELETE FROM "StayEntry";"""); + + migrationBuilder.DropTable( + name: "GuestDocument" + ); + + migrationBuilder.CreateTable( + name: "GuestDocument", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Version = table.Column(type: "integer", nullable: false), + DocumentType = table.Column(type: "character varying(8)", maxLength: 8, nullable: false), + DocumentNum = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + IssueDate = table.Column(type: "date", nullable: false), + SupportNum = table.Column(type: "character varying(10)", maxLength: 10, nullable: true), + Nationality = table.Column(type: "character varying(3)", maxLength: 3, nullable: false), + Name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Surname = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Surname2 = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + BirthDate = table.Column(type: "date", nullable: false), + Gender = table.Column(type: "character varying(1)", maxLength: 1, nullable: true), + PhoneNumber = table.Column(type: "character varying(32)", maxLength: 32, nullable: true), + EmailAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + CreateTime = table.Column(type: "timestamp(3) with time zone", nullable: false), + UpdateTime = table.Column(type: "timestamp(3) with time zone", nullable: false), + IsLastVersion = table.Column(type: "boolean", nullable: false), + AddressDetails_Address = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + AddressDetails_AddressCity = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + AddressDetails_AddressCountry = table.Column(type: "character varying(3)", maxLength: 3, nullable: true), + AddressDetails_AddressProvince = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + AddressDetails_AddressState = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + AddressDetails_ZipCode = table.Column(type: "character varying(12)", maxLength: 12, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_GuestDocument", x => new { x.Id, x.Version }); + } + ); + + + //Existing index for "DocType" and "DocNum" can be useful, so let's keep it. + migrationBuilder.CreateTable( + name: "GuestDocumentStayEntry", + columns: table => new + { + StayEntryId = table.Column(type: "bigint", nullable: false), + GuestDocumentsId = table.Column(type: "bigint", nullable: false), + GuestDocumentsVersion = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GuestDocumentStayEntry", x => new { x.StayEntryId, x.GuestDocumentsId, x.GuestDocumentsVersion }); + table.ForeignKey( + name: "FK_GuestDocumentStayEntry_GuestDocument_GuestDocumentsId_Guest~", + columns: x => new { x.GuestDocumentsId, x.GuestDocumentsVersion }, + principalTable: "GuestDocument", + principalColumns: new[] { "Id", "Version" }, + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_GuestDocumentStayEntry_StayEntry_StayEntryId", + column: x => x.StayEntryId, + principalTable: "StayEntry", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_GuestDocumentStayEntry_GuestDocumentsId_GuestDocumentsVersi~", + table: "GuestDocumentStayEntry", + columns: new[] { "GuestDocumentsId", "GuestDocumentsVersion" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + throw new NotSupportedException("Rollback is not supported for this migration!"); + } + } +} diff --git a/src/Somnia.GuestLog/Infrastructure/Migrations/GuestLogContextModelSnapshot.cs b/src/Somnia.GuestLog/Infrastructure/Migrations/GuestLogContextModelSnapshot.cs index b99767e..e54c420 100644 --- a/src/Somnia.GuestLog/Infrastructure/Migrations/GuestLogContextModelSnapshot.cs +++ b/src/Somnia.GuestLog/Infrastructure/Migrations/GuestLogContextModelSnapshot.cs @@ -19,6 +19,9 @@ namespace Somnia.GuestLog.Infrastructure.Migrations #pragma warning disable 612, 618 modelBuilder .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true) .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -28,34 +31,45 @@ namespace Somnia.GuestLog.Infrastructure.Migrations b.Property("StayEntryId") .HasColumnType("bigint"); - b.Property("GuestDocumentsDocumentType") - .HasColumnType("character varying(8)"); + b.Property("GuestDocumentsId") + .HasColumnType("bigint"); - b.Property("GuestDocumentsDocumentNum") - .HasColumnType("character varying(64)"); + b.Property("GuestDocumentsVersion") + .HasColumnType("integer"); - b.HasKey("StayEntryId", "GuestDocumentsDocumentType", "GuestDocumentsDocumentNum"); + b.HasKey("StayEntryId", "GuestDocumentsId", "GuestDocumentsVersion"); - b.HasIndex("GuestDocumentsDocumentType", "GuestDocumentsDocumentNum"); + b.HasIndex("GuestDocumentsId", "GuestDocumentsVersion"); b.ToTable("GuestDocumentStayEntry"); }); modelBuilder.Entity("Somnia.GuestLog.Models.Entities.GuestDocument", b => { - b.Property("DocumentType") - .HasMaxLength(8) - .HasColumnType("character varying(8)"); + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); - b.Property("DocumentNum") - .HasMaxLength(64) - .HasColumnType("character varying(64)"); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Version") + .HasColumnType("integer"); b.Property("BirthDate") .HasColumnType("date"); b.Property("CreateTime") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp(3) with time zone"); + + b.Property("DocumentNum") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("DocumentType") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); b.Property("EmailAddress") .HasMaxLength(256) @@ -65,6 +79,9 @@ namespace Somnia.GuestLog.Infrastructure.Migrations .HasMaxLength(1) .HasColumnType("character varying(1)"); + b.Property("IsLastVersion") + .HasColumnType("boolean"); + b.Property("IssueDate") .HasColumnType("date"); @@ -96,7 +113,7 @@ namespace Somnia.GuestLog.Infrastructure.Migrations .HasColumnType("character varying(64)"); b.Property("UpdateTime") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp(3) with time zone"); b.ComplexProperty(typeof(Dictionary), "AddressDetails", "Somnia.GuestLog.Models.Entities.GuestDocument.AddressDetails#GuestAddress", b1 => { @@ -126,7 +143,7 @@ namespace Somnia.GuestLog.Infrastructure.Migrations .HasColumnType("character varying(12)"); }); - b.HasKey("DocumentType", "DocumentNum"); + b.HasKey("Id", "Version"); b.ToTable("GuestDocument", (string)null); }); @@ -178,7 +195,7 @@ namespace Somnia.GuestLog.Infrastructure.Migrations b.HasOne("Somnia.GuestLog.Models.Entities.GuestDocument", null) .WithMany() - .HasForeignKey("GuestDocumentsDocumentType", "GuestDocumentsDocumentNum") + .HasForeignKey("GuestDocumentsId", "GuestDocumentsVersion") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); diff --git a/src/Somnia.GuestLog/Models/DTOs/GuestDocFilterDTO.cs b/src/Somnia.GuestLog/Models/DTOs/GuestDocFilterDTO.cs index ff5a8ee..90824d0 100644 --- a/src/Somnia.GuestLog/Models/DTOs/GuestDocFilterDTO.cs +++ b/src/Somnia.GuestLog/Models/DTOs/GuestDocFilterDTO.cs @@ -10,6 +10,8 @@ public class GuestDocFilterDTO public string? PhoneNumber { get; set; } + public bool OnlyLastVersion { get; set; } + public bool IsEmpty => string.IsNullOrEmpty(DocumentType) && string.IsNullOrEmpty(DocumentNumber) diff --git a/src/Somnia.GuestLog/Models/DTOs/GuestDocumentDTO.cs b/src/Somnia.GuestLog/Models/DTOs/GuestDocumentDTO.cs index bfee270..e0e6230 100644 --- a/src/Somnia.GuestLog/Models/DTOs/GuestDocumentDTO.cs +++ b/src/Somnia.GuestLog/Models/DTOs/GuestDocumentDTO.cs @@ -2,6 +2,9 @@ namespace Somnia.GuestLog.Models.DTOs; public class GuestDocumentDTO { + public long? Id { get; set; } + public int? Version { get; set; } + public string? DocumentType { get; set; } public string? DocumentNum { get; set; } @@ -33,5 +36,6 @@ public class GuestDocumentDTO public DateTime? UpdateTime { get; set; } - public string FullKey => $"{DocumentType}-{DocumentNum}"; + public string FullKey => $"{Id}-{Version}"; + public string FullDocument => $"{DocumentType ?? "???"}-{DocumentNum ?? "???"}"; } \ No newline at end of file diff --git a/src/Somnia.GuestLog/Models/Entities/GuestDocument.cs b/src/Somnia.GuestLog/Models/Entities/GuestDocument.cs index 0080bb1..6e28df0 100644 --- a/src/Somnia.GuestLog/Models/Entities/GuestDocument.cs +++ b/src/Somnia.GuestLog/Models/Entities/GuestDocument.cs @@ -2,6 +2,9 @@ namespace Somnia.GuestLog.Models.Entities; public class GuestDocument : BaseEntity { + public long Id { get; set; } + public int Version { get; set; } + public required string DocumentType { get; set; } public required string DocumentNum { get; set; } @@ -26,6 +29,8 @@ public class GuestDocument : BaseEntity public required DateTime CreateTime { get; set; } public required DateTime UpdateTime { get; set; } + public required bool IsLastVersion { get; set; } - public override string GetFullKey() => $"{DocumentType}-{DocumentNum}"; + + public override string GetFullKey() => $"{Id}-{Version}"; } \ No newline at end of file diff --git a/src/Somnia.GuestLog/Models/EntityMapping.cs b/src/Somnia.GuestLog/Models/EntityMapping.cs index b7183c1..68467df 100644 --- a/src/Somnia.GuestLog/Models/EntityMapping.cs +++ b/src/Somnia.GuestLog/Models/EntityMapping.cs @@ -10,6 +10,8 @@ internal static class EntityMapping { return new() { + Id = entity.Id, + Version = entity.Version, DocumentType = entity.DocumentType, DocumentNum = entity.DocumentNum, IssueDate = entity.IssueDate.ToDateTime(TimeOnly.MinValue), diff --git a/src/Somnia.GuestLog/Resources/LanguageLocale.resx b/src/Somnia.GuestLog/Resources/LanguageLocale.resx index 064c0b7..378e084 100644 --- a/src/Somnia.GuestLog/Resources/LanguageLocale.resx +++ b/src/Somnia.GuestLog/Resources/LanguageLocale.resx @@ -122,6 +122,8 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + ID + Version Document Type Document Number Issue Date @@ -183,7 +185,8 @@ Failed entity validation Not found - Guest ('{0}') is already in one stay during the period: {1} + Guest (ID: '{0}') is in a stay. + Guest (ID: '{0}') is already in one stay during the period: {1} Guests diff --git a/src/Somnia.GuestLog/Services/Entity/GuestDocumentEntityService.cs b/src/Somnia.GuestLog/Services/Entity/GuestDocumentEntityService.cs index a2f818c..c55f9e3 100644 --- a/src/Somnia.GuestLog/Services/Entity/GuestDocumentEntityService.cs +++ b/src/Somnia.GuestLog/Services/Entity/GuestDocumentEntityService.cs @@ -12,7 +12,8 @@ namespace Somnia.GuestLog.Services.Entity; public class GuestDocumentEntityService( ILogger logger, IDbContextFactory contextFactory, - IValidator dtoValidator + IValidator dtoValidator, + IStayEntryEntityService stayEntityService ) : IGuestDocumentEntityService { public async Task CreateDocumentAsync(GuestDocumentDTO dto) @@ -40,6 +41,7 @@ public class GuestDocumentEntityService( DateTime createTime = DateTime.UtcNow; GuestDocument dbItem = new() { + Version = GuestConsts.GuestDocument_FirstVersion, DocumentType = dto.DocumentType!, DocumentNum = dto.DocumentNum!, IssueDate = DateOnly.FromDateTime(dto.IssueDate!.Value), @@ -55,6 +57,7 @@ public class GuestDocumentEntityService( AddressDetails = addressItem, CreateTime = createTime, UpdateTime = createTime, + IsLastVersion = true, //On creation, created entity is last version. }; try @@ -80,54 +83,93 @@ public class GuestDocumentEntityService( public Task DeleteDocumentAsync(GuestDocumentDTO dto) { - if (dto.DocumentType is null || dto.DocumentNum is null) + if (dto.Id is null) { - throw new InvalidOperationException("GuestDocumentService - DeleteDocument: A DTO without key can't be deleted!"); + throw new InvalidOperationException("GuestDocumentService - DeleteDocument: A DTO without ID can't be deleted!"); } - return DeleteDocumentAsync(dto.DocumentType, dto.DocumentNum); + return DeleteDocumentAsync(dto.Id.Value); } - public async Task DeleteDocumentAsync(string docType, string docNum) + public async Task DeleteDocumentAsync(long id) { - using var dbContext = contextFactory.CreateDbContext(); - GuestDocument? dbItem = await dbContext.GuestDocuments.FindAsync(docType, docNum); - if (dbItem is null) - { - logger.LogWarning("GuestDocumentService - DeleteDocument: Error deleting GuestDocument. Entity not found with key: '{key}-{key2}'", docType, docNum); - return Result.Fail(ResultCode.NotFound); - } - try { - dbContext.GuestDocuments.Remove(dbItem); - await dbContext.SaveChangesAsync(); + using var dbContext = contextFactory.CreateDbContext(); - logger.LogInformation("GuestDocumentService - DeleteDocument: GuestDocument deleted: '{id}'", dbItem.GetFullKey()); + //Check if GuestDocument is used in a Stay. + bool isUsed = await stayEntityService.IsGuestInStay(id); + if (isUsed) + { + logger.LogWarning("GuestDocumentService - DeleteDocument: Error deleting GuestDocument. Guest (ID: '{ID}') is in a Stay", id); + return Result.FailWithContext(ResultCode.GuestInStay, id); + } + + int deletedCount = await dbContext.GuestDocuments + .Where(e => e.Id == id) + .ExecuteDeleteAsync(); + + if (deletedCount == 0) + { + logger.LogWarning("GuestDocumentService - DeleteDocument: Error deleting GuestDocument. Entity not found with ID: '{ID}'", id); + return Result.Fail(ResultCode.NotFound); + } + + logger.LogInformation( + "GuestDocumentService - DeleteDocument: GuestDocument deleted with Id: '{id}'. Versions deleted: '{Count}'", + id, deletedCount); return Result.Ok(); } catch (Exception ex) { - logger.LogError(ex, "GuestDocumentService - CreateDocument: Error deleting GuestDocument: '{id}'", dbItem.GetFullKey()); + logger.LogError(ex, "GuestDocumentService - CreateDocument: Error deleting GuestDocument: '{id}'", id); return Result.FailUnknownError(); } } - - public async Task GetDtoByIdAsync(string docType, string docNum) + public async Task GetLastVersionDtoByIdAsync(long id) { using var dbContext = contextFactory.CreateDbContext(); - GuestDocument? dbItem = await dbContext.GuestDocuments.FindAsync(docType, docNum); + GuestDocument? dbItem = await dbContext.GuestDocuments + .Where(e => e.Id == id) + .OrderByDescending(e => e.Version) + .FirstOrDefaultAsync(); + return dbItem is not null ? GetDtoFromDb(dbItem) : null; } - public async Task> GetDtoListAsync() + public async Task GetLastVersionDtoByDocumentAsync(string docType, string docNum) { using var dbContext = contextFactory.CreateDbContext(); - var dbList = await dbContext.GuestDocuments - .OrderByDescending(e => e.UpdateTime) + GuestDocument? dbItem = await dbContext.GuestDocuments + .Where(e => e.DocumentType == docType && e.DocumentNum == docNum) + .OrderByDescending(e => e.Version) + .FirstOrDefaultAsync(); + + return dbItem is not null ? GetDtoFromDb(dbItem) : null; + } + + public async Task> GetDtoListAsync(bool onlyLastVersion) + { + using var dbContext = contextFactory.CreateDbContext(); + + // TEMP solution + var query = dbContext.GuestDocuments.AsQueryable(); + if (onlyLastVersion) + { + query = query.Where(e => e.IsLastVersion == true); + } + var dbList = await query.OrderByDescending(e => e.UpdateTime) .ToListAsync(); + // TODO: Waiting for EF Core Window function improvements: + // See expected translation: https://github.com/dotnet/efcore/issues/36380#issuecomment-3082788964 + // See window function status: https://github.com/dotnet/efcore/issues/12747 + // var dbList = await dbContext.GuestDocuments + // .GroupBy(e => e.Id) + // .Select(g => g.OrderByDescending(e => e.Version).First()) + // .ToListAsync(); + return dbList.Select(GetDtoFromDb); } @@ -154,8 +196,21 @@ public class GuestDocumentEntityService( query = query.WhereIf(!string.IsNullOrEmpty(filter.Surname), e => EF.Functions.Like(e.Surname, filter.Surname)); + // TEMP while waiting for EF Core Window function improvements + query = query.WhereIf(filter.OnlyLastVersion, + e => e.IsLastVersion == true); query = query.OrderByDescending(e => e.UpdateTime); + + // TODO: Waiting for EF Core Window function improvements: + // See: https://github.com/dotnet/efcore/issues/27470 + // See window function status: https://github.com/dotnet/efcore/issues/12747 + // For Ordering (or operations post-grouping), see: https://github.com/dotnet/efcore/pull/38687 + // query = query + // .OrderByDescending(e => e.UpdateTime) + // .GroupBy(e => e.Id) + // .Select(g => g.OrderByDescending(e => e.Version).First()); + if (limit > 0) { query = query.Take(limit); diff --git a/src/Somnia.GuestLog/Services/Entity/IGuestDocumentEntityService.cs b/src/Somnia.GuestLog/Services/Entity/IGuestDocumentEntityService.cs index 5eb2c17..3f21754 100644 --- a/src/Somnia.GuestLog/Services/Entity/IGuestDocumentEntityService.cs +++ b/src/Somnia.GuestLog/Services/Entity/IGuestDocumentEntityService.cs @@ -6,11 +6,23 @@ public interface IGuestDocumentEntityService { public Task CreateDocumentAsync(GuestDocumentDTO dto); public Task UpdateDocumentAsync(GuestDocumentDTO dto); - public Task DeleteDocumentAsync(GuestDocumentDTO dto); - public Task DeleteDocumentAsync(string docType, string docNum); - public Task GetDtoByIdAsync(string docType, string docNum); + /// + /// Same behavior as + /// + public Task DeleteDocumentAsync(GuestDocumentDTO dto); + + /// + /// Delete documents by ID. + /// This operation will delete all versions of a document. + /// + /// ID of the document to Delete + public Task DeleteDocumentAsync(long id); + + public Task GetLastVersionDtoByIdAsync(long id); + public Task GetLastVersionDtoByDocumentAsync(string docType, string docNum); + + public Task> GetDtoListAsync(bool onlyLastVersion); public Task> GetDtoListByFilterAsync(GuestDocFilterDTO filter, int limit = 0); - public Task> GetDtoListAsync(); } \ No newline at end of file diff --git a/src/Somnia.GuestLog/Services/Entity/IStayEntryEntityService.cs b/src/Somnia.GuestLog/Services/Entity/IStayEntryEntityService.cs index d503f47..62f672e 100644 --- a/src/Somnia.GuestLog/Services/Entity/IStayEntryEntityService.cs +++ b/src/Somnia.GuestLog/Services/Entity/IStayEntryEntityService.cs @@ -15,7 +15,8 @@ public interface IStayEntryEntityService public Task> GetDtoListByFilterAsync(StayEntryFilterDTO filter, int limit = 0, StayEntryQueryConfig? queryConfig = null); - public Task IsGuestInStayInPeriod(string docType, string docNum, DateTime startTime, DateTime endTime); - public Task> GetStaysWithGuest(string docType, string docNum, StayEntryQueryConfig? queryConfig = null); + public Task IsGuestInStay(long guestDocId); + public Task IsGuestInStayInPeriod(long guestDocId, DateTime startTime, DateTime endTime); + public Task> GetStaysWithGuest(long guestDocId, StayEntryQueryConfig? queryConfig = null); } \ No newline at end of file diff --git a/src/Somnia.GuestLog/Services/Entity/StayEntryEntityService.cs b/src/Somnia.GuestLog/Services/Entity/StayEntryEntityService.cs index a34dff5..c326176 100644 --- a/src/Somnia.GuestLog/Services/Entity/StayEntryEntityService.cs +++ b/src/Somnia.GuestLog/Services/Entity/StayEntryEntityService.cs @@ -46,12 +46,13 @@ public class StayEntryEntityService( //Check and add GuestDocuments foreach (GuestDocumentDTO guestDto in dto.GuestDocuments!) { - //Get it from db. - GuestDocument? dbGuest = await dbContext.GuestDocuments.FindAsync(guestDto.DocumentType, guestDto.DocumentNum) - ?? throw new InvalidOperationException($"GuestDocument not found! Id: '{guestDto.FullKey}'"); + //Get guest from db. + GuestDocument dbGuest = await dbContext.GuestDocuments + .Where(gd => gd.Id == guestDto.Id && gd.Version == guestDto.Version) + .SingleAsync(); //Check if Guest is already in other stay during the same period. - bool isInOtherStay = await this.IsGuestInStayInPeriod(dbGuest.DocumentType, dbGuest.DocumentNum, dbStay.DateIn, dbStay.DateOut); + bool isInOtherStay = await this.IsGuestInStayInPeriod(dbGuest.Id, dbStay.DateIn, dbStay.DateOut); if (isInOtherStay) { string period = $"{dbStay.DateIn}-{dbStay.DateOut}"; @@ -168,24 +169,34 @@ public class StayEntryEntityService( } - public async Task IsGuestInStayInPeriod(string docType, string docNum, DateTime startTime, DateTime endTime) + public async Task IsGuestInStay(long guestDocId) + { + using var dbContext = contextFactory.CreateDbContext(); + + bool rs = await dbContext.StayEntries + .AnyAsync(s => s.GuestDocuments.Any(g => g.Id == guestDocId)); + + return rs; + } + + public async Task IsGuestInStayInPeriod(long guestDocId, DateTime startTime, DateTime endTime) { using var dbContext = contextFactory.CreateDbContext(); bool rs = await dbContext.StayEntries .AnyAsync(s => s.DateIn < endTime && s.DateOut > startTime - && s.GuestDocuments.Any(g => g.DocumentType == docType && g.DocumentNum == docNum) + && s.GuestDocuments.Any(g => g.Id == guestDocId) ); return rs; } - public async Task> GetStaysWithGuest(string docType, string docNum, StayEntryQueryConfig? queryConfig = null) + public async Task> GetStaysWithGuest(long guestDocId, StayEntryQueryConfig? queryConfig = null) { using var dbContext = contextFactory.CreateDbContext(); var rs = await dbContext.StayEntries - .Where(s => s.GuestDocuments.Any(g => g.DocumentType == docType && g.DocumentNum == docNum)) + .Where(s => s.GuestDocuments.Any(g => g.Id == guestDocId)) .AddQueryConfig(queryConfig) .OrderByDescending(e => e.DateIn) .ToListAsync();