diff --git a/Common.Model/Common - Backup.Model.csproj b/Common.Model/Common - Backup.Model.csproj
new file mode 100644
index 0000000..5d211e6
--- /dev/null
+++ b/Common.Model/Common - Backup.Model.csproj
@@ -0,0 +1,14 @@
+
+
+
+ net7.0
+ enable
+ enable
+ $(MSBuildProjectName)
+
+
+
+
+
+
+
diff --git a/Common.Model/Common.Model.csproj b/Common.Model/Common.Model.csproj
new file mode 100644
index 0000000..ec9d83a
--- /dev/null
+++ b/Common.Model/Common.Model.csproj
@@ -0,0 +1,15 @@
+
+
+
+ net7.0
+ enable
+ enable
+ $(MSBuildProjectName)
+ Common
+
+
+
+
+
+
+
diff --git a/Common.Model/CommonRoutes.cs b/Common.Model/CommonRoutes.cs
new file mode 100644
index 0000000..e833d89
--- /dev/null
+++ b/Common.Model/CommonRoutes.cs
@@ -0,0 +1,8 @@
+namespace Common;
+
+public static class CommonRoutes
+{
+ public const string Common = "/common";
+ public const string Management = "/management";
+ public const string Documents = "/documents";
+}
diff --git a/Common.Model/Documents/DocumentArgs.cs b/Common.Model/Documents/DocumentArgs.cs
new file mode 100644
index 0000000..1e90daf
--- /dev/null
+++ b/Common.Model/Documents/DocumentArgs.cs
@@ -0,0 +1,41 @@
+using Connected.Annotations;
+using Connected.ServiceModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Common.Documents;
+
+public class DocumentArgs : Dto
+{
+ [MaxLength(32)]
+ public string? Code { get; set; } = default!;
+}
+
+public abstract class InsertDocumentArgs : DocumentArgs
+{
+ public int? Author { get; set; } = default!;
+
+ public DateTimeOffset? Created { get; set; }
+}
+
+public abstract class UpdateDocumentArgs : DocumentArgs
+ where TPrimaryKey : notnull
+{
+ [MinValue(1)]
+ public TPrimaryKey Id { get; set; } = default!;
+
+ public DateTimeOffset? Modified { get; set; }
+
+ public int? Owner { get; set; }
+}
+
+public sealed class SelectDocumentArgs : Dto
+{
+ [Required, MaxLength(32)]
+ public string Domain { get; set; } = default!;
+
+ [Required, MaxLength(32)]
+ public string Type { get; set; } = default!;
+
+ [Required, MaxLength(32)]
+ public string Code { set; get; } = default!;
+}
\ No newline at end of file
diff --git a/Common.Model/Documents/IDocument.cs b/Common.Model/Documents/IDocument.cs
new file mode 100644
index 0000000..c057869
--- /dev/null
+++ b/Common.Model/Documents/IDocument.cs
@@ -0,0 +1,42 @@
+using Connected.Data;
+
+namespace Common.Documents
+{
+ ///
+ /// Represents the base entity for all documents.
+ ///
+ ///
+ /// Document is primary entity of the business processes. It provides
+ /// schema which is used in a business process lifecycle. Documents, apart from
+ /// some basic validation, do not provide any specifiec business logic. Business
+ /// processes are entirely responsible for the business logic.
+ ///
+ public interface IDocument : IPrimaryKey
+ where TPrimaryKey : notnull
+ {
+ ///
+ /// The date when document was created.
+ ///
+ DateTimeOffset Created { get; init; }
+ ///
+ /// The date when document was last updated.
+ ///
+ DateTimeOffset? Modified { get; init; }
+ ///
+ /// The unique identifier of the document. This is
+ /// usually set by a customer specific numbering system.
+ ///
+ string? Code { get; init; }
+ ///
+ /// The user which created the document. Can be null if document was created by the system.
+ ///
+ int? Author { get; init; }
+ ///
+ /// The user last modified the document. Once user modifies the document it becomes the Owner.
+ ///
+ ///
+ /// This behavior could be overriden in documents implementation.
+ ///
+ int? Owner { get; init; }
+ }
+}
diff --git a/Common.Model/Documents/IDocumentService.cs b/Common.Model/Documents/IDocumentService.cs
new file mode 100644
index 0000000..1e54eaf
--- /dev/null
+++ b/Common.Model/Documents/IDocumentService.cs
@@ -0,0 +1,9 @@
+using Connected.Notifications;
+
+namespace Common.Documents;
+public interface IDocumentService : IServiceNotifications
+{
+ event ServiceEventHandler>? ItemInserted;
+ event ServiceEventHandler>? ItemUpdated;
+ event ServiceEventHandler>? ItemDeleted;
+}
diff --git a/Common.Model/Units.cs b/Common.Model/Units.cs
new file mode 100644
index 0000000..cf97585
--- /dev/null
+++ b/Common.Model/Units.cs
@@ -0,0 +1,6 @@
+namespace Common;
+
+public static class Units
+{
+ public const int MB = 1024 * 1024;
+}
diff --git a/Common.Notes.Model/Boot.cs b/Common.Notes.Model/Boot.cs
new file mode 100644
index 0000000..99159b5
--- /dev/null
+++ b/Common.Notes.Model/Boot.cs
@@ -0,0 +1,3 @@
+using Connected.Annotations;
+
+[assembly: MicroService(MicroServiceType.Contract)]
\ No newline at end of file
diff --git a/Common.Notes.Model/Common.Notes.Model.csproj b/Common.Notes.Model/Common.Notes.Model.csproj
new file mode 100644
index 0000000..1379137
--- /dev/null
+++ b/Common.Notes.Model/Common.Notes.Model.csproj
@@ -0,0 +1,15 @@
+
+
+
+ net7.0
+ enable
+ enable
+ Common.Notes
+
+
+
+
+
+
+
+
diff --git a/Common.Notes.Model/INote.cs b/Common.Notes.Model/INote.cs
new file mode 100644
index 0000000..606a783
--- /dev/null
+++ b/Common.Notes.Model/INote.cs
@@ -0,0 +1,9 @@
+using Connected.Data;
+
+namespace Common.Notes;
+
+public interface INote : IEntityContainer
+{
+ int Author { get; init; }
+ DateTimeOffset Created { get; init; }
+}
diff --git a/Common.Notes.Model/INoteSearch.cs b/Common.Notes.Model/INoteSearch.cs
new file mode 100644
index 0000000..40efb8a
--- /dev/null
+++ b/Common.Notes.Model/INoteSearch.cs
@@ -0,0 +1,9 @@
+using Connected.Data;
+
+namespace Common.Notes;
+public interface INoteSearch : IEntityContainer
+{
+ int Author { get; init; }
+ string Text { get; init; }
+ DateTimeOffset Created { get; init; }
+}
diff --git a/Common.Notes.Model/INoteService.cs b/Common.Notes.Model/INoteService.cs
new file mode 100644
index 0000000..3d63544
--- /dev/null
+++ b/Common.Notes.Model/INoteService.cs
@@ -0,0 +1,24 @@
+using System.Collections.Immutable;
+using Connected.Annotations;
+using Connected.ServiceModel;
+using Connected.ServiceModel.Search;
+
+namespace Common.Notes;
+
+[Service]
+[ServiceUrl(NoteUrls.Notes)]
+public interface INoteService
+{
+ Task> Query(NoteArgs args);
+ Task Select(PrimaryKeyArgs args);
+
+ Task Insert(InsertNoteArgs args);
+ Task Update(UpdateNoteArgs args);
+ Task Delete(PrimaryKeyArgs args);
+
+ Task> QueryText(QueryNoteTextArgs args);
+ [ServiceMethod(ServiceMethodVerbs.Get)]
+ Task SelectText(SelectNoteTextArgs args);
+
+ Task> Search(SearchArgs args);
+}
diff --git a/Common.Notes.Model/INoteText.cs b/Common.Notes.Model/INoteText.cs
new file mode 100644
index 0000000..75e2df3
--- /dev/null
+++ b/Common.Notes.Model/INoteText.cs
@@ -0,0 +1,10 @@
+using Connected.Data;
+
+namespace Common.Notes;
+
+public interface INoteText : IPrimaryKey
+{
+ string Entity { get; init; }
+ string EntityId { get; init; }
+ string? Text { get; init; }
+}
diff --git a/Common.Notes.Model/NoteArgs.cs b/Common.Notes.Model/NoteArgs.cs
new file mode 100644
index 0000000..c2a65fb
--- /dev/null
+++ b/Common.Notes.Model/NoteArgs.cs
@@ -0,0 +1,44 @@
+using System.ComponentModel.DataAnnotations;
+using Connected.ServiceModel;
+
+namespace Common.Notes;
+public class NoteArgs : Dto
+{
+ [Required, MaxLength(128)]
+ public string Entity { get; set; } = default!;
+
+ [Required, MaxLength(128)]
+ public string PrimaryKey { get; set; } = default!;
+}
+
+public class InsertNoteArgs : NoteArgs
+{
+ [Range(1, int.MaxValue)]
+ public int Author { get; set; }
+
+ public DateTimeOffset? Created { get; set; }
+
+ [Required, MaxLength(1024 * 1024)]
+ public string Text { get; set; } = default!;
+}
+
+public sealed class UpdateNoteArgs : PrimaryKeyArgs
+{
+ [Required, MaxLength(Units.MB)]
+ public string Text { get; set; } = default!;
+}
+
+public sealed class QueryNoteTextArgs : PrimaryKeyListArgs
+{
+ [Required, MaxLength(128)]
+ public string Entity { get; set; } = default!;
+}
+
+public sealed class SelectNoteTextArgs : PrimaryKeyArgs
+{
+ [Required, MaxLength(128)]
+ public string Entity { get; set; } = default!;
+
+ [Required, MaxLength(128)]
+ public string EntityId { get; set; } = default!;
+}
\ No newline at end of file
diff --git a/Common.Notes.Model/NoteUrls.cs b/Common.Notes.Model/NoteUrls.cs
new file mode 100644
index 0000000..92e218b
--- /dev/null
+++ b/Common.Notes.Model/NoteUrls.cs
@@ -0,0 +1,5 @@
+namespace Common.Notes;
+public static class NoteUrls
+{
+ public const string Notes = $"{CommonRoutes.Common}/notes";
+}
diff --git a/Common.Notes/Bootstrap.cs b/Common.Notes/Bootstrap.cs
new file mode 100644
index 0000000..0fe0937
--- /dev/null
+++ b/Common.Notes/Bootstrap.cs
@@ -0,0 +1,3 @@
+using Connected.Annotations;
+
+[assembly: MicroService(MicroServiceType.Sys)]
\ No newline at end of file
diff --git a/Common.Notes/Common.Notes.csproj b/Common.Notes/Common.Notes.csproj
new file mode 100644
index 0000000..d5d620f
--- /dev/null
+++ b/Common.Notes/Common.Notes.csproj
@@ -0,0 +1,16 @@
+
+
+
+ net7.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
diff --git a/Common.Notes/Note.cs b/Common.Notes/Note.cs
new file mode 100644
index 0000000..5e8eb95
--- /dev/null
+++ b/Common.Notes/Note.cs
@@ -0,0 +1,17 @@
+using Connected.Annotations;
+using Connected.Entities.Annotations;
+using Connected.Entities.Containers;
+
+namespace Common.Notes;
+
+[Table(Schema = Constants.CommonSchema)]
+public sealed record Note : ContainerEntity, INote
+{
+ public const string EntityKey = $"{Constants.CommonSchema}.{nameof(Note)}";
+
+ [Ordinal(0)]
+ public int Author { get; init; }
+
+ [Ordinal(1)]
+ public DateTimeOffset Created { get; init; }
+}
diff --git a/Common.Notes/NoteOps.cs b/Common.Notes/NoteOps.cs
new file mode 100644
index 0000000..71b8c8c
--- /dev/null
+++ b/Common.Notes/NoteOps.cs
@@ -0,0 +1,240 @@
+using System.Collections.Immutable;
+using Connected.Caching;
+using Connected.Entities;
+using Connected.Entities.Storage;
+using Connected.Notifications;
+using Connected.Notifications.Events;
+using Connected.ServiceModel;
+using Connected.ServiceModel.Search;
+using Connected.Services;
+
+namespace Common.Notes;
+internal static class NoteOps
+{
+ ///
+ /// Deletes the note entity, its text and search entity from the storage.
+ ///
+ public class Delete : ServiceAction>
+ {
+ ///
+ /// Create a new instance.
+ ///
+ public Delete(IStorageProvider storage, IEventService events)
+ {
+ Storage = storage;
+ Events = events;
+ }
+
+ private IStorageProvider Storage { get; }
+ private IEventService Events { get; }
+
+ protected override async Task OnInvoke()
+ {
+ /*
+ * First delete the entity.
+ */
+ await Storage.Open().Update(Arguments.AsEntity(State.Deleted));
+ /*
+ * Delete the note's text.
+ */
+ await Storage.Open().Update(Arguments.AsEntity(State.Deleted));
+ /*
+ * Delete search entry.
+ */
+ await Storage.Open().Update(Arguments.AsEntity(State.Deleted));
+ }
+
+ protected override async Task OnCommitted()
+ {
+ /*
+ * We are triggering notify only for the entity, not for text and search transactions.
+ * Note that we don't cache text and search entries. They are read from the storage every time
+ * it is requested.
+ */
+ await Events.Enqueue(this, typeof(NoteService), nameof(IServiceNotifications.Deleted), Arguments);
+ }
+ }
+ ///
+ /// Inserts a new note entity into storage.
+ ///
+ ///
+ /// This class inserts a new entity into storage, then inserts text
+ /// into table storage and the creates a search index entry.
+ ///
+ public sealed class Insert : ServiceFunction
+ {
+ public Insert(IStorageProvider storage, IEventService events)
+ {
+ Storage = storage;
+ Events = events;
+ }
+
+ private IStorageProvider Storage { get; }
+ private IEventService Events { get; }
+
+ protected override async Task OnInvoke()
+ {
+ /*
+ * This call inserts a new record into storage (database) and returns its id.
+ */
+ var result = await Storage.Open().Update(Arguments.AsEntity(State.New));
+ /*
+ * We'll be using a newly inserted id to create a new record in the table
+ * storage for text.
+ */
+ await Storage.Open().Update(Arguments.AsEntity(State.New, new { result.Id }));
+ /*
+ * And create entry in the search index.
+ */
+ //await Storage.Open().Update(Arguments.AsEntity(State.New, new { result.Id }));
+
+ return result.Id;
+ }
+
+ protected override async Task OnCommitted()
+ {
+ await Events.Enqueue(this, typeof(NoteService), nameof(IServiceNotifications.Inserted), new PrimaryKeyArgs { Id = Result });
+ }
+ }
+
+ public sealed class Update : ServiceAction
+ {
+ public Update(IStorageProvider storage, ICacheContext cache, IEventService events, INoteService notes)
+ {
+ Storage = storage;
+ Cache = cache;
+ Events = events;
+ Notes = notes;
+ }
+
+ private IStorageProvider Storage { get; }
+ private ICacheContext Cache { get; }
+ private IEventService Events { get; }
+ private INoteService Notes { get; }
+
+ protected override async Task OnInvoke()
+ {
+ if (await Notes.Select(Arguments.Id) is not INote entity)
+ return;
+
+ await Storage.Open().Update(Arguments.AsEntity(State.Default), Arguments, async () =>
+ {
+ await Cache.Remove(Note.EntityKey, Arguments.Id);
+
+ return (await Notes.Select(Arguments.Id)) as Note;
+ });
+
+ await Storage.Open().Update(Arguments.AsEntity(State.Default), Arguments, async () =>
+ {
+ await Cache.Remove(NoteText.EntityKey, Arguments.Id);
+
+ return (await Notes.SelectText(new SelectNoteTextArgs
+ {
+ Entity = entity.Entity,
+ EntityId = entity.EntityId,
+ Id = Arguments.Id
+ })) as NoteText;
+ });
+
+ await Storage.Open().Update(Arguments.AsEntity(State.Default));
+ }
+
+ protected override async Task OnCommitted()
+ {
+ await Cache.Remove(Note.EntityKey, Arguments.Id);
+ await Cache.Remove(NoteText.EntityKey, Arguments.Id);
+
+ await Events.Enqueue(this, typeof(NoteService), nameof(IServiceNotifications.Updated), new PrimaryKeyArgs { Id = Arguments.Id });
+ }
+ }
+
+ public sealed class Query : ServiceFunction>
+ {
+ public Query(IStorageProvider provider)
+ {
+ Provider = provider;
+ }
+
+ private IStorageProvider Provider { get; }
+
+ protected override async Task> OnInvoke()
+ {
+ return await (from dc in Provider.Open() select dc).AsEntities();
+ }
+ }
+
+ public sealed class Select : ServiceFunction, INote>
+ {
+ public Select(IStorageProvider provider, ICacheContext cache)
+ {
+ Provider = provider;
+ Cache = cache;
+ }
+
+ private IStorageProvider Provider { get; }
+ private ICacheContext Cache { get; }
+
+ protected override Task OnInvoke()
+ {
+ return Cache.Get(Note.EntityKey, Arguments.Id,
+ async (f) =>
+ {
+ return await (from dc in Provider.Open() where dc.Id == Arguments.Id select dc).AsEntity();
+ });
+ }
+ }
+
+ public sealed class Search : ServiceFunction>
+ {
+ public Search(IStorageProvider storage)
+ {
+ Storage = storage;
+ }
+
+ private IStorageProvider Storage { get; }
+
+ protected override async Task?> OnInvoke()
+ {
+ return await (from dc in Storage.Open()
+ where dc.Text.Contains(Arguments.Text)
+ select dc).AsEntities();
+ }
+ }
+
+ public sealed class QueryText : ServiceFunction>
+ {
+ public QueryText(IStorageProvider storage)
+ {
+ Storage = storage;
+ }
+
+ private IStorageProvider Storage { get; }
+
+ protected override async Task?> OnInvoke()
+ {
+ return await (from dc in Storage.Open()
+ where string.Equals(dc.Entity, Arguments.Entity, StringComparison.Ordinal)
+ && Arguments.IdList.Any(f => f == dc.Id)
+ select dc).AsEntities();
+ }
+ }
+
+ public sealed class SelectText : ServiceFunction
+ {
+ public SelectText(IStorageProvider provider)
+ {
+ Provider = provider;
+ }
+
+ private IStorageProvider Provider { get; }
+
+ protected override async Task OnInvoke()
+ {
+ return await (from dc in Provider.Open()
+ where string.Equals(dc.Entity, Arguments.Entity)
+ && string.Equals(dc.EntityId, Arguments.EntityId)
+ && dc.Id == Arguments.Id
+ select dc).AsEntity();
+ }
+ }
+}
diff --git a/Common.Notes/NoteSearch.cs b/Common.Notes/NoteSearch.cs
new file mode 100644
index 0000000..8eac90c
--- /dev/null
+++ b/Common.Notes/NoteSearch.cs
@@ -0,0 +1,17 @@
+using Connected.Annotations;
+using Connected.Entities.Annotations;
+using Connected.Entities.Containers;
+
+namespace Common.Notes;
+[Persistence(Persistence = ColumnPersistence.InMemory)]
+public record NoteSearch : ContainerEntity, INoteSearch
+{
+ [Ordinal(0)]
+ public int Author { get; init; }
+
+ [Ordinal(1), Length(Units.MB)]
+ public string Text { get; init; } = default!;
+
+ [Ordinal(2)]
+ public DateTimeOffset Created { get; init; }
+}
diff --git a/Common.Notes/NoteService.cs b/Common.Notes/NoteService.cs
new file mode 100644
index 0000000..885bbc5
--- /dev/null
+++ b/Common.Notes/NoteService.cs
@@ -0,0 +1,61 @@
+using System.Collections.Immutable;
+using Connected.ServiceModel;
+using Connected.ServiceModel.Search;
+using Connected.Services;
+
+namespace Common.Notes;
+internal sealed class NoteService : Service, INoteService
+{
+ public NoteService(IContext context) : base(context)
+ {
+ }
+
+ public async Task Delete(PrimaryKeyArgs args)
+ {
+ await Invoke(GetOperation(), args);
+ }
+
+ public async Task Insert(InsertNoteArgs args)
+ {
+ return await Invoke(GetOperation(), args);
+ }
+
+ public async Task> Query(NoteArgs args)
+ {
+ return await Invoke(GetOperation(), args);
+ }
+
+ public async Task> QueryText(QueryNoteTextArgs args)
+ {
+ return await Invoke(GetOperation(), args);
+ }
+
+ public async Task> Search(SearchArgs args)
+ {
+ return await Invoke(GetOperation(), args);
+ }
+
+ public async Task Select(PrimaryKeyArgs args)
+ {
+ return await Invoke(GetOperation(), args);
+ }
+
+ public async Task SelectText(SelectNoteTextArgs args)
+ {
+ await Invoke(GetOperation(), new InsertNoteArgs
+ {
+ Entity = "Entity",
+ PrimaryKey = "10",
+ Author = 10,
+ Created = DateTime.UtcNow,
+ Text = "Note text"
+ }, "Insert");
+
+ return await Invoke(GetOperation(), args);
+ }
+
+ public async Task Update(UpdateNoteArgs args)
+ {
+ await Invoke(GetOperation(), args);
+ }
+}
\ No newline at end of file
diff --git a/Common.Notes/NoteText.cs b/Common.Notes/NoteText.cs
new file mode 100644
index 0000000..bb917c7
--- /dev/null
+++ b/Common.Notes/NoteText.cs
@@ -0,0 +1,20 @@
+using System.ComponentModel.DataAnnotations;
+using Connected.Annotations;
+using Connected.Entities.Annotations;
+using Connected.ServiceModel.Annotations;
+using Connected.ServiceModel.Data;
+
+namespace Common.Notes;
+public sealed record NoteText : TableEntity, INoteText
+{
+ public const string EntityKey = $"{Constants.CommonSchema}.{nameof(NoteText)}";
+
+ [Ordinal(-50000), PartitionKey, MaxLength(128)]
+ public string Entity { get; init; } = default!;
+
+ [Ordinal(-49000), PrimaryKey, MaxLength(128)]
+ public string EntityId { get; init; } = default!;
+
+ [Ordinal(0), Length(Units.MB), Nullable]
+ public string? Text { get; init; }
+}
diff --git a/Common.Numbering.Middleware/Common.Numbering.Middleware.csproj b/Common.Numbering.Middleware/Common.Numbering.Middleware.csproj
new file mode 100644
index 0000000..2d0affe
--- /dev/null
+++ b/Common.Numbering.Middleware/Common.Numbering.Middleware.csproj
@@ -0,0 +1,15 @@
+
+
+
+ net7.0
+ enable
+ enable
+ Common.Numbering
+
+
+
+
+
+
+
+
diff --git a/Common.Numbering.Middleware/INumberingProvider.cs b/Common.Numbering.Middleware/INumberingProvider.cs
new file mode 100644
index 0000000..32a2969
--- /dev/null
+++ b/Common.Numbering.Middleware/INumberingProvider.cs
@@ -0,0 +1,16 @@
+using Connected;
+
+namespace Common.Numbering;
+///
+/// Provides middleware for providing a numbering algorithm.
+///
+public interface INumberingProvider : IMiddleware
+{
+ ///
+ /// Creates a new value based on the specified arguments.
+ ///
+ /// The arguments providing information about the entity for which
+ /// value need to be provided.
+ /// A new value if the numbering is supported by the middleware, null otherwise.
+ Task Invoke(NumberingCalculateArgs args);
+}
diff --git a/Common.Numbering.Model/Common.Numbering.Model.csproj b/Common.Numbering.Model/Common.Numbering.Model.csproj
new file mode 100644
index 0000000..a7c8f5d
--- /dev/null
+++ b/Common.Numbering.Model/Common.Numbering.Model.csproj
@@ -0,0 +1,14 @@
+
+
+
+ net7.0
+ enable
+ enable
+ Common.Numbering
+
+
+
+
+
+
+
diff --git a/Common.Numbering.Model/INumbering.cs b/Common.Numbering.Model/INumbering.cs
new file mode 100644
index 0000000..d27cde2
--- /dev/null
+++ b/Common.Numbering.Model/INumbering.cs
@@ -0,0 +1,8 @@
+using Connected.Data;
+
+namespace Common.Numbering;
+public interface INumbering : IPrimaryKey
+{
+ string Entity { get; init; }
+ string Value { get; init; }
+}
diff --git a/Common.Numbering.Model/INumberingService.cs b/Common.Numbering.Model/INumberingService.cs
new file mode 100644
index 0000000..c541e0a
--- /dev/null
+++ b/Common.Numbering.Model/INumberingService.cs
@@ -0,0 +1,17 @@
+using Connected.Annotations;
+using Connected.ServiceModel;
+
+namespace Common.Numbering;
+[Service]
+[ServiceUrl(NumberingUrls.Numbering)]
+public interface INumberingService
+{
+ [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)]
+ Task Calculate(NumberingCalculateArgs args);
+
+ [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)]
+ Task Select(NumberingSelectArgs args);
+
+ [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)]
+ Task Select(PrimaryKeyArgs args);
+}
diff --git a/Common.Numbering.Model/NumberingArgs.cs b/Common.Numbering.Model/NumberingArgs.cs
new file mode 100644
index 0000000..6a9160d
--- /dev/null
+++ b/Common.Numbering.Model/NumberingArgs.cs
@@ -0,0 +1,15 @@
+using Connected.ServiceModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Common.Numbering;
+public sealed class NumberingCalculateArgs : Dto
+{
+ [Required, MaxLength(128)]
+ public string Entity { get; set; } = default!;
+}
+
+public sealed class NumberingSelectArgs : Dto
+{
+ [Required, MaxLength(128)]
+ public string Entity { get; set; } = default!;
+}
diff --git a/Common.Numbering.Model/NumberingUrls.cs b/Common.Numbering.Model/NumberingUrls.cs
new file mode 100644
index 0000000..a6c0de3
--- /dev/null
+++ b/Common.Numbering.Model/NumberingUrls.cs
@@ -0,0 +1,5 @@
+namespace Common.Numbering;
+public static class NumberingUrls
+{
+ public const string Numbering = "common/numbering";
+}
diff --git a/Common.Numbering/Common.Numbering.csproj b/Common.Numbering/Common.Numbering.csproj
new file mode 100644
index 0000000..a128285
--- /dev/null
+++ b/Common.Numbering/Common.Numbering.csproj
@@ -0,0 +1,16 @@
+
+
+
+ net7.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
diff --git a/Common.Numbering/DefaultProvider.cs b/Common.Numbering/DefaultProvider.cs
new file mode 100644
index 0000000..af2fa24
--- /dev/null
+++ b/Common.Numbering/DefaultProvider.cs
@@ -0,0 +1,23 @@
+using Connected.Annotations;
+using Connected.Middleware;
+
+namespace Common.Numbering;
+
+[Priority(0)]
+internal sealed class DefaultProvider : MiddlewareComponent, INumberingProvider
+{
+ public DefaultProvider(INumberingService numbering)
+ {
+ Numbering = numbering;
+ }
+
+ private INumberingService Numbering { get; }
+
+ public async Task Invoke(NumberingCalculateArgs args)
+ {
+ if (Numbering is not NumberingService service)
+ throw new InvalidCastException(nameof(NumberingService));
+
+ return await service.NextValue(args);
+ }
+}
diff --git a/Common.Numbering/Numbering.cs b/Common.Numbering/Numbering.cs
new file mode 100644
index 0000000..c27df6a
--- /dev/null
+++ b/Common.Numbering/Numbering.cs
@@ -0,0 +1,15 @@
+using Connected.Annotations;
+using Connected.Entities.Annotations;
+using Connected.Entities.Consistency;
+using System.ComponentModel.DataAnnotations;
+
+namespace Common.Numbering;
+[Table(Schema = CommonSchemas.CommonSchema)]
+internal sealed record Numbering : ConsistentEntity, INumbering
+{
+ [Ordinal(0), MaxLength(128), Index(Name = $"ui_{CommonSchemas.CommonSchema}_numbering_entity")]
+ public string Entity { get; init; } = default!;
+
+ [Ordinal(1), MaxLength(128)]
+ public string Value { get; init; } = default!;
+}
diff --git a/Common.Numbering/NumberingOps.cs b/Common.Numbering/NumberingOps.cs
new file mode 100644
index 0000000..78fc6db
--- /dev/null
+++ b/Common.Numbering/NumberingOps.cs
@@ -0,0 +1,137 @@
+using Connected.Entities;
+using Connected.Entities.Storage;
+using Connected.Interop;
+using Connected.Middleware;
+using Connected.ServiceModel;
+using Connected.Services;
+
+namespace Common.Numbering;
+internal sealed class NumberingOps
+{
+ public sealed class Calculate : ServiceFunction
+ {
+ public Calculate(IMiddlewareService middleware)
+ {
+ Middleware = middleware;
+ }
+
+ private IMiddlewareService Middleware { get; }
+
+ protected override async Task OnInvoke()
+ {
+ foreach (var middleware in await Middleware.Query())
+ {
+ if (await middleware.Invoke(Arguments) is string result && !string.IsNullOrEmpty(result))
+ return result;
+ }
+
+ return null;
+ }
+ }
+
+ public sealed class NextValue : ServiceFunction
+ {
+ public NextValue(IStorageProvider storage, INumberingService numbering)
+ {
+ Storage = storage;
+ Numbering = numbering;
+ }
+
+ private IStorageProvider Storage { get; }
+ private INumberingService Numbering { get; }
+
+ protected override async Task OnInvoke()
+ {
+ var result = await Prepare();
+
+ await Storage.Open().Update(result, Arguments,
+ async () =>
+ {
+ result = await Prepare();
+
+ return result;
+ },
+ async (f) =>
+ {
+ await Task.CompletedTask;
+
+ return f;
+ });
+
+ return result.Value;
+ }
+
+ private async Task Prepare()
+ {
+ var current = await Numbering.Select(Arguments.AsArguments());
+
+ if (current is null)
+ {
+ var id = await TryInsert();
+
+ if (id == 0)
+ current = await Numbering.Select(Arguments.AsArguments());
+ else
+ current = await Numbering.Select(id);
+ }
+
+ if (current is null)
+ throw new NullReferenceException(nameof(INumbering));
+
+ var newValue = string.Empty;
+
+ if (TypeConversion.TryConvert(current.Value, out long existingValue))
+ newValue = existingValue++.ToString();
+ else
+ newValue = current.Value;
+
+ return (Numbering)current with { Value = newValue };
+ }
+
+ private async Task TryInsert()
+ {
+ try
+ {
+ return (await Storage.Open().Update(Arguments.AsEntity(State.New))).Id;
+ }
+ catch
+ {
+ return 0;
+ }
+ }
+ }
+
+ public sealed class SelectByEntity : ServiceFunction
+ {
+ public SelectByEntity(IStorageProvider storage)
+ {
+ Storage = storage;
+ }
+
+ private IStorageProvider Storage { get; }
+
+ protected override async Task OnInvoke()
+ {
+ return await (from e in Storage.Open()
+ where string.Equals(e.Entity, Arguments.Entity, StringComparison.OrdinalIgnoreCase)
+ select e).AsEntity();
+ }
+ }
+
+ public sealed class Select : ServiceFunction, INumbering>
+ {
+ public Select(IStorageProvider storage)
+ {
+ Storage = storage;
+ }
+
+ private IStorageProvider Storage { get; }
+
+ protected override async Task OnInvoke()
+ {
+ return await (from e in Storage.Open()
+ where e.Id == Arguments.Id
+ select e).AsEntity();
+ }
+ }
+}
diff --git a/Common.Numbering/NumberingService.cs b/Common.Numbering/NumberingService.cs
new file mode 100644
index 0000000..aa392dd
--- /dev/null
+++ b/Common.Numbering/NumberingService.cs
@@ -0,0 +1,31 @@
+using Connected.ServiceModel;
+using Connected.Services;
+using Ops = Common.Numbering.NumberingOps;
+
+namespace Common.Numbering;
+internal sealed class NumberingService : Service, INumberingService
+{
+ public NumberingService(IContext context) : base(context)
+ {
+ }
+
+ public async Task Calculate(NumberingCalculateArgs args)
+ {
+ return await Invoke(GetOperation(), args);
+ }
+
+ public async Task Select(NumberingSelectArgs args)
+ {
+ return await Invoke(GetOperation(), args);
+ }
+
+ public async Task Select(PrimaryKeyArgs args)
+ {
+ return await Invoke(GetOperation(), args);
+ }
+
+ internal async Task NextValue(NumberingCalculateArgs args)
+ {
+ return await Invoke(GetOperation(), args);
+ }
+}
diff --git a/Common.sln b/Common.sln
new file mode 100644
index 0000000..cbe9e29
--- /dev/null
+++ b/Common.sln
@@ -0,0 +1,121 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.4.32916.344
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Dependencies", "Dependencies", "{75ED46E6-38CD-4948-9F3C-5167A9FFD7FA}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common", "Common\Common.csproj", "{CB582FC6-7A9A-46D1-BA75-4A103E096674}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.Globalization", "..\Framework\Connected.Globalization\Connected.Globalization.csproj", "{B9438432-CD2B-4570-B96C-C1B47A1CF5BB}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.Net", "..\Framework\Connected.Net\Connected.Net.csproj", "{664BD509-4D4A-45D4-8B82-5E54094A95E6}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.Services", "..\Framework\Connected.Services\Connected.Services.csproj", "{A4616625-88A0-434B-8433-EC8693E1E53B}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.Validation", "..\Framework\Connected.Validation\Connected.Validation.csproj", "{71403C62-2D04-4E0B-9FAB-1ED23B0FB6B7}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected", "..\Connected\Connected\Connected.csproj", "{B2AE8588-0786-4556-9563-0D941A145C7C}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.Model", "Common.Model\Common.Model.csproj", "{ABF6BF35-ED9F-43A9-8581-23CBD9701E94}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.Notes", "Common.Notes\Common.Notes.csproj", "{05B6148F-467C-4090-8FEA-8EA16A4D9956}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.Notes.Model", "Common.Notes.Model\Common.Notes.Model.csproj", "{DF77BAAB-8223-4BEA-B7BA-0C087D9C6750}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.Entities", "..\Framework\Connected.Entities\Connected.Entities.csproj", "{0D2BE8CB-1C1B-4C74-9940-8F1BA5B1ED42}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.ServiceModel", "..\Framework.ServiceModel\Connected.ServiceModel\Connected.ServiceModel.csproj", "{3F224810-2034-45BB-BB53-DE5F6E83A07B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common.Numbering.Model", "Common.Numbering.Model\Common.Numbering.Model.csproj", "{83F83DE6-28BF-4AB7-902E-42D38A54A578}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common.Numbering", "Common.Numbering\Common.Numbering.csproj", "{4E810676-E37E-4C25-95FC-747BB32E5A69}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common.Numbering.Middleware", "Common.Numbering.Middleware\Common.Numbering.Middleware.csproj", "{424C1CBE-8490-431C-A0A8-1ACB22A65864}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.Middleware", "..\Framework\Connected.Middleware\Connected.Middleware.csproj", "{085068A9-6739-423D-957F-DFA22564B574}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {CB582FC6-7A9A-46D1-BA75-4A103E096674}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {CB582FC6-7A9A-46D1-BA75-4A103E096674}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {CB582FC6-7A9A-46D1-BA75-4A103E096674}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {CB582FC6-7A9A-46D1-BA75-4A103E096674}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B9438432-CD2B-4570-B96C-C1B47A1CF5BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B9438432-CD2B-4570-B96C-C1B47A1CF5BB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B9438432-CD2B-4570-B96C-C1B47A1CF5BB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B9438432-CD2B-4570-B96C-C1B47A1CF5BB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {664BD509-4D4A-45D4-8B82-5E54094A95E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {664BD509-4D4A-45D4-8B82-5E54094A95E6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {664BD509-4D4A-45D4-8B82-5E54094A95E6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {664BD509-4D4A-45D4-8B82-5E54094A95E6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A4616625-88A0-434B-8433-EC8693E1E53B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A4616625-88A0-434B-8433-EC8693E1E53B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A4616625-88A0-434B-8433-EC8693E1E53B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A4616625-88A0-434B-8433-EC8693E1E53B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {71403C62-2D04-4E0B-9FAB-1ED23B0FB6B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {71403C62-2D04-4E0B-9FAB-1ED23B0FB6B7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {71403C62-2D04-4E0B-9FAB-1ED23B0FB6B7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {71403C62-2D04-4E0B-9FAB-1ED23B0FB6B7}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B2AE8588-0786-4556-9563-0D941A145C7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B2AE8588-0786-4556-9563-0D941A145C7C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B2AE8588-0786-4556-9563-0D941A145C7C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B2AE8588-0786-4556-9563-0D941A145C7C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {ABF6BF35-ED9F-43A9-8581-23CBD9701E94}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {ABF6BF35-ED9F-43A9-8581-23CBD9701E94}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {ABF6BF35-ED9F-43A9-8581-23CBD9701E94}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {ABF6BF35-ED9F-43A9-8581-23CBD9701E94}.Release|Any CPU.Build.0 = Release|Any CPU
+ {05B6148F-467C-4090-8FEA-8EA16A4D9956}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {05B6148F-467C-4090-8FEA-8EA16A4D9956}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {05B6148F-467C-4090-8FEA-8EA16A4D9956}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {05B6148F-467C-4090-8FEA-8EA16A4D9956}.Release|Any CPU.Build.0 = Release|Any CPU
+ {DF77BAAB-8223-4BEA-B7BA-0C087D9C6750}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {DF77BAAB-8223-4BEA-B7BA-0C087D9C6750}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {DF77BAAB-8223-4BEA-B7BA-0C087D9C6750}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {DF77BAAB-8223-4BEA-B7BA-0C087D9C6750}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0D2BE8CB-1C1B-4C74-9940-8F1BA5B1ED42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0D2BE8CB-1C1B-4C74-9940-8F1BA5B1ED42}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0D2BE8CB-1C1B-4C74-9940-8F1BA5B1ED42}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0D2BE8CB-1C1B-4C74-9940-8F1BA5B1ED42}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3F224810-2034-45BB-BB53-DE5F6E83A07B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3F224810-2034-45BB-BB53-DE5F6E83A07B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3F224810-2034-45BB-BB53-DE5F6E83A07B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3F224810-2034-45BB-BB53-DE5F6E83A07B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {83F83DE6-28BF-4AB7-902E-42D38A54A578}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {83F83DE6-28BF-4AB7-902E-42D38A54A578}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {83F83DE6-28BF-4AB7-902E-42D38A54A578}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {83F83DE6-28BF-4AB7-902E-42D38A54A578}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4E810676-E37E-4C25-95FC-747BB32E5A69}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4E810676-E37E-4C25-95FC-747BB32E5A69}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4E810676-E37E-4C25-95FC-747BB32E5A69}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4E810676-E37E-4C25-95FC-747BB32E5A69}.Release|Any CPU.Build.0 = Release|Any CPU
+ {424C1CBE-8490-431C-A0A8-1ACB22A65864}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {424C1CBE-8490-431C-A0A8-1ACB22A65864}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {424C1CBE-8490-431C-A0A8-1ACB22A65864}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {424C1CBE-8490-431C-A0A8-1ACB22A65864}.Release|Any CPU.Build.0 = Release|Any CPU
+ {085068A9-6739-423D-957F-DFA22564B574}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {085068A9-6739-423D-957F-DFA22564B574}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {085068A9-6739-423D-957F-DFA22564B574}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {085068A9-6739-423D-957F-DFA22564B574}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {B9438432-CD2B-4570-B96C-C1B47A1CF5BB} = {75ED46E6-38CD-4948-9F3C-5167A9FFD7FA}
+ {664BD509-4D4A-45D4-8B82-5E54094A95E6} = {75ED46E6-38CD-4948-9F3C-5167A9FFD7FA}
+ {A4616625-88A0-434B-8433-EC8693E1E53B} = {75ED46E6-38CD-4948-9F3C-5167A9FFD7FA}
+ {71403C62-2D04-4E0B-9FAB-1ED23B0FB6B7} = {75ED46E6-38CD-4948-9F3C-5167A9FFD7FA}
+ {B2AE8588-0786-4556-9563-0D941A145C7C} = {75ED46E6-38CD-4948-9F3C-5167A9FFD7FA}
+ {0D2BE8CB-1C1B-4C74-9940-8F1BA5B1ED42} = {75ED46E6-38CD-4948-9F3C-5167A9FFD7FA}
+ {3F224810-2034-45BB-BB53-DE5F6E83A07B} = {75ED46E6-38CD-4948-9F3C-5167A9FFD7FA}
+ {085068A9-6739-423D-957F-DFA22564B574} = {75ED46E6-38CD-4948-9F3C-5167A9FFD7FA}
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {23EF0531-5260-4441-876C-7569138BD7FA}
+ EndGlobalSection
+EndGlobal
diff --git a/Common/Collections/MessageJob.cs b/Common/Collections/MessageJob.cs
new file mode 100644
index 0000000..6f1f0e5
--- /dev/null
+++ b/Common/Collections/MessageJob.cs
@@ -0,0 +1,25 @@
+using Connected.Collections.Concurrent;
+using Connected.Collections.Queues;
+using Connected.ServiceModel;
+
+namespace Common.Collections;
+internal sealed class MessageJob : DispatcherJob
+{
+ public MessageJob(IContextProvider provider)
+ {
+ Provider = provider;
+ }
+
+ public IContextProvider Provider { get; }
+
+ protected override async Task OnInvoke(IQueueMessage args, CancellationToken cancellationToken)
+ {
+ //TODO: need to do proper conversions
+ using var ctx = Provider.Create();
+ var type = Type.GetType(args.Queue);
+ var client = ctx.GetService(type) as IQueueClient;
+
+ await client.Initialize();
+ await client.Invoke(args, args.Arguments);
+ }
+}
diff --git a/Common/Collections/QueueCache.cs b/Common/Collections/QueueCache.cs
new file mode 100644
index 0000000..75717ad
--- /dev/null
+++ b/Common/Collections/QueueCache.cs
@@ -0,0 +1,19 @@
+using Connected.Entities.Caching;
+
+namespace Common.Collections;
+
+internal interface IQueueCache : IEntityCacheClient
+{
+ void Update(QueueMessage message);
+}
+internal class QueueCache : EntityCacheClient, IQueueCache
+{
+ public QueueCache(IEntityCacheContext context) : base(context, QueueMessage.CacheKey)
+ {
+ }
+
+ public void Update(QueueMessage message)
+ {
+ Set(message.Id, message, TimeSpan.Zero);
+ }
+}
diff --git a/Common/Collections/QueueClientService.cs b/Common/Collections/QueueClientService.cs
new file mode 100644
index 0000000..19a9266
--- /dev/null
+++ b/Common/Collections/QueueClientService.cs
@@ -0,0 +1,53 @@
+using Connected.Collections.Queues;
+using Connected.Hosting.Workers;
+using Connected.Middleware;
+using Connected.ServiceModel;
+
+namespace Common.Collections;
+internal sealed class QueueClientService : ScheduledWorker
+{
+ public QueueClientService(IContextProvider provider)
+ {
+ Dispatcher = new();
+ Timer = TimeSpan.FromMilliseconds(500);
+ Queues = new();
+ Provider = provider;
+ }
+
+ private IContextProvider Provider { get; }
+ private QueueMessageDispatcher Dispatcher { get; }
+
+ private List Queues { get; }
+
+ public override async Task StartAsync(CancellationToken cancellationToken)
+ {
+ using var ctx = Provider.Create();
+
+ if (ctx.GetService() is not IMiddlewareService middleware)
+ return;
+
+ foreach (var m in await middleware.Query>())
+ {
+ if (m.GetType().FullName is string fullName)
+ Queues.Add(fullName);
+ }
+ }
+
+ protected override async Task OnInvoke(CancellationToken cancellationToken)
+ {
+ using var ctx = Provider.Create();
+
+ if (ctx.GetService() is not IQueueService queue)
+ return;
+
+ var messages = await queue.Dequeue(new DequeueArgs
+ {
+ MaxCount = Dispatcher.Available,
+ NextVisible = TimeSpan.FromSeconds(30),
+ Queues = Queues
+ });
+
+ foreach (var message in messages)
+ Dispatcher.Enqueue(message);
+ }
+}
diff --git a/Common/Collections/QueueMessage.cs b/Common/Collections/QueueMessage.cs
new file mode 100644
index 0000000..8f21b5e
--- /dev/null
+++ b/Common/Collections/QueueMessage.cs
@@ -0,0 +1,49 @@
+using Connected.Annotations;
+using Connected.Collections.Queues;
+using Connected.Data;
+using Connected.Entities.Annotations;
+using Connected.Entities.Concurrency;
+
+namespace Common.Collections;
+///
+[Table(Schema = SchemaAttribute.SysSchema)]
+internal sealed record QueueMessage : ConcurrentEntity, IQueueMessage
+{
+ public const string CacheKey = $"{SchemaAttribute.SysSchema}.{nameof(QueueMessage)}";
+ ///
+ [Ordinal(0), Date(Kind = DateKind.DateTime)]
+ public DateTime Created { get; init; }
+ ///
+ [Ordinal(2)]
+ public int DequeueCount { get; init; }
+ ///
+ [Ordinal(3), Date(Kind = DateKind.DateTime)]
+ public DateTime? DequeueTimestamp { get; init; }
+ ///
+ [Ordinal(4), Length(32)]
+ public string Queue { get; init; } = default!;
+ ///
+ /// The serialized value of the arguments if specified.
+ ///
+ ///
+ /// If the queue message must be persisted the arguments get serialized
+ /// and stored in this property. When selected from the storage, the
+ /// Arguments object is recreated from this property.
+ /// Note that queue serialized Arguments must be less that 1024 characters long.
+ ///
+ [Ordinal(7), Length(1024)]
+ public string? SerializedArguments { get; init; }
+ ///
+ [Ordinal(8), Date(Kind = DateKind.DateTime)]
+ public DateTime NextVisible { get; init; }
+ ///
+ [Ordinal(9)]
+ public Guid? PopReceipt { get; init; }
+ ///
+ ///
+ /// This property is persisted through the
+ /// property.
+ ///
+ [Persistence(Persistence = ColumnPersistence.InMemory)]
+ public QueueArgs Arguments { get; init; }
+}
diff --git a/Common/Collections/QueueMessageDispatcher.cs b/Common/Collections/QueueMessageDispatcher.cs
new file mode 100644
index 0000000..2db7ed4
--- /dev/null
+++ b/Common/Collections/QueueMessageDispatcher.cs
@@ -0,0 +1,10 @@
+using Connected.Collections.Concurrent;
+using Connected.Collections.Queues;
+
+namespace Common.Collections;
+internal sealed class QueueMessageDispatcher : Dispatcher
+{
+ public QueueMessageDispatcher() : base(128)
+ {
+ }
+}
diff --git a/Common/Collections/QueueOps.cs b/Common/Collections/QueueOps.cs
new file mode 100644
index 0000000..3a4a9f4
--- /dev/null
+++ b/Common/Collections/QueueOps.cs
@@ -0,0 +1,98 @@
+using System.Collections.Immutable;
+using Connected.Collections.Queues;
+using Connected.Entities;
+using Connected.Interop;
+using Connected.Services;
+
+namespace Common.Collections;
+
+internal sealed class QueueOps
+{
+ public sealed class Dequeue : ServiceFunction>
+ {
+ public Dequeue(IQueueCache cache)
+ {
+ Cache = cache;
+ }
+
+ private IQueueCache Cache { get; }
+
+ protected override async Task?> OnInvoke()
+ {
+ var targets = await SelectTargets();
+ var result = new List();
+
+ if (!targets.Any())
+ return ImmutableList.Empty;
+
+ foreach (var message in targets)
+ {
+ var modified = new QueueMessage
+ {
+ DequeueTimestamp = DateTime.UtcNow,
+ Arguments = message.Arguments,
+ Created = message.Created,
+ DequeueCount = message.DequeueCount + 1,
+ Id = message.Id,
+ NextVisible = DateTime.UtcNow.Add(Arguments.NextVisible),
+ PopReceipt = Guid.NewGuid(),
+ Queue = message.Queue,
+ State = State.Default,
+ Sync = message.Sync,
+ ETag = message.ETag
+ };
+
+ try
+ {
+ Cache.Update(modified);
+ result.Add(modified);
+ }
+ catch
+ {
+ //concurrent exception, someone was faster
+ }
+ }
+
+ return result.ToImmutableList();
+ }
+
+ private async Task> SelectTargets()
+ {
+ var targets = new List();
+
+ var items = await (from dc in Cache
+ where dc.NextVisible <= DateTime.UtcNow
+ && dc.Arguments.Options.Expire > DateTime.UtcNow
+ && Arguments.Queues.Any(f => string.Equals(f, dc.Queue, StringComparison.OrdinalIgnoreCase))
+ select dc).AsEntities();
+
+ if (!items.Any())
+ return ImmutableList.Empty;
+
+ var ordered = targets.OrderBy(f => f.NextVisible).ThenBy(f => f.Id);
+
+ if (ordered.Count() <= Arguments.MaxCount)
+ return ordered.ToImmutableList();
+
+ return ordered.Take(Arguments.MaxCount).ToImmutableList();
+ }
+ }
+ public sealed class Enqueue : ServiceAction
+ {
+ public Enqueue(IQueueCache cache)
+ {
+ Cache = cache;
+ }
+
+ private IQueueCache Cache { get; }
+
+ protected override Task OnInvoke()
+ {
+ var message = Serializer.Serialize(Arguments);
+
+ Cache.Update(Arguments.AsEntity(State.New, new { Arguments, SerializedMessage = message, Queue = typeof(TClient).FullName }));
+
+ return Task.CompletedTask;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Common/Collections/QueueService.cs b/Common/Collections/QueueService.cs
new file mode 100644
index 0000000..3e27f37
--- /dev/null
+++ b/Common/Collections/QueueService.cs
@@ -0,0 +1,40 @@
+using System.Collections.Immutable;
+using Connected.Collections;
+using Connected.Collections.Queues;
+using Connected.Net;
+using Connected.ServiceModel;
+using Connected.Services;
+using Ops = Common.Collections.QueueOps;
+
+namespace Common.Collections;
+
+internal sealed class QueueService : DistributedService, IQueueService
+{
+ public QueueService(IContext context) : base(context)
+ {
+ }
+
+ public async Task> Dequeue(DequeueArgs args)
+ {
+ if (await IsServer())
+ return await Invoke(GetOperation(), args) ?? ImmutableList.Empty;
+
+ if (await Http.Get>(await ParseUrl(CollectionRoutes.Queue), args) is List result)
+ return result.ToImmutableList();
+
+ return ImmutableList.Empty;
+ }
+
+ public async Task Enqueue(TArgs args)
+ where TClient : IQueueClient
+ where TArgs : QueueArgs
+ {
+ if (await IsServer())
+ {
+ await Invoke(GetOperation>(), args);
+ return;
+ }
+
+ await Http.Post>(await ParseUrl(CollectionRoutes.Queue), new object[] { typeof(TClient), args });
+ }
+}
diff --git a/Common/Common.csproj b/Common/Common.csproj
new file mode 100644
index 0000000..4f9d9e2
--- /dev/null
+++ b/Common/Common.csproj
@@ -0,0 +1,36 @@
+
+
+
+ net7.0
+ enable
+ enable
+ $(MSBuildProjectName)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ True
+ True
+ SR.resx
+
+
+
+
+
+ ResXFileCodeGenerator
+ SR.Designer.cs
+
+
+
+
+
diff --git a/Common/CommonSchemas.cs b/Common/CommonSchemas.cs
new file mode 100644
index 0000000..ea061b8
--- /dev/null
+++ b/Common/CommonSchemas.cs
@@ -0,0 +1,6 @@
+namespace Common;
+public static class CommonSchemas
+{
+ public const string DocumentSchema = "dcm";
+ public const string CommonSchema = "cmn";
+}
diff --git a/Common/CommonStartup.cs b/Common/CommonStartup.cs
new file mode 100644
index 0000000..a8617a6
--- /dev/null
+++ b/Common/CommonStartup.cs
@@ -0,0 +1,39 @@
+using Common.Documents;
+using Common.Globalization;
+using Common.Security.Identity;
+using Connected;
+using Connected.Annotations;
+using Connected.Globalization;
+using Connected.Net.Endpoints;
+using Connected.Net.Server;
+using Connected.Security.Identity;
+using Connected.ServiceModel;
+using Microsoft.Extensions.DependencyInjection;
+
+[assembly: MicroService(MicroServiceType.Service)]
+
+namespace Common
+{
+ internal class CommonStartup : Startup
+ {
+ protected override void OnConfigureServices(IServiceCollection services)
+ {
+ services.AddScoped(typeof(IGlobalizationService), typeof(GlobalizationService));
+ services.AddScoped(typeof(IIdentityService), typeof(IdentityService));
+ services.AddTransient(typeof(IDocumentLocker<,>), typeof(DocumentLocker<,>));
+ }
+
+ protected override async Task OnInitialize(Dictionary args)
+ {
+ if (Services is null || Services.GetService() is not IContextProvider provider)
+ return;
+
+ using var ctx = provider.Create();
+
+ if (ctx.GetService() is not IEndpointService endpoints || ctx.GetService() is not IEndpointServer server)
+ return;
+
+ await server.Initialize(await endpoints.Query(), ctx.CancellationToken);
+ }
+ }
+}
diff --git a/Common/Definitions.cs b/Common/Definitions.cs
new file mode 100644
index 0000000..a754b20
--- /dev/null
+++ b/Common/Definitions.cs
@@ -0,0 +1,19 @@
+using Connected.Data;
+
+namespace Common;
+
+public static class RecordStatusLocalizer
+{
+ public static string Localize(Status status) =>
+ status switch
+ {
+ Status.Enabled => SR.RecordStatusEnabled,
+ Status.Disabled => SR.RecordStatusDisabled,
+ _ => status.ToString()
+ };
+}
+
+public static class Constants
+{
+ public const string CommonSchema = "cmn";
+}
diff --git a/Common/Distributed/DistributedLock.cs b/Common/Distributed/DistributedLock.cs
new file mode 100644
index 0000000..6e8fae0
--- /dev/null
+++ b/Common/Distributed/DistributedLock.cs
@@ -0,0 +1,22 @@
+using System.ComponentModel.DataAnnotations;
+using Connected.Annotations;
+using Connected.Entities.Annotations;
+using Connected.ServiceModel.Annotations;
+using Connected.ServiceModel.Data;
+
+namespace Common.Distributed;
+
+[Table(Schema = CommonSchemas.CommonSchema)]
+internal sealed record DistributedLock : TableEntity, IDistributedLock
+{
+ public const string EntityKey = $"{SchemaAttribute.SysSchema}.{nameof(DistributedLock)}";
+
+ [Ordinal(-50000), PartitionKey, MaxLength(128)]
+ public string Entity { get; init; } = default!;
+
+ [Ordinal(-49000), PrimaryKey, MaxLength(128)]
+ public string EntityId { get; init; } = default!;
+
+ [Ordinal(1)]
+ public DateTimeOffset Expiration { get; init; }
+}
diff --git a/Common/Distributed/DistributedLockArgs.cs b/Common/Distributed/DistributedLockArgs.cs
new file mode 100644
index 0000000..4e719cd
--- /dev/null
+++ b/Common/Distributed/DistributedLockArgs.cs
@@ -0,0 +1,19 @@
+using Connected.ServiceModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Common.Distributed;
+public sealed class DistributedLockArgs : Dto
+{
+ [Required, MaxLength(128)]
+ public string Entity { get; set; } = default!;
+
+ [Required, MaxLength(128)]
+ public string EntityId { get; set; } = default!;
+
+ public TimeSpan? Duration { get; set; }
+}
+
+public sealed class DistributedLockPingArgs : PrimaryKeyArgs
+{
+ public TimeSpan? Duration { get; set; }
+}
\ No newline at end of file
diff --git a/Common/Distributed/DistributedLockOps.cs b/Common/Distributed/DistributedLockOps.cs
new file mode 100644
index 0000000..cfb5317
--- /dev/null
+++ b/Common/Distributed/DistributedLockOps.cs
@@ -0,0 +1,126 @@
+using Connected.Caching;
+using Connected.Entities;
+using Connected.Entities.Storage;
+using Connected.ServiceModel;
+using Connected.Services;
+
+namespace Common.Distributed;
+internal sealed class DistributedLockOps
+{
+ public class Lock : ServiceFunction
+ {
+ static Lock()
+ {
+ SynchronizationState = new();
+ }
+ private static HashSet SynchronizationState { get; }
+
+ public Lock(IStorageProvider storage, ICacheContext cache)
+ {
+ Cache = cache;
+ Storage = storage;
+ }
+
+ private ICacheContext Cache { get; }
+ private IStorageProvider Storage { get; }
+ private IDistributedLock Entity { get; set; }
+ protected override async Task OnInvoke()
+ {
+ /*
+ * We must ensure that only one lock request is performed at a time for each entity. This is
+ * because we must guarantee that one and only one entry exist for each entity and its primary key.
+ */
+ var key = $"{Arguments.Entity}.{Arguments.EntityId}".ToLowerInvariant();
+ /*
+ * If the hashset already holds the key it means someone else was faster than we and we are not able to
+ * perform a lock.
+ */
+ if (!SynchronizationState.Add(key))
+ throw new SynchronizationLockException($"{SR.ValLock} ({key})");
+
+ try
+ {
+ Entity = await Storage.Open().Update(Arguments.AsEntity(State.New, new
+ {
+ Id = Guid.NewGuid(),
+ Expiration = DateTime.UtcNow.Add(Arguments.Duration ?? TimeSpan.FromSeconds(5))
+ }));
+
+ return Entity.Id;
+ }
+ finally
+ {
+ /*
+ * Free to remove the hash lock. Any subsequent lock requests will fail at data protection level.
+ */
+ SynchronizationState.Remove(key);
+ }
+ }
+
+ protected override async Task OnCommitted()
+ {
+ /*
+ * Put the lock it the local cache.
+ */
+ Cache.Set(DistributedLock.EntityKey, Entity.Id, Entity, TimeSpan.Zero);
+
+ await Task.CompletedTask;
+ }
+ }
+
+ public sealed class Unlock : ServiceAction>
+ {
+ public Unlock(IStorageProvider storage, ICacheContext cache)
+ {
+ Storage = storage;
+ Cache = cache;
+ }
+
+ private IStorageProvider Storage { get; }
+ private ICacheContext Cache { get; }
+
+ protected override async Task OnInvoke()
+ {
+ await Storage.Open().Update(Arguments.AsEntity(State.Deleted));
+ }
+
+ protected override async Task OnCommitted()
+ {
+ await Cache.Remove(DistributedLock.EntityKey, Arguments.Id);
+ }
+ }
+
+ public sealed class Ping : ServiceAction
+ {
+ public Ping(IStorageProvider storage, ICacheContext cache)
+ {
+ Storage = storage;
+ Cache = cache;
+ }
+
+ private IStorageProvider Storage { get; }
+ private ICacheContext Cache { get; }
+
+ protected override async Task OnInvoke()
+ {
+ var entity = await Cache.Get(DistributedLock.EntityKey, Arguments.Id,
+ async (f) =>
+ {
+ return await (from dc in Storage.Open() where dc.Id == Arguments.Id select dc).AsEntity();
+ });
+
+ if (entity is not null)
+ {
+ await Storage.Open().Update(Arguments.AsEntity(State.Default, new
+ {
+ Expiration = DateTime.UtcNow.Add(Arguments.Duration ?? TimeSpan.FromSeconds(5))
+ }));
+ }
+ }
+
+ protected override async Task OnCommitted()
+ {
+ await Cache.Remove(DistributedLock.EntityKey, Arguments.Id);
+ }
+ }
+}
diff --git a/Common/Distributed/DistributedLockProtector.cs b/Common/Distributed/DistributedLockProtector.cs
new file mode 100644
index 0000000..702722d
--- /dev/null
+++ b/Common/Distributed/DistributedLockProtector.cs
@@ -0,0 +1,73 @@
+using Connected.Caching;
+using Connected.Data.DataProtection;
+using Connected.Data.EntityProtection;
+using Connected.Entities;
+using Connected.Entities.Storage;
+using Connected.Middleware;
+
+namespace Common.Distributed;
+internal sealed class DistributedLockProtector : MiddlewareComponent, IEntityProtector
+{
+ public DistributedLockProtector(IStorageProvider storage, ICacheContext cache)
+ {
+ Storage = storage;
+ Cache = cache;
+ }
+
+ private IStorageProvider Storage { get; }
+ private ICacheContext Cache { get; }
+
+ public async Task Invoke(EntityProtectionArgs args)
+ {
+ /*
+ * We don't care for deleted entities.
+ */
+ if (args.State == State.Deleted)
+ return;
+ /*
+ * The most important thing is to prevent duplicate inserts. If the lock
+ * already exists we will reject the transaction. Each entity record can have
+ * only one entry in the distributed lock table.
+ */
+ if (args.State == State.New)
+ {
+ /*
+ * First we'll look into the memory cache.
+ */
+ var existing = Cache.Get(DistributedLock.EntityKey,
+ f => string.Equals(f.Entity, args.Entity.Entity, StringComparison.OrdinalIgnoreCase)
+ && string.Equals(f.EntityId, args.Entity.EntityId, StringComparison.OrdinalIgnoreCase));
+ /*
+ * If the cache entry exists and holds a valid lock, we simply reject the transaction.
+ */
+ if (existing is not null && existing.Expiration > DateTime.UtcNow)
+ throw new InvalidOperationException($"{SR.ValLock} ({args.Entity.Entity}, {args.Entity.EntityId})");
+ /*
+ * Entry doesn't exist in the cache. Let's look in the storage. Note that this scenarios is unusual because
+ * locks tend to be short and there are only two possible schenarios that lock exists in the storage but not
+ * in the cache:
+ * - the process has rebooted
+ * - the master has changed. It this case all distributed services are redirected to the new master which must
+ * load record by record from the cache. In this case it's most probably that all locks already expired but we
+ * must check that anyway
+ */
+ var entry = await (from dc in Storage.Open()
+ where string.Equals(dc.Entity, args.Entity.Entity, StringComparison.OrdinalIgnoreCase)
+ && string.Equals(dc.EntityId, args.Entity.EntityId, StringComparison.OrdinalIgnoreCase)
+ select dc).AsEntity();
+ /*
+ * It exists, we must perform additional checks.
+ */
+ if (entry is not null)
+ {
+ /*
+ * Set it in the cache so it gets deleted by the recycling service.
+ */
+ Cache.Set(DistributedLock.EntityKey, entry.Id, entry, TimeSpan.Zero);
+
+ if (entry.Expiration > DateTime.UtcNow)
+ throw new InvalidOperationException($"{SR.ValLock} ({args.Entity.Entity}, {args.Entity.EntityId})");
+ }
+ }
+ }
+}
diff --git a/Common/Distributed/DistributedLockService.cs b/Common/Distributed/DistributedLockService.cs
new file mode 100644
index 0000000..6e99be2
--- /dev/null
+++ b/Common/Distributed/DistributedLockService.cs
@@ -0,0 +1,32 @@
+using Connected.ServiceModel;
+using Connected.Services;
+using Ops = Common.Distributed.DistributedLockOps;
+
+namespace Common.Distributed;
+internal sealed class DistributedLockService : DistributedService, IDistributedLockService
+{
+ public DistributedLockService(IContext context) : base(context)
+ {
+ }
+ ///
+ /// This method performs a distribubuted lock on an entity. If the lock
+ /// cannot be obtained the exception is thrown.
+ ///
+ /// The arguments containing the entity on which
+ /// a distributed lock will be performed.
+ /// The key of a newly acquired lock.
+ public async Task Lock(DistributedLockArgs args)
+ {
+ return await Invoke(GetOperation(), args);
+ }
+
+ public async Task Ping(DistributedLockPingArgs args)
+ {
+ await Invoke(GetOperation(), args);
+ }
+
+ public async Task Unlock(PrimaryKeyArgs args)
+ {
+ await Invoke(GetOperation(), args);
+ }
+}
diff --git a/Common/Distributed/IDistributedLock.cs b/Common/Distributed/IDistributedLock.cs
new file mode 100644
index 0000000..20b518a
--- /dev/null
+++ b/Common/Distributed/IDistributedLock.cs
@@ -0,0 +1,9 @@
+using Connected.Data;
+
+namespace Common.Distributed;
+public interface IDistributedLock : IPrimaryKey
+{
+ string Entity { get; init; }
+ string EntityId { get; init; }
+ DateTimeOffset Expiration { get; init; }
+}
diff --git a/Common/Distributed/IDistributedLockService.cs b/Common/Distributed/IDistributedLockService.cs
new file mode 100644
index 0000000..37c661e
--- /dev/null
+++ b/Common/Distributed/IDistributedLockService.cs
@@ -0,0 +1,12 @@
+using Connected.Annotations;
+using Connected.ServiceModel;
+
+namespace Common.Distributed;
+
+[Service]
+public interface IDistributedLockService
+{
+ Task Lock(DistributedLockArgs args);
+ Task Unlock(PrimaryKeyArgs args);
+ Task Ping(DistributedLockPingArgs args);
+}
diff --git a/Common/Documents/Document.cs b/Common/Documents/Document.cs
new file mode 100644
index 0000000..6069d84
--- /dev/null
+++ b/Common/Documents/Document.cs
@@ -0,0 +1,32 @@
+using System.ComponentModel.DataAnnotations;
+using Connected.Annotations;
+using Connected.Entities.Annotations;
+using Connected.Entities.Consistency;
+
+namespace Common.Documents;
+///
+/// Default implementation of the interface.
+///
+/// The type of the primary key used by document.
+/// This is usually int or long.
+///
+[Table(Schema = CommonSchemas.DocumentSchema)]
+public abstract record Document : ConsistentEntity, IDocument
+ where TPrimaryKey : notnull
+{
+ ///
+ [Ordinal(-1007), Date(Kind = DateKind.SmallDateTime)]
+ public DateTimeOffset Created { get; init; }
+ ///
+ [Ordinal(-1006), Nullable, Date(Kind = DateKind.SmallDateTime)]
+ public DateTimeOffset? Modified { get; init; }
+ ///
+ [Ordinal(-1005), MaxLength(32), Nullable]
+ public string? Code { get; init; } = default!;
+ ///
+ [Ordinal(-1004), Nullable]
+ public int? Author { get; init; }
+ ///
+ [Ordinal(-1003), Nullable]
+ public int? Owner { get; init; }
+}
diff --git a/Common/Documents/DocumentListener.cs b/Common/Documents/DocumentListener.cs
new file mode 100644
index 0000000..6678b1b
--- /dev/null
+++ b/Common/Documents/DocumentListener.cs
@@ -0,0 +1,60 @@
+using Connected;
+using Connected.Notifications.Events;
+
+namespace Common.Documents;
+public abstract class DocumentListener : EventListener
+ where TArgs : IDto
+ where TDocument : IDocument
+ where TPrimaryKey : notnull
+{
+ public DocumentListener(IDocumentLocker locker)
+ {
+ Locker = locker;
+ }
+
+ private IDocumentLocker Locker { get; }
+ protected abstract TDocument Document { get; }
+ protected override async Task OnInvoke()
+ {
+ await OnPreparing();
+
+ if (Document is null)
+ return;
+
+ Locker.Expired += async (s, e) => { await OnException(new TimeoutException()); };
+
+ try
+ {
+ await Locker.Lock(Document);
+ await OnInvoking();
+ await Locker.Unlock();
+ }
+ catch (Exception ex)
+ {
+ await Locker.Unlock();
+ await OnException(ex);
+ }
+ }
+
+ protected virtual async Task OnPreparing()
+ {
+ await Task.CompletedTask;
+
+ return false;
+ }
+
+ protected virtual async Task OnInvoking()
+ {
+ await Task.CompletedTask;
+ }
+
+ protected virtual async Task OnException(Exception ex)
+ {
+ await Task.CompletedTask;
+ }
+
+ protected override void OnDisposing()
+ {
+ Locker.Dispose();
+ }
+}
diff --git a/Common/Documents/DocumentLocker.cs b/Common/Documents/DocumentLocker.cs
new file mode 100644
index 0000000..ddf3770
--- /dev/null
+++ b/Common/Documents/DocumentLocker.cs
@@ -0,0 +1,96 @@
+using Common.Distributed;
+using Connected;
+using Connected.Interop;
+using Connected.ServiceModel;
+using Connected.Threading;
+
+namespace Common.Documents;
+internal sealed class DocumentLocker : IDocumentLocker
+ where TDocument : IDocument
+ where TPrimaryKey : notnull
+
+{
+ public event EventHandler? Expired;
+ public DocumentLocker(IDistributedLockService locking)
+ {
+ Locking = locking;
+ Cancel = new();
+ }
+
+ private bool IsDisposed { get; set; }
+ public Guid Key { get; private set; }
+ private IDistributedLockService Locking { get; }
+
+ public TimeSpan LockTimeout { get; set; } = TimeSpan.FromSeconds(2);
+ public TimeSpan Timeout { get; set; } = TimeSpan.FromMilliseconds(1500);
+ public TimeSpan Lifetime { get; set; } = TimeSpan.FromSeconds(30);
+ private CancellationTokenSource Cancel { get; }
+ public async Task Lock(TDocument document)
+ {
+ var id = TypeConversion.Convert(document.Id);
+
+ if (string.IsNullOrEmpty(id))
+ throw new NullReferenceException(nameof(document.Id));
+ /*
+ * Obtain the lock on the document to ensure no other process
+ * will change it while we are updating the stock. If the lock
+ * could not be obtained the exception will be thrown.
+ */
+ Key = await Locking.Lock(new DistributedLockArgs
+ {
+ Duration = LockTimeout,
+ Entity = typeof(TDocument).Name,
+ EntityId = id
+ });
+ /*
+ * Make sure we don't get outdated by using the scheduled
+ * task which will ping the lock before it expires.
+ */
+ using var timeout = new ScheduledTask(async () =>
+ {
+ await Locking.Ping(new DistributedLockPingArgs
+ {
+ Id = Key,
+ Duration = LockTimeout
+ });
+ }, async () =>
+ {
+ await Unlock();
+
+ Expired?.Invoke(this, EventArgs.Empty);
+ }, Timeout, Lifetime, Cancel.Token);
+
+ timeout.Start();
+ }
+
+ public async Task Unlock()
+ {
+ /*
+ * Finally release the lock on the document so it becomes
+ * updatable again.
+ */
+ await Locking.Unlock(new PrimaryKeyArgs { Id = Key });
+ }
+
+ private void Dispose(bool disposing)
+ {
+ if (!IsDisposed)
+ {
+ if (disposing)
+ {
+ AsyncUtils.RunSync(Unlock);
+
+ Cancel.Cancel();
+ Cancel.Dispose();
+ }
+
+ IsDisposed = true;
+ }
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/Common/Documents/DocumentService.cs b/Common/Documents/DocumentService.cs
new file mode 100644
index 0000000..a9c55df
--- /dev/null
+++ b/Common/Documents/DocumentService.cs
@@ -0,0 +1,15 @@
+using Connected.Notifications;
+using Connected.ServiceModel;
+using Connected.Services;
+
+namespace Common.Documents;
+public abstract class DocumentService : EntityService, IDocumentService
+{
+ protected DocumentService(IContext context) : base(context)
+ {
+ }
+
+ public event ServiceEventHandler>? ItemInserted;
+ public event ServiceEventHandler>? ItemUpdated;
+ public event ServiceEventHandler>? ItemDeleted;
+}
diff --git a/Common/Documents/IDocumentLocker.cs b/Common/Documents/IDocumentLocker.cs
new file mode 100644
index 0000000..3167428
--- /dev/null
+++ b/Common/Documents/IDocumentLocker.cs
@@ -0,0 +1,13 @@
+namespace Common.Documents;
+public interface IDocumentLocker : IDisposable
+ where TDocument : IDocument
+ where TPrimaryKey : notnull
+{
+ event EventHandler? Expired;
+ Guid Key { get; }
+ TimeSpan LockTimeout { get; set; }
+ TimeSpan Timeout { get; set; }
+ TimeSpan Lifetime { get; set; }
+ Task Lock(TDocument document);
+ Task Unlock();
+}
diff --git a/Common/Documents/Validators.cs b/Common/Documents/Validators.cs
new file mode 100644
index 0000000..4c21332
--- /dev/null
+++ b/Common/Documents/Validators.cs
@@ -0,0 +1,61 @@
+using Connected.Middleware;
+using Connected.Security.Identity;
+using Connected.Validation;
+
+namespace Common.Documents;
+public abstract class InsertDocumentValidator : MiddlewareComponent, IValidator
+ where TDocumentArgs : InsertDocumentArgs
+{
+ public InsertDocumentValidator(IUserService users)
+ {
+ Users = users;
+ }
+ protected IUserService Users { get; }
+ protected TDocumentArgs Arguments { get; private set; }
+ public async Task Validate(TDocumentArgs args)
+ {
+ Arguments = args;
+
+ await ValidateAuthor();
+ }
+
+ protected virtual async Task OnValidating()
+ {
+ await Task.CompletedTask;
+ }
+
+ private async Task ValidateAuthor()
+ {
+ if (Arguments.Author is null)
+ return;
+
+ if (await Users.Select(Arguments.Author) is null)
+ throw new ArgumentException(nameof(Arguments.Author));
+ }
+}
+
+public abstract class UpdateDocumentValidator : MiddlewareComponent, IValidator
+ where TDocumentArgs : UpdateDocumentArgs
+ where TPrimaryKey : notnull
+{
+ public UpdateDocumentValidator(IUserService users)
+ {
+ Users = users;
+ }
+
+ protected IUserService Users { get; }
+
+ public async Task Validate(TDocumentArgs args)
+ {
+ await ValidateOwner(args);
+ }
+
+ private async Task ValidateOwner(TDocumentArgs args)
+ {
+ if (args.Owner is null)
+ return;
+
+ if (await Users.Select(args.Owner) is null)
+ throw new ArgumentException(nameof(args.Owner));
+ }
+}
\ No newline at end of file
diff --git a/Common/Globalization/GlobalizationService.cs b/Common/Globalization/GlobalizationService.cs
new file mode 100644
index 0000000..8fbc187
--- /dev/null
+++ b/Common/Globalization/GlobalizationService.cs
@@ -0,0 +1,105 @@
+using System.Globalization;
+using Connected;
+using Connected.Data;
+using Connected.Globalization;
+using Connected.Globalization.Languages;
+using Connected.Security.Identity;
+using Connected.ServiceModel;
+
+namespace Common.Globalization;
+
+internal class GlobalizationService : IGlobalizationService
+{
+ private ILanguage _language;
+ private CultureInfo _culture;
+ public GlobalizationService(IIdentityService identityService, ILanguageService languageService)
+ {
+ IdentityService = identityService;
+ LanguageService = languageService;
+ }
+
+ private IIdentityService IdentityService { get; }
+ private ILanguageService LanguageService { get; }
+
+ public TimeZoneInfo TimeZone
+ {
+ get
+ {
+ if (!IdentityService.IsAuthenticated || IdentityService.CurrentUser is null)
+ return TimeZoneInfo.Utc;
+ else
+ {
+ try
+ {
+ if (TimeZoneInfo.FindSystemTimeZoneById(IdentityService.CurrentUser.TimeZone) is not TimeZoneInfo timezone)
+ return TimeZoneInfo.Utc;
+ else
+ return timezone;
+ }
+ catch
+ {
+ return TimeZoneInfo.Utc;
+ }
+ }
+
+ }
+ }
+
+ public DateTimeOffset Now => FromUtc(DateTimeOffset.UtcNow);
+
+ public async Task GetCurrentLanguage()
+ {
+ if (_language is null)
+ {
+ if (IdentityService.CurrentUser is not null && IdentityService.CurrentUser.Language > 0)
+ _language = await LanguageService.Select(new PrimaryKeyArgs() { Id = IdentityService.CurrentUser.Language });
+
+ if (_language is not null && _language.Status == Status.Disabled)
+ _language = null;
+ }
+
+ return _language;
+ }
+ public async Task GetCurrentCulture()
+ {
+ if (_culture is null)
+ {
+ if (await GetCurrentLanguage() is not ILanguage language)
+ _culture = Thread.CurrentThread.CurrentUICulture;
+ else
+ {
+ try
+ {
+ if (CultureInfo.GetCultureInfo(language.Lcid) is not CultureInfo culture)
+ _culture = Thread.CurrentThread.CurrentUICulture;
+ }
+ catch
+ {
+ _culture = Thread.CurrentThread.CurrentUICulture;
+ }
+ }
+ }
+
+ return _culture;
+ }
+
+ public DateTime FromUtc(DateTime value)
+ {
+ return DateExtensions.FromUtc(value, TimeZone);
+ }
+
+ public DateTimeOffset FromUtc(DateTimeOffset value)
+ {
+ return DateExtensions.FromUtc(value, TimeZone);
+ }
+
+ public DateTime ToUtc(DateTime value)
+ {
+ return DateExtensions.ToUtc(value, TimeZone);
+ }
+
+ public DateTimeOffset ToUtc(DateTimeOffset value)
+ {
+ return DateExtensions.ToUtc(value, TimeZone);
+ }
+}
diff --git a/Common/Globalization/Language.cs b/Common/Globalization/Language.cs
new file mode 100644
index 0000000..cfa9499
--- /dev/null
+++ b/Common/Globalization/Language.cs
@@ -0,0 +1,31 @@
+using Connected.Annotations;
+using Connected.Data;
+using Connected.Entities.Annotations;
+using Connected.Entities.Consistency;
+using Connected.Globalization.Languages;
+
+namespace Common.Globalization;
+
+///
+/// The implementation of the entity.
+///
+[Table(Schema = SchemaAttribute.SysSchema)]
+internal record Language : ConsistentEntity, ILanguage
+{
+ public const string CacheKey = $"{SchemaAttribute.SysSchema}.{nameof(Language)}";
+
+ [Length(128)]
+ [Ordinal(0)]
+ public string? Name { get; init; }
+
+ [Ordinal(1)]
+ public int Lcid { get; init; }
+
+ [Default(Status.Enabled)]
+ [Ordinal(2)]
+ public Status Status { get; init; }
+
+ [Length(128)]
+ [Ordinal(3)]
+ public string? Mappings { get; init; }
+}
diff --git a/Common/Globalization/LanguageCache.cs b/Common/Globalization/LanguageCache.cs
new file mode 100644
index 0000000..61d4a74
--- /dev/null
+++ b/Common/Globalization/LanguageCache.cs
@@ -0,0 +1,77 @@
+using System.Collections.Immutable;
+using Connected.Entities.Caching;
+using Connected.ServiceModel;
+
+namespace Common.Globalization;
+
+internal interface ILanguageCache : IEntityCacheClient
+{
+ Language? Select(string mappings);
+}
+///
+/// Represents stateful cache of the entities.
+///
+internal class LanguageCache : EntityCacheClient, ILanguageCache
+{
+ private readonly Dictionary _mappings;
+ public LanguageCache(IEntityCacheContext context) : base(context, Language.CacheKey)
+ {
+ _mappings = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ }
+
+ private Dictionary Mappings => _mappings;
+ protected override async Task> OnInitializing(IContext context)
+ {
+ var result = await base.OnInitializing(context);
+
+ await ResetMappings();
+
+ return result;
+ }
+
+ protected override async Task OnInvalidating(IContext context, int id)
+ {
+ var result = await base.OnInvalidating(context, id);
+
+ await ResetMappings();
+
+ return result;
+ }
+
+ public Language? Select(string mappings)
+ {
+ if (string.IsNullOrWhiteSpace(mappings))
+ return null;
+
+ var tokens = mappings.Split(',', StringSplitOptions.RemoveEmptyEntries);
+
+ foreach (var token in tokens)
+ {
+ if (Mappings.TryGetValue(token.Trim(), out Language? language))
+ return language;
+ }
+
+ return null;
+ }
+
+ private async Task ResetMappings()
+ {
+ Mappings.Clear();
+
+ foreach (var language in await All())
+ {
+ if (string.IsNullOrWhiteSpace(language.Mappings))
+ continue;
+
+ var tokens = language.Mappings.Split(',', StringSplitOptions.RemoveEmptyEntries);
+
+ foreach (var token in tokens)
+ {
+ if (Mappings.TryGetValue(token.Trim(), out _))
+ continue;
+
+ Mappings.Add(token.Trim(), language);
+ }
+ }
+ }
+}
diff --git a/Common/Globalization/LanguageOps.cs b/Common/Globalization/LanguageOps.cs
new file mode 100644
index 0000000..69fe4e2
--- /dev/null
+++ b/Common/Globalization/LanguageOps.cs
@@ -0,0 +1,234 @@
+using System.Collections.Immutable;
+using Connected;
+using Connected.Entities;
+using Connected.Entities.Storage;
+using Connected.Globalization.Languages;
+using Connected.Notifications.Events;
+using Connected.ServiceModel;
+using Connected.Services;
+
+namespace Common.Globalization;
+
+///
+/// Queries all records except those marked as deleting.
+///
+internal sealed class QueryLanguages : ServiceFunction?>
+{
+ public QueryLanguages(ILanguageCache cache)
+ {
+ Cache = cache;
+ }
+
+ private ILanguageCache Cache { get; }
+ protected override async Task?> OnInvoke()
+ {
+ /*
+ * Filter records to return only currently valid record. Those with
+ * deleting state should never be returned to any client.
+ */
+ return await (from dc in Cache
+ select dc).AsEntities();
+ }
+}
+///
+/// Queries the records for the specified set of ids.
+///
+internal sealed class LookupLanguages : ServiceFunction, ImmutableList>
+{
+ public LookupLanguages(ILanguageCache cache)
+ {
+ Cache = cache;
+ }
+
+ private ILanguageCache Cache { get; }
+ protected override async Task?> OnInvoke()
+ {
+ if (Arguments?.IdList is null)
+ return default;
+
+ return await (from dc in Cache
+ where Arguments.IdList.Any(f => f == dc.Id)
+ select dc).AsEntities();
+ }
+}
+///
+/// Returns first with matches the provided mapping.
+///
+internal sealed class ResolveLanguage : ServiceFunction
+{
+ public ResolveLanguage(ILanguageCache cache)
+ {
+ Cache = cache;
+ }
+
+ private ILanguageCache Cache { get; }
+ protected override Task OnInvoke()
+ {
+ return Task.FromResult(Cache.Select(Arguments.Mapping));
+ }
+}
+///
+/// Returns with the specified id or null
+/// if the record for the specified id does not exist.
+///
+internal sealed class SelectLanguage : ServiceFunction, ILanguage?>
+{
+ public SelectLanguage(ILanguageCache cache)
+ {
+ Cache = cache;
+ }
+
+ private ILanguageCache Cache { get; }
+ protected override async Task OnInvoke()
+ {
+ return await (from dc in Cache
+ where dc.Id == Arguments.Id
+ select dc).AsEntity();
+ }
+}
+///
+/// Returns with the specified name and rate or null if
+/// the record with the specified arguments does not exist.
+///
+internal sealed class SelectLanguageByName : ServiceFunction
+{
+ public SelectLanguageByName(ILanguageCache cache)
+ {
+ Cache = cache;
+ }
+
+ private ILanguageCache Cache { get; }
+
+ protected override async Task OnInvoke()
+ {
+ return await (from dc in Cache
+ where string.Equals(dc.Name, Arguments.Name, StringComparison.OrdinalIgnoreCase)
+ select dc).AsEntity();
+ }
+}
+///
+/// Inserts a new and returns its Id.
+///
+internal sealed class InsertLanguage : ServiceFunction
+{
+ public InsertLanguage(ILanguageService languageService, IStorageProvider storage, IEventService events, ILanguageCache cache)
+ {
+ LanguageService = languageService;
+ Storage = storage;
+ Events = events;
+ Cache = cache;
+ }
+ ///
+ /// We need this service to call a distribute event.
+ ///
+ private ILanguageService LanguageService { get; }
+ private IStorageProvider Storage { get; }
+ private IEventService Events { get; }
+ private ILanguageCache Cache { get; }
+
+ protected override async Task OnInvoke()
+ {
+ /*
+ * First, create a new entity from the passed arguments and mark its state as new. This will
+ * signal the DatabaseContext to perform an insert operation when calling the Update.
+ */
+ var entity = Arguments.AsEntity(State.New);
+ /*
+ * Call update on the DatabaseContext. This call will return a new ILanguage of the inserted
+ * entity.
+ */
+ var result = await Storage.Open().Update(entity);
+ /*
+ * Return a newly inserted id to the caller.
+ */
+ return result.Id;
+ }
+
+ protected override async Task OnCommitted()
+ {
+ await Cache.Refresh(Result);
+ /*
+ * If ILanguageServer is our implementation (and should be) it's a IServerNotificationsTriggers
+ * for sure.
+ */
+ await Events.Enqueue(this, LanguageService, ServiceEvents.Inserted, Result);
+ }
+}
+internal sealed class DeleteLanguage : ServiceAction>
+{
+ public DeleteLanguage(ILanguageService languageService, IStorageProvider storage, ILanguageCache cache, IEventService events)
+ {
+ LanguageService = languageService;
+ Storage = storage;
+ Cache = cache;
+ Events = events;
+ }
+
+ private ILanguageService LanguageService { get; }
+ private IStorageProvider Storage { get; }
+ private ILanguageCache Cache { get; }
+ private IEventService Events { get; }
+
+ protected override async Task OnInvoke()
+ {
+ var entity = new Language { Id = Arguments.Id, State = State.Deleted };
+
+ await Storage.Open().Update(entity);
+ }
+
+ protected override async Task OnCommitted()
+ {
+ await Cache.Remove(Arguments.Id);
+ await Events.Enqueue(this, LanguageService, ServiceEvents.Deleted, Arguments.Id);
+ }
+}
+///
+/// Updates entity.
+///
+internal sealed class UpdateLanguage : ServiceAction
+{
+ public UpdateLanguage(ILanguageService languageService, IStorageProvider storage, ILanguageCache cache, IEventService events)
+ {
+ LanguageService = languageService;
+ Storage = storage;
+ Cache = cache;
+ Events = events;
+ }
+ private ILanguageService LanguageService { get; }
+ private IStorageProvider Storage { get; }
+ private ILanguageCache Cache { get; }
+ private IEventService Events { get; }
+
+ protected override async Task OnInvoke()
+ {
+ /*
+ * Updating Concurrency entity requires a bit more logic. Since Concurrency entity
+ * guarantees data consistency we must use a retry logic in case of
+ * Concurrency failure. We'll call Update method with reload lambda function.
+ */
+ await Storage.Open().Update(await Load(), Arguments, async () =>
+ {
+ /*
+ * Remove entry from the cache to ensure it will be loaded from the database
+ * next time.
+ */
+ await Cache.Refresh(Arguments.Id);
+
+ return await Load();
+ });
+ }
+
+ private async Task Load() => await (from dc in Cache where dc.Id == Arguments.Id select dc).AsEntity();
+ protected override async Task OnCommitted()
+ {
+ /*
+ * Once the update is complete remove the entity from the cache because its concurrency
+ * state is not valid enymore.
+ */
+ await Cache.Remove(Arguments.Id);
+ /*
+ * Now trigger the distributed event notifying the update has completed.
+ */
+ await Events.Enqueue(this, LanguageService, ServiceEvents.Updated, Arguments.Id);
+ }
+}
diff --git a/Common/Globalization/LanguageService.cs b/Common/Globalization/LanguageService.cs
new file mode 100644
index 0000000..dd28d99
--- /dev/null
+++ b/Common/Globalization/LanguageService.cs
@@ -0,0 +1,66 @@
+using System.Collections.Immutable;
+using Common.Security;
+using Connected.Globalization.Languages;
+using Connected.ServiceModel;
+using Connected.Services;
+using Connected.Services.Annotations;
+
+namespace Common.Globalization;
+
+///
+/// The implementation of the service.
+///
+internal class LanguageService : EntityService, ILanguageService
+{
+ public LanguageService(IContext context) : base(context)
+ {
+ }
+
+ [ServiceAuthorization(CommonClaims.CommonDelete)]
+ public async Task Delete(PrimaryKeyArgs args)
+ {
+ await Invoke(GetOperation(), args);
+ }
+
+ [ServiceAuthorization(CommonClaims.CommonAdd)]
+ public async Task Insert(LanguageInsertArgs args)
+ {
+ return await Invoke(GetOperation(), args);
+ }
+
+ [ServiceAuthorization(CommonClaims.CommonRead)]
+ public async Task?> Query()
+ {
+ return await Invoke(GetOperation(), Dto.Empty);
+ }
+
+ [ServiceAuthorization(CommonClaims.CommonRead)]
+ public async Task